From a1c4ecaa93723d2737e5b52fef1670ae2e7ea7a6 Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 16 Jul 2026 18:56:20 +0300 Subject: [PATCH] fix(ui): enforce canonical inspector selects --- apps/catalog/package.json | 4 +- apps/catalog/src/CatalogApp.tsx | 47 ++++++------ apps/catalog/src/MapFixturePreview.tsx | 18 ++--- docs/COMPONENTS.md | 3 + docs/SOURCE_BASELINE.md | 2 +- package-lock.json | 14 ++-- package.json | 5 +- packages/ui-core/package.json | 2 +- packages/ui-core/styles.css | 16 ++++ packages/ui-dom/package.json | 4 +- packages/ui-react/package.json | 4 +- packages/ui-react/src/Dropdown.tsx | 22 +++--- packages/ui-react/src/Inspector.tsx | 87 ++++++++++++++-------- packages/ui-react/src/InspectorContext.ts | 4 + packages/ui-react/src/Select.tsx | 16 ++-- packages/ui-react/src/SharingModals.tsx | 3 + packages/ui-react/src/Window.tsx | 75 ++++++++++--------- registry/components.json | 6 +- scripts/inspector-select-contract.test.mjs | 61 +++++++++++++++ 19 files changed, 258 insertions(+), 135 deletions(-) create mode 100644 packages/ui-react/src/InspectorContext.ts create mode 100644 scripts/inspector-select-contract.test.mjs diff --git a/apps/catalog/package.json b/apps/catalog/package.json index 2bc3411..0eb2686 100644 --- a/apps/catalog/package.json +++ b/apps/catalog/package.json @@ -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" diff --git a/apps/catalog/src/CatalogApp.tsx b/apps/catalog/src/CatalogApp.tsx index 31450e7..622bfef 100644 --- a/apps/catalog/src/CatalogApp.tsx +++ b/apps/catalog/src/CatalogApp.tsx @@ -26,6 +26,7 @@ import { Icon, IconButton, Inspector, + InspectorSelectField, MediaSourceField, RangeControl, SegmentedControl, @@ -1161,19 +1162,16 @@ export function CatalogApp() { tone: "accent" as const, content: ( <> - - - + diff --git a/apps/catalog/src/MapFixturePreview.tsx b/apps/catalog/src/MapFixturePreview.tsx index d0e52d7..f52964e 100644 --- a/apps/catalog/src/MapFixturePreview.tsx +++ b/apps/catalog/src/MapFixturePreview.tsx @@ -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 {animationModeEnabled ? <> Стартовая точка берётся из текущей позиции камеры. Камера смотрит почти в надир, а маршрут ждёт текущие tiles перед продолжением. Движение идёт по региональной геодезической спирали WGS84 до выбранного радиуса. - - + + ); +} diff --git a/packages/ui-react/src/InspectorContext.ts b/packages/ui-react/src/InspectorContext.ts new file mode 100644 index 0000000..cd4387a --- /dev/null +++ b/packages/ui-react/src/InspectorContext.ts @@ -0,0 +1,4 @@ +import { createContext } from "react"; + +/** Internal presentation policy. Window portals explicitly reset this scope. */ +export const InspectorSelectPolicyContext = createContext(false); diff --git a/packages/ui-react/src/Select.tsx b/packages/ui-react/src/Select.tsx index dcb634b..ab0898d 100644 --- a/packages/ui-react/src/Select.tsx +++ b/packages/ui-react/src/Select.tsx @@ -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 { @@ -48,6 +49,8 @@ export function Select({ triggerClassName, menuClassName, }: SelectProps) { + 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({ 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) => { if (event.key === "ArrowDown" || event.key === "Enter" || event.key === " ") { event.preventDefault(); if (!open) toggle(); } }; - if (variant === "split") { + if (resolvedVariant === "split") { return ( -
-
+
+
{selected?.icon ? {selected.icon} : null} {selected?.label ?? "—"}
- -
{children}
- {footer ?
{footer}
: null} +
{ + 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(); + }} + > +
+

{title}

+ {subtitle ?

{subtitle}

: null} +
+ +
+
{children}
+ {footer ?
{footer}
: null} +
-
, + , document.body, ); } diff --git a/registry/components.json b/registry/components.json index 42a4b84..1403871 100644 --- a/registry/components.json +++ b/registry/components.json @@ -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." ] } diff --git a/scripts/inspector-select-contract.test.mjs b/scripts/inspector-select-contract.test.mjs new file mode 100644 index 0000000..036b47a --- /dev/null +++ b/scripts/inspector-select-contract.test.mjs @@ -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">Профиль покрытия { + 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, //); + assert.equal((catalog.match(/