Merge branch 'main' of https://github.com/xyflow/xyflow into 5030-focus-nodes-when-user-selects-via-tab
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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} />}
|
||||
</>
|
||||
|
||||
@@ -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={
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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<
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -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> = {
|
||||
|
||||
Reference in New Issue
Block a user