| 1 | // chrome/chrome.js: optional behaviour for estilo-bar and the one top-level |
| 2 | // menu group. Load it with <script defer>. Everything already works |
| 3 | // without it; this only adds what <details> and the platform cannot do for |
| 4 | // themselves: |
| 5 | // |
| 6 | // - Escape closes the open member of the group (name="estilo-menus": the |
| 7 | // bar's Menu, switcher and account, and every menubar menu) and returns |
| 8 | // focus to its title; |
| 9 | // - a click outside the open member closes it; |
| 10 | // - Escape in the editable title restores the saved value and leaves |
| 11 | // the field; |
| 12 | // - a row or button that opens a dialog (command="show-modal") closes |
| 13 | // the menu it sat in, returns focus to that menu's title when the |
| 14 | // dialog closes, and opens the dialog itself on an engine without |
| 15 | // invoker commands. |
| 16 | // |
| 17 | // The group is one group in every app, so closing it is estilo's job, not |
| 18 | // each app's: Sheets' menubar lost Escape and outside-click entirely when |
| 19 | // it left rastrillo's dropdown. |
| 20 | // |
| 21 | // Both listen in the capture phase. A document app has to stop key |
| 22 | // events from propagating out of its menus — Sheets' grid library acts on |
| 23 | // every key it hears on document, so an arrow in an open menu would walk |
| 24 | // the cell cursor too — and a bubble-phase listener on document never |
| 25 | // hears the Escape such an app stops. Capture hears it first. An app that |
| 26 | // closes its own menu on Escape (Docs' menubar) finds nothing open and |
| 27 | // does nothing, so nothing is handled twice. |
| 28 | ;(() => { |
| 29 | const group = 'details[name="estilo-menus"][open]' |
| 30 | const openPanel = () => document.querySelector(group) |
| 31 | |
| 32 | // Focus returns to a menu's title; in a menubar with a roving tab stop |
| 33 | // (one title tabbable, the rest tabindex=-1) the stop moves with it, so |
| 34 | // Tab then Shift+Tab comes back to the menu you were in, not to File |
| 35 | // (Astra, estilo #11). A menubar without one is left without one. |
| 36 | const returnTo = (title) => { |
| 37 | title.focus() |
| 38 | const bar = title.closest('[estilo-menubar]') |
| 39 | if (!bar) return |
| 40 | const titles = bar.querySelectorAll('[estilo-menubar-title]') |
| 41 | if (!Array.prototype.some.call(titles, (t) => t.hasAttribute('tabindex'))) return |
| 42 | Array.prototype.forEach.call(titles, (t) => { t.tabIndex = t === title ? 0 : -1 }) |
| 43 | } |
| 44 | |
| 45 | document.addEventListener('keydown', (e) => { |
| 46 | if (e.key !== 'Escape' || e.defaultPrevented) return |
| 47 | const input = e.target.closest && e.target.closest('[estilo-bar-title-input]') |
| 48 | if (input) { |
| 49 | input.value = input.defaultValue |
| 50 | input.blur() |
| 51 | e.preventDefault() |
| 52 | return |
| 53 | } |
| 54 | const open = openPanel() |
| 55 | if (!open) return |
| 56 | open.open = false |
| 57 | const summary = open.querySelector('summary') |
| 58 | if (summary) returnTo(summary) |
| 59 | e.preventDefault() |
| 60 | }, true) |
| 61 | |
| 62 | document.addEventListener('click', (e) => { |
| 63 | const open = openPanel() |
| 64 | if (open && !open.contains(e.target)) open.open = false |
| 65 | }, true) |
| 66 | |
| 67 | const invokers = typeof HTMLButtonElement !== 'undefined' && 'command' in HTMLButtonElement.prototype |
| 68 | document.addEventListener('click', (e) => { |
| 69 | const button = e.target.closest && e.target.closest('button[commandfor][command="show-modal"]') |
| 70 | if (!button || button.disabled) return |
| 71 | const dialog = document.getElementById(button.getAttribute('commandfor')) |
| 72 | const holder = button.closest('details[name="estilo-menus"]') |
| 73 | if (holder) { |
| 74 | holder.open = false |
| 75 | const summary = holder.querySelector('summary') |
| 76 | if (dialog && summary) dialog.addEventListener('close', () => returnTo(summary), { once: true }) |
| 77 | } |
| 78 | if (invokers || !dialog || typeof dialog.showModal !== 'function' || dialog.open) return |
| 79 | dialog.showModal() |
| 80 | e.preventDefault() |
| 81 | }) |
| 82 | })() |
| 83 | |