// fixtures/menu.mjs — the menu bar's behaviour. // // The bar itself is server-rendered markup (internal/docs/menu.go for // the model and the copy, views/show.html for the markup), and it is // six
elements in a name= exclusive group. That means it // already works before this file runs: every menu opens, every link // navigates, every form posts, and Tab reaches all of it. THAT IS THE // CONTRACT THIS MODULE MUST NOT BREAK. The exports are in the File menu // now, and they worked with scripts disabled before they moved // (editor_view_test.go's TestShowRendersContentWithoutScript is built // on the same guarantee); if this file were load-bearing rather than // additive, moving them would have quietly removed the only route to a // person's own document. // // So everything here ADDS: // // - a roving tabindex, so the bar is one tab stop rather than six; // - Left/Right between menus, Up/Down within one; // - and the command items, which are the one thing that genuinely // cannot work without an editor to act on. // // Escape and the click-away close are estilo's (chrome/chrome.js, loaded // by layout.html), not this file's: the menubar menus are in the one // name="estilo-menus" group, and estilo closes whichever member is open, // in the capture phase, returning focus to its title and moving the // roving tab stop with it. This file used to do both as well; since // estilo 894afdc its Escape case found nothing open and its click-away // repeated estilo's, so both went. // // Command items ship disabled from the server because the editor has not // mounted yet; sync switches each on exactly when its command would do // something. (Items for features nobody has built used to ship here too, // flagged data-docs-unavailable and never enabled. They are not rendered // at all now: menu.go, spec §7.3.) // // The markup is estilo's ([estilo-menubar], [estilo-menubar-menu], // [estilo-menubar-title], [estilo-menu-item]): the menu style every // Oficina app shares. // // - and, while one menu is open, pointing at another title opens that // one, which is what every desktop menu bar does. // // Checkable items (View's paged/infinite pair, Format's paragraph styles // and alignments) carry aria-pressed on every row of their group; sync // sets it from the caller's isPressed, and a checkable item that is the // current one stays enabled even though choosing it again does nothing — // "current" must never be drawn as "unavailable". import { setAttr, setProp } from './dom-writes.mjs' // Menu commands that are not editor commands. Everything else is // resolved against fixtures/toolbar.mjs's own table, which is the point // — the menu is a second surface onto the toolbar's commands, not a // second implementation of them. // // internal/docs's TestMenuCommandsExistInTheBundle greps this file and // toolbar.mjs for every data-docs-command name menu.go renders, so a // menu item naming a command nobody implements fails the gate instead // of failing silently in front of a person. That test looks for the // literal name, so these keys are spelled out rather than built. const LOCAL_COMMANDS = { // window.print() and not a link to the PDF export: printing is the // browser's own dialogue over the page a person is looking at, // including whatever the view is currently showing. The PDF export // is a different thing and has its own File item. print: () => { window.print() return true }, } // openMenu returns the currently open
, or null. function openMenu(root) { return root.querySelector('[estilo-menubar-menu][open]') } function titles(root) { return Array.from(root.querySelectorAll('[estilo-menubar-title]')) } // itemsOf returns the focusable rows of one menu, in DOM order. // Disabled items are skipped rather than focused-and-inert: arrow keys // that stop on a row nothing happens at are worse than arrow keys that // pass over it, and the row is still announced by a screen reader // reading the menu, which is where "this exists but is unavailable" // belongs. function itemsOf(menu) { return Array.from(menu.querySelectorAll('[estilo-menu-item]')).filter((el) => !el.disabled) } // focusTitle moves the roving tabindex as well as the focus. The bar is // one tab stop, so exactly one title is tabbable at a time and it is // whichever one was last used — the same rule // fixtures/toolbar-roving.mjs applies to the formatting bar, kept // deliberately identical so the two bars do not behave differently // under the same keys. function focusTitle(root, el) { for (const t of titles(root)) setProp(t, 'tabIndex', t === el ? 0 : -1) el.focus() } // close shuts whichever menu is open, leaving focus where it is. function close(root) { const menu = openMenu(root) if (menu) menu.open = false } export function mountMenu(root, { getView, commands, extraCommands, isPressed }) { if (!root) return null const all = titles(root) if (!all.length) return null for (const [i, t] of all.entries()) setProp(t, 'tabIndex', i === 0 ? 0 : -1) // The non-editor commands: this module's own (Print) plus whatever // the caller passes in (the View menu's paged/infinite pair, which // change how the document is drawn rather than what it contains). // // Merged into one table rather than checked in two places, so `run` // and `sync` below cannot disagree about which commands are // selection-independent — the bug that would show as a menu item // greying itself out for a reason that does not apply to it. const localCommands = { ...LOCAL_COMMANDS, ...(extraCommands || {}) } // run dispatches one command name. Returns whether anything ran, so // the caller can leave the menu open on a miss rather than closing it // and leaving a person unsure whether they hit the item. function run(name, from) { const local = localCommands[name] if (local) { // Focus first, then run. // // The caller closes the menu straight after this returns, and a // focused element inside a closed
is not rendered — so // focus fell to after Print, after either View mode, and // after both Tools items, leaving a keyboard user at the top of // the document every time. For the two dialogs it was worse: // showModal() records the focused element as its return target, // and could not restore to a button that had stopped existing. // // Focus goes to the menu's own title, which is where a person // was: the menu closes under them and they are still on the bar, // one arrow key from where they started. Doing it BEFORE the // command runs is what lets showModal see a real return target. if (from) { const title = from.closest('[estilo-menubar-menu]') if (title) { const summary = title.querySelector('[estilo-menubar-title]') if (summary) focusTitle(root, summary) } } return local() } const view = getView() if (!view) return false const command = commands[name] if (!command) return false const ran = command(view.state, view.dispatch, view) // Focus goes back to the document, always, and not only when the // command ran. A menu item is not a place to leave the caret: the // person's next keystroke is meant for their document, and every // one of these commands acts on a selection that only exists while // the editor holds focus. view.focus() return ran } root.addEventListener('click', (e) => { const item = e.target.closest('[estilo-menu-item]') if (!item || item.disabled) return // Links and form buttons are left entirely alone — they are what // makes this bar work without JavaScript, and intercepting them // here would be this module quietly becoming load-bearing. if (!item.hasAttribute('data-docs-command')) { close(root) return } e.preventDefault() run(item.getAttribute('data-docs-command'), item) close(root) }) root.addEventListener('keydown', (e) => { const title = e.target.closest('[estilo-menubar-title]') const item = e.target.closest('[estilo-menu-item]') const menu = openMenu(root) switch (e.key) { case 'ArrowRight': case 'ArrowLeft': { // Left/Right move between menus from anywhere in the bar — // including from inside an open one, which is what every menu // bar does and what makes browsing across them possible // without closing and reopening. const from = title || (menu && menu.querySelector('[estilo-menubar-title]')) if (!from) return e.preventDefault() const list = titles(root) const next = list[(list.indexOf(from) + (e.key === 'ArrowRight' ? 1 : -1) + list.length) % list.length] const wasOpen = Boolean(menu) close(root) if (wasOpen) next.closest('[estilo-menubar-menu]').open = true focusTitle(root, next) return } case 'ArrowDown': case 'ArrowUp': { if (title) { // From a title: open the menu and land on its first item. e.preventDefault() const own = title.closest('[estilo-menubar-menu]') own.open = true const rows = itemsOf(own) if (rows.length) rows[e.key === 'ArrowDown' ? 0 : rows.length - 1].focus() return } if (item && menu) { e.preventDefault() const rows = itemsOf(menu) const i = rows.indexOf(item) if (i < 0 || !rows.length) return rows[(i + (e.key === 'ArrowDown' ? 1 : -1) + rows.length) % rows.length].focus() } return } case 'Home': case 'End': { if (!item || !menu) return e.preventDefault() const rows = itemsOf(menu) if (rows.length) rows[e.key === 'Home' ? 0 : rows.length - 1].focus() return } case 'Tab': // Tab leaves the bar rather than walking its items. The menu // closes so a person does not tab away leaving a panel hanging // open over the document. close(root) } }) // While a menu is open, pointing at another title opens that one — the // menu bar behaviour every desktop app has, and the one thing a row of //
cannot do for itself. With no menu open, hovering does // nothing: opening a menu is always a click or a key. root.addEventListener('pointerover', (e) => { if (e.pointerType && e.pointerType !== 'mouse') return const title = e.target.closest && e.target.closest('[estilo-menubar-title]') const menu = openMenu(root) if (!title || !menu || menu.contains(title)) return const next = title.closest('[estilo-menubar-menu]') if (!next) return next.open = true focusTitle(root, title) }) // sync is called on every editor state update, through the toolbar's // own plugin (toolbar.mjs's onSync) rather than through a second // plugin of this module's own. One plugin, so the two bars can never // disagree about the same selection, and so a menu item and the // toolbar button beside it are never enabled differently. // // An item is available exactly when its command reports that it would // do something — the same test the toolbar's buttons already use, and // the same contract: a command called with a null dispatch must // perform nothing and only report. buildCommands' own comment records // what it cost to learn that y-prosemirror's bare `undo` does not // honour it, so nothing here may call a command any other way. // // One exclusion: a command in LOCAL_COMMANDS is not an editor command // at all. Print does not act on a selection and is available whenever // the page is. // // And one inclusion: a checkable item that is the current one. Setting // the paragraph style a paragraph already has does nothing, so its // command reports false, and "Body text" used to show as disabled while // the caret sat in body text. It is checked and enabled now. // // Through setProp and setAttr, which write only when the value would // change. Setting a reflected boolean like `disabled` to the value it // already holds still sets the content attribute, so it still records // a mutation and still invalidates style — and this runs on every state // update. Five of the 23 mutations behind the tab-switch flash were // these lines. fixtures/dom-writes.mjs has the measurement, and // fixtures/toolbar.test.mjs holds this bar to it: a state update that // changes nothing must write nothing here either. function sync(view) { for (const el of root.querySelectorAll('[estilo-menu-item][data-docs-command]')) { const name = el.getAttribute('data-docs-command') let pressed = false if (el.hasAttribute('aria-pressed') && isPressed) { pressed = Boolean(isPressed(name, view)) setAttr(el, 'aria-pressed', pressed ? 'true' : 'false') } // localCommands, NOT the LOCAL_COMMANDS module constant. // // This line read LOCAL_COMMANDS for one review cycle, and it was // the worst bug on the branch. That constant holds only `print`; // everything passed in through extraCommands — paged, infinite, // word count, the shortcuts sheet — fell through to the toolbar's // table, which has none of them, and was set disabled on every // editor update. Four menu items that ship disabled from the // server stayed disabled for the life of the page, so three of // the night's deliverables were unreachable from the UI. // // The comment eight lines above the merge says the two tables // were merged "so run and sync cannot disagree about which // commands are selection-independent". They then disagreed, // because only one of them was changed to read the merged table. // Writing the invariant down is not the same as holding it. if (localCommands[name]) { setProp(el, 'disabled', false) continue } const command = commands[name] const available = Boolean(view && command && (pressed || command(view.state, null, view))) setProp(el, 'disabled', !available) } } return { sync } }