oficina / docs Public

Clone
git clone https://amadan.net/oficina/docs

Plain git — no account needed to clone.

Download

Download this file

1// fixtures/toolbar-roving.mjs — the formatting bar's keyboard
2// navigation, split out of toolbar.mjs to keep both under spec §12's
3// 300-line-per-module cap.
4//
5// It is a genuinely separate concern: toolbar.mjs decides what each
6// control DOES to the document, and this decides which control the
7// keyboard is on. Nothing here knows about ProseMirror.
8
9
10// setupRoving makes the whole bar ONE tab stop, with the arrow keys
11// moving between the controls inside it — the WAI-ARIA toolbar
12// pattern, which `role="toolbar"` in views/show.html is a promise to
13// implement.
14//
15// Without it the bar is fourteen tab stops between the shell bar and
16// the first word of the document, on every page load, for someone who
17// only wanted to type. It is also what a word processor does, so it is
18// what people's hands already expect. (Raised by the caret-placement
19// session, who was right that the fix is this and not reordering the
20// DOM: the bar sits above the paper visually, so DOM order already
21// matches reading order, and pulling focus order away from visual
22// order to save a few tab presses would trade a small annoyance for a
23// WCAG 2.4.3 Focus Order violation.)
24//
25// Left/Right rather than all four arrows, and preventDefault on the
26// ones handled: a closed native <select> changes its own value on
27// Left/Right, so the toolbar has to consume those; Up/Down are left
28// alone, which is how the three selects stay operable from the
29// keyboard without opening.
30//
31// Nothing here touches focus *visibility*. The controls keep
32// rastrillo's own focus ring — a roving tabindex decides which control
33// Tab reaches, not whether a person can see which one they are on, and
34// show.html's ring removal is scoped to [docs-editor] alone precisely
35// so this stays true.
36export function setupRoving(root, controls) {
37 if (!root || controls.length === 0) return { ensureReachable() {}, focusStop() { return false } }
38
39 const reachable = () => controls.filter((c) => !c.disabled)
40
41 function setStop(target) {
42 for (const c of controls) c.tabIndex = c === target ? 0 : -1
43 }
44
45 // ensureReachable is called after every sync, because sync is what
46 // enables and disables buttons: undo and redo start disabled, and a
47 // disabled button cannot take focus at all — so a tab stop parked on
48 // one makes the entire bar unreachable by keyboard rather than
49 // merely awkward. That failure is invisible to every other check
50 // here, which is why fixtures/browser-drive.mjs tabs in from outside
51 // and asserts it can get through.
52 function ensureReachable() {
53 const list = reachable()
54 if (list.length === 0) return
55 if (list.some((c) => c.tabIndex === 0)) return
56 setStop(list[0])
57 }
58
59 root.addEventListener('keydown', (event) => {
60 const key = event.key
61 if (key !== 'ArrowRight' && key !== 'ArrowLeft' && key !== 'Home' && key !== 'End') return
62 const list = reachable()
63 const at = list.indexOf(document.activeElement)
64 if (at < 0 || list.length === 0) return
65
66 let next
67 if (key === 'Home') next = list[0]
68 else if (key === 'End') next = list[list.length - 1]
69 else if (key === 'ArrowRight') next = list[(at + 1) % list.length]
70 else next = list[(at - 1 + list.length) % list.length]
71
72 event.preventDefault()
73 setStop(next)
74 next.focus()
75 })
76
77 // A control reached by mouse becomes the tab stop, so Tab leaves
78 // from where the person last was rather than from wherever the
79 // keyboard happened to leave it.
80 root.addEventListener('focusin', (event) => {
81 if (controls.includes(event.target)) setStop(event.target)
82 })
83
84 ensureReachable()
85 // focusStop moves focus onto the bar's current tab stop, and reports
86 // whether it managed to.
87 //
88 // It exists for Escape inside the editable surface. Tab is
89 // repurposed in there — it indents rather than moving focus — and
90 // although Shift+Tab still escapes backwards, there is no forward
91 // exit below indent 8. WCAG 2.1.2 permits a modified key provided
92 // the person is told, but "you can only leave backwards" is a poor
93 // thing to have to tell them, so Escape leaves forwards onto the
94 // nearest chrome instead.
95 function focusStop() {
96 const list = reachable()
97 if (list.length === 0) return false
98 const target = list.find((c) => c.tabIndex === 0) || list[0]
99 setStop(target)
100 target.focus()
101 return true
102 }
103
104 return { ensureReachable, focusStop }
105}
106