fix(a11y): let keyboard users move nodes, create connections, and hear the canvas respond (#14673)
* fix(a11y): make the flow canvas keyboard-operable — move nodes, create connections, hear what happened
The canvas was mouse-only for node movement: disableKeyboardA11y={true}
suppressed ReactFlow's arrow-key movement, keyboard selection, and — less
obviously — its aria-live region, so the canvas shipped with no announcements
at all. Flipping the flag alone was not enough:
- Keyboard moves bypassed persistence and undo. Both hung off the pointer-drag
handlers (onNodeDragStart -> takeSnapshot, onNodeDragStop -> autoSaveFlow),
so an arrow-key move was lost on reload and invisible to Cmd+Z. A new
wrapper around onNodesChange snapshots before the first arrow press of a
burst and autosaves on a trailing debounce; pointer-drag settle changes are
excluded via a ref that mirrors the drag lifecycle.
- Focusable nodes and edges exposed role="group", which fails IBM
element_tabbable_role_valid. Every widget role with presentational children
(button, option) just trades that for aria_descendant_valid, because nodes
contain interactive handles and fields. Nodes get role="application" — the
ARIA role for a composite canvas widget with its own keyboard model — and
edges (no interactive children) get role="button". The canvas now scans
0 IBM violations.
Keyboard connection creation (Enter on a source handle, Enter on a compatible
target) already worked via the handles' Enter/Space support; it is now pinned
by a regression test, along with arrow movement + announcement + undo +
persistence, and a guard test that arrow keys inside an open menu (toolbar
menu, inline field dropdowns) never move the node underneath — the first test
to exercise stopCanvasKeyPropagation against real node movement.
Canvas rendering is pixel-identical: 0 of 988,320 pixels differ between base
and this change on the same flow.
* test: catch the multi-line getByRole("group") node lookups the first sweep missed
The first pass only matched single-line patterns; six files (including the
text-input-output-flow helper that freeze.spec / freeze-path.spec build on)
query nodes with the name on its own line. Same change as before: nodes are
role="application" now, edges role="button".
Verified the six previously-red CI specs are selector-clean; the remaining
local failures on extended/@components specs reproduce identically on clean
release-1.12.0 (loopback/MCP environment, not this change).
* [autofix.ci] apply automated fixes
* fix(a11y): address review — selection-drag double undo, translated SR strings, real unmount flush
Three findings from review, all confirmed:
1. A selection-rectangle drag was misread as a keyboard move. The rect drag
fires onSelectionDrag*, not the node drag handlers, so the pointer-drag ref
stayed false and the keyboard wrapper stacked a second snapshot on the one
onSelectionDragStart already takes — one drag, two undos, plus a redundant
autosave. The ref now mirrors the selection-drag lifecycle too (a stop
handler was never wired), and belt-and-braces: any change carrying
dragging: true is skipped outright — ReactFlow marks every mid-drag change
that way and never marks keyboard moves.
2. Enabling ReactFlow keyboard a11y shipped its hard-coded English
screen-reader strings (node/edge aria-describedby instructions and the
'Moved selected node…' live message) into a translated product.
ariaLabelConfig is now built from t(...) with the direction word translated
separately; 7 new keys in all seven locales. The en strings match the
upstream text, so existing assertions hold.
3. The unmount 'flush' did not flush: it scheduled the debounced autosave,
which fires after navigation when the store can already point at a
different flow — the rescued move was silently dropped. The wrapper now
takes a flush variant for the unmount path that invokes the autosave's
synchronous flush() while the store still holds this flow.
Unit suite grown to 7: a selection-drag case that drives real dragging: true
payloads (the shape the old suite could not catch), and the unmount case now
asserts the flush variant fires instead of the plain persist.
---------
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> V
Viktor Avelino committed
a5ab0abd4f69c226bda94130d811b87859fc7a76
Parent: c9bcd8f
Committed by GitHub <noreply@github.com>
on 8/20/2026, 3:53:43 PM