Merge branch 'main' of https://github.com/xyflow/xyflow into 5030-focus-nodes-when-user-selects-via-tab

This commit is contained in:
Abbey Yacoe
2025-06-04 12:00:34 +02:00
41 changed files with 360 additions and 84 deletions
+7
View File
@@ -0,0 +1,7 @@
---
"@xyflow/react": minor
"@xyflow/svelte": minor
"@xyflow/system": patch
---
Add `ariaRole` prop to nodes and edges
+1 -1
View File
@@ -2,4 +2,4 @@
'@xyflow/svelte': patch '@xyflow/svelte': patch
--- ---
Fix data in EdgeProps not typed correctly Fix data in `EdgeProps` that was not typed correctly
+1 -1
View File
@@ -1,7 +1,7 @@
--- ---
'@xyflow/react': minor '@xyflow/react': minor
'@xyflow/svelte': minor '@xyflow/svelte': minor
'@xyflow/system': minor '@xyflow/system': patch
--- ---
Improve typing for Nodes Improve typing for Nodes
+1 -1
View File
@@ -1,7 +1,7 @@
--- ---
'@xyflow/react': minor '@xyflow/react': minor
'@xyflow/svelte': minor '@xyflow/svelte': minor
'@xyflow/system': minor '@xyflow/system': patch
--- ---
Add an `ease` and `interpolate` option to all function that alter the viewport Add an `ease` and `interpolate` option to all function that alter the viewport
+8
View File
@@ -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.
+2 -9
View File
@@ -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 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 4. merge changeset PR if you want to release to Github and npm
## The xyflow team ## Built by [xyflow](https://xyflow.com)
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)
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 ## License
+6
View File
@@ -1,3 +1,4 @@
import A11y from '../examples/A11y';
import Basic from '../examples/Basic'; import Basic from '../examples/Basic';
import Backgrounds from '../examples/Backgrounds'; import Backgrounds from '../examples/Backgrounds';
import BrokenNodes from '../examples/BrokenNodes'; import BrokenNodes from '../examples/BrokenNodes';
@@ -68,6 +69,11 @@ const routes: IRoute[] = [
path: 'add-node-edge-drop', path: 'add-node-edge-drop',
component: AddNodeOnEdgeDrop, component: AddNodeOnEdgeDrop,
}, },
{
name: 'A11y',
path: 'a11y',
component: A11y,
},
{ {
name: 'Basic', name: 'Basic',
path: 'basic', path: 'basic',
@@ -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<AriaLabelConfig> = {
'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 (
<ReactFlow
defaultNodes={initialNodes}
defaultEdges={initialEdges}
onNodesChange={console.log}
onNodeClick={onNodeClick}
onNodeDragStop={onNodeDragStop}
onNodeDragStart={onNodeDragStart}
onNodeDrag={onNodeDrag}
className="react-flow-basic-example"
minZoom={0.2}
maxZoom={4}
fitView
selectNodesOnDrag={false}
elevateEdgesOnSelect
elevateNodesOnSelect={false}
nodeDragThreshold={0}
ariaLabelConfig={ariaLabelConfig}
>
<Background variant={BackgroundVariant.Dots} />
<MiniMap />
<Controls />
</ReactFlow>
);
};
export default function App() {
return (
<ReactFlowProvider>
<A11y />
</ReactFlowProvider>
);
}
@@ -3,6 +3,7 @@
import { page } from '$app/stores'; import { page } from '$app/stores';
const routes = [ const routes = [
'a11y',
'add-node-on-drop', 'add-node-on-drop',
'color-mode', 'color-mode',
'custom-connection-line', 'custom-connection-line',
@@ -0,0 +1,45 @@
<script lang="ts">
import { SvelteFlow, Controls, Background, MiniMap } from '@xyflow/svelte';
import '@xyflow/svelte/dist/style.css';
let nodes = $state.raw([
{
id: 'A',
position: { x: 0, y: 0 },
data: { label: 'A' }
},
{ id: 'B', position: { x: -100, y: 150 }, data: { label: 'B' } },
{ id: 'C', position: { x: 100, y: 150 }, data: { label: 'C' } },
{ id: 'D', position: { x: 0, y: 260 }, data: { label: 'D' } }
]);
let edges = $state.raw([
{ id: 'A-B', source: 'A', target: 'B' },
{ id: 'A-C', source: 'A', target: 'C' },
{ id: 'A-D', source: 'A', target: 'D' }
]);
</script>
<SvelteFlow
bind:nodes
bind:edges
fitView
ariaLabelConfig={{
'node.a11yDescription.default': 'Svelte Custom Node Desc.',
'node.a11yDescription.keyboardDisabled': 'Svelte Custom Keyboard Desc.',
'node.a11yDescription.ariaLiveMessage': ({ direction, x, y }) =>
`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'
}}
>
<Controls />
<Background />
<MiniMap />
</SvelteFlow>
@@ -19,6 +19,7 @@ const selector = (s: ReactFlowState) => ({
isInteractive: s.nodesDraggable || s.nodesConnectable || s.elementsSelectable, isInteractive: s.nodesDraggable || s.nodesConnectable || s.elementsSelectable,
minZoomReached: s.transform[2] <= s.minZoom, minZoomReached: s.transform[2] <= s.minZoom,
maxZoomReached: s.transform[2] >= s.maxZoom, maxZoomReached: s.transform[2] >= s.maxZoom,
ariaLabelConfig: s.ariaLabelConfig,
}); });
function ControlsComponent({ function ControlsComponent({
@@ -35,10 +36,10 @@ function ControlsComponent({
children, children,
position = 'bottom-left', position = 'bottom-left',
orientation = 'vertical', orientation = 'vertical',
'aria-label': ariaLabel = 'React Flow controls', 'aria-label': ariaLabel,
}: ControlProps) { }: ControlProps) {
const store = useStoreApi(); const store = useStoreApi();
const { isInteractive, minZoomReached, maxZoomReached } = useStore(selector, shallow); const { isInteractive, minZoomReached, maxZoomReached, ariaLabelConfig } = useStore(selector, shallow);
const { zoomIn, zoomOut, fitView } = useReactFlow(); const { zoomIn, zoomOut, fitView } = useReactFlow();
const onZoomInHandler = () => { const onZoomInHandler = () => {
@@ -67,22 +68,21 @@ function ControlsComponent({
}; };
const orientationClass = orientation === 'horizontal' ? 'horizontal' : 'vertical'; const orientationClass = orientation === 'horizontal' ? 'horizontal' : 'vertical';
return ( return (
<Panel <Panel
className={cc(['react-flow__controls', orientationClass, className])} className={cc(['react-flow__controls', orientationClass, className])}
position={position} position={position}
style={style} style={style}
data-testid="rf__controls" data-testid="rf__controls"
aria-label={ariaLabel} aria-label={ariaLabel ?? ariaLabelConfig['controls.ariaLabel']}
> >
{showZoom && ( {showZoom && (
<> <>
<ControlButton <ControlButton
onClick={onZoomInHandler} onClick={onZoomInHandler}
className="react-flow__controls-zoomin" className="react-flow__controls-zoomin"
title="zoom in" title={ariaLabelConfig['controls.zoomIn.ariaLabel']}
aria-label="zoom in" aria-label={ariaLabelConfig['controls.zoomIn.ariaLabel']}
disabled={maxZoomReached} disabled={maxZoomReached}
> >
<PlusIcon /> <PlusIcon />
@@ -90,8 +90,8 @@ function ControlsComponent({
<ControlButton <ControlButton
onClick={onZoomOutHandler} onClick={onZoomOutHandler}
className="react-flow__controls-zoomout" className="react-flow__controls-zoomout"
title="zoom out" title={ariaLabelConfig['controls.zoomOut.ariaLabel']}
aria-label="zoom out" aria-label={ariaLabelConfig['controls.zoomOut.ariaLabel']}
disabled={minZoomReached} disabled={minZoomReached}
> >
<MinusIcon /> <MinusIcon />
@@ -102,8 +102,8 @@ function ControlsComponent({
<ControlButton <ControlButton
className="react-flow__controls-fitview" className="react-flow__controls-fitview"
onClick={onFitViewHandler} onClick={onFitViewHandler}
title="fit view" title={ariaLabelConfig['controls.fitView.ariaLabel']}
aria-label="fit view" aria-label={ariaLabelConfig['controls.fitView.ariaLabel']}
> >
<FitViewIcon /> <FitViewIcon />
</ControlButton> </ControlButton>
@@ -112,8 +112,8 @@ function ControlsComponent({
<ControlButton <ControlButton
className="react-flow__controls-interactive" className="react-flow__controls-interactive"
onClick={onToggleInteractivity} onClick={onToggleInteractivity}
title="toggle interactivity" title={ariaLabelConfig['controls.interactive.ariaLabel']}
aria-label="toggle interactivity" aria-label={ariaLabelConfig['controls.interactive.ariaLabel']}
> >
{isInteractive ? <UnlockIcon /> : <LockIcon />} {isInteractive ? <UnlockIcon /> : <LockIcon />}
</ControlButton> </ControlButton>
@@ -36,11 +36,11 @@ const selector = (s: ReactFlowState) => {
translateExtent: s.translateExtent, translateExtent: s.translateExtent,
flowWidth: s.width, flowWidth: s.width,
flowHeight: s.height, flowHeight: s.height,
ariaLabelConfig: s.ariaLabelConfig,
}; };
}; };
const ARIA_LABEL_KEY = 'react-flow__minimap-desc'; const ARIA_LABEL_KEY = 'react-flow__minimap-desc';
function MiniMapComponent<NodeType extends Node = Node>({ function MiniMapComponent<NodeType extends Node = Node>({
style, style,
className, className,
@@ -63,14 +63,17 @@ function MiniMapComponent<NodeType extends Node = Node>({
onNodeClick, onNodeClick,
pannable = false, pannable = false,
zoomable = false, zoomable = false,
ariaLabel = 'React Flow mini map', ariaLabel,
inversePan, inversePan,
zoomStep = 10, zoomStep = 10,
offsetScale = 5, offsetScale = 5,
}: MiniMapProps<NodeType>) { }: MiniMapProps<NodeType>) {
const store = useStoreApi<NodeType>(); const store = useStoreApi<NodeType>();
const svg = useRef<SVGSVGElement>(null); const svg = useRef<SVGSVGElement>(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 elementWidth = (style?.width as number) ?? defaultWidth;
const elementHeight = (style?.height as number) ?? defaultHeight; const elementHeight = (style?.height as number) ?? defaultHeight;
const scaledWidth = boundingRect.width / elementWidth; const scaledWidth = boundingRect.width / elementWidth;
@@ -130,6 +133,8 @@ function MiniMapComponent<NodeType extends Node = Node>({
}, []) }, [])
: undefined; : undefined;
const _ariaLabel = ariaLabel ?? ariaLabelConfig['minimap.ariaLabel'];
return ( return (
<Panel <Panel
position={position} position={position}
@@ -159,7 +164,8 @@ function MiniMapComponent<NodeType extends Node = Node>({
ref={svg} ref={svg}
onClick={onSvgClick} onClick={onSvgClick}
> >
{ariaLabel && <title id={labelledBy}>{ariaLabel}</title>} {_ariaLabel && <title id={labelledBy}>{_ariaLabel}</title>}
<MiniMapNodes<NodeType> <MiniMapNodes<NodeType>
onClick={onSvgNodeClick} onClick={onSvgNodeClick}
nodeColor={nodeColor} nodeColor={nodeColor}
@@ -83,7 +83,7 @@ export type MiniMapProps<NodeType extends Node = Node> = Omit<HTMLAttributes<SVG
* There is no text inside the minimap for a screen reader to use as an accessible name, so it's * There is no text inside the minimap for a screen reader to use as an accessible name, so it's
* important we provide one to make the minimap accessible. The default is sufficient, but you may * important we provide one to make the minimap accessible. The default is sufficient, but you may
* want to replace it with something more relevant to your app or product. * want to replace it with something more relevant to your app or product.
* @default "React Flow mini map" * @default "Mini Map"
*/ */
ariaLabel?: string | null; ariaLabel?: string | null;
/** Invert direction when panning the minimap viewport. */ /** Invert direction when panning the minimap viewport. */
@@ -20,10 +20,11 @@ export const ARIA_NODE_DESC_KEY = 'react-flow__node-desc';
export const ARIA_EDGE_DESC_KEY = 'react-flow__edge-desc'; export const ARIA_EDGE_DESC_KEY = 'react-flow__edge-desc';
export const ARIA_LIVE_MESSAGE = 'react-flow__aria-live'; export const ARIA_LIVE_MESSAGE = 'react-flow__aria-live';
const selector = (s: ReactFlowState) => s.ariaLiveMessage; const ariaLiveSelector = (s: ReactFlowState) => s.ariaLiveMessage;
const ariaLabelConfigSelector = (s: ReactFlowState) => s.ariaLabelConfig;
function AriaLiveMessage({ rfId }: { rfId: string }) { function AriaLiveMessage({ rfId }: { rfId: string }) {
const ariaLiveMessage = useStore(selector); const ariaLiveMessage = useStore(ariaLiveSelector);
return ( return (
<div id={`${ARIA_LIVE_MESSAGE}-${rfId}`} aria-live="assertive" aria-atomic="true" style={ariaLiveStyle}> <div id={`${ARIA_LIVE_MESSAGE}-${rfId}`} aria-live="assertive" aria-atomic="true" style={ariaLiveStyle}>
@@ -33,15 +34,17 @@ function AriaLiveMessage({ rfId }: { rfId: string }) {
} }
export function A11yDescriptions({ rfId, disableKeyboardA11y }: { rfId: string; disableKeyboardA11y: boolean }) { export function A11yDescriptions({ rfId, disableKeyboardA11y }: { rfId: string; disableKeyboardA11y: boolean }) {
const ariaLabelConfig = useStore(ariaLabelConfigSelector);
return ( return (
<> <>
<div id={`${ARIA_NODE_DESC_KEY}-${rfId}`} style={style}> <div id={`${ARIA_NODE_DESC_KEY}-${rfId}`} style={style}>
Press enter or space to select a node. {disableKeyboardA11y
{!disableKeyboardA11y && 'You can then use the arrow keys to move the node around.'} Press delete to remove it ? ariaLabelConfig['node.a11yDescription.default']
and escape to cancel.{' '} : ariaLabelConfig['node.a11yDescription.keyboardDisabled']}
</div> </div>
<div id={`${ARIA_EDGE_DESC_KEY}-${rfId}`} style={style}> <div id={`${ARIA_EDGE_DESC_KEY}-${rfId}`} style={style}>
Press enter or space to select an edge. You can then press delete to remove it or escape to cancel. {ariaLabelConfig['edge.a11yDescription.default']}
</div> </div>
{!disableKeyboardA11y && <AriaLiveMessage rfId={rfId} />} {!disableKeyboardA11y && <AriaLiveMessage rfId={rfId} />}
</> </>
@@ -136,28 +136,28 @@ export function EdgeWrapper<EdgeType extends Edge = Edge>({
const onEdgeDoubleClick = onDoubleClick const onEdgeDoubleClick = onDoubleClick
? (event: React.MouseEvent) => { ? (event: React.MouseEvent) => {
onDoubleClick(event, { ...edge }); onDoubleClick(event, { ...edge });
} }
: undefined; : undefined;
const onEdgeContextMenu = onContextMenu const onEdgeContextMenu = onContextMenu
? (event: React.MouseEvent) => { ? (event: React.MouseEvent) => {
onContextMenu(event, { ...edge }); onContextMenu(event, { ...edge });
} }
: undefined; : undefined;
const onEdgeMouseEnter = onMouseEnter const onEdgeMouseEnter = onMouseEnter
? (event: React.MouseEvent) => { ? (event: React.MouseEvent) => {
onMouseEnter(event, { ...edge }); onMouseEnter(event, { ...edge });
} }
: undefined; : undefined;
const onEdgeMouseMove = onMouseMove const onEdgeMouseMove = onMouseMove
? (event: React.MouseEvent) => { ? (event: React.MouseEvent) => {
onMouseMove(event, { ...edge }); onMouseMove(event, { ...edge });
} }
: undefined; : undefined;
const onEdgeMouseLeave = onMouseLeave const onEdgeMouseLeave = onMouseLeave
? (event: React.MouseEvent) => { ? (event: React.MouseEvent) => {
onMouseLeave(event, { ...edge }); onMouseLeave(event, { ...edge });
} }
: undefined; : undefined;
const onKeyDown = (event: KeyboardEvent) => { const onKeyDown = (event: KeyboardEvent) => {
@@ -198,7 +198,8 @@ export function EdgeWrapper<EdgeType extends Edge = Edge>({
onMouseLeave={onEdgeMouseLeave} onMouseLeave={onEdgeMouseLeave}
onKeyDown={isFocusable ? onKeyDown : undefined} onKeyDown={isFocusable ? onKeyDown : undefined}
tabIndex={isFocusable ? 0 : undefined} tabIndex={isFocusable ? 0 : undefined}
role={isFocusable ? 'button' : 'img'} role={edge.ariaRole ?? (isFocusable ? 'group' : 'img')}
aria-roledescription={edge.ariaRoleDescription || 'edge'}
data-id={id} data-id={id}
data-testid={`rf__edge-${id}`} data-testid={`rf__edge-${id}`}
aria-label={ aria-label={
@@ -145,10 +145,14 @@ export function NodeWrapper<NodeType extends Node>({
// prevent default scrolling behavior on arrow key press when node is moved // prevent default scrolling behavior on arrow key press when node is moved
event.preventDefault(); event.preventDefault();
const { ariaLabelConfig } = store.getState();
store.setState({ store.setState({
ariaLiveMessage: `Moved selected node ${event.key ariaLiveMessage: ariaLabelConfig['node.a11yDescription.ariaLiveMessage']({
.replace('Arrow', '') direction: event.key.replace('Arrow', '').toLowerCase(),
.toLowerCase()}. New position, x: ${~~internals.positionAbsolute.x}, y: ${~~internals.positionAbsolute.y}`, x: ~~internals.positionAbsolute.x,
y: ~~internals.positionAbsolute.y,
}),
}); });
moveSelectedNodes({ moveSelectedNodes({
@@ -223,7 +227,8 @@ export function NodeWrapper<NodeType extends Node>({
onKeyDown={isFocusable ? onKeyDown : undefined} onKeyDown={isFocusable ? onKeyDown : undefined}
tabIndex={isFocusable ? 0 : undefined} tabIndex={isFocusable ? 0 : undefined}
onFocus={isFocusable ? onFocus : 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-describedby={disableKeyboardA11y ? undefined : `${ARIA_NODE_DESC_KEY}-${rfId}`}
aria-label={node.ariaLabel} aria-label={node.ariaLabel}
> >
@@ -5,7 +5,7 @@
*/ */
import { useEffect, useRef } from 'react'; import { useEffect, useRef } from 'react';
import { shallow } from 'zustand/shallow'; 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 { useStore, useStoreApi } from '../../hooks/useStore';
import type { Node, Edge, ReactFlowState, ReactFlowProps, FitViewOptions } from '../../types'; import type { Node, Edge, ReactFlowState, ReactFlowProps, FitViewOptions } from '../../types';
@@ -68,6 +68,7 @@ const reactFlowFieldsToTrack = [
'debug', 'debug',
'autoPanSpeed', 'autoPanSpeed',
'paneClickDistance', 'paneClickDistance',
'ariaLabelConfig',
] as const; ] as const;
type ReactFlowFieldsToTrack = (typeof reactFlowFieldsToTrack)[number]; type ReactFlowFieldsToTrack = (typeof reactFlowFieldsToTrack)[number];
@@ -156,6 +157,10 @@ export function StoreUpdater<NodeType extends Node = Node, EdgeType extends Edge
// Renamed fields // Renamed fields
else if (fieldName === 'fitView') store.setState({ fitViewQueued: fieldValue as boolean }); else if (fieldName === 'fitView') store.setState({ fitViewQueued: fieldValue as boolean });
else if (fieldName === 'fitViewOptions') store.setState({ fitViewOptions: fieldValue as FitViewOptions }); else if (fieldName === 'fitViewOptions') store.setState({ fitViewOptions: fieldValue as FitViewOptions });
if (fieldName === 'ariaLabelConfig') {
store.setState({ ariaLabelConfig: mergeAriaLabelConfig(fieldValue as AriaLabelConfig) });
}
// General case // General case
else store.setState({ [fieldName]: fieldValue }); else store.setState({ [fieldName]: fieldValue });
} }
@@ -145,6 +145,7 @@ function ReactFlow<NodeType extends Node = Node, EdgeType extends Edge = Edge>(
colorMode = 'light', colorMode = 'light',
debug, debug,
onScroll, onScroll,
ariaLabelConfig,
...rest ...rest
}: ReactFlowProps<NodeType, EdgeType>, }: ReactFlowProps<NodeType, EdgeType>,
ref: ForwardedRef<HTMLDivElement> ref: ForwardedRef<HTMLDivElement>
@@ -170,6 +171,7 @@ function ReactFlow<NodeType extends Node = Node, EdgeType extends Edge = Edge>(
ref={ref} ref={ref}
className={cc(['react-flow', className, colorModeClassName])} className={cc(['react-flow', className, colorModeClassName])}
id={id} id={id}
role="application"
> >
<Wrapper <Wrapper
nodes={nodes} nodes={nodes}
@@ -305,6 +307,7 @@ function ReactFlow<NodeType extends Node = Node, EdgeType extends Edge = Edge>(
onBeforeDelete={onBeforeDelete} onBeforeDelete={onBeforeDelete}
paneClickDistance={paneClickDistance} paneClickDistance={paneClickDistance}
debug={debug} debug={debug}
ariaLabelConfig={ariaLabelConfig}
/> />
<SelectionListener<NodeType, EdgeType> onSelectionChange={onSelectionChange} /> <SelectionListener<NodeType, EdgeType> onSelectionChange={onSelectionChange} />
{children} {children}
+1
View File
@@ -108,6 +108,7 @@ export {
type NoConnection, type NoConnection,
type NodeConnection, type NodeConnection,
type OnReconnect, type OnReconnect,
type AriaLabelConfig,
} from '@xyflow/system'; } from '@xyflow/system';
// we need this workaround to prevent a duplicate identifier error // we need this workaround to prevent a duplicate identifier error
+2
View File
@@ -10,6 +10,7 @@ import {
NodeOrigin, NodeOrigin,
initialConnection, initialConnection,
CoordinateExtent, CoordinateExtent,
defaultAriaLabelConfig,
} from '@xyflow/system'; } from '@xyflow/system';
import type { Edge, FitViewOptions, InternalNode, Node, ReactFlowStore } from '../types'; import type { Edge, FitViewOptions, InternalNode, Node, ReactFlowStore } from '../types';
@@ -141,6 +142,7 @@ const getInitialState = ({
lib: 'react', lib: 'react',
debug: false, debug: false,
ariaLabelConfig: defaultAriaLabelConfig,
}; };
}; };
@@ -21,6 +21,7 @@ import type {
ColorMode, ColorMode,
SnapGrid, SnapGrid,
OnReconnect, OnReconnect,
AriaLabelConfig,
} from '@xyflow/system'; } from '@xyflow/system';
import type { import type {
@@ -666,4 +667,9 @@ export interface ReactFlowProps<NodeType extends Node = Node, EdgeType extends E
* @default false * @default false
*/ */
debug?: boolean; debug?: boolean;
/**
* 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<AriaLabelConfig>;
} }
+13 -1
View File
@@ -1,6 +1,13 @@
/* eslint-disable @typescript-eslint/no-explicit-any */ /* 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 { import type {
EdgeBase, EdgeBase,
BezierPathOptions, BezierPathOptions,
@@ -57,6 +64,11 @@ export type Edge<
*/ */
reconnectable?: boolean | HandleType; reconnectable?: boolean | HandleType;
focusable?: boolean; focusable?: boolean;
/**
* The ARIA role attribute for the edge, used for accessibility.
* @default "group"
*/
ariaRole?: AriaRole;
}; };
type SmoothStepEdge<EdgeData extends Record<string, unknown> = Record<string, unknown>> = Edge< type SmoothStepEdge<EdgeData extends Record<string, unknown> = Record<string, unknown>> = Edge<
+7 -1
View File
@@ -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 type { CoordinateExtent, NodeBase, OnError, NodeProps as NodePropsBase, InternalNodeBase } from '@xyflow/system';
import { NodeTypes } from './general'; import { NodeTypes } from './general';
@@ -18,6 +18,12 @@ export type Node<
className?: string; className?: string;
resizing?: boolean; resizing?: boolean;
focusable?: boolean; focusable?: boolean;
/**
* The ARIA role attribute for the node element, used for accessibility.
* @default "group"
*/
ariaRole?: AriaRole;
}; };
/** /**
+2 -1
View File
@@ -28,6 +28,7 @@ import {
type NodeChange, type NodeChange,
type EdgeChange, type EdgeChange,
type ParentLookup, type ParentLookup,
type AriaLabelConfig,
} from '@xyflow/system'; } from '@xyflow/system';
import type { import type {
@@ -67,7 +68,6 @@ export type ReactFlowStore<NodeType extends Node = Node, EdgeType extends Edge =
domNode: HTMLDivElement | null; domNode: HTMLDivElement | null;
paneDragging: boolean; paneDragging: boolean;
noPanClassName: string; noPanClassName: string;
panZoom: PanZoomInstance | null; panZoom: PanZoomInstance | null;
minZoom: number; minZoom: number;
maxZoom: number; maxZoom: number;
@@ -148,6 +148,7 @@ export type ReactFlowStore<NodeType extends Node = Node, EdgeType extends Edge =
lib: string; lib: string;
debug: boolean; debug: boolean;
ariaLabelConfig: AriaLabelConfig;
}; };
export type ReactFlowActions<NodeType extends Node, EdgeType extends Edge> = { export type ReactFlowActions<NodeType extends Node, EdgeType extends Edge> = {
@@ -7,15 +7,12 @@
</script> </script>
<div id={`${ARIA_NODE_DESC_KEY}-${store.flowId}`} class="a11y-hidden"> <div id={`${ARIA_NODE_DESC_KEY}-${store.flowId}`} class="a11y-hidden">
Press enter or space to select a node. {store.disableKeyboardA11y
{#if !store.disableKeyboardA11y} ? store.ariaLabelConfig['node.a11yDescription.default']
You can then use the arrow keys to move the node around. : store.ariaLabelConfig['node.a11yDescription.keyboardDisabled']}
{/if}
Press delete to remove it and escape to cancel.
</div> </div>
<div id={`${ARIA_EDGE_DESC_KEY}-${store.flowId}`} class="a11y-hidden"> <div id={`${ARIA_EDGE_DESC_KEY}-${store.flowId}`} class="a11y-hidden">
Press enter or space to select an edge. You can then press delete to remove it or escape to {store.ariaLabelConfig['edge.a11yDescription.default']}
cancel.
</div> </div>
{#if !store.disableKeyboardA11y} {#if !store.disableKeyboardA11y}
@@ -36,7 +36,6 @@
style:width={toPxString(width)} style:width={toPxString(width)}
style:height={toPxString(height)} style:height={toPxString(height)}
style:z-index={z} style:z-index={z}
role="button"
tabindex="-1" tabindex="-1"
onclick={() => { onclick={() => {
if (selectEdgeOnClick && id) store.handleEdgeSelection(id); if (selectEdgeOnClick && id) store.handleEdgeSelection(id);
@@ -137,7 +137,8 @@
? ariaLabel ? ariaLabel
: `Edge from ${source} to ${target}`} : `Edge from ${source} to ${target}`}
aria-describedby={focusable ? `${ARIA_EDGE_DESC_KEY}-${store.flowId}` : undefined} 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} onkeydown={focusable ? onkeydown : undefined}
tabindex={focusable ? 0 : undefined} tabindex={focusable ? 0 : undefined}
> >
@@ -44,6 +44,8 @@
); );
let store = useStore(); let store = useStore();
let ariaLabelConfig = $derived(store.ariaLabelConfig);
let prevConnections: Map<string, HandleConnection> | null = null; let prevConnections: Map<string, HandleConnection> | null = null;
$effect.pre(() => { $effect.pre(() => {
@@ -227,6 +229,7 @@ The Handle component is the part of a node that can be used to connect nodes.
onkeypress={() => {}} onkeypress={() => {}}
{style} {style}
role="button" role="button"
aria-label={ariaLabelConfig[`handle.ariaLabel`]}
tabindex="-1" tabindex="-1"
{...rest} {...rest}
> >
@@ -85,6 +85,7 @@
let prevTargetPosition: Position | undefined = targetPosition; let prevTargetPosition: Position | undefined = targetPosition;
let NodeComponent = $derived(store.nodeTypes[type] ?? DefaultNode); let NodeComponent = $derived(store.nodeTypes[type] ?? DefaultNode);
let ariaLabelConfig = $derived(store.ariaLabelConfig);
let connectableContext: ConnectableContext = { let connectableContext: ConnectableContext = {
get value() { get value() {
@@ -188,11 +189,11 @@
) { ) {
// prevent default scrolling behavior on arrow key press when node is moved // prevent default scrolling behavior on arrow key press when node is moved
event.preventDefault(); event.preventDefault();
store.ariaLiveMessage = ariaLabelConfig['node.a11yDescription.ariaLiveMessage']({
store.ariaLiveMessage = `Moved selected node ${event.key direction: event.key.replace('Arrow', '').toLowerCase(),
.replace('Arrow', '') x: ~~node.internals.positionAbsolute.x,
.toLowerCase()}. New position, x: ${node.internals.positionAbsolute.x}, y: ${node.internals.positionAbsolute.y}`; y: ~~node.internals.positionAbsolute.y
});
store.moveSelectedNodes(arrowKeyDiffs[event.key], event.shiftKey ? 4 : 1); store.moveSelectedNodes(arrowKeyDiffs[event.key], event.shiftKey ? 4 : 1);
} }
} }
@@ -274,7 +275,8 @@
onkeydown={focusable ? onKeyDown : undefined} onkeydown={focusable ? onKeyDown : undefined}
onfocus={focusable ? onFocus : undefined} onfocus={focusable ? onFocus : undefined}
tabIndex={focusable ? 0 : undefined} tabIndex={focusable ? 0 : undefined}
role={focusable ? 'button' : undefined} role={node.ariaRole ?? (focusable ? 'group' : undefined)}
aria-roledescription={node.ariaRoleDescription || 'node'}
aria-describedby={store.disableKeyboardA11y aria-describedby={store.disableKeyboardA11y
? undefined ? undefined
: `${ARIA_NODE_DESC_KEY}-${store.flowId}`} : `${ARIA_NODE_DESC_KEY}-${store.flowId}`}
@@ -92,6 +92,7 @@
noDragClass, noDragClass,
noPanClass, noPanClass,
noWheelClass, noWheelClass,
ariaLabelConfig,
...divAttributes ...divAttributes
} = $derived(rest); } = $derived(rest);
/* eslint-enable @typescript-eslint/no-unused-vars */ /* eslint-enable @typescript-eslint/no-unused-vars */
@@ -20,7 +20,8 @@ import type {
OnConnectEnd, OnConnectEnd,
OnReconnect, OnReconnect,
OnReconnectStart, OnReconnectStart,
OnReconnectEnd OnReconnectEnd,
AriaLabelConfig
} from '@xyflow/system'; } from '@xyflow/system';
import type { import type {
@@ -471,4 +472,9 @@ export type SvelteFlowProps<
onselectionstart?: (event: PointerEvent) => void; onselectionstart?: (event: PointerEvent) => void;
/** This event handler gets called when the user finishes dragging a selection box */ /** This event handler gets called when the user finishes dragging a selection box */
onselectionend?: (event: PointerEvent) => void; 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<AriaLabelConfig>;
}; };
+2 -1
View File
@@ -112,7 +112,8 @@ export {
type ResizeParamsWithDirection, type ResizeParamsWithDirection,
type ResizeDragEvent, type ResizeDragEvent,
type IsValidConnection, type IsValidConnection,
type NodeConnection type NodeConnection,
type AriaLabelConfig
} from '@xyflow/system'; } from '@xyflow/system';
// system utils // system utils
@@ -46,6 +46,7 @@
); );
let minZoomReached = $derived(store.viewport.zoom <= store.minZoom); let minZoomReached = $derived(store.viewport.zoom <= store.minZoom);
let maxZoomReached = $derived(store.viewport.zoom >= store.maxZoom); let maxZoomReached = $derived(store.viewport.zoom >= store.maxZoom);
let ariaLabelConfig = $derived(store.ariaLabelConfig);
let orientationClass = $derived(orientation === 'horizontal' ? 'horizontal' : 'vertical'); let orientationClass = $derived(orientation === 'horizontal' ? 'horizontal' : 'vertical');
const onZoomInHandler = () => { const onZoomInHandler = () => {
@@ -72,7 +73,7 @@
class={['svelte-flow__controls', orientationClass, className]} class={['svelte-flow__controls', orientationClass, className]}
{position} {position}
data-testid="svelte-flow__controls" data-testid="svelte-flow__controls"
aria-label={ariaLabel ?? 'Svelte Flow controls'} aria-label={ariaLabelConfig['controls.ariaLabel']}
{style} {style}
{...rest} {...rest}
> >
@@ -83,8 +84,8 @@
<ControlButton <ControlButton
onclick={onZoomInHandler} onclick={onZoomInHandler}
class="svelte-flow__controls-zoomin" class="svelte-flow__controls-zoomin"
title="zoom in" title={ariaLabelConfig['controls.zoomIn.ariaLabel']}
aria-label="zoom in" aria-label={ariaLabelConfig['controls.zoomIn.ariaLabel']}
disabled={maxZoomReached} disabled={maxZoomReached}
{...buttonProps} {...buttonProps}
> >
@@ -93,8 +94,8 @@
<ControlButton <ControlButton
onclick={onZoomOutHandler} onclick={onZoomOutHandler}
class="svelte-flow__controls-zoomout" class="svelte-flow__controls-zoomout"
title="zoom out" title={ariaLabelConfig['controls.zoomOut.ariaLabel']}
aria-label="zoom out" aria-label={ariaLabelConfig['controls.zoomOut.ariaLabel']}
disabled={minZoomReached} disabled={minZoomReached}
{...buttonProps} {...buttonProps}
> >
@@ -105,8 +106,8 @@
<ControlButton <ControlButton
class="svelte-flow__controls-fitview" class="svelte-flow__controls-fitview"
onclick={onFitViewHandler} onclick={onFitViewHandler}
title="fit view" title={ariaLabelConfig['controls.fitView.ariaLabel']}
aria-label="fit view" aria-label={ariaLabelConfig['controls.fitView.ariaLabel']}
{...buttonProps} {...buttonProps}
> >
<FitViewIcon /> <FitViewIcon />
@@ -116,8 +117,8 @@
<ControlButton <ControlButton
class="svelte-flow__controls-interactive" class="svelte-flow__controls-interactive"
onclick={onToggleInteractivity} onclick={onToggleInteractivity}
title="toggle interactivity" title={ariaLabelConfig['controls.interactive.ariaLabel']}
aria-label="toggle interactivity" aria-label={ariaLabelConfig['controls.interactive.ariaLabel']}
{...buttonProps} {...buttonProps}
> >
{#if isInteractive}<UnlockIcon />{:else}<LockIcon />{/if} {#if isInteractive}<UnlockIcon />{:else}<LockIcon />{/if}
@@ -21,7 +21,7 @@
let { let {
position = 'bottom-right', position = 'bottom-right',
ariaLabel = 'Mini map', ariaLabel,
nodeStrokeColor = 'transparent', nodeStrokeColor = 'transparent',
nodeColor, nodeColor,
nodeClass = '', nodeClass = '',
@@ -42,6 +42,7 @@
}: MiniMapProps = $props(); }: MiniMapProps = $props();
let store = $derived(useStore()); let store = $derived(useStore());
let ariaLabelConfig = $derived(store.ariaLabelConfig);
const nodeColorFunc = nodeColor === undefined ? undefined : getAttrFunction(nodeColor); const nodeColorFunc = nodeColor === undefined ? undefined : getAttrFunction(nodeColor);
const nodeStrokeColorFunc = getAttrFunction(nodeStrokeColor); const nodeStrokeColorFunc = getAttrFunction(nodeStrokeColor);
@@ -113,7 +114,9 @@
zoomable zoomable
}} }}
> >
{#if ariaLabel}<title id={labelledBy}>{ariaLabel}</title>{/if} {#if ariaLabel ?? ariaLabelConfig['minimap.ariaLabel']}
<title id={labelledBy}>{ariaLabel ?? ariaLabelConfig['minimap.ariaLabel']}</title>
{/if}
{#each store.nodes as userNode (userNode.id)} {#each store.nodes as userNode (userNode.id)}
{@const node = store.nodeLookup.get(userNode.id)} {@const node = store.nodeLookup.get(userNode.id)}
@@ -7,6 +7,7 @@ import {
getViewportForBounds, getViewportForBounds,
updateConnectionLookup, updateConnectionLookup,
initialConnection, initialConnection,
mergeAriaLabelConfig,
type SelectionRect, type SelectionRect,
type SnapGrid, type SnapGrid,
type MarkerProps, type MarkerProps,
@@ -32,7 +33,8 @@ import {
type Handle, type Handle,
type OnReconnect, type OnReconnect,
type OnReconnectStart, type OnReconnectStart,
type OnReconnectEnd type OnReconnectEnd,
type AriaLabelConfig
} from '@xyflow/system'; } from '@xyflow/system';
import DefaultNode from '$lib/components/nodes/DefaultNode.svelte'; import DefaultNode from '$lib/components/nodes/DefaultNode.svelte';
@@ -289,6 +291,9 @@ export function getInitialStore<NodeType extends Node = Node, EdgeType extends E
noPanClass: string = $derived(signals.props.noPanClass ?? 'nopan'); noPanClass: string = $derived(signals.props.noPanClass ?? 'nopan');
noDragClass: string = $derived(signals.props.noDragClass ?? 'nodrag'); noDragClass: string = $derived(signals.props.noDragClass ?? 'nodrag');
noWheelClass: string = $derived(signals.props.noWheelClass ?? 'nowheel'); noWheelClass: string = $derived(signals.props.noWheelClass ?? 'nowheel');
ariaLabelConfig: AriaLabelConfig = $derived(
mergeAriaLabelConfig(signals.props.ariaLabelConfig)
);
// _viewport is the internal viewport. // _viewport is the internal viewport.
// when binding to viewport, we operate on signals.viewport instead // when binding to viewport, we operate on signals.viewport instead
+5
View File
@@ -25,6 +25,11 @@ export type Edge<
style?: string; style?: string;
class?: ClassValue; class?: ClassValue;
focusable?: boolean; focusable?: boolean;
/**
* The ARIA role attribute for the edge, used for accessibility.
* @default "group"
*/
ariaRole?: HTMLAttributes<HTMLElement>['role'];
}; };
export type BaseEdgeProps = Pick< export type BaseEdgeProps = Pick<
+6 -1
View File
@@ -1,5 +1,5 @@
import type { Component } from 'svelte'; 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'; import type { InternalNodeBase, NodeBase, NodeProps as NodePropsBase } from '@xyflow/system';
/** /**
@@ -21,6 +21,11 @@ export type Node<
class?: ClassValue; class?: ClassValue;
style?: string; style?: string;
focusable?: boolean; focusable?: boolean;
/**
* The ARIA role attribute for the node element, used for accessibility.
* @default "group"
*/
ariaRole?: HTMLAttributes<HTMLElement>['role'];
}; };
// @todo: currently generics for nodes are not really supported // @todo: currently generics for nodes are not really supported
+26
View File
@@ -36,3 +36,29 @@ export const infiniteExtent: CoordinateExtent = [
]; ];
export const elementSelectionKeys = ['Enter', ' ', 'Escape']; 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;
+5
View File
@@ -40,6 +40,11 @@ export type EdgeBase<
* This property sets the width of that invisible path. * This property sets the width of that invisible path.
*/ */
interactionWidth?: number; interactionWidth?: number;
/**
* A description of the edge's, used for accessibility.
* @default "edge"
*/
ariaRoleDescription?: string;
}; };
export type SmoothStepPathOptions = { export type SmoothStepPathOptions = {
+5
View File
@@ -73,6 +73,11 @@ export type NodeBase<
*/ */
origin?: NodeOrigin; origin?: NodeOrigin;
handles?: NodeHandle[]; handles?: NodeHandle[];
/**
* A description of the node's role, used for accessibility.
* @default "node"
*/
ariaRoleDescription?: string;
measured?: { measured?: {
width?: number; width?: number;
height?: number; height?: number;
+6
View File
@@ -16,6 +16,8 @@ import type {
import { type Viewport } from '../types'; import { type Viewport } from '../types';
import { getNodePositionWithOrigin, isInternalNodeBase } from './graph'; 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 clamp = (val: number, min = 0, max = 1): number => Math.min(Math.max(val, min), max);
export const clampPosition = ( export const clampPosition = (
@@ -418,3 +420,7 @@ export function withResolvers<T>(): {
}); });
return { promise, resolve, reject }; return { promise, resolve, reject };
} }
export function mergeAriaLabelConfig(partial?: Partial<AriaLabelConfig>): AriaLabelConfig {
return { ...defaultAriaLabelConfig, ...(partial || {}) };
}