feat(nodes-edges): add nodesFocusable, edgesFocusable, cleanup keyboard controls

This commit is contained in:
moklick
2022-10-03 14:31:34 +02:00
parent d00faa6b3e
commit be6590a5d6
13 changed files with 73 additions and 26 deletions
@@ -92,7 +92,7 @@ const BasicFlow = () => {
defaultEdgeOptions={defaultEdgeOptions} defaultEdgeOptions={defaultEdgeOptions}
selectNodesOnDrag={false} selectNodesOnDrag={false}
> >
<Background variant={BackgroundVariant.Lines} /> <Background variant={BackgroundVariant.Dots} />
<MiniMap /> <MiniMap />
<Controls /> <Controls />
@@ -51,7 +51,7 @@ export default (EdgeComponent: ComponentType<EdgeProps>) => {
markerStart, markerStart,
rfId, rfId,
ariaLabel, ariaLabel,
disableKeyboardA11y, isFocusable,
pathOptions, pathOptions,
interactionWidth, interactionWidth,
}: WrapEdgeProps): JSX.Element | null => { }: WrapEdgeProps): JSX.Element | null => {
@@ -158,12 +158,12 @@ export default (EdgeComponent: ComponentType<EdgeProps>) => {
onMouseEnter={onEdgeMouseEnter} onMouseEnter={onEdgeMouseEnter}
onMouseMove={onEdgeMouseMove} onMouseMove={onEdgeMouseMove}
onMouseLeave={onEdgeMouseLeave} onMouseLeave={onEdgeMouseLeave}
onKeyDown={disableKeyboardA11y ? undefined : onKeyDown} onKeyDown={isFocusable ? onKeyDown : undefined}
tabIndex={disableKeyboardA11y ? undefined : 0} tabIndex={isFocusable ? 0 : undefined}
role={disableKeyboardA11y ? undefined : 'button'} role={isFocusable ? 'button' : undefined}
data-testid={`rf__edge-${id}`} data-testid={`rf__edge-${id}`}
aria-label={ariaLabel === null ? undefined : ariaLabel ? ariaLabel : `Edge from ${source} to ${target}`} aria-label={ariaLabel === null ? undefined : ariaLabel ? ariaLabel : `Edge from ${source} to ${target}`}
aria-describedby={disableKeyboardA11y ? undefined : `${ARIA_EDGE_DESC_KEY}-${rfId}`} aria-describedby={isFocusable ? `${ARIA_EDGE_DESC_KEY}-${rfId}` : undefined}
ref={edgeRef} ref={edgeRef}
> >
{!updating && ( {!updating && (
+26 -10
View File
@@ -11,10 +11,10 @@ import { NodeProps, WrapNodeProps, XYPosition } from '../../types';
import { elementSelectionKeys } from '../../utils'; import { elementSelectionKeys } from '../../utils';
export const arrowKeyDiffs: Record<string, XYPosition> = { export const arrowKeyDiffs: Record<string, XYPosition> = {
ArrowUp: { x: 0, y: -10 }, ArrowUp: { x: 0, y: -1 },
ArrowDown: { x: 0, y: 10 }, ArrowDown: { x: 0, y: 1 },
ArrowLeft: { x: -10, y: 0 }, ArrowLeft: { x: -1, y: 0 },
ArrowRight: { x: 10, y: 0 }, ArrowRight: { x: 1, y: 0 },
}; };
export default (NodeComponent: ComponentType<NodeProps>) => { export default (NodeComponent: ComponentType<NodeProps>) => {
@@ -38,6 +38,7 @@ export default (NodeComponent: ComponentType<NodeProps>) => {
isDraggable, isDraggable,
isSelectable, isSelectable,
isConnectable, isConnectable,
isFocusable,
selectNodesOnDrag, selectNodesOnDrag,
sourcePosition, sourcePosition,
targetPosition, targetPosition,
@@ -82,6 +83,7 @@ export default (NodeComponent: ComponentType<NodeProps>) => {
}; };
const onKeyDown = (event: KeyboardEvent) => { const onKeyDown = (event: KeyboardEvent) => {
const { snapGrid, snapToGrid } = store.getState();
if (elementSelectionKeys.includes(event.key) && isSelectable) { if (elementSelectionKeys.includes(event.key) && isSelectable) {
const unselect = event.key === 'Escape'; const unselect = event.key === 'Escape';
if (unselect) { if (unselect) {
@@ -92,14 +94,28 @@ export default (NodeComponent: ComponentType<NodeProps>) => {
store, store,
unselect, unselect,
}); });
} else if (selected && Object.prototype.hasOwnProperty.call(arrowKeyDiffs, event.key)) { } else if (
!disableKeyboardA11y &&
isDraggable &&
selected &&
Object.prototype.hasOwnProperty.call(arrowKeyDiffs, event.key)
) {
store.setState({ store.setState({
ariaLiveMessage: `Moved selected node ten pixels ${event.key ariaLiveMessage: `Moved selected node ${event.key
.replace('Arrow', '') .replace('Arrow', '')
.toLowerCase()}. New position, x: ${~~xPos}, y: ${~~yPos}`, .toLowerCase()}. New position, x: ${~~xPos}, y: ${~~yPos}`,
}); });
updatePositions(arrowKeyDiffs[event.key]); // by default a node moves 5px on each key press, or 20px if shift is pressed
// if snap grid is enabled, we use that for the velocity.
const xVelo = snapToGrid ? snapGrid[0] : 5;
const yVelo = snapToGrid ? snapGrid[1] : 5;
const factor = event.shiftKey ? 4 : 1;
updatePositions({
x: arrowKeyDiffs[event.key].x * xVelo * factor,
y: arrowKeyDiffs[event.key].y * yVelo * factor,
});
} }
}; };
@@ -179,9 +195,9 @@ export default (NodeComponent: ComponentType<NodeProps>) => {
onContextMenu={onContextMenuHandler} onContextMenu={onContextMenuHandler}
onClick={onSelectNodeHandler} onClick={onSelectNodeHandler}
onDoubleClick={onDoubleClickHandler} onDoubleClick={onDoubleClickHandler}
onKeyDown={disableKeyboardA11y ? undefined : onKeyDown} onKeyDown={isFocusable ? onKeyDown : undefined}
tabIndex={disableKeyboardA11y ? undefined : 0} tabIndex={isFocusable ? 0 : undefined}
role={disableKeyboardA11y ? undefined : 'button'} role={isFocusable ? 'button' : undefined}
aria-describedby={disableKeyboardA11y ? undefined : `${ARIA_NODE_DESC_KEY}-${rfId}`} aria-describedby={disableKeyboardA11y ? undefined : `${ARIA_NODE_DESC_KEY}-${rfId}`}
aria-label={ariaLabel} aria-label={ariaLabel}
> >
@@ -18,6 +18,8 @@ type StoreUpdaterProps = Pick<
| 'onClickConnectEnd' | 'onClickConnectEnd'
| 'nodesDraggable' | 'nodesDraggable'
| 'nodesConnectable' | 'nodesConnectable'
| 'nodesFocusable'
| 'edgesFocusable'
| 'minZoom' | 'minZoom'
| 'maxZoom' | 'maxZoom'
| 'nodeExtent' | 'nodeExtent'
@@ -89,6 +91,8 @@ const StoreUpdater = ({
onClickConnectEnd, onClickConnectEnd,
nodesDraggable, nodesDraggable,
nodesConnectable, nodesConnectable,
nodesFocusable,
edgesFocusable,
minZoom, minZoom,
maxZoom, maxZoom,
nodeExtent, nodeExtent,
@@ -145,6 +149,8 @@ const StoreUpdater = ({
useDirectStoreUpdater('onClickConnectEnd', onClickConnectEnd, store.setState); useDirectStoreUpdater('onClickConnectEnd', onClickConnectEnd, store.setState);
useDirectStoreUpdater('nodesDraggable', nodesDraggable, store.setState); useDirectStoreUpdater('nodesDraggable', nodesDraggable, store.setState);
useDirectStoreUpdater('nodesConnectable', nodesConnectable, store.setState); useDirectStoreUpdater('nodesConnectable', nodesConnectable, store.setState);
useDirectStoreUpdater('nodesFocusable', nodesFocusable, store.setState);
useDirectStoreUpdater('edgesFocusable', edgesFocusable, store.setState);
useDirectStoreUpdater('elementsSelectable', elementsSelectable, store.setState); useDirectStoreUpdater('elementsSelectable', elementsSelectable, store.setState);
useDirectStoreUpdater('snapToGrid', snapToGrid, store.setState); useDirectStoreUpdater('snapToGrid', snapToGrid, store.setState);
useDirectStoreUpdater('snapGrid', snapGrid, store.setState); useDirectStoreUpdater('snapGrid', snapGrid, store.setState);
@@ -46,6 +46,7 @@ const selector = (s: ReactFlowState) => ({
connectionNodeId: s.connectionNodeId, connectionNodeId: s.connectionNodeId,
connectionHandleType: s.connectionHandleType, connectionHandleType: s.connectionHandleType,
nodesConnectable: s.nodesConnectable, nodesConnectable: s.nodesConnectable,
edgesFocusable: s.edgesFocusable,
elementsSelectable: s.elementsSelectable, elementsSelectable: s.elementsSelectable,
width: s.width, width: s.width,
height: s.height, height: s.height,
@@ -58,6 +59,7 @@ const EdgeRenderer = (props: EdgeRendererProps) => {
connectionNodeId, connectionNodeId,
connectionHandleType, connectionHandleType,
nodesConnectable, nodesConnectable,
edgesFocusable,
elementsSelectable, elementsSelectable,
width, width,
height, height,
@@ -119,6 +121,7 @@ const EdgeRenderer = (props: EdgeRendererProps) => {
const targetHandle = getHandle(targetNodeHandles!, edge.targetHandle || null); const targetHandle = getHandle(targetNodeHandles!, edge.targetHandle || null);
const sourcePosition = sourceHandle?.position || Position.Bottom; const sourcePosition = sourceHandle?.position || Position.Bottom;
const targetPosition = targetHandle?.position || Position.Top; const targetPosition = targetHandle?.position || Position.Top;
const isFocusable = !!(edge.focusable || (edgesFocusable && typeof edge.focusable === 'undefined'));
if (!sourceHandle || !targetHandle) { if (!sourceHandle || !targetHandle) {
devWarn( devWarn(
@@ -181,7 +184,7 @@ const EdgeRenderer = (props: EdgeRendererProps) => {
onEdgeUpdateEnd={props.onEdgeUpdateEnd} onEdgeUpdateEnd={props.onEdgeUpdateEnd}
rfId={props.rfId} rfId={props.rfId}
ariaLabel={edge.ariaLabel} ariaLabel={edge.ariaLabel}
disableKeyboardA11y={props.disableKeyboardA11y} isFocusable={isFocusable}
pathOptions={'pathOptions' in edge ? edge.pathOptions : undefined} pathOptions={'pathOptions' in edge ? edge.pathOptions : undefined}
interactionWidth={edge.interactionWidth} interactionWidth={edge.interactionWidth}
/> />
@@ -31,12 +31,16 @@ type NodeRendererProps = Pick<
const selector = (s: ReactFlowState) => ({ const selector = (s: ReactFlowState) => ({
nodesDraggable: s.nodesDraggable, nodesDraggable: s.nodesDraggable,
nodesConnectable: s.nodesConnectable, nodesConnectable: s.nodesConnectable,
nodesFocusable: s.nodesFocusable,
elementsSelectable: s.elementsSelectable, elementsSelectable: s.elementsSelectable,
updateNodeDimensions: s.updateNodeDimensions, updateNodeDimensions: s.updateNodeDimensions,
}); });
const NodeRenderer = (props: NodeRendererProps) => { const NodeRenderer = (props: NodeRendererProps) => {
const { nodesDraggable, nodesConnectable, elementsSelectable, updateNodeDimensions } = useStore(selector, shallow); const { nodesDraggable, nodesConnectable, nodesFocusable, elementsSelectable, updateNodeDimensions } = useStore(
selector,
shallow
);
const nodes = useVisibleNodes(props.onlyRenderVisibleElements); const nodes = useVisibleNodes(props.onlyRenderVisibleElements);
const resizeObserverRef = useRef<ResizeObserver>(); const resizeObserverRef = useRef<ResizeObserver>();
@@ -82,6 +86,8 @@ const NodeRenderer = (props: NodeRendererProps) => {
const isDraggable = !!(node.draggable || (nodesDraggable && typeof node.draggable === 'undefined')); const isDraggable = !!(node.draggable || (nodesDraggable && typeof node.draggable === 'undefined'));
const isSelectable = !!(node.selectable || (elementsSelectable && typeof node.selectable === 'undefined')); const isSelectable = !!(node.selectable || (elementsSelectable && typeof node.selectable === 'undefined'));
const isConnectable = !!(node.connectable || (nodesConnectable && typeof node.connectable === 'undefined')); const isConnectable = !!(node.connectable || (nodesConnectable && typeof node.connectable === 'undefined'));
const isFocusable = !!(node.focusable || (nodesFocusable && typeof node.focusable === 'undefined'));
const clampedPosition = props.nodeExtent const clampedPosition = props.nodeExtent
? clampPosition(node.positionAbsolute, props.nodeExtent) ? clampPosition(node.positionAbsolute, props.nodeExtent)
: node.positionAbsolute; : node.positionAbsolute;
@@ -122,6 +128,7 @@ const NodeRenderer = (props: NodeRendererProps) => {
isDraggable={isDraggable} isDraggable={isDraggable}
isSelectable={isSelectable} isSelectable={isSelectable}
isConnectable={isConnectable} isConnectable={isConnectable}
isFocusable={isFocusable}
resizeObserver={resizeObserver} resizeObserver={resizeObserver}
dragHandle={node.dragHandle} dragHandle={node.dragHandle}
zIndex={node[internalsSymbol]?.z ?? 0} zIndex={node[internalsSymbol]?.z ?? 0}
@@ -108,7 +108,9 @@ const ReactFlow = forwardRef<ReactFlowRefType, ReactFlowProps>(
selectNodesOnDrag = true, selectNodesOnDrag = true,
nodesDraggable, nodesDraggable,
nodesConnectable, nodesConnectable,
nodesFocusable,
nodeOrigin = initNodeOrigin, nodeOrigin = initNodeOrigin,
edgesFocusable,
elementsSelectable, elementsSelectable,
defaultViewport = initDefaultViewport, defaultViewport = initDefaultViewport,
minZoom = 0.5, minZoom = 0.5,
@@ -245,6 +247,8 @@ const ReactFlow = forwardRef<ReactFlowRefType, ReactFlowProps>(
onClickConnectEnd={onClickConnectEnd} onClickConnectEnd={onClickConnectEnd}
nodesDraggable={nodesDraggable} nodesDraggable={nodesDraggable}
nodesConnectable={nodesConnectable} nodesConnectable={nodesConnectable}
nodesFocusable={nodesFocusable}
edgesFocusable={edgesFocusable}
elementsSelectable={elementsSelectable} elementsSelectable={elementsSelectable}
minZoom={minZoom} minZoom={minZoom}
maxZoom={maxZoom} maxZoom={maxZoom}
@@ -9,17 +9,19 @@ function useUpdateNodePositions() {
const store = useStoreApi(); const store = useStoreApi();
const updatePositions = useCallback((positionDiff: XYPosition) => { const updatePositions = useCallback((positionDiff: XYPosition) => {
const { nodeInternals, nodeExtent, updateNodePositions } = store.getState(); const { nodeInternals, nodeExtent, updateNodePositions, snapToGrid, snapGrid } = store.getState();
const selectedNodes = Array.from(nodeInternals.values()).filter((n) => n.selected); const selectedNodes = Array.from(nodeInternals.values()).filter((n) => n.selected);
const nodeUpdates = selectedNodes.map((n) => { const nodeUpdates = selectedNodes.map((n) => {
if (n.positionAbsolute) { if (n.positionAbsolute) {
const updatedPos = calcNextPosition( const nextPosition = { x: n.positionAbsolute.x + positionDiff.x, y: n.positionAbsolute.y + positionDiff.y };
n,
{ x: n.positionAbsolute.x + positionDiff.x, y: n.positionAbsolute.y + positionDiff.y }, if (snapToGrid) {
nodeInternals, nextPosition.x = snapGrid[0] * Math.round(nextPosition.x / snapGrid[0]);
nodeExtent nextPosition.y = snapGrid[1] * Math.round(nextPosition.y / snapGrid[1]);
); }
const updatedPos = calcNextPosition(n, nextPosition, nodeInternals, nodeExtent);
n.position = updatedPos.position; n.position = updatedPos.position;
n.positionAbsolute = updatedPos.positionAbsolute; n.positionAbsolute = updatedPos.positionAbsolute;
+2
View File
@@ -40,6 +40,8 @@ const initialState: ReactFlowStore = {
nodesDraggable: true, nodesDraggable: true,
nodesConnectable: true, nodesConnectable: true,
nodesFocusable: true,
edgesFocusable: true,
elementsSelectable: true, elementsSelectable: true,
fitViewOnInit: false, fitViewOnInit: false,
fitViewOnInitDone: false, fitViewOnInitDone: false,
@@ -92,7 +92,9 @@ export interface ReactFlowProps extends HTMLAttributes<HTMLDivElement> {
onlyRenderVisibleElements?: boolean; onlyRenderVisibleElements?: boolean;
nodesDraggable?: boolean; nodesDraggable?: boolean;
nodesConnectable?: boolean; nodesConnectable?: boolean;
nodesFocusable?: boolean;
nodeOrigin?: NodeOrigin; nodeOrigin?: NodeOrigin;
edgesFocusable?: boolean;
initNodeOrigin?: NodeOrigin; initNodeOrigin?: NodeOrigin;
elementsSelectable?: boolean; elementsSelectable?: boolean;
selectNodesOnDrag?: boolean; selectNodesOnDrag?: boolean;
+2 -1
View File
@@ -33,6 +33,7 @@ type DefaultEdge<T = any> = {
zIndex?: number; zIndex?: number;
ariaLabel?: string; ariaLabel?: string;
interactionWidth?: number; interactionWidth?: number;
focusable?: boolean;
}; };
export type SmoothStepPathOptions = { export type SmoothStepPathOptions = {
@@ -132,7 +133,7 @@ export type WrapEdgeProps<T = any> = Omit<Edge<T>, 'sourceHandle' | 'targetHandl
onEdgeUpdateStart?: (event: React.MouseEvent, edge: Edge, handleType: HandleType) => void; onEdgeUpdateStart?: (event: React.MouseEvent, edge: Edge, handleType: HandleType) => void;
onEdgeUpdateEnd?: (event: MouseEvent, edge: Edge, handleType: HandleType) => void; onEdgeUpdateEnd?: (event: MouseEvent, edge: Edge, handleType: HandleType) => void;
rfId?: string; rfId?: string;
disableKeyboardA11y: boolean; isFocusable: boolean;
pathOptions?: BezierPathOptions | SmoothStepPathOptions; pathOptions?: BezierPathOptions | SmoothStepPathOptions;
}; };
+2
View File
@@ -167,6 +167,8 @@ export type ReactFlowStore = {
nodesDraggable: boolean; nodesDraggable: boolean;
nodesConnectable: boolean; nodesConnectable: boolean;
nodesFocusable: boolean;
edgesFocusable: boolean;
elementsSelectable: boolean; elementsSelectable: boolean;
multiSelectionActive: boolean; multiSelectionActive: boolean;
+2
View File
@@ -31,6 +31,7 @@ export interface Node<T = any> {
expandParent?: boolean; expandParent?: boolean;
positionAbsolute?: XYPosition; positionAbsolute?: XYPosition;
ariaLabel?: string; ariaLabel?: string;
focusable?: boolean;
// only used internally // only used internally
[internalsSymbol]?: { [internalsSymbol]?: {
@@ -73,6 +74,7 @@ export interface WrapNodeProps<T = any> {
initialized: boolean; initialized: boolean;
isSelectable: boolean; isSelectable: boolean;
isDraggable: boolean; isDraggable: boolean;
isFocusable: boolean;
selectNodesOnDrag: boolean; selectNodesOnDrag: boolean;
onClick?: NodeMouseHandler; onClick?: NodeMouseHandler;
onDoubleClick?: NodeMouseHandler; onDoubleClick?: NodeMouseHandler;