Skip to content

feat: add tile-level focus trap and keyboard navigation for toolbars [CLUE-391]#2783

Open
dougmartin wants to merge 9 commits intomasterfrom
CLUE-391-tab-order-in-tiles-toolbars
Open

feat: add tile-level focus trap and keyboard navigation for toolbars [CLUE-391]#2783
dougmartin wants to merge 9 commits intomasterfrom
CLUE-391-tab-order-in-tiles-toolbars

Conversation

@dougmartin
Copy link
Member

Implement keyboard accessibility for tile toolbars and focus trap cycling so keyboard-only and screen reader users can navigate into tiles, operate toolbar buttons, and move between tiles without a mouse.

  • Add focus trap in tile-component.tsx: Tab/Shift+Tab cycles through title, toolbar (single stop), and content; Escape exits to tile container; ArrowUp exits from non-editable elements; Enter enters trap
  • Add roving tabindex to tile toolbar (Left/Right arrow keys navigate buttons as a flat sequence regardless of visual row layout)
  • Add ARIA attributes: role="toolbar" on toolbar, aria-label on toolbar and buttons, aria-pressed on toggle buttons, role="group" and aria-label on tile containers
  • Switch disabled toolbar buttons from HTML disabled to aria-disabled so they remain focusable; announce "Select something to enable this action" on activation
  • Add ITileApi.getFocusableElements() for tile-type-agnostic focus navigation; implement for text tiles (Slate editor + title)
  • Add RegisterToolbarContext for cross-FloatingPortal tile↔toolbar communication without document.querySelector
  • Add inter-tile navigation: Escape→Tab moves to sibling tiles with one-shot pass-through (escapedFocusTrap + justArrivedViaNav flags prevent infinite navigation loops)
  • Add screen reader announcements via aria-live region on trap enter/exit, including the toolbar Escape path (FloatingPortal is outside tile DOM so needs its own announcement)
  • Use native capture-phase DOM listeners for Tab/Shift+Tab (React 17 delegated events fire too late to preventDefault)
  • Fix useRovingTabindex stale state: split useLayoutEffect so tabindex attributes update on every render for dynamic button changes
  • Focus trap entry and cycling verify focus actually moved via focusFirstAvailable(), skipping non-focusable elements (e.g., plain div title) to prevent dead-end traps
  • Add 64 new unit tests across tile-component, tile-toolbar, and use-roving-tabindex

…LUE-391]

Implement keyboard accessibility for tile toolbars and focus trap cycling
so keyboard-only and screen reader users can navigate into tiles, operate
toolbar buttons, and move between tiles without a mouse.

- Add focus trap in tile-component.tsx: Tab/Shift+Tab cycles through
  title, toolbar (single stop), and content; Escape exits to tile
  container; ArrowUp exits from non-editable elements; Enter enters trap
- Add roving tabindex to tile toolbar (Left/Right arrow keys navigate
  buttons as a flat sequence regardless of visual row layout)
- Add ARIA attributes: role="toolbar" on toolbar, aria-label on toolbar
  and buttons, aria-pressed on toggle buttons, role="group" and
  aria-label on tile containers
- Switch disabled toolbar buttons from HTML disabled to aria-disabled so
  they remain focusable; announce "Select something to enable this
  action" on activation
- Add ITileApi.getFocusableElements() for tile-type-agnostic focus
  navigation; implement for text tiles (Slate editor + title)
- Add RegisterToolbarContext for cross-FloatingPortal tile↔toolbar
  communication without document.querySelector
- Add inter-tile navigation: Escape→Tab moves to sibling tiles with
  one-shot pass-through (escapedFocusTrap + justArrivedViaNav flags
  prevent infinite navigation loops)
- Add screen reader announcements via aria-live region on trap
  enter/exit, including the toolbar Escape path (FloatingPortal is
  outside tile DOM so needs its own announcement)
- Use native capture-phase DOM listeners for Tab/Shift+Tab (React 17
  delegated events fire too late to preventDefault)
- Fix useRovingTabindex stale state: split useLayoutEffect so tabindex
  attributes update on every render for dynamic button changes
- Focus trap entry and cycling verify focus actually moved via
  focusFirstAvailable(), skipping non-focusable elements (e.g., plain
  div title) to prevent dead-end traps
- Add 64 new unit tests across tile-component, tile-toolbar, and
  use-roving-tabindex
@codecov
Copy link

codecov bot commented Mar 1, 2026

Codecov Report

❌ Patch coverage is 93.58491% with 17 lines in your changes missing coverage. Please review.
✅ Project coverage is 83.14%. Comparing base (886bf62) to head (5558839).
⚠️ Report is 12 commits behind head on master.

Files with missing lines Patch % Lines
src/components/tiles/text/text-tile.tsx 0.00% 10 Missing and 2 partials ⚠️
src/components/tiles/tile-component.tsx 98.20% 3 Missing ⚠️
src/components/toolbar/tile-toolbar.tsx 96.92% 2 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##           master    #2783      +/-   ##
==========================================
- Coverage   85.46%   83.14%   -2.32%     
==========================================
  Files         847      847              
  Lines       46358    46613     +255     
  Branches    12048    12151     +103     
==========================================
- Hits        39618    38756     -862     
- Misses       6311     7355    +1044     
- Partials      429      502      +73     
Flag Coverage Δ
cypress ?
cypress-regression 71.01% <32.41%> (+1.60%) ⬆️
cypress-smoke 43.25% <26.48%> (-0.12%) ⬇️
jest 50.81% <93.56%> (+0.31%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@cypress
Copy link

cypress bot commented Mar 1, 2026

collaborative-learning    Run #17975

Run Properties:  status check passed Passed #17975  •  git commit 5558839f90: fix: Tab focuses tile without selecting; Enter required to enter focus trap
Project collaborative-learning
Branch Review CLUE-391-tab-order-in-tiles-toolbars
Run status status check passed Passed #17975
Run duration 03m 17s
Commit git commit 5558839f90: fix: Tab focuses tile without selecting; Enter required to enter focus trap
Committer Doug Martin
View all properties for this run ↗︎

Test results
Tests that failed  Failures 0
Tests that were flaky  Flaky 0
Tests that did not run due to a developer annotating a test with .skip  Pending 0
Tests that did not run due to a failure in a mocha hook  Skipped 0
Tests that passed  Passing 4
View all changes introduced in this branch ↗︎

Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds tile-level keyboard accessibility by introducing a focus trap that cycles through a tile’s title/toolbar/content, plus roving tabindex keyboard navigation for tile toolbar buttons (including ARIA improvements and SR announcements). This builds on the existing tile + FloatingPortal toolbar architecture by adding explicit cross-portal coordination for focus management.

Changes:

  • Implement tile focus-trap entry/exit/cycling (Tab/Shift+Tab, Enter to enter, Escape/ArrowUp to exit) and inter-tile navigation behavior.
  • Add roving tabindex support + native capture-phase Tab/Escape handling for FloatingPortal toolbars, plus ARIA attributes for toolbars/buttons/tiles.
  • Extend tile API with getFocusableElements() and implement it for text tiles; add substantial unit test coverage for focus behavior.

Reviewed changes

Copilot reviewed 10 out of 10 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
src/hooks/use-roving-tabindex.ts Updates roving tabindex behavior to keep tabindex attributes in sync with dynamic button DOM changes.
src/hooks/use-roving-tabindex.test.tsx Adds tests for dynamic add/remove button scenarios and roving target clamping.
src/components/toolbar/tile-toolbar.tsx Adds roving tabindex wiring, capture-phase Tab/Escape handling, toolbar ARIA, and registers toolbar element back to tiles.
src/components/toolbar/tile-toolbar.test.tsx Adds ARIA and keyboard navigation tests for toolbars (including Tab/Escape/ArrowUp routing).
src/components/toolbar/tile-toolbar-button.tsx Switches to aria-disabled, adds ARIA labeling/pressed state, and announces activation of disabled actions.
src/components/tiles/tile-component.tsx Implements tile-level focus trap + SR announcements, plus inter-tile navigation and toolbar registration plumbing.
src/components/tiles/tile-component.test.tsx Adds extensive focus-trap/inter-tile navigation/live-region tests via a dedicated test tile type.
src/components/tiles/tile-api.tsx Introduces ITileApi.getFocusableElements() and RegisterToolbarContext for focus trap integration.
src/components/tiles/text/text-tile.tsx Implements getFocusableElements() for text tiles (Slate editor + title).
specs/CLUE-391-tab-order-in-tiles-toolbars-text-tiles.md Adds/records the detailed accessibility spec and decisions for the new behavior.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Toolbar buttons using aria-disabled remain clickable, so without an explicit type="button" they default to type="submit" inside forms, which could cause unintended form submission.
When tabbing from the workspace toolbar into a text tile, the Slate
editor received DOM focus but silently ignored keyboard input because
ReactEditor.focus() does not create a selection if the editor has never
been focused before.

Changes:
- Add tabIndex={0} to tile containers so they are reachable via Tab
- Add handleFocus handler to auto-enter the focus trap on Tab arrival
- Add focusContent callback to ITileFocusableElements for custom editors
- Implement focusContent in text-tile using ReactEditor.focus() with
  fallback selection initialization at end of document
- Update tile-toolbar Tab handler to use focusContent when cycling
- Update CLUE-391 spec with new architecture notes
… trap auto-entry

handleFocus was auto-entering the focus trap on every .focus() call to
the tile container, including programmatic focus from ArrowUp exit and
inter-tile navigation. Added relatedTarget check so auto-entry only
triggers when focus arrives from outside the tile (e.g., Tab from the
workspace toolbar). Updated tests to reflect that pass-through
navigation now auto-enters the destination tile's focus trap.
…destination tile

The justArrivedViaNav/tile-navigation-focus mechanism caused a confusing
two-hop skip: Escape+Tab from tile 1 would land on tile 2 without
trapping, then Tab again would skip to tile 3 where focus finally
trapped. This was inconsistent with the first tile's behavior where Tab
immediately entered the focus trap.

Removed the entire pass-through mechanism (justArrivedViaNav field,
tile-navigation-focus event, propagateNavMode parameter) so that every
tile now auto-enters its focus trap when Tab'd into via handleFocus.
This ensures consistent behavior: Tab always traps, Escape+Tab always
navigates to the next tile and traps there.
Copy link
Contributor

@emcelroy emcelroy left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looking good 👍 I left some comments about things you may want to clean up or tweak before merging.

- Use generic wording in spec (tile's editable content, not text-specific)
- Change aria-live from "assertive" to "polite" for non-urgent status messages
- Remove unused titleElement from text-tile getFocusableElements
- Improve contentElement typing to avoid unnecessary cast
- Add comment explaining why DOM-based tile navigation is used
- Remove empty afterEach in tile-toolbar test
…nges

Update Cypress test assertions to check aria-disabled instead of HTML
disabled attribute, since toolbar buttons now use aria-disabled to remain
keyboard-focusable. Add tileHandlesOwnSelection guard in handleFocus to
prevent placeholder tiles from being auto-selected on focus. Add
stopPropagation to editable-axis-label Escape handler to prevent
tile-component from stealing focus and triggering an unwanted blur-save.
…s trap

Change keyboard navigation so Tab shows a focus ring without selecting or
entering tiles. Enter explicitly selects and enters the focus trap, Escape
always deselects. This removes the escapedFocusTrap flag entirely — tile
selection state is the sole routing signal for Tab behavior.

Key changes:
- handleFocus no longer selects or enters focus trap, only announces
- handleTabKeyDown routes on selection: selected → enter trap, else → sibling
- Enter selects unconditionally then enters focus trap best-effort
- Escape deselects from anywhere inside tile or on selected container
- navigateToSiblingTile no longer selects destination tile
- handleToolbarEscape deselects tile on toolbar Escape
- :focus-visible ring hidden when tile is selected (.selected overrides)
- aria-disabled styling added to toolbar buttons
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants