@adia-ai/web-components 0.7.23 → 0.7.24

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,20 @@
1
1
  # Changelog — @adia-ai/web-components
2
2
 
3
+ ## [0.7.24] — 2026-07-01
4
+
5
+ Upstream consumer findings from `adia-pay` against installed `0.7.23` (F1–F6).
6
+
7
+ ### Added
8
+ - **`display-field-ui` (`components/display-field/`) — new display-only labeled-value primitive.** Fills a capability gap: a field-shaped read-out for a static or masked value (a card on file, `•••• 4242`, an account id) or a labeled slot that hosts a third-party element (a Stripe Element iframe) under a proper caption. Unlike `field-ui` it wraps **no** interactive control — no focus, no contenteditable, no form participation, no `[for]`/id-minting — so a readonly `input-ui` (which keeps input semantics) is no longer the only option. Props: `label` (required; names the group via `role="group"` + `aria-labelledby`), `value` (static text), `variant` (`default` | `masked` — mono/tabular treatment so `•••• 4242` aligns), `hint` (caption + `aria-describedby`), `icon` (leading glyph). The value comes from the `value` attr **or** an unnamed default-slot child (the foreign-element path) — slot content wins when both are present. Ships yaml + css + js + d.ts + a2ui sidecar + demo (`display-field.examples.html`) + 6 unit tests; registered in the component barrel + `styles/components.css`; `dist/` + `custom-elements.json` (120 → 121 modules) regenerated. Additive (PATCH). (upstream finding F5)
9
+ - **`table-ui` (`components/table/`) — `rowExpandable` per-row expand gate.** `expandable` was host-level: every row got the same `caret-right` toggle and one host-level `expandRenderer` drove all detail panes, so heterogeneous rows (a *succeeded* payment needing no detail vs. a *failed* one that expands to a reason) could not vary the affordance. New optional `rowExpandable(row, index) => boolean` callback gates the caret per row — non-expandable rows keep the reserved expand-column cell (grid alignment never shifts) but show no caret and can't open a detail pane; `toggleExpand()` is guarded to match (it still collapses an already-open row if the predicate later flips, so a row can't get stuck open). `null` (default) preserves the legacy "every row expandable" behavior. Demo: the `rowExpandable` section in `table.examples.html` (only Failed payments expand). Regression: 3 tests in `table.test.js`. Additive — no API removed (PATCH). (upstream finding F6)
10
+
11
+ ### Fixed
12
+ - **`icon-ui` (`components/icon/`) — fails loud when the icon registry was never wired.** Under Vite 8 / rolldown the zero-config `import.meta.glob('/node_modules/@phosphor-icons/…')` path in `icons-phosphor.js` does not transform from inside a published dependency, leaving the registry empty so every `<icon-ui>` renders blank — previously **silently** (a single missing icon warned, but a wholly-unwired registry did not). New `iconRegistryUnwired()` predicate (`core/icons.js`) distinguishes "never wired" (`registry.size === 0 && !registryReady`) from "one bad name"; `icon.class.js` schedules a one-shot, macrotask-deferred `console.error` naming the exact fix (install `@phosphor-icons/core` + call `installIconLoaders` from the app entry) only when a named icon resolves to nothing AND the registry is unwired. The deferral means a correctly-wired app entry is never false-flagged. Documented as the primary bundler path in `USAGE.md` § Icons. (upstream finding F1)
13
+ - **`custom-elements.json` — manifest no longer fabricates nonexistent modules.** `scripts/build/generate-custom-elements-json.mjs` emitted a `javascript-module` + class + `custom-element-definition` for every component yaml by assuming a `components/<dir>/<base>.js` file existed — fabricating e.g. `UIHeader` → `header.js` (the CSS-only `header-ui` has no class file) and pointing co-located children (menu-item-ui, menu-divider-ui, …) at nonexistent per-child `.js` files. The generator now scans the actual `.js` sources for `customElements.define` / `defineIfFree` / `defineIfUndefined` calls, maps each tag to the file that really defines it, groups declarations by real module path, and lists genuinely CSS-only tags (aside/footer/header/section-ui) as skipped. Regenerated manifest: 120 modules / 134 tags, 0 dangling file paths (was 110 modules carrying fabricated paths). (upstream finding F4)
14
+
15
+ ### Changed
16
+ - **`@phosphor-icons/core` is now a direct dependency (`^2.1.1`).** It was referenced by the icon-loader glob but absent from every dependency field, so the documented zero-config icon path could not resolve its SVG assets from a clean install. Added to `dependencies` so the assets resolve; the app still wires the loader glob from its own source graph (see `USAGE.md` § Icons / finding F1). (upstream finding F2)
17
+
3
18
  ## [0.7.23] — 2026-06-30
4
19
 
5
20
  ### Fixed
package/USAGE.md CHANGED
@@ -9,17 +9,18 @@ The complete reference for engineers and agents **integrating** AdiaUI into an a
9
9
  ## Contents
10
10
 
11
11
  1. [Install](#install)
12
- 2. [The mental model](#the-mental-model)
13
- 3. [Property reactivity (signal-backed)](#property-reactivity-signal-backed)
14
- 4. [Property binding patterns (templates / framework integration)](#property-binding-patterns)
15
- 5. [Event contract `CustomEvent` with `detail`](#event-contract)
16
- 6. [Form participation — `UIFormElement` + `ElementInternals`](#form-participation)
17
- 7. [Data binding with `data-stream-*`](#data-binding-with-data-stream-)
18
- 8. [Lifecycle `connected` / `render` / `updated` / `disconnected`](#lifecycle)
19
- 9. [Theming, density, size](#theming-density-size)
20
- 10. [Registration auto vs explicit](#registration--auto-vs-explicit)
21
- 11. [TypeScript](#typescript)
22
- 12. [Anti-patterns](#anti-patterns)
12
+ 2. [Icons wiring the registry](#icons--wiring-the-registry)
13
+ 3. [The mental model](#the-mental-model)
14
+ 4. [Property reactivity (signal-backed)](#property-reactivity-signal-backed)
15
+ 5. [Property binding patterns (templates / framework integration)](#property-binding-patterns)
16
+ 6. [Event contract — `CustomEvent` with `detail`](#event-contract)
17
+ 7. [Form participation `UIFormElement` + `ElementInternals`](#form-participation)
18
+ 8. [Data binding with `data-stream-*`](#data-binding-with-data-stream-)
19
+ 9. [Lifecycle `connected` / `render` / `updated` / `disconnected`](#lifecycle)
20
+ 10. [Theming, density, size](#theming-density-size)
21
+ 11. [Registration — auto vs explicit](#registration--auto-vs-explicit)
22
+ 12. [TypeScript](#typescript)
23
+ 13. [Anti-patterns](#anti-patterns)
23
24
 
24
25
  ---
25
26
 
@@ -96,6 +97,50 @@ For the live demo of every component: <https://ui-kit.exe.xyz/site/components/>.
96
97
 
97
98
  ---
98
99
 
100
+ ## Icons — wiring the registry
101
+
102
+ `<icon-ui>` (and every component that renders an icon — `<button-ui icon>`,
103
+ `<badge-ui>`, menus, etc.) draws from an **icon registry** that your app
104
+ must wire **once, from your own entry module**. Out of the box the registry
105
+ is empty; an un-wired registry renders every icon **blank**.
106
+
107
+ **Why the app has to do it.** The zero-config glob in the package's own
108
+ `icons-phosphor.js` uses `import.meta.glob('/node_modules/@phosphor-icons/…')`,
109
+ which is a **build-time macro your bundler rewrites** — and under Vite 8 /
110
+ rolldown that rewrite **does not happen for code imported from inside a
111
+ published dependency**. The glob has to be evaluated in *your* source graph.
112
+ `@phosphor-icons/core` ships as a dependency of this package (so the SVG
113
+ assets resolve), but the glob that reads them must live in your app.
114
+
115
+ **The one-time wiring (do this in your app entry, before first paint):**
116
+
117
+ ```js
118
+ import { installIconLoaders } from '@adia-ai/web-components/core/icons';
119
+
120
+ installIconLoaders({
121
+ regular: import.meta.glob(
122
+ '/node_modules/@phosphor-icons/core/assets/regular/*.svg',
123
+ { query: '?raw', import: 'default', eager: true },
124
+ ),
125
+ // add other weights only if you use them:
126
+ // bold: import.meta.glob('/node_modules/@phosphor-icons/core/assets/bold/*.svg', { query: '?raw', import: 'default', eager: true }),
127
+ // fill: import.meta.glob('/node_modules/@phosphor-icons/core/assets/fill/*.svg', { query: '?raw', import: 'default', eager: true }),
128
+ });
129
+ ```
130
+
131
+ `installIconLoaders` is idempotent, may be called before or after the
132
+ elements connect (icons already on the page repaint when it runs), and
133
+ accepts either the per-weight map above or a flat `path → svg-string` map
134
+ if you source icons some other way (`registerIcon(name, svg)` /
135
+ `registerIcons(map)` are the lower-level primitives).
136
+
137
+ **Fail-loud, not fail-blank.** If any `<icon-ui name="…">` renders and the
138
+ registry was never wired, the component logs a one-shot `console.error`
139
+ naming this exact fix. A single *missing* icon (registry wired, that one
140
+ name absent) warns separately and does not trip the un-wired error.
141
+
142
+ ---
143
+
99
144
  ## The mental model
100
145
 
101
146
  Every AdiaUI primitive is a **standard light-DOM custom element**. There's no shadow DOM, no compiler, no framework — just `customElements.define()` extending a small `UIElement` base class. The base class wires a signal-backed property system on top.
@@ -0,0 +1,185 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://adiaui.dev/a2ui/v0_9/components/DisplayField.json",
4
+ "title": "DisplayField",
5
+ "description": "Display-only labeled value — a field-shaped read-out for a static or masked value (e.g. a card on file \"•••• 4242\"), or a labeled slot that hosts a third-party element (a Stripe Element iframe) under a proper caption. Unlike field-ui it wraps NO interactive control: no focus, no contenteditable, no form participation, no [for]/id-minting. The label names the whole field for assistive tech via role=\"group\" + aria-labelledby, so a slotted foreign element (iframe) is announced with its caption. Reach for field-ui + input-ui when the value is EDITABLE; stat-ui for a prominent metric/KPI; description-list-ui for many read-only key/value pairs at once.",
6
+ "type": "object",
7
+ "allOf": [
8
+ {
9
+ "$ref": "common_types.json#/$defs/ComponentCommon"
10
+ },
11
+ {
12
+ "$ref": "common_types.json#/$defs/CatalogComponentCommon"
13
+ }
14
+ ],
15
+ "properties": {
16
+ "component": {
17
+ "const": "DisplayField"
18
+ },
19
+ "hint": {
20
+ "description": "Optional caption rendered below the value in subtle style (e.g. \"Expires 12/26\"). Wired into the field's aria-describedby.",
21
+ "type": "string",
22
+ "default": ""
23
+ },
24
+ "icon": {
25
+ "description": "Optional leading icon name shown beside the value (e.g. a lock or card-brand glyph). Lazily creates a single icon-ui child.",
26
+ "type": "string",
27
+ "default": ""
28
+ },
29
+ "label": {
30
+ "description": "The field caption. Names the whole field for assistive tech.",
31
+ "type": "string",
32
+ "default": ""
33
+ },
34
+ "value": {
35
+ "description": "The static value text to display (e.g. \"•••• 4242\"). Ignored when the default slot already carries content — slot a foreign element OR set value=, not both.",
36
+ "type": "string",
37
+ "default": ""
38
+ },
39
+ "variant": {
40
+ "description": "Cosmetic value treatment. `default` renders the value in the UI font; `masked` switches to the code/mono family with tabular numerals and light letter-spacing so masked strings (\"•••• 4242\") align cleanly. Tokens only — no layout change.",
41
+ "type": "string",
42
+ "enum": [
43
+ "default",
44
+ "masked"
45
+ ],
46
+ "default": "default"
47
+ }
48
+ },
49
+ "required": [
50
+ "component",
51
+ "label"
52
+ ],
53
+ "unevaluatedProperties": false,
54
+ "x-adiaui": {
55
+ "anti_patterns": [
56
+ {
57
+ "description": "Using input-ui[readonly] to render a static masked value. A readonly input still carries contenteditable/focus semantics and tab-stops; a display read-out should not be a control.",
58
+ "right": "<display-field-ui label=\"Card on file\" value=\"•••• 4242\" variant=\"masked\"></display-field-ui>\n",
59
+ "rule": "A non-editable masked value is display-field-ui, not a readonly input.",
60
+ "wrong": "<input-ui readonly value=\"•••• 4242\"></input-ui>\n"
61
+ }
62
+ ],
63
+ "category": "form",
64
+ "composes": [
65
+ "icon-ui"
66
+ ],
67
+ "events": {},
68
+ "examples": [
69
+ {
70
+ "description": "A masked \"card on file\" read-out with a leading lock icon and an expiry hint.",
71
+ "a2ui": "[\n {\n \"id\": \"root\",\n \"component\": \"DisplayField\",\n \"label\": \"Card on file\",\n \"value\": \"•••• •••• •••• 4242\",\n \"variant\": \"masked\",\n \"icon\": \"lock\",\n \"hint\": \"Expires 12/26\"\n }\n]",
72
+ "name": "card-on-file"
73
+ },
74
+ {
75
+ "description": "A labeled slot hosting a third-party element (e.g. a Stripe Element mount).",
76
+ "a2ui": "[\n {\n \"id\": \"root\",\n \"component\": \"DisplayField\",\n \"label\": \"Card number\",\n \"children\": [\"mount\"]\n },\n { \"id\": \"mount\", \"component\": \"Text\", \"text\": \"[Stripe Element mounts here]\" }\n]",
77
+ "name": "foreign-element-slot"
78
+ }
79
+ ],
80
+ "keywords": [
81
+ "display field",
82
+ "readonly",
83
+ "read-only",
84
+ "masked",
85
+ "mask",
86
+ "static value",
87
+ "card on file",
88
+ "last four",
89
+ "value",
90
+ "readout",
91
+ "stripe element",
92
+ "iframe slot",
93
+ "non-editable"
94
+ ],
95
+ "name": "UIDisplayField",
96
+ "related": [
97
+ "field",
98
+ "input",
99
+ "stat",
100
+ "description-list"
101
+ ],
102
+ "slots": {
103
+ "default": {
104
+ "description": "The value content. Slot a third-party element here — a Stripe Element iframe mount, or rich markup — and it takes the value region under the label, replacing the [value] attribute. The host's role=\"group\" + aria-labelledby names it for assistive tech."
105
+ },
106
+ "hint": {
107
+ "description": "The hint caption below the value. Auto-stamped from the [hint] attribute; wired into aria-describedby."
108
+ },
109
+ "icon": {
110
+ "description": "The leading icon (a single icon-ui). Auto-stamped from the [icon] attribute."
111
+ },
112
+ "label": {
113
+ "description": "The caption element. Auto-stamped from the [label] attribute; slot your own [slot=\"label\"] child to override with richer markup. It is the group's accessible name (the aria-labelledby target)."
114
+ },
115
+ "value": {
116
+ "description": "The static value element. Auto-stamped from the [value] attribute; suppressed when the default slot carries foreign content."
117
+ }
118
+ },
119
+ "states": [
120
+ {
121
+ "description": "Default, non-interactive read-out.",
122
+ "name": "idle"
123
+ }
124
+ ],
125
+ "status": "stable",
126
+ "synonyms": {
127
+ "masked": [
128
+ "display field",
129
+ "field",
130
+ "value"
131
+ ],
132
+ "readout": [
133
+ "display field",
134
+ "stat",
135
+ "field"
136
+ ],
137
+ "value": [
138
+ "display field",
139
+ "field",
140
+ "stat"
141
+ ]
142
+ },
143
+ "tag": "display-field-ui",
144
+ "tokens": {
145
+ "--display-field-gap": {
146
+ "description": "Vertical gap between the label, value, and hint rows."
147
+ },
148
+ "--display-field-hint-color": {
149
+ "description": "Hint foreground color."
150
+ },
151
+ "--display-field-hint-size": {
152
+ "description": "Hint font size."
153
+ },
154
+ "--display-field-icon-gap": {
155
+ "description": "Horizontal gap between the leading icon and the value."
156
+ },
157
+ "--display-field-label-color": {
158
+ "description": "Label foreground color."
159
+ },
160
+ "--display-field-label-size": {
161
+ "description": "Label font size."
162
+ },
163
+ "--display-field-label-weight": {
164
+ "description": "Label font weight."
165
+ },
166
+ "--display-field-masked-font": {
167
+ "description": "Font family applied to a masked value."
168
+ },
169
+ "--display-field-masked-tracking": {
170
+ "description": "Letter-spacing applied to a masked value."
171
+ },
172
+ "--display-field-value-color": {
173
+ "description": "Value foreground color."
174
+ },
175
+ "--display-field-value-size": {
176
+ "description": "Value font size."
177
+ },
178
+ "--display-field-value-weight": {
179
+ "description": "Value font weight."
180
+ }
181
+ },
182
+ "traits": [],
183
+ "version": 1
184
+ }
185
+ }
@@ -0,0 +1,91 @@
1
+ @scope (display-field-ui) {
2
+ :where(:scope) {
3
+ /* ── Tokens ── (label chrome mirrors field-ui so a display value reads
4
+ as a sibling of an editable field) */
5
+ --display-field-gap: var(--a-space-1);
6
+ --display-field-icon-gap: var(--a-space-2);
7
+ --display-field-label-color: var(--a-fg);
8
+ --display-field-label-size: var(--a-ui-sm);
9
+ --display-field-label-weight: var(--a-weight-medium);
10
+ --display-field-value-color: var(--a-fg);
11
+ --display-field-value-size: var(--a-ui-md);
12
+ --display-field-value-weight: var(--a-weight-regular);
13
+ --display-field-hint-color: var(--a-fg-muted);
14
+ --display-field-hint-size: var(--a-ui-sm);
15
+ --display-field-masked-font: var(--a-font-family-code);
16
+ --display-field-masked-tracking: 0.06em;
17
+ }
18
+
19
+ /* ── Base — single grid, rows placed by [slot] named areas. No DOM
20
+ reparenting; the value area accepts the auto [slot="value"] element
21
+ (from the `value` attr) AND any unnamed foreign child (a slotted
22
+ iframe mount). ── */
23
+ :scope {
24
+ box-sizing: border-box;
25
+ display: grid;
26
+ grid-template-columns: minmax(0, 1fr);
27
+ grid-template-areas:
28
+ "label"
29
+ "value"
30
+ "hint";
31
+ row-gap: var(--display-field-gap);
32
+ min-width: 0;
33
+ }
34
+
35
+ /* With a leading icon → two columns; the icon spans the value row only,
36
+ label + hint stay full-width. The column is gated on a present icon so
37
+ no empty track leaks a column-gap when there's no icon. */
38
+ :scope:has(> [slot="icon"]) {
39
+ grid-template-columns: auto minmax(0, 1fr);
40
+ grid-template-areas:
41
+ "label label"
42
+ "icon value"
43
+ "hint hint";
44
+ column-gap: var(--display-field-icon-gap);
45
+ }
46
+
47
+ /* ── Slot styling ── */
48
+ :scope > [slot="label"] {
49
+ grid-area: label;
50
+ color: var(--display-field-label-color);
51
+ font-size: var(--display-field-label-size);
52
+ font-weight: var(--display-field-label-weight);
53
+ line-height: 1.3;
54
+ min-width: 0;
55
+ }
56
+
57
+ :scope > [slot="icon"] {
58
+ grid-area: icon;
59
+ align-self: center;
60
+ color: var(--a-fg-muted);
61
+ }
62
+
63
+ /* The value area: the auto attr element + any foreign unnamed child. */
64
+ :scope > [slot="value"],
65
+ :scope > :not([slot]) {
66
+ grid-area: value;
67
+ color: var(--display-field-value-color);
68
+ font-size: var(--display-field-value-size);
69
+ font-weight: var(--display-field-value-weight);
70
+ line-height: 1.3;
71
+ min-width: 0;
72
+ }
73
+
74
+ :scope > [slot="hint"] {
75
+ grid-area: hint;
76
+ color: var(--display-field-hint-color);
77
+ font-size: var(--display-field-hint-size);
78
+ line-height: 1.3;
79
+ min-width: 0;
80
+ }
81
+
82
+ /* ── Variant: masked — code/mono family + tabular numerals + tracking so
83
+ bullet-and-digit masked strings ("•••• 4242") align cleanly. Cosmetic
84
+ only; no layout change. ── */
85
+ :scope[variant="masked"] > [slot="value"],
86
+ :scope[variant="masked"] > :not([slot]) {
87
+ font-family: var(--display-field-masked-font);
88
+ font-variant-numeric: tabular-nums;
89
+ letter-spacing: var(--display-field-masked-tracking);
90
+ }
91
+ }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * `<display-field-ui>` — Display-only labeled value — a field-shaped read-out for a static or masked value (e.g. a card on file "•••• 4242"), or a labeled slot that hosts a third-party element (a Stripe Element iframe) under a proper caption. Unlike field-ui it wraps NO interactive control: no focus, no contenteditable, no form participation, no [for]/id-minting. The label names the whole field for assistive tech via role="group" + aria-labelledby, so a slotted foreign element (iframe) is announced with its caption. Reach for field-ui + input-ui when the value is EDITABLE; stat-ui for a prominent metric/KPI; description-list-ui for many read-only key/value pairs at once.
3
+ *
4
+ * @see https://ui-kit.exe.xyz/site/components/display-field
5
+ *
6
+ * Type declarations generated by scripts/build/dts-codegen.mjs from
7
+ * the component's `.a2ui.json` sidecar(s). Edit the source `.yaml`,
8
+ * run `npm run build:components`, then `npm run codegen:dts` to
9
+ * regenerate; or hand-author this file fully if rich event types are
10
+ * needed beyond what the yaml `events:` block can express.
11
+ */
12
+
13
+ import { UIElement } from '../../core/element.js';
14
+
15
+ export class UIDisplayField extends UIElement {
16
+ /** Optional caption rendered below the value in subtle style (e.g. "Expires 12/26"). Wired into the field's aria-describedby. */
17
+ hint: string;
18
+ /** Optional leading icon name shown beside the value (e.g. a lock or card-brand glyph). Lazily creates a single icon-ui child. */
19
+ icon: string;
20
+ /** The field caption. Names the whole field for assistive tech. */
21
+ label: string;
22
+ /** The static value text to display (e.g. "•••• 4242"). Ignored when the default slot already carries content — slot a foreign element OR set value=, not both. */
23
+ value: string;
24
+ /** Cosmetic value treatment. `default` renders the value in the UI font; `masked` switches to the code/mono family with tabular numerals and light letter-spacing so masked strings ("•••• 4242") align cleanly. Tokens only — no layout change. */
25
+ variant: 'default' | 'masked';
26
+ }
@@ -0,0 +1,44 @@
1
+ # display-field — Examples
2
+
3
+ A display-only labeled value. No input semantics (no focus, no
4
+ contenteditable, no form participation). For an editable field use
5
+ `field-ui` + `input-ui`; for a metric use `stat-ui`; for many read-only
6
+ pairs use `description-list-ui`.
7
+
8
+ ## Basic
9
+
10
+ ```html
11
+ <display-field-ui label="Account ID" value="acct_1Q9xK2"></display-field-ui>
12
+ ```
13
+
14
+ ## Masked value
15
+
16
+ ```html
17
+ <display-field-ui
18
+ label="Card on file"
19
+ value="•••• •••• •••• 4242"
20
+ variant="masked"></display-field-ui>
21
+ ```
22
+
23
+ ## With a leading icon and a hint
24
+
25
+ ```html
26
+ <display-field-ui
27
+ label="Card on file"
28
+ value="•••• 4242"
29
+ variant="masked"
30
+ icon="lock"
31
+ hint="Expires 12/26"></display-field-ui>
32
+ ```
33
+
34
+ ## Hosting a third-party element (Stripe Element)
35
+
36
+ Slot the foreign element as the default child; it takes the value region
37
+ under the label. The host's `role="group"` + `aria-labelledby` names it for
38
+ assistive tech.
39
+
40
+ ```html
41
+ <display-field-ui label="Card number">
42
+ <div id="card-element"><!-- stripe.elements().create('card').mount('#card-element') --></div>
43
+ </display-field-ui>
44
+ ```
@@ -0,0 +1,141 @@
1
+ /**
2
+ * <display-field-ui label="Card on file" value="•••• 4242" variant="masked" icon="lock" hint="Expires 12/26"></display-field-ui>
3
+ *
4
+ * Display-only labeled value — a field-shaped read-out for a static or
5
+ * masked value, or a labeled slot that hosts a third-party element (a
6
+ * Stripe Element iframe) under a proper caption.
7
+ *
8
+ * Unlike <field-ui> it wraps NO interactive control: no focus, no
9
+ * contenteditable, no form participation, no [for]/id-minting. The label
10
+ * names the whole field for assistive tech via role="group" +
11
+ * aria-labelledby, so a slotted foreign element is announced with its
12
+ * caption.
13
+ *
14
+ * Value comes from EITHER the `value` attribute OR unnamed default-slot
15
+ * children (the foreign-element path) — slot content wins when both are
16
+ * present.
17
+ */
18
+
19
+ import { UIElement } from '../../core/element.js';
20
+ import { defineIfFree } from '../../core/register.js';
21
+
22
+ // Monotonic id source for label/hint association (aria-labelledby /
23
+ // aria-describedby need stable ids; multiple instances must not collide).
24
+ let uid = 0;
25
+
26
+ class UIDisplayField extends UIElement {
27
+ static properties = {
28
+ label: { type: String, default: '', reflect: true },
29
+ value: { type: String, default: '', reflect: true },
30
+ variant: { type: String, default: 'default', reflect: true },
31
+ hint: { type: String, default: '', reflect: true },
32
+ icon: { type: String, default: '', reflect: true },
33
+ };
34
+
35
+ static template = () => null;
36
+
37
+ #labelEl = null;
38
+ #valueEl = null;
39
+ #hintEl = null;
40
+ #iconEl = null;
41
+
42
+ connected() {
43
+ // A non-interactive labeled group — the label is the accessible name,
44
+ // which holds even when the value region is a foreign iframe.
45
+ this.setAttribute('role', 'group');
46
+
47
+ // The label element is the group's accessible-name source; create it
48
+ // once and mint a stable id for aria-labelledby. Prepend so it reads
49
+ // first for assistive tech (visual placement is grid-area driven).
50
+ this.#labelEl = this.querySelector(':scope > [slot="label"]');
51
+ if (!this.#labelEl) {
52
+ this.#labelEl = document.createElement('span');
53
+ this.#labelEl.setAttribute('slot', 'label');
54
+ this.insertBefore(this.#labelEl, this.firstChild);
55
+ }
56
+ if (!this.#labelEl.id) this.#labelEl.id = `display-field-label-${++uid}`;
57
+
58
+ // Re-bind any optional slot children that already exist in the light DOM.
59
+ // On a DOM move (disconnect → reconnect — e.g. UIElement.reconcile's
60
+ // insertBefore during a keyed list reorder) disconnected() nulled these
61
+ // fields but left the nodes in place; without re-binding, render() would
62
+ // see the null field and stamp a DUPLICATE. Query-or-leave-null mirrors
63
+ // the label handling above.
64
+ this.#valueEl = this.querySelector(':scope > [slot="value"]');
65
+ this.#hintEl = this.querySelector(':scope > [slot="hint"]');
66
+ this.#iconEl = this.querySelector(':scope > icon-ui[slot="icon"]');
67
+ }
68
+
69
+ render() {
70
+ if (!this.#labelEl) return;
71
+
72
+ // ── Label — the group's accessible name. ──
73
+ if (this.label) {
74
+ this.#labelEl.textContent = this.label;
75
+ this.#labelEl.hidden = false;
76
+ this.setAttribute('aria-labelledby', this.#labelEl.id);
77
+ } else {
78
+ this.#labelEl.hidden = true;
79
+ this.removeAttribute('aria-labelledby');
80
+ }
81
+
82
+ // ── Value — attribute path. Suppressed when the consumer slotted
83
+ // foreign content (a Stripe mount, rich markup): that content owns
84
+ // the value region. ──
85
+ const foreign = this.querySelector(':scope > :not([slot])');
86
+ if (foreign) {
87
+ if (this.#valueEl) { this.#valueEl.remove(); this.#valueEl = null; }
88
+ } else {
89
+ if (!this.#valueEl) {
90
+ this.#valueEl = document.createElement('span');
91
+ this.#valueEl.setAttribute('slot', 'value');
92
+ this.appendChild(this.#valueEl);
93
+ }
94
+ this.#valueEl.textContent = this.value;
95
+ }
96
+
97
+ // ── Hint — optional caption; drives aria-describedby. Removed when
98
+ // absent so no empty grid row leaks a gap. ──
99
+ if (this.hint) {
100
+ if (!this.#hintEl) {
101
+ this.#hintEl = document.createElement('span');
102
+ this.#hintEl.setAttribute('slot', 'hint');
103
+ this.#hintEl.id = `display-field-hint-${++uid}`;
104
+ this.appendChild(this.#hintEl);
105
+ }
106
+ this.#hintEl.textContent = this.hint;
107
+ this.setAttribute('aria-describedby', this.#hintEl.id);
108
+ } else if (this.#hintEl) {
109
+ this.#hintEl.remove();
110
+ this.#hintEl = null;
111
+ this.removeAttribute('aria-describedby');
112
+ }
113
+
114
+ // ── Icon — optional leading glyph. Removed when absent so the icon
115
+ // column collapses (the CSS gates the two-column layout on a
116
+ // present [slot="icon"] child). ──
117
+ if (this.icon) {
118
+ if (!this.#iconEl) {
119
+ this.#iconEl = document.createElement('icon-ui');
120
+ this.#iconEl.setAttribute('slot', 'icon');
121
+ this.#iconEl.setAttribute('aria-hidden', 'true');
122
+ this.appendChild(this.#iconEl);
123
+ }
124
+ this.#iconEl.setAttribute('name', this.icon);
125
+ } else if (this.#iconEl) {
126
+ this.#iconEl.remove();
127
+ this.#iconEl = null;
128
+ }
129
+ }
130
+
131
+ disconnected() {
132
+ this.#labelEl = null;
133
+ this.#valueEl = null;
134
+ this.#hintEl = null;
135
+ this.#iconEl = null;
136
+ }
137
+ }
138
+
139
+ defineIfFree('display-field-ui', UIDisplayField);
140
+
141
+ export { UIDisplayField };