| 1 | // fixtures/menu.mjs — the menu bar's behaviour. |
| 2 | // |
| 3 | // The bar itself is server-rendered markup (internal/docs/menu.go for |
| 4 | // the model and the copy, views/show.html for the markup), and it is |
| 5 | // six <details> elements in a name= exclusive group. That means it |
| 6 | // already works before this file runs: every menu opens, every link |
| 7 | // navigates, every form posts, and Tab reaches all of it. THAT IS THE |
| 8 | // CONTRACT THIS MODULE MUST NOT BREAK. The exports are in the File menu |
| 9 | // now, and they worked with scripts disabled before they moved |
| 10 | // (editor_view_test.go's TestShowRendersContentWithoutScript is built |
| 11 | // on the same guarantee); if this file were load-bearing rather than |
| 12 | // additive, moving them would have quietly removed the only route to a |
| 13 | // person's own document. |
| 14 | // |
| 15 | // So everything here ADDS: |
| 16 | // |
| 17 | // - a roving tabindex, so the bar is one tab stop rather than six; |
| 18 | // - Left/Right between menus, Up/Down within one; |
| 19 | // - and the command items, which are the one thing that genuinely |
| 20 | // cannot work without an editor to act on. |
| 21 | // |
| 22 | // Escape and the click-away close are estilo's (chrome/chrome.js, loaded |
| 23 | // by layout.html), not this file's: the menubar menus are in the one |
| 24 | // name="estilo-menus" group, and estilo closes whichever member is open, |
| 25 | // in the capture phase, returning focus to its title and moving the |
| 26 | // roving tab stop with it. This file used to do both as well; since |
| 27 | // estilo 894afdc its Escape case found nothing open and its click-away |
| 28 | // repeated estilo's, so both went. |
| 29 | // |
| 30 | // Command items ship disabled from the server because the editor has not |
| 31 | // mounted yet; sync switches each on exactly when its command would do |
| 32 | // something. (Items for features nobody has built used to ship here too, |
| 33 | // flagged data-docs-unavailable and never enabled. They are not rendered |
| 34 | // at all now: menu.go, spec §7.3.) |
| 35 | // |
| 36 | // The markup is estilo's ([estilo-menubar], [estilo-menubar-menu], |
| 37 | // [estilo-menubar-title], [estilo-menu-item]): the menu style every |
| 38 | // Oficina app shares. |
| 39 | // |
| 40 | // - and, while one menu is open, pointing at another title opens that |
| 41 | // one, which is what every desktop menu bar does. |
| 42 | // |
| 43 | // Checkable items (View's paged/infinite pair, Format's paragraph styles |
| 44 | // and alignments) carry aria-pressed on every row of their group; sync |
| 45 | // sets it from the caller's isPressed, and a checkable item that is the |
| 46 | // current one stays enabled even though choosing it again does nothing — |
| 47 | // "current" must never be drawn as "unavailable". |
| 48 | |
| 49 | import { setAttr, setProp } from './dom-writes.mjs' |
| 50 | |
| 51 | // Menu commands that are not editor commands. Everything else is |
| 52 | // resolved against fixtures/toolbar.mjs's own table, which is the point |
| 53 | // — the menu is a second surface onto the toolbar's commands, not a |
| 54 | // second implementation of them. |
| 55 | // |
| 56 | // internal/docs's TestMenuCommandsExistInTheBundle greps this file and |
| 57 | // toolbar.mjs for every data-docs-command name menu.go renders, so a |
| 58 | // menu item naming a command nobody implements fails the gate instead |
| 59 | // of failing silently in front of a person. That test looks for the |
| 60 | // literal name, so these keys are spelled out rather than built. |
| 61 | const LOCAL_COMMANDS = { |
| 62 | // window.print() and not a link to the PDF export: printing is the |
| 63 | // browser's own dialogue over the page a person is looking at, |
| 64 | // including whatever the view is currently showing. The PDF export |
| 65 | // is a different thing and has its own File item. |
| 66 | print: () => { |
| 67 | window.print() |
| 68 | return true |
| 69 | }, |
| 70 | } |
| 71 | |
| 72 | // openMenu returns the currently open <details>, or null. |
| 73 | function openMenu(root) { |
| 74 | return root.querySelector('[estilo-menubar-menu][open]') |
| 75 | } |
| 76 | |
| 77 | function titles(root) { |
| 78 | return Array.from(root.querySelectorAll('[estilo-menubar-title]')) |
| 79 | } |
| 80 | |
| 81 | // itemsOf returns the focusable rows of one menu, in DOM order. |
| 82 | // Disabled items are skipped rather than focused-and-inert: arrow keys |
| 83 | // that stop on a row nothing happens at are worse than arrow keys that |
| 84 | // pass over it, and the row is still announced by a screen reader |
| 85 | // reading the menu, which is where "this exists but is unavailable" |
| 86 | // belongs. |
| 87 | function itemsOf(menu) { |
| 88 | return Array.from(menu.querySelectorAll('[estilo-menu-item]')).filter((el) => !el.disabled) |
| 89 | } |
| 90 | |
| 91 | // focusTitle moves the roving tabindex as well as the focus. The bar is |
| 92 | // one tab stop, so exactly one title is tabbable at a time and it is |
| 93 | // whichever one was last used — the same rule |
| 94 | // fixtures/toolbar-roving.mjs applies to the formatting bar, kept |
| 95 | // deliberately identical so the two bars do not behave differently |
| 96 | // under the same keys. |
| 97 | function focusTitle(root, el) { |
| 98 | for (const t of titles(root)) setProp(t, 'tabIndex', t === el ? 0 : -1) |
| 99 | el.focus() |
| 100 | } |
| 101 | |
| 102 | // close shuts whichever menu is open, leaving focus where it is. |
| 103 | function close(root) { |
| 104 | const menu = openMenu(root) |
| 105 | if (menu) menu.open = false |
| 106 | } |
| 107 | |
| 108 | export function mountMenu(root, { getView, commands, extraCommands, isPressed }) { |
| 109 | if (!root) return null |
| 110 | |
| 111 | const all = titles(root) |
| 112 | if (!all.length) return null |
| 113 | for (const [i, t] of all.entries()) setProp(t, 'tabIndex', i === 0 ? 0 : -1) |
| 114 | |
| 115 | // The non-editor commands: this module's own (Print) plus whatever |
| 116 | // the caller passes in (the View menu's paged/infinite pair, which |
| 117 | // change how the document is drawn rather than what it contains). |
| 118 | // |
| 119 | // Merged into one table rather than checked in two places, so `run` |
| 120 | // and `sync` below cannot disagree about which commands are |
| 121 | // selection-independent — the bug that would show as a menu item |
| 122 | // greying itself out for a reason that does not apply to it. |
| 123 | const localCommands = { ...LOCAL_COMMANDS, ...(extraCommands || {}) } |
| 124 | |
| 125 | // run dispatches one command name. Returns whether anything ran, so |
| 126 | // the caller can leave the menu open on a miss rather than closing it |
| 127 | // and leaving a person unsure whether they hit the item. |
| 128 | function run(name, from) { |
| 129 | const local = localCommands[name] |
| 130 | if (local) { |
| 131 | // Focus first, then run. |
| 132 | // |
| 133 | // The caller closes the menu straight after this returns, and a |
| 134 | // focused element inside a closed <details> is not rendered — so |
| 135 | // focus fell to <body> after Print, after either View mode, and |
| 136 | // after both Tools items, leaving a keyboard user at the top of |
| 137 | // the document every time. For the two dialogs it was worse: |
| 138 | // showModal() records the focused element as its return target, |
| 139 | // and could not restore to a button that had stopped existing. |
| 140 | // |
| 141 | // Focus goes to the menu's own title, which is where a person |
| 142 | // was: the menu closes under them and they are still on the bar, |
| 143 | // one arrow key from where they started. Doing it BEFORE the |
| 144 | // command runs is what lets showModal see a real return target. |
| 145 | if (from) { |
| 146 | const title = from.closest('[estilo-menubar-menu]') |
| 147 | if (title) { |
| 148 | const summary = title.querySelector('[estilo-menubar-title]') |
| 149 | if (summary) focusTitle(root, summary) |
| 150 | } |
| 151 | } |
| 152 | return local() |
| 153 | } |
| 154 | const view = getView() |
| 155 | if (!view) return false |
| 156 | const command = commands[name] |
| 157 | if (!command) return false |
| 158 | const ran = command(view.state, view.dispatch, view) |
| 159 | // Focus goes back to the document, always, and not only when the |
| 160 | // command ran. A menu item is not a place to leave the caret: the |
| 161 | // person's next keystroke is meant for their document, and every |
| 162 | // one of these commands acts on a selection that only exists while |
| 163 | // the editor holds focus. |
| 164 | view.focus() |
| 165 | return ran |
| 166 | } |
| 167 | |
| 168 | root.addEventListener('click', (e) => { |
| 169 | const item = e.target.closest('[estilo-menu-item]') |
| 170 | if (!item || item.disabled) return |
| 171 | // Links and form buttons are left entirely alone — they are what |
| 172 | // makes this bar work without JavaScript, and intercepting them |
| 173 | // here would be this module quietly becoming load-bearing. |
| 174 | if (!item.hasAttribute('data-docs-command')) { |
| 175 | close(root) |
| 176 | return |
| 177 | } |
| 178 | e.preventDefault() |
| 179 | run(item.getAttribute('data-docs-command'), item) |
| 180 | close(root) |
| 181 | }) |
| 182 | |
| 183 | root.addEventListener('keydown', (e) => { |
| 184 | const title = e.target.closest('[estilo-menubar-title]') |
| 185 | const item = e.target.closest('[estilo-menu-item]') |
| 186 | const menu = openMenu(root) |
| 187 | |
| 188 | switch (e.key) { |
| 189 | case 'ArrowRight': |
| 190 | case 'ArrowLeft': { |
| 191 | // Left/Right move between menus from anywhere in the bar — |
| 192 | // including from inside an open one, which is what every menu |
| 193 | // bar does and what makes browsing across them possible |
| 194 | // without closing and reopening. |
| 195 | const from = title || (menu && menu.querySelector('[estilo-menubar-title]')) |
| 196 | if (!from) return |
| 197 | e.preventDefault() |
| 198 | const list = titles(root) |
| 199 | const next = list[(list.indexOf(from) + (e.key === 'ArrowRight' ? 1 : -1) + list.length) % list.length] |
| 200 | const wasOpen = Boolean(menu) |
| 201 | close(root) |
| 202 | if (wasOpen) next.closest('[estilo-menubar-menu]').open = true |
| 203 | focusTitle(root, next) |
| 204 | return |
| 205 | } |
| 206 | case 'ArrowDown': |
| 207 | case 'ArrowUp': { |
| 208 | if (title) { |
| 209 | // From a title: open the menu and land on its first item. |
| 210 | e.preventDefault() |
| 211 | const own = title.closest('[estilo-menubar-menu]') |
| 212 | own.open = true |
| 213 | const rows = itemsOf(own) |
| 214 | if (rows.length) rows[e.key === 'ArrowDown' ? 0 : rows.length - 1].focus() |
| 215 | return |
| 216 | } |
| 217 | if (item && menu) { |
| 218 | e.preventDefault() |
| 219 | const rows = itemsOf(menu) |
| 220 | const i = rows.indexOf(item) |
| 221 | if (i < 0 || !rows.length) return |
| 222 | rows[(i + (e.key === 'ArrowDown' ? 1 : -1) + rows.length) % rows.length].focus() |
| 223 | } |
| 224 | return |
| 225 | } |
| 226 | case 'Home': |
| 227 | case 'End': { |
| 228 | if (!item || !menu) return |
| 229 | e.preventDefault() |
| 230 | const rows = itemsOf(menu) |
| 231 | if (rows.length) rows[e.key === 'Home' ? 0 : rows.length - 1].focus() |
| 232 | return |
| 233 | } |
| 234 | case 'Tab': |
| 235 | // Tab leaves the bar rather than walking its items. The menu |
| 236 | // closes so a person does not tab away leaving a panel hanging |
| 237 | // open over the document. |
| 238 | close(root) |
| 239 | } |
| 240 | }) |
| 241 | |
| 242 | // While a menu is open, pointing at another title opens that one — the |
| 243 | // menu bar behaviour every desktop app has, and the one thing a row of |
| 244 | // <details> cannot do for itself. With no menu open, hovering does |
| 245 | // nothing: opening a menu is always a click or a key. |
| 246 | root.addEventListener('pointerover', (e) => { |
| 247 | if (e.pointerType && e.pointerType !== 'mouse') return |
| 248 | const title = e.target.closest && e.target.closest('[estilo-menubar-title]') |
| 249 | const menu = openMenu(root) |
| 250 | if (!title || !menu || menu.contains(title)) return |
| 251 | const next = title.closest('[estilo-menubar-menu]') |
| 252 | if (!next) return |
| 253 | next.open = true |
| 254 | focusTitle(root, title) |
| 255 | }) |
| 256 | |
| 257 | // sync is called on every editor state update, through the toolbar's |
| 258 | // own plugin (toolbar.mjs's onSync) rather than through a second |
| 259 | // plugin of this module's own. One plugin, so the two bars can never |
| 260 | // disagree about the same selection, and so a menu item and the |
| 261 | // toolbar button beside it are never enabled differently. |
| 262 | // |
| 263 | // An item is available exactly when its command reports that it would |
| 264 | // do something — the same test the toolbar's buttons already use, and |
| 265 | // the same contract: a command called with a null dispatch must |
| 266 | // perform nothing and only report. buildCommands' own comment records |
| 267 | // what it cost to learn that y-prosemirror's bare `undo` does not |
| 268 | // honour it, so nothing here may call a command any other way. |
| 269 | // |
| 270 | // One exclusion: a command in LOCAL_COMMANDS is not an editor command |
| 271 | // at all. Print does not act on a selection and is available whenever |
| 272 | // the page is. |
| 273 | // |
| 274 | // And one inclusion: a checkable item that is the current one. Setting |
| 275 | // the paragraph style a paragraph already has does nothing, so its |
| 276 | // command reports false, and "Body text" used to show as disabled while |
| 277 | // the caret sat in body text. It is checked and enabled now. |
| 278 | // |
| 279 | // Through setProp and setAttr, which write only when the value would |
| 280 | // change. Setting a reflected boolean like `disabled` to the value it |
| 281 | // already holds still sets the content attribute, so it still records |
| 282 | // a mutation and still invalidates style — and this runs on every state |
| 283 | // update. Five of the 23 mutations behind the tab-switch flash were |
| 284 | // these lines. fixtures/dom-writes.mjs has the measurement, and |
| 285 | // fixtures/toolbar.test.mjs holds this bar to it: a state update that |
| 286 | // changes nothing must write nothing here either. |
| 287 | function sync(view) { |
| 288 | for (const el of root.querySelectorAll('[estilo-menu-item][data-docs-command]')) { |
| 289 | const name = el.getAttribute('data-docs-command') |
| 290 | let pressed = false |
| 291 | if (el.hasAttribute('aria-pressed') && isPressed) { |
| 292 | pressed = Boolean(isPressed(name, view)) |
| 293 | setAttr(el, 'aria-pressed', pressed ? 'true' : 'false') |
| 294 | } |
| 295 | // localCommands, NOT the LOCAL_COMMANDS module constant. |
| 296 | // |
| 297 | // This line read LOCAL_COMMANDS for one review cycle, and it was |
| 298 | // the worst bug on the branch. That constant holds only `print`; |
| 299 | // everything passed in through extraCommands — paged, infinite, |
| 300 | // word count, the shortcuts sheet — fell through to the toolbar's |
| 301 | // table, which has none of them, and was set disabled on every |
| 302 | // editor update. Four menu items that ship disabled from the |
| 303 | // server stayed disabled for the life of the page, so three of |
| 304 | // the night's deliverables were unreachable from the UI. |
| 305 | // |
| 306 | // The comment eight lines above the merge says the two tables |
| 307 | // were merged "so run and sync cannot disagree about which |
| 308 | // commands are selection-independent". They then disagreed, |
| 309 | // because only one of them was changed to read the merged table. |
| 310 | // Writing the invariant down is not the same as holding it. |
| 311 | if (localCommands[name]) { |
| 312 | setProp(el, 'disabled', false) |
| 313 | continue |
| 314 | } |
| 315 | const command = commands[name] |
| 316 | const available = Boolean(view && command && (pressed || command(view.state, null, view))) |
| 317 | setProp(el, 'disabled', !available) |
| 318 | } |
| 319 | } |
| 320 | |
| 321 | return { sync } |
| 322 | } |
| 323 | |