SIGN IN SIGN UP

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