diff --git a/.changeset/dry-pianos-fail.md b/.changeset/dry-pianos-fail.md new file mode 100644 index 00000000..d8c7acf4 --- /dev/null +++ b/.changeset/dry-pianos-fail.md @@ -0,0 +1,7 @@ +--- +"@xyflow/react": minor +"@xyflow/svelte": minor +"@xyflow/system": patch +--- + +Add `ariaRole` prop to nodes and edges diff --git a/.changeset/friendly-monkeys-fry.md b/.changeset/friendly-monkeys-fry.md index 5da27a70..d62a60eb 100644 --- a/.changeset/friendly-monkeys-fry.md +++ b/.changeset/friendly-monkeys-fry.md @@ -2,4 +2,4 @@ '@xyflow/svelte': patch --- -Fix data in EdgeProps not typed correctly +Fix data in `EdgeProps` that was not typed correctly diff --git a/.changeset/grumpy-spiders-play.md b/.changeset/grumpy-spiders-play.md index cf7d295f..2a3cedd6 100644 --- a/.changeset/grumpy-spiders-play.md +++ b/.changeset/grumpy-spiders-play.md @@ -1,7 +1,7 @@ --- '@xyflow/react': minor '@xyflow/svelte': minor -'@xyflow/system': minor +'@xyflow/system': patch --- Improve typing for Nodes diff --git a/.changeset/late-taxis-press.md b/.changeset/late-taxis-press.md index e48f797b..05def642 100644 --- a/.changeset/late-taxis-press.md +++ b/.changeset/late-taxis-press.md @@ -1,7 +1,7 @@ --- '@xyflow/react': minor '@xyflow/svelte': minor -'@xyflow/system': minor +'@xyflow/system': patch --- Add an `ease` and `interpolate` option to all function that alter the viewport diff --git a/.changeset/quiet-forks-visit.md b/.changeset/quiet-forks-visit.md new file mode 100644 index 00000000..89c3a0ee --- /dev/null +++ b/.changeset/quiet-forks-visit.md @@ -0,0 +1,8 @@ +--- +"@xyflow/react": minor +"@xyflow/svelte": minor +"@xyflow/system": patch +--- + +Add `ariaLabelConfig` prop for customizing UI text like aria labels and descriptions. + diff --git a/README.md b/README.md index 585dac0e..3ed80c4b 100644 --- a/README.md +++ b/README.md @@ -159,16 +159,9 @@ For releasing packages we are using [changesets](https://github.com/changesets/c 3. changset creates a PR that bumps all packages based on the changesets 4. merge changeset PR if you want to release to Github and npm -## The xyflow team - -React Flow and Svelte Flow are maintained by the team behind [xyflow](https://xyflow.com). If you need help or want to talk to us about a collaboration, reach out through our [contact form](https://xyflow.com/contact) or by joining our [Discord Server](https://discord.gg/Bqt6xrs). - -- Christopher • [Twitter](https://twitter.com/chrtze) • [Github](https://github.com/chrtze) -- Hayleigh • [Twitter](https://twitter.com/hayleighdotdev) • [Github](https://github.com/hayleigh-dot-dev) -- Abbey • [Github](https://github.com/printerscanner) -- Moritz • [Twitter](https://twitter.com/moklick) • [Github](https://github.com/moklick) -- Peter • [Github](https://github.com/peterkogo) +## Built by [xyflow](https://xyflow.com) +React Flow and Svelte Flow are maintained by the [xyflow team](https://xyflow.com/about). If you need help or want to talk to us about a collaboration, reach out through our [contact form](https://xyflow.com/contact) or by joining our [Discord Server](https://discord.gg/Bqt6xrs). ## License diff --git a/examples/react/src/App/routes.ts b/examples/react/src/App/routes.ts index 2256ce52..7bbe7890 100644 --- a/examples/react/src/App/routes.ts +++ b/examples/react/src/App/routes.ts @@ -1,3 +1,4 @@ +import A11y from '../examples/A11y'; import Basic from '../examples/Basic'; import Backgrounds from '../examples/Backgrounds'; import BrokenNodes from '../examples/BrokenNodes'; @@ -68,6 +69,11 @@ const routes: IRoute[] = [ path: 'add-node-edge-drop', component: AddNodeOnEdgeDrop, }, + { + name: 'A11y', + path: 'a11y', + component: A11y, + }, { name: 'Basic', path: 'basic', diff --git a/examples/react/src/examples/A11y/index.tsx b/examples/react/src/examples/A11y/index.tsx new file mode 100644 index 00000000..f7f0b5b2 --- /dev/null +++ b/examples/react/src/examples/A11y/index.tsx @@ -0,0 +1,94 @@ +import { MouseEvent } from 'react'; +import { + ReactFlow, + MiniMap, + Background, + BackgroundVariant, + Controls, + ReactFlowProvider, + Node, + Edge, + OnNodeDrag, + AriaLabelConfig, +} from '@xyflow/react'; + +const onNodeDrag: OnNodeDrag = (_, node: Node, nodes: Node[]) => console.log('drag', node, nodes); +const onNodeDragStart = (_: MouseEvent, node: Node, nodes: Node[]) => console.log('drag start', node, nodes); +const onNodeDragStop = (_: MouseEvent, node: Node, nodes: Node[]) => console.log('drag stop', node, nodes); +const onNodeClick = (_: MouseEvent, node: Node) => console.log('click', node); + +const initialNodes: Node[] = [ + { + id: '1', + type: 'input', + data: { label: 'A11y Node 1' }, + position: { x: 250, y: 5 }, + className: 'light', + }, + { + id: '2', + data: { label: 'Node 2' }, + position: { x: 100, y: 100 }, + className: 'light', + }, + { + id: '3', + data: { label: 'Node 3' }, + position: { x: 400, y: 100 }, + className: 'light', + }, +]; + +const initialEdges: Edge[] = [ + { id: 'e1-2', source: '1', target: '2', animated: true }, + { id: 'e1-3', source: '1', target: '3' }, +]; + +const ariaLabelConfig: Partial = { + 'node.a11yDescription.default': 'Custom Node Desc.', + 'node.a11yDescription.keyboardDisabled': 'Custom Keyboard Desc.', + 'node.a11yDescription.ariaLiveMessage': ({ direction, x, y }) => + `Custom Moved selected node ${direction}. New position, x: ${x}, y: ${y}`, + 'edge.a11yDescription.default': 'Custom Edge Desc.', + 'controls.ariaLabel': 'Custom Controls Aria Label', + 'controls.zoomIn.ariaLabel': 'Custom Zoom in', + 'controls.zoomOut.ariaLabel': 'Custom Zoom Out', + 'controls.fitView.ariaLabel': 'Custom Fit View', + 'controls.interactive.ariaLabel': 'Custom Toggle Interactivity', + 'minimap.ariaLabel': 'Custom Aria Label', +}; + +const A11y = () => { + return ( + + + + + + ); +}; + +export default function App() { + return ( + + + + ); +} diff --git a/examples/svelte/src/components/Header/Header.svelte b/examples/svelte/src/components/Header/Header.svelte index 1c251c67..f6260917 100644 --- a/examples/svelte/src/components/Header/Header.svelte +++ b/examples/svelte/src/components/Header/Header.svelte @@ -3,6 +3,7 @@ import { page } from '$app/stores'; const routes = [ + 'a11y', 'add-node-on-drop', 'color-mode', 'custom-connection-line', diff --git a/examples/svelte/src/routes/examples/a11y/+page.svelte b/examples/svelte/src/routes/examples/a11y/+page.svelte new file mode 100644 index 00000000..07f53110 --- /dev/null +++ b/examples/svelte/src/routes/examples/a11y/+page.svelte @@ -0,0 +1,45 @@ + + + + `Custom Moved selected node ${direction}. New position, x: ${x}, y: ${y}`, + 'edge.a11yDescription.default': 'Svelte Custom Edge Desc.', + 'controls.ariaLabel': 'Svelte Custom Control Aria Label', + 'controls.zoomIn.ariaLabel': 'Svelte Custom Zoom in', + 'controls.zoomOut.ariaLabel': 'Svelte Custom Zoom Out', + // 'controls.fitView.ariaLabel': 'Svelte Custom Fit View', + 'controls.interactive.ariaLabel': 'Svelte Custom Toggle Interactivity', + 'minimap.ariaLabel': 'Svelte Custom Minimap' + }} +> + + + + diff --git a/packages/react/src/additional-components/Controls/Controls.tsx b/packages/react/src/additional-components/Controls/Controls.tsx index 84337a28..128286ee 100644 --- a/packages/react/src/additional-components/Controls/Controls.tsx +++ b/packages/react/src/additional-components/Controls/Controls.tsx @@ -19,6 +19,7 @@ const selector = (s: ReactFlowState) => ({ isInteractive: s.nodesDraggable || s.nodesConnectable || s.elementsSelectable, minZoomReached: s.transform[2] <= s.minZoom, maxZoomReached: s.transform[2] >= s.maxZoom, + ariaLabelConfig: s.ariaLabelConfig, }); function ControlsComponent({ @@ -35,10 +36,10 @@ function ControlsComponent({ children, position = 'bottom-left', orientation = 'vertical', - 'aria-label': ariaLabel = 'React Flow controls', + 'aria-label': ariaLabel, }: ControlProps) { const store = useStoreApi(); - const { isInteractive, minZoomReached, maxZoomReached } = useStore(selector, shallow); + const { isInteractive, minZoomReached, maxZoomReached, ariaLabelConfig } = useStore(selector, shallow); const { zoomIn, zoomOut, fitView } = useReactFlow(); const onZoomInHandler = () => { @@ -67,22 +68,21 @@ function ControlsComponent({ }; const orientationClass = orientation === 'horizontal' ? 'horizontal' : 'vertical'; - return ( {showZoom && ( <> @@ -90,8 +90,8 @@ function ControlsComponent({ @@ -102,8 +102,8 @@ function ControlsComponent({ @@ -112,8 +112,8 @@ function ControlsComponent({ {isInteractive ? : } diff --git a/packages/react/src/additional-components/MiniMap/MiniMap.tsx b/packages/react/src/additional-components/MiniMap/MiniMap.tsx index 38e63f72..891f280e 100644 --- a/packages/react/src/additional-components/MiniMap/MiniMap.tsx +++ b/packages/react/src/additional-components/MiniMap/MiniMap.tsx @@ -36,11 +36,11 @@ const selector = (s: ReactFlowState) => { translateExtent: s.translateExtent, flowWidth: s.width, flowHeight: s.height, + ariaLabelConfig: s.ariaLabelConfig, }; }; const ARIA_LABEL_KEY = 'react-flow__minimap-desc'; - function MiniMapComponent({ style, className, @@ -63,14 +63,17 @@ function MiniMapComponent({ onNodeClick, pannable = false, zoomable = false, - ariaLabel = 'React Flow mini map', + ariaLabel, inversePan, zoomStep = 10, offsetScale = 5, }: MiniMapProps) { const store = useStoreApi(); const svg = useRef(null); - const { boundingRect, viewBB, rfId, panZoom, translateExtent, flowWidth, flowHeight } = useStore(selector, shallow); + const { boundingRect, viewBB, rfId, panZoom, translateExtent, flowWidth, flowHeight, ariaLabelConfig } = useStore( + selector, + shallow + ); const elementWidth = (style?.width as number) ?? defaultWidth; const elementHeight = (style?.height as number) ?? defaultHeight; const scaledWidth = boundingRect.width / elementWidth; @@ -130,6 +133,8 @@ function MiniMapComponent({ }, []) : undefined; + const _ariaLabel = ariaLabel ?? ariaLabelConfig['minimap.ariaLabel']; + return ( ({ ref={svg} onClick={onSvgClick} > - {ariaLabel && {ariaLabel}} + {_ariaLabel && {_ariaLabel}} + onClick={onSvgNodeClick} nodeColor={nodeColor} diff --git a/packages/react/src/additional-components/MiniMap/types.ts b/packages/react/src/additional-components/MiniMap/types.ts index 02513b10..53b01114 100644 --- a/packages/react/src/additional-components/MiniMap/types.ts +++ b/packages/react/src/additional-components/MiniMap/types.ts @@ -83,7 +83,7 @@ export type MiniMapProps = Omit s.ariaLiveMessage; +const ariaLiveSelector = (s: ReactFlowState) => s.ariaLiveMessage; +const ariaLabelConfigSelector = (s: ReactFlowState) => s.ariaLabelConfig; function AriaLiveMessage({ rfId }: { rfId: string }) { - const ariaLiveMessage = useStore(selector); + const ariaLiveMessage = useStore(ariaLiveSelector); return (
@@ -33,15 +34,17 @@ function AriaLiveMessage({ rfId }: { rfId: string }) { } export function A11yDescriptions({ rfId, disableKeyboardA11y }: { rfId: string; disableKeyboardA11y: boolean }) { + const ariaLabelConfig = useStore(ariaLabelConfigSelector); + return ( <>
- Press enter or space to select a node. - {!disableKeyboardA11y && 'You can then use the arrow keys to move the node around.'} Press delete to remove it - and escape to cancel.{' '} + {disableKeyboardA11y + ? ariaLabelConfig['node.a11yDescription.default'] + : ariaLabelConfig['node.a11yDescription.keyboardDisabled']}
- Press enter or space to select an edge. You can then press delete to remove it or escape to cancel. + {ariaLabelConfig['edge.a11yDescription.default']}
{!disableKeyboardA11y && } diff --git a/packages/react/src/components/EdgeWrapper/index.tsx b/packages/react/src/components/EdgeWrapper/index.tsx index 72c5c2f8..a5db4441 100644 --- a/packages/react/src/components/EdgeWrapper/index.tsx +++ b/packages/react/src/components/EdgeWrapper/index.tsx @@ -136,28 +136,28 @@ export function EdgeWrapper({ const onEdgeDoubleClick = onDoubleClick ? (event: React.MouseEvent) => { - onDoubleClick(event, { ...edge }); - } + onDoubleClick(event, { ...edge }); + } : undefined; const onEdgeContextMenu = onContextMenu ? (event: React.MouseEvent) => { - onContextMenu(event, { ...edge }); - } + onContextMenu(event, { ...edge }); + } : undefined; const onEdgeMouseEnter = onMouseEnter ? (event: React.MouseEvent) => { - onMouseEnter(event, { ...edge }); - } + onMouseEnter(event, { ...edge }); + } : undefined; const onEdgeMouseMove = onMouseMove ? (event: React.MouseEvent) => { - onMouseMove(event, { ...edge }); - } + onMouseMove(event, { ...edge }); + } : undefined; const onEdgeMouseLeave = onMouseLeave ? (event: React.MouseEvent) => { - onMouseLeave(event, { ...edge }); - } + onMouseLeave(event, { ...edge }); + } : undefined; const onKeyDown = (event: KeyboardEvent) => { @@ -198,7 +198,8 @@ export function EdgeWrapper({ onMouseLeave={onEdgeMouseLeave} onKeyDown={isFocusable ? onKeyDown : undefined} tabIndex={isFocusable ? 0 : undefined} - role={isFocusable ? 'button' : 'img'} + role={edge.ariaRole ?? (isFocusable ? 'group' : 'img')} + aria-roledescription={edge.ariaRoleDescription || 'edge'} data-id={id} data-testid={`rf__edge-${id}`} aria-label={ diff --git a/packages/react/src/components/NodeWrapper/index.tsx b/packages/react/src/components/NodeWrapper/index.tsx index 3560fe13..49b74174 100644 --- a/packages/react/src/components/NodeWrapper/index.tsx +++ b/packages/react/src/components/NodeWrapper/index.tsx @@ -145,10 +145,14 @@ export function NodeWrapper({ // prevent default scrolling behavior on arrow key press when node is moved event.preventDefault(); + const { ariaLabelConfig } = store.getState(); + store.setState({ - ariaLiveMessage: `Moved selected node ${event.key - .replace('Arrow', '') - .toLowerCase()}. New position, x: ${~~internals.positionAbsolute.x}, y: ${~~internals.positionAbsolute.y}`, + ariaLiveMessage: ariaLabelConfig['node.a11yDescription.ariaLiveMessage']({ + direction: event.key.replace('Arrow', '').toLowerCase(), + x: ~~internals.positionAbsolute.x, + y: ~~internals.positionAbsolute.y, + }), }); moveSelectedNodes({ @@ -223,7 +227,8 @@ export function NodeWrapper({ onKeyDown={isFocusable ? onKeyDown : undefined} tabIndex={isFocusable ? 0 : undefined} onFocus={isFocusable ? onFocus : undefined} - role={isFocusable ? 'button' : undefined} + role={node.ariaRole ?? (isFocusable ? 'group' : undefined)} + aria-roledescription={node.ariaRoleDescription || 'node'} aria-describedby={disableKeyboardA11y ? undefined : `${ARIA_NODE_DESC_KEY}-${rfId}`} aria-label={node.ariaLabel} > diff --git a/packages/react/src/components/StoreUpdater/index.tsx b/packages/react/src/components/StoreUpdater/index.tsx index 5cad0358..95b713af 100644 --- a/packages/react/src/components/StoreUpdater/index.tsx +++ b/packages/react/src/components/StoreUpdater/index.tsx @@ -5,7 +5,7 @@ */ import { useEffect, useRef } from 'react'; import { shallow } from 'zustand/shallow'; -import { infiniteExtent, type CoordinateExtent } from '@xyflow/system'; +import { infiniteExtent, type CoordinateExtent, mergeAriaLabelConfig, AriaLabelConfig } from '@xyflow/system'; import { useStore, useStoreApi } from '../../hooks/useStore'; import type { Node, Edge, ReactFlowState, ReactFlowProps, FitViewOptions } from '../../types'; @@ -68,6 +68,7 @@ const reactFlowFieldsToTrack = [ 'debug', 'autoPanSpeed', 'paneClickDistance', + 'ariaLabelConfig', ] as const; type ReactFlowFieldsToTrack = (typeof reactFlowFieldsToTrack)[number]; @@ -156,6 +157,10 @@ export function StoreUpdater( colorMode = 'light', debug, onScroll, + ariaLabelConfig, ...rest }: ReactFlowProps, ref: ForwardedRef @@ -170,6 +171,7 @@ function ReactFlow( ref={ref} className={cc(['react-flow', className, colorModeClassName])} id={id} + role="application" > ( onBeforeDelete={onBeforeDelete} paneClickDistance={paneClickDistance} debug={debug} + ariaLabelConfig={ariaLabelConfig} /> onSelectionChange={onSelectionChange} /> {children} diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index 3397a677..305acea7 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -108,6 +108,7 @@ export { type NoConnection, type NodeConnection, type OnReconnect, + type AriaLabelConfig, } from '@xyflow/system'; // we need this workaround to prevent a duplicate identifier error diff --git a/packages/react/src/store/initialState.ts b/packages/react/src/store/initialState.ts index 34507efd..15bfc1d8 100644 --- a/packages/react/src/store/initialState.ts +++ b/packages/react/src/store/initialState.ts @@ -10,6 +10,7 @@ import { NodeOrigin, initialConnection, CoordinateExtent, + defaultAriaLabelConfig, } from '@xyflow/system'; import type { Edge, FitViewOptions, InternalNode, Node, ReactFlowStore } from '../types'; @@ -141,6 +142,7 @@ const getInitialState = ({ lib: 'react', debug: false, + ariaLabelConfig: defaultAriaLabelConfig, }; }; diff --git a/packages/react/src/types/component-props.ts b/packages/react/src/types/component-props.ts index 9bea9004..d088c26b 100644 --- a/packages/react/src/types/component-props.ts +++ b/packages/react/src/types/component-props.ts @@ -21,6 +21,7 @@ import type { ColorMode, SnapGrid, OnReconnect, + AriaLabelConfig, } from '@xyflow/system'; import type { @@ -666,4 +667,9 @@ export interface ReactFlowProps; } diff --git a/packages/react/src/types/edges.ts b/packages/react/src/types/edges.ts index a5c22c75..83d74c2d 100644 --- a/packages/react/src/types/edges.ts +++ b/packages/react/src/types/edges.ts @@ -1,6 +1,13 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ -import type { CSSProperties, SVGAttributes, ReactNode, MouseEvent as ReactMouseEvent, ComponentType } from 'react'; +import type { + CSSProperties, + SVGAttributes, + ReactNode, + MouseEvent as ReactMouseEvent, + ComponentType, + AriaRole, +} from 'react'; import type { EdgeBase, BezierPathOptions, @@ -57,6 +64,11 @@ export type Edge< */ reconnectable?: boolean | HandleType; focusable?: boolean; + /** + * The ARIA role attribute for the edge, used for accessibility. + * @default "group" + */ + ariaRole?: AriaRole; }; type SmoothStepEdge = Record> = Edge< diff --git a/packages/react/src/types/nodes.ts b/packages/react/src/types/nodes.ts index 8dc213d7..d9e2671b 100644 --- a/packages/react/src/types/nodes.ts +++ b/packages/react/src/types/nodes.ts @@ -1,4 +1,4 @@ -import type { CSSProperties, MouseEvent as ReactMouseEvent } from 'react'; +import type { CSSProperties, MouseEvent as ReactMouseEvent, AriaRole } from 'react'; import type { CoordinateExtent, NodeBase, OnError, NodeProps as NodePropsBase, InternalNodeBase } from '@xyflow/system'; import { NodeTypes } from './general'; @@ -18,6 +18,12 @@ export type Node< className?: string; resizing?: boolean; focusable?: boolean; + /** + * The ARIA role attribute for the node element, used for accessibility. + * @default "group" + */ + + ariaRole?: AriaRole; }; /** diff --git a/packages/react/src/types/store.ts b/packages/react/src/types/store.ts index 1e10fe06..ba909869 100644 --- a/packages/react/src/types/store.ts +++ b/packages/react/src/types/store.ts @@ -28,6 +28,7 @@ import { type NodeChange, type EdgeChange, type ParentLookup, + type AriaLabelConfig, } from '@xyflow/system'; import type { @@ -67,7 +68,6 @@ export type ReactFlowStore = { diff --git a/packages/svelte/src/lib/components/A11yDescriptions/A11yDescriptions.svelte b/packages/svelte/src/lib/components/A11yDescriptions/A11yDescriptions.svelte index a9716478..24b49111 100644 --- a/packages/svelte/src/lib/components/A11yDescriptions/A11yDescriptions.svelte +++ b/packages/svelte/src/lib/components/A11yDescriptions/A11yDescriptions.svelte @@ -7,15 +7,12 @@
- Press enter or space to select a node. - {#if !store.disableKeyboardA11y} - You can then use the arrow keys to move the node around. - {/if} - Press delete to remove it and escape to cancel. + {store.disableKeyboardA11y + ? store.ariaLabelConfig['node.a11yDescription.default'] + : store.ariaLabelConfig['node.a11yDescription.keyboardDisabled']}
- Press enter or space to select an edge. You can then press delete to remove it or escape to - cancel. + {store.ariaLabelConfig['edge.a11yDescription.default']}
{#if !store.disableKeyboardA11y} diff --git a/packages/svelte/src/lib/components/EdgeLabel/EdgeLabel.svelte b/packages/svelte/src/lib/components/EdgeLabel/EdgeLabel.svelte index 187567d7..83ce1411 100644 --- a/packages/svelte/src/lib/components/EdgeLabel/EdgeLabel.svelte +++ b/packages/svelte/src/lib/components/EdgeLabel/EdgeLabel.svelte @@ -36,7 +36,6 @@ style:width={toPxString(width)} style:height={toPxString(height)} style:z-index={z} - role="button" tabindex="-1" onclick={() => { if (selectEdgeOnClick && id) store.handleEdgeSelection(id); diff --git a/packages/svelte/src/lib/components/EdgeWrapper/EdgeWrapper.svelte b/packages/svelte/src/lib/components/EdgeWrapper/EdgeWrapper.svelte index c1c988fd..c63d85e5 100644 --- a/packages/svelte/src/lib/components/EdgeWrapper/EdgeWrapper.svelte +++ b/packages/svelte/src/lib/components/EdgeWrapper/EdgeWrapper.svelte @@ -137,7 +137,8 @@ ? ariaLabel : `Edge from ${source} to ${target}`} aria-describedby={focusable ? `${ARIA_EDGE_DESC_KEY}-${store.flowId}` : undefined} - role={focusable ? 'button' : 'img'} + role={edge.ariaRole ?? (focusable ? 'group' : 'img')} + aria-roledescription={edge.ariaRoleDescription || 'edge'} onkeydown={focusable ? onkeydown : undefined} tabindex={focusable ? 0 : undefined} > diff --git a/packages/svelte/src/lib/components/Handle/Handle.svelte b/packages/svelte/src/lib/components/Handle/Handle.svelte index fbc0ea6e..71ccbd5a 100644 --- a/packages/svelte/src/lib/components/Handle/Handle.svelte +++ b/packages/svelte/src/lib/components/Handle/Handle.svelte @@ -44,6 +44,8 @@ ); let store = useStore(); + let ariaLabelConfig = $derived(store.ariaLabelConfig); + let prevConnections: Map | null = null; $effect.pre(() => { @@ -227,6 +229,7 @@ The Handle component is the part of a node that can be used to connect nodes. onkeypress={() => {}} {style} role="button" + aria-label={ariaLabelConfig[`handle.ariaLabel`]} tabindex="-1" {...rest} > diff --git a/packages/svelte/src/lib/components/NodeWrapper/NodeWrapper.svelte b/packages/svelte/src/lib/components/NodeWrapper/NodeWrapper.svelte index 24556d42..14790314 100644 --- a/packages/svelte/src/lib/components/NodeWrapper/NodeWrapper.svelte +++ b/packages/svelte/src/lib/components/NodeWrapper/NodeWrapper.svelte @@ -85,6 +85,7 @@ let prevTargetPosition: Position | undefined = targetPosition; let NodeComponent = $derived(store.nodeTypes[type] ?? DefaultNode); + let ariaLabelConfig = $derived(store.ariaLabelConfig); let connectableContext: ConnectableContext = { get value() { @@ -188,11 +189,11 @@ ) { // prevent default scrolling behavior on arrow key press when node is moved event.preventDefault(); - - store.ariaLiveMessage = `Moved selected node ${event.key - .replace('Arrow', '') - .toLowerCase()}. New position, x: ${node.internals.positionAbsolute.x}, y: ${node.internals.positionAbsolute.y}`; - + store.ariaLiveMessage = ariaLabelConfig['node.a11yDescription.ariaLiveMessage']({ + direction: event.key.replace('Arrow', '').toLowerCase(), + x: ~~node.internals.positionAbsolute.x, + y: ~~node.internals.positionAbsolute.y + }); store.moveSelectedNodes(arrowKeyDiffs[event.key], event.shiftKey ? 4 : 1); } } @@ -274,7 +275,8 @@ onkeydown={focusable ? onKeyDown : undefined} onfocus={focusable ? onFocus : undefined} tabIndex={focusable ? 0 : undefined} - role={focusable ? 'button' : undefined} + role={node.ariaRole ?? (focusable ? 'group' : undefined)} + aria-roledescription={node.ariaRoleDescription || 'node'} aria-describedby={store.disableKeyboardA11y ? undefined : `${ARIA_NODE_DESC_KEY}-${store.flowId}`} diff --git a/packages/svelte/src/lib/container/SvelteFlow/Wrapper.svelte b/packages/svelte/src/lib/container/SvelteFlow/Wrapper.svelte index b1353a42..0f7e39f6 100644 --- a/packages/svelte/src/lib/container/SvelteFlow/Wrapper.svelte +++ b/packages/svelte/src/lib/container/SvelteFlow/Wrapper.svelte @@ -92,6 +92,7 @@ noDragClass, noPanClass, noWheelClass, + ariaLabelConfig, ...divAttributes } = $derived(rest); /* eslint-enable @typescript-eslint/no-unused-vars */ diff --git a/packages/svelte/src/lib/container/SvelteFlow/types.ts b/packages/svelte/src/lib/container/SvelteFlow/types.ts index 3df0a991..38e07f4a 100644 --- a/packages/svelte/src/lib/container/SvelteFlow/types.ts +++ b/packages/svelte/src/lib/container/SvelteFlow/types.ts @@ -20,7 +20,8 @@ import type { OnConnectEnd, OnReconnect, OnReconnectStart, - OnReconnectEnd + OnReconnectEnd, + AriaLabelConfig } from '@xyflow/system'; import type { @@ -471,4 +472,9 @@ export type SvelteFlowProps< onselectionstart?: (event: PointerEvent) => void; /** This event handler gets called when the user finishes dragging a selection box */ onselectionend?: (event: PointerEvent) => void; + /** + * Configuration for customizable labels, descriptions, and UI text. Provided keys will override the corresponding defaults. + * Allows localization, customization of ARIA descriptions, control labels, minimap labels, and other UI strings. + */ + ariaLabelConfig?: Partial; }; diff --git a/packages/svelte/src/lib/index.ts b/packages/svelte/src/lib/index.ts index 095c8b87..2fc0befc 100644 --- a/packages/svelte/src/lib/index.ts +++ b/packages/svelte/src/lib/index.ts @@ -112,7 +112,8 @@ export { type ResizeParamsWithDirection, type ResizeDragEvent, type IsValidConnection, - type NodeConnection + type NodeConnection, + type AriaLabelConfig } from '@xyflow/system'; // system utils diff --git a/packages/svelte/src/lib/plugins/Controls/Controls.svelte b/packages/svelte/src/lib/plugins/Controls/Controls.svelte index d91588f9..be6a1d59 100644 --- a/packages/svelte/src/lib/plugins/Controls/Controls.svelte +++ b/packages/svelte/src/lib/plugins/Controls/Controls.svelte @@ -46,6 +46,7 @@ ); let minZoomReached = $derived(store.viewport.zoom <= store.minZoom); let maxZoomReached = $derived(store.viewport.zoom >= store.maxZoom); + let ariaLabelConfig = $derived(store.ariaLabelConfig); let orientationClass = $derived(orientation === 'horizontal' ? 'horizontal' : 'vertical'); const onZoomInHandler = () => { @@ -72,7 +73,7 @@ class={['svelte-flow__controls', orientationClass, className]} {position} data-testid="svelte-flow__controls" - aria-label={ariaLabel ?? 'Svelte Flow controls'} + aria-label={ariaLabelConfig['controls.ariaLabel']} {style} {...rest} > @@ -83,8 +84,8 @@ @@ -93,8 +94,8 @@ @@ -105,8 +106,8 @@ @@ -116,8 +117,8 @@ {#if isInteractive}{:else}{/if} diff --git a/packages/svelte/src/lib/plugins/Minimap/Minimap.svelte b/packages/svelte/src/lib/plugins/Minimap/Minimap.svelte index 5dcb5b3f..ac346066 100644 --- a/packages/svelte/src/lib/plugins/Minimap/Minimap.svelte +++ b/packages/svelte/src/lib/plugins/Minimap/Minimap.svelte @@ -21,7 +21,7 @@ let { position = 'bottom-right', - ariaLabel = 'Mini map', + ariaLabel, nodeStrokeColor = 'transparent', nodeColor, nodeClass = '', @@ -42,6 +42,7 @@ }: MiniMapProps = $props(); let store = $derived(useStore()); + let ariaLabelConfig = $derived(store.ariaLabelConfig); const nodeColorFunc = nodeColor === undefined ? undefined : getAttrFunction(nodeColor); const nodeStrokeColorFunc = getAttrFunction(nodeStrokeColor); @@ -113,7 +114,9 @@ zoomable }} > - {#if ariaLabel}{ariaLabel}{/if} + {#if ariaLabel ?? ariaLabelConfig['minimap.ariaLabel']} + {ariaLabel ?? ariaLabelConfig['minimap.ariaLabel']} + {/if} {#each store.nodes as userNode (userNode.id)} {@const node = store.nodeLookup.get(userNode.id)} diff --git a/packages/svelte/src/lib/store/initial-store.svelte.ts b/packages/svelte/src/lib/store/initial-store.svelte.ts index 8ba4efa4..7971f320 100644 --- a/packages/svelte/src/lib/store/initial-store.svelte.ts +++ b/packages/svelte/src/lib/store/initial-store.svelte.ts @@ -7,6 +7,7 @@ import { getViewportForBounds, updateConnectionLookup, initialConnection, + mergeAriaLabelConfig, type SelectionRect, type SnapGrid, type MarkerProps, @@ -32,7 +33,8 @@ import { type Handle, type OnReconnect, type OnReconnectStart, - type OnReconnectEnd + type OnReconnectEnd, + type AriaLabelConfig } from '@xyflow/system'; import DefaultNode from '$lib/components/nodes/DefaultNode.svelte'; @@ -289,6 +291,9 @@ export function getInitialStore['role']; }; export type BaseEdgeProps = Pick< diff --git a/packages/svelte/src/lib/types/nodes.ts b/packages/svelte/src/lib/types/nodes.ts index f861c13b..f7f65469 100644 --- a/packages/svelte/src/lib/types/nodes.ts +++ b/packages/svelte/src/lib/types/nodes.ts @@ -1,5 +1,5 @@ import type { Component } from 'svelte'; -import type { ClassValue } from 'svelte/elements'; +import type { ClassValue, HTMLAttributes } from 'svelte/elements'; import type { InternalNodeBase, NodeBase, NodeProps as NodePropsBase } from '@xyflow/system'; /** @@ -21,6 +21,11 @@ export type Node< class?: ClassValue; style?: string; focusable?: boolean; + /** + * The ARIA role attribute for the node element, used for accessibility. + * @default "group" + */ + ariaRole?: HTMLAttributes['role']; }; // @todo: currently generics for nodes are not really supported diff --git a/packages/system/src/constants.ts b/packages/system/src/constants.ts index 9bb65656..ba92d9e8 100644 --- a/packages/system/src/constants.ts +++ b/packages/system/src/constants.ts @@ -36,3 +36,29 @@ export const infiniteExtent: CoordinateExtent = [ ]; export const elementSelectionKeys = ['Enter', ' ', 'Escape']; + +export const defaultAriaLabelConfig = { + 'node.a11yDescription.default': + 'Press enter or space to select a node. Press delete to remove it and escape to cancel.', + 'node.a11yDescription.keyboardDisabled': + 'Press enter or space to select a node. You can then use the arrow keys to move the node around. Press delete to remove it and escape to cancel.', + 'node.a11yDescription.ariaLiveMessage': ({ direction, x, y }: { direction: string; x: number; y: number }) => + `Moved selected node ${direction}. New position, x: ${x}, y: ${y}`, + 'edge.a11yDescription.default': + 'Press enter or space to select an edge. You can then press delete to remove it or escape to cancel.', + + // Control elements + 'controls.ariaLabel': 'Control Panel', + 'controls.zoomIn.ariaLabel': 'Zoom In', + 'controls.zoomOut.ariaLabel': 'Zoom Out', + 'controls.fitView.ariaLabel': 'Fit View', + 'controls.interactive.ariaLabel': 'Toggle Interactivity', + + // Mini map + 'minimap.ariaLabel': 'Mini Map', + + // Handle + 'handle.ariaLabel': 'Handle', +}; + +export type AriaLabelConfig = typeof defaultAriaLabelConfig; diff --git a/packages/system/src/types/edges.ts b/packages/system/src/types/edges.ts index c0568200..3e69bf4c 100644 --- a/packages/system/src/types/edges.ts +++ b/packages/system/src/types/edges.ts @@ -40,6 +40,11 @@ export type EdgeBase< * This property sets the width of that invisible path. */ interactionWidth?: number; + /** + * A description of the edge's, used for accessibility. + * @default "edge" + */ + ariaRoleDescription?: string; }; export type SmoothStepPathOptions = { diff --git a/packages/system/src/types/nodes.ts b/packages/system/src/types/nodes.ts index 687f81ba..49012024 100644 --- a/packages/system/src/types/nodes.ts +++ b/packages/system/src/types/nodes.ts @@ -73,6 +73,11 @@ export type NodeBase< */ origin?: NodeOrigin; handles?: NodeHandle[]; + /** + * A description of the node's role, used for accessibility. + * @default "node" + */ + ariaRoleDescription?: string; measured?: { width?: number; height?: number; diff --git a/packages/system/src/utils/general.ts b/packages/system/src/utils/general.ts index 7fa4a3d8..fdfd6b75 100644 --- a/packages/system/src/utils/general.ts +++ b/packages/system/src/utils/general.ts @@ -16,6 +16,8 @@ import type { import { type Viewport } from '../types'; import { getNodePositionWithOrigin, isInternalNodeBase } from './graph'; +import { defaultAriaLabelConfig, type AriaLabelConfig } from '../constants'; + export const clamp = (val: number, min = 0, max = 1): number => Math.min(Math.max(val, min), max); export const clampPosition = ( @@ -418,3 +420,7 @@ export function withResolvers(): { }); return { promise, resolve, reject }; } + +export function mergeAriaLabelConfig(partial?: Partial): AriaLabelConfig { + return { ...defaultAriaLabelConfig, ...(partial || {}) }; +}