Compare commits
15
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d51d8bb7f6 | ||
|
|
c8f4916423 | ||
|
|
6e7255ecdb | ||
|
|
c7e136cc14 | ||
|
|
51cb426c6b | ||
|
|
8f38c76f79 | ||
|
|
2fa1951f51 | ||
|
|
19f0d97e23 | ||
|
|
bbb50e06b4 | ||
|
|
d6c62da470 | ||
|
|
a3385e83c4 | ||
|
|
9fa81fde9a | ||
|
|
117bfe0c3a | ||
|
|
1c5246afe8 | ||
|
|
4116f5ba95 |
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties,
|
||||
import { applyGlassMaterial, applyNodedcTheme, defaultGlassMaterial, type GlassMaterialSettings, type NodedcTheme, type RgbTuple } from "@nodedc/ui-core";
|
||||
import { createTemplateFeatures, getPageTemplate, pageTemplates, type PageTemplateDefinition } from "@nodedc/page-patterns";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
AdminNavigationPanel,
|
||||
AppHeader,
|
||||
ApplicationPanel,
|
||||
@@ -32,6 +33,7 @@ import {
|
||||
Select,
|
||||
ShareAccessModal,
|
||||
ShareLinkModal,
|
||||
SplitPane,
|
||||
SortableItem,
|
||||
SortableScope,
|
||||
SettingsCard,
|
||||
@@ -208,7 +210,7 @@ const iconGroups: Array<{ title: string; note: string; icons: IconName[] }> = [
|
||||
{
|
||||
title: "Состояние и доступ",
|
||||
note: "Вся платформа",
|
||||
icons: ["check", "alert", "activity", "lock", "key", "shield", "circle"],
|
||||
icons: ["check", "alert", "activity", "lock", "key", "shield", "circle", "eye", "eye-off"],
|
||||
},
|
||||
{
|
||||
title: "Сущности",
|
||||
@@ -218,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"],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -227,6 +229,7 @@ const iconLabels: Record<IconName, string> = {
|
||||
alert: "Предупреждение",
|
||||
apps: "Приложения",
|
||||
building: "Компания",
|
||||
camera: "Камера",
|
||||
check: "Готово",
|
||||
"chevron-down": "Раскрыть",
|
||||
"chevron-left": "Назад",
|
||||
@@ -238,6 +241,8 @@ const iconLabels: Record<IconName, string> = {
|
||||
database: "База данных",
|
||||
download: "Скачать",
|
||||
edit: "Редактировать",
|
||||
eye: "Показать",
|
||||
"eye-off": "Скрыть",
|
||||
expand: "Развернуть",
|
||||
external: "Открыть снаружи",
|
||||
file: "Файл",
|
||||
@@ -253,6 +258,8 @@ const iconLabels: Record<IconName, string> = {
|
||||
minimize: "Свернуть",
|
||||
network: "Связи",
|
||||
panel: "Панель",
|
||||
plan: "План",
|
||||
play: "Воспроизвести",
|
||||
target: "Таргеты",
|
||||
plus: "Добавить",
|
||||
profile: "Профиль",
|
||||
@@ -262,6 +269,7 @@ const iconLabels: Record<IconName, string> = {
|
||||
settings: "Настройки",
|
||||
shield: "Доступ",
|
||||
sliders: "Параметры",
|
||||
stop: "Остановить",
|
||||
trash: "Удалить",
|
||||
upload: "Загрузить",
|
||||
users: "Участники",
|
||||
@@ -359,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);
|
||||
@@ -1278,7 +1287,7 @@ export function CatalogApp() {
|
||||
<>
|
||||
<ControlRow label="Цвет"><ColorField value={lightColor} onChange={setLightColor} /></ControlRow>
|
||||
<RangeControl label="Яркость" value={brightness} min={0} max={100} formatValue={(value) => `${value}%`} onChange={setBrightness} />
|
||||
<RangeControl label="Дистанция свечения" value={glowDistance} min={0} max={200} formatValue={(value) => `${value}%`} onChange={setGlowDistance} />
|
||||
<RangeControl label="Дистанция свечения" value={glowDistance} min={0} max={200} exactValueBounds={{ min: 0 }} formatValue={(value) => `${value}%`} onChange={setGlowDistance} />
|
||||
</>
|
||||
),
|
||||
},
|
||||
@@ -1324,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} />
|
||||
@@ -1342,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>
|
||||
@@ -1372,6 +1394,20 @@ export function CatalogApp() {
|
||||
<IconButton label="Добавить"><Icon name="plus" /></IconButton>
|
||||
</div>
|
||||
</Preview>
|
||||
<Preview title="Активность" note="default / compact" className="catalog-preview--compact">
|
||||
<div className="catalog-inline catalog-inline--wrap">
|
||||
<span className="catalog-inline">
|
||||
<ActivityIndicator label="Загружаем данные" />
|
||||
<span>Загружаем данные</span>
|
||||
</span>
|
||||
<Button
|
||||
aria-busy="true"
|
||||
disabled
|
||||
icon={<ActivityIndicator size="compact" />}
|
||||
>Подключаем…</Button>
|
||||
</div>
|
||||
<p className="catalog-preview__explanation">При reduced motion кольцо остаётся видимым без вращения; процесс и его завершение принадлежат приложению.</p>
|
||||
</Preview>
|
||||
<Preview title="Оконные действия" note="круг `46 px`" className="catalog-preview--compact">
|
||||
<div className="catalog-window-actions-demo">
|
||||
<Button shape="pill" icon={<Icon name="refresh" />}>Обновить источник</Button>
|
||||
@@ -1380,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 ? (
|
||||
@@ -1418,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 ? (
|
||||
@@ -1621,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;
|
||||
|
||||
+29
-4
@@ -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,16 @@ Button используется для всех текстовых действ
|
||||
|
||||
Icon-only action по умолчанию круглый. Квадратная кнопка с маленьким радиусом допустима только как кнопка закрытия окна или плотный инструмент, где это зафиксировано контрактом.
|
||||
|
||||
`size="dense"` — каноническая плотность для текстовых layer/mode-действий внутри визуализатора. Она уменьшает площадь и подпись контрола, но не меняет hover, active, disabled и focus-состояния. В шапках приложения и обычных формах эта плотность не используется.
|
||||
|
||||
Переключаемый IconButton передаёт контролируемое состояние через `aria-pressed`. Активная поверхность и контраст принадлежат дизайн-системе; размер круга и glyph при переключении не меняются.
|
||||
|
||||
## ActivityIndicator
|
||||
|
||||
`ActivityIndicator` — общий индикатор неопределённого по длительности процесса. `default` используется рядом с самостоятельным статусом, `compact` — в icon-slot кнопки. Владелец операции по-прежнему задаёт видимый текст pending-состояния и `aria-busy`; индикатор не хранит таймер и не определяет завершение операции.
|
||||
|
||||
Без `label` индикатор декоративный и скрыт от accessibility tree. `label` включает `role="status"` только когда сам индикатор должен объявить процесс. При `prefers-reduced-motion: reduce` кольцо остаётся видимым, но не вращается.
|
||||
|
||||
## Field
|
||||
|
||||
FieldFrame объединяет label, control, hint и description. TextField/TextAreaField реализуют стандартные текстовые поля.
|
||||
@@ -39,7 +51,9 @@ FieldFrame объединяет label, control, hint и description. TextField/T
|
||||
|
||||
## RangeControl
|
||||
|
||||
Pill-range с заполнением акцентным цветом, встроенной подписью и значением. Домен определяет min/max/step и формат числа. Drag всегда принадлежит невидимому native range и продолжается под областью значения. Над числом постоянно смонтирован один прозрачный native text input: клик включает редактирование без замены DOM-узла, браузер ставит каретку в фактическое место клика, а drag-selection остаётся нативным. Поле не получает собственной подложки, select-all или второго focus-ring. Enter/blur применяют значение с clamp/step-нормализацией, Escape отменяет ввод; события редактора не всплывают в оконные shortcuts. Оконный focus-manager выполняет начальный autofocus только при открытии и не отбирает фокус при последующих ререндерах.
|
||||
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 только при открытии и не отбирает фокус при последующих ререндерах.
|
||||
|
||||
## ColorField
|
||||
|
||||
@@ -69,10 +83,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`.
|
||||
|
||||
@@ -139,6 +154,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.
|
||||
@@ -153,6 +176,8 @@ Read-only link/copy окно из BIM Viewer. React-компонент испо
|
||||
|
||||
Pill navigation для верхней панели и компактного переключения режимов. Active segment использует активную поверхность темы; это не обязательно основной accent приложения.
|
||||
|
||||
`size="dense"` применяется только в насыщенной панели слоёв или режимов непосредственно над viewer. Шапка приложения сохраняет default-геометрию; consumer не воспроизводит dense-отступы или шрифт локальным CSS.
|
||||
|
||||
## AppHeader
|
||||
|
||||
Трёхосевая верхняя панель:
|
||||
@@ -265,7 +290,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
|
||||
|
||||
|
||||
+6
-2
@@ -19,12 +19,16 @@
|
||||
| Окно и слой | `close`, `plus`, `expand`, `minimize`, `refresh`, `panel`, `apps` |
|
||||
| Навигация | `chevron-left`, `chevron-right`, `chevron-down`, `grid`, `list`, `sliders`, `search` |
|
||||
| Редактирование | `save`, `edit`, `trash`, `copy`, `upload`, `download`, `external` |
|
||||
| Состояние и доступ | `check`, `alert`, `activity`, `lock`, `key`, `shield`, `circle` |
|
||||
| Состояние и доступ | `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`.
|
||||
|
||||
+8
-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: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:control-density && 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",
|
||||
@@ -31,9 +31,15 @@
|
||||
"test:map-subject-card": "node --test scripts/map-subject-card.test.mjs",
|
||||
"test:map-subject-detail-profile": "node --test server/map-subject-detail-profile.test.mjs server/map-live-data-slot.test.mjs",
|
||||
"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:control-density": "node --test scripts/control-density-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"
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
|
||||
--nodedc-control-height: 2.875rem;
|
||||
--nodedc-control-height-compact: 2.375rem;
|
||||
--nodedc-control-height-dense: 1.875rem;
|
||||
--nodedc-icon-button-size: 2.875rem;
|
||||
--nodedc-header-row-height: 3rem;
|
||||
--nodedc-header-pill-height: 3.45rem;
|
||||
@@ -61,6 +62,7 @@
|
||||
|
||||
--nodedc-font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
--nodedc-font-size-xs: 0.6875rem;
|
||||
--nodedc-font-size-dense: 0.45rem;
|
||||
--nodedc-font-size-sm: 0.75rem;
|
||||
--nodedc-font-size-md: 0.8125rem;
|
||||
--nodedc-font-size-lg: 1rem;
|
||||
|
||||
+174
-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);
|
||||
}
|
||||
@@ -138,6 +142,13 @@
|
||||
font-size: var(--nodedc-font-size-sm);
|
||||
}
|
||||
|
||||
.nodedc-button[data-size="dense"] {
|
||||
min-height: var(--nodedc-control-height-dense);
|
||||
border-radius: var(--nodedc-radius-control-compact);
|
||||
padding-inline: 0.65rem;
|
||||
font-size: var(--nodedc-font-size-dense);
|
||||
}
|
||||
|
||||
.nodedc-button[data-width="full"] {
|
||||
width: 100%;
|
||||
}
|
||||
@@ -211,6 +222,30 @@
|
||||
transform: scale(var(--nodedc-icon-glyph-scale));
|
||||
}
|
||||
|
||||
.nodedc-activity-indicator {
|
||||
display: inline-block;
|
||||
width: 1.125rem;
|
||||
height: 1.125rem;
|
||||
flex: 0 0 auto;
|
||||
box-sizing: border-box;
|
||||
border: 2px solid currentColor;
|
||||
border-inline-end-color: transparent;
|
||||
border-radius: var(--nodedc-radius-circle);
|
||||
color: inherit;
|
||||
opacity: 0.78;
|
||||
animation: nodedc-activity-indicator-spin 780ms linear infinite;
|
||||
}
|
||||
|
||||
.nodedc-activity-indicator[data-size="compact"] {
|
||||
width: 0.875rem;
|
||||
height: 0.875rem;
|
||||
border-width: 1.5px;
|
||||
}
|
||||
|
||||
@keyframes nodedc-activity-indicator-spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.nodedc-icon-button {
|
||||
display: inline-grid;
|
||||
width: var(--nodedc-icon-button-size);
|
||||
@@ -231,6 +266,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);
|
||||
}
|
||||
@@ -769,6 +809,10 @@ textarea.nodedc-field__control {
|
||||
caret-color: currentColor;
|
||||
}
|
||||
|
||||
.nodedc-range__editor[data-active][data-contrast="fill"] {
|
||||
color: rgb(var(--nodedc-on-accent-rgb));
|
||||
}
|
||||
|
||||
.nodedc-range[data-editing] > .nodedc-range__value,
|
||||
.nodedc-range[data-editing] > .nodedc-range__fill-text .nodedc-range__value {
|
||||
opacity: 0;
|
||||
@@ -1002,6 +1046,19 @@ textarea.nodedc-field__control {
|
||||
color: var(--nodedc-glass-control-active-text);
|
||||
}
|
||||
|
||||
.nodedc-segmented[data-size="dense"] {
|
||||
min-height: var(--nodedc-control-height-dense);
|
||||
gap: 0.1rem;
|
||||
padding: 0.16rem;
|
||||
}
|
||||
|
||||
.nodedc-segmented[data-size="dense"] .nodedc-segmented__item {
|
||||
min-height: calc(var(--nodedc-control-height-dense) - 0.32rem);
|
||||
gap: 0.25rem;
|
||||
padding: 0.1rem 0.68rem;
|
||||
font-size: var(--nodedc-font-size-dense);
|
||||
}
|
||||
|
||||
.nodedc-header-shell {
|
||||
position: relative;
|
||||
z-index: var(--nodedc-layer-header);
|
||||
@@ -2182,6 +2239,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);
|
||||
}
|
||||
@@ -2805,6 +2889,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);
|
||||
@@ -3585,6 +3756,7 @@ textarea.nodedc-field__control {
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.nodedc-activity-indicator,
|
||||
.nodedc-dropdown-surface,
|
||||
.nodedc-overlay,
|
||||
.nodedc-window,
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { HTMLAttributes } from "react";
|
||||
import { cn } from "./cn.js";
|
||||
|
||||
export type ActivityIndicatorSize = "default" | "compact";
|
||||
|
||||
export interface ActivityIndicatorProps extends Omit<
|
||||
HTMLAttributes<HTMLSpanElement>,
|
||||
"aria-hidden" | "aria-label" | "children" | "role"
|
||||
> {
|
||||
size?: ActivityIndicatorSize;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export function ActivityIndicator({
|
||||
size = "default",
|
||||
label,
|
||||
className,
|
||||
...props
|
||||
}: ActivityIndicatorProps) {
|
||||
const accessibleLabel = label?.trim() || undefined;
|
||||
|
||||
return (
|
||||
<span
|
||||
{...props}
|
||||
className={cn("nodedc-activity-indicator", className)}
|
||||
data-size={size === "default" ? undefined : size}
|
||||
role={accessibleLabel ? "status" : undefined}
|
||||
aria-label={accessibleLabel}
|
||||
aria-hidden={accessibleLabel ? undefined : "true"}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import { createAccentVariables, type RgbTuple } from "@nodedc/ui-core";
|
||||
import { cn } from "./cn.js";
|
||||
|
||||
export type ButtonVariant = "primary" | "secondary" | "ghost" | "danger" | "accent";
|
||||
export type ButtonSize = "default" | "compact";
|
||||
export type ButtonSize = "default" | "compact" | "dense";
|
||||
export type ButtonShape = "default" | "pill" | "rounded";
|
||||
|
||||
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
|
||||
@@ -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,
|
||||
@@ -12,6 +13,8 @@ import {
|
||||
Copy,
|
||||
Database,
|
||||
Download,
|
||||
Eye,
|
||||
EyeOff,
|
||||
ExternalLink,
|
||||
FileText,
|
||||
FolderOpen,
|
||||
@@ -24,11 +27,13 @@ import {
|
||||
LocateFixed,
|
||||
LockKeyhole,
|
||||
MailPlus,
|
||||
Map,
|
||||
Maximize2,
|
||||
Minimize2,
|
||||
Network,
|
||||
PanelTop,
|
||||
Pencil,
|
||||
Play,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Save,
|
||||
@@ -36,6 +41,7 @@ import {
|
||||
Settings,
|
||||
ShieldCheck,
|
||||
SlidersHorizontal,
|
||||
Square,
|
||||
Trash2,
|
||||
UploadCloud,
|
||||
UserCircle,
|
||||
@@ -51,6 +57,7 @@ const icons = {
|
||||
alert: AlertTriangle,
|
||||
apps: Boxes,
|
||||
building: Building2,
|
||||
camera: Camera,
|
||||
check: Check,
|
||||
"chevron-down": ChevronDown,
|
||||
"chevron-left": ChevronLeft,
|
||||
@@ -62,6 +69,8 @@ const icons = {
|
||||
database: Database,
|
||||
download: Download,
|
||||
edit: Pencil,
|
||||
eye: Eye,
|
||||
"eye-off": EyeOff,
|
||||
expand: Maximize2,
|
||||
external: ExternalLink,
|
||||
file: FileText,
|
||||
@@ -78,6 +87,8 @@ const icons = {
|
||||
minimize: Minimize2,
|
||||
network: Network,
|
||||
panel: PanelTop,
|
||||
plan: Map,
|
||||
play: Play,
|
||||
plus: Plus,
|
||||
profile: UserCircle,
|
||||
refresh: RefreshCw,
|
||||
@@ -86,6 +97,7 @@ const icons = {
|
||||
settings: Settings,
|
||||
shield: ShieldCheck,
|
||||
sliders: SlidersHorizontal,
|
||||
stop: Square,
|
||||
trash: Trash2,
|
||||
upload: UploadCloud,
|
||||
users: UsersRound,
|
||||
@@ -99,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}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useRef, useState, type CSSProperties, type InputHTMLAttributes, type KeyboardEvent, type PointerEvent } from "react";
|
||||
import { useEffect, useLayoutEffect, useRef, useState, type CSSProperties, type InputHTMLAttributes, type KeyboardEvent, type PointerEvent } from "react";
|
||||
import { cn } from "./cn.js";
|
||||
import { Dropdown } from "./Dropdown.js";
|
||||
|
||||
@@ -7,6 +7,10 @@ export interface RangeControlProps extends Omit<InputHTMLAttributes<HTMLInputEle
|
||||
value: number;
|
||||
min: number;
|
||||
max: number;
|
||||
exactValueBounds?: {
|
||||
min?: number;
|
||||
max?: number;
|
||||
};
|
||||
formatValue?: (value: number) => string;
|
||||
onChange: (value: number) => void;
|
||||
}
|
||||
@@ -29,14 +33,17 @@ const normalizeEditedRangeValue = (
|
||||
min: number,
|
||||
max: number,
|
||||
step: RangeControlProps["step"],
|
||||
exactValueBounds: RangeControlProps["exactValueBounds"],
|
||||
) => {
|
||||
const clamped = clampRangeValue(value, min, max);
|
||||
const exactMin = exactValueBounds?.min ?? Number.NEGATIVE_INFINITY;
|
||||
const exactMax = exactValueBounds?.max ?? Number.POSITIVE_INFINITY;
|
||||
const clamped = clampRangeValue(value, exactMin, exactMax);
|
||||
if (step === "any") return clamped;
|
||||
const numericStep = step === undefined ? 1 : Number(step);
|
||||
if (!Number.isFinite(numericStep) || numericStep <= 0) return clamped;
|
||||
const precision = Math.min(12, Math.max(decimalPlaces(min), decimalPlaces(numericStep)));
|
||||
const aligned = min + Math.round((clamped - min) / numericStep) * numericStep;
|
||||
return clampRangeValue(Number(aligned.toFixed(precision)), min, max);
|
||||
return clampRangeValue(Number(aligned.toFixed(precision)), exactMin, exactMax);
|
||||
};
|
||||
|
||||
const editableNumber = (value: number) => (
|
||||
@@ -48,6 +55,7 @@ export function RangeControl({
|
||||
value,
|
||||
min,
|
||||
max,
|
||||
exactValueBounds,
|
||||
step,
|
||||
formatValue = String,
|
||||
onChange,
|
||||
@@ -56,11 +64,13 @@ export function RangeControl({
|
||||
tabIndex,
|
||||
...props
|
||||
}: RangeControlProps) {
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const rangeRef = useRef<HTMLInputElement>(null);
|
||||
const editorRef = useRef<HTMLInputElement>(null);
|
||||
const cancelNextBlurRef = useRef(false);
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [draft, setDraft] = useState(() => editableNumber(value));
|
||||
const [editorContrast, setEditorContrast] = useState<"base" | "fill">("base");
|
||||
const safeMax = max === min ? min + 1 : max;
|
||||
const progress = Math.max(0, Math.min(100, ((value - min) / (safeMax - min)) * 100));
|
||||
const displayValue = formatValue(value);
|
||||
@@ -77,10 +87,30 @@ export function RangeControl({
|
||||
if (!editing) setDraft(editableNumber(value));
|
||||
}, [editing, value]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const root = rootRef.current;
|
||||
const editor = editorRef.current;
|
||||
if (!root || !editor) return undefined;
|
||||
const updateContrast = () => {
|
||||
const rootBounds = root.getBoundingClientRect();
|
||||
const editorBounds = editor.getBoundingClientRect();
|
||||
const paddingRight = Number.parseFloat(getComputedStyle(editor).paddingRight) || 0;
|
||||
const fillRight = rootBounds.left + rootBounds.width * progress / 100;
|
||||
const textRight = editorBounds.right - paddingRight;
|
||||
setEditorContrast(fillRight >= textRight ? "fill" : "base");
|
||||
};
|
||||
updateContrast();
|
||||
const observer = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(updateContrast);
|
||||
observer?.observe(root);
|
||||
return () => observer?.disconnect();
|
||||
}, [editorCharacters, progress]);
|
||||
|
||||
const finishEditing = (commit: boolean, restoreRangeFocus: boolean) => {
|
||||
if (commit) {
|
||||
const parsed = Number(draft.trim().replace(",", "."));
|
||||
if (Number.isFinite(parsed)) onChange(normalizeEditedRangeValue(parsed, min, max, step));
|
||||
if (Number.isFinite(parsed)) {
|
||||
onChange(normalizeEditedRangeValue(parsed, min, max, step, exactValueBounds));
|
||||
}
|
||||
}
|
||||
setEditing(false);
|
||||
if (restoreRangeFocus) requestAnimationFrame(() => rangeRef.current?.focus());
|
||||
@@ -103,7 +133,7 @@ export function RangeControl({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn("nodedc-range", className)} style={style} data-editing={editing || undefined}>
|
||||
<div ref={rootRef} className={cn("nodedc-range", className)} style={style} data-editing={editing || undefined}>
|
||||
<input
|
||||
ref={rangeRef}
|
||||
{...props}
|
||||
@@ -129,22 +159,14 @@ export function RangeControl({
|
||||
className="nodedc-range__editor"
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
value={editing ? draft : editableNumber(value)}
|
||||
value={draft}
|
||||
aria-label={`${label}: точное значение`}
|
||||
disabled={disabled}
|
||||
spellCheck={false}
|
||||
data-active={editing || undefined}
|
||||
onPointerDown={() => {
|
||||
if (!editing) {
|
||||
setDraft(editableNumber(value));
|
||||
setEditing(true);
|
||||
}
|
||||
}}
|
||||
data-contrast={editorContrast}
|
||||
onFocus={() => {
|
||||
if (!editing) {
|
||||
setDraft(editableNumber(value));
|
||||
setEditing(true);
|
||||
}
|
||||
if (!editing) setEditing(true);
|
||||
}}
|
||||
onChange={(event) => setDraft(event.target.value)}
|
||||
onKeyDown={handleEditorKeyDown}
|
||||
|
||||
@@ -8,10 +8,13 @@ export interface SegmentedItem<T extends string> {
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export type SegmentedControlSize = "default" | "dense";
|
||||
|
||||
export interface SegmentedControlProps<T extends string> {
|
||||
value: T;
|
||||
items: Array<SegmentedItem<T>>;
|
||||
label: string;
|
||||
size?: SegmentedControlSize;
|
||||
className?: string;
|
||||
onChange: (value: T) => void;
|
||||
}
|
||||
@@ -20,11 +23,17 @@ export function SegmentedControl<T extends string>({
|
||||
value,
|
||||
items,
|
||||
label,
|
||||
size = "default",
|
||||
className,
|
||||
onChange,
|
||||
}: SegmentedControlProps<T>) {
|
||||
return (
|
||||
<div className={cn("nodedc-segmented", className)} role="tablist" aria-label={label}>
|
||||
<div
|
||||
className={cn("nodedc-segmented", className)}
|
||||
role="tablist"
|
||||
aria-label={label}
|
||||
data-size={size === "default" ? undefined : size}
|
||||
>
|
||||
{items.map((item) => (
|
||||
<button
|
||||
key={item.value}
|
||||
@@ -43,4 +52,3 @@ export function SegmentedControl<T extends string>({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export * from "./AppHeader.js";
|
||||
export * from "./ActivityIndicator.js";
|
||||
export * from "./AdminNavigationPanel.js";
|
||||
export * from "./ApplicationShell.js";
|
||||
export * from "./ApplicationSidePanel.js";
|
||||
@@ -20,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.",
|
||||
@@ -26,13 +28,31 @@
|
||||
"domContract": ["nodedc-button", "nodedc-icon-button"],
|
||||
"summary": "Text and icon actions with shared sizing, states and computed accent contrast.",
|
||||
"variants": ["primary", "secondary", "ghost", "danger", "accent"],
|
||||
"sizes": ["default", "compact", "dense"],
|
||||
"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."
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "activity-indicator",
|
||||
"status": "baseline",
|
||||
"package": "@nodedc/ui-react",
|
||||
"exports": ["ActivityIndicator", "ActivityIndicatorProps", "ActivityIndicatorSize"],
|
||||
"domContract": ["nodedc-activity-indicator"],
|
||||
"summary": "Theme-independent indeterminate progress indicator for inline operations and pending action controls.",
|
||||
"variants": ["default", "compact"],
|
||||
"behavior": ["decorative by default", "optional status semantics", "static reduced-motion presentation"],
|
||||
"rules": [
|
||||
"Use compact inside a Button icon slot and default for standalone inline progress.",
|
||||
"The process owner exposes aria-busy and visible pending copy; provide label only when the indicator itself is the status announcement.",
|
||||
"The component never owns operation state, timing or completion.",
|
||||
"Reduced-motion preferences stop rotation without hiding the pending-state affordance."
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "field",
|
||||
"status": "baseline",
|
||||
@@ -65,13 +85,16 @@
|
||||
"exports": ["RangeControl"],
|
||||
"domContract": ["nodedc-range"],
|
||||
"summary": "Filled pill range control with embedded label, drag interaction and a persistent native inline exact-value editor.",
|
||||
"behavior": ["native range drag", "value click exact editing", "Enter/blur commit", "Escape cancel", "min/max clamp", "step normalization"],
|
||||
"behavior": ["native range drag", "native caret and selection replacement", "automatic fill/base contrast", "unbounded finite exact editing by default", "Enter/blur commit", "Escape cancel", "independent optional exact-value bounds", "step normalization"],
|
||||
"rules": [
|
||||
"The accent fill follows the active application theme.",
|
||||
"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.",
|
||||
"The browser places the caret at the clicked character and keeps native drag selection; entering exact editing never forces select-all or adds a second focus ring.",
|
||||
"Slider min/max define drag travel only; finite typed values are independent and may exceed that geometry.",
|
||||
"exactValueBounds adds explicit domain limits when a parameter must reject values beyond them.",
|
||||
"The browser places the caret at the clicked character, preserves native drag or keyboard selection, and replaces the selected substring on typing without pointer-state interception.",
|
||||
"Editor foreground and caret switch automatically between theme text and on-accent tokens according to the actual fill edge beneath the value area.",
|
||||
"The editor has no separate background box and keeps focus across parent window rerenders and pointer leave.",
|
||||
"Editor keyboard events do not bubble into enclosing Window shortcuts."
|
||||
]
|
||||
@@ -113,11 +136,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."
|
||||
]
|
||||
@@ -163,6 +187,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",
|
||||
@@ -198,7 +238,11 @@
|
||||
"package": "@nodedc/ui-react",
|
||||
"exports": ["SegmentedControl"],
|
||||
"domContract": ["nodedc-segmented"],
|
||||
"summary": "Pill navigation used in the shared top header and compact mode switches."
|
||||
"summary": "Pill navigation used in the shared top header and compact mode switches.",
|
||||
"sizes": ["default", "dense"],
|
||||
"rules": [
|
||||
"Dense is reserved for high-density in-viewer layer and mode toolbars; application headers retain default geometry."
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "app-header",
|
||||
@@ -369,9 +413,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."
|
||||
]
|
||||
|
||||
+5
-3
@@ -6,6 +6,7 @@
|
||||
"defaultSize": 18,
|
||||
"supportedSizes": [16, 18, 20],
|
||||
"strokeWidth": 1.8,
|
||||
"filledNames": ["play", "stop"],
|
||||
"groups": [
|
||||
{
|
||||
"id": "window-layer",
|
||||
@@ -29,7 +30,7 @@
|
||||
"id": "state-access",
|
||||
"label": "Состояние и доступ",
|
||||
"referenceSources": ["launcher", "seo", "task-manager"],
|
||||
"names": ["check", "alert", "activity", "lock", "key", "shield", "circle"]
|
||||
"names": ["check", "alert", "activity", "lock", "key", "shield", "circle", "eye", "eye-off"]
|
||||
},
|
||||
{
|
||||
"id": "entities",
|
||||
@@ -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,41 @@
|
||||
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 { ActivityIndicator } from "../packages/ui-react/dist/index.js";
|
||||
|
||||
test("ActivityIndicator separates decorative and announced progress", () => {
|
||||
const decorative = renderToStaticMarkup(createElement(ActivityIndicator, { size: "compact" }));
|
||||
const announced = renderToStaticMarkup(createElement(ActivityIndicator, { label: "Подключаем устройство" }));
|
||||
|
||||
assert.match(decorative, /class="nodedc-activity-indicator"/);
|
||||
assert.match(decorative, /data-size="compact"/);
|
||||
assert.match(decorative, /aria-hidden="true"/);
|
||||
assert.doesNotMatch(decorative, /role="status"/);
|
||||
|
||||
assert.match(announced, /role="status"/);
|
||||
assert.match(announced, /aria-label="Подключаем устройство"/);
|
||||
assert.doesNotMatch(announced, /aria-hidden/);
|
||||
assert.doesNotMatch(announced, /data-size=/);
|
||||
});
|
||||
|
||||
test("ActivityIndicator is registered, cataloged and motion-safe", async () => {
|
||||
const [styles, registrySource, docs, catalog] = await Promise.all([
|
||||
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((component) => component.id === "activity-indicator");
|
||||
|
||||
assert.deepEqual(entry?.variants, ["default", "compact"]);
|
||||
assert.ok(entry?.exports.includes("ActivityIndicator"));
|
||||
assert.match(styles, /\.nodedc-activity-indicator\s*\{[\s\S]*?animation: nodedc-activity-indicator-spin/);
|
||||
assert.match(styles, /\.nodedc-activity-indicator\[data-size="compact"\]/);
|
||||
assert.match(styles, /@media \(prefers-reduced-motion: reduce\) \{\s*\.nodedc-activity-indicator,[\s\S]*?animation: none/);
|
||||
assert.match(docs, /## ActivityIndicator/);
|
||||
assert.match(catalog, /<ActivityIndicator label="Загружаем данные"/);
|
||||
assert.match(catalog, /icon=\{<ActivityIndicator size="compact" \/>\}/);
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
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 { Button, SegmentedControl } from "../packages/ui-react/dist/index.js";
|
||||
|
||||
test("dense viewer controls remain a public Button and SegmentedControl contract", async () => {
|
||||
const button = renderToStaticMarkup(createElement(Button, { size: "dense" }, "Source points"));
|
||||
const segmented = renderToStaticMarkup(createElement(SegmentedControl, {
|
||||
size: "dense",
|
||||
value: "source",
|
||||
items: [{ label: "Source points", value: "source" }],
|
||||
label: "Viewer layer",
|
||||
onChange: () => {},
|
||||
}));
|
||||
const [tokens, styles, registrySource, docs] = await Promise.all([
|
||||
readFile(new URL("../packages/tokens/tokens.css", 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"),
|
||||
]);
|
||||
const registry = JSON.parse(registrySource);
|
||||
const buttonEntry = registry.components.find((component) => component.id === "button");
|
||||
const segmentedEntry = registry.components.find((component) => component.id === "segmented-control");
|
||||
|
||||
assert.match(button, /data-size="dense"/);
|
||||
assert.match(segmented, /data-size="dense"/);
|
||||
assert.ok(buttonEntry?.sizes.includes("dense"));
|
||||
assert.ok(segmentedEntry?.sizes.includes("dense"));
|
||||
assert.match(tokens, /--nodedc-control-height-dense: 1\.875rem/);
|
||||
assert.match(tokens, /--nodedc-font-size-dense: 0\.45rem/);
|
||||
assert.match(styles, /\.nodedc-button\[data-size="dense"\]/);
|
||||
assert.match(styles, /\.nodedc-segmented\[data-size="dense"\]/);
|
||||
assert.match(docs, /size="dense"/);
|
||||
assert.match(docs, /viewer/);
|
||||
});
|
||||
@@ -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`);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -30,7 +30,17 @@ test("exact editing is a stable native caret field without remount, forced selec
|
||||
readFile(new URL("../packages/ui-react/src/Window.tsx", import.meta.url), "utf8"),
|
||||
]);
|
||||
|
||||
assert.match(component, /normalizeEditedRangeValue\(parsed, min, max, step\)/);
|
||||
assert.match(component, /exactValueBounds\?: \{/);
|
||||
assert.match(component, /exactValueBounds\?\.min \?\? Number\.NEGATIVE_INFINITY/);
|
||||
assert.match(component, /exactValueBounds\?\.max \?\? Number\.POSITIVE_INFINITY/);
|
||||
assert.match(component, /normalizeEditedRangeValue\(parsed, min, max, step, exactValueBounds\)/);
|
||||
assert.match(component, /value=\{draft\}/);
|
||||
assert.match(component, /data-contrast=\{editorContrast\}/);
|
||||
assert.match(component, /fillRight >= textRight \? "fill" : "base"/);
|
||||
assert.match(component, /new ResizeObserver\(updateContrast\)/);
|
||||
const editorSource = component.match(/className="nodedc-range__editor"[\s\S]*?onBlur=\{\(\) => \{[\s\S]*?\n \}\}\n \/>/)?.[0] ?? "";
|
||||
assert.notEqual(editorSource, "");
|
||||
assert.doesNotMatch(editorSource, /onPointerDown=/);
|
||||
assert.match(component, /event\.key === "Enter"/);
|
||||
assert.match(component, /event\.key === "Escape"/);
|
||||
assert.match(component, /event\.stopPropagation\(\)/);
|
||||
@@ -43,6 +53,7 @@ test("exact editing is a stable native caret field without remount, forced selec
|
||||
assert.match(styles, /\.nodedc-range input\[type="range"\][\s\S]*?z-index: 4/);
|
||||
assert.match(styles, /\.nodedc-range__editor[\s\S]*?z-index: 5[\s\S]*?background: transparent[\s\S]*?color: transparent[\s\S]*?caret-color: transparent[\s\S]*?box-shadow: none/);
|
||||
assert.match(styles, /\.nodedc-range__editor\[data-active\][\s\S]*?caret-color: currentColor/);
|
||||
assert.match(styles, /\.nodedc-range__editor\[data-active\]\[data-contrast="fill"\][\s\S]*?rgb\(var\(--nodedc-on-accent-rgb\)\)/);
|
||||
assert.match(windowComponent, /const onCloseRef = useRef\(onClose\)/);
|
||||
assert.match(windowComponent, /onCloseRef\.current = onClose/);
|
||||
assert.match(windowComponent, /onCloseRef\.current\(\)/);
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import test from "node:test";
|
||||
|
||||
function lineNumber(lines, value) {
|
||||
return lines.findIndex((line) => line.includes(value));
|
||||
}
|
||||
|
||||
function assertLine(lines, value, message) {
|
||||
const index = lineNumber(lines, value);
|
||||
assert.ok(index >= 0, `${message} (expected near line 1+, missing: ${value})`);
|
||||
return index + 1;
|
||||
}
|
||||
|
||||
function extractBlock(lines, key) {
|
||||
const start = lines.findIndex((line) => line.trim() === `${key} = """`);
|
||||
assert.ok(start >= 0, `missing ${key} block start`);
|
||||
let end = start + 1;
|
||||
while (end < lines.length && lines[end] !== "\"\"\"") {
|
||||
end += 1;
|
||||
}
|
||||
assert.ok(end < lines.length, `unterminated ${key} block`);
|
||||
return lines.slice(start + 1, end).join("\n").replace(/\s+/g, " ").trim();
|
||||
}
|
||||
|
||||
test("global spark agent configuration matches governance contract", async () => {
|
||||
const configLines = (await readFile(new URL("../.codex/config.toml", import.meta.url), "utf8")).split(/\r?\n/);
|
||||
const agentsLine = assertLine(configLines, "[agents]", "global config should declare [agents]");
|
||||
const enabledLine = assertLine(configLines, 'enabled = true', "global config should enable agents");
|
||||
const threadLine = assertLine(configLines, "max_concurrent_threads_per_session = 2", "global config should cap threads at 2");
|
||||
const defaultModelLine = assertLine(configLines, 'default_subagent_model = "gpt-5.6-terra"', "global config should set default subagent model");
|
||||
const effortLine = assertLine(configLines, 'default_subagent_reasoning_effort = "low"', "global config should set default low reasoning for unpinned subagents");
|
||||
const interruptLine = assertLine(configLines, "interrupt_message = true", "global config should keep interruption messages enabled");
|
||||
|
||||
assert.equal(configLines[agentsLine - 1].trim(), "[agents]", "[agents] block should be present");
|
||||
assert.equal(configLines[enabledLine - 1].trim(), 'enabled = true');
|
||||
assert.equal(configLines[threadLine - 1].trim(), "max_concurrent_threads_per_session = 2");
|
||||
assert.equal(configLines[defaultModelLine - 1].trim(), 'default_subagent_model = "gpt-5.6-terra"');
|
||||
assert.equal(configLines[effortLine - 1].trim(), 'default_subagent_reasoning_effort = "low"');
|
||||
assert.equal(configLines[interruptLine - 1].trim(), "interrupt_message = true");
|
||||
});
|
||||
|
||||
test("spark_explorer contract", async () => {
|
||||
const explorerLines = (await readFile(new URL("../.codex/agents/spark-explorer.toml", import.meta.url), "utf8")).split(/\r?\n/);
|
||||
const instructions = extractBlock(explorerLines, "developer_instructions");
|
||||
|
||||
const nameLine = assertLine(explorerLines, 'name = "spark_explorer"', "explorer name should be spark_explorer");
|
||||
const modelLine = assertLine(explorerLines, 'model = "gpt-5.3-codex-spark"', "explorer should pin model gpt-5.3-codex-spark");
|
||||
const effortLine = assertLine(explorerLines, 'model_reasoning_effort = "medium"', "explorer should use medium reasoning");
|
||||
const sandboxLine = assertLine(explorerLines, 'sandbox_mode = "read-only"', "explorer should use read-only sandbox mode");
|
||||
|
||||
assert.equal(explorerLines[nameLine - 1].trim(), 'name = "spark_explorer"');
|
||||
assert.equal(explorerLines[modelLine - 1].trim(), 'model = "gpt-5.3-codex-spark"');
|
||||
assert.equal(explorerLines[effortLine - 1].trim(), 'model_reasoning_effort = "medium"');
|
||||
assert.equal(explorerLines[sandboxLine - 1].trim(), 'sandbox_mode = "read-only"');
|
||||
assert.ok(
|
||||
instructions.includes("Never edit, create, move, or delete files."),
|
||||
"explorer instructions must prohibit edits",
|
||||
);
|
||||
assert.ok(
|
||||
instructions.includes("Never spawn another agent."),
|
||||
"explorer instructions must prohibit nested agents",
|
||||
);
|
||||
});
|
||||
|
||||
test("spark_worker contract", async () => {
|
||||
const workerLines = (await readFile(new URL("../.codex/agents/spark-worker.toml", import.meta.url), "utf8")).split(/\r?\n/);
|
||||
const instructions = extractBlock(workerLines, "developer_instructions");
|
||||
|
||||
const nameLine = assertLine(workerLines, 'name = "spark_worker"', "worker name should be spark_worker");
|
||||
const modelLine = assertLine(workerLines, 'model = "gpt-5.3-codex-spark"', "worker should pin model gpt-5.3-codex-spark");
|
||||
const effortLine = assertLine(workerLines, 'model_reasoning_effort = "medium"', "worker should use medium reasoning");
|
||||
const sandboxLine = assertLine(workerLines, 'sandbox_mode = "workspace-write"', "worker should use workspace-write sandbox mode");
|
||||
|
||||
assert.equal(workerLines[nameLine - 1].trim(), 'name = "spark_worker"');
|
||||
assert.equal(workerLines[modelLine - 1].trim(), 'model = "gpt-5.3-codex-spark"');
|
||||
assert.equal(workerLines[effortLine - 1].trim(), 'model_reasoning_effort = "medium"');
|
||||
assert.equal(workerLines[sandboxLine - 1].trim(), 'sandbox_mode = "workspace-write"');
|
||||
assert.ok(
|
||||
instructions.includes("exact allowed file"),
|
||||
"worker instructions must require exact allowed path allowlist",
|
||||
);
|
||||
assert.ok(instructions.includes("Never commit, push, deploy, install dependencies, or change external systems."), "worker must not commit, push, deploy, or install");
|
||||
assert.ok(instructions.includes("Never spawn another agent."), "worker must prohibit nested agents");
|
||||
assert.ok(instructions.includes("One corrective retry is allowed after a failed check; then stop and return the failure evidence."), "worker must limit corrective retries");
|
||||
});
|
||||
|
||||
test("AGENTS and governance contract documents", async () => {
|
||||
const agentsLines = (await readFile(new URL("../AGENTS.md", import.meta.url), "utf8")).split(/\r?\n/);
|
||||
const governanceLines = (await readFile(new URL("../docs/CODEX_SUBAGENT_GOVERNANCE.md", import.meta.url), "utf8")).split(/\r?\n/);
|
||||
|
||||
assert.ok(
|
||||
agentsLines.some((line) => line.includes("`docs/CODEX_SUBAGENT_GOVERNANCE.md`")),
|
||||
"AGENTS.md must point to governance document",
|
||||
);
|
||||
assert.ok(
|
||||
agentsLines.some((line) => line.toLowerCase().includes("at most two subagents")),
|
||||
"AGENTS.md should state max two subagents",
|
||||
);
|
||||
assert.ok(
|
||||
agentsLines.some((line) => line.toLowerCase().includes("one write-capable")),
|
||||
"AGENTS.md should state max one writer subagent",
|
||||
);
|
||||
|
||||
assert.ok(
|
||||
governanceLines.some((line) => line.includes("## Mandatory task packet")),
|
||||
"governance should define mandatory task packet",
|
||||
);
|
||||
assert.ok(
|
||||
governanceLines.some((line) => line.includes("1. Objective: one concrete outcome.")),
|
||||
"governance should include mandatory objective field",
|
||||
);
|
||||
assert.ok(
|
||||
governanceLines.some((line) => line.includes("2. Allowed scope: exact files, directories, or read-only tools.")),
|
||||
"governance should include allowed scope field",
|
||||
);
|
||||
assert.ok(
|
||||
governanceLines.some((line) => line.includes("3. Forbidden actions: especially external writes, commits, pushes, and deploys.")),
|
||||
"governance should include forbidden actions field",
|
||||
);
|
||||
assert.ok(
|
||||
governanceLines.some((line) => line.includes("4. Acceptance criteria: observable evidence of completion.")),
|
||||
"governance should include acceptance criteria field",
|
||||
);
|
||||
assert.ok(
|
||||
governanceLines.some((line) => line.includes("5. Verification: exact checks the worker may run.")),
|
||||
"governance should include verification field",
|
||||
);
|
||||
assert.ok(
|
||||
governanceLines.some((line) => line.includes("6. Output: a short structured summary, not raw logs.")),
|
||||
"governance should include output field",
|
||||
);
|
||||
assert.ok(
|
||||
governanceLines.some((line) => line.includes("Reviews the diff and performs final verification itself.")),
|
||||
"governance should retain final primary review and verification ownership",
|
||||
);
|
||||
});
|
||||
@@ -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