@adia-ai/web-components 0.8.37 → 0.8.39
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.
- package/CHANGELOG.md +36 -0
- package/MIGRATION.md +9 -7
- package/README.md +3 -3
- package/bin/doc.mjs +27 -5
- package/components/calendar-picker/calendar-picker.css +4 -1
- package/components/card/card.css +1 -1
- package/components/combobox/combobox.css +6 -0
- package/components/command/command.a2ui.json +3 -0
- package/components/command/command.class.js +28 -6
- package/components/command/command.css +14 -3
- package/components/command/command.yaml +5 -0
- package/components/date-range-picker/date-range-picker.css +6 -0
- package/components/datetime-picker/datetime-picker.css +4 -0
- package/components/drilldown/drilldown.a2ui.json +244 -0
- package/components/drilldown/drilldown.class.js +550 -0
- package/components/drilldown/drilldown.css +304 -0
- package/components/drilldown/drilldown.d.ts +68 -0
- package/components/drilldown/drilldown.examples.md +20 -0
- package/components/drilldown/drilldown.js +17 -0
- package/components/drilldown/drilldown.yaml +273 -0
- package/components/index.js +1 -0
- package/components/modal/modal.class.js +68 -4
- package/components/nav/nav.a2ui.json +5 -0
- package/components/nav/nav.class.js +35 -12
- package/components/nav/nav.d.ts +2 -0
- package/components/nav/nav.yaml +18 -0
- package/components/nav-item/nav-item.class.js +9 -6
- package/components/page/page.a2ui.json +13 -1
- package/components/page/page.css +113 -0
- package/components/page/page.yaml +30 -3
- package/components/select/select.class.js +30 -12
- package/components/select/select.css +11 -2
- package/components/swatch/swatch.css +6 -4
- package/components/toggle-group/toggle-group.class.js +21 -11
- package/components/toggle-group/toggle-group.css +16 -8
- package/components/toggle-group/toggle-group.d.ts +6 -0
- package/components/toggle-group/toggle-group.yaml +10 -0
- package/components/toggle-group/toggle-option.a2ui.json +5 -0
- package/components/toggle-group/toggle-option.yaml +18 -2
- package/custom-elements.json +277 -100
- package/dist/theme-provider.min.js +3 -3
- package/dist/web-components.min.css +1 -1
- package/dist/web-components.min.js +110 -88
- package/dist/web-components.sheet.js +1 -1
- package/package.json +1 -1
- package/patterns/bulk-action-toolbar/bulk-action-toolbar.examples.html +1 -1
- package/styles/components.css +1 -0
- package/traits/view-transition/view-transition.js +7 -0
|
@@ -43,6 +43,7 @@
|
|
|
43
43
|
*/
|
|
44
44
|
|
|
45
45
|
import { UIElement } from '../../core/element.js';
|
|
46
|
+
import { logicalSlotted } from '../../core/logical-children.js';
|
|
46
47
|
|
|
47
48
|
export class UIModal extends UIElement {
|
|
48
49
|
#bound = false;
|
|
@@ -51,6 +52,14 @@ export class UIModal extends UIElement {
|
|
|
51
52
|
#closeTimer = null;
|
|
52
53
|
#dialogRef = null;
|
|
53
54
|
|
|
55
|
+
// Guards `close` against firing twice for one dismissal — #animateClose's
|
|
56
|
+
// timer dispatches it directly (the authoritative path, gh#1295) and the
|
|
57
|
+
// native <dialog> 'close' listener also calls in as a redundant safety
|
|
58
|
+
// net for a consumer that closes the raw <dialog> element itself, and
|
|
59
|
+
// disconnected()'s teardown fallback (below) is a third path into the
|
|
60
|
+
// same guard. Reset whenever the dialog (re)opens.
|
|
61
|
+
#closeDispatched = false;
|
|
62
|
+
|
|
54
63
|
// Monotonic, instance-agnostic counter for the fallback aria-labelledby
|
|
55
64
|
// id stamped onto an author's [slot="heading"] (see render()) — multiple
|
|
56
65
|
// <modal-ui> instances on one page must not collide on the same id.
|
|
@@ -93,6 +102,19 @@ export class UIModal extends UIElement {
|
|
|
93
102
|
};
|
|
94
103
|
|
|
95
104
|
#onDialogClose = () => {
|
|
105
|
+
// Native <dialog> 'close' — fires as a browser-QUEUED task (per the
|
|
106
|
+
// HTML spec, dialog.close() queues a task; it is not synchronous), so
|
|
107
|
+
// it can be lost if a consumer tears the listener down (removes this
|
|
108
|
+
// element) before that task runs. #animateClose's own timer is the
|
|
109
|
+
// authoritative dispatch path (gh#1295); this stays wired as a
|
|
110
|
+
// redundant net for a consumer calling the raw <dialog> element's
|
|
111
|
+
// own `.close()` directly.
|
|
112
|
+
this.#emitCloseOnce();
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
#emitCloseOnce = () => {
|
|
116
|
+
if (this.#closeDispatched) return;
|
|
117
|
+
this.#closeDispatched = true;
|
|
96
118
|
this.open = false;
|
|
97
119
|
this.#previousFocus?.focus();
|
|
98
120
|
this.#previousFocus = null;
|
|
@@ -128,6 +150,32 @@ export class UIModal extends UIElement {
|
|
|
128
150
|
clearTimeout(this.#closeTimer);
|
|
129
151
|
this.#closeTimer = null;
|
|
130
152
|
}
|
|
153
|
+
// Teardown fallback — the ROOT CAUSE of gh#1295 (adiav2 ADIA2-8581).
|
|
154
|
+
// Every internal close path (the native <dialog> 'close' listener,
|
|
155
|
+
// #animateClose's own timer) only ever runs from THIS element's own
|
|
156
|
+
// render effect (element.js's connectedCallback), which is set up as
|
|
157
|
+
// a signals.js effect that reacts to the `open` property write via a
|
|
158
|
+
// MICROTASK — the property setter itself only marks the signal dirty;
|
|
159
|
+
// it doesn't render synchronously. `disconnectedCallback()` disposes
|
|
160
|
+
// every effect (including that one) BEFORE disconnected() runs. A
|
|
161
|
+
// consumer whose own re-render removes <modal-ui> from the DOM in the
|
|
162
|
+
// SAME tick as setting `open = false` — an extremely common framework
|
|
163
|
+
// shape: a Confirm/Cancel handler flips reactive state that ALSO
|
|
164
|
+
// unmounts the modal on that same pass — tears down the render effect
|
|
165
|
+
// before it ever gets a chance to run, so #animateClose's timer is
|
|
166
|
+
// never even CREATED, let alone fired. No error, no warning: the
|
|
167
|
+
// ENTIRE close pipeline silently never starts, and any caller awaiting
|
|
168
|
+
// `close` (showConfirmDialog-style APIs) hangs forever. Confirmed via
|
|
169
|
+
// browser reproduction — vitest/happy-dom's synchronous effect
|
|
170
|
+
// scheduling masked this (see modal.test.js "parent-driven teardown").
|
|
171
|
+
// Disconnection always resolves an open-or-mid-close modal's `close`
|
|
172
|
+
// obligation as a last resort, regardless of which (if any) internal
|
|
173
|
+
// path got a chance to run first — #emitCloseOnce's guard makes this
|
|
174
|
+
// safe to call unconditionally alongside those other paths.
|
|
175
|
+
if (this.open || this.#closing || this.#dialogRef?.open) {
|
|
176
|
+
this.#emitCloseOnce();
|
|
177
|
+
}
|
|
178
|
+
this.#closing = false;
|
|
131
179
|
this.#bound = false;
|
|
132
180
|
this.#dialogRef = null;
|
|
133
181
|
}
|
|
@@ -136,10 +184,15 @@ export class UIModal extends UIElement {
|
|
|
136
184
|
// Read the computed `--modal-duration` (= --a-duration = 250ms unless a
|
|
137
185
|
// consumer overrides) so the JS close timer matches the CSS exit animation
|
|
138
186
|
// exactly — a stale/unset value clipped the animation ~50ms early
|
|
139
|
-
// (bug class shared with swiper bug-29).
|
|
187
|
+
// (bug class shared with swiper bug-29). `parseFloat` alone silently
|
|
188
|
+
// drops a `s` vs `ms` unit (gh#1295 — a consumer-computed value observed
|
|
189
|
+
// as `.25s` parsed to 0.25, a 0.25ms timer instead of 250ms), so a
|
|
190
|
+
// trailing `s` (and not `ms`) is converted explicitly rather than
|
|
191
|
+
// trusted to already be milliseconds.
|
|
140
192
|
const cs = getComputedStyle(this);
|
|
141
193
|
const raw = cs.getPropertyValue('--modal-duration').trim();
|
|
142
|
-
|
|
194
|
+
const ms = /ms\s*$/.test(raw) ? parseFloat(raw) : /s\s*$/.test(raw) ? parseFloat(raw) * 1000 : parseFloat(raw);
|
|
195
|
+
return ms > 0 ? ms : 200;
|
|
143
196
|
}
|
|
144
197
|
|
|
145
198
|
render() {
|
|
@@ -195,8 +248,12 @@ export class UIModal extends UIElement {
|
|
|
195
248
|
// dialog-modal pattern's accessible-name requirement. Re-evaluated
|
|
196
249
|
// every render so a later `text=` set, or the heading being removed,
|
|
197
250
|
// clears the reference instead of leaving it stale. (gh#947, from
|
|
198
|
-
// PR #944.)
|
|
199
|
-
|
|
251
|
+
// PR #944.) `logicalSlotted` (not a `:scope >` direct-child query)
|
|
252
|
+
// pierces the template engine's display:contents/role=presentation
|
|
253
|
+
// wrapper spans, so a consumer that renders the heading conditionally
|
|
254
|
+
// (`${title ? html\`<span slot="heading">…\` : null}`) still gets an
|
|
255
|
+
// aria-labelledby — the wrapper-trap class, gh#1301.
|
|
256
|
+
const authoredHeading = logicalSlotted(header, 'heading')[0];
|
|
200
257
|
if (!this.text && authoredHeading) {
|
|
201
258
|
if (!authoredHeading.id) authoredHeading.id = `modal-heading-${++UIModal.#headingIdSeq}`;
|
|
202
259
|
if (dialog.getAttribute('aria-labelledby') !== authoredHeading.id) {
|
|
@@ -261,6 +318,7 @@ export class UIModal extends UIElement {
|
|
|
261
318
|
// See .claude/docs/BROWSER-COMPAT.md §3a (Flavor C).
|
|
262
319
|
if (this.open && !dialog.open) {
|
|
263
320
|
this.#closing = false;
|
|
321
|
+
this.#closeDispatched = false;
|
|
264
322
|
this.#previousFocus = document.activeElement;
|
|
265
323
|
dialog.showModal();
|
|
266
324
|
void dialog.offsetHeight;
|
|
@@ -285,6 +343,12 @@ export class UIModal extends UIElement {
|
|
|
285
343
|
this.#closing = false;
|
|
286
344
|
dialog.removeAttribute('data-closing');
|
|
287
345
|
if (dialog.open) dialog.close();
|
|
346
|
+
// Authoritative dispatch (gh#1295) — don't rely solely on the native
|
|
347
|
+
// <dialog> 'close' listener above, whose task can be lost if this
|
|
348
|
+
// element (or an ancestor) is torn down before it runs.
|
|
349
|
+
// #emitCloseOnce guards so a close event ALSO reaching
|
|
350
|
+
// #onDialogClose is a no-op.
|
|
351
|
+
this.#emitCloseOnce();
|
|
288
352
|
}, this.#getDuration());
|
|
289
353
|
}
|
|
290
354
|
|
|
@@ -31,6 +31,11 @@
|
|
|
31
31
|
"type": "string",
|
|
32
32
|
"default": ""
|
|
33
33
|
},
|
|
34
|
+
"multiExpand": {
|
|
35
|
+
"description": "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.",
|
|
36
|
+
"type": "boolean",
|
|
37
|
+
"default": false
|
|
38
|
+
},
|
|
34
39
|
"variant": {
|
|
35
40
|
"description": "Visual treatment. primary = app sidebar; section = subnav rail.",
|
|
36
41
|
"type": "string",
|
|
@@ -45,10 +45,14 @@ import { UIElement } from '../../core/element.js';
|
|
|
45
45
|
|
|
46
46
|
export class UINav extends UIElement {
|
|
47
47
|
static properties = {
|
|
48
|
-
variant:
|
|
49
|
-
collapsed:
|
|
50
|
-
divider:
|
|
51
|
-
heading:
|
|
48
|
+
variant: { type: String, default: 'primary', reflect: true },
|
|
49
|
+
collapsed: { type: Boolean, default: false, reflect: true },
|
|
50
|
+
divider: { type: Boolean, default: false, reflect: true },
|
|
51
|
+
heading: { type: String, default: '', reflect: true },
|
|
52
|
+
// gh#1306: selection is the cleanup event for manually multi-expanded
|
|
53
|
+
// groups — default-on; opt out to let multiple groups stay open across
|
|
54
|
+
// selections (the operator's ruling, 2026-08-14).
|
|
55
|
+
multiExpand: { type: Boolean, default: false, reflect: true, attribute: 'multi-expand' },
|
|
52
56
|
};
|
|
53
57
|
|
|
54
58
|
static template = () => null;
|
|
@@ -91,9 +95,15 @@ export class UINav extends UIElement {
|
|
|
91
95
|
|
|
92
96
|
select(item) {
|
|
93
97
|
const prev = this.selectedItem;
|
|
94
|
-
|
|
98
|
+
// gh#1254: re-selecting the already-selected item is a no-op — no
|
|
99
|
+
// attribute churn, no hover flush, no event. Selection genuinely
|
|
100
|
+
// unchanged is not a selection event.
|
|
101
|
+
if (prev === item) return;
|
|
102
|
+
if (prev) prev.removeAttribute('selected');
|
|
95
103
|
if (item) {
|
|
96
104
|
item.setAttribute('selected', '');
|
|
105
|
+
this.#flushHoverState();
|
|
106
|
+
this.#collapseSiblingGroups(item);
|
|
97
107
|
this.dispatchEvent(new CustomEvent('nav-select', {
|
|
98
108
|
bubbles: true,
|
|
99
109
|
detail: { item, text: item.text, value: item.value },
|
|
@@ -101,19 +111,32 @@ export class UINav extends UIElement {
|
|
|
101
111
|
}
|
|
102
112
|
}
|
|
103
113
|
|
|
114
|
+
// gh#1306: on selection, collapse every top-level group except the one
|
|
115
|
+
// containing the newly selected item (none, when the selection is
|
|
116
|
+
// ungrouped — every group collapses). Manual multi-expansion stays
|
|
117
|
+
// allowed right up until the next selection; [multi-expand] opts out
|
|
118
|
+
// entirely. Runs for every select() caller — click, keyboard, and the
|
|
119
|
+
// collapsed-rail popover path (nav-group.class.js's showPopover(),
|
|
120
|
+
// which also calls nav.select()).
|
|
121
|
+
#collapseSiblingGroups(item) {
|
|
122
|
+
if (this.multiExpand) return;
|
|
123
|
+
const activeGroup = item.closest('nav-group-ui');
|
|
124
|
+
for (const group of this.querySelectorAll(':scope > nav-group-ui')) {
|
|
125
|
+
if (group !== activeGroup) group.open = false;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
104
129
|
toggle() {
|
|
105
130
|
if (this.variant === 'section') return; // no-op for section variant
|
|
106
131
|
this.collapsed = !this.collapsed;
|
|
107
132
|
}
|
|
108
133
|
|
|
109
134
|
#onClick = (e) => {
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
return;
|
|
116
|
-
}
|
|
135
|
+
// gh#1254: <nav-item-ui> owns its own click + keyboard activation
|
|
136
|
+
// (nav-item.class.js #onClick calls parent.select(this) directly) —
|
|
137
|
+
// delegating the same selection here too double-invoked select() per
|
|
138
|
+
// click (the layered handlers the issue reports). Do NOT re-add an
|
|
139
|
+
// item branch here; this listener is for group expand/popover only.
|
|
117
140
|
|
|
118
141
|
// Group expand/popover — primary variant only.
|
|
119
142
|
if (this.variant === 'section') return;
|
package/components/nav/nav.d.ts
CHANGED
|
@@ -38,6 +38,8 @@ export class UINav extends UIElement {
|
|
|
38
38
|
divider: boolean;
|
|
39
39
|
/** Optional kicker label. Section variant renders it via ::before; primary uses it as aria-label only. */
|
|
40
40
|
heading: string;
|
|
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
|
+
multiExpand: boolean;
|
|
41
43
|
/** Visual treatment. primary = app sidebar; section = subnav rail. */
|
|
42
44
|
variant: 'primary' | 'section';
|
|
43
45
|
|
package/components/nav/nav.yaml
CHANGED
|
@@ -39,6 +39,12 @@ props:
|
|
|
39
39
|
type: string
|
|
40
40
|
default: ''
|
|
41
41
|
description: "Optional kicker label. Section variant renders it via ::before; primary uses it as aria-label only."
|
|
42
|
+
multiExpand:
|
|
43
|
+
type: boolean
|
|
44
|
+
default: false
|
|
45
|
+
reflect: true
|
|
46
|
+
attribute: multi-expand
|
|
47
|
+
description: "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
48
|
|
|
43
49
|
events:
|
|
44
50
|
nav-select:
|
|
@@ -91,6 +97,18 @@ a2ui:
|
|
|
91
97
|
or anchor) → <nav-ui>. If the user switches VIEWS within the same
|
|
92
98
|
logical page → <tabs-ui>. Never use <nav-ui> as an in-page section
|
|
93
99
|
switcher.
|
|
100
|
+
- >-
|
|
101
|
+
Group expansion on selection: selecting a <nav-item-ui> collapses
|
|
102
|
+
every <nav-group-ui> except the one containing the newly selected
|
|
103
|
+
item (all groups, when the selection is ungrouped). Manual
|
|
104
|
+
multi-expansion is allowed right up until the next selection. Set
|
|
105
|
+
[multi-expand] on <nav-ui> to opt out and keep multiple groups open
|
|
106
|
+
across selections. This clears the JS [open] state (and
|
|
107
|
+
aria-expanded) on every collapsed group in BOTH variants — primary
|
|
108
|
+
visually hides that group's children as a result; section variant's
|
|
109
|
+
CSS keeps children visible regardless of [open] (per the
|
|
110
|
+
section-variant cascade rule above), so only the accessibility
|
|
111
|
+
state resets there, with no visible change.
|
|
94
112
|
- >-
|
|
95
113
|
Anti-patterns: do NOT wrap <nav-ui> children in <col-ui> /
|
|
96
114
|
<row-ui> — wrapping breaks selection bubbling + the variant
|
|
@@ -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
|
-
*
|
|
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)
|
|
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": [
|
package/components/page/page.css
CHANGED
|
@@ -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>,
|
|
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: []
|
|
@@ -439,8 +439,14 @@ export class UISelect extends UIFormElement {
|
|
|
439
439
|
: this.icon
|
|
440
440
|
? `<icon-ui slot="leading" data-select-leading name="${escapeHTML(this.icon)}"></icon-ui>`
|
|
441
441
|
: '';
|
|
442
|
+
// No native <input> wrap (per ADR-0055 / ADR-0025): the searchable
|
|
443
|
+
// trigger is a `contenteditable="plaintext-only"` surface carrying
|
|
444
|
+
// `role="combobox"`, mirroring combobox-ui's editable-surface pattern.
|
|
445
|
+
// Placeholder text renders via the [data-empty]::before pseudo (see
|
|
446
|
+
// select.css), same mechanism as input-ui / combobox-ui.
|
|
447
|
+
const initialDisplayText = this.#displayText() === this.placeholder ? '' : this.#displayText();
|
|
442
448
|
const displayMarkup = this.searchable
|
|
443
|
-
? `<
|
|
449
|
+
? `<span slot="display" contenteditable="plaintext-only" role="combobox" aria-autocomplete="list" tabindex="0" data-placeholder="${escapeHTML(this.placeholder || '')}"${initialDisplayText ? '' : ' data-empty'}>${escapeHTML(initialDisplayText)}</span>`
|
|
444
450
|
: `<span slot="display">${escapeHTML(this.#displayText())}</span>`;
|
|
445
451
|
// §184 (v0.5.5, FEEDBACK-08 §7): optional hint slot beneath the
|
|
446
452
|
// trigger. Mirrors slider-ui's hint pattern + matches the
|
|
@@ -478,7 +484,7 @@ export class UISelect extends UIFormElement {
|
|
|
478
484
|
this.#searchInput.removeEventListener('focus', this.#onSearchFocus);
|
|
479
485
|
this.#searchInput.removeEventListener('click', this.#onSearchClick);
|
|
480
486
|
}
|
|
481
|
-
this.#searchInput = this.querySelector('
|
|
487
|
+
this.#searchInput = this.querySelector('[slot="display"][contenteditable]');
|
|
482
488
|
if (this.#searchInput) {
|
|
483
489
|
this.#searchInput.addEventListener('input', this.#onSearchInput);
|
|
484
490
|
this.#searchInput.addEventListener('focus', this.#onSearchFocus);
|
|
@@ -492,9 +498,13 @@ export class UISelect extends UIFormElement {
|
|
|
492
498
|
const display = this.querySelector('[slot="display"]');
|
|
493
499
|
if (display && !this.multiple) {
|
|
494
500
|
// Single-select: keep the canonical text-rendering path.
|
|
495
|
-
if (display.
|
|
496
|
-
// Only update
|
|
497
|
-
if (!this.#query)
|
|
501
|
+
if (display.hasAttribute('contenteditable')) {
|
|
502
|
+
// Only update text when not actively editing (no active query)
|
|
503
|
+
if (!this.#query) {
|
|
504
|
+
const text = this.#displayText() === this.placeholder ? '' : this.#displayText();
|
|
505
|
+
display.textContent = text;
|
|
506
|
+
display.toggleAttribute('data-empty', !text);
|
|
507
|
+
}
|
|
498
508
|
} else {
|
|
499
509
|
display.textContent = this.#displayText();
|
|
500
510
|
}
|
|
@@ -502,10 +512,13 @@ export class UISelect extends UIFormElement {
|
|
|
502
512
|
// Multi-select: display element holds the placeholder ONLY when
|
|
503
513
|
// no chips are present (otherwise the chip row IS the display).
|
|
504
514
|
const hasChips = this.#values().length > 0;
|
|
505
|
-
if (display.
|
|
515
|
+
if (display.hasAttribute('contenteditable')) {
|
|
506
516
|
// search input — keep placeholder; never inject the value text.
|
|
507
|
-
display.placeholder
|
|
508
|
-
if (!this.#query)
|
|
517
|
+
display.setAttribute('data-placeholder', hasChips ? '' : (this.placeholder || ''));
|
|
518
|
+
if (!this.#query) {
|
|
519
|
+
display.textContent = '';
|
|
520
|
+
display.toggleAttribute('data-empty', true);
|
|
521
|
+
}
|
|
509
522
|
} else {
|
|
510
523
|
display.textContent = hasChips ? '' : (this.placeholder || '');
|
|
511
524
|
}
|
|
@@ -991,7 +1004,7 @@ export class UISelect extends UIFormElement {
|
|
|
991
1004
|
// before it would start removing query characters.
|
|
992
1005
|
if (e.key === 'Backspace' && this.multiple && !this.disabled && !this.readonly) {
|
|
993
1006
|
const target = e.target;
|
|
994
|
-
const fromInput = target && target.
|
|
1007
|
+
const fromInput = target && target.hasAttribute?.('contenteditable') && target.getAttribute('slot') === 'display';
|
|
995
1008
|
const queryEmpty = !this.#query || this.#query.length === 0;
|
|
996
1009
|
if (!fromInput || queryEmpty) {
|
|
997
1010
|
if (this.#values().length > 0) {
|
|
@@ -1005,8 +1018,11 @@ export class UISelect extends UIFormElement {
|
|
|
1005
1018
|
if (this.searchable && this.#query) {
|
|
1006
1019
|
// First Escape: clear query
|
|
1007
1020
|
this.#query = '';
|
|
1008
|
-
const input = this.querySelector('
|
|
1009
|
-
if (input)
|
|
1021
|
+
const input = this.querySelector('[slot="display"][contenteditable]');
|
|
1022
|
+
if (input) {
|
|
1023
|
+
input.textContent = '';
|
|
1024
|
+
input.toggleAttribute('data-empty', true);
|
|
1025
|
+
}
|
|
1010
1026
|
this.#applyFilter();
|
|
1011
1027
|
return;
|
|
1012
1028
|
}
|
|
@@ -1061,7 +1077,9 @@ export class UISelect extends UIFormElement {
|
|
|
1061
1077
|
};
|
|
1062
1078
|
|
|
1063
1079
|
#onSearchInput = (e) => {
|
|
1064
|
-
|
|
1080
|
+
const text = e.target.textContent || '';
|
|
1081
|
+
this.#query = text;
|
|
1082
|
+
e.target.toggleAttribute('data-empty', !text);
|
|
1065
1083
|
if (!this.open) this.open = true;
|
|
1066
1084
|
this.#applyFilter();
|
|
1067
1085
|
};
|
|
@@ -149,7 +149,7 @@
|
|
|
149
149
|
white-space: nowrap;
|
|
150
150
|
color: var(--select-fg);
|
|
151
151
|
}
|
|
152
|
-
|
|
152
|
+
[slot="display"][contenteditable] {
|
|
153
153
|
border: none;
|
|
154
154
|
outline: none;
|
|
155
155
|
background: transparent;
|
|
@@ -159,9 +159,18 @@
|
|
|
159
159
|
color: var(--select-fg);
|
|
160
160
|
min-width: 0;
|
|
161
161
|
width: 100%;
|
|
162
|
+
/* Positioning context for the [data-empty]::before placeholder pseudo
|
|
163
|
+
below — out-of-flow, same rationale as input-ui/combobox-ui: an
|
|
164
|
+
in-flow pseudo would render the caret after the placeholder text
|
|
165
|
+
instead of at content-start. */
|
|
166
|
+
position: relative;
|
|
162
167
|
}
|
|
163
|
-
|
|
168
|
+
[slot="display"][contenteditable][data-empty]::before {
|
|
169
|
+
content: attr(data-placeholder);
|
|
164
170
|
color: var(--select-placeholder-fg);
|
|
171
|
+
pointer-events: none;
|
|
172
|
+
position: absolute;
|
|
173
|
+
inset: 0;
|
|
165
174
|
}
|
|
166
175
|
:scope:not([value]) [slot="display"],
|
|
167
176
|
:scope[value=""] [slot="display"] {
|
|
@@ -274,20 +274,22 @@
|
|
|
274
274
|
z-index: 1;
|
|
275
275
|
}
|
|
276
276
|
:scope[shape="block"][auto-contrast] > [data-label][data-on-dark] {
|
|
277
|
-
color: var(--a-chrome-light
|
|
277
|
+
color: var(--a-chrome-light);
|
|
278
278
|
}
|
|
279
279
|
:scope[shape="block"][auto-contrast] > [data-label][data-on-light] {
|
|
280
|
-
color: var(--a-chrome-dark
|
|
280
|
+
color: var(--a-chrome-dark);
|
|
281
281
|
}
|
|
282
282
|
/* Detail line follows the same auto-contrast choice. */
|
|
283
283
|
:scope[shape="block"][auto-contrast] > [data-detail] {
|
|
284
284
|
z-index: 1;
|
|
285
285
|
}
|
|
286
286
|
:scope[shape="block"][auto-contrast] > [data-label][data-on-dark] ~ [data-detail] {
|
|
287
|
-
color:
|
|
287
|
+
color: var(--a-chrome-light);
|
|
288
|
+
opacity: 0.8;
|
|
288
289
|
}
|
|
289
290
|
:scope[shape="block"][auto-contrast] > [data-label][data-on-light] ~ [data-detail] {
|
|
290
|
-
color:
|
|
291
|
+
color: var(--a-chrome-dark);
|
|
292
|
+
opacity: 0.7;
|
|
291
293
|
}
|
|
292
294
|
|
|
293
295
|
/* ═══════ Copy button — a stamped <button-ui variant="ghost" size="sm">
|