feat(ui): add spatial clipping scale and compact record actions

This commit is contained in:
Codex
2026-09-21 08:45:10 +03:00
parent 67025ee209
commit 8dd9190573
15 changed files with 263 additions and 21 deletions
+13 -2
View File
@@ -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="Получение сведений" />
+18
View File
@@ -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.
+2
View File
@@ -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
View File
@@ -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"
},
+49
View File
@@ -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}
>
+2
View File
@@ -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,
+24
View File
@@ -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
+37 -11
View File
@@ -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}
</>
+4 -3
View File
@@ -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>
);
}
+9 -3
View File
@@ -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
View File
@@ -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",
+43
View File
@@ -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"),
+39
View File
@@ -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\)\)/);
});
+18
View File
@@ -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;/);
});