oficina / memoria Public

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

Plain git — no account needed to clone.

Download

Download this file

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