oficina / docs Public

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

Plain git — no account needed to clone.

Download

Download this file

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
49import { 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.
61const 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.
73function openMenu(root) {
74 return root.querySelector('[estilo-menubar-menu][open]')
75}
76
77function 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.
87function 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.
97function 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.
103function close(root) {
104 const menu = openMenu(root)
105 if (menu) menu.open = false
106}
107
108export 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