Feat: basic keyboard controls and better WAI-ARIA defaults (#2333)

* feat(nodes): focusable and moveable with keys
* refactor(key-handling): cleanup, handle selections
* feat(edges): selectable with keys
* refactor(nodes-edges): cleanup keyboard controls and aria- attrs
* refactor(nodes): node needs to be selected for arrow keys
* refactor(minimap): create const for labelledby

closes #1033
This commit is contained in:
Moritz Klack
2022-08-01 13:58:18 +02:00
committed by GitHub
parent 8eb1053aed
commit f7dc75b0f3
32 changed files with 7921 additions and 5200 deletions

View File

@@ -29,6 +29,7 @@ const initBgColor = '#1A192B';
const connectionLineStyle = { stroke: '#fff' };
const snapGrid: SnapGrid = [16, 16];
const defaultViewport = { x: 0, y: 0, zoom: 1.5 };
const nodeTypes = {
selectorNode: ColorSelectorNode,
@@ -141,7 +142,7 @@ const CustomNodeFlow = () => {
connectionLineStyle={connectionLineStyle}
snapToGrid={true}
snapGrid={snapGrid}
defaultZoom={1.5}
defaultViewport={defaultViewport}
fitView
>
<MiniMap

View File

@@ -192,7 +192,7 @@ const EdgesFlow = () => {
const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges);
const onConnect = useCallback(
(params: Connection | Edge) => setEdges((eds) => addEdge(params, eds)),
[]
[setEdges]
);
return (

View File

@@ -158,8 +158,7 @@ const NestedFlow = () => {
onEdgeClick={onEdgeClick}
onConnect={onConnect}
onNodeDragStop={onNodeDragStop}
className='react-flow-basic-example'
defaultZoom={1.5}
className="react-flow-basic-example"
minZoom={0.2}
maxZoom={4}
onlyRenderVisibleElements={false}

View File

@@ -24,6 +24,8 @@ const onNodeDragStop = (_: MouseEvent, node: Node, nodes: Node[]) =>
const onNodeClick = (_: MouseEvent, node: Node) => console.log('click', node);
const onEdgeClick = (_: MouseEvent, edge: Edge) => console.log('click', edge);
const defaultViewport = { x: 0, y: 0, zoom: 1.5 };
const initialNodes: Node[] = [
{
id: '1',
@@ -206,8 +208,8 @@ const Subflow = () => {
onConnect={onConnect}
onNodeDrag={onNodeDrag}
onNodeDragStop={onNodeDragStop}
className='react-flow-basic-example'
defaultZoom={1.5}
className="react-flow-basic-example"
defaultViewport={defaultViewport}
minZoom={0.2}
maxZoom={4}
onlyRenderVisibleElements={false}

View File

@@ -1,4 +1,4 @@
import React, { MouseEvent } from 'react';
import React, { MouseEvent, useCallback } from 'react';
import ReactFlow, {
addEdge,
Node,
@@ -19,12 +19,14 @@ const nodesA: Node[] = [
data: { label: 'Node 1' },
position: { x: 250, y: 5 },
className: 'light',
ariaLabel: 'Input Node 1',
},
{
id: '2a',
data: { label: 'Node 2' },
position: { x: 100, y: 100 },
className: 'light',
ariaLabel: 'Default Node 2',
},
{
id: '3a',
@@ -41,7 +43,7 @@ const nodesA: Node[] = [
];
const edgesA: Edge[] = [
{ id: 'e1-2', source: '1a', target: '2a' },
{ id: 'e1-2', source: '1a', target: '2a', ariaLabel: null },
{ id: 'e1-3', source: '1a', target: '3a' },
];
@@ -52,18 +54,21 @@ const nodesB: Node[] = [
data: { label: 'Input' },
position: { x: 300, y: 5 },
className: 'light',
ariaLabel: 'Input Node',
},
{
id: '1b',
data: { label: 'Node 1' },
position: { x: 0, y: 100 },
className: 'light',
ariaLabel: 'Node with id 1',
},
{
id: '2b',
data: { label: 'Node 2' },
position: { x: 200, y: 100 },
className: 'light',
ariaLabel: 'Node with id 2',
},
{
id: '3b',
@@ -80,7 +85,7 @@ const nodesB: Node[] = [
];
const edgesB: Edge[] = [
{ id: 'e1b', source: 'inputb', target: '1b' },
{ id: 'e1b', source: 'inputb', target: '1b', ariaLabel: 'edge to connect' },
{ id: 'e2b', source: 'inputb', target: '2b' },
{ id: 'e3b', source: 'inputb', target: '3b' },
{ id: 'e4b', source: 'inputb', target: '4b' },
@@ -90,8 +95,10 @@ const BasicFlow = () => {
const [nodes, setNodes, onNodesChange] = useNodesState(nodesA);
const [edges, setEdges, onEdgesChange] = useEdgesState(edgesA);
const onConnect = (params: Connection | Edge) =>
setEdges((eds) => addEdge(params, eds));
const onConnect = useCallback(
(params: Connection | Edge) => setEdges((eds) => addEdge(params, eds)),
[setEdges]
);
return (
<ReactFlow
@@ -102,6 +109,7 @@ const BasicFlow = () => {
onNodeClick={onNodeClick}
onConnect={onConnect}
onNodeDragStop={onNodeDragStop}
disableKeyboardA11y
>
<div style={{ position: 'absolute', right: 10, top: 10, zIndex: 4 }}>
<button

View File

@@ -69,7 +69,6 @@ const UpdateNode = () => {
<ReactFlow
nodes={nodes}
edges={edges}
defaultZoom={1.5}
minZoom={0.2}
maxZoom={4}
onNodesChange={onNodesChange}
@@ -85,10 +84,10 @@ const UpdateNode = () => {
<label className={styles.bgLabel}>background:</label>
<input value={nodeBg} onChange={(evt) => setNodeBg(evt.target.value)} />
<div className='updatenode__checkboxwrapper'>
<div className="updatenode__checkboxwrapper">
<label>hidden:</label>
<input
type='checkbox'
type="checkbox"
checked={nodeHidden}
onChange={(evt) => setNodeHidden(evt.target.checked)}
/>

View File

@@ -1,4 +1,4 @@
import React, { memo, FC, useEffect, useState, useRef } from 'react';
import React, { memo, FC, useRef, useId } from 'react';
import cc from 'classcat';
import { useStore, ReactFlowState } from '@react-flow/core';
@@ -19,7 +19,7 @@ const defaultSize = {
const transformSelector = (s: ReactFlowState) => s.transform;
const Background: FC<BackgroundProps> = ({
function Background({
variant = BackgroundVariant.Dots,
gap = 25,
// only used for dots and cross
@@ -29,18 +29,11 @@ const Background: FC<BackgroundProps> = ({
color,
style,
className,
}) => {
}: BackgroundProps) {
const ref = useRef<SVGSVGElement>(null);
const [patternId, setPatternId] = useState<string | null>(null);
const patternId = useId();
const [tX, tY, tScale] = useStore(transformSelector);
useEffect(() => {
// when there are multiple flows on a page we need to make sure that every background gets its own pattern.
const bgs = document.querySelectorAll('.react-flow__background');
const index = Array.from(bgs).findIndex((bg) => bg === ref.current);
setPatternId(`pattern-${index}`);
}, []);
const patternColor = color ? color : defaultColors[variant];
const patternSize = size ? size : defaultSize[variant];
const isDots = variant === BackgroundVariant.Dots;
@@ -74,39 +67,35 @@ const Background: FC<BackgroundProps> = ({
}}
ref={ref}
>
{patternId && (
<>
<pattern
id={patternId}
x={tX % scaledGap[0]}
y={tY % scaledGap[1]}
width={scaledGap[0]}
height={scaledGap[1]}
patternUnits="userSpaceOnUse"
patternTransform={`translate(-${patternOffset[0]},-${patternOffset[1]})`}
>
{isDots ? (
<DotPattern color={patternColor} radius={scaledSize / 2} />
) : (
<LinePattern
dimensions={patternDimensions}
color={patternColor}
lineWidth={lineWidth}
/>
)}
</pattern>
<rect
x="0"
y="0"
width="100%"
height="100%"
fill={`url(#${patternId})`}
<pattern
id={patternId}
x={tX % scaledGap[0]}
y={tY % scaledGap[1]}
width={scaledGap[0]}
height={scaledGap[1]}
patternUnits="userSpaceOnUse"
patternTransform={`translate(-${patternOffset[0]},-${patternOffset[1]})`}
>
{isDots ? (
<DotPattern color={patternColor} radius={scaledSize / 2} />
) : (
<LinePattern
dimensions={patternDimensions}
color={patternColor}
lineWidth={lineWidth}
/>
</>
)}
)}
</pattern>
<rect
x="0"
y="0"
width="100%"
height="100%"
fill={`url(#${patternId})`}
/>
</svg>
);
};
}
Background.displayName = 'Background';

View File

@@ -0,0 +1,26 @@
import React from 'react';
const style = { display: 'none' };
export const ARIA_NODE_DESC_KEY = 'react-flow__node-desc';
export const ARIA_EDGE_DESC_KEY = 'react-flow__edge-desc';
function A11yDescriptions({ rfId, disableKeyboardA11y }: { rfId: string; disableKeyboardA11y: boolean }) {
if (disableKeyboardA11y) {
return null;
}
return (
<>
<div id={`${ARIA_NODE_DESC_KEY}-${rfId}`} style={style}>
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 press escape to cancel.
</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 press escape to cancel.
</div>
</>
);
}
export default A11yDescriptions;

View File

@@ -22,7 +22,7 @@ function Attribution({ proOptions, position = 'bottom-right' }: AttributionProps
className={cc(['react-flow__attribution', ...positionClasses])}
data-message="Please only hide this attribution when you are subscribed to React Flow Pro: https://pro.reactflow.dev/pricing"
>
<a href="https://reactflow.dev" target="_blank" rel="noopener noreferrer">
<a href="https://reactflow.dev" target="_blank" rel="noopener noreferrer" aria-label="React Flow attribution">
React Flow
</a>
</div>

View File

@@ -1,12 +1,14 @@
import React, { memo, ComponentType, useState, useMemo } from 'react';
import React, { memo, ComponentType, useState, useMemo, KeyboardEvent, useRef } from 'react';
import cc from 'classcat';
import { useStoreApi } from '../../store';
import { EdgeProps, WrapEdgeProps, Connection } from '../../types';
import { handleMouseDown } from '../../components/Handle/handler';
import { ARIA_EDGE_DESC_KEY } from '../A11yDescriptions';
import { handleMouseDown } from '../Handle/handler';
import { EdgeAnchor } from './EdgeAnchor';
import { getMarkerId } from '../../utils/graph';
import { getMouseHandler } from './utils';
import { EdgeProps, WrapEdgeProps, Connection } from '../../types';
import { elementSelectionKeys } from '../../utils';
export default (EdgeComponent: ComponentType<EdgeProps>) => {
const EdgeWrapper = ({
@@ -48,10 +50,20 @@ export default (EdgeComponent: ComponentType<EdgeProps>) => {
markerEnd,
markerStart,
rfId,
ariaLabel,
disableKeyboardA11y,
}: WrapEdgeProps): JSX.Element | null => {
const edgeRef = useRef<SVGGElement>(null);
const [updating, setUpdating] = useState<boolean>(false);
const store = useStoreApi();
const markerStartUrl = useMemo(() => `url(#${getMarkerId(markerStart, rfId)})`, [markerStart, rfId]);
const markerEndUrl = useMemo(() => `url(#${getMarkerId(markerEnd, rfId)})`, [markerEnd, rfId]);
if (hidden) {
return null;
}
const onEdgeClick = (event: React.MouseEvent<SVGGElement, MouseEvent>): void => {
const { edges, addSelectedEdges } = store.getState();
const edge = edges.find((e) => e.id === id)!;
@@ -107,12 +119,6 @@ export default (EdgeComponent: ComponentType<EdgeProps>) => {
const onEdgeUpdaterMouseEnter = () => setUpdating(true);
const onEdgeUpdaterMouseOut = () => setUpdating(false);
const markerStartUrl = useMemo(() => `url(#${getMarkerId(markerStart, rfId)})`, [markerStart, rfId]);
const markerEndUrl = useMemo(() => `url(#${getMarkerId(markerEnd, rfId)})`, [markerEnd, rfId]);
if (hidden) {
return null;
}
const inactive = !elementsSelectable && !onClick;
const handleEdgeUpdate = typeof onEdgeUpdate !== 'undefined';
@@ -123,6 +129,20 @@ export default (EdgeComponent: ComponentType<EdgeProps>) => {
{ selected, animated, inactive, updating },
]);
const onKeyDown = (event: KeyboardEvent) => {
if (elementSelectionKeys.includes(event.key) && elementsSelectable) {
const { unselectNodesAndEdges, addSelectedEdges, edges } = store.getState();
const unselect = event.key === 'Escape';
if (unselect) {
edgeRef.current?.blur();
unselectNodesAndEdges({ edges: [edges.find((e) => e.id === id)!] });
} else {
addSelectedEdges([id]);
}
}
};
return (
<g
className={edgeClasses}
@@ -132,6 +152,12 @@ export default (EdgeComponent: ComponentType<EdgeProps>) => {
onMouseEnter={onEdgeMouseEnter}
onMouseMove={onEdgeMouseMove}
onMouseLeave={onEdgeMouseLeave}
onKeyDown={disableKeyboardA11y ? undefined : onKeyDown}
tabIndex={disableKeyboardA11y ? undefined : 0}
role={disableKeyboardA11y ? undefined : 'button'}
aria-label={ariaLabel === null ? undefined : ariaLabel ? ariaLabel : `Edge from ${source} to ${target}`}
aria-describedby={disableKeyboardA11y ? undefined : `${ARIA_EDGE_DESC_KEY}-${rfId}`}
ref={edgeRef}
>
<EdgeComponent
id={id}

View File

@@ -51,12 +51,14 @@ export function getMouseHandler(
export function handleNodeClick({
id,
store,
unselect = false,
}: {
id: string;
store: {
getState: GetState<ReactFlowState>;
setState: SetState<ReactFlowState>;
};
unselect?: boolean;
}) {
const { addSelectedNodes, unselectNodesAndEdges, multiSelectionActive, nodeInternals } = store.getState();
const node = nodeInternals.get(id)!;
@@ -65,7 +67,7 @@ export function handleNodeClick({
if (!node.selected) {
addSelectedNodes([id]);
} else if (node.selected && multiSelectionActive) {
} else if (unselect || (node.selected && multiSelectionActive)) {
unselectNodesAndEdges({ nodes: [node] });
}
}

View File

@@ -1,11 +1,21 @@
import React, { useEffect, useRef, memo, ComponentType, MouseEvent } from 'react';
import React, { useEffect, useRef, memo, ComponentType, MouseEvent, KeyboardEvent } from 'react';
import cc from 'classcat';
import { useStoreApi } from '../../store';
import { Provider } from '../../contexts/NodeIdContext';
import { NodeProps, WrapNodeProps } from '../../types';
import { ARIA_NODE_DESC_KEY } from '../A11yDescriptions';
import useDrag from '../../hooks/useDrag';
import useUpdateNodePositions from '../../hooks/useUpdateNodePositions';
import { getMouseHandler, handleNodeClick } from './utils';
import { NodeProps, WrapNodeProps, XYPosition } from '../../types';
import { elementSelectionKeys } from '../../utils';
export const arrowKeyDiffs: Record<string, XYPosition> = {
ArrowUp: { x: 0, y: -10 },
ArrowDown: { x: 0, y: 10 },
ArrowLeft: { x: -10, y: 0 },
ArrowRight: { x: 10, y: 0 },
};
export default (NodeComponent: ComponentType<NodeProps>) => {
const NodeWrapper = ({
@@ -37,6 +47,9 @@ export default (NodeComponent: ComponentType<NodeProps>) => {
noPanClassName,
noDragClassName,
initialized,
disableKeyboardA11y,
ariaLabel,
rfId,
}: WrapNodeProps) => {
const store = useStoreApi();
const nodeRef = useRef<HTMLDivElement>(null);
@@ -44,6 +57,7 @@ export default (NodeComponent: ComponentType<NodeProps>) => {
const prevTargetPosition = useRef(targetPosition);
const prevType = useRef(type);
const hasPointerEvents = isSelectable || isDraggable || onClick || onMouseEnter || onMouseMove || onMouseLeave;
const updatePositions = useUpdateNodePositions();
const onMouseEnterHandler = getMouseHandler(id, store.getState, onMouseEnter);
const onMouseMoveHandler = getMouseHandler(id, store.getState, onMouseMove);
@@ -65,6 +79,22 @@ export default (NodeComponent: ComponentType<NodeProps>) => {
}
};
const onKeyDown = (event: KeyboardEvent) => {
if (elementSelectionKeys.includes(event.key) && isSelectable) {
const unselect = event.key === 'Escape';
if (unselect) {
nodeRef.current?.blur();
}
handleNodeClick({
id,
store,
unselect,
});
} else if (selected && arrowKeyDiffs.hasOwnProperty(event.key)) {
updatePositions(arrowKeyDiffs[event.key]);
}
};
useEffect(() => {
if (nodeRef.current && !hidden) {
const currNode = nodeRef.current;
@@ -129,13 +159,18 @@ export default (NodeComponent: ComponentType<NodeProps>) => {
visibility: initialized ? 'visible' : 'hidden',
...style,
}}
data-id={id}
onMouseEnter={onMouseEnterHandler}
onMouseMove={onMouseMoveHandler}
onMouseLeave={onMouseLeaveHandler}
onContextMenu={onContextMenuHandler}
onClick={onSelectNodeHandler}
onDoubleClick={onDoubleClickHandler}
data-id={id}
onKeyDown={disableKeyboardA11y ? undefined : onKeyDown}
tabIndex={disableKeyboardA11y ? undefined : 0}
role={disableKeyboardA11y ? undefined : 'button'}
aria-describedby={disableKeyboardA11y ? undefined : `${ARIA_NODE_DESC_KEY}-${rfId}`}
aria-label={ariaLabel}
>
<Provider value={id}>
<NodeComponent

View File

@@ -3,7 +3,7 @@
* made a selection with on or several nodes
*/
import React, { memo, useRef, MouseEvent } from 'react';
import React, { memo, useRef, MouseEvent, KeyboardEvent, useEffect } from 'react';
import cc from 'classcat';
import shallow from 'zustand/shallow';
@@ -11,10 +11,13 @@ import { useStore, useStoreApi } from '../../store';
import { Node, ReactFlowState } from '../../types';
import { getRectOfNodes } from '../../utils/graph';
import useDrag from '../../hooks/useDrag';
import { arrowKeyDiffs } from '../Nodes/wrapNode';
import useUpdateNodePositions from '../../hooks/useUpdateNodePositions';
export interface NodesSelectionProps {
onSelectionContextMenu?: (event: MouseEvent, nodes: Node[]) => void;
noPanClassName?: string;
disableKeyboardA11y: boolean;
}
const selector = (s: ReactFlowState) => ({
@@ -28,12 +31,19 @@ const bboxSelector = (s: ReactFlowState) => {
return getRectOfNodes(selectedNodes);
};
function NodesSelection({ onSelectionContextMenu, noPanClassName }: NodesSelectionProps) {
function NodesSelection({ onSelectionContextMenu, noPanClassName, disableKeyboardA11y }: NodesSelectionProps) {
const store = useStoreApi();
const { transformString, userSelectionActive } = useStore(selector, shallow);
const { width, height, x: left, y: top } = useStore(bboxSelector, shallow);
const updatePositions = useUpdateNodePositions();
const nodeRef = useRef(null);
const nodeRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!disableKeyboardA11y) {
nodeRef.current?.focus();
}
}, [disableKeyboardA11y]);
useDrag({
nodeRef,
@@ -50,6 +60,12 @@ function NodesSelection({ onSelectionContextMenu, noPanClassName }: NodesSelecti
}
: undefined;
const onKeyDown = (event: KeyboardEvent) => {
if (arrowKeyDiffs.hasOwnProperty(event.key)) {
updatePositions(arrowKeyDiffs[event.key]);
}
};
return (
<div
className={cc(['react-flow__nodesselection', 'react-flow__container', noPanClassName])}
@@ -61,6 +77,8 @@ function NodesSelection({ onSelectionContextMenu, noPanClassName }: NodesSelecti
ref={nodeRef}
className="react-flow__nodesselection-rect"
onContextMenu={onContextMenu}
tabIndex={disableKeyboardA11y ? undefined : -1}
onKeyDown={disableKeyboardA11y ? undefined : onKeyDown}
style={{
width,
height,

View File

@@ -40,7 +40,8 @@ interface EdgeRendererProps {
edgeUpdaterRadius?: number;
noPanClassName?: string;
elevateEdgesOnSelect: boolean;
rfId?: string;
rfId: string;
disableKeyboardA11y: boolean;
}
const selector = (s: ReactFlowState) => ({
@@ -182,6 +183,8 @@ const EdgeRenderer = (props: EdgeRendererProps) => {
onEdgeUpdateStart={props.onEdgeUpdateStart}
onEdgeUpdateEnd={props.onEdgeUpdateEnd}
rfId={props.rfId}
ariaLabel={edge.ariaLabel}
disableKeyboardA11y={props.disableKeyboardA11y}
/>
);
})}

View File

@@ -22,6 +22,7 @@ export type FlowRendererProps = Omit<
| 'onlyRenderVisibleElements'
| 'selectNodesOnDrag'
| 'defaultMarkerColor'
| 'rfId'
> & {
children: ReactNode;
};
@@ -56,6 +57,7 @@ const FlowRenderer = ({
onSelectionContextMenu,
noWheelClassName,
noPanClassName,
disableKeyboardA11y,
}: FlowRendererProps) => {
const store = useStoreApi();
const nodesSelectionActive = useStore(selector);
@@ -95,7 +97,11 @@ const FlowRenderer = ({
{children}
<UserSelection selectionKeyPressed={selectionKeyPressed} />
{nodesSelectionActive && (
<NodesSelection onSelectionContextMenu={onSelectionContextMenu} noPanClassName={noPanClassName} />
<NodesSelection
onSelectionContextMenu={onSelectionContextMenu}
noPanClassName={noPanClassName}
disableKeyboardA11y={disableKeyboardA11y}
/>
)}
<div
className="react-flow__pane react-flow__container"

View File

@@ -22,6 +22,8 @@ export interface GraphViewProps
noWheelClassName: string;
noPanClassName: string;
defaultViewport: Viewport;
rfId: string;
disableKeyboardA11y: boolean;
}
const GraphView = ({
@@ -79,7 +81,8 @@ const GraphView = ({
noWheelClassName,
noPanClassName,
elevateEdgesOnSelect,
id,
disableKeyboardA11y,
rfId,
}: GraphViewProps) => {
useOnInitHandler(onInit);
@@ -112,6 +115,7 @@ const GraphView = ({
noDragClassName={noDragClassName}
noWheelClassName={noWheelClassName}
noPanClassName={noPanClassName}
disableKeyboardA11y={disableKeyboardA11y}
>
<ViewportWrapper>
<EdgeRenderer
@@ -134,7 +138,8 @@ const GraphView = ({
defaultMarkerColor={defaultMarkerColor}
noPanClassName={noPanClassName}
elevateEdgesOnSelect={!!elevateEdgesOnSelect}
rfId={id}
disableKeyboardA11y={disableKeyboardA11y}
rfId={rfId}
/>
<NodeRenderer
nodeTypes={nodeTypes}
@@ -148,6 +153,8 @@ const GraphView = ({
onlyRenderVisibleElements={onlyRenderVisibleElements}
noPanClassName={noPanClassName}
noDragClassName={noDragClassName}
disableKeyboardA11y={disableKeyboardA11y}
rfId={rfId}
/>
</ViewportWrapper>
</FlowRenderer>

View File

@@ -18,6 +18,8 @@ interface NodeRendererProps {
onlyRenderVisibleElements: boolean;
noPanClassName: string;
noDragClassName: string;
rfId: string;
disableKeyboardA11y: boolean;
}
const selector = (s: ReactFlowState) => ({
@@ -110,6 +112,9 @@ const NodeRenderer = (props: NodeRendererProps) => {
noDragClassName={props.noDragClassName}
noPanClassName={props.noPanClassName}
initialized={!!node.width && !!node.height}
rfId={props.rfId}
disableKeyboardA11y={props.disableKeyboardA11y}
ariaLabel={node.ariaLabel}
/>
);
})}

View File

@@ -1,4 +1,4 @@
import React, { forwardRef } from 'react';
import React, { forwardRef, useId } from 'react';
import cc from 'classcat';
import Attribution from '../../components/Attribution';
@@ -29,6 +29,7 @@ import GraphView from '../GraphView';
import { createNodeTypes } from '../NodeRenderer/utils';
import { useNodeOrEdgeTypes } from './utils';
import Wrapper from './Wrapper';
import A11yDescriptions from '../../components/A11yDescriptions';
const defaultNodeTypes: NodeTypes = {
input: InputNode,
@@ -141,12 +142,14 @@ const ReactFlow = forwardRef<ReactFlowRefType, ReactFlowProps>(
proOptions,
defaultEdgeOptions,
elevateEdgesOnSelect = false,
disableKeyboardA11y = false,
...rest
},
ref
) => {
const nodeTypesWrapped = useNodeOrEdgeTypes(nodeTypes, createNodeTypes) as NodeTypesWrapped;
const edgeTypesWrapped = useNodeOrEdgeTypes(edgeTypes, createEdgeTypes) as EdgeTypesWrapped;
const rfId = useId();
return (
<div {...rest} ref={ref} className={cc(['react-flow', className])}>
@@ -205,7 +208,8 @@ const ReactFlow = forwardRef<ReactFlowRefType, ReactFlowProps>(
noWheelClassName={noWheelClassName}
noPanClassName={noPanClassName}
elevateEdgesOnSelect={elevateEdgesOnSelect}
id={rest?.id}
rfId={rfId}
disableKeyboardA11y={disableKeyboardA11y}
/>
<StoreUpdater
nodes={nodes}
@@ -245,6 +249,7 @@ const ReactFlow = forwardRef<ReactFlowRefType, ReactFlowProps>(
{onSelectionChange && <SelectionListener onSelectionChange={onSelectionChange} />}
{children}
<Attribution proOptions={proOptions} position={attributionPosition} />
<A11yDescriptions rfId={rfId} disableKeyboardA11y={disableKeyboardA11y} />
</Wrapper>
</div>
);

View File

@@ -12,7 +12,7 @@ import { FlowRendererProps } from '../FlowRenderer';
type ZoomPaneProps = Omit<
FlowRendererProps,
'deleteKeyCode' | 'selectionKeyCode' | 'multiSelectionKeyCode' | 'noDragClassName'
'deleteKeyCode' | 'selectionKeyCode' | 'multiSelectionKeyCode' | 'noDragClassName' | 'disableKeyboardA11y'
> & { selectionKeyPressed: boolean };
const viewChanged = (prevViewport: Viewport, eventViewport: any): boolean =>

View File

@@ -5,7 +5,7 @@ import { select } from 'd3-selection';
import { useStoreApi } from '../../store';
import { pointToRendererPoint } from '../../utils/graph';
import { NodeDragItem, Node, SelectionDragHandler } from '../../types';
import { getDragItems, getEventHandlerParams, hasSelector, updatePosition } from './utils';
import { getDragItems, getEventHandlerParams, hasSelector, calcNextPosition } from './utils';
import { handleNodeClick } from '../../components/Nodes/utils';
export type UseDragEvent = D3DragEvent<HTMLDivElement, null, SubjectPosition>;
@@ -102,9 +102,19 @@ function useDrag({
// skip events without movement
if ((lastPos.current.x !== pointerPos.x || lastPos.current.y !== pointerPos.y) && dragItems.current) {
lastPos.current = pointerPos;
dragItems.current = dragItems.current.map((n) =>
updatePosition(n, pointerPos, nodeInternals, nodeExtent)
);
dragItems.current = dragItems.current.map((n) => {
const updatedPos = calcNextPosition(
n,
{ x: pointerPos.x - n.distance.x, y: pointerPos.y - n.distance.y },
nodeInternals,
nodeExtent
);
n.position = updatedPos.position;
n.positionAbsolute = updatedPos.positionAbsolute;
return n;
});
const onDrag = nodeId ? onNodeDrag : wrapSelectionDragFunc(onSelectionDrag);

View File

@@ -56,25 +56,24 @@ export function getDragItems(nodeInternals: NodeInternals, mousePos: XYPosition,
}));
}
export function updatePosition(
dragItem: NodeDragItem,
mousePos: XYPosition,
export function calcNextPosition(
node: NodeDragItem | Node,
nextPosition: XYPosition,
nodeInternals: NodeInternals,
nodeExtent?: CoordinateExtent
): NodeDragItem {
let currentExtent = dragItem.extent || nodeExtent;
const nextPosition = { x: mousePos.x - dragItem.distance.x, y: mousePos.y - dragItem.distance.y };
): { position: XYPosition; positionAbsolute: XYPosition } {
let currentExtent = node.extent || nodeExtent;
if (dragItem.extent === 'parent') {
if (dragItem.parentNode && dragItem.width && dragItem.height) {
const parent = nodeInternals.get(dragItem.parentNode);
if (node.extent === 'parent') {
if (node.parentNode && node.width && node.height) {
const parent = nodeInternals.get(node.parentNode);
currentExtent =
parent?.positionAbsolute && parent?.width && parent?.height
? [
[parent.positionAbsolute.x, parent.positionAbsolute.y],
[
parent.positionAbsolute.x + parent.width - dragItem.width,
parent.positionAbsolute.y + parent.height - dragItem.height,
parent.positionAbsolute.x + parent.width - node.width,
parent.positionAbsolute.y + parent.height - node.height,
],
]
: currentExtent;
@@ -85,33 +84,34 @@ export function updatePosition(
}
currentExtent = nodeExtent;
}
} else if (dragItem.extent && dragItem.parentNode) {
const parent = nodeInternals.get(dragItem.parentNode);
} else if (node.extent && node.parentNode) {
const parent = nodeInternals.get(node.parentNode);
const parentX = parent?.positionAbsolute?.x ?? 0;
const parentY = parent?.positionAbsolute?.y ?? 0;
currentExtent = [
[dragItem.extent[0][0] + parentX, dragItem.extent[0][1] + parentY],
[dragItem.extent[1][0] + parentX, dragItem.extent[1][1] + parentY],
[node.extent[0][0] + parentX, node.extent[0][1] + parentY],
[node.extent[1][0] + parentX, node.extent[1][1] + parentY],
];
}
let parentPosition = { x: 0, y: 0 };
if (dragItem.parentNode) {
const parentNode = nodeInternals.get(dragItem.parentNode);
if (node.parentNode) {
const parentNode = nodeInternals.get(node.parentNode);
parentPosition = { x: parentNode?.positionAbsolute?.x ?? 0, y: parentNode?.positionAbsolute?.y ?? 0 };
}
dragItem.positionAbsolute = currentExtent
const positionAbsolute = currentExtent
? clampPosition(nextPosition, currentExtent as CoordinateExtent)
: nextPosition;
dragItem.position = {
x: dragItem.positionAbsolute.x - parentPosition.x,
y: dragItem.positionAbsolute.y - parentPosition.y,
return {
position: {
x: positionAbsolute.x - parentPosition.x,
y: positionAbsolute.y - parentPosition.y,
},
positionAbsolute,
};
return dragItem;
}
// returns two params:

View File

@@ -1,35 +1,40 @@
import { useEffect } from 'react';
import shallow from 'zustand/shallow';
import { useStore, useStoreApi } from '../store';
import { useStoreApi } from '../store';
import useKeyPress from './useKeyPress';
import { getConnectedEdges } from '../utils/graph';
import { EdgeChange, KeyCode, NodeChange, Node, ReactFlowState } from '../types';
import { KeyCode, NodeChange, Node } from '../types';
interface HookParams {
deleteKeyCode: KeyCode | null;
multiSelectionKeyCode: KeyCode | null;
}
const selector = (s: ReactFlowState) => ({
onNodesChange: s.onNodesChange,
onEdgesChange: s.onEdgesChange,
});
export default ({ deleteKeyCode, multiSelectionKeyCode }: HookParams): void => {
const store = useStoreApi();
const { onNodesChange, onEdgesChange } = useStore(selector, shallow);
const deleteKeyPressed = useKeyPress(deleteKeyCode);
const multiSelectionKeyPressed = useKeyPress(multiSelectionKeyCode);
useEffect(() => {
const { nodeInternals, edges, hasDefaultNodes, hasDefaultEdges, onNodesDelete, onEdgesDelete } = store.getState();
if (!deleteKeyPressed) {
return;
}
const {
nodeInternals,
edges,
hasDefaultNodes,
hasDefaultEdges,
onNodesDelete,
onEdgesDelete,
onNodesChange,
onEdgesChange,
} = store.getState();
const nodes = Array.from(nodeInternals.values());
const nodesToRemove = nodes.reduce<Node[]>((res, node) => {
if (!node.selected && node.parentNode && res.find((n) => n.id === node.parentNode)) {
res.push(node);
} else if (node.selected) {
const parentSelected = !node.selected && node.parentNode && res.find((n) => n.id === node.parentNode);
if (node.selected || parentSelected) {
res.push(node);
}
@@ -37,7 +42,7 @@ export default ({ deleteKeyCode, multiSelectionKeyCode }: HookParams): void => {
}, []);
const selectedEdges = edges.filter((e) => e.selected);
if (deleteKeyPressed && (nodesToRemove || selectedEdges)) {
if (nodesToRemove || selectedEdges) {
const connectedEdges = getConnectedEdges(nodesToRemove, edges);
const edgesToRemove = [...selectedEdges, ...connectedEdges];
const edgeIdsToRemove = edgesToRemove.reduce<string[]>((res, edge) => {
@@ -69,11 +74,12 @@ export default ({ deleteKeyCode, multiSelectionKeyCode }: HookParams): void => {
onEdgesDelete?.(edgesToRemove);
if (onEdgesChange) {
const edgeChanges: EdgeChange[] = edgeIdsToRemove.map((id) => ({
id,
type: 'remove',
}));
onEdgesChange(edgeChanges);
onEdgesChange(
edgeIdsToRemove.map((id) => ({
id,
type: 'remove',
}))
);
}
}
@@ -88,7 +94,7 @@ export default ({ deleteKeyCode, multiSelectionKeyCode }: HookParams): void => {
store.setState({ nodesSelectionActive: false });
}
}, [deleteKeyPressed, onNodesChange, onEdgesChange]);
}, [deleteKeyPressed]);
useEffect(() => {
store.setState({ multiSelectionActive: multiSelectionKeyPressed });

View File

@@ -0,0 +1,37 @@
import { useCallback } from 'react';
import { useStoreApi } from '../store';
import { calcNextPosition } from './useDrag/utils';
import { XYPosition } from '../types';
function useUpdateNodePositions() {
const store = useStoreApi();
const updatePositions = useCallback((positionDiff: XYPosition) => {
const { nodeInternals, nodeExtent, updateNodePositions } = store.getState();
const selectedNodes = Array.from(nodeInternals.values()).filter((n) => n.selected);
const nodeUpdates = selectedNodes.map((n) => {
if (n.positionAbsolute) {
const updatedPos = calcNextPosition(
n,
{ x: n.positionAbsolute.x + positionDiff.x, y: n.positionAbsolute.y + positionDiff.y },
nodeInternals,
nodeExtent
);
n.position = updatedPos.position;
n.positionAbsolute = updatedPos.positionAbsolute;
}
return n;
});
updateNodePositions(nodeUpdates, true, true);
}, []);
return updatePositions;
}
export default useUpdateNodePositions;

View File

@@ -97,7 +97,7 @@ const createStore = () =>
onNodesChange?.(changes);
}
},
updateNodePositions: (nodeDragItems: NodeDragItem[], positionChanged = true, dragging = false) => {
updateNodePositions: (nodeDragItems: NodeDragItem[] | Node[], positionChanged = true, dragging = false) => {
const { onNodesChange, nodeInternals, hasDefaultNodes } = get();
if (hasDefaultNodes || onNodesChange) {

View File

@@ -1,5 +1,8 @@
.react-flow__edge {
&.selected {
&.selected,
&:focus,
&:focus-visible {
outline: none;
.react-flow__edge-path {
stroke: #555;
}
@@ -54,8 +57,11 @@
background: #fff;
border-color: #1a192b;
&.selected {
&.selected,
&:focus,
&:focus-visible {
box-shadow: 0 0 0 0.5px #1a192b;
outline: none;
}
.react-flow__handle {
@@ -71,7 +77,10 @@
box-shadow: 0 1px 4px 1px rgba(0, 0, 0, 0.08);
}
&.selected {
&.selected,
&:focus,
&:focus-visible {
outline: none;
box-shadow: 0 0 0 0.5px #1a192b;
}
}
@@ -85,6 +94,11 @@
.react-flow__selection {
background: rgba(0, 89, 220, 0.08);
border: 1px dotted rgba(0, 89, 220, 0.8);
&:focus,
&:focus-visible {
outline: none;
}
}
.react-flow__handle {

View File

@@ -125,6 +125,7 @@ export interface ReactFlowProps extends HTMLAttributes<HTMLDivElement> {
attributionPosition?: AttributionPosition;
proOptions?: ProOptions;
elevateEdgesOnSelect?: boolean;
disableKeyboardA11y?: boolean;
}
export type ReactFlowRefType = HTMLDivElement;

View File

@@ -29,6 +29,7 @@ export interface Edge<T = any> {
markerStart?: EdgeMarkerType;
markerEnd?: EdgeMarkerType;
zIndex?: number;
ariaLabel?: string;
}
export type DefaultEdgeOptions = Omit<
@@ -122,6 +123,8 @@ export interface WrapEdgeProps<T = any> {
markerStart?: EdgeMarkerType;
markerEnd?: EdgeMarkerType;
rfId?: string;
ariaLabel?: string | null;
disableKeyboardA11y: boolean;
}
export interface EdgeSmoothStepProps<T = any> extends EdgeProps<T> {

View File

@@ -198,7 +198,7 @@ export type ReactFlowActions = {
setEdges: (edges: Edge[]) => void;
setDefaultNodesAndEdges: (nodes?: Node[], edges?: Edge[]) => void;
updateNodeDimensions: (updates: NodeDimensionUpdate[]) => void;
updateNodePositions: (nodeDragItems: NodeDragItem[], positionChanged: boolean, dragging: boolean) => void;
updateNodePositions: (nodeDragItems: NodeDragItem[] | Node[], positionChanged: boolean, dragging: boolean) => void;
resetSelectedElements: () => void;
unselectNodesAndEdges: (params?: UnselectNodesAndEdgesParams) => void;
addSelectedNodes: (nodeIds: string[]) => void;

View File

@@ -28,6 +28,7 @@ export interface Node<T = any> {
extent?: 'parent' | CoordinateExtent;
expandParent?: boolean;
positionAbsolute?: XYPosition;
ariaLabel?: string;
// only used internally
[internalsSymbol]?: {
@@ -86,6 +87,9 @@ export interface WrapNodeProps<T = any> {
isParent: boolean;
noPanClassName: string;
noDragClassName: string;
rfId: string;
disableKeyboardA11y: boolean;
ariaLabel?: string;
}
export type NodeHandleBounds = {

View File

@@ -42,3 +42,6 @@ export const getBoundsofRects = (rect1: Rect, rect2: Rect): Rect =>
export const isNumeric = (n: any): n is number => !isNaN(n) && isFinite(n);
export const internalsSymbol = Symbol('internals');
// used for a11y key board controls for nodes and edges
export const elementSelectionKeys = ['Enter', ' ', 'Escape'];

View File

@@ -1,4 +1,4 @@
import React, { memo } from 'react';
import React, { memo, useId } from 'react';
import cc from 'classcat';
import shallow from 'zustand/shallow';
import {
@@ -54,7 +54,9 @@ export const boxToRect = ({ x, y, x2, y2 }: Box): Rect => ({
export const getBoundsofRects = (rect1: Rect, rect2: Rect): Rect =>
boxToRect(getBoundsOfBoxes(rectToBox(rect1), rectToBox(rect2)));
const MiniMap = ({
const ARIA_LABEL_KEY = 'react-flow__minimap-desc';
function MiniMap({
style,
className,
nodeStrokeColor = '#555',
@@ -63,7 +65,8 @@ const MiniMap = ({
nodeBorderRadius = 5,
nodeStrokeWidth = 2,
maskColor = 'rgb(240, 242, 243, 0.7)',
}: MiniMapProps) => {
}: MiniMapProps) {
const minimapId = useId();
const {
width: containerWidth,
height: containerHeight,
@@ -97,6 +100,7 @@ const MiniMap = ({
typeof window === 'undefined' || !!window.chrome
? 'crispEdges'
: 'geometricPrecision';
const labelledBy = `${ARIA_LABEL_KEY}-${minimapId}`;
return (
<svg
@@ -105,7 +109,10 @@ const MiniMap = ({
viewBox={`${x} ${y} ${width} ${height}`}
style={style}
className={cc(['react-flow__minimap', className])}
role="img"
aria-labelledby={labelledBy}
>
<title id={labelledBy}>React Flow mini map</title>
{nodes
.filter((node) => !node.hidden && node.width && node.height)
.map((node) => {
@@ -133,7 +140,7 @@ const MiniMap = ({
height={viewBB.height}
/>
<path
className='react-flow__minimap-mask'
className="react-flow__minimap-mask"
d={`M${x - offset},${y - offset}h${width + offset * 2}v${
height + offset * 2
}h${-width - offset * 2}z
@@ -141,11 +148,11 @@ const MiniMap = ({
viewBB.height
}h${-viewBB.width}z`}
fill={maskColor}
fillRule='evenodd'
fillRule="evenodd"
/>
</svg>
);
};
}
MiniMap.displayName = 'MiniMap';

12631
yarn.lock

File diff suppressed because it is too large Load Diff