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: "Состояние и доступ",
|
||||
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: "Сущности",
|
||||
@@ -257,6 +257,7 @@ const iconLabels: Record<IconName, string> = {
|
||||
grid: "Обзор",
|
||||
image: "Изображение",
|
||||
inbox: "Уведомления",
|
||||
info: "Информация",
|
||||
key: "Ключ",
|
||||
layers: "Слои",
|
||||
list: "Список задач",
|
||||
@@ -1294,6 +1295,8 @@ export function CatalogApp() {
|
||||
<>
|
||||
<ControlRow label="Цвет"><ColorField value={lightColor} onChange={setLightColor} /></ControlRow>
|
||||
<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} />
|
||||
</>
|
||||
),
|
||||
@@ -1345,6 +1348,14 @@ export function CatalogApp() {
|
||||
<FieldFrame label="Hub / integrated select">
|
||||
<Select label="Статус" value={selectedStatus} options={[...selectOptions]} searchable onChange={setSelectedStatus} />
|
||||
</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">
|
||||
<Select
|
||||
variant="split"
|
||||
@@ -1415,7 +1426,7 @@ export function CatalogApp() {
|
||||
<Preview title="Загрузка содержимого" note="в центре собственной области">
|
||||
<LoadingRegion loading label="Ожидаем содержимое" />
|
||||
</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="измеренный / неизвестный / завершённый">
|
||||
<ProgressBar label="Подготовка" value={0.6} valueText="Три этапа из пяти" />
|
||||
<ProgressBar label="Получение сведений" />
|
||||
|
||||
@@ -57,10 +57,18 @@ FieldFrame объединяет label, control, hint и description. TextField/T
|
||||
|
||||
## 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 }` сохраняет неотрицательное значение, но не ограничивает верхнюю границу ползунком.
|
||||
|
||||
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
|
||||
|
||||
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-форме.
|
||||
|
||||
`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 — единая механика открытия modal и правой panel:
|
||||
@@ -240,6 +252,10 @@ setup-команды, сохранение, API и права принадлеж
|
||||
|
||||
Постоянные действия всего рабочего окна передаются через `ApplicationPanel.utilityActions` и всегда находятся в правой части шапки перед expand/close. Сохранение общего layout или настроек приложения — одно такое действие для всего окна; оно не дублируется внутри отдельных карточек и секций.
|
||||
|
||||
Переключение режима выбранного объекта может передать `pressed` в utility action: состояние получает `aria-pressed` и общую активную поверхность. Например, `info` открывает обзор текущей сохранённой сессии Mission Core.
|
||||
|
||||
Если заголовок и действия не помещаются в одну строку, шапка переносит действия на следующую строку с выравниванием вправо. Кнопки не обрезаются, заголовок сохраняет читаемую ширину. Состав действий одинаков в обычном и развёрнутом окне.
|
||||
|
||||
Точные состояния и размеры зафиксированы в `docs/APPLICATION_TEMPLATE.md`.
|
||||
|
||||
## MediaSourceField
|
||||
@@ -308,6 +324,8 @@ Sortable-строки ограничены вертикальной осью: г
|
||||
|
||||
Статус использует semantic tone: neutral, success, warning, danger или accent. Бизнес-статус приложения маппится на tone в самом приложении.
|
||||
|
||||
`pulse` добавляет пульсацию только лампы, не текста и не фона. Подходит для потери подтверждения или другого состояния, требующего внимания. При `prefers-reduced-motion: reduce` лампа остаётся постоянной. Текст статуса обязателен рядом с отдельным индикатором или в самом badge.
|
||||
|
||||
## 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.
|
||||
|
||||
@@ -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 не делает иконку канонической.
|
||||
|
||||
## Геометрия
|
||||
|
||||
+1
-1
@@ -38,7 +38,7 @@
|
||||
"test:icon-contract": "node --test scripts/icon-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: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:split-pane": "node --test scripts/split-pane-contract.test.mjs"
|
||||
},
|
||||
|
||||
@@ -842,6 +842,33 @@ textarea.nodedc-field__control {
|
||||
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__value {
|
||||
position: absolute;
|
||||
@@ -1627,6 +1654,8 @@ textarea.nodedc-field__control {
|
||||
|
||||
.nodedc-application-panel__head {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
min-width: 0;
|
||||
min-height: 3.55rem;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
@@ -1635,6 +1664,7 @@ textarea.nodedc-field__control {
|
||||
|
||||
.nodedc-application-panel__titles {
|
||||
display: grid;
|
||||
flex: 1 1 18rem;
|
||||
min-width: 0;
|
||||
gap: 0.24rem;
|
||||
padding: 0.2rem 0.25rem;
|
||||
@@ -1669,12 +1699,17 @@ textarea.nodedc-field__control {
|
||||
.nodedc-application-panel__actions {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
flex-wrap: wrap;
|
||||
max-width: 100%;
|
||||
margin-left: auto;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.nodedc-application-panel__tools {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
align-items: center;
|
||||
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="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. */
|
||||
.nodedc-landing-stage {
|
||||
position: relative;
|
||||
|
||||
@@ -68,6 +68,7 @@ export interface ApplicationPanelUtilityAction {
|
||||
icon: IconName;
|
||||
onClick: () => void;
|
||||
disabled?: boolean;
|
||||
pressed?: boolean;
|
||||
}
|
||||
|
||||
export function ApplicationPanel({
|
||||
@@ -102,6 +103,8 @@ export function ApplicationPanel({
|
||||
className="nodedc-application-panel__action"
|
||||
data-action="utility"
|
||||
aria-label={action.label}
|
||||
aria-pressed={action.pressed}
|
||||
data-active={action.pressed ? "true" : undefined}
|
||||
disabled={action.disabled}
|
||||
onClick={action.onClick}
|
||||
>
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
Globe2,
|
||||
Image,
|
||||
Inbox,
|
||||
Info,
|
||||
KeyRound,
|
||||
Layers,
|
||||
LayoutDashboard,
|
||||
@@ -80,6 +81,7 @@ const icons = {
|
||||
grid: LayoutDashboard,
|
||||
image: Image,
|
||||
inbox: Inbox,
|
||||
info: Info,
|
||||
key: KeyRound,
|
||||
layers: Layers,
|
||||
list: ListChecks,
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Dropdown } from "./Dropdown.js";
|
||||
|
||||
export interface RangeControlProps extends Omit<InputHTMLAttributes<HTMLInputElement>, "type" | "value" | "onChange"> {
|
||||
label: string;
|
||||
orientation?: "horizontal" | "vertical";
|
||||
value: number;
|
||||
min: number;
|
||||
max: number;
|
||||
@@ -12,6 +13,10 @@ export interface RangeControlProps extends Omit<InputHTMLAttributes<HTMLInputEle
|
||||
max?: number;
|
||||
};
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -52,12 +57,15 @@ const editableNumber = (value: number) => (
|
||||
|
||||
export function RangeControl({
|
||||
label,
|
||||
orientation = "horizontal",
|
||||
value,
|
||||
min,
|
||||
max,
|
||||
exactValueBounds,
|
||||
step,
|
||||
formatValue = String,
|
||||
formatLimit = String,
|
||||
limitSide = "right",
|
||||
onChange,
|
||||
className,
|
||||
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 (
|
||||
<div ref={rootRef} className={cn("nodedc-range", className)} style={style} data-editing={editing || undefined}>
|
||||
<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 { Dropdown } from "./Dropdown.js";
|
||||
import { IconButton } from "./Button.js";
|
||||
import { InspectorSelectPolicyContext } from "./InspectorContext.js";
|
||||
import { cn } from "./cn.js";
|
||||
|
||||
@@ -10,6 +11,7 @@ export interface SelectOption<T extends string> {
|
||||
description?: string;
|
||||
icon?: ReactNode;
|
||||
disabled?: boolean;
|
||||
action?: { label: string; icon: ReactNode; disabled?: boolean; tone?: "neutral" | "danger"; onAction: () => void };
|
||||
}
|
||||
|
||||
export type SelectVariant = "integrated" | "split" | "inline";
|
||||
@@ -52,7 +54,11 @@ export function Select<T extends string>({
|
||||
const inspectorSplitRequired = useContext(InspectorSelectPolicyContext);
|
||||
const resolvedVariant: SelectVariant = inspectorSplitRequired ? "split" : variant;
|
||||
const [query, setQuery] = useState("");
|
||||
const triggerButton = useRef<HTMLButtonElement | null>(null);
|
||||
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 normalized = query.trim().toLocaleLowerCase();
|
||||
if (!normalized) return options;
|
||||
@@ -66,10 +72,15 @@ export function Select<T extends string>({
|
||||
minWidth={minMenuWidth}
|
||||
width={menuWidth}
|
||||
disabled={disabled}
|
||||
surfaceRole="listbox"
|
||||
surfaceRole={popupRole}
|
||||
surfaceClassName={cn(resolvedVariant === "split" && "nodedc-select__menu", menuClassName)}
|
||||
trigger={({ open, toggle, setAnchorRef, setTriggerRef, surfaceId }) => {
|
||||
const handleClick = (event: MouseEvent<HTMLButtonElement>) => {
|
||||
triggerButton.current = event.currentTarget;
|
||||
toggle();
|
||||
};
|
||||
const handleKeyDown = (event: KeyboardEvent<HTMLButtonElement>) => {
|
||||
triggerButton.current = event.currentTarget;
|
||||
if (event.key === "ArrowDown" || event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
if (!open) toggle();
|
||||
@@ -88,11 +99,11 @@ export function Select<T extends string>({
|
||||
type="button"
|
||||
className="nodedc-select__toggle"
|
||||
aria-label={label}
|
||||
aria-haspopup="listbox"
|
||||
aria-haspopup={popupRole}
|
||||
aria-controls={surfaceId}
|
||||
aria-expanded={open}
|
||||
disabled={disabled}
|
||||
onClick={toggle}
|
||||
onClick={handleClick}
|
||||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
<span className="nodedc-select-trigger__chevron" aria-hidden="true" />
|
||||
@@ -109,11 +120,11 @@ export function Select<T extends string>({
|
||||
type="button"
|
||||
className={cn("nodedc-select-inline", triggerClassName)}
|
||||
aria-label={label}
|
||||
aria-haspopup="listbox"
|
||||
aria-haspopup={popupRole}
|
||||
aria-controls={surfaceId}
|
||||
aria-expanded={open}
|
||||
disabled={disabled}
|
||||
onClick={toggle}
|
||||
onClick={handleClick}
|
||||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
{selected?.icon ? <span className="nodedc-select-trigger__icon">{selected.icon}</span> : null}
|
||||
@@ -128,11 +139,11 @@ export function Select<T extends string>({
|
||||
type="button"
|
||||
className={cn("nodedc-select-trigger", triggerClassName)}
|
||||
aria-label={label}
|
||||
aria-haspopup="listbox"
|
||||
aria-haspopup={popupRole}
|
||||
aria-controls={surfaceId}
|
||||
aria-expanded={open}
|
||||
disabled={disabled}
|
||||
onClick={toggle}
|
||||
onClick={handleClick}
|
||||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
{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>
|
||||
) : null}
|
||||
{visibleOptions.map((option) => (
|
||||
<div key={option.value} className="nodedc-select-option-row">
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
className="nodedc-dropdown-option"
|
||||
role="option"
|
||||
aria-selected={option.value === value}
|
||||
role={withActions ? undefined : "option"}
|
||||
aria-selected={withActions ? undefined : option.value === value}
|
||||
aria-pressed={withActions ? option.value === value : undefined}
|
||||
data-selected={option.value === value ? "true" : undefined}
|
||||
disabled={option.disabled}
|
||||
onClick={() => {
|
||||
@@ -173,6 +185,20 @@ export function Select<T extends string>({
|
||||
</span>
|
||||
{option.value === value ? <span className="nodedc-dropdown-option__check" aria-hidden="true">•</span> : null}
|
||||
</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}
|
||||
</>
|
||||
|
||||
@@ -6,13 +6,14 @@ export type StatusTone = "neutral" | "success" | "warning" | "danger" | "accent"
|
||||
export interface StatusBadgeProps extends HTMLAttributes<HTMLSpanElement> {
|
||||
tone?: StatusTone;
|
||||
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 (
|
||||
<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}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -169,10 +169,13 @@
|
||||
"package": "@nodedc/ui-react",
|
||||
"exports": ["RangeControl"],
|
||||
"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"],
|
||||
"rules": [
|
||||
"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 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.",
|
||||
@@ -222,8 +225,10 @@
|
||||
"domExports": ["createSelectController"],
|
||||
"summary": "Theme-independent selection control using the canonical dropdown layer.",
|
||||
"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": [
|
||||
"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.",
|
||||
"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.",
|
||||
@@ -513,7 +518,8 @@
|
||||
"exports": ["StatusBadge"],
|
||||
"variants": ["badge", "indicator"],
|
||||
"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",
|
||||
|
||||
+1
-1
@@ -30,7 +30,7 @@
|
||||
"id": "state-access",
|
||||
"label": "Состояние и доступ",
|
||||
"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",
|
||||
|
||||
@@ -23,6 +23,49 @@ test("RangeControl keeps native drag semantics and exposes one persistent exact-
|
||||
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 () => {
|
||||
const [component, styles, windowComponent] = await Promise.all([
|
||||
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