+
{Array.from({ length: progress.steps }, (_, index) => (
))}
) : null}
-
+
@@ -261,7 +307,7 @@ function StructuredCardContent({ content }: { content: WireCardContent | undefin
) : null}
{content?.footer ? (
-
{content.footer}
+
{content.footer}
) : null}
);
diff --git a/packages/wire-react/src/components/WireNodeList.tsx b/packages/wire-react/src/components/WireNodeList.tsx
index b9f2a60..ea9f341 100644
--- a/packages/wire-react/src/components/WireNodeList.tsx
+++ b/packages/wire-react/src/components/WireNodeList.tsx
@@ -1,6 +1,7 @@
import type { CSSProperties, ReactElement, ReactNode } from "react";
import type { WireNode } from "@aigentive/wire-core";
import { useWireDiagram, useWireEvents, useWireSelection } from "../hooks.js";
+import { normalizeWireSelection, sameWireSelection } from "../provider/runtimeState.js";
import { KindChip } from "../primitives/KindChip.js";
import { cx } from "./classes.js";
@@ -14,6 +15,12 @@ export interface WireNodeListProps {
inspectOnClick?: boolean;
selectOnClick?: boolean;
renderItem?: (context: WireNodeListRenderContext) => ReactNode;
+ unstyled?: boolean;
+ classNames?: {
+ root?: string;
+ item?: string;
+ empty?: string;
+ };
className?: string;
style?: CSSProperties;
}
@@ -23,6 +30,8 @@ export function WireNodeList({
inspectOnClick = true,
selectOnClick = true,
renderItem,
+ unstyled = false,
+ classNames,
className,
style
}: WireNodeListProps): ReactElement {
@@ -35,11 +44,24 @@ export function WireNodeList({
return (
+ {nodes.length === 0 ? (
+
+ No nodes
+
+ ) : null}
{nodes.map((node) => {
const selected = selectedNodeIds.has(node.id);
return (
@@ -48,16 +70,20 @@ export function WireNodeList({
type="button"
aria-selected={selected}
className={cx(
- "grid w-full grid-cols-[auto_1fr] items-start gap-x-2 gap-y-0.5 rounded-md px-2 py-1.5 text-left transition-colors hover:bg-wire-sunken",
- selected ? "bg-wire-sunken" : "bg-transparent"
+ "wire-node-list__item",
+ !unstyled && "grid w-full grid-cols-[auto_1fr] items-start gap-x-2 gap-y-0.5 rounded-md px-2 py-1.5 text-left transition-colors hover:bg-wire-sunken",
+ !unstyled && (selected ? "bg-wire-sunken" : "bg-transparent"),
+ classNames?.item
)}
onClick={() => {
events.emit({ type: "node.click", source: "node-list", nodeId: node.id });
if (inspectOnClick) events.emit({ type: "node.inspect", source: "node-list", nodeId: node.id });
if (selectOnClick) {
- const nextSelection = { nodeIds: [node.id], edgeIds: [] };
- selectionActions.setSelection(nextSelection);
- events.emit({ type: "selection.change", source: "node-list", selection: nextSelection });
+ const nextSelection = normalizeWireSelection({ nodeIds: [node.id], edgeIds: [] });
+ if (!sameWireSelection(selection, nextSelection)) {
+ selectionActions.setSelection(nextSelection, { source: "node-list", previousSelection: selection, cause: "node" });
+ events.emit({ type: "selection.change", source: "node-list", selection: nextSelection, previousSelection: selection, cause: "node" });
+ }
}
}}
>
diff --git a/packages/wire-react/src/components/WireOptionPanel.tsx b/packages/wire-react/src/components/WireOptionPanel.tsx
index 239b5c1..536065c 100644
--- a/packages/wire-react/src/components/WireOptionPanel.tsx
+++ b/packages/wire-react/src/components/WireOptionPanel.tsx
@@ -1,40 +1,49 @@
import type { CSSProperties, ReactElement } from "react";
-import type { WireNode } from "@aigentive/wire-core";
import { useWireActions, useWireDiagram, useWireSelection, useWireValidation } from "../hooks.js";
import {
- inferOptionType,
- optionChoiceKey,
- optionChoiceLabel,
- optionChoiceValue,
- patchWireOption,
- readWireOption,
wireOptionSpecsForNode,
- type WireOptionCatalog,
- type WireOptionChoice,
- type WireOptionPrimitive,
- type WireOptionSpec
+ type WireOptionCatalog
} from "../options.js";
import { Eyebrow } from "../primitives/Eyebrow.js";
import { StatusPill } from "../primitives/StatusPill.js";
import { cx } from "./classes.js";
+import { WireOptionFieldList, type WireOptionFieldListProps } from "./optionFields.js";
export interface WireOptionPanelProps {
catalog: WireOptionCatalog;
/** Explicit node id. When omitted, the panel follows the current single-node selection. */
nodeId?: string;
title?: string;
+ readOnly?: boolean;
+ renderField?: WireOptionFieldListProps["renderField"];
+ renderSection?: WireOptionFieldListProps["renderSection"];
+ onOptionCommit?: (context: {
+ node: NonNullable
;
+ option: Parameters[1];
+ value: unknown;
+ action: Parameters[0];
+ }) => void;
+ classNames?: {
+ root?: string;
+ field?: string;
+ section?: string;
+ validation?: string;
+ };
+ unstyled?: boolean;
className?: string;
style?: CSSProperties;
}
-const CONTROL_CLASS =
- "w-full min-h-8 rounded-md border border-wire bg-wire-surface px-[9px] py-[5px] text-[12.5px] text-wire-primary outline-none transition-colors focus:border-wire-focus";
-const FIELD_LABEL_CLASS = "text-[11.5px] font-medium text-wire-secondary mb-[3px]";
-
export function WireOptionPanel({
catalog,
nodeId,
title = "Options",
+ readOnly = false,
+ renderField,
+ renderSection,
+ onOptionCommit,
+ classNames,
+ unstyled = false,
className,
style
}: WireOptionPanelProps): ReactElement {
@@ -50,7 +59,12 @@ export function WireOptionPanel({
if (!node) {
return (
{title}
@@ -64,27 +78,31 @@ export function WireOptionPanel({
return (
{title}
{specs.length === 0 ? (
No options
) : null}
- {specs.map((spec) => (
- {
- actions.dispatch({
- type: "node.patch",
- id: node.id,
- patch: patchWireOption(node, spec, value)
- });
- }}
- />
- ))}
+ {
+ actions.dispatch(action);
+ onOptionCommit?.({ node, option, value, action });
+ }}
+ />
{isValid ? (
Valid
@@ -93,90 +111,3 @@ export function WireOptionPanel({
);
}
-
-function OptionField({
- node,
- spec,
- onChange
-}: {
- node: WireNode;
- spec: WireOptionSpec;
- onChange: (value: unknown) => void;
-}): ReactElement {
- const type = inferOptionType(spec);
- const rawValue = readWireOption(node, spec) ?? spec.defaultValue;
- const label = spec.label ?? labelFromKey(spec.key);
-
- return (
-
- {label}
- {type === "textarea" ? (
-
- );
-}
-
-function valueFromChoice(value: string, choices: WireOptionChoice[]): WireOptionPrimitive | null {
- if (value === "") return null;
- const match = choices.find((choice) => optionChoiceKey(choice) === value);
- return match === undefined ? value : optionChoiceValue(match);
-}
-
-function choiceValueToInputValue(value: unknown): string {
- if (value === undefined || value === null) return "";
- return String(value);
-}
-
-function labelFromKey(key: string): string {
- return key
- .replace(/[-_]+/g, " ")
- .replace(/([a-z])([A-Z])/g, "$1 $2")
- .replace(/\b\w/g, (char) => char.toUpperCase());
-}
diff --git a/packages/wire-react/src/components/WirePalette.tsx b/packages/wire-react/src/components/WirePalette.tsx
index bf8381b..a2c9f27 100644
--- a/packages/wire-react/src/components/WirePalette.tsx
+++ b/packages/wire-react/src/components/WirePalette.tsx
@@ -37,17 +37,33 @@ const KIND_LABEL: Record = {
export interface WirePaletteProps {
kinds?: WireNode["kind"][];
+ unstyled?: boolean;
+ classNames?: {
+ root?: string;
+ item?: string;
+ };
className?: string;
style?: CSSProperties;
}
-export function WirePalette({ kinds = DEFAULT_KINDS, className, style }: WirePaletteProps): ReactElement {
+export function WirePalette({
+ kinds = DEFAULT_KINDS,
+ unstyled = false,
+ classNames,
+ className,
+ style
+}: WirePaletteProps): ReactElement {
const diagram = useWireDiagram();
const actions = useWireActions();
return (
Add node
@@ -55,7 +71,11 @@ export function WirePalette({ kinds = DEFAULT_KINDS, className, style }: WirePal
{
const id = nextNodeId(kind, diagram.nodes.map((node) => node.id));
actions.dispatch({
diff --git a/packages/wire-react/src/components/WireToolbar.tsx b/packages/wire-react/src/components/WireToolbar.tsx
index c0118e4..878bd63 100644
--- a/packages/wire-react/src/components/WireToolbar.tsx
+++ b/packages/wire-react/src/components/WireToolbar.tsx
@@ -1,7 +1,14 @@
import type { CSSProperties, ReactElement } from "react";
import { useWireHistory, useWireMode } from "../hooks.js";
+import { cx } from "./classes.js";
export interface WireToolbarProps {
+ unstyled?: boolean;
+ classNames?: {
+ root?: string;
+ group?: string;
+ button?: string;
+ };
className?: string;
style?: CSSProperties;
}
@@ -47,21 +54,24 @@ const DISABLED_STYLE: CSSProperties = {
cursor: "not-allowed"
};
-export function WireToolbar({ className, style }: WireToolbarProps): ReactElement {
+export function WireToolbar({ unstyled = false, classNames, className, style }: WireToolbarProps): ReactElement {
const history = useWireHistory();
const [mode, setMode] = useWireMode();
- const undoStyle = history.canUndo ? ICON_BUTTON_STYLE : { ...ICON_BUTTON_STYLE, ...DISABLED_STYLE };
- const redoStyle = history.canRedo ? ICON_BUTTON_STYLE : { ...ICON_BUTTON_STYLE, ...DISABLED_STYLE };
+ const undoStyle = unstyled ? undefined : history.canUndo ? ICON_BUTTON_STYLE : { ...ICON_BUTTON_STYLE, ...DISABLED_STYLE };
+ const redoStyle = unstyled ? undefined : history.canRedo ? ICON_BUTTON_STYLE : { ...ICON_BUTTON_STYLE, ...DISABLED_STYLE };
+ const buttonStyle = unstyled ? undefined : BUTTON_STYLE;
return (
-
-
+
+
+
↺
-
-
+
+
↻
-
- setMode(mode === "edit" ? "view" : "edit")} style={BUTTON_STYLE}>
+
+
+
setMode(mode === "edit" ? "view" : "edit", { source: "workspace", previousMode: mode, cause: "toolbar" })} style={buttonStyle}>
{mode === "edit" ? "View" : "Edit"}
diff --git a/packages/wire-react/src/components/WireValidationPanel.tsx b/packages/wire-react/src/components/WireValidationPanel.tsx
index 7b3038c..47474d7 100644
--- a/packages/wire-react/src/components/WireValidationPanel.tsx
+++ b/packages/wire-react/src/components/WireValidationPanel.tsx
@@ -5,6 +5,14 @@ import { StatusPill, type StatusPillKind } from "../primitives/StatusPill.js";
import { cx } from "./classes.js";
export interface WireValidationPanelProps {
+ unstyled?: boolean;
+ classNames?: {
+ root?: string;
+ header?: string;
+ list?: string;
+ issue?: string;
+ empty?: string;
+ };
className?: string;
style?: CSSProperties;
}
@@ -16,7 +24,12 @@ const DOT_CLASS: Record = {
invalid: "bg-wire-status-invalid"
};
-export function WireValidationPanel({ className, style }: WireValidationPanelProps): ReactElement {
+export function WireValidationPanel({
+ unstyled = false,
+ classNames,
+ className,
+ style
+}: WireValidationPanelProps): ReactElement {
const validation = useWireValidation();
const hasErrors = validation.issues.some((issue) => issue.severity === "error");
@@ -27,24 +40,46 @@ export function WireValidationPanel({ className, style }: WireValidationPanelPro
return (
-
+
- {validation.issues.length === 0 ? null : (
-
+ {validation.issues.length === 0 ? (
+
+ No issues
+
+ ) : (
+
{validation.issues.map((issue, index) => {
const issueStatus: StatusPillKind = issue.severity === "error" ? "invalid" : "warn";
return (
{
diagram: WireDiagram;
+ selection?: WireSelection;
+ defaultSelection?: WireSelection;
+ onSelectionChange?: (selection: WireSelection, event: Extract) => void;
+ viewport?: WireViewport;
+ defaultViewport?: WireViewport;
+ onViewportChange?: (viewport: WireViewport, event: {
+ source: WireEventSource;
+ viewport: WireViewport;
+ previousViewport?: WireViewport;
+ cause?: "pan" | "zoom" | "fit-view" | "keyboard" | "api";
+ intent?: "fit-view" | "fit-selection";
+ }) => void;
+ onEvent?: (event: WireEvent) => void;
}
-export function WireViewer({ diagram, ...canvasProps }: WireViewerProps): ReactElement {
+export function WireViewer({
+ diagram,
+ selection,
+ defaultSelection,
+ onSelectionChange,
+ viewport,
+ defaultViewport,
+ onViewportChange,
+ onEvent,
+ ...canvasProps
+}: WireViewerProps): ReactElement {
return (
-
+
);
diff --git a/packages/wire-react/src/components/WireWorkspace.tsx b/packages/wire-react/src/components/WireWorkspace.tsx
index a1f5514..b2ded36 100644
--- a/packages/wire-react/src/components/WireWorkspace.tsx
+++ b/packages/wire-react/src/components/WireWorkspace.tsx
@@ -1,15 +1,19 @@
-import { useCallback, useState, type CSSProperties, type ReactElement, type ReactNode } from "react";
+import { useCallback, useEffect, useRef, useState, type CSSProperties, type ReactElement, type ReactNode } from "react";
import type { ApplyWireActionResult, WireAction, WireDiagram } from "@aigentive/wire-core";
import { WireCanvas, type WireCanvasProps } from "../canvas/WireCanvas.js";
import type { WireNodeRenderer } from "../canvas/nodeTypes.js";
import { WireProvider } from "../provider/WireProvider.js";
-import type { WireChangeEvent, WireEvent } from "../provider/types.js";
+import type { WireChangeEvent, WireEvent, WireEventSource, WireMode, WireSelection, WireViewport } from "../provider/types.js";
import type { WireOptionCatalog } from "../options.js";
-import { cx } from "./classes.js";
+import { cx, themeClass, type WireColorMode } from "./classes.js";
import { WireGroupFrame, WireNodeCardView } from "./WireNodeCardView.js";
+import { WireInspector, type WireInspectorProps } from "./WireInspector.js";
import { WireNodeList } from "./WireNodeList.js";
-import { WireOptionPanel } from "./WireOptionPanel.js";
-import { WireValidationPanel } from "./WireValidationPanel.js";
+import {
+ WIRE_INSPECTOR_FOCUS_REQUEST_EVENT,
+ type WireInspectorFocusRequestDetail,
+ type WireWorkspaceFocusItem
+} from "./workspaceFocusEvents.js";
export interface WireWorkspaceProps {
diagram?: WireDiagram;
@@ -19,10 +23,55 @@ export interface WireWorkspaceProps {
onEvent?: (event: WireEvent) => void;
validateOnChange?: boolean;
history?: boolean;
+ selection?: WireSelection;
+ defaultSelection?: WireSelection;
+ onSelectionChange?: (selection: WireSelection, event: Extract) => void;
+ viewport?: WireViewport;
+ defaultViewport?: WireViewport;
+ onViewportChange?: (viewport: WireViewport, event: {
+ source: WireEventSource;
+ viewport: WireViewport;
+ previousViewport?: WireViewport;
+ cause?: "pan" | "zoom" | "fit-view" | "keyboard" | "api";
+ intent?: "fit-view" | "fit-selection";
+ }) => void;
+ mode?: WireMode;
+ defaultMode?: WireMode;
+ onModeChange?: (mode: WireMode, event: {
+ source: WireEventSource;
+ mode: WireMode;
+ previousMode?: WireMode;
+ cause?: "toolbar" | "keyboard" | "api";
+ }) => void;
+ dirty?: boolean;
+ defaultDirty?: boolean;
+ onDirtyChange?: (dirty: boolean, event: {
+ source: WireEventSource;
+ dirty: boolean;
+ previousDirty?: boolean;
+ cause?: "edit" | "undo" | "redo" | "reset" | "api";
+ }) => void;
optionCatalog?: WireOptionCatalog;
+ readOnly?: boolean;
+ colorMode?: WireColorMode;
+ unstyled?: boolean;
+ classNames?: {
+ root?: string;
+ header?: string;
+ sidebar?: string;
+ canvasRegion?: string;
+ canvas?: string;
+ inspector?: string;
+ nodeList?: string;
+ optionPanel?: string;
+ validationPanel?: string;
+ };
inspectNodeId?: string;
defaultInspectNodeId?: string;
onInspectNodeChange?: (nodeId: string | undefined, event: WireEvent) => void;
+ inspectEdgeId?: string;
+ defaultInspectEdgeId?: string;
+ onInspectEdgeChange?: (edgeId: string | undefined, event: WireEvent) => void;
clearInspectOnPaneClick?: boolean;
title?: ReactNode;
subtitle?: ReactNode;
@@ -34,7 +83,8 @@ export interface WireWorkspaceProps {
layout?: "fixed" | "embedded";
renderNodeCard?: WireNodeRenderer;
renderGroup?: WireNodeRenderer;
- canvasProps?: Omit;
+ canvasProps?: Omit;
+ inspectorProps?: Omit;
className?: string;
sidebarClassName?: string;
canvasClassName?: string;
@@ -50,10 +100,29 @@ export function WireWorkspace({
onEvent,
validateOnChange,
history,
+ selection,
+ defaultSelection,
+ onSelectionChange,
+ viewport,
+ defaultViewport,
+ onViewportChange,
+ mode,
+ defaultMode,
+ onModeChange,
+ dirty,
+ defaultDirty,
+ onDirtyChange,
optionCatalog,
+ readOnly = false,
+ colorMode,
+ unstyled = false,
+ classNames,
inspectNodeId,
defaultInspectNodeId,
onInspectNodeChange,
+ inspectEdgeId,
+ defaultInspectEdgeId,
+ onInspectEdgeChange,
clearInspectOnPaneClick = false,
title = "Wire",
subtitle,
@@ -66,6 +135,7 @@ export function WireWorkspace({
renderNodeCard = WireNodeCardView,
renderGroup = WireGroupFrame,
canvasProps,
+ inspectorProps,
className,
sidebarClassName,
canvasClassName,
@@ -73,28 +143,73 @@ export function WireWorkspace({
style
}: WireWorkspaceProps): ReactElement {
const [internalInspectNodeId, setInternalInspectNodeId] = useState(defaultInspectNodeId);
+ const [internalInspectEdgeId, setInternalInspectEdgeId] = useState(defaultInspectEdgeId);
+ const mainRef = useRef(null);
+ const inspectorRef = useRef(null);
+ const lastCanvasFocusItemRef = useRef(null);
const activeInspectNodeId = inspectNodeId ?? internalInspectNodeId;
+ const activeInspectEdgeId = activeInspectNodeId ? undefined : inspectEdgeId ?? internalInspectEdgeId;
- const setInspectNode = useCallback(
+ const setInspectTarget = useCallback(
(nodeId: string | undefined, event: WireEvent) => {
+ const edgeId = undefined;
if (inspectNodeId === undefined) setInternalInspectNodeId(nodeId);
+ if (inspectEdgeId === undefined) setInternalInspectEdgeId(edgeId);
onInspectNodeChange?.(nodeId, event);
+ onInspectEdgeChange?.(edgeId, event);
},
- [inspectNodeId, onInspectNodeChange]
+ [inspectEdgeId, inspectNodeId, onInspectEdgeChange, onInspectNodeChange]
+ );
+
+ const setInspectEdge = useCallback(
+ (edgeId: string | undefined, event: WireEvent) => {
+ const nodeId = undefined;
+ if (inspectNodeId === undefined) setInternalInspectNodeId(nodeId);
+ if (inspectEdgeId === undefined) setInternalInspectEdgeId(edgeId);
+ onInspectNodeChange?.(nodeId, event);
+ onInspectEdgeChange?.(edgeId, event);
+ },
+ [inspectEdgeId, inspectNodeId, onInspectEdgeChange, onInspectNodeChange]
);
const handleEvent = useCallback(
(event: WireEvent) => {
if (event.type === "node.inspect") {
- setInspectNode(event.nodeId, event);
+ setInspectTarget(event.nodeId, event);
+ } else if (event.type === "edge.click" && event.intent === "inspect") {
+ setInspectEdge(event.edgeId, event);
} else if (event.type === "pane.click" && clearInspectOnPaneClick) {
- setInspectNode(undefined, event);
+ setInspectTarget(undefined, event);
}
onEvent?.(event);
},
- [clearInspectOnPaneClick, onEvent, setInspectNode]
+ [clearInspectOnPaneClick, onEvent, setInspectEdge, setInspectTarget]
);
+ const inspectorTabs: WireInspectorProps["tabs"] = [
+ ...(showOptions ? (optionCatalog ? ["configure" as const, "style" as const] : ["style" as const]) : []),
+ ...(showOptions ? ["edge" as const, "json" as const] : []),
+ ...(showValidation ? ["validation" as const] : [])
+ ];
+
+ useEffect(() => {
+ if (activeInspectNodeId) lastCanvasFocusItemRef.current = { type: "node", id: activeInspectNodeId };
+ else if (activeInspectEdgeId) lastCanvasFocusItemRef.current = { type: "edge", id: activeInspectEdgeId };
+ }, [activeInspectEdgeId, activeInspectNodeId]);
+
+ useEffect(() => {
+ const element = mainRef.current;
+ if (!element) return undefined;
+ const handleInspectorFocusRequest = (event: Event) => {
+ const detail = (event as CustomEvent).detail;
+ lastCanvasFocusItemRef.current = detail?.item ?? lastCanvasFocusItemRef.current;
+ event.preventDefault();
+ focusWorkspaceInspector(inspectorRef.current);
+ };
+ element.addEventListener(WIRE_INSPECTOR_FOCUS_REQUEST_EVENT, handleInspectorFocusRequest);
+ return () => element.removeEventListener(WIRE_INSPECTOR_FOCUS_REQUEST_EVENT, handleInspectorFocusRequest);
+ }, []);
+
return (
-
-
- {title}
- {subtitle ? {subtitle}
: null}
+
+
+ {title}
+ {subtitle ? {subtitle}
: null}
- {sidebar ?? (showNodeList ? : null)}
+ {sidebar ?? (showNodeList ? : null)}
-
+
-
- {inspector ?? (
- <>
- {showOptions && optionCatalog ? : null}
- {showValidation ? : null}
- >
- )}
+ {
+ if (event.key !== "Enter" || !event.altKey || !event.shiftKey) return;
+ event.preventDefault();
+ const item = activeInspectNodeId
+ ? { type: "node" as const, id: activeInspectNodeId }
+ : activeInspectEdgeId
+ ? { type: "edge" as const, id: activeInspectEdgeId }
+ : lastCanvasFocusItemRef.current;
+ focusWorkspaceCanvasItem(mainRef.current, item);
+ }}
+ >
+ {inspector ?? (inspectorTabs.length > 0 ? (
+
+ ) : null)}
);
}
+
+function focusWorkspaceInspector(inspector: HTMLElement | null): void {
+ requestFrame(() => {
+ const target = inspector?.querySelector(
+ "[role='tab'][aria-selected='true'], input, textarea, select, button, [tabindex]:not([tabindex='-1'])"
+ );
+ (target ?? inspector)?.focus();
+ });
+}
+
+function focusWorkspaceCanvasItem(root: HTMLElement | null, item: WireWorkspaceFocusItem | null): void {
+ requestFrame(() => {
+ const canvas = root?.querySelector("[data-wire-canvas]");
+ if (!item) {
+ canvas?.focus();
+ return;
+ }
+ const candidates = root?.querySelectorAll(
+ item.type === "node" ? "[data-wire-node-id]" : "[data-wire-edge-id]"
+ );
+ const target = [...(candidates ?? [])].find((candidate) =>
+ item.type === "node"
+ ? candidate.dataset.wireNodeId === item.id
+ : candidate.dataset.wireEdgeId === item.id
+ );
+ (target ?? canvas)?.focus();
+ });
+}
+
+function requestFrame(callback: () => void): void {
+ if (typeof requestAnimationFrame === "undefined") {
+ setTimeout(callback, 0);
+ return;
+ }
+ requestAnimationFrame(callback);
+}
diff --git a/packages/wire-react/src/components/classes.ts b/packages/wire-react/src/components/classes.ts
index 46edd49..e1a1a5d 100644
--- a/packages/wire-react/src/components/classes.ts
+++ b/packages/wire-react/src/components/classes.ts
@@ -1,3 +1,9 @@
export function cx(...classes: Array): string {
return classes.filter(Boolean).join(" ");
}
+
+export type WireColorMode = "light" | "dark" | "system";
+
+export function themeClass(colorMode: WireColorMode | undefined): string | undefined {
+ return colorMode ? `wire-theme-${colorMode}` : undefined;
+}
diff --git a/packages/wire-react/src/components/inspectionState.ts b/packages/wire-react/src/components/inspectionState.ts
new file mode 100644
index 0000000..6eeb9a3
--- /dev/null
+++ b/packages/wire-react/src/components/inspectionState.ts
@@ -0,0 +1,73 @@
+import { normalize, type ResolvedEdge, type WireDiagram, type WireEdge, type WireNode } from "@aigentive/wire-core";
+import type { WireSelection } from "../provider/types.js";
+
+export type WireInspectedTarget =
+ | { type: "node"; node: WireNode }
+ | { type: "edge"; edge: ResolvedEdge; explicitEdge?: WireEdge; editable: boolean }
+ | { type: "mixed" }
+ | { type: "empty" };
+
+export interface ResolveWireInspectionTargetOptions {
+ nodeId?: string;
+ edgeId?: string;
+ selection?: WireSelection;
+}
+
+export function resolveWireInspectionTarget(
+ diagram: WireDiagram,
+ { nodeId, edgeId, selection }: ResolveWireInspectionTargetOptions
+): WireInspectedTarget {
+ if (nodeId) {
+ const node = diagram.nodes.find((candidate) => candidate.id === nodeId);
+ return node ? { type: "node", node } : { type: "empty" };
+ }
+
+ if (edgeId) {
+ return resolveEdgeTarget(diagram, edgeId);
+ }
+
+ const selectedNodeIds = selection?.nodeIds ?? [];
+ const selectedEdgeIds = selection?.edgeIds ?? [];
+ if (selectedNodeIds.length === 1 && selectedEdgeIds.length === 0) {
+ const node = diagram.nodes.find((candidate) => candidate.id === selectedNodeIds[0]);
+ return node ? { type: "node", node } : { type: "empty" };
+ }
+ if (selectedEdgeIds.length === 1 && selectedNodeIds.length === 0) {
+ return resolveEdgeTarget(diagram, selectedEdgeIds[0]!);
+ }
+ if (selectedNodeIds.length > 0 || selectedEdgeIds.length > 0) return { type: "mixed" };
+ return { type: "empty" };
+}
+
+function resolveEdgeTarget(diagram: WireDiagram, edgeId: string): WireInspectedTarget {
+ const explicitEdge = diagram.edges.find((edge) => edge.id === edgeId);
+ const resolved = normalize(diagram).resolvedEdges.find((edge) => edge.id === edgeId);
+ if (!resolved && !explicitEdge) return { type: "empty" };
+ if (resolved) {
+ return {
+ type: "edge",
+ edge: resolved,
+ explicitEdge,
+ editable: Boolean(explicitEdge?.id) && !resolved.synthesized
+ };
+ }
+ return {
+ type: "edge",
+ edge: {
+ id: edgeId,
+ from: explicitEdge!.from,
+ to: explicitEdge!.to,
+ branch: explicitEdge!.branch,
+ label: explicitEdge!.label,
+ tone: explicitEdge!.tone,
+ synthesized: false,
+ fromHandle: explicitEdge!.fromHandle,
+ toHandle: explicitEdge!.toHandle,
+ style: explicitEdge!.style,
+ labelStyle: explicitEdge!.labelStyle,
+ routing: explicitEdge!.routing
+ },
+ explicitEdge,
+ editable: Boolean(explicitEdge!.id)
+ };
+}
diff --git a/packages/wire-react/src/components/interaction.test.tsx b/packages/wire-react/src/components/interaction.test.tsx
index 7e31082..8351cd9 100644
--- a/packages/wire-react/src/components/interaction.test.tsx
+++ b/packages/wire-react/src/components/interaction.test.tsx
@@ -4,6 +4,7 @@ import { act, type ReactElement } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, describe, expect, it, vi } from "vitest";
import { applyWireAction, emptyDiagram, validate, type ApplyWireActionResult, type ValidationResult, type WireAction, type WireDiagram } from "@aigentive/wire-core";
+import { WireCanvas } from "../canvas/WireCanvas.js";
import { WireContext, DEFAULT_VIEWPORT, EMPTY_SELECTION, type WireContextValue } from "../provider/context.js";
import type { WireEvent, WireSelection } from "../provider/types.js";
import { WireInspector } from "./WireInspector.js";
@@ -11,6 +12,7 @@ import { WireNodeList } from "./WireNodeList.js";
import { WireOptionPanel } from "./WireOptionPanel.js";
import { WirePalette } from "./WirePalette.js";
import { WireToolbar } from "./WireToolbar.js";
+import { WireWorkspace } from "./WireWorkspace.js";
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
@@ -79,6 +81,279 @@ describe("wire component interactions", () => {
expect(events[0]).toMatchObject({ source: "node-list", nodeId: "start" });
});
+ it("emits edge inspection intent from canvas edge clicks", () => {
+ const events: WireEvent[] = [];
+ const selections: WireSelection[] = [];
+ const { container } = renderWithContext(
+ ,
+ contextFor(edgeDiagram(), {
+ setSelection: (selection) => selections.push(selection),
+ emit: (event) => events.push(event)
+ })
+ );
+
+ const hitPath = container.querySelector("[data-wire-edge-id='approval'] path[stroke='transparent']");
+ if (!hitPath) throw new Error("Edge hit path not found.");
+ click(hitPath);
+
+ expect(events).toContainEqual({ type: "edge.click", source: "canvas", edgeId: "approval", intent: "inspect" });
+ expect(selections).toEqual([{ nodeIds: [], edgeIds: ["approval"] }]);
+ });
+
+ it("handles canvas keyboard selection, deletion, and read-only movement from the root", () => {
+ const events: WireEvent[] = [];
+ const selections: WireSelection[] = [];
+ const actions: WireAction[] = [];
+ const { container } = renderWithContext(
+ ,
+ contextFor(edgeDiagram(), {
+ selection: { nodeIds: [], edgeIds: ["approval"] },
+ setSelection: (selection) => selections.push(selection),
+ emit: (event) => events.push(event),
+ dispatch: (action) => {
+ actions.push(action);
+ return applyResult(edgeDiagram(), action);
+ }
+ })
+ );
+
+ const root = container.querySelector("[data-wire-canvas]")!;
+ const node = container.querySelector("[data-wire-node-id='a']")!;
+ keyDown(root, "Delete");
+ expect(actions).toContainEqual({ type: "edge.remove", id: "approval" });
+
+ focus(node);
+ keyDown(root, "Enter");
+ expect(events).toContainEqual({ type: "node.click", source: "canvas", nodeId: "a", input: "keyboard" });
+ expect(events).toContainEqual({ type: "node.inspect", source: "canvas", nodeId: "a", input: "keyboard" });
+ expect(selections).toContainEqual({ nodeIds: ["a"], edgeIds: [] });
+
+ const readOnlyActions: WireAction[] = [];
+ const readOnly = renderWithContext(
+ ,
+ contextFor(edgeDiagram(), {
+ selection: { nodeIds: ["a"], edgeIds: [] },
+ dispatch: (action) => {
+ readOnlyActions.push(action);
+ return applyResult(edgeDiagram(), action);
+ }
+ })
+ );
+ focus(readOnly.container.querySelector("[data-wire-node-id='a']")!);
+ keyDown(readOnly.container.querySelector("[data-wire-canvas]")!, "ArrowRight");
+ expect(readOnlyActions).toHaveLength(0);
+ });
+
+ it("searches canvas nodes with combobox semantics and focuses the chosen result", async () => {
+ const actions: WireAction[] = [];
+ const { container } = renderWithContext(
+ ,
+ contextFor(edgeDiagram(), {
+ dispatch: (action) => {
+ actions.push(action);
+ return applyResult(edgeDiagram(), action);
+ }
+ })
+ );
+
+ const root = container.querySelector("[data-wire-canvas]")!;
+ focus(container.querySelector("[data-wire-node-id='a']")!);
+ keyDown(root, "/");
+
+ const search = container.querySelector("input[role='combobox']")!;
+ expect(search.getAttribute("aria-expanded")).toBe("true");
+ expect(container.querySelector("[role='listbox']")).toBeTruthy();
+
+ input(search, "B");
+ expect(container.textContent).toContain("B action node");
+ keyDown(search, "Enter");
+ await flush();
+
+ expect(document.activeElement).toBe(container.querySelector("[data-wire-node-id='b']"));
+ expect(actions).toHaveLength(0);
+ });
+
+ it("keeps large search result popups bounded while reporting the full result count", () => {
+ const { container } = renderWithContext(
+ ,
+ contextFor(manyNodeDiagram(80))
+ );
+
+ const root = container.querySelector("[data-wire-canvas]")!;
+ focus(container.querySelector("[data-wire-node-id='node-0']")!);
+ keyDown(root, "/");
+
+ const options = container.querySelectorAll("[role='option']");
+ expect(options).toHaveLength(60);
+ expect(options[0]?.getAttribute("aria-setsize")).toBe("80");
+ expect(container.textContent).toContain("80 results");
+ });
+
+ it("creates and rejects keyboard connections through the target picker", () => {
+ const actions: WireAction[] = [];
+ const { container } = renderWithContext(
+ ,
+ contextFor(edgeDiagram(), {
+ dispatch: (action) => {
+ actions.push(action);
+ return applyResult(edgeDiagram(), action);
+ }
+ })
+ );
+
+ const root = container.querySelector("[data-wire-canvas]")!;
+ focus(container.querySelector("[data-wire-node-id='a']")!);
+ keyDown(root, "c");
+
+ const picker = container.querySelector("input[role='combobox']")!;
+ expect(picker.getAttribute("aria-controls")).toBeTruthy();
+ expect(container.textContent).toContain("Choose connection target");
+ expect(container.textContent).toContain("B");
+ keyDown(picker, "Enter");
+
+ expect(actions).toContainEqual({
+ type: "edge.connect",
+ edge: { from: "a", to: "b", fromHandle: "right", toHandle: "left" }
+ });
+
+ const rejectedActions: WireAction[] = [];
+ const rejected = renderWithContext(
+ "Blocked by policy."}
+ />,
+ contextFor(edgeDiagram(), {
+ dispatch: (action) => {
+ rejectedActions.push(action);
+ return applyResult(edgeDiagram(), action);
+ }
+ })
+ );
+ const rejectedRoot = rejected.container.querySelector("[data-wire-canvas]")!;
+ focus(rejected.container.querySelector("[data-wire-node-id='a']")!);
+ keyDown(rejectedRoot, "c");
+ const rejectedPicker = rejected.container.querySelector("input[role='combobox']")!;
+ keyDown(rejectedPicker, "Enter");
+
+ expect(rejectedActions).toHaveLength(0);
+ expect(rejectedPicker.getAttribute("aria-invalid")).toBe("true");
+ expect(rejected.container.textContent).toContain("Blocked by policy.");
+ });
+
+ it("keeps large connection target popups bounded while reporting all targets", () => {
+ const { container } = renderWithContext(
+ ,
+ contextFor(manyNodeDiagram(80))
+ );
+
+ const root = container.querySelector("[data-wire-canvas]")!;
+ focus(container.querySelector("[data-wire-node-id='node-0']")!);
+ keyDown(root, "c");
+
+ const options = container.querySelectorAll("[role='option']");
+ expect(options).toHaveLength(60);
+ expect(options[0]?.getAttribute("aria-setsize")).toBe("79");
+ expect(container.textContent).toContain("79 targets");
+ });
+
+ it("moves focus between owned workspace canvas and inspector", async () => {
+ const { container } = render(
+
+ );
+
+ const root = container.querySelector("[data-wire-canvas]")!;
+ const node = container.querySelector("[data-wire-node-id='a']")!;
+ focus(node);
+ keyDown(root, "Enter", { shiftKey: true });
+ await flush();
+
+ expect(document.activeElement?.getAttribute("role")).toBe("tab");
+
+ keyDown(document.activeElement as HTMLElement, "Enter", { altKey: true, shiftKey: true });
+ await flush();
+
+ expect(document.activeElement).toBe(node);
+ });
+
+ it("exposes skip-to-inspector as the next canvas tab stop", async () => {
+ const { container } = render(
+
+ );
+
+ const root = container.querySelector("[data-wire-canvas]")!;
+ const skip = buttonByLabel(container, "Skip to inspector and controls");
+ let focusRequests = 0;
+ root.closest("main")?.addEventListener("wire:inspector-focus-request", () => {
+ focusRequests += 1;
+ });
+ expect(root.querySelector("button")).toBe(skip);
+
+ focus(root);
+ click(skip);
+ await flush();
+
+ expect(focusRequests).toBe(1);
+ expect([
+ container.querySelector("[role='tab'][aria-selected='true']"),
+ container.querySelector(".wire-workspace__inspector")
+ ]).toContain(document.activeElement);
+ });
+
+ it("fits selected items with shared default padding, explicit padding overrides, and focus recovery", async () => {
+ const viewportEvents: Array<{ viewport: WireContextValue["viewport"]; event?: Parameters[1] }> = [];
+ const diagram = edgeDiagram();
+ const selection: WireSelection = { nodeIds: ["a", "b"], edgeIds: ["approval"] };
+ const { container } = renderWithContext(
+ ,
+ contextFor(diagram, {
+ selection,
+ setViewport: (viewport, event) => viewportEvents.push({ viewport, event })
+ })
+ );
+
+ const root = container.querySelector("[data-wire-canvas]")!;
+ setCanvasRect(root, 800, 420);
+ const selectedNode = container.querySelector("[data-wire-node-id='a']")!;
+ focus(selectedNode);
+ click(buttonByLabel(container, "Fit view"));
+ await flush();
+ click(buttonByLabel(container, "Fit selection"));
+ await flush();
+
+ expect(viewportEvents).toHaveLength(2);
+ expect(viewportEvents[0]?.event).toMatchObject({ source: "canvas", cause: "fit-view", intent: "fit-view" });
+ expect(viewportEvents[1]?.event).toMatchObject({ source: "canvas", cause: "fit-view", intent: "fit-selection" });
+ expect(viewportEvents[1]?.viewport).toEqual(viewportEvents[0]?.viewport);
+ expect(document.activeElement).toBe(selectedNode);
+ expect(container.textContent).toContain("Fitted 3 selected items.");
+
+ const explicitEvents: Array<{ viewport: WireContextValue["viewport"]; event?: Parameters[1] }> = [];
+ const explicit = renderWithContext(
+ ,
+ contextFor(diagram, {
+ selection,
+ setViewport: (viewport, event) => explicitEvents.push({ viewport, event })
+ })
+ );
+ setCanvasRect(explicit.container.querySelector("[data-wire-canvas]")!, 800, 420);
+ click(buttonByLabel(explicit.container, "Fit selection"));
+ await flush();
+
+ expect(explicitEvents[0]?.event).toMatchObject({ intent: "fit-selection" });
+ expect(explicitEvents[0]?.viewport.zoom).not.toBe(viewportEvents[1]?.viewport.zoom);
+ });
+
it("dispatches option patches for text, textarea, number, boolean, and select fields", () => {
const actions: WireAction[] = [];
const diagram = optionDiagram();
@@ -123,6 +398,63 @@ describe("wire component interactions", () => {
expect(actions[4]).toMatchObject({ type: "node.patch", id: "task", patch: { data: { options: { mode: "careful" } } } });
});
+ it("supports option sections, predicates, validation, and commit modes", () => {
+ const actions: WireAction[] = [];
+ const commits: string[] = [];
+ const diagram = optionDiagram();
+ const { container } = renderWithContext(
+ true }
+ ]
+ }}
+ onOptionCommit={({ option }) => commits.push(option.key)}
+ />,
+ contextFor(diagram, {
+ selection: { nodeIds: ["task"], edgeIds: [] },
+ dispatch: (action) => {
+ actions.push(action);
+ return applyResult(diagram, action);
+ }
+ })
+ );
+
+ expect(container.textContent).not.toContain("Hidden");
+ expect(container.textContent).toContain("Timing");
+
+ const blurred = inputByPlaceholder(container, "Blur value");
+ input(blurred, "Draft");
+ input(blurred, "");
+ expect(actions).toHaveLength(0);
+ blur(blurred);
+ expect(actions).toHaveLength(0);
+ expect(container.textContent).toContain("Blurred is required.");
+
+ input(blurred, "Ready");
+ blur(blurred);
+ expect(actions).toHaveLength(1);
+ expect(actions[0]).toMatchObject({ type: "node.patch", id: "task", patch: { data: { options: expect.objectContaining({ blurred: "Ready" }) } } });
+ expect(commits).toEqual(["blurred"]);
+
+ const submitted = inputByPlaceholder(container, "Submit value");
+ input(submitted, "Queued");
+ expect(actions).toHaveLength(1);
+ click(buttonByText(container, "Apply"));
+ expect(actions).toHaveLength(2);
+ expect(actions[1]).toMatchObject({ type: "node.patch", id: "task", patch: { data: { options: expect.objectContaining({ submitted: "Queued" }) } } });
+ expect(commits).toEqual(["blurred", "submitted"]);
+
+ input(inputByPlaceholder(container, "Locked value"), "Ignored");
+ input(inputByPlaceholder(container, "Disabled value"), "Ignored");
+ expect(actions).toHaveLength(2);
+ });
+
it("dispatches inspector title, description, preset, custom style, clear, and reset patches", () => {
const actions: WireAction[] = [];
const diagram: WireDiagram = {
@@ -180,6 +512,91 @@ describe("wire component interactions", () => {
expect(actions[7]).toMatchObject({ patch: { tone: null, style: null } });
});
+ it("renders inspector configure fields for explicit node ids without selection", () => {
+ const actions: WireAction[] = [];
+ const commits: string[] = [];
+ const diagram = optionDiagram();
+ const { container } = renderWithContext(
+ commits.push(option.key)}
+ />,
+ contextFor(diagram, {
+ dispatch: (action) => {
+ actions.push(action);
+ return applyResult(diagram, action);
+ }
+ })
+ );
+
+ expect(buttonByText(container, "Configure").getAttribute("aria-selected")).toBe("true");
+ input(container.querySelector("input:not([type])")!, "Grace");
+
+ expect(actions).toHaveLength(1);
+ expect(actions[0]).toMatchObject({ type: "node.patch", id: "task", patch: { data: { options: expect.objectContaining({ owner: "Grace" }) } } });
+ expect(commits).toEqual(["owner"]);
+ });
+
+ it("renders inspector JSON and honors node-over-edge precedence", () => {
+ const diagram = edgeDiagram();
+ const { container } = renderWithContext(
+ ,
+ contextFor(diagram)
+ );
+
+ expect(container.textContent).toContain("Style");
+ expect(container.textContent).not.toContain("Edge");
+ click(buttonByText(container, "JSON"));
+ expect(container.textContent).toContain("\"id\": \"a\"");
+ });
+
+ it("edits explicit edge fields and renders stale edge ids as empty state", () => {
+ const actions: WireAction[] = [];
+ const diagram = edgeDiagram();
+ const { container } = renderWithContext(
+ ,
+ contextFor(diagram, {
+ dispatch: (action) => {
+ actions.push(action);
+ return applyResult(diagram, action);
+ }
+ })
+ );
+
+ expect(buttonByText(container, "Edge").getAttribute("aria-selected")).toBe("true");
+ input(container.querySelector("input:not([type])")!, "new label");
+ change(container.querySelector("select")!, "success");
+ change([...container.querySelectorAll("select")].at(1)!, "straight");
+
+ expect(actions).toEqual([
+ expect.objectContaining({ type: "edge.patch", id: "approval", patch: { label: "new label" } }),
+ expect.objectContaining({ type: "edge.patch", id: "approval", patch: { tone: "success" } }),
+ expect.objectContaining({ type: "edge.patch", id: "approval", patch: { routing: "straight" } })
+ ]);
+
+ const stale = renderWithContext( , contextFor(diagram));
+ expect(stale.container.textContent).toContain("No node selected");
+ });
+
+ it("keeps inspector read-only mode non-mutating", () => {
+ const actions: WireAction[] = [];
+ const diagram = optionDiagram();
+ const { container } = renderWithContext(
+ ,
+ contextFor(diagram, {
+ dispatch: (action) => {
+ actions.push(action);
+ return applyResult(diagram, action);
+ }
+ })
+ );
+
+ input(container.querySelector("input:not([type])")!, "Blocked");
+ change(container.querySelector("select")!, "success");
+ expect(actions).toHaveLength(0);
+ });
+
it("wires toolbar mode, history, and layout actions", () => {
const undo = vi.fn();
const redo = vi.fn();
@@ -202,17 +619,21 @@ describe("wire component interactions", () => {
expect(undo).toHaveBeenCalledOnce();
expect(redo).toHaveBeenCalledOnce();
- expect(setMode).toHaveBeenCalledWith("view");
+ expect(setMode).toHaveBeenCalledWith("view", { source: "workspace", previousMode: "edit", cause: "toolbar" });
});
});
function renderWithContext(element: ReactElement, value: WireContextValue): { container: HTMLDivElement } {
+ return render({element} );
+}
+
+function render(element: ReactElement): { container: HTMLDivElement } {
const container = document.createElement("div");
document.body.append(container);
const root = createRoot(container);
mounted.push(root);
act(() => {
- root.render({element} );
+ root.render(element);
});
return { container };
}
@@ -231,6 +652,32 @@ function optionDiagram(): WireDiagram {
};
}
+function edgeDiagram(): WireDiagram {
+ return {
+ ...emptyDiagram({ id: "edges", title: "Edges" }),
+ nodes: [
+ { id: "a", kind: "trigger", title: "A" },
+ { id: "b", kind: "action", title: "B" }
+ ],
+ edges: [{ id: "approval", from: "a", to: "b", label: "old" }]
+ };
+}
+
+function manyNodeDiagram(count: number): WireDiagram {
+ return {
+ ...emptyDiagram({ id: "many-nodes", title: "Many nodes" }),
+ nodes: Array.from({ length: count }, (_, index) => ({
+ id: `node-${index}`,
+ kind: index === 0 ? "trigger" : "action",
+ title: `Node ${index}`,
+ position: {
+ x: (index % 10) * 260,
+ y: Math.floor(index / 10) * 140
+ }
+ }))
+ };
+}
+
function contextFor(
diagram: WireDiagram,
overrides: {
@@ -243,7 +690,8 @@ function contextFor(
canRedo?: boolean;
undo?: () => ApplyWireActionResult | undefined;
redo?: () => ApplyWireActionResult | undefined;
- setMode?: (mode: "view" | "edit" | "connect" | "comment") => void;
+ setMode?: (mode: "view" | "edit" | "connect" | "comment", event?: Parameters[1]) => void;
+ setViewport?: (viewport: WireContextValue["viewport"], event?: Parameters[1]) => void;
} = {}
): WireContextValue {
const validation = overrides.validation ?? validate(diagram);
@@ -271,7 +719,7 @@ function contextFor(
clearSelection: () => undefined
},
viewportActions: {
- setViewport: () => undefined
+ setViewport: overrides.setViewport ?? (() => undefined)
},
eventActions: {
emit: overrides.emit ?? (() => undefined)
@@ -282,7 +730,8 @@ function contextFor(
undo: overrides.undo ?? (() => undefined),
redo: overrides.redo ?? (() => undefined)
},
- setMode: overrides.setMode ?? (() => undefined)
+ setMode: overrides.setMode ?? (() => undefined),
+ markClean: () => undefined
};
}
@@ -309,6 +758,33 @@ function click(element: HTMLElement): void {
});
}
+function focus(element: HTMLElement): void {
+ act(() => {
+ element.focus();
+ element.dispatchEvent(new FocusEvent("focusin", { bubbles: true }));
+ });
+}
+
+function keyDown(element: HTMLElement, key: string, init: KeyboardEventInit = {}): void {
+ act(() => {
+ element.dispatchEvent(new KeyboardEvent("keydown", { key, bubbles: true, ...init }));
+ });
+}
+
+function setCanvasRect(element: HTMLElement, width: number, height: number): void {
+ element.getBoundingClientRect = () => ({
+ x: 0,
+ y: 0,
+ left: 0,
+ top: 0,
+ right: width,
+ bottom: height,
+ width,
+ height,
+ toJSON: () => ({})
+ });
+}
+
function input(element: HTMLInputElement | HTMLTextAreaElement, value: string): void {
act(() => {
setNativeValue(element, value);
@@ -316,6 +792,14 @@ function input(element: HTMLInputElement | HTMLTextAreaElement, value: string):
});
}
+function blur(element: HTMLElement): void {
+ act(() => {
+ element.focus();
+ element.blur();
+ element.dispatchEvent(new FocusEvent("blur"));
+ });
+}
+
function change(element: HTMLSelectElement, value: string): void {
act(() => {
setNativeValue(element, value);
@@ -327,3 +811,21 @@ function setNativeValue(element: HTMLInputElement | HTMLTextAreaElement | HTMLSe
const descriptor = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(element), "value");
descriptor?.set?.call(element, value);
}
+
+function inputByPlaceholder(container: ParentNode, placeholder: string): HTMLInputElement {
+ const input = container.querySelector(`input[placeholder="${placeholder}"]`);
+ if (!input) throw new Error(`Input not found: ${placeholder}`);
+ return input;
+}
+
+async function flush(): Promise {
+ await act(async () => {
+ await new Promise((resolve) => {
+ if (typeof requestAnimationFrame === "undefined") {
+ setTimeout(resolve, 0);
+ return;
+ }
+ requestAnimationFrame(() => setTimeout(resolve, 0));
+ });
+ });
+}
diff --git a/packages/wire-react/src/components/optionFields.tsx b/packages/wire-react/src/components/optionFields.tsx
new file mode 100644
index 0000000..465a93f
--- /dev/null
+++ b/packages/wire-react/src/components/optionFields.tsx
@@ -0,0 +1,553 @@
+import {
+ useEffect,
+ useMemo,
+ useState,
+ type KeyboardEvent,
+ type ReactElement,
+ type ReactNode
+} from "react";
+import type { WireAction, WireDiagram, WireNode } from "@aigentive/wire-core";
+import {
+ inferOptionType,
+ optionChoiceKey,
+ optionChoiceLabel,
+ optionChoiceValue,
+ patchWireOption,
+ readWireOption,
+ type WireOptionChoice,
+ type WireOptionPrimitive,
+ type WireOptionSpec
+} from "../options.js";
+import { cx } from "./classes.js";
+
+export interface WireOptionFieldRendererContext {
+ fieldId: string;
+ labelId: string;
+ descriptionId?: string;
+ errorId?: string;
+ describedBy?: string;
+ node: WireNode;
+ diagram: WireDiagram;
+ option: WireOptionSpec;
+ value: unknown;
+ disabled: boolean;
+ readOnly: boolean;
+ required: boolean;
+ issues: Array<{ message: string; severity?: "error" | "warning" | "info" }>;
+ onChange(value: unknown): void;
+ onCommit?(value: unknown): void;
+}
+
+export interface WireOptionSectionRendererContext {
+ section: string;
+ options: WireOptionSpec[];
+ children: ReactNode;
+}
+
+export interface WireOptionFieldListProps {
+ diagram: WireDiagram;
+ node: WireNode;
+ specs: WireOptionSpec[];
+ readOnly?: boolean;
+ renderField?: (context: WireOptionFieldRendererContext) => ReactNode;
+ renderSection?: (context: WireOptionSectionRendererContext) => ReactNode;
+ onCommit(action: WireAction, option: WireOptionSpec, value: unknown): void;
+ classNames?: {
+ field?: string;
+ section?: string;
+ validation?: string;
+ };
+}
+
+const CONTROL_CLASS =
+ "w-full min-h-8 rounded-md border border-wire bg-wire-surface px-[9px] py-[5px] text-[12.5px] text-wire-primary outline-none transition-colors focus:border-wire-focus disabled:cursor-not-allowed disabled:bg-wire-sunken disabled:text-wire-muted";
+const FIELD_LABEL_CLASS = "text-[11.5px] font-medium text-wire-secondary mb-[3px]";
+const SMALL_BUTTON_CLASS =
+ "rounded-md border border-wire bg-wire-surface px-2 py-1 text-[11px] font-medium text-wire-tertiary transition-colors hover:border-wire-strong hover:text-wire-primary disabled:cursor-not-allowed disabled:text-wire-muted";
+
+export function WireOptionFieldList({
+ diagram,
+ node,
+ specs,
+ readOnly = false,
+ renderField,
+ renderSection,
+ onCommit,
+ classNames
+}: WireOptionFieldListProps): ReactElement {
+ const visibleSpecs = useMemo(
+ () => specs.filter((spec) => !resolveOptionBoolean(spec.hidden, spec, node, diagram, readWireOption(node, spec))),
+ [diagram, node, specs]
+ );
+ const sections = useMemo(() => groupOptionSpecs(visibleSpecs), [visibleSpecs]);
+
+ return (
+ <>
+ {sections.map((section) => {
+ const children = section.options.map((spec) => (
+
+ ));
+
+ if (!section.name) return {children} ;
+
+ const sectionBody = (
+
+
+ {section.name}
+
+ {children}
+
+ );
+
+ return (
+
+ {renderSection ? renderSection({ section: section.name, options: section.options, children: sectionBody }) : sectionBody}
+
+ );
+ })}
+ >
+ );
+}
+
+function WireOptionField({
+ diagram,
+ node,
+ spec,
+ parentReadOnly,
+ renderField,
+ onCommit,
+ className,
+ validationClassName
+}: {
+ diagram: WireDiagram;
+ node: WireNode;
+ spec: WireOptionSpec;
+ parentReadOnly: boolean;
+ renderField?: (context: WireOptionFieldRendererContext) => ReactNode;
+ onCommit(action: WireAction, option: WireOptionSpec, value: unknown): void;
+ className?: string;
+ validationClassName?: string;
+}): ReactElement | null {
+ const rawValue = readWireOption(node, spec) ?? spec.defaultValue;
+ const type = inferOptionType(spec);
+ const label = spec.label ?? labelFromKey(spec.key);
+ const fieldKey = `${node.id}-${spec.storage ?? "data-options"}-${spec.key}`.replace(/[^a-zA-Z0-9_-]+/g, "-");
+ const fieldId = `wire-option-${fieldKey}`;
+ const labelId = `${fieldId}-label`;
+ const descriptionId = spec.description ? `${fieldId}-description` : undefined;
+ const [localIssue, setLocalIssue] = useState<{ message: string; severity?: "error" | "warning" | "info" } | undefined>();
+ const externalIssues = evaluateOptionValidation(spec, node, diagram, rawValue);
+ const issues = localIssue ? [localIssue, ...externalIssues] : externalIssues;
+ const errorIssue = issues.find((issue) => (issue.severity ?? "error") === "error");
+ const errorId = errorIssue ? `${fieldId}-error` : undefined;
+ const describedBy = [descriptionId, errorId].filter(Boolean).join(" ") || undefined;
+ const disabled = resolveOptionBoolean(spec.disabled, spec, node, diagram, rawValue);
+ const optionReadOnly = !disabled && (parentReadOnly || resolveOptionBoolean(spec.readOnly, spec, node, diagram, rawValue));
+ const required = Boolean(spec.required);
+ const commitMode = spec.commitMode ?? "change";
+ const [pendingInput, setPendingInput] = useState(() => formatInputValue(rawValue, spec, node, diagram, type));
+ const [dirty, setDirty] = useState(false);
+
+ useEffect(() => {
+ setPendingInput(formatInputValue(rawValue, spec, node, diagram, type));
+ setDirty(false);
+ setLocalIssue(undefined);
+ }, [diagram, node, rawValue, spec, type]);
+
+ useEffect(() => {
+ if (!dirty || disabled || optionReadOnly || commitMode !== "change" || !spec.debounceMs || type === "boolean") return undefined;
+ const handle = setTimeout(() => {
+ commitInput(pendingInput);
+ }, spec.debounceMs);
+ return () => clearTimeout(handle);
+ }, [commitMode, dirty, disabled, optionReadOnly, pendingInput, spec.debounceMs, type]);
+
+ if (resolveOptionBoolean(spec.hidden, spec, node, diagram, rawValue)) return null;
+
+ const actionForValue = (value: unknown): WireAction => ({
+ type: "node.patch",
+ id: node.id,
+ patch: patchWireOption(node, spec, value)
+ });
+
+ const commitParsedValue = (value: unknown): void => {
+ const validationIssues = validateParsedOptionValue(value, spec, node, diagram);
+ const blockingIssue = validationIssues.find((issue) => (issue.severity ?? "error") === "error");
+ if (blockingIssue) {
+ setLocalIssue(blockingIssue);
+ return;
+ }
+ setLocalIssue(undefined);
+ setDirty(false);
+ onCommit(actionForValue(value), spec, value);
+ };
+
+ const commitInput = (input: unknown): void => {
+ if (disabled || optionReadOnly) return;
+ const parsed = parseOptionInput(input, type, spec, node, diagram, rawValue);
+ if (!parsed.ok) {
+ setLocalIssue({ message: parsed.message });
+ return;
+ }
+ commitParsedValue(parsed.value);
+ };
+
+ const updateInput = (value: unknown): void => {
+ if (disabled || optionReadOnly) return;
+ setPendingInput(value);
+ setDirty(true);
+ if (commitMode === "change" && !spec.debounceMs) commitInput(value);
+ };
+
+ const revert = (): void => {
+ setPendingInput(formatInputValue(rawValue, spec, node, diagram, type));
+ setDirty(false);
+ setLocalIssue(undefined);
+ };
+
+ const rendererContext: WireOptionFieldRendererContext = {
+ fieldId,
+ labelId,
+ descriptionId,
+ errorId,
+ describedBy,
+ node,
+ diagram,
+ option: spec,
+ value: rawValue,
+ disabled,
+ readOnly: optionReadOnly,
+ required,
+ issues,
+ onChange: disabled || optionReadOnly ? () => undefined : updateInput,
+ onCommit: disabled || optionReadOnly ? () => undefined : commitParsedValue
+ };
+
+ return (
+
+
+ {label}{required ? * : null}
+
+ {renderField ? (
+
+ {renderField(rendererContext)}
+
+ ) : renderNativeControl({
+ type,
+ spec,
+ value: pendingInput,
+ fieldId,
+ labelId,
+ describedBy,
+ disabled,
+ readOnly: optionReadOnly,
+ required,
+ invalid: Boolean(errorIssue),
+ onInput: updateInput,
+ onCommit: () => commitInput(pendingInput),
+ onBlur: () => {
+ if (commitMode === "blur" && dirty) commitInput(pendingInput);
+ },
+ onRevert: revert,
+ commitMode,
+ dirty
+ })}
+ {spec.description ? (
+
+ {spec.description}
+
+ ) : null}
+ {errorIssue ? (
+
+ {errorIssue.message}
+
+ ) : null}
+
+ );
+}
+
+function renderNativeControl({
+ type,
+ spec,
+ value,
+ fieldId,
+ labelId,
+ describedBy,
+ disabled,
+ readOnly,
+ required,
+ invalid,
+ onInput,
+ onCommit,
+ onBlur,
+ onRevert,
+ commitMode,
+ dirty
+}: {
+ type: ReturnType;
+ spec: WireOptionSpec;
+ value: unknown;
+ fieldId: string;
+ labelId: string;
+ describedBy?: string;
+ disabled: boolean;
+ readOnly: boolean;
+ required: boolean;
+ invalid: boolean;
+ onInput(value: unknown): void;
+ onCommit(): void;
+ onBlur(): void;
+ onRevert(): void;
+ commitMode: "change" | "blur" | "submit";
+ dirty: boolean;
+}): ReactElement {
+ const shared = {
+ id: fieldId,
+ "aria-labelledby": labelId,
+ "aria-describedby": describedBy,
+ "aria-invalid": invalid || undefined,
+ required,
+ disabled,
+ readOnly
+ };
+ const handleKeyDown = (event: KeyboardEvent) => {
+ if (commitMode !== "submit") return;
+ if (event.key === "Escape") {
+ event.preventDefault();
+ onRevert();
+ } else if (event.key === "Enter" && (event.currentTarget.tagName !== "TEXTAREA" || event.metaKey || event.ctrlKey)) {
+ event.preventDefault();
+ onCommit();
+ }
+ };
+
+ const control = type === "textarea" ? (
+