@adia-ai/web-components 0.8.44 → 0.8.45

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 (43) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/components/context-menu/context-menu.a2ui.json +8 -3
  3. package/components/context-menu/context-menu.class.js +46 -5
  4. package/components/context-menu/context-menu.d.ts +6 -3
  5. package/components/context-menu/context-menu.examples.md +2 -2
  6. package/components/context-menu/context-menu.yaml +22 -5
  7. package/components/nav/nav.a2ui.json +2 -2
  8. package/components/nav/nav.css +1 -1
  9. package/components/nav/nav.d.ts +1 -1
  10. package/components/nav/nav.yaml +14 -3
  11. package/components/nav-group/nav-group.css +37 -3
  12. package/components/pagination/pagination.class.js +52 -22
  13. package/components/search/search.class.js +39 -5
  14. package/components/select/select.a2ui.json +5 -0
  15. package/components/select/select.class.js +20 -0
  16. package/components/select/select.css +25 -0
  17. package/components/select/select.d.ts +2 -0
  18. package/components/select/select.yaml +12 -0
  19. package/components/table/cell-types.js +9 -0
  20. package/components/table/table.class.js +247 -33
  21. package/components/table/table.css +7 -4
  22. package/components/table/table.yaml +6 -1
  23. package/components/table-toolbar/table-toolbar.a2ui.json +15 -0
  24. package/components/table-toolbar/table-toolbar.class.js +61 -17
  25. package/components/table-toolbar/table-toolbar.css +34 -0
  26. package/components/table-toolbar/table-toolbar.d.ts +10 -0
  27. package/components/table-toolbar/table-toolbar.yaml +41 -9
  28. package/core/data-stream.js +37 -2
  29. package/core/index.d.ts +1 -0
  30. package/core/index.js +1 -0
  31. package/core/provider.d.ts +9 -13
  32. package/core/provider.js +9 -113
  33. package/core/store.d.ts +46 -0
  34. package/core/store.js +89 -0
  35. package/custom-elements.json +54 -4
  36. package/dist/host.min.css +1 -1
  37. package/dist/host.sheet.js +1 -1
  38. package/dist/theme-provider.min.js +1 -1
  39. package/dist/web-components.min.css +1 -1
  40. package/dist/web-components.min.js +93 -93
  41. package/dist/web-components.sheet.js +1 -1
  42. package/package.json +1 -1
  43. package/styles/api/sizing.css +46 -0
@@ -98,6 +98,31 @@
98
98
  :scope:not([label])::before,
99
99
  :scope[label=""]::before { display: none; }
100
100
 
101
+ /* Visually-hidden label (gh#1748) — [label-hidden] keeps the a11y name
102
+ (aria-label, bridged from [label] in select.class.js's
103
+ #syncAccessibleName()) but drops the visible text, for compositions
104
+ that already show the name elsewhere (e.g. a table-toolbar-ui
105
+ [slot="scope"] select whose scope/view name is redundant chrome next
106
+ to the toolbar's own title). Same canonical sr-only recipe as
107
+ <visually-hidden-ui> (visually-hidden.css) and check-ui's own
108
+ [label-hidden] (gh#1010) — clip-path does the real hiding, the rest is
109
+ belt-and-suspenders for cross-UA. More specific than the bare
110
+ `::before` rule above, so it wins whenever both [label] and
111
+ [label-hidden] are set. */
112
+ :scope[label][label-hidden]::before {
113
+ content: attr(label);
114
+ position: absolute !important;
115
+ width: 1px !important;
116
+ height: 1px !important;
117
+ padding: 0 !important;
118
+ margin: -1px !important;
119
+ overflow: hidden !important;
120
+ clip: rect(0, 0, 0, 0) !important;
121
+ clip-path: inset(100%) !important;
122
+ white-space: nowrap !important;
123
+ border: 0 !important;
124
+ }
125
+
101
126
  /* Trigger */
102
127
  [slot="trigger"] {
103
128
  display: flex;
@@ -45,6 +45,8 @@ export class UISelect extends UIFormElement {
45
45
  /** Open/closed reflected attribute — toggled by trigger click / keyboard. */
46
46
  open: boolean;
47
47
  label: string;
48
+ /** When true, [label] still sets the accessible name but the visible text is sr-only hidden (gh#1748, mirrors check-ui's [label-hidden], gh#1010). */
49
+ labelHidden: boolean;
48
50
  /** Leading icon name (Phosphor registry). */
49
51
  icon: string;
50
52
  /** Leading avatar URL or name. */
@@ -104,6 +104,18 @@ props:
104
104
  description: Label text above the trigger
105
105
  type: string
106
106
  default: ""
107
+ label-hidden:
108
+ description: >-
109
+ When true, [label] still sets the accessible name (aria-label) but the
110
+ visible `::before` text is suppressed via the canonical sr-only
111
+ technique (gh#1748, mirrors check-ui's [label-hidden], gh#1010). Use
112
+ when a sibling/ancestor composition already conveys the same name
113
+ visually (e.g. a scope/view-switcher select in a table-toolbar-ui
114
+ [slot="scope"], where the toolbar's own title already names the
115
+ view) and a second visible "Scope"-style label would paint it twice.
116
+ type: boolean
117
+ default: false
118
+ reflect: true
107
119
  hint:
108
120
  description: |-
109
121
  §184 (v0.5.5, FEEDBACK-08 §7): small caption rendered beneath the select. Sets `aria-describedby` on the host so screen readers announce it as a description (distinct from `aria-label`, which comes from `label`). Does not conflict with the in-component `label`.
@@ -312,6 +312,15 @@ registerCellType('progress', {
312
312
 
313
313
  // 13. actions
314
314
  registerCellType('actions', {
315
+ // gh#1678 CodeRabbit follow-up: this render() calls addEventListener()
316
+ // directly on a plain node it creates — unlike every other cell type
317
+ // above (which only set attributes on custom elements, safe to adopt
318
+ // structurally), a discarded 'actions' cell loses a real listener with
319
+ // no way for isEqualNode() to see the loss. `attachesListeners: true`
320
+ // tells table.class.js's adoptOrDiffChildren() (via RENDERER_OWNED) to
321
+ // always replace this cell rather than ever adopt it on structural
322
+ // equality alone. See ssr-compatibility's references/guard-patterns.md §4.1.
323
+ attachesListeners: true,
315
324
  render(value, row, cell, meta) {
316
325
  const actions = meta?.actions || [];
317
326
  let wrapper = cell.querySelector('row-ui');
@@ -45,13 +45,22 @@
45
45
  * .sortState → [{key, dir}] (read-only)
46
46
  * .exportCSV(filename?)
47
47
  *
48
- * Events:
48
+ * Events (fired):
49
49
  * sort — { detail: { key, dir, sortState } }
50
50
  * select — { detail: { selected: [...indices] } }
51
51
  * page — { detail: { page } }
52
52
  * resize — { detail: { key, width } }
53
53
  * cell-click — { detail: { key, row, value, dataIndex } }
54
54
  * row-click — { detail: { row, dataIndex } } — row-level companion to cell-click
55
+ *
56
+ * Events (listened) — gh#1764/#1780, ADR-0079. table-toolbar-ui dispatches
57
+ * these directly at its [for]-resolved table-ui target instead of writing
58
+ * a property or calling a method; any other consumer may dispatch them too:
59
+ * toolbar-search — { detail: { value } } → this.search =
60
+ * toolbar-filter-set — { detail: { key, value, op } } → this.setFilter(key, value, op)
61
+ * toolbar-filter-clear — { detail: {} } → this.clearFilters()
62
+ * toolbar-columns-set — { detail: { columns } } → this.columns =
63
+ * toolbar-paginate — { detail: { pageSize } } → this.paginate =
55
64
  */
56
65
 
57
66
  import { UIElement } from '../../core/element.js';
@@ -94,6 +103,117 @@ function csvEscape(val) {
94
103
  return str;
95
104
  }
96
105
 
106
+ // ── SSR adopt-in-place (gh#1678) ─────────────────────────────────────────────
107
+
108
+ /**
109
+ * Idempotent attribute set — skip the DOM write entirely when the value
110
+ * already matches. `setAttribute` mutates (and queues a MutationObserver
111
+ * record) even when the new value is byte-identical to the old one, so a
112
+ * host/row-level attribute this render path sets on EVERY call (`role`,
113
+ * `tabindex`, `data-index`, `aria-selected`) must compare-before-write to
114
+ * let a byte-identical SSR-rendered subtree survive upgrade with zero
115
+ * subtree mutations (AC-004a-equivalent probe) — bare `setAttribute` cannot
116
+ * give that guarantee even when the value never actually changes.
117
+ */
118
+ function setAttrIfChanged(el, name, value) {
119
+ if (el.getAttribute(name) !== value) el.setAttribute(name, value);
120
+ }
121
+
122
+ /** Idempotent attribute removal — same rationale as setAttrIfChanged. */
123
+ function removeAttrIfPresent(el, name) {
124
+ if (el.hasAttribute(name)) el.removeAttribute(name);
125
+ }
126
+
127
+ /**
128
+ * Marks a freshly-built candidate cell as renderer-owned — its content came
129
+ * from something free to attach an event listener or otherwise stash
130
+ * runtime state on the node it returns, where `Node.isEqualNode()` (the
131
+ * adopt-or-diff structural check below) has no way to see the difference
132
+ * (it compares tag/attributes/text/descendants only). Two, and only two,
133
+ * sources are marked:
134
+ *
135
+ * 1. `col.render()` — arbitrary consumer code. It can do anything to the
136
+ * node it hands back, so it's always marked; there's no way to inspect
137
+ * an opaque function for safety.
138
+ * 2. A built-in cell-type renderer (`typeDef.render`) that declares
139
+ * `attachesListeners: true` (currently only `cellTypes.actions`, which
140
+ * calls `addEventListener()` directly — see cell-types.js).
141
+ *
142
+ * Deliberately NOT marked: `col.format()`, the plain-text fallback, and
143
+ * every OTHER built-in cell-type renderer (text/number/currency/percent/
144
+ * date/datetime/boolean/badge/avatar/link/markdown/progress) — each of
145
+ * those only sets attributes on already-declarative custom elements
146
+ * (badge-ui, avatar-ui, check-ui, progress-ui, …) or plain nodes with no
147
+ * listeners; their own runtime behavior lives in their OWN
148
+ * `connectedCallback`, which fires independent of whether table.class.js
149
+ * discards or adopts the cell that contains them. Marking every
150
+ * `typeDef.render` cell unconditionally (an earlier draft of this fix) was
151
+ * tried and reverted — it broke the AC-004a zero-mutation acceptance
152
+ * criterion for the overwhelmingly common case (plain text/number/date
153
+ * cells) for no correctness benefit, since none of those renderers ever
154
+ * attach a listener. A cell that discards without ever being marked here
155
+ * (and turns out later to have needed it) is exactly the gap
156
+ * `guard-patterns.md` §4.1 asks a new interactive cell type to close by
157
+ * declaring `attachesListeners: true` on itself.
158
+ *
159
+ * A renderer-owned cell that happens to be structurally identical to the
160
+ * existing DOM would, without this guard, silently keep the OLD node (and
161
+ * its stale/missing listener) instead of the fresh one the renderer just
162
+ * built (gh#1678 CodeRabbit finding, table.class.js `adoptOrDiffChildren`
163
+ * L141-150 as originally shipped).
164
+ */
165
+ const RENDERER_OWNED = new WeakSet();
166
+
167
+ /**
168
+ * Adopt-or-diff child reconciliation — the core of table-ui's SSR
169
+ * adopt-in-place fix. `freshChildren` are already-built, detached candidate
170
+ * nodes (header cells, row cells, …) computed exactly as this file always
171
+ * computed them. Rather than unconditionally `replaceChild`-ing every
172
+ * position (the old, SSR-hostile behavior — a byte-identical server-
173
+ * rendered subtree got fully rebuilt at upgrade regardless), each position
174
+ * is compared against its EXISTING child with `Node.isEqualNode()` — a
175
+ * standard, structural + attribute + text deep-equality check present in
176
+ * every environment this framework runs under (real browsers, happy-dom,
177
+ * and linkedom under SSR — confirmed directly; unlike the browser-only APIs
178
+ * `ssr-compatibility`'s guard-patterns.md §1 guards, `isEqualNode` is core
179
+ * DOM and never absent). A match discards the freshly-built candidate and
180
+ * leaves the live DOM completely untouched — the "adopt" half. A mismatch
181
+ * replaces the position wholesale exactly as before — the "fall back to
182
+ * full rebuild" half. Never a partial/half-adopt: a position either
183
+ * survives byte-for-byte or gets fully rebuilt, matching the shape
184
+ * pagination-ui's own first-connect adoption fix (gh#1687) established for
185
+ * its flat, keyed item list — generalized here to an arbitrary positional
186
+ * child (header cells, row cells) via a value check instead of a
187
+ * shape/key check, since these cells have no stable identity key of their
188
+ * own the way a pagination item does.
189
+ *
190
+ * **Renderer-owned exception (gh#1678 CodeRabbit follow-up):** structural
191
+ * equality is a necessary but not sufficient adoption test — it says
192
+ * nothing about listeners or other runtime state a renderer attached to the
193
+ * node it returned. A cell in `RENDERER_OWNED` is therefore ALWAYS replaced
194
+ * with its fresh candidate, never adopted via the structural-equality path,
195
+ * even when `isEqualNode()` would have reported a match — correctness over
196
+ * the zero-mutation optimization for exactly the cells where structural
197
+ * equality can't prove the node is safe to keep. See
198
+ * `ssr-compatibility`'s `references/failure-shapes.md` §5.1 and
199
+ * `references/guard-patterns.md` §4.1 for the general shape.
200
+ */
201
+ function adoptOrDiffChildren(container, freshChildren) {
202
+ while (container.children.length > freshChildren.length) container.lastChild.remove();
203
+ for (let i = 0; i < freshChildren.length; i++) {
204
+ const existing = container.children[i];
205
+ const fresh = freshChildren[i];
206
+ if (!existing) {
207
+ container.appendChild(fresh);
208
+ } else if (RENDERER_OWNED.has(fresh) || !existing.isEqualNode(fresh)) {
209
+ container.replaceChild(fresh, existing);
210
+ }
211
+ // else: existing already matches fresh byte-for-byte AND fresh carries
212
+ // no renderer-owned runtime state — adopt in place, discard the
213
+ // candidate, touch nothing.
214
+ }
215
+ }
216
+
97
217
  // ── Component ────────────────────────────────────────────────────────────────
98
218
 
99
219
  export class UITable extends UIElement {
@@ -250,6 +370,52 @@ export class UITable extends UIElement {
250
370
 
251
371
  get filters() { return Object.fromEntries(this.#filters); }
252
372
 
373
+ // ── table-toolbar-ui command-event listeners (gh#1764/#1780, ADR-0079) ──
374
+ // Each handler applies the exact call table-toolbar.class.js used to make
375
+ // directly on this element before the events-only normalization — no
376
+ // behavior change, only the interaction shape.
377
+ //
378
+ // `e.target !== this` guard (code-checker finding, 2026-08-20): these
379
+ // events are dispatched `bubbles: true` (mirroring the ratified
380
+ // chart-legend-ui↔chart-ui model), so one fired at an INNER table-ui
381
+ // (e.g. inside an expanded row — table-ui's own row-expansion API makes
382
+ // a nested table a real composition) would otherwise also reach and
383
+ // apply to every ANCESTOR table-ui's listener. The old direct-write code
384
+ // had no such hazard — it targeted the resolved element precisely. The
385
+ // ratified model guards this exact case (`chart.class.js`'s
386
+ // `#onLegendToggle` returns unless the bubbled event's own [for] matches
387
+ // `this.id`); here the equivalent guard is simpler since these events are
388
+ // always dispatched directly at their intended table-ui, never through a
389
+ // [for] filter — `e.target !== this` is the correct, minimal check.
390
+
391
+ #onToolbarSearch = (e) => {
392
+ if (e.target !== this) return;
393
+ this.search = e.detail?.value ?? '';
394
+ };
395
+
396
+ #onToolbarFilterSet = (e) => {
397
+ if (e.target !== this) return;
398
+ const { key, value, op } = e.detail || {};
399
+ if (!key) return;
400
+ this.setFilter(key, value, op);
401
+ };
402
+
403
+ #onToolbarFilterClear = (e) => {
404
+ if (e.target !== this) return;
405
+ this.clearFilters();
406
+ };
407
+
408
+ #onToolbarColumnsSet = (e) => {
409
+ if (e.target !== this) return;
410
+ const columns = e.detail?.columns;
411
+ if (Array.isArray(columns)) this.columns = columns;
412
+ };
413
+
414
+ #onToolbarPaginate = (e) => {
415
+ if (e.target !== this) return;
416
+ this.paginate = Number(e.detail?.pageSize) || 0;
417
+ };
418
+
253
419
  // ── Public API: expansion ──
254
420
 
255
421
  toggleExpand(index) {
@@ -369,8 +535,12 @@ export class UITable extends UIElement {
369
535
  } catch (_) { /* malformed JSON — leave empty, render() shows empty state */ }
370
536
  }
371
537
 
372
- this.setAttribute('role', 'grid');
373
- this.setAttribute('tabindex', '0');
538
+ // gh#1678 — idempotent: a byte-identical SSR-rendered host already
539
+ // carries both attributes, and an unconditional setAttribute() here
540
+ // would still queue a mutation record even though the value never
541
+ // actually changes.
542
+ setAttrIfChanged(this, 'role', 'grid');
543
+ setAttrIfChanged(this, 'tabindex', '0');
374
544
 
375
545
  // Restore persisted state
376
546
  this.#restoreState();
@@ -386,6 +556,18 @@ export class UITable extends UIElement {
386
556
  // pen automatically; Playwright's page.mouse.* synthesizes pointer
387
557
  // events (not mouse events), so a mousedown-only handler tests dead.
388
558
  this.addEventListener('pointerdown', this.#onPointerdown);
559
+ // gh#1764/#1780 (events-only interaction contract, ADR-0079) — the
560
+ // `toolbar-*` command events table-toolbar-ui dispatches AT this
561
+ // element in place of the direct property writes / method calls it
562
+ // used to make. Each handler applies the exact same public-API call
563
+ // the toolbar previously made directly, so observable behavior is
564
+ // unchanged; only the interaction shape moved to events (mirrors
565
+ // chart-legend-ui↔chart-ui's own bubbling-CustomEvent model).
566
+ this.addEventListener('toolbar-search', this.#onToolbarSearch);
567
+ this.addEventListener('toolbar-filter-set', this.#onToolbarFilterSet);
568
+ this.addEventListener('toolbar-filter-clear', this.#onToolbarFilterClear);
569
+ this.addEventListener('toolbar-columns-set', this.#onToolbarColumnsSet);
570
+ this.addEventListener('toolbar-paginate', this.#onToolbarPaginate);
389
571
  }
390
572
  }
391
573
 
@@ -393,6 +575,11 @@ export class UITable extends UIElement {
393
575
  this.removeEventListener('click', this.#onClick);
394
576
  this.removeEventListener('keydown', this.#onKeydown);
395
577
  this.removeEventListener('pointerdown', this.#onPointerdown);
578
+ this.removeEventListener('toolbar-search', this.#onToolbarSearch);
579
+ this.removeEventListener('toolbar-filter-set', this.#onToolbarFilterSet);
580
+ this.removeEventListener('toolbar-filter-clear', this.#onToolbarFilterClear);
581
+ this.removeEventListener('toolbar-columns-set', this.#onToolbarColumnsSet);
582
+ this.removeEventListener('toolbar-paginate', this.#onToolbarPaginate);
396
583
  this.#bound = false;
397
584
  this.#cleanupResize();
398
585
  if (this.#renderRaf) {
@@ -629,8 +816,13 @@ export class UITable extends UIElement {
629
816
 
630
817
  const visCols = this.#visibleColumns;
631
818
 
632
- // Set grid template
633
- this.style.gridTemplateColumns = this.#buildGridTemplate();
819
+ // Set grid template — gh#1678: guard the write, same rationale as the
820
+ // host attribute sets in connected(). Assigning the identical string
821
+ // again is still a real style-attribute mutation, not a no-op.
822
+ const gridTemplate = this.#buildGridTemplate();
823
+ if (this.style.gridTemplateColumns !== gridTemplate) {
824
+ this.style.gridTemplateColumns = gridTemplate;
825
+ }
634
826
 
635
827
  // ── Header row ──
636
828
 
@@ -669,13 +861,12 @@ export class UITable extends UIElement {
669
861
  const row = existing || this.#createRow(idx, visCols);
670
862
  if (existing) this.#updateRow(existing, idx, visCols);
671
863
 
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');
864
+ // Expand toggle in first cell — gh#1678: idempotent (see the
865
+ // module-level helpers' own rationale).
866
+ if (this.#isRowExpandable(idx) && this.#expanded.has(idx)) {
867
+ setAttrIfChanged(row, 'data-expanded', '');
677
868
  } else {
678
- row.removeAttribute('data-expanded');
869
+ removeAttrIfPresent(row, 'data-expanded');
679
870
  }
680
871
 
681
872
  bodyChildren.push(row);
@@ -830,12 +1021,10 @@ export class UITable extends UIElement {
830
1021
  cells.push(cell);
831
1022
  }
832
1023
 
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
- }
1024
+ // Reconcile header cells — gh#1678: adopt-or-diff instead of an
1025
+ // unconditional replaceChild per position (see the module-level
1026
+ // adoptOrDiffChildren() doc comment for why this is safe under SSR).
1027
+ adoptOrDiffChildren(header, cells);
839
1028
  }
840
1029
 
841
1030
  // ── Row Builders ───────────────────────────────────────────────────────────
@@ -850,12 +1039,16 @@ export class UITable extends UIElement {
850
1039
  #updateRow(row, dataIndex, visCols) {
851
1040
  const data = this.#data[dataIndex];
852
1041
  const isSelected = this.#selected.has(dataIndex);
853
- row.dataset.index = dataIndex;
1042
+ // gh#1678 — idempotent: `#updateRow` runs on an ADOPTED (SSR-matched)
1043
+ // row exactly as often as on a freshly-created one, and a plain
1044
+ // `row.dataset.index = …` / `setAttribute` always mutates regardless of
1045
+ // whether the value already matches.
1046
+ setAttrIfChanged(row, 'data-index', String(dataIndex));
854
1047
 
855
1048
  if (isSelected) {
856
- row.setAttribute('aria-selected', 'true');
1049
+ setAttrIfChanged(row, 'aria-selected', 'true');
857
1050
  } else {
858
- row.removeAttribute('aria-selected');
1051
+ removeAttrIfPresent(row, 'aria-selected');
859
1052
  }
860
1053
 
861
1054
  const cells = [];
@@ -901,6 +1094,12 @@ export class UITable extends UIElement {
901
1094
  const value = getCellValue(data, col);
902
1095
 
903
1096
  // Render priority: column.render > column.format > cellType.render > text
1097
+ // gh#1678 CodeRabbit follow-up: `col.render()` and a cell-type renderer
1098
+ // are free to attach listeners / runtime state to the node they hand
1099
+ // back — mark the cell RENDERER_OWNED so adoptOrDiffChildren() never
1100
+ // adopts it on structural equality alone (see that function's own doc
1101
+ // comment). `col.format()` and the plain-text fallback below build
1102
+ // nothing but a static <span>, so they're never marked.
904
1103
  if (typeof col.render === 'function') {
905
1104
  const result = col.render(value, data, cell, dataIndex);
906
1105
  if (isNode(result)) {
@@ -908,6 +1107,7 @@ export class UITable extends UIElement {
908
1107
  } else if (typeof result === 'string') {
909
1108
  cell.innerHTML = result;
910
1109
  }
1110
+ RENDERER_OWNED.add(cell);
911
1111
  } else if (typeof col.format === 'function') {
912
1112
  const t = col.format(value, data);
913
1113
  cell.appendChild(Object.assign(document.createElement('span'), { textContent: t }));
@@ -916,6 +1116,16 @@ export class UITable extends UIElement {
916
1116
  const typeDef = cellTypes[col.type || 'text'];
917
1117
  if (typeDef?.render) {
918
1118
  typeDef.render(value, data, cell, col.meta);
1119
+ // Most built-in cell-type renderers only set attributes on
1120
+ // custom elements (badge-ui, avatar-ui, check-ui, …) — safe to
1121
+ // adopt structurally, since their own runtime behavior lives in
1122
+ // their own connectedCallback, independent of node identity. Only
1123
+ // a type that declares `attachesListeners` (currently: 'actions',
1124
+ // which calls addEventListener() directly) is marked renderer-
1125
+ // owned; marking every typeDef.render() cell would defeat the
1126
+ // zero-mutation adoption for the overwhelming common case (plain
1127
+ // text/number/date/badge/... cells) for no correctness benefit.
1128
+ if (typeDef.attachesListeners) RENDERER_OWNED.add(cell);
919
1129
  } else {
920
1130
  const t = value != null ? String(value) : '';
921
1131
  cell.appendChild(Object.assign(document.createElement('span'), { textContent: t }));
@@ -949,12 +1159,13 @@ export class UITable extends UIElement {
949
1159
  cells.push(cell);
950
1160
  }
951
1161
 
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
- }
1162
+ // Reconcile cells in row — gh#1678: adopt-or-diff (see
1163
+ // adoptOrDiffChildren()'s doc comment). This is the fix's main hot
1164
+ // path: `#updateRow` is called on every already-adopted row found by
1165
+ // `data-index` at connect, so this is what stops a byte-identical
1166
+ // SSR-rendered row from having every one of its cells rebuilt at
1167
+ // upgrade.
1168
+ adoptOrDiffChildren(row, cells);
958
1169
  }
959
1170
 
960
1171
  // ── Overlays ───────────────────────────────────────────────────────────────
@@ -1084,10 +1295,14 @@ export class UITable extends UIElement {
1084
1295
  default: result = '';
1085
1296
  }
1086
1297
 
1087
- // Format using cell type if available
1298
+ // Format using cell type if available. gh#1678 CodeRabbit follow-up:
1299
+ // same attachesListeners-only scoping as the body-row cell path
1300
+ // above — only a cell type that declares it attaches listeners
1301
+ // needs the always-replace guard.
1088
1302
  const typeDef = cellTypes[col.type || 'text'];
1089
1303
  if (typeof result === 'number' && typeDef?.render) {
1090
1304
  typeDef.render(result, {}, cell, col.meta);
1305
+ if (typeDef.attachesListeners) RENDERER_OWNED.add(cell);
1091
1306
  } else {
1092
1307
  cell.textContent = typeof result === 'number' ? result.toLocaleString() : result;
1093
1308
  }
@@ -1096,12 +1311,11 @@ export class UITable extends UIElement {
1096
1311
  cells.push(cell);
1097
1312
  }
1098
1313
 
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
- }
1314
+ // Reconcile cells — gh#1678: adopt-or-diff, same helper as the header
1315
+ // and body-row cell reconciliation above (consistency; the aggregation
1316
+ // row is built from the exact same per-cell full-rebuild shape those
1317
+ // had).
1318
+ adoptOrDiffChildren(aggRow, cells);
1105
1319
  }
1106
1320
 
1107
1321
  // ── Pagination ─────────────────────────────────────────────────────────────
@@ -486,10 +486,13 @@
486
486
  /* ═══════ Density ═══════ */
487
487
 
488
488
  /* No local override (ADR-0054 convergence) — --table-py/-px above already
489
- derive from --a-space-* steps, which the ambient global
490
- [density="compact"|"spacious"] attribute already scales via
491
- --a-density. Do not reintroduce a component-local :scope[density=…]
492
- block; that would re-shadow the global grammar (ADR-0053). */
489
+ derive from --a-space-* steps, which the global [density="compact"|
490
+ "spacious"] attribute scales via --a-density on ANY host, root or
491
+ scoped (gh#1771 — api/sizing.css re-declares the --a-space-* formulas
492
+ at the [density] boundary itself, the gh#570 cure; previously this
493
+ comment's "already scales" claim only held for root-level [density]).
494
+ Do not reintroduce a component-local :scope[density=…] block; that
495
+ would re-shadow the global grammar (ADR-0053). */
493
496
 
494
497
  /* ═══════ Pinned cells ═══════ */
495
498
 
@@ -302,7 +302,12 @@ a2ui:
302
302
  needs search / filter / sort / columns visibility. Do NOT
303
303
  re-implement those affordances in the card header — the toolbar
304
304
  auto-wires search/filter/sort/columns changes into the bound
305
- table.
305
+ table via `toolbar-*` CustomEvents (gh#1764/#1780, ADR-0079 —
306
+ events-only interaction contract; table-ui listens for
307
+ toolbar-search/toolbar-filter-set/toolbar-filter-clear/
308
+ toolbar-columns-set/toolbar-paginate on itself). Any consumer, not
309
+ only table-toolbar-ui, may dispatch these events directly at a
310
+ table-ui instance.
306
311
  - >-
307
312
  Cells truncate single-line by default (v0.6.21 §403
308
313
  truncate-default). Opt out per-table with [wrap] for whole-table
@@ -142,6 +142,21 @@
142
142
  },
143
143
  "sort-change": {
144
144
  "description": "Sort state changed. Detail: { sortState }."
145
+ },
146
+ "toolbar-columns-set": {
147
+ "description": "gh#1764/#1780, ADR-0079 — dispatched directly at the resolved [for] target in place of the pre-#1780 direct `target.columns =` write. table-ui listens for this on itself. Detail: { columns }."
148
+ },
149
+ "toolbar-filter-clear": {
150
+ "description": "gh#1764/#1780, ADR-0079 — dispatched directly at the resolved [for] target in place of the pre-#1780 direct `target.clearFilters()` call. table-ui listens for this on itself. No detail."
151
+ },
152
+ "toolbar-filter-set": {
153
+ "description": "gh#1764/#1780, ADR-0079 — dispatched directly at the resolved [for] target in place of the pre-#1780 direct `target.setFilter()` call. table-ui listens for this on itself. Detail: { key, value, op }; a null `value` clears that one column's filter."
154
+ },
155
+ "toolbar-paginate": {
156
+ "description": "gh#1764/#1780, ADR-0079 — dispatched directly at the resolved [for] target in place of the pre-#1780 direct `target.paginate =` write. table-ui listens for this on itself. Detail: { pageSize }."
157
+ },
158
+ "toolbar-search": {
159
+ "description": "gh#1764/#1780, ADR-0079 (events-only interaction contract) — dispatched directly at the resolved [for] target (not bubbled from this element) in place of the pre-#1780 direct `.search =` write. table-ui listens for this on itself. Detail: { value }."
145
160
  }
146
161
  },
147
162
  "examples": [