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
+2 -2
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": {
+13 -9
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>
);
}
+58 -29
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,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);
+10 -6
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}
+3
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);
+39 -36
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,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,
);
}