From 0fdc2d0c441adbf5ca171cdf23447d3902ace0dc Mon Sep 17 00:00:00 2001 From: rtibblesbot Date: Wed, 26 Aug 2026 12:47:50 -0700 Subject: [PATCH 1/3] feat: add useRovingTabIndex composable 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) --- .../composables/useRovingTabIndex.js | 91 ++++++++++++++ .../__tests__/useRovingTabIndex.spec.js | 111 ++++++++++++++++++ 2 files changed, 202 insertions(+) create mode 100644 contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/composables/useRovingTabIndex.js create mode 100644 contentcuration/contentcuration/frontend/shared/views/TipTapEditor/__tests__/useRovingTabIndex.spec.js diff --git a/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/composables/useRovingTabIndex.js b/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/composables/useRovingTabIndex.js new file mode 100644 index 0000000000..851632130c --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/composables/useRovingTabIndex.js @@ -0,0 +1,91 @@ +import { onMounted, onUnmounted } from 'vue'; + +const TOOLBAR_ITEM_SELECTOR = '[data-toolbar-item]'; + +/** + * Roving tabindex over the `[data-toolbar-item]` controls inside `containerRef`, + * per the WAI-ARIA APG toolbar pattern. + * + * Controls must not bind `tabindex` themselves — a re-render would strip the + * toolbar's only tab stop. + * + * @param {import('vue').Ref} containerRef - the `role="toolbar"` element. + */ +export function useRovingTabIndex(containerRef) { + // Vue clears template refs before `onUnmounted`, so hold the element itself + // for the lifetime of the listeners. + let container = null; + let activeItem = null; + let observer = null; + + // KListWithOverflow leaves overflowed controls in the DOM and hides them by + // setting `visibility` on their wrapper, so only the computed value shows it. + const getItems = () => + Array.from(container.querySelectorAll(TOOLBAR_ITEM_SELECTOR)).filter( + item => window.getComputedStyle(item).visibility !== 'hidden', + ); + + const syncTabIndexes = () => { + const items = getItems(); + if (!items.includes(activeItem)) { + activeItem = items[0] || null; + } + items.forEach(item => item.setAttribute('tabindex', item === activeItem ? '0' : '-1')); + }; + + // Page direction comes from ``, rendered server-side by `base.html`. + const isRtl = () => document.documentElement.dir === 'rtl'; + + const handleKeydown = event => { + const step = { ArrowRight: 1, ArrowLeft: -1 }[event.key]; + if (!step) { + return; + } + // Open menus own their arrow keys; a control must not become navigable + // just because a menu was nested inside it. + if (event.target.closest('[role="menu"]')) { + return; + } + const items = getItems(); + const index = items.indexOf(event.target.closest(TOOLBAR_ITEM_SELECTOR)); + if (index === -1) { + return; + } + event.preventDefault(); + const offset = isRtl() ? -step : step; + activeItem = items[(index + offset + items.length) % items.length]; + syncTabIndexes(); + activeItem.focus(); + }; + + // Tabbing back into the toolbar must return to the control that last had focus. + const handleFocusin = event => { + const item = event.target.closest(TOOLBAR_ITEM_SELECTOR); + if (item && getItems().includes(item)) { + activeItem = item; + syncTabIndexes(); + } + }; + + onMounted(() => { + container = containerRef.value; + container.addEventListener('keydown', handleKeydown); + container.addEventListener('focusin', handleFocusin); + observer = new MutationObserver(syncTabIndexes); + // Filtering to `style` — the attribute that hides overflowed controls — also + // keeps our own `tabindex` writes from re-triggering this. + observer.observe(container, { + childList: true, + subtree: true, + attributes: true, + attributeFilter: ['style'], + }); + syncTabIndexes(); + }); + + onUnmounted(() => { + container.removeEventListener('keydown', handleKeydown); + container.removeEventListener('focusin', handleFocusin); + observer.disconnect(); + }); +} diff --git a/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/__tests__/useRovingTabIndex.spec.js b/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/__tests__/useRovingTabIndex.spec.js new file mode 100644 index 0000000000..b7a092cb63 --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/__tests__/useRovingTabIndex.spec.js @@ -0,0 +1,111 @@ +import { render, screen, fireEvent, waitFor } from '@testing-library/vue'; +import { ref } from 'vue'; +import VueRouter from 'vue-router'; +import { useRovingTabIndex } from '../TipTapEditor/composables/useRovingTabIndex'; + +// The menu is nested inside `three` rather than being a sibling: nesting is the +// only arrangement that reaches the `[role="menu"]` guard, since a sibling menu +// is already excluded by the item lookup. +const Harness = { + template: ` +
+ + + + + +
+ `, + setup() { + const toolbar = ref(null); + const extra = ref(false); + useRovingTabIndex(toolbar); + return { toolbar, extra }; + }, +}; + +// The single tab stop and plain arrow movement are covered against the real +// toolbars in EditorToolbar.spec.js; this file covers what those cannot reach. +describe('useRovingTabIndex', () => { + let one, two, three, unmount; + + beforeEach(() => { + ({ unmount } = render(Harness, { router: new VueRouter() })); + one = screen.getByTestId('one'); + two = screen.getByTestId('two'); + three = screen.getByTestId('three'); + }); + + afterEach(() => { + document.documentElement.removeAttribute('dir'); + }); + + it('wraps to the first item on ArrowRight from the last item', async () => { + await fireEvent.keyDown(three, { key: 'ArrowRight' }); + + expect(one).toHaveFocus(); + }); + + it('skips controls KListWithOverflow has hidden', async () => { + two.style.visibility = 'hidden'; + + await fireEvent.keyDown(one, { key: 'ArrowRight' }); + + expect(three).toHaveFocus(); + }); + + it('reverses the arrow directions in RTL', async () => { + document.documentElement.dir = 'rtl'; + + await fireEvent.keyDown(two, { key: 'ArrowRight' }); + expect(one).toHaveFocus(); + + await fireEvent.keyDown(two, { key: 'ArrowLeft' }); + expect(three).toHaveFocus(); + }); + + it('moves the tab stop to whichever item receives focus', async () => { + await fireEvent.focusIn(three); + + expect(three).toHaveAttribute('tabindex', '0'); + expect(one).toHaveAttribute('tabindex', '-1'); + }); + + it('leaves focus and the tab stop alone for other keys', async () => { + one.focus(); + + await fireEvent.keyDown(one, { key: 'Enter' }); + + expect(one).toHaveFocus(); + expect(one).toHaveAttribute('tabindex', '0'); + expect(two).toHaveAttribute('tabindex', '-1'); + expect(three).toHaveAttribute('tabindex', '-1'); + }); + + it('gives an item added after mount a tabindex', async () => { + await fireEvent.click(screen.getByTestId('add')); + + await waitFor(() => expect(screen.getByTestId('four')).toHaveAttribute('tabindex', '-1')); + }); + + it('stops handling arrow keys once unmounted', async () => { + unmount(); + + await fireEvent.keyDown(one, { key: 'ArrowRight' }); + + expect(one).toHaveAttribute('tabindex', '0'); + expect(two).toHaveAttribute('tabindex', '-1'); + }); + + it('ignores arrow keys raised from inside an open menu', async () => { + await fireEvent.focusIn(three); + + await fireEvent.keyDown(screen.getByTestId('menu-item'), { key: 'ArrowRight' }); + + expect(three).toHaveAttribute('tabindex', '0'); + expect(one).toHaveAttribute('tabindex', '-1'); + }); +}); From ac6bb0bf283dd74d1d4e209fdcf82ac026df8739 Mon Sep 17 00:00:00 2001 From: rtibblesbot Date: Wed, 26 Aug 2026 12:47:58 -0700 Subject: [PATCH 2/3] feat: make the rich text editor toolbars a single tab stop 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) --- .../TipTapEditor/components/EditorToolbar.vue | 3 + .../components/toolbar/FormatDropdown.vue | 1 + .../components/toolbar/MobileTopBar.vue | 7 ++ .../components/toolbar/PasteDropdown.vue | 2 + .../components/toolbar/ToolbarButton.vue | 10 +- .../__tests__/EditorToolbar.spec.js | 92 +++++++++++++++++++ .../__tests__/MobileTopBar.spec.js | 28 ++++++ .../__tests__/ToolbarButton.spec.js | 42 +++++++++ 8 files changed, 177 insertions(+), 8 deletions(-) create mode 100644 contentcuration/contentcuration/frontend/shared/views/TipTapEditor/__tests__/EditorToolbar.spec.js create mode 100644 contentcuration/contentcuration/frontend/shared/views/TipTapEditor/__tests__/MobileTopBar.spec.js create mode 100644 contentcuration/contentcuration/frontend/shared/views/TipTapEditor/__tests__/ToolbarButton.spec.js diff --git a/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/components/EditorToolbar.vue b/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/components/EditorToolbar.vue index df6732dff6..c159c85207 100644 --- a/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/components/EditorToolbar.vue +++ b/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/components/EditorToolbar.vue @@ -81,6 +81,7 @@