From 6693c7300e41609cb50c693af46c3ca6891aa244 Mon Sep 17 00:00:00 2001 From: Abbey Yacoe Date: Thu, 5 Jun 2025 12:22:06 +0200 Subject: [PATCH] feat: create enablePanOnFocus prop, update onFocus func --- examples/react/src/examples/A11y/index.tsx | 46 +++++++++++------ .../src/routes/examples/a11y/+page.svelte | 39 +++++++++++---- .../src/components/NodeWrapper/index.tsx | 20 ++++---- .../src/components/StoreUpdater/index.tsx | 1 + .../src/container/NodeRenderer/index.tsx | 11 ++-- .../react/src/container/ReactFlow/index.tsx | 2 + packages/react/src/store/initialState.ts | 1 + packages/react/src/types/component-props.ts | 5 ++ packages/react/src/types/nodes.ts | 1 + packages/react/src/types/store.ts | 1 + .../components/NodeWrapper/NodeWrapper.svelte | 50 ++++++++++++------- .../lib/container/SvelteFlow/Wrapper.svelte | 1 + .../src/lib/container/SvelteFlow/types.ts | 5 ++ .../src/lib/store/initial-store.svelte.ts | 1 + 14 files changed, 125 insertions(+), 59 deletions(-) diff --git a/examples/react/src/examples/A11y/index.tsx b/examples/react/src/examples/A11y/index.tsx index a4d0f2ad..daf3ce47 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, @@ -10,13 +10,9 @@ import { 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', @@ -41,9 +37,16 @@ const initialNodes: Node[] = [ id: '4', data: { label: 'Node 4' }, position: { x: 300, y: 100 }, - className: 'light', - ariaRoleDescription: 'custom node role', - ariaRole: 'button', + }, + { + id: '5', + data: { label: 'Node 5' }, + position: { x: 400, y: 200 }, + }, + { + id: '6', + data: { label: 'Node 6' }, + position: { x: -1000, y: 200 }, }, ]; @@ -51,6 +54,8 @@ 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 = { @@ -68,19 +73,14 @@ const ariaLabelConfig: Partial = { }; const A11y = () => { + const [isFocusPannable, setEnablePanOnFocus] = 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..962cd726 100644 --- a/examples/svelte/src/routes/examples/a11y/+page.svelte +++ b/examples/svelte/src/routes/examples/a11y/+page.svelte @@ -1,5 +1,5 @@ - - + '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', @@ -37,9 +33,30 @@ // '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 49b74174..f3542b22 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'; @@ -29,6 +30,7 @@ export function NodeWrapper({ onContextMenu, onDoubleClick, nodesDraggable, + enablePanOnFocus, elementsSelectable, nodesConnectable, nodesFocusable, @@ -163,28 +165,26 @@ export function NodeWrapper({ }; const onFocus = () => { - if (disableKeyboardA11y) { + if (disableKeyboardA11y || !enablePanOnFocus) { return; } - // Return early if focus is not from keyboard navigation (i.e., was clicked) if (!nodeRef.current?.matches(':focus-visible')) { return; } - const { x, y, zoom } = getViewport(); - const isNodeVisible = - node.position.x >= x && - node.position.x <= x + window.innerWidth && - node.position.y >= y && - node.position.y <= y + window.innerHeight; + const { transform, width, height } = store.getState(); + const visibleNodes = getNodesInside(new Map([[id, node]]), { x: 0, y: 0, width, height }, transform, true); + + const isNodeVisible = visibleNodes.length > 0; if (!isNodeVisible) { + const zoomLevel = transform[2]; fitView({ nodes: [{ id }], duration: 100, - minZoom: zoom, - maxZoom: zoom, + minZoom: zoomLevel, + maxZoom: zoomLevel, }); } }; diff --git a/packages/react/src/components/StoreUpdater/index.tsx b/packages/react/src/components/StoreUpdater/index.tsx index 95b713af..ba0d66bf 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', + 'enablePanOnFocus', 'nodesConnectable', 'nodesFocusable', 'edgesFocusable', diff --git a/packages/react/src/container/NodeRenderer/index.tsx b/packages/react/src/container/NodeRenderer/index.tsx index 02126d73..093e3f95 100644 --- a/packages/react/src/container/NodeRenderer/index.tsx +++ b/packages/react/src/container/NodeRenderer/index.tsx @@ -29,6 +29,7 @@ export type NodeRendererProps = Pick< const selector = (s: ReactFlowState) => ({ nodesDraggable: s.nodesDraggable, + enablePanOnFocus: s.enablePanOnFocus, nodesConnectable: s.nodesConnectable, nodesFocusable: s.nodesFocusable, elementsSelectable: s.elementsSelectable, @@ -36,7 +37,10 @@ const selector = (s: ReactFlowState) => ({ }); function NodeRendererComponent(props: NodeRendererProps) { - const { nodesDraggable, nodesConnectable, nodesFocusable, elementsSelectable, onError } = useStore(selector, shallow); + const { nodesDraggable, nodesConnectable, nodesFocusable, elementsSelectable, enablePanOnFocus, onError } = useStore( + selector, + shallow + ); const nodeIds = useVisibleNodeIds(props.onlyRenderVisibleElements); const resizeObserver = useResizeObserver(); @@ -48,13 +52,13 @@ function NodeRendererComponent(props: NodeRendererProps(props: NodeRendererProps( onlyRenderVisibleElements = false, selectNodesOnDrag, nodesDraggable, + enablePanOnFocus, nodesConnectable, nodesFocusable, nodeOrigin = defaultNodeOrigin, @@ -261,6 +262,7 @@ function ReactFlow( onClickConnectStart={onClickConnectStart} onClickConnectEnd={onClickConnectEnd} nodesDraggable={nodesDraggable} + enablePanOnFocus={enablePanOnFocus} nodesConnectable={nodesConnectable} nodesFocusable={nodesFocusable} edgesFocusable={edgesFocusable} diff --git a/packages/react/src/store/initialState.ts b/packages/react/src/store/initialState.ts index 15bfc1d8..77d10769 100644 --- a/packages/react/src/store/initialState.ts +++ b/packages/react/src/store/initialState.ts @@ -112,6 +112,7 @@ const getInitialState = ({ snapToGrid: false, nodesDraggable: true, + enablePanOnFocus: false, nodesConnectable: true, nodesFocusable: true, edgesFocusable: true, diff --git a/packages/react/src/types/component-props.ts b/packages/react/src/types/component-props.ts index d088c26b..f09995bf 100644 --- a/packages/react/src/types/component-props.ts +++ b/packages/react/src/types/component-props.ts @@ -383,6 +383,11 @@ export interface ReactFlowProps = { nodesConnectable: boolean; elementsSelectable: boolean; nodesDraggable: boolean; + enablePanOnFocus: boolean; nodesFocusable: boolean; onClick?: NodeMouseHandler; onDoubleClick?: NodeMouseHandler; diff --git a/packages/react/src/types/store.ts b/packages/react/src/types/store.ts index ba909869..25d8f42e 100644 --- a/packages/react/src/types/store.ts +++ b/packages/react/src/types/store.ts @@ -88,6 +88,7 @@ export type ReactFlowStore { + console.log('before', store.enablePanOnFocus); + if (store.disableKeyboardA11y || !store.enablePanOnFocus) { + console.log("should return early", store.enablePanOnFocus); return; } - - const zoom = store.panZoom?.getViewport().zoom ?? 1; - - // Get node dimensions - const nodeWidth = nodeRef?.offsetWidth || 0; - const nodeHeight = nodeRef?.offsetHeight || 0; - - store.panZoom?.setViewport( - { - x: -(positionX + nodeWidth / 2) * zoom + window.innerWidth / 2, - y: -(positionY + nodeHeight / 2) * zoom + window.innerHeight / 2, - zoom: zoom, - }, - { duration: 100 } - ); - } + + if (!nodeRef?.matches(':focus-visible')) { + return; + } + const width = store.width; + const height = store.height; + const viewport: [number, number, number] = [store.viewport.x, store.viewport.y, store.viewport.zoom]; + const zoom = store.viewport.zoom; + + const visibleNodes = getNodesInside(new Map([[id, node]]), { x: 0, y: 0, width, height }, viewport, true); + + const isNodeVisible = visibleNodes.length > 0; + + if (!isNodeVisible) { + console.log('About to call fitView - this should NOT happen when enablePanOnFocus is false'); + + console.log('after', store.enablePanOnFocus); + store.fitView({ + nodes: [{ id }], + duration: 100, + minZoom: zoom, + maxZoom: zoom, + }); + } + }; {#if !hidden} diff --git a/packages/svelte/src/lib/container/SvelteFlow/Wrapper.svelte b/packages/svelte/src/lib/container/SvelteFlow/Wrapper.svelte index 0f7e39f6..80b6e0dd 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, + enablePanOnFocus, nodesConnectable, elementsSelectable, nodesFocusable, diff --git a/packages/svelte/src/lib/container/SvelteFlow/types.ts b/packages/svelte/src/lib/container/SvelteFlow/types.ts index 38e07f4a..0d67197c 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 false + */ + enablePanOnFocus?: boolean; /** * Controls if all nodes should be connectable to each other * @default true diff --git a/packages/svelte/src/lib/store/initial-store.svelte.ts b/packages/svelte/src/lib/store/initial-store.svelte.ts index 7971f320..bedb2e2d 100644 --- a/packages/svelte/src/lib/store/initial-store.svelte.ts +++ b/packages/svelte/src/lib/store/initial-store.svelte.ts @@ -247,6 +247,7 @@ export function getInitialStore