From 0acbdf39b3baee9be1aef986987bc234f803dde7 Mon Sep 17 00:00:00 2001 From: Wojciech Krzesaj Date: Wed, 30 Sep 2026 15:58:31 +0200 Subject: [PATCH 1/4] feat(sdk): the Properties header shows the selected node --- .changeset/properties-header-node.md | 5 + packages/sdk/src/features/i18n/locales/en.ts | 1 + packages/sdk/src/features/i18n/locales/pl.ts | 1 + .../components/header/node-heading.module.css | 30 ++++++ .../components/header/node-heading.tsx | 37 +++++++ .../header/properties-bar-header.module.css | 23 ++-- .../header/properties-bar-header.tsx | 76 +++++++++---- .../properties-bar/properties-bar.spec.tsx | 102 +++++++++++++++++- .../properties-bar/properties-bar.tsx | 8 +- .../properties-bar/properties-bar.types.ts | 4 + packages/sdk/src/node/node-data.ts | 2 +- 11 files changed, 242 insertions(+), 47 deletions(-) create mode 100644 .changeset/properties-header-node.md create mode 100644 packages/sdk/src/features/properties-bar/components/header/node-heading.module.css create mode 100644 packages/sdk/src/features/properties-bar/components/header/node-heading.tsx diff --git a/.changeset/properties-header-node.md b/.changeset/properties-header-node.md new file mode 100644 index 000000000..2fecf13ea --- /dev/null +++ b/.changeset/properties-header-node.md @@ -0,0 +1,5 @@ +--- +'@workflowbuilder/sdk': minor +--- + +The Properties panel header shows the selected node's icon in its accent, its label and its type label, or the selected edge's icon, label and "Link", with the collapse toggle on the right. `PropertiesBarProps.headerLabel` is now shown only while nothing is selected. diff --git a/packages/sdk/src/features/i18n/locales/en.ts b/packages/sdk/src/features/i18n/locales/en.ts index e21f1e74a..d885952ea 100644 --- a/packages/sdk/src/features/i18n/locales/en.ts +++ b/packages/sdk/src/features/i18n/locales/en.ts @@ -71,6 +71,7 @@ export const en = { }, propertiesBar: { label: 'Properties', + edge: 'Link', deleteNode: 'Delete node', deleteEdge: 'Delete edge', }, diff --git a/packages/sdk/src/features/i18n/locales/pl.ts b/packages/sdk/src/features/i18n/locales/pl.ts index 3fd7ae020..55b8e2b79 100644 --- a/packages/sdk/src/features/i18n/locales/pl.ts +++ b/packages/sdk/src/features/i18n/locales/pl.ts @@ -73,6 +73,7 @@ export const pl = { }, propertiesBar: { label: 'Właściwości', + edge: 'Połączenie', deleteNode: 'Usuń węzeł', deleteEdge: 'Usuń krawędź', }, diff --git a/packages/sdk/src/features/properties-bar/components/header/node-heading.module.css b/packages/sdk/src/features/properties-bar/components/header/node-heading.module.css new file mode 100644 index 000000000..92f86ba5e --- /dev/null +++ b/packages/sdk/src/features/properties-bar/components/header/node-heading.module.css @@ -0,0 +1,30 @@ +.container { + display: flex; + align-items: center; + gap: var(--wb-ds-space-100); + min-width: 0; + + .text { + display: flex; + flex-direction: column; + gap: var(--wb-ds-space-25); + min-width: 0; + } + + .label, + .subtitle { + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + } + + .label { + color: var(--wb-ds-ui-text-default); + } + + .subtitle { + /* The label class's 100% line height crops descenders once overflow is hidden. */ + line-height: 1.4; + color: var(--wb-ds-ui-text-subtle-default); + } +} diff --git a/packages/sdk/src/features/properties-bar/components/header/node-heading.tsx b/packages/sdk/src/features/properties-bar/components/header/node-heading.tsx new file mode 100644 index 000000000..7fc7a3621 --- /dev/null +++ b/packages/sdk/src/features/properties-bar/components/header/node-heading.tsx @@ -0,0 +1,37 @@ +import { NodeIcon, type NodeIconAccent } from '@workflowbuilder/ui'; +import clsx from 'clsx'; + +import { Icon } from '@workflow-builder/icons'; + +import styles from './node-heading.module.css'; + +import type { IconType } from '../../../../node/common'; + +export type NodeHeadingProps = { + label: string; + /** Second line under the label, for example the node type. */ + subtitle?: string; + /** Without it the heading has no icon. */ + icon?: IconType; + accent?: NodeIconAccent; + className?: string; +}; + +export function NodeHeading({ label, subtitle, icon, accent, className }: NodeHeadingProps) { + return ( +
+ {icon && } accent={accent} />} +
+ {/* Native title until the DS Tooltip takes over; no keyboard or touch access today (follow-up: node-text-ds-tooltip). */} + + {label} + + {subtitle && ( + + {subtitle} + + )} +
+
+ ); +} diff --git a/packages/sdk/src/features/properties-bar/components/header/properties-bar-header.module.css b/packages/sdk/src/features/properties-bar/components/header/properties-bar-header.module.css index 511bf21d5..17073bb11 100644 --- a/packages/sdk/src/features/properties-bar/components/header/properties-bar-header.module.css +++ b/packages/sdk/src/features/properties-bar/components/header/properties-bar-header.module.css @@ -1,21 +1,14 @@ .header { + --wb-public-node-icon-padding: var(--wb-ds-space-125); + display: flex; - justify-content: space-between; align-items: center; - gap: 0.5rem; - - .text-container { - display: flex; - flex-direction: column; - flex-grow: 1; + gap: var(--wb-ds-space-100); + /* The collapsed panel sizes to its content, so a long label would widen it past the open panel. */ + max-width: calc(var(--wb-sdk-sidebar-expanded-width) - 2 * var(--wb-sdk-sidebar-horizontal-padding)); - p { - display: -webkit-box; - -webkit-line-clamp: 2; - line-clamp: 2; - -webkit-box-orient: vertical; - overflow: hidden; - margin: 0; - } + .heading { + flex: 1; + min-width: 0; } } diff --git a/packages/sdk/src/features/properties-bar/components/header/properties-bar-header.tsx b/packages/sdk/src/features/properties-bar/components/header/properties-bar-header.tsx index 037a8c5fe..590080bdf 100644 --- a/packages/sdk/src/features/properties-bar/components/header/properties-bar-header.tsx +++ b/packages/sdk/src/features/properties-bar/components/header/properties-bar-header.tsx @@ -5,38 +5,38 @@ import { Icon } from '@workflow-builder/icons'; import styles from './properties-bar-header.module.css'; +import { useTranslateIfPossible } from '../../../../hooks/use-translate-if-possible'; +import type { WorkflowBuilderNode } from '../../../../node/node-data'; +import { useStore } from '../../../../store/store'; +import type { SingleSelectedElement } from '../../use-single-selected-element'; +import { NodeHeading } from './node-heading'; + type Props = { - header: string; - name: string; - hasSelection: boolean; - isExpendable: boolean; + selection: SingleSelectedElement | null; + headerLabel: string; + isOpen: boolean; onTogglePropertiesBar: () => void; onDotsClick?: () => void; }; -export function PropertiesBarHeader({ - onTogglePropertiesBar, - isExpendable: isPropertiesBarOpen, - header, - hasSelection, - name, - onDotsClick, -}: Props) { +export function PropertiesBarHeader({ selection, headerLabel, isOpen, onTogglePropertiesBar, onDotsClick }: Props) { const { t } = useTranslation(); + const toggleLabel = isOpen ? t('tooltips.closePropertiesBar') : t('tooltips.openPropertiesBar'); return (
- } - /> -
- {header} - {name &&

{name}

} +
+ {selection?.node ? ( + + ) : selection?.edge ? ( + + ) : ( + {headerLabel} + )}
{onDotsClick && ( } /> )} + } + />
); } + +type SelectedNodeHeadingProps = { + node: WorkflowBuilderNode; + fallbackLabel: string; +}; + +function SelectedNodeHeading({ node, fallbackLabel }: SelectedNodeHeadingProps) { + const definition = useStore((store) => store.getNodeDefinition(node.data.type)); + const translateIfPossible = useTranslateIfPossible(); + + const typeLabel = definition && (translateIfPossible(definition.label) || definition.label); + const label = node.data.properties.label; + + return ( + + ); +} diff --git a/packages/sdk/src/features/properties-bar/components/properties-bar/properties-bar.spec.tsx b/packages/sdk/src/features/properties-bar/components/properties-bar/properties-bar.spec.tsx index c8e2e0178..123830f3b 100644 --- a/packages/sdk/src/features/properties-bar/components/properties-bar/properties-bar.spec.tsx +++ b/packages/sdk/src/features/properties-bar/components/properties-bar/properties-bar.spec.tsx @@ -1,12 +1,18 @@ -import { fireEvent, render, screen } from '@testing-library/react'; +import { cleanup, fireEvent, render, screen } from '@testing-library/react'; +import i18n from 'i18next'; import { StrictMode } from 'react'; -import { describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import type { WorkflowBuilderNode } from '../../../../node/node-data'; +import type { PaletteItem } from '../../../../node/common'; +import type { WorkflowBuilderEdge, WorkflowBuilderNode } from '../../../../node/node-data'; +import { useStore } from '../../../../store/store'; +import '../../../i18n/index'; import { PropertiesPanelFooter } from '../properties-panel-footer/properties-panel-footer'; import { PropertiesBar } from './properties-bar'; import type { PropertiesBarProps } from './properties-bar.types'; +vi.mock('@workflow-builder/icons', () => ({ Icon: ({ name }: { name: string }) => })); + const node = { id: 'node-1', type: 'node', @@ -105,6 +111,96 @@ describe('PropertiesBar footer', () => { }); }); +const actionDefinition = { + type: 'action', + icon: 'Play', + accent: 'violet', + label: 'specNodes.action', + description: 'Runs an action', +} as unknown as PaletteItem; + +function edge(label?: string): WorkflowBuilderEdge { + return { id: 'edge-1', source: 'node-1', target: 'node-2', data: { label } }; +} + +const nodeIcon = (container: HTMLElement) => container.querySelector('[data-icon="Play"]')?.parentElement; + +// Without tabs the header has no segment picker, whose first item also reads "Properties". +const renderHeader = (props: Partial = {}) => renderBar({ withContent: false, tabs: [], ...props }); + +describe('PropertiesBar header', () => { + beforeEach(() => { + i18n.addResourceBundle('en', 'translation', { specNodes: { action: 'Action' } }, true, true); + useStore.setState({ data: [actionDefinition] }); + }); + + afterEach(() => { + cleanup(); + useStore.setState(useStore.getInitialState(), true); + }); + + it('shows the node icon in its accent, the node label and the translated type label', () => { + const { container } = renderHeader(); + + expect(screen.getByText('Review')).not.toBeNull(); + expect(screen.getByText('Action')).not.toBeNull(); + expect(nodeIcon(container)?.getAttribute('style')).toContain('--wb-public-node-icon-color-violet'); + expect(screen.queryByText('Properties')).toBeNull(); + }); + + it('shows the type label, never the node description', () => { + renderHeader(); + + expect(screen.queryByText('Runs an action')).toBeNull(); + }); + + it('shows the label of a selected edge with an icon and the Link subtitle', () => { + const { container } = renderHeader({ selection: { node: null, edge: edge('Approved') } }); + + expect(screen.getByText('Approved')).not.toBeNull(); + expect(screen.getByText('Link')).not.toBeNull(); + expect(container.querySelector('[data-icon="CaretRight"]')).not.toBeNull(); + expect(screen.queryByText('Properties')).toBeNull(); + }); + + it('titles an edge without a label Link', () => { + renderHeader({ selection: { node: null, edge: edge() } }); + + expect(screen.getAllByText('Link')).toHaveLength(2); + expect(screen.queryByText('Properties')).toBeNull(); + }); + + it('shows the header label and a disabled toggle while nothing is selected', () => { + renderHeader({ selection: null }); + + expect(screen.getByText('Properties')).not.toBeNull(); + expect(button('Open properties bar')).toBeNull(); + expect((button('Close properties bar') as HTMLButtonElement).disabled).toBe(true); + expect(screen.queryByText('Form')).toBeNull(); + }); + + it('has a menu button only with onMenuHeaderClick, left of the toggle', () => { + const onMenuHeaderClick = vi.fn(); + const { rerenderBar } = renderHeader(); + expect(button('Menu')).toBeNull(); + + rerenderBar({ onMenuHeaderClick }); + fireEvent.click(button('Menu')!); + + expect(onMenuHeaderClick).toHaveBeenCalledOnce(); + expect(button('Menu')!.compareDocumentPosition(button('Close properties bar')!)).toBe( + Node.DOCUMENT_POSITION_FOLLOWING, + ); + }); + + it('exposes a long node label in full as a title', () => { + const label = 'A node label far too long to fit on a single line of the properties panel header'; + renderHeader({ selection: { node: { ...node, data: { ...node.data, properties: { label } } }, edge: null } }); + + expect(screen.getByText(label).getAttribute('title')).toBe(label); + }); +}); + describe('PropertiesPanelFooter', () => { it('renders nothing outside the properties panel', () => { render( diff --git a/packages/sdk/src/features/properties-bar/components/properties-bar/properties-bar.tsx b/packages/sdk/src/features/properties-bar/components/properties-bar/properties-bar.tsx index 8fb5464b7..2ccd2976c 100644 --- a/packages/sdk/src/features/properties-bar/components/properties-bar/properties-bar.tsx +++ b/packages/sdk/src/features/properties-bar/components/properties-bar/properties-bar.tsx @@ -36,7 +36,6 @@ function PropertiesBarComponent({ const [isPropertiesBarOpen, setIsPropertiesBarOpen] = useState(true); const footerTarget = usePropertiesPanelFooterTarget(); - const name = selection?.node?.data?.properties?.label ?? selection?.edge?.data?.label; const isExpanded = !!selection && isPropertiesBarOpen; const hasCustomItems = tabs.length > 0; const hasFooter = footerTarget.hasContent || !!onDeleteClick; @@ -83,11 +82,10 @@ function PropertiesBarComponent({ header={ <> {isExpanded && renderComponent([segmentPicker], selection, selectedTab)} diff --git a/packages/sdk/src/features/properties-bar/components/properties-bar/properties-bar.types.ts b/packages/sdk/src/features/properties-bar/components/properties-bar/properties-bar.types.ts index a2bd26796..73cd58ea3 100644 --- a/packages/sdk/src/features/properties-bar/components/properties-bar/properties-bar.types.ts +++ b/packages/sdk/src/features/properties-bar/components/properties-bar/properties-bar.types.ts @@ -29,9 +29,13 @@ export type PropertiesBarItem = { * The Delete button shows only with `onDeleteClick`; a decorator on the * `'PropertiesBar'` slot that sets it to `undefined` removes the button. * + * The header shows the selected node's icon, label and type label, or the + * selected edge's icon, label and "Link". `onMenuHeaderClick` adds a menu button to it. + * * @category Components */ export type PropertiesBarProps = PropertiesBarBaseProps & { + /** Header text while nothing is selected; a selected node or edge shows its own heading instead. */ headerLabel: string; deleteNodeLabel: string; deleteEdgeLabel: string; diff --git a/packages/sdk/src/node/node-data.ts b/packages/sdk/src/node/node-data.ts index 4821ac3bb..ca30ce33b 100644 --- a/packages/sdk/src/node/node-data.ts +++ b/packages/sdk/src/node/node-data.ts @@ -31,7 +31,7 @@ export type NodeDefinition = { uischema?: UISchema; /** describes the output properties this node produces, used by the variable picker */ outputSchema?: NodeOutputSchema; - /** icon color on the canvas and in the palette, resolved by node type and never saved into the diagram */ + /** icon color on the canvas, palette and Properties header; resolved by node type, never saved into the diagram */ accent?: NodeIconAccent; } & Required> & Pick; From 93a91452a6927b27f3091fb34537eaf7fc659379 Mon Sep 17 00:00:00 2001 From: Wojciech Krzesaj Date: Wed, 30 Sep 2026 16:03:55 +0200 Subject: [PATCH 2/4] fix(ui): the node subtitle keeps the bottom of its letters --- .changeset/node-subtitle-descenders-ui.md | 5 +++++ .changeset/node-subtitle-descenders.md | 5 +++++ .../node/node-description/node-description.module.css | 2 ++ 3 files changed, 12 insertions(+) create mode 100644 .changeset/node-subtitle-descenders-ui.md create mode 100644 .changeset/node-subtitle-descenders.md diff --git a/.changeset/node-subtitle-descenders-ui.md b/.changeset/node-subtitle-descenders-ui.md new file mode 100644 index 000000000..a2825d88b --- /dev/null +++ b/.changeset/node-subtitle-descenders-ui.md @@ -0,0 +1,5 @@ +--- +'@workflowbuilder/ui': patch +--- + +The `NodeDescription` subtitle no longer crops the bottom of letters such as g, p and y. diff --git a/.changeset/node-subtitle-descenders.md b/.changeset/node-subtitle-descenders.md new file mode 100644 index 000000000..973b39e25 --- /dev/null +++ b/.changeset/node-subtitle-descenders.md @@ -0,0 +1,5 @@ +--- +'@workflowbuilder/sdk': patch +--- + +The node subtitle no longer crops the bottom of letters such as g, p and y. 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 bb2ab5e9d..81579cb92 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 @@ -29,6 +29,8 @@ } .subtitle { + /* The label class's 100% line height crops descenders once overflow is hidden. */ + line-height: 1.4; color: var(--wb-public-node-title-subtitle); } From 82c84d8dec8275070944a0a8d7f18b3b08dce09c Mon Sep 17 00:00:00 2001 From: Wojciech Krzesaj Date: Wed, 30 Sep 2026 17:55:54 +0200 Subject: [PATCH 3/4] fix(sdk): properties header fallbacks, edge icon and panel name --- .../components/header/node-heading.tsx | 5 +- .../header/properties-bar-header.module.css | 5 +- .../header/properties-bar-header.tsx | 38 ++++++----- .../properties-bar/properties-bar.spec.tsx | 68 +++++++++++++++++-- .../properties-bar/properties-bar.tsx | 4 +- 5 files changed, 90 insertions(+), 30 deletions(-) diff --git a/packages/sdk/src/features/properties-bar/components/header/node-heading.tsx b/packages/sdk/src/features/properties-bar/components/header/node-heading.tsx index 7fc7a3621..4705749ba 100644 --- a/packages/sdk/src/features/properties-bar/components/header/node-heading.tsx +++ b/packages/sdk/src/features/properties-bar/components/header/node-heading.tsx @@ -7,17 +7,16 @@ import styles from './node-heading.module.css'; import type { IconType } from '../../../../node/common'; -export type NodeHeadingProps = { +type Props = { label: string; /** Second line under the label, for example the node type. */ subtitle?: string; - /** Without it the heading has no icon. */ icon?: IconType; accent?: NodeIconAccent; className?: string; }; -export function NodeHeading({ label, subtitle, icon, accent, className }: NodeHeadingProps) { +export function NodeHeading({ label, subtitle, icon, accent, className }: Props) { return (
{icon && } accent={accent} />} diff --git a/packages/sdk/src/features/properties-bar/components/header/properties-bar-header.module.css b/packages/sdk/src/features/properties-bar/components/header/properties-bar-header.module.css index 17073bb11..46c32dde8 100644 --- a/packages/sdk/src/features/properties-bar/components/header/properties-bar-header.module.css +++ b/packages/sdk/src/features/properties-bar/components/header/properties-bar-header.module.css @@ -4,8 +4,9 @@ display: flex; align-items: center; gap: var(--wb-ds-space-100); - /* The collapsed panel sizes to its content, so a long label would widen it past the open panel. */ - max-width: calc(var(--wb-sdk-sidebar-expanded-width) - 2 * var(--wb-sdk-sidebar-horizontal-padding)); + /* The collapsed panel sizes to its content, so a long label would widen it past the open panel. + 2px is the panel's 1px border on each side. */ + max-width: calc(var(--wb-sdk-sidebar-expanded-width) - 2 * var(--wb-sdk-sidebar-horizontal-padding) - 2px); .heading { flex: 1; diff --git a/packages/sdk/src/features/properties-bar/components/header/properties-bar-header.tsx b/packages/sdk/src/features/properties-bar/components/header/properties-bar-header.tsx index 590080bdf..f695959ac 100644 --- a/packages/sdk/src/features/properties-bar/components/header/properties-bar-header.tsx +++ b/packages/sdk/src/features/properties-bar/components/header/properties-bar-header.tsx @@ -26,17 +26,7 @@ export function PropertiesBarHeader({ selection, headerLabel, isOpen, onTogglePr return (
- {selection?.node ? ( - - ) : selection?.edge ? ( - - ) : ( - {headerLabel} - )} +
{onDotsClick && ( ; + +function SelectionHeading({ selection, headerLabel }: SelectionHeadingProps) { + const { t } = useTranslation(); + + if (selection?.node) { + return ; + } + + if (!selection?.edge) { + return {headerLabel}; + } + + const edgeType = t('propertiesBar.edge'); + const { label, icon } = selection.edge.data ?? {}; + + return ; +} -function SelectedNodeHeading({ node, fallbackLabel }: SelectedNodeHeadingProps) { +function SelectedNodeHeading({ node }: { node: WorkflowBuilderNode }) { const definition = useStore((store) => store.getNodeDefinition(node.data.type)); const translateIfPossible = useTranslateIfPossible(); @@ -72,7 +76,7 @@ function SelectedNodeHeading({ node, fallbackLabel }: SelectedNodeHeadingProps) return ( container.querySelector('[data-icon="Play"]')?.parentElement; +const nodeIcon = (container: HTMLElement, name = 'Play') => + container.querySelector(`[data-icon="${name}"]`)?.parentElement; + +const withNodeData = (data: Partial) => ({ + selection: { node: { ...node, data: { ...node.data, ...data } }, edge: null }, +}); // Without tabs the header has no segment picker, whose first item also reads "Properties". const renderHeader = (props: Partial = {}) => renderBar({ withContent: false, tabs: [], ...props }); @@ -149,11 +154,60 @@ describe('PropertiesBar header', () => { }); it('shows the type label, never the node description', () => { - renderHeader(); + renderHeader(withNodeData({ properties: { label: 'Review', description: 'Checks the request' } })); + expect(screen.getByText('Action')).not.toBeNull(); + expect(screen.queryByText('Checks the request')).toBeNull(); expect(screen.queryByText('Runs an action')).toBeNull(); }); + it('prefers the icon of the node definition over the one saved on the node', () => { + const { container } = renderHeader(); + + expect(nodeIcon(container)).toBeDefined(); + expect(nodeIcon(container, 'Lightning')).toBeUndefined(); + }); + + it('titles a node without a label with its type label, without a subtitle', () => { + renderHeader(withNodeData({ properties: {} })); + + expect(screen.getAllByText('Action')).toHaveLength(1); + }); + + it('titles an unlabelled node of an unknown type with its type and shows its saved icon', () => { + useStore.setState({ data: [] }); + const { container } = renderHeader(withNodeData({ properties: {} })); + + expect(screen.getByText('action')).not.toBeNull(); + expect(screen.queryByText('Properties')).toBeNull(); + expect(nodeIcon(container, 'Lightning')?.className).not.toMatch(/accent-/); + }); + + it('keeps the node heading and offers to open the panel after collapsing it', () => { + renderHeader(); + + fireEvent.click(button('Close properties bar')!); + + expect(screen.getByText('Review').compareDocumentPosition(button('Open properties bar')!)).toBe( + Node.DOCUMENT_POSITION_FOLLOWING, + ); + }); + + it('names the panel with the header label while a node is selected', () => { + renderHeader(); + + expect(screen.getByRole('region', { name: 'Properties' }).contains(screen.getByText('Review'))).toBe(true); + }); + + it('shows the icon of a selected edge in place of the default arrow', () => { + const { container } = renderHeader({ + selection: { node: null, edge: { ...edge(), data: { label: 'Approved', icon: 'Check' } } }, + }); + + expect(container.querySelector('[data-icon="Check"]')).not.toBeNull(); + expect(container.querySelector('[data-icon="CaretRight"]')).toBeNull(); + }); + it('shows the label of a selected edge with an icon and the Link subtitle', () => { const { container } = renderHeader({ selection: { node: null, edge: edge('Approved') } }); @@ -170,12 +224,12 @@ describe('PropertiesBar header', () => { expect(screen.queryByText('Properties')).toBeNull(); }); - it('shows the header label and a disabled toggle while nothing is selected', () => { - renderHeader({ selection: null }); + it('shows the header label, no content and a disabled Open toggle while nothing is selected', () => { + renderBar({ withContent: true, selection: null }); expect(screen.getByText('Properties')).not.toBeNull(); - expect(button('Open properties bar')).toBeNull(); - expect((button('Close properties bar') as HTMLButtonElement).disabled).toBe(true); + expect(button('Close properties bar')).toBeNull(); + expect((button('Open properties bar') as HTMLButtonElement).disabled).toBe(true); expect(screen.queryByText('Form')).toBeNull(); }); diff --git a/packages/sdk/src/features/properties-bar/components/properties-bar/properties-bar.tsx b/packages/sdk/src/features/properties-bar/components/properties-bar/properties-bar.tsx index 2ccd2976c..5e74ef1b2 100644 --- a/packages/sdk/src/features/properties-bar/components/properties-bar/properties-bar.tsx +++ b/packages/sdk/src/features/properties-bar/components/properties-bar/properties-bar.tsx @@ -77,6 +77,8 @@ function PropertiesBarComponent({ return ( From 36ebf61a1e34c60b437f17b3210a019b7279427c Mon Sep 17 00:00:00 2001 From: Wojciech Krzesaj Date: Wed, 30 Sep 2026 19:40:46 +0200 Subject: [PATCH 4/4] refactor(sdk): properties header round 2 review fixes --- .changeset/properties-header-node.md | 2 +- .../components/header/node-heading.module.css | 3 +- .../components/header/node-heading.tsx | 5 ++-- .../properties-bar/properties-bar.spec.tsx | 30 +++++++++++++------ packages/sdk/src/node/node-data.ts | 1 + .../node-description.module.css | 2 +- 6 files changed, 27 insertions(+), 16 deletions(-) diff --git a/.changeset/properties-header-node.md b/.changeset/properties-header-node.md index 2fecf13ea..4a6cd9c76 100644 --- a/.changeset/properties-header-node.md +++ b/.changeset/properties-header-node.md @@ -2,4 +2,4 @@ '@workflowbuilder/sdk': minor --- -The Properties panel header shows the selected node's icon in its accent, its label and its type label, or the selected edge's icon, label and "Link", with the collapse toggle on the right. `PropertiesBarProps.headerLabel` is now shown only while nothing is selected. +The Properties panel header shows the selected node's icon in its accent, its label and its type label, or the selected edge's icon, label and "Link", with the collapse toggle on the right. `PropertiesBarProps.headerLabel` is now shown only while nothing is selected, and always names the panel region for screen readers. diff --git a/packages/sdk/src/features/properties-bar/components/header/node-heading.module.css b/packages/sdk/src/features/properties-bar/components/header/node-heading.module.css index 92f86ba5e..a19b0bb16 100644 --- a/packages/sdk/src/features/properties-bar/components/header/node-heading.module.css +++ b/packages/sdk/src/features/properties-bar/components/header/node-heading.module.css @@ -2,7 +2,6 @@ display: flex; align-items: center; gap: var(--wb-ds-space-100); - min-width: 0; .text { display: flex; @@ -23,7 +22,7 @@ } .subtitle { - /* The label class's 100% line height crops descenders once overflow is hidden. */ + /* The 100% line height of wb-text-label-s crops descenders once overflow is hidden. */ line-height: 1.4; color: var(--wb-ds-ui-text-subtle-default); } diff --git a/packages/sdk/src/features/properties-bar/components/header/node-heading.tsx b/packages/sdk/src/features/properties-bar/components/header/node-heading.tsx index 4705749ba..fdf3313bf 100644 --- a/packages/sdk/src/features/properties-bar/components/header/node-heading.tsx +++ b/packages/sdk/src/features/properties-bar/components/header/node-heading.tsx @@ -13,12 +13,11 @@ type Props = { subtitle?: string; icon?: IconType; accent?: NodeIconAccent; - className?: string; }; -export function NodeHeading({ label, subtitle, icon, accent, className }: Props) { +export function NodeHeading({ label, subtitle, icon, accent }: Props) { return ( -
+
{icon && } accent={accent} />}
{/* Native title until the DS Tooltip takes over; no keyboard or touch access today (follow-up: node-text-ds-tooltip). */} diff --git a/packages/sdk/src/features/properties-bar/components/properties-bar/properties-bar.spec.tsx b/packages/sdk/src/features/properties-bar/components/properties-bar/properties-bar.spec.tsx index 6855e3359..d65a95967 100644 --- a/packages/sdk/src/features/properties-bar/components/properties-bar/properties-bar.spec.tsx +++ b/packages/sdk/src/features/properties-bar/components/properties-bar/properties-bar.spec.tsx @@ -5,7 +5,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { PaletteItem } from '../../../../node/common'; import type { WorkflowBuilderEdge, WorkflowBuilderNode } from '../../../../node/node-data'; -import { useStore } from '../../../../store/store'; +import { resetWorkflowStore, useStore } from '../../../../store/store'; import '../../../i18n/index'; import { PropertiesPanelFooter } from '../properties-panel-footer/properties-panel-footer'; import { PropertiesBar } from './properties-bar'; @@ -141,7 +141,7 @@ describe('PropertiesBar header', () => { afterEach(() => { cleanup(); - useStore.setState(useStore.getInitialState(), true); + resetWorkflowStore(); }); it('shows the node icon in its accent, the node label and the translated type label', () => { @@ -164,8 +164,8 @@ describe('PropertiesBar header', () => { it('prefers the icon of the node definition over the one saved on the node', () => { const { container } = renderHeader(); - expect(nodeIcon(container)).toBeDefined(); - expect(nodeIcon(container, 'Lightning')).toBeUndefined(); + expect(container.querySelector('[data-icon="Play"]')).not.toBeNull(); + expect(container.querySelector('[data-icon="Lightning"]')).toBeNull(); }); it('titles a node without a label with its type label, without a subtitle', () => { @@ -180,7 +180,7 @@ describe('PropertiesBar header', () => { expect(screen.getByText('action')).not.toBeNull(); expect(screen.queryByText('Properties')).toBeNull(); - expect(nodeIcon(container, 'Lightning')?.className).not.toMatch(/accent-/); + expect(nodeIcon(container, 'Lightning')?.className).not.toMatch(/accent/); }); it('keeps the node heading and offers to open the panel after collapsing it', () => { @@ -193,10 +193,22 @@ describe('PropertiesBar header', () => { ); }); + it('shows the content again and offers to close the panel after expanding it', () => { + renderBar({ withContent: true }); + + fireEvent.click(button('Close properties bar')!); + expect(screen.queryByText('Form')).toBeNull(); + fireEvent.click(button('Open properties bar')!); + + expect(screen.getByText('Form')).not.toBeNull(); + expect(button('Close properties bar')).not.toBeNull(); + expect(button('Open properties bar')).toBeNull(); + }); + it('names the panel with the header label while a node is selected', () => { - renderHeader(); + renderHeader({ headerLabel: 'Inspector' }); - expect(screen.getByRole('region', { name: 'Properties' }).contains(screen.getByText('Review'))).toBe(true); + expect(screen.getByRole('region', { name: 'Inspector' }).contains(screen.getByText('Review'))).toBe(true); }); it('shows the icon of a selected edge in place of the default arrow', () => { @@ -225,9 +237,9 @@ describe('PropertiesBar header', () => { }); it('shows the header label, no content and a disabled Open toggle while nothing is selected', () => { - renderBar({ withContent: true, selection: null }); + renderBar({ withContent: true, selection: null, headerLabel: 'Inspector' }); - expect(screen.getByText('Properties')).not.toBeNull(); + expect(screen.getByText('Inspector')).not.toBeNull(); expect(button('Close properties bar')).toBeNull(); expect((button('Open properties bar') as HTMLButtonElement).disabled).toBe(true); expect(screen.queryByText('Form')).toBeNull(); diff --git a/packages/sdk/src/node/node-data.ts b/packages/sdk/src/node/node-data.ts index ca30ce33b..6cb76daf4 100644 --- a/packages/sdk/src/node/node-data.ts +++ b/packages/sdk/src/node/node-data.ts @@ -65,5 +65,6 @@ export type NodeData> = { export type EdgeData = { label?: string; + /** Drawn in place of the label on a `labelEdge`, and shown in the Properties panel header. */ icon?: IconType; }; 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 81579cb92..9e7d0ac59 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 @@ -29,7 +29,7 @@ } .subtitle { - /* The label class's 100% line height crops descenders once overflow is hidden. */ + /* The 100% line height of wb-text-label-s crops descenders once overflow is hidden. */ line-height: 1.4; color: var(--wb-public-node-title-subtitle); }