Make the rich text editor toolbars a single tab stop with arrow-key navigation - #6108
Make the rich text editor toolbars a single tab stop with arrow-key navigation#6108rtibblesbot wants to merge 4 commits into
Conversation
953bca2 to
2871664
Compare
2871664 to
ac6bb0b
Compare
AlexVelezLl
left a comment
There was a problem hiding this comment.
Hey @rtibblesbot, on mobile, the mobile toolbar is not reachable via tab, could you fix it please, and make that the corresponding tab position on the RTE toolbar goes to the mobile toolbar instead?
|
@AlexVelezLl fixed in 1c54e96. The floating formatting bar rendered only while the editor content held focus ( On the tab position: the bar sits between the top bar and the content, which is where the RTE toolbar sits on desktop — Shift+Tab from the text now lands on it. One caveat worth your call: the bar is still only rendered once the content has been focused, so a first forward Tab through the editor passes from the top bar straight to the text. Rendering it earlier would make it visible before the keyboard opens; say the word if you want that instead. Class sweep: New specs cover the reachability fix (focus moves from the content into the bar and the bar survives), the single tab stop and arrow movement on both toolbars, and the unavailable format button keeping its place. @rtibblesbot's comments are generated by an LLM, and should be evaluated accordingly |
1c54e96 to
583f4d2
Compare
| const toolbarRef = ref(null); | ||
|
|
||
| useRovingTabIndex(toolbarRef); |
There was a problem hiding this comment.
Why do we need this here? it is inside the mobile toolbar, its not a different toolbar.
There was a problem hiding this comment.
MobileFormattingBar is a sibling of MobileTopBar, not a child — each is its own role="toolbar" element with its own aria-label, and the composable roves within one container element, so each needs its own call. Without it every button in the bar is a separate tab stop.
They are further apart now: the bar moved after EditorContentWrapper so it follows the content in tab order, matching where it sits on screen.
| render(MobileTopBar, { | ||
| provide: { editor: ref(makeEditorStub()) }, | ||
| router: new VueRouter(), | ||
| }); | ||
| // Every button, not only the `data-toolbar-item` ones: an unmarked control | ||
| // would be a second tab stop. | ||
| const controls = screen.getAllByRole('button'); | ||
|
|
||
| expect(controls.length).toBeGreaterThan(1); | ||
| expect(controls[0]).toHaveAttribute('aria-disabled', 'true'); | ||
| expect(controls[0]).toHaveAttribute('tabindex', '0'); | ||
| controls.slice(1).forEach(control => expect(control).toHaveAttribute('tabindex', '-1')); |
There was a problem hiding this comment.
Flagging that even if these buttons have a proper tabindex, if I press Tab or Shift + Tab when focusing the editor, the toolbar disappears.
Grabacion.de.pantalla.2026-08-31.a.la.s.4.29.46.p.m.mov
There was a problem hiding this comment.
Fixed in b3c5ba4.
Blurring the content scheduled a Vue re-render, and a browser flushes microtasks between blur and the focus landing on the next control — so the bar unmounted before Tab could reach it, and focus fell back into the top bar. Focus is now tracked across the whole .editor-container, cleared only by focusout, the one event that names where focus is going.
The bar also moved after EditorContentWrapper, so Tab from the content steps into it rather than out of the editor; Shift+Tab reaches the top bar.
I searched shared/views/TipTapEditor/ for other mounts driven by editor focus — this v-if was the only one. Three tests in MobileFormattingBar.spec.js cover it; two of them fail against the previous commit.
b8c1d9a to
643924f
Compare
Roving tabindex over a container's `[data-toolbar-item]` controls, per the WAI-ARIA APG toolbar pattern: one tab stop, Left/Right between controls, wrapping and reversed in RTL. Excludes controls KListWithOverflow has hidden via `visibility`, and ignores arrow keys raised inside an open menu. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
643924f to
78a204c
Compare
|
Rebased onto
One test-only change on top: @rtibblesbot's comments are generated by an LLM, and should be evaluated accordingly |
Mark every toolbar control `data-toolbar-item` and drive the toolbars with useRovingTabIndex, so Tab moves into the toolbar and then out. Unavailable ToolbarButtons carry `aria-disabled` instead of the native `disabled`, keeping them focusable and in the arrow-key order. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Give MobileFormattingBar and LinkBubbleMenu the same single tab stop as the other two role="toolbar" elements. The formatting bar rendered only while the editor content held focus, so blurring the content scheduled a re-render that unmounted the bar before the browser could land focus on it — Tab and Shift+Tab both made it vanish instead of stepping into it. Track focus across the whole editor container, which only `focusout` — the one event that names where focus is going — can clear. The bar sits below the content on screen, so it now follows it in the DOM too. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The bar unmounts as soon as the editor loses focus, which can happen inside the 150ms wait for the virtual keyboard. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
78a204c to
02fce8f
Compare
Summary
A
useRovingTabIndexcomposable keeps exactly one toolbar control attabindex="0"and moves focus with Left/Right, wrapping at both ends and reversed in RTL. Unavailable controls are markedaria-disabledrather thandisabled, so they keep their place in the arrow order. Wired into all fourrole="toolbar"elements in the editor:EditorToolbar,MobileTopBar,MobileFormattingBarandLinkBubbleMenu.References
Fixes #6104. Pattern: WAI-ARIA APG toolbar.
Reviewer guidance
MobileFormattingBaralso needed a mount fix: it rendered only while the editor content held focus, so Tab blurred the content and unmounted the bar before focus could land in it. It now renders while focus is anywhere in the editor container, and clears its scroll-into-view timeout on unmount.AI usage
Used Claude Code to write the composable and its tests test-first against a pre-agreed plan, and to drive the toolbar in a browser for the capture above. Verified with the full Jest suite, pre-commit, an axe-core audit, and keyboard QA in Chromium.
@rtibblesbot's comments are generated by an LLM, and should be evaluated accordingly
How was this generated?
🟡 Waiting for feedback
Last updated: 2026-08-31 22:45 UTC