@adia-ai/web-components 0.8.43 → 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 (113) hide show
  1. package/CHANGELOG.md +54 -0
  2. package/components/avatar/avatar.class.js +12 -1
  3. package/components/button/button.d.ts +4 -0
  4. package/components/chart/chart.a2ui.json +26 -0
  5. package/components/chart/chart.class.js +1304 -222
  6. package/components/chart/chart.css +151 -1
  7. package/components/chart/chart.d.ts +6 -0
  8. package/components/chart/chart.yaml +57 -0
  9. package/components/chart-legend/chart-legend.a2ui.json +17 -2
  10. package/components/chart-legend/chart-legend.class.js +69 -4
  11. package/components/chart-legend/chart-legend.css +65 -1
  12. package/components/chart-legend/chart-legend.d.ts +4 -2
  13. package/components/chart-legend/chart-legend.examples.md +4 -4
  14. package/components/chart-legend/chart-legend.yaml +43 -3
  15. package/components/combobox/combobox.a2ui.json +3 -0
  16. package/components/combobox/combobox.class.js +9 -8
  17. package/components/combobox/combobox.css +25 -4
  18. package/components/combobox/combobox.yaml +6 -0
  19. package/components/command/command.css +17 -1
  20. package/components/context-menu/context-menu.a2ui.json +8 -3
  21. package/components/context-menu/context-menu.class.js +46 -5
  22. package/components/context-menu/context-menu.d.ts +8 -3
  23. package/components/context-menu/context-menu.examples.md +2 -2
  24. package/components/context-menu/context-menu.yaml +22 -5
  25. package/components/heatmap/heatmap.a2ui.json +5 -0
  26. package/components/heatmap/heatmap.d.ts +2 -0
  27. package/components/heatmap/heatmap.yaml +8 -0
  28. package/components/input/input.a2ui.json +2 -2
  29. package/components/input/input.class.js +12 -7
  30. package/components/input/input.yaml +6 -5
  31. package/components/integration-card/integration-card.d.ts +2 -0
  32. package/components/nav/nav.a2ui.json +2 -2
  33. package/components/nav/nav.css +1 -1
  34. package/components/nav/nav.d.ts +1 -1
  35. package/components/nav/nav.yaml +14 -3
  36. package/components/nav-group/nav-group.css +37 -3
  37. package/components/noodles/noodles.a2ui.json +0 -15
  38. package/components/noodles/noodles.yaml +0 -12
  39. package/components/number-format/number-format.class.js +10 -0
  40. package/components/pagination/pagination.class.js +140 -26
  41. package/components/popover/popover.d.ts +2 -0
  42. package/components/progress/progress.class.js +11 -6
  43. package/components/range/range.class.js +9 -1
  44. package/components/search/search.class.js +39 -5
  45. package/components/select/select.a2ui.json +10 -1
  46. package/components/select/select.class.js +34 -14
  47. package/components/select/select.css +39 -1
  48. package/components/select/select.d.ts +2 -0
  49. package/components/select/select.yaml +19 -1
  50. package/components/stream/stream.d.ts +2 -0
  51. package/components/table/cell-types.js +9 -0
  52. package/components/table/table.a2ui.json +6 -1
  53. package/components/table/table.class.js +265 -34
  54. package/components/table/table.css +38 -10
  55. package/components/table/table.d.ts +3 -1
  56. package/components/table/table.examples.md +2 -2
  57. package/components/table/table.yaml +15 -2
  58. package/components/table-toolbar/table-toolbar.a2ui.json +43 -4
  59. package/components/table-toolbar/table-toolbar.class.js +407 -28
  60. package/components/table-toolbar/table-toolbar.css +299 -0
  61. package/components/table-toolbar/table-toolbar.d.ts +15 -1
  62. package/components/table-toolbar/table-toolbar.yaml +162 -15
  63. package/components/tag/tag.class.js +13 -1
  64. package/components/text/text.a2ui.json +3 -3
  65. package/components/text/text.d.ts +5 -3
  66. package/components/text/text.yaml +3 -3
  67. package/components/textarea/textarea.class.js +9 -1
  68. package/components/tour/tour.d.ts +10 -0
  69. package/core/a11y.d.ts +22 -0
  70. package/core/a11y.js +64 -0
  71. package/core/anchor.js +9 -5
  72. package/core/data-stream.js +37 -2
  73. package/core/element.js +1 -1
  74. package/core/index.d.ts +1 -0
  75. package/core/index.js +1 -0
  76. package/core/provider.d.ts +9 -13
  77. package/core/provider.js +9 -113
  78. package/core/store.d.ts +46 -0
  79. package/core/store.js +89 -0
  80. package/custom-elements.json +127 -33
  81. package/dist/host.min.css +1 -1
  82. package/dist/host.sheet.js +1 -1
  83. package/dist/theme-provider.min.js +1 -1
  84. package/dist/web-components.min.css +1 -1
  85. package/dist/web-components.min.js +119 -119
  86. package/dist/web-components.sheet.js +1 -1
  87. package/package.json +1 -1
  88. package/patterns/access-requests/access-requests.examples.html +1 -1
  89. package/patterns/admin-keys-and-export/admin-keys-and-export.examples.html +7 -7
  90. package/patterns/agent-cost/agent-cost.examples.html +6 -6
  91. package/patterns/agent-memory/agent-memory.examples.html +3 -3
  92. package/patterns/agent-prompt-library/agent-prompt-library.examples.html +8 -8
  93. package/patterns/agent-tool-call/agent-tool-call.examples.html +1 -1
  94. package/patterns/approvals/approvals.examples.html +6 -6
  95. package/patterns/audit-log/audit-log.examples.html +1 -1
  96. package/patterns/bulk-action-toolbar/bulk-action-toolbar.examples.html +4 -4
  97. package/patterns/chart-in-card/chart-in-card.examples.html +114 -0
  98. package/patterns/chart-in-card/chart-in-card.examples.js +37 -0
  99. package/patterns/comments-and-collaboration/comments-and-collaboration.examples.html +5 -5
  100. package/patterns/data-tables-inline-edit-and-tree/data-tables-inline-edit-and-tree.examples.html +2 -2
  101. package/patterns/diff-review/diff-review.examples.html +6 -6
  102. package/patterns/export-flow/export-flow.examples.html +2 -2
  103. package/patterns/filter-bar/filter-bar.examples.html +2 -2
  104. package/patterns/forms-conditional-and-autocomplete/forms-conditional-and-autocomplete.examples.html +2 -2
  105. package/patterns/inline-dialog/inline-dialog.examples.html +2 -2
  106. package/patterns/marketing-engagement/marketing-engagement.examples.html +4 -4
  107. package/patterns/notifications-bell-and-digest/notifications-bell-and-digest.examples.html +1 -1
  108. package/patterns/permissions-matrix/permissions-matrix.examples.html +1 -1
  109. package/patterns/permissions-sharing/permissions-sharing.examples.html +2 -2
  110. package/patterns/profile-public-and-verification/profile-public-and-verification.examples.html +4 -4
  111. package/patterns/search-discovery/search-discovery.examples.html +2 -2
  112. package/styles/api/sizing.css +46 -0
  113. package/styles/type/roles.css +29 -9
@@ -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);
@@ -41,11 +41,6 @@
41
41
  "type": "boolean",
42
42
  "default": false
43
43
  },
44
- "port-size": {
45
- "description": "Port indicator dot size in pixels",
46
- "type": "number",
47
- "default": 10
48
- },
49
44
  "portSize": {
50
45
  "description": "Port indicator size",
51
46
  "type": "number",
@@ -56,21 +51,11 @@
56
51
  "type": "boolean",
57
52
  "default": false
58
53
  },
59
- "show-ports": {
60
- "description": "Show port indicator dots. Defaults to true when editable",
61
- "type": "boolean",
62
- "default": false
63
- },
64
54
  "showPorts": {
65
55
  "description": "Show port indicators",
66
56
  "type": "boolean",
67
57
  "default": false
68
58
  },
69
- "stroke-width": {
70
- "description": "Stroke width in pixels",
71
- "type": "number",
72
- "default": 2
73
- },
74
59
  "strokeWidth": {
75
60
  "description": "Line stroke width",
76
61
  "type": "number",
@@ -30,10 +30,6 @@ props:
30
30
  description: Allow interactive creation/deletion of connections via drag
31
31
  type: boolean
32
32
  default: false
33
- port-size:
34
- description: Port indicator dot size in pixels
35
- type: number
36
- default: 10
37
33
  portSize:
38
34
  description: Port indicator size
39
35
  type: number
@@ -44,19 +40,11 @@ props:
44
40
  type: boolean
45
41
  default: false
46
42
  reflect: true
47
- show-ports:
48
- description: Show port indicator dots. Defaults to true when editable
49
- type: boolean
50
- default: false
51
43
  showPorts:
52
44
  description: Show port indicators
53
45
  type: boolean
54
46
  default: false
55
47
  attribute: show-ports
56
- stroke-width:
57
- description: Stroke width in pixels
58
- type: number
59
- default: 2
60
48
  strokeWidth:
61
49
  description: Line stroke width
62
50
  type: number
@@ -87,6 +87,16 @@ export class UINumberFormat extends UIElement {
87
87
  this.textContent = text;
88
88
  // Mirror raw value via aria-label so screen readers can read the
89
89
  // underlying number alongside the formatted glyphs.
90
+ //
91
+ // gh#1647 judgment call (triage-corrections comment, 2026-08-18):
92
+ // this aria-label is intentional component-owned chrome, not a
93
+ // consumer-overridable name — it mirrors the RAW underlying value
94
+ // ("42% (raw: 0.42)") specifically for a11y clarity, so a consumer
95
+ // override would likely defeat that purpose. Deliberately NOT ported
96
+ // to the value-tracking `#lastAutoAriaLabel` guard (core/a11y.js)
97
+ // that the other five gh#1647 primitives got — kept unconditional,
98
+ // auto-deriving on every render. Revisit only if a stated consumer
99
+ // need for overriding it surfaces.
90
100
  if (text && Number.isFinite(Number(this.value))) {
91
101
  this.setAttribute('aria-label', `${text} (raw: ${this.value})`);
92
102
  } else {
@@ -22,6 +22,33 @@
22
22
  */
23
23
 
24
24
  import { UIElement } from '../../core/element.js';
25
+ import { KEY_MAP } from '../../core/template.js';
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
+ }
25
52
 
26
53
  export class UIPagination extends UIElement {
27
54
  static properties = {
@@ -48,10 +75,32 @@ export class UIPagination extends UIElement {
48
75
 
49
76
  connected() {
50
77
  if (!this.#nav) {
51
- this.#nav = document.createElement('nav');
52
- this.#nav.setAttribute('slot', 'nav');
53
- this.#nav.setAttribute('aria-label', 'Pagination');
54
- this.appendChild(this.#nav);
78
+ // Adopt a pre-existing server-rendered <nav slot="nav"> instead of
79
+ // unconditionally appending a fresh one — first-connect adoption
80
+ // (gh#1687). Steady-state reconcile is unaffected; this only guards
81
+ // the construction-time #nav field being null on upgrade.
82
+ this.#nav = this.querySelector(':scope > nav[slot="nav"]');
83
+ if (this.#nav) {
84
+ // Adopting an existing nav is only half the fix — reconcile()'s
85
+ // own keyed-diff (element.js) keys off a WeakMap-shaped
86
+ // `parent[KEY_MAP]` populated by THIS element's own prior render
87
+ // calls, which a freshly-parsed SSR fragment never has. Without
88
+ // seeding it here, the very first render treats every adopted
89
+ // child as unrecognized, stamps a full set of BRAND NEW nodes
90
+ // alongside them, and never removes the orphaned originals —
91
+ // silently doubling the button count inside the (correctly
92
+ // singular) adopted <nav>. Positionally zip the adopted
93
+ // children against the SAME key order #buildRange would
94
+ // produce right now; a shape mismatch (stale/hand-edited SSR
95
+ // markup) is left unseeded so reconcile falls back to its
96
+ // normal rebuild-from-scratch behavior instead of miskeying.
97
+ this.#seedKeyMapFromAdoptedNav();
98
+ } else {
99
+ this.#nav = document.createElement('nav');
100
+ this.#nav.setAttribute('slot', 'nav');
101
+ this.#nav.setAttribute('aria-label', 'Pagination');
102
+ this.appendChild(this.#nav);
103
+ }
55
104
  }
56
105
 
57
106
  if (!this.#bound) {
@@ -69,6 +118,67 @@ export class UIPagination extends UIElement {
69
118
  this.#bound = false;
70
119
  }
71
120
 
121
+ // Review finding (PR #1692) — the tag/marker a given item.type produces
122
+ // via #createItem(): ellipsis is a bare <span data-ellipsis>; prev/next/
123
+ // page are all <button-ui> distinguished by their own data-* marker
124
+ // (page additionally carries data-value). A same-LENGTH adopted child
125
+ // that doesn't match this shape (e.g. a plain <span> standing in for a
126
+ // page-cell <button-ui>) would still get SEEDED by a count-only check,
127
+ // then #updateItem() — which only sets attributes, never replaces the
128
+ // element — leaves it permanently the wrong tag. Checking shape here
129
+ // is what makes the fallback in #seedKeyMapFromAdoptedNav trustworthy.
130
+ #childMatchesItem(el, item) {
131
+ if (item.type === 'ellipsis') {
132
+ return el.tagName === 'SPAN' && el.hasAttribute('data-ellipsis');
133
+ }
134
+ if (el.tagName !== 'BUTTON-UI') return false;
135
+ if (item.type === 'prev') return el.hasAttribute('data-prev');
136
+ if (item.type === 'next') return el.hasAttribute('data-next');
137
+ return el.hasAttribute('data-page') && el.dataset.value === String(item.value);
138
+ }
139
+
140
+ // gh#1687 — see the call site's comment in connected(). Positionally
141
+ // zips the adopted nav's existing children against the key order the
142
+ // very next render() will compute, seeding reconcile()'s own
143
+ // `parent[KEY_MAP]` (element.js) so that render finds every item
144
+ // already present instead of stamping a duplicate full set.
145
+ #seedKeyMapFromAdoptedNav() {
146
+ const page = Math.max(1, Math.min(this.page, this.total));
147
+ const total = Math.max(1, this.total);
148
+ const siblings = Math.max(0, this.siblings);
149
+ const range = this.#buildRange(page, total, siblings);
150
+ const children = [...this.#nav.children];
151
+ const shapeMatches = children.length === range.length
152
+ && range.every((item, i) => this.#childMatchesItem(children[i], item));
153
+ if (!shapeMatches) {
154
+ // Review finding (PR #1692, two rounds) — a bare return here left
155
+ // the mismatch case worse off than doing nothing: reconcile()'s
156
+ // own cleanup loop only removes entries it finds IN
157
+ // `parent[KEY_MAP]`, so an unseeded map means every existing
158
+ // child is invisible to it — the next render() then stamps a
159
+ // full fresh set AND the stale adopted children survive alongside
160
+ // it (reproduced: 3 stale placeholder buttons + a correct fresh
161
+ // 9-item render = 12 nodes). Length alone isn't a reliable
162
+ // correlation signal either — a same-length fixture with the
163
+ // WRONG tag/marker per position (e.g. N bare <span>s standing in
164
+ // for N <button-ui> page-cells) would pass a count-only check and
165
+ // get seeded, then #updateItem() — attribute-only, never a tag
166
+ // swap — leaves it permanently wrong. Either kind of mismatch
167
+ // means the adopted markup can't be trusted to correlate with the
168
+ // next render's key order at all — abandon adoption for real:
169
+ // clear the stale children so the fallback is an actual clean
170
+ // rebuild, the same shape #nav would be in if it had just been
171
+ // freshly created.
172
+ this.#nav.replaceChildren();
173
+ return;
174
+ }
175
+ const map = new Map();
176
+ for (let i = 0; i < range.length; i++) {
177
+ map.set(range[i].key, children[i]);
178
+ }
179
+ this.#nav[KEY_MAP] = map;
180
+ }
181
+
72
182
  render() {
73
183
  if (!this.#nav) return;
74
184
 
@@ -167,21 +277,21 @@ export class UIPagination extends UIElement {
167
277
  }
168
278
 
169
279
  const btn = document.createElement('button-ui');
170
- btn.setAttribute('size', this.size);
280
+ setAttrIfChanged(btn, 'size', this.size);
171
281
 
172
282
  if (item.type === 'prev') {
173
283
  btn.setAttribute('data-prev', '');
174
- btn.setAttribute('icon', 'caret-left');
175
- btn.setAttribute('aria-label', 'Previous page');
284
+ setAttrIfChanged(btn, 'icon', 'caret-left');
285
+ setAttrIfChanged(btn, 'aria-label', 'Previous page');
176
286
  } else if (item.type === 'next') {
177
287
  btn.setAttribute('data-next', '');
178
- btn.setAttribute('icon', 'caret-right');
179
- btn.setAttribute('aria-label', 'Next page');
288
+ setAttrIfChanged(btn, 'icon', 'caret-right');
289
+ setAttrIfChanged(btn, 'aria-label', 'Next page');
180
290
  } else {
181
291
  btn.setAttribute('data-page', '');
182
- btn.dataset.value = String(item.value);
183
- btn.setAttribute('text', String(item.value));
184
- 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}`);
185
295
  }
186
296
 
187
297
  this.#updateItem(btn, item, page);
@@ -192,32 +302,36 @@ export class UIPagination extends UIElement {
192
302
  if (item.type === 'ellipsis') return;
193
303
 
194
304
  // Keep size in sync \u2014 the host's [size] may change between renders.
195
- 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);
196
310
 
197
311
  if (item.type === 'prev') {
198
- el.setAttribute('variant', this.#restVariant());
199
- if (page <= 1) { el.setAttribute('disabled', ''); el.setAttribute('tabindex', '-1'); }
200
- 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'); }
201
315
  } else if (item.type === 'next') {
202
- el.setAttribute('variant', this.#restVariant());
203
- if (page >= this.total) { el.setAttribute('disabled', ''); el.setAttribute('tabindex', '-1'); }
204
- 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'); }
205
319
  } else {
206
- el.dataset.value = String(item.value);
207
- el.setAttribute('text', String(item.value));
208
- 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}`);
209
323
  if (item.value === page) {
210
324
  // Active page reads as `variant="primary"` so the filled-accent
211
325
  // state comes from button-ui's primary surface matrix (the
212
326
  // canonical token chain) \u2014 not a pagination-tier re-impl.
213
- el.setAttribute('variant', 'primary');
327
+ setAttrIfChanged(el, 'variant', 'primary');
214
328
  // aria-current="page" alone carries the active-page state — no
215
329
  // private data-active mirror (gh#1332 Category A; admin-roster's
216
330
  // aria-checked precedent from PR #1438).
217
- el.setAttribute('aria-current', 'page');
331
+ setAttrIfChanged(el, 'aria-current', 'page');
218
332
  } else {
219
- el.setAttribute('variant', this.#restVariant());
220
- el.removeAttribute('aria-current');
333
+ setAttrIfChanged(el, 'variant', this.#restVariant());
334
+ removeAttrIfPresent(el, 'aria-current');
221
335
  }
222
336
  }
223
337
  }
@@ -13,6 +13,8 @@
13
13
  import { UIElement } from '../../core/element.js';
14
14
 
15
15
  export class UIPopover extends UIElement {
16
+ /** Size the content panel to the trigger's width instead of max-content. Opt-in — the default (unset) keeps every existing consumer's content-sized panel unchanged. */
17
+ matchWidth: boolean;
16
18
  /** Pixel offset between the anchor and the popover. Renamed from `[gap]` (gh#1335) — a raw-px number can't share a name with the global Scale-grammar `[gap]` attribute (ADR-0053). */
17
19
  offset: number;
18
20
  /** Controls visibility via showPopover()/hidePopover() */
@@ -28,6 +28,7 @@
28
28
  */
29
29
 
30
30
  import { UIElement } from '../../core/element.js';
31
+ import { syncAutoAriaLabel } from '../../core/a11y.js';
31
32
 
32
33
  export class UIProgress extends UIElement {
33
34
  static properties = {
@@ -42,6 +43,10 @@ export class UIProgress extends UIElement {
42
43
  #fill = null;
43
44
  #labelEl = null;
44
45
  #metaEl = null;
46
+ // gh#1647: tracks the aria-label VALUE this element itself last derived
47
+ // from `label`/`meta` (undefined when none) — value-tracking guard
48
+ // ported from tag.class.js/select.class.js (gh#1644/#1646).
49
+ #lastAutoAriaLabel = undefined;
45
50
 
46
51
  connected() {
47
52
  this.setAttribute('role', 'progressbar');
@@ -111,12 +116,12 @@ export class UIProgress extends UIElement {
111
116
  }
112
117
  }
113
118
 
114
- // ARIA: label the control so screen readers announce "CPU, 42 percent".
115
- if (this.label) {
116
- this.setAttribute('aria-label', this.meta ? `${this.label}, ${this.meta}` : this.label);
117
- } else {
118
- this.removeAttribute('aria-label');
119
- }
119
+ // ARIA: label the control so screen readers announce "CPU, 42 percent"
120
+ // — never clobbering a consumer's own aria-label (gh#1647,
121
+ // value-tracking guard ported from tag.class.js/select.class.js,
122
+ // gh#1644/#1646).
123
+ const ariaSource = this.label ? (this.meta ? `${this.label}, ${this.meta}` : this.label) : '';
124
+ this.#lastAutoAriaLabel = syncAutoAriaLabel(this, this.#lastAutoAriaLabel, ariaSource);
120
125
  }
121
126
 
122
127
  disconnected() {
@@ -25,6 +25,7 @@
25
25
  */
26
26
 
27
27
  import { UIFormElement } from '../../core/form.js';
28
+ import { syncAutoAriaLabel } from '../../core/a11y.js';
28
29
 
29
30
  export class UIRange extends UIFormElement {
30
31
  // Opt out of UIFormElement's per-control `label` deprecation warning.
@@ -52,6 +53,10 @@ export class UIRange extends UIFormElement {
52
53
  #dragging = false;
53
54
  #startX = 0;
54
55
  #startVal = 0;
56
+ // gh#1647: tracks the aria-label VALUE this element itself last derived
57
+ // from `label` (undefined when none) — value-tracking guard ported from
58
+ // tag.class.js/select.class.js (gh#1644/#1646).
59
+ #lastAutoAriaLabel = undefined;
55
60
 
56
61
  connected() {
57
62
  super.connected();
@@ -96,7 +101,10 @@ export class UIRange extends UIFormElement {
96
101
  this.setAttribute('aria-valuemax', this.max);
97
102
  this.setAttribute('aria-valuenow', this.value);
98
103
  this.setAttribute('aria-valuetext', `${this.value}${this.suffix ? ' ' + this.suffix : ''}`);
99
- this.setAttribute('aria-label', this.label || '');
104
+
105
+ // A11y (gh#1647): never clobber a consumer's own aria-label
106
+ // (value-tracking guard, gh#1644/#1646).
107
+ this.#lastAutoAriaLabel = syncAutoAriaLabel(this, this.#lastAutoAriaLabel, this.label || '');
100
108
 
101
109
  this.syncValue(String(this.value));
102
110
  }
@@ -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",
@@ -301,7 +306,11 @@
301
306
  ]
302
307
  },
303
308
  "tag": "select-ui",
304
- "tokens": {},
309
+ "tokens": {
310
+ "--select-min-width": {
311
+ "description": "Intrinsic min-width floor on the host (default `20ch`, matching input-ui's own `--input-min-width` UA-baseline derivation) so a select in a shrinking flex/grid cell doesn't crush to unusable width. Set to `0` to opt back into full collapse (gh#1633)."
312
+ }
313
+ },
305
314
  "traits": [],
306
315
  "version": 1
307
316
  }