@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.
@@ -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),