From 1f440c830d60b1ab6f97bb9bdde3ef824c328d98 Mon Sep 17 00:00:00 2001 From: Jan Librowski Date: Mon, 14 Sep 2026 10:25:35 +0200 Subject: [PATCH] feat(sdk): render palette entries with the canvas Node states Design answer to the palette questions: the palette has no component of its own, an entry is the Node without ports and badge, and its states are the Node states. Hover already came from the shell; the drag preview now renders the Active state (outline and ring), and read-only entries render the Disabled variant instead of a faded copy. NodePanel.Root, NodeDescription and NodeIcon gain a disabled prop with their own public defaults; the four node templates pass it through. The palette's unused variables file and outline styling are removed. --- .changeset/node-panel-disabled.md | 5 +++ .changeset/palette-node-states.md | 5 +++ .../diagram-components/node-description.mdx | 5 +++ .../diagram-components/node-icon.mdx | 4 ++ .../diagram-components/node-panel.mdx | 6 +++ .../ai-agent-node-template.tsx | 9 +++-- .../decision-node-template.tsx | 9 +++-- .../start-node-template.tsx | 9 +++-- .../workflow-node-template.tsx | 9 +++-- .../components/items/palette-item.module.css | 4 -- .../palette/components/items/palette-item.tsx | 2 +- .../palette/node-preview-container.spec.tsx | 14 ++++++- .../palette/node-preview-container.tsx | 29 +++++++++++--- .../features/palette/palette-container.tsx | 3 +- .../sdk/src/features/palette/variables.css | 4 -- .../node-description.module.css | 12 ++++++ .../node-description.spec.tsx | 6 +++ .../node-description/node-description.tsx | 6 ++- .../node/node-icon/node-icon.module.css | 7 ++++ .../components/node/node-icon/node-icon.tsx | 6 ++- .../node/node-panel/node-panel.module.css | 8 +++- .../node/node-panel/node-panel.spec.tsx | 39 +++++++++++++++++++ .../components/node/node-panel/node-panel.tsx | 9 ++++- 23 files changed, 174 insertions(+), 36 deletions(-) create mode 100644 .changeset/node-panel-disabled.md create mode 100644 .changeset/palette-node-states.md delete mode 100644 packages/sdk/src/features/palette/variables.css create mode 100644 packages/ui/src/components/node/node-panel/node-panel.spec.tsx diff --git a/.changeset/node-panel-disabled.md b/.changeset/node-panel-disabled.md new file mode 100644 index 000000000..e6b74943a --- /dev/null +++ b/.changeset/node-panel-disabled.md @@ -0,0 +1,5 @@ +--- +'@workflowbuilder/ui': minor +--- + +`NodePanel.Root`, `NodeDescription` and `NodeIcon` accept `disabled` and render the Node Disabled variant: muted surface, title, subtitle, icon and no hover reaction. New public properties: `--wb-public-node-background-color-disabled`, `--wb-public-node-title-color-disabled`, `--wb-public-node-subtitle-color-disabled`, `--wb-public-node-icon-color-disabled`, `--wb-public-node-icon-container-background-color-disabled`. diff --git a/.changeset/palette-node-states.md b/.changeset/palette-node-states.md new file mode 100644 index 000000000..7d14e9c42 --- /dev/null +++ b/.changeset/palette-node-states.md @@ -0,0 +1,5 @@ +--- +'@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. 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 e914f4b46..2ee21548b 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,11 @@ 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. +## Disabled + +`disabled` mutes both lines with the Node Disabled text color. The node templates pass it +down together with `NodePanel.Root` and `NodeIcon`, so a whole node switches state at once. + ## Props diff --git a/apps/docs/src/content/docs/ui-library/diagram-components/node-icon.mdx b/apps/docs/src/content/docs/ui-library/diagram-components/node-icon.mdx index 6bb9c9ba4..346d3e94b 100644 --- a/apps/docs/src/content/docs/ui-library/diagram-components/node-icon.mdx +++ b/apps/docs/src/content/docs/ui-library/diagram-components/node-icon.mdx @@ -31,6 +31,10 @@ function NodeHeader({ label, description }) { } ``` +## Disabled + +`disabled` mutes the glyph and the container with the Node Disabled colors. + ## Props 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 b8b76eb99..ad151f7d1 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 @@ -63,6 +63,12 @@ function WorkflowNode({ id, icon, label, description, selected, isValid, showHan } ``` +## States + +`selected` draws the active outline and shadow. `disabled` renders the Node Disabled +variant: muted surface, title, subtitle and icon, and no hover reaction. The palette uses +it for node types that cannot be added at the moment, for example in read-only mode. + ## Parts | Part | Props | Description | diff --git a/packages/sdk/src/features/diagram/nodes/ai-agent-node-template/ai-agent-node-template.tsx b/packages/sdk/src/features/diagram/nodes/ai-agent-node-template/ai-agent-node-template.tsx index fc79969cf..d6ade1f5a 100644 --- a/packages/sdk/src/features/diagram/nodes/ai-agent-node-template/ai-agent-node-template.tsx +++ b/packages/sdk/src/features/diagram/nodes/ai-agent-node-template/ai-agent-node-template.tsx @@ -24,6 +24,8 @@ type Props = { label: string; description: string; selected?: boolean; + /** Render the Node Disabled variant (palette entries that cannot be added). */ + disabled?: boolean; isConnecting?: boolean; showHandles?: boolean; chatModel?: ItemOption | undefined; @@ -40,6 +42,7 @@ export const AiAgentNodeTemplate = memo( label, description, selected = false, + disabled = false, showHandles = true, chatModel, memoryModel, @@ -62,10 +65,10 @@ export const AiAgentNodeTemplate = memo( return ( - + - - + + {isCanvasNode && } 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 3f7c2766c..0ffa2e06e 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 @@ -21,6 +21,8 @@ type Props = { label: string; description: string; selected?: boolean; + /** Render the Node Disabled variant (palette entries that cannot be added). */ + disabled?: boolean; layoutDirection?: LayoutDirection; isConnecting?: boolean; showHandles?: boolean; @@ -37,6 +39,7 @@ export const DecisionNodeTemplate = memo( description, showHandles, selected = false, + disabled = false, isValid, decisionBranches, layoutDirection = 'RIGHT', @@ -52,10 +55,10 @@ export const DecisionNodeTemplate = memo( const handlesAlignment = getHandlesAlignment({ layoutDirection }); return ( - + - - + + diff --git a/packages/sdk/src/features/diagram/nodes/start-node-template/start-node-template.tsx b/packages/sdk/src/features/diagram/nodes/start-node-template/start-node-template.tsx index 25c1e713b..27bc67687 100644 --- a/packages/sdk/src/features/diagram/nodes/start-node-template/start-node-template.tsx +++ b/packages/sdk/src/features/diagram/nodes/start-node-template/start-node-template.tsx @@ -22,6 +22,8 @@ type StartNodeTemplateProps = { description: string; data?: NodeData; selected?: boolean; + /** Render the Node Disabled variant (palette entries that cannot be added). */ + disabled?: boolean; layoutDirection?: LayoutDirection; isConnecting?: boolean; showHandles?: boolean; @@ -37,6 +39,7 @@ const StartNodeTemplateComponent = memo( description, layoutDirection = 'RIGHT', selected = false, + disabled = false, showHandles = true, isValid, children, @@ -54,10 +57,10 @@ const StartNodeTemplateComponent = memo( return ( - + - - + + {!!children && } diff --git a/packages/sdk/src/features/diagram/nodes/workflow-node-template/workflow-node-template.tsx b/packages/sdk/src/features/diagram/nodes/workflow-node-template/workflow-node-template.tsx index 9eb025dbd..13608bb7f 100644 --- a/packages/sdk/src/features/diagram/nodes/workflow-node-template/workflow-node-template.tsx +++ b/packages/sdk/src/features/diagram/nodes/workflow-node-template/workflow-node-template.tsx @@ -46,6 +46,8 @@ export type WorkflowNodeTemplateProps

; selected?: boolean; + /** Render the Node Disabled variant (palette entries that cannot be added). */ + disabled?: boolean; layoutDirection?: LayoutDirection; isConnecting?: boolean; showHandles?: boolean; @@ -61,6 +63,7 @@ const WorkflowNodeTemplateComponent = memo( description, layoutDirection = 'RIGHT', selected = false, + disabled = false, showHandles = true, isValid, children, @@ -80,10 +83,10 @@ const WorkflowNodeTemplateComponent = memo( return ( - + - - + + {!!children && } diff --git a/packages/sdk/src/features/palette/components/items/palette-item.module.css b/packages/sdk/src/features/palette/components/items/palette-item.module.css index 3cc503c2d..a5238a53e 100644 --- a/packages/sdk/src/features/palette/components/items/palette-item.module.css +++ b/packages/sdk/src/features/palette/components/items/palette-item.module.css @@ -1,12 +1,8 @@ .item { - border-radius: 0.75rem; cursor: grab; - outline-offset: -1px; - outline: 1px solid transparent; } .disabled { cursor: default; user-select: none; - opacity: 0.5; } diff --git a/packages/sdk/src/features/palette/components/items/palette-item.tsx b/packages/sdk/src/features/palette/components/items/palette-item.tsx index ecf337864..cf898cc33 100644 --- a/packages/sdk/src/features/palette/components/items/palette-item.tsx +++ b/packages/sdk/src/features/palette/components/items/palette-item.tsx @@ -24,7 +24,7 @@ export function PaletteItem({ item, onDragStart, onMouseDown, isDisabled = false onMouseDown={() => onMouseDown(item.type)} onDragStart={onDragStart} > - + ); } diff --git a/packages/sdk/src/features/palette/node-preview-container.spec.tsx b/packages/sdk/src/features/palette/node-preview-container.spec.tsx index e5349dae4..16d7ead61 100644 --- a/packages/sdk/src/features/palette/node-preview-container.spec.tsx +++ b/packages/sdk/src/features/palette/node-preview-container.spec.tsx @@ -15,7 +15,11 @@ vi.mock('../diagram/nodes/start-node-template/start-node-template', () => ({ StartNodeTemplate: () => null, })); vi.mock('../diagram/nodes/workflow-node-template/workflow-node-template', () => ({ - WorkflowNodeTemplate: (props: WorkflowNodeTemplateProps) =>

{props.label}
, + WorkflowNodeTemplate: (props: WorkflowNodeTemplateProps) => ( +
+ {props.label} +
+ ), })); let mockNodeDefinition: PaletteItem | undefined; @@ -78,6 +82,14 @@ describe('NodePreviewContainer', () => { expect(screen.queryByTestId('custom-template')).toBeNull(); }); + it('passes the selected and disabled node states to the template', () => { + render(); + + const element = screen.getByTestId('built-in-template'); + expect(element.dataset.selected).toBe('true'); + expect(element.dataset.disabled).toBe('true'); + }); + it('renders nothing when the palette type is unknown', () => { mockNodeDefinition = undefined; diff --git a/packages/sdk/src/features/palette/node-preview-container.tsx b/packages/sdk/src/features/palette/node-preview-container.tsx index 0fc22636b..f9e7e1fa6 100644 --- a/packages/sdk/src/features/palette/node-preview-container.tsx +++ b/packages/sdk/src/features/palette/node-preview-container.tsx @@ -27,11 +27,18 @@ const BUILT_IN_TEMPLATES: NodeTemplateRegistry = { [NodeType.DecisionNode]: DecisionNodeTemplate, }; -type NodePreviewContainerProps = { +type NodeStateProps = { + /** Drag preview: the Node Active state (outline and ring). */ + selected?: boolean; + /** Unavailable palette entry: the Node Disabled state. */ + disabled?: boolean; +}; + +type NodePreviewContainerProps = NodeStateProps & { type: string; }; -export function NodePreviewContainer({ type }: NodePreviewContainerProps) { +export function NodePreviewContainer({ type, selected, disabled }: NodePreviewContainerProps) { const getNodeDefinition = useStore((state) => state.getNodeDefinition); const nodeDefinition = getNodeDefinition(type); @@ -39,14 +46,14 @@ export function NodePreviewContainer({ type }: NodePreviewContainerProps) { return; } - return ; + return ; } -type NodePreviewProps = { +type NodePreviewProps = NodeStateProps & { nodeDefinition: PaletteItem; }; -function NodePreview({ nodeDefinition }: NodePreviewProps) { +function NodePreview({ nodeDefinition, selected, disabled }: NodePreviewProps) { const { type, icon, label, description, templateType = NodeType.Node } = nodeDefinition; const translateIfPossible = useTranslateIfPossible(); @@ -58,5 +65,15 @@ function NodePreview({ nodeDefinition }: NodePreviewProps) { const templateKey = resolveReactFlowNodeType(type, templateType, custom); const TemplateComponent = custom[templateKey] ?? BUILT_IN_TEMPLATES[templateKey] ?? BUILT_IN_TEMPLATES[NodeType.Node]; - return ; + return ( + + ); } diff --git a/packages/sdk/src/features/palette/palette-container.tsx b/packages/sdk/src/features/palette/palette-container.tsx index 78544ac4c..de2d69532 100644 --- a/packages/sdk/src/features/palette/palette-container.tsx +++ b/packages/sdk/src/features/palette/palette-container.tsx @@ -1,7 +1,6 @@ import { useEffect } from 'react'; import styles from './palette-container.module.css'; -import './variables.css'; import { Sidebar } from '../../components/sidebar/sidebar'; import { useStore } from '../../store/store'; @@ -50,7 +49,7 @@ export function PaletteContainer() { /> {draggedItem && ( - + )} diff --git a/packages/sdk/src/features/palette/variables.css b/packages/sdk/src/features/palette/variables.css deleted file mode 100644 index 2915411e0..000000000 --- a/packages/sdk/src/features/palette/variables.css +++ /dev/null @@ -1,4 +0,0 @@ -:root { - --wb-sdk-palette-item-outline-color-hover: var(--wb-ds-canvas-node-stroke-hover); - --wb-sdk-palette-item-shad-color-hover: rgba(21, 21, 22, 0.1); -} diff --git a/packages/ui/src/components/node/node-description/node-description.module.css b/packages/ui/src/components/node/node-description/node-description.module.css index 9981c910b..bb2ab5e9d 100644 --- a/packages/ui/src/components/node/node-description/node-description.module.css +++ b/packages/ui/src/components/node/node-description/node-description.module.css @@ -1,6 +1,8 @@ :root { --wb-public-node-title-color: var(--wb-ds-ui-text-default); --wb-public-node-title-subtitle: var(--wb-ds-ui-text-subtle-default); + --wb-public-node-title-color-disabled: var(--wb-ds-canvas-node-text-disabled); + --wb-public-node-subtitle-color-disabled: var(--wb-ds-canvas-node-text-disabled); } @layer ui.component { @@ -29,5 +31,15 @@ .subtitle { color: var(--wb-public-node-title-subtitle); } + + &.disabled { + .title { + color: var(--wb-public-node-title-color-disabled); + } + + .subtitle { + color: var(--wb-public-node-subtitle-color-disabled); + } + } } } diff --git a/packages/ui/src/components/node/node-description/node-description.spec.tsx b/packages/ui/src/components/node/node-description/node-description.spec.tsx index 30cdd515d..3fea8e0b3 100644 --- a/packages/ui/src/components/node/node-description/node-description.spec.tsx +++ b/packages/ui/src/components/node/node-description/node-description.spec.tsx @@ -30,6 +30,12 @@ describe('NodeDescription', () => { expect(subtitle.getAttribute('title')).toBe('Sends the ticket to the right responder.'); }); + it('marks the block as disabled', () => { + act(() => root.render()); + + expect(container.firstElementChild?.className).toMatch(/disabled/); + }); + it('renders no tooltip attribute when there is no description', () => { act(() => root.render()); 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 80a8a9bd5..263707348 100644 --- a/packages/ui/src/components/node/node-description/node-description.tsx +++ b/packages/ui/src/components/node/node-description/node-description.tsx @@ -5,12 +5,14 @@ import styles from './node-description.module.css'; export type NodeDescriptionProps = { label: string; description?: string; + /** Muted title and subtitle of the Node Disabled variant. */ + disabled?: boolean; className?: string; }; -export function NodeDescription({ label, description, className }: NodeDescriptionProps) { +export function NodeDescription({ label, description, disabled = false, className }: NodeDescriptionProps) { return ( -
+
{label} diff --git a/packages/ui/src/components/node/node-icon/node-icon.module.css b/packages/ui/src/components/node/node-icon/node-icon.module.css index 1f42a5734..f2e5e123a 100644 --- a/packages/ui/src/components/node/node-icon/node-icon.module.css +++ b/packages/ui/src/components/node/node-icon/node-icon.module.css @@ -6,6 +6,8 @@ --wb-public-node-icon-color: var(--wb-ds-canvas-node-icon-primary); --wb-public-node-icon-container-border-color: var(--wb-ds-canvas-node-stroke-default); --wb-public-node-icon-container-background-color: var(--wb-ds-canvas-node-bg-secondary-default); + --wb-public-node-icon-color-disabled: var(--wb-ds-ui-icon-disabled); + --wb-public-node-icon-container-background-color-disabled: var(--wb-ds-canvas-node-bg-primary-disabled); } @layer ui.component { @@ -19,5 +21,10 @@ border-radius: var(--wb-public-node-icon-border-radius); border: var(--wb-public-node-icon-border-size) solid var(--wb-public-node-icon-container-border-color); background: var(--wb-public-node-icon-container-background-color); + + &.disabled { + color: var(--wb-public-node-icon-color-disabled); + background: var(--wb-public-node-icon-container-background-color-disabled); + } } } diff --git a/packages/ui/src/components/node/node-icon/node-icon.tsx b/packages/ui/src/components/node/node-icon/node-icon.tsx index 7060949c7..f2e818557 100644 --- a/packages/ui/src/components/node/node-icon/node-icon.tsx +++ b/packages/ui/src/components/node/node-icon/node-icon.tsx @@ -5,9 +5,11 @@ import styles from './node-icon.module.css'; export type NodeIconProps = { icon: ReactNode; + /** Muted glyph and container of the Node Disabled variant. */ + disabled?: boolean; className?: string; }; -export function NodeIcon({ icon, className }: NodeIconProps) { - return
{icon}
; +export function NodeIcon({ icon, disabled = false, className }: NodeIconProps) { + return
{icon}
; } diff --git a/packages/ui/src/components/node/node-panel/node-panel.module.css b/packages/ui/src/components/node/node-panel/node-panel.module.css index 4a8e962f0..cffac5194 100644 --- a/packages/ui/src/components/node/node-panel/node-panel.module.css +++ b/packages/ui/src/components/node/node-panel/node-panel.module.css @@ -12,6 +12,8 @@ --wb-public-node-hover-background-color: var(--wb-ds-canvas-node-bg-primary-default); --wb-public-node-border-hover-color: var(--wb-ds-canvas-node-stroke-hover); + --wb-public-node-background-color-disabled: var(--wb-ds-canvas-node-bg-primary-disabled); + --wb-public-node-border-color-selected: var(--wb-ds-canvas-node-stroke-hover); --wb-public-node-box-shadow-selected: var(--wb-ds-shadow-canvas-focus-node-x) var(--wb-ds-shadow-canvas-focus-node-y) var(--wb-ds-shadow-canvas-focus-node-blur) var(--wb-ds-shadow-canvas-focus-node-spread) @@ -19,7 +21,7 @@ } @layer ui.component { - .node-panel-wrapper:hover .container { + .node-panel-wrapper:hover .container:not(.disabled) { background: var(--wb-public-node-hover-background-color); border-color: var(--wb-public-node-border-hover-color); } @@ -42,6 +44,10 @@ box-shadow: var(--wb-public-node-box-shadow-selected); } + &.disabled { + background: var(--wb-public-node-background-color-disabled); + } + &:not(:hover) { transition: border-color var(--wb-public-transition), 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 new file mode 100644 index 000000000..4e16fd058 --- /dev/null +++ b/packages/ui/src/components/node/node-panel/node-panel.spec.tsx @@ -0,0 +1,39 @@ +import { act } from 'react'; +import { createRoot } from 'react-dom/client'; +import { afterEach, describe, expect, it } from 'vitest'; + +import { NodePanel } from './node-panel'; + +const containers: HTMLDivElement[] = []; + +function renderRoot(props: { selected: boolean; disabled?: boolean }) { + const container = document.createElement('div'); + document.body.append(container); + containers.push(container); + act(() => { + createRoot(container).render( + + header + , + ); + }); + 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 }); + expect(shell.className).toMatch(/selected/); + expect(shell.className).not.toMatch(/disabled/); + }); + + it('marks the shell as disabled', () => { + const shell = renderRoot({ selected: false, disabled: true }); + expect(shell.className).toMatch(/disabled/); + expect(shell.className).not.toMatch(/selected/); + }); +}); diff --git a/packages/ui/src/components/node/node-panel/node-panel.tsx b/packages/ui/src/components/node/node-panel/node-panel.tsx index c579a2b9d..6c8652a68 100644 --- a/packages/ui/src/components/node/node-panel/node-panel.tsx +++ b/packages/ui/src/components/node/node-panel/node-panel.tsx @@ -7,6 +7,12 @@ import nodeStyles from './node-panel.module.css'; type Props = { /** Whether the node panel is selected */ selected: boolean; + /** + * Renders the node in its disabled state (muted surface, text and icon, + * no hover). Used for node types that cannot be added right now, for + * example palette entries in read-only mode. + */ + disabled?: boolean; /** The content of the node panel */ children?: React.ReactNode; /** css className of the node panel */ @@ -64,7 +70,7 @@ const Handles = memo(function Handles({ return <>{isVisible && children}; }); -const Root = memo(function Root({ selected, children, className }: Props) { +const Root = memo(function Root({ selected, disabled = false, children, className }: Props) { const { headerComponent, contentComponent, handlesComponent, handlesAlignment, hasHandles } = useMemo(() => { const childrenArray = Children.toArray(children); @@ -91,6 +97,7 @@ const Root = memo(function Root({ selected, children, className }: Props) {