@adia-ai/web-components 0.6.17 → 0.6.19

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 (40) hide show
  1. package/CHANGELOG.md +140 -0
  2. package/USAGE.md +6 -0
  3. package/components/button/button.a2ui.json +8 -1
  4. package/components/button/button.yaml +13 -1
  5. package/components/button/class.js +36 -0
  6. package/components/chart/chart.a2ui.json +5 -0
  7. package/components/chart/chart.d.ts +2 -0
  8. package/components/chart/chart.yaml +13 -0
  9. package/components/chart/class.js +13 -0
  10. package/components/drawer/class.js +60 -2
  11. package/components/drawer/drawer.a2ui.json +11 -1
  12. package/components/drawer/drawer.d.ts +2 -0
  13. package/components/drawer/drawer.yaml +26 -1
  14. package/components/segmented/class.js +23 -0
  15. package/components/segmented/segmented.a2ui.json +11 -4
  16. package/components/segmented/segmented.yaml +52 -6
  17. package/components/stat/stat.a2ui.json +7 -1
  18. package/components/stat/stat.d.ts +2 -0
  19. package/components/stat/stat.js +34 -5
  20. package/components/stat/stat.test.js +108 -0
  21. package/components/stat/stat.yaml +9 -0
  22. package/components/table/class.js +43 -8
  23. package/components/table/table.a2ui.json +2 -1
  24. package/components/table/table.css +20 -4
  25. package/components/table/table.d.ts +1 -1
  26. package/components/table/table.test.js +174 -0
  27. package/components/table/table.yaml +6 -2
  28. package/components/text/class.js +14 -4
  29. package/components/text/text.a2ui.json +46 -0
  30. package/components/text/text.css +41 -0
  31. package/components/text/text.d.ts +6 -0
  32. package/components/text/text.test.js +90 -0
  33. package/components/text/text.yaml +36 -0
  34. package/components/textarea/textarea.a2ui.json +25 -0
  35. package/components/textarea/textarea.yaml +23 -0
  36. package/components/toggle-scheme/class.js +6 -0
  37. package/components/toggle-scheme/toggle-scheme.yaml +7 -1
  38. package/core/element.js +13 -0
  39. package/css-module.d.ts +6 -0
  40. package/package.json +18 -5
package/CHANGELOG.md CHANGED
@@ -1,5 +1,145 @@
1
1
  # Changelog — @adia-ai/web-components
2
2
 
3
+ ## [0.6.19] — 2026-05-21
4
+
5
+ ### Fixed — `drawer-ui` `innerHTML`-on-host orphaned the internal `<dialog>` (FEEDBACK-30)
6
+
7
+ - Setting `.innerHTML` on a mounted `<drawer-ui>` wiped the stamped
8
+ `<dialog>` part; `#dialogRef` (bound once in `connected()`) was left
9
+ detached, so `#syncDialog()` called `showModal()` on a detached node and
10
+ the drawer silently never reopened. `render()` now re-binds `#dialogRef`
11
+ + its listeners when `ensure('dialog')` re-stamps; `#syncDialog()` guards
12
+ `dialog.isConnected`; a failed `showModal()` surfaces a `console.error`.
13
+ `drawer.yaml` gains an `anti_patterns` entry for the innerHTML trap.
14
+
15
+ ### Added — `drawer-ui` `opened` event (FEEDBACK-27)
16
+
17
+ - `<drawer-ui>` now dispatches a bubbling `opened` event after the open
18
+ transition completes (mirrors `close`). Lets tests + consumers await full
19
+ interactivity instead of `waitForTimeout` heuristics. `drawer.yaml`
20
+ `events:` documents it.
21
+
22
+ ### Added — `chart-ui` `.data` shape docs + wrong-shape warning (FEEDBACK-24)
23
+
24
+ - `chart.yaml` now documents the `data` prop: an array of plain objects
25
+ keyed by the `x`/`y` attributes — not the Chart.js `{labels, datasets}`
26
+ envelope. Setting `.data` to a non-array (the common envelope mistake)
27
+ emits a one-shot `console.warn` instead of silently rendering blank.
28
+ (`x` and `y` were already documented props — the ticket's Finding 2
29
+ claim otherwise was inaccurate.)
30
+
31
+ ### Added — `segmented-ui` non-`<segment-ui>`-child warning (FEEDBACK-23)
32
+
33
+ - `<segmented-ui>` emits a one-shot `console.warn` when a direct child is
34
+ not a `<segment-ui>` (a bare `<segment>` renders text but gets no sliding
35
+ indicator, `role`, or `aria-checked`). `segmented.yaml` `description` +
36
+ `slots` now document the `<segment-ui>` child contract.
37
+
38
+ ### Changed — `UIElement.ensure()` part-wipe invariant (FEEDBACK-31)
39
+
40
+ - `UIElement.ensure()` (`core/element.js`) marks stamped structural parts
41
+ with `_uiPart = true` and its doc comment states the invariant: call
42
+ `ensure()` from `render()`, never cache a part reference once in
43
+ `connected()` — a host `innerHTML` wipe detaches it.
44
+
45
+ ### Added — `types` condition on CSS subpath exports (FEEDBACK-26)
46
+
47
+ - `@adia-ai/web-components/css` and the per-component `.css` subpath exports
48
+ now carry a `types` condition (`css-module.d.ts` stub), so TypeScript
49
+ under `moduleResolution: bundler` no longer raises TS2882 on CSS
50
+ side-effect imports.
51
+
52
+ ### Docs — no-`.js`-suffix import rule (FEEDBACK-22)
53
+
54
+ - `USAGE.md` notes that component subpath imports use the directory name
55
+ (`…/components/button`), never the explicit file (`…/button/button.js`) —
56
+ the `exports` wildcard rejects the suffixed form under Vite 8.
57
+
58
+ ## [0.6.18] — 2026-05-21
59
+
60
+ ### Added — `[data-scheme]` attribute on `toggle-scheme-ui` (FB-14)
61
+
62
+ - **`toggle-scheme-ui` now writes the `[data-scheme]` attribute** on the target
63
+ element alongside the existing `color-scheme` inline style. `styles/themes.css`
64
+ selects on `[data-scheme="light|dark|system"]`; consumer CSS patterned after
65
+ it never fired because the component wrote only the inline style.
66
+ `#writeTarget()` sets the attribute, `#clearTargetOverride()` removes it —
67
+ paired with the style so the two never diverge. Non-breaking: consumers
68
+ relying on the `color-scheme` style alone are unaffected. (~6 LOC `class.js`.)
69
+
70
+ ### Added — `button-ui` text/icon symbol-duplication warning (FB-15)
71
+
72
+ - **`button-ui` emits a one-shot `console.warn`** when `text=` begins with a
73
+ symbol that `icon=` already renders — e.g. `text="+ New Item" icon="plus"`
74
+ renders a doubled `+`. Covers the high-frequency `plus` / `minus` / `x` /
75
+ `check` / `arrow-*` collisions. Warning-only (WeakSet-guarded, GC-friendly);
76
+ the button still renders and `text=` is never mutated. `button.yaml` gains the
77
+ matching `anti_patterns` entry so A2UI validators flag it at generation time.
78
+ (~36 LOC `class.js` + `button.yaml` + a2ui regen.)
79
+
80
+ ### Added — Loading state as first-class citizen (FB-12 P2)
81
+
82
+ - **`stat-ui loading` prop.** New `loading: Boolean` (reflect: true). When set,
83
+ the value + change slots render `<skeleton-ui>` shimmer placeholders and the
84
+ host gets `aria-busy="true"`. Label and icon are preserved as static
85
+ metadata. Toggle off when data arrives — slot textContent setters restore
86
+ cleanly. `composes:` now includes `skeleton-ui`. (~28 LOC `stat.js` + 9 LOC
87
+ `stat.yaml` + a2ui chunk regen.)
88
+ - **`table-ui` skeleton rows.** `loading=true` now renders N ghost
89
+ `[data-skeleton-row]` rows (N = `paginate` capped at 8, else 5) instead of
90
+ the legacy `<progress-ui>` overlay. Header + columns stay intact so layout
91
+ is preserved while data fetches. Cell widths cycle 60/80/70/50/90% so rows
92
+ read as natural data. Host gets `aria-busy="true"`. `composes:` now
93
+ includes `skeleton-ui`. Fixes yaml-vs-impl drift: the yaml description
94
+ always promised "skeleton rows" since the initial cut; the impl only
95
+ shipped the overlay. (~42 LOC `class.js` rewrite of `#renderOverlays` +
96
+ CSS rewrite of the `:scope[loading] [data-body]` rule.)
97
+
98
+ ### Added — `text-ui` overlay attributes (FB-10)
99
+
100
+ - **`size` / `color` / `weight` / `text-align` props on `text-ui`.** Pre-v0.6.18
101
+ the skill documented an overlay API (`<text-ui color="subtle" size="sm"
102
+ weight="semibold" text-align="center">`) but the substrate ignored those
103
+ attributes — they were valid HTML but no-op CSS. v0.6.18 implements them
104
+ as `:scope[attr="value"]` rules that override the `--text-*` CSS vars set
105
+ by `[variant]`. Enums:
106
+ - `size` — `sm | md | lg` → `--a-body-sm | --a-body-md | --a-body-lg`
107
+ - `color` — `default | subtle | strong | accent | danger | success | warning`
108
+ - `weight` — `regular | medium | semibold | bold`
109
+ - `text-align` — `start | center | end | justify` (sets `text-align` directly;
110
+ only takes effect when text-ui is block-like)
111
+ Permissive: unknown values are no-ops (variant default wins). Substrate now
112
+ matches the skill's already-documented intent.
113
+
114
+ ### Tests
115
+
116
+ - 9 new tests in `stat.test.js` (FB-12 P2 #1 coverage).
117
+ - 9 new tests in `table.test.js` (FB-12 P2 #3 coverage; uses RAF-based settle
118
+ helper because table-ui renders via `requestAnimationFrame`, not microtasks).
119
+ - 18 new tests in `text.test.js` for the overlay attributes (attribute
120
+ reflection × 18 values + CSS rule presence × 18 + 3 enum-vs-CSS
121
+ consistency checks + permissive-fallback smoke).
122
+ - **Total: 99/99 passing** (text 82 + stat 9 + table 9 — the existing text
123
+ variant tests still pass alongside the new overlay tests).
124
+
125
+ ### Deferred (see `outbox/FEEDBACK-12--...--followup.md`)
126
+
127
+ - **`chart-ui` `loading` prop (FB-12 P2 #2)** — design decision upstream.
128
+ `chart.examples.html` documents intentional "compose loading wrappers"
129
+ pattern; pivoting in a patch cut would silently lock in a direction.
130
+ - **`skeleton-ui` `variant` reconcile (FB-12 P3)** — two valid dispositions
131
+ (purge a2ui corpus refs / implement prop) need maintainer signoff.
132
+
133
+ ### No-op (FB-11 P3 substrate)
134
+
135
+ - Verified that `import { UIFeed } from '@adia-ai/web-components/components/feed/class'`
136
+ resolves correctly via the existing `./components/*/class` exports pattern.
137
+ No package.json change needed. The original ticket flagged
138
+ `/components/feed/feed.js` as failing — confirmed (Node exports wildcards
139
+ can't substitute `*` across multiple path segments) but the `/class`
140
+ shorthand is the canonical path and works today. Skill v2.11.0 documents
141
+ this.
142
+
3
143
  ## [0.6.17] — 2026-05-21
4
144
 
5
145
  ### Maintenance
package/USAGE.md CHANGED
@@ -49,6 +49,12 @@ import '@adia-ai/web-components/components/button';
49
49
  import '@adia-ai/web-components/components/button.css';
50
50
  ```
51
51
 
52
+ > **No `.js` suffix on component subpaths.** Import the directory name —
53
+ > `…/components/button`, never `…/components/button/button.js`. The
54
+ > `exports` wildcard matches only the directory form; appending the
55
+ > explicit filename fails to resolve under Vite 8 (rolldown). The on-disk
56
+ > filename is irrelevant to the subpath specifier.
57
+
52
58
  Subpath exports also available:
53
59
 
54
60
  ```js
@@ -96,7 +96,14 @@
96
96
  ],
97
97
  "unevaluatedProperties": false,
98
98
  "x-adiaui": {
99
- "anti_patterns": [],
99
+ "anti_patterns": [
100
+ {
101
+ "description": "Beginning text= with a symbol that icon= already renders. icon=\"plus\" paints a Phosphor \"+\" glyph; text=\"+ New Item\" then renders the literal \"+\" too, so the symbol appears twice ([+ icon] [+ New Item]).",
102
+ "right": "<button-ui text=\"New Claim\" icon=\"plus\" variant=\"primary\"></button-ui>\n",
103
+ "rule": "Do not repeat the icon's glyph in text=. The icon provides the symbol; text= carries only the words. Applies to plus / minus / x / check / arrow icons.",
104
+ "wrong": "<button-ui text=\"+ New Claim\" icon=\"plus\" variant=\"primary\"></button-ui>\n"
105
+ }
106
+ ],
100
107
  "category": "action",
101
108
  "composes": [
102
109
  "icon-ui"
@@ -140,7 +140,19 @@ tokens:
140
140
  description: Inherited multiplier for padding
141
141
  a2ui:
142
142
  rules: []
143
- anti_patterns: []
143
+ anti_patterns:
144
+ - description: >-
145
+ Beginning text= with a symbol that icon= already renders. icon="plus"
146
+ paints a Phosphor "+" glyph; text="+ New Item" then renders the literal
147
+ "+" too, so the symbol appears twice ([+ icon] [+ New Item]).
148
+ wrong: |
149
+ <button-ui text="+ New Claim" icon="plus" variant="primary"></button-ui>
150
+ right: |
151
+ <button-ui text="New Claim" icon="plus" variant="primary"></button-ui>
152
+ rule: >-
153
+ Do not repeat the icon's glyph in text=. The icon provides the symbol;
154
+ text= carries only the words. Applies to plus / minus / x / check /
155
+ arrow icons.
144
156
  examples: []
145
157
  keywords: []
146
158
  synonyms: {}
@@ -16,6 +16,19 @@
16
16
  import { UIElement, signal, html } from '../../core/element.js';
17
17
  import { getIcon } from '../../core/icons.js';
18
18
 
19
+ // FEEDBACK-15: highest-frequency icon ⇄ text-prefix collisions. When an
20
+ // author writes e.g. text="+ New Item" icon="plus", the Phosphor glyph and
21
+ // the literal symbol both render — a doubled "+". Map is intentionally a
22
+ // curated high-frequency set, not exhaustive; the warning is non-breaking.
23
+ const ICON_TEXT_PREFIXES = {
24
+ plus: ['+', '+'],
25
+ minus: ['-', '−', '–'],
26
+ x: ['×', 'x ', 'X '],
27
+ check: ['✓', '✔'],
28
+ 'arrow-right': ['→', '>'],
29
+ 'arrow-left': ['←', '<'],
30
+ };
31
+
19
32
  export class UIButton extends UIElement {
20
33
  static properties = {
21
34
  text: { type: String, default: '', reflect: true },
@@ -57,6 +70,26 @@ export class UIButton extends UIElement {
57
70
  }
58
71
  }
59
72
 
73
+ // FEEDBACK-15: warn when text= begins with a symbol that icon= already
74
+ // renders (e.g. text="+ New Claim" icon="plus" → a doubled "+"). One-shot
75
+ // per element via WeakSet; warning-only — the button still renders. The
76
+ // text value is never mutated (text="+1" with no icon is a valid label).
77
+ if (this.icon && this.text) {
78
+ const prefixes = ICON_TEXT_PREFIXES[this.icon];
79
+ if (prefixes && prefixes.some((p) => this.text.startsWith(p))) {
80
+ if (!UIButton.#dupSymbolWarned.has(this)) {
81
+ UIButton.#dupSymbolWarned.add(this);
82
+ // eslint-disable-next-line no-console
83
+ console.warn(
84
+ `[button-ui] text="${this.text}" begins with a symbol that ` +
85
+ `icon="${this.icon}" already renders — the glyph appears twice. ` +
86
+ `Drop the leading symbol from text= (the icon provides it).`,
87
+ this,
88
+ );
89
+ }
90
+ }
91
+ }
92
+
60
93
  // §184 (v0.5.5, FEEDBACK-08 §8): icon-only a11y warning + title→aria-label
61
94
  // auto-derive. Two complementary mechanisms (consumer chooses):
62
95
  // (a) When [title="Undo"] is set on an icon-only button without an
@@ -97,6 +130,9 @@ export class UIButton extends UIElement {
97
130
  // is gone the entry is collected.
98
131
  static #a11yWarned = new WeakSet();
99
132
 
133
+ // FEEDBACK-15: one-shot set for the text/icon symbol-duplication warning.
134
+ static #dupSymbolWarned = new WeakSet();
135
+
100
136
  #onClick = (e) => {
101
137
  if (this.disabled) { e.stopPropagation(); return; }
102
138
  if (this.type === 'submit') {
@@ -65,6 +65,11 @@
65
65
  "component": {
66
66
  "const": "Chart"
67
67
  },
68
+ "data": {
69
+ "description": "JS property (set programmatically — `el.data = [...]`). An array of plain objects; each object's keys are named by the `x` and `y` attributes — e.g. `<chart-ui x=\"month\" y=\"revenue\">` consumes `[{month:'Jan', revenue:3200}, {month:'Feb', revenue:4100}]`. The Chart.js `{labels, datasets}` envelope is NOT chart-ui's API — passing it (or any non-array value) renders an empty chart. May also be supplied declaratively as a JSON-array `data=\"[…]\"` attribute, hydrated once at connect. Custom accessor on the element class, not a reflected attribute.",
70
+ "type": "array",
71
+ "default": []
72
+ },
68
73
  "format": {
69
74
  "description": "Number-format mode applied to axis labels + value overlays + donut total + gauge value + treemap value + funnel value + internal tooltip. `abbr` is the legacy 1.2K / 3M format; `decimal` fixes 2 decimals; `currency` prefixes via `--chart-currency-prefix` token (default \"$\"); `percent` multiplies × 100 and adds a % suffix.",
70
75
  "type": "string",
@@ -24,6 +24,8 @@ export class UIChart extends UIElement {
24
24
  aspect: 'std' | 'wide' | 'square' | 'tall';
25
25
  /** Color scheme */
26
26
  color: 'accent' | 'success' | 'warning' | 'danger' | 'info';
27
+ /** JS property (set programmatically — `el.data = [...]`). An array of plain objects; each object's keys are named by the `x` and `y` attributes — e.g. `<chart-ui x="month" y="revenue">` consumes `[{month:'Jan', revenue:3200}, {month:'Feb', revenue:4100}]`. The Chart.js `{labels, datasets}` envelope is NOT chart-ui's API — passing it (or any non-array value) renders an empty chart. May also be supplied declaratively as a JSON-array `data="[…]"` attribute, hydrated once at connect. Custom accessor on the element class, not a reflected attribute. */
28
+ data: unknown[];
27
29
  /** Number-format mode applied to axis labels + value overlays + donut total + gauge value + treemap value + funnel value + internal tooltip. `abbr` is the legacy 1.2K / 3M format; `decimal` fixes 2 decimals; `currency` prefixes via `--chart-currency-prefix` token (default "$"); `percent` multiplies × 100 and adds a % suffix. */
28
30
  format: 'abbr' | 'decimal' | 'currency' | 'percent';
29
31
  /** DEPRECATED (OD-CHART-02). Place chart titles in an enclosing card-ui's `<header><span slot="heading">...</span></header>` instead. Still honored for back-compat; emits a one-shot console.warn per instance when set. Used as the chart's `aria-label` when no explicit label is provided. */
@@ -122,6 +122,19 @@ props:
122
122
  description: Y-axis key(s), comma-separated for multi-series
123
123
  type: string
124
124
  default: ""
125
+ data:
126
+ description: >-
127
+ JS property (set programmatically — `el.data = [...]`). An array of
128
+ plain objects; each object's keys are named by the `x` and `y`
129
+ attributes — e.g. `<chart-ui x="month" y="revenue">` consumes
130
+ `[{month:'Jan', revenue:3200}, {month:'Feb', revenue:4100}]`. The
131
+ Chart.js `{labels, datasets}` envelope is NOT chart-ui's API —
132
+ passing it (or any non-array value) renders an empty chart. May also
133
+ be supplied declaratively as a JSON-array `data="[…]"` attribute,
134
+ hydrated once at connect. Custom accessor on the element class, not a
135
+ reflected attribute.
136
+ type: array
137
+ default: []
125
138
  events:
126
139
  chart-hover:
127
140
  description: >-
@@ -231,6 +231,7 @@ export class UIChart extends UIElement {
231
231
  static template = () => null;
232
232
 
233
233
  #data = [];
234
+ #shapeWarned = false; // FEEDBACK-24: one-shot wrong-.data-shape warning
234
235
  #resizeObs = null;
235
236
  #resizeRaf = null;
236
237
  #lastW = 0;
@@ -250,6 +251,18 @@ export class UIChart extends UIElement {
250
251
  }
251
252
 
252
253
  set data(arr) {
254
+ // FEEDBACK-24: a non-array — typically the Chart.js `{labels, datasets}`
255
+ // envelope — is silently coerced to [] below, producing a blank chart
256
+ // with no diagnostic. Warn once per element.
257
+ if (!Array.isArray(arr) && !this.#shapeWarned) {
258
+ this.#shapeWarned = true;
259
+ // eslint-disable-next-line no-console
260
+ console.warn(
261
+ `[chart-ui] .data must be an array of plain objects — received ` +
262
+ `${arr === null ? 'null' : typeof arr}. The {labels, datasets} ` +
263
+ `envelope is the Chart.js API, not the chart-ui API. See chart.yaml.`,
264
+ );
265
+ }
253
266
  this.#data = Array.isArray(arr) ? arr : [];
254
267
  this.#warnIfOverBudget();
255
268
  this.#renderChart();
@@ -39,7 +39,8 @@
39
39
  * drawer.show() · drawer.hide() · drawer.open = true|false
40
40
  *
41
41
  * Events:
42
- * close — fired after the drawer finishes closing
42
+ * close — fired after the drawer finishes closing
43
+ * opened — fired after the drawer finishes its open transition
43
44
  */
44
45
 
45
46
  import { UIElement } from '../../core/element.js';
@@ -49,6 +50,7 @@ export class UIDrawer extends UIElement {
49
50
  #closing = false;
50
51
  #previousFocus = null;
51
52
  #closeTimer = null;
53
+ #openTimer = null;
52
54
  #dialogRef = null;
53
55
  // §156 (v0.5.3): track the reason the drawer closed so the dispatched
54
56
  // `close` CustomEvent carries `detail.reason`. Set at each entry point
@@ -161,6 +163,10 @@ export class UIDrawer extends UIElement {
161
163
  clearTimeout(this.#closeTimer);
162
164
  this.#closeTimer = null;
163
165
  }
166
+ if (this.#openTimer != null) {
167
+ clearTimeout(this.#openTimer);
168
+ this.#openTimer = null;
169
+ }
164
170
  this.#bound = false;
165
171
  this.#dialogRef = null;
166
172
  }
@@ -213,6 +219,27 @@ export class UIDrawer extends UIElement {
213
219
  render() {
214
220
  const dialog = this.ensure('dialog');
215
221
 
222
+ // §FB-30 (P0): render() is the single source of truth for the dialog
223
+ // binding. When a consumer sets `drawer.innerHTML` on a mounted drawer
224
+ // the internal <dialog> is wiped; ensure('dialog') re-stamps a fresh
225
+ // one, but #dialogRef (set once in connected()) still points at the
226
+ // detached old dialog. Re-bind the cancel/close/click listeners onto
227
+ // the new dialog so #syncDialog() never calls showModal() on a
228
+ // detached node. On first render after mount `dialog` already equals
229
+ // #dialogRef (set in connected()), so the listeners are NOT
230
+ // double-added. Per FEEDBACK-30.
231
+ if (dialog !== this.#dialogRef) {
232
+ if (this.#dialogRef) {
233
+ this.#dialogRef.removeEventListener('cancel', this.#onDialogCancel);
234
+ this.#dialogRef.removeEventListener('close', this.#onDialogClose);
235
+ this.#dialogRef.removeEventListener('click', this.#onDialogClick);
236
+ }
237
+ this.#dialogRef = dialog;
238
+ dialog.addEventListener('cancel', this.#onDialogCancel);
239
+ dialog.addEventListener('close', this.#onDialogClose);
240
+ dialog.addEventListener('click', this.#onDialogClick);
241
+ }
242
+
216
243
  if (this.text) {
217
244
  dialog.setAttribute('aria-label', this.text);
218
245
  this.setAttribute('aria-label', this.text);
@@ -289,10 +316,23 @@ export class UIDrawer extends UIElement {
289
316
  #syncDialog() {
290
317
  const dialog = this.#dialogRef;
291
318
  if (!dialog) return;
319
+ // §FB-30 (P0): guard against a detached dialog. If a consumer wiped
320
+ // the host's children via innerHTML between render() passes, #dialogRef
321
+ // could momentarily point at a node outside the document — showModal()
322
+ // on a detached <dialog> throws InvalidStateError. Per FEEDBACK-30.
323
+ if (!dialog.isConnected) return;
292
324
  if (this.open && !dialog.open) {
293
325
  this.#closing = false;
294
326
  this.#previousFocus = document.activeElement;
295
- dialog.showModal();
327
+ // §FB-30 (P0): wrap showModal() so a detached-dialog InvalidStateError
328
+ // surfaces synchronously instead of being swallowed by the effect
329
+ // runner and rethrown as an unhandled microtask rejection. Per
330
+ // FEEDBACK-30.
331
+ try {
332
+ dialog.showModal();
333
+ } catch (e) {
334
+ console.error('[drawer-ui] showModal() failed — dialog may be detached:', e);
335
+ }
296
336
  // Synchronous reflow instead of rAF — Safari throttles
297
337
  // requestAnimationFrame when a top-layer dialog is open, sometimes
298
338
  // delaying [data-open] (and the slide-in transition) by tens of
@@ -300,6 +340,17 @@ export class UIDrawer extends UIElement {
300
340
  // synchronous frame. See docs/BROWSER-COMPAT.md §3a (Flavor C).
301
341
  void dialog.offsetHeight;
302
342
  dialog.setAttribute('data-open', '');
343
+ // §FB-27 (P2): emit `opened` after the slide-in transition completes
344
+ // so Playwright (and any consumer waiting for "drawer fully open and
345
+ // interactive") can listen for it instead of guessing with
346
+ // waitForTimeout. Mirrors #closeTimer — cleared in #animateClose and
347
+ // disconnected() so a rapid close→open never fires a stale `opened`.
348
+ // Per FEEDBACK-27.
349
+ if (this.#openTimer != null) clearTimeout(this.#openTimer);
350
+ this.#openTimer = setTimeout(() => {
351
+ this.#openTimer = null;
352
+ this.dispatchEvent(new CustomEvent('opened', { bubbles: true }));
353
+ }, this.#getDuration());
303
354
  } else if (!this.open && dialog.open && !this.#closing) {
304
355
  this.#animateClose(dialog);
305
356
  }
@@ -307,6 +358,13 @@ export class UIDrawer extends UIElement {
307
358
 
308
359
  #animateClose(dialog) {
309
360
  this.#closing = true;
361
+ // §FB-27 (P2): a close cancels any pending `opened` — clear the open
362
+ // timer so a rapid open→close never fires a stale `opened` event after
363
+ // the drawer is already sliding out. Per FEEDBACK-27.
364
+ if (this.#openTimer != null) {
365
+ clearTimeout(this.#openTimer);
366
+ this.#openTimer = null;
367
+ }
310
368
  // Set [data-closing] FIRST (carries the transition spec), force a
311
369
  // reflow, THEN remove [data-open]. If both attribute changes batch
312
370
  // into a single style update, Safari can skip the slide-out animation
@@ -58,7 +58,14 @@
58
58
  ],
59
59
  "unevaluatedProperties": false,
60
60
  "x-adiaui": {
61
- "anti_patterns": [],
61
+ "anti_patterns": [
62
+ {
63
+ "description": "Replacing a drawer's children wholesale — `drawer.innerHTML = '…'` — wipes the internal <dialog> part UIDrawer stamps and tracks. render() re-stamps and re-binds the dialog defensively (FEEDBACK-30), but the authored header/section/footer skeleton is still destroyed on every mutation, and any consumer-held reference to a former child is stale.",
64
+ "right": "<drawer-ui><header>Title</header><section id=\"body\"></section></drawer-ui>\n<!-- mutate a stable inner element, never the component host -->\ndocument.getElementById('body').innerHTML = renderFields(claim);\n",
65
+ "rule": "Never set .innerHTML on a UIElement component host. Keep <header>/<section>/<footer> as persistent children and mutate the content of a plain inner element instead.",
66
+ "wrong": "drawer.innerHTML = `<header>${title}</header><section>${body}</section>`;\n"
67
+ }
68
+ ],
62
69
  "category": "container",
63
70
  "composes": [],
64
71
  "events": {
@@ -76,6 +83,9 @@
76
83
  ]
77
84
  }
78
85
  }
86
+ },
87
+ "opened": {
88
+ "description": "Fired after the drawer finishes its open transition — scheduled `--drawer-duration` ms after `showModal()` + `[data-open]`. Lets Playwright (and any consumer code waiting for the drawer to be fully open and interactive) listen for an event instead of guessing with a fixed timeout. Cancelled if the drawer closes before the open transition completes, so a rapid close→open never fires a stale `opened`. Added per FEEDBACK-27."
79
89
  }
80
90
  },
81
91
  "examples": [
@@ -18,6 +18,7 @@ export interface DrawerCloseEventDetail {
18
18
  }
19
19
 
20
20
  export type DrawerCloseEvent = CustomEvent<DrawerCloseEventDetail>;
21
+ export type DrawerOpenedEvent = CustomEvent<unknown>;
21
22
 
22
23
  export class UIDrawer extends UIElement {
23
24
  /** Controls visibility. When false, backdrop and panel are removed from DOM. */
@@ -37,4 +38,5 @@ export class UIDrawer extends UIElement {
37
38
  options?: boolean | AddEventListenerOptions,
38
39
  ): void;
39
40
  addEventListener(type: 'close', listener: (ev: DrawerCloseEvent) => unknown, options?: boolean | AddEventListenerOptions): void;
41
+ addEventListener(type: 'opened', listener: (ev: DrawerOpenedEvent) => unknown, options?: boolean | AddEventListenerOptions): void;
40
42
  }
@@ -60,6 +60,15 @@ events:
60
60
  `'programmatic'` (consumer set `.open = false` from JS).
61
61
  Defaults to `'programmatic'` when no event-driven path
62
62
  captured a reason. Added v0.5.3 §156 per FEEDBACK-06 §2.
63
+ opened:
64
+ description: >-
65
+ Fired after the drawer finishes its open transition — scheduled
66
+ `--drawer-duration` ms after `showModal()` + `[data-open]`. Lets
67
+ Playwright (and any consumer code waiting for the drawer to be fully
68
+ open and interactive) listen for an event instead of guessing with
69
+ a fixed timeout. Cancelled if the drawer closes before the open
70
+ transition completes, so a rapid close→open never fires a stale
71
+ `opened`. Added per FEEDBACK-27.
63
72
  slots:
64
73
  backdrop:
65
74
  description: Scrim overlay behind the drawer (stamped by the component).
@@ -123,7 +132,23 @@ traits: []
123
132
  tokens: {}
124
133
  a2ui:
125
134
  rules: []
126
- anti_patterns: []
135
+ anti_patterns:
136
+ - description: >-
137
+ Replacing a drawer's children wholesale — `drawer.innerHTML = '…'` —
138
+ wipes the internal <dialog> part UIDrawer stamps and tracks. render()
139
+ re-stamps and re-binds the dialog defensively (FEEDBACK-30), but the
140
+ authored header/section/footer skeleton is still destroyed on every
141
+ mutation, and any consumer-held reference to a former child is stale.
142
+ wrong: |
143
+ drawer.innerHTML = `<header>${title}</header><section>${body}</section>`;
144
+ right: |
145
+ <drawer-ui><header>Title</header><section id="body"></section></drawer-ui>
146
+ <!-- mutate a stable inner element, never the component host -->
147
+ document.getElementById('body').innerHTML = renderFields(claim);
148
+ rule: >-
149
+ Never set .innerHTML on a UIElement component host. Keep
150
+ <header>/<section>/<footer> as persistent children and mutate the
151
+ content of a plain inner element instead.
127
152
  examples:
128
153
  - name: drawer-panel
129
154
  description: Card with a trigger button that opens a Drawer side panel containing a form with inputs
@@ -33,6 +33,12 @@ export class UISegmented extends UIFormElement {
33
33
 
34
34
  static template = () => null;
35
35
 
36
+ // FEEDBACK-23: one-shot per-element warn when a consumer authors a direct
37
+ // child that isn't <segment-ui> (typically a bare <segment> tag — renders
38
+ // text but gets no sliding indicator, role, or aria-checked). WeakSet so
39
+ // the warn never pins the element from GC.
40
+ static #warnedNonSegment = new WeakSet();
41
+
36
42
  #indicator = null;
37
43
  #bound = false;
38
44
  #transitionRaf = null;
@@ -97,6 +103,23 @@ export class UISegmented extends UIFormElement {
97
103
  }
98
104
 
99
105
  render() {
106
+ // FEEDBACK-23: flag a non-<segment-ui> direct child once per element.
107
+ // Runs before the empty-guard so an all-bare-<segment> group still warns.
108
+ if (!UISegmented.#warnedNonSegment.has(this)) {
109
+ const bad = [...this.children].find(
110
+ (c) => c.tagName !== 'SEGMENT-UI' && !c.hasAttribute('data-indicator'),
111
+ );
112
+ if (bad) {
113
+ UISegmented.#warnedNonSegment.add(this);
114
+ // eslint-disable-next-line no-console
115
+ console.warn(
116
+ `[segmented-ui] child <${bad.tagName.toLowerCase()}> is not ` +
117
+ `<segment-ui> — bare <segment> tags render text but receive no ` +
118
+ `sliding indicator or aria-checked state. Use <segment-ui>.`,
119
+ );
120
+ }
121
+ }
122
+
100
123
  const segs = this.#segments;
101
124
  if (!segs.length) return;
102
125
 
@@ -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/Segmented.json",
4
4
  "title": "Segmented",
5
- "description": "<segmented-ui value=\"tab1\">",
5
+ "description": "Single-select toggle group with an animated sliding indicator. Children must be segment-ui elements.",
6
6
  "type": "object",
7
7
  "allOf": [
8
8
  {
@@ -37,8 +37,8 @@
37
37
  },
38
38
  "examples": [
39
39
  {
40
- "description": "Basic Segmented usage",
41
- "a2ui": "[\n {\n \"id\": \"root\",\n \"component\": \"Card\",\n \"children\": [\n \"sec\"\n ]\n },\n {\n \"id\": \"sec\",\n \"component\": \"Section\",\n \"children\": [\n \"comp\"\n ]\n },\n {\n \"id\": \"comp\",\n \"component\": \"Segmented\",\n \"value\": \"\"\n }\n]",
40
+ "description": "Segmented control with three segment-ui children for view switching.",
41
+ "a2ui": "[\n {\n \"id\": \"root\",\n \"component\": \"Card\",\n \"children\": [\n \"sec\"\n ]\n },\n {\n \"id\": \"sec\",\n \"component\": \"Section\",\n \"children\": [\n \"comp\"\n ]\n },\n {\n \"id\": \"comp\",\n \"component\": \"Segmented\",\n \"value\": \"daily\",\n \"children\": [\n \"s1\",\n \"s2\",\n \"s3\"\n ]\n },\n {\n \"id\": \"s1\",\n \"component\": \"Segment\",\n \"value\": \"daily\",\n \"text\": \"Daily\"\n },\n {\n \"id\": \"s2\",\n \"component\": \"Segment\",\n \"value\": \"weekly\",\n \"text\": \"Weekly\"\n },\n {\n \"id\": \"s3\",\n \"component\": \"Segment\",\n \"value\": \"monthly\",\n \"text\": \"Monthly\"\n }\n]",
42
42
  "name": "basic-segmented"
43
43
  }
44
44
  ],
@@ -54,7 +54,14 @@
54
54
  ],
55
55
  "name": "UISegmented",
56
56
  "related": [],
57
- "slots": {},
57
+ "slots": {
58
+ "default": {
59
+ "description": "Child segment-ui elements that form the toggle group. Children MUST be segment-ui — bare segment tags render text but are silently ignored for the sliding indicator and role/aria-checked state."
60
+ },
61
+ "indicator": {
62
+ "description": "Auto-created sliding indicator element prepended on first render."
63
+ }
64
+ },
58
65
  "states": [
59
66
  {
60
67
  "description": "Default, ready for interaction.",