Skip to content

Commit 953bca2

Browse files
rtibblesbotclaude
andcommitted
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) <noreply@anthropic.com>
1 parent 2404efd commit 953bca2

8 files changed

Lines changed: 177 additions & 8 deletions

File tree

contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/components/EditorToolbar.vue

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@
8181
<template #more="{ overflowItems }">
8282
<button
8383
class="more-button"
84+
data-toolbar-item
8485
:class="
8586
$computedClass({
8687
':is([aria-expanded=\'true\'])': {
@@ -142,6 +143,7 @@
142143
import { useToolbarActions } from '../composables/useToolbarActions';
143144
import { getTipTapEditorStrings } from '../TipTapEditorStrings';
144145
import { useDropdowns } from '../composables/useDropdowns';
146+
import { useRovingTabIndex } from '../composables/useRovingTabIndex';
145147
import ToolbarButton from './toolbar/ToolbarButton.vue';
146148
import FormatDropdown from './toolbar/FormatDropdown.vue';
147149
import PasteDropdown from './toolbar/PasteDropdown.vue';
@@ -157,6 +159,7 @@
157159
},
158160
setup(props, { emit }) {
159161
const toolbarRef = ref(null);
162+
useRovingTabIndex(toolbarRef);
160163
161164
const {
162165
handleCopy,

contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/components/toolbar/FormatDropdown.vue

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
<button
99
ref="dropdownButton"
1010
class="format-dropdown"
11+
data-toolbar-item
1112
:aria-expanded="isOpen"
1213
:aria-haspopup="true"
1314
:aria-label="textFormatOptions$()"

contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/components/toolbar/MobileTopBar.vue

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
<template>
22

33
<div
4+
ref="toolbarRef"
45
class="toolbar top-bar"
56
role="toolbar"
67
:aria-label="editorControls$()"
@@ -24,6 +25,7 @@
2425
<div class="topbar-actions">
2526
<button
2627
class="insert-button"
28+
data-toolbar-item
2729
:title="insertContent$()"
2830
:aria-label="insertContentMenu$()"
2931
:aria-expanded="isInsertMenuOpen"
@@ -75,6 +77,7 @@
7577
import { defineComponent, ref, onMounted, onBeforeUnmount } from 'vue';
7678
import { useToolbarActions } from '../../composables/useToolbarActions';
7779
import { getTipTapEditorStrings } from '../../TipTapEditorStrings';
80+
import { useRovingTabIndex } from '../../composables/useRovingTabIndex';
7881
import ToolbarButton from './ToolbarButton.vue';
7982
8083
export default defineComponent({
@@ -83,6 +86,9 @@
8386
setup(props, { emit }) {
8487
const isInsertMenuOpen = ref(false);
8588
const dropdown = ref(null);
89+
const toolbarRef = ref(null);
90+
91+
useRovingTabIndex(toolbarRef);
8692
8793
const { historyActions, insertTools, minimizeAction } = useToolbarActions(emit);
8894
@@ -120,6 +126,7 @@
120126
minimizeAction,
121127
isInsertMenuOpen,
122128
dropdown,
129+
toolbarRef,
123130
editorControls$,
124131
historyActions$,
125132
insertContent$,

contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/components/toolbar/PasteDropdown.vue

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
>
88
<button
99
class="paste-main-btn toolbar-btn"
10+
data-toolbar-item
1011
:title="paste$()"
1112
:aria-label="paste$()"
1213
@click="handlePaste"
@@ -21,6 +22,7 @@
2122
<button
2223
ref="dropdownButton"
2324
class="paste-dropdown-btn"
25+
data-toolbar-item
2426
:title="pasteOptions$()"
2527
:aria-expanded="isOpen"
2628
:aria-haspopup="true"

contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/components/toolbar/ToolbarButton.vue

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,8 @@
44
class="toolbar-btn"
55
:title="title"
66
:class="{ active: isActive, disabled: !isAvailable }"
7-
:disabled="!isAvailable"
8-
:tabindex="isAvailable ? 0 : -1"
7+
data-toolbar-item
8+
:aria-disabled="isAvailable ? 'false' : 'true'"
99
:aria-label="title"
1010
:aria-pressed="isActive ? 'true' : 'false'"
1111
@mousedown.prevent
@@ -143,12 +143,6 @@
143143
opacity: 0.3;
144144
}
145145
146-
.toolbar-btn:disabled {
147-
pointer-events: none;
148-
cursor: not-allowed;
149-
opacity: 0.3;
150-
}
151-
152146
.toolbar-icon.rtl-flip {
153147
transform: scaleX(-1);
154148
}
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import { render, screen, fireEvent } from '@testing-library/vue';
2+
import { ref, nextTick } from 'vue';
3+
import VueRouter from 'vue-router';
4+
import EditorToolbar from '../TipTapEditor/components/EditorToolbar.vue';
5+
import { getTipTapEditorStrings } from '../TipTapEditor/TipTapEditorStrings';
6+
7+
const { textFormatOptions$ } = getTipTapEditorStrings();
8+
9+
// Every editor read the toolbar makes while rendering: undo/redo availability,
10+
// mark state, the alignment probe in `getEffectiveAlignment`, and the
11+
// transaction listener in `useDropdowns`.
12+
function makeEditorStub({ canUndo = true, canRedo = false } = {}) {
13+
return {
14+
isActive: () => false,
15+
can: () => ({ undo: () => canUndo, redo: () => canRedo }),
16+
state: {
17+
selection: { from: 0, to: 0, empty: true },
18+
doc: { nodesBetween: () => {} },
19+
},
20+
view: { domAtPos: () => ({ node: document.createElement('div') }) },
21+
on: () => {},
22+
off: () => {},
23+
};
24+
}
25+
26+
// In jsdom every control measures 0 wide, so KListWithOverflow restores them all
27+
// and drops the more button — two ticks after the first render.
28+
async function renderToolbar(editorOptions) {
29+
render(EditorToolbar, {
30+
provide: { editor: ref(makeEditorStub(editorOptions)) },
31+
router: new VueRouter(),
32+
});
33+
await nextTick();
34+
await nextTick();
35+
// Every button, not only the `data-toolbar-item` ones: an unmarked control
36+
// would be a second tab stop.
37+
return screen.getAllByRole('button');
38+
}
39+
40+
describe('EditorToolbar roving tabindex', () => {
41+
it('is a single tab stop, on the first control', async () => {
42+
const controls = await renderToolbar();
43+
44+
expect(controls.length).toBeGreaterThan(1);
45+
expect(controls[0]).toHaveAttribute('tabindex', '0');
46+
controls.slice(1).forEach(control => expect(control).toHaveAttribute('tabindex', '-1'));
47+
});
48+
49+
it('moves focus and the tab stop to the next control on ArrowRight', async () => {
50+
const controls = await renderToolbar();
51+
52+
await fireEvent.keyDown(controls[0], { key: 'ArrowRight' });
53+
54+
expect(controls[1]).toHaveFocus();
55+
expect(controls[1]).toHaveAttribute('tabindex', '0');
56+
});
57+
58+
it('wraps from the first control to the last on ArrowLeft', async () => {
59+
const controls = await renderToolbar();
60+
61+
await fireEvent.keyDown(controls[0], { key: 'ArrowLeft' });
62+
63+
expect(controls[controls.length - 1]).toHaveFocus();
64+
});
65+
66+
it('arrows on and off the format dropdown trigger like any other control', async () => {
67+
const controls = await renderToolbar();
68+
const index = controls.indexOf(screen.getByRole('button', { name: textFormatOptions$() }));
69+
70+
await fireEvent.keyDown(controls[index - 1], { key: 'ArrowRight' });
71+
expect(controls[index]).toHaveFocus();
72+
73+
await fireEvent.keyDown(controls[index], { key: 'ArrowRight' });
74+
expect(controls[index + 1]).toHaveFocus();
75+
});
76+
77+
it.each(['Enter', ' '])('opens the format dropdown with %p', async key => {
78+
await renderToolbar();
79+
80+
await fireEvent.keyDown(screen.getByRole('button', { name: textFormatOptions$() }), { key });
81+
82+
expect(screen.getByRole('menu')).toBeInTheDocument();
83+
});
84+
85+
it('keeps the tab stop on an unavailable control', async () => {
86+
const controls = await renderToolbar({ canUndo: false });
87+
88+
expect(controls[0]).toHaveAttribute('aria-disabled', 'true');
89+
expect(controls[0]).toHaveAttribute('tabindex', '0');
90+
});
91+
});
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import { render, screen } from '@testing-library/vue';
2+
import { ref } from 'vue';
3+
import VueRouter from 'vue-router';
4+
import MobileTopBar from '../TipTapEditor/components/toolbar/MobileTopBar.vue';
5+
6+
// Undo and redo report unavailable, so the bar renders `aria-disabled` controls.
7+
function makeEditorStub() {
8+
return {
9+
isActive: () => false,
10+
can: () => ({ undo: () => false, redo: () => false }),
11+
};
12+
}
13+
14+
describe('MobileTopBar roving tabindex', () => {
15+
it('is a single tab stop, held by the unavailable undo control', () => {
16+
render(MobileTopBar, {
17+
provide: { editor: ref(makeEditorStub()) },
18+
router: new VueRouter(),
19+
});
20+
// Every button, not only the `data-toolbar-item` ones: an unmarked control
21+
// would be a second tab stop.
22+
const controls = screen.getAllByRole('button');
23+
24+
expect(controls.length).toBeGreaterThan(1);
25+
expect(controls[0]).toHaveAttribute('aria-disabled', 'true');
26+
expect(controls[0]).toHaveAttribute('tabindex', '0');
27+
controls.slice(1).forEach(control => expect(control).toHaveAttribute('tabindex', '-1'));
28+
});
29+
});
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import { render, screen, fireEvent } from '@testing-library/vue';
2+
import VueRouter from 'vue-router';
3+
import ToolbarButton from '../TipTapEditor/components/toolbar/ToolbarButton.vue';
4+
5+
const TITLE = 'Strong';
6+
7+
function renderButton(props = {}) {
8+
const { emitted } = render(ToolbarButton, {
9+
props: { title: TITLE, icon: 'bold.svg', ...props },
10+
router: new VueRouter(),
11+
});
12+
return { button: screen.getByRole('button', { name: TITLE }), emitted };
13+
}
14+
15+
describe('ToolbarButton', () => {
16+
it('keeps an unavailable control focusable and marks it aria-disabled', () => {
17+
const { button } = renderButton({ isAvailable: false });
18+
19+
button.focus();
20+
21+
expect(button).toHaveAttribute('aria-disabled', 'true');
22+
expect(button).toHaveFocus();
23+
});
24+
25+
it('does not emit click when unavailable', async () => {
26+
const { button, emitted } = renderButton({ isAvailable: false });
27+
28+
await fireEvent.click(button);
29+
30+
expect(emitted().click).toBeUndefined();
31+
});
32+
33+
it('emits click when available', async () => {
34+
const { button, emitted } = renderButton();
35+
36+
expect(button).toHaveAttribute('aria-disabled', 'false');
37+
38+
await fireEvent.click(button);
39+
40+
expect(emitted().click).toHaveLength(1);
41+
});
42+
});

0 commit comments

Comments
 (0)