fix(ui): enforce canonical inspector selects
This commit is contained in:
parent
751d9963f1
commit
a1c4ecaa93
|
|
@ -10,8 +10,8 @@
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@nodedc/page-patterns": "0.1.0",
|
"@nodedc/page-patterns": "0.1.0",
|
||||||
"@nodedc/ui-core": "0.6.0",
|
"@nodedc/ui-core": "0.7.0",
|
||||||
"@nodedc/ui-react": "0.6.0",
|
"@nodedc/ui-react": "0.7.0",
|
||||||
"cesium": "^1.143.0",
|
"cesium": "^1.143.0",
|
||||||
"react": "^19.1.0",
|
"react": "^19.1.0",
|
||||||
"react-dom": "^19.1.0"
|
"react-dom": "^19.1.0"
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,7 @@ import {
|
||||||
Icon,
|
Icon,
|
||||||
IconButton,
|
IconButton,
|
||||||
Inspector,
|
Inspector,
|
||||||
|
InspectorSelectField,
|
||||||
MediaSourceField,
|
MediaSourceField,
|
||||||
RangeControl,
|
RangeControl,
|
||||||
SegmentedControl,
|
SegmentedControl,
|
||||||
|
|
@ -1161,19 +1162,16 @@ export function CatalogApp() {
|
||||||
tone: "accent" as const,
|
tone: "accent" as const,
|
||||||
content: (
|
content: (
|
||||||
<>
|
<>
|
||||||
<ControlRow label="Позиция">
|
<InspectorSelectField
|
||||||
<Select
|
label="Позиция Toolbar"
|
||||||
variant="split"
|
value={toolbarPlacement}
|
||||||
label="Позиция Toolbar"
|
options={[
|
||||||
value={toolbarPlacement}
|
{ value: "left", label: "Слева" },
|
||||||
options={[
|
{ value: "right", label: "Справа" },
|
||||||
{ value: "left", label: "Слева" },
|
{ value: "bottom", label: "Снизу" },
|
||||||
{ value: "right", label: "Справа" },
|
]}
|
||||||
{ value: "bottom", label: "Снизу" },
|
onChange={(value) => setToolbarPlacement(value as ToolbarPlacement)}
|
||||||
]}
|
/>
|
||||||
onChange={(value) => setToolbarPlacement(value as ToolbarPlacement)}
|
|
||||||
/>
|
|
||||||
</ControlRow>
|
|
||||||
<ControlRow label="Фон"><ColorField value={toolbarBg} onChange={setToolbarBg} /></ControlRow>
|
<ControlRow label="Фон"><ColorField value={toolbarBg} onChange={setToolbarBg} /></ControlRow>
|
||||||
<ControlRow label="Border"><ColorField value={toolbarBorder} onChange={setToolbarBorder} /></ControlRow>
|
<ControlRow label="Border"><ColorField value={toolbarBorder} onChange={setToolbarBorder} /></ControlRow>
|
||||||
<ControlRow label="Outline"><ColorField value={toolbarOutline} onChange={setToolbarOutline} /></ControlRow>
|
<ControlRow label="Outline"><ColorField value={toolbarOutline} onChange={setToolbarOutline} /></ControlRow>
|
||||||
|
|
@ -1206,19 +1204,16 @@ export function CatalogApp() {
|
||||||
tone: "accent" as const,
|
tone: "accent" as const,
|
||||||
content: (
|
content: (
|
||||||
<>
|
<>
|
||||||
<ControlRow label="Связи по умолчанию">
|
<InspectorSelectField
|
||||||
<Select
|
label="Связи по умолчанию"
|
||||||
variant="split"
|
value={connectionType}
|
||||||
label="Тип связи"
|
options={[
|
||||||
value={connectionType}
|
{ value: "spline", label: "Сплайн" },
|
||||||
options={[
|
{ value: "straight", label: "Прямая" },
|
||||||
{ value: "spline", label: "Сплайн" },
|
{ value: "step", label: "Ступенчатая" },
|
||||||
{ value: "straight", label: "Прямая" },
|
]}
|
||||||
{ value: "step", label: "Ступенчатая" },
|
onChange={setConnectionType}
|
||||||
]}
|
/>
|
||||||
onChange={setConnectionType}
|
|
||||||
/>
|
|
||||||
</ControlRow>
|
|
||||||
<Checker checked={usePortColors} label="Использовать цвет портов" onChange={setUsePortColors} />
|
<Checker checked={usePortColors} label="Использовать цвет портов" onChange={setUsePortColors} />
|
||||||
<ControlRow label="Цвет связей по умолчанию"><ColorField value={connectionColor} onChange={setConnectionColor} /></ControlRow>
|
<ControlRow label="Цвет связей по умолчанию"><ColorField value={connectionColor} onChange={setConnectionColor} /></ControlRow>
|
||||||
<ControlRow label="Применить ко всем"><Button variant="primary" shape="pill" width="full">Применить</Button></ControlRow>
|
<ControlRow label="Применить ко всем"><Button variant="primary" shape="pill" width="full">Применить</Button></ControlRow>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { forwardRef, lazy, Suspense, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState, type CSSProperties, type PointerEvent } from "react";
|
import { forwardRef, lazy, Suspense, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState, type CSSProperties, type PointerEvent } from "react";
|
||||||
import { Button, Checker, ColorField, ControlRow, GlassSurface, Icon, IconButton, Inspector, RangeControl, Select, Window } from "@nodedc/ui-react";
|
import { Button, Checker, ColorField, ControlRow, GlassSurface, Icon, IconButton, Inspector, InspectorSelectField, RangeControl, Window } from "@nodedc/ui-react";
|
||||||
import type { SelectOption } from "@nodedc/ui-react";
|
import type { SelectOption } from "@nodedc/ui-react";
|
||||||
import type {
|
import type {
|
||||||
CameraSpiralState,
|
CameraSpiralState,
|
||||||
|
|
@ -715,15 +715,13 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
|
||||||
<Checker checked={animationModeEnabled} label="Режим анимации" onChange={setAnimationMode} />
|
<Checker checked={animationModeEnabled} label="Режим анимации" onChange={setAnimationMode} />
|
||||||
{animationModeEnabled ? <>
|
{animationModeEnabled ? <>
|
||||||
<small className="catalog-map-inspector__note">Стартовая точка берётся из текущей позиции камеры. Камера смотрит почти в надир, а маршрут ждёт текущие tiles перед продолжением. Движение идёт по региональной геодезической спирали WGS84 до выбранного радиуса.</small>
|
<small className="catalog-map-inspector__note">Стартовая точка берётся из текущей позиции камеры. Камера смотрит почти в надир, а маршрут ждёт текущие tiles перед продолжением. Движение идёт по региональной геодезической спирали WGS84 до выбранного радиуса.</small>
|
||||||
<ControlRow label="Профиль покрытия">
|
<InspectorSelectField
|
||||||
<Select
|
label="Профиль покрытия"
|
||||||
value={spiralPresetId}
|
value={spiralPresetId}
|
||||||
options={spiralPresetOptions}
|
options={spiralPresetOptions}
|
||||||
label="Профиль покрытия камеры"
|
disabled={spiralRunning}
|
||||||
disabled={spiralRunning}
|
onChange={selectSpiralPreset}
|
||||||
onChange={selectSpiralPreset}
|
/>
|
||||||
/>
|
|
||||||
</ControlRow>
|
|
||||||
<small className="catalog-map-inspector__note">У текущего OSM Buildings подтверждено {OSM_BUILDINGS_OBSERVED_BAND_COUNT} иерархических bands. Десять профилей управляют высотой и покрытием; фактический LOD Cesium выбирает по SSE, viewport и расстоянию.</small>
|
<small className="catalog-map-inspector__note">У текущего OSM Buildings подтверждено {OSM_BUILDINGS_OBSERVED_BAND_COUNT} иерархических bands. Десять профилей управляют высотой и покрытием; фактический LOD Cesium выбирает по SSE, viewport и расстоянию.</small>
|
||||||
<ControlRow label="Слои прохода"><small>Imagery · Terrain · OSM Buildings</small></ControlRow>
|
<ControlRow label="Слои прохода"><small>Imagery · Terrain · OSM Buildings</small></ControlRow>
|
||||||
<RangeControl
|
<RangeControl
|
||||||
|
|
|
||||||
|
|
@ -57,6 +57,7 @@ Dropdown владеет floating-layer поведением:
|
||||||
- ограничивает размеры viewport;
|
- ограничивает размеры viewport;
|
||||||
- обновляется при resize и scroll;
|
- обновляется при resize и scroll;
|
||||||
- закрывается по outside pointer и Escape;
|
- закрывается по outside pointer и Escape;
|
||||||
|
- после Escape возвращает фокус на реальную trigger-кнопку, не закрывая родительское окно;
|
||||||
- закрывает другой открытый dropdown.
|
- закрывает другой открытый dropdown.
|
||||||
|
|
||||||
Содержимое меню передаётся приложением. Selection, action menu и filter menu используют один engine.
|
Содержимое меню передаётся приложением. Selection, action menu и filter menu используют один engine.
|
||||||
|
|
@ -70,6 +71,8 @@ Select добавляет к Dropdown контролируемое значен
|
||||||
|
|
||||||
Native select не используется как видимый runtime UI. Меню рендерится через portal; у Engine-варианта меню имеет радиус `20 px`, а строки — `14 px` и высоту `42 px`.
|
Native select не используется как видимый runtime UI. Меню рендерится через portal; у Engine-варианта меню имеет радиус `20 px`, а строки — `14 px` и высоту `42 px`.
|
||||||
|
|
||||||
|
Внутри `Inspector` форма `integrated` запрещена. Selection-поле собирается только через `InspectorSelectField`: видимая подпись находится сверху, а полноширинный control всегда использует `split`. Компонент намеренно не принимает `layout` и `variant`, поэтому consumer не может вернуть Inspector к Hub/Launcher pill-форме.
|
||||||
|
|
||||||
## Window
|
## Window
|
||||||
|
|
||||||
Window — единая механика открытия modal и правой panel:
|
Window — единая механика открытия modal и правой panel:
|
||||||
|
|
|
||||||
|
|
@ -43,7 +43,7 @@ CMS подтверждает необходимость DOM-пакета без
|
||||||
|
|
||||||
Все остальные legacy inspectors и старые plates не участвуют в принятии решений. Их будущее приведение к новому дизайну должно идти через эту библиотеку.
|
Все остальные legacy inspectors и старые plates не участвуют в принятии решений. Их будущее приведение к новому дизайну должно идти через эту библиотеку.
|
||||||
|
|
||||||
Размеры зафиксированы непосредственно по разрешённым исходникам: панель `390 px`, контент `330 px`, control row `154/14/162 px`, control height `46 px`, split-select `276/8/46 px`, section header `50 px` и radius `12 px`. Скриншоты от 10 июля 2026 года использованы как визуальная проверка, а не как замена исходному CSS.
|
Размеры зафиксированы непосредственно по разрешённым исходникам: панель `390 px`, контент `330 px`, control row `154/14/162 px`, control height `46 px`, split-select `276/8/46 px`, section header `50 px` и radius `12 px`. В Inspector любой selection-control имеет подпись сверху и split-геометрию; integrated pill остаётся только Hub/Launcher-формой. Скриншоты от 10 июля 2026 года использованы как визуальная проверка, а не как замена исходному CSS.
|
||||||
|
|
||||||
`EnvironmentColorField` и BIM-эквивалент подтверждают каноническую anatomy `26 px` swatch + HEX value внутри glass pill. Библиотека сохраняет эту геометрию, но заменяет OS-native popup на единый portal-picker, чтобы палитра не зависела от светлой/тёмной системной темы браузера.
|
`EnvironmentColorField` и BIM-эквивалент подтверждают каноническую anatomy `26 px` swatch + HEX value внутри glass pill. Библиотека сохраняет эту геометрию, но заменяет OS-native popup на единый portal-picker, чтобы палитра не зависела от светлой/тёмной системной темы браузера.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -28,8 +28,8 @@
|
||||||
"version": "0.7.0",
|
"version": "0.7.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@nodedc/page-patterns": "0.1.0",
|
"@nodedc/page-patterns": "0.1.0",
|
||||||
"@nodedc/ui-core": "0.6.0",
|
"@nodedc/ui-core": "0.7.0",
|
||||||
"@nodedc/ui-react": "0.6.0",
|
"@nodedc/ui-react": "0.7.0",
|
||||||
"cesium": "^1.143.0",
|
"cesium": "^1.143.0",
|
||||||
"react": "^19.1.0",
|
"react": "^19.1.0",
|
||||||
"react-dom": "^19.1.0"
|
"react-dom": "^19.1.0"
|
||||||
|
|
@ -2466,26 +2466,26 @@
|
||||||
},
|
},
|
||||||
"packages/ui-core": {
|
"packages/ui-core": {
|
||||||
"name": "@nodedc/ui-core",
|
"name": "@nodedc/ui-core",
|
||||||
"version": "0.6.0",
|
"version": "0.7.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@nodedc/tokens": "0.6.0"
|
"@nodedc/tokens": "0.6.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"packages/ui-dom": {
|
"packages/ui-dom": {
|
||||||
"name": "@nodedc/ui-dom",
|
"name": "@nodedc/ui-dom",
|
||||||
"version": "0.6.0",
|
"version": "0.7.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@nodedc/ui-core": "0.6.0"
|
"@nodedc/ui-core": "0.7.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"packages/ui-react": {
|
"packages/ui-react": {
|
||||||
"name": "@nodedc/ui-react",
|
"name": "@nodedc/ui-react",
|
||||||
"version": "0.6.0",
|
"version": "0.7.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
"@dnd-kit/sortable": "^10.0.0",
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@nodedc/ui-core": "0.6.0",
|
"@nodedc/ui-core": "0.7.0",
|
||||||
"lucide-react": "^0.468.0"
|
"lucide-react": "^0.468.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|
|
||||||
|
|
@ -11,14 +11,15 @@
|
||||||
"scripts": {
|
"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/ui-catalog",
|
"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/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",
|
"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",
|
||||||
"check": "npm run build:packages && npm run typecheck --workspaces --if-present && npm run validate:registry",
|
"check": "npm run build:packages && npm run typecheck --workspaces --if-present && npm run validate:registry && npm run test:inspector-select",
|
||||||
"dev": "npm run build:packages && npm run dev --workspace @nodedc/ui-catalog",
|
"dev": "npm run build:packages && npm run dev --workspace @nodedc/ui-catalog",
|
||||||
"serve": "node server/catalog-server.mjs",
|
"serve": "node server/catalog-server.mjs",
|
||||||
"validate:registry": "node scripts/validate-registry.mjs",
|
"validate:registry": "node scripts/validate-registry.mjs",
|
||||||
"test:platform-settings": "node scripts/smoke-platform-settings.mjs",
|
"test:platform-settings": "node scripts/smoke-platform-settings.mjs",
|
||||||
"test:data-product-runtime": "node scripts/smoke-data-product-runtime.mjs",
|
"test:data-product-runtime": "node scripts/smoke-data-product-runtime.mjs",
|
||||||
"test:map-animation": "node --test scripts/map-spiral.test.mjs scripts/map-camera-presets.test.mjs",
|
"test:map-animation": "node --test scripts/map-spiral.test.mjs scripts/map-camera-presets.test.mjs",
|
||||||
"test:map-cache-contract": "node --test scripts/map-cache-resource-contract.test.mjs"
|
"test:map-cache-contract": "node --test scripts/map-cache-resource-contract.test.mjs",
|
||||||
|
"test:inspector-select": "node --test scripts/inspector-select-contract.test.mjs"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20"
|
"node": ">=20"
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@nodedc/ui-core",
|
"name": "@nodedc/ui-core",
|
||||||
"version": "0.6.0",
|
"version": "0.7.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"files": [
|
"files": [
|
||||||
"dist",
|
"dist",
|
||||||
|
|
|
||||||
|
|
@ -1810,6 +1810,14 @@ textarea.nodedc-field__control {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.nodedc-select[data-disabled="true"] {
|
||||||
|
opacity: 0.42;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nodedc-select[data-disabled="true"] .nodedc-select__toggle {
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
.nodedc-select__control {
|
.nodedc-select__control {
|
||||||
display: grid;
|
display: grid;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|
@ -2634,6 +2642,14 @@ textarea.nodedc-field__control {
|
||||||
gap: var(--nodedc-space-2);
|
gap: var(--nodedc-space-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Inspector selection controls are always stacked, including legacy consumers
|
||||||
|
that still compose a raw Select inside the default inline ControlRow. */
|
||||||
|
.nodedc-inspector .nodedc-control-row:has(.nodedc-select-anchor) {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: var(--nodedc-space-2);
|
||||||
|
}
|
||||||
|
|
||||||
.nodedc-control-row__label {
|
.nodedc-control-row__label {
|
||||||
color: var(--nodedc-text-secondary);
|
color: var(--nodedc-text-secondary);
|
||||||
font-size: var(--nodedc-font-size-sm);
|
font-size: var(--nodedc-font-size-sm);
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@nodedc/ui-dom",
|
"name": "@nodedc/ui-dom",
|
||||||
"version": "0.6.0",
|
"version": "0.7.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"files": [
|
"files": [
|
||||||
"dist"
|
"dist"
|
||||||
|
|
@ -18,6 +18,6 @@
|
||||||
"typecheck": "tsc -p tsconfig.json --noEmit"
|
"typecheck": "tsc -p tsconfig.json --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@nodedc/ui-core": "0.6.0"
|
"@nodedc/ui-core": "0.7.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@nodedc/ui-react",
|
"name": "@nodedc/ui-react",
|
||||||
"version": "0.6.0",
|
"version": "0.7.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"files": [
|
"files": [
|
||||||
"dist"
|
"dist"
|
||||||
|
|
@ -21,7 +21,7 @@
|
||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
"@dnd-kit/sortable": "^10.0.0",
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@nodedc/ui-core": "0.6.0",
|
"@nodedc/ui-core": "0.7.0",
|
||||||
"lucide-react": "^0.468.0"
|
"lucide-react": "^0.468.0"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ export interface DropdownTriggerApi {
|
||||||
show: () => void;
|
show: () => void;
|
||||||
close: () => void;
|
close: () => void;
|
||||||
toggle: () => void;
|
toggle: () => void;
|
||||||
|
setAnchorRef: (node: HTMLElement | null) => void;
|
||||||
setTriggerRef: (node: HTMLElement | null) => void;
|
setTriggerRef: (node: HTMLElement | null) => void;
|
||||||
surfaceId: string;
|
surfaceId: string;
|
||||||
}
|
}
|
||||||
|
|
@ -49,6 +50,7 @@ export function Dropdown({
|
||||||
const instanceId = useId();
|
const instanceId = useId();
|
||||||
const surfaceId = `${instanceId.replaceAll(":", "")}-surface`;
|
const surfaceId = `${instanceId.replaceAll(":", "")}-surface`;
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const [anchorElement, setAnchorElement] = useState<HTMLElement | null>(null);
|
||||||
const [triggerElement, setTriggerElement] = useState<HTMLElement | null>(null);
|
const [triggerElement, setTriggerElement] = useState<HTMLElement | null>(null);
|
||||||
const surfaceRef = useRef<HTMLDivElement>(null);
|
const surfaceRef = useRef<HTMLDivElement>(null);
|
||||||
const [surfaceStyle, setSurfaceStyle] = useState<CSSProperties>({ visibility: "hidden" });
|
const [surfaceStyle, setSurfaceStyle] = useState<CSSProperties>({ visibility: "hidden" });
|
||||||
|
|
@ -62,8 +64,9 @@ export function Dropdown({
|
||||||
}, [disabled]);
|
}, [disabled]);
|
||||||
|
|
||||||
const updatePosition = useCallback(() => {
|
const updatePosition = useCallback(() => {
|
||||||
if (!triggerElement || !surfaceRef.current) return;
|
const anchorNode = anchorElement ?? triggerElement;
|
||||||
const anchor = triggerElement.getBoundingClientRect();
|
if (!anchorNode || !surfaceRef.current) return;
|
||||||
|
const anchor = anchorNode.getBoundingClientRect();
|
||||||
const measured = surfaceRef.current.getBoundingClientRect();
|
const measured = surfaceRef.current.getBoundingClientRect();
|
||||||
const surfaceWidth = typeof width === "number"
|
const surfaceWidth = typeof width === "number"
|
||||||
? width
|
? width
|
||||||
|
|
@ -83,7 +86,7 @@ export function Dropdown({
|
||||||
maxHeight: position.maxHeight,
|
maxHeight: position.maxHeight,
|
||||||
visibility: "visible",
|
visibility: "visible",
|
||||||
});
|
});
|
||||||
}, [minWidth, offset, placement, triggerElement, width]);
|
}, [anchorElement, minWidth, offset, placement, triggerElement, width]);
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
if (!isOpen) return;
|
if (!isOpen) return;
|
||||||
|
|
@ -95,12 +98,14 @@ export function Dropdown({
|
||||||
const handlePointerDown = (event: PointerEvent) => {
|
const handlePointerDown = (event: PointerEvent) => {
|
||||||
const target = event.target;
|
const target = event.target;
|
||||||
if (!(target instanceof Node)) return;
|
if (!(target instanceof Node)) return;
|
||||||
if (triggerElement?.contains(target) || surfaceRef.current?.contains(target)) return;
|
if (anchorElement?.contains(target) || triggerElement?.contains(target) || surfaceRef.current?.contains(target)) return;
|
||||||
close();
|
close();
|
||||||
};
|
};
|
||||||
const handleKeyDown = (event: KeyboardEvent) => {
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
if (event.key === "Escape") {
|
if (event.key === "Escape") {
|
||||||
close();
|
close();
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
triggerElement?.focus();
|
triggerElement?.focus();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -112,22 +117,22 @@ export function Dropdown({
|
||||||
|
|
||||||
window.dispatchEvent(new CustomEvent("nodedc-dropdown-open", { detail: { id: instanceId } }));
|
window.dispatchEvent(new CustomEvent("nodedc-dropdown-open", { detail: { id: instanceId } }));
|
||||||
document.addEventListener("pointerdown", handlePointerDown);
|
document.addEventListener("pointerdown", handlePointerDown);
|
||||||
document.addEventListener("keydown", handleKeyDown);
|
document.addEventListener("keydown", handleKeyDown, true);
|
||||||
window.addEventListener("nodedc-dropdown-open", handleOtherOpen as EventListener);
|
window.addEventListener("nodedc-dropdown-open", handleOtherOpen as EventListener);
|
||||||
window.addEventListener("resize", handleViewportChange);
|
window.addEventListener("resize", handleViewportChange);
|
||||||
window.addEventListener("scroll", handleViewportChange, true);
|
window.addEventListener("scroll", handleViewportChange, true);
|
||||||
return () => {
|
return () => {
|
||||||
document.removeEventListener("pointerdown", handlePointerDown);
|
document.removeEventListener("pointerdown", handlePointerDown);
|
||||||
document.removeEventListener("keydown", handleKeyDown);
|
document.removeEventListener("keydown", handleKeyDown, true);
|
||||||
window.removeEventListener("nodedc-dropdown-open", handleOtherOpen as EventListener);
|
window.removeEventListener("nodedc-dropdown-open", handleOtherOpen as EventListener);
|
||||||
window.removeEventListener("resize", handleViewportChange);
|
window.removeEventListener("resize", handleViewportChange);
|
||||||
window.removeEventListener("scroll", handleViewportChange, true);
|
window.removeEventListener("scroll", handleViewportChange, true);
|
||||||
};
|
};
|
||||||
}, [close, instanceId, isOpen, triggerElement, updatePosition]);
|
}, [anchorElement, close, instanceId, isOpen, triggerElement, updatePosition]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span className={cn("nodedc-dropdown-anchor", className)}>
|
<span className={cn("nodedc-dropdown-anchor", className)}>
|
||||||
{trigger({ open: isOpen, show, close, toggle, setTriggerRef: setTriggerElement, surfaceId })}
|
{trigger({ open: isOpen, show, close, toggle, setAnchorRef: setAnchorElement, setTriggerRef: setTriggerElement, surfaceId })}
|
||||||
{isOpen && typeof document !== "undefined"
|
{isOpen && typeof document !== "undefined"
|
||||||
? createPortal(
|
? createPortal(
|
||||||
<div
|
<div
|
||||||
|
|
@ -145,4 +150,3 @@ export function Dropdown({
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,6 @@
|
||||||
import { useMemo, useState, type ReactNode } from "react";
|
import { useMemo, useState, type ReactNode } from "react";
|
||||||
|
import { InspectorSelectPolicyContext } from "./InspectorContext.js";
|
||||||
|
import { Select, type SelectProps } from "./Select.js";
|
||||||
import { cn } from "./cn.js";
|
import { cn } from "./cn.js";
|
||||||
|
|
||||||
export interface InspectorSectionSpec {
|
export interface InspectorSectionSpec {
|
||||||
|
|
@ -53,35 +55,37 @@ export function Inspector({
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn("nodedc-inspector", className)}>
|
<InspectorSelectPolicyContext.Provider value>
|
||||||
{groups.map((group, groupIndex) => (
|
<div className={cn("nodedc-inspector", className)}>
|
||||||
<div className="nodedc-inspector__group" key={`${group.label ?? "root"}-${groupIndex}`}>
|
{groups.map((group, groupIndex) => (
|
||||||
{group.sections.map((section) => {
|
<div className="nodedc-inspector__group" key={`${group.label ?? "root"}-${groupIndex}`}>
|
||||||
const isOpen = openIds.has(section.id);
|
{group.sections.map((section) => {
|
||||||
return (
|
const isOpen = openIds.has(section.id);
|
||||||
<section className="nodedc-inspector__section" key={section.id}>
|
return (
|
||||||
<button
|
<section className="nodedc-inspector__section" key={section.id}>
|
||||||
type="button"
|
<button
|
||||||
className="nodedc-inspector__section-trigger"
|
type="button"
|
||||||
data-open={isOpen ? "true" : undefined}
|
className="nodedc-inspector__section-trigger"
|
||||||
data-active={activeId === section.id ? "true" : undefined}
|
data-open={isOpen ? "true" : undefined}
|
||||||
data-tone={section.tone === "accent" ? "accent" : undefined}
|
data-active={activeId === section.id ? "true" : undefined}
|
||||||
aria-expanded={isOpen}
|
data-tone={section.tone === "accent" ? "accent" : undefined}
|
||||||
disabled={section.disabled}
|
aria-expanded={isOpen}
|
||||||
onClick={() => toggle(section.id)}
|
disabled={section.disabled}
|
||||||
>
|
onClick={() => toggle(section.id)}
|
||||||
<span className="nodedc-inspector__section-label">{section.label}</span>
|
>
|
||||||
{section.description ? (
|
<span className="nodedc-inspector__section-label">{section.label}</span>
|
||||||
<span className="nodedc-inspector__section-description">{section.description}</span>
|
{section.description ? (
|
||||||
) : null}
|
<span className="nodedc-inspector__section-description">{section.description}</span>
|
||||||
</button>
|
) : null}
|
||||||
{isOpen ? <div className="nodedc-inspector__section-content">{section.content}</div> : null}
|
</button>
|
||||||
</section>
|
{isOpen ? <div className="nodedc-inspector__section-content">{section.content}</div> : null}
|
||||||
);
|
</section>
|
||||||
})}
|
);
|
||||||
</div>
|
})}
|
||||||
))}
|
</div>
|
||||||
</div>
|
))}
|
||||||
|
</div>
|
||||||
|
</InspectorSelectPolicyContext.Provider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -103,3 +107,28 @@ export function ControlRow({ label, children, layout = "inline", className }: Co
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface InspectorSelectFieldProps<T extends string>
|
||||||
|
extends Omit<SelectProps<T>, "label" | "variant"> {
|
||||||
|
label: string;
|
||||||
|
fieldClassName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Canonical Inspector selection field.
|
||||||
|
*
|
||||||
|
* Inspector labels always sit above a full-width Engine split select. The
|
||||||
|
* public API intentionally exposes neither `layout` nor `variant`, so an
|
||||||
|
* Inspector consumer cannot fall back to the Hub/Launcher integrated pill.
|
||||||
|
*/
|
||||||
|
export function InspectorSelectField<T extends string>({
|
||||||
|
label,
|
||||||
|
fieldClassName,
|
||||||
|
...selectProps
|
||||||
|
}: InspectorSelectFieldProps<T>) {
|
||||||
|
return (
|
||||||
|
<ControlRow label={label} layout="stack" className={fieldClassName}>
|
||||||
|
<Select {...selectProps} label={label} variant="split" />
|
||||||
|
</ControlRow>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,4 @@
|
||||||
|
import { createContext } from "react";
|
||||||
|
|
||||||
|
/** Internal presentation policy. Window portals explicitly reset this scope. */
|
||||||
|
export const InspectorSelectPolicyContext = createContext(false);
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { useMemo, useState, type KeyboardEvent, type ReactNode } from "react";
|
import { useContext, useMemo, useState, type KeyboardEvent, type ReactNode } from "react";
|
||||||
import type { FloatingPlacement } from "@nodedc/ui-core";
|
import type { FloatingPlacement } from "@nodedc/ui-core";
|
||||||
import { Dropdown } from "./Dropdown.js";
|
import { Dropdown } from "./Dropdown.js";
|
||||||
|
import { InspectorSelectPolicyContext } from "./InspectorContext.js";
|
||||||
import { cn } from "./cn.js";
|
import { cn } from "./cn.js";
|
||||||
|
|
||||||
export interface SelectOption<T extends string> {
|
export interface SelectOption<T extends string> {
|
||||||
|
|
@ -48,6 +49,8 @@ export function Select<T extends string>({
|
||||||
triggerClassName,
|
triggerClassName,
|
||||||
menuClassName,
|
menuClassName,
|
||||||
}: SelectProps<T>) {
|
}: SelectProps<T>) {
|
||||||
|
const inspectorSplitRequired = useContext(InspectorSelectPolicyContext);
|
||||||
|
const resolvedVariant: SelectVariant = inspectorSplitRequired ? "split" : variant;
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
const selected = options.find((option) => option.value === value) ?? options[0];
|
const selected = options.find((option) => option.value === value) ?? options[0];
|
||||||
const visibleOptions = useMemo(() => {
|
const visibleOptions = useMemo(() => {
|
||||||
|
|
@ -64,23 +67,24 @@ export function Select<T extends string>({
|
||||||
width={menuWidth}
|
width={menuWidth}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
surfaceRole="listbox"
|
surfaceRole="listbox"
|
||||||
surfaceClassName={cn(variant === "split" && "nodedc-select__menu", menuClassName)}
|
surfaceClassName={cn(resolvedVariant === "split" && "nodedc-select__menu", menuClassName)}
|
||||||
trigger={({ open, toggle, setTriggerRef, surfaceId }) => {
|
trigger={({ open, toggle, setAnchorRef, setTriggerRef, surfaceId }) => {
|
||||||
const handleKeyDown = (event: KeyboardEvent<HTMLButtonElement>) => {
|
const handleKeyDown = (event: KeyboardEvent<HTMLButtonElement>) => {
|
||||||
if (event.key === "ArrowDown" || event.key === "Enter" || event.key === " ") {
|
if (event.key === "ArrowDown" || event.key === "Enter" || event.key === " ") {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (!open) toggle();
|
if (!open) toggle();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
if (variant === "split") {
|
if (resolvedVariant === "split") {
|
||||||
return (
|
return (
|
||||||
<div className={cn("nodedc-select", triggerClassName)}>
|
<div className={cn("nodedc-select", triggerClassName)} data-disabled={disabled ? "true" : undefined}>
|
||||||
<div ref={setTriggerRef} className="nodedc-select__control">
|
<div ref={setAnchorRef} className="nodedc-select__control">
|
||||||
<div className="nodedc-select__value">
|
<div className="nodedc-select__value">
|
||||||
{selected?.icon ? <span className="nodedc-select__value-icon">{selected.icon}</span> : null}
|
{selected?.icon ? <span className="nodedc-select__value-icon">{selected.icon}</span> : null}
|
||||||
<span>{selected?.label ?? "—"}</span>
|
<span>{selected?.label ?? "—"}</span>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
|
ref={setTriggerRef}
|
||||||
type="button"
|
type="button"
|
||||||
className="nodedc-select__toggle"
|
className="nodedc-select__toggle"
|
||||||
aria-label={label}
|
aria-label={label}
|
||||||
|
|
|
||||||
|
|
@ -73,6 +73,9 @@ export function ShareAccessModal<Role extends string>({
|
||||||
};
|
};
|
||||||
const handleKeyDown = (event: KeyboardEvent) => {
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
if (event.key !== "Escape") return;
|
if (event.key !== "Escape") return;
|
||||||
|
// An open nested dropdown owns the first Escape press. Let it close and
|
||||||
|
// restore focus without collapsing the surrounding members disclosure.
|
||||||
|
if (document.querySelector(".nodedc-dropdown-surface")) return;
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
event.stopImmediatePropagation();
|
event.stopImmediatePropagation();
|
||||||
setMembersOpen(false);
|
setMembersOpen(false);
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import {
|
||||||
type ReactNode,
|
type ReactNode,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
|
import { InspectorSelectPolicyContext } from "./InspectorContext.js";
|
||||||
import { cn } from "./cn.js";
|
import { cn } from "./cn.js";
|
||||||
import { Icon } from "./Icon.js";
|
import { Icon } from "./Icon.js";
|
||||||
|
|
||||||
|
|
@ -148,44 +149,46 @@ export function Window({
|
||||||
};
|
};
|
||||||
|
|
||||||
return createPortal(
|
return createPortal(
|
||||||
<div className="nodedc-overlay nodedc-ui-root" data-placement={placement} onPointerDown={handleBackdropPointerDown}>
|
<InspectorSelectPolicyContext.Provider value={false}>
|
||||||
<div
|
<div className="nodedc-overlay nodedc-ui-root" data-placement={placement} onPointerDown={handleBackdropPointerDown}>
|
||||||
ref={dialogRef}
|
<div
|
||||||
className={cn("nodedc-window nodedc-glass-material nodedc-material-rim", className)}
|
ref={dialogRef}
|
||||||
data-material="glass-v4"
|
className={cn("nodedc-window nodedc-glass-material nodedc-material-rim", className)}
|
||||||
data-size={size === "md" ? undefined : size}
|
data-material="glass-v4"
|
||||||
data-placement={placement}
|
data-size={size === "md" ? undefined : size}
|
||||||
data-draggable={draggable ? "true" : undefined}
|
data-placement={placement}
|
||||||
role="dialog"
|
data-draggable={draggable ? "true" : undefined}
|
||||||
aria-modal={placement === "center" ? "true" : undefined}
|
role="dialog"
|
||||||
aria-labelledby={titleId}
|
aria-modal={placement === "center" ? "true" : undefined}
|
||||||
aria-describedby={subtitle ? descriptionId : undefined}
|
aria-labelledby={titleId}
|
||||||
tabIndex={-1}
|
aria-describedby={subtitle ? descriptionId : undefined}
|
||||||
style={dragPosition ? { ...style, position: "fixed", left: dragPosition.left, top: dragPosition.top, right: "auto", bottom: "auto" } : style}
|
tabIndex={-1}
|
||||||
{...props}
|
style={dragPosition ? { ...style, position: "fixed", left: dragPosition.left, top: dragPosition.top, right: "auto", bottom: "auto" } : style}
|
||||||
>
|
{...props}
|
||||||
<header
|
|
||||||
className="nodedc-window__head"
|
|
||||||
onPointerDown={(event) => {
|
|
||||||
if (!draggable || event.button !== 0 || (event.target as HTMLElement).closest("button, input, select, textarea, a")) return;
|
|
||||||
const bounds = dialogRef.current?.getBoundingClientRect();
|
|
||||||
if (!bounds) return;
|
|
||||||
dragRef.current = { pointerId: event.pointerId, offsetX: event.clientX - bounds.left, offsetY: event.clientY - bounds.top };
|
|
||||||
event.preventDefault();
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<div className="nodedc-window__titles">
|
<header
|
||||||
<h2 id={titleId} className="nodedc-window__title">{title}</h2>
|
className="nodedc-window__head"
|
||||||
{subtitle ? <p id={descriptionId} className="nodedc-window__subtitle">{subtitle}</p> : null}
|
onPointerDown={(event) => {
|
||||||
</div>
|
if (!draggable || event.button !== 0 || (event.target as HTMLElement).closest("button, input, select, textarea, a")) return;
|
||||||
<button type="button" className="nodedc-window__close" aria-label={closeLabel} onClick={onClose}>
|
const bounds = dialogRef.current?.getBoundingClientRect();
|
||||||
<Icon name="close" size={16} strokeWidth={1.6} />
|
if (!bounds) return;
|
||||||
</button>
|
dragRef.current = { pointerId: event.pointerId, offsetX: event.clientX - bounds.left, offsetY: event.clientY - bounds.top };
|
||||||
</header>
|
event.preventDefault();
|
||||||
<div className="nodedc-window__body">{children}</div>
|
}}
|
||||||
{footer ? <footer className="nodedc-window__footer">{footer}</footer> : null}
|
>
|
||||||
|
<div className="nodedc-window__titles">
|
||||||
|
<h2 id={titleId} className="nodedc-window__title">{title}</h2>
|
||||||
|
{subtitle ? <p id={descriptionId} className="nodedc-window__subtitle">{subtitle}</p> : null}
|
||||||
|
</div>
|
||||||
|
<button type="button" className="nodedc-window__close" aria-label={closeLabel} onClick={onClose}>
|
||||||
|
<Icon name="close" size={16} strokeWidth={1.6} />
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
<div className="nodedc-window__body">{children}</div>
|
||||||
|
{footer ? <footer className="nodedc-window__footer">{footer}</footer> : null}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>,
|
</InspectorSelectPolicyContext.Provider>,
|
||||||
document.body,
|
document.body,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -91,7 +91,7 @@
|
||||||
"domExports": ["createFloatingLayer"],
|
"domExports": ["createFloatingLayer"],
|
||||||
"domContract": ["nodedc-dropdown-surface", "nodedc-dropdown-option"],
|
"domContract": ["nodedc-dropdown-surface", "nodedc-dropdown-option"],
|
||||||
"summary": "Shared portal/fixed floating layer for action and selection menus.",
|
"summary": "Shared portal/fixed floating layer for action and selection menus.",
|
||||||
"behavior": ["portal to document body", "viewport clamp", "vertical flip", "outside pointer close", "Escape close", "scroll and resize reposition", "one open dropdown per window"],
|
"behavior": ["portal to document body", "viewport clamp", "vertical flip", "outside pointer close", "Escape close with trigger focus restore", "scroll and resize reposition", "one open dropdown per window"],
|
||||||
"rules": [
|
"rules": [
|
||||||
"Never render a runtime dropdown as an absolute child of a card, sticky header or scroll container.",
|
"Never render a runtime dropdown as an absolute child of a card, sticky header or scroll container.",
|
||||||
"Action menus and selection menus share floating behavior even when their row content differs."
|
"Action menus and selection menus share floating behavior even when their row content differs."
|
||||||
|
|
@ -110,6 +110,7 @@
|
||||||
"rules": [
|
"rules": [
|
||||||
"Integrated is the single-pill Hub/Launcher form.",
|
"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.",
|
"Split is the Engine form: a separate value surface and a 46 px toggle separated by an 8 px gap.",
|
||||||
|
"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."
|
"The portal menu preserves its source-family row geometry instead of inheriting card radii."
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|
@ -313,13 +314,14 @@
|
||||||
"id": "inspector",
|
"id": "inspector",
|
||||||
"status": "baseline",
|
"status": "baseline",
|
||||||
"package": "@nodedc/ui-react",
|
"package": "@nodedc/ui-react",
|
||||||
"exports": ["Inspector", "ControlRow"],
|
"exports": ["Inspector", "ControlRow", "InspectorSelectField"],
|
||||||
"domContract": ["nodedc-inspector", "nodedc-control-row"],
|
"domContract": ["nodedc-inspector", "nodedc-control-row"],
|
||||||
"summary": "Accordion settings/definition inspector derived only from the redesigned Engine environment and NDC agent inspector.",
|
"summary": "Accordion settings/definition inspector derived only from the redesigned Engine environment and NDC agent inspector.",
|
||||||
"rules": [
|
"rules": [
|
||||||
"Legacy Engine inspectors are not reference implementations.",
|
"Legacy Engine inspectors are not reference implementations.",
|
||||||
"Domain-specific field definitions remain in Engine; reusable layout and controls live here.",
|
"Domain-specific field definitions remain in Engine; reusable layout and controls live here.",
|
||||||
"Desktop Engine geometry is fixed at 390 px panel, 330 px content, 154/14/162 px control rows and 50 px section headers with a 12 px radius.",
|
"Desktop Engine geometry is fixed at 390 px panel, 330 px content, 154/14/162 px control rows and 50 px section headers with a 12 px radius.",
|
||||||
|
"Inspector selection fields use InspectorSelectField only: a stacked visible label and the 276/8/46 px split control; integrated pills are prohibited.",
|
||||||
"Accent-filled section headers belong to Environment Settings; neutral headers remain available for other approved inspector contexts."
|
"Accent-filled section headers belong to Environment Settings; neutral headers remain available for other approved inspector contexts."
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,61 @@
|
||||||
|
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 { Inspector, InspectorSelectField, Select } from "../packages/ui-react/dist/index.js";
|
||||||
|
|
||||||
|
test("InspectorSelectField renders a stacked label and split control", () => {
|
||||||
|
const markup = renderToStaticMarkup(createElement(InspectorSelectField, {
|
||||||
|
label: "Профиль покрытия",
|
||||||
|
value: "profile-1",
|
||||||
|
options: [{ value: "profile-1", label: "Профиль 1 · 80 м" }],
|
||||||
|
onChange: () => {},
|
||||||
|
}));
|
||||||
|
|
||||||
|
assert.match(markup, /class="nodedc-control-row" data-layout="stack"/);
|
||||||
|
assert.match(markup, /class="nodedc-control-row__label">Профиль покрытия</);
|
||||||
|
assert.match(markup, /class="nodedc-select__value"/);
|
||||||
|
assert.match(markup, /class="nodedc-select__toggle"/);
|
||||||
|
assert.match(markup, /aria-label="Профиль покрытия"/);
|
||||||
|
assert.doesNotMatch(markup, /class="[^"]*\bnodedc-select-trigger(?:\s|\")/);
|
||||||
|
|
||||||
|
assert.ok(
|
||||||
|
markup.indexOf("nodedc-control-row__label") < markup.indexOf("nodedc-select__control"),
|
||||||
|
"the visible Inspector label must precede the split control",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Inspector hard-forces a raw integrated Select to the split presentation", () => {
|
||||||
|
const markup = renderToStaticMarkup(createElement(Inspector, {
|
||||||
|
defaultOpen: ["selection"],
|
||||||
|
sections: [{
|
||||||
|
id: "selection",
|
||||||
|
label: "Selection",
|
||||||
|
content: createElement(Select, {
|
||||||
|
variant: "integrated",
|
||||||
|
label: "Нарушающий consumer",
|
||||||
|
value: "one",
|
||||||
|
options: [{ value: "one", label: "One" }],
|
||||||
|
onChange: () => {},
|
||||||
|
}),
|
||||||
|
}],
|
||||||
|
}));
|
||||||
|
|
||||||
|
assert.match(markup, /class="nodedc-select__value"/);
|
||||||
|
assert.match(markup, /class="nodedc-select__toggle"/);
|
||||||
|
assert.doesNotMatch(markup, /class="[^"]*\bnodedc-select-trigger(?:\s|\")/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Foundry Inspector screens consume the semantic selection field", async () => {
|
||||||
|
const [mapPreview, catalog, coreStyles] = await Promise.all([
|
||||||
|
readFile(new URL("../apps/catalog/src/MapFixturePreview.tsx", import.meta.url), "utf8"),
|
||||||
|
readFile(new URL("../apps/catalog/src/CatalogApp.tsx", import.meta.url), "utf8"),
|
||||||
|
readFile(new URL("../packages/ui-core/styles.css", import.meta.url), "utf8"),
|
||||||
|
]);
|
||||||
|
|
||||||
|
assert.match(mapPreview, /<InspectorSelectField\s+label="Профиль покрытия"/);
|
||||||
|
assert.doesNotMatch(mapPreview, /<ControlRow label="Профиль покрытия">/);
|
||||||
|
assert.equal((catalog.match(/<InspectorSelectField/g) ?? []).length, 2);
|
||||||
|
assert.match(coreStyles, /\.nodedc-inspector \.nodedc-control-row:has\(\.nodedc-select-anchor\)/);
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue