feat(ui): stabilize laboratory viewer primitives

This commit is contained in:
Codex
2026-08-24 22:31:35 +03:00
parent c7e136cc14
commit 6e7255ecdb
17 changed files with 590 additions and 30 deletions
+52 -2
View File
@@ -33,6 +33,7 @@ import {
Select,
ShareAccessModal,
ShareLinkModal,
SplitPane,
SortableItem,
SortableScope,
SettingsCard,
@@ -219,7 +220,7 @@ const iconGroups: Array<{ title: string; note: string; icons: IconName[] }> = [
{
title: "Контент",
note: "SEO / BIM / Engine",
icons: ["image", "video", "file", "folder", "clipboard", "settings"],
icons: ["camera", "plan", "play", "stop", "image", "video", "file", "folder", "clipboard", "settings"],
},
];
@@ -228,6 +229,7 @@ const iconLabels: Record<IconName, string> = {
alert: "Предупреждение",
apps: "Приложения",
building: "Компания",
camera: "Камера",
check: "Готово",
"chevron-down": "Раскрыть",
"chevron-left": "Назад",
@@ -256,6 +258,8 @@ const iconLabels: Record<IconName, string> = {
minimize: "Свернуть",
network: "Связи",
panel: "Панель",
plan: "План",
play: "Воспроизвести",
target: "Таргеты",
plus: "Добавить",
profile: "Профиль",
@@ -265,6 +269,7 @@ const iconLabels: Record<IconName, string> = {
settings: "Настройки",
shield: "Доступ",
sliders: "Параметры",
stop: "Остановить",
trash: "Удалить",
upload: "Загрузить",
users: "Участники",
@@ -362,6 +367,7 @@ export function CatalogApp() {
const [workspaceWindowDemoOpen, setWorkspaceWindowDemoOpen] = useState(true);
const [workspaceWindowDemoMaximized, setWorkspaceWindowDemoMaximized] = useState(false);
const [workspaceWindowDemoRect, setWorkspaceWindowDemoRect] = useState<WorkspaceWindowRect>({ x: 24, y: 24, width: 340, height: 230 });
const [splitPaneDemoSize, setSplitPaneDemoSize] = useState(50);
const [sidePanelDemoOpen, setSidePanelDemoOpen] = useState(true);
const [createModalOpen, setCreateModalOpen] = useState(false);
const [confirmOpen, setConfirmOpen] = useState(false);
@@ -1327,7 +1333,7 @@ export function CatalogApp() {
case "controls":
return (
<div className="catalog-grid">
<Preview title="Selection" note="integrated / split">
<Preview title="Selection" note="integrated / split / inline">
<div className="catalog-form">
<FieldFrame label="Hub / integrated select">
<Select label="Статус" value={selectedStatus} options={[...selectOptions]} searchable onChange={setSelectedStatus} />
@@ -1345,6 +1351,19 @@ export function CatalogApp() {
onChange={setConnectionType}
/>
</FieldFrame>
<FieldFrame label="Toolbar / inline select">
<Select
variant="inline"
label="Скорость воспроизведения"
value="1"
options={[
{ value: "0.5", label: "0,5×" },
{ value: "1", label: "1×" },
{ value: "2", label: "2×" },
]}
onChange={() => undefined}
/>
</FieldFrame>
<Dropdown
trigger={({ open, toggle, setTriggerRef, surfaceId }) => (
<Button ref={setTriggerRef} aria-expanded={open} aria-controls={surfaceId} icon={<Icon name="chevron-down" />} onClick={toggle}>Действия</Button>
@@ -1397,6 +1416,21 @@ export function CatalogApp() {
<IconButton label="Закрыть"><Icon name="close" /></IconButton>
</div>
</Preview>
<Preview title="Вертикальная рейка" note="glass pill / круг `46 px`" className="catalog-preview--compact">
<GlassSurface
className="catalog-icon-rail-demo"
tone="strong"
radius="pill"
padding="sm"
materialRim={false}
role="toolbar"
aria-label="Режимы просмотра"
>
<IconButton label="Камера" aria-pressed="true"><Icon name="camera" /></IconButton>
<IconButton label="3D"><span aria-hidden="true">3D</span></IconButton>
<IconButton label="План"><Icon name="plan" /></IconButton>
</GlassSurface>
</Preview>
<Preview title="Workspace window" note="inline / bounded / controlled" className="catalog-preview--wide catalog-workspace-window-preview">
<div ref={workspaceWindowDemoRef} className="catalog-workspace-window-demo">
{workspaceWindowDemoOpen ? (
@@ -1435,6 +1469,20 @@ export function CatalogApp() {
</div>
<p className="catalog-preview__explanation">Rectangle, maximize, close и stacking остаются состоянием приложения; дизайн-система владеет одинаковой bounded-геометрией и доступным управлением.</p>
</Preview>
<Preview title="Split pane" note="pointer / keyboard / controlled" className="catalog-preview--wide catalog-split-pane-preview">
<div className="catalog-split-pane-demo">
<SplitPane
primarySize={splitPaneDemoSize}
onPrimarySizeChange={setSplitPaneDemoSize}
minPrimarySize={25}
minSecondarySize={25}
separatorLabel="Изменить ширину синхронных представлений"
primary={<div className="catalog-split-pane-demo__panel"><Icon name="video" /><strong>Видео</strong></div>}
secondary={<div className="catalog-split-pane-demo__panel"><Icon name="grid" /><strong>Пространственная сцена</strong></div>}
/>
</div>
<p className="catalog-preview__explanation">Граница изменяет контролируемую долю панелей; клавиши и pointer используют одни ограничения, а resize контейнера не сбрасывает выбранное соотношение.</p>
</Preview>
<Preview title="Application side panel" note="end / push / controlled" className="catalog-preview--wide catalog-side-panel-preview">
<div className="catalog-side-panel-demo">
{sidePanelDemoOpen ? (
@@ -1638,6 +1686,8 @@ export function CatalogApp() {
<div className="catalog-icon-catalog">
<Preview title="Размеры и поверхности" note="glyph 16 px" className="catalog-preview--wide">
<div className="catalog-window-actions-demo">
<IconButton label="Камера включена" aria-pressed="true"><Icon name="camera" /></IconButton>
<IconButton label="План"><Icon name="plan" /></IconButton>
<IconButton label="Добавить"><Icon name="plus" /></IconButton>
<IconButton label="Обновить"><Icon name="refresh" /></IconButton>
<IconButton label="Развернуть"><Icon name="expand" /></IconButton>
+35
View File
@@ -1678,6 +1678,33 @@ textarea {
transform: translate(-50%, -50%);
}
.catalog-split-pane-preview .catalog-preview__body {
align-content: stretch;
}
.catalog-split-pane-demo {
min-height: 18rem;
overflow: hidden;
border-radius: var(--nodedc-radius-card);
background: var(--nodedc-nested-surface);
}
.catalog-split-pane-demo__panel {
display: grid;
width: 100%;
height: 100%;
place-content: center;
justify-items: center;
gap: 0.5rem;
background: color-mix(in srgb, var(--nodedc-canvas) 82%, transparent);
color: var(--nodedc-text-secondary);
}
.catalog-split-pane-demo__panel strong {
color: var(--nodedc-text-primary);
font-size: 0.82rem;
}
.catalog-side-panel-preview .catalog-preview__body {
align-content: stretch;
}
@@ -1754,6 +1781,14 @@ textarea {
flex-wrap: wrap;
}
.catalog-icon-rail-demo {
display: flex;
width: max-content;
flex-direction: column;
align-items: center;
gap: var(--nodedc-space-2);
}
.catalog-form {
display: grid;
gap: 0.75rem;
+16 -3
View File
@@ -10,6 +10,8 @@
- `strong` — dropdown, modal и поверхность над сложным фоном;
- `soft` — вложенная или вторичная область.
`radius="pill"` задаёт каноническую капсульную геометрию через `--nodedc-radius-circle`. Она предназначена, в частности, для компактных вертикальных и горизонтальных реек из круглых действий: верхняя и нижняя части поверхности повторяют круг кнопок без прямых торцевых пролётов. Consumer не воспроизводит этот радиус локальным CSS.
Material rim допустим только как часть floating glass. Жёсткая цветная рамка, случайный browser outline или debug border не являются rim.
`GlassMaterialSurface` — отдельный переносимый контракт Engine Glass V4. Он централизует tint/opacity, blur, saturation, brightness, gradient rim и shadow. Его используют только modal `Window` и modeless draggable Inspector; обычные Launcher/SEO панели остаются непрозрачными. Сам `Window` всегда получает класс `nodedc-glass-material` и `data-material="glass-v4"`, поэтому sharing, confirmation и остальные modal-паттерны физически используют тот же surface, что Inspector и лабораторный preview. Настройки применяются через `applyGlassMaterial`, поэтому consumer не пересобирает CSS материала вручную.
@@ -25,6 +27,8 @@ Button используется для всех текстовых действ
Icon-only action по умолчанию круглый. Квадратная кнопка с маленьким радиусом допустима только как кнопка закрытия окна или плотный инструмент, где это зафиксировано контрактом.
Переключаемый IconButton передаёт контролируемое состояние через `aria-pressed`. Активная поверхность и контраст принадлежат дизайн-системе; размер круга и glyph при переключении не меняются.
## ActivityIndicator
`ActivityIndicator` — общий индикатор неопределённого по длительности процесса. `default` используется рядом с самостоятельным статусом, `compact` — в icon-slot кнопки. Владелец операции по-прежнему задаёт видимый текст pending-состояния и `aria-busy`; индикатор не хранит таймер и не определяет завершение операции.
@@ -75,10 +79,11 @@ Dropdown владеет floating-layer поведением:
## Select
Select добавляет к Dropdown контролируемое значение, options и необязательный поиск. У него две канонические формы:
Select добавляет к Dropdown контролируемое значение, options и необязательный поиск. У него три канонические формы:
- `integrated` — единая pill-поверхность Hub/Launcher: label слева, chevron строго у правого края;
- `split` — форма Engine с отдельной областью значения и отдельной кнопкой раскрытия `46 px`, между ними зазор `8 px`.
- `split` — форма Engine с отдельной областью значения и отдельной кнопкой раскрытия `46 px`, между ними зазор `8 px`;
- `inline` — компактный toolbar-trigger: только текущее значение без постоянной подложки и chevron; всё значение открывает обычное portal-меню.
Native select не используется как видимый runtime UI. Меню рендерится через portal; у Engine-варианта меню имеет радиус `20 px`, а строки — `14 px` и высоту `42 px`.
@@ -145,6 +150,14 @@ Enter при отсутствии совпадения разрешает оди
ConfirmationModal оборачивает Window и защищает async-confirm от повторного запуска. До завершения операции закрытие можно заблокировать.
## SplitPane
`SplitPane` — контролируемая раскладка двух синхронных представлений с общей регулируемой границей. Приложение передаёт содержимое обеих панелей, процент primary-панели и callback изменения; дизайн-система владеет pointer capture, ограничениями размеров, focus-state и доступностью `role="separator"`.
Вертикальная граница меняется мышью или клавишами `←/→`, горизонтальная — `↑/↓`; `Home/End` переходят к разрешённым границам, а `Shift` ускоряет клавиатурный шаг. Доля сохраняется при resize контейнера. Минимальные проценты обеих панелей применяются одинаково к pointer и keyboard interaction.
Разделитель является структурной границей между двумя рабочими представлениями и не создаёт декоративную рамку вокруг контента. При `resizable={false}` панели сохраняют ту же стабильную DOM-композицию, а separator полностью отсутствует в визуальном слое и accessibility tree; это позволяет приложению сворачивать одну из панелей без remount тяжёлого renderer.
## ShareAccessModal
Workflow-sharing modal из нового Engine: заголовок ресурса, compact avatar stack, список участников, редактирование ролей, удаление доступа, email и роль нового участника. Компонент сохраняет Engine-геометрию `600 px` и controls `46 px`, но не импортирует ACL API. Consumer передаёт members, permissions и callbacks.
@@ -271,7 +284,7 @@ Sortable-строки ограничены вертикальной осью: г
## ToastStack
`ToastCard` и `ToastStack` фиксируют Tasker-derived bottom-right уведомления для `success`, `error`, `warning`, `info` и `loading`. Приложение владеет текстом и состоянием операции; стек владеет portal, геометрией, aria-live и таймерами. Loading не закрывается автоматически и обновляется тем же id после завершения операции. 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.
## Environment Controls: Inspector и ControlRow
+5 -1
View File
@@ -21,10 +21,14 @@
| Редактирование | `save`, `edit`, `trash`, `copy`, `upload`, `download`, `external` |
| Состояние и доступ | `check`, `alert`, `activity`, `lock`, `key`, `shield`, `circle`, `eye`, `eye-off` |
| Сущности | `profile`, `users`, `building`, `globe`, `database`, `network`, `inbox`, `mail` |
| Контент | `image`, `video`, `file`, `folder`, `clipboard`, `settings` |
| Контент | `camera`, `plan`, `play`, `stop`, `image`, `video`, `file`, `folder`, `clipboard`, `settings` |
Живая таблица с названиями, поверхностями и размерами находится в разделе `Guideline → Иконки`. Машинный список — в `registry/icons.json`.
## Правило расширения
Новая иконка добавляется только после подтверждённого применения в продукте. Нужно выбрать семантическое имя, добавить export в `Icon`, запись в registry и specimen в каталоге. Локальный импорт иконки только ради немного другой формы запрещён.
`camera` обозначает оптический канал, а `plan` — пространственное представление сверху. Оба glyph контурные и используют общую stroke-геометрию набора.
`play` и `stop` — компактные полнотелые транспортные glyph: залитый треугольник запуска и залитый квадрат остановки/паузы. Видимая текстовая подпись не требуется, но action обязан сохранить доступный `aria-label`.
+5 -2
View File
@@ -11,7 +11,7 @@
"scripts": {
"build": "npm run build --workspace @nodedc/ui-core && npm run build --workspace @nodedc/ui-dom && npm run build --workspace @nodedc/ui-react && npm run build --workspace @nodedc/page-patterns && npm run build --workspace @nodedc/map-cesium-react && npm run build --workspace @nodedc/ui-catalog",
"build:packages": "npm run build --workspace @nodedc/ui-core && npm run build --workspace @nodedc/ui-dom && npm run build --workspace @nodedc/ui-react && npm run build --workspace @nodedc/page-patterns && npm run build --workspace @nodedc/map-cesium-react",
"check": "npm run build:packages && npm run typecheck --workspaces --if-present && npm run validate:registry && npm run test:spark-governance && npm run test:activity-indicator && npm run test:floating-position && npm run test:inspector-select && npm run test:range-control && npm run test:hgeozone-projection && npm run test:map-grid-lod && npm run test:map-filters && npm run test:map-object-layers && npm run test:map-inspector-overlay-state && npm run test:map-reference-stations && npm run test:map-search && npm run test:map-subject-card && npm run test:map-subject-detail-profile",
"check": "npm run build:packages && npm run typecheck --workspaces --if-present && npm run validate:registry && npm run test:spark-governance && npm run test:activity-indicator && npm run test:icon-contract && npm run test:toast-contract && npm run test:floating-position && npm run test:inspector-select && npm run test:range-control && npm run test:split-pane && npm run test:hgeozone-projection && npm run test:map-grid-lod && npm run test:map-filters && npm run test:map-object-layers && npm run test:map-inspector-overlay-state && npm run test:map-reference-stations && npm run test:map-search && npm run test:map-subject-card && npm run test:map-subject-detail-profile",
"dev": "npm run build:packages && npm run dev --workspace @nodedc/ui-catalog",
"serve": "node server/catalog-server.mjs",
"validate:registry": "node scripts/validate-registry.mjs",
@@ -33,9 +33,12 @@
"test:map-cache-contract": "node --test scripts/map-cache-resource-contract.test.mjs",
"test:spark-governance": "node --test scripts/spark-governance-contract.test.mjs",
"test:activity-indicator": "node --test scripts/activity-indicator-contract.test.mjs",
"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: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"
},
"engines": {
"node": ">=20"
+125 -2
View File
@@ -15,13 +15,13 @@
box-sizing: border-box;
}
:where(.nodedc-button, .nodedc-icon-button, .nodedc-checker, .nodedc-select-trigger, .nodedc-select__toggle, .nodedc-dropdown-option, .nodedc-segmented__item, .nodedc-header__nav-item, .nodedc-inspector__section-trigger, .nodedc-window__close, .nodedc-workspace-window__action, .nodedc-workspace-window__resize) {
:where(.nodedc-button, .nodedc-icon-button, .nodedc-checker, .nodedc-select-trigger, .nodedc-select-inline, .nodedc-select__toggle, .nodedc-dropdown-option, .nodedc-segmented__item, .nodedc-header__nav-item, .nodedc-inspector__section-trigger, .nodedc-window__close, .nodedc-workspace-window__action, .nodedc-workspace-window__resize) {
border: 0;
outline: 0;
font: inherit;
}
:where(.nodedc-button, .nodedc-icon-button, .nodedc-checker, .nodedc-select-trigger, .nodedc-select__toggle, .nodedc-dropdown-option, .nodedc-segmented__item, .nodedc-header__nav-item, .nodedc-inspector__section-trigger, .nodedc-window__close, .nodedc-workspace-window__action, .nodedc-workspace-window__resize):focus-visible {
:where(.nodedc-button, .nodedc-icon-button, .nodedc-checker, .nodedc-select-trigger, .nodedc-select-inline, .nodedc-select__toggle, .nodedc-dropdown-option, .nodedc-segmented__item, .nodedc-header__nav-item, .nodedc-inspector__section-trigger, .nodedc-window__close, .nodedc-workspace-window__action, .nodedc-workspace-window__resize):focus-visible {
background-color: var(--nodedc-focus-surface);
box-shadow: var(--nodedc-glass-control-shadow), inset 0 0 0 1px rgb(var(--nodedc-accent-rgb) / 0.22);
}
@@ -73,6 +73,10 @@
border-radius: var(--nodedc-radius-modal);
}
.nodedc-glass[data-radius="pill"] {
border-radius: var(--nodedc-radius-circle);
}
.nodedc-glass[data-padding="sm"] {
padding: var(--nodedc-space-3);
}
@@ -255,6 +259,11 @@
color: var(--nodedc-text-primary);
}
.nodedc-icon-button[aria-pressed="true"] {
background: var(--nodedc-glass-control-active);
color: var(--nodedc-glass-control-active-text);
}
.nodedc-icon-button:active:not(:disabled) {
transform: translateY(1px);
}
@@ -2206,6 +2215,33 @@ textarea.nodedc-field__control {
transform: translateY(2px) rotate(225deg);
}
.nodedc-select-inline {
display: inline-flex;
min-height: var(--nodedc-control-height-compact);
align-items: center;
justify-content: center;
gap: var(--nodedc-space-2);
border-radius: var(--nodedc-radius-circle);
background: transparent;
color: var(--nodedc-text-secondary);
padding: 0 var(--nodedc-space-3);
font-size: var(--nodedc-font-size-sm);
font-weight: var(--nodedc-font-weight-strong);
line-height: 1;
cursor: pointer;
}
.nodedc-select-inline:hover:not(:disabled),
.nodedc-select-inline[aria-expanded="true"] {
background: var(--nodedc-glass-control-hover);
color: var(--nodedc-text-primary);
}
.nodedc-select-inline:disabled {
opacity: 0.42;
cursor: not-allowed;
}
.nodedc-select__toggle[aria-expanded="true"] .nodedc-select-trigger__chevron {
transform: translateY(2px) rotate(225deg);
}
@@ -2829,6 +2865,93 @@ textarea.nodedc-field__control {
color: var(--nodedc-text-primary);
}
.nodedc-split-pane {
--nodedc-split-pane-primary: 50%;
position: relative;
display: grid;
width: 100%;
height: 100%;
min-width: 0;
min-height: 0;
overflow: hidden;
}
.nodedc-split-pane[data-orientation="vertical"] {
grid-template-columns: minmax(0, var(--nodedc-split-pane-primary)) minmax(0, 1fr);
}
.nodedc-split-pane[data-orientation="horizontal"] {
grid-template-rows: minmax(0, var(--nodedc-split-pane-primary)) minmax(0, 1fr);
}
.nodedc-split-pane__panel {
min-width: 0;
min-height: 0;
overflow: hidden;
}
.nodedc-split-pane__separator {
position: absolute;
z-index: 5;
border: 0;
outline: 0;
background: transparent;
padding: 0;
touch-action: none;
}
.nodedc-split-pane[data-orientation="vertical"] > .nodedc-split-pane__separator {
top: 0;
bottom: 0;
left: var(--nodedc-split-pane-primary);
width: 0.9rem;
cursor: col-resize;
transform: translateX(-50%);
}
.nodedc-split-pane[data-orientation="horizontal"] > .nodedc-split-pane__separator {
right: 0;
bottom: calc(100% - var(--nodedc-split-pane-primary));
left: 0;
height: 0.9rem;
cursor: row-resize;
transform: translateY(50%);
}
.nodedc-split-pane__separator::before {
position: absolute;
background: var(--nodedc-glass-outline);
content: "";
transition: background var(--nodedc-duration-fast) var(--nodedc-ease-standard), box-shadow var(--nodedc-duration-fast) var(--nodedc-ease-standard);
}
.nodedc-split-pane[data-orientation="vertical"] > .nodedc-split-pane__separator::before {
top: 0;
bottom: 0;
left: 50%;
width: 1px;
transform: translateX(-50%);
}
.nodedc-split-pane[data-orientation="horizontal"] > .nodedc-split-pane__separator::before {
right: 0;
bottom: 50%;
left: 0;
height: 1px;
transform: translateY(50%);
}
.nodedc-split-pane__separator:hover::before,
.nodedc-split-pane__separator:focus-visible::before,
.nodedc-split-pane[data-dragging="true"] > .nodedc-split-pane__separator::before {
background: rgb(var(--nodedc-accent-rgb));
box-shadow: 0 0 0 2px rgb(var(--nodedc-accent-rgb) / 0.16);
}
.nodedc-split-pane[data-dragging="true"] {
user-select: none;
}
.nodedc-toolbar-wrap {
position: fixed;
z-index: calc(var(--nodedc-layer-header) + 20);
+1 -1
View File
@@ -2,7 +2,7 @@ import type { HTMLAttributes, ReactNode } from "react";
import { cn } from "./cn.js";
export type GlassTone = "default" | "strong" | "soft";
export type GlassRadius = "card" | "panel" | "modal";
export type GlassRadius = "card" | "panel" | "modal" | "pill";
export type GlassPadding = "none" | "sm" | "md" | "lg";
export interface GlassSurfaceProps extends HTMLAttributes<HTMLDivElement> {
+12 -2
View File
@@ -3,6 +3,7 @@ import {
AlertTriangle,
Boxes,
Building2,
Camera,
Check,
ChevronDown,
ChevronLeft,
@@ -26,11 +27,13 @@ import {
LocateFixed,
LockKeyhole,
MailPlus,
Map,
Maximize2,
Minimize2,
Network,
PanelTop,
Pencil,
Play,
Plus,
RefreshCw,
Save,
@@ -38,6 +41,7 @@ import {
Settings,
ShieldCheck,
SlidersHorizontal,
Square,
Trash2,
UploadCloud,
UserCircle,
@@ -53,6 +57,7 @@ const icons = {
alert: AlertTriangle,
apps: Boxes,
building: Building2,
camera: Camera,
check: Check,
"chevron-down": ChevronDown,
"chevron-left": ChevronLeft,
@@ -82,6 +87,8 @@ const icons = {
minimize: Minimize2,
network: Network,
panel: PanelTop,
plan: Map,
play: Play,
plus: Plus,
profile: UserCircle,
refresh: RefreshCw,
@@ -90,6 +97,7 @@ const icons = {
settings: Settings,
shield: ShieldCheck,
sliders: SlidersHorizontal,
stop: Square,
trash: Trash2,
upload: UploadCloud,
users: UsersRound,
@@ -103,13 +111,15 @@ export interface IconProps extends Omit<LucideProps, "ref"> {
label?: string;
}
export function Icon({ name, label, size = 16, strokeWidth = 1.6, ...props }: IconProps) {
export function Icon({ name, label, size = 16, strokeWidth = 1.6, fill, ...props }: IconProps) {
const IconComponent = icons[name];
const filledTransport = name === "play" || name === "stop";
return (
<IconComponent
size={size}
strokeWidth={strokeWidth}
strokeWidth={filledTransport ? 0 : strokeWidth}
fill={fill ?? (filledTransport ? "currentColor" : "none")}
aria-hidden={label ? undefined : "true"}
aria-label={label}
role={label ? "img" : undefined}
+21 -1
View File
@@ -12,7 +12,7 @@ export interface SelectOption<T extends string> {
disabled?: boolean;
}
export type SelectVariant = "integrated" | "split";
export type SelectVariant = "integrated" | "split" | "inline";
export interface SelectProps<T extends string> {
value: T;
@@ -102,6 +102,26 @@ export function Select<T extends string>({
);
}
if (resolvedVariant === "inline") {
return (
<button
ref={setTriggerRef}
type="button"
className={cn("nodedc-select-inline", triggerClassName)}
aria-label={label}
aria-haspopup="listbox"
aria-controls={surfaceId}
aria-expanded={open}
disabled={disabled}
onClick={toggle}
onKeyDown={handleKeyDown}
>
{selected?.icon ? <span className="nodedc-select-trigger__icon">{selected.icon}</span> : null}
<span>{selected?.label ?? "—"}</span>
</button>
);
}
return (
<button
ref={setTriggerRef}
+156
View File
@@ -0,0 +1,156 @@
import {
useRef,
useState,
type CSSProperties,
type HTMLAttributes,
type KeyboardEvent,
type PointerEvent,
type ReactNode,
} from "react";
import { cn } from "./cn.js";
export type SplitPaneOrientation = "vertical" | "horizontal";
export interface SplitPaneProps extends Omit<HTMLAttributes<HTMLDivElement>, "children"> {
primary: ReactNode;
secondary: ReactNode;
primarySize: number;
onPrimarySizeChange: (primarySize: number) => void;
orientation?: SplitPaneOrientation;
minPrimarySize?: number;
minSecondarySize?: number;
step?: number;
resizable?: boolean;
separatorLabel: string;
}
const clamp = (value: number, minimum: number, maximum: number) => (
Math.min(Math.max(value, minimum), maximum)
);
const normalizedLimits = (minPrimarySize: number, minSecondarySize: number) => {
const primary = clamp(minPrimarySize, 0, 100);
const secondary = clamp(minSecondarySize, 0, 100);
if (primary + secondary <= 100) return { minimum: primary, maximum: 100 - secondary };
const scale = 100 / (primary + secondary);
return { minimum: primary * scale, maximum: 100 - secondary * scale };
};
export function SplitPane({
primary,
secondary,
primarySize,
onPrimarySizeChange,
orientation = "vertical",
minPrimarySize = 20,
minSecondarySize = 20,
step = 2,
resizable = true,
separatorLabel,
className,
style,
...props
}: SplitPaneProps) {
const rootRef = useRef<HTMLDivElement>(null);
const activePointerIdRef = useRef<number | null>(null);
const [dragging, setDragging] = useState(false);
const limits = normalizedLimits(minPrimarySize, minSecondarySize);
const size = clamp(Number.isFinite(primarySize) ? primarySize : 50, limits.minimum, limits.maximum);
const keyboardIncrement = Number.isFinite(step) && step > 0 ? step : 2;
const emitPointerSize = (event: PointerEvent<HTMLElement>) => {
const root = rootRef.current;
if (!root) return;
const rect = root.getBoundingClientRect();
const available = orientation === "vertical" ? rect.width : rect.height;
if (available <= 0) return;
const offset = orientation === "vertical"
? event.clientX - rect.left
: event.clientY - rect.top;
onPrimarySizeChange(clamp(offset / available * 100, limits.minimum, limits.maximum));
};
const handleKeyDown = (event: KeyboardEvent<HTMLElement>) => {
const multiplier = event.shiftKey ? 5 : 1;
const decrement = orientation === "vertical" ? "ArrowLeft" : "ArrowUp";
const increment = orientation === "vertical" ? "ArrowRight" : "ArrowDown";
let next: number | null = null;
if (event.key === decrement) next = size - keyboardIncrement * multiplier;
if (event.key === increment) next = size + keyboardIncrement * multiplier;
if (event.key === "Home") next = limits.minimum;
if (event.key === "End") next = limits.maximum;
if (next === null) return;
event.preventDefault();
onPrimarySizeChange(clamp(next, limits.minimum, limits.maximum));
};
const splitStyle = {
...style,
"--nodedc-split-pane-primary": `${size}%`,
} as CSSProperties;
return (
<div
ref={rootRef}
className={cn("nodedc-split-pane", className)}
data-orientation={orientation}
data-dragging={dragging ? "true" : undefined}
style={splitStyle}
{...props}
>
<div className="nodedc-split-pane__panel" data-pane="primary">
{primary}
</div>
<div className="nodedc-split-pane__panel" data-pane="secondary">
{secondary}
</div>
{resizable ? (
<div
className="nodedc-split-pane__separator"
role="separator"
tabIndex={0}
aria-label={separatorLabel}
aria-orientation={orientation}
aria-valuemin={Math.round(limits.minimum)}
aria-valuemax={Math.round(limits.maximum)}
aria-valuenow={Math.round(size)}
aria-valuetext={`${Math.round(size)}% / ${Math.round(100 - size)}%`}
onKeyDown={handleKeyDown}
onPointerDown={(event) => {
if (event.button !== 0) return;
event.currentTarget.focus();
activePointerIdRef.current = event.pointerId;
event.currentTarget.setPointerCapture(event.pointerId);
setDragging(true);
emitPointerSize(event);
event.preventDefault();
}}
onPointerMove={(event) => {
if (activePointerIdRef.current !== event.pointerId) return;
emitPointerSize(event);
event.preventDefault();
}}
onPointerUp={(event) => {
if (activePointerIdRef.current !== event.pointerId) return;
activePointerIdRef.current = null;
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
event.currentTarget.releasePointerCapture(event.pointerId);
}
setDragging(false);
}}
onPointerCancel={(event) => {
if (activePointerIdRef.current !== event.pointerId) return;
activePointerIdRef.current = null;
setDragging(false);
}}
onLostPointerCapture={(event) => {
if (activePointerIdRef.current !== event.pointerId) return;
activePointerIdRef.current = null;
setDragging(false);
}}
/>
) : null}
</div>
);
}
+23 -12
View File
@@ -1,4 +1,4 @@
import { useEffect, type HTMLAttributes } from "react";
import { useEffect, useRef, type HTMLAttributes } from "react";
import { createPortal } from "react-dom";
import { cn } from "./cn.js";
import { Icon, type IconName } from "./Icon.js";
@@ -21,6 +21,8 @@ const toastIcons: Record<ToastTone, IconName> = {
loading: "refresh",
};
const DEFAULT_TOAST_DURATION_MS = 10_000;
export interface ToastCardProps extends HTMLAttributes<HTMLDivElement> {
item: ToastItem;
onDismiss?: (id: string) => void;
@@ -48,21 +50,30 @@ export function ToastCard({ item, onDismiss, className, ...props }: ToastCardPro
);
}
export function ToastStack({ items, onDismiss }: { items: ToastItem[]; onDismiss: (id: string) => void }) {
useEffect(() => {
const timers = items.flatMap((item) => {
const duration = item.durationMs === undefined ? 4200 : item.durationMs;
return typeof duration === "number" && duration > 0
? [window.setTimeout(() => onDismiss(item.id), duration)]
: [];
});
return () => timers.forEach((timer) => window.clearTimeout(timer));
}, [items, onDismiss]);
function TimedToastCard({ item, onDismiss }: { item: ToastItem; onDismiss: (id: string) => void }) {
const dismissRef = useRef(onDismiss);
useEffect(() => {
dismissRef.current = onDismiss;
}, [onDismiss]);
useEffect(() => {
const duration = item.durationMs === undefined
? item.tone === "loading" ? null : DEFAULT_TOAST_DURATION_MS
: item.durationMs;
if (typeof duration !== "number" || duration <= 0) return undefined;
const timer = window.setTimeout(() => dismissRef.current(item.id), duration);
return () => window.clearTimeout(timer);
}, [item.durationMs, item.id, item.tone]);
return <ToastCard item={item} onDismiss={onDismiss} />;
}
export function ToastStack({ items, onDismiss }: { items: ToastItem[]; onDismiss: (id: string) => void }) {
if (typeof document === "undefined" || items.length === 0) return null;
return createPortal(
<div className="nodedc-toast-viewport nodedc-ui-root" aria-live="polite" aria-relevant="additions removals">
{items.map((item) => <ToastCard key={item.id} item={item} onDismiss={onDismiss} />)}
{items.map((item) => <TimedToastCard key={item.id} item={item} onDismiss={onDismiss} />)}
</div>,
document.body,
);
+1
View File
@@ -21,6 +21,7 @@ export * from "./Select.js";
export * from "./StatusBadge.js";
export * from "./Settings.js";
export * from "./SharingModals.js";
export * from "./SplitPane.js";
export * from "./Toolbar.js";
export * from "./Toast.js";
export * from "./UserProfileMenu.js";
+23 -2
View File
@@ -10,8 +10,10 @@
"summary": "Matte application surfaces, the Engine V4 modal material and the light translucent surface used over Cesium imagery.",
"anatomy": ["theme-provided surface", "modal tint", "map-only light translucency", "gradient rim", "backdrop blur/saturation/brightness", "content"],
"variants": ["default", "strong", "soft", "map"],
"radiusVariants": ["card", "panel", "modal", "pill"],
"rules": [
"Geometry is invariant across application themes.",
"Compact rails of canonical round actions use the pill radius; consumers do not recreate it with a local border radius.",
"A material rim is a glass highlight, not a hard product-colored border.",
"Nested surfaces must use a softer tone to avoid card-inside-card noise.",
"GlassyMaterialSurface is restricted to modal Windows and the draggable Inspector.",
@@ -29,6 +31,7 @@
"shapes": ["default", "pill", "rounded"],
"rules": [
"Icon-only actions are circular by default.",
"Controlled toggle actions expose aria-pressed and use the canonical active surface without changing geometry.",
"Filled accent actions derive foreground contrast from the actual accent.",
"Destructive actions remain neutral until confirmation unless danger is the principal message."
]
@@ -129,11 +132,12 @@
"domPackage": "@nodedc/ui-dom",
"domExports": ["createSelectController"],
"summary": "Theme-independent selection control using the canonical dropdown layer.",
"variants": ["integrated", "split"],
"variants": ["integrated", "split", "inline"],
"behavior": ["controlled value", "optional search", "disabled options", "selected state", "portal menu"],
"rules": [
"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.",
"Integrated Select is forbidden inside Inspector; use InspectorSelectField so the visible label is above a full-width split control.",
"The portal menu preserves its source-family row geometry instead of inheriting card radii."
]
@@ -179,6 +183,22 @@
"summary": "Async-safe confirmation window for destructive and consequential operations.",
"behavior": ["pending state", "double-submit protection", "disabled close policy while pending"]
},
{
"id": "split-pane",
"status": "baseline",
"package": "@nodedc/ui-react",
"exports": ["SplitPane", "SplitPaneProps", "SplitPaneOrientation"],
"domContract": ["nodedc-split-pane", "nodedc-split-pane__panel", "nodedc-split-pane__separator"],
"summary": "Controlled two-panel layout with a pointer- and keyboard-resizable accessible separator.",
"anatomy": ["primary panel", "structural separator", "secondary panel"],
"variants": ["vertical", "horizontal"],
"behavior": ["controlled percentage", "optional resizable separator", "pointer capture drag", "Arrow/Home/End keyboard sizing", "ARIA separator range", "ratio preservation across host resize"],
"rules": [
"The application owns the controlled primary percentage and panel content; the component owns separator interaction and accessibility.",
"Minimum primary and secondary percentages clamp both pointer and keyboard changes.",
"The separator is a necessary structural divider, never a decorative perimeter border."
]
},
{
"id": "share-access-modal",
"status": "baseline",
@@ -385,9 +405,10 @@
"domContract": ["nodedc-toast-viewport", "nodedc-toast", "nodedc-toast__icon", "nodedc-toast__copy"],
"summary": "Tasker-derived non-blocking status notification with a bottom-right glass stack.",
"variants": ["success", "error", "warning", "info", "loading"],
"behavior": ["controlled items", "optional auto-dismiss", "manual dismiss", "portal viewport", "polite live region"],
"behavior": ["controlled items", "independent 10 second default auto-dismiss per terminal item", "manual dismiss", "portal viewport", "polite live region"],
"rules": [
"Applications own operation state and message copy; ToastStack owns viewport geometry and dismissal timing.",
"Adding or updating another notification never restarts the lifetime of an existing item; each item owns its timer by stable id.",
"Loading notifications remain until the operation updates or dismisses them.",
"Status notifications never replace a blocking confirmation modal."
]
+4 -2
View File
@@ -6,6 +6,7 @@
"defaultSize": 18,
"supportedSizes": [16, 18, 20],
"strokeWidth": 1.8,
"filledNames": ["play", "stop"],
"groups": [
{
"id": "window-layer",
@@ -41,13 +42,14 @@
"id": "content",
"label": "Контент",
"referenceSources": ["seo", "bim-viewer", "engine"],
"names": ["image", "video", "file", "folder", "clipboard", "settings"]
"names": ["camera", "plan", "play", "stop", "image", "video", "file", "folder", "clipboard", "settings"]
}
],
"rules": [
"Use semantic icon names from this registry instead of importing an application-local icon by shape.",
"Icons inherit currentColor; their button surface, active state and hit target are owned by Button, IconButton or the containing pattern.",
"Do not copy the complete Font Awesome or Lucide vendor catalog into application code.",
"A new icon requires a confirmed product use, a registry entry and a catalog specimen."
"A new icon requires a confirmed product use, a registry entry and a catalog specimen.",
"Playback icons play and stop are filled semantic glyphs; all other canonical glyphs remain outline unless their semantic contract says otherwise."
]
}
+30
View File
@@ -0,0 +1,30 @@
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 { Icon } from "../packages/ui-react/dist/index.js";
test("only registered playback glyphs are filled", async () => {
const registry = JSON.parse(await readFile(
new URL("../registry/icons.json", import.meta.url),
"utf8",
));
const names = registry.groups.flatMap((group) => group.names);
const filledNames = new Set(registry.filledNames);
assert.deepEqual([...filledNames], ["play", "stop"]);
assert.equal(new Set(names).size, names.length);
for (const name of names) {
const markup = renderToStaticMarkup(createElement(Icon, { name }));
const rootTag = markup.slice(0, markup.indexOf(">") + 1);
if (filledNames.has(name)) {
assert.match(rootTag, /fill="currentColor"/, `${name} must be filled`);
assert.match(rootTag, /stroke-width="0"/, `${name} must not retain an outline`);
} else {
assert.match(rootTag, /fill="none"/, `${name} must remain outline-only`);
assert.doesNotMatch(rootTag, /fill="currentColor"/, `${name} must not be filled`);
}
}
});
+63
View File
@@ -0,0 +1,63 @@
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 { SplitPane } from "../packages/ui-react/dist/index.js";
test("SplitPane exposes one controlled accessible separator", () => {
const markup = renderToStaticMarkup(createElement(SplitPane, {
primary: createElement("div", null, "Видео"),
secondary: createElement("div", null, "3D"),
primarySize: 64,
onPrimarySizeChange: () => {},
minPrimarySize: 25,
minSecondarySize: 30,
separatorLabel: "Изменить ширину представлений",
}));
assert.match(markup, /class="nodedc-split-pane"/);
assert.match(markup, /role="separator"/);
assert.match(markup, /aria-orientation="vertical"/);
assert.match(markup, /aria-valuemin="25"/);
assert.match(markup, /aria-valuemax="70"/);
assert.match(markup, /aria-valuenow="64"/);
assert.match(markup, /aria-valuetext="64% \/ 36%"/);
});
test("SplitPane can keep panel ownership stable without an inactive separator", () => {
const markup = renderToStaticMarkup(createElement(SplitPane, {
primary: createElement("div", null, "Видео"),
secondary: createElement("div", null, "3D"),
primarySize: 100,
onPrimarySizeChange: () => {},
separatorLabel: "Изменить ширину представлений",
resizable: false,
}));
assert.match(markup, /data-pane="primary"/);
assert.match(markup, /data-pane="secondary"/);
assert.doesNotMatch(markup, /role="separator"/);
});
test("SplitPane is registered, documented, cataloged and keyboard-addressable", async () => {
const [component, styles, registrySource, docs, catalog] = await Promise.all([
readFile(new URL("../packages/ui-react/src/SplitPane.tsx", import.meta.url), "utf8"),
readFile(new URL("../packages/ui-core/styles.css", import.meta.url), "utf8"),
readFile(new URL("../registry/components.json", import.meta.url), "utf8"),
readFile(new URL("../docs/COMPONENTS.md", import.meta.url), "utf8"),
readFile(new URL("../apps/catalog/src/CatalogApp.tsx", import.meta.url), "utf8"),
]);
const registry = JSON.parse(registrySource);
const entry = registry.components.find((item) => item.id === "split-pane");
assert.ok(entry?.exports.includes("SplitPane"));
assert.deepEqual(entry?.variants, ["vertical", "horizontal"]);
assert.match(component, /event\.key === "Home"/);
assert.match(component, /event\.key === "End"/);
assert.match(component, /setPointerCapture/);
assert.match(component, /activePointerIdRef\.current !== event\.pointerId/);
assert.match(component, /role="separator"/);
assert.match(styles, /\.nodedc-split-pane__separator:focus-visible::before/);
assert.match(docs, /## SplitPane/);
assert.match(catalog, /<SplitPane/);
});
+18
View File
@@ -0,0 +1,18 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
const source = readFileSync(
new URL("../packages/ui-react/src/Toast.tsx", import.meta.url),
"utf8",
);
test("toast items own independent ten-second terminal lifetimes", () => {
assert.match(source, /const DEFAULT_TOAST_DURATION_MS = 10_000/);
assert.match(source, /function TimedToastCard/);
assert.match(source, /item\.tone === "loading" \? null : DEFAULT_TOAST_DURATION_MS/);
assert.match(source, /window\.setTimeout\(\(\) => dismissRef\.current\(item\.id\), duration\)/);
assert.match(source, /\[item\.durationMs, item\.id, item\.tone\]/);
assert.match(source, /items\.map\(\(item\) => <TimedToastCard key=\{item\.id\}/);
assert.doesNotMatch(source, /items\.flatMap/);
});