@adia-ai/web-components 0.8.46 → 0.8.48

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 (39) hide show
  1. package/CHANGELOG.md +43 -0
  2. package/components/card/card.css +25 -2
  3. package/components/chart/chart.a2ui.json +1 -1
  4. package/components/chart/chart.d.ts +1 -1
  5. package/components/chart/chart.yaml +6 -3
  6. package/components/link/link.css +3 -2
  7. package/components/nav/nav.css +7 -3
  8. package/components/nav-group/nav-group.css +38 -41
  9. package/components/nav-item/nav-item.css +11 -5
  10. package/components/preview/preview.class.js +12 -2
  11. package/components/richtext/richtext.css +2 -2
  12. package/components/table/table.a2ui.json +5 -2
  13. package/components/table/table.class.js +36 -1
  14. package/components/table/table.d.ts +2 -2
  15. package/components/table/table.yaml +17 -1
  16. package/components/table-footer/table-footer.a2ui.json +20 -2
  17. package/components/table-footer/table-footer.class.js +94 -8
  18. package/components/table-footer/table-footer.d.ts +6 -2
  19. package/components/table-footer/table-footer.yaml +57 -6
  20. package/components/table-toolbar/table-toolbar.a2ui.json +35 -2
  21. package/components/table-toolbar/table-toolbar.class.js +117 -9
  22. package/components/table-toolbar/table-toolbar.css +104 -3
  23. package/components/table-toolbar/table-toolbar.d.ts +10 -2
  24. package/components/table-toolbar/table-toolbar.examples.md +47 -45
  25. package/components/table-toolbar/table-toolbar.yaml +97 -9
  26. package/core/anchor.js +21 -1
  27. package/custom-elements.json +50 -5
  28. package/dist/host.min.css +1 -1
  29. package/dist/host.sheet.js +1 -1
  30. package/dist/theme-provider.min.js +8 -8
  31. package/dist/themes.min.css +1 -1
  32. package/dist/themes.sheet.js +1 -1
  33. package/dist/web-components.min.css +1 -1
  34. package/dist/web-components.min.js +52 -52
  35. package/dist/web-components.sheet.js +1 -1
  36. package/package.json +1 -1
  37. package/styles/colors/material-static.css +298 -234
  38. package/styles/themes.css +2676 -0
  39. package/styles/type/elements.css +1 -0
@@ -33,12 +33,23 @@
33
33
  *
34
34
  * Degradation (REQ-S):
35
35
  * - range-total ABSENT → indeterminate/loading: both regions hidden.
36
+ * - range-total EXPLICIT "?" → gh#1877/ADR-0082 Amendment — OPEN/unproven
37
+ * total (cursor/hasMore paging): "Showing
38
+ * X–Y" with no "of N", pager stays in
39
+ * has-more mode (next enabled) until a
40
+ * real range-total resolves.
36
41
  * - range-total EXPLICIT "0" → pager hidden; [slot="empty"] renders in
37
42
  * the range region if supplied, else hides.
38
43
  * - page-size ABSENT or "0" → count-only label ("128 items"), no pager
39
44
  * (REQ-S-005 — the ordinary DEFAULT state,
40
45
  * since page-size defaults to 0).
41
46
  * - derived pages ≤ 1 → pager hidden, range label still renders.
47
+ * - range-of="<whole>" → gh#1877/ADR-0082 Amendment — secondary
48
+ * whole-set total, appends "(of <whole>
49
+ * total)"; unset changes nothing.
50
+ * - range-noun="<noun>" → gh#1877/ADR-0082 Amendment — appends the
51
+ * per-slice noun ("of 80 users"); unset
52
+ * changes nothing.
42
53
  *
43
54
  * Target derivation (REQ-W-006, amended 0.2.0/ADR-0082) — a two-branch
44
55
  * ladder when range-total is unset, per-attribute:
@@ -94,6 +105,15 @@ export class UITableFooter extends UIElement {
94
105
  page: { type: Number, default: 1, reflect: true },
95
106
  pageSize: { type: Number, default: 0, reflect: true, attribute: 'page-size' },
96
107
  rangeTotal: { type: Number, default: 0, reflect: true, attribute: 'range-total' },
108
+ // gh#1877/ADR-0082 Amendment — secondary whole-set total (the
109
+ // "filtered-of-whole" gap: a client-side-narrowed range-total plus the
110
+ // unfiltered set size), and the optional per-slice noun consumers used
111
+ // to hand-paint ("of 80 users"). Both presence-gated like range-total —
112
+ // hasAttribute decides whether the suffix renders, never the coerced
113
+ // value alone (an author-set range-of="0" is nonsensical and treated
114
+ // as absent by #draw()'s safeRangeTotal reuse).
115
+ rangeOf: { type: Number, default: 0, reflect: true, attribute: 'range-of' },
116
+ rangeNoun: { type: String, default: '', reflect: true, attribute: 'range-noun' },
97
117
  // Pass-throughs to the composed pagination-ui (REQ-F-002) — the
98
118
  // defaults below deliberately mirror pagination-ui's own defaults, so
99
119
  // an unset pass-through is a no-op mirror, not a silent override.
@@ -133,7 +153,7 @@ export class UITableFooter extends UIElement {
133
153
  // one, so it needs the raw attribute callback, not the signal diff.
134
154
  attributeChangedCallback(name, oldVal, newVal) {
135
155
  super.attributeChangedCallback(name, oldVal, newVal);
136
- if (name === 'range-total') this.#draw();
156
+ if (name === 'range-total' || name === 'range-of' || name === 'range-noun') this.#draw();
137
157
  }
138
158
 
139
159
  // ── DOM stamp ────────────────────────────────────────────────────────────
@@ -316,11 +336,29 @@ export class UITableFooter extends UIElement {
316
336
  const n = Number(raw);
317
337
  return Number.isFinite(n) && n > 0 ? Math.trunc(n) : 0;
318
338
  };
319
- const rangeTotal = hasRangeTotal
320
- ? safeRangeTotal(this.rangeTotal)
321
- : targetServer
322
- ? safeRangeTotal(target.rangeTotal)
323
- : (clientMode ? (Number(target.filteredCount) || 0) : 0);
339
+ // gh#1877/ADR-0082 Amendment — the open/unproven-total state: the
340
+ // resolved source of range-total (local attribute if set, else the
341
+ // server-mode target's own) coerces to a non-finite number. NOT keyed
342
+ // on the raw attribute string — rangeTotal is a Number-typed reflected
343
+ // prop, so range-total="?" parses via `+"?"` NaN and the property
344
+ // setter's own reflect() immediately rewrites the DOM attribute to the
345
+ // literal string "NaN" (core/element.js) before this ever runs;
346
+ // getAttribute('range-total') would read "NaN", never "?". The
347
+ // coerced PROPERTY still holds NaN stably (Object.is(NaN, NaN) is
348
+ // true, so the reflect's re-parse never re-writes it) — that's the
349
+ // one signal that survives, and what tells "unknown yet" apart from
350
+ // "confirmed zero" (a finite 0) once safeRangeTotal has clamped both
351
+ // to 0.
352
+ const rangeOpen = hasRangeTotal
353
+ ? !Number.isFinite(Number(this.rangeTotal))
354
+ : (targetServer && !Number.isFinite(Number(target.rangeTotal)));
355
+ const rangeTotal = rangeOpen
356
+ ? null
357
+ : hasRangeTotal
358
+ ? safeRangeTotal(this.rangeTotal)
359
+ : targetServer
360
+ ? safeRangeTotal(target.rangeTotal)
361
+ : (clientMode ? (Number(target.filteredCount) || 0) : 0);
324
362
  // page-size still derives from target.paginate in EITHER derived mode
325
363
  // (server or client) — unchanged behavior, just widened to cover the
326
364
  // targetServer branch too (a server-mode target's paginate is its
@@ -329,6 +367,15 @@ export class UITableFooter extends UIElement {
329
367
  ? (Number(this.pageSize) || 0)
330
368
  : ((targetServer || clientMode) ? (Number(target.paginate) || 0) : (Number(this.pageSize) || 0));
331
369
 
370
+ // gh#1877/ADR-0082 Amendment — range-of (secondary whole-set total) and
371
+ // range-noun (per-slice noun) are both additive suffixes onto the
372
+ // normal finite "of N" text; neither applies to the loading, confirmed-
373
+ // empty, or open-total branches below.
374
+ const hasRangeOf = this.hasAttribute('range-of');
375
+ const rangeOfVal = hasRangeOf ? safeRangeTotal(this.rangeOf) : 0;
376
+ const rangeOfSuffix = hasRangeOf && rangeOfVal > 0 ? ` (of ${rangeOfVal} total)` : '';
377
+ const nounSuffix = this.rangeNoun ? ` ${this.rangeNoun}` : '';
378
+
332
379
  if (!rangeEl) return;
333
380
 
334
381
  if (!totalKnown) {
@@ -342,6 +389,45 @@ export class UITableFooter extends UIElement {
342
389
 
343
390
  rangeEl.hidden = false;
344
391
 
392
+ if (rangeOpen) {
393
+ // gh#1877/ADR-0082 Amendment REQ-open — "Showing X–Y" with no
394
+ // "of N", pager stays in has-more mode (next enabled) until a real
395
+ // range-total resolves. Deterministic window math off page/page-size
396
+ // alone, same shape as the finite branch below — no clamp against a
397
+ // total we don't have yet (adiav2 ehr-subscriptions-paging.ts).
398
+ if (rangeEmpty) rangeEmpty.hidden = true;
399
+ if (pageSize <= 0) {
400
+ // No page-size known yet either — nothing to derive a window from.
401
+ if (rangeText) {
402
+ rangeText.hidden = false;
403
+ rangeText.textContent = nounSuffix ? `Loading${nounSuffix}…` : 'Loading…';
404
+ }
405
+ if (pagerWrap) pagerWrap.hidden = true;
406
+ return;
407
+ }
408
+ const page = Math.max(1, Number(this.page) || 1);
409
+ const start = (page - 1) * pageSize + 1;
410
+ const end = page * pageSize;
411
+ if (rangeText) {
412
+ rangeText.hidden = false;
413
+ rangeText.textContent = `Showing ${start}–${end}${nounSuffix}`;
414
+ }
415
+ if (pagerWrap) {
416
+ pagerWrap.hidden = false;
417
+ }
418
+ if (pag) {
419
+ // Keeps `next` enabled indefinitely (never a false "last page") —
420
+ // mirrors table.class.js's own #pageCount open-mode math
421
+ // (gh#1877/ADR-0082 Amendment): total is always one page ahead of
422
+ // wherever the pager currently sits.
423
+ pag.setAttribute('page', String(page));
424
+ pag.setAttribute('total', String(page + 1));
425
+ pag.setAttribute('siblings', String(Math.max(0, Number(this.siblings) || 0)));
426
+ pag.setAttribute('size', this.size || 'md');
427
+ }
428
+ return;
429
+ }
430
+
345
431
  if (rangeTotal === 0) {
346
432
  // REQ-S-002 — confirmed-empty. The empty slot always wins over the
347
433
  // range text whenever range-total is confirmed zero, mutually
@@ -361,7 +447,7 @@ export class UITableFooter extends UIElement {
361
447
  // "Showing 1–0 of N" math an unbounded pager would otherwise need.
362
448
  if (rangeText) {
363
449
  rangeText.hidden = false;
364
- rangeText.textContent = `${rangeTotal} items`;
450
+ rangeText.textContent = `${rangeTotal} items${rangeOfSuffix}`;
365
451
  }
366
452
  if (pagerWrap) pagerWrap.hidden = true;
367
453
  return;
@@ -374,7 +460,7 @@ export class UITableFooter extends UIElement {
374
460
 
375
461
  if (rangeText) {
376
462
  rangeText.hidden = false;
377
- rangeText.textContent = `Showing ${start}–${end} of ${rangeTotal}`;
463
+ rangeText.textContent = `Showing ${start}–${end} of ${rangeTotal}${nounSuffix}${rangeOfSuffix}`;
378
464
  }
379
465
 
380
466
  if (pagerWrap) {
@@ -22,8 +22,12 @@ export class UITableFooter extends UIElement {
22
22
  page: number;
23
23
  /** Rows per page. Default 0 (unknown) is the ORDINARY default state, not an exotic edge case: with page-size absent/0 and range-total present, the range label renders in count-only form ("128 items") and the pager does not render — the derivation math (pages = range-total / page-size) is undefined without a page size. In a client-mode composition (the resolved target has [paginate] > 0) and this attribute is unset, the footer derives page-size from the target's own [paginate] value. */
24
24
  pageSize: number;
25
- /** Total ROWS across all pages. Absent (the default) means "not yet known" (loading) — never "zero"; both the range label and the pager stay hidden until this is set. Explicit range-total="0" is the confirmed-empty state (pager hidden; the `empty` slot renders in the range label's position when supplied). Named identically to table-toolbar-ui's [range-total] and table-ui's own [range-total] (gh#1754, ADR-0082) and deliberately NOT `total` (pagination-ui's `total` is total PAGES the ADR-0063 B5 collision rule). When this attribute is unset, the footer derives it from the resolved target via a two-branch ladder (REQ-W-006, amended 0.2.0/ADR-0082): (1) the target's own [range-total] attribute, when present — the table-authoritative SERVER mode (gh#1754 Shape 1's second lawful composition); the identity rule: the footer's range-total derives from the table's range-total, never the table's filteredCount, and this branch wins even when the target's [paginate] is also > 0; (2) otherwise, when the target has [paginate] > 0, CLIENT mode: the target's filtered row count (search + column filters applied, before pagination). Refreshes on the target's `page` / `filter-change` / `sort` events in either branch. Widened named staleness edge: a programmatic `.data` swap on the target — or a `range-total` update on the target without an accompanying page move — emits no event; either re-set this footer's own attributes after such a change, or accept staleness until the target's next event. */
26
- rangeTotal: number;
25
+ /** gh#1877, ADR-0082 Amendmentoptional per-slice noun appended after the range total ("of 80 users") so consumers stop hand-painting the noun themselves. Applies in both the normal finite range text and the open/unproven-total text ("Showing 51–100 users" with no "of N"). Unset (the default) changes nothing. Named identically on table-toolbar-ui (ADR-0082 §"same name on both companions"). */
26
+ rangeNoun: string;
27
+ /** gh#1877, ADR-0082 Amendment — secondary whole-set total for the "filtered-of-whole" shape: a client-side facet narrows [range-total] (a filtered count) while range-of names the unfiltered set size, e.g. "Showing 1–4 of 4 traversals (6 total)". Appends "(of <range-of> total)" onto the normal finite range text; unset (the default) changes nothing. Never applies while [range-total] is absent (loading) or "?" (open/unproven) — there's no finite range text to append onto yet. Named identically on table-toolbar-ui (ADR-0082 §"same name on both companions"). */
28
+ rangeOf: number;
29
+ /** Total ROWS across all pages. Absent (the default) means "not yet known" (loading) — never "zero"; both the range label and the pager stay hidden until this is set. Explicit range-total="0" is the confirmed-empty state (pager hidden; the `empty` slot renders in the range label's position when supplied). Named identically to table-toolbar-ui's [range-total] and table-ui's own [range-total] (gh#1754, ADR-0082) and deliberately NOT `total` (pagination-ui's `total` is total PAGES — the ADR-0063 B5 collision rule). Explicit range-total="?" (gh#1877, ADR-0082 Amendment) is the OPEN/unproven- total state — cursor/hasMore server paging with no known row count yet: the range label renders "Showing X–Y" with no "of N", and the pager stays in has-more mode (next enabled) rather than hiding — distinct from both "loading" (attribute absent) and "confirmed empty" (range-total="0"). Resolves to the normal finite behavior the moment a real number replaces the "?". When this attribute is unset, the footer derives it from the resolved target via a two-branch ladder (REQ-W-006, amended 0.2.0/ADR-0082): (1) the target's own [range-total] attribute, when present — the table-authoritative SERVER mode (gh#1754 Shape 1's second lawful composition; the target's own "?" carries through identically); the identity rule: the footer's range-total derives from the table's range-total, never the table's filteredCount, and this branch wins even when the target's [paginate] is also > 0; (2) otherwise, when the target has [paginate] > 0, CLIENT mode: the target's filtered row count (search + column filters applied, before pagination). Refreshes on the target's `page` / `filter-change` / `sort` events in either branch. Widened named staleness edge: a programmatic `.data` swap on the target — or a `range-total` update on the target without an accompanying page move — emits no event; either re-set this footer's own attributes after such a change, or accept staleness until the target's next event. */
30
+ rangeTotal: number | string;
27
31
  /** Passed through to the composed pagination-ui's own [siblings] (page-number window size on each side of the current page). */
28
32
  siblings: number;
29
33
  /** Passed through to the composed pagination-ui's own [size] (sm | md | lg, the universal size system). */
@@ -55,14 +55,22 @@ props:
55
55
  range label's position when supplied). Named identically to
56
56
  table-toolbar-ui's [range-total] and table-ui's own [range-total]
57
57
  (gh#1754, ADR-0082) and deliberately NOT `total` (pagination-ui's
58
- `total` is total PAGES — the ADR-0063 B5 collision rule). When this
59
- attribute is unset, the footer derives it from the resolved target
60
- via a two-branch ladder (REQ-W-006, amended 0.2.0/ADR-0082):
58
+ `total` is total PAGES — the ADR-0063 B5 collision rule). Explicit
59
+ range-total="?" (gh#1877, ADR-0082 Amendment) is the OPEN/unproven-
60
+ total state cursor/hasMore server paging with no known row count
61
+ yet: the range label renders "Showing X–Y" with no "of N", and the
62
+ pager stays in has-more mode (next enabled) rather than hiding —
63
+ distinct from both "loading" (attribute absent) and "confirmed empty"
64
+ (range-total="0"). Resolves to the normal finite behavior the moment
65
+ a real number replaces the "?". When this attribute is unset, the
66
+ footer derives it from the resolved target via a two-branch ladder
67
+ (REQ-W-006, amended 0.2.0/ADR-0082):
61
68
  (1) the target's own [range-total] attribute, when present — the
62
69
  table-authoritative SERVER mode (gh#1754 Shape 1's second lawful
63
- composition); the identity rule: the footer's range-total derives
64
- from the table's range-total, never the table's filteredCount, and
65
- this branch wins even when the target's [paginate] is also > 0;
70
+ composition; the target's own "?" carries through identically); the
71
+ identity rule: the footer's range-total derives from the table's
72
+ range-total, never the table's filteredCount, and this branch wins
73
+ even when the target's [paginate] is also > 0;
66
74
  (2) otherwise, when the target has [paginate] > 0, CLIENT mode: the
67
75
  target's filtered row count (search + column filters applied, before
68
76
  pagination). Refreshes on the target's `page` / `filter-change` /
@@ -71,9 +79,34 @@ props:
71
79
  on the target without an accompanying page move — emits no event;
72
80
  either re-set this footer's own attributes after such a change, or
73
81
  accept staleness until the target's next event.
82
+ type: [number, string]
83
+ default: 0
84
+ reflect: true
85
+ range-of:
86
+ description: >-
87
+ gh#1877, ADR-0082 Amendment — secondary whole-set total for the
88
+ "filtered-of-whole" shape: a client-side facet narrows [range-total]
89
+ (a filtered count) while range-of names the unfiltered set size, e.g.
90
+ "Showing 1–4 of 4 traversals (6 total)". Appends "(of <range-of>
91
+ total)" onto the normal finite range text; unset (the default)
92
+ changes nothing. Never applies while [range-total] is absent
93
+ (loading) or "?" (open/unproven) — there's no finite range text to
94
+ append onto yet. Named identically on table-toolbar-ui (ADR-0082
95
+ §"same name on both companions").
74
96
  type: number
75
97
  default: 0
76
98
  reflect: true
99
+ range-noun:
100
+ description: >-
101
+ gh#1877, ADR-0082 Amendment — optional per-slice noun appended after
102
+ the range total ("of 80 users") so consumers stop hand-painting the
103
+ noun themselves. Applies in both the normal finite range text and the
104
+ open/unproven-total text ("Showing 51–100 users" with no "of N").
105
+ Unset (the default) changes nothing. Named identically on
106
+ table-toolbar-ui (ADR-0082 §"same name on both companions").
107
+ type: string
108
+ default: ""
109
+ reflect: true
77
110
  siblings:
78
111
  description: Passed through to the composed pagination-ui's own [siblings] (page-number window size on each side of the current page).
79
112
  type: number
@@ -166,6 +199,13 @@ a2ui:
166
199
  range label's own position, without a layout shift versus the normal
167
200
  range text. Only fires on an EXPLICIT range-total="0" — omitting
168
201
  range-total entirely (loading) never shows it.
202
+ - >-
203
+ gh#1877, ADR-0082 Amendment — cursor/hasMore server paging (no
204
+ server-reported row count, only "more rows exist or not") sets
205
+ [range-total="?"] instead of a real number: the range label reads
206
+ "Showing 51–100" with no "of N", and the pager keeps `next` enabled
207
+ until a later response supplies a real [range-total], at which point
208
+ the footer renders the normal finite text automatically.
169
209
  anti_patterns:
170
210
  - wrong: '<table-footer-ui range-start="1" range-end="10" range-total="128"></table-footer-ui>'
171
211
  why: >-
@@ -185,6 +225,17 @@ examples:
185
225
  {"id": "tests", "component": "Table", "raw": true},
186
226
  {"id": "ftr", "component": "TableFooter", "for": "tests", "page": 1, "page-size": 10, "range-total": 128}
187
227
  ]
228
+ - name: table-footer-open-total
229
+ description: >-
230
+ gh#1877, ADR-0082 Amendment — cursor/hasMore server mode with no known
231
+ row count yet: [range-total="?"] renders "Showing 1–50 subscriptions"
232
+ (via [range-noun]) with `next` kept enabled until a real total resolves.
233
+ a2ui: >-
234
+ [
235
+ {"id": "root", "component": "Column", "gap": "0", "children": ["subs", "ftr"]},
236
+ {"id": "subs", "component": "Table", "raw": true},
237
+ {"id": "ftr", "component": "TableFooter", "for": "subs", "page": 1, "page-size": 50, "range-total": "?", "range-noun": "subscriptions"}
238
+ ]
188
239
  - name: table-footer-client-mode
189
240
  description: Client-mode footer bound to a [paginate]d table-ui with its own internal pager suppressed via [no-pager].
190
241
  a2ui: >-
@@ -70,16 +70,39 @@
70
70
  "type": "number",
71
71
  "default": 0
72
72
  },
73
+ "rangeNoun": {
74
+ "description": "gh#1877, ADR-0082 Amendment — optional per-slice noun appended after the range total (\"of 80 users\") so consumers stop hand-painting the noun themselves. Applies in both the normal finite range summary and the open/unproven-total summary (\"Showing 51–100 users\" with no \"of N\"). Unset (the default) changes nothing. Named identically to table-footer-ui's own [range-noun] (ADR-0082 §\"same name on both companions\").",
75
+ "type": "string",
76
+ "default": ""
77
+ },
78
+ "rangeOf": {
79
+ "description": "gh#1877, ADR-0082 Amendment — secondary whole-set total for the \"filtered-of-whole\" shape: a client-side facet narrows [rangeTotal] (a filtered count) while rangeOf names the unfiltered set size, e.g. \"Showing 1–4 of 4 traversals (6 total)\". Appends \"(of <rangeOf> total)\" onto the normal finite range summary; unset (the default) changes nothing. Never applies while the summary is in its open (\"?\") or opted-out (all-zero) state. Named identically to table-footer-ui's own [range-of] (ADR-0082 §\"same name on both companions\").",
80
+ "type": "number",
81
+ "default": 0
82
+ },
73
83
  "rangeStart": {
74
84
  "description": "1-indexed first row number of the current page, for the \"Showing X–Y of N\" range summary (e.g. `1` in \"Showing 1–25 of 320\"). Renders alongside — never in place of — [text]/[count]; the range summary is its own cluster, independent of the title cluster. All three of rangeStart/rangeEnd/rangeTotal must be set (> 0) for the summary to render; omit all three (the default) to opt out entirely.",
75
85
  "type": "number",
76
86
  "default": 0
77
87
  },
78
88
  "rangeTotal": {
79
- "description": "Total row count across all pages, for the range summary (e.g. `320` in \"Showing 1–25 of 320\"). Distinct from pagination-ui's `total` (total PAGES) — this is total ROWS; deliberately not spelled `total` to avoid that cross-sibling name collision (ADR-0063 B5). See rangeStart.",
80
- "type": "number",
89
+ "description": "Total row count across all pages, for the range summary (e.g. `320` in \"Showing 1–25 of 320\"). Distinct from pagination-ui's `total` (total PAGES) — this is total ROWS; deliberately not spelled `total` to avoid that cross-sibling name collision (ADR-0063 B5). See rangeStart. Explicit range-total=\"?\" (gh#1877, ADR-0082 Amendment) is the OPEN/unproven-total state — cursor/hasMore server paging with no known row count yet: the summary reads \"Showing X–Y\" with no \"of N\", requiring only [range-start]/[range-end] (> 0) to render, not rangeTotal too. Resolves to the normal finite summary the moment a real number replaces the \"?\". Named identically to table-footer-ui's own [range-total] (ADR-0082 §\"same name on both companions\").",
90
+ "type": [
91
+ "number",
92
+ "string"
93
+ ],
81
94
  "default": 0
82
95
  },
96
+ "size": {
97
+ "description": "Toolbar scale, via sizing.css's universal [size=\"sm|md|lg\"] ambient tokens. Not table-toolbar-specific chrome — the same attribute that scales any component. Set it once on the toolbar instead of on each slotted scope/actions-leading/actions control individually; anything slotted with no [size] of its own inherits the ambient value. Default (empty) = md.",
98
+ "type": "string",
99
+ "enum": [
100
+ "sm",
101
+ "md",
102
+ "lg"
103
+ ],
104
+ "default": ""
105
+ },
83
106
  "stage": {
84
107
  "description": "Explicit compaction-stage override (ADR-0076). Unset (the default, empty string) means auto-snap: the toolbar's own inline-size container query picks the nearest stage from its live width against the three --table-toolbar-bp-* breakpoints, snapping discretely, never interpolating. Setting stage explicitly pins that stage's rendering regardless of the container's actual width — overriding the container query, mirroring chart-ui's ratio override shape. Primary use: visual-eval fixtures that need a deterministic stage without resizing a real container, and a consumer embedding the toolbar inside a known-narrow panel who wants to skip auto-detection entirely. All four stages' CSS is now live (ADR-0076): search-tight steps the search field down (step 2); icon-only drops control labels and compacts/hides the range summary (steps 3+4); overflow collapses Filter/Sort/Columns into one \"More\" trigger routed through the same menu-ui/menu-item-ui pattern used elsewhere in the corpus (step 5). The resolved stage — the pin if set, otherwise the live width classification — also reflects onto the host as `data-stage-resolved` (step 7, informational only, mirrors chart-ui's `data-ratio-resolved` convention — never drives table-toolbar's own rendering, which stays 100% CSS-driven).",
85
108
  "type": "string",
@@ -97,6 +120,11 @@
97
120
  "type": "string",
98
121
  "default": ""
99
122
  },
123
+ "titleAlways": {
124
+ "description": "Opt out of the scope-reconciliation title hide: by default, a filled [slot=\"scope\"] region (a select/menu whose own displayed value already names the entity being listed) hides the separate [text]/[count] title cluster next to it, since together they'd say the same thing twice. Set [title-always] to keep the title cluster visible alongside a filled scope slot — e.g. a scope select PLUS an explicit text/count title on the same row (gh#1879).",
125
+ "type": "boolean",
126
+ "default": false
127
+ },
100
128
  "variant": {
101
129
  "description": "Toolbar visual variant. `default` renders bare on parent surface; `card` adds the same chrome as a card-ui header.",
102
130
  "type": "string",
@@ -169,6 +197,11 @@
169
197
  "description": "The ratified adiav2 list-page toolbar row (ADIA2-9123, gh#1649): scope menu, range summary, app-owned Filter/Columns triggers (their own filter drawer, not the native popovers — [no-filter]/[no-columns]/ [no-sort]/[no-search] drop the native affordances), page-size select, and a primary CTA — [slot=\"actions-leading\"] carries the Filter/Columns triggers so they land BEFORE the page-size select, while [slot=\"actions\"] carries the CTA so it lands after.",
170
198
  "a2ui": "[\n {\"id\": \"root\", \"component\": \"Column\", \"gap\": \"3\", \"children\": [\"bar\", \"card\"]},\n {\"id\": \"bar\", \"component\": \"TableToolbar\", \"for\": \"accounts\", \"no-filter\": true, \"no-sort\": true, \"no-columns\": true, \"no-search\": true, \"range-start\": 1, \"range-end\": 25, \"range-total\": 320, \"page-size\": 25, \"page-size-options\": [10, 25, 50, 100], \"children\": [\"btn-filter\", \"btn-columns\", \"btn-new\"]},\n {\"id\": \"btn-filter\", \"component\": \"Button\", \"text\": \"Filter\", \"variant\": \"ghost\", \"slot\": \"actions-leading\"},\n {\"id\": \"btn-columns\", \"component\": \"Button\", \"text\": \"Columns\", \"variant\": \"ghost\", \"slot\": \"actions-leading\"},\n {\"id\": \"btn-new\", \"component\": \"Button\", \"text\": \"New account\", \"variant\": \"primary\", \"slot\": \"actions\"},\n {\"id\": \"card\", \"component\": \"Card\", \"children\": [\"sec\"]},\n {\"id\": \"sec\", \"component\": \"Section\", \"bleed\": true, \"children\": [\"tbl\"]},\n {\"id\": \"tbl\", \"component\": \"Table\", \"id\": \"accounts\", \"sortable\": true, \"raw\": true}\n]",
171
199
  "name": "adiav2-list-page-toolbar-row"
200
+ },
201
+ {
202
+ "description": "gh#1877, ADR-0082 Amendment — cursor/hasMore server paging with no known row count yet: [range-total=\"?\"] renders \"Showing 1–50 subscriptions\" (via [range-noun]) with no \"of N\", resolving to the normal finite summary once a real total arrives.",
203
+ "a2ui": "[\n {\"id\": \"root\", \"component\": \"Column\", \"gap\": \"3\", \"children\": [\"bar\", \"card\"]},\n {\"id\": \"bar\", \"component\": \"TableToolbar\", \"for\": \"subs\", \"range-start\": 1, \"range-end\": 50, \"range-total\": \"?\", \"range-noun\": \"subscriptions\"},\n {\"id\": \"card\", \"component\": \"Card\", \"children\": [\"sec\"]},\n {\"id\": \"sec\", \"component\": \"Section\", \"bleed\": true, \"children\": [\"tbl\"]},\n {\"id\": \"tbl\", \"component\": \"Table\", \"id\": \"subs\", \"raw\": true}\n]",
204
+ "name": "table-toolbar-open-total"
172
205
  }
173
206
  ],
174
207
  "keywords": [
@@ -153,11 +153,31 @@ function collectUniqueValues(col, data) {
153
153
  export class UITableToolbar extends UIElement {
154
154
  static properties = {
155
155
  for: { type: String, default: '', reflect: true },
156
+ // Not consumed by any table-toolbar-specific CSS — sizing.css's
157
+ // universal [size="sm|md|lg"] (unscoped attribute selector, ambient
158
+ // inheriting tokens) already governs the host AND every slotted
159
+ // consumer content (scope select-ui, actions-leading/actions
160
+ // button-ui) that carries no [size] of its own, same as
161
+ // `<div size="sm">…</div>`. Declared (reflect: true) purely so this
162
+ // A2UI-modeled prop survives the docs-transpiler's schema-scoped
163
+ // attribute walk (unmodeled attributes are silently dropped there —
164
+ // verified live: `<table-toolbar-ui size="sm">` authored in
165
+ // table-toolbar.examples.html rendered with no [size] attribute at
166
+ // all on the generated site-a2ui page until this was added) and so
167
+ // `el.size = 'sm'` works from JS, matching card-ui/button-ui's own
168
+ // `size` property shape.
169
+ size: { type: String, default: '', reflect: true },
156
170
  text: { type: String, default: '', reflect: false },
157
171
  count: { type: String, default: '', reflect: false },
158
172
  rangeStart: { type: Number, default: 0, reflect: true, attribute: 'range-start' },
159
173
  rangeEnd: { type: Number, default: 0, reflect: true, attribute: 'range-end' },
160
174
  rangeTotal: { type: Number, default: 0, reflect: true, attribute: 'range-total' },
175
+ // gh#1877/ADR-0082 Amendment — same-name mirrors of table-footer-ui's
176
+ // own range-of/range-noun (ADR-0082 §"same name on both companions"):
177
+ // range-of is the secondary whole-set total ("filtered-of-whole"),
178
+ // range-noun the optional per-slice noun. Both presence-gated.
179
+ rangeOf: { type: Number, default: 0, reflect: true, attribute: 'range-of' },
180
+ rangeNoun: { type: String, default: '', reflect: true, attribute: 'range-noun' },
161
181
  pageSize: { type: Number, default: 0, reflect: true, attribute: 'page-size' },
162
182
  noFilter: { type: Boolean, default: false, reflect: true, attribute: 'no-filter' },
163
183
  noSort: { type: Boolean, default: false, reflect: true, attribute: 'no-sort' },
@@ -167,6 +187,13 @@ export class UITableToolbar extends UIElement {
167
187
  // above. Pure, absolute OR: see #updateControlVisibility(), which is the
168
188
  // single place that combines chromeOnly with each granular no-* value.
169
189
  chromeOnly: { type: Boolean, default: false, reflect: true, attribute: 'chrome-only' },
190
+ // gh#1879 — opt-out for the scope-reconciliation title hide
191
+ // (table-toolbar.css's `:scope:not([title-always]):has([data-scope]:not(:empty))
192
+ // [data-title] { display: none; }`). Presence-only: the CSS rule reads
193
+ // the reflected attribute directly, so no JS branch is needed here
194
+ // beyond the standard Boolean reflect declaration (same shape as
195
+ // noFilter/noSort/noColumns/noSearch/chromeOnly above).
196
+ titleAlways: { type: Boolean, default: false, reflect: true, attribute: 'title-always' },
170
197
  placeholder: { type: String, default: 'Search...', reflect: false },
171
198
  variant: { type: String, default: 'default', reflect: true },
172
199
  // ADR-0076 REQ-O-001 — explicit compaction-stage override. Unset (the
@@ -233,6 +260,27 @@ export class UITableToolbar extends UIElement {
233
260
 
234
261
  connected() {
235
262
  this.setAttribute('role', 'toolbar');
263
+ // `size` (declared above, reflect: true) defaults to '' — the base
264
+ // property system's own reflect pass (core/element.js) deliberately
265
+ // does NOT auto-stamp a non-empty string/number default onto the DOM
266
+ // (gh#961: scoped to Boolean-true defaults only, to avoid a
267
+ // framework-wide `attr=""`-stamping cascade), so a bare
268
+ // `<table-toolbar-ui>` never actually gets a `[size]` attribute from
269
+ // the default alone. Stamped explicitly, once, HERE — sizing.css's
270
+ // `[size="sm"]` is a plain unscoped attribute selector that needs the
271
+ // literal attribute present to set the ambient `--a-size`/`--a-ui-size`
272
+ // tokens at all. Every native control this component stamps below
273
+ // (badge, search, page-size select, granular buttons, overflow
274
+ // trigger) is a light-DOM descendant with no `[size]` of its own, so
275
+ // it inherits those tokens the same way any plain
276
+ // `<div size="sm">…</div>` cascades to its children — no per-child
277
+ // `size=` stamping needed (tried first, reverted: redundant with what
278
+ // inheritance already does for every element still in this light-DOM
279
+ // subtree, and actively wrong once a consumer sets an explicit
280
+ // `size="lg"` after connect — the per-child copies would go stale
281
+ // since nothing re-stamps them on a later property change, while
282
+ // inheritance updates live).
283
+ if (!this.hasAttribute('size')) this.setAttribute('size', this.size || 'sm');
236
284
  this.#warnUnknownOptOutAttrs();
237
285
  // gh#1615 — hydrate `page-size-options="[10,25,50]"` once at connect,
238
286
  // same pattern as table-ui's `data`/`columns` JSON-array attributes
@@ -307,7 +355,7 @@ export class UITableToolbar extends UIElement {
307
355
  // value one, so it needs the raw attribute callback, not the signal diff.
308
356
  attributeChangedCallback(name, oldVal, newVal) {
309
357
  super.attributeChangedCallback(name, oldVal, newVal);
310
- if (name === 'range-total') this.#updateSummary();
358
+ if (name === 'range-total' || name === 'range-of' || name === 'range-noun') this.#updateSummary();
311
359
  // ADR-0076 REQ-M-005 — the SECOND of the two triggers `data-stage-
312
360
  // resolved` must react to: a live resize (the ResizeObserver callback
313
361
  // above) AND a `stage` attribute change alone, with no resize needed.
@@ -479,6 +527,26 @@ export class UITableToolbar extends UIElement {
479
527
 
480
528
  // ── DOM stamp ────────────────────────────────────────────────────────────
481
529
 
530
+ // Used ONLY for the two stamped elements that end up OUTSIDE
531
+ // table-toolbar-ui's own light DOM — the popover-panel action buttons
532
+ // (#mkPopoverAction) and the filter-input (#fillFilterPanel), both
533
+ // appended to `document.body` for the Popover API's top-layer promotion
534
+ // (#togglePopover) — so ambient CSS inheritance from the host's own
535
+ // `[size]` attribute (stamped once in connected(), above) can never
536
+ // reach them. Every OTHER stamped control (badge, search, page-size
537
+ // select, granular Filter/Sort/Columns buttons, the overflow trigger)
538
+ // stays in the host's own light DOM and inherits that ambient value
539
+ // directly — no per-element `size=` copy needed, and no risk of one
540
+ // going stale after a later `.size =` change the way an explicit copy
541
+ // would (tried first, reverted: every in-tree control hardcoded
542
+ // `size="sm"` unconditionally, which not only ignored a consumer's
543
+ // `size="lg"` but also fought its OWN copy going stale on any later
544
+ // property change nothing re-stamped). Falls back to "sm" (the
545
+ // pre-existing hardcoded default) when `size` is unset.
546
+ #stampSize() {
547
+ return this.size || 'sm';
548
+ }
549
+
482
550
  #stamp() {
483
551
  if (this.querySelector(':scope > [data-toolbar]')) return;
484
552
 
@@ -509,7 +577,8 @@ export class UITableToolbar extends UIElement {
509
577
 
510
578
  const badge = document.createElement('badge-ui');
511
579
  badge.setAttribute('data-toolbar-count-badge', '');
512
- badge.setAttribute('size', 'sm');
580
+ // No own [size] — inherits the host's ambient --a-size (stamped in
581
+ // connected(), above).
513
582
  badge.setAttribute('tone', 'muted');
514
583
  badge.hidden = true;
515
584
  title.appendChild(badge);
@@ -577,7 +646,7 @@ export class UITableToolbar extends UIElement {
577
646
  // name renders as literal text before the icon registry resolves.
578
647
  const search = document.createElement('search-ui');
579
648
  search.setAttribute('data-search', '');
580
- search.setAttribute('size', 'sm'); // match toolbar buttons + badge + filter chips
649
+ // No own [size] inherits the host's ambient --a-size, same as badge.
581
650
  search.setAttribute('placeholder', this.placeholder);
582
651
  search.setAttribute('debounce', String(SEARCH_DEBOUNCE));
583
652
  search.addEventListener('search', this.#onSearch);
@@ -590,7 +659,7 @@ export class UITableToolbar extends UIElement {
590
659
  // existing default behavior for consumers that never set pageSizeOptions.
591
660
  const pageSize = document.createElement('select-ui');
592
661
  pageSize.setAttribute('data-page-size', '');
593
- pageSize.setAttribute('size', 'sm');
662
+ // No own [size] — inherits the host's ambient --a-size, same as badge.
594
663
  pageSize.setAttribute('aria-label', 'Rows per page');
595
664
  pageSize.hidden = true;
596
665
  pageSize.addEventListener('change', this.#onPageSizeChange);
@@ -645,7 +714,12 @@ export class UITableToolbar extends UIElement {
645
714
  btn.setAttribute('data-popover-action', '');
646
715
  btn.setAttribute('text', label);
647
716
  btn.setAttribute('variant', 'ghost');
648
- btn.setAttribute('size', 'sm');
717
+ // Explicit [size] IS still needed here, unlike the in-tree stamped
718
+ // controls above — #togglePopover appends this button's panel straight
719
+ // to `document.body` (Popover API top-layer promotion), not nested
720
+ // inside table-toolbar-ui in the DOM, so ambient CSS inheritance from
721
+ // the host's own [size] never reaches it.
722
+ btn.setAttribute('size', this.#stampSize());
649
723
  btn.setAttribute('stretch', '');
650
724
  btn.addEventListener('click', onClick);
651
725
  return btn;
@@ -657,7 +731,7 @@ export class UITableToolbar extends UIElement {
657
731
  btn.setAttribute('icon', icon);
658
732
  btn.setAttribute('text', label);
659
733
  btn.setAttribute('variant', 'outline');
660
- btn.setAttribute('size', 'sm');
734
+ // No own [size] — inherits the host's ambient --a-size, same as badge.
661
735
  btn.setAttribute('aria-haspopup', 'menu');
662
736
  // ADR-0076 REQ-X-002 — unconditional (harmless at full-text stages,
663
737
  // becomes the hover-tooltip affordance at the icon-only stage once
@@ -689,7 +763,7 @@ export class UITableToolbar extends UIElement {
689
763
  trigger.setAttribute('slot', 'trigger');
690
764
  trigger.setAttribute('icon', 'dots-three-vertical');
691
765
  trigger.setAttribute('variant', 'outline');
692
- trigger.setAttribute('size', 'sm');
766
+ // No own [size] — inherits the host's ambient --a-size, same as badge.
693
767
  // REQ-X-003 — a genuinely new interactive element (not a repurposed
694
768
  // existing button), so it needs its own static accessible name rather
695
769
  // than an inherited one. No [text] attribute — always icon-only,
@@ -788,13 +862,45 @@ export class UITableToolbar extends UIElement {
788
862
  const end = Number(this.rangeEnd) || 0;
789
863
  const total = Number(this.rangeTotal) || 0;
790
864
 
865
+ // gh#1877/ADR-0082 Amendment — same open/unproven-total discipline as
866
+ // table-footer-ui's own #draw(): NOT keyed on the raw attribute
867
+ // string. rangeTotal is a Number-typed reflected prop, so
868
+ // range-total="?" parses via `+"?"` → NaN and the property setter's
869
+ // own reflect() immediately rewrites the DOM attribute to the literal
870
+ // string "NaN" (core/element.js) — getAttribute('range-total') would
871
+ // read "NaN", never "?". The coerced PROPERTY still holds NaN stably
872
+ // (Object.is(NaN, NaN) is true, so the reflect's re-parse never
873
+ // re-writes it), and that's what tells "unknown yet" apart from a
874
+ // confirmed range-total="0" (a finite 0).
875
+ const rangeOpen = !Number.isFinite(Number(this.rangeTotal));
876
+ // gh#1877/ADR-0082 Amendment — additive suffixes onto the normal
877
+ // finite "of N" text; range-of (secondary whole-set total) never
878
+ // applies to the open-total branch (no finite total to append against).
879
+ const hasRangeOf = this.hasAttribute('range-of');
880
+ const rangeOfVal = hasRangeOf ? (Number(this.rangeOf) || 0) : 0;
881
+ const rangeOfSuffix = hasRangeOf && rangeOfVal > 0 ? ` (of ${rangeOfVal} total)` : '';
882
+ const nounSuffix = this.rangeNoun ? ` ${this.rangeNoun}` : '';
883
+
884
+ if (rangeOpen && start > 0 && end > 0) {
885
+ // REQ-open (gh#1877/ADR-0082 Amendment) — "Showing X–Y" with no
886
+ // "of N"; the short-form mirrors it minus the "Showing " prefix,
887
+ // same paired-population discipline as the finite branch below.
888
+ if (text) text.textContent = `Showing ${start}–${end}${nounSuffix}`;
889
+ if (text) text.hidden = false;
890
+ if (compact) compact.textContent = `${start}–${end}`;
891
+ if (compact) compact.hidden = false;
892
+ if (empty) empty.hidden = true;
893
+ summary.hidden = false;
894
+ return;
895
+ }
896
+
791
897
  if (start > 0 && end > 0 && total > 0) {
792
898
  // Normal range text — always wins over the empty slot (AC-014).
793
899
  // Both the full ("Showing X–Y of N") and short-form ("X–Y/N", ADR-0076
794
900
  // REQ-S-003) strings are populated together — which one paints is a
795
901
  // pure CSS decision (icon-only @container rule / [stage] pin), never
796
902
  // a JS branch (REQ-M-002).
797
- if (text) text.textContent = `Showing ${start}–${end} of ${total}`;
903
+ if (text) text.textContent = `Showing ${start}–${end} of ${total}${nounSuffix}${rangeOfSuffix}`;
798
904
  if (text) text.hidden = false;
799
905
  if (compact) compact.textContent = `${start}–${end}/${total}`;
800
906
  if (compact) compact.hidden = false;
@@ -1085,7 +1191,9 @@ export class UITableToolbar extends UIElement {
1085
1191
  } else {
1086
1192
  const input = document.createElement('input-ui');
1087
1193
  input.setAttribute('type', 'text');
1088
- input.setAttribute('size', 'sm');
1194
+ // Explicit [size] needed — this panel is appended straight to
1195
+ // document.body (see #mkPopoverAction's comment above).
1196
+ input.setAttribute('size', this.#stampSize());
1089
1197
  input.setAttribute('data-filter-input', '');
1090
1198
  input.setAttribute('placeholder', '—');
1091
1199
  if (current?.op === 'contains') input.value = current.value ?? '';