@adia-ai/web-components 0.8.37 → 0.8.38

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.
@@ -91,9 +91,14 @@ export class UINav extends UIElement {
91
91
 
92
92
  select(item) {
93
93
  const prev = this.selectedItem;
94
- if (prev && prev !== item) prev.removeAttribute('selected');
94
+ // gh#1254: re-selecting the already-selected item is a no-op — no
95
+ // attribute churn, no hover flush, no event. Selection genuinely
96
+ // unchanged is not a selection event.
97
+ if (prev === item) return;
98
+ if (prev) prev.removeAttribute('selected');
95
99
  if (item) {
96
100
  item.setAttribute('selected', '');
101
+ this.#flushHoverState();
97
102
  this.dispatchEvent(new CustomEvent('nav-select', {
98
103
  bubbles: true,
99
104
  detail: { item, text: item.text, value: item.value },
@@ -107,13 +112,11 @@ export class UINav extends UIElement {
107
112
  }
108
113
 
109
114
  #onClick = (e) => {
110
- const item = e.target.closest('nav-item-ui');
111
- if (item && this.contains(item)) {
112
- if (item.disabled) return;
113
- this.select(item);
114
- this.#flushHoverState();
115
- return;
116
- }
115
+ // gh#1254: <nav-item-ui> owns its own click + keyboard activation
116
+ // (nav-item.class.js #onClick calls parent.select(this) directly)
117
+ // delegating the same selection here too double-invoked select() per
118
+ // click (the layered handlers the issue reports). Do NOT re-add an
119
+ // item branch here; this listener is for group expand/popover only.
117
120
 
118
121
  // Group expand/popover — primary variant only.
119
122
  if (this.variant === 'section') return;
@@ -18,8 +18,10 @@
18
18
  *
19
19
  * Supports icon, label, optional badge, selected/disabled state, and
20
20
  * keyboard activation (Enter/Space). Selection is managed by the parent
21
- * <nav-ui>; clicking or keyboard-activating an item calls nav.select(this)
22
- * and dispatches `nav-select` (bubbles, detail: { item, text, value }).
21
+ * <nav-ui>; clicking or keyboard-activating an item calls nav.select(this),
22
+ * which dispatches `nav-select` (bubbles from <nav-ui>, detail: { item,
23
+ * text, value }) exactly once when selection actually changes — see
24
+ * nav.class.js#select. This class does not dispatch its own copy (gh#1254).
23
25
  */
24
26
 
25
27
  import { UIElement } from '../../core/element.js';
@@ -39,12 +41,13 @@ export class UINavItem extends UIElement {
39
41
 
40
42
  #onClick = (e) => {
41
43
  if (this.disabled) { e.preventDefault(); return; }
44
+ // gh#1254: nav.select() is the single source of `nav-select` — it
45
+ // dispatches (bubbling from <nav-ui>) only when selection actually
46
+ // changes, and is a no-op re-clicking the already-selected item.
47
+ // A second dispatch here duplicated the event on every click and
48
+ // fired again even on a no-op reselect.
42
49
  const parent = this.closest('nav-ui');
43
50
  parent?.select?.(this);
44
- this.dispatchEvent(new CustomEvent('nav-select', {
45
- bubbles: true,
46
- detail: { item: this, text: this.text, value: this.value },
47
- }));
48
51
  };
49
52
 
50
53
  #onKey = (e) => {
@@ -85,8 +85,20 @@
85
85
  "footer"
86
86
  ],
87
87
  "slots": {
88
+ "description": {
89
+ "description": "Secondary metadata — grid row 2 beneath the heading inside the page header. Also accepts bare `<p>` / `<small>` / body-variant `<text-ui>` as direct children without slot=\"description\"."
90
+ },
88
91
  "default": {
89
- "description": "Composes from the slot primitives — `<header-ui>` (page header),\n`<section-ui>` (main content), optional `<footer-ui>`. Native\n`<header>` / `<section>` / `<footer>` also work; the @scope rules\ntarget both via `:where(header, header-ui)`.\n"
92
+ "description": "Composes from the slot primitives — `<header-ui>` (page header),\n`<section-ui>` (main content), optional `<footer-ui>`. Native\n`<header>` / `<section>` / `<footer>` also work; the @scope rules\ntarget both via `:where(header, header-ui)`. The page header\nalso activates the named slot-gated grid (`slot=\"icon\"` /\n`slot=\"heading\"` / `slot=\"description\"` / `slot=\"action\"`) once\nany DIRECT header child carries a `slot` attribute — same\ncontract as `<card-ui>`'s header (gh#1253).\n"
93
+ },
94
+ "action": {
95
+ "description": "Trailing control cluster inside the page header (icon-buttons, menu trigger, scheme toggle, more-options). Aligns to the flex-end edge — the fix for gh#1253's dropped `<toggle-scheme-ui>`."
96
+ },
97
+ "heading": {
98
+ "description": "Primary title — grid row 1 inside the page header. Also accepts bare `<h1>`–`<h6>` tags (or A2UI's transpiled display/title/heading/subsection `<text-ui>` variants) as direct children without slot=\"heading\". A slot=\"heading\" wrapper can contain inline badges or metadata alongside the title text."
99
+ },
100
+ "icon": {
101
+ "description": "Leading icon for the page header — status, brand, or type marker. Placed in column 1 of the header grid when present; heading + description shift to column 2. Direct child of `<header>` / `<header-ui>` only, per the :has(> [slot=\"icon\"]) gate."
90
102
  }
91
103
  },
92
104
  "states": [
@@ -26,6 +26,12 @@
26
26
  --page-sticky-bg: var(--a-canvas-0);
27
27
  --page-sticky-border: 1px solid var(--md-sys-color-neutral-outline-variant);
28
28
  --page-sticky-shadow: var(--a-shadow-sm);
29
+
30
+ /* ── Header slot-gated grid (gh#1253) — matches card-ui's header pair ── */
31
+ --page-header-gap: var(--a-space-2);
32
+ --page-heading-fg: var(--a-fg-strong);
33
+ --page-heading-size: var(--a-ui-lg);
34
+ --page-heading-weight: var(--a-weight-semibold);
29
35
  }
30
36
 
31
37
  :scope {
@@ -118,4 +124,111 @@
118
124
  border-block-end: var(--page-sticky-border);
119
125
  box-shadow: var(--page-sticky-shadow);
120
126
  }
127
+
128
+ /* ═══════ Header slot-gated grid (gh#1253) ═══════
129
+ card-ui / drawer-ui / modal-ui already wire the slot vocabulary
130
+ (`slot="icon"` / `slot="heading"` / `slot="action"`) into a header grid
131
+ (ADR-0009: "the slot vocabulary unifies ... Card, Drawer, Modal, AND
132
+ app-shell-ui / page-ui"); page-ui never shipped its half — the docs
133
+ (page.examples.html's "Slot vocabulary" section) promised it, but the
134
+ page's own @scope had no rule keying on `[slot]`. Mirrors card.css's
135
+ `& > header` rules verbatim — same guard (`:has(> [slot])` on a DIRECT
136
+ child only, so a nested [slot="icon"] inside e.g. an <avatar-ui> can't
137
+ falsely activate the grid), same column templates, same row
138
+ placement. */
139
+ :scope > :where(header, header-ui):has(> [slot]) {
140
+ display: grid;
141
+ gap: var(--page-header-gap);
142
+ align-items: center;
143
+ }
144
+
145
+ :scope > :where(header, header-ui):has(> [slot="icon"]):has(> [slot="action"]) { grid-template-columns: max-content 1fr auto; }
146
+ :scope > :where(header, header-ui):has(> [slot="icon"]):not(:has(> [slot="action"])) { grid-template-columns: max-content 1fr; }
147
+ :scope > :where(header, header-ui):not(:has(> [slot="icon"])):has(> [slot="action"]) { grid-template-columns: 1fr auto; }
148
+ :scope > :where(header, header-ui):not(:has(> [slot="icon"])):not(:has(> [slot="action"])) { grid-template-columns: 1fr; }
149
+
150
+ /* Unslotted children stack above the heading/description/action grid,
151
+ full-width — same escape hatch as card.css (zettel fragment injection,
152
+ logos, banners) without needing to know the slot vocabulary. */
153
+ :scope > :where(header, header-ui):has(> [slot]) > *:not([slot]):not(h1):not(h2):not(h3):not(h4):not(h5):not(h6):not(p):not(small):not(text-ui) {
154
+ grid-column: 1 / -1;
155
+ justify-self: center;
156
+ }
157
+
158
+ /* Icon — first column. Spans both rows (anchored to start) once a
159
+ description row exists, matching card.css. */
160
+ :scope > :where(header, header-ui) > [slot="icon"] {
161
+ grid-column: 1;
162
+ grid-row: 1;
163
+ align-self: center;
164
+ display: flex;
165
+ align-items: center;
166
+ justify-content: center;
167
+ }
168
+
169
+ /* :not([slot]) on the bare-tag branches (gh#1253 review) — a native <p>/
170
+ <small>/body-caption text-ui carrying its OWN slot (e.g. slot="heading")
171
+ must not also count as "there's a description row" just because the tag
172
+ matches; only an actual [slot="description"] or an unslotted bare tag
173
+ does. */
174
+ :scope > :where(header, header-ui):has(> :is([slot="description"], p:not([slot]), small:not([slot]), text-ui[variant="body"]:not([slot]), text-ui[variant="caption"]:not([slot]))) > [slot="icon"] {
175
+ grid-row: 1 / span 2;
176
+ align-self: start;
177
+ }
178
+
179
+ /* Heading — row 1. Matches native h1-h6 and the A2UI-transpiled text-ui
180
+ heading variants, only when unslotted (an explicit slot="heading"
181
+ always wins) — same as card.css. */
182
+ :scope > :where(header, header-ui) > :is([slot="heading"], h1, h2, h3, h4, h5, h6),
183
+ :scope > :where(header, header-ui) > :is(text-ui[variant="display"], text-ui[variant="title"], text-ui[variant="heading"], text-ui[variant="subsection"]):not([slot]),
184
+ :scope > :where(header, header-ui) > [slot="heading"] :is(h1, h2, h3, h4, h5, h6) {
185
+ grid-row: 1;
186
+ line-height: 1.3;
187
+ margin: 0;
188
+ }
189
+
190
+ :scope > :where(header, header-ui) > [slot="heading"] {
191
+ display: flex;
192
+ align-items: center;
193
+ gap: var(--page-header-gap);
194
+ color: var(--page-heading-fg);
195
+ }
196
+ :scope > :where(header, header-ui) > [slot="heading"]:not(:is(h1, h2, h3, h4, h5, h6, text-ui)) {
197
+ font-size: var(--page-heading-size);
198
+ font-weight: var(--page-heading-weight);
199
+ }
200
+ :scope > :where(header, header-ui):has(> [slot="icon"]) > :is([slot="heading"], h1, h2, h3, h4, h5, h6) { grid-column: 2; }
201
+ :scope > :where(header, header-ui):has(> [slot="icon"]) > :is(text-ui[variant="display"], text-ui[variant="title"], text-ui[variant="heading"], text-ui[variant="subsection"]):not([slot]) { grid-column: 2; }
202
+ :scope > :where(header, header-ui):not(:has(> [slot="icon"])) > :is([slot="heading"], h1, h2, h3, h4, h5, h6) { grid-column: 1; }
203
+ :scope > :where(header, header-ui):not(:has(> [slot="icon"])) > :is(text-ui[variant="display"], text-ui[variant="title"], text-ui[variant="heading"], text-ui[variant="subsection"]):not([slot]) { grid-column: 1; }
204
+
205
+ /* Description — row 2. :not([slot]) so a bare <p>/<small> carrying its
206
+ own slot (e.g. slot="heading") doesn't ALSO match the fallback here —
207
+ equal specificity + later source order would otherwise let this rule
208
+ win and drop it to row 2 (gh#1253 review). */
209
+ :scope > :where(header, header-ui) > :is([slot="description"], p:not([slot]), small:not([slot])),
210
+ :scope > :where(header, header-ui) > :is(text-ui[variant="body"], text-ui[variant="caption"]):not([slot]) {
211
+ grid-row: 2;
212
+ grid-column: 1 / -1;
213
+ line-height: 1.4;
214
+ margin: 0;
215
+ }
216
+ :scope > :where(header, header-ui):has(> [slot="icon"]) > :is([slot="description"], p:not([slot]), small:not([slot])) { grid-column: 2 / -1; }
217
+ :scope > :where(header, header-ui):has(> [slot="icon"]) > :is(text-ui[variant="body"], text-ui[variant="caption"]):not([slot]) { grid-column: 2 / -1; }
218
+
219
+ /* Action — row 1, last column. Flex container so it can hold badge +
220
+ button + anything inline (e.g. <toggle-scheme-ui> — gh#1253's probe).
221
+ Direct-child `>` (gh#1253 review) — a deeply-nested [slot="action"]
222
+ (e.g. inside an unrelated composite) must not pick up header-grid
223
+ flex/alignment styling; only a DIRECT header child activates it. */
224
+ :scope > :where(header, header-ui) > [slot="action"] {
225
+ justify-self: end;
226
+ align-self: center;
227
+ grid-row: 1;
228
+ grid-column: -2 / -1;
229
+ display: flex;
230
+ align-items: center;
231
+ gap: var(--page-header-gap);
232
+ min-width: 0;
233
+ }
121
234
  }
@@ -66,7 +66,34 @@ slots:
66
66
  Composes from the slot primitives — `<header-ui>` (page header),
67
67
  `<section-ui>` (main content), optional `<footer-ui>`. Native
68
68
  `<header>` / `<section>` / `<footer>` also work; the @scope rules
69
- target both via `:where(header, header-ui)`.
69
+ target both via `:where(header, header-ui)`. The page header
70
+ also activates the named slot-gated grid (`slot="icon"` /
71
+ `slot="heading"` / `slot="description"` / `slot="action"`) once
72
+ any DIRECT header child carries a `slot` attribute — same
73
+ contract as `<card-ui>`'s header (gh#1253).
74
+ icon:
75
+ description: >-
76
+ Leading icon for the page header — status, brand, or type marker.
77
+ Placed in column 1 of the header grid when present; heading +
78
+ description shift to column 2. Direct child of `<header>` /
79
+ `<header-ui>` only, per the :has(> [slot="icon"]) gate.
80
+ heading:
81
+ description: >-
82
+ Primary title — grid row 1 inside the page header. Also accepts
83
+ bare `<h1>`–`<h6>` tags (or A2UI's transpiled display/title/heading/subsection
84
+ `<text-ui>` variants) as direct children without slot="heading".
85
+ A slot="heading" wrapper can contain inline badges or metadata
86
+ alongside the title text.
87
+ description:
88
+ description: >-
89
+ Secondary metadata — grid row 2 beneath the heading inside the
90
+ page header. Also accepts bare `<p>` / `<small>` / body-variant
91
+ `<text-ui>` as direct children without slot="description".
92
+ action:
93
+ description: >-
94
+ Trailing control cluster inside the page header (icon-buttons,
95
+ menu trigger, scheme toggle, more-options). Aligns to the
96
+ flex-end edge — the fix for gh#1253's dropped `<toggle-scheme-ui>`.
70
97
  states:
71
98
  - name: idle
72
99
  description: Default, ready for interaction.
@@ -78,8 +105,8 @@ a2ui:
78
105
  rules:
79
106
  - rule: 'Top-level page container — wraps an entire route''s content surface.'
80
107
  reason: 'Page-level chrome primitive.'
81
- - rule: 'Inside <admin-shell-ui>, <chat-shell-ui>, or <editor-shell-ui>, use the shell''s own body slot instead; page-ui is for standalone routes without shell chrome.'
82
- reason: 'Shell hosting precedence.'
108
+ - rule: 'Inside <admin-shell-ui>, <chat-shell-ui>, or <editor-shell-ui>, page-ui still composes — nest it in the shell''s content column with [scroll] omitted (the shell already owns scrolling) to get page-ui''s max-width clamp, padding scale, and region rhythm. Skip page-ui only when the shell''s own header/body slots already give you everything you need.'
109
+ reason: 'Shell hosting precedence — the shell owns scroll; page-ui supplies layout, not a second scroll surface.'
83
110
  - rule: 'Hosts arbitrary children — no enforced child contract.'
84
111
  reason: 'Generic container.'
85
112
  anti_patterns: []