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,
@@ -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}
/>
);
})}
@@ -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"
@@ -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>
@@ -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}
/>
);
})}
@@ -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>
);
@@ -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 =>
+14 -4
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);
+23 -23
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:
+26 -20
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 });
@@ -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;
+1 -1
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) {
+17 -3
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 {
@@ -125,6 +125,7 @@ export interface ReactFlowProps extends HTMLAttributes<HTMLDivElement> {
attributionPosition?: AttributionPosition;
proOptions?: ProOptions;
elevateEdgesOnSelect?: boolean;
disableKeyboardA11y?: boolean;
}
export type ReactFlowRefType = HTMLDivElement;
+3
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> {
+1 -1
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;
+4
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 = {
+3
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'];