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

View File

@@ -0,0 +1,7 @@
---
"@xyflow/react": minor
"@xyflow/svelte": minor
"@xyflow/system": patch
---
Add `ariaRole` prop to nodes and edges

View File

@@ -2,4 +2,4 @@
'@xyflow/svelte': patch
---
Fix data in EdgeProps not typed correctly
Fix data in `EdgeProps` that was not typed correctly

View File

@@ -1,7 +1,7 @@
---
'@xyflow/react': minor
'@xyflow/svelte': minor
'@xyflow/system': minor
'@xyflow/system': patch
---
Improve typing for Nodes

View File

@@ -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

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.

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
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

View File

@@ -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',

View File

@@ -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>
);
}

View File

@@ -3,6 +3,7 @@
import { page } from '$app/stores';
const routes = [
'a11y',
'add-node-on-drop',
'color-mode',
'custom-connection-line',

View File

@@ -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>

View File

@@ -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 (
<Panel
className={cc(['react-flow__controls', orientationClass, className])}
position={position}
style={style}
data-testid="rf__controls"
aria-label={ariaLabel}
aria-label={ariaLabel ?? ariaLabelConfig['controls.ariaLabel']}
>
{showZoom && (
<>
<ControlButton
onClick={onZoomInHandler}
className="react-flow__controls-zoomin"
title="zoom in"
aria-label="zoom in"
title={ariaLabelConfig['controls.zoomIn.ariaLabel']}
aria-label={ariaLabelConfig['controls.zoomIn.ariaLabel']}
disabled={maxZoomReached}
>
<PlusIcon />
@@ -90,8 +90,8 @@ function ControlsComponent({
<ControlButton
onClick={onZoomOutHandler}
className="react-flow__controls-zoomout"
title="zoom out"
aria-label="zoom out"
title={ariaLabelConfig['controls.zoomOut.ariaLabel']}
aria-label={ariaLabelConfig['controls.zoomOut.ariaLabel']}
disabled={minZoomReached}
>
<MinusIcon />
@@ -102,8 +102,8 @@ function ControlsComponent({
<ControlButton
className="react-flow__controls-fitview"
onClick={onFitViewHandler}
title="fit view"
aria-label="fit view"
title={ariaLabelConfig['controls.fitView.ariaLabel']}
aria-label={ariaLabelConfig['controls.fitView.ariaLabel']}
>
<FitViewIcon />
</ControlButton>
@@ -112,8 +112,8 @@ function ControlsComponent({
<ControlButton
className="react-flow__controls-interactive"
onClick={onToggleInteractivity}
title="toggle interactivity"
aria-label="toggle interactivity"
title={ariaLabelConfig['controls.interactive.ariaLabel']}
aria-label={ariaLabelConfig['controls.interactive.ariaLabel']}
>
{isInteractive ? <UnlockIcon /> : <LockIcon />}
</ControlButton>

View File

@@ -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<NodeType extends Node = Node>({
style,
className,
@@ -63,14 +63,17 @@ function MiniMapComponent<NodeType extends Node = Node>({
onNodeClick,
pannable = false,
zoomable = false,
ariaLabel = 'React Flow mini map',
ariaLabel,
inversePan,
zoomStep = 10,
offsetScale = 5,
}: MiniMapProps<NodeType>) {
const store = useStoreApi<NodeType>();
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 elementHeight = (style?.height as number) ?? defaultHeight;
const scaledWidth = boundingRect.width / elementWidth;
@@ -130,6 +133,8 @@ function MiniMapComponent<NodeType extends Node = Node>({
}, [])
: undefined;
const _ariaLabel = ariaLabel ?? ariaLabelConfig['minimap.ariaLabel'];
return (
<Panel
position={position}
@@ -159,7 +164,8 @@ function MiniMapComponent<NodeType extends Node = Node>({
ref={svg}
onClick={onSvgClick}
>
{ariaLabel && <title id={labelledBy}>{ariaLabel}</title>}
{_ariaLabel && <title id={labelledBy}>{_ariaLabel}</title>}
<MiniMapNodes<NodeType>
onClick={onSvgNodeClick}
nodeColor={nodeColor}

View File

@@ -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
* 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.
* @default "React Flow mini map"
* @default "Mini Map"
*/
ariaLabel?: string | null;
/** Invert direction when panning the minimap viewport. */

View File

@@ -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_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 }) {
const ariaLiveMessage = useStore(selector);
const ariaLiveMessage = useStore(ariaLiveSelector);
return (
<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 }) {
const ariaLabelConfig = useStore(ariaLabelConfigSelector);
return (
<>
<div id={`${ARIA_NODE_DESC_KEY}-${rfId}`} style={style}>
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']}
</div>
<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>
{!disableKeyboardA11y && <AriaLiveMessage rfId={rfId} />}
</>

View File

@@ -136,28 +136,28 @@ export function EdgeWrapper<EdgeType extends Edge = Edge>({
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<EdgeType extends Edge = Edge>({
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={

View File

@@ -145,10 +145,14 @@ export function NodeWrapper<NodeType extends Node>({
// 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<NodeType extends Node>({
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}
>

View File

@@ -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<NodeType extends Node = Node, EdgeType extends Edge
// Renamed fields
else if (fieldName === 'fitView') store.setState({ fitViewQueued: fieldValue as boolean });
else if (fieldName === 'fitViewOptions') store.setState({ fitViewOptions: fieldValue as FitViewOptions });
if (fieldName === 'ariaLabelConfig') {
store.setState({ ariaLabelConfig: mergeAriaLabelConfig(fieldValue as AriaLabelConfig) });
}
// General case
else store.setState({ [fieldName]: fieldValue });
}

View File

@@ -145,6 +145,7 @@ function ReactFlow<NodeType extends Node = Node, EdgeType extends Edge = Edge>(
colorMode = 'light',
debug,
onScroll,
ariaLabelConfig,
...rest
}: ReactFlowProps<NodeType, EdgeType>,
ref: ForwardedRef<HTMLDivElement>
@@ -170,6 +171,7 @@ function ReactFlow<NodeType extends Node = Node, EdgeType extends Edge = Edge>(
ref={ref}
className={cc(['react-flow', className, colorModeClassName])}
id={id}
role="application"
>
<Wrapper
nodes={nodes}
@@ -305,6 +307,7 @@ function ReactFlow<NodeType extends Node = Node, EdgeType extends Edge = Edge>(
onBeforeDelete={onBeforeDelete}
paneClickDistance={paneClickDistance}
debug={debug}
ariaLabelConfig={ariaLabelConfig}
/>
<SelectionListener<NodeType, EdgeType> onSelectionChange={onSelectionChange} />
{children}

View File

@@ -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

View File

@@ -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,
};
};

View File

@@ -21,6 +21,7 @@ import type {
ColorMode,
SnapGrid,
OnReconnect,
AriaLabelConfig,
} from '@xyflow/system';
import type {
@@ -666,4 +667,9 @@ export interface ReactFlowProps<NodeType extends Node = Node, EdgeType extends E
* @default false
*/
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>;
}

View File

@@ -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<EdgeData extends Record<string, unknown> = Record<string, unknown>> = Edge<

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 { 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;
};
/**

View File

@@ -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<NodeType extends Node = Node, EdgeType extends Edge =
domNode: HTMLDivElement | null;
paneDragging: boolean;
noPanClassName: string;
panZoom: PanZoomInstance | null;
minZoom: number;
maxZoom: number;
@@ -148,6 +148,7 @@ export type ReactFlowStore<NodeType extends Node = Node, EdgeType extends Edge =
lib: string;
debug: boolean;
ariaLabelConfig: AriaLabelConfig;
};
export type ReactFlowActions<NodeType extends Node, EdgeType extends Edge> = {

View File

@@ -7,15 +7,12 @@
</script>
<div id={`${ARIA_NODE_DESC_KEY}-${store.flowId}`} class="a11y-hidden">
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']}
</div>
<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
cancel.
{store.ariaLabelConfig['edge.a11yDescription.default']}
</div>
{#if !store.disableKeyboardA11y}

View File

@@ -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);

View File

@@ -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}
>

View File

@@ -44,6 +44,8 @@
);
let store = useStore();
let ariaLabelConfig = $derived(store.ariaLabelConfig);
let prevConnections: Map<string, HandleConnection> | 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}
>

View File

@@ -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}`}

View File

@@ -92,6 +92,7 @@
noDragClass,
noPanClass,
noWheelClass,
ariaLabelConfig,
...divAttributes
} = $derived(rest);
/* eslint-enable @typescript-eslint/no-unused-vars */

View File

@@ -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<AriaLabelConfig>;
};

View File

@@ -112,7 +112,8 @@ export {
type ResizeParamsWithDirection,
type ResizeDragEvent,
type IsValidConnection,
type NodeConnection
type NodeConnection,
type AriaLabelConfig
} from '@xyflow/system';
// system utils

View File

@@ -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 @@
<ControlButton
onclick={onZoomInHandler}
class="svelte-flow__controls-zoomin"
title="zoom in"
aria-label="zoom in"
title={ariaLabelConfig['controls.zoomIn.ariaLabel']}
aria-label={ariaLabelConfig['controls.zoomIn.ariaLabel']}
disabled={maxZoomReached}
{...buttonProps}
>
@@ -93,8 +94,8 @@
<ControlButton
onclick={onZoomOutHandler}
class="svelte-flow__controls-zoomout"
title="zoom out"
aria-label="zoom out"
title={ariaLabelConfig['controls.zoomOut.ariaLabel']}
aria-label={ariaLabelConfig['controls.zoomOut.ariaLabel']}
disabled={minZoomReached}
{...buttonProps}
>
@@ -105,8 +106,8 @@
<ControlButton
class="svelte-flow__controls-fitview"
onclick={onFitViewHandler}
title="fit view"
aria-label="fit view"
title={ariaLabelConfig['controls.fitView.ariaLabel']}
aria-label={ariaLabelConfig['controls.fitView.ariaLabel']}
{...buttonProps}
>
<FitViewIcon />
@@ -116,8 +117,8 @@
<ControlButton
class="svelte-flow__controls-interactive"
onclick={onToggleInteractivity}
title="toggle interactivity"
aria-label="toggle interactivity"
title={ariaLabelConfig['controls.interactive.ariaLabel']}
aria-label={ariaLabelConfig['controls.interactive.ariaLabel']}
{...buttonProps}
>
{#if isInteractive}<UnlockIcon />{:else}<LockIcon />{/if}

View File

@@ -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}<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)}
{@const node = store.nodeLookup.get(userNode.id)}

View File

@@ -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<NodeType extends Node = Node, EdgeType extends E
noPanClass: string = $derived(signals.props.noPanClass ?? 'nopan');
noDragClass: string = $derived(signals.props.noDragClass ?? 'nodrag');
noWheelClass: string = $derived(signals.props.noWheelClass ?? 'nowheel');
ariaLabelConfig: AriaLabelConfig = $derived(
mergeAriaLabelConfig(signals.props.ariaLabelConfig)
);
// _viewport is the internal viewport.
// when binding to viewport, we operate on signals.viewport instead

View File

@@ -25,6 +25,11 @@ export type Edge<
style?: string;
class?: ClassValue;
focusable?: boolean;
/**
* The ARIA role attribute for the edge, used for accessibility.
* @default "group"
*/
ariaRole?: HTMLAttributes<HTMLElement>['role'];
};
export type BaseEdgeProps = Pick<

View File

@@ -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<HTMLElement>['role'];
};
// @todo: currently generics for nodes are not really supported

View File

@@ -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;

View File

@@ -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 = {

View File

@@ -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;

View File

@@ -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<T>(): {
});
return { promise, resolve, reject };
}
export function mergeAriaLabelConfig(partial?: Partial<AriaLabelConfig>): AriaLabelConfig {
return { ...defaultAriaLabelConfig, ...(partial || {}) };
}