diff --git a/.changeset/thirty-snakes-float.md b/.changeset/thirty-snakes-float.md new file mode 100644 index 00000000..8b39c4f3 --- /dev/null +++ b/.changeset/thirty-snakes-float.md @@ -0,0 +1,6 @@ +--- +"@xyflow/react": minor +"@xyflow/svelte": minor +--- + +Focus nodes on tab if not within the viewport and add a new prop `autoPanOnNodeFocus` diff --git a/examples/react/src/examples/A11y/index.tsx b/examples/react/src/examples/A11y/index.tsx index f7f0b5b2..389b275c 100644 --- a/examples/react/src/examples/A11y/index.tsx +++ b/examples/react/src/examples/A11y/index.tsx @@ -1,4 +1,4 @@ -import { MouseEvent } from 'react'; +import { MouseEvent, useState } from 'react'; import { ReactFlow, MiniMap, @@ -8,40 +8,53 @@ import { ReactFlowProvider, Node, Edge, - OnNodeDrag, AriaLabelConfig, + Panel, } from '@xyflow/react'; -const onNodeDrag: OnNodeDrag = (_, node: Node, nodes: Node[]) => console.log('drag', node, nodes); -const onNodeDragStart = (_: MouseEvent, node: Node, nodes: Node[]) => console.log('drag start', node, nodes); -const onNodeDragStop = (_: MouseEvent, node: Node, nodes: Node[]) => console.log('drag stop', node, nodes); -const onNodeClick = (_: MouseEvent, node: Node) => console.log('click', node); - const initialNodes: Node[] = [ { id: '1', type: 'input', data: { label: 'A11y Node 1' }, position: { x: 250, y: 5 }, - className: 'light', }, { id: '2', data: { label: 'Node 2' }, - position: { x: 100, y: 100 }, - className: 'light', + position: { x: 1000, y: 100 }, }, { id: '3', data: { label: 'Node 3' }, - position: { x: 400, y: 100 }, + position: { x: 100, y: 100 }, className: 'light', + ariaRoleDescription: 'custom node role', + ariaRole: 'button', + }, + { + id: '4', + data: { label: 'Node 4' }, + position: { x: 300, y: 100 }, + }, + { + id: '5', + data: { label: 'Node 5' }, + position: { x: 400, y: 200 }, + }, + { + id: '6', + data: { label: 'Node 6' }, + position: { x: -1000, y: 200 }, }, ]; const initialEdges: Edge[] = [ { id: 'e1-2', source: '1', target: '2', animated: true }, { id: 'e1-3', source: '1', target: '3' }, + { id: 'e1-4', source: '1', target: '4' }, + { id: 'e1-5', source: '4', target: '5' }, + { id: 'e1-6', source: '3', target: '6' }, ]; const ariaLabelConfig: Partial = { @@ -59,19 +72,13 @@ const ariaLabelConfig: Partial = { }; const A11y = () => { + const [autoPanOnNodeFocus, setAutoPanOnNodeFocus] = useState(true); + return ( { + +
+ +
+
); }; diff --git a/examples/svelte/src/routes/examples/a11y/+page.svelte b/examples/svelte/src/routes/examples/a11y/+page.svelte index 07f53110..aa7dc938 100644 --- a/examples/svelte/src/routes/examples/a11y/+page.svelte +++ b/examples/svelte/src/routes/examples/a11y/+page.svelte @@ -1,5 +1,5 @@ - - - `Custom Moved selected node ${direction}. New position, x: ${x}, y: ${y}`, + 'node.a11yDescription.ariaLiveMessage': ({ + direction, + x, + y + }: { + direction: string; + x: number; + y: number; + }) => `Custom Moved selected node ${direction}. New position, x: ${x}, y: ${y}`, 'edge.a11yDescription.default': 'Svelte Custom Edge Desc.', 'controls.ariaLabel': 'Svelte Custom Control Aria Label', 'controls.zoomIn.ariaLabel': 'Svelte Custom Zoom in', @@ -37,9 +39,24 @@ // 'controls.fitView.ariaLabel': 'Svelte Custom Fit View', 'controls.interactive.ariaLabel': 'Svelte Custom Toggle Interactivity', 'minimap.ariaLabel': 'Svelte Custom Minimap' - }} -> + }); + + + + +
+ +
+
diff --git a/packages/react/src/components/NodeWrapper/index.tsx b/packages/react/src/components/NodeWrapper/index.tsx index 82302cc7..46e1ee01 100644 --- a/packages/react/src/components/NodeWrapper/index.tsx +++ b/packages/react/src/components/NodeWrapper/index.tsx @@ -7,6 +7,7 @@ import { getNodeDimensions, isInputDOMNode, nodeHasDimensions, + getNodesInside, } from '@xyflow/system'; import { useStore, useStoreApi } from '../../hooks/useStore'; @@ -158,6 +159,28 @@ export function NodeWrapper({ }); } }; + + const onFocus = () => { + if (disableKeyboardA11y || !nodeRef.current?.matches(':focus-visible')) { + return; + } + + const { transform, width, height, autoPanOnNodeFocus, setCenter } = store.getState(); + + if (!autoPanOnNodeFocus) { + return; + } + + const withinViewport = + getNodesInside(new Map([[id, node]]), { x: 0, y: 0, width, height }, transform, true).length > 0; + + if (!withinViewport) { + setCenter(node.position.x + nodeDimensions.width / 2, node.position.y + nodeDimensions.height / 2, { + zoom: transform[2], + }); + } + }; + return (
({ onDoubleClick={onDoubleClickHandler} onKeyDown={isFocusable ? onKeyDown : undefined} tabIndex={isFocusable ? 0 : undefined} + onFocus={isFocusable ? onFocus : undefined} role={node.ariaRole ?? (isFocusable ? 'group' : undefined)} aria-roledescription={node.ariaRoleDescription || 'node'} aria-describedby={disableKeyboardA11y ? undefined : `${ARIA_NODE_DESC_KEY}-${rfId}`} diff --git a/packages/react/src/components/StoreUpdater/index.tsx b/packages/react/src/components/StoreUpdater/index.tsx index 95b713af..5a5878c3 100644 --- a/packages/react/src/components/StoreUpdater/index.tsx +++ b/packages/react/src/components/StoreUpdater/index.tsx @@ -23,6 +23,7 @@ const reactFlowFieldsToTrack = [ 'onClickConnectStart', 'onClickConnectEnd', 'nodesDraggable', + 'autoPanOnNodeFocus', 'nodesConnectable', 'nodesFocusable', 'edgesFocusable', diff --git a/packages/react/src/container/NodeRenderer/index.tsx b/packages/react/src/container/NodeRenderer/index.tsx index 02126d73..0b055bdb 100644 --- a/packages/react/src/container/NodeRenderer/index.tsx +++ b/packages/react/src/container/NodeRenderer/index.tsx @@ -48,13 +48,13 @@ function NodeRendererComponent(props: NodeRendererProps( onlyRenderVisibleElements = false, selectNodesOnDrag, nodesDraggable, + autoPanOnNodeFocus, nodesConnectable, nodesFocusable, nodeOrigin = defaultNodeOrigin, @@ -261,6 +262,7 @@ function ReactFlow( onClickConnectStart={onClickConnectStart} onClickConnectEnd={onClickConnectEnd} nodesDraggable={nodesDraggable} + autoPanOnNodeFocus={autoPanOnNodeFocus} nodesConnectable={nodesConnectable} nodesFocusable={nodesFocusable} edgesFocusable={edgesFocusable} diff --git a/packages/react/src/hooks/useViewportHelper.ts b/packages/react/src/hooks/useViewportHelper.ts index fc01a150..eee97484 100644 --- a/packages/react/src/hooks/useViewportHelper.ts +++ b/packages/react/src/hooks/useViewportHelper.ts @@ -63,25 +63,7 @@ const useViewportHelper = (): ViewportHelperFunctions => { return { x, y, zoom }; }, setCenter: async (x, y, options) => { - const { width, height, maxZoom, panZoom } = store.getState(); - const nextZoom = typeof options?.zoom !== 'undefined' ? options.zoom : maxZoom; - const centerX = width / 2 - x * nextZoom; - const centerY = height / 2 - y * nextZoom; - - if (!panZoom) { - return Promise.resolve(false); - } - - await panZoom.setViewport( - { - x: centerX, - y: centerY, - zoom: nextZoom, - }, - { duration: options?.duration, ease: options?.ease, interpolate: options?.interpolate } - ); - - return Promise.resolve(true); + return store.getState().setCenter(x, y, options); }, fitBounds: async (bounds, options) => { const { width, height, minZoom, maxZoom, panZoom } = store.getState(); diff --git a/packages/react/src/store/index.ts b/packages/react/src/store/index.ts index 39702bd0..085f8b1e 100644 --- a/packages/react/src/store/index.ts +++ b/packages/react/src/store/index.ts @@ -360,6 +360,26 @@ const createStore = ({ return panBySystem({ delta, panZoom, transform, translateExtent, width, height }); }, + setCenter: async (x, y, options) => { + const { width, height, maxZoom, panZoom } = get(); + + if (!panZoom) { + return Promise.resolve(false); + } + + const nextZoom = typeof options?.zoom !== 'undefined' ? options.zoom : maxZoom; + + await panZoom.setViewport( + { + x: width / 2 - x * nextZoom, + y: height / 2 - y * nextZoom, + zoom: nextZoom, + }, + { duration: options?.duration, ease: options?.ease, interpolate: options?.interpolate } + ); + + return Promise.resolve(true); + }, cancelConnection: () => { set({ connection: { ...initialConnection }, diff --git a/packages/react/src/store/initialState.ts b/packages/react/src/store/initialState.ts index 15bfc1d8..2532b5ad 100644 --- a/packages/react/src/store/initialState.ts +++ b/packages/react/src/store/initialState.ts @@ -134,7 +134,9 @@ const getInitialState = ({ ariaLiveMessage: '', autoPanOnConnect: true, autoPanOnNodeDrag: true, + autoPanOnNodeFocus: true, autoPanSpeed: 15, + connectionRadius: 20, onError: devWarn, isValidConnection: undefined, diff --git a/packages/react/src/types/component-props.ts b/packages/react/src/types/component-props.ts index d088c26b..fc8b94a6 100644 --- a/packages/react/src/types/component-props.ts +++ b/packages/react/src/types/component-props.ts @@ -383,6 +383,11 @@ export interface ReactFlowProps = { triggerNodeChanges: (changes: NodeChange[]) => void; triggerEdgeChanges: (changes: EdgeChange[]) => void; panBy: PanBy; + setCenter: SetCenter; setPaneClickDistance: (distance: number) => void; }; diff --git a/packages/svelte/src/lib/components/NodeWrapper/NodeWrapper.svelte b/packages/svelte/src/lib/components/NodeWrapper/NodeWrapper.svelte index 28909dbc..12be831a 100644 --- a/packages/svelte/src/lib/components/NodeWrapper/NodeWrapper.svelte +++ b/packages/svelte/src/lib/components/NodeWrapper/NodeWrapper.svelte @@ -5,7 +5,8 @@ errorMessages, isInputDOMNode, nodeHasDimensions, - Position + Position, + getNodesInside } from '@xyflow/system'; import drag from '$lib/actions/drag'; @@ -197,6 +198,34 @@ store.moveSelectedNodes(arrowKeyDiffs[event.key], event.shiftKey ? 4 : 1); } } + + const onFocus = () => { + if ( + store.disableKeyboardA11y || + !store.autoPanOnNodeFocus || + !nodeRef?.matches(':focus-visible') + ) { + return; + } + + const { width, height, viewport } = store; + + const withinViewport = + getNodesInside( + new Map([[id, node]]), + { x: 0, y: 0, width, height }, + [viewport.x, viewport.y, viewport.zoom], + true + ).length > 0; + + if (!withinViewport) { + store.setCenter( + node.position.x + (node.measured.width ?? 0) / 2, + node.position.y + (node.measured.height ?? 0) / 2, + { zoom: viewport.zoom } + ); + } + }; {#if !hidden} @@ -252,6 +281,7 @@ ? (event) => onnodecontextmenu({ node: userNode, event }) : undefined} onkeydown={focusable ? onKeyDown : undefined} + onfocus={focusable ? onFocus : undefined} tabIndex={focusable ? 0 : undefined} role={node.ariaRole ?? (focusable ? 'group' : undefined)} aria-roledescription={node.ariaRoleDescription || 'node'} diff --git a/packages/svelte/src/lib/container/SvelteFlow/Wrapper.svelte b/packages/svelte/src/lib/container/SvelteFlow/Wrapper.svelte index 0f7e39f6..206fdc8c 100644 --- a/packages/svelte/src/lib/container/SvelteFlow/Wrapper.svelte +++ b/packages/svelte/src/lib/container/SvelteFlow/Wrapper.svelte @@ -84,6 +84,7 @@ elevateNodesOnSelect, elevateEdgesOnSelect, nodesDraggable, + autoPanOnNodeFocus, nodesConnectable, elementsSelectable, nodesFocusable, @@ -100,6 +101,16 @@ type OnlyDivAttributes = { [K in keyof T]: K extends keyof HTMLAttributes ? T[K] : never; }; + + // Undo scroll events, preventing viewport from shifting when nodes outside of it are focused + function wrapperOnScroll(e: UIEvent & { currentTarget: EventTarget & HTMLDivElement }) { + e.currentTarget.scrollTo({ top: 0, left: 0, behavior: 'auto' }); + + // Forward the event to any existing onscroll handler if needed + if (rest.onscroll) { + rest.onscroll(e); + } + }
} > {@render children?.()} diff --git a/packages/svelte/src/lib/container/SvelteFlow/types.ts b/packages/svelte/src/lib/container/SvelteFlow/types.ts index 38e07f4a..d575db1a 100644 --- a/packages/svelte/src/lib/container/SvelteFlow/types.ts +++ b/packages/svelte/src/lib/container/SvelteFlow/types.ts @@ -232,6 +232,11 @@ export type SvelteFlowProps< * @default true */ nodesDraggable?: boolean; + /** + * When `true`, the viewport will pan when a node is focused. + * @default true + */ + autoPanOnNodeFocus?: boolean; /** * Controls if all nodes should be connectable to each other * @default true diff --git a/packages/svelte/src/lib/hooks/useSvelteFlow.svelte.ts b/packages/svelte/src/lib/hooks/useSvelteFlow.svelte.ts index 82c66064..c4869c33 100644 --- a/packages/svelte/src/lib/hooks/useSvelteFlow.svelte.ts +++ b/packages/svelte/src/lib/hooks/useSvelteFlow.svelte.ts @@ -366,28 +366,8 @@ export function useSvelteFlow $state.snapshot(store.viewport), - setCenter: async (x, y, options) => { - const nextZoom = typeof options?.zoom !== 'undefined' ? options.zoom : store.maxZoom; - const currentPanZoom = store.panZoom; - - if (!currentPanZoom) { - return Promise.resolve(false); - } - - await currentPanZoom.setViewport( - { - x: store.width / 2 - x * nextZoom, - y: store.height / 2 - y * nextZoom, - zoom: nextZoom - }, - { duration: options?.duration, ease: options?.ease, interpolate: options?.interpolate } - ); - - return Promise.resolve(true); - }, - fitView: (options?: FitViewOptions) => { - return store.fitView(options); - }, + setCenter: async (x, y, options) => store.setCenter(x, y, options), + fitView: (options?: FitViewOptions) => store.fitView(options), fitBounds: async (bounds: Rect, options?: FitBoundsOptions) => { if (!store.panZoom) { return Promise.resolve(false); diff --git a/packages/svelte/src/lib/store/index.ts b/packages/svelte/src/lib/store/index.ts index 657fdce2..a1f220bb 100644 --- a/packages/svelte/src/lib/store/index.ts +++ b/packages/svelte/src/lib/store/index.ts @@ -14,7 +14,8 @@ import { type ConnectionState, updateAbsolutePositions, snapPosition, - calculateNodePosition + calculateNodePosition, + type SetCenterOptions } from '@xyflow/system'; import type { EdgeTypes, NodeTypes, Node, Edge, FitViewOptions } from '$lib/types'; @@ -126,6 +127,26 @@ export function createStore void; setPaneClickDistance: (distance: number) => void; fitView: (options?: FitViewOptions) => Promise; + setCenter: SetCenter; updateNodePositions: UpdateNodePositions; updateNodeInternals: (updates: Map) => void; unselectNodesAndEdges: (params?: { nodes?: NodeType[]; edges?: EdgeType[] }) => void; diff --git a/tooling/eslint-config/src/index.js b/tooling/eslint-config/src/index.js index 60f82b0e..0a80797e 100644 --- a/tooling/eslint-config/src/index.js +++ b/tooling/eslint-config/src/index.js @@ -36,7 +36,6 @@ module.exports = { projectService: true, }, rules: { - '@typescript-eslint/no-deprecated': 'error', '@typescript-eslint/no-unnecessary-type-assertion': 'error', }, },