fix(ui): enforce canonical inspector selects

This commit is contained in:
Codex 2026-07-16 18:56:20 +03:00
parent 751d9963f1
commit a1c4ecaa93
19 changed files with 258 additions and 135 deletions

View File

@ -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"

View File

@ -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>

View File

@ -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

View File

@ -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:

View File

@ -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, чтобы палитра не зависела от светлой/тёмной системной темы браузера.

14
package-lock.json generated
View File

@ -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": {

View File

@ -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"

View File

@ -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",

View File

@ -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);

View File

@ -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"
} }
} }

View File

@ -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": {

View File

@ -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>
); );
} }

View File

@ -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>
);
}

View File

@ -0,0 +1,4 @@
import { createContext } from "react";
/** Internal presentation policy. Window portals explicitly reset this scope. */
export const InspectorSelectPolicyContext = createContext(false);

View File

@ -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}

View File

@ -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);

View File

@ -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,
); );
} }

View File

@ -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."
] ]
} }

View File

@ -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\)/);
});