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

View File

@ -26,6 +26,7 @@ import {
Icon,
IconButton,
Inspector,
InspectorSelectField,
MediaSourceField,
RangeControl,
SegmentedControl,
@ -1161,9 +1162,7 @@ export function CatalogApp() {
tone: "accent" as const,
content: (
<>
<ControlRow label="Позиция">
<Select
variant="split"
<InspectorSelectField
label="Позиция Toolbar"
value={toolbarPlacement}
options={[
@ -1173,7 +1172,6 @@ export function CatalogApp() {
]}
onChange={(value) => setToolbarPlacement(value as ToolbarPlacement)}
/>
</ControlRow>
<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,10 +1204,8 @@ export function CatalogApp() {
tone: "accent" as const,
content: (
<>
<ControlRow label="Связи по умолчанию">
<Select
variant="split"
label="Тип связи"
<InspectorSelectField
label="Связи по умолчанию"
value={connectionType}
options={[
{ value: "spline", label: "Сплайн" },
@ -1218,7 +1214,6 @@ export function CatalogApp() {
]}
onChange={setConnectionType}
/>
</ControlRow>
<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>

View File

@ -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
<InspectorSelectField
label="Профиль покрытия"
value={spiralPresetId}
options={spiralPresetOptions}
label="Профиль покрытия камеры"
disabled={spiralRunning}
onChange={selectSpiralPreset}
/>
</ControlRow>
<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

View File

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

View File

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

14
package-lock.json generated
View File

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

View File

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

View File

@ -1,6 +1,6 @@
{
"name": "@nodedc/ui-core",
"version": "0.6.0",
"version": "0.7.0",
"type": "module",
"files": [
"dist",

View File

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

View File

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

View File

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

View File

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

View File

@ -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,6 +55,7 @@ export function Inspector({
};
return (
<InspectorSelectPolicyContext.Provider value>
<div className={cn("nodedc-inspector", className)}>
{groups.map((group, groupIndex) => (
<div className="nodedc-inspector__group" key={`${group.label ?? "root"}-${groupIndex}`}>
@ -82,6 +85,7 @@ export function Inspector({
</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>
);
}

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

View File

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

View File

@ -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,6 +149,7 @@ export function Window({
};
return createPortal(
<InspectorSelectPolicyContext.Provider value={false}>
<div className="nodedc-overlay nodedc-ui-root" data-placement={placement} onPointerDown={handleBackdropPointerDown}>
<div
ref={dialogRef}
@ -185,7 +187,8 @@ export function Window({
<div className="nodedc-window__body">{children}</div>
{footer ? <footer className="nodedc-window__footer">{footer}</footer> : null}
</div>
</div>,
</div>
</InspectorSelectPolicyContext.Provider>,
document.body,
);
}

View File

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

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