@adia-ai/web-components 0.8.44 → 0.8.46

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 (69) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/components/card/card.css +12 -23
  3. package/components/card/card.yaml +11 -0
  4. package/components/chart/chart.a2ui.json +16 -1
  5. package/components/chart/chart.class.js +611 -41
  6. package/components/chart/chart.css +174 -0
  7. package/components/chart/chart.d.ts +5 -1
  8. package/components/chart/chart.yaml +45 -1
  9. package/components/context-menu/context-menu.a2ui.json +8 -3
  10. package/components/context-menu/context-menu.class.js +46 -5
  11. package/components/context-menu/context-menu.d.ts +6 -3
  12. package/components/context-menu/context-menu.examples.md +2 -2
  13. package/components/context-menu/context-menu.yaml +22 -5
  14. package/components/field/field.css +24 -2
  15. package/components/index.js +1 -0
  16. package/components/input/input.css +7 -0
  17. package/components/nav/nav.a2ui.json +2 -2
  18. package/components/nav/nav.css +1 -1
  19. package/components/nav/nav.d.ts +1 -1
  20. package/components/nav/nav.yaml +14 -3
  21. package/components/nav-group/nav-group.css +37 -3
  22. package/components/pagination/pagination.class.js +52 -22
  23. package/components/search/search.class.js +39 -5
  24. package/components/select/select.a2ui.json +5 -0
  25. package/components/select/select.class.js +20 -0
  26. package/components/select/select.css +25 -0
  27. package/components/select/select.d.ts +2 -0
  28. package/components/select/select.yaml +12 -0
  29. package/components/table/cell-types.js +9 -0
  30. package/components/table/table.a2ui.json +19 -4
  31. package/components/table/table.class.js +410 -45
  32. package/components/table/table.css +7 -4
  33. package/components/table/table.d.ts +9 -3
  34. package/components/table/table.yaml +115 -9
  35. package/components/table-footer/table-footer.a2ui.json +150 -0
  36. package/components/table-footer/table-footer.class.js +391 -0
  37. package/components/table-footer/table-footer.css +64 -0
  38. package/components/table-footer/table-footer.d.ts +39 -0
  39. package/components/table-footer/table-footer.examples.md +46 -0
  40. package/components/table-footer/table-footer.js +17 -0
  41. package/components/table-footer/table-footer.yaml +219 -0
  42. package/components/table-toolbar/table-toolbar.a2ui.json +15 -0
  43. package/components/table-toolbar/table-toolbar.class.js +61 -17
  44. package/components/table-toolbar/table-toolbar.css +34 -0
  45. package/components/table-toolbar/table-toolbar.d.ts +10 -0
  46. package/components/table-toolbar/table-toolbar.yaml +58 -15
  47. package/core/data-stream.js +37 -2
  48. package/core/index.d.ts +1 -0
  49. package/core/index.js +1 -0
  50. package/core/provider.d.ts +9 -13
  51. package/core/provider.js +9 -113
  52. package/core/store.d.ts +46 -0
  53. package/core/store.js +89 -0
  54. package/custom-elements.json +192 -8
  55. package/dist/host.min.css +1 -1
  56. package/dist/host.sheet.js +1 -1
  57. package/dist/theme-provider.min.js +1 -1
  58. package/dist/web-components.min.css +1 -1
  59. package/dist/web-components.min.js +96 -96
  60. package/dist/web-components.sheet.js +1 -1
  61. package/package.json +1 -1
  62. package/patterns/chart-in-card/chart-in-card.examples.html +36 -9
  63. package/patterns/new-enrollments/new-enrollments.examples.html +140 -0
  64. package/patterns/new-enrollments/new-enrollments.html +54 -0
  65. package/patterns/table-in-card/table-in-card.examples.html +168 -0
  66. package/patterns/table-in-card/table-in-card.examples.js +139 -0
  67. package/patterns/table-in-card/table-in-card.html +86 -0
  68. package/styles/api/sizing.css +46 -0
  69. package/styles/components.css +1 -0
@@ -26,7 +26,21 @@
26
26
  * table-ui has no local `density` prop (ADR-0054) — it converges onto the
27
27
  * ambient global [density="compact"|"spacious"] attribute; --table-py/-px
28
28
  * already derive from --a-space-* steps, which --a-density scales.
29
- * paginate — rows per page (0 = no pagination)
29
+ * paginate — rows per page (0 = no pagination); purely presentational
30
+ * (page-size only) when [range-total] is present — gh#1754/ADR-0082
31
+ * range-total — total row count across ALL server pages (Number, reflected,
32
+ * presence-gated via hasAttribute — the attribute's PRESENCE, not
33
+ * its value, is the server-mode switch, gh#1754/ADR-0082). Absent
34
+ * → client mode, today's behavior exactly. Present + [paginate] > 0
35
+ * → server mode: no local slicing (`.data` IS the current page and
36
+ * renders whole, after local search/sort/filter), page count =
37
+ * max(1, ceil(range-total / paginate)), and the internal page-reset
38
+ * sites (`data` set, setFilter, clearFilters) are suppressed so a
39
+ * fetch write-back never fights the pager back to page 0. Explicit
40
+ * range-total="0" is server-confirmed empty (pager hidden), never
41
+ * conflated with absent. With [paginate] absent/0, [range-total] is
42
+ * inert for this table's own rendering but stays readable by a
43
+ * bound table-footer-ui for its count-only label.
30
44
  * loading — show loading overlay
31
45
  * search — global search filter string
32
46
  *
@@ -45,13 +59,43 @@
45
59
  * .sortState → [{key, dir}] (read-only)
46
60
  * .exportCSV(filename?)
47
61
  *
48
- * Events:
62
+ * Events (fired):
49
63
  * sort — { detail: { key, dir, sortState } }
50
64
  * select — { detail: { selected: [...indices] } }
51
65
  * page — { detail: { page } }
52
66
  * resize — { detail: { key, width } }
53
67
  * cell-click — { detail: { key, row, value, dataIndex } }
54
68
  * row-click — { detail: { row, dataIndex } } — row-level companion to cell-click
69
+ *
70
+ * Events (listened) — gh#1764/#1780, ADR-0079. table-toolbar-ui dispatches
71
+ * these directly at its [for]-resolved table-ui target instead of writing
72
+ * a property or calling a method; any other consumer may dispatch them too:
73
+ * toolbar-search — { detail: { value } } → this.search =
74
+ * toolbar-filter-set — { detail: { key, value, op } } → this.setFilter(key, value, op)
75
+ * toolbar-filter-clear — { detail: {} } → this.clearFilters()
76
+ * toolbar-columns-set — { detail: { columns } } → this.columns =
77
+ * toolbar-paginate — { detail: { pageSize } } → this.paginate =
78
+ * footer-page — { detail: { page } }, 1-based → gh#1807/ADR-0080,
79
+ * refined 0.2.0/ADR-0082. Dispatched directly at this
80
+ * element (table-footer-ui, or any other consumer).
81
+ * Applies whenever [paginate] > 0 — client mode and
82
+ * table-authoritative server mode ([range-total]
83
+ * present, REQ-D-003) alike, since #pageCount already
84
+ * knows server math; clamped to [1, pageCount]; a
85
+ * non-numeric/NaN detail.page is a documented no-op.
86
+ * At [paginate="0"] the command stays a no-op in
87
+ * either mode — with no page size there is no page
88
+ * range to move within. On a valid page, applies
89
+ * exactly like an internal pager click: updates the
90
+ * page, re-renders, and fires the existing `page`
91
+ * event (the fetch trigger in server mode).
92
+ *
93
+ * [no-pager] — boolean, reflected (gh#1807/ADR-0080). Hides the internal
94
+ * pagination bar while [paginate] slicing, page state, the `page` event, and
95
+ * the `footer-page` listener all stay intact — the anti-doubled-pager
96
+ * mechanism for a table-footer-ui composition, client- or server-mode alike
97
+ * (gh#1754/ADR-0082). Explicit author intent, never an auto-suppression
98
+ * side-effect of binding a footer.
55
99
  */
56
100
 
57
101
  import { UIElement } from '../../core/element.js';
@@ -94,6 +138,117 @@ function csvEscape(val) {
94
138
  return str;
95
139
  }
96
140
 
141
+ // ── SSR adopt-in-place (gh#1678) ─────────────────────────────────────────────
142
+
143
+ /**
144
+ * Idempotent attribute set — skip the DOM write entirely when the value
145
+ * already matches. `setAttribute` mutates (and queues a MutationObserver
146
+ * record) even when the new value is byte-identical to the old one, so a
147
+ * host/row-level attribute this render path sets on EVERY call (`role`,
148
+ * `tabindex`, `data-index`, `aria-selected`) must compare-before-write to
149
+ * let a byte-identical SSR-rendered subtree survive upgrade with zero
150
+ * subtree mutations (AC-004a-equivalent probe) — bare `setAttribute` cannot
151
+ * give that guarantee even when the value never actually changes.
152
+ */
153
+ function setAttrIfChanged(el, name, value) {
154
+ if (el.getAttribute(name) !== value) el.setAttribute(name, value);
155
+ }
156
+
157
+ /** Idempotent attribute removal — same rationale as setAttrIfChanged. */
158
+ function removeAttrIfPresent(el, name) {
159
+ if (el.hasAttribute(name)) el.removeAttribute(name);
160
+ }
161
+
162
+ /**
163
+ * Marks a freshly-built candidate cell as renderer-owned — its content came
164
+ * from something free to attach an event listener or otherwise stash
165
+ * runtime state on the node it returns, where `Node.isEqualNode()` (the
166
+ * adopt-or-diff structural check below) has no way to see the difference
167
+ * (it compares tag/attributes/text/descendants only). Two, and only two,
168
+ * sources are marked:
169
+ *
170
+ * 1. `col.render()` — arbitrary consumer code. It can do anything to the
171
+ * node it hands back, so it's always marked; there's no way to inspect
172
+ * an opaque function for safety.
173
+ * 2. A built-in cell-type renderer (`typeDef.render`) that declares
174
+ * `attachesListeners: true` (currently only `cellTypes.actions`, which
175
+ * calls `addEventListener()` directly — see cell-types.js).
176
+ *
177
+ * Deliberately NOT marked: `col.format()`, the plain-text fallback, and
178
+ * every OTHER built-in cell-type renderer (text/number/currency/percent/
179
+ * date/datetime/boolean/badge/avatar/link/markdown/progress) — each of
180
+ * those only sets attributes on already-declarative custom elements
181
+ * (badge-ui, avatar-ui, check-ui, progress-ui, …) or plain nodes with no
182
+ * listeners; their own runtime behavior lives in their OWN
183
+ * `connectedCallback`, which fires independent of whether table.class.js
184
+ * discards or adopts the cell that contains them. Marking every
185
+ * `typeDef.render` cell unconditionally (an earlier draft of this fix) was
186
+ * tried and reverted — it broke the AC-004a zero-mutation acceptance
187
+ * criterion for the overwhelmingly common case (plain text/number/date
188
+ * cells) for no correctness benefit, since none of those renderers ever
189
+ * attach a listener. A cell that discards without ever being marked here
190
+ * (and turns out later to have needed it) is exactly the gap
191
+ * `guard-patterns.md` §4.1 asks a new interactive cell type to close by
192
+ * declaring `attachesListeners: true` on itself.
193
+ *
194
+ * A renderer-owned cell that happens to be structurally identical to the
195
+ * existing DOM would, without this guard, silently keep the OLD node (and
196
+ * its stale/missing listener) instead of the fresh one the renderer just
197
+ * built (gh#1678 CodeRabbit finding, table.class.js `adoptOrDiffChildren`
198
+ * L141-150 as originally shipped).
199
+ */
200
+ const RENDERER_OWNED = new WeakSet();
201
+
202
+ /**
203
+ * Adopt-or-diff child reconciliation — the core of table-ui's SSR
204
+ * adopt-in-place fix. `freshChildren` are already-built, detached candidate
205
+ * nodes (header cells, row cells, …) computed exactly as this file always
206
+ * computed them. Rather than unconditionally `replaceChild`-ing every
207
+ * position (the old, SSR-hostile behavior — a byte-identical server-
208
+ * rendered subtree got fully rebuilt at upgrade regardless), each position
209
+ * is compared against its EXISTING child with `Node.isEqualNode()` — a
210
+ * standard, structural + attribute + text deep-equality check present in
211
+ * every environment this framework runs under (real browsers, happy-dom,
212
+ * and linkedom under SSR — confirmed directly; unlike the browser-only APIs
213
+ * `ssr-compatibility`'s guard-patterns.md §1 guards, `isEqualNode` is core
214
+ * DOM and never absent). A match discards the freshly-built candidate and
215
+ * leaves the live DOM completely untouched — the "adopt" half. A mismatch
216
+ * replaces the position wholesale exactly as before — the "fall back to
217
+ * full rebuild" half. Never a partial/half-adopt: a position either
218
+ * survives byte-for-byte or gets fully rebuilt, matching the shape
219
+ * pagination-ui's own first-connect adoption fix (gh#1687) established for
220
+ * its flat, keyed item list — generalized here to an arbitrary positional
221
+ * child (header cells, row cells) via a value check instead of a
222
+ * shape/key check, since these cells have no stable identity key of their
223
+ * own the way a pagination item does.
224
+ *
225
+ * **Renderer-owned exception (gh#1678 CodeRabbit follow-up):** structural
226
+ * equality is a necessary but not sufficient adoption test — it says
227
+ * nothing about listeners or other runtime state a renderer attached to the
228
+ * node it returned. A cell in `RENDERER_OWNED` is therefore ALWAYS replaced
229
+ * with its fresh candidate, never adopted via the structural-equality path,
230
+ * even when `isEqualNode()` would have reported a match — correctness over
231
+ * the zero-mutation optimization for exactly the cells where structural
232
+ * equality can't prove the node is safe to keep. See
233
+ * `ssr-compatibility`'s `references/failure-shapes.md` §5.1 and
234
+ * `references/guard-patterns.md` §4.1 for the general shape.
235
+ */
236
+ function adoptOrDiffChildren(container, freshChildren) {
237
+ while (container.children.length > freshChildren.length) container.lastChild.remove();
238
+ for (let i = 0; i < freshChildren.length; i++) {
239
+ const existing = container.children[i];
240
+ const fresh = freshChildren[i];
241
+ if (!existing) {
242
+ container.appendChild(fresh);
243
+ } else if (RENDERER_OWNED.has(fresh) || !existing.isEqualNode(fresh)) {
244
+ container.replaceChild(fresh, existing);
245
+ }
246
+ // else: existing already matches fresh byte-for-byte AND fresh carries
247
+ // no renderer-owned runtime state — adopt in place, discard the
248
+ // candidate, touch nothing.
249
+ }
250
+ }
251
+
97
252
  // ── Component ────────────────────────────────────────────────────────────────
98
253
 
99
254
  export class UITable extends UIElement {
@@ -123,6 +278,18 @@ export class UITable extends UIElement {
123
278
  wrap: { type: Boolean, default: false, reflect: true },
124
279
  raw: { type: Boolean, default: false, reflect: true },
125
280
  paginate: { type: Number, default: 0, reflect: true },
281
+ // gh#1807/ADR-0080 — opt-out for the internal pagination bar. Additive:
282
+ // [paginate] slicing, page state, the `page` event, and the
283
+ // `footer-page` listener are all unaffected; only the internally
284
+ // stamped [data-footer] pagination-ui is suppressed. Explicit intent,
285
+ // for a client-mode table-footer-ui composition to avoid a doubled
286
+ // pager (REQ-W-005).
287
+ noPager: { type: Boolean, default: false, reflect: true, attribute: 'no-pager' },
288
+ // gh#1754/ADR-0082 — the server-total data contract. Presence-gated
289
+ // (#serverMode reads hasAttribute, never this coerced value): total row
290
+ // count across all server pages. See the class-header doc comment above
291
+ // and REQ-D-003..006 for the full server-mode contract.
292
+ rangeTotal: { type: Number, default: 0, reflect: true, attribute: 'range-total' },
126
293
  loading: { type: Boolean, default: false, reflect: true },
127
294
  search: { type: String, default: '', reflect: true },
128
295
  // adiav2 zero-overrides purge (gh#1680) deleted an app-side [data-clickable]
@@ -151,6 +318,23 @@ export class UITable extends UIElement {
151
318
  #openFilter = null; // column key of open filter dropdown
152
319
  #filterFocusRaf = null;
153
320
 
321
+ // gh#1754/ADR-0082 — the mode switch is the [range-total] attribute's
322
+ // PRESENCE, never its coerced value (mirrors table-footer-ui's own
323
+ // REQ-S-001/002 hasAttribute discipline). One predicate for both the
324
+ // page-count/no-slice/visibility branches (step 2) and the reset-guard
325
+ // sites (step 3) — deliberately not narrowed by `paginate > 0` (see the
326
+ // reset-suppression call sites for why that widened scope is safe).
327
+ get #serverMode() { return this.hasAttribute('range-total'); }
328
+
329
+ // gh#1754/ADR-0082 REQ-D-003 (CodeRabbit, PR #1828) — a raw
330
+ // `Number(x) || 0` accepts negative and non-finite values verbatim (both
331
+ // are truthy), which can drive #pageCount/the footer's own range text
332
+ // negative or infinite. Clamps to a finite, non-negative integer.
333
+ #safeRangeTotal() {
334
+ const n = Number(this.rangeTotal);
335
+ return Number.isFinite(n) && n > 0 ? Math.trunc(n) : 0;
336
+ }
337
+
154
338
  // ── Public API: columns ──
155
339
 
156
340
  set columns(arr) {
@@ -165,7 +349,11 @@ export class UITable extends UIElement {
165
349
  set data(arr) {
166
350
  this.#data = Array.isArray(arr) ? arr : [];
167
351
  this.#selected.clear();
168
- this.#page = 0;
352
+ // gh#1754/ADR-0082 REQ-D-004 — in server mode a `.data` set is the
353
+ // consumer's fetch write-back for the CURRENT page; resetting to page 0
354
+ // here would fight the pager back to the start on every fetch. Selection
355
+ // still clears unconditionally (rows changed under it either way).
356
+ if (!this.#serverMode) this.#page = 0;
169
357
  this.#requestRender();
170
358
  }
171
359
 
@@ -229,7 +417,10 @@ export class UITable extends UIElement {
229
417
  } else {
230
418
  this.#filters.set(key, { op, value });
231
419
  }
232
- this.#page = 0;
420
+ // gh#1754/ADR-0082 REQ-D-004 — server mode: page state moves only via
421
+ // pager interaction, the `footer-page` command, or setState(); a filter
422
+ // change never resets it (the consumer's fetch loop owns page moves).
423
+ if (!this.#serverMode) this.#page = 0;
233
424
  this.#requestRender();
234
425
  this.dispatchEvent(new CustomEvent('filter-change', {
235
426
  detail: { filters: Object.fromEntries(this.#filters) },
@@ -240,7 +431,7 @@ export class UITable extends UIElement {
240
431
  clearFilters() {
241
432
  this.#filters.clear();
242
433
  this.#openFilter = null;
243
- this.#page = 0;
434
+ if (!this.#serverMode) this.#page = 0; // gh#1754/ADR-0082 REQ-D-004 — same suppression as setFilter above.
244
435
  this.#requestRender();
245
436
  this.dispatchEvent(new CustomEvent('filter-change', {
246
437
  detail: { filters: {} },
@@ -250,6 +441,88 @@ export class UITable extends UIElement {
250
441
 
251
442
  get filters() { return Object.fromEntries(this.#filters); }
252
443
 
444
+ // ── table-toolbar-ui command-event listeners (gh#1764/#1780, ADR-0079) ──
445
+ // Each handler applies the exact call table-toolbar.class.js used to make
446
+ // directly on this element before the events-only normalization — no
447
+ // behavior change, only the interaction shape.
448
+ //
449
+ // `e.target !== this` guard (code-checker finding, 2026-08-20): these
450
+ // events are dispatched `bubbles: true` (mirroring the ratified
451
+ // chart-legend-ui↔chart-ui model), so one fired at an INNER table-ui
452
+ // (e.g. inside an expanded row — table-ui's own row-expansion API makes
453
+ // a nested table a real composition) would otherwise also reach and
454
+ // apply to every ANCESTOR table-ui's listener. The old direct-write code
455
+ // had no such hazard — it targeted the resolved element precisely. The
456
+ // ratified model guards this exact case (`chart.class.js`'s
457
+ // `#onLegendToggle` returns unless the bubbled event's own [for] matches
458
+ // `this.id`); here the equivalent guard is simpler since these events are
459
+ // always dispatched directly at their intended table-ui, never through a
460
+ // [for] filter — `e.target !== this` is the correct, minimal check.
461
+
462
+ #onToolbarSearch = (e) => {
463
+ if (e.target !== this) return;
464
+ this.search = e.detail?.value ?? '';
465
+ };
466
+
467
+ #onToolbarFilterSet = (e) => {
468
+ if (e.target !== this) return;
469
+ const { key, value, op } = e.detail || {};
470
+ if (!key) return;
471
+ this.setFilter(key, value, op);
472
+ };
473
+
474
+ #onToolbarFilterClear = (e) => {
475
+ if (e.target !== this) return;
476
+ this.clearFilters();
477
+ };
478
+
479
+ #onToolbarColumnsSet = (e) => {
480
+ if (e.target !== this) return;
481
+ const columns = e.detail?.columns;
482
+ if (Array.isArray(columns)) this.columns = columns;
483
+ };
484
+
485
+ #onToolbarPaginate = (e) => {
486
+ if (e.target !== this) return;
487
+ this.paginate = Number(e.detail?.pageSize) || 0;
488
+ };
489
+
490
+ // gh#1807/ADR-0080, refined 0.2.0/ADR-0082 — `footer-page` is the
491
+ // footer→table page-navigation command (REQ-W-002), sender-prefixed like
492
+ // the five `toolbar-*` commands above but from a second sender
493
+ // (table-footer-ui). Applies whenever [paginate] > 0 — client mode AND
494
+ // table-authoritative server mode ([range-total] present, REQ-D-003)
495
+ // alike, since #pageCount already knows server math (step 2 above): the
496
+ // clamp below reads the right ceiling in either mode with no extra
497
+ // branching needed here. At [paginate="0"] the command stays a documented
498
+ // no-op in either mode — with no page size there is no page range to move
499
+ // within. Clamps to the valid page range and never throws (AC-004/AC-018);
500
+ // a non-numeric/NaN detail.page is silently ignored (no page change, no
501
+ // `page` notification).
502
+ #onFooterPage = (e) => {
503
+ if (e.target !== this) return;
504
+ if (!(this.paginate > 0)) return; // paginate=0: documented no-op, either mode
505
+ const n = Number(e.detail?.page);
506
+ if (!Number.isFinite(n)) return;
507
+ const clamped = Math.max(1, Math.min(Math.trunc(n), this.#pageCount));
508
+ this.#page = clamped - 1; // this.#page is 0-based internally
509
+ this.render();
510
+ this.dispatchEvent(new CustomEvent('page', {
511
+ bubbles: true,
512
+ detail: { page: this.#page },
513
+ }));
514
+ };
515
+
516
+ // ── Public API: filteredCount ──
517
+ // gh#1807/ADR-0080, REQ-W-006 — read-only. The row count AFTER search +
518
+ // column filters apply but BEFORE pagination slices it — exactly the
519
+ // number table-footer-ui's client-mode derivation needs for its
520
+ // range-total (a raw `.data.length` read, the toolbar's own `count`
521
+ // fallback precedent, would double-count a filtered-out row). Undocumented
522
+ // in table.yaml by design, matching `.selected`/`.sortState`/`.filters` —
523
+ // a plain read-only JS getter, not a reflected attribute.
524
+ get filteredCount() { return this.#getProcessedIndices().length; }
525
+
253
526
  // ── Public API: expansion ──
254
527
 
255
528
  toggleExpand(index) {
@@ -369,8 +642,12 @@ export class UITable extends UIElement {
369
642
  } catch (_) { /* malformed JSON — leave empty, render() shows empty state */ }
370
643
  }
371
644
 
372
- this.setAttribute('role', 'grid');
373
- this.setAttribute('tabindex', '0');
645
+ // gh#1678 — idempotent: a byte-identical SSR-rendered host already
646
+ // carries both attributes, and an unconditional setAttribute() here
647
+ // would still queue a mutation record even though the value never
648
+ // actually changes.
649
+ setAttrIfChanged(this, 'role', 'grid');
650
+ setAttrIfChanged(this, 'tabindex', '0');
374
651
 
375
652
  // Restore persisted state
376
653
  this.#restoreState();
@@ -386,6 +663,21 @@ export class UITable extends UIElement {
386
663
  // pen automatically; Playwright's page.mouse.* synthesizes pointer
387
664
  // events (not mouse events), so a mousedown-only handler tests dead.
388
665
  this.addEventListener('pointerdown', this.#onPointerdown);
666
+ // gh#1764/#1780 (events-only interaction contract, ADR-0079) — the
667
+ // `toolbar-*` command events table-toolbar-ui dispatches AT this
668
+ // element in place of the direct property writes / method calls it
669
+ // used to make. Each handler applies the exact same public-API call
670
+ // the toolbar previously made directly, so observable behavior is
671
+ // unchanged; only the interaction shape moved to events (mirrors
672
+ // chart-legend-ui↔chart-ui's own bubbling-CustomEvent model).
673
+ this.addEventListener('toolbar-search', this.#onToolbarSearch);
674
+ this.addEventListener('toolbar-filter-set', this.#onToolbarFilterSet);
675
+ this.addEventListener('toolbar-filter-clear', this.#onToolbarFilterClear);
676
+ this.addEventListener('toolbar-columns-set', this.#onToolbarColumnsSet);
677
+ this.addEventListener('toolbar-paginate', this.#onToolbarPaginate);
678
+ // gh#1807/ADR-0080 — a second sender-prefixed command, alongside the
679
+ // five toolbar-* listeners above (same symmetric-lifecycle rule).
680
+ this.addEventListener('footer-page', this.#onFooterPage);
389
681
  }
390
682
  }
391
683
 
@@ -393,6 +685,12 @@ export class UITable extends UIElement {
393
685
  this.removeEventListener('click', this.#onClick);
394
686
  this.removeEventListener('keydown', this.#onKeydown);
395
687
  this.removeEventListener('pointerdown', this.#onPointerdown);
688
+ this.removeEventListener('toolbar-search', this.#onToolbarSearch);
689
+ this.removeEventListener('toolbar-filter-set', this.#onToolbarFilterSet);
690
+ this.removeEventListener('toolbar-filter-clear', this.#onToolbarFilterClear);
691
+ this.removeEventListener('toolbar-columns-set', this.#onToolbarColumnsSet);
692
+ this.removeEventListener('toolbar-paginate', this.#onToolbarPaginate);
693
+ this.removeEventListener('footer-page', this.#onFooterPage);
396
694
  this.#bound = false;
397
695
  this.#cleanupResize();
398
696
  if (this.#renderRaf) {
@@ -405,6 +703,21 @@ export class UITable extends UIElement {
405
703
  }
406
704
  }
407
705
 
706
+ // gh#1754/ADR-0082 — ported from table-footer.class.js's own
707
+ // attributeChangedCallback gotcha (its rangeTotal, same default-0 shape).
708
+ // [range-total]'s default is already 0 (Number), so an absent → "0"
709
+ // transition parses to the SAME coerced value; the base class property
710
+ // setter's Object.is short-circuit (element.js installProps) swallows the
711
+ // signal write, and the reactive render effect never re-runs on its own.
712
+ // This is a presence concern (#serverMode reads hasAttribute, not the
713
+ // coerced value), not a value one. Batched via #requestRender — never
714
+ // render() directly — honoring the R7 render-clock discipline (no edits to
715
+ // #requestRender/#renderRaf themselves).
716
+ attributeChangedCallback(name, oldVal, newVal) {
717
+ super.attributeChangedCallback(name, oldVal, newVal);
718
+ if (name === 'range-total') this.#requestRender();
719
+ }
720
+
408
721
  // ── <col-def> Parsing ──────────────────────────────────────────────────────
409
722
 
410
723
  #parseColDefs() {
@@ -600,9 +913,15 @@ export class UITable extends UIElement {
600
913
  }
601
914
 
602
915
  /**
603
- * Paginate a set of indices.
916
+ * Paginate a set of indices. gh#1754/ADR-0082 REQ-D-003 — server mode:
917
+ * `.data` IS the current page already (the consumer sliced it server-side),
918
+ * so no local slicing runs; [paginate] supplies only the page-size for
919
+ * pager math. `indices` here has already had local search/sort/filter
920
+ * applied by #getProcessedIndices — server mode still renders that whole
921
+ * (unsliced) result.
604
922
  */
605
923
  #getPageSlice(indices) {
924
+ if (this.#serverMode) return indices;
606
925
  if (!this.paginate || this.paginate <= 0) return indices;
607
926
  const start = this.#page * this.paginate;
608
927
  return indices.slice(start, start + this.paginate);
@@ -610,6 +929,11 @@ export class UITable extends UIElement {
610
929
 
611
930
  get #pageCount() {
612
931
  if (!this.paginate || this.paginate <= 0) return 1;
932
+ // gh#1754/ADR-0082 REQ-D-003 — server mode: page count is server-total-
933
+ // derived, not the loaded page's own length (the exact dual-math drift
934
+ // gh#1754's evidence quoted — this is now the ONE calc site;
935
+ // #renderPagination reads this getter instead of recomputing).
936
+ if (this.#serverMode) return Math.max(1, Math.ceil(this.#safeRangeTotal() / this.paginate));
613
937
  // pageCount should reflect filtered data, not raw data
614
938
  const filteredCount = this.#getProcessedIndices().length;
615
939
  return Math.max(1, Math.ceil(filteredCount / this.paginate));
@@ -629,8 +953,13 @@ export class UITable extends UIElement {
629
953
 
630
954
  const visCols = this.#visibleColumns;
631
955
 
632
- // Set grid template
633
- this.style.gridTemplateColumns = this.#buildGridTemplate();
956
+ // Set grid template — gh#1678: guard the write, same rationale as the
957
+ // host attribute sets in connected(). Assigning the identical string
958
+ // again is still a real style-attribute mutation, not a no-op.
959
+ const gridTemplate = this.#buildGridTemplate();
960
+ if (this.style.gridTemplateColumns !== gridTemplate) {
961
+ this.style.gridTemplateColumns = gridTemplate;
962
+ }
634
963
 
635
964
  // ── Header row ──
636
965
 
@@ -669,13 +998,12 @@ export class UITable extends UIElement {
669
998
  const row = existing || this.#createRow(idx, visCols);
670
999
  if (existing) this.#updateRow(existing, idx, visCols);
671
1000
 
672
- // Expand toggle in first cell
673
- if (this.#isRowExpandable(idx)) {
674
- const isExpanded = this.#expanded.has(idx);
675
- if (isExpanded) row.setAttribute('data-expanded', '');
676
- else row.removeAttribute('data-expanded');
1001
+ // Expand toggle in first cell — gh#1678: idempotent (see the
1002
+ // module-level helpers' own rationale).
1003
+ if (this.#isRowExpandable(idx) && this.#expanded.has(idx)) {
1004
+ setAttrIfChanged(row, 'data-expanded', '');
677
1005
  } else {
678
- row.removeAttribute('data-expanded');
1006
+ removeAttrIfPresent(row, 'data-expanded');
679
1007
  }
680
1008
 
681
1009
  bodyChildren.push(row);
@@ -719,7 +1047,15 @@ export class UITable extends UIElement {
719
1047
 
720
1048
  // ── Pagination footer ──
721
1049
 
722
- const showPagination = this.paginate > 0 && this.#data.length > 0;
1050
+ // gh#1807/ADR-0080 REQ-W-005 [no-pager] hides only the internally
1051
+ // stamped bar; slicing/page-state/events are untouched (they don't
1052
+ // route through this flag at all).
1053
+ // gh#1754/ADR-0082 REQ-D-005 — server mode drops the client-mode
1054
+ // `data.length > 0` term: an in-flight fetch (momentarily empty `.data`)
1055
+ // must not flicker the pager away, so visibility gates on `range-total`
1056
+ // instead.
1057
+ const showPagination = this.paginate > 0 && !this.noPager
1058
+ && (this.#serverMode ? this.#safeRangeTotal() > 0 : this.#data.length > 0);
723
1059
  let footer = this.querySelector(':scope > [data-footer]');
724
1060
 
725
1061
  if (showPagination) {
@@ -728,7 +1064,7 @@ export class UITable extends UIElement {
728
1064
  footer.setAttribute('data-footer', '');
729
1065
  this.appendChild(footer);
730
1066
  }
731
- this.#renderPagination(footer, allProcessed.length);
1067
+ this.#renderPagination(footer);
732
1068
  } else if (footer) {
733
1069
  footer.remove();
734
1070
  }
@@ -830,12 +1166,10 @@ export class UITable extends UIElement {
830
1166
  cells.push(cell);
831
1167
  }
832
1168
 
833
- // Reconcile header cells
834
- while (header.children.length > cells.length) header.lastChild.remove();
835
- for (let i = 0; i < cells.length; i++) {
836
- if (header.children[i]) header.replaceChild(cells[i], header.children[i]);
837
- else header.appendChild(cells[i]);
838
- }
1169
+ // Reconcile header cells — gh#1678: adopt-or-diff instead of an
1170
+ // unconditional replaceChild per position (see the module-level
1171
+ // adoptOrDiffChildren() doc comment for why this is safe under SSR).
1172
+ adoptOrDiffChildren(header, cells);
839
1173
  }
840
1174
 
841
1175
  // ── Row Builders ───────────────────────────────────────────────────────────
@@ -850,12 +1184,16 @@ export class UITable extends UIElement {
850
1184
  #updateRow(row, dataIndex, visCols) {
851
1185
  const data = this.#data[dataIndex];
852
1186
  const isSelected = this.#selected.has(dataIndex);
853
- row.dataset.index = dataIndex;
1187
+ // gh#1678 — idempotent: `#updateRow` runs on an ADOPTED (SSR-matched)
1188
+ // row exactly as often as on a freshly-created one, and a plain
1189
+ // `row.dataset.index = …` / `setAttribute` always mutates regardless of
1190
+ // whether the value already matches.
1191
+ setAttrIfChanged(row, 'data-index', String(dataIndex));
854
1192
 
855
1193
  if (isSelected) {
856
- row.setAttribute('aria-selected', 'true');
1194
+ setAttrIfChanged(row, 'aria-selected', 'true');
857
1195
  } else {
858
- row.removeAttribute('aria-selected');
1196
+ removeAttrIfPresent(row, 'aria-selected');
859
1197
  }
860
1198
 
861
1199
  const cells = [];
@@ -901,6 +1239,12 @@ export class UITable extends UIElement {
901
1239
  const value = getCellValue(data, col);
902
1240
 
903
1241
  // Render priority: column.render > column.format > cellType.render > text
1242
+ // gh#1678 CodeRabbit follow-up: `col.render()` and a cell-type renderer
1243
+ // are free to attach listeners / runtime state to the node they hand
1244
+ // back — mark the cell RENDERER_OWNED so adoptOrDiffChildren() never
1245
+ // adopts it on structural equality alone (see that function's own doc
1246
+ // comment). `col.format()` and the plain-text fallback below build
1247
+ // nothing but a static <span>, so they're never marked.
904
1248
  if (typeof col.render === 'function') {
905
1249
  const result = col.render(value, data, cell, dataIndex);
906
1250
  if (isNode(result)) {
@@ -908,6 +1252,7 @@ export class UITable extends UIElement {
908
1252
  } else if (typeof result === 'string') {
909
1253
  cell.innerHTML = result;
910
1254
  }
1255
+ RENDERER_OWNED.add(cell);
911
1256
  } else if (typeof col.format === 'function') {
912
1257
  const t = col.format(value, data);
913
1258
  cell.appendChild(Object.assign(document.createElement('span'), { textContent: t }));
@@ -916,6 +1261,16 @@ export class UITable extends UIElement {
916
1261
  const typeDef = cellTypes[col.type || 'text'];
917
1262
  if (typeDef?.render) {
918
1263
  typeDef.render(value, data, cell, col.meta);
1264
+ // Most built-in cell-type renderers only set attributes on
1265
+ // custom elements (badge-ui, avatar-ui, check-ui, …) — safe to
1266
+ // adopt structurally, since their own runtime behavior lives in
1267
+ // their own connectedCallback, independent of node identity. Only
1268
+ // a type that declares `attachesListeners` (currently: 'actions',
1269
+ // which calls addEventListener() directly) is marked renderer-
1270
+ // owned; marking every typeDef.render() cell would defeat the
1271
+ // zero-mutation adoption for the overwhelming common case (plain
1272
+ // text/number/date/badge/... cells) for no correctness benefit.
1273
+ if (typeDef.attachesListeners) RENDERER_OWNED.add(cell);
919
1274
  } else {
920
1275
  const t = value != null ? String(value) : '';
921
1276
  cell.appendChild(Object.assign(document.createElement('span'), { textContent: t }));
@@ -949,12 +1304,13 @@ export class UITable extends UIElement {
949
1304
  cells.push(cell);
950
1305
  }
951
1306
 
952
- // Reconcile cells in row
953
- while (row.children.length > cells.length) row.lastChild.remove();
954
- for (let i = 0; i < cells.length; i++) {
955
- if (row.children[i]) row.replaceChild(cells[i], row.children[i]);
956
- else row.appendChild(cells[i]);
957
- }
1307
+ // Reconcile cells in row — gh#1678: adopt-or-diff (see
1308
+ // adoptOrDiffChildren()'s doc comment). This is the fix's main hot
1309
+ // path: `#updateRow` is called on every already-adopted row found by
1310
+ // `data-index` at connect, so this is what stops a byte-identical
1311
+ // SSR-rendered row from having every one of its cells rebuilt at
1312
+ // upgrade.
1313
+ adoptOrDiffChildren(row, cells);
958
1314
  }
959
1315
 
960
1316
  // ── Overlays ───────────────────────────────────────────────────────────────
@@ -1084,10 +1440,14 @@ export class UITable extends UIElement {
1084
1440
  default: result = '';
1085
1441
  }
1086
1442
 
1087
- // Format using cell type if available
1443
+ // Format using cell type if available. gh#1678 CodeRabbit follow-up:
1444
+ // same attachesListeners-only scoping as the body-row cell path
1445
+ // above — only a cell type that declares it attaches listeners
1446
+ // needs the always-replace guard.
1088
1447
  const typeDef = cellTypes[col.type || 'text'];
1089
1448
  if (typeof result === 'number' && typeDef?.render) {
1090
1449
  typeDef.render(result, {}, cell, col.meta);
1450
+ if (typeDef.attachesListeners) RENDERER_OWNED.add(cell);
1091
1451
  } else {
1092
1452
  cell.textContent = typeof result === 'number' ? result.toLocaleString() : result;
1093
1453
  }
@@ -1096,17 +1456,16 @@ export class UITable extends UIElement {
1096
1456
  cells.push(cell);
1097
1457
  }
1098
1458
 
1099
- // Reconcile cells
1100
- while (aggRow.children.length > cells.length) aggRow.lastChild.remove();
1101
- for (let i = 0; i < cells.length; i++) {
1102
- if (aggRow.children[i]) aggRow.replaceChild(cells[i], aggRow.children[i]);
1103
- else aggRow.appendChild(cells[i]);
1104
- }
1459
+ // Reconcile cells — gh#1678: adopt-or-diff, same helper as the header
1460
+ // and body-row cell reconciliation above (consistency; the aggregation
1461
+ // row is built from the exact same per-cell full-rebuild shape those
1462
+ // had).
1463
+ adoptOrDiffChildren(aggRow, cells);
1105
1464
  }
1106
1465
 
1107
1466
  // ── Pagination ─────────────────────────────────────────────────────────────
1108
1467
 
1109
- #renderPagination(footer, filteredTotal) {
1468
+ #renderPagination(footer) {
1110
1469
  let pag = footer.querySelector('pagination-ui');
1111
1470
  if (!pag) {
1112
1471
  footer.innerHTML = '';
@@ -1121,10 +1480,16 @@ export class UITable extends UIElement {
1121
1480
  });
1122
1481
  footer.appendChild(pag);
1123
1482
  }
1124
- const pageCount = this.paginate > 0
1125
- ? Math.max(1, Math.ceil(filteredTotal / this.paginate))
1126
- : 1;
1127
- pag.setAttribute('page', String(this.#page + 1));
1483
+ // gh#1754/ADR-0082 — #pageCount is now the single calc site (client AND
1484
+ // server math); the duplicated `ceil(filteredTotal / paginate)` this
1485
+ // function used to recompute is gone — that dual-math drift is exactly
1486
+ // gh#1754's evidence. Clamp only the RENDERED page attribute (a display
1487
+ // concern, e.g. the server total shrank below the current page) — never
1488
+ // mutate `#page` or dispatch `page` from render (REQ-D-005: a render must
1489
+ // never self-trigger a fetch).
1490
+ const pageCount = this.#pageCount;
1491
+ const p = Math.min(this.#page, pageCount - 1);
1492
+ pag.setAttribute('page', String(p + 1));
1128
1493
  pag.setAttribute('total', String(pageCount));
1129
1494
  }
1130
1495