flexdesk 0.2.0 → 0.4.0

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 (58) hide show
  1. package/README.md +26 -0
  2. package/css/base.css +2243 -872
  3. package/css/flexdesk.css +1375 -14
  4. package/css/overrides.css +44 -0
  5. package/css/tokens.css +45 -0
  6. package/dist/charts.js +5 -3
  7. package/dist/charts.js.map +1 -1
  8. package/dist/{chunk-DVU44T77.js → chunk-ELXVW542.js} +196 -75
  9. package/dist/chunk-ELXVW542.js.map +7 -0
  10. package/dist/chunk-LH5TSOZW.js +1237 -0
  11. package/dist/chunk-LH5TSOZW.js.map +7 -0
  12. package/dist/{chunk-TLZUUFOE.js → chunk-O5OHMWBB.js} +10 -2
  13. package/dist/chunk-O5OHMWBB.js.map +7 -0
  14. package/dist/{chunk-CT4YXXLP.js → chunk-QIU5S2RU.js} +371 -73
  15. package/dist/chunk-QIU5S2RU.js.map +7 -0
  16. package/dist/chunk-QNQHQ24V.js +408 -0
  17. package/dist/chunk-QNQHQ24V.js.map +7 -0
  18. package/dist/{chunk-DRYCDMEG.js → chunk-XKDTIT4Q.js} +168 -12
  19. package/dist/chunk-XKDTIT4Q.js.map +7 -0
  20. package/dist/editor.js +3 -380
  21. package/dist/editor.js.map +3 -3
  22. package/dist/flexdesk.css +1375 -14
  23. package/dist/tiles.js +168 -41
  24. package/dist/tiles.js.map +2 -2
  25. package/dist/tokens.css +45 -0
  26. package/dist/widgets.js +44 -14
  27. package/dist/widgets.js.map +2 -2
  28. package/dist/wm.js +3140 -157
  29. package/dist/wm.js.map +4 -4
  30. package/package.json +3 -2
  31. package/src/charts/chart_types.js +167 -0
  32. package/src/charts/plotly_wrapper.js +178 -10
  33. package/src/editor/notebook_tab_bar.js +39 -3
  34. package/src/tiles/tile_base.js +143 -35
  35. package/src/tiles/tile_grid.js +52 -1
  36. package/src/tiling/command_palette.js +71 -18
  37. package/src/tiling/desktops.js +36 -12
  38. package/src/tiling/keymap.js +24 -4
  39. package/src/tiling/shell.js +156 -25
  40. package/src/tiling/tab_strip.js +184 -0
  41. package/src/tiling/tile_breadcrumb.js +34 -2
  42. package/src/tiling/tile_renderer.js +1386 -21
  43. package/src/tiling/tile_tab_menu.js +101 -0
  44. package/src/tiling/tile_tree.js +115 -11
  45. package/src/tiling/wm.js +2375 -84
  46. package/src/tiling/zoom.js +248 -0
  47. package/src/ui/components/action_dropdown.js +34 -3
  48. package/src/ui/components/autocomplete_field.js +65 -13
  49. package/src/ui/components/context_menu.js +79 -8
  50. package/src/ui/components/data_table.js +508 -84
  51. package/src/ui/components/managed_window.js +928 -36
  52. package/src/ui/components/modal.js +214 -8
  53. package/dist/chunk-CT4YXXLP.js.map +0 -7
  54. package/dist/chunk-DRYCDMEG.js.map +0 -7
  55. package/dist/chunk-DVU44T77.js.map +0 -7
  56. package/dist/chunk-TLZUUFOE.js.map +0 -7
  57. package/dist/chunk-UCJ2WD4D.js +0 -625
  58. package/dist/chunk-UCJ2WD4D.js.map +0 -7
@@ -0,0 +1,248 @@
1
+ /**
2
+ * zoom.js — the shell's content zoom: a − / track / + / readout control, and
3
+ * the one place that decides what "zoom the workspace" is allowed to touch.
4
+ *
5
+ * Opt-in, like every shell feature that changes what the user sees: an embedder
6
+ * that passes no `chrome.zoom` element gets no control and no scaling, so no
7
+ * existing consumer changes by upgrading. The control is Excel's — a continuous
8
+ * track with a detent-free middle, buttons either side that move in tens, and a
9
+ * readout that is itself the reset — because that is the shape people already
10
+ * know, and it is the one Tables shipped in its own status bar before this was
11
+ * lifted into the framework.
12
+ *
13
+ * ── WHAT IT SCALES, AND WHAT IT MUST NOT ───────────────────────────────────
14
+ *
15
+ * It scales CONTENT SURFACES and nothing a window is dragged across:
16
+ *
17
+ * .twm-leaf__body what a tile's content factory mounted into
18
+ * .twm-window-content what a promoted window's content mounted into
19
+ *
20
+ * and it does NOT scale the root, a leaf wrap, tile chrome, tab bars, or a
21
+ * window frame. That line is load-bearing rather than a matter of taste. CSS
22
+ * `zoom` establishes a scaled coordinate space, and FlexDesk's window drag,
23
+ * resize and snap all do arithmetic between the pointer (viewport pixels) and a
24
+ * window's `left`/`top` (the pixels of whatever contains it). A contained window
25
+ * (C21) lives in a LEAF WRAP and is re-parented to the ROOT for the length of a
26
+ * drag (R1); zoom either of those and every drag drifts by the zoom factor, and
27
+ * every C15 snap probe measures a tile in units the pointer is not in. Tile
28
+ * bodies and window content sit BELOW all of that geometry, so scaling them
29
+ * changes the text and none of the maths.
30
+ *
31
+ * ── HOW IT IS APPLIED ─────────────────────────────────────────────────────
32
+ *
33
+ * A CSS variable and a class, both on `root` — the element the embedder handed
34
+ * the shell. Never `document.body`, never a selector the framework did not
35
+ * author (the doctrine at the top of shell.js). Tiles and windows are created
36
+ * and destroyed long after any given change, so anything written element by
37
+ * element would have to be re-applied on every mount; a variable on the root is
38
+ * read by whatever exists at the time.
39
+ *
40
+ * The class is what keeps 100% free of any declaration. A rule that always said
41
+ * `zoom: var(--twm-zoom, 1)` would establish a scaled coordinate space even at
42
+ * 1, so at the default the class comes off and no `zoom` applies anywhere — an
43
+ * unzoomed shell lays out byte-for-byte as it did before this existed.
44
+ *
45
+ * A window an embedder mounts on `document.body` itself is outside the shell's
46
+ * root and therefore outside this: it is not the shell's to scale. Every window
47
+ * the WM promotes under `promoteInPlace` (C21) is inside the root, and so is
48
+ * every window for the duration of a drag.
49
+ *
50
+ * ── `zoom`, NOT `transform: scale()` ──────────────────────────────────────
51
+ *
52
+ * `zoom` reflows: text stays on the pixel grid, and a scroll container still
53
+ * measures the content it is scrolling. A transform would blur the text and
54
+ * leave the layout box at its old size, so a zoomed-in table would overflow a
55
+ * pane that did not know it had grown.
56
+ */
57
+
58
+ /** The range. Below 50% a data row stops being readable; above 200% a typical
59
+ * row no longer fits its own columns. */
60
+ export const ZOOM_MIN = 50;
61
+ export const ZOOM_MAX = 200;
62
+
63
+ /** The track's granularity — fine, because a control you have to aim is one
64
+ * people stop using. */
65
+ export const ZOOM_STEP = 5;
66
+
67
+ /** The BUTTONS' step: Excel's split, where the track is continuous and the − / +
68
+ * cover ground. Commensurate with ZOOM_STEP by construction, so a button press
69
+ * always lands on a notch and repeated presses cannot drift. */
70
+ export const ZOOM_NUDGE = 10;
71
+
72
+ /**
73
+ * Where the shell opens, and where a reset returns. Load-bearing: `applyZoom`
74
+ * removes the class at exactly this value, and that is what makes "unzoomed"
75
+ * mean "no declaration at all". Only true while the default IS 100.
76
+ */
77
+ export const ZOOM_DEFAULT = 100;
78
+
79
+ /** The logical key under the host's `state` capability. A per-person display
80
+ * preference, persisted the same way the desktops are. */
81
+ export const ZOOM_STATE_KEY = 'zoom';
82
+
83
+ /**
84
+ * A number from anywhere — a slider, a stored preference, a caller — as a zoom
85
+ * the shell will accept.
86
+ *
87
+ * `null`, `undefined` and `''` are "no value" and read as the default. They are
88
+ * checked BEFORE the cast because `Number()` turns both `null` and `''` into 0 —
89
+ * a perfectly finite number that would then clamp to the floor, so a host with
90
+ * no saved zoom, or a read that failed, would silently open the shell at 50%.
91
+ *
92
+ * It QUANTISES, which is what makes 100% reachable: the track is stepped, so a
93
+ * stored 97 has to land on a notch rather than sit between two where neither the
94
+ * track nor the buttons can leave it.
95
+ */
96
+ export function clampZoom(value) {
97
+ if (value == null || value === '') return ZOOM_DEFAULT;
98
+ const n = Number(value);
99
+ if (!Number.isFinite(n)) return ZOOM_DEFAULT;
100
+ const stepped = Math.round(n / ZOOM_STEP) * ZOOM_STEP;
101
+ return Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, stepped));
102
+ }
103
+
104
+ /**
105
+ * Apply one zoom to a shell root. Idempotent. Takes the ELEMENT, never finds it.
106
+ *
107
+ * @param {Element} root the shell's root
108
+ * @param {number} percent already clamped
109
+ */
110
+ export function applyZoom(root, percent) {
111
+ if (!root?.style) return;
112
+ root.style.setProperty('--twm-zoom', String(percent / 100));
113
+ root.classList.toggle('twm-zoomed', percent !== ZOOM_DEFAULT);
114
+ }
115
+
116
+ /**
117
+ * Paint the control into the element the embedder handed over, restore the saved
118
+ * zoom, and keep the root in step.
119
+ *
120
+ * @param {Element|null} hostEl where the control goes; absent → no control
121
+ * @param {object} opts
122
+ * @param {Element} opts.root the shell root the zoom applies to
123
+ * @param {object} [opts.host] the host port; its `state` persists the zoom
124
+ * @param {string} [opts.stateKey]
125
+ * @param {Function} [opts.onChange] `(percent) => void`, after every change
126
+ * @returns {{el: Element, get: () => number, set: (percent: number) => void,
127
+ * ready: Promise<number>, dispose: () => void} | null}
128
+ */
129
+ export function mountZoomControl(hostEl, { root, host = null, stateKey = ZOOM_STATE_KEY, onChange } = {}) {
130
+ if (!hostEl || !root) return null;
131
+
132
+ const doc = hostEl.ownerDocument;
133
+ let current = ZOOM_DEFAULT;
134
+ let disposed = false;
135
+
136
+ const el = doc.createElement('div');
137
+ el.className = 'twm-zoom';
138
+
139
+ const stepButton = (label, title, delta) => {
140
+ const b = doc.createElement('button');
141
+ b.type = 'button';
142
+ b.className = 'twm-zoom__step';
143
+ b.textContent = label;
144
+ b.title = title;
145
+ b.setAttribute('aria-label', title);
146
+ b.addEventListener('click', () => commit(clampZoom(current + delta)));
147
+ return b;
148
+ };
149
+
150
+ const slider = doc.createElement('input');
151
+ slider.type = 'range';
152
+ slider.className = 'twm-zoom__slider';
153
+ slider.min = String(ZOOM_MIN);
154
+ slider.max = String(ZOOM_MAX);
155
+ slider.step = String(ZOOM_STEP);
156
+ slider.value = String(ZOOM_DEFAULT);
157
+ slider.title = `Zoom the workspace, ${ZOOM_MIN}–${ZOOM_MAX}% — double-click to reset.`;
158
+ slider.setAttribute('aria-label', 'Zoom the workspace');
159
+ // `input`, NOT `change`: the readout has to follow the thumb while it is
160
+ // being dragged, or the number under the mouse is the number you left.
161
+ slider.addEventListener('input', () => commit(clampZoom(slider.value)));
162
+ // A double-click on the track resets. It arrives after two mousedowns that
163
+ // each set the value and fire `input`, so the honest description is "one real
164
+ // write, then the reset", and a brief jump to wherever you clicked is visible
165
+ // before it snaps back. Excel's track does exactly that. It must not be
166
+ // "fixed" by swallowing `input` — that is the event the drag is made of.
167
+ slider.addEventListener('dblclick', () => commit(ZOOM_DEFAULT));
168
+
169
+ // The readout IS the reset, where Excel puts it and where a hand already is.
170
+ // A separate "100%" button would be a fourth control in a bar 20px tall, and a
171
+ // percentage nobody can click answers the question while refusing the obvious
172
+ // next request.
173
+ const readout = doc.createElement('button');
174
+ readout.type = 'button';
175
+ readout.className = 'twm-zoom__value';
176
+ readout.title = `Back to ${ZOOM_DEFAULT}%`;
177
+ readout.addEventListener('click', () => commit(ZOOM_DEFAULT));
178
+
179
+ el.append(
180
+ stepButton('−', `Zoom out ${ZOOM_NUDGE}%`, -ZOOM_NUDGE),
181
+ slider,
182
+ stepButton('+', `Zoom in ${ZOOM_NUDGE}%`, ZOOM_NUDGE),
183
+ readout,
184
+ );
185
+ hostEl.appendChild(el);
186
+
187
+ /** Paint the control and the root. No persistence — used by the restore,
188
+ * where writing back what was just read would be a pointless round trip. */
189
+ const paint = (percent) => {
190
+ current = percent;
191
+ slider.value = String(percent);
192
+ readout.textContent = `${percent}%`;
193
+ applyZoom(root, percent);
194
+ };
195
+
196
+ // Debounced, because a drag fires `input` per pixel and each one would
197
+ // otherwise be a write through the host. A host without `state` is legal,
198
+ // exactly as it is for the desktops: the control still works, the zoom just
199
+ // does not survive a reload.
200
+ let saveTimer = 0;
201
+ const persist = (percent) => {
202
+ const state = host?.state;
203
+ if (!state) return;
204
+ clearTimeout(saveTimer);
205
+ saveTimer = setTimeout(() => {
206
+ Promise.resolve()
207
+ .then(() => state.write(stateKey, percent))
208
+ .catch((err) => console.warn('[zoom] save failed', err));
209
+ }, 400);
210
+ };
211
+
212
+ const commit = (percent) => {
213
+ if (disposed || percent === current) return;
214
+ paint(percent);
215
+ persist(percent);
216
+ try { onChange?.(percent); } catch (err) { console.warn('[zoom] onChange threw', err); }
217
+ };
218
+
219
+ paint(ZOOM_DEFAULT);
220
+
221
+ // Restored after the control is already on screen and usable: a zoom is not
222
+ // worth blocking a first paint on, and a failed read costs the default. The
223
+ // promise is returned so an embedder that DOES want to wait before mounting —
224
+ // to avoid content painting at 100% and then jumping — can.
225
+ const ready = Promise.resolve()
226
+ .then(() => host?.state?.read?.(stateKey))
227
+ .then((saved) => {
228
+ if (!disposed && saved != null) paint(clampZoom(saved));
229
+ return current;
230
+ })
231
+ .catch((err) => {
232
+ console.warn('[zoom] load failed', err);
233
+ return current;
234
+ });
235
+
236
+ return {
237
+ el,
238
+ get: () => current,
239
+ set: (percent) => commit(clampZoom(percent)),
240
+ ready,
241
+ dispose: () => {
242
+ disposed = true;
243
+ clearTimeout(saveTimer);
244
+ el.remove();
245
+ applyZoom(root, ZOOM_DEFAULT);
246
+ },
247
+ };
248
+ }
@@ -174,13 +174,27 @@ export class ActionDropdown {
174
174
  // Position against trigger
175
175
  ActionDropdown.position(this.trigger, this.menuEl);
176
176
 
177
- // Animate in
177
+ // Animate in.
178
+ //
179
+ // OPTIONAL CHAINING, AND IT IS LOAD-BEARING. This callback runs a frame
180
+ // after `open()` returned, and `destroy()` sets `this.menuEl = null`
181
+ // (see below) — so a dropdown that is opened and then destroyed inside
182
+ // one frame threw an uncaught `TypeError: Cannot read properties of
183
+ // null` out of an animation-frame callback, where no caller has a stack
184
+ // to catch it. That is not a hypothetical: it is what a user does every
185
+ // time they open a picker and then click something that unmounts the
186
+ // pane around it, and it was reproduced eighteen times in one run of a
187
+ // consumer's settings suite. Nothing is lost by skipping the class — the
188
+ // element it would have been added to no longer exists.
178
189
  requestAnimationFrame(() => {
179
- this.menuEl.classList.add('visible');
190
+ this.menuEl?.classList.add('visible');
180
191
  });
181
192
 
182
193
  // Update trigger state
183
194
  this.trigger?.classList.add('twm-is-open');
195
+ if (this.trigger?.hasAttribute('aria-expanded')) {
196
+ this.trigger.setAttribute('aria-expanded', 'true');
197
+ }
184
198
 
185
199
  // Add document listeners
186
200
  document.addEventListener('click', this._boundHandleDocumentClick, true);
@@ -204,8 +218,16 @@ export class ActionDropdown {
204
218
  this.menuEl.hidden = true;
205
219
  }
206
220
 
207
- // Update trigger state
221
+ // Update trigger state. `aria-expanded` belongs on the TRIGGER and has
222
+ // to be written on every close, not only on the ones a click caused —
223
+ // an embedder that synced it from its own click handler was announcing
224
+ // an expanded menu to a screen reader every time Escape or an outside
225
+ // click dismissed one. The component knows when it closed; nothing else
226
+ // reliably does.
208
227
  this.trigger?.classList.remove('twm-is-open');
228
+ if (this.trigger?.hasAttribute('aria-expanded')) {
229
+ this.trigger.setAttribute('aria-expanded', 'false');
230
+ }
209
231
 
210
232
  // Remove document listeners
211
233
  document.removeEventListener('click', this._boundHandleDocumentClick, true);
@@ -235,6 +257,15 @@ export class ActionDropdown {
235
257
  */
236
258
  _handleKeydown(e) {
237
259
  if (e.key === 'Escape') {
260
+ // AND NOBODY ELSE GETS IT. An open dropdown is the innermost thing
261
+ // on screen, so Escape means "close this" and nothing further —
262
+ // but the event was left to bubble, and inside a `ManagedWindow`
263
+ // (which binds its own Escape to dismiss) that meant one keystroke
264
+ // closed the dropdown AND the dialog around it. The user loses a
265
+ // form they were filling in because they changed their mind about
266
+ // one field.
267
+ e.preventDefault();
268
+ e.stopPropagation();
238
269
  this.close();
239
270
  this.trigger?.focus();
240
271
  return;
@@ -80,6 +80,10 @@ export class AutocompleteField {
80
80
  this._activeIndex = -1;
81
81
  this._isOpen = false;
82
82
  this._disposers = [];
83
+ // Every call to the provider is numbered. See `#updateSuggestions`: a
84
+ // provider that answers over the network answers out of order, and the
85
+ // reply to "No" must not be allowed to overwrite the reply to "North".
86
+ this._suggestSeq = 0;
83
87
  }
84
88
 
85
89
  getValue() {
@@ -431,24 +435,68 @@ export class AutocompleteField {
431
435
  #updateSuggestions() {
432
436
  // Pass the fragment (variable part) for filtering, not the full composed value
433
437
  const fragment = this.#getInputFragment();
438
+
439
+ // A PROVIDER MAY ANSWER LATER THAN IT WAS ASKED.
440
+ //
441
+ // This used to require the answer synchronously — `provider(...) || []`
442
+ // straight into `Array.isArray`, so a provider that returned a promise
443
+ // produced an empty list and a dropdown that closed on every keystroke.
444
+ // That ruled out the entire class of completion source that lives on a
445
+ // server, which is most of them outside a single-page simulation: a
446
+ // table's rows, a query's result set, an index's matches.
447
+ //
448
+ // A provider that answers synchronously still takes the synchronous
449
+ // path, unchanged and untouched, so nothing that works today changes.
450
+ // A provider that hands back a thenable is awaited, and the reply is
451
+ // used only if it is the reply to the most recent question — typing
452
+ // "North" fires five requests and the network is free to answer them in
453
+ // any order, so without the sequence number the list can settle on the
454
+ // matches for "Nor".
455
+ const request = ++this._suggestSeq;
456
+ let produced;
434
457
  try {
435
- const items = this.provider({
436
- value: fragment,
437
- scope: this.scope,
438
- namespace: this.selectedNamespace
439
- }) || [];
440
- this._items = Array.isArray(items) ? items : [];
441
- this._activeIndex = items.length > 0 ? 0 : -1;
442
- this.#renderDropdown();
443
- if (items.length > 0) {
444
- this.#open();
445
- } else {
446
- this.#close();
447
- }
458
+ produced = this.provider({
459
+ value: fragment,
460
+ scope: this.scope,
461
+ namespace: this.selectedNamespace
462
+ });
448
463
  } catch (err) {
449
464
  this.logger?.warn?.('autocomplete', 'Provider error', { err });
450
465
  this._items = [];
451
466
  this.#close();
467
+ return;
468
+ }
469
+
470
+ if (produced && typeof produced.then === 'function') {
471
+ produced.then(
472
+ (items) => {
473
+ if (request !== this._suggestSeq) return;
474
+ this.#applySuggestions(items);
475
+ },
476
+ (err) => {
477
+ this.logger?.warn?.('autocomplete', 'Provider error', { err });
478
+ if (request !== this._suggestSeq) return;
479
+ this._items = [];
480
+ this.#close();
481
+ }
482
+ );
483
+ return;
484
+ }
485
+
486
+ this.#applySuggestions(produced);
487
+ }
488
+
489
+ /** Draw whatever the provider produced. Split out of `#updateSuggestions`
490
+ * so the synchronous and the awaited paths cannot drift apart. */
491
+ #applySuggestions(produced) {
492
+ const items = Array.isArray(produced) ? produced : [];
493
+ this._items = items;
494
+ this._activeIndex = items.length > 0 ? 0 : -1;
495
+ this.#renderDropdown();
496
+ if (items.length > 0) {
497
+ this.#open();
498
+ } else {
499
+ this.#close();
452
500
  }
453
501
  }
454
502
 
@@ -714,6 +762,10 @@ export class AutocompleteField {
714
762
  }
715
763
 
716
764
  dispose() {
765
+ // Retire the outstanding question first. An awaited provider can answer
766
+ // after the field is gone, and `#applySuggestions` would then paint into
767
+ // a dropdown that has been removed and nulled.
768
+ this._suggestSeq += 1;
717
769
  // Close dropdown before cleanup
718
770
  this.#close();
719
771
  this._disposers.forEach((fn) => {
@@ -10,28 +10,65 @@
10
10
  * ], (action) => { ... });
11
11
  *
12
12
  * The menu auto-closes on outside click / scroll / Escape.
13
+ *
14
+ * ── It is operable by keyboard ────────────────────────────────────────
15
+ * Items were bare `<div>`s with a click listener: no role, no tab stop, no
16
+ * arrow-key movement, and `showContextMenu` never moved focus into the menu.
17
+ * A consumer that opened this from Shift+F10 — the rail in Tables does — put a
18
+ * menu on screen that the keyboard could only dismiss. Every command in it was
19
+ * unreachable without a pointer, and nothing said so.
20
+ *
21
+ * So an item is a `<button role="menuitem">`, the first enabled one takes focus
22
+ * when the menu opens, Up/Down/Home/End move between them and wrap, Enter and
23
+ * Space activate, Escape closes, and focus returns to whatever had it before.
24
+ * Disabled items are skipped by the arrows rather than focusable-but-inert.
25
+ * `<button>` rather than a div with `tabindex`: the browser then gives Enter and
26
+ * Space for free and screen readers announce it without further help.
13
27
  */
14
28
 
29
+ import { modalHost } from './modal.js';
30
+
15
31
  let _activeMenu = null;
32
+ let _returnFocusTo = null;
16
33
 
17
34
  export function showContextMenu(x, y, items, onAction) {
18
35
  hideContextMenu();
19
36
 
37
+ // Whatever had focus when the menu opened gets it back when the menu
38
+ // closes. Without this a keyboard user who presses Escape is returned to
39
+ // `document.body` and has to tab back to where they were.
40
+ _returnFocusTo = document.activeElement;
41
+
20
42
  const menu = document.createElement('div');
21
43
  menu.className = 'twm-context-menu ea-context-menu';
44
+ menu.setAttribute('role', 'menu');
22
45
 
23
46
  for (const it of items) {
24
47
  if (it.separator) {
25
48
  const sep = document.createElement('div');
26
49
  sep.className = 'twm-context-menu__separator';
50
+ sep.setAttribute('role', 'separator');
27
51
  menu.appendChild(sep);
28
52
  continue;
29
53
  }
30
- const row = document.createElement('div');
54
+ const row = document.createElement('button');
55
+ row.type = 'button';
56
+ row.setAttribute('role', 'menuitem');
31
57
  let cls = 'twm-context-menu-item';
32
58
  if (it.danger) cls += ' twm-delete-node';
33
59
  if (it.disabled) cls += ' disabled';
34
60
  row.className = cls;
61
+ if (it.disabled) {
62
+ row.disabled = true;
63
+ row.setAttribute('aria-disabled', 'true');
64
+ }
65
+ // A DISABLED ROW WITH NO EXPLANATION IS A DEAD CONTROL. `title` was
66
+ // accepted by callers and rendered by nothing — the item was built with
67
+ // one, the row silently dropped it, and the user got a greyed line with
68
+ // no way to learn why. It is the same shape as the classes this
69
+ // repository keeps finding: no error, no throw, and invisible to every
70
+ // test that checks the row is disabled.
71
+ if (it.title) row.title = it.title;
35
72
  row.innerHTML = `
36
73
  <span class="material-symbols-outlined">${it.icon || ''}</span>
37
74
  <span>${escapeHtml(it.label)}</span>
@@ -46,7 +83,12 @@ export function showContextMenu(x, y, items, onAction) {
46
83
  menu.appendChild(row);
47
84
  }
48
85
 
49
- document.body.appendChild(menu);
86
+ // C25. THE SAME QUESTION A MODAL ASKS: which window is the user in? A
87
+ // consumer spanning two browser windows sets the host when its focus moves;
88
+ // null — every consumer today — is `document.body`, exactly as before. A
89
+ // menu in the wrong window is worse than a modal in the wrong window,
90
+ // because it is positioned at coordinates from the OTHER one.
91
+ (modalHost() || document.body).appendChild(menu);
50
92
  menu.style.display = 'block';
51
93
  _activeMenu = menu;
52
94
 
@@ -61,23 +103,52 @@ export function showContextMenu(x, y, items, onAction) {
61
103
  setTimeout(() => {
62
104
  document.addEventListener('mousedown', _outsideHandler, { once: true, capture: true });
63
105
  }, 0);
64
- document.addEventListener('keydown', _escHandler);
106
+ document.addEventListener('keydown', _keyHandler);
65
107
  window.addEventListener('scroll', hideContextMenu, { once: true, capture: true });
108
+
109
+ // Focus the first item the keyboard can actually use. `preventScroll` so a
110
+ // menu opened near the bottom of a long page does not jump it.
111
+ _enabledItems(menu)[0]?.focus({ preventScroll: true });
66
112
  }
67
113
 
68
114
  export function hideContextMenu() {
69
115
  if (!_activeMenu) return;
116
+ const returnTo = _returnFocusTo;
117
+ const held = _activeMenu.contains(document.activeElement);
70
118
  _activeMenu.remove();
71
119
  _activeMenu = null;
72
- document.removeEventListener('keydown', _escHandler);
120
+ _returnFocusTo = null;
121
+ document.removeEventListener('keydown', _keyHandler);
122
+ // Only take focus back if the menu still had it. A click elsewhere has
123
+ // already moved focus deliberately and must not be undone.
124
+ if (held && returnTo?.isConnected) returnTo.focus?.({ preventScroll: true });
73
125
  }
74
126
 
75
- function _outsideHandler(e) {
76
- if (_activeMenu && !_activeMenu.contains(e.target)) hideContextMenu();
127
+ function _enabledItems(menu) {
128
+ return [...menu.querySelectorAll('.twm-context-menu-item:not(.disabled)')];
129
+ }
130
+
131
+ /** Up/Down/Home/End move; the list WRAPS, which is what a menu of four items
132
+ * wants and what every desktop menu does. Enter and Space are the button's
133
+ * own, so they are not bound here. */
134
+ function _keyHandler(e) {
135
+ if (!_activeMenu) return;
136
+ if (e.key === 'Escape') { e.preventDefault(); hideContextMenu(); return; }
137
+ const items = _enabledItems(_activeMenu);
138
+ if (items.length === 0) return;
139
+ const at = items.indexOf(document.activeElement);
140
+ let next = null;
141
+ if (e.key === 'ArrowDown') next = items[(at + 1 + items.length) % items.length];
142
+ else if (e.key === 'ArrowUp') next = items[(at - 1 + items.length) % items.length];
143
+ else if (e.key === 'Home') next = items[0];
144
+ else if (e.key === 'End') next = items[items.length - 1];
145
+ if (!next) return;
146
+ e.preventDefault();
147
+ next.focus({ preventScroll: true });
77
148
  }
78
149
 
79
- function _escHandler(e) {
80
- if (e.key === 'Escape') hideContextMenu();
150
+ function _outsideHandler(e) {
151
+ if (_activeMenu && !_activeMenu.contains(e.target)) hideContextMenu();
81
152
  }
82
153
 
83
154
  function escapeHtml(s) {