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:
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user