feat(ui): stabilize laboratory viewer primitives
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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
@@ -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."
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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`);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -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/);
|
||||
});
|
||||
Reference in New Issue
Block a user