@adia-ai/web-components 0.8.44 → 0.8.45

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/components/context-menu/context-menu.a2ui.json +8 -3
  3. package/components/context-menu/context-menu.class.js +46 -5
  4. package/components/context-menu/context-menu.d.ts +6 -3
  5. package/components/context-menu/context-menu.examples.md +2 -2
  6. package/components/context-menu/context-menu.yaml +22 -5
  7. package/components/nav/nav.a2ui.json +2 -2
  8. package/components/nav/nav.css +1 -1
  9. package/components/nav/nav.d.ts +1 -1
  10. package/components/nav/nav.yaml +14 -3
  11. package/components/nav-group/nav-group.css +37 -3
  12. package/components/pagination/pagination.class.js +52 -22
  13. package/components/search/search.class.js +39 -5
  14. package/components/select/select.a2ui.json +5 -0
  15. package/components/select/select.class.js +20 -0
  16. package/components/select/select.css +25 -0
  17. package/components/select/select.d.ts +2 -0
  18. package/components/select/select.yaml +12 -0
  19. package/components/table/cell-types.js +9 -0
  20. package/components/table/table.class.js +247 -33
  21. package/components/table/table.css +7 -4
  22. package/components/table/table.yaml +6 -1
  23. package/components/table-toolbar/table-toolbar.a2ui.json +15 -0
  24. package/components/table-toolbar/table-toolbar.class.js +61 -17
  25. package/components/table-toolbar/table-toolbar.css +34 -0
  26. package/components/table-toolbar/table-toolbar.d.ts +10 -0
  27. package/components/table-toolbar/table-toolbar.yaml +41 -9
  28. package/core/data-stream.js +37 -2
  29. package/core/index.d.ts +1 -0
  30. package/core/index.js +1 -0
  31. package/core/provider.d.ts +9 -13
  32. package/core/provider.js +9 -113
  33. package/core/store.d.ts +46 -0
  34. package/core/store.js +89 -0
  35. package/custom-elements.json +54 -4
  36. package/dist/host.min.css +1 -1
  37. package/dist/host.sheet.js +1 -1
  38. package/dist/theme-provider.min.js +1 -1
  39. package/dist/web-components.min.css +1 -1
  40. package/dist/web-components.min.js +93 -93
  41. package/dist/web-components.sheet.js +1 -1
  42. package/package.json +1 -1
  43. package/styles/api/sizing.css +46 -0
package/CHANGELOG.md CHANGED
@@ -1,5 +1,29 @@
1
1
  # Changelog — @adia-ai/web-components
2
2
 
3
+ ## [0.8.45] — 2026-08-20
4
+
5
+ ### Added
6
+ - **`createStore()` — a signal-backed store primitive in `core/store.js` (gh#1761).** `subscribe(cb) -> unsubscribe`, an `Object.is` no-notify guard, and interop with `UIElement.controller`; ships alongside a new `check-no-hand-rolled-stores.mjs` lint gate (gh#1779) that rejects new hand-rolled Set-of-listeners stores outside `core/`.
7
+
8
+ ### Changed
9
+ - **`for=`-carrying components normalized to an events-only interaction contract (gh#1764/#1780), ADR-0079.** `table-toolbar-ui` → `table-ui` direct writes/calls (`paginate=`, `search=`, `setFilter()`, `clearFilters()`, `columns=`) became five `toolbar-*` command CustomEvents with matching `table-ui` listeners — additive, zero observable-behavior change. `context-menu-ui`'s `for` (selector-flavored) renamed to `target-selector`, `for` kept as a deprecated back-compat alias.
10
+ - **`RouteController` deduplicated into `core/controller.js` (gh#1766).** `provider.js` now imports/re-exports the canonical location.
11
+ - **`[density="compact"|"spacious"]` re-declares `--a-space-*` at the scoped selector, not only `:root` (gh#1771, gh#570).** Element-scoped density was globally inert; `components/table/table.css`'s stale ADR-0054 comment corrected alongside.
12
+ - **Billing composites (`billing-overview`, `invoice-history`, `invoice-detail`, `plan-picker`) route fetch through `core/transport.js`, opting out of the `core/data-stream.js` auto-observer via a new `data-stream-managed="false"` marker (gh#1760).**
13
+ - **`select`/`table-toolbar` — visually-hidden `select` label (`[label-hidden]`) + actions-leading icon-only Filter/Columns reduction (gh#1748).**
14
+ - **`table-ui` adopts an `isEqualNode()`-based `adoptOrDiffChildren()` for SSR adopt-in-place, with `RENDERER_OWNED` `WeakSet` tagging for unconditional replacement of renderer-owned cells (gh#1678).**
15
+ - **`search-ui`/`pagination-ui` post-adopt attribute writes are idempotent under SSR zero-mutation (gh#1755).**
16
+ - **Three independently-authored path-walker implementations in `packages/gen-ui/a2ui/path-pointer.js` unified, preserving each call site's real divergent edge-case semantics rather than silently collapsing them (empty-path, primitive mid-path, doubled-slash) — mechanical staging toward ADR-0078's vendored-model migration.**
17
+
18
+ ### Docs
19
+ - **New `table-with-toolbar-in-card-header` demo** — `table-toolbar-ui` composed inside `card-ui`'s header (sole-child case; a real `card.css` grid/centering conflict documented for the heading+action-slot case, not yet supported).
20
+ - **`nav-group-ui`'s `[data-nav-label]` group-label hook documented (gh#1736).**
21
+ - **Render clock & external-store wiring doctrine ratified in `.claude/docs/specs/component-implementation-patterns.md` (gh#1777/#1778)** — `table-ui`'s rAF-batched render clock named as the sole verified exception to the microtask-flush contract; `BaseController` promoted as a `createStore()` adapter via its existing optional-chaining `controller` setter (no new adapter code needed — the contract was already satisfied).
22
+
23
+ ### Maintenance
24
+ - **`dist/` bundles rebuilt** in this cut's window (6 file(s)) — regenerated from the source changes described above, not independent edits.
25
+ - **`styles/` touched in this release window** (2 file(s), e.g. `api/sizing.css`) — carried by the entries above.
26
+
3
27
  ## [0.8.44] — 2026-08-20
4
28
 
5
29
  ### Added
@@ -2,7 +2,7 @@
2
2
  "$schema": "https://json-schema.org/draft/2020-12/schema",
3
3
  "$id": "https://adiaui.dev/a2ui/v0_9/components/ContextMenu.json",
4
4
  "title": "ContextMenu",
5
- "description": "Right-click activated menu — the OS-native context-menu pattern as a\nweb component. Distinct from `menu-ui` (which is button-triggered):\nsame item shape (`menu-item-ui` children), different trigger surface\n(`contextmenu` event), and pointer-anchored positioning instead of\nelement-anchored. Pattern: WAI-APG Menu.\n\nTwo binding modes:\n **A. Wrap.** Default-slot child becomes the target:\n `<context-menu-ui><my-table>...</my-table>...items</context-menu-ui>`.\n **B. Selector.** Point at one or more existing elements via [for]:\n `<context-menu-ui for=\"#my-table\">...items</context-menu-ui>`.\n\nOn `contextmenu` event on a target: `preventDefault()`, position the\nmenu at the pointer coords, show via Popover API. Touch long-press\n(configurable via [long-press-ms]) does the same. Shift+F10 / Menu\nkey opens at the focused target's center for keyboard users.\n",
5
+ "description": "Right-click activated menu — the OS-native context-menu pattern as a\nweb component. Distinct from `menu-ui` (which is button-triggered):\nsame item shape (`menu-item-ui` children), different trigger surface\n(`contextmenu` event), and pointer-anchored positioning instead of\nelement-anchored. Pattern: WAI-APG Menu.\n\nTwo binding modes:\n **A. Wrap.** Default-slot child becomes the target:\n `<context-menu-ui><my-table>...</my-table>...items</context-menu-ui>`.\n **B. Selector.** Point at one or more existing elements via\n [target-selector]:\n `<context-menu-ui target-selector=\"#my-table\">...items</context-menu-ui>`.\n\nOn `contextmenu` event on a target: `preventDefault()`, position the\nmenu at the pointer coords, show via Popover API. Touch long-press\n(configurable via [long-press-ms]) does the same. Shift+F10 / Menu\nkey opens at the focused target's center for keyboard users.\n",
6
6
  "type": "object",
7
7
  "allOf": [
8
8
  {
@@ -17,7 +17,7 @@
17
17
  "const": "ContextMenu"
18
18
  },
19
19
  "for": {
20
- "description": "CSS selector(s) for target element(s). Empty = use default-slot child.",
20
+ "description": "DEPRECATED alias for [target-selector] — still a CSS selector, NOT an id-ref. Honored when [target-selector] is unset, with a one-shot console.warn pointing consumers at the replacement. New authoring should use [target-selector].",
21
21
  "type": "string",
22
22
  "default": ""
23
23
  },
@@ -30,6 +30,11 @@
30
30
  "description": "Programmatic open state. Set true to open at target center.",
31
31
  "type": "boolean",
32
32
  "default": false
33
+ },
34
+ "targetSelector": {
35
+ "description": "CSS selector(s) for target element(s). Empty = use default-slot child. Ratified name (gh#1764/#1780, ADR-0079) for what [for] used to carry — [for] means an id-ref everywhere else in AdiaUI (table-toolbar-ui, chart-legend-ui, tooltip-ui); context-menu-ui's own selector meaning predates that convention and was a same-name/ different-contract collision (ADR-0053's no-shadowing doctrine).",
36
+ "type": "string",
37
+ "default": ""
33
38
  }
34
39
  },
35
40
  "required": [
@@ -39,7 +44,7 @@
39
44
  "x-adiaui": {
40
45
  "anti_patterns": [
41
46
  {
42
- "fix": "Wrap a target: `<context-menu-ui><my-target></my-target>...items</context-menu-ui>` OR point at one: `<context-menu-ui for=\"#my-target\">...items</context-menu-ui>`.",
47
+ "fix": "Wrap a target: `<context-menu-ui><my-target></my-target>...items</context-menu-ui>` OR point at one: `<context-menu-ui target-selector=\"#my-target\">...items</context-menu-ui>`.",
43
48
  "why": "No target binding — the menu never opens.",
44
49
  "wrong": "<context-menu-ui>...just items...</context-menu-ui>"
45
50
  }
@@ -4,8 +4,16 @@
4
4
  * Two binding shapes:
5
5
  * A. Wrap a target — first non-menu-item-ui default-slot child becomes
6
6
  * the contextmenu host.
7
- * B. Point at targets via [for="<selector>"] — useful for whole-table
8
- * / whole-canvas menus where wrapping isn't practical.
7
+ * B. Point at targets via [target-selector="<css-selector>"] — useful for
8
+ * whole-table / whole-canvas menus where wrapping isn't practical.
9
+ * [for] is a DEPRECATED alias for this (gh#1764/#1780, ADR-0079): `for=`
10
+ * means an id-ref everywhere else in AdiaUI (table-toolbar-ui,
11
+ * chart-legend-ui, tooltip-ui) — context-menu-ui's own pre-ruling [for]
12
+ * carried a CSS-selector meaning instead, the exact same-attribute-
13
+ * different-contract shape ADR-0053's no-shadowing doctrine forbids.
14
+ * [target-selector] still resolves via `document.querySelectorAll()`
15
+ * (multiple targets); a still-set legacy [for] is honored with a
16
+ * one-shot console.warn pointing at the replacement.
9
17
  *
10
18
  * Architecture: reuses the existing anchor pattern (core/anchor.js) by
11
19
  * creating a virtual 1×1 anchor element at the pointer coords on each
@@ -23,8 +31,21 @@ import { anchorPopover } from '../../core/anchor.js';
23
31
 
24
32
  const LONG_PRESS_DEFAULT = 500;
25
33
 
34
+ // DEPRECATED [for]-alias one-shot warning guard (gh#1764/#1780, ADR-0079) —
35
+ // module-scope, matching this file's own idiom (a plain top-level const
36
+ // rather than a lazily-created public static field, which stayed mutable
37
+ // and externally visible for no reason).
38
+ const warnedForDeprecated = new WeakSet();
39
+
26
40
  export class UIContextMenu extends UIElement {
27
41
  static properties = {
42
+ // gh#1764/#1780, ADR-0079 — the ratified replacement for the
43
+ // selector-flavored [for] below; a plain CSS selector, not an id-ref.
44
+ targetSelector: { type: String, default: '', reflect: true, attribute: 'target-selector' },
45
+ // DEPRECATED — kept as a back-compat alias for `targetSelector` only.
46
+ // Honored (with a one-shot console.warn, see #legacyForSelector())
47
+ // whenever [target-selector] is unset. Never given id-ref semantics;
48
+ // the sweep did not attempt to dual-purpose this attribute.
28
49
  for: { type: String, default: '', reflect: true },
29
50
  open: { type: Boolean, default: false, reflect: true },
30
51
  longPressMs: { type: Number, default: LONG_PRESS_DEFAULT, reflect: false, attribute: 'long-press-ms' },
@@ -97,11 +118,12 @@ export class UIContextMenu extends UIElement {
97
118
  }
98
119
 
99
120
  #resolveTargets() {
100
- if (this.for) {
101
- try { return [...document.querySelectorAll(this.for)]; }
121
+ const selector = this.targetSelector || this.#legacyForSelector();
122
+ if (selector) {
123
+ try { return [...document.querySelectorAll(selector)]; }
102
124
  catch (e) {
103
125
  // eslint-disable-next-line no-console
104
- console.warn(`[context-menu-ui] invalid [for] selector: ${this.for}`, e);
126
+ console.warn(`[context-menu-ui] invalid [target-selector] selector: ${selector}`, e);
105
127
  return [];
106
128
  }
107
129
  }
@@ -113,6 +135,25 @@ export class UIContextMenu extends UIElement {
113
135
  return [];
114
136
  }
115
137
 
138
+ // DEPRECATED alias path (gh#1764/#1780, ADR-0079) — [for] used to carry
139
+ // context-menu-ui's own CSS-selector binding; [target-selector] is now
140
+ // the canonical name. Warn once per element, never per resolve (this
141
+ // runs on every #bindTargets() call, including reconnects).
142
+ #legacyForSelector() {
143
+ if (!this.for) return '';
144
+ if (!warnedForDeprecated.has(this)) {
145
+ warnedForDeprecated.add(this);
146
+ // eslint-disable-next-line no-console
147
+ console.warn(
148
+ `[context-menu-ui] [for="${this.for}"] is deprecated — use ` +
149
+ `[target-selector="${this.for}"] instead. [for] now means an ` +
150
+ 'id-ref everywhere else in AdiaUI; context-menu-ui\'s own prior ' +
151
+ '[for] carried a CSS-selector meaning (ADR-0079).',
152
+ );
153
+ }
154
+ return this.for;
155
+ }
156
+
116
157
  /* ── Trigger event handlers ──────────────────────────────────────── */
117
158
 
118
159
  #onContextMenu = (e) => {
@@ -8,8 +8,9 @@ element-anchored. Pattern: WAI-APG Menu.
8
8
  Two binding modes:
9
9
  **A. Wrap.** Default-slot child becomes the target:
10
10
  `<context-menu-ui><my-table>...</my-table>...items</context-menu-ui>`.
11
- **B. Selector.** Point at one or more existing elements via [for]:
12
- `<context-menu-ui for="#my-table">...items</context-menu-ui>`.
11
+ **B. Selector.** Point at one or more existing elements via
12
+ [target-selector]:
13
+ `<context-menu-ui target-selector="#my-table">...items</context-menu-ui>`.
13
14
 
14
15
  On `contextmenu` event on a target: `preventDefault()`, position the
15
16
  menu at the pointer coords, show via Popover API. Touch long-press
@@ -54,12 +55,14 @@ export interface ContextMenuSelectEventDetail {
54
55
  export type ContextMenuSelectEvent = CustomEvent<ContextMenuSelectEventDetail>;
55
56
 
56
57
  export class UIContextMenu extends UIElement {
57
- /** CSS selector(s) for target element(s). Empty = use default-slot child. */
58
+ /** DEPRECATED alias for [target-selector] — still a CSS selector, NOT an id-ref. Honored when [target-selector] is unset, with a one-shot console.warn pointing consumers at the replacement. New authoring should use [target-selector]. */
58
59
  for: string;
59
60
  /** Long-press duration (ms) on touch devices to open the menu. */
60
61
  longPressMs: number;
61
62
  /** Programmatic open state. Set true to open at target center. */
62
63
  open: boolean;
64
+ /** CSS selector(s) for target element(s). Empty = use default-slot child. Ratified name (gh#1764/#1780, ADR-0079) for what [for] used to carry — [for] means an id-ref everywhere else in AdiaUI (table-toolbar-ui, chart-legend-ui, tooltip-ui); context-menu-ui's own selector meaning predates that convention and was a same-name/ different-contract collision (ADR-0053's no-shadowing doctrine). */
65
+ targetSelector: string;
63
66
 
64
67
  addEventListener(type: 'context-menu-close', listener: (ev: ContextMenuCloseEvent) => unknown, options?: boolean | AddEventListenerOptions): void;
65
68
  addEventListener(type: 'context-menu-open', listener: (ev: ContextMenuOpenEvent) => unknown, options?: boolean | AddEventListenerOptions): void;
@@ -17,7 +17,7 @@
17
17
  </context-menu-ui>
18
18
  ```
19
19
 
20
- ## [for] selector mode — point at existing targets
20
+ ## [target-selector] mode — point at existing targets
21
21
 
22
22
  ```html
23
23
  <div style="display: grid; gap: var(--a-space-2);">
@@ -25,7 +25,7 @@
25
25
  <div data-row tabindex="0" style="padding: var(--a-space-3); border: 1px solid var(--md-sys-color-neutral-outline); border-radius: var(--a-radius-sm); background: var(--md-sys-color-neutral-background);">Row B — right-click</div>
26
26
  <div data-row tabindex="0" style="padding: var(--a-space-3); border: 1px solid var(--md-sys-color-neutral-outline); border-radius: var(--a-radius-sm); background: var(--md-sys-color-neutral-background);">Row C — right-click</div>
27
27
  </div>
28
- <context-menu-ui for='[data-chunk="context-menu-for"] [data-row]'>
28
+ <context-menu-ui target-selector='[data-chunk="context-menu-for"] [data-row]'>
29
29
  <menu-item-ui value="edit" text="Edit"></menu-item-ui>
30
30
  <menu-item-ui value="duplicate" text="Duplicate"></menu-item-ui>
31
31
  <menu-divider-ui></menu-divider-ui>
@@ -15,8 +15,9 @@ description: |
15
15
  Two binding modes:
16
16
  **A. Wrap.** Default-slot child becomes the target:
17
17
  `<context-menu-ui><my-table>...</my-table>...items</context-menu-ui>`.
18
- **B. Selector.** Point at one or more existing elements via [for]:
19
- `<context-menu-ui for="#my-table">...items</context-menu-ui>`.
18
+ **B. Selector.** Point at one or more existing elements via
19
+ [target-selector]:
20
+ `<context-menu-ui target-selector="#my-table">...items</context-menu-ui>`.
20
21
 
21
22
  On `contextmenu` event on a target: `preventDefault()`, position the
22
23
  menu at the pointer coords, show via Popover API. Touch long-press
@@ -25,8 +26,24 @@ description: |
25
26
  composes:
26
27
  - menu-item-ui
27
28
  props:
29
+ targetSelector:
30
+ description: >-
31
+ CSS selector(s) for target element(s). Empty = use default-slot
32
+ child. Ratified name (gh#1764/#1780, ADR-0079) for what [for] used
33
+ to carry — [for] means an id-ref everywhere else in AdiaUI
34
+ (table-toolbar-ui, chart-legend-ui, tooltip-ui); context-menu-ui's
35
+ own selector meaning predates that convention and was a same-name/
36
+ different-contract collision (ADR-0053's no-shadowing doctrine).
37
+ type: string
38
+ default: ""
39
+ reflect: true
40
+ attribute: target-selector
28
41
  for:
29
- description: CSS selector(s) for target element(s). Empty = use default-slot child.
42
+ description: >-
43
+ DEPRECATED alias for [target-selector] — still a CSS selector, NOT
44
+ an id-ref. Honored when [target-selector] is unset, with a one-shot
45
+ console.warn pointing consumers at the replacement. New authoring
46
+ should use [target-selector].
30
47
  type: string
31
48
  default: ""
32
49
  reflect: true
@@ -101,12 +118,12 @@ a2ui:
101
118
  reason: 'Trigger surface boundary.'
102
119
  - rule: 'Items are <menu-item-ui> children inside the default slot — same shape as <menu-ui> items.'
103
120
  reason: 'Single menu vocabulary.'
104
- - rule: 'Bind target via wrap (default-slot first non-menu-item-ui child) OR [for] selector. The selector form is useful for whole-table or whole-canvas menus where wrapping isn''t practical.'
121
+ - rule: 'Bind target via wrap (default-slot first non-menu-item-ui child) OR [target-selector]. The selector form is useful for whole-table or whole-canvas menus where wrapping isn''t practical. [for] still works as a deprecated alias but new authoring should use [target-selector] (gh#1764/#1780, ADR-0079).'
105
122
  reason: 'Two binding shapes.'
106
123
  anti_patterns:
107
124
  - wrong: '<context-menu-ui>...just items...</context-menu-ui>'
108
125
  why: 'No target binding — the menu never opens.'
109
- fix: 'Wrap a target: `<context-menu-ui><my-target></my-target>...items</context-menu-ui>` OR point at one: `<context-menu-ui for="#my-target">...items</context-menu-ui>`.'
126
+ fix: 'Wrap a target: `<context-menu-ui><my-target></my-target>...items</context-menu-ui>` OR point at one: `<context-menu-ui target-selector="#my-target">...items</context-menu-ui>`.'
110
127
  examples:
111
128
  - name: file-actions
112
129
  description: Right-click a file row for Open / Rename / Delete.
@@ -27,7 +27,7 @@
27
27
  "default": false
28
28
  },
29
29
  "heading": {
30
- "description": "Optional kicker label. Section variant renders it via ::before; primary uses it as aria-label only.",
30
+ "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.",
31
31
  "type": "string",
32
32
  "default": ""
33
33
  },
@@ -108,7 +108,7 @@
108
108
  ],
109
109
  "slots": {
110
110
  "default": {
111
- "description": "Primary slot — accepts <nav-group-ui> + <nav-item-ui> children, plus <hr data-nav-divider> for hand-placed dividers."
111
+ "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)."
112
112
  }
113
113
  },
114
114
  "states": [
@@ -32,7 +32,7 @@
32
32
  --nav-label-fg: var(--a-fg-muted);
33
33
  --nav-label-font-size: var(--a-kicker-sm);
34
34
  --nav-label-weight: var(--a-weight-medium);
35
- --nav-label-px: var(--a-space-2);
35
+ --nav-label-px: var(--a-space-4);
36
36
  --nav-label-py: var(--a-space-3) var(--a-space-1);
37
37
  }
38
38
 
@@ -36,7 +36,7 @@ export class UINav extends UIElement {
36
36
  collapsed: boolean;
37
37
  /** Auto-place dividers between adjacent groups + items. */
38
38
  divider: boolean;
39
- /** Optional kicker label. Section variant renders it via ::before; primary uses it as aria-label only. */
39
+ /** 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. */
40
40
  heading: string;
41
41
  /** Opt out of the default single-expanded-group behavior. When unset, selecting a nav-item-ui collapses every nav-group-ui except the one containing the newly selected item (all, when the selection is ungrouped); manual multi-expansion still works up until the next selection. */
42
42
  multiExpand: boolean;
@@ -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;