@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
@@ -38,7 +38,7 @@ props:
38
38
  heading:
39
39
  type: string
40
40
  default: ''
41
- description: "Optional kicker label. Section variant renders it via ::before; primary uses it as aria-label only."
41
+ description: "Optional kicker label. Section variant renders it via ::before; primary uses it as aria-label only. For a VISIBLE kicker inside a primary-variant rail — or more than one kicker per <nav-ui> — hand-place <span data-nav-label> in the default slot instead; see slots below."
42
42
  multiExpand:
43
43
  type: boolean
44
44
  default: false
@@ -62,7 +62,7 @@ events:
62
62
 
63
63
  slots:
64
64
  default:
65
- description: "Primary slot — accepts <nav-group-ui> + <nav-item-ui> children, plus <hr data-nav-divider> for hand-placed dividers."
65
+ description: "Primary slot — accepts <nav-group-ui> + <nav-item-ui> children, plus <hr data-nav-divider> for hand-placed dividers and <span data-nav-label> for hand-placed group-label kickers (titled runs of items/groups that aren't wrapped in a <nav-group-ui>). <span data-nav-label> renders with the same uppercase/tracking/muted kicker treatment as the [heading] ::before kicker, and is hidden alongside dividers whenever the primary-variant rail collapses ([collapsed] or ≤96px container width)."
66
66
 
67
67
  states:
68
68
  - name: idle
@@ -79,7 +79,18 @@ a2ui:
79
79
  wrapped in <section-ui> for app sidebars; inside an
80
80
  <aside data-subnav> with variant="section" for section / subnav
81
81
  rails; standalone on docs / auth pages. Children: <nav-group-ui>,
82
- <nav-item-ui>, optional <hr data-nav-divider>.
82
+ <nav-item-ui>, optional <hr data-nav-divider>, optional
83
+ <span data-nav-label> (hand-placed titled section header).
84
+ - >-
85
+ Titled nav-item groups: hand-place <span data-nav-label>Text</span>
86
+ directly in the default slot, before a run of <nav-item-ui> /
87
+ <nav-group-ui> children, to render a section-header kicker — the
88
+ same uppercase/tracking/muted treatment as the [heading] ::before
89
+ kicker on variant="section", but usable anywhere in the child list
90
+ (not limited to one kicker per <nav-ui>) and always visually
91
+ rendered, unlike [heading] which is aria-only on variant="primary".
92
+ Hidden automatically whenever the primary-variant rail collapses
93
+ ([collapsed] or ≤96px container width), same as <hr data-nav-divider>.
83
94
  - >-
84
95
  Variants: variant="primary" (default) — app sidebar; ResizeObserver
85
96
  collapses to icon-only ≤96px; collapsible groups open as a popover
@@ -17,13 +17,31 @@
17
17
  selected — bg container · icon + text ON-SURFACE
18
18
  The selected icon no longer takes primary (supersedes gh#522's
19
19
  glyph ruling); Figma's neutral/surfaceBright was considered for the
20
- selected fill and RULED OUT — container stays (gh#795's mirror). */
21
- --nav-group-fg: var(--md-sys-color-neutral-high);
20
+ selected fill and RULED OUT — container stays (gh#795's mirror).
21
+ gh#968's ladder above is about ROUTE selection (hover/selected);
22
+ it says nothing about the group's own open/closed disclosure state,
23
+ which --nav-group-fg left pinned unconditionally to one role
24
+ (neutral-high) regardless of [open]. gh#1740 (operator ruling,
25
+ 2026-08-19) supersedes that unconditional pin with a second,
26
+ orthogonal axis — the header text and caret glyph now also shift
27
+ role by [open], the same way they already shift by [selected]/
28
+ hover; the two ladders compose (a selected-route header still wins
29
+ its --nav-group-fg-selected treatment regardless of open state —
30
+ see the :scope[open] rule below, kept lower-precedence than
31
+ :scope[selected] by source order). */
32
+ --nav-group-fg: var(--md-sys-color-neutral);
33
+ --nav-group-fg-active: var(--md-sys-color-neutral-high);
22
34
  --nav-group-fg-hover: var(--a-fg-strong);
23
35
  --nav-group-fg-muted: var(--a-fg-muted);
24
36
  --nav-group-fg-selected: var(--a-fg-strong);
25
37
  --nav-group-icon-fg: var(--md-sys-color-neutral);
26
38
  --nav-group-icon-fg-selected: var(--a-fg-strong);
39
+ /* Caret role ladder (gh#1740) — idle/closed: NEUTRAL-LOW (one step
40
+ quieter than the muted badge/kicker role); open: NEUTRAL, matching
41
+ the icon's own idle role so the caret doesn't outshine the icon
42
+ once the group is expanded. */
43
+ --nav-group-caret-fg: var(--md-sys-color-neutral-low);
44
+ --nav-group-caret-fg-active: var(--md-sys-color-neutral);
27
45
  --nav-group-bg-hover: var(--a-bg-muted);
28
46
  --nav-group-bg-selected: var(--a-bg-selected); /* mirrors --nav-item-bg-selected (gh#795) */
29
47
  --nav-group-icon-size: calc(var(--nav-group-row-height) - var(--a-space-2));
@@ -71,6 +89,20 @@
71
89
  background: var(--nav-group-indent-rail-bg, var(--md-sys-color-neutral-outline-variant));
72
90
  pointer-events: none;
73
91
  }
92
+ /* Disclosure-state ladder (gh#1740, operator ruling 2026-08-19): the
93
+ header text and caret glyph step UP a role when the group is open —
94
+ idle/closed reads NEUTRAL, expanded reads NEUTRAL-HIGH. Placed BEFORE
95
+ the [selected]/[data-selected-within] block below so an equal-
96
+ specificity (0,3,0) tie always resolves to the route-selection color:
97
+ a selected-route group keeps its existing --nav-group-fg-selected
98
+ treatment regardless of its own open/closed state — this rule only
99
+ ever wins when the group is open AND not selected. */
100
+ :scope[open] > [slot="header"] {
101
+ color: var(--nav-group-fg-active);
102
+ }
103
+ :scope[open] > [slot="header"] [slot="caret"] {
104
+ color: var(--nav-group-caret-fg-active);
105
+ }
74
106
  /* Selected-look header treatment keys on [selected] — the CSS half of
75
107
  gh issue 459 (PR 460 corrected the icon weight): the group CONTAINING
76
108
  the selected route reads strong; a merely-expanded group does not.
@@ -174,7 +206,9 @@
174
206
 
175
207
  [slot="header"] [slot="caret"] {
176
208
  /*--a-icon-size: 0.5rem;*/
177
- color: var(--nav-group-fg-muted);
209
+ /* idle/closed caret = NEUTRAL-LOW (gh#1740 role ladder); steps up to
210
+ --nav-group-caret-fg-active via the :scope[open] rule above. */
211
+ color: var(--nav-group-caret-fg);
178
212
  transition:
179
213
  transform var(--nav-duration-fast) var(--nav-easing),
180
214
  color var(--nav-duration-fast) var(--nav-easing);
@@ -24,6 +24,32 @@
24
24
  import { UIElement } from '../../core/element.js';
25
25
  import { KEY_MAP } from '../../core/template.js';
26
26
 
27
+ // ── SSR idempotent-write guard (gh#1755, follow-up to table-ui's gh#1678) ──
28
+ //
29
+ // Local copies of table.class.js's own setAttrIfChanged()/removeAttrIfPresent()
30
+ // — module-local there too (not exported), so a shared-utility extraction for
31
+ // two call sites would be a premature abstraction; these mirror that shape.
32
+ //
33
+ /**
34
+ * Idempotent attribute set — skip the DOM write entirely when the value
35
+ * already matches. `setAttribute` mutates (and queues a MutationObserver
36
+ * record) even when the new value is byte-identical to the old one, so a
37
+ * per-item attribute this render path sets on EVERY call (`aria-current`,
38
+ * `variant`, `disabled`, `tabindex`, `text`, `aria-label`, `data-value`,
39
+ * `size`) must compare-before-write to let a byte-identical adopted SSR
40
+ * `<nav>` (gh#1687) survive re-render with zero mutations — bare
41
+ * `setAttribute` cannot give that guarantee even when the value never
42
+ * actually changes.
43
+ */
44
+ function setAttrIfChanged(el, name, value) {
45
+ if (el.getAttribute(name) !== value) el.setAttribute(name, value);
46
+ }
47
+
48
+ /** Idempotent attribute removal — same rationale as setAttrIfChanged. */
49
+ function removeAttrIfPresent(el, name) {
50
+ if (el.hasAttribute(name)) el.removeAttribute(name);
51
+ }
52
+
27
53
  export class UIPagination extends UIElement {
28
54
  static properties = {
29
55
  page: { type: Number, default: 1, reflect: true },
@@ -251,21 +277,21 @@ export class UIPagination extends UIElement {
251
277
  }
252
278
 
253
279
  const btn = document.createElement('button-ui');
254
- btn.setAttribute('size', this.size);
280
+ setAttrIfChanged(btn, 'size', this.size);
255
281
 
256
282
  if (item.type === 'prev') {
257
283
  btn.setAttribute('data-prev', '');
258
- btn.setAttribute('icon', 'caret-left');
259
- btn.setAttribute('aria-label', 'Previous page');
284
+ setAttrIfChanged(btn, 'icon', 'caret-left');
285
+ setAttrIfChanged(btn, 'aria-label', 'Previous page');
260
286
  } else if (item.type === 'next') {
261
287
  btn.setAttribute('data-next', '');
262
- btn.setAttribute('icon', 'caret-right');
263
- btn.setAttribute('aria-label', 'Next page');
288
+ setAttrIfChanged(btn, 'icon', 'caret-right');
289
+ setAttrIfChanged(btn, 'aria-label', 'Next page');
264
290
  } else {
265
291
  btn.setAttribute('data-page', '');
266
- btn.dataset.value = String(item.value);
267
- btn.setAttribute('text', String(item.value));
268
- btn.setAttribute('aria-label', `Page ${item.value}`);
292
+ setAttrIfChanged(btn, 'data-value', String(item.value));
293
+ setAttrIfChanged(btn, 'text', String(item.value));
294
+ setAttrIfChanged(btn, 'aria-label', `Page ${item.value}`);
269
295
  }
270
296
 
271
297
  this.#updateItem(btn, item, page);
@@ -276,32 +302,36 @@ export class UIPagination extends UIElement {
276
302
  if (item.type === 'ellipsis') return;
277
303
 
278
304
  // Keep size in sync \u2014 the host's [size] may change between renders.
279
- el.setAttribute('size', this.size);
305
+ // gh#1755 \u2014 compare-before-write: this runs on EVERY render pass
306
+ // (including one triggered by an unrelated reactive dependency), so a
307
+ // bare setAttribute() would re-mutate an already-correct adopted
308
+ // gh#1687 SSR `<nav>` child even when nothing here actually changed.
309
+ setAttrIfChanged(el, 'size', this.size);
280
310
 
281
311
  if (item.type === 'prev') {
282
- el.setAttribute('variant', this.#restVariant());
283
- if (page <= 1) { el.setAttribute('disabled', ''); el.setAttribute('tabindex', '-1'); }
284
- else { el.removeAttribute('disabled'); el.setAttribute('tabindex', '0'); }
312
+ setAttrIfChanged(el, 'variant', this.#restVariant());
313
+ if (page <= 1) { setAttrIfChanged(el, 'disabled', ''); setAttrIfChanged(el, 'tabindex', '-1'); }
314
+ else { removeAttrIfPresent(el, 'disabled'); setAttrIfChanged(el, 'tabindex', '0'); }
285
315
  } else if (item.type === 'next') {
286
- el.setAttribute('variant', this.#restVariant());
287
- if (page >= this.total) { el.setAttribute('disabled', ''); el.setAttribute('tabindex', '-1'); }
288
- else { el.removeAttribute('disabled'); el.setAttribute('tabindex', '0'); }
316
+ setAttrIfChanged(el, 'variant', this.#restVariant());
317
+ if (page >= this.total) { setAttrIfChanged(el, 'disabled', ''); setAttrIfChanged(el, 'tabindex', '-1'); }
318
+ else { removeAttrIfPresent(el, 'disabled'); setAttrIfChanged(el, 'tabindex', '0'); }
289
319
  } else {
290
- el.dataset.value = String(item.value);
291
- el.setAttribute('text', String(item.value));
292
- el.setAttribute('aria-label', `Page ${item.value}`);
320
+ setAttrIfChanged(el, 'data-value', String(item.value));
321
+ setAttrIfChanged(el, 'text', String(item.value));
322
+ setAttrIfChanged(el, 'aria-label', `Page ${item.value}`);
293
323
  if (item.value === page) {
294
324
  // Active page reads as `variant="primary"` so the filled-accent
295
325
  // state comes from button-ui's primary surface matrix (the
296
326
  // canonical token chain) \u2014 not a pagination-tier re-impl.
297
- el.setAttribute('variant', 'primary');
327
+ setAttrIfChanged(el, 'variant', 'primary');
298
328
  // aria-current="page" alone carries the active-page state — no
299
329
  // private data-active mirror (gh#1332 Category A; admin-roster's
300
330
  // aria-checked precedent from PR #1438).
301
- el.setAttribute('aria-current', 'page');
331
+ setAttrIfChanged(el, 'aria-current', 'page');
302
332
  } else {
303
- el.setAttribute('variant', this.#restVariant());
304
- el.removeAttribute('aria-current');
333
+ setAttrIfChanged(el, 'variant', this.#restVariant());
334
+ removeAttrIfPresent(el, 'aria-current');
305
335
  }
306
336
  }
307
337
  }
@@ -22,6 +22,30 @@
22
22
 
23
23
  import { UIFormElement } from '../../core/form.js';
24
24
 
25
+ // ── SSR idempotent-write guard (gh#1755, follow-up to table-ui's gh#1678) ──
26
+ //
27
+ // Local copies of table.class.js's own setAttrIfChanged()/removeAttrIfPresent()
28
+ // — module-local there too (not exported), so a shared-utility extraction for
29
+ // two call sites would be a premature abstraction; these mirror that shape.
30
+ //
31
+ /**
32
+ * Idempotent attribute set — skip the DOM write entirely when the value
33
+ * already matches. `setAttribute` mutates (and queues a MutationObserver
34
+ * record) even when the new value is byte-identical to the old one, so a
35
+ * host/child attribute this render path sets on EVERY call (`placeholder`,
36
+ * `value`) must compare-before-write to let a byte-identical SSR-rendered
37
+ * `<input-ui>` survive upgrade with zero mutations — bare `setAttribute`
38
+ * cannot give that guarantee even when the value never actually changes.
39
+ */
40
+ function setAttrIfChanged(el, name, value) {
41
+ if (el.getAttribute(name) !== value) el.setAttribute(name, value);
42
+ }
43
+
44
+ /** Idempotent attribute removal — same rationale as setAttrIfChanged. */
45
+ function removeAttrIfPresent(el, name) {
46
+ if (el.hasAttribute(name)) el.removeAttribute(name);
47
+ }
48
+
25
49
  export class UISearch extends UIFormElement {
26
50
  static properties = {
27
51
  ...UIFormElement.properties,
@@ -37,7 +61,10 @@ export class UISearch extends UIFormElement {
37
61
 
38
62
  connected() {
39
63
  super.connected();
40
- this.setAttribute('role', 'search');
64
+ // gh#1755 — a byte-identical SSR-rendered host already carries this
65
+ // attribute; an unconditional setAttribute() here would still queue a
66
+ // mutation record even though the value never actually changes.
67
+ setAttrIfChanged(this, 'role', 'search');
41
68
 
42
69
  if (!this.querySelector('input-ui')) {
43
70
  const size = this.getAttribute('size');
@@ -69,12 +96,19 @@ export class UISearch extends UIFormElement {
69
96
  render() {
70
97
  if (!this.#inputEl) return;
71
98
 
72
- this.#inputEl.setAttribute('placeholder', this.placeholder);
73
- if (this.disabled) this.#inputEl.setAttribute('disabled', '');
74
- else this.#inputEl.removeAttribute('disabled');
99
+ // gh#1755 — this render effect re-runs on ANY of this element's
100
+ // reactive properties changing (element.js's connectedCallback effect
101
+ // reads every signal unconditionally), not only placeholder/disabled/
102
+ // value themselves — so a bare setAttribute() here would re-mutate an
103
+ // already-correct adopted `<input-ui>` (or this host's own `value`
104
+ // attribute) on every unrelated re-render, defeating SSR zero-mutation
105
+ // upgrade even when nothing this pass actually cares about changed.
106
+ setAttrIfChanged(this.#inputEl, 'placeholder', this.placeholder);
107
+ if (this.disabled) setAttrIfChanged(this.#inputEl, 'disabled', '');
108
+ else removeAttrIfPresent(this.#inputEl, 'disabled');
75
109
 
76
110
  // Reflect value for CSS (clear button visibility)
77
- this.setAttribute('value', this.value || '');
111
+ setAttrIfChanged(this, 'value', this.value || '');
78
112
  }
79
113
 
80
114
  #onInput = () => {
@@ -66,6 +66,11 @@
66
66
  "type": "string",
67
67
  "default": ""
68
68
  },
69
+ "label-hidden": {
70
+ "description": "When true, [label] still sets the accessible name (aria-label) but the visible `::before` text is suppressed via the canonical sr-only technique (gh#1748, mirrors check-ui's [label-hidden], gh#1010). Use when a sibling/ancestor composition already conveys the same name visually (e.g. a scope/view-switcher select in a table-toolbar-ui [slot=\"scope\"], where the toolbar's own title already names the view) and a second visible \"Scope\"-style label would paint it twice.",
71
+ "type": "boolean",
72
+ "default": false
73
+ },
69
74
  "mark": {
70
75
  "description": "Renders the Adia brand mark (`<adia-mark-ui>`) as the leading visual — takes precedence over avatar and icon. Token-driven (light/dark handled internally), so it fits a scheme-switching workspace/app switcher where a static logo URL can't invert. Per-option `mark` on an `<option>`/options-array entry works the same way, scoped to that row.",
71
76
  "type": "boolean",
@@ -47,6 +47,11 @@ export class UISelect extends UIFormElement {
47
47
  size: { type: String, default: 'md', reflect: true },
48
48
  open: { type: Boolean, default: false, reflect: true },
49
49
  label: { type: String, default: '', reflect: true },
50
+ // gh#1748: keeps [label] as the accessible name (bridged to an explicit
51
+ // aria-label in #syncAccessibleName below) while suppressing the VISIBLE
52
+ // `::before` text (select.css) — for compositions that already show the
53
+ // name elsewhere. Mirrors check-ui's [label-hidden] (gh#1010).
54
+ labelHidden: { type: Boolean, default: false, reflect: true, attribute: 'label-hidden' },
50
55
  icon: { type: String, default: '', reflect: true },
51
56
  avatar: { type: String, default: '', reflect: true },
52
57
  mark: { type: Boolean, default: false, reflect: true },
@@ -361,6 +366,21 @@ export class UISelect extends UIFormElement {
361
366
  */
362
367
  #syncAccessibleName() {
363
368
  const DEFAULT_PLACEHOLDER = 'Select...'; // matches static properties.placeholder.default
369
+ // gh#1748: [label-hidden] suppresses the visible `::before` text
370
+ // (select.css's `attr(label)` content) but [label] must still name the
371
+ // control — bridge it through the SAME tracked-ownership pipeline used
372
+ // below for the placeholder-only case, so a consumer's own directly-set
373
+ // aria-label is never clobbered (gh#1646 guarantee extends for free) and
374
+ // toggling [label-hidden] back off cleanly releases the bridged value
375
+ // (falls through to the visible-label path, which sets no aria-label —
376
+ // the generated `::before` content supplies the name instead, unchanged
377
+ // from today). Mirrors check-ui's [label-hidden] (gh#1010), which
378
+ // instead sets aria-label unconditionally since check-ui never relies
379
+ // on generated-content naming.
380
+ if (this.labelHidden && this.label) {
381
+ this.#lastAutoAriaLabel = syncAutoAriaLabel(this, this.#lastAutoAriaLabel, this.label);
382
+ return;
383
+ }
364
384
  const labeledElsewhere = !!this.label || this.hasAttribute('aria-labelledby');
365
385
  const namable = !labeledElsewhere
366
386
  && this.placeholder && this.placeholder !== DEFAULT_PLACEHOLDER;
@@ -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');
@@ -34,6 +34,11 @@
34
34
  "type": "boolean",
35
35
  "default": false
36
36
  },
37
+ "filteredCount": {
38
+ "description": "Read-only. The row count AFTER search + column filters apply but BEFORE pagination slices it (gh#1807, ADR-0080, REQ-W-006) — a plain JS getter, no setter, never a reflected attribute. table-footer-ui's client-mode range-total derivation reads this directly; a raw `.data.length` read (the toolbar's own `count` fallback precedent) would double-count a filtered-out row. In server mode ([range-total] set, gh#1754, ADR-0082), filteredCount stays page-scoped — the loaded page's rows after local filters, NOT the server total across all pages; [range-total] is the server total, filteredCount never is (REQ-D-006).",
39
+ "type": "number",
40
+ "readOnly": true
41
+ },
37
42
  "frameless": {
38
43
  "description": "Drop the table's outer 1px perimeter and nothing else (gh#511) — row dividers are untouched (the perimeter is split onto its own --table-perimeter token, defaulting to --table-border). For card-composed tables: the card owns the frame, so the table's square perimeter otherwise renders a doubled edge (flush bleed) or a stray inner box (with content above). Standalone tables keep their chrome by default. Theming path without the attribute: set --table-perimeter to transparent per instance. Note: `<card-ui><section bleed>` already drops a direct-child table-ui's chrome automatically (card.css, gh#796) — `frameless`/`raw` on the instance still applies and is honored, but is no longer required just to avoid the doubled-edge look in that composition.",
39
44
  "type": "boolean",
@@ -44,8 +49,18 @@
44
49
  "type": "boolean",
45
50
  "default": false
46
51
  },
52
+ "no-pager": {
53
+ "description": "Hide the internal pagination bar while leaving [paginate] slicing, page state, the `page` event, and the `footer-page` command listener all intact (gh#1807, ADR-0080). The anti-doubled-pager mechanism for a table-footer-ui composition, client- or server-mode alike (gh#1754, ADR-0082): without it, a bound footer's own composed pager and the table's own internal one would both render for any non-empty paginated table. Explicit author intent — never suppressed automatically just because a footer happens to be bound.",
54
+ "type": "boolean",
55
+ "default": false
56
+ },
47
57
  "paginate": {
48
- "description": "Rows per page. 0 = show all rows without pagination. When > 0, renders a pagination bar below the table.",
58
+ "description": "Rows per page. 0 = show all rows without pagination. When > 0, renders the internal pagination bar below the table — UNLESS [no-pager] is also set (gh#1807, ADR-0080), which keeps [paginate]'s slicing/page-state/`page`-event/`footer-page`-listener behavior but suppresses only the bar itself (the anti-doubled-pager path for a table-footer-ui composition). When [range-total] is also set (gh#1754, ADR-0082), [paginate] becomes purely presentational — it supplies only the page-size for pager math; no local slicing runs.",
59
+ "type": "number",
60
+ "default": 0
61
+ },
62
+ "range-total": {
63
+ "description": "Total row count across ALL server pages (gh#1754, ADR-0082) — the table-authoritative server-mode data contract. Presence-gated: the attribute's PRESENCE, not its value, is the mode switch (checked via hasAttribute, never the coerced value alone — the same discipline table-footer-ui's own [range-total] ships). Absent (the default) means client mode: today's behavior exactly, [paginate] slices `.data` as always. Present with [paginate] > 0 means server mode: no local slicing (`.data` IS the current page and renders whole, after local search/sort/filter), the internal pager's page count becomes `max(1, ceil(range-total / paginate))` (one calc site, consolidated), and the internal page-reset sites (`data` set, `setFilter`, `clearFilters`) are suppressed so a fetch write-back never fights the pager back to page 0 — page state then moves only via pager interaction, the `footer-page` command, or `setState()`. The existing `page` event (0-based, unchanged) becomes the fetch trigger: the consumer listens for it, fetches that server page, and writes `.data` back. Explicit `range-total=\"0\"` is server-confirmed empty (pager hidden), never conflated with absent. With [paginate] absent/0, [range-total] is inert for this table's own rendering but stays readable by a bound table-footer-ui (REQ-W-006 branch 2) for its count-only label — see the footer-authoritative shape below, which remains lawful and is unaffected by this attribute. Named identically to table-toolbar-ui's and table-footer-ui's own [range-total] (rows, never `pagination-ui[total]`'s pages — the ADR-0063 B5 collision rule); a third instance of one name for one concept.",
49
64
  "type": "number",
50
65
  "default": 0
51
66
  },
@@ -55,7 +70,7 @@
55
70
  "default": false
56
71
  },
57
72
  "search": {
58
- "description": "Global search/filter string. Filters visible rows across all columns using case-insensitive substring matching. Resets to page 1 on change.",
73
+ "description": "Global search/filter string. Filters visible rows across all columns using case-insensitive substring matching. Does NOT reset the current page (verified against source at the gh#1754/ADR-0082 build — unlike setFilter()/clearFilters(), no internal page reset ever ran for a search change; this description previously claimed \"resets to page 1 on change,\" which the source never did). Aligning the doc to observed behavior here, not a behavior change — a page reset on search, if wanted, is a separate client-mode ticket.",
59
74
  "type": "string",
60
75
  "default": ""
61
76
  },
@@ -141,10 +156,10 @@
141
156
  }
142
157
  },
143
158
  "page": {
144
- "description": "Fired when the user navigates to a different page.",
159
+ "description": "Fired when the table's own internal page state changes — an internal pager click, or a `footer-page` command applied (REQ-W-002). This is a RESYNC notification a bound `<table-footer-ui>` listens to for its own `page` attribute (REQ-W-004) — it is NOT the public server-mode fetch trigger a consumer should listen to; use `<table-footer-ui>`'s own `page-change` event for that (1-based, gh#1754/ADR-0082 REQ-W-003). Listening to both risks a duplicate fetch or an index-base mismatch.",
145
160
  "detail": {
146
161
  "page": {
147
- "description": "New active page index (1-based).",
162
+ "description": "New active page index (0-based — gh#1808; the `footer-page` command and the `page` attribute on <table-footer-ui> are 1-based, this event's own detail is not).",
148
163
  "type": "number"
149
164
  }
150
165
  }