-
-
{
- 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 ?
: 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 ?
: 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">Профиль покрытия);
+ 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, /
/);
+ assert.equal((catalog.match(/