implemented keyboard interactions
This commit is contained in:
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -77,6 +77,9 @@
|
||||
nodesDraggable,
|
||||
nodesConnectable,
|
||||
elementsSelectable,
|
||||
nodesFocusable,
|
||||
edgesFocusable,
|
||||
disableKeyboardA11y,
|
||||
...divAttributes
|
||||
} = $derived(rest);
|
||||
|
||||
|
||||
@@ -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
|
||||
*
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user