diff --git a/packages/react/src/components/NodeWrapper/utils.tsx b/packages/react/src/components/NodeWrapper/utils.tsx index 8c699c64..c8c7d5c7 100644 --- a/packages/react/src/components/NodeWrapper/utils.tsx +++ b/packages/react/src/components/NodeWrapper/utils.tsx @@ -23,9 +23,9 @@ export const builtinNodeTypes: NodeTypes = { export function getNodeInlineStyleDimensions( node: InternalNode ): { - width: number | string | undefined; - height: number | string | undefined; - } { + width: number | string | undefined; + height: number | string | undefined; +} { if (node.internals.handleBounds === undefined) { return { width: node.width ?? node.initialWidth ?? node.style?.width, diff --git a/packages/react/src/components/NodesSelection/index.tsx b/packages/react/src/components/NodesSelection/index.tsx index ae587b56..ecaef17b 100644 --- a/packages/react/src/components/NodesSelection/index.tsx +++ b/packages/react/src/components/NodesSelection/index.tsx @@ -61,9 +61,9 @@ export function NodesSelection({ const onContextMenu = onSelectionContextMenu ? (event: MouseEvent) => { - const selectedNodes = store.getState().nodes.filter((n) => n.selected); - onSelectionContextMenu(event, selectedNodes); - } + const selectedNodes = store.getState().nodes.filter((n) => n.selected); + onSelectionContextMenu(event, selectedNodes); + } : undefined; const onKeyDown = (event: KeyboardEvent) => { diff --git a/packages/svelte/src/lib/components/EdgeWrapper/EdgeWrapper.svelte b/packages/svelte/src/lib/components/EdgeWrapper/EdgeWrapper.svelte index b7f225ab..0194196f 100644 --- a/packages/svelte/src/lib/components/EdgeWrapper/EdgeWrapper.svelte +++ b/packages/svelte/src/lib/components/EdgeWrapper/EdgeWrapper.svelte @@ -1,7 +1,7 @@ - - + {#if !hidden} { - onMouseEvent(e, onedgecontextmenu); + onmouseevent(e, onedgecontextmenu); } : undefined} onpointerenter={onedgepointerenter ? (e) => { - onMouseEvent(e, onedgepointerenter); + onmouseevent(e, onedgepointerenter); } : undefined} onpointerleave={onedgepointerleave ? (e) => { - onMouseEvent(e, onedgepointerleave); + onmouseevent(e, onedgepointerleave); } : undefined} aria-label={ariaLabel === null @@ -113,7 +133,9 @@ : ariaLabel ? ariaLabel : `Edge from ${source} to ${target}`} - role="img" + role={focusable ? 'button' : 'img'} + onkeydown={focusable ? onkeydown : undefined} + tabIndex={focusable ? 0 : undefined} > - import { getInternalNodesBounds, isNumeric, type Rect } from '@xyflow/system'; + import { arrowKeyDiffs, getInternalNodesBounds, isNumeric, type Rect } from '@xyflow/system'; import { Selection } from '$lib/components/Selection'; import drag from '$lib/actions/drag'; @@ -16,6 +16,16 @@ onselectioncontextmenu }: NodeSelectionProps = $props(); + let ref = $state(); + + $effect(() => { + if (!store.disableKeyboardA11y) { + ref?.focus({ + preventScroll: true + }); + } + }); + let bounds: Rect | null = $derived.by(() => { if (store.selectionRectMode === 'nodes') { // eslint-disable-next-line @typescript-eslint/no-unused-expressions @@ -34,6 +44,14 @@ const selectedNodes = store.nodes.filter((n) => n.selected); onselectionclick?.({ nodes: selectedNodes, event }); } + + function onkeydown(event: KeyboardEvent) { + console.log('yas'); + if (Object.prototype.hasOwnProperty.call(arrowKeyDiffs, event.key)) { + event.preventDefault(); + store.moveSelectedNodes(arrowKeyDiffs[event.key], event.shiftKey ? 4 : 1); + } + } {#if store.selectionRectMode === 'nodes' && bounds && isNumeric(bounds.x) && isNumeric(bounds.y)} @@ -57,9 +75,10 @@ }} {oncontextmenu} {onclick} - role="button" - tabindex="-1" - onkeyup={() => {}} + role={store.disableKeyboardA11y ? undefined : 'button'} + tabIndex={store.disableKeyboardA11y ? undefined : -1} + onkeydown={store.disableKeyboardA11y ? undefined : onkeydown} + bind:this={ref} > diff --git a/packages/svelte/src/lib/components/NodeWrapper/NodeWrapper.svelte b/packages/svelte/src/lib/components/NodeWrapper/NodeWrapper.svelte index 3754fd9f..9382d66a 100644 --- a/packages/svelte/src/lib/components/NodeWrapper/NodeWrapper.svelte +++ b/packages/svelte/src/lib/components/NodeWrapper/NodeWrapper.svelte @@ -1,6 +1,13 @@ - - {#if !hidden}
onnodepointerleave({ node, event }) : undefined} onpointermove={onnodepointermove ? (event) => onnodepointermove({ node, event }) : undefined} oncontextmenu={onnodecontextmenu ? (event) => onnodecontextmenu({ node, event }) : undefined} + onkeydown={focusable ? onKeyDown : undefined} + tabIndex={focusable ? 0 : undefined} + role={focusable ? 'button' : undefined} > nodeRef?.blur()); } } @@ -288,6 +292,55 @@ export function createStore(signals: StoreSignals): SvelteFlowStore { } } + function moveSelectedNodes(direction: XYPosition, factor: number) { + const { nodeExtent, snapGrid, nodeOrigin, nodeLookup, nodesDraggable, onerror } = store; + + const nodeUpdates = new Map(); + /* + * by default a node moves 5px on each key press + * if snap grid is enabled, we use that for the velocity + */ + const xVelo = snapGrid?.[0] ?? 5; + const yVelo = snapGrid?.[1] ?? 5; + + const xDiff = direction.x * xVelo * factor; + const yDiff = direction.y * yVelo * factor; + + for (const node of nodeLookup.values()) { + const isSelected = + node.selected && + (node.draggable || (nodesDraggable && typeof node.draggable === 'undefined')); + + if (!isSelected) { + continue; + } + + let nextPosition = { + x: node.internals.positionAbsolute.x + xDiff, + y: node.internals.positionAbsolute.y + yDiff + }; + + if (snapGrid) { + nextPosition = snapPosition(nextPosition, snapGrid); + } + + const { position, positionAbsolute } = calculateNodePosition({ + nodeId: node.id, + nextPosition, + nodeLookup, + nodeExtent, + nodeOrigin, + onError: onerror + }); + + node.position = position; + node.internals.positionAbsolute = positionAbsolute; + + nodeUpdates.set(node.id, node); + } + updateNodePositions(nodeUpdates); + } + function panBy(delta: XYPosition) { return panBySystem({ delta, @@ -335,6 +388,7 @@ export function createStore(signals: StoreSignals): SvelteFlowStore { addSelectedEdges, handleNodeSelection, handleEdgeSelection, + moveSelectedNodes, panBy, updateConnection, cancelConnection, diff --git a/packages/svelte/src/lib/store/initial-store.svelte.ts b/packages/svelte/src/lib/store/initial-store.svelte.ts index 893c7fb3..fdf5a56c 100644 --- a/packages/svelte/src/lib/store/initial-store.svelte.ts +++ b/packages/svelte/src/lib/store/initial-store.svelte.ts @@ -196,6 +196,10 @@ export const getInitialStore = (signals: StoreSignals) => { nodesDraggable: boolean = $derived(signals.props.nodesDraggable ?? true); nodesConnectable: boolean = $derived(signals.props.nodesConnectable ?? true); elementsSelectable: boolean = $derived(signals.props.elementsSelectable ?? true); + nodesFocusable: boolean = $derived(signals.props.nodesFocusable ?? true); + edgesFocusable: boolean = $derived(signals.props.edgesFocusable ?? true); + + disableKeyboardA11y: boolean = $derived(signals.props.disableKeyboardA11y ?? false); minZoom: number = $derived(signals.props.minZoom ?? 0.5); maxZoom: number = $derived(signals.props.maxZoom ?? 2); diff --git a/packages/svelte/src/lib/store/types.ts b/packages/svelte/src/lib/store/types.ts index f53fedbf..4f8c9b07 100644 --- a/packages/svelte/src/lib/store/types.ts +++ b/packages/svelte/src/lib/store/types.ts @@ -29,8 +29,9 @@ export type SvelteFlowStoreActions = { unselectNodesAndEdges: (params?: { nodes?: Node[]; edges?: Edge[] }) => void; addSelectedNodes: (ids: string[]) => void; addSelectedEdges: (ids: string[]) => void; - handleNodeSelection: (id: string) => void; + handleNodeSelection: (id: string, unselect?: boolean, nodeRef?: HTMLDivElement | null) => void; handleEdgeSelection: (id: string) => void; + moveSelectedNodes: (direction: XYPosition, factor: number) => void; panBy: (delta: XYPosition) => Promise; updateConnection: UpdateConnection; cancelConnection: () => void; diff --git a/packages/svelte/src/lib/types/edges.ts b/packages/svelte/src/lib/types/edges.ts index 941a16e1..a9488610 100644 --- a/packages/svelte/src/lib/types/edges.ts +++ b/packages/svelte/src/lib/types/edges.ts @@ -24,6 +24,7 @@ export type Edge< labelStyle?: string; style?: string; class?: ClassValue; + focusable?: boolean; }; export type BaseEdgeProps = Pick< @@ -150,30 +151,7 @@ export type EdgeTypes = Record< export type DefaultEdgeOptions = DefaultEdgeOptionsBase; -export type EdgeLayouted = Pick< - Edge, - | 'type' - | 'id' - | 'data' - | 'style' - | 'source' - | 'target' - | 'animated' - | 'selected' - | 'selectable' - | 'deletable' - | 'label' - | 'labelStyle' - | 'interactionWidth' - | 'markerStart' - | 'markerEnd' - | 'sourceHandle' - | 'targetHandle' - | 'ariaLabel' - | 'hidden' - | 'class' - | 'zIndex' -> & +export type EdgeLayouted = Edge & EdgePosition & { sourceNode?: Node; targetNode?: Node; diff --git a/packages/svelte/src/lib/types/nodes.ts b/packages/svelte/src/lib/types/nodes.ts index 77f2d55a..2299f11f 100644 --- a/packages/svelte/src/lib/types/nodes.ts +++ b/packages/svelte/src/lib/types/nodes.ts @@ -20,6 +20,7 @@ export type Node< > = NodeBase & { class?: ClassValue; style?: string; + focusable?: boolean; }; // @todo: currently generics for nodes are not really supported diff --git a/packages/system/src/constants.ts b/packages/system/src/constants.ts index 9bb65656..e9fc0b18 100644 --- a/packages/system/src/constants.ts +++ b/packages/system/src/constants.ts @@ -1,4 +1,4 @@ -import { CoordinateExtent, HandleType } from './types'; +import { CoordinateExtent, HandleType, XYPosition } from './types'; export const errorMessages = { error001: () => @@ -36,3 +36,9 @@ export const infiniteExtent: CoordinateExtent = [ ]; export const elementSelectionKeys = ['Enter', ' ', 'Escape']; +export const arrowKeyDiffs: Record = { + ArrowUp: { x: 0, y: -1 }, + ArrowDown: { x: 0, y: 1 }, + ArrowLeft: { x: -1, y: 0 }, + ArrowRight: { x: 1, y: 0 }, +}; diff --git a/packages/system/src/styles/init.css b/packages/system/src/styles/init.css index ca4d6de7..3c35cf81 100644 --- a/packages/system/src/styles/init.css +++ b/packages/system/src/styles/init.css @@ -144,6 +144,10 @@ pointer-events: none; } + &:focus { + outline: 1px solid red !important; + } + &.selected, &:focus, &:focus-visible {