Merge pull request #5277 from xyflow/a11y

feat(a11y): add static support for a11y message localization
This commit is contained in:
Moritz Klack
2025-06-03 22:09:17 +02:00
committed by GitHub
30 changed files with 291 additions and 57 deletions
+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.
+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} />}
</> </>
@@ -142,10 +142,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({
@@ -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>
@@ -306,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>;
} }
+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}
@@ -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);
} }
} }
@@ -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
+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;
+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 || {}) };
}