implemented keyboard interactions

This commit is contained in:
peterkogo
2025-04-16 11:39:50 +02:00
parent 7c776a31a7
commit 3ef63ad10e
14 changed files with 201 additions and 54 deletions
@@ -23,9 +23,9 @@ export const builtinNodeTypes: NodeTypes = {
export function getNodeInlineStyleDimensions<NodeType extends Node = Node>( export function getNodeInlineStyleDimensions<NodeType extends Node = Node>(
node: InternalNode<NodeType> node: InternalNode<NodeType>
): { ): {
width: number | string | undefined; width: number | string | undefined;
height: number | string | undefined; height: number | string | undefined;
} { } {
if (node.internals.handleBounds === undefined) { if (node.internals.handleBounds === undefined) {
return { return {
width: node.width ?? node.initialWidth ?? node.style?.width, width: node.width ?? node.initialWidth ?? node.style?.width,
@@ -61,9 +61,9 @@ export function NodesSelection<NodeType extends Node>({
const onContextMenu = onSelectionContextMenu const onContextMenu = onSelectionContextMenu
? (event: MouseEvent) => { ? (event: MouseEvent) => {
const selectedNodes = store.getState().nodes.filter((n) => n.selected); const selectedNodes = store.getState().nodes.filter((n) => n.selected);
onSelectionContextMenu(event, selectedNodes); onSelectionContextMenu(event, selectedNodes);
} }
: undefined; : undefined;
const onKeyDown = (event: KeyboardEvent) => { const onKeyDown = (event: KeyboardEvent) => {
@@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import { setContext } from 'svelte'; import { setContext } from 'svelte';
import { getMarkerId } from '@xyflow/system'; import { elementSelectionKeys, getMarkerId } from '@xyflow/system';
import { BezierEdgeInternal } from '$lib/components/edges'; import { BezierEdgeInternal } from '$lib/components/edges';
@@ -38,7 +38,8 @@
targetHandle, targetHandle,
markerStart, markerStart,
markerEnd, markerEnd,
selectable: edgeSelectable, selectable: _selectable,
focusable: _focusable,
deletable = true, deletable = true,
hidden, hidden,
zIndex, zIndex,
@@ -46,12 +47,15 @@
ariaLabel ariaLabel
} = $derived(edge); } = $derived(edge);
$inspect(edge); // svelte-ignore non_reactive_update
let edgeRef: SVGGElement | null = null;
const { id } = edge; const { id } = edge;
setContext('svelteflow__edge_id', id); setContext('svelteflow__edge_id', id);
let selectable = $derived(edgeSelectable ?? store.elementsSelectable); let selectable = $derived(_selectable ?? store.elementsSelectable);
let focusable = $derived(_focusable ?? store.edgesFocusable);
let EdgeComponent = $derived(store.edgeTypes[type] ?? BezierEdgeInternal); let EdgeComponent = $derived(store.edgeTypes[type] ?? BezierEdgeInternal);
let markerStartUrl = $derived( let markerStartUrl = $derived(
@@ -70,7 +74,7 @@
} }
} }
function onMouseEvent<T = MouseEvent>( function onmouseevent<T = MouseEvent>(
event: T, event: T,
callback: ({ edge, event }: { edge: Edge; event: T }) => void callback: ({ edge, event }: { edge: Edge; event: T }) => void
) { ) {
@@ -80,13 +84,29 @@
callback({ event, edge }); callback({ event, edge });
} }
} }
onkeydown = (event: KeyboardEvent) => {
// TODO: Possible Svelte Bug? onkeydown is always firing for the last edge
if (!store.disableKeyboardA11y && elementSelectionKeys.includes(event.key) && selectable) {
const { unselectNodesAndEdges, addSelectedEdges } = store;
const unselect = event.key === 'Escape';
if (unselect) {
edgeRef?.blur();
unselectNodesAndEdges({ edges: [edge] });
} else {
console.log(id);
addSelectedEdges([id]);
}
}
};
</script> </script>
<!-- svelte-ignore a11y_click_events_have_key_events --> <!-- TODO: aria-label, describedby -->
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
{#if !hidden} {#if !hidden}
<svg style:z-index={zIndex}> <svg style:z-index={zIndex}>
<g <g
bind:this={edgeRef}
class={['svelte-flow__edge', className]} class={['svelte-flow__edge', className]}
class:animated class:animated
class:selected class:selected
@@ -95,17 +115,17 @@
{onclick} {onclick}
oncontextmenu={onedgecontextmenu oncontextmenu={onedgecontextmenu
? (e) => { ? (e) => {
onMouseEvent(e, onedgecontextmenu); onmouseevent(e, onedgecontextmenu);
} }
: undefined} : undefined}
onpointerenter={onedgepointerenter onpointerenter={onedgepointerenter
? (e) => { ? (e) => {
onMouseEvent(e, onedgepointerenter); onmouseevent(e, onedgepointerenter);
} }
: undefined} : undefined}
onpointerleave={onedgepointerleave onpointerleave={onedgepointerleave
? (e) => { ? (e) => {
onMouseEvent(e, onedgepointerleave); onmouseevent(e, onedgepointerleave);
} }
: undefined} : undefined}
aria-label={ariaLabel === null aria-label={ariaLabel === null
@@ -113,7 +133,9 @@
: ariaLabel : ariaLabel
? ariaLabel ? ariaLabel
: `Edge from ${source} to ${target}`} : `Edge from ${source} to ${target}`}
role="img" role={focusable ? 'button' : 'img'}
onkeydown={focusable ? onkeydown : undefined}
tabIndex={focusable ? 0 : undefined}
> >
<EdgeComponent <EdgeComponent
{id} {id}
@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { getInternalNodesBounds, isNumeric, type Rect } from '@xyflow/system'; import { arrowKeyDiffs, getInternalNodesBounds, isNumeric, type Rect } from '@xyflow/system';
import { Selection } from '$lib/components/Selection'; import { Selection } from '$lib/components/Selection';
import drag from '$lib/actions/drag'; import drag from '$lib/actions/drag';
@@ -16,6 +16,16 @@
onselectioncontextmenu onselectioncontextmenu
}: NodeSelectionProps = $props(); }: NodeSelectionProps = $props();
let ref = $state<HTMLDivElement>();
$effect(() => {
if (!store.disableKeyboardA11y) {
ref?.focus({
preventScroll: true
});
}
});
let bounds: Rect | null = $derived.by(() => { let bounds: Rect | null = $derived.by(() => {
if (store.selectionRectMode === 'nodes') { if (store.selectionRectMode === 'nodes') {
// eslint-disable-next-line @typescript-eslint/no-unused-expressions // eslint-disable-next-line @typescript-eslint/no-unused-expressions
@@ -34,6 +44,14 @@
const selectedNodes = store.nodes.filter((n) => n.selected); const selectedNodes = store.nodes.filter((n) => n.selected);
onselectionclick?.({ nodes: selectedNodes, event }); 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);
}
}
</script> </script>
{#if store.selectionRectMode === 'nodes' && bounds && isNumeric(bounds.x) && isNumeric(bounds.y)} {#if store.selectionRectMode === 'nodes' && bounds && isNumeric(bounds.x) && isNumeric(bounds.y)}
@@ -57,9 +75,10 @@
}} }}
{oncontextmenu} {oncontextmenu}
{onclick} {onclick}
role="button" role={store.disableKeyboardA11y ? undefined : 'button'}
tabindex="-1" tabIndex={store.disableKeyboardA11y ? undefined : -1}
onkeyup={() => {}} onkeydown={store.disableKeyboardA11y ? undefined : onkeydown}
bind:this={ref}
> >
<Selection width="100%" height="100%" x={0} y={0} /> <Selection width="100%" height="100%" x={0} y={0} />
</div> </div>
@@ -1,6 +1,13 @@
<script lang="ts"> <script lang="ts">
import { setContext, onDestroy } from 'svelte'; import { setContext, onDestroy } from 'svelte';
import { errorMessages, nodeHasDimensions, Position } from '@xyflow/system'; import {
arrowKeyDiffs,
elementSelectionKeys,
errorMessages,
isInputDOMNode,
nodeHasDimensions,
Position
} from '@xyflow/system';
import drag from '$lib/actions/drag'; import drag from '$lib/actions/drag';
import DefaultNode from '$lib/components/nodes/DefaultNode.svelte'; import DefaultNode from '$lib/components/nodes/DefaultNode.svelte';
@@ -31,6 +38,7 @@
selectable: _selectable, selectable: _selectable,
deletable = true, deletable = true,
connectable: _connectable, connectable: _connectable,
focusable: _focusable,
hidden = false, hidden = false,
dragging = false, dragging = false,
style = '', style = '',
@@ -57,6 +65,7 @@
let selectable = $derived(_selectable ?? store.elementsSelectable); let selectable = $derived(_selectable ?? store.elementsSelectable);
let connectable = $derived(_connectable ?? store.nodesConnectable); let connectable = $derived(_connectable ?? store.nodesConnectable);
let initialized = $derived(nodeHasDimensions(node) && !!node.internals.handleBounds); let initialized = $derived(nodeHasDimensions(node) && !!node.internals.handleBounds);
let focusable = $derived(_focusable ?? store.nodesFocusable);
function isInParentLookup(id: string) { function isInParentLookup(id: string) {
return store.parentLookup.has(id); return store.parentLookup.has(id);
@@ -155,10 +164,36 @@
onnodeclick?.({ node, event }); onnodeclick?.({ node, event });
} }
function onKeyDown(event: KeyboardEvent) {
if (isInputDOMNode(event) || store.disableKeyboardA11y) {
return;
}
if (elementSelectionKeys.includes(event.key) && selectable) {
const unselect = event.key === 'Escape';
store.handleNodeSelection(id, unselect, nodeRef);
} else if (
draggable &&
node.selected &&
Object.prototype.hasOwnProperty.call(arrowKeyDiffs, event.key)
) {
// prevent default scrolling behavior on arrow key press when node is moved
event.preventDefault();
// TODO: aria live message
// store.setState({
// ariaLiveMessage: `Moved selected node ${event.key
// .replace('Arrow', '')
// .toLowerCase()}. New position, x: ${~~internals.positionAbsolute.x}, y: ${~~internals.positionAbsolute.y}`,
// });
store.moveSelectedNodes(arrowKeyDiffs[event.key], event.shiftKey ? 4 : 1);
}
}
</script> </script>
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
{#if !hidden} {#if !hidden}
<div <div
use:drag={{ use:drag={{
@@ -199,6 +234,9 @@
onpointerleave={onnodepointerleave ? (event) => onnodepointerleave({ node, event }) : undefined} onpointerleave={onnodepointerleave ? (event) => onnodepointerleave({ node, event }) : undefined}
onpointermove={onnodepointermove ? (event) => onnodepointermove({ node, event }) : undefined} onpointermove={onnodepointermove ? (event) => onnodepointermove({ node, event }) : undefined}
oncontextmenu={onnodecontextmenu ? (event) => onnodecontextmenu({ node, event }) : undefined} oncontextmenu={onnodecontextmenu ? (event) => onnodecontextmenu({ node, event }) : undefined}
onkeydown={focusable ? onKeyDown : undefined}
tabIndex={focusable ? 0 : undefined}
role={focusable ? 'button' : undefined}
> >
<NodeComponent <NodeComponent
{data} {data}
@@ -77,6 +77,9 @@
nodesDraggable, nodesDraggable,
nodesConnectable, nodesConnectable,
elementsSelectable, elementsSelectable,
nodesFocusable,
edgesFocusable,
disableKeyboardA11y,
...divAttributes ...divAttributes
} = $derived(rest); } = $derived(rest);
@@ -216,6 +216,18 @@ export type SvelteFlowProps = NodeEvents &
* @default true * @default true
*/ */
elementsSelectable?: boolean; elementsSelectable?: boolean;
/**
* When `true`, focus between nodes can be cycled with the `Tab` key and selected with the `Enter`
* key. This option can be overridden by individual nodes by setting their `focusable` prop.
* @default true
*/
nodesFocusable?: boolean;
/**
* When `true`, focus between edges can be cycled with the `Tab` key and selected with the `Enter`
* key. This option can be overridden by individual edges by setting their `focusable` prop.
* @default true
*/
edgesFocusable?: boolean;
/** By default the viewport extends infinitely. You can use this prop to set a boundary. /** By default the viewport extends infinitely. You can use this prop to set a boundary.
* *
* The first pair of coordinates is the top left boundary and the second pair is the bottom right. * The first pair of coordinates is the top left boundary and the second pair is the bottom right.
@@ -297,7 +309,6 @@ export type SvelteFlowProps = NodeEvents &
* hidden: false, * hidden: false,
* deletable: true, * deletable: true,
* selected: false, * selected: false,
* focusable: true,
* markerStart: EdgeMarker.ArrowClosed, * markerStart: EdgeMarker.ArrowClosed,
* markerEnd: EdgeMarker.ArrowClosed, * markerEnd: EdgeMarker.ArrowClosed,
* zIndex: 12, * zIndex: 12,
@@ -343,6 +354,12 @@ export type SvelteFlowProps = NodeEvents &
* @default true * @default true
*/ */
elevateEdgesOnSelect?: boolean; elevateEdgesOnSelect?: boolean;
/**
* You can use this prop to disable keyboard accessibility features such as selecting nodes or
* moving selected nodes with the arrow keys.
* @default false
*/
disableKeyboardA11y?: boolean;
/** /**
* This callback can be used to validate a new connection * This callback can be used to validate a new connection
* *
+57 -3
View File
@@ -12,7 +12,9 @@ import {
type CoordinateExtent, type CoordinateExtent,
type UpdateConnection, type UpdateConnection,
type ConnectionState, type ConnectionState,
updateAbsolutePositions updateAbsolutePositions,
snapPosition,
calculateNodePosition
} from '@xyflow/system'; } from '@xyflow/system';
import type { EdgeTypes, NodeTypes, Node, Edge, FitViewOptions } from '$lib/types'; import type { EdgeTypes, NodeTypes, Node, Edge, FitViewOptions } from '$lib/types';
@@ -247,7 +249,7 @@ export function createStore(signals: StoreSignals): SvelteFlowStore {
} }
} }
function handleNodeSelection(id: string) { function handleNodeSelection(id: string, unselect?: boolean, nodeRef?: HTMLDivElement | null) {
const node = store.nodeLookup.get(id); const node = store.nodeLookup.get(id);
if (!node) { if (!node) {
@@ -260,8 +262,10 @@ export function createStore(signals: StoreSignals): SvelteFlowStore {
if (!node.selected) { if (!node.selected) {
addSelectedNodes([id]); addSelectedNodes([id]);
} else if (node.selected && store.multiselectionKeyPressed) { } else if (unselect || (node.selected && store.multiselectionKeyPressed)) {
unselectNodesAndEdges({ nodes: [node], edges: [] }); unselectNodesAndEdges({ nodes: [node], edges: [] });
requestAnimationFrame(() => 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) { function panBy(delta: XYPosition) {
return panBySystem({ return panBySystem({
delta, delta,
@@ -335,6 +388,7 @@ export function createStore(signals: StoreSignals): SvelteFlowStore {
addSelectedEdges, addSelectedEdges,
handleNodeSelection, handleNodeSelection,
handleEdgeSelection, handleEdgeSelection,
moveSelectedNodes,
panBy, panBy,
updateConnection, updateConnection,
cancelConnection, cancelConnection,
@@ -196,6 +196,10 @@ export const getInitialStore = (signals: StoreSignals) => {
nodesDraggable: boolean = $derived(signals.props.nodesDraggable ?? true); nodesDraggable: boolean = $derived(signals.props.nodesDraggable ?? true);
nodesConnectable: boolean = $derived(signals.props.nodesConnectable ?? true); nodesConnectable: boolean = $derived(signals.props.nodesConnectable ?? true);
elementsSelectable: boolean = $derived(signals.props.elementsSelectable ?? 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); minZoom: number = $derived(signals.props.minZoom ?? 0.5);
maxZoom: number = $derived(signals.props.maxZoom ?? 2); maxZoom: number = $derived(signals.props.maxZoom ?? 2);
+2 -1
View File
@@ -29,8 +29,9 @@ export type SvelteFlowStoreActions = {
unselectNodesAndEdges: (params?: { nodes?: Node[]; edges?: Edge[] }) => void; unselectNodesAndEdges: (params?: { nodes?: Node[]; edges?: Edge[] }) => void;
addSelectedNodes: (ids: string[]) => void; addSelectedNodes: (ids: string[]) => void;
addSelectedEdges: (ids: string[]) => void; addSelectedEdges: (ids: string[]) => void;
handleNodeSelection: (id: string) => void; handleNodeSelection: (id: string, unselect?: boolean, nodeRef?: HTMLDivElement | null) => void;
handleEdgeSelection: (id: string) => void; handleEdgeSelection: (id: string) => void;
moveSelectedNodes: (direction: XYPosition, factor: number) => void;
panBy: (delta: XYPosition) => Promise<boolean>; panBy: (delta: XYPosition) => Promise<boolean>;
updateConnection: UpdateConnection; updateConnection: UpdateConnection;
cancelConnection: () => void; cancelConnection: () => void;
+2 -24
View File
@@ -24,6 +24,7 @@ export type Edge<
labelStyle?: string; labelStyle?: string;
style?: string; style?: string;
class?: ClassValue; class?: ClassValue;
focusable?: boolean;
}; };
export type BaseEdgeProps = Pick< export type BaseEdgeProps = Pick<
@@ -150,30 +151,7 @@ export type EdgeTypes = Record<
export type DefaultEdgeOptions = DefaultEdgeOptionsBase<Edge>; export type DefaultEdgeOptions = DefaultEdgeOptionsBase<Edge>;
export type EdgeLayouted = Pick< export type EdgeLayouted = Edge &
Edge,
| 'type'
| 'id'
| 'data'
| 'style'
| 'source'
| 'target'
| 'animated'
| 'selected'
| 'selectable'
| 'deletable'
| 'label'
| 'labelStyle'
| 'interactionWidth'
| 'markerStart'
| 'markerEnd'
| 'sourceHandle'
| 'targetHandle'
| 'ariaLabel'
| 'hidden'
| 'class'
| 'zIndex'
> &
EdgePosition & { EdgePosition & {
sourceNode?: Node; sourceNode?: Node;
targetNode?: Node; targetNode?: Node;
+1
View File
@@ -20,6 +20,7 @@ export type Node<
> = NodeBase<NodeData, NodeType> & { > = NodeBase<NodeData, NodeType> & {
class?: ClassValue; class?: ClassValue;
style?: string; style?: string;
focusable?: boolean;
}; };
// @todo: currently generics for nodes are not really supported // @todo: currently generics for nodes are not really supported
+7 -1
View File
@@ -1,4 +1,4 @@
import { CoordinateExtent, HandleType } from './types'; import { CoordinateExtent, HandleType, XYPosition } from './types';
export const errorMessages = { export const errorMessages = {
error001: () => error001: () =>
@@ -36,3 +36,9 @@ export const infiniteExtent: CoordinateExtent = [
]; ];
export const elementSelectionKeys = ['Enter', ' ', 'Escape']; export const elementSelectionKeys = ['Enter', ' ', 'Escape'];
export const arrowKeyDiffs: Record<string, XYPosition> = {
ArrowUp: { x: 0, y: -1 },
ArrowDown: { x: 0, y: 1 },
ArrowLeft: { x: -1, y: 0 },
ArrowRight: { x: 1, y: 0 },
};
+4
View File
@@ -144,6 +144,10 @@
pointer-events: none; pointer-events: none;
} }
&:focus {
outline: 1px solid red !important;
}
&.selected, &.selected,
&:focus, &:focus,
&:focus-visible { &:focus-visible {