From 8023f9f2aa8bf5c8c221460f668b46060987898e Mon Sep 17 00:00:00 2001 From: rtibblesbot Date: Mon, 31 Aug 2026 15:32:30 -0700 Subject: [PATCH 1/6] 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 | 89 +++++++++++++ .../__tests__/useRovingTabIndex.spec.js | 123 ++++++++++++++++++ 2 files changed, 212 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..573e6bbffe --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/composables/useRovingTabIndex.js @@ -0,0 +1,89 @@ +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')); + }; + + 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(); + // `window.isRTL` is the page direction, rendered server-side by `base.html`. + const offset = window.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) { + 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..63f109fb5a --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/__tests__/useRovingTabIndex.spec.js @@ -0,0 +1,123 @@ +import { render, screen, waitFor } from '@testing-library/vue'; +import userEvent from '@testing-library/user-event'; +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 user, one, two, three, unmount; + + beforeEach(() => { + user = userEvent.setup(); + ({ unmount } = render(Harness, { router: new VueRouter() })); + one = screen.getByTestId('one'); + two = screen.getByTestId('two'); + three = screen.getByTestId('three'); + }); + + afterEach(() => { + delete window.isRTL; + }); + + it('wraps to the first item on ArrowRight from the last item', async () => { + three.focus(); + + await user.keyboard('{ArrowRight}'); + + expect(one).toHaveFocus(); + }); + + it('skips controls KListWithOverflow has hidden', async () => { + two.style.visibility = 'hidden'; + one.focus(); + + await user.keyboard('{ArrowRight}'); + + expect(three).toHaveFocus(); + }); + + it('reverses the arrow directions in RTL', async () => { + window.isRTL = true; + two.focus(); + + await user.keyboard('{ArrowRight}'); + expect(one).toHaveFocus(); + + two.focus(); + await user.keyboard('{ArrowLeft}'); + expect(three).toHaveFocus(); + }); + + it('returns the tab stop to the item that last had focus', async () => { + three.focus(); + + await user.tab(); + await user.tab({ shift: true }); + + expect(three).toHaveFocus(); + }); + + it('leaves focus and the tab stop alone for other keys', async () => { + one.focus(); + + await user.keyboard('{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 user.click(screen.getByTestId('add')); + + await waitFor(() => expect(screen.getByTestId('four')).toHaveAttribute('tabindex', '-1')); + }); + + it('stops handling arrow keys once unmounted', () => { + unmount(); + + // Dispatched by hand rather than through `userEvent`: the items are detached + // from the document once unmounted, so nothing can focus them to type into. + one.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true })); + + expect(one).toHaveAttribute('tabindex', '0'); + expect(two).toHaveAttribute('tabindex', '-1'); + }); + + it('ignores arrow keys raised from inside an open menu', async () => { + three.focus(); + screen.getByTestId('menu-item').focus(); + + await user.keyboard('{ArrowRight}'); + + expect(three).toHaveAttribute('tabindex', '0'); + expect(one).toHaveAttribute('tabindex', '-1'); + }); +}); From 042bd9bc9ccfdccb79d3ab90e9513e1f5bb42571 Mon Sep 17 00:00:00 2001 From: rtibblesbot Date: Mon, 31 Aug 2026 15:32:39 -0700 Subject: [PATCH 2/6] 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) --- .../frontend/shared/utils/testing.js | 15 +++ .../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 | 102 ++++++++++++++++++ .../__tests__/MobileTopBar.spec.js | 33 ++++++ .../__tests__/ToolbarButton.spec.js | 50 +++++++++ 9 files changed, 215 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/utils/testing.js b/contentcuration/contentcuration/frontend/shared/utils/testing.js index cc78737696..37c3e4cd45 100644 --- a/contentcuration/contentcuration/frontend/shared/utils/testing.js +++ b/contentcuration/contentcuration/frontend/shared/utils/testing.js @@ -25,3 +25,18 @@ export async function resetMockChannelScope() { Session.currentChannelId = Session._oldCurrentChannelId; delete Session._oldCurrentChannelId; } + +/** + * Tab into the component under test, entering backwards from a sentinel after it. + * + * Tabbing forward from the start of the document stops on the CSRF input the + * shared Jest setup leaves at the top of the body. + * + * @param {import('@testing-library/user-event').UserEvent} user + */ +export async function tabIn(user) { + const sentinel = document.body.appendChild(document.createElement('button')); + sentinel.focus(); + await user.tab({ shift: true }); + sentinel.remove(); +} 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 @@ @@ -167,6 +171,15 @@ 'insert-math': target => mathHandler.openCreateMathModal({ targetElement: target }), })); + // Tracked on the container rather than on the editor content: tabbing out of + // the content blurs it, and the re-render that blur schedules would unmount + // the mobile formatting bar before focus could land on it. `focusout` is the + // only signal that names where focus is going, so it alone clears this. + const hasFocusWithin = ref(false); + const handleFocusout = event => { + hasFocusWithin.value = editorContainer.value.contains(event.relatedTarget); + }; + const handleDrop = event => { const file = event.dataTransfer.files[0]; if (file) { @@ -278,7 +291,8 @@ return { editorContainer, isReady, - isFocused, + hasFocusWithin, + handleFocusout, handleDrop, linkHandler, editor, diff --git a/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/components/link/LinkBubbleMenu.vue b/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/components/link/LinkBubbleMenu.vue index 4746dd2174..a8962f3ec3 100644 --- a/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/components/link/LinkBubbleMenu.vue +++ b/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/components/link/LinkBubbleMenu.vue @@ -1,6 +1,7 @@