| 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. |
| 36 | export 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 | |