@danieldeusing/design 0.20.0 → 0.22.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 +5 -1
- package/dist/danieldeusing-design.css +327 -1
- package/dist/danieldeusing-design.min.css +2 -2
- package/package.json +1 -1
- package/runtime/index.js +2 -0
- package/runtime/pagination.js +341 -0
- package/runtime/select.js +539 -0
- package/src/base.css +14 -0
- package/src/components.css +311 -0
- package/src/print.css +1 -0
- package/templates/documentation.html +11 -6
- package/templates/page-chrome.html +6 -1
|
@@ -0,0 +1,539 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* select.js — replaces the OPERATING SYSTEM's dropdown with the estate's own.
|
|
3
|
+
*
|
|
4
|
+
* Markup contract:
|
|
5
|
+
* <select>…</select>
|
|
6
|
+
*
|
|
7
|
+
* That is the whole contract. Like `initTableScroll()`, this takes plain HTML
|
|
8
|
+
* and needs no classes, no wrapper and no data attributes. Call `initSelects()`
|
|
9
|
+
* once; every `<select>` on the page is enhanced, and so is every one rendered
|
|
10
|
+
* afterwards — cockpit rebuilds its config tables out of innerHTML on every
|
|
11
|
+
* poll, so a widget that only enhanced what existed at load would work until
|
|
12
|
+
* the first refresh and then quietly stop.
|
|
13
|
+
*
|
|
14
|
+
* WHY A REPLACEMENT AND NOT CSS. A `<select>`'s option list is painted by the
|
|
15
|
+
* OS outside the document: rounded corners, a blue system highlight, the system
|
|
16
|
+
* font. No stylesheet reaches it. Chrome 135+ can style it with
|
|
17
|
+
* `appearance: base-select`, but Safari and Firefox cannot, and a fix that
|
|
18
|
+
* lands on one browser leaves the estate disagreeing with ITSELF, which is
|
|
19
|
+
* worse than being consistently wrong. So the list is rebuilt in the page.
|
|
20
|
+
*
|
|
21
|
+
* THE <select> STAYS AND STAYS AUTHORITATIVE. It is not cloned, mirrored or
|
|
22
|
+
* replaced by hidden inputs: it remains the element that holds the value, that
|
|
23
|
+
* a form submits, that `select.value` reads, and that emits `input`/`change`.
|
|
24
|
+
* Page code sees exactly what it saw before — that is what let 28 call sites
|
|
25
|
+
* adopt this without a single edit. It is laid transparently OVER the trigger
|
|
26
|
+
* rather than `display: none`, because Chrome refuses to show a validation
|
|
27
|
+
* bubble on an unfocusable control and then blocks the submit with no message
|
|
28
|
+
* at all, which would silently break every `required` select.
|
|
29
|
+
*
|
|
30
|
+
* Keyboard follows the ARIA APG select-only combobox: Enter/Space/Arrow open,
|
|
31
|
+
* Up/Down move, Home/End jump, printable characters type ahead (a repeated
|
|
32
|
+
* character cycles, as a native select does), Enter selects, Escape closes
|
|
33
|
+
* without changing anything, Tab moves on. Focus never leaves the trigger —
|
|
34
|
+
* the active option is pointed at with `aria-activedescendant` — so there is
|
|
35
|
+
* nowhere for it to get stuck.
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
const TYPEAHEAD_MS = 700;
|
|
39
|
+
const GAP = 4; // visual px between the trigger and the panel
|
|
40
|
+
const EDGE = 8; // keep the panel this far off the viewport edge
|
|
41
|
+
|
|
42
|
+
const enhanced = new WeakMap();
|
|
43
|
+
let counter = 0;
|
|
44
|
+
let openInstance = null;
|
|
45
|
+
let documentObserver = null;
|
|
46
|
+
let globalsInstalled = false;
|
|
47
|
+
|
|
48
|
+
/*
|
|
49
|
+
* Consumers set `zoom` on <html> (initResolutionZoom lays every page out
|
|
50
|
+
* against a 1920px reference), and that puts the two halves of any positioning
|
|
51
|
+
* sum in different coordinate spaces: getBoundingClientRect() and
|
|
52
|
+
* innerWidth/innerHeight are VISUAL px, already multiplied, while style.left is
|
|
53
|
+
* a CSS length the browser multiplies AGAIN on the way out. Writing a rect
|
|
54
|
+
* straight into a length therefore applies the zoom twice — an error that grows
|
|
55
|
+
* with distance from the origin, which is how it survives review (it looks fine
|
|
56
|
+
* near the top left). Fixed twice before in this runtime: tooltip.js and
|
|
57
|
+
* lsnav.js. Divide on the WRITE; never "fix" a comparison whose operands are
|
|
58
|
+
* both already visual.
|
|
59
|
+
*/
|
|
60
|
+
const zoomOf = () => Number(getComputedStyle(document.documentElement).zoom) || 1;
|
|
61
|
+
|
|
62
|
+
const optionsOf = (instance) => instance.select.options;
|
|
63
|
+
const label = (element) => (element.textContent || "").trim();
|
|
64
|
+
|
|
65
|
+
const firstEnabled = (instance) => {
|
|
66
|
+
const options = optionsOf(instance);
|
|
67
|
+
for (let i = 0; i < options.length; i += 1) if (!options[i].disabled) return i;
|
|
68
|
+
return -1;
|
|
69
|
+
};
|
|
70
|
+
const lastEnabled = (instance) => {
|
|
71
|
+
const options = optionsOf(instance);
|
|
72
|
+
for (let i = options.length - 1; i >= 0; i -= 1) if (!options[i].disabled) return i;
|
|
73
|
+
return -1;
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
/* ── the closed control ─────────────────────────────────────────────────── */
|
|
77
|
+
|
|
78
|
+
function syncTrigger(instance) {
|
|
79
|
+
const { select, trigger, value } = instance;
|
|
80
|
+
const option = select.selectedIndex >= 0 ? select.options[select.selectedIndex] : null;
|
|
81
|
+
value.textContent = option ? label(option) : "";
|
|
82
|
+
trigger.disabled = select.disabled;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/*
|
|
86
|
+
* The trigger's accessible name is the LABEL plus the CURRENT VALUE — "lines,
|
|
87
|
+
* 200" — which is what a native select announces and what the APG's select-only
|
|
88
|
+
* combobox prescribes. Pointing `aria-labelledby` at the trigger's own id is
|
|
89
|
+
* how the value gets into the name; it looks like a mistake and is the pattern.
|
|
90
|
+
*
|
|
91
|
+
* The label is found the same three ways the platform finds it, in the platform's
|
|
92
|
+
* order, so a page that already labels its select correctly needs no change:
|
|
93
|
+
* an explicit aria-label, an explicit aria-labelledby, then a <label> — whether
|
|
94
|
+
* associated by `for=` or by wrapping.
|
|
95
|
+
*/
|
|
96
|
+
function nameTrigger(instance) {
|
|
97
|
+
const { select, trigger } = instance;
|
|
98
|
+
const explicit = select.getAttribute("aria-label");
|
|
99
|
+
if (explicit) {
|
|
100
|
+
trigger.setAttribute("aria-label", explicit);
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
let labelId = select.getAttribute("aria-labelledby");
|
|
104
|
+
if (!labelId) {
|
|
105
|
+
const element =
|
|
106
|
+
(select.id && document.querySelector(`label[for="${CSS.escape(select.id)}"]`)) ||
|
|
107
|
+
select.closest("label");
|
|
108
|
+
if (element) {
|
|
109
|
+
if (!element.id) element.id = `${instance.id}-label`;
|
|
110
|
+
labelId = element.id;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
if (labelId) trigger.setAttribute("aria-labelledby", `${labelId} ${trigger.id}`);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function enhance(select) {
|
|
117
|
+
if (enhanced.has(select)) return;
|
|
118
|
+
// `multiple` and `size > 1` are not popups — the platform renders them inline
|
|
119
|
+
// and there is no OS menu to replace. `data-select="off"` is the opt-out.
|
|
120
|
+
if (select.multiple || select.size > 1) return;
|
|
121
|
+
if (select.dataset.select === "off") return;
|
|
122
|
+
if (select.parentElement?.classList.contains("select-field")) return;
|
|
123
|
+
if (!select.parentNode) return;
|
|
124
|
+
|
|
125
|
+
counter += 1;
|
|
126
|
+
const id = `dd-select-${counter}`;
|
|
127
|
+
|
|
128
|
+
const field = document.createElement("span");
|
|
129
|
+
field.className = "select-field";
|
|
130
|
+
// A page sizes its control on the <select> (`style="max-width:18rem"`), and
|
|
131
|
+
// once the select is out of the flow that sizing has nothing to act on. The
|
|
132
|
+
// wrapper is what occupies the space now, so it takes the inline style. Copied,
|
|
133
|
+
// not moved: the select's own style attribute is still the page's to read.
|
|
134
|
+
const inlineStyle = select.getAttribute("style");
|
|
135
|
+
if (inlineStyle) field.setAttribute("style", inlineStyle);
|
|
136
|
+
|
|
137
|
+
const trigger = document.createElement("button");
|
|
138
|
+
trigger.type = "button";
|
|
139
|
+
trigger.className = "select-trigger";
|
|
140
|
+
trigger.id = `${id}-trigger`;
|
|
141
|
+
trigger.setAttribute("role", "combobox");
|
|
142
|
+
trigger.setAttribute("aria-haspopup", "listbox");
|
|
143
|
+
trigger.setAttribute("aria-expanded", "false");
|
|
144
|
+
trigger.setAttribute("aria-controls", `${id}-panel`);
|
|
145
|
+
|
|
146
|
+
const value = document.createElement("span");
|
|
147
|
+
value.className = "select-value";
|
|
148
|
+
trigger.appendChild(value);
|
|
149
|
+
|
|
150
|
+
// A tooltip anchored to a control nobody can hover is a tooltip that never
|
|
151
|
+
// shows. Both flavours move to the visible element; the select keeps its copy
|
|
152
|
+
// so page code that reads the attribute still finds it.
|
|
153
|
+
for (const attribute of ["title", "data-tip"]) {
|
|
154
|
+
const text = select.getAttribute(attribute);
|
|
155
|
+
if (text !== null) trigger.setAttribute(attribute, text);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
select.parentNode.insertBefore(field, select);
|
|
159
|
+
field.appendChild(select);
|
|
160
|
+
field.appendChild(trigger);
|
|
161
|
+
select.setAttribute("tabindex", "-1");
|
|
162
|
+
select.setAttribute("aria-hidden", "true");
|
|
163
|
+
|
|
164
|
+
const instance = { select, field, trigger, value, id, panel: null, items: [], active: -1, typed: "", typedAt: 0 };
|
|
165
|
+
enhanced.set(select, instance);
|
|
166
|
+
nameTrigger(instance);
|
|
167
|
+
syncTrigger(instance);
|
|
168
|
+
|
|
169
|
+
trigger.addEventListener("click", () => (instance.panel ? close(instance, true) : open(instance)));
|
|
170
|
+
trigger.addEventListener("keydown", (event) => onKeydown(instance, event));
|
|
171
|
+
// Focus aimed at the hidden control — a <label> click, or page code calling
|
|
172
|
+
// select.focus() — belongs to the one the reader can see.
|
|
173
|
+
select.addEventListener("focus", () => trigger.focus());
|
|
174
|
+
// A page that sets the value itself and announces it the normal way is honoured.
|
|
175
|
+
// Our own dispatch lands here too; re-syncing an already-synced trigger is a no-op.
|
|
176
|
+
select.addEventListener("change", () => syncTrigger(instance));
|
|
177
|
+
|
|
178
|
+
// Cockpit rewrites a select's options from fetched data — a new model list, a
|
|
179
|
+
// new credential list, a filter column derived from the rows that just arrived.
|
|
180
|
+
// Without this the trigger would keep showing the label of an option that is no
|
|
181
|
+
// longer in the list, which reads as the page having lost the setting.
|
|
182
|
+
instance.observer = new MutationObserver(() => {
|
|
183
|
+
syncTrigger(instance);
|
|
184
|
+
if (instance.panel) {
|
|
185
|
+
// The list changed under an open panel. Rebuild it rather than show a stale
|
|
186
|
+
// one; focus is on the trigger throughout, so nothing is disturbed.
|
|
187
|
+
close(instance, false);
|
|
188
|
+
open(instance);
|
|
189
|
+
}
|
|
190
|
+
});
|
|
191
|
+
instance.observer.observe(select, {
|
|
192
|
+
childList: true,
|
|
193
|
+
subtree: true,
|
|
194
|
+
characterData: true,
|
|
195
|
+
attributes: true,
|
|
196
|
+
attributeFilter: ["disabled", "selected", "value", "label"],
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/* ── the panel ──────────────────────────────────────────────────────────── */
|
|
201
|
+
|
|
202
|
+
function buildPanel(instance) {
|
|
203
|
+
const panel = document.createElement("ul");
|
|
204
|
+
panel.className = "select-panel";
|
|
205
|
+
panel.id = `${instance.id}-panel`;
|
|
206
|
+
panel.setAttribute("role", "listbox");
|
|
207
|
+
panel.tabIndex = -1;
|
|
208
|
+
|
|
209
|
+
instance.items = [];
|
|
210
|
+
let index = 0;
|
|
211
|
+
const addOption = (option) => {
|
|
212
|
+
const item = document.createElement("li");
|
|
213
|
+
item.className = "select-option";
|
|
214
|
+
item.id = `${instance.id}-o${index}`;
|
|
215
|
+
item.setAttribute("role", "option");
|
|
216
|
+
item.setAttribute("aria-selected", String(index === instance.select.selectedIndex));
|
|
217
|
+
if (option.disabled) item.setAttribute("aria-disabled", "true");
|
|
218
|
+
item.textContent = label(option);
|
|
219
|
+
item.dataset.index = String(index);
|
|
220
|
+
instance.items[index] = item;
|
|
221
|
+
index += 1;
|
|
222
|
+
return item;
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
// Walked child by child rather than over `select.options`, so an <optgroup>
|
|
226
|
+
// keeps its heading. The walk order is document order, which is exactly the
|
|
227
|
+
// order `select.options` flattens to — that is what keeps `data-index` a valid
|
|
228
|
+
// index into the real control.
|
|
229
|
+
for (const child of instance.select.children) {
|
|
230
|
+
if (child.tagName === "OPTGROUP") {
|
|
231
|
+
const heading = document.createElement("li");
|
|
232
|
+
heading.className = "select-group";
|
|
233
|
+
heading.setAttribute("role", "presentation");
|
|
234
|
+
heading.textContent = child.label;
|
|
235
|
+
panel.appendChild(heading);
|
|
236
|
+
for (const option of child.children) {
|
|
237
|
+
if (option.tagName === "OPTION") panel.appendChild(addOption(option));
|
|
238
|
+
}
|
|
239
|
+
} else if (child.tagName === "OPTION") {
|
|
240
|
+
panel.appendChild(addOption(child));
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
return panel;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/*
|
|
247
|
+
* THE PANEL IS BUILT FRESH ON EVERY OPEN, from the select's options as they are
|
|
248
|
+
* at that instant. That is not laziness about caching — it is the only way the
|
|
249
|
+
* list cannot be stale, and staleness is the failure this widget is most likely
|
|
250
|
+
* to have shipped: cockpit replaces a select's options from fetched data all the
|
|
251
|
+
* time, and a snapshot taken at enhance time would show a model list from before
|
|
252
|
+
* the last poll while the value underneath had moved on.
|
|
253
|
+
*/
|
|
254
|
+
function open(instance) {
|
|
255
|
+
if (instance.select.disabled) return;
|
|
256
|
+
if (openInstance && openInstance !== instance) close(openInstance, false);
|
|
257
|
+
|
|
258
|
+
const panel = buildPanel(instance);
|
|
259
|
+
instance.panel = panel;
|
|
260
|
+
// Appended to the nearest <dialog> when there is one: a modal dialog is in the
|
|
261
|
+
// top layer and nothing outside it can paint above it, so a panel on <body>
|
|
262
|
+
// would open behind the dialog that owns the select. Everywhere else <body> is
|
|
263
|
+
// right — it escapes `.tablewrap`'s scroll clipping, which is where most of
|
|
264
|
+
// cockpit's selects live.
|
|
265
|
+
(instance.trigger.closest("dialog") || document.body).appendChild(panel);
|
|
266
|
+
instance.trigger.setAttribute("aria-expanded", "true");
|
|
267
|
+
openInstance = instance;
|
|
268
|
+
|
|
269
|
+
// Options are never focusable; the panel is pointed at with
|
|
270
|
+
// aria-activedescendant instead. preventDefault on mousedown is what keeps
|
|
271
|
+
// focus on the trigger when an option is clicked.
|
|
272
|
+
panel.addEventListener("mousedown", (event) => event.preventDefault());
|
|
273
|
+
panel.addEventListener("click", (event) => {
|
|
274
|
+
const item = event.target.closest(".select-option");
|
|
275
|
+
if (item) commit(instance, Number(item.dataset.index));
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
const selected = instance.select.selectedIndex;
|
|
279
|
+
setActive(instance, selected >= 0 && !instance.select.options[selected].disabled ? selected : firstEnabled(instance));
|
|
280
|
+
position(instance);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function close(instance, focusTrigger) {
|
|
284
|
+
if (instance.panel) {
|
|
285
|
+
instance.panel.remove();
|
|
286
|
+
instance.panel = null;
|
|
287
|
+
}
|
|
288
|
+
instance.items = [];
|
|
289
|
+
instance.active = -1;
|
|
290
|
+
instance.typed = "";
|
|
291
|
+
instance.trigger.setAttribute("aria-expanded", "false");
|
|
292
|
+
instance.trigger.removeAttribute("aria-activedescendant");
|
|
293
|
+
if (openInstance === instance) openInstance = null;
|
|
294
|
+
if (focusTrigger) instance.trigger.focus();
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function setActive(instance, index) {
|
|
298
|
+
const previous = instance.items[instance.active];
|
|
299
|
+
if (previous) previous.removeAttribute("data-active");
|
|
300
|
+
instance.active = index;
|
|
301
|
+
const item = instance.items[index];
|
|
302
|
+
if (!item) {
|
|
303
|
+
instance.trigger.removeAttribute("aria-activedescendant");
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
item.setAttribute("data-active", "true");
|
|
307
|
+
instance.trigger.setAttribute("aria-activedescendant", item.id);
|
|
308
|
+
item.scrollIntoView({ block: "nearest" });
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function commit(instance, index) {
|
|
312
|
+
const option = optionsOf(instance)[index];
|
|
313
|
+
if (!option || option.disabled) return; // a disabled option is not a choice; the list stays open
|
|
314
|
+
const changed = instance.select.selectedIndex !== index;
|
|
315
|
+
if (changed) {
|
|
316
|
+
instance.select.selectedIndex = index;
|
|
317
|
+
syncTrigger(instance);
|
|
318
|
+
}
|
|
319
|
+
// Closed BEFORE the events go out: a `change` handler here re-renders the table
|
|
320
|
+
// this select lives in, so anything touching the instance afterwards would be
|
|
321
|
+
// touching a detached node.
|
|
322
|
+
close(instance, true);
|
|
323
|
+
if (changed) {
|
|
324
|
+
// A native select fires `input` and THEN `change`, and both bubble. Cockpit's
|
|
325
|
+
// table filters listen for both on a container element, so dispatching only
|
|
326
|
+
// `change` would make this a quieter control than the one it replaced.
|
|
327
|
+
instance.select.dispatchEvent(new Event("input", { bubbles: true }));
|
|
328
|
+
instance.select.dispatchEvent(new Event("change", { bubbles: true }));
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function position(instance) {
|
|
333
|
+
const { trigger, panel } = instance;
|
|
334
|
+
const zoom = zoomOf();
|
|
335
|
+
const rect = trigger.getBoundingClientRect(); // visual px
|
|
336
|
+
|
|
337
|
+
// offsetWidth is a LAYOUT length, already in the same space as the panel's own
|
|
338
|
+
// min-width, so this one must NOT be divided. Mixing the two is the trap.
|
|
339
|
+
panel.style.minWidth = `${trigger.offsetWidth}px`;
|
|
340
|
+
panel.style.maxWidth = `${(window.innerWidth - EDGE * 2) / zoom}px`;
|
|
341
|
+
panel.style.maxHeight = "";
|
|
342
|
+
|
|
343
|
+
const spaceBelow = window.innerHeight - rect.bottom - EDGE - GAP;
|
|
344
|
+
const spaceAbove = rect.top - EDGE - GAP;
|
|
345
|
+
const wanted = panel.getBoundingClientRect().height;
|
|
346
|
+
// Flip only when flipping actually helps. A long list near the bottom of a tall
|
|
347
|
+
// page has room in neither direction, and flipping it there just moves the
|
|
348
|
+
// clipping to the other end.
|
|
349
|
+
const flip = wanted > spaceBelow && spaceAbove > spaceBelow;
|
|
350
|
+
const room = Math.max(flip ? spaceAbove : spaceBelow, 72);
|
|
351
|
+
if (wanted > room) panel.style.maxHeight = `${room / zoom}px`;
|
|
352
|
+
|
|
353
|
+
const box = panel.getBoundingClientRect(); // visual px, after the clamp
|
|
354
|
+
const x = Math.min(Math.max(rect.left, EDGE), Math.max(EDGE, window.innerWidth - box.width - EDGE));
|
|
355
|
+
const y = flip ? rect.top - GAP - box.height : rect.bottom + GAP;
|
|
356
|
+
panel.style.left = `${x / zoom}px`;
|
|
357
|
+
panel.style.top = `${y / zoom}px`;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/* ── keyboard ───────────────────────────────────────────────────────────── */
|
|
361
|
+
|
|
362
|
+
const isPrintable = (event) =>
|
|
363
|
+
event.key.length === 1 && !event.ctrlKey && !event.metaKey && !event.altKey;
|
|
364
|
+
|
|
365
|
+
function step(instance, direction) {
|
|
366
|
+
const options = optionsOf(instance);
|
|
367
|
+
for (let i = instance.active + direction; i >= 0 && i < options.length; i += direction) {
|
|
368
|
+
if (!options[i].disabled) {
|
|
369
|
+
setActive(instance, i);
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function typeahead(instance, character) {
|
|
376
|
+
const now = Date.now();
|
|
377
|
+
if (now - instance.typedAt > TYPEAHEAD_MS) instance.typed = "";
|
|
378
|
+
instance.typedAt = now;
|
|
379
|
+
instance.typed += character.toLowerCase();
|
|
380
|
+
|
|
381
|
+
// One character repeated CYCLES through the options starting with it, which is
|
|
382
|
+
// what a native select does — pressing "c" three times walks three c-options
|
|
383
|
+
// rather than hunting for "ccc".
|
|
384
|
+
const repeated = instance.typed.length > 1 && new Set(instance.typed).size === 1;
|
|
385
|
+
const needle = repeated ? instance.typed[0] : instance.typed;
|
|
386
|
+
const advance = repeated || instance.typed.length === 1;
|
|
387
|
+
const options = optionsOf(instance);
|
|
388
|
+
const from = advance ? instance.active + 1 : Math.max(instance.active, 0);
|
|
389
|
+
for (let i = 0; i < options.length; i += 1) {
|
|
390
|
+
const index = (from + i + options.length) % options.length;
|
|
391
|
+
if (options[index].disabled) continue;
|
|
392
|
+
if (label(options[index]).toLowerCase().startsWith(needle)) {
|
|
393
|
+
setActive(instance, index);
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
function onKeydown(instance, event) {
|
|
400
|
+
const isOpen = Boolean(instance.panel);
|
|
401
|
+
const key = event.key;
|
|
402
|
+
|
|
403
|
+
if (key === "Escape") {
|
|
404
|
+
if (!isOpen) return;
|
|
405
|
+
// BOTH, and both matter. preventDefault stops the UA's close-request, and
|
|
406
|
+
// stopPropagation stops the page's own handler: without them, dismissing this
|
|
407
|
+
// list inside a modal <dialog> closes the dialog as well, losing the form.
|
|
408
|
+
event.preventDefault();
|
|
409
|
+
event.stopPropagation();
|
|
410
|
+
close(instance, true);
|
|
411
|
+
return;
|
|
412
|
+
}
|
|
413
|
+
if (key === "Tab") {
|
|
414
|
+
if (isOpen) close(instance, false); // Tab moves on and commits nothing
|
|
415
|
+
return;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
if (!isOpen) {
|
|
419
|
+
if (key === "Enter" || key === " " || key === "ArrowDown" || key === "ArrowUp" || key === "Home" || key === "End") {
|
|
420
|
+
event.preventDefault();
|
|
421
|
+
open(instance);
|
|
422
|
+
if (key === "Home") setActive(instance, firstEnabled(instance));
|
|
423
|
+
else if (key === "End") setActive(instance, lastEnabled(instance));
|
|
424
|
+
return;
|
|
425
|
+
}
|
|
426
|
+
if (isPrintable(event)) {
|
|
427
|
+
event.preventDefault();
|
|
428
|
+
open(instance);
|
|
429
|
+
typeahead(instance, key);
|
|
430
|
+
}
|
|
431
|
+
return;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
// A space CONTINUES a live typeahead rather than selecting — option labels here
|
|
435
|
+
// contain spaces ("public — posts on the PR"), so treating it as Enter would
|
|
436
|
+
// make half of them untypeable.
|
|
437
|
+
if (key === " " && instance.typed && Date.now() - instance.typedAt <= TYPEAHEAD_MS) {
|
|
438
|
+
event.preventDefault();
|
|
439
|
+
typeahead(instance, key);
|
|
440
|
+
return;
|
|
441
|
+
}
|
|
442
|
+
switch (key) {
|
|
443
|
+
case "Enter":
|
|
444
|
+
case " ":
|
|
445
|
+
event.preventDefault();
|
|
446
|
+
commit(instance, instance.active);
|
|
447
|
+
return;
|
|
448
|
+
case "ArrowDown":
|
|
449
|
+
event.preventDefault();
|
|
450
|
+
step(instance, 1);
|
|
451
|
+
return;
|
|
452
|
+
case "ArrowUp":
|
|
453
|
+
event.preventDefault();
|
|
454
|
+
step(instance, -1);
|
|
455
|
+
return;
|
|
456
|
+
case "Home":
|
|
457
|
+
event.preventDefault();
|
|
458
|
+
setActive(instance, firstEnabled(instance));
|
|
459
|
+
return;
|
|
460
|
+
case "End":
|
|
461
|
+
event.preventDefault();
|
|
462
|
+
setActive(instance, lastEnabled(instance));
|
|
463
|
+
return;
|
|
464
|
+
default:
|
|
465
|
+
if (isPrintable(event)) {
|
|
466
|
+
event.preventDefault();
|
|
467
|
+
typeahead(instance, key);
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/* ── document-level wiring, installed once ──────────────────────────────── */
|
|
473
|
+
|
|
474
|
+
function installGlobals() {
|
|
475
|
+
if (globalsInstalled) return;
|
|
476
|
+
globalsInstalled = true;
|
|
477
|
+
|
|
478
|
+
document.addEventListener(
|
|
479
|
+
"pointerdown",
|
|
480
|
+
(event) => {
|
|
481
|
+
if (!openInstance) return;
|
|
482
|
+
const target = event.target;
|
|
483
|
+
if (openInstance.panel.contains(target) || openInstance.field.contains(target)) return;
|
|
484
|
+
close(openInstance, false);
|
|
485
|
+
},
|
|
486
|
+
true,
|
|
487
|
+
);
|
|
488
|
+
document.addEventListener("focusin", (event) => {
|
|
489
|
+
if (!openInstance) return;
|
|
490
|
+
if (openInstance.field.contains(event.target) || openInstance.panel.contains(event.target)) return;
|
|
491
|
+
close(openInstance, false);
|
|
492
|
+
});
|
|
493
|
+
// The trigger moves when the page or a scroll container moves under it. Capture,
|
|
494
|
+
// because most of these selects sit in a `.tablewrap` that scrolls on its own and
|
|
495
|
+
// a scroll event there does not bubble.
|
|
496
|
+
addEventListener("resize", () => openInstance && position(openInstance));
|
|
497
|
+
addEventListener("scroll", () => openInstance && position(openInstance), true);
|
|
498
|
+
// form.reset() rewinds selectedIndex without firing an event or touching the DOM,
|
|
499
|
+
// so nothing else here would notice. One delegated listener rather than one per
|
|
500
|
+
// select: these selects are re-created on every render and the <form> is not, so
|
|
501
|
+
// per-select listeners would pile up on it for the life of the page.
|
|
502
|
+
document.addEventListener("reset", (event) => {
|
|
503
|
+
const form = event.target;
|
|
504
|
+
queueMicrotask(() => {
|
|
505
|
+
for (const select of form.querySelectorAll?.("select") ?? []) {
|
|
506
|
+
const instance = enhanced.get(select);
|
|
507
|
+
if (instance) syncTrigger(instance);
|
|
508
|
+
}
|
|
509
|
+
});
|
|
510
|
+
});
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
/**
|
|
514
|
+
* Replace every native `<select>` dropdown on the page with the design system's
|
|
515
|
+
* own, and keep doing so for selects rendered later.
|
|
516
|
+
*
|
|
517
|
+
* @param {ParentNode} [root=document] Where to look for the initial pass.
|
|
518
|
+
*/
|
|
519
|
+
export function initSelects(root = document) {
|
|
520
|
+
installGlobals();
|
|
521
|
+
for (const select of root.querySelectorAll("select")) enhance(select);
|
|
522
|
+
|
|
523
|
+
if (documentObserver) return;
|
|
524
|
+
documentObserver = new MutationObserver((records) => {
|
|
525
|
+
// A re-render can take the open panel's trigger out of the document from
|
|
526
|
+
// underneath it — a background poll rewriting the table it sits in. The panel
|
|
527
|
+
// is on <body>, so it would be left floating over a control that no longer
|
|
528
|
+
// exists.
|
|
529
|
+
if (openInstance && !openInstance.trigger.isConnected) close(openInstance, false);
|
|
530
|
+
for (const record of records) {
|
|
531
|
+
for (const node of record.addedNodes) {
|
|
532
|
+
if (node.nodeType !== 1) continue;
|
|
533
|
+
if (node.tagName === "SELECT") enhance(node);
|
|
534
|
+
else for (const select of node.querySelectorAll("select")) enhance(select);
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
});
|
|
538
|
+
documentObserver.observe(document.documentElement, { childList: true, subtree: true });
|
|
539
|
+
}
|
package/src/base.css
CHANGED
|
@@ -87,6 +87,20 @@ tbody tr:last-child > * {
|
|
|
87
87
|
tr > *:first-child { padding-inline-start: 0; }
|
|
88
88
|
tr > *:last-child { padding-inline-end: 0; }
|
|
89
89
|
|
|
90
|
+
/* A HIDDEN ROW MUST ACTUALLY BE HIDDEN, and `!important` is what makes that true.
|
|
91
|
+
The UA's own `[hidden] { display: none }` is a one-attribute selector, so ANY rule that
|
|
92
|
+
sets `display` on a row outruns it — and the estate has those: a narrow-screen media
|
|
93
|
+
query that restacks a key/value table as `.ask table.kv tr { display: block }` scores
|
|
94
|
+
higher and would put every hidden row back on screen, on phones only. Worse than a
|
|
95
|
+
cosmetic slip, because `hidden` that loses to a stylesheet is still exposed to a screen
|
|
96
|
+
reader: the table would announce rows nobody can see it announcing.
|
|
97
|
+
|
|
98
|
+
This is the mechanism `initTablePagination()` turns a page with, and it is declared
|
|
99
|
+
here rather than beside the pager because it is a correction to the ELEMENT: a row a
|
|
100
|
+
consumer hides for its own reasons has exactly the same problem. Same `display: none
|
|
101
|
+
!important` the print stylesheet and the diagram overlay already use. */
|
|
102
|
+
tr[hidden] { display: none !important; }
|
|
103
|
+
|
|
90
104
|
/* Tailwind v4's reset drops the default button cursor — restore it for all
|
|
91
105
|
interactive controls (buttons, summaries, role=button). */
|
|
92
106
|
button:not(:disabled),
|