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/.changeset/properties-header-node.md b/.changeset/properties-header-node.md new file mode 100644 index 000000000..4a6cd9c76 --- /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, and always names the panel region for screen readers. 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..a19b0bb16 --- /dev/null +++ b/packages/sdk/src/features/properties-bar/components/header/node-heading.module.css @@ -0,0 +1,29 @@ +.container { + display: flex; + align-items: center; + gap: var(--wb-ds-space-100); + + .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 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 new file mode 100644 index 000000000..fdf3313bf --- /dev/null +++ b/packages/sdk/src/features/properties-bar/components/header/node-heading.tsx @@ -0,0 +1,35 @@ +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'; + +type Props = { + label: string; + /** Second line under the label, for example the node type. */ + subtitle?: string; + icon?: IconType; + accent?: NodeIconAccent; +}; + +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). */} + + {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..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 @@ -1,21 +1,15 @@ .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. + 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); - 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..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 @@ -5,38 +5,28 @@ 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}

} +
+
{onDotsClick && ( } /> )} + } + />
); } + +type SelectionHeadingProps = Pick; + +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 }: { node: WorkflowBuilderNode }) { + 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..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 @@ -1,17 +1,23 @@ -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 { resetWorkflowStore, 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', position: { x: 0, y: 0 }, - data: { type: 'action', icon: 'Play', properties: { label: 'Review' } }, + data: { type: 'action', icon: 'Lightning', properties: { label: 'Review' } }, } as unknown as WorkflowBuilderNode; function Decision() { @@ -105,6 +111,162 @@ 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, 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 }); + +describe('PropertiesBar header', () => { + beforeEach(() => { + i18n.addResourceBundle('en', 'translation', { specNodes: { action: 'Action' } }, true, true); + useStore.setState({ data: [actionDefinition] }); + }); + + afterEach(() => { + cleanup(); + resetWorkflowStore(); + }); + + 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(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(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', () => { + 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('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({ headerLabel: 'Inspector' }); + + 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', () => { + 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') } }); + + 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, no content and a disabled Open toggle while nothing is selected', () => { + renderBar({ withContent: true, selection: null, headerLabel: 'Inspector' }); + + 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(); + }); + + 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..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 @@ -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; @@ -78,16 +77,17 @@ function PropertiesBarComponent({ return ( {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..6cb76daf4 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; @@ -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 bb2ab5e9d..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,6 +29,8 @@ } .subtitle { + /* 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); }