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

View File

@@ -23,9 +23,9 @@ export const builtinNodeTypes: NodeTypes = {
export function getNodeInlineStyleDimensions<NodeType extends Node = Node>(
node: InternalNode<NodeType>
): {
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,

View File

@@ -61,9 +61,9 @@ export function NodesSelection<NodeType extends Node>({
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) => {

View File

@@ -1,7 +1,7 @@
<script lang="ts">
import { setContext } from 'svelte';
import { getMarkerId } from '@xyflow/system';
import { elementSelectionKeys, getMarkerId } from '@xyflow/system';
import { BezierEdgeInternal } from '$lib/components/edges';
@@ -38,7 +38,8 @@
targetHandle,
markerStart,
markerEnd,
selectable: edgeSelectable,
selectable: _selectable,
focusable: _focusable,
deletable = true,
hidden,
zIndex,
@@ -46,12 +47,15 @@
ariaLabel
} = $derived(edge);
$inspect(edge);
// svelte-ignore non_reactive_update
let edgeRef: SVGGElement | null = null;
const { id } = edge;
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 markerStartUrl = $derived(
@@ -70,7 +74,7 @@
}
}
function onMouseEvent<T = MouseEvent>(
function onmouseevent<T = MouseEvent>(
event: T,
callback: ({ edge, event }: { edge: Edge; event: T }) => void
) {
@@ -80,13 +84,29 @@
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>
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<!-- TODO: aria-label, describedby -->
{#if !hidden}
<svg style:z-index={zIndex}>
<g
bind:this={edgeRef}
class={['svelte-flow__edge', className]}
class:animated
class:selected
@@ -95,17 +115,17 @@
{onclick}
oncontextmenu={onedgecontextmenu
? (e) => {
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}
>
<EdgeComponent
{id}

View File

@@ -1,5 +1,5 @@
<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 drag from '$lib/actions/drag';
@@ -16,6 +16,16 @@
onselectioncontextmenu
}: NodeSelectionProps = $props();
let ref = $state<HTMLDivElement>();
$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);
}
}
</script>
{#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}
>
<Selection width="100%" height="100%" x={0} y={0} />
</div>

View File

@@ -1,6 +1,13 @@
<script lang="ts">
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 DefaultNode from '$lib/components/nodes/DefaultNode.svelte';
@@ -31,6 +38,7 @@
selectable: _selectable,
deletable = true,
connectable: _connectable,
focusable: _focusable,
hidden = false,
dragging = false,
style = '',
@@ -57,6 +65,7 @@
let selectable = $derived(_selectable ?? store.elementsSelectable);
let connectable = $derived(_connectable ?? store.nodesConnectable);
let initialized = $derived(nodeHasDimensions(node) && !!node.internals.handleBounds);
let focusable = $derived(_focusable ?? store.nodesFocusable);
function isInParentLookup(id: string) {
return store.parentLookup.has(id);
@@ -155,10 +164,36 @@
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>
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
{#if !hidden}
<div
use:drag={{
@@ -199,6 +234,9 @@
onpointerleave={onnodepointerleave ? (event) => 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}
>
<NodeComponent
{data}

View File

@@ -77,6 +77,9 @@
nodesDraggable,
nodesConnectable,
elementsSelectable,
nodesFocusable,
edgesFocusable,
disableKeyboardA11y,
...divAttributes
} = $derived(rest);

View File

@@ -216,6 +216,18 @@ export type SvelteFlowProps = NodeEvents &
* @default true
*/
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.
*
* 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,
* deletable: true,
* selected: false,
* focusable: true,
* markerStart: EdgeMarker.ArrowClosed,
* markerEnd: EdgeMarker.ArrowClosed,
* zIndex: 12,
@@ -343,6 +354,12 @@ export type SvelteFlowProps = NodeEvents &
* @default true
*/
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
*

View File

@@ -12,7 +12,9 @@ import {
type CoordinateExtent,
type UpdateConnection,
type ConnectionState,
updateAbsolutePositions
updateAbsolutePositions,
snapPosition,
calculateNodePosition
} from '@xyflow/system';
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);
if (!node) {
@@ -260,8 +262,10 @@ export function createStore(signals: StoreSignals): SvelteFlowStore {
if (!node.selected) {
addSelectedNodes([id]);
} else if (node.selected && store.multiselectionKeyPressed) {
} else if (unselect || (node.selected && store.multiselectionKeyPressed)) {
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) {
return panBySystem({
delta,
@@ -335,6 +388,7 @@ export function createStore(signals: StoreSignals): SvelteFlowStore {
addSelectedEdges,
handleNodeSelection,
handleEdgeSelection,
moveSelectedNodes,
panBy,
updateConnection,
cancelConnection,

View File

@@ -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);

View File

@@ -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<boolean>;
updateConnection: UpdateConnection;
cancelConnection: () => void;

View File

@@ -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<Edge>;
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;

View File

@@ -20,6 +20,7 @@ export type Node<
> = NodeBase<NodeData, NodeType> & {
class?: ClassValue;
style?: string;
focusable?: boolean;
};
// @todo: currently generics for nodes are not really supported

View File

@@ -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<string, XYPosition> = {
ArrowUp: { x: 0, y: -1 },
ArrowDown: { x: 0, y: 1 },
ArrowLeft: { x: -1, y: 0 },
ArrowRight: { x: 1, y: 0 },
};

View File

@@ -144,6 +144,10 @@
pointer-events: none;
}
&:focus {
outline: 1px solid red !important;
}
&.selected,
&:focus,
&:focus-visible {