diff --git a/.changeset/connectable-item-tooltip.md b/.changeset/connectable-item-tooltip.md index fb2e23c80..03aa0e433 100644 --- a/.changeset/connectable-item-tooltip.md +++ b/.changeset/connectable-item-tooltip.md @@ -2,4 +2,4 @@ '@workflowbuilder/sdk': patch --- -Decision branch rows and AI tool rows show the full label in a tooltip when the text is truncated. +Decision branch rows and AI tool rows expose the full label as a native tooltip (`title`), so a clipped row stays readable. diff --git a/.changeset/node-rows-fill.md b/.changeset/node-rows-fill.md new file mode 100644 index 000000000..00f9563b6 --- /dev/null +++ b/.changeset/node-rows-fill.md @@ -0,0 +1,5 @@ +--- +'@workflowbuilder/sdk': patch +--- + +Decision branch rows and AI tool rows fill the section width in the horizontal layout, as the design's `Node / Row` does, instead of capping it with a derived number; branch rows are spaced with the node body gap (8px) like tool rows. diff --git a/.changeset/node-text-ellipsis.md b/.changeset/node-text-ellipsis.md index a817db82e..55ddf0373 100644 --- a/.changeset/node-text-ellipsis.md +++ b/.changeset/node-text-ellipsis.md @@ -2,4 +2,4 @@ '@workflowbuilder/ui': patch --- -`NodeDescription` keeps both the title and the subtitle on one line: long text is truncated with an ellipsis and exposed in full through the element's native tooltip, and the text no longer widens nodes whose body sizes to its content (for example the Decision node). Nodes keep the fixed design width and grow in height only. +`NodeDescription` keeps both the title and the subtitle on one line: long text is truncated with an ellipsis and exposed in full through the element's native tooltip, and the text no longer widens nodes whose body sizes to its content (for example the Decision node). Nodes keep the fixed design width and grow in height only. The block relies on `contain: inline-size`, so it needs an ancestor with a definite inline size; inside an `inline-flex` or `fit-content` wrapper it renders at zero width. diff --git a/.changeset/palette-node-states.md b/.changeset/palette-node-states.md index 7d14e9c42..e170cb4ff 100644 --- a/.changeset/palette-node-states.md +++ b/.changeset/palette-node-states.md @@ -2,4 +2,6 @@ '@workflowbuilder/sdk': minor --- -Palette entries render the canvas Node states instead of their own styling: hover is the Node Hover state, the drag preview is the Node Active state (outline and ring), and entries that cannot be added (read-only mode) use the Node Disabled state instead of a faded copy. The node templates (`WorkflowNodeTemplate`, `StartNodeTemplate`, `DecisionNodeTemplate`, `AiAgentNodeTemplate`) accept a `disabled` prop. +Palette entries render the canvas Node states instead of their own styling: hover is the Node Hover state, the drag preview is the Node Active state (outline and ring), and entries that cannot be added (read-only mode) render the Node Disabled state instead of a faded copy. `WorkflowNodeTemplateProps` gains `disabled?: boolean`; the built-in templates forward it. + +Migration for custom node templates: forward `disabled` to `NodePanel.Root`, `NodeIcon` and `NodeDescription`, otherwise the palette entry looks draggable while the palette is locked (the wrapper no longer fades it). diff --git a/.changeset/persist-without-measured.md b/.changeset/persist-without-measured.md index 095c7bf66..bf099f0b9 100644 --- a/.changeset/persist-without-measured.md +++ b/.changeset/persist-without-measured.md @@ -2,4 +2,4 @@ '@workflowbuilder/sdk': patch --- -Saved and exported diagrams (`getStoreDataForIntegration`, the localStorage/API/props integrations) no longer include the runtime `measured` node sizes; nodes are measured again on load, so stored data cannot carry stale dimensions. +Saved and exported diagrams (`getStoreDataForIntegration`, the localStorage/API/props integrations) no longer include the runtime `measured` node sizes or the `dragging` flag; nodes are measured again on load, so stored data cannot carry stale dimensions. diff --git a/.changeset/self-loop-offset.md b/.changeset/self-loop-offset.md new file mode 100644 index 000000000..46f0763d9 --- /dev/null +++ b/.changeset/self-loop-offset.md @@ -0,0 +1,5 @@ +--- +'@workflowbuilder/sdk': minor +--- + +The apex of a self-connecting edge sits 48px above the node's top edge regardless of the node height and of where the source port sits (design decision of 2026-09-16). `SELF_CONNECTING_EDGE_LABEL_OFFSET` is now `48` and is measured from the top edge; `getSelfLoopHeight` is exported for custom edges that draw their own loop. Before, the loop grew with the node height and reached 132px above a 64px node. diff --git a/apps/ai-studio/src/data/ai-debate-flow.ts b/apps/ai-studio/src/data/ai-debate-flow.ts index 5b94fd544..d36623e88 100644 --- a/apps/ai-studio/src/data/ai-debate-flow.ts +++ b/apps/ai-studio/src/data/ai-debate-flow.ts @@ -21,7 +21,6 @@ const diagram: DiagramModel = { icon: 'Lightning', }, selected: false, - dragging: false, }, { id: 'optimist-1', @@ -40,7 +39,6 @@ why now. Be persuasive but honest, no hype.`, icon: 'AiAgent', }, selected: false, - dragging: false, }, { id: 'skeptic-1', @@ -59,7 +57,6 @@ hidden costs, what could go wrong. Surface the objections others gloss over.`, icon: 'AiAgent', }, selected: false, - dragging: false, }, { id: 'verdict-1', @@ -89,7 +86,6 @@ Be decisive.`, icon: 'AiAgent', }, selected: false, - dragging: false, }, { id: 'visualize-1', @@ -106,7 +102,6 @@ Be decisive.`, icon: 'Eye', }, selected: false, - dragging: false, }, ], edges: [ diff --git a/apps/ai-studio/src/data/content-repurposer-flow.ts b/apps/ai-studio/src/data/content-repurposer-flow.ts index 2d71af2b2..53e91f552 100644 --- a/apps/ai-studio/src/data/content-repurposer-flow.ts +++ b/apps/ai-studio/src/data/content-repurposer-flow.ts @@ -26,7 +26,6 @@ of the most tedious parts of their week.`, icon: 'Lightning', }, selected: false, - dragging: false, }, { id: 'twitter-1', @@ -47,7 +46,6 @@ Number each post (1/, 2/, ...).`, icon: 'AiAgent', }, selected: false, - dragging: false, }, { id: 'linkedin-1', @@ -69,7 +67,6 @@ Keep it under 200 words. Professional but human.`, icon: 'AiAgent', }, selected: false, - dragging: false, }, { id: 'instagram-1', @@ -90,7 +87,6 @@ Keep it under 200 words. Professional but human.`, icon: 'AiAgent', }, selected: false, - dragging: false, }, { id: 'pack-1', @@ -121,7 +117,6 @@ Keep each draft's wording as-is - do not rewrite it. Just organize and label.`, icon: 'AiAgent', }, selected: false, - dragging: false, }, { id: 'visualize-1', @@ -138,7 +133,6 @@ Keep each draft's wording as-is - do not rewrite it. Just organize and label.`, icon: 'Eye', }, selected: false, - dragging: false, }, ], edges: [ diff --git a/apps/ai-studio/src/data/meeting-notes-flow.ts b/apps/ai-studio/src/data/meeting-notes-flow.ts index d32aa6d14..7937c929a 100644 --- a/apps/ai-studio/src/data/meeting-notes-flow.ts +++ b/apps/ai-studio/src/data/meeting-notes-flow.ts @@ -25,7 +25,6 @@ const diagram: DiagramModel = { icon: 'Lightning', }, selected: false, - dragging: false, }, { id: 'summary-1', @@ -43,7 +42,6 @@ was decided. Neutral, factual tone.`, icon: 'AiAgent', }, selected: false, - dragging: false, }, { id: 'actions-1', @@ -62,7 +60,6 @@ mark it "unassigned". Do not invent items that were not discussed.`, icon: 'AiAgent', }, selected: false, - dragging: false, }, { id: 'recap-1', @@ -91,7 +88,6 @@ line "_Meeting Bot_".`, icon: 'AiAgent', }, selected: false, - dragging: false, }, { id: 'visualize-1', @@ -108,7 +104,6 @@ line "_Meeting Bot_".`, icon: 'Eye', }, selected: false, - dragging: false, }, ], edges: [ diff --git a/apps/ai-studio/src/data/research-flow.ts b/apps/ai-studio/src/data/research-flow.ts index 4a48b7d4a..31639c192 100644 --- a/apps/ai-studio/src/data/research-flow.ts +++ b/apps/ai-studio/src/data/research-flow.ts @@ -20,7 +20,6 @@ const diagram: DiagramModel = { icon: 'Lightning', }, selected: false, - dragging: false, }, { id: 'research-1', @@ -51,7 +50,6 @@ Only state things you found via search. If a claim isn't supported by a result, icon: 'AiAgent', }, selected: false, - dragging: false, }, { id: 'visualize-1', @@ -68,7 +66,6 @@ Only state things you found via search. If a claim isn't supported by a result, icon: 'Eye', }, selected: false, - dragging: false, }, ], edges: [ diff --git a/apps/ai-studio/src/data/support-triage-flow.ts b/apps/ai-studio/src/data/support-triage-flow.ts index ebd7257fe..995430002 100644 --- a/apps/ai-studio/src/data/support-triage-flow.ts +++ b/apps/ai-studio/src/data/support-triage-flow.ts @@ -34,7 +34,6 @@ Head of Ops, Brightwave`, icon: 'Lightning', }, selected: false, - dragging: false, }, { id: 'classify-1', @@ -64,7 +63,6 @@ Use the exact lowercase keyword on the Type line - it drives downstream routing. icon: 'AiAgent', }, selected: false, - dragging: false, }, { id: 'decision-1', @@ -114,7 +112,6 @@ Use the exact lowercase keyword on the Type line - it drives downstream routing. icon: 'ArrowsSplit', }, selected: false, - dragging: false, }, { id: 'billing-1', @@ -138,7 +135,6 @@ You handle billing issues. Draft a reply to the customer: icon: 'AiAgent', }, selected: false, - dragging: false, }, { id: 'bug-1', @@ -162,7 +158,6 @@ You triage product bugs. Draft a reply to the customer: icon: 'AiAgent', }, selected: false, - dragging: false, }, { id: 'general-1', @@ -185,7 +180,6 @@ You answer how-to and general questions. Draft a friendly reply: icon: 'AiAgent', }, selected: false, - dragging: false, }, { id: 'qa-1', @@ -211,7 +205,6 @@ If not, output "⚠️ NEEDS REVISION" followed by specific, actionable fixes.`, icon: 'AiAgent', }, selected: false, - dragging: false, }, { id: 'visualize-1', @@ -228,7 +221,6 @@ If not, output "⚠️ NEEDS REVISION" followed by specific, actionable fixes.`, icon: 'Eye', }, selected: false, - dragging: false, }, ], edges: [ diff --git a/apps/demo/src/app/components/multi-port-node/multi-port-node-template.tsx b/apps/demo/src/app/components/multi-port-node/multi-port-node-template.tsx index 05fb08080..595d0911c 100644 --- a/apps/demo/src/app/components/multi-port-node/multi-port-node-template.tsx +++ b/apps/demo/src/app/components/multi-port-node/multi-port-node-template.tsx @@ -24,6 +24,7 @@ export const MultiPortNodeTemplate = defineNodeTemplate( label, description, selected = false, + disabled = false, data, showHandles = true, }: WorkflowNodeTemplateProps) => { @@ -40,10 +41,10 @@ export const MultiPortNodeTemplate = defineNodeTemplate( return (
- + - - + + diff --git a/apps/demo/src/app/data/templates/black-friday.ts b/apps/demo/src/app/data/templates/black-friday.ts index 358e26d11..7e5d74fbc 100644 --- a/apps/demo/src/app/data/templates/black-friday.ts +++ b/apps/demo/src/app/data/templates/black-friday.ts @@ -72,7 +72,6 @@ const diagram: DiagramModel = { icon: 'PlayCircle', }, selected: false, - dragging: false, }, { id: 'd6adcc9c-3146-46fd-9b0f-af78e94ad04d', @@ -132,7 +131,6 @@ const diagram: DiagramModel = { icon: 'ListChecks', }, selected: false, - dragging: false, }, { id: 'da28f8fe-c3a0-44c0-8213-c7c88d62164e', @@ -184,7 +182,6 @@ const diagram: DiagramModel = { icon: 'PlayCircle', }, selected: false, - dragging: false, }, { id: 'f1a5e5cb-58fa-4379-a45e-437553153b5f', @@ -206,7 +203,6 @@ const diagram: DiagramModel = { icon: 'Timer', }, selected: false, - dragging: false, }, ], edges: [ diff --git a/apps/demo/src/app/data/templates/call-flow.ts b/apps/demo/src/app/data/templates/call-flow.ts index 898d6d705..c7a2dc737 100644 --- a/apps/demo/src/app/data/templates/call-flow.ts +++ b/apps/demo/src/app/data/templates/call-flow.ts @@ -85,7 +85,6 @@ const diagram: DiagramModel = { icon: 'ListChecks', }, selected: false, - dragging: false, }, { id: 'b2a4b7a7-1900-4218-aea4-902de2e4eb51', @@ -137,7 +136,6 @@ const diagram: DiagramModel = { icon: 'ListChecks', }, selected: false, - dragging: false, }, { id: 'f1d785cc-1715-4b27-97e4-59347f08e660', @@ -189,7 +187,6 @@ const diagram: DiagramModel = { icon: 'ListChecks', }, selected: false, - dragging: false, }, { id: '18562830-da0b-443e-a859-79cb95473f59', @@ -224,7 +221,6 @@ const diagram: DiagramModel = { icon: 'PlayCircle', }, selected: false, - dragging: false, }, { id: '6ab2c9e2-7e1e-4061-83ea-83a88aa246eb', @@ -242,7 +238,6 @@ const diagram: DiagramModel = { icon: 'PlayCircle', }, selected: false, - dragging: false, }, { id: 'feb863db-de51-4aee-8746-f9e02d993cb7', @@ -260,7 +255,6 @@ const diagram: DiagramModel = { icon: 'PlayCircle', }, selected: false, - dragging: false, }, { id: '456f755a-a1fe-4e18-a850-802cf28b4cf5', @@ -295,7 +289,6 @@ const diagram: DiagramModel = { icon: 'PlayCircle', }, selected: false, - dragging: false, }, { id: '53f8322b-96ef-4775-824a-020f02cccd20', @@ -313,7 +306,6 @@ const diagram: DiagramModel = { icon: 'PlayCircle', }, selected: false, - dragging: false, }, ], edges: [ diff --git a/apps/demo/src/app/data/templates/simple-flow.ts b/apps/demo/src/app/data/templates/simple-flow.ts index e49506c9e..55f39df58 100644 --- a/apps/demo/src/app/data/templates/simple-flow.ts +++ b/apps/demo/src/app/data/templates/simple-flow.ts @@ -23,7 +23,6 @@ const defaultDiagram: DiagramModel = { icon: 'Lightning', }, selected: false, - dragging: false, }, { id: 'da47caa9-c695-47bb-be52-b30bb8a6be6d', @@ -41,7 +40,6 @@ const defaultDiagram: DiagramModel = { icon: 'ListChecks', }, selected: false, - dragging: false, }, { id: '47375954-4e4a-4567-b7d3-c70c3921e1dd', @@ -59,7 +57,6 @@ const defaultDiagram: DiagramModel = { icon: 'PlayCircle', }, selected: false, - dragging: false, }, { id: 'e7ecd597-55ca-4bba-9d32-d0c51173046d', @@ -77,7 +74,6 @@ const defaultDiagram: DiagramModel = { icon: 'ArrowsSplit', }, selected: false, - dragging: false, }, { id: '798dbba1-d356-4fcd-8ba2-90e75f4912f9', @@ -116,7 +112,6 @@ const defaultDiagram: DiagramModel = { icon: 'Timer', }, selected: false, - dragging: false, }, { id: 'fd7745bf-4562-447f-a65c-2581b6a77eac', @@ -206,7 +201,6 @@ const defaultDiagram: DiagramModel = { icon: 'Timer', }, selected: false, - dragging: false, }, { id: '33bfbf0d-f0eb-452e-aeba-f330bb9badec', @@ -275,7 +269,6 @@ const defaultDiagram: DiagramModel = { icon: 'PaperPlaneRight', }, selected: false, - dragging: false, }, ], edges: [ diff --git a/apps/demo/src/app/data/templates/user-registration.ts b/apps/demo/src/app/data/templates/user-registration.ts index d3e1b4c9c..e24f6bf6d 100644 --- a/apps/demo/src/app/data/templates/user-registration.ts +++ b/apps/demo/src/app/data/templates/user-registration.ts @@ -68,7 +68,6 @@ const diagram: DiagramModel = { icon: 'ListChecks', }, selected: false, - dragging: false, }, { id: 'eea762a9-de40-4783-affe-aee7a0f02be6', @@ -90,7 +89,6 @@ const diagram: DiagramModel = { icon: 'Timer', }, selected: false, - dragging: false, }, { id: 'c9f39f2a-5d60-409e-bf6a-c01602583c96', @@ -108,7 +106,6 @@ const diagram: DiagramModel = { icon: 'PlayCircle', }, selected: false, - dragging: false, }, { id: '8fd0bd71-81ab-4499-8cf5-7b39967aa7f8', @@ -126,7 +123,6 @@ const diagram: DiagramModel = { icon: 'ListChecks', }, selected: false, - dragging: false, }, { id: '0ce0778e-8592-4763-81be-070797bf50c2', @@ -144,7 +140,6 @@ const diagram: DiagramModel = { icon: 'PlayCircle', }, selected: false, - dragging: false, }, { id: '862de19e-af00-4e22-aea3-f1558b9140e7', @@ -166,7 +161,6 @@ const diagram: DiagramModel = { icon: 'Timer', }, selected: false, - dragging: false, }, { id: 'cd262d84-8735-47be-8ccd-174296f2e21a', @@ -184,7 +178,6 @@ const diagram: DiagramModel = { icon: 'PlayCircle', }, selected: false, - dragging: false, }, { id: '7d55e5c9-6761-4940-8559-90a0d2bfc805', diff --git a/apps/docs/src/content/docs/guides/add-a-custom-node.mdx b/apps/docs/src/content/docs/guides/add-a-custom-node.mdx index a7ebb5537..dbd015aac 100644 --- a/apps/docs/src/content/docs/guides/add-a-custom-node.mdx +++ b/apps/docs/src/content/docs/guides/add-a-custom-node.mdx @@ -224,7 +224,15 @@ import { Handle, Position } from '@xyflow/react'; import { memo, useMemo } from 'react'; export const MyNodeTemplate = memo( - ({ id, icon, label, description, selected = false, showHandles = true }: WorkflowNodeTemplateProps) => { + ({ + id, + icon, + label, + description, + selected = false, + disabled = false, + showHandles = true, + }: WorkflowNodeTemplateProps) => { const iconElement = useMemo(() => , [icon]); const handleTargetTopId = getHandleId({ nodeId: id, handleType: 'target', innerId: 'top' }); @@ -233,10 +241,10 @@ export const MyNodeTemplate = memo( const handleSourceRightId = getHandleId({ nodeId: id, handleType: 'source', innerId: 'right' }); return ( - + - - + + @@ -266,7 +274,7 @@ Wire it through the `nodeTemplates` prop on ``: The key must match the palette item's `type`. Keys that collide with built-in template names (`'node'`, `'start-node'`, `'ai-node'`, `'decision-node'`) override the built-in renderer for that node category. This affects rendering only — a node is an entry point because of its `isStartNode` flag, never because of the template it draws with. Declare `nodeTemplates` at module level — recreating the map on every render busts ReactFlow's internal memoisation and remounts every node on the canvas. -The same template renders both the canvas node and the static palette thumbnail / drag-ghost. In preview mode `data`, `selected`, and `layoutDirection` are `undefined` — read them with optional chaining and fall back to defaults. +The same template renders the canvas node, the palette thumbnail and the drag ghost. In the palette `data` and `layoutDirection` are `undefined` — read them with optional chaining and fall back to defaults. The palette passes `selected` for the drag ghost (the Node Active ring) and `disabled` for entries that cannot be added, for example in read-only mode; forward both to `NodePanel.Root`, `NodeIcon` and `NodeDescription`, otherwise your entry looks draggable while the palette is locked. One thing the custom template path does **not** include: `NodeAsPortWrapper` (drag-to-create connections by dropping onto the node body). If you need that, register a custom node container through a plugin instead — see [Build a plugin](/guides/build-a-plugin/). diff --git a/apps/docs/src/content/docs/ui-library/diagram-components/node-description.mdx b/apps/docs/src/content/docs/ui-library/diagram-components/node-description.mdx index 2ee21548b..f5a4e69a8 100644 --- a/apps/docs/src/content/docs/ui-library/diagram-components/node-description.mdx +++ b/apps/docs/src/content/docs/ui-library/diagram-components/node-description.mdx @@ -36,6 +36,10 @@ Both lines are single-line. Text that does not fit the node width is cut with an full text is available through the element's native tooltip (`title`). The block never widens its node: a node keeps the design width and grows in height only through its body content. +That last property comes from `contain: inline-size`, so the block contributes nothing to its +parent's width. Render it inside an ancestor with a definite inline size (`NodePanel.Header` inside +the 241px shell qualifies); in an `inline-flex` or `fit-content` wrapper it collapses to zero width. + ## Disabled `disabled` mutes both lines with the Node Disabled text color. The node templates pass it diff --git a/apps/docs/src/content/docs/ui-library/diagram-components/node-panel.mdx b/apps/docs/src/content/docs/ui-library/diagram-components/node-panel.mdx index ad151f7d1..04ef70398 100644 --- a/apps/docs/src/content/docs/ui-library/diagram-components/node-panel.mdx +++ b/apps/docs/src/content/docs/ui-library/diagram-components/node-panel.mdx @@ -71,12 +71,12 @@ it for node types that cannot be added at the moment, for example in read-only m ## Parts -| Part | Props | Description | -| ------------------- | ----------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------- | -| `NodePanel.Root` | `selected: boolean`, `className?`, `children?` | Outer wrapper. Lays out header + handles, then content; applies selected styling. | -| `NodePanel.Header` | `className?`, `children?` | Header row. At most one per `Root`. | -| `NodePanel.Content` | `isVisible?` (default `true`), `className?`, `children?` | Main body. Renders nothing when `isVisible` is `false`. At most one per `Root`. | -| `NodePanel.Handles` | `isVisible?` (default `true`), `alignment?` (`'center' \| 'header'`, default `'center'`), `children?` | Container for connection handles. At most one per `Root`. | +| Part | Props | Description | +| ------------------- | ----------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- | +| `NodePanel.Root` | `selected: boolean`, `disabled?: boolean`, `className?`, `children?` | Outer wrapper. Lays out header + handles, then content; applies selected and disabled styling. | +| `NodePanel.Header` | `className?`, `children?` | Header row. At most one per `Root`. | +| `NodePanel.Content` | `isVisible?` (default `true`), `className?`, `children?` | Main body. Renders nothing when `isVisible` is `false`. At most one per `Root`. | +| `NodePanel.Handles` | `isVisible?` (default `true`), `alignment?` (`'center' \| 'header'`, default `'center'`), `children?` | Container for connection handles. At most one per `Root`. | ## CSS variables diff --git a/packages/sdk/src/features/diagram/edges/edge.consts.ts b/packages/sdk/src/features/diagram/edges/edge.consts.ts index 86f52779a..fba28149a 100644 --- a/packages/sdk/src/features/diagram/edges/edge.consts.ts +++ b/packages/sdk/src/features/diagram/edges/edge.consts.ts @@ -15,9 +15,9 @@ export const EDGE_CURVE_RADIUS = 16; /** * Vertical distance (px) between the source node's top edge and the - * apex of a self-connecting edge's loop. Also drives where the edge's - * label sits on a self-connecting edge. + * apex of a self-connecting edge's loop, independent of the node height. + * Also drives where the edge's label sits on a self-connecting edge. * * @category Constants */ -export const SELF_CONNECTING_EDGE_LABEL_OFFSET = 100; +export const SELF_CONNECTING_EDGE_LABEL_OFFSET = 48; diff --git a/packages/sdk/src/features/diagram/edges/label-edge/label-edge.spec.tsx b/packages/sdk/src/features/diagram/edges/label-edge/label-edge.spec.tsx index 221ed9f26..78cea497f 100644 --- a/packages/sdk/src/features/diagram/edges/label-edge/label-edge.spec.tsx +++ b/packages/sdk/src/features/diagram/edges/label-edge/label-edge.spec.tsx @@ -8,6 +8,7 @@ import { LabelEdge } from './label-edge'; const { nodeLookup } = vi.hoisted(() => ({ nodeLookup: new Map() })); vi.mock('@xyflow/react', () => ({ + Position: { Top: 'top', Right: 'right', Bottom: 'bottom', Left: 'left' }, getSmoothStepPath: () => ['M 0 0', 0, 0], useStore: (selector: (state: { nodeLookup: Map }) => unknown) => selector({ nodeLookup }), })); @@ -58,8 +59,8 @@ describe('LabelEdge', () => { const { container } = render(); - expect(container.querySelector('[data-edge-id="self-loop"]')?.getAttribute('d')).toContain('Q 125 120 109 120'); - expect(screen.getByTestId('edge-label').dataset.labelY).toBe('120'); + expect(container.querySelector('[data-edge-id="self-loop"]')?.getAttribute('d')).toContain('Q 125 212 109 212'); + expect(screen.getByTestId('edge-label').dataset.labelY).toBe('212'); }); it('falls back to the explicit node height when no measurement exists', () => { @@ -67,7 +68,7 @@ describe('LabelEdge', () => { render(); - expect(screen.getByTestId('edge-label').dataset.labelY).toBe('160'); + expect(screen.getByTestId('edge-label').dataset.labelY).toBe('232'); }); it('does not read the node lookup for a regular edge', () => { @@ -82,6 +83,6 @@ describe('LabelEdge', () => { it('treats an unknown source node as zero height', () => { render(); - expect(screen.getByTestId('edge-label').dataset.labelY).toBe('200'); + expect(screen.getByTestId('edge-label').dataset.labelY).toBe('252'); }); }); diff --git a/packages/sdk/src/features/diagram/edges/label-edge/label-edge.tsx b/packages/sdk/src/features/diagram/edges/label-edge/label-edge.tsx index 143c7eb0d..58f78fa3c 100644 --- a/packages/sdk/src/features/diagram/edges/label-edge/label-edge.tsx +++ b/packages/sdk/src/features/diagram/edges/label-edge/label-edge.tsx @@ -4,9 +4,13 @@ import { Icon } from '@workflow-builder/icons'; import type { WorkflowBuilderEdge } from '../../../../node/node-data'; import { EdgeLabel } from '../edge-label-renderer/edge-label-renderer'; -import { EDGE_CURVE_RADIUS, EDGE_OFFSET, SELF_CONNECTING_EDGE_LABEL_OFFSET } from '../edge.consts'; +import { EDGE_CURVE_RADIUS, EDGE_OFFSET } from '../edge.consts'; import { EnhancedBaseEdge } from '../enhanced-base-edge/enhanced-base-edge'; -import { SelfConnectingEdge, useSelfLoopNodeHeight } from '../self-connecting-edge/self-connecting-edge'; +import { + SelfConnectingEdge, + getSelfLoopHeight, + useSelfLoopNodeHeight, +} from '../self-connecting-edge/self-connecting-edge'; import { useLabelEdgeHover } from './use-label-edge-hover'; /** @@ -65,7 +69,7 @@ export function LabelEdge({ }; if (source === target) { - const selfConnectingLabelY = sourceY - (nodeHeight + SELF_CONNECTING_EDGE_LABEL_OFFSET); + const selfConnectingLabelY = sourceY - getSelfLoopHeight(nodeHeight, sourcePosition); return ( <> diff --git a/packages/sdk/src/features/diagram/edges/self-connecting-edge/self-connecting-edge.spec.tsx b/packages/sdk/src/features/diagram/edges/self-connecting-edge/self-connecting-edge.spec.tsx index 35a9d370b..ac6eb351e 100644 --- a/packages/sdk/src/features/diagram/edges/self-connecting-edge/self-connecting-edge.spec.tsx +++ b/packages/sdk/src/features/diagram/edges/self-connecting-edge/self-connecting-edge.spec.tsx @@ -1,5 +1,5 @@ import { render } from '@testing-library/react'; -import type { EdgeProps } from '@xyflow/react'; +import { type EdgeProps, Position } from '@xyflow/react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import type { WorkflowBuilderEdge } from '../../../../node/node-data'; @@ -8,6 +8,7 @@ import { SelfConnectingEdge } from './self-connecting-edge'; const { nodeLookup } = vi.hoisted(() => ({ nodeLookup: new Map() })); vi.mock('@xyflow/react', () => ({ + Position: { Top: 'top', Right: 'right', Bottom: 'bottom', Left: 'left' }, useStore: (selector: (state: { nodeLookup: Map }) => unknown) => selector({ nodeLookup }), })); @@ -49,7 +50,15 @@ describe('SelfConnectingEdge', () => { const { container } = render(); - expect(loopApexY(container)).toBe(300 - (80 + 100)); + expect(loopApexY(container)).toBe(300 - (80 / 2 + 48)); + }); + + it('measures the offset from the top edge when the source port sits on the bottom edge', () => { + nodeLookup.set('node-1', { measured: { height: 80 } }); + + const { container } = render(); + + expect(loopApexY(container)).toBe(300 - (80 + 48)); }); it('prefers an explicit nodeHeight over the store', () => { @@ -57,6 +66,6 @@ describe('SelfConnectingEdge', () => { const { container } = render(); - expect(loopApexY(container)).toBe(300 - (20 + 100)); + expect(loopApexY(container)).toBe(300 - (20 / 2 + 48)); }); }); diff --git a/packages/sdk/src/features/diagram/edges/self-connecting-edge/self-connecting-edge.tsx b/packages/sdk/src/features/diagram/edges/self-connecting-edge/self-connecting-edge.tsx index 45c5761ca..90abbbbb4 100644 --- a/packages/sdk/src/features/diagram/edges/self-connecting-edge/self-connecting-edge.tsx +++ b/packages/sdk/src/features/diagram/edges/self-connecting-edge/self-connecting-edge.tsx @@ -1,5 +1,5 @@ import { type EdgeState, useEdgeStyle } from '@workflowbuilder/ui'; -import { type EdgeProps, useStore as useReactFlowStore } from '@xyflow/react'; +import { type EdgeProps, Position, useStore as useReactFlowStore } from '@xyflow/react'; import type { WorkflowBuilderEdge } from '../../../../node/node-data'; import { EDGE_CURVE_RADIUS, SELF_CONNECTING_EDGE_LABEL_OFFSET } from '../edge.consts'; @@ -33,13 +33,27 @@ export function useSelfLoopNodeHeight(source: string, target: string) { }); } +/** + * Vertical distance from the source port to the apex of a self-loop, so that + * the apex sits {@link SELF_CONNECTING_EDGE_LABEL_OFFSET} above the node's top + * edge whatever the node height. Ports sit mid-height in the horizontal layout + * and on the bottom edge in the vertical one. xyflow anchors a bottom port on + * its outer edge, so in that layout the apex lands half a port lower. + * + * @category Utilities + */ +export function getSelfLoopHeight(nodeHeight: number, sourcePosition?: Position) { + const portToTopEdge = + sourcePosition === Position.Bottom ? nodeHeight : sourcePosition === Position.Top ? 0 : nodeHeight / 2; + return portToTopEdge + SELF_CONNECTING_EDGE_LABEL_OFFSET; +} + type Point = { x: number; y: number; }; -function createSelfConnectingPath(source: Point, target: Point, nodeHeight: number, radius: number) { - const loopHeight = nodeHeight + SELF_CONNECTING_EDGE_LABEL_OFFSET; +function createSelfConnectingPath(source: Point, target: Point, loopHeight: number, radius: number) { const horizontalOffset = 25; const points = { @@ -84,6 +98,7 @@ export function SelfConnectingEdge({ hovered, source, target, + sourcePosition, nodeHeight, }: SelfConnectingEdgeProps) { const measuredNodeHeight = useSelfLoopNodeHeight(source, target); @@ -93,7 +108,7 @@ export function SelfConnectingEdge({ const path = createSelfConnectingPath( { x: sourceX, y: sourceY }, { x: targetX, y: targetY }, - nodeHeight ?? measuredNodeHeight, + getSelfLoopHeight(nodeHeight ?? measuredNodeHeight, sourcePosition), EDGE_CURVE_RADIUS, ); diff --git a/packages/sdk/src/features/diagram/nodes/ai-agent-node-template/components/tool-info/tool-info.module.css b/packages/sdk/src/features/diagram/nodes/ai-agent-node-template/components/tool-info/tool-info.module.css index 46b8b647d..3a43dc0c0 100644 --- a/packages/sdk/src/features/diagram/nodes/ai-agent-node-template/components/tool-info/tool-info.module.css +++ b/packages/sdk/src/features/diagram/nodes/ai-agent-node-template/components/tool-info/tool-info.module.css @@ -1,7 +1,7 @@ .tools-container { display: flex; flex-direction: column; - gap: 0.25rem; + gap: var(--wb-ds-canvas-node-body-gap); &:empty { display: none; diff --git a/packages/sdk/src/features/diagram/nodes/components/connectable-item/connectable-item-width.decision-log.md b/packages/sdk/src/features/diagram/nodes/components/connectable-item/connectable-item-width.decision-log.md index c976e372a..858a0484c 100644 --- a/packages/sdk/src/features/diagram/nodes/components/connectable-item/connectable-item-width.decision-log.md +++ b/packages/sdk/src/features/diagram/nodes/components/connectable-item/connectable-item-width.decision-log.md @@ -2,7 +2,7 @@ ### Proposed by: Jan Librowski -### Date: 07.09.2026 +### Date: 07.09.2026 (revised 14.09.2026, 15.09.2026 and 16.09.2026) ## Context @@ -30,13 +30,19 @@ Two problems surfaced while moving the node shell to the DS 2.0 geometry (width the border box and the terms inflate the cap: at 241px the old rule allows 219px while only 201px are available inside a section, so a long label could overflow its section by 18px. -The design system does not specify a width for these rows. Any cap is therefore a provisional -implementation decision, to be revisited when the design publishes one -(follow-up: connectable-item-design-width). +The design master `Node / Row` has no width of its own: it is `fill` inside the body, so the row is +as wide as its section. Design confirmed on 16.09.2026 that this is the rule and that no width role +will be added to the tokens. ## Decision -The cap is derived from named insets between the node's outer edge and the item: +In the vertical list (RIGHT layout) the row fills its container, as in the design: `width: 100%`, +`min-width: 0`, no cap. The container is the section (or `NodeInfoWrapper` for AI tools), so the row +measures `241 - 2 x (8 + 1) - 2 x (8 + 1) = 205px`; the design draws 209 because a Figma stroke does +not take layout space while a CSS border does. 205 is the reference value for QA. + +The horizontal branch row of the DOWN layout has no design master and keeps the derived cap, so a +long label cannot widen the node further than one row: ```css max-width: calc( @@ -48,37 +54,28 @@ max-width: calc( `--wb-sdk-connectable-item-inset` is the horizontal inset (one side) added by the container that wraps the items. It defaults to `0rem` and each wrapping container declares its own value: -- `NodeSection` sets it to its padding plus border width, so Decision branches inside a section get - `241 - 2 x (8 + 1) - 2 x (10 + 1) = 201px` (with the previous 258px shell: 218px). -- The AI template wraps its tool rows in `NodeInfoWrapper` (padding 0.625rem plus a 1px border - per side), which therefore declares the same inset, so tool rows get - `241 - 2 x (8 + 1) - 2 x (10 + 1) = 201px` as well. The default `0rem` applies only to a +- `NodeSection` sets it to its padding plus border width (`canvas/node/body-h-pad` 8px + 1px), so a + capped Decision branch gets 205px, the same as a filled one. +- `NodeInfoWrapper` (AI tools) declares the same inset. The default `0rem` applies only to a container that adds no horizontal padding. +Row padding, gap and radius themselves bind to `canvas/node/row-*`, section padding, gap and radius +to `canvas/node/body-*`; design confirmed that matrix (8 / 8 / 8 / 8, row radius 4) on 09.09.2026 and +published the roles in the 15.09.2026 export. + The variable is not cumulative: a wrapper declares the inset it adds itself, and a container that adds horizontal padding without declaring it lets its rows exceed the visible width by that padding. ## Consequences -- Item width follows the shell geometry exactly and can no longer exceed the space its container - actually offers. -- The variable makes the nesting explicit and reviewable per container instead of encoding it in a - single global multiplier. -- Provisional until the design specifies the row width (follow-up: connectable-item-design-width). - The design's node body matrix (row padding 8px, radius 4px) is a separate change and does not - alter this derivation. - -## Update 14.09.2026 - -Design confirmed the body matrix (DR-143): section padding and gap 8px, row padding 8px, row radius 4px, -section radius 8px (`canvas/node/content-radius`). With the section inset now `8 + 1`, the derived row width -is `241 - 2 x (8 + 1) - 2 x (8 + 1) = 205px` in both Decision and AI Agent. The formula is unchanged; only -the inset value moved. Row padding, row radius, section padding and gap bind to primitives -(`space/100`, `radius/50`) with `missing token` markers until design publishes the `node.body.*` roles. +- In the vertical list the row width is whatever the section offers, with no number of its own to + keep in sync with the shell. +- In the DOWN row the cap follows the shell geometry exactly, and the inset variable keeps the + nesting explicit per container instead of a global multiplier. -## Update 15.09.2026 +## Status -The export now carries the roles `canvas/node/body-{h-pad,v-pad,gap,radius}` and -`canvas/node/row-{h-pad,v-pad,gap,radius}` (design changelog 1.1.9). Sections and rows bind to them; -the provisional primitives and their markers are gone. Values are unchanged (8 / 8 / 8 / 8, row radius 4), -so the derived width stays 205px. +Accepted. Revised 14.09.2026 (body matrix 8 / 8 / 4 applied, derived width 201 → 205px), +15.09.2026 (sections and rows bind to the `body-*` and `row-*` roles from the export) and 16.09.2026 +(design confirmed the fill rule; vertical rows fill, the cap remains only for the DOWN row). +Closed: the row has no width role by design. diff --git a/packages/sdk/src/features/diagram/nodes/components/connectable-item/connectable-item.module.css b/packages/sdk/src/features/diagram/nodes/components/connectable-item/connectable-item.module.css index 394c50596..1bd2d7a97 100644 --- a/packages/sdk/src/features/diagram/nodes/components/connectable-item/connectable-item.module.css +++ b/packages/sdk/src/features/diagram/nodes/components/connectable-item/connectable-item.module.css @@ -43,5 +43,11 @@ } .connectable-item--right { + /* Node / Row is "fill" in the design: the row takes the section width and only + the label truncates. The derived cap above stays for the horizontal branch row + of the DOWN layout, which has no design master. */ + width: 100%; + min-width: 0; + max-width: none; gap: var(--wb-sdk-connectable-item-gap); } diff --git a/packages/sdk/src/features/diagram/nodes/components/connectable-item/connectable-item.tsx b/packages/sdk/src/features/diagram/nodes/components/connectable-item/connectable-item.tsx index 273b5cf12..aadb77703 100644 --- a/packages/sdk/src/features/diagram/nodes/components/connectable-item/connectable-item.tsx +++ b/packages/sdk/src/features/diagram/nodes/components/connectable-item/connectable-item.tsx @@ -22,6 +22,7 @@ export function ConnectableItem({ handleId, label, canHaveBottomHandle = true }: [styles['connectable-item--right']]: layoutDirection === 'RIGHT', })} > + {/* Native title until the DS Tooltip takes over (follow-up: node-text-ds-tooltip). */}
{label}
diff --git a/packages/sdk/src/features/diagram/nodes/decision-node-template/components/branches-container.module.css b/packages/sdk/src/features/diagram/nodes/decision-node-template/components/branches-container.module.css index f27559a42..d8d1d9ce9 100644 --- a/packages/sdk/src/features/diagram/nodes/decision-node-template/components/branches-container.module.css +++ b/packages/sdk/src/features/diagram/nodes/decision-node-template/components/branches-container.module.css @@ -1,5 +1,5 @@ :root { - --wb-sdk-branches-container-gap: 0.25rem; + --wb-sdk-branches-container-gap: var(--wb-ds-canvas-node-body-gap); } .branches-container { diff --git a/packages/sdk/src/features/diagram/nodes/decision-node-template/decision-node-template.module.css b/packages/sdk/src/features/diagram/nodes/decision-node-template/decision-node-template.module.css index 50bac4c97..e36b8d888 100644 --- a/packages/sdk/src/features/diagram/nodes/decision-node-template/decision-node-template.module.css +++ b/packages/sdk/src/features/diagram/nodes/decision-node-template/decision-node-template.module.css @@ -1,4 +1,6 @@ -.decision-node { +/* The DOWN layout lays the branch rows out horizontally, so the body may widen the node. + In the RIGHT layout rows fill the fixed width and truncate instead. */ +.decision-node--down { > div { min-width: max-content; } diff --git a/packages/sdk/src/features/diagram/nodes/decision-node-template/decision-node-template.tsx b/packages/sdk/src/features/diagram/nodes/decision-node-template/decision-node-template.tsx index 0ffa2e06e..a8199d834 100644 --- a/packages/sdk/src/features/diagram/nodes/decision-node-template/decision-node-template.tsx +++ b/packages/sdk/src/features/diagram/nodes/decision-node-template/decision-node-template.tsx @@ -1,5 +1,6 @@ import { NodeDescription, NodeIcon, NodePanel, Status } from '@workflowbuilder/ui'; import { Handle } from '@xyflow/react'; +import clsx from 'clsx'; import { memo, useMemo } from 'react'; import { Icon } from '@workflow-builder/icons'; @@ -55,7 +56,11 @@ export const DecisionNodeTemplate = memo( const handlesAlignment = getHandlesAlignment({ layoutDirection }); return ( - + diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index d5ef1e83a..e100d3b6d 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -137,6 +137,7 @@ export { EdgeLabel } from './features/diagram/edges/edge-label-renderer/edge-lab export { LabelEdge } from './features/diagram/edges/label-edge/label-edge'; export { SelfConnectingEdge, + getSelfLoopHeight, useSelfLoopNodeHeight, } from './features/diagram/edges/self-connecting-edge/self-connecting-edge'; export { NodeSection } from './features/diagram/nodes/components/node-section/node-section'; diff --git a/packages/sdk/src/store/slices/diagram-slice/actions.spec.ts b/packages/sdk/src/store/slices/diagram-slice/actions.spec.ts index 2c1c8317c..a7fa00859 100644 --- a/packages/sdk/src/store/slices/diagram-slice/actions.spec.ts +++ b/packages/sdk/src/store/slices/diagram-slice/actions.spec.ts @@ -8,7 +8,7 @@ import { setCustomPaletteNodes } from '../../../data/palette'; import type { WorkflowBuilderNode } from '../../../node/node-data'; import { mockNodeDelay } from '../../../utils/validation/get-node-errors.mock'; import { resetWorkflowStore, useStore } from '../../store'; -import { refreshNodesErrorsIfNeeded } from './actions'; +import { getStoreDataForIntegration, refreshNodesErrorsIfNeeded } from './actions'; // Mirrors the definition the consumer's palette would supply for `delay` nodes; // `description` is required, so a node missing it must surface a validation error. @@ -86,3 +86,47 @@ describe('refreshNodesErrorsIfNeeded', () => { expect(useStore.getState().nodes).toBe(before); }); }); + +describe('getStoreDataForIntegration', () => { + beforeEach(() => { + resetWorkflowStore(); + useStore.setState({ + nodes: [ + { + ...mockNodeDelay, + selected: true, + dragging: true, + measured: { width: 241, height: 64 }, + } as WorkflowBuilderNode, + ], + edges: [ + { + id: 'edge-1', + source: 'a', + target: 'b', + selected: true, + data: { routerPointsFromAvoidNodes: [{ x: 1, y: 2 }], layoutPoints: [{ x: 3, y: 4 }] }, + } as never, + ], + }); + }); + + it('strips runtime-only values by default', () => { + const { nodes, edges } = getStoreDataForIntegration(); + + expect(nodes[0]).not.toHaveProperty('measured'); + expect(nodes[0]).not.toHaveProperty('dragging'); + expect(nodes[0].selected).toBe(false); + expect(edges[0].selected).toBe(false); + expect(edges[0].data).toMatchObject({ routerPointsFromAvoidNodes: [], layoutPoints: [] }); + }); + + it('returns the live store objects when asked to keep dynamic values', () => { + const state = useStore.getState(); + const { nodes, edges } = getStoreDataForIntegration({ shouldSkipDynamicValues: false }); + + expect(nodes).toBe(state.nodes); + expect(edges).toBe(state.edges); + expect(nodes[0]).toHaveProperty('measured'); + }); +}); diff --git a/packages/sdk/src/store/slices/diagram-slice/actions.ts b/packages/sdk/src/store/slices/diagram-slice/actions.ts index 3fe7e61d4..0405101dc 100644 --- a/packages/sdk/src/store/slices/diagram-slice/actions.ts +++ b/packages/sdk/src/store/slices/diagram-slice/actions.ts @@ -88,8 +88,8 @@ type GetStoreDataParams = { * persistable payload to the host — e.g. inside a `props`-strategy * `onDataSave` callback or before posting to a custom backend. * - * Dynamic, runtime-only values (selection, measured node sizes, computed - * avoid-edge points, …) are stripped by default; pass + * Dynamic, runtime-only values (selection, measured node sizes, the dragging + * flag, computed avoid-edge points, …) are stripped by default; pass * `shouldSkipDynamicValues: false` if you specifically need the live values. * * @category Store diff --git a/packages/sdk/src/store/slices/diagram-slice/utils/dynamic-values.spec.ts b/packages/sdk/src/store/slices/diagram-slice/utils/dynamic-values.spec.ts index d1939bccb..cf5065da8 100644 --- a/packages/sdk/src/store/slices/diagram-slice/utils/dynamic-values.spec.ts +++ b/packages/sdk/src/store/slices/diagram-slice/utils/dynamic-values.spec.ts @@ -4,12 +4,13 @@ import type { WorkflowBuilderEdge, WorkflowBuilderNode } from '../../../../node/ import { skipDynamicValuesInEdges, skipDynamicValuesInNodes } from './dynamic-values'; describe('skipDynamicValuesInNodes', () => { - it('drops measured sizes and selection while keeping the rest of the node', () => { + it('drops measured sizes, dragging and selection while keeping the rest of the node', () => { const node = { id: 'node-1', type: 'node', position: { x: 10, y: 20 }, selected: true, + dragging: true, measured: { width: 258, height: 64 }, data: { label: 'Node' }, } as unknown as WorkflowBuilderNode; @@ -17,6 +18,7 @@ describe('skipDynamicValuesInNodes', () => { const [result] = skipDynamicValuesInNodes([node]); expect(result).not.toHaveProperty('measured'); + expect(result).not.toHaveProperty('dragging'); expect(result.selected).toBe(false); expect(result).toMatchObject({ id: 'node-1', type: 'node', position: { x: 10, y: 20 }, data: { label: 'Node' } }); expect(node.measured).toEqual({ width: 258, height: 64 }); diff --git a/packages/sdk/src/store/slices/diagram-slice/utils/dynamic-values.ts b/packages/sdk/src/store/slices/diagram-slice/utils/dynamic-values.ts index 40a7a9621..4888ba78d 100644 --- a/packages/sdk/src/store/slices/diagram-slice/utils/dynamic-values.ts +++ b/packages/sdk/src/store/slices/diagram-slice/utils/dynamic-values.ts @@ -1,7 +1,7 @@ import type { WorkflowBuilderEdge, WorkflowBuilderNode } from '../../../../node/node-data'; export const skipDynamicValuesInNodes = (nodes: WorkflowBuilderNode[]): WorkflowBuilderNode[] => { - return nodes.map(({ measured: _measured, ...node }) => ({ + return nodes.map(({ measured: _measured, dragging: _dragging, ...node }) => ({ ...node, selected: false, })); diff --git a/packages/ui/src/components/menu/menu.spec.tsx b/packages/ui/src/components/menu/menu.spec.tsx index 3c0b0eed6..e29892976 100644 --- a/packages/ui/src/components/menu/menu.spec.tsx +++ b/packages/ui/src/components/menu/menu.spec.tsx @@ -1,26 +1,31 @@ import { act } from 'react'; -import { createRoot } from 'react-dom/client'; -import { afterEach, describe, expect, it } from 'vitest'; +import { type Root, createRoot } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { Menu } from './menu'; -const containers: HTMLDivElement[] = []; +(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; -function renderMenu(items: Parameters[0]['items']) { - const container = document.createElement('div'); +let container: HTMLDivElement; +let root: Root; + +beforeEach(() => { + container = document.createElement('div'); document.body.append(container); - containers.push(container); - const root = createRoot(container); + root = createRoot(container); +}); + +afterEach(() => { + act(() => root.unmount()); + container.remove(); +}); + +function renderMenu(items: Parameters[0]['items']) { act(() => { root.render(); }); - return root; } -afterEach(() => { - for (const container of containers.splice(0)) container.remove(); -}); - describe('Menu selection', () => { it('renders plain menu items when no item defines selected', () => { renderMenu([{ label: 'Edit' }, { label: 'Delete', destructive: true }]); diff --git a/packages/ui/src/components/menu/menu.tsx b/packages/ui/src/components/menu/menu.tsx index d57eb076d..ce9ebdeae 100644 --- a/packages/ui/src/components/menu/menu.tsx +++ b/packages/ui/src/components/menu/menu.tsx @@ -87,7 +87,9 @@ export const Menu = memo( > {hasSelection ? ( - {renderedItems} + + {renderedItems} + ) : ( renderedItems )} diff --git a/packages/ui/src/components/node/node-description/node-description.tsx b/packages/ui/src/components/node/node-description/node-description.tsx index 263707348..b64abf1de 100644 --- a/packages/ui/src/components/node/node-description/node-description.tsx +++ b/packages/ui/src/components/node/node-description/node-description.tsx @@ -13,6 +13,7 @@ export type NodeDescriptionProps = { export function NodeDescription({ label, description, disabled = false, className }: NodeDescriptionProps) { return (
+ {/* Native title until the DS Tooltip takes over; no keyboard or touch access today (follow-up: node-text-ds-tooltip). */} {label} diff --git a/packages/ui/src/components/node/node-panel/node-panel.spec.tsx b/packages/ui/src/components/node/node-panel/node-panel.spec.tsx index 4e16fd058..7f68c1b80 100644 --- a/packages/ui/src/components/node/node-panel/node-panel.spec.tsx +++ b/packages/ui/src/components/node/node-panel/node-panel.spec.tsx @@ -1,17 +1,28 @@ import { act } from 'react'; -import { createRoot } from 'react-dom/client'; -import { afterEach, describe, expect, it } from 'vitest'; +import { type Root, createRoot } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { NodePanel } from './node-panel'; -const containers: HTMLDivElement[] = []; +(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; -function renderRoot(props: { selected: boolean; disabled?: boolean }) { - const container = document.createElement('div'); +let container: HTMLDivElement; +let root: Root; + +beforeEach(() => { + container = document.createElement('div'); document.body.append(container); - containers.push(container); + root = createRoot(container); +}); + +afterEach(() => { + act(() => root.unmount()); + container.remove(); +}); + +function renderRoot(props: { selected: boolean; disabled?: boolean }) { act(() => { - createRoot(container).render( + root.render( header , @@ -20,10 +31,6 @@ function renderRoot(props: { selected: boolean; disabled?: boolean }) { return container.firstElementChild!.firstElementChild as HTMLElement; } -afterEach(() => { - for (const container of containers.splice(0)) container.remove(); -}); - describe('NodePanel.Root states', () => { it('marks the shell as selected', () => { const shell = renderRoot({ selected: true }); diff --git a/packages/ui/src/shared/styles/list-box.module.css b/packages/ui/src/shared/styles/list-box.module.css index ab233dffa..f7cb4fffb 100644 --- a/packages/ui/src/shared/styles/list-box.module.css +++ b/packages/ui/src/shared/styles/list-box.module.css @@ -45,4 +45,14 @@ transform: scaleY(0.96); } } + + /* A Base UI RadioGroup wraps the items in its own div[role="group"], which + would otherwise swallow the popup's column gap. */ + .group { + display: flex; + flex-direction: column; + align-items: center; + align-self: stretch; + gap: var(--wb-public-list-box-gap); + } }