feat(ui): add spatial clipping scale and compact record actions
This commit is contained in:
@@ -216,7 +216,7 @@ const iconGroups: Array<{ title: string; note: string; icons: IconName[] }> = [
|
|||||||
{
|
{
|
||||||
title: "Состояние и доступ",
|
title: "Состояние и доступ",
|
||||||
note: "Вся платформа",
|
note: "Вся платформа",
|
||||||
icons: ["check", "alert", "activity", "lock", "key", "shield", "circle", "eye", "eye-off"],
|
icons: ["check", "alert", "info", "activity", "lock", "key", "shield", "circle", "eye", "eye-off"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Сущности",
|
title: "Сущности",
|
||||||
@@ -257,6 +257,7 @@ const iconLabels: Record<IconName, string> = {
|
|||||||
grid: "Обзор",
|
grid: "Обзор",
|
||||||
image: "Изображение",
|
image: "Изображение",
|
||||||
inbox: "Уведомления",
|
inbox: "Уведомления",
|
||||||
|
info: "Информация",
|
||||||
key: "Ключ",
|
key: "Ключ",
|
||||||
layers: "Слои",
|
layers: "Слои",
|
||||||
list: "Список задач",
|
list: "Список задач",
|
||||||
@@ -1294,6 +1295,8 @@ export function CatalogApp() {
|
|||||||
<>
|
<>
|
||||||
<ControlRow label="Цвет"><ColorField value={lightColor} onChange={setLightColor} /></ControlRow>
|
<ControlRow label="Цвет"><ColorField value={lightColor} onChange={setLightColor} /></ControlRow>
|
||||||
<RangeControl label="Яркость" value={brightness} min={0} max={100} formatValue={(value) => `${value}%`} onChange={setBrightness} />
|
<RangeControl label="Яркость" value={brightness} min={0} max={100} formatValue={(value) => `${value}%`} onChange={setBrightness} />
|
||||||
|
<div style={{ height: 180 }} title="Тонкая шкала: границы справа; подпись и значение закреплены в центре, контраст следует заливке"><RangeControl orientation="vertical" label="Срез" value={brightness / 100 * 52.7 - 1.2} min={-1.2} max={51.5} step="any" formatValue={(value) => `${value.toFixed(1).replace('.', ',')} м`} formatLimit={(value) => value.toFixed(1).replace('.', ',')} onChange={(value) => setBrightness((value + 1.2) / 52.7 * 100)} /></div>
|
||||||
|
<div style={{ height: 180, display: 'flex', justifyContent: 'flex-end' }} title="Шкала справа: границы слева; без контура при drag и фокусе, фокус подчёркивает подпись"><RangeControl orientation="vertical" limitSide="left" label="Срез" value={brightness / 100 * 52.7 - 1.2} min={-1.2} max={51.5} step="any" formatValue={(value) => `${value.toFixed(1).replace('.', ',')} м`} formatLimit={(value) => value.toFixed(1).replace('.', ',')} onChange={(value) => setBrightness((value + 1.2) / 52.7 * 100)} /></div>
|
||||||
<RangeControl label="Дистанция свечения" value={glowDistance} min={0} max={200} exactValueBounds={{ min: 0 }} formatValue={(value) => `${value}%`} onChange={setGlowDistance} />
|
<RangeControl label="Дистанция свечения" value={glowDistance} min={0} max={200} exactValueBounds={{ min: 0 }} formatValue={(value) => `${value}%`} onChange={setGlowDistance} />
|
||||||
</>
|
</>
|
||||||
),
|
),
|
||||||
@@ -1345,6 +1348,14 @@ export function CatalogApp() {
|
|||||||
<FieldFrame label="Hub / integrated select">
|
<FieldFrame label="Hub / integrated select">
|
||||||
<Select label="Статус" value={selectedStatus} options={[...selectOptions]} searchable onChange={setSelectedStatus} />
|
<Select label="Статус" value={selectedStatus} options={[...selectOptions]} searchable onChange={setSelectedStatus} />
|
||||||
</FieldFrame>
|
</FieldFrame>
|
||||||
|
<FieldFrame label="Select / независимое действие строки">
|
||||||
|
<Select label="Записи" value={selectedStatus} searchable onChange={setSelectedStatus}
|
||||||
|
options={selectOptions.map(option => ({ ...option, action: {
|
||||||
|
label: `Удалить ${option.label}`, icon: <Icon name="trash" />, tone: 'danger' as const,
|
||||||
|
disabled: option.value === 'active',
|
||||||
|
onAction: () => setConfirmOpen(true),
|
||||||
|
} }))} />
|
||||||
|
</FieldFrame>
|
||||||
<FieldFrame label="Engine / split select">
|
<FieldFrame label="Engine / split select">
|
||||||
<Select
|
<Select
|
||||||
variant="split"
|
variant="split"
|
||||||
@@ -1415,7 +1426,7 @@ export function CatalogApp() {
|
|||||||
<Preview title="Загрузка содержимого" note="в центре собственной области">
|
<Preview title="Загрузка содержимого" note="в центре собственной области">
|
||||||
<LoadingRegion loading label="Ожидаем содержимое" />
|
<LoadingRegion loading label="Ожидаем содержимое" />
|
||||||
</Preview>
|
</Preview>
|
||||||
<Preview title="Лампы состояния" note="один индикатор без дублирующего значка"><StatusBadge variant="indicator" tone="success" aria-label="Готово" title="Готово" /><StatusBadge variant="indicator" aria-label="Недоступно" title="Недоступно" /></Preview>
|
<Preview title="Лампы состояния" note="один индикатор без дублирующего значка"><StatusBadge variant="indicator" tone="success" aria-label="Готово" title="Готово" /><StatusBadge variant="indicator" aria-label="Недоступно" title="Недоступно" /><StatusBadge tone="danger" pulse>Требуется внимание</StatusBadge></Preview>
|
||||||
<Preview title="Линейный прогресс" note="измеренный / неизвестный / завершённый">
|
<Preview title="Линейный прогресс" note="измеренный / неизвестный / завершённый">
|
||||||
<ProgressBar label="Подготовка" value={0.6} valueText="Три этапа из пяти" />
|
<ProgressBar label="Подготовка" value={0.6} valueText="Три этапа из пяти" />
|
||||||
<ProgressBar label="Получение сведений" />
|
<ProgressBar label="Получение сведений" />
|
||||||
|
|||||||
@@ -57,10 +57,18 @@ FieldFrame объединяет label, control, hint и description. TextField/T
|
|||||||
|
|
||||||
## RangeControl
|
## RangeControl
|
||||||
|
|
||||||
|
Вертикальная шкала занимает `0.4 × --nodedc-control-height` по ширине (18.4 px при базовой высоте 46 px): на 20% тоньше первоначального варианта 11.09.2026. Высота, диапазон и управление сохраняются.
|
||||||
|
|
||||||
Pill-range с заполнением акцентным цветом, встроенной подписью и значением. Домен определяет min/max/step и формат числа. `min/max` задают только рабочий диапазон перетаскивания; ручной ввод по умолчанию принимает любое конечное число и не меняет геометрию ползунка. Опциональный `exactValueBounds` независимо задаёт жёсткие границы ручного ввода: например, `{ min: 0 }` сохраняет неотрицательное значение, но не ограничивает верхнюю границу ползунком.
|
Pill-range с заполнением акцентным цветом, встроенной подписью и значением. Домен определяет min/max/step и формат числа. `min/max` задают только рабочий диапазон перетаскивания; ручной ввод по умолчанию принимает любое конечное число и не меняет геометрию ползунка. Опциональный `exactValueBounds` независимо задаёт жёсткие границы ручного ввода: например, `{ min: 0 }` сохраняет неотрицательное значение, но не ограничивает верхнюю границу ползунком.
|
||||||
|
|
||||||
Drag всегда принадлежит невидимому native range и продолжается под областью значения. Над числом постоянно смонтирован один прозрачный native text input: фокус включает редактирование без замены DOM-узла и без pointer-state mutation, браузер ставит каретку в фактическое место клика, а drag/keyboard-selection и замена выделенного текста остаются полностью нативными. Поле не получает собственной подложки, select-all или второго focus-ring. Цвет текста и каретки автоматически выбирается между theme text и `on-accent` по фактической границе заливки под областью значения, поэтому светлая заливка получает тёмный контраст без application-local цвета. Enter/blur применяют значение с exact-bound/step-нормализацией, Escape отменяет ввод; события редактора не всплывают в оконные shortcuts. Оконный focus-manager выполняет начальный autofocus только при открытии и не отбирает фокус при последующих ререндерах.
|
Drag всегда принадлежит невидимому native range и продолжается под областью значения. Над числом постоянно смонтирован один прозрачный native text input: фокус включает редактирование без замены DOM-узла и без pointer-state mutation, браузер ставит каретку в фактическое место клика, а drag/keyboard-selection и замена выделенного текста остаются полностью нативными. Поле не получает собственной подложки, select-all или второго focus-ring. Цвет текста и каретки автоматически выбирается между theme text и `on-accent` по фактической границе заливки под областью значения, поэтому светлая заливка получает тёмный контраст без application-local цвета. Enter/blur применяют значение с exact-bound/step-нормализацией, Escape отменяет ввод; события редактора не всплывают в оконные shortcuts. Оконный focus-manager выполняет начальный autofocus только при открытии и не отбирает фокус при последующих ререндерах.
|
||||||
|
|
||||||
|
`RangeControl orientation="vertical"` — утверждённый владельцем 11.09.2026 вертикальный вариант для высоты среза. Максимум расположен сверху, заливка растёт снизу, стрелки вверх/вниз меняют значение. Подпись, числовое значение, контраст и focus принадлежат тому же компоненту. Узкий вертикальный вариант использует drag/клавиатуру без inline-редактора; точные границы задаёт домен.
|
||||||
|
|
||||||
|
Последнее уточнение владельца от 19.09.2026 заменяет промежуточный вариант с границами внутри: верхняя и нижняя границы вынесены с внутренней стороны сцены, читаются горизонтально и сохраняют цвет theme text, не меняясь от заливки. `formatLimit` форматирует их отдельно от текущего значения, без единицы измерения по умолчанию. По умолчанию это правая сторона; `limitSide="left"` зеркалит только внешние границы для шкалы, закреплённой справа от сцены. В центре неподвижно расположена строка «подпись · значение»; единица остаётся в `formatValue`. Внутренний и наружный шрифт уменьшены на 10% относительно токенов sm/xs. Два совпадающих центральных текстовых слоя и маска формы заливки меняют контраст ровно по её границе, в том числе посередине символа.
|
||||||
|
|
||||||
|
Уточнение от 20.09.2026: вертикальная шкала не получает border, outline или тень по периметру ни в одном состоянии. Native range может сохранять `:focus-visible` и после pointer-ввода, поэтому этот селектор больше не рисует контур. Доступный фокус обозначается подчёркиванием центральной подписи в обоих контрастных слоях; стрелки и Home/End продолжают работать нативно.
|
||||||
|
|
||||||
## ColorField
|
## ColorField
|
||||||
|
|
||||||
Engine/BIM-геометрия color field: круглый swatch и HEX-значение в pill control. Swatch открывает общий portal-picker: saturation/value plane, hue slider и быстрые цвета. Это устраняет зависимость от тёмной системной палитры браузера и делает выбор одинаковым в dark/light.
|
Engine/BIM-геометрия color field: круглый swatch и HEX-значение в pill control. Swatch открывает общий portal-picker: saturation/value plane, hue slider и быстрые цвета. Это устраняет зависимость от тёмной системной палитры браузера и делает выбор одинаковым в dark/light.
|
||||||
@@ -99,6 +107,10 @@ Native select не используется как видимый runtime UI. М
|
|||||||
|
|
||||||
Внутри `Inspector` форма `integrated` запрещена. Selection-поле собирается только через `InspectorSelectField`: видимая подпись находится сверху, а полноширинный control всегда использует `split`. Компонент намеренно не принимает `layout` и `variant`, поэтому consumer не может вернуть Inspector к Hub/Launcher pill-форме.
|
Внутри `Inspector` форма `integrated` запрещена. Selection-поле собирается только через `InspectorSelectField`: видимая подпись находится сверху, а полноширинный control всегда использует `split`. Компонент намеренно не принимает `layout` и `variant`, поэтому consumer не может вернуть Inspector к Hub/Launcher pill-форме.
|
||||||
|
|
||||||
|
`SelectOption.action` добавляет независимое действие справа от строки (`label`, `icon`, `disabled`, `onAction`), например корзину. Это соседний канонический `IconButton`, не вложенная кнопка. Действие закрывает меню, не выбирает строку и не вызывает `onChange`; подтверждение и запрос принадлежат приложению. Меню с действиями использует dialog-семантику и отдельные доступные кнопки выбора/действия; обычный Select сохраняет listbox. Состояние `option.disabled` не блокирует независимое действие — для него предусмотрен `action.disabled`.
|
||||||
|
|
||||||
|
Действие строки повторяет принятый образец каталога сессий Mission Core: прозрачная колонка нажатия `2.35rem`, иконка `15 px`, приглушённый цвет, без круглой подложки, скругления или тени. Опциональный `tone="danger"` меняет цвет при наведении и клавиатурном фокусе, но не окрашивает корзину постоянно. Доступный focus-индикатор общего IconButton сохраняется.
|
||||||
|
|
||||||
## Window
|
## Window
|
||||||
|
|
||||||
Window — единая механика открытия modal и правой panel:
|
Window — единая механика открытия modal и правой panel:
|
||||||
@@ -240,6 +252,10 @@ setup-команды, сохранение, API и права принадлеж
|
|||||||
|
|
||||||
Постоянные действия всего рабочего окна передаются через `ApplicationPanel.utilityActions` и всегда находятся в правой части шапки перед expand/close. Сохранение общего layout или настроек приложения — одно такое действие для всего окна; оно не дублируется внутри отдельных карточек и секций.
|
Постоянные действия всего рабочего окна передаются через `ApplicationPanel.utilityActions` и всегда находятся в правой части шапки перед expand/close. Сохранение общего layout или настроек приложения — одно такое действие для всего окна; оно не дублируется внутри отдельных карточек и секций.
|
||||||
|
|
||||||
|
Переключение режима выбранного объекта может передать `pressed` в utility action: состояние получает `aria-pressed` и общую активную поверхность. Например, `info` открывает обзор текущей сохранённой сессии Mission Core.
|
||||||
|
|
||||||
|
Если заголовок и действия не помещаются в одну строку, шапка переносит действия на следующую строку с выравниванием вправо. Кнопки не обрезаются, заголовок сохраняет читаемую ширину. Состав действий одинаков в обычном и развёрнутом окне.
|
||||||
|
|
||||||
Точные состояния и размеры зафиксированы в `docs/APPLICATION_TEMPLATE.md`.
|
Точные состояния и размеры зафиксированы в `docs/APPLICATION_TEMPLATE.md`.
|
||||||
|
|
||||||
## MediaSourceField
|
## MediaSourceField
|
||||||
@@ -308,6 +324,8 @@ Sortable-строки ограничены вертикальной осью: г
|
|||||||
|
|
||||||
Статус использует semantic tone: neutral, success, warning, danger или accent. Бизнес-статус приложения маппится на tone в самом приложении.
|
Статус использует semantic tone: neutral, success, warning, danger или accent. Бизнес-статус приложения маппится на tone в самом приложении.
|
||||||
|
|
||||||
|
`pulse` добавляет пульсацию только лампы, не текста и не фона. Подходит для потери подтверждения или другого состояния, требующего внимания. При `prefers-reduced-motion: reduce` лампа остаётся постоянной. Текст статуса обязателен рядом с отдельным индикатором или в самом badge.
|
||||||
|
|
||||||
## ToastStack
|
## ToastStack
|
||||||
|
|
||||||
`ToastCard` и `ToastStack` фиксируют Tasker-derived bottom-right уведомления для `success`, `error`, `warning`, `info` и `loading`. Приложение владеет текстом и состоянием операции; стек владеет portal, геометрией, aria-live и таймерами. Обычный terminal toast по умолчанию живёт `10 000 ms`; каждый стабильный `id` получает собственный таймер, поэтому добавление соседнего уведомления не продлевает уже показанное. Loading не закрывается автоматически и обновляется тем же id после завершения операции; после перехода в terminal tone для него запускается обычный независимый таймер. Toast не используется вместо modal confirmation.
|
`ToastCard` и `ToastStack` фиксируют Tasker-derived bottom-right уведомления для `success`, `error`, `warning`, `info` и `loading`. Приложение владеет текстом и состоянием операции; стек владеет portal, геометрией, aria-live и таймерами. Обычный terminal toast по умолчанию живёт `10 000 ms`; каждый стабильный `id` получает собственный таймер, поэтому добавление соседнего уведомления не продлевает уже показанное. Loading не закрывается автоматически и обновляется тем же id после завершения операции; после перехода в terminal tone для него запускается обычный независимый таймер. Toast не используется вместо modal confirmation.
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
# Иконки
|
# Иконки
|
||||||
|
|
||||||
|
`info` — информация о выбранном объекте или записи. Иконка используется в стандартном круглом `IconButton`, с доступной подписью и `aria-pressed` для переключаемого обзора. Продуктовое применение: обзор сохранённой сессии Mission Core; одобрено владельцем 2026-09-11.
|
||||||
|
|
||||||
Канонический набор собран по реально используемым действиям Launcher/Hub, SEO, BIM Viewer и разрешённым новым участкам Engine. Полные vendor-наборы не являются частью дизайн-системы: наличие SVG в Font Awesome или export в Lucide не делает иконку канонической.
|
Канонический набор собран по реально используемым действиям Launcher/Hub, SEO, BIM Viewer и разрешённым новым участкам Engine. Полные vendor-наборы не являются частью дизайн-системы: наличие SVG в Font Awesome или export в Lucide не делает иконку канонической.
|
||||||
|
|
||||||
## Геометрия
|
## Геометрия
|
||||||
|
|||||||
+1
-1
@@ -38,7 +38,7 @@
|
|||||||
"test:icon-contract": "node --test scripts/icon-contract.test.mjs",
|
"test:icon-contract": "node --test scripts/icon-contract.test.mjs",
|
||||||
"test:toast-contract": "node --test scripts/toast-contract.test.mjs",
|
"test:toast-contract": "node --test scripts/toast-contract.test.mjs",
|
||||||
"test:floating-position": "node --test scripts/floating-position-contract.test.mjs",
|
"test:floating-position": "node --test scripts/floating-position-contract.test.mjs",
|
||||||
"test:inspector-select": "node --test scripts/inspector-select-contract.test.mjs",
|
"test:inspector-select": "node --test scripts/inspector-select-contract.test.mjs scripts/select-actions-contract.test.mjs",
|
||||||
"test:range-control": "node --test scripts/range-control-contract.test.mjs",
|
"test:range-control": "node --test scripts/range-control-contract.test.mjs",
|
||||||
"test:split-pane": "node --test scripts/split-pane-contract.test.mjs"
|
"test:split-pane": "node --test scripts/split-pane-contract.test.mjs"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -842,6 +842,33 @@ textarea.nodedc-field__control {
|
|||||||
cursor: default;
|
cursor: default;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.nodedc-select-option-row { display: flex; align-items: stretch; gap: 0.2rem; }
|
||||||
|
.nodedc-select-option-row > .nodedc-dropdown-option { flex: 1; min-width: 0; }
|
||||||
|
.nodedc-select-option-row > .nodedc-icon-button {
|
||||||
|
width: 2.35rem; height: auto; flex: 0 0 2.35rem;
|
||||||
|
border-radius: 0; background: transparent; box-shadow: none;
|
||||||
|
color: var(--nodedc-text-muted);
|
||||||
|
}
|
||||||
|
.nodedc-select-option-row > .nodedc-icon-button > svg { width: 15px; height: 15px; transform: none; }
|
||||||
|
.nodedc-select-option-row > .nodedc-icon-button:hover:not(:disabled),
|
||||||
|
.nodedc-select-option-row > .nodedc-icon-button:focus-visible { background: transparent; color: var(--nodedc-text-primary); }
|
||||||
|
.nodedc-select-option-row > .nodedc-icon-button[data-tone="danger"]:hover:not(:disabled),
|
||||||
|
.nodedc-select-option-row > .nodedc-icon-button[data-tone="danger"]:focus-visible { color: rgb(var(--nodedc-danger-rgb)); }
|
||||||
|
.nodedc-select-option-row > .nodedc-icon-button:active:not(:disabled) { transform: none; }
|
||||||
|
.nodedc-range-scale { position: relative; width: calc(var(--nodedc-control-height) * 0.4); height: 100%; min-height: 6rem; }
|
||||||
|
.nodedc-range[data-orientation="vertical"] { width: 100%; height: 100%; border: 0; outline: 0; box-shadow: none; }
|
||||||
|
.nodedc-range[data-orientation="vertical"]::before { inset: auto 0 0; width: 100%; height: var(--nodedc-range-progress); }
|
||||||
|
.nodedc-range[data-orientation="vertical"] input[type="range"] { writing-mode: vertical-lr; direction: rtl; }
|
||||||
|
.nodedc-range[data-orientation="vertical"] .nodedc-range__label { left: 50%; max-width: none; font-size: calc(var(--nodedc-font-size-sm) * 0.9); writing-mode: vertical-rl; transform: translate(-50%, -50%) rotate(180deg); }
|
||||||
|
.nodedc-range-scale__limit { position: absolute; left: calc(100% + 0.4rem); color: var(--nodedc-text-primary); font-size: calc(var(--nodedc-font-size-xs) * 0.9); font-weight: var(--nodedc-font-weight-medium); line-height: 1; font-variant-numeric: tabular-nums; white-space: nowrap; pointer-events: none; }
|
||||||
|
.nodedc-range-scale[data-limit-side="left"] .nodedc-range-scale__limit { right: calc(100% + 0.4rem); left: auto; text-align: right; }
|
||||||
|
.nodedc-range-scale__limit--max { top: 0.75rem; }
|
||||||
|
.nodedc-range-scale__limit--min { bottom: 0.75rem; }
|
||||||
|
.nodedc-range[data-orientation="vertical"] .nodedc-range__fill-text { clip-path: inset(calc(100% - var(--nodedc-range-progress)) 0 0 round var(--nodedc-radius-circle)); }
|
||||||
|
/* Native ranges may match focus-visible after pointer input too. Keep every
|
||||||
|
state perimeter-free; underline both contrast layers to identify focus. */
|
||||||
|
.nodedc-range[data-orientation="vertical"]:has(input:focus-visible) .nodedc-range__label { text-decoration: underline; text-decoration-thickness: 2px; text-underline-offset: 2px; }
|
||||||
|
|
||||||
.nodedc-range__label,
|
.nodedc-range__label,
|
||||||
.nodedc-range__value {
|
.nodedc-range__value {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
@@ -1627,6 +1654,8 @@ textarea.nodedc-field__control {
|
|||||||
|
|
||||||
.nodedc-application-panel__head {
|
.nodedc-application-panel__head {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
min-width: 0;
|
||||||
min-height: 3.55rem;
|
min-height: 3.55rem;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
@@ -1635,6 +1664,7 @@ textarea.nodedc-field__control {
|
|||||||
|
|
||||||
.nodedc-application-panel__titles {
|
.nodedc-application-panel__titles {
|
||||||
display: grid;
|
display: grid;
|
||||||
|
flex: 1 1 18rem;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
gap: 0.24rem;
|
gap: 0.24rem;
|
||||||
padding: 0.2rem 0.25rem;
|
padding: 0.2rem 0.25rem;
|
||||||
@@ -1669,12 +1699,17 @@ textarea.nodedc-field__control {
|
|||||||
.nodedc-application-panel__actions {
|
.nodedc-application-panel__actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
max-width: 100%;
|
||||||
|
margin-left: auto;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.45rem;
|
gap: 0.45rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nodedc-application-panel__tools {
|
.nodedc-application-panel__tools {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
max-width: 100%;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
margin-right: 0.1rem;
|
margin-right: 0.1rem;
|
||||||
}
|
}
|
||||||
@@ -3926,6 +3961,20 @@ textarea.nodedc-field__control {
|
|||||||
.nodedc-status[data-variant="indicator"][data-tone="danger"]::before { background: rgb(var(--nodedc-danger-rgb)); }
|
.nodedc-status[data-variant="indicator"][data-tone="danger"]::before { background: rgb(var(--nodedc-danger-rgb)); }
|
||||||
.nodedc-status[data-variant="indicator"][data-tone="accent"]::before { background: rgb(var(--nodedc-accent-rgb)); }
|
.nodedc-status[data-variant="indicator"][data-tone="accent"]::before { background: rgb(var(--nodedc-accent-rgb)); }
|
||||||
|
|
||||||
|
.nodedc-status[data-pulse="true"]::before {
|
||||||
|
content: ""; display: block; width: .42rem; height: .42rem; flex: 0 0 .42rem;
|
||||||
|
border-radius: var(--nodedc-radius-circle); background: currentColor;
|
||||||
|
animation: nodedc-status-pulse 1.6s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
.nodedc-status[data-pulse="true"]:not([data-variant="indicator"]) { gap: .42rem; }
|
||||||
|
@keyframes nodedc-status-pulse {
|
||||||
|
0%, 100% { opacity: 1; transform: scale(1); }
|
||||||
|
50% { opacity: .35; transform: scale(.75); }
|
||||||
|
}
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.nodedc-status[data-pulse="true"]::before { animation: none; }
|
||||||
|
}
|
||||||
|
|
||||||
/* Shared product landing and environment settings. */
|
/* Shared product landing and environment settings. */
|
||||||
.nodedc-landing-stage {
|
.nodedc-landing-stage {
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ export interface ApplicationPanelUtilityAction {
|
|||||||
icon: IconName;
|
icon: IconName;
|
||||||
onClick: () => void;
|
onClick: () => void;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
|
pressed?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ApplicationPanel({
|
export function ApplicationPanel({
|
||||||
@@ -102,6 +103,8 @@ export function ApplicationPanel({
|
|||||||
className="nodedc-application-panel__action"
|
className="nodedc-application-panel__action"
|
||||||
data-action="utility"
|
data-action="utility"
|
||||||
aria-label={action.label}
|
aria-label={action.label}
|
||||||
|
aria-pressed={action.pressed}
|
||||||
|
data-active={action.pressed ? "true" : undefined}
|
||||||
disabled={action.disabled}
|
disabled={action.disabled}
|
||||||
onClick={action.onClick}
|
onClick={action.onClick}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
Globe2,
|
Globe2,
|
||||||
Image,
|
Image,
|
||||||
Inbox,
|
Inbox,
|
||||||
|
Info,
|
||||||
KeyRound,
|
KeyRound,
|
||||||
Layers,
|
Layers,
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
@@ -80,6 +81,7 @@ const icons = {
|
|||||||
grid: LayoutDashboard,
|
grid: LayoutDashboard,
|
||||||
image: Image,
|
image: Image,
|
||||||
inbox: Inbox,
|
inbox: Inbox,
|
||||||
|
info: Info,
|
||||||
key: KeyRound,
|
key: KeyRound,
|
||||||
layers: Layers,
|
layers: Layers,
|
||||||
list: ListChecks,
|
list: ListChecks,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { Dropdown } from "./Dropdown.js";
|
|||||||
|
|
||||||
export interface RangeControlProps extends Omit<InputHTMLAttributes<HTMLInputElement>, "type" | "value" | "onChange"> {
|
export interface RangeControlProps extends Omit<InputHTMLAttributes<HTMLInputElement>, "type" | "value" | "onChange"> {
|
||||||
label: string;
|
label: string;
|
||||||
|
orientation?: "horizontal" | "vertical";
|
||||||
value: number;
|
value: number;
|
||||||
min: number;
|
min: number;
|
||||||
max: number;
|
max: number;
|
||||||
@@ -12,6 +13,10 @@ export interface RangeControlProps extends Omit<InputHTMLAttributes<HTMLInputEle
|
|||||||
max?: number;
|
max?: number;
|
||||||
};
|
};
|
||||||
formatValue?: (value: number) => string;
|
formatValue?: (value: number) => string;
|
||||||
|
/** Unit-free endpoint labels beside the vertical scale. */
|
||||||
|
formatLimit?: (value: number) => string;
|
||||||
|
/** Physical side for vertical endpoint labels; use the inward side of a scene rail. */
|
||||||
|
limitSide?: "left" | "right";
|
||||||
onChange: (value: number) => void;
|
onChange: (value: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,12 +57,15 @@ const editableNumber = (value: number) => (
|
|||||||
|
|
||||||
export function RangeControl({
|
export function RangeControl({
|
||||||
label,
|
label,
|
||||||
|
orientation = "horizontal",
|
||||||
value,
|
value,
|
||||||
min,
|
min,
|
||||||
max,
|
max,
|
||||||
exactValueBounds,
|
exactValueBounds,
|
||||||
step,
|
step,
|
||||||
formatValue = String,
|
formatValue = String,
|
||||||
|
formatLimit = String,
|
||||||
|
limitSide = "right",
|
||||||
onChange,
|
onChange,
|
||||||
className,
|
className,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
@@ -132,6 +140,22 @@ export function RangeControl({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (orientation === "vertical") return (
|
||||||
|
<div className="nodedc-range-scale" data-limit-side={limitSide}>
|
||||||
|
<div ref={rootRef} className={cn("nodedc-range", className)} style={style} data-orientation="vertical">
|
||||||
|
<input ref={rangeRef} {...props} type="range" value={value} min={min} max={safeMax} step={step}
|
||||||
|
disabled={disabled} tabIndex={tabIndex} aria-label={label} aria-orientation="vertical"
|
||||||
|
aria-valuetext={displayValue} onChange={(event) => onChange(Number(event.target.value))} />
|
||||||
|
<span className="nodedc-range__label">{label} · {displayValue}</span>
|
||||||
|
<span className="nodedc-range__fill-text" aria-hidden="true">
|
||||||
|
<span className="nodedc-range__label">{label} · {displayValue}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span className="nodedc-range-scale__limit nodedc-range-scale__limit--max">{formatLimit(max)}</span>
|
||||||
|
<span className="nodedc-range-scale__limit nodedc-range-scale__limit--min">{formatLimit(min)}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={rootRef} className={cn("nodedc-range", className)} style={style} data-editing={editing || undefined}>
|
<div ref={rootRef} className={cn("nodedc-range", className)} style={style} data-editing={editing || undefined}>
|
||||||
<input
|
<input
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useContext, useMemo, useState, type KeyboardEvent, type ReactNode } from "react";
|
import { useContext, useMemo, useRef, useState, type KeyboardEvent, type MouseEvent, type ReactNode } from "react";
|
||||||
import type { FloatingPlacement } from "@nodedc/ui-core";
|
import type { FloatingPlacement } from "@nodedc/ui-core";
|
||||||
import { Dropdown } from "./Dropdown.js";
|
import { Dropdown } from "./Dropdown.js";
|
||||||
|
import { IconButton } from "./Button.js";
|
||||||
import { InspectorSelectPolicyContext } from "./InspectorContext.js";
|
import { InspectorSelectPolicyContext } from "./InspectorContext.js";
|
||||||
import { cn } from "./cn.js";
|
import { cn } from "./cn.js";
|
||||||
|
|
||||||
@@ -10,6 +11,7 @@ export interface SelectOption<T extends string> {
|
|||||||
description?: string;
|
description?: string;
|
||||||
icon?: ReactNode;
|
icon?: ReactNode;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
|
action?: { label: string; icon: ReactNode; disabled?: boolean; tone?: "neutral" | "danger"; onAction: () => void };
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SelectVariant = "integrated" | "split" | "inline";
|
export type SelectVariant = "integrated" | "split" | "inline";
|
||||||
@@ -52,7 +54,11 @@ export function Select<T extends string>({
|
|||||||
const inspectorSplitRequired = useContext(InspectorSelectPolicyContext);
|
const inspectorSplitRequired = useContext(InspectorSelectPolicyContext);
|
||||||
const resolvedVariant: SelectVariant = inspectorSplitRequired ? "split" : variant;
|
const resolvedVariant: SelectVariant = inspectorSplitRequired ? "split" : variant;
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
|
const triggerButton = useRef<HTMLButtonElement | null>(null);
|
||||||
const selected = options.find((option) => option.value === value) ?? options[0];
|
const selected = options.find((option) => option.value === value) ?? options[0];
|
||||||
|
// Row actions are siblings, never nested interactive listbox options.
|
||||||
|
const withActions = options.some((option) => option.action);
|
||||||
|
const popupRole = withActions ? "dialog" : "listbox";
|
||||||
const visibleOptions = useMemo(() => {
|
const visibleOptions = useMemo(() => {
|
||||||
const normalized = query.trim().toLocaleLowerCase();
|
const normalized = query.trim().toLocaleLowerCase();
|
||||||
if (!normalized) return options;
|
if (!normalized) return options;
|
||||||
@@ -66,10 +72,15 @@ export function Select<T extends string>({
|
|||||||
minWidth={minMenuWidth}
|
minWidth={minMenuWidth}
|
||||||
width={menuWidth}
|
width={menuWidth}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
surfaceRole="listbox"
|
surfaceRole={popupRole}
|
||||||
surfaceClassName={cn(resolvedVariant === "split" && "nodedc-select__menu", menuClassName)}
|
surfaceClassName={cn(resolvedVariant === "split" && "nodedc-select__menu", menuClassName)}
|
||||||
trigger={({ open, toggle, setAnchorRef, setTriggerRef, surfaceId }) => {
|
trigger={({ open, toggle, setAnchorRef, setTriggerRef, surfaceId }) => {
|
||||||
|
const handleClick = (event: MouseEvent<HTMLButtonElement>) => {
|
||||||
|
triggerButton.current = event.currentTarget;
|
||||||
|
toggle();
|
||||||
|
};
|
||||||
const handleKeyDown = (event: KeyboardEvent<HTMLButtonElement>) => {
|
const handleKeyDown = (event: KeyboardEvent<HTMLButtonElement>) => {
|
||||||
|
triggerButton.current = event.currentTarget;
|
||||||
if (event.key === "ArrowDown" || event.key === "Enter" || event.key === " ") {
|
if (event.key === "ArrowDown" || event.key === "Enter" || event.key === " ") {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (!open) toggle();
|
if (!open) toggle();
|
||||||
@@ -88,11 +99,11 @@ export function Select<T extends string>({
|
|||||||
type="button"
|
type="button"
|
||||||
className="nodedc-select__toggle"
|
className="nodedc-select__toggle"
|
||||||
aria-label={label}
|
aria-label={label}
|
||||||
aria-haspopup="listbox"
|
aria-haspopup={popupRole}
|
||||||
aria-controls={surfaceId}
|
aria-controls={surfaceId}
|
||||||
aria-expanded={open}
|
aria-expanded={open}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
onClick={toggle}
|
onClick={handleClick}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
>
|
>
|
||||||
<span className="nodedc-select-trigger__chevron" aria-hidden="true" />
|
<span className="nodedc-select-trigger__chevron" aria-hidden="true" />
|
||||||
@@ -109,11 +120,11 @@ export function Select<T extends string>({
|
|||||||
type="button"
|
type="button"
|
||||||
className={cn("nodedc-select-inline", triggerClassName)}
|
className={cn("nodedc-select-inline", triggerClassName)}
|
||||||
aria-label={label}
|
aria-label={label}
|
||||||
aria-haspopup="listbox"
|
aria-haspopup={popupRole}
|
||||||
aria-controls={surfaceId}
|
aria-controls={surfaceId}
|
||||||
aria-expanded={open}
|
aria-expanded={open}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
onClick={toggle}
|
onClick={handleClick}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
>
|
>
|
||||||
{selected?.icon ? <span className="nodedc-select-trigger__icon">{selected.icon}</span> : null}
|
{selected?.icon ? <span className="nodedc-select-trigger__icon">{selected.icon}</span> : null}
|
||||||
@@ -128,11 +139,11 @@ export function Select<T extends string>({
|
|||||||
type="button"
|
type="button"
|
||||||
className={cn("nodedc-select-trigger", triggerClassName)}
|
className={cn("nodedc-select-trigger", triggerClassName)}
|
||||||
aria-label={label}
|
aria-label={label}
|
||||||
aria-haspopup="listbox"
|
aria-haspopup={popupRole}
|
||||||
aria-controls={surfaceId}
|
aria-controls={surfaceId}
|
||||||
aria-expanded={open}
|
aria-expanded={open}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
onClick={toggle}
|
onClick={handleClick}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
>
|
>
|
||||||
{selected?.icon ? <span className="nodedc-select-trigger__icon">{selected.icon}</span> : <span className="nodedc-select-trigger__icon" aria-hidden="true" />}
|
{selected?.icon ? <span className="nodedc-select-trigger__icon">{selected.icon}</span> : <span className="nodedc-select-trigger__icon" aria-hidden="true" />}
|
||||||
@@ -151,12 +162,13 @@ export function Select<T extends string>({
|
|||||||
</label>
|
</label>
|
||||||
) : null}
|
) : null}
|
||||||
{visibleOptions.map((option) => (
|
{visibleOptions.map((option) => (
|
||||||
|
<div key={option.value} className="nodedc-select-option-row">
|
||||||
<button
|
<button
|
||||||
key={option.value}
|
|
||||||
type="button"
|
type="button"
|
||||||
className="nodedc-dropdown-option"
|
className="nodedc-dropdown-option"
|
||||||
role="option"
|
role={withActions ? undefined : "option"}
|
||||||
aria-selected={option.value === value}
|
aria-selected={withActions ? undefined : option.value === value}
|
||||||
|
aria-pressed={withActions ? option.value === value : undefined}
|
||||||
data-selected={option.value === value ? "true" : undefined}
|
data-selected={option.value === value ? "true" : undefined}
|
||||||
disabled={option.disabled}
|
disabled={option.disabled}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -173,6 +185,20 @@ export function Select<T extends string>({
|
|||||||
</span>
|
</span>
|
||||||
{option.value === value ? <span className="nodedc-dropdown-option__check" aria-hidden="true">•</span> : null}
|
{option.value === value ? <span className="nodedc-dropdown-option__check" aria-hidden="true">•</span> : null}
|
||||||
</button>
|
</button>
|
||||||
|
{option.action ? <IconButton
|
||||||
|
label={option.action.label}
|
||||||
|
data-tone={option.action.tone ?? "neutral"}
|
||||||
|
disabled={option.action.disabled}
|
||||||
|
onClick={() => {
|
||||||
|
setQuery("");
|
||||||
|
close();
|
||||||
|
// A modal must restore focus to a surviving trigger, not this
|
||||||
|
// row action which disappears when the menu closes.
|
||||||
|
triggerButton.current?.focus();
|
||||||
|
option.action?.onAction();
|
||||||
|
}}
|
||||||
|
>{option.action.icon}</IconButton> : null}
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
{visibleOptions.length === 0 ? <div className="nodedc-empty-state">{emptyLabel}</div> : null}
|
{visibleOptions.length === 0 ? <div className="nodedc-empty-state">{emptyLabel}</div> : null}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -6,13 +6,14 @@ export type StatusTone = "neutral" | "success" | "warning" | "danger" | "accent"
|
|||||||
export interface StatusBadgeProps extends HTMLAttributes<HTMLSpanElement> {
|
export interface StatusBadgeProps extends HTMLAttributes<HTMLSpanElement> {
|
||||||
tone?: StatusTone;
|
tone?: StatusTone;
|
||||||
variant?: "badge" | "indicator";
|
variant?: "badge" | "indicator";
|
||||||
|
/** Attention signal on the lamp only; respects reduced-motion preferences. */
|
||||||
|
pulse?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function StatusBadge({ tone = "neutral", variant = "badge", className, children, ...props }: StatusBadgeProps) {
|
export function StatusBadge({ tone = "neutral", variant = "badge", pulse = false, className, children, ...props }: StatusBadgeProps) {
|
||||||
return (
|
return (
|
||||||
<span className={cn("nodedc-status", className)} data-tone={tone === "neutral" ? undefined : tone} data-variant={variant === "indicator" ? variant : undefined} {...props}>
|
<span className={cn("nodedc-status", className)} data-tone={tone === "neutral" ? undefined : tone} data-variant={variant === "indicator" ? variant : undefined} data-pulse={pulse ? "true" : undefined} {...props}>
|
||||||
{variant === "indicator" ? null : children}
|
{variant === "indicator" ? null : children}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -169,10 +169,13 @@
|
|||||||
"package": "@nodedc/ui-react",
|
"package": "@nodedc/ui-react",
|
||||||
"exports": ["RangeControl"],
|
"exports": ["RangeControl"],
|
||||||
"domContract": ["nodedc-range"],
|
"domContract": ["nodedc-range"],
|
||||||
"summary": "Filled pill range control with embedded label, drag interaction and a persistent native inline exact-value editor.",
|
"summary": "Filled pill range control with horizontal exact editing or a slim vertical scale with endpoints on the scene-inward side and a fixed centered label/value.",
|
||||||
"behavior": ["native range drag", "native caret and selection replacement", "automatic fill/base contrast", "unbounded finite exact editing by default", "Enter/blur commit", "Escape cancel", "independent optional exact-value bounds", "step normalization"],
|
"behavior": ["native range drag", "native caret and selection replacement", "automatic fill/base contrast", "unbounded finite exact editing by default", "Enter/blur commit", "Escape cancel", "independent optional exact-value bounds", "step normalization"],
|
||||||
"rules": [
|
"rules": [
|
||||||
"The accent fill follows the active application theme.",
|
"The accent fill follows the active application theme.",
|
||||||
|
"Vertical scales place formatLimit(min/max) on the configured scene-inward side (right by default, left with limitSide=left) in theme text, without units by default; label and formatValue(value) stay together at the center of the pill.",
|
||||||
|
"The vertical shaft is 40 percent of control height (20 percent slimmer than the original half-width variant); center and endpoint typography are 90 percent of their sm/xs tokens.",
|
||||||
|
"Central vertical text uses two identical layers clipped to the rounded fill, including partially covered glyphs. Vertical scales never have a perimeter border, outline or shadow, including focus; focus-visible underlines both central text layers with their existing contrast.",
|
||||||
"The native range remains the accessible drag input while its chrome is visually replaced.",
|
"The native range remains the accessible drag input while its chrome is visually replaced.",
|
||||||
"The fill travels beneath the visible value; the persistent transparent native editor above the value receives exact-edit clicks without remounting.",
|
"The fill travels beneath the visible value; the persistent transparent native editor above the value receives exact-edit clicks without remounting.",
|
||||||
"Exact editing stays inside the existing value area and never expands the control.",
|
"Exact editing stays inside the existing value area and never expands the control.",
|
||||||
@@ -222,8 +225,10 @@
|
|||||||
"domExports": ["createSelectController"],
|
"domExports": ["createSelectController"],
|
||||||
"summary": "Theme-independent selection control using the canonical dropdown layer.",
|
"summary": "Theme-independent selection control using the canonical dropdown layer.",
|
||||||
"variants": ["integrated", "split", "inline"],
|
"variants": ["integrated", "split", "inline"],
|
||||||
"behavior": ["controlled value", "optional search", "disabled options", "selected state", "portal menu"],
|
"behavior": ["controlled value", "optional search", "disabled options", "selected state", "portal menu", "optional independent row action"],
|
||||||
"rules": [
|
"rules": [
|
||||||
|
"An option.action is a sibling canonical IconButton with an independent disabled state; it closes the menu without selecting the option. Action-bearing menus use dialog semantics instead of nesting interactive controls in listbox options. Confirmation and mutation remain consumer-owned.",
|
||||||
|
"Select row actions match the compact Mission Core session-catalog reference: transparent 2.35rem hit column, 15px glyph, muted foreground and no round backing. An optional danger tone changes only hover/focus foreground; canonical keyboard focus remains visible.",
|
||||||
"Integrated is the single-pill Hub/Launcher form.",
|
"Integrated is the single-pill Hub/Launcher form.",
|
||||||
"Split is the Engine form: a separate value surface and a 46 px toggle separated by an 8 px gap.",
|
"Split is the Engine form: a separate value surface and a 46 px toggle separated by an 8 px gap.",
|
||||||
"Inline is a compact label-only toolbar trigger without a persistent surface or chevron; its menu and keyboard behavior remain canonical.",
|
"Inline is a compact label-only toolbar trigger without a persistent surface or chevron; its menu and keyboard behavior remain canonical.",
|
||||||
@@ -513,7 +518,8 @@
|
|||||||
"exports": ["StatusBadge"],
|
"exports": ["StatusBadge"],
|
||||||
"variants": ["badge", "indicator"],
|
"variants": ["badge", "indicator"],
|
||||||
"domContract": ["nodedc-status"],
|
"domContract": ["nodedc-status"],
|
||||||
"summary": "Semantic status pill whose tone is independent from the application accent."
|
"summary": "Semantic status pill whose tone is independent from the application accent.",
|
||||||
|
"behavior": ["optional pulse animates only the status lamp", "pulse is static with reduced motion"]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "toast",
|
"id": "toast",
|
||||||
|
|||||||
+1
-1
@@ -30,7 +30,7 @@
|
|||||||
"id": "state-access",
|
"id": "state-access",
|
||||||
"label": "Состояние и доступ",
|
"label": "Состояние и доступ",
|
||||||
"referenceSources": ["launcher", "seo", "task-manager"],
|
"referenceSources": ["launcher", "seo", "task-manager"],
|
||||||
"names": ["check", "alert", "activity", "lock", "key", "shield", "circle", "eye", "eye-off"]
|
"names": ["check", "alert", "info", "activity", "lock", "key", "shield", "circle", "eye", "eye-off"]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "entities",
|
"id": "entities",
|
||||||
|
|||||||
@@ -23,6 +23,49 @@ test("RangeControl keeps native drag semantics and exposes one persistent exact-
|
|||||||
assert.doesNotMatch(markup, /nodedc-range__value-hit/);
|
assert.doesNotMatch(markup, /nodedc-range__value-hit/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("vertical scale keeps endpoints outside and the label/value centered at every fill level", () => {
|
||||||
|
for (const [value, progress] of [[-1.2, 0], [25.15, 50], [51.5, 100]]) {
|
||||||
|
const markup = renderToStaticMarkup(createElement(RangeControl, {
|
||||||
|
label: "Срез", orientation: "vertical", value, min: -1.2, max: 51.5, step: "any",
|
||||||
|
formatValue: (n) => `${n.toFixed(1).replace('.', ',')} м`,
|
||||||
|
formatLimit: (n) => n.toFixed(1).replace('.', ','), onChange: () => {},
|
||||||
|
}));
|
||||||
|
const renderedProgress = Number(markup.match(/--nodedc-range-progress:([\d.]+)%/)?.[1]);
|
||||||
|
assert.ok(Math.abs(renderedProgress - progress) < 1e-9);
|
||||||
|
assert.match(markup, /aria-label="Срез" aria-orientation="vertical"/);
|
||||||
|
assert.match(markup, /min="-1.2" max="51.5" step="any"/);
|
||||||
|
assert.equal((markup.match(/nodedc-range-scale__limit--max">51,5<\/span>/g) ?? []).length, 1);
|
||||||
|
assert.equal((markup.match(/nodedc-range-scale__limit--min">-1,2<\/span>/g) ?? []).length, 1);
|
||||||
|
assert.match(markup, /<\/span><\/span><\/div><span class="nodedc-range-scale__limit/);
|
||||||
|
assert.equal((markup.match(/nodedc-range__label">Срез · /g) ?? []).length, 2);
|
||||||
|
assert.match(markup, /nodedc-range__fill-text" aria-hidden="true"/);
|
||||||
|
assert.doesNotMatch(markup, /nodedc-range__value|nodedc-range__editor|formatLimit=/);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("vertical scale mirrors only endpoint labels for the opposite scene edge", () => {
|
||||||
|
const markup = renderToStaticMarkup(createElement(RangeControl, {
|
||||||
|
label: "Срез", orientation: "vertical", limitSide: "left", value: 25.15, min: -1.2, max: 51.5,
|
||||||
|
step: "any", formatLimit: (n) => n.toFixed(1).replace('.', ','), onChange: () => {},
|
||||||
|
}));
|
||||||
|
assert.match(markup, /class="nodedc-range-scale" data-limit-side="left"/);
|
||||||
|
assert.match(markup, /nodedc-range-scale__limit--max">51,5<\/span>/);
|
||||||
|
assert.match(markup, /nodedc-range-scale__limit--min">-1,2<\/span>/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("vertical contrast clips both glyph layers and visible focus has no perimeter", async () => {
|
||||||
|
const styles = await readFile(new URL("../packages/ui-core/styles.css", import.meta.url), "utf8");
|
||||||
|
assert.match(styles, /\.nodedc-range\[data-orientation="vertical"\] \.nodedc-range__fill-text \{ clip-path: inset\(calc\(100% - var\(--nodedc-range-progress\)\) 0 0 round var\(--nodedc-radius-circle\)\)/);
|
||||||
|
assert.match(styles, /\.nodedc-range-scale \{[^}]*width: calc\(var\(--nodedc-control-height\) \* 0\.4\)/);
|
||||||
|
assert.match(styles, /\.nodedc-range-scale__limit \{[^}]*left: calc\(100% \+ 0\.4rem\);[^}]*color: var\(--nodedc-text-primary\);[^}]*font-size: calc\(var\(--nodedc-font-size-xs\) \* 0\.9\)/);
|
||||||
|
assert.match(styles, /\.nodedc-range-scale\[data-limit-side="left"\] \.nodedc-range-scale__limit \{[^}]*right: calc\(100% \+ 0\.4rem\);[^}]*left: auto;[^}]*text-align: right/);
|
||||||
|
assert.match(styles, /\.nodedc-range\[data-orientation="vertical"\] \.nodedc-range__label \{[^}]*font-size: calc\(var\(--nodedc-font-size-sm\) \* 0\.9\)/);
|
||||||
|
assert.match(styles, /\.nodedc-range\[data-orientation="vertical"\] \{[^}]*border: 0; outline: 0; box-shadow: none;/);
|
||||||
|
assert.match(styles, /\.nodedc-range\[data-orientation="vertical"\]:has\(input:focus-visible\) \.nodedc-range__label \{ text-decoration: underline; text-decoration-thickness: 2px;/);
|
||||||
|
assert.doesNotMatch(styles, /\.nodedc-range\[data-orientation="vertical"\]:has\(input:focus-visible\) \{/);
|
||||||
|
assert.doesNotMatch(styles, /\.nodedc-range\[data-orientation="vertical"\]:focus-within/);
|
||||||
|
});
|
||||||
|
|
||||||
test("exact editing is a stable native caret field without remount, forced selection or a dark editor box", async () => {
|
test("exact editing is a stable native caret field without remount, forced selection or a dark editor box", async () => {
|
||||||
const [component, styles, windowComponent] = await Promise.all([
|
const [component, styles, windowComponent] = await Promise.all([
|
||||||
readFile(new URL("../packages/ui-react/src/RangeControl.tsx", import.meta.url), "utf8"),
|
readFile(new URL("../packages/ui-react/src/RangeControl.tsx", import.meta.url), "utf8"),
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { readFile } from 'node:fs/promises';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { createElement } from 'react';
|
||||||
|
import { renderToStaticMarkup } from 'react-dom/server';
|
||||||
|
import { Select } from '../packages/ui-react/dist/index.js';
|
||||||
|
|
||||||
|
test('select action menus use dialog semantics in all variants; ordinary select stays a listbox', () => {
|
||||||
|
for (const variant of ['integrated', 'split', 'inline']) {
|
||||||
|
for (const withAction of [false, true]) {
|
||||||
|
const markup = renderToStaticMarkup(createElement(Select, {
|
||||||
|
label: 'Records', variant, value: 'one', onChange: () => {},
|
||||||
|
options: [{value: 'one', label: 'One', ...(withAction ? {action: {label: 'Delete One', icon: 'x', onAction: () => {}}} : {})}],
|
||||||
|
}));
|
||||||
|
assert.match(markup, new RegExp(`aria-haspopup="${withAction ? 'dialog' : 'listbox'}"`));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('row action is independent of selection, closes first and uses canonical IconButton', async () => {
|
||||||
|
const source = await readFile(new URL('../packages/ui-react/src/Select.tsx', import.meta.url), 'utf8');
|
||||||
|
assert.match(source, /<\/button>\s*\{option\.action \? <IconButton/);
|
||||||
|
assert.match(source, /disabled=\{option\.action\.disabled\}/);
|
||||||
|
assert.match(source, /close\(\);[\s\S]*triggerButton.current\?\.focus\(\);\s*option\.action\?\.onAction\(\)/);
|
||||||
|
assert.match(source, /role=\{withActions \? undefined : "option"\}/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('row action matches the compact session catalog without a circular backing', async () => {
|
||||||
|
const css = await readFile(new URL('../packages/ui-core/styles.css', import.meta.url), 'utf8');
|
||||||
|
const rule = css.match(/\.nodedc-select-option-row > \.nodedc-icon-button \{([^}]+)\}/)?.[1];
|
||||||
|
assert.match(rule, /width: 2\.35rem/);
|
||||||
|
assert.match(rule, /height: auto/);
|
||||||
|
assert.match(rule, /border-radius: 0/);
|
||||||
|
assert.match(rule, /background: transparent/);
|
||||||
|
assert.match(rule, /box-shadow: none/);
|
||||||
|
assert.match(rule, /color: var\(--nodedc-text-muted\)/);
|
||||||
|
assert.match(css, /\.nodedc-select-option-row > \.nodedc-icon-button > svg \{ width: 15px; height: 15px; transform: none/);
|
||||||
|
assert.match(css, /data-tone="danger"\]\:focus-visible \{ color: rgb\(var\(--nodedc-danger-rgb\)\)/);
|
||||||
|
});
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import {readFileSync} from "node:fs";
|
||||||
|
import test from "node:test";
|
||||||
|
import {createElement} from "react";
|
||||||
|
import {renderToStaticMarkup} from "react-dom/server";
|
||||||
|
import {StatusBadge} from "../packages/ui-react/dist/index.js";
|
||||||
|
|
||||||
|
test("status pulse is opt-in and does not replace the status text",()=>{
|
||||||
|
const status=renderToStaticMarkup(createElement(StatusBadge,{tone:"danger",pulse:true},"Привязка потеряна"));
|
||||||
|
assert.match(status,/data-pulse="true"/);
|
||||||
|
assert.match(status,/Привязка потеряна/);
|
||||||
|
assert.doesNotMatch(renderToStaticMarkup(createElement(StatusBadge,{},"Готово")),/data-pulse/);
|
||||||
|
});
|
||||||
|
test("only the lamp animates, with static reduced-motion fallback",()=>{
|
||||||
|
const css=readFileSync(new URL("../packages/ui-core/styles.css",import.meta.url),"utf8");
|
||||||
|
assert.match(css,/\.nodedc-status\[data-pulse="true"\]::before\s*\{[^}]*animation: nodedc-status-pulse/);
|
||||||
|
assert.match(css,/@media \(prefers-reduced-motion: reduce\) \{\s*\.nodedc-status\[data-pulse="true"\]::before \{ animation: none;/);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user