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:
@@ -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
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)}
|
||||
/>
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
26
packages/core/src/components/A11yDescriptions/index.tsx
Normal file
26
packages/core/src/components/A11yDescriptions/index.tsx
Normal 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;
|
||||
@@ -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,
|
||||
|
||||
@@ -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 =>
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 });
|
||||
|
||||
37
packages/core/src/hooks/useUpdateNodePositions.ts
Normal file
37
packages/core/src/hooks/useUpdateNodePositions.ts
Normal 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;
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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'];
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
Reference in New Issue
Block a user