@divlet/web 0.0.0-stage → 0.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/README.md +12 -2
  2. package/dist/LICENSE +21 -0
  3. package/dist/board.css +950 -0
  4. package/dist/board.js +427 -0
  5. package/dist/index.d.ts +4 -0
  6. package/dist/index.html +73 -0
  7. package/dist/index.js +3 -0
  8. package/dist/lib/actions.js +472 -0
  9. package/dist/lib/activity.js +311 -0
  10. package/dist/lib/artboards.js +56 -0
  11. package/dist/lib/ask.js +350 -0
  12. package/dist/lib/attrs.js +144 -0
  13. package/dist/lib/board-host.d.ts +14 -0
  14. package/dist/lib/board-host.js +2 -0
  15. package/dist/lib/board-session.d.ts +53 -0
  16. package/dist/lib/board-session.js +24 -0
  17. package/dist/lib/canvas.js +1594 -0
  18. package/dist/lib/chat.js +156 -0
  19. package/dist/lib/clip.js +231 -0
  20. package/dist/lib/crumbbar.js +56 -0
  21. package/dist/lib/crumbs.js +63 -0
  22. package/dist/lib/css.js +238 -0
  23. package/dist/lib/expandbar.js +64 -0
  24. package/dist/lib/file-picker.js +103 -0
  25. package/dist/lib/help.js +273 -0
  26. package/dist/lib/host.js +16 -0
  27. package/dist/lib/http-services.js +24 -0
  28. package/dist/lib/http-session.js +45 -0
  29. package/dist/lib/http.js +17 -0
  30. package/dist/lib/inspector.js +857 -0
  31. package/dist/lib/layers.js +298 -0
  32. package/dist/lib/menu.js +119 -0
  33. package/dist/lib/palette.js +292 -0
  34. package/dist/lib/panels.js +27 -0
  35. package/dist/lib/project.js +188 -0
  36. package/dist/lib/resize.js +114 -0
  37. package/dist/lib/shortcuts.js +91 -0
  38. package/dist/lib/sidebar.js +85 -0
  39. package/dist/lib/store.js +249 -0
  40. package/dist/lib/suggest.js +130 -0
  41. package/dist/lib/toolbar.js +54 -0
  42. package/dist/lib/typography.js +120 -0
  43. package/dist/lib/util.js +177 -0
  44. package/package.json +37 -4
@@ -0,0 +1,298 @@
1
+ import { $, el, icon, button, pressGuard } from './util.js';
2
+ import { store, on, documents, loadModel, cachedModel, edit, selectArtboards, selectNodes, parentMap, canContain, textChild } from './store.js';
3
+ import { reveal, postToFrame } from './canvas.js';
4
+ import { insertMenu, artboardMenu, nodeMenu, newArtboard, renameLayer } from './actions.js';
5
+ import * as sidebar from './sidebar.js';
6
+
7
+ const panel = $('#layers');
8
+ const collapsed = new Set();
9
+ let dragged = null;
10
+ let token = 0;
11
+ let renaming = null;
12
+ let lastSelectionKey = '';
13
+
14
+ export const isOpen = () => sidebar.isOpen('layers');
15
+
16
+ /**
17
+ * The Layers pane always has an artboard to show: opening it (or losing the selected artboard to an undo, a delete or another
18
+ * client) selects the one that was chosen before if it still exists, else the first artboard. Only a project without artboards
19
+ * has nothing selected here — that is the one honest empty state.
20
+ */
21
+ let lastChosen = null;
22
+ const keepAnArtboardSelected = () => {
23
+ if (!isOpen()) return false;
24
+ const list = documents();
25
+ if (!list.length) { lastChosen = null; return false; }
26
+ if (store.selected.size) {
27
+ lastChosen = store.selected.has(lastChosen) ? lastChosen : [...store.selected][0];
28
+ return false;
29
+ }
30
+ const next = list.find(doc => doc.id === lastChosen) ?? list[0];
31
+ lastChosen = next.id;
32
+ selectArtboards([next.id]);
33
+ return true;
34
+ };
35
+ export function toggle(force) {
36
+ if (force === undefined) sidebar.toggle('layers');
37
+ else if (force) sidebar.show('layers');
38
+ else if (isOpen()) sidebar.close();
39
+ }
40
+
41
+ const label = (model, node) => {
42
+ const name = node.metadata?.name;
43
+ if (typeof name === 'string' && name.trim()) return { text: name.trim(), named: true };
44
+ if (node.type === 'ref') {
45
+ // An instance reads best by the text it overrides first ("Pro", "Sign up").
46
+ const first = Object.values(node.descendants || {}).map(item => item.content ?? item.text).find(value => typeof value === 'string' && value.trim());
47
+ return { text: first ? first.replace(/\s+/g, ' ').trim() : '', named: false };
48
+ }
49
+ const text = textChild(model, node);
50
+ return { text: text ? text.content.replace(/\s+/g, ' ').trim() : '', named: false };
51
+ };
52
+
53
+ function rowKeydown(event, row) {
54
+ const rows = [...panel.querySelectorAll('.row-item')];
55
+ const index = rows.indexOf(row);
56
+ const go = target => { if (!target) return; event.preventDefault(); target.focus(); target.click(); };
57
+ if (event.key === 'ArrowDown') { event.stopPropagation(); go(rows[index + 1]); }
58
+ else if (event.key === 'ArrowUp') { event.stopPropagation(); go(rows[index - 1]); }
59
+ else if (event.key === 'ArrowRight' || event.key === 'ArrowLeft') {
60
+ event.stopPropagation();
61
+ event.preventDefault();
62
+ const twist = row.querySelector('.twist:not(.none)');
63
+ const open = twist?.classList.contains('open');
64
+ if (event.key === 'ArrowRight' && twist && !open) twist.click();
65
+ else if (event.key === 'ArrowLeft' && twist && open) twist.click();
66
+ else if (event.key === 'ArrowRight') go(rows[index + 1]);
67
+ else if (row.dataset.parent) go(panel.querySelector(`.row-item[data-node-id="${CSS.escape(row.dataset.parent)}"]`));
68
+ else if (row.classList.contains('node')) go(panel.querySelector('.board.row-item.on, .board.row-item'));
69
+ } else if (event.key === 'F2' || (event.key === 'Enter' && event.altKey)) { event.preventDefault(); event.stopPropagation(); row.dispatchEvent(new MouseEvent('dblclick')); }
70
+ }
71
+
72
+ function inlineRename(host, value, save) {
73
+ renaming = host;
74
+ const input = el('input', 'row-rename');
75
+ input.value = value;
76
+ input.setAttribute('aria-label', 'Name');
77
+ host.replaceChildren(input);
78
+ input.focus();
79
+ input.select();
80
+ let finished = false;
81
+ const done = commitIt => {
82
+ if (finished) return;
83
+ finished = true;
84
+ renaming = null;
85
+ if (commitIt && input.value.trim() !== value) save(input.value.trim());
86
+ render();
87
+ };
88
+ input.addEventListener('keydown', event => {
89
+ event.stopPropagation();
90
+ if (event.key === 'Enter') done(true);
91
+ else if (event.key === 'Escape') done(false);
92
+ });
93
+ input.addEventListener('blur', () => done(true));
94
+ input.addEventListener('click', event => event.stopPropagation());
95
+ }
96
+
97
+ function tree(doc, model) {
98
+ const list = el('div', 'tree');
99
+ list.setAttribute('role', 'group');
100
+ const selectedNodes = store.node?.documentId === doc.id ? new Set(store.node.nodeIds) : new Set();
101
+ const parents = parentMap(model);
102
+ const ancestors = new Set();
103
+ for (const id of selectedNodes) for (let p = parents.get(id); p; p = parents.get(p)) ancestors.add(p);
104
+ const walk = (id, depth) => {
105
+ const node = model.nodes[id];
106
+ if (!node) return;
107
+ const textOnly = !!textChild(model, node) && node.type === 'element';
108
+ const hasKids = node.type === 'element' && node.children.length > 0 && !textOnly;
109
+ const isCollapsed = hasKids && collapsed.has(`${doc.id}/${id}`) && !ancestors.has(id);
110
+ const line = el('div', `node row-item${selectedNodes.has(id) ? ' on' : ''}`);
111
+ line.style.setProperty('--depth', String(depth));
112
+ line.dataset.nodeId = id;
113
+ line.dataset.documentId = doc.id;
114
+ if (parents.get(id)) line.dataset.parent = parents.get(id);
115
+ line.tabIndex = selectedNodes.has(id) ? 0 : -1;
116
+ line.setAttribute('role', 'treeitem');
117
+ line.setAttribute('aria-selected', String(selectedNodes.has(id)));
118
+ if (hasKids) line.setAttribute('aria-expanded', String(!isCollapsed));
119
+ line.draggable = id !== model.rootId;
120
+ const twist = el('span', `twist${hasKids ? (isCollapsed ? '' : ' open') : ' none'}`);
121
+ if (hasKids) {
122
+ twist.append(icon('chevron'));
123
+ twist.addEventListener('click', event => { event.stopPropagation(); const key = `${doc.id}/${id}`; if (collapsed.has(key)) collapsed.delete(key); else collapsed.add(key); render(); });
124
+ }
125
+ const caption = label(model, node);
126
+ const nameHost = el('span', `snip${caption.named ? ' named' : ''}`, caption.text);
127
+ const tagName = el('span', `tagname${node.type === 'text' ? ' is-text' : node.type === 'ref' ? ' is-instance' : ''}`, node.type === 'text' ? 'T' : node.type === 'ref' ? '' : node.tag);
128
+ if (node.type === 'ref') tagName.append(icon('instance'), document.createTextNode(node.ref.split('/').pop()));
129
+ line.append(twist, tagName, nameHost);
130
+ if (node.type === 'element' && node.reusable) { const mark = icon('component', 'icon component-dot'); line.append(mark); line.classList.add('is-component'); }
131
+ line.dataset.tip = node.type === 'ref' ? `${node.id} · instance of ${node.ref}` : node.reusable ? `${node.id} · component` : node.id;
132
+ line.addEventListener('click', event => {
133
+ if (renaming) return;
134
+ const current = store.node?.documentId === doc.id ? store.node.nodeIds : [];
135
+ const additive = event.shiftKey || event.metaKey || event.ctrlKey;
136
+ selectNodes(doc.id, additive ? (current.includes(id) ? current.filter(item => item !== id) : [...current, id]) : [id], { reveal: true });
137
+ });
138
+ line.addEventListener('dblclick', event => {
139
+ event.stopPropagation();
140
+ if (node.type === 'text') return;
141
+ inlineRename(nameHost, typeof node.metadata?.name === 'string' ? node.metadata.name : caption.text, value => renameLayer(doc.id, id, value));
142
+ });
143
+ line.addEventListener('contextmenu', event => {
144
+ event.preventDefault();
145
+ if (!selectedNodes.has(id)) selectNodes(doc.id, [id], { reveal: true });
146
+ nodeMenu(doc.id, { x: event.clientX, y: event.clientY });
147
+ });
148
+ line.addEventListener('keydown', event => rowKeydown(event, line));
149
+ line.addEventListener('mouseenter', () => postToFrame(doc.id, { type: 'divlet:hover', nodeId: id }));
150
+ line.addEventListener('mouseleave', () => postToFrame(doc.id, { type: 'divlet:hover', nodeId: null }));
151
+ line.addEventListener('dragstart', event => { dragged = { documentId: doc.id, id }; event.dataTransfer.effectAllowed = 'move'; event.dataTransfer.setData('text/plain', id); line.classList.add('dragging'); });
152
+ line.addEventListener('dragend', () => { dragged = null; line.classList.remove('dragging'); clearDrop(); if (pending) schedule(); });
153
+ line.addEventListener('dragover', event => {
154
+ if (!dragged || dragged.documentId !== doc.id || dragged.id === id) return;
155
+ const zone = dropZone(event, line, node, model);
156
+ if (!zone) return;
157
+ event.preventDefault();
158
+ clearDrop();
159
+ line.classList.add(`drop-${zone}`);
160
+ });
161
+ line.addEventListener('dragleave', () => line.classList.remove('drop-before', 'drop-after', 'drop-inside'));
162
+ line.addEventListener('drop', event => {
163
+ event.preventDefault();
164
+ clearDrop();
165
+ if (!dragged || dragged.documentId !== doc.id || dragged.id === id) return;
166
+ const zone = dropZone(event, line, node, model);
167
+ if (zone) dropNode(doc.id, model, dragged.id, id, zone);
168
+ dragged = null;
169
+ });
170
+ list.append(line);
171
+ if (hasKids && !isCollapsed) for (const child of node.children) walk(child, depth + 1);
172
+ };
173
+ walk(model.rootId, 0);
174
+ return list;
175
+ }
176
+
177
+ function dropZone(event, line, node, model) {
178
+ const rect = line.getBoundingClientRect();
179
+ const y = (event.clientY - rect.top) / rect.height;
180
+ if (canContain(node) && !textChild(model, node) && y > 0.28 && y < 0.72) return 'inside';
181
+ if (node.id === model.rootId) return canContain(node) ? 'inside' : null;
182
+ return y < 0.5 ? 'before' : 'after';
183
+ }
184
+
185
+ function clearDrop() {
186
+ for (const node of panel.querySelectorAll('.drop-before, .drop-after, .drop-inside')) node.classList.remove('drop-before', 'drop-after', 'drop-inside');
187
+ }
188
+
189
+ function dropNode(documentId, model, id, targetId, zone) {
190
+ const op = zone === 'inside' ? { type: 'move', id, parentId: targetId } : { type: 'move', id, [zone]: targetId };
191
+ edit(documentId, op, { label: `move ${id}` }).then(() => selectNodes(documentId, [id])).catch(() => {});
192
+ }
193
+
194
+ /** One artboard in the list on top: a name, lit when selected. Choosing it draws its tree below. */
195
+ function boardRow(doc, model) {
196
+ const item = el('div', `board row-item${store.selected.has(doc.id) ? ' on' : ''}`);
197
+ item.dataset.documentId = doc.id;
198
+ item.tabIndex = store.selected.has(doc.id) && !store.node ? 0 : -1;
199
+ item.setAttribute('role', 'option');
200
+ item.setAttribute('aria-selected', String(store.selected.has(doc.id)));
201
+ item.append(icon('artboard'), el('span', 'name', doc.name || doc.id));
202
+ item.dataset.tip = `${doc.name || doc.id} · ${Math.round(doc.artboard.width)} × ${Math.round(doc.artboard.height)}`;
203
+ // Dropping a layer on the artboard row moves it into the root element.
204
+ if (model) {
205
+ item.addEventListener('dragover', event => {
206
+ if (!dragged || dragged.documentId !== doc.id) return;
207
+ event.preventDefault();
208
+ clearDrop();
209
+ item.classList.add('drop-inside');
210
+ });
211
+ item.addEventListener('dragleave', () => item.classList.remove('drop-inside'));
212
+ item.addEventListener('drop', event => {
213
+ event.preventDefault();
214
+ clearDrop();
215
+ if (dragged && dragged.documentId === doc.id) dropNode(doc.id, model, dragged.id, model.rootId, 'inside');
216
+ dragged = null;
217
+ });
218
+ }
219
+ item.addEventListener('click', event => {
220
+ if (renaming) return;
221
+ if (event.shiftKey || event.metaKey || event.ctrlKey) selectArtboards(store.selected.has(doc.id) ? [...store.selected].filter(id => id !== doc.id) : [doc.id], { additive: !store.selected.has(doc.id) });
222
+ else selectArtboards([doc.id]);
223
+ reveal(doc.id);
224
+ });
225
+ item.addEventListener('contextmenu', event => {
226
+ event.preventDefault();
227
+ if (!store.selected.has(doc.id)) selectArtboards([doc.id]);
228
+ artboardMenu(doc.id, { x: event.clientX, y: event.clientY });
229
+ });
230
+ item.addEventListener('keydown', event => rowKeydown(event, item));
231
+ return item;
232
+ }
233
+
234
+ let pending = false;
235
+ const pressing = pressGuard(panel, () => { if (pending) render(); });
236
+
237
+ export async function render() {
238
+ if (!isOpen() || renaming) return;
239
+ if (keepAnArtboardSelected()) return;
240
+ if (pressing() || dragged) { pending = true; return; }
241
+ pending = false;
242
+ const mine = ++token;
243
+ const single = store.selected.size === 1 ? [...store.selected][0] : null;
244
+ let model = null;
245
+ if (single) {
246
+ model = cachedModel(single);
247
+ if (!model) { try { model = await loadModel(single); } catch {} if (mine !== token) return; }
248
+ }
249
+ // Block 1: the artboards (a select-like list, about seven rows high); block 2: the tree of the chosen one.
250
+ const listHead = el('div', 'panel-head');
251
+ listHead.append(el('span', 'caption', 'Artboards'), el('span', 'grow'), button('artboard', 'New artboard · N', () => newArtboard(), 'mini'));
252
+ const list = el('div', 'board-list');
253
+ list.setAttribute('role', 'listbox');
254
+ list.setAttribute('aria-label', 'Artboards');
255
+ for (const doc of documents()) list.append(boardRow(doc, doc.id === single ? model : null));
256
+ if (!documents().length) list.append(el('div', 'quiet', 'No artboards yet'));
257
+ const treeHead = el('div', 'panel-head');
258
+ const add = button('plus', 'Add element', event => insertMenu(single, { anchor: event.currentTarget }), 'mini');
259
+ add.id = 'layers-add';
260
+ add.disabled = !single;
261
+ // The chosen artboard is already lit right above: naming it here again only costs a line of narrow panel. One word, and the
262
+ // tree below is unambiguous.
263
+ treeHead.append(el('span', 'caption', 'Elements'), el('span', 'grow'), add);
264
+ const body = el('div', 'panel-body');
265
+ body.setAttribute('role', 'tree');
266
+ body.setAttribute('aria-label', 'Elements');
267
+ if (single && model) body.append(tree(documents().find(doc => doc.id === single), model));
268
+ else body.append(el('div', 'quiet', documents().length ? (store.selected.size > 1 ? 'Select one artboard to see its elements' : 'Choose an artboard above, or on the canvas') : ''));
269
+ const focusKey = panel.contains(document.activeElement) && document.activeElement.classList.contains('row-item') ? 'row' : null;
270
+ const scroll = panel.querySelector('.panel-body')?.scrollTop ?? 0;
271
+ const listScroll = panel.querySelector('.board-list')?.scrollTop ?? 0;
272
+ panel.replaceChildren(listHead, list, treeHead, body);
273
+ body.scrollTop = scroll;
274
+ list.scrollTop = listScroll;
275
+ const selected = body.querySelector('.node.on') || list.querySelector('.board.on');
276
+ if (focusKey === 'row') (selected || body.querySelector('.row-item') || list.querySelector('.row-item'))?.focus({ preventScroll: true });
277
+ const selectionKey = `${[...store.selected].join(',')}|${store.node?.nodeIds.join(',') || ''}`;
278
+ if (selectionKey !== lastSelectionKey) {
279
+ // The chosen artboard comes to the top of its list when it is not in view; the chosen element scrolls into its tree.
280
+ const row = list.querySelector('.board.on');
281
+ if (row && (row.offsetTop < list.scrollTop || row.offsetTop + row.offsetHeight > list.scrollTop + list.clientHeight)) list.scrollTop = Math.max(0, row.offsetTop - 3);
282
+ body.querySelector('.node.on')?.scrollIntoView({ block: 'nearest' });
283
+ }
284
+ lastSelectionKey = selectionKey;
285
+ }
286
+
287
+ const schedule = (() => { let frame = 0; return () => { cancelAnimationFrame(frame); frame = requestAnimationFrame(render); }; })();
288
+ // The invariant holds the moment the selection changes, not one frame later: a cleared selection returns to the remembered
289
+ // artboard here, so nothing that reads the board between two frames can observe "nothing selected while Layers is open".
290
+ on('selection', () => { keepAnArtboardSelected(); schedule(); });
291
+ on('state', schedule);
292
+ on('model', schedule);
293
+ on('sidebar', ({ open, tab }) => { if (open && tab === 'layers') { keepAnArtboardSelected(); render(); } });
294
+ on('hover-node', hover => {
295
+ for (const row of panel.querySelectorAll('.node.hover')) row.classList.remove('hover');
296
+ if (!hover) return;
297
+ panel.querySelector(`.node[data-document-id="${CSS.escape(hover.documentId)}"][data-node-id="${CSS.escape(hover.nodeId)}"]`)?.classList.add('hover');
298
+ });
@@ -0,0 +1,119 @@
1
+ import { el, icon, clamp, uiScale } from './util.js';
2
+
3
+ /**
4
+ * One floating menu at a time: context menus, the insert menu and small pickers.
5
+ * Items: { label, icon?, keys?, run, disabled?, danger?, checked? } or '-' for a separator,
6
+ * or { heading } for a section title.
7
+ */
8
+ let current = null;
9
+
10
+ export const menuOpen = () => !!current;
11
+ export const menuAnchor = () => current?.anchor ?? null;
12
+
13
+ export function closeMenu({ restoreFocus = true } = {}) {
14
+ if (!current) return;
15
+ const { node, cleanup, returnFocus, anchor } = current;
16
+ current = null;
17
+ cleanup();
18
+ node.remove();
19
+ if (restoreFocus && returnFocus?.isConnected) returnFocus.focus({ preventScroll: true });
20
+ anchor?.dispatchEvent?.(new CustomEvent('divlet:menu-close', { bubbles: true }));
21
+ }
22
+
23
+ export function openMenu(items, { x, y, anchor, label = 'Menu', className = '' } = {}) {
24
+ // Pressing the control that opened the menu again closes it (no close-and-reopen flicker).
25
+ if (anchor && current?.anchor === anchor) { closeMenu(); return null; }
26
+ closeMenu({ restoreFocus: false });
27
+ const visible = items.filter(Boolean);
28
+ if (!visible.length) return null;
29
+ const node = el('div', `menu ${className}`.trim());
30
+ node.setAttribute('role', 'menu');
31
+ node.setAttribute('aria-label', label);
32
+ const buttons = [];
33
+ let previousSeparator = true;
34
+ for (const item of visible) {
35
+ if (item === '-') {
36
+ if (!previousSeparator) node.append(el('div', 'menu-sep'));
37
+ previousSeparator = true;
38
+ continue;
39
+ }
40
+ previousSeparator = false;
41
+ if (item.heading) { node.append(el('div', 'menu-heading', item.heading)); continue; }
42
+ const entry = el('button', `menu-item${item.danger ? ' danger' : ''}${item.checked ? ' checked' : ''}`);
43
+ entry.type = 'button';
44
+ entry.setAttribute('role', item.checked === undefined ? 'menuitem' : 'menuitemradio');
45
+ if (item.checked !== undefined) entry.setAttribute('aria-checked', String(!!item.checked));
46
+ entry.disabled = !!item.disabled;
47
+ entry.append(item.icon ? icon(item.icon) : el('span', 'icon-space'), el('span', 'menu-label', item.label));
48
+ if (item.keys) entry.append(el('span', 'menu-keys', item.keys));
49
+ if (item.hint) entry.title = item.hint;
50
+ entry.addEventListener('click', event => {
51
+ event.stopPropagation();
52
+ closeMenu();
53
+ try { const result = item.run?.(); result?.catch?.(() => {}); } catch (error) { console.error(error); }
54
+ });
55
+ node.append(entry);
56
+ buttons.push(entry);
57
+ }
58
+ if (node.lastElementChild?.classList.contains('menu-sep')) node.lastElementChild.remove();
59
+ document.body.append(node);
60
+
61
+ // Position next to the anchor or at the pointer, always inside the viewport.
62
+ const scale = uiScale();
63
+ const w = node.offsetWidth * scale;
64
+ const h = node.offsetHeight * scale;
65
+ let left = x ?? 0;
66
+ let top = y ?? 0;
67
+ if (anchor) {
68
+ const r = anchor.getBoundingClientRect();
69
+ left = r.left;
70
+ top = r.bottom + 6;
71
+ if (top + h > innerHeight - 8) top = r.top - h - 6;
72
+ } else if (top + h > innerHeight - 8) top = Math.max(8, top - h);
73
+ node.style.left = `${clamp(left, 8, innerWidth - w - 8)}px`;
74
+ node.style.top = `${clamp(top, 8, innerHeight - h - 8)}px`;
75
+
76
+ const enabled = () => buttons.filter(button => !button.disabled);
77
+ const move = step => {
78
+ const list = enabled();
79
+ if (!list.length) return;
80
+ const index = list.indexOf(document.activeElement);
81
+ list[(index + step + list.length) % list.length].focus();
82
+ };
83
+ const onKey = event => {
84
+ event.stopPropagation();
85
+ if (event.key === 'Escape') { event.preventDefault(); closeMenu(); }
86
+ else if (event.key === 'ArrowDown') { event.preventDefault(); move(1); }
87
+ else if (event.key === 'ArrowUp') { event.preventDefault(); move(-1); }
88
+ else if (event.key === 'Home') { event.preventDefault(); enabled()[0]?.focus(); }
89
+ else if (event.key === 'End') { event.preventDefault(); enabled().at(-1)?.focus(); }
90
+ else if (event.key === 'Tab') { event.preventDefault(); closeMenu(); }
91
+ else if (event.key.length === 1 && !event.ctrlKey && !event.metaKey) {
92
+ const match = enabled().find(button => button.textContent.trim().toLowerCase().startsWith(event.key.toLowerCase()));
93
+ match?.focus();
94
+ }
95
+ };
96
+ // A press on the anchor is left to its click, which toggles the menu; any other outside press dismisses it.
97
+ const outside = event => { if (!node.contains(event.target) && !anchor?.contains?.(event.target)) closeMenu({ restoreFocus: false }); };
98
+ const dismiss = event => { if (!(event?.type === 'wheel' && node.contains(event.target))) closeMenu({ restoreFocus: false }); };
99
+ node.addEventListener('keydown', onKey);
100
+ node.addEventListener('contextmenu', event => event.preventDefault());
101
+ const timer = setTimeout(() => document.addEventListener('pointerdown', outside, true), 0);
102
+ window.addEventListener('blur', dismiss);
103
+ window.addEventListener('resize', dismiss);
104
+ window.addEventListener('wheel', dismiss, { passive: true });
105
+ current = {
106
+ node,
107
+ anchor: anchor ?? null,
108
+ returnFocus: document.activeElement instanceof HTMLElement ? document.activeElement : null,
109
+ cleanup() {
110
+ clearTimeout(timer);
111
+ document.removeEventListener('pointerdown', outside, true);
112
+ window.removeEventListener('blur', dismiss);
113
+ window.removeEventListener('resize', dismiss);
114
+ window.removeEventListener('wheel', dismiss);
115
+ },
116
+ };
117
+ (enabled()[0] || node).focus({ preventScroll: true });
118
+ return node;
119
+ }