@adia-ai/web-components 0.6.44 → 0.6.46

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 CHANGED
@@ -1,5 +1,46 @@
1
1
  # Changelog — @adia-ai/web-components
2
2
 
3
+ ## [0.6.46] — 2026-05-29
4
+
5
+ ### Added — declarative `traits="..."` Quick start sections on all 56 trait pages
6
+
7
+ - **`traits/*/\*.examples.html`** — every trait page now opens with a "Quick start" section before the hero demo showing `traits="traitname"` on a real `*-ui` host element. No JS, no import — the declarative attribute is the primary example. Variants shown where config attrs exist. Hallucinated config attr names corrected in 13 pages (agents invented plausible-sounding names; fixed against JS source: e.g. `data-snap-grid` not `data-grid-size`, `data-noise-strength` not `data-noise-opacity`, `data-intersection-threshold` not `data-threshold`, etc.).
8
+
9
+ ### Added — expanded slot vocabulary for `header-ui`, `footer-ui`, `card-ui`
10
+
11
+ - **`components/header/header.yaml`** — added `icon`, `heading`, `description` slots (previously only `default` + `action` were declared, but the parent container's `@scope` activates a 5-slot grid vocabulary when these are present). Each description explains the parent-`@scope` activation contract.
12
+ - **`components/footer/footer.yaml`** — added `heading`, `description`, `action`, `action-leading` slots (all were styled by card/drawer/modal `@scope` but absent from the yaml; footer `heading`/`description` is the trend-stat / KPI pattern).
13
+ - **`components/card/card.yaml`** — added `media` slot (full-bleed hero image/video region); updated `heading`, `description`, `action`, `action-leading` descriptions to accurately state they work in both `<header>` AND `<footer>` context, not just "in the header".
14
+
15
+ ### Fixed — spurious `bleed` removed from 31 section elements
16
+
17
+ - **`patterns/kanban-board/kanban-board.examples.html`** and **`traits/confetti|magnetic-hover|tilt-hover/...examples.html`** — removed `bleed` from `<section bleed>` where the direct first child was `text-ui`, `stack-ui`, or `list-ui` without `divider`. `bleed` removes the card section's inset padding and is only correct for edge-to-edge content (tables, charts, code, divider-lists); it was being cargo-culted onto text sections that needed normal padding.
18
+
19
+ ### Fixed — stale token references in shipped examples
20
+
21
+ - **`components/skeleton/skeleton.examples.html`**: `var(--ui-border)` → `var(--a-border)` (dead token; border was rendering colorless).
22
+ - **`styles/overview.examples.html`**: token tier documentation table updated — `--ui-primary` → `--a-primary`, `--button-bg: var(--ui-primary)` → `var(--a-primary)` (the actual semantic token and component wiring).
23
+
24
+ ### Maintenance
25
+
26
+ - **`packages/web-components/README.md`** — CDN example URLs updated from frozen `@0.6.30` to `@0.6` minor-range; `check:cdn-pins` guard added to `npm run check` aggregate.
27
+ - **`dist/web-components.min.{js,css}` + `dist/icons-manifest.js`** — bundle rebuild.
28
+
29
+ ## [0.6.45] — 2026-05-29
30
+
31
+ ### Fixed — `<canvas-ui>` lazy `a2ui-root` import uses a bare specifier (FEEDBACK-85, follow-up to FB-81)
32
+
33
+ - **`components/canvas/canvas.js`** — the FB-81 lazy import used a *relative* cross-package path (`../../../web-modules/runtime/a2ui-root/a2ui-root.js`). That resolves from the real `node_modules` location but breaks once Vite pre-bundles `web-components` into `node_modules/.vite/deps/` — `vite:import-analysis` fails to resolve the relative path (even with `/* @vite-ignore */`) and aborts dep optimization, so the app won't boot (blank page, Vite overlay only). Switched to a **bare specifier** `import('@adia-ai/web-modules/runtime/a2ui-root')`, which resolves identically whether `web-components` is pre-bundled or not (via web-modules' `./runtime/*` export). **`@adia-ai/web-modules` is now declared as an `optionalDependency`** (`^0.6.0`) so the specifier resolves under any bundler; the import stays dynamic + `.catch()`-guarded, so it's loaded only when `<canvas-ui>` is used and degrades gracefully if web-modules is genuinely absent. Lockfile regen (`npm install --package-lock-only`) required at the release cut.
34
+
35
+ ### Fixed — `<select-ui>` derives an accessible name from `placeholder` (FEEDBACK-88)
36
+
37
+ - **`components/select/select.class.js`** — a placeholder-only `<select-ui placeholder="Status">` (no `label`/`aria-label`/`<field-ui>`) had **no accessible name** (WCAG 4.1.2 fail), while the sibling `<input-ui>` names itself from its placeholder. Added `#syncAccessibleName()` (run in `render()`) mirroring input-ui: derive `aria-label` from `placeholder` when the select is otherwise unnamed. **Guards** (a placeholder-only fix must not clobber a real label): an author `aria-label`, a visible `label`, or `aria-labelledby` always win; the **default `'Select...'` prompt is never used as a name** — otherwise every `<field-ui>`-wrapped select (named via a sibling `<label for>`, which `aria-label` would override) would be renamed to the generic prompt. A `#placeholderNamed` flag tracks the derived name so re-renders refresh/clear it without touching an author's aria-label. +5 unit tests. Known residual (matches input-ui): a `<field-ui>` + a *custom* placeholder on the same select still yields an aria-label from the placeholder — an unusual author combo.
38
+
39
+ ### Maintenance
40
+
41
+ - **`package.json`** — `@adia-ai/web-modules` added as an `optionalDependency` (`^0.6.0`) for the FB-85 bare-specifier resolution; lockfile regenerated.
42
+ - **`dist/web-components.min.js` + `dist/icons-manifest.js`** — bundle rebuild reflecting the `canvas.js` + `select.class.js` fixes above.
43
+
3
44
  ## [0.6.44] — 2026-05-28
4
45
 
5
46
  ### Fixed — `<canvas-ui>` no longer hard-requires `@adia-ai/web-modules` at build time (FEEDBACK-81)
package/README.md CHANGED
@@ -38,12 +38,14 @@ Since **v0.6.30**, this package ships pre-flattened + minified bundles under `di
38
38
 
39
39
  ```html
40
40
  <!-- CSS: all primitives, tokens, resets (443 KB raw / ~50 KB gzipped) -->
41
- <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@adia-ai/web-components@0.6.30/dist/web-components.min.css">
41
+ <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@adia-ai/web-components@0.6/dist/web-components.min.css">
42
42
 
43
43
  <!-- JS: registers all 95 primitives (~250 KB gzipped via Brotli) -->
44
- <script type="module" src="https://cdn.jsdelivr.net/npm/@adia-ai/web-components@0.6.30/dist/web-components.min.js"></script>
44
+ <script type="module" src="https://cdn.jsdelivr.net/npm/@adia-ai/web-components@0.6/dist/web-components.min.js"></script>
45
45
  ```
46
46
 
47
+ The `@0.6` range tracks the latest `0.6.x` patch automatically (won't jump to a breaking `0.7`). For reproducible builds, pin an exact version instead — e.g. `@adia-ai/web-components@0.6.45/dist/web-components.min.css`.
48
+
47
49
  For composite shells, add the corresponding bundle from `@adia-ai/web-modules` — see [its README](../web-modules/#cdn-no-bundler--codepen-marketing-pages-static-html) or the [CDN usage guide](https://ui-kit.exe.xyz/site/cdn-usage). The kitchen-sink path is `@adia-ai/web-modules/dist/everything.min.js` (all primitives + all 4 shells; ~190 KB gzipped) — one tag for CodePen demos.
48
50
 
49
51
  **Pick ONE bundle path.** Mixing (e.g. `everything.min.js` + a separate `web-components.min.js`) causes `customElements.define` to throw "name already defined" on dup-load. The choice tree:
@@ -1,9 +1,16 @@
1
1
  import { UIElement } from '../../core/element.js';
2
2
  // NOTE: the A2UI renderer (<a2ui-root>) lives in @adia-ai/web-modules and is
3
3
  // loaded lazily + guarded inside #ensureRenderer() (kicked off from connected()).
4
- // It is deliberately NOT a static import: @adia-ai/web-components declares no
5
- // dependency on @adia-ai/web-modules, so a static cross-package import hard-fails
6
- // the build for primitives-only consumers who never use <canvas-ui> (FEEDBACK-81).
4
+ // Two constraints shape this import:
5
+ // NOT static (FEEDBACK-81): a static cross-package import hard-fails the build
6
+ // for primitives-only consumers who never use <canvas-ui>. Hence lazy import().
7
+ // • BARE specifier, not a relative path (FEEDBACK-85): a relative
8
+ // '../../../web-modules/…' breaks Vite dep pre-bundling — once web-components is
9
+ // flattened into node_modules/.vite/deps/, the relative path no longer resolves
10
+ // and import-analysis aborts (even with @vite-ignore). A bare specifier resolves
11
+ // identically whether pre-bundled or not. @adia-ai/web-modules is declared as an
12
+ // optionalDependency so it resolves; the .catch() below degrades gracefully if
13
+ // it is genuinely absent at runtime.
7
14
 
8
15
  /**
9
16
  * <canvas-ui> — A2UI rendering surface.
@@ -102,7 +109,7 @@ export class UICanvas extends UIElement {
102
109
  this.#rendererReady = Promise.resolve(true);
103
110
  return this.#rendererReady;
104
111
  }
105
- this.#rendererReady = import(/* @vite-ignore */ '../../../web-modules/runtime/a2ui-root/a2ui-root.js')
112
+ this.#rendererReady = import('@adia-ai/web-modules/runtime/a2ui-root')
106
113
  .then(() => customElements.whenDefined('a2ui-root'))
107
114
  .then(() => true)
108
115
  .catch((err) => {
@@ -115,19 +115,22 @@
115
115
  ],
116
116
  "slots": {
117
117
  "description": {
118
- "description": "Optional descriptive text beneath the heading. Renders in the header slot at body-subtle typography. Use for short metadata lines (timestamp, author, status sentence)."
118
+ "description": "Secondary metadata grid row 2 beneath the heading inside `<header>`, or a period/caption line beneath a metric inside `<footer>` (e.g. \"Jan Mar 2024\"). Also accepts bare `<p>` / `<small>` / body-variant `<text-ui>` as direct children without slot=\"description\". Triggers space-between layout in the footer when combined with slot=\"action\"."
119
119
  },
120
120
  "action": {
121
- "description": "Trailing action cluster in the header (e.g. icon-buttons, menu trigger, more-options). Aligns to the header's flex-end edge."
121
+ "description": "Trailing control cluster inside `<header>` (icon-buttons, menu trigger, more-options) or the primary action cluster inside `<footer>` (e.g. Save, Confirm). Aligns to the flex-end edge in both contexts. Pair with slot=\"action-leading\" for dual-cluster footers."
122
122
  },
123
123
  "action-leading": {
124
- "description": "Leading action cluster in the header (e.g. back button, switcher, breadcrumb-context). Aligns to the header's flex-start edge, before the icon/heading column."
124
+ "description": "Leading control cluster inline-start edge of the header or footer. In the header: back button, breadcrumb context, or workspace switcher, before the icon/heading column. In the footer: secondary action (e.g. Back) opposite the primary cluster."
125
125
  },
126
126
  "heading": {
127
- "description": "Card title. Renders in the header slot with title typography. Typically a short noun phrase or document/object name."
127
+ "description": "Primary title grid row 1 inside `<header>`, or the prominent metric value inside `<footer>` (trend-stat / KPI pattern). Also accepts bare `<h1>`–`<h6>` tags as direct children without slot=\"heading\". A slot=\"heading\" wrapper can contain inline badges or metadata alongside the title text."
128
128
  },
129
129
  "icon": {
130
- "description": "Optional leading icon for the card header (status / brand / type marker). Renders next to the heading. Use `<icon-ui name=\"…\">` or any inline icon element."
130
+ "description": "Leading icon for the card header status, brand, or type marker. Placed in column 1 of the 3-column header grid when present; heading + description shift to column 2. Use `<icon-ui name=\"…\">`, `<avatar-ui>`, or any inline icon element as a DIRECT child of the `<header>` with slot=\"icon\" (not inside a wrapper — direct-child only, per the :has(> [slot=\"icon\"]) gate)."
131
+ },
132
+ "media": {
133
+ "description": "Full-bleed media region — placed before the first `<header>` or `<section>` as the card's hero image / video / illustration slot. Stretches edge-to-edge (negative margin equal to the card's inset); first-child `[slot=\"media\"]` gets zero border-radius on the top corners so it sits flush with the card border."
131
134
  }
132
135
  },
133
136
  "states": [
@@ -61,27 +61,45 @@ events: {}
61
61
  slots:
62
62
  icon:
63
63
  description: >-
64
- Optional leading icon for the card header (status / brand / type
65
- marker). Renders next to the heading. Use `<icon-ui name="…">` or
66
- any inline icon element.
64
+ Leading icon for the card header status, brand, or type marker.
65
+ Placed in column 1 of the 3-column header grid when present;
66
+ heading + description shift to column 2. Use `<icon-ui name="…">`,
67
+ `<avatar-ui>`, or any inline icon element as a DIRECT child of the
68
+ `<header>` with slot="icon" (not inside a wrapper — direct-child
69
+ only, per the :has(> [slot="icon"]) gate).
67
70
  heading:
68
71
  description: >-
69
- Card title. Renders in the header slot with title typography.
70
- Typically a short noun phrase or document/object name.
72
+ Primary title grid row 1 inside `<header>`, or the prominent metric
73
+ value inside `<footer>` (trend-stat / KPI pattern). Also accepts bare
74
+ `<h1>`–`<h6>` tags as direct children without slot="heading". A
75
+ slot="heading" wrapper can contain inline badges or metadata alongside
76
+ the title text.
71
77
  description:
72
78
  description: >-
73
- Optional descriptive text beneath the heading. Renders in the
74
- header slot at body-subtle typography. Use for short metadata
75
- lines (timestamp, author, status sentence).
79
+ Secondary metadata grid row 2 beneath the heading inside `<header>`,
80
+ or a period/caption line beneath a metric inside `<footer>` (e.g.
81
+ "Jan Mar 2024"). Also accepts bare `<p>` / `<small>` / body-variant
82
+ `<text-ui>` as direct children without slot="description". Triggers
83
+ space-between layout in the footer when combined with slot="action".
76
84
  action:
77
85
  description: >-
78
- Trailing action cluster in the header (e.g. icon-buttons, menu
79
- trigger, more-options). Aligns to the header's flex-end edge.
86
+ Trailing control cluster inside `<header>` (icon-buttons, menu
87
+ trigger, more-options) or the primary action cluster inside `<footer>`
88
+ (e.g. Save, Confirm). Aligns to the flex-end edge in both contexts.
89
+ Pair with slot="action-leading" for dual-cluster footers.
80
90
  action-leading:
81
91
  description: >-
82
- Leading action cluster in the header (e.g. back button, switcher,
83
- breadcrumb-context). Aligns to the header's flex-start edge,
84
- before the icon/heading column.
92
+ Leading control cluster inline-start edge of the header or footer.
93
+ In the header: back button, breadcrumb context, or workspace switcher,
94
+ before the icon/heading column. In the footer: secondary action (e.g.
95
+ Back) opposite the primary cluster.
96
+ media:
97
+ description: >-
98
+ Full-bleed media region — placed before the first `<header>` or
99
+ `<section>` as the card's hero image / video / illustration slot.
100
+ Stretches edge-to-edge (negative margin equal to the card's inset);
101
+ first-child `[slot="media"]` gets zero border-radius on the top
102
+ corners so it sits flush with the card border.
85
103
  states:
86
104
  - name: idle
87
105
  description: Default, ready for interaction.
@@ -61,8 +61,20 @@
61
61
  "table"
62
62
  ],
63
63
  "slots": {
64
+ "description": {
65
+ "description": "Caption or period text rendered beneath slot=\"heading\" — small, muted. Triggers space-between layout when combined with slot=\"action\". Also accepts bare <p> / <small> elements as direct children without slot=\"description\"."
66
+ },
64
67
  "default": {
65
- "description": "Default slot — primary child content."
68
+ "description": "Default slot — primary child content. Typically flat <button-ui> children laid out by the `justify` prop. Prefer flat children; a nested <row-ui> double-applies layout and breaks the chrome's gap."
69
+ },
70
+ "action": {
71
+ "description": "Trailing action cluster — self-aligns to the inline-end edge of the footer. Pair with slot=\"action-leading\" for dual-cluster footers (e.g. Back on the left, Save on the right). Activated by card-ui, drawer-ui, and modal-ui @scope rules."
72
+ },
73
+ "action-leading": {
74
+ "description": "Leading action cluster — self-aligns to the inline-start edge of the footer. Typically a Back or secondary action that needs to sit opposite the primary cluster. Activated by drawer-ui @scope; also works in card-ui footers via the same slot selector."
75
+ },
76
+ "heading": {
77
+ "description": "Metric or period label — rendered prominently at the leading edge of the footer chrome (e.g. \"$12.4k\" / \"Jan – Mar 2024\"). When present the parent's @scope switches to a heading + description layout. Pairs with slot=\"description\" for a caption line below the value (trend-stat footer pattern). Activated by card-ui and drawer-ui @scope rules."
66
78
  }
67
79
  },
68
80
  "states": [
@@ -21,7 +21,35 @@ props:
21
21
  events: {}
22
22
  slots:
23
23
  default:
24
- description: "Default slot — primary child content."
24
+ description: >-
25
+ Default slot — primary child content. Typically flat <button-ui>
26
+ children laid out by the `justify` prop. Prefer flat children; a
27
+ nested <row-ui> double-applies layout and breaks the chrome's gap.
28
+ heading:
29
+ description: >-
30
+ Metric or period label — rendered prominently at the leading edge of
31
+ the footer chrome (e.g. "$12.4k" / "Jan – Mar 2024"). When present
32
+ the parent's @scope switches to a heading + description layout. Pairs
33
+ with slot="description" for a caption line below the value (trend-stat
34
+ footer pattern). Activated by card-ui and drawer-ui @scope rules.
35
+ description:
36
+ description: >-
37
+ Caption or period text rendered beneath slot="heading" — small,
38
+ muted. Triggers space-between layout when combined with slot="action".
39
+ Also accepts bare <p> / <small> elements as direct children without
40
+ slot="description".
41
+ action:
42
+ description: >-
43
+ Trailing action cluster — self-aligns to the inline-end edge of the
44
+ footer. Pair with slot="action-leading" for dual-cluster footers (e.g.
45
+ Back on the left, Save on the right). Activated by card-ui, drawer-ui,
46
+ and modal-ui @scope rules.
47
+ action-leading:
48
+ description: >-
49
+ Leading action cluster — self-aligns to the inline-start edge of the
50
+ footer. Typically a Back or secondary action that needs to sit opposite
51
+ the primary cluster. Activated by drawer-ui @scope; also works in
52
+ card-ui footers via the same slot selector.
25
53
  states:
26
54
  - name: idle
27
55
  description: Default, ready for interaction.
@@ -55,11 +55,20 @@
55
55
  "alert"
56
56
  ],
57
57
  "slots": {
58
+ "description": {
59
+ "description": "Secondary metadata — grid row 2 beneath the heading, spanning the heading and action columns. Also accepts bare <p> / <small> / body-variant <text-ui> as direct children without slot=\"description\". Renders muted + small."
60
+ },
58
61
  "default": {
59
- "description": "Default slot — primary child content."
62
+ "description": "Default slot — primary child content. When no named slot= attributes are used, all children flow here as a block. Most card/drawer/modal usages prefer the named vocabulary below so the parent's @scope grid activates."
60
63
  },
61
64
  "action": {
62
- "description": "Action region — buttons or badges on the right."
65
+ "description": "Trailing control cluster placed in the last column of the parent container's header grid. Use for icon-buttons, menu triggers, or badge/button combinations. The first [slot=\"action\"] child pushes itself to the grid's end; subsequent siblings flow with gap."
66
+ },
67
+ "heading": {
68
+ "description": "Primary title — grid row 1 of the header grid. Also accepts bare <h1>–<h6> tags as direct children without slot=\"heading\". Renders at title weight + size per the parent's @scope. A slot=\"heading\" wrapper element can contain inline badges or metadata alongside the title text."
69
+ },
70
+ "icon": {
71
+ "description": "Leading icon column — placed in column 1 of the parent container's header grid (card-ui / drawer-ui / modal-ui / page-ui). Use <icon-ui name=\"...\">, <avatar-ui>, or any inline icon element. The grid activates only when this slot is present; without it the heading spans the full width."
63
72
  }
64
73
  },
65
74
  "states": [
@@ -17,9 +17,37 @@ props:
17
17
  events: {}
18
18
  slots:
19
19
  default:
20
- description: "Default slot — primary child content."
20
+ description: >-
21
+ Default slot — primary child content. When no named slot= attributes
22
+ are used, all children flow here as a block. Most card/drawer/modal
23
+ usages prefer the named vocabulary below so the parent's @scope grid
24
+ activates.
25
+ icon:
26
+ description: >-
27
+ Leading icon column — placed in column 1 of the parent container's
28
+ header grid (card-ui / drawer-ui / modal-ui / page-ui). Use
29
+ <icon-ui name="...">, <avatar-ui>, or any inline icon element.
30
+ The grid activates only when this slot is present; without it the
31
+ heading spans the full width.
32
+ heading:
33
+ description: >-
34
+ Primary title — grid row 1 of the header grid. Also accepts bare
35
+ <h1>–<h6> tags as direct children without slot="heading". Renders
36
+ at title weight + size per the parent's @scope. A slot="heading"
37
+ wrapper element can contain inline badges or metadata alongside
38
+ the title text.
39
+ description:
40
+ description: >-
41
+ Secondary metadata — grid row 2 beneath the heading, spanning the
42
+ heading and action columns. Also accepts bare <p> / <small> /
43
+ body-variant <text-ui> as direct children without slot="description".
44
+ Renders muted + small.
21
45
  action:
22
- description: "Action region — buttons or badges on the right."
46
+ description: >-
47
+ Trailing control cluster — placed in the last column of the parent
48
+ container's header grid. Use for icon-buttons, menu triggers, or
49
+ badge/button combinations. The first [slot="action"] child pushes
50
+ itself to the grid's end; subsequent siblings flow with gap.
23
51
  states:
24
52
  - name: idle
25
53
  description: Default, ready for interaction.
@@ -324,6 +324,37 @@ export class UISelect extends UIFormElement {
324
324
  else this.selectAllOptions();
325
325
  };
326
326
 
327
+ // FB-88: tracks whether the host's current aria-label was derived from
328
+ // `placeholder` (vs author-supplied), so re-renders refresh/clear OUR name
329
+ // without ever clobbering an author's aria-label.
330
+ #placeholderNamed = false;
331
+
332
+ /**
333
+ * Derive an accessible name from `placeholder` when the select is otherwise
334
+ * unnamed — mirrors input-ui so a placeholder-only select (the idiomatic
335
+ * filter-bar shape) still has a name for assistive tech (WCAG 4.1.2, FB-88).
336
+ *
337
+ * Guards (a placeholder-only fix must NOT clobber a real label):
338
+ * • an author `aria-label`, a visible `label`, or an `aria-labelledby` wins;
339
+ * • the default 'Select...' prompt is NOT used as a name — otherwise every
340
+ * field-ui-wrapped select (named by a sibling `<label for>`, which aria-label
341
+ * would override) would be renamed to the generic prompt.
342
+ */
343
+ #syncAccessibleName() {
344
+ const DEFAULT_PLACEHOLDER = 'Select...'; // matches static properties.placeholder.default
345
+ const authoredAria = this.hasAttribute('aria-label') && !this.#placeholderNamed;
346
+ const labeledElsewhere = !!this.label || this.hasAttribute('aria-labelledby');
347
+ const namable = !authoredAria && !labeledElsewhere
348
+ && this.placeholder && this.placeholder !== DEFAULT_PLACEHOLDER;
349
+ if (namable) {
350
+ this.setAttribute('aria-label', this.placeholder);
351
+ this.#placeholderNamed = true;
352
+ } else if (this.#placeholderNamed) {
353
+ this.removeAttribute('aria-label');
354
+ this.#placeholderNamed = false;
355
+ }
356
+ }
357
+
327
358
  connected() {
328
359
  super.connected();
329
360
  this.setAttribute('role', 'combobox');
@@ -349,6 +380,10 @@ export class UISelect extends UIFormElement {
349
380
  if (this.multiple) this.setAttribute('data-multi-chips', '');
350
381
  else this.removeAttribute('data-multi-chips');
351
382
 
383
+ // A11y (FB-88): name a placeholder-only select from its placeholder, like
384
+ // input-ui, so it isn't unnamed for assistive tech (WCAG 4.1.2).
385
+ this.#syncAccessibleName();
386
+
352
387
  // Stamp default trigger if none provided
353
388
  if (!this.querySelector('[slot="trigger"]')) {
354
389
  // Detach listbox before innerHTML wipe so it isn't destroyed
@@ -288,4 +288,38 @@ describe('select-ui', () => {
288
288
  await tick();
289
289
  expect(s.value).toBe('b');
290
290
  });
291
+
292
+ // §FB-88 — placeholder-only select derives an accessible name (WCAG 4.1.2),
293
+ // mirroring input-ui, without clobbering a real label.
294
+ describe('accessible name from placeholder (FB-88)', () => {
295
+ it('names a placeholder-only select from its placeholder', async () => {
296
+ const s = mount(`<select-ui placeholder="Status"></select-ui>`);
297
+ await tick();
298
+ expect(s.getAttribute('aria-label')).toBe('Status');
299
+ });
300
+
301
+ it('does NOT derive a name from the default "Select..." placeholder', async () => {
302
+ const s = mount(`<select-ui></select-ui>`);
303
+ await tick();
304
+ expect(s.hasAttribute('aria-label')).toBe(false);
305
+ });
306
+
307
+ it('preserves an author-supplied aria-label (override wins over placeholder)', async () => {
308
+ const s = mount(`<select-ui aria-label="Filter by status" placeholder="Status"></select-ui>`);
309
+ await tick();
310
+ expect(s.getAttribute('aria-label')).toBe('Filter by status');
311
+ });
312
+
313
+ it('does not set aria-label when a visible label is present', async () => {
314
+ const s = mount(`<select-ui label="Status" placeholder="Pick one"></select-ui>`);
315
+ await tick();
316
+ expect(s.hasAttribute('aria-label')).toBe(false);
317
+ });
318
+
319
+ it('does not set aria-label when aria-labelledby is present (e.g. field-ui)', async () => {
320
+ const s = mount(`<select-ui aria-labelledby="ext-label" placeholder="Pick one"></select-ui>`);
321
+ await tick();
322
+ expect(s.hasAttribute('aria-label')).toBe(false);
323
+ });
324
+ });
291
325
  });