Make the rich text editor toolbars a single tab stop with arrow-key navigation - #6108
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>
78a204c to
02fce8f
Compare
| <div | ||
| v-if="isExpanded" | ||
| id="formatting-tools" | ||
| class="scrollable-tools" | ||
| :aria-label="textFormattingToolbar$()" | ||
| @touchstart="event => event.stopPropagation()" | ||
| @touchend="event => event.stopPropagation()" |
There was a problem hiding this comment.
This div seems to be getting an unnecessary tab stop. It seems it's because it's a scrollable div, and Chrome does that. Could you please add a tabindex="-1" here, since it's not necessary and we already have focusable inputs within it; they're just dynamically set.
There was a problem hiding this comment.
Added tabindex="-1" with a comment on why.
Swept the branch for other scroll containers holding roving items: grep -rn overflow over TipTapEditor/components/ — .scrollable-tools is the only one. (FormulasMenu also scrolls but is not a roving toolbar.)
| > | ||
| <button | ||
| class="toggle-btn" | ||
| data-toolbar-item |
There was a problem hiding this comment.
Added :focus-visible for .toggle-btn and .format-btn, matching the background: #e6e6e6; outline: 2px solid #0097f2 used elsewhere in the editor. Dropped the shared border-radius: 4px since both already set their own.
Audited every [data-toolbar-item] on the branch against the :focus-visible rules in each file — 10 classes, these two were the only ones missing a rule: .more-button, .link-url, .bubble-menu-button, .toolbar-btn, .format-dropdown, .paste-dropdown-btn and .insert-button were already covered.
There was a problem hiding this comment.
This dropdown menu here does not have a proper accessibility implementation. Could you please use KDropdownMenu here instead?
There was a problem hiding this comment.
Replaced it with KDropdownMenu nested in the trigger button, the same shape EditorToolbar's .more-button already uses — KDS takes aria-haspopup/aria-expanded on the trigger, menu semantics, arrow-key navigation and focus return, so the hand-rolled outside-click listener, dropdown markup/styles and the now-unused insertContentOption string are gone. Modal anchoring passes null (centred), since the menu item is gone by the time the handler runs.
Added a MobileTopBar test that opens the menu with Enter and asserts focus lands inside it.
Swept the branch for other hand-rolled menus: grep -rn 'role="menu"' over TipTapEditor/ — FormatDropdown and PasteDropdown also roll their own, but both are outside this PR's diff and unchanged by it, so I left them. Happy to file a follow-up if you want them converted.
4cda7be to
3b0aa34
Compare
There was a problem hiding this comment.
Fixed in d72a4fb. ClickableRegion's .content-wrapper carried z-index: 1, which opens a stacking context: the editor's position: fixed children are trapped inside that answer option, and the next option — same z-index, later in the DOM — paints over them. Dropping the z-index keeps the wrapper above .overlay-button (both positioned, wrapper is later in the DOM) and lets the bar's own z-index: 100 count again.
Class check: grepped every z-index under QTIEditor/ and in the three legacy editors that embed the RTE (AnswersEditor, HintsEditor, AssessmentItemEditor). One declaration matched, the one changed. It was also trapping the link, image and math popovers (z-index: 2/10 in TipTapEditor.vue), and it covers all three interaction editors that use ClickableRegion, not just choice.
No test: jsdom has no layout or paint order. Verified by reproducing the nesting in a standalone page in headless Chromium — the later option's radio and text sit over the bar before the change and under it after.
| <KDropdownMenu | ||
| :options="insertOptions" | ||
| @select="onInsertSelect" | ||
| > | ||
| <img | ||
| :src="tool.icon" | ||
| alt="" | ||
| class="dropdown-icon" | ||
| aria-hidden="true" | ||
| > | ||
| <span class="dropdown-title">{{ tool.title }}</span> | ||
| </button> | ||
| </div> | ||
| <template #option="{ option }"> | ||
| <div class="insert-option"> | ||
| <img | ||
| :src="option.icon" | ||
| alt="" | ||
| class="dropdown-icon" | ||
| aria-hidden="true" | ||
| > | ||
| <span class="dropdown-title">{{ option.label }}</span> | ||
| </div> | ||
| </template> | ||
| </KDropdownMenu> |
There was a problem hiding this comment.
Fixed in 3369b91. .insert-option now matches EditorToolbar's .overflow-item — gap: 12px, padding: 8px 12px, font-size: 1.2rem, line-height: 140% — and the icon matches .dropdown-item-icon with flex-shrink: 0 and a 20px box.
Class check: three #option slots in the frontend. EditorToolbar is the KListWithOverflow menu being matched; StudioNavigation's is a nav menu with its own sizing, so this was the only divergent one.
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. Its toggle and format-size buttons had no focus-visible outline; nothing had ever focused them. 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>
The hand-rolled menu had no menu semantics, no keyboard navigation and no focus return. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The z-index opened a stacking context, so the mobile formatting bar and the link, image and math popovers were painted over by the next answer option. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
3b0aa34 to
ae77d4a
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
+button opens the insert menu with Enter, arrows through its items and returns focus to+on close.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, clears its scroll-into-view timeout on unmount, gives its toggle and format-size buttons a focus-visible outline, and marks its scroll containertabindex="-1"so Chrome stops giving that container its own tab stop.The mobile top bar's hand-rolled insert dropdown is now a
KDropdownMenu: the old one had no menu semantics, keyboard navigation or focus return.ClickableRegionlost thez-indexon its content wrapper: the stacking context it opened painted the formatting bar and the link, image and math popovers behind the next answer option.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-09-01 13:15 UTC