fix(ui): enforce canonical inspector selects
This commit is contained in:
parent
751d9963f1
commit
a1c4ecaa93
|
|
@ -10,8 +10,8 @@
|
|||
},
|
||||
"dependencies": {
|
||||
"@nodedc/page-patterns": "0.1.0",
|
||||
"@nodedc/ui-core": "0.6.0",
|
||||
"@nodedc/ui-react": "0.6.0",
|
||||
"@nodedc/ui-core": "0.7.0",
|
||||
"@nodedc/ui-react": "0.7.0",
|
||||
"cesium": "^1.143.0",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0"
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ import {
|
|||
Icon,
|
||||
IconButton,
|
||||
Inspector,
|
||||
InspectorSelectField,
|
||||
MediaSourceField,
|
||||
RangeControl,
|
||||
SegmentedControl,
|
||||
|
|
@ -1161,19 +1162,16 @@ export function CatalogApp() {
|
|||
tone: "accent" as const,
|
||||
content: (
|
||||
<>
|
||||
<ControlRow label="Позиция">
|
||||
<Select
|
||||
variant="split"
|
||||
label="Позиция Toolbar"
|
||||
value={toolbarPlacement}
|
||||
options={[
|
||||
{ value: "left", label: "Слева" },
|
||||
{ value: "right", label: "Справа" },
|
||||
{ value: "bottom", label: "Снизу" },
|
||||
]}
|
||||
onChange={(value) => setToolbarPlacement(value as ToolbarPlacement)}
|
||||
/>
|
||||
</ControlRow>
|
||||
<InspectorSelectField
|
||||
label="Позиция Toolbar"
|
||||
value={toolbarPlacement}
|
||||
options={[
|
||||
{ value: "left", label: "Слева" },
|
||||
{ value: "right", label: "Справа" },
|
||||
{ value: "bottom", label: "Снизу" },
|
||||
]}
|
||||
onChange={(value) => setToolbarPlacement(value as ToolbarPlacement)}
|
||||
/>
|
||||
<ControlRow label="Фон"><ColorField value={toolbarBg} onChange={setToolbarBg} /></ControlRow>
|
||||
<ControlRow label="Border"><ColorField value={toolbarBorder} onChange={setToolbarBorder} /></ControlRow>
|
||||
<ControlRow label="Outline"><ColorField value={toolbarOutline} onChange={setToolbarOutline} /></ControlRow>
|
||||
|
|
@ -1206,19 +1204,16 @@ export function CatalogApp() {
|
|||
tone: "accent" as const,
|
||||
content: (
|
||||
<>
|
||||
<ControlRow label="Связи по умолчанию">
|
||||
<Select
|
||||
variant="split"
|
||||
label="Тип связи"
|
||||
value={connectionType}
|
||||
options={[
|
||||
{ value: "spline", label: "Сплайн" },
|
||||
{ value: "straight", label: "Прямая" },
|
||||
{ value: "step", label: "Ступенчатая" },
|
||||
]}
|
||||
onChange={setConnectionType}
|
||||
/>
|
||||
</ControlRow>
|
||||
<InspectorSelectField
|
||||
label="Связи по умолчанию"
|
||||
value={connectionType}
|
||||
options={[
|
||||
{ value: "spline", label: "Сплайн" },
|
||||
{ value: "straight", label: "Прямая" },
|
||||
{ value: "step", label: "Ступенчатая" },
|
||||
]}
|
||||
onChange={setConnectionType}
|
||||
/>
|
||||
<Checker checked={usePortColors} label="Использовать цвет портов" onChange={setUsePortColors} />
|
||||
<ControlRow label="Цвет связей по умолчанию"><ColorField value={connectionColor} onChange={setConnectionColor} /></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 { 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 {
|
||||
CameraSpiralState,
|
||||
|
|
@ -715,15 +715,13 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
|
|||
<Checker checked={animationModeEnabled} label="Режим анимации" onChange={setAnimationMode} />
|
||||
{animationModeEnabled ? <>
|
||||
<small className="catalog-map-inspector__note">Стартовая точка берётся из текущей позиции камеры. Камера смотрит почти в надир, а маршрут ждёт текущие tiles перед продолжением. Движение идёт по региональной геодезической спирали WGS84 до выбранного радиуса.</small>
|
||||
<ControlRow label="Профиль покрытия">
|
||||
<Select
|
||||
value={spiralPresetId}
|
||||
options={spiralPresetOptions}
|
||||
label="Профиль покрытия камеры"
|
||||
disabled={spiralRunning}
|
||||
onChange={selectSpiralPreset}
|
||||
/>
|
||||
</ControlRow>
|
||||
<InspectorSelectField
|
||||
label="Профиль покрытия"
|
||||
value={spiralPresetId}
|
||||
options={spiralPresetOptions}
|
||||
disabled={spiralRunning}
|
||||
onChange={selectSpiralPreset}
|
||||
/>
|
||||
<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>
|
||||
<RangeControl
|
||||
|
|
|
|||
|
|
@ -57,6 +57,7 @@ Dropdown владеет floating-layer поведением:
|
|||
- ограничивает размеры viewport;
|
||||
- обновляется при resize и scroll;
|
||||
- закрывается по outside pointer и Escape;
|
||||
- после Escape возвращает фокус на реальную trigger-кнопку, не закрывая родительское окно;
|
||||
- закрывает другой открытый dropdown.
|
||||
|
||||
Содержимое меню передаётся приложением. Selection, action menu и filter menu используют один engine.
|
||||
|
|
@ -70,6 +71,8 @@ Select добавляет к Dropdown контролируемое значен
|
|||
|
||||
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 — единая механика открытия modal и правой panel:
|
||||
|
|
|
|||
|
|
@ -43,7 +43,7 @@ CMS подтверждает необходимость DOM-пакета без
|
|||
|
||||
Все остальные 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, чтобы палитра не зависела от светлой/тёмной системной темы браузера.
|
||||
|
||||
|
|
|
|||
|
|
@ -28,8 +28,8 @@
|
|||
"version": "0.7.0",
|
||||
"dependencies": {
|
||||
"@nodedc/page-patterns": "0.1.0",
|
||||
"@nodedc/ui-core": "0.6.0",
|
||||
"@nodedc/ui-react": "0.6.0",
|
||||
"@nodedc/ui-core": "0.7.0",
|
||||
"@nodedc/ui-react": "0.7.0",
|
||||
"cesium": "^1.143.0",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0"
|
||||
|
|
@ -2466,26 +2466,26 @@
|
|||
},
|
||||
"packages/ui-core": {
|
||||
"name": "@nodedc/ui-core",
|
||||
"version": "0.6.0",
|
||||
"version": "0.7.0",
|
||||
"dependencies": {
|
||||
"@nodedc/tokens": "0.6.0"
|
||||
}
|
||||
},
|
||||
"packages/ui-dom": {
|
||||
"name": "@nodedc/ui-dom",
|
||||
"version": "0.6.0",
|
||||
"version": "0.7.0",
|
||||
"dependencies": {
|
||||
"@nodedc/ui-core": "0.6.0"
|
||||
"@nodedc/ui-core": "0.7.0"
|
||||
}
|
||||
},
|
||||
"packages/ui-react": {
|
||||
"name": "@nodedc/ui-react",
|
||||
"version": "0.6.0",
|
||||
"version": "0.7.0",
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@nodedc/ui-core": "0.6.0",
|
||||
"@nodedc/ui-core": "0.7.0",
|
||||
"lucide-react": "^0.468.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
|
|||
|
|
@ -11,14 +11,15 @@
|
|||
"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: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",
|
||||
"serve": "node server/catalog-server.mjs",
|
||||
"validate:registry": "node scripts/validate-registry.mjs",
|
||||
"test:platform-settings": "node scripts/smoke-platform-settings.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-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": {
|
||||
"node": ">=20"
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@nodedc/ui-core",
|
||||
"version": "0.6.0",
|
||||
"version": "0.7.0",
|
||||
"type": "module",
|
||||
"files": [
|
||||
"dist",
|
||||
|
|
|
|||
|
|
@ -1810,6 +1810,14 @@ textarea.nodedc-field__control {
|
|||
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 {
|
||||
display: grid;
|
||||
width: 100%;
|
||||
|
|
@ -2634,6 +2642,14 @@ textarea.nodedc-field__control {
|
|||
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 {
|
||||
color: var(--nodedc-text-secondary);
|
||||
font-size: var(--nodedc-font-size-sm);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@nodedc/ui-dom",
|
||||
"version": "0.6.0",
|
||||
"version": "0.7.0",
|
||||
"type": "module",
|
||||
"files": [
|
||||
"dist"
|
||||
|
|
@ -18,6 +18,6 @@
|
|||
"typecheck": "tsc -p tsconfig.json --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nodedc/ui-core": "0.6.0"
|
||||
"@nodedc/ui-core": "0.7.0"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@nodedc/ui-react",
|
||||
"version": "0.6.0",
|
||||
"version": "0.7.0",
|
||||
"type": "module",
|
||||
"files": [
|
||||
"dist"
|
||||
|
|
@ -21,7 +21,7 @@
|
|||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@nodedc/ui-core": "0.6.0",
|
||||
"@nodedc/ui-core": "0.7.0",
|
||||
"lucide-react": "^0.468.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ export interface DropdownTriggerApi {
|
|||
show: () => void;
|
||||
close: () => void;
|
||||
toggle: () => void;
|
||||
setAnchorRef: (node: HTMLElement | null) => void;
|
||||
setTriggerRef: (node: HTMLElement | null) => void;
|
||||
surfaceId: string;
|
||||
}
|
||||
|
|
@ -49,6 +50,7 @@ export function Dropdown({
|
|||
const instanceId = useId();
|
||||
const surfaceId = `${instanceId.replaceAll(":", "")}-surface`;
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [anchorElement, setAnchorElement] = useState<HTMLElement | null>(null);
|
||||
const [triggerElement, setTriggerElement] = useState<HTMLElement | null>(null);
|
||||
const surfaceRef = useRef<HTMLDivElement>(null);
|
||||
const [surfaceStyle, setSurfaceStyle] = useState<CSSProperties>({ visibility: "hidden" });
|
||||
|
|
@ -62,8 +64,9 @@ export function Dropdown({
|
|||
}, [disabled]);
|
||||
|
||||
const updatePosition = useCallback(() => {
|
||||
if (!triggerElement || !surfaceRef.current) return;
|
||||
const anchor = triggerElement.getBoundingClientRect();
|
||||
const anchorNode = anchorElement ?? triggerElement;
|
||||
if (!anchorNode || !surfaceRef.current) return;
|
||||
const anchor = anchorNode.getBoundingClientRect();
|
||||
const measured = surfaceRef.current.getBoundingClientRect();
|
||||
const surfaceWidth = typeof width === "number"
|
||||
? width
|
||||
|
|
@ -83,7 +86,7 @@ export function Dropdown({
|
|||
maxHeight: position.maxHeight,
|
||||
visibility: "visible",
|
||||
});
|
||||
}, [minWidth, offset, placement, triggerElement, width]);
|
||||
}, [anchorElement, minWidth, offset, placement, triggerElement, width]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!isOpen) return;
|
||||
|
|
@ -95,12 +98,14 @@ export function Dropdown({
|
|||
const handlePointerDown = (event: PointerEvent) => {
|
||||
const target = event.target;
|
||||
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();
|
||||
};
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") {
|
||||
close();
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
triggerElement?.focus();
|
||||
}
|
||||
};
|
||||
|
|
@ -112,22 +117,22 @@ export function Dropdown({
|
|||
|
||||
window.dispatchEvent(new CustomEvent("nodedc-dropdown-open", { detail: { id: instanceId } }));
|
||||
document.addEventListener("pointerdown", handlePointerDown);
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
document.addEventListener("keydown", handleKeyDown, true);
|
||||
window.addEventListener("nodedc-dropdown-open", handleOtherOpen as EventListener);
|
||||
window.addEventListener("resize", handleViewportChange);
|
||||
window.addEventListener("scroll", handleViewportChange, true);
|
||||
return () => {
|
||||
document.removeEventListener("pointerdown", handlePointerDown);
|
||||
document.removeEventListener("keydown", handleKeyDown);
|
||||
document.removeEventListener("keydown", handleKeyDown, true);
|
||||
window.removeEventListener("nodedc-dropdown-open", handleOtherOpen as EventListener);
|
||||
window.removeEventListener("resize", handleViewportChange);
|
||||
window.removeEventListener("scroll", handleViewportChange, true);
|
||||
};
|
||||
}, [close, instanceId, isOpen, triggerElement, updatePosition]);
|
||||
}, [anchorElement, close, instanceId, isOpen, triggerElement, updatePosition]);
|
||||
|
||||
return (
|
||||
<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"
|
||||
? createPortal(
|
||||
<div
|
||||
|
|
@ -145,4 +150,3 @@ export function Dropdown({
|
|||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
import { useMemo, useState, type ReactNode } from "react";
|
||||
import { InspectorSelectPolicyContext } from "./InspectorContext.js";
|
||||
import { Select, type SelectProps } from "./Select.js";
|
||||
import { cn } from "./cn.js";
|
||||
|
||||
export interface InspectorSectionSpec {
|
||||
|
|
@ -53,35 +55,37 @@ export function Inspector({
|
|||
};
|
||||
|
||||
return (
|
||||
<div className={cn("nodedc-inspector", className)}>
|
||||
{groups.map((group, groupIndex) => (
|
||||
<div className="nodedc-inspector__group" key={`${group.label ?? "root"}-${groupIndex}`}>
|
||||
{group.sections.map((section) => {
|
||||
const isOpen = openIds.has(section.id);
|
||||
return (
|
||||
<section className="nodedc-inspector__section" key={section.id}>
|
||||
<button
|
||||
type="button"
|
||||
className="nodedc-inspector__section-trigger"
|
||||
data-open={isOpen ? "true" : undefined}
|
||||
data-active={activeId === section.id ? "true" : undefined}
|
||||
data-tone={section.tone === "accent" ? "accent" : undefined}
|
||||
aria-expanded={isOpen}
|
||||
disabled={section.disabled}
|
||||
onClick={() => toggle(section.id)}
|
||||
>
|
||||
<span className="nodedc-inspector__section-label">{section.label}</span>
|
||||
{section.description ? (
|
||||
<span className="nodedc-inspector__section-description">{section.description}</span>
|
||||
) : null}
|
||||
</button>
|
||||
{isOpen ? <div className="nodedc-inspector__section-content">{section.content}</div> : null}
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<InspectorSelectPolicyContext.Provider value>
|
||||
<div className={cn("nodedc-inspector", className)}>
|
||||
{groups.map((group, groupIndex) => (
|
||||
<div className="nodedc-inspector__group" key={`${group.label ?? "root"}-${groupIndex}`}>
|
||||
{group.sections.map((section) => {
|
||||
const isOpen = openIds.has(section.id);
|
||||
return (
|
||||
<section className="nodedc-inspector__section" key={section.id}>
|
||||
<button
|
||||
type="button"
|
||||
className="nodedc-inspector__section-trigger"
|
||||
data-open={isOpen ? "true" : undefined}
|
||||
data-active={activeId === section.id ? "true" : undefined}
|
||||
data-tone={section.tone === "accent" ? "accent" : undefined}
|
||||
aria-expanded={isOpen}
|
||||
disabled={section.disabled}
|
||||
onClick={() => toggle(section.id)}
|
||||
>
|
||||
<span className="nodedc-inspector__section-label">{section.label}</span>
|
||||
{section.description ? (
|
||||
<span className="nodedc-inspector__section-description">{section.description}</span>
|
||||
) : null}
|
||||
</button>
|
||||
{isOpen ? <div className="nodedc-inspector__section-content">{section.content}</div> : null}
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</InspectorSelectPolicyContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -103,3 +107,28 @@ export function ControlRow({ label, children, layout = "inline", className }: Co
|
|||
</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 { Dropdown } from "./Dropdown.js";
|
||||
import { InspectorSelectPolicyContext } from "./InspectorContext.js";
|
||||
import { cn } from "./cn.js";
|
||||
|
||||
export interface SelectOption<T extends string> {
|
||||
|
|
@ -48,6 +49,8 @@ export function Select<T extends string>({
|
|||
triggerClassName,
|
||||
menuClassName,
|
||||
}: SelectProps<T>) {
|
||||
const inspectorSplitRequired = useContext(InspectorSelectPolicyContext);
|
||||
const resolvedVariant: SelectVariant = inspectorSplitRequired ? "split" : variant;
|
||||
const [query, setQuery] = useState("");
|
||||
const selected = options.find((option) => option.value === value) ?? options[0];
|
||||
const visibleOptions = useMemo(() => {
|
||||
|
|
@ -64,23 +67,24 @@ export function Select<T extends string>({
|
|||
width={menuWidth}
|
||||
disabled={disabled}
|
||||
surfaceRole="listbox"
|
||||
surfaceClassName={cn(variant === "split" && "nodedc-select__menu", menuClassName)}
|
||||
trigger={({ open, toggle, setTriggerRef, surfaceId }) => {
|
||||
surfaceClassName={cn(resolvedVariant === "split" && "nodedc-select__menu", menuClassName)}
|
||||
trigger={({ open, toggle, setAnchorRef, setTriggerRef, surfaceId }) => {
|
||||
const handleKeyDown = (event: KeyboardEvent<HTMLButtonElement>) => {
|
||||
if (event.key === "ArrowDown" || event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
if (!open) toggle();
|
||||
}
|
||||
};
|
||||
if (variant === "split") {
|
||||
if (resolvedVariant === "split") {
|
||||
return (
|
||||
<div className={cn("nodedc-select", triggerClassName)}>
|
||||
<div ref={setTriggerRef} className="nodedc-select__control">
|
||||
<div className={cn("nodedc-select", triggerClassName)} data-disabled={disabled ? "true" : undefined}>
|
||||
<div ref={setAnchorRef} className="nodedc-select__control">
|
||||
<div className="nodedc-select__value">
|
||||
{selected?.icon ? <span className="nodedc-select__value-icon">{selected.icon}</span> : null}
|
||||
<span>{selected?.label ?? "—"}</span>
|
||||
</div>
|
||||
<button
|
||||
ref={setTriggerRef}
|
||||
type="button"
|
||||
className="nodedc-select__toggle"
|
||||
aria-label={label}
|
||||
|
|
|
|||
|
|
@ -73,6 +73,9 @@ export function ShareAccessModal<Role extends string>({
|
|||
};
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
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.stopImmediatePropagation();
|
||||
setMembersOpen(false);
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import {
|
|||
type ReactNode,
|
||||
} from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { InspectorSelectPolicyContext } from "./InspectorContext.js";
|
||||
import { cn } from "./cn.js";
|
||||
import { Icon } from "./Icon.js";
|
||||
|
||||
|
|
@ -148,44 +149,46 @@ export function Window({
|
|||
};
|
||||
|
||||
return createPortal(
|
||||
<div className="nodedc-overlay nodedc-ui-root" data-placement={placement} onPointerDown={handleBackdropPointerDown}>
|
||||
<div
|
||||
ref={dialogRef}
|
||||
className={cn("nodedc-window nodedc-glass-material nodedc-material-rim", className)}
|
||||
data-material="glass-v4"
|
||||
data-size={size === "md" ? undefined : size}
|
||||
data-placement={placement}
|
||||
data-draggable={draggable ? "true" : undefined}
|
||||
role="dialog"
|
||||
aria-modal={placement === "center" ? "true" : undefined}
|
||||
aria-labelledby={titleId}
|
||||
aria-describedby={subtitle ? descriptionId : undefined}
|
||||
tabIndex={-1}
|
||||
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();
|
||||
}}
|
||||
<InspectorSelectPolicyContext.Provider value={false}>
|
||||
<div className="nodedc-overlay nodedc-ui-root" data-placement={placement} onPointerDown={handleBackdropPointerDown}>
|
||||
<div
|
||||
ref={dialogRef}
|
||||
className={cn("nodedc-window nodedc-glass-material nodedc-material-rim", className)}
|
||||
data-material="glass-v4"
|
||||
data-size={size === "md" ? undefined : size}
|
||||
data-placement={placement}
|
||||
data-draggable={draggable ? "true" : undefined}
|
||||
role="dialog"
|
||||
aria-modal={placement === "center" ? "true" : undefined}
|
||||
aria-labelledby={titleId}
|
||||
aria-describedby={subtitle ? descriptionId : undefined}
|
||||
tabIndex={-1}
|
||||
style={dragPosition ? { ...style, position: "fixed", left: dragPosition.left, top: dragPosition.top, right: "auto", bottom: "auto" } : style}
|
||||
{...props}
|
||||
>
|
||||
<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}
|
||||
<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">
|
||||
<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>,
|
||||
</InspectorSelectPolicyContext.Provider>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -91,7 +91,7 @@
|
|||
"domExports": ["createFloatingLayer"],
|
||||
"domContract": ["nodedc-dropdown-surface", "nodedc-dropdown-option"],
|
||||
"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": [
|
||||
"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."
|
||||
|
|
@ -110,6 +110,7 @@
|
|||
"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.",
|
||||
"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."
|
||||
]
|
||||
},
|
||||
|
|
@ -313,13 +314,14 @@
|
|||
"id": "inspector",
|
||||
"status": "baseline",
|
||||
"package": "@nodedc/ui-react",
|
||||
"exports": ["Inspector", "ControlRow"],
|
||||
"exports": ["Inspector", "ControlRow", "InspectorSelectField"],
|
||||
"domContract": ["nodedc-inspector", "nodedc-control-row"],
|
||||
"summary": "Accordion settings/definition inspector derived only from the redesigned Engine environment and NDC agent inspector.",
|
||||
"rules": [
|
||||
"Legacy Engine inspectors are not reference implementations.",
|
||||
"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.",
|
||||
"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."
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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