fix(ui): enforce canonical inspector selects
This commit is contained in:
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user