oficina / sheets Public

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

Plain git — no account needed to clone.

Download

Download this file

1/* Keyboard navigation for the menu bar.
2 *
3 * The menus are <details>/<summary>, not role="menubar". That is a
4 * decision, not a shortcut: a disclosure is a control browsers and
5 * screen readers already handle correctly, and it opens with scripting
6 * switched off. Claiming menubar semantics and then not delivering the
7 * whole pattern — typeahead, submenu roles, the lot — is worse than an
8 * honest disclosure. This module ADDS the arrow keys on top; nothing
9 * here is load-bearing for the menu working at all.
10 *
11 * estilo's chrome.js already closes the open menu on a click outside
12 * and on Escape, and returns focus to its title: every menu here is in
13 * the one estilo-menus group with the bar's own. None of that is
14 * repeated here — a second Escape handler would move focus twice.
15 *
16 * The one thing this module must not skip: stopping every key it
17 * consumes. Jspreadsheet binds arrow keys on `document` and acts
18 * whenever a worksheet is current, which it still is while the menu has
19 * focus — so without the stop, ArrowDown in an open menu also walks the
20 * cell cursor down the sheet.
21 */
22(function () {
23 'use strict';
24
25 var bar = document.querySelector('[data-menubar]');
26 if (!bar) return;
27
28 var menus = Array.prototype.slice.call(bar.children).filter(function (el) {
29 return el.tagName === 'DETAILS';
30 });
31 if (!menus.length) return;
32
33 function summaryOf(details) { return details.querySelector(':scope > summary'); }
34
35 /* Roving tabindex: the bar is ONE tab stop, not six. Tab should move
36 * past the menus to the toolbar, not walk through them — arrow keys
37 * are what move within a group, which is the whole convention this
38 * makes the bar follow. */
39 function rove(to) {
40 menus.forEach(function (d) {
41 var s = summaryOf(d);
42 if (s) s.tabIndex = (d === to ? 0 : -1);
43 });
44 }
45 rove(menus[0]);
46
47 /* items lists what is reachable inside an open menu, in visual order.
48 * Hidden items are excluded — several are hidden until commands.js
49 * wires them. There are no submenus: a nested <details> in a grouped
50 * one closes its parent (TestNoNestedExclusiveDetails). */
51 function items(details) {
52 var out = [];
53 var panel = details.querySelector(':scope > [estilo-menu]');
54 if (!panel) return out;
55 walk(panel);
56 return out;
57
58 function walk(node) {
59 Array.prototype.forEach.call(node.children, function (el) {
60 if (el.hidden || el.disabled || el.type === 'hidden') return;
61 if (el.tagName === 'FORM') { walk(el); return; }
62 if (el.tagName === 'A' || el.tagName === 'BUTTON' || el.tagName === 'INPUT') out.push(el);
63 else if (el.children.length) walk(el);
64 });
65 }
66 }
67
68 /* where is 'items', 'summary' or 'none'.
69 *
70 * Three and not a boolean because the pointer path needs the third:
71 * hovering across the bar must not move focus. A menu bar that
72 * focuses whatever the mouse passes over drags the keyboard around
73 * behind the cursor, and here it would also fire the focusout handler
74 * below on every menu the pointer crossed. */
75 function openMenu(details, where) {
76 if (!details) return;
77 menus.forEach(function (d) { if (d !== details) d.open = false; });
78 details.open = true;
79 rove(details);
80 if (where === 'none') return;
81 var list = where === 'items' ? items(details) : [];
82 if (list.length) list[0].focus();
83 else { var s = summaryOf(details); if (s) s.focus(); }
84 }
85
86 function step(from, delta) {
87 var i = menus.indexOf(from);
88 if (i < 0) return null;
89 return menus[(i + delta + menus.length) % menus.length];
90 }
91
92 function ownerMenu(el) {
93 for (var i = 0; i < menus.length; i++) {
94 if (menus[i].contains(el)) return menus[i];
95 }
96 return null;
97 }
98
99 bar.addEventListener('keydown', function (e) {
100 if (e.altKey || e.ctrlKey || e.metaKey) return;
101 var target = e.target;
102 var menu = ownerMenu(target);
103 if (!menu) return;
104 // Menu keys must not reach the grid, including native button activation.
105 e.stopPropagation();
106
107 // Resize fields own their cursor, number stepping, and form submission.
108 if (target.matches('input, textarea, select')) { e.stopPropagation(); return; }
109
110 var isTop = summaryOf(menu) === target;
111 var consumed = true;
112
113 switch (e.key) {
114 case 'ArrowDown':
115 if (isTop) openMenu(menu, 'items');
116 else move(menu, target, 1);
117 break;
118 case 'ArrowUp':
119 if (isTop) openMenu(menu, 'items');
120 else move(menu, target, -1);
121 break;
122 case 'ArrowRight':
123 openMenu(step(menu, 1), (!isTop || menu.open) ? 'items' : 'summary');
124 break;
125 case 'ArrowLeft':
126 openMenu(step(menu, -1), (!isTop || menu.open) ? 'items' : 'summary');
127 break;
128 case 'Home':
129 if (isTop) { consumed = false; break; }
130 move(menu, null, 1);
131 break;
132 case 'End':
133 if (isTop) { consumed = false; break; }
134 move(menu, null, -1);
135 break;
136 case 'Enter':
137 case ' ':
138 /* Only the top-level summary needs help here: <summary> toggles
139 * on Enter natively but not always on Space, and an item is an
140 * <a> or a <button>, which both do the right thing already. */
141 if (!isTop) { consumed = false; break; }
142 e.preventDefault();
143 openMenu(menu, 'items');
144 break;
145 default:
146 consumed = false;
147 }
148
149 if (consumed) {
150 e.preventDefault();
151 /* Jspreadsheet is listening on document. See the file comment. */
152 e.stopPropagation();
153 }
154 });
155
156 function move(menu, from, delta) {
157 var list = items(menu);
158 if (!list.length) return;
159 if (from === null) {
160 list[delta > 0 ? 0 : list.length - 1].focus();
161 return;
162 }
163 var i = list.indexOf(from);
164 if (i < 0) { list[0].focus(); return; }
165 list[(i + delta + list.length) % list.length].focus();
166 }
167
168 /* Tab out closes. focusout rather than blur, because blur does not
169 * bubble and the focus may be leaving from any depth. The relatedTarget
170 * check is what distinguishes "moved to another item in this menu"
171 * from "left the menu entirely". */
172 // WebKit does not focus buttons on pointer activation. Its mousedown can
173 // blur the summary with no relatedTarget, before click/submit happens.
174 // Keep the menu visible through that activation; otherwise its item vanishes
175 // before the click and native form actions (including New sheet) never run.
176 var pointerMenu = null;
177 document.addEventListener('pointerdown', function (e) {
178 pointerMenu = ownerMenu(e.target);
179 }, true);
180 document.addEventListener('pointerup', function () { pointerMenu = null; }, true);
181 document.addEventListener('pointercancel', function () { pointerMenu = null; }, true);
182
183 bar.addEventListener('focusout', function (e) {
184 var menu = ownerMenu(e.target);
185 if (!menu || !menu.open) return;
186 var to = e.relatedTarget;
187 if (!to && pointerMenu === menu) return;
188 if (to && menu.contains(to)) return;
189 menu.open = false;
190 });
191
192 /* Activating an item closes its menu and puts focus back on the grid.
193 *
194 * Two rules, both fixed and different on purpose: Escape returns focus
195 * to the summary, because you changed your mind and are still in the
196 * menu; activating returns it to the grid, because the thing you asked
197 * for happened to a cell and that is where you want to be next.
198 *
199 * Links are excluded — a link is navigating away, not activating. */
200 bar.addEventListener('click', function (e) {
201 var btn = e.target.closest('button[data-cmd]');
202 if (!btn) return;
203 var menu = ownerMenu(btn);
204 if (menu) menu.open = false;
205 if (window.SheetsGrid) window.SheetsGrid.focus();
206 });
207
208 /* Pointer hover switches between open menus, the way every menu bar
209 * does: open File, slide to Edit, Edit opens. Pointer only — on a
210 * touch screen "hover" is the tap that opened it, and this would
211 * reopen a menu the tap was closing.
212 *
213 * hoverOpened is not a nicety. Reaching for a menu with a mouse is
214 * ENTER THEN CLICK, and <summary>'s activation behaviour toggles: the
215 * hover opens it, the click that follows a millisecond later closes
216 * it again, and the menu bar appears not to work at all with a
217 * pointer while testing perfectly from the keyboard. It did exactly
218 * that, and only a real browser found it. So a click on the summary
219 * this handler just opened suppresses the native toggle once. */
220 var hoverOpened = null;
221
222 menus.forEach(function (d) {
223 var s = summaryOf(d);
224 if (!s) return;
225
226 s.addEventListener('pointerenter', function (e) {
227 if (e.pointerType !== 'mouse') return;
228 if (d.open) return;
229 if (!menus.some(function (m) { return m.open; })) return;
230 openMenu(d, 'none');
231 hoverOpened = d;
232 });
233
234 /* Leaving without clicking clears the suppression, so a later click
235 * on this summary closes the menu the way it should. */
236 s.addEventListener('pointerleave', function () {
237 if (hoverOpened === d) hoverOpened = null;
238 });
239
240 s.addEventListener('click', function (e) {
241 if (hoverOpened !== d) return;
242 hoverOpened = null;
243 e.preventDefault();
244 });
245 });
246}());
247