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
@@ -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;
@@ -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>
@@ -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}
+3 -1
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] });
}
}
@@ -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
@@ -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,