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.
- package/README.md +26 -0
- package/css/base.css +2243 -872
- package/css/flexdesk.css +1375 -14
- package/css/overrides.css +44 -0
- package/css/tokens.css +45 -0
- package/dist/charts.js +5 -3
- package/dist/charts.js.map +1 -1
- package/dist/{chunk-DVU44T77.js → chunk-ELXVW542.js} +196 -75
- package/dist/chunk-ELXVW542.js.map +7 -0
- package/dist/chunk-LH5TSOZW.js +1237 -0
- package/dist/chunk-LH5TSOZW.js.map +7 -0
- package/dist/{chunk-TLZUUFOE.js → chunk-O5OHMWBB.js} +10 -2
- package/dist/chunk-O5OHMWBB.js.map +7 -0
- package/dist/{chunk-CT4YXXLP.js → chunk-QIU5S2RU.js} +371 -73
- package/dist/chunk-QIU5S2RU.js.map +7 -0
- package/dist/chunk-QNQHQ24V.js +408 -0
- package/dist/chunk-QNQHQ24V.js.map +7 -0
- package/dist/{chunk-DRYCDMEG.js → chunk-XKDTIT4Q.js} +168 -12
- package/dist/chunk-XKDTIT4Q.js.map +7 -0
- package/dist/editor.js +3 -380
- package/dist/editor.js.map +3 -3
- package/dist/flexdesk.css +1375 -14
- package/dist/tiles.js +168 -41
- package/dist/tiles.js.map +2 -2
- package/dist/tokens.css +45 -0
- package/dist/widgets.js +44 -14
- package/dist/widgets.js.map +2 -2
- package/dist/wm.js +3140 -157
- package/dist/wm.js.map +4 -4
- package/package.json +3 -2
- package/src/charts/chart_types.js +167 -0
- package/src/charts/plotly_wrapper.js +178 -10
- package/src/editor/notebook_tab_bar.js +39 -3
- package/src/tiles/tile_base.js +143 -35
- package/src/tiles/tile_grid.js +52 -1
- package/src/tiling/command_palette.js +71 -18
- package/src/tiling/desktops.js +36 -12
- package/src/tiling/keymap.js +24 -4
- package/src/tiling/shell.js +156 -25
- package/src/tiling/tab_strip.js +184 -0
- package/src/tiling/tile_breadcrumb.js +34 -2
- package/src/tiling/tile_renderer.js +1386 -21
- package/src/tiling/tile_tab_menu.js +101 -0
- package/src/tiling/tile_tree.js +115 -11
- package/src/tiling/wm.js +2375 -84
- package/src/tiling/zoom.js +248 -0
- package/src/ui/components/action_dropdown.js +34 -3
- package/src/ui/components/autocomplete_field.js +65 -13
- package/src/ui/components/context_menu.js +79 -8
- package/src/ui/components/data_table.js +508 -84
- package/src/ui/components/managed_window.js +928 -36
- package/src/ui/components/modal.js +214 -8
- package/dist/chunk-CT4YXXLP.js.map +0 -7
- package/dist/chunk-DRYCDMEG.js.map +0 -7
- package/dist/chunk-DVU44T77.js.map +0 -7
- package/dist/chunk-TLZUUFOE.js.map +0 -7
- package/dist/chunk-UCJ2WD4D.js +0 -625
- 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
|
|
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
|
-
|
|
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('
|
|
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
|
-
|
|
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',
|
|
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
|
-
|
|
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
|
|
76
|
-
|
|
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
|
|
80
|
-
if (e.
|
|
150
|
+
function _outsideHandler(e) {
|
|
151
|
+
if (_activeMenu && !_activeMenu.contains(e.target)) hideContextMenu();
|
|
81
152
|
}
|
|
82
153
|
|
|
83
154
|
function escapeHtml(s) {
|