@adia-ai/web-components 0.7.22 → 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.
@@ -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 };
@@ -0,0 +1,115 @@
1
+ /**
2
+ * display-field-ui — display-only labeled value (upstream finding F5).
3
+ *
4
+ * Covers the contract that makes it NOT a control: group-label a11y, the
5
+ * dual value path (attr vs foreign slot), and the optional icon/hint that
6
+ * are removed (not just hidden) when their attribute clears.
7
+ */
8
+ import { describe, it, expect, beforeEach } from 'vitest';
9
+ import '../../core/element.js';
10
+ import './display-field.js';
11
+ import '../icon/icon.js';
12
+
13
+ const raf = () => new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)));
14
+
15
+ function mount(html) {
16
+ const wrap = document.createElement('div');
17
+ wrap.innerHTML = html;
18
+ document.body.appendChild(wrap);
19
+ return wrap.firstElementChild;
20
+ }
21
+
22
+ describe('display-field-ui', () => {
23
+ beforeEach(() => { document.body.innerHTML = ''; });
24
+
25
+ it('renders as a labeled group: role=group + aria-labelledby → the label element', async () => {
26
+ const el = mount('<display-field-ui label="Account ID" value="acct_1Q9xK2"></display-field-ui>');
27
+ await raf();
28
+ expect(el.getAttribute('role')).toBe('group');
29
+ const labelEl = el.querySelector(':scope > [slot="label"]');
30
+ expect(labelEl).not.toBeNull();
31
+ expect(labelEl.textContent).toBe('Account ID');
32
+ expect(labelEl.id).toBeTruthy();
33
+ expect(el.getAttribute('aria-labelledby')).toBe(labelEl.id);
34
+ });
35
+
36
+ it('carries no input/focus/form semantics (no contenteditable, not focusable, no tabindex)', async () => {
37
+ const el = mount('<display-field-ui label="Card" value="•••• 4242"></display-field-ui>');
38
+ await raf();
39
+ expect(el.getAttribute('contenteditable')).toBeNull();
40
+ expect(el.getAttribute('tabindex')).toBeNull();
41
+ const valueEl = el.querySelector(':scope > [slot="value"]');
42
+ expect(valueEl.getAttribute('contenteditable')).toBeNull();
43
+ expect(valueEl.textContent).toBe('•••• 4242');
44
+ });
45
+
46
+ it('variant="masked" reflects and the value text is preserved', async () => {
47
+ const el = mount('<display-field-ui label="Card" value="•••• 4242" variant="masked"></display-field-ui>');
48
+ await raf();
49
+ expect(el.getAttribute('variant')).toBe('masked');
50
+ expect(el.querySelector(':scope > [slot="value"]').textContent).toBe('•••• 4242');
51
+ });
52
+
53
+ it('a foreign default-slot child owns the value region — the attr value element is suppressed', async () => {
54
+ const el = mount('<display-field-ui label="Card number"><div id="mount">[stripe]</div></display-field-ui>');
55
+ await raf();
56
+ // No auto [slot="value"] element — the foreign child is the value.
57
+ expect(el.querySelector(':scope > [slot="value"]')).toBeNull();
58
+ const foreign = el.querySelector(':scope > #mount');
59
+ expect(foreign).not.toBeNull();
60
+ expect(foreign.getAttribute('slot')).toBeNull(); // genuinely unnamed
61
+ });
62
+
63
+ it('icon attribute creates a leading icon-ui; clearing it removes the element (no phantom column)', async () => {
64
+ const el = mount('<display-field-ui label="Card" value="•••• 4242" icon="lock"></display-field-ui>');
65
+ await raf();
66
+ const iconEl = el.querySelector(':scope > icon-ui[slot="icon"]');
67
+ expect(iconEl).not.toBeNull();
68
+ expect(iconEl.getAttribute('name')).toBe('lock');
69
+
70
+ el.icon = '';
71
+ await raf();
72
+ expect(el.querySelector(':scope > [slot="icon"]')).toBeNull();
73
+ });
74
+
75
+ it('hint attribute creates a hint element + aria-describedby; clearing it removes both', async () => {
76
+ const el = mount('<display-field-ui label="Card" value="•••• 4242" hint="Expires 12/26"></display-field-ui>');
77
+ await raf();
78
+ const hintEl = el.querySelector(':scope > [slot="hint"]');
79
+ expect(hintEl).not.toBeNull();
80
+ expect(hintEl.textContent).toBe('Expires 12/26');
81
+ expect(el.getAttribute('aria-describedby')).toBe(hintEl.id);
82
+
83
+ el.hint = '';
84
+ await raf();
85
+ expect(el.querySelector(':scope > [slot="hint"]')).toBeNull();
86
+ expect(el.getAttribute('aria-describedby')).toBeNull();
87
+ });
88
+
89
+ it('surviving a DOM move (disconnect→reconnect) does NOT duplicate slot children', async () => {
90
+ const host1 = mount('<div></div>');
91
+ const el = document.createElement('display-field-ui');
92
+ el.setAttribute('label', 'Card');
93
+ el.setAttribute('value', '•••• 4242');
94
+ el.setAttribute('hint', 'Expires 12/26');
95
+ el.setAttribute('icon', 'lock');
96
+ host1.appendChild(el);
97
+ await raf();
98
+ expect(el.querySelectorAll(':scope > [slot="value"]').length).toBe(1);
99
+ expect(el.querySelectorAll(':scope > [slot="hint"]').length).toBe(1);
100
+ expect(el.querySelectorAll(':scope > icon-ui[slot="icon"]').length).toBe(1);
101
+
102
+ // Move to a new parent — fires disconnectedCallback then connectedCallback,
103
+ // exactly what UIElement.reconcile's insertBefore does on a keyed reorder.
104
+ const host2 = mount('<div></div>');
105
+ host2.appendChild(el);
106
+ await raf();
107
+
108
+ // No duplicates after the move — connected() re-binds the existing nodes.
109
+ expect(el.querySelectorAll(':scope > [slot="label"]').length).toBe(1);
110
+ expect(el.querySelectorAll(':scope > [slot="value"]').length).toBe(1);
111
+ expect(el.querySelectorAll(':scope > [slot="hint"]').length).toBe(1);
112
+ expect(el.querySelectorAll(':scope > icon-ui[slot="icon"]').length).toBe(1);
113
+ expect(el.querySelector(':scope > [slot="value"]').textContent).toBe('•••• 4242');
114
+ });
115
+ });
@@ -0,0 +1,191 @@
1
+ # Edit this file; run `npm run build:components` to regenerate a2ui.json.
2
+ $schema: ../../../../scripts/schemas/component.yaml.schema.json
3
+ name: UIDisplayField
4
+ tag: display-field-ui
5
+ status: stable
6
+ composes:
7
+ - icon-ui
8
+ component: DisplayField
9
+ category: form
10
+ version: 1
11
+ description: >-
12
+ Display-only labeled value — a field-shaped read-out for a static or
13
+ masked value (e.g. a card on file "•••• 4242"), or a labeled slot that
14
+ hosts a third-party element (a Stripe Element iframe) under a proper
15
+ caption. Unlike field-ui it wraps NO interactive control: no focus, no
16
+ contenteditable, no form participation, no [for]/id-minting. The label
17
+ names the whole field for assistive tech via role="group" +
18
+ aria-labelledby, so a slotted foreign element (iframe) is announced
19
+ with its caption. Reach for field-ui + input-ui when the value is
20
+ EDITABLE; stat-ui for a prominent metric/KPI; description-list-ui for
21
+ many read-only key/value pairs at once.
22
+ props:
23
+ label:
24
+ description: The field caption. Names the whole field for assistive tech.
25
+ required: true
26
+ type: string
27
+ default: ""
28
+ reflect: true
29
+ value:
30
+ description: >-
31
+ The static value text to display (e.g. "•••• 4242"). Ignored when
32
+ the default slot already carries content — slot a foreign element
33
+ OR set value=, not both.
34
+ type: string
35
+ default: ""
36
+ reflect: true
37
+ variant:
38
+ description: >-
39
+ Cosmetic value treatment. `default` renders the value in the UI
40
+ font; `masked` switches to the code/mono family with tabular
41
+ numerals and light letter-spacing so masked strings ("•••• 4242")
42
+ align cleanly. Tokens only — no layout change.
43
+ type: string
44
+ default: default
45
+ enum: [default, masked]
46
+ reflect: true
47
+ hint:
48
+ description: >-
49
+ Optional caption rendered below the value in subtle style (e.g.
50
+ "Expires 12/26"). Wired into the field's aria-describedby.
51
+ type: string
52
+ default: ""
53
+ reflect: true
54
+ icon:
55
+ description: >-
56
+ Optional leading icon name shown beside the value (e.g. a lock or
57
+ card-brand glyph). Lazily creates a single icon-ui child.
58
+ type: string
59
+ default: ""
60
+ reflect: true
61
+ events: {}
62
+ slots:
63
+ default:
64
+ description: >-
65
+ The value content. Slot a third-party element here — a Stripe
66
+ Element iframe mount, or rich markup — and it takes the value
67
+ region under the label, replacing the [value] attribute. The
68
+ host's role="group" + aria-labelledby names it for assistive tech.
69
+ label:
70
+ description: >-
71
+ The caption element. Auto-stamped from the [label] attribute; slot
72
+ your own [slot="label"] child to override with richer markup. It is
73
+ the group's accessible name (the aria-labelledby target).
74
+ value:
75
+ description: >-
76
+ The static value element. Auto-stamped from the [value] attribute;
77
+ suppressed when the default slot carries foreign content.
78
+ hint:
79
+ description: >-
80
+ The hint caption below the value. Auto-stamped from the [hint]
81
+ attribute; wired into aria-describedby.
82
+ icon:
83
+ description: >-
84
+ The leading icon (a single icon-ui). Auto-stamped from the [icon]
85
+ attribute.
86
+ states:
87
+ - name: idle
88
+ description: Default, non-interactive read-out.
89
+ traits: []
90
+ tokens:
91
+ --display-field-gap:
92
+ description: Vertical gap between the label, value, and hint rows.
93
+ --display-field-icon-gap:
94
+ description: Horizontal gap between the leading icon and the value.
95
+ --display-field-label-color:
96
+ description: Label foreground color.
97
+ --display-field-label-size:
98
+ description: Label font size.
99
+ --display-field-label-weight:
100
+ description: Label font weight.
101
+ --display-field-value-color:
102
+ description: Value foreground color.
103
+ --display-field-value-size:
104
+ description: Value font size.
105
+ --display-field-value-weight:
106
+ description: Value font weight.
107
+ --display-field-hint-color:
108
+ description: Hint foreground color.
109
+ --display-field-hint-size:
110
+ description: Hint font size.
111
+ --display-field-masked-font:
112
+ description: Font family applied to a masked value.
113
+ --display-field-masked-tracking:
114
+ description: Letter-spacing applied to a masked value.
115
+ a2ui:
116
+ rules:
117
+ - rule: "Use for a display-ONLY labeled value — a masked card number, a 'card on file' read-out, an account id — anywhere a value reads as a field but must not be editable."
118
+ reason: "field-ui wraps an editable control; display-field-ui carries no input/focus/form semantics."
119
+ - rule: "To host a third-party element (a Stripe Element iframe) under a label, slot it as the default child instead of setting value=. The label names it via role=group + aria-labelledby."
120
+ reason: "field-ui auto-mints id/[for] on a real control; a foreign iframe is not labelable, so group-labelling is the correct a11y path."
121
+ - rule: "For an editable field use field-ui + input-ui; for a prominent KPI use stat-ui; for many read-only pairs at once use description-list-ui."
122
+ reason: "Distinct roles — don't reach for display-field-ui when the value is editable or when it's a metric."
123
+ anti_patterns:
124
+ - description: >-
125
+ Using input-ui[readonly] to render a static masked value. A
126
+ readonly input still carries contenteditable/focus semantics and
127
+ tab-stops; a display read-out should not be a control.
128
+ wrong: |
129
+ <input-ui readonly value="•••• 4242"></input-ui>
130
+ right: |
131
+ <display-field-ui label="Card on file" value="•••• 4242" variant="masked"></display-field-ui>
132
+ rule: A non-editable masked value is display-field-ui, not a readonly input.
133
+ examples:
134
+ - name: card-on-file
135
+ description: A masked "card on file" read-out with a leading lock icon and an expiry hint.
136
+ a2ui: >-
137
+ [
138
+ {
139
+ "id": "root",
140
+ "component": "DisplayField",
141
+ "label": "Card on file",
142
+ "value": "•••• •••• •••• 4242",
143
+ "variant": "masked",
144
+ "icon": "lock",
145
+ "hint": "Expires 12/26"
146
+ }
147
+ ]
148
+ - name: foreign-element-slot
149
+ description: A labeled slot hosting a third-party element (e.g. a Stripe Element mount).
150
+ a2ui: >-
151
+ [
152
+ {
153
+ "id": "root",
154
+ "component": "DisplayField",
155
+ "label": "Card number",
156
+ "children": ["mount"]
157
+ },
158
+ { "id": "mount", "component": "Text", "text": "[Stripe Element mounts here]" }
159
+ ]
160
+ keywords:
161
+ - display field
162
+ - readonly
163
+ - read-only
164
+ - masked
165
+ - mask
166
+ - static value
167
+ - card on file
168
+ - last four
169
+ - value
170
+ - readout
171
+ - stripe element
172
+ - iframe slot
173
+ - non-editable
174
+ synonyms:
175
+ masked:
176
+ - display field
177
+ - field
178
+ - value
179
+ readout:
180
+ - display field
181
+ - stat
182
+ - field
183
+ value:
184
+ - display field
185
+ - field
186
+ - stat
187
+ related:
188
+ - field
189
+ - input
190
+ - stat
191
+ - description-list
@@ -12,7 +12,35 @@
12
12
  */
13
13
 
14
14
  import { UIElement } from '../../core/element.js';
15
- import { getIcon, whenIconRegistryReady } from '../../core/icons.js';
15
+ import { getIcon, whenIconRegistryReady, iconRegistryUnwired } from '../../core/icons.js';
16
+
17
+ // Upstream finding F1: the zero-config Phosphor glob path can leave the icon
18
+ // registry completely unwired under Vite 8 / rolldown (the `import.meta.glob`
19
+ // macro doesn't transform from inside a published dependency), so every
20
+ // `<icon-ui>` renders blank. The loader layer warns, but the per-icon miss is
21
+ // silent (`getIcon → ''`). Fail loud ONCE from the component too, with the
22
+ // actionable fix. Deferred a macrotask so an app that calls
23
+ // `installIconLoaders()` at entry (the correct path) is never false-flagged.
24
+ let _unwiredCheckScheduled = false;
25
+ function warnIfIconRegistryUnwired() {
26
+ if (_unwiredCheckScheduled) return;
27
+ _unwiredCheckScheduled = true;
28
+ setTimeout(() => {
29
+ if (!iconRegistryUnwired()) return; // loaders arrived after all — fine
30
+ // eslint-disable-next-line no-console
31
+ console.error(
32
+ `[icon-ui] No icons are registered and no icon loaders were installed — every <icon-ui> renders blank.\n` +
33
+ ` The zero-config import.meta.glob path does not work from inside a published dependency under Vite 8 / rolldown.\n` +
34
+ ` Fix — from your APP entry (where import.meta.glob is transformed):\n` +
35
+ ` npm i @phosphor-icons/core\n` +
36
+ ` import { installIconLoaders } from '@adia-ai/web-components/core/icons';\n` +
37
+ ` installIconLoaders({ regular: import.meta.glob(\n` +
38
+ ` '/node_modules/@phosphor-icons/core/assets/regular/*.svg',\n` +
39
+ ` { query: '?raw', import: 'default', eager: true }) });\n` +
40
+ ` See USAGE.md § Icons.`,
41
+ );
42
+ }, 0);
43
+ }
16
44
 
17
45
  export class UIIcon extends UIElement {
18
46
  static properties = {
@@ -62,6 +90,11 @@ export class UIIcon extends UIElement {
62
90
  if (this.label) this.setAttribute('aria-label', this.label);
63
91
  const svg = getIcon(this.name, this.weight || 'regular');
64
92
  if (svg && this.innerHTML !== svg) this.innerHTML = svg;
93
+ // A named icon that resolved to nothing AND a never-wired registry is the
94
+ // F1 symptom — schedule the one-shot fail-loud. A plain typo (registry
95
+ // populated, this one name absent) settles `iconRegistryUnwired()` false
96
+ // and is correctly NOT flagged here.
97
+ else if (this.name && !svg) warnIfIconRegistryUnwired();
65
98
 
66
99
  // Pixel / rem / em passthrough — `size="48"` → 48px,
67
100
  // `size="3.5rem"` → 3.5rem. Named-scale values (xs/sm/md/lg/xl/
@@ -71,6 +71,7 @@ export { UISwatch } from './swatch/swatch.js';
71
71
  export { UICol } from './col/col.js';
72
72
  export { UIField } from './field/field.js';
73
73
  export { UIFields } from './fields/fields.js';
74
+ export { UIDisplayField } from './display-field/display-field.js';
74
75
  export { UIRow } from './row/row.js';
75
76
  export { UIGrid } from './grid/grid.js';
76
77
  export { UIStack } from './stack/stack.js';
@@ -240,6 +240,10 @@ export class UITable extends UIElement {
240
240
  // ── Public API: expansion ──
241
241
 
242
242
  toggleExpand(index) {
243
+ // A row gated out by `rowExpandable` cannot be toggled — but always allow
244
+ // collapsing one that is already open (e.g. the predicate flipped while a
245
+ // detail pane was showing), so it can't get stuck expanded.
246
+ if (!this.#isRowExpandable(index) && !this.#expanded.has(index)) return;
243
247
  if (this.#expanded.has(index)) {
244
248
  this.#expanded.delete(index);
245
249
  this.dispatchEvent(new CustomEvent('row-collapse', { detail: { index, row: this.#data[index] }, bubbles: true }));
@@ -255,6 +259,27 @@ export class UITable extends UIElement {
255
259
  /** @type {((row: object, index: number) => HTMLElement)|null} */
256
260
  expandRenderer = null;
257
261
 
262
+ /**
263
+ * Per-row expand gate. When `expandable` is on and this is set, a row shows
264
+ * the expand caret (and can open a detail pane) only when the predicate
265
+ * returns truthy for that row. Lets heterogeneous rows opt out of the
266
+ * uniform affordance — e.g. `rowExpandable: r => r.status === 'failed'` so a
267
+ * succeeded row carries no caret while a failed one does. The reserved
268
+ * expand column is kept either way, so column alignment never shifts.
269
+ * `null` (default) = every row is expandable (legacy behavior).
270
+ * @type {((row: object, index: number) => boolean)|null}
271
+ */
272
+ rowExpandable = null;
273
+
274
+ /** True when `expandable` is on AND this row passes the per-row gate. */
275
+ #isRowExpandable(dataIndex) {
276
+ if (!this.expandable) return false;
277
+ if (typeof this.rowExpandable === 'function') {
278
+ return !!this.rowExpandable(this.#data[dataIndex], dataIndex);
279
+ }
280
+ return true;
281
+ }
282
+
258
283
  // ── Public API: state persistence ──
259
284
 
260
285
  getState() {
@@ -601,16 +626,18 @@ export class UITable extends UIElement {
601
626
  if (existing) this.#updateRow(existing, idx, visCols);
602
627
 
603
628
  // Expand toggle in first cell
604
- if (this.expandable) {
629
+ if (this.#isRowExpandable(idx)) {
605
630
  const isExpanded = this.#expanded.has(idx);
606
631
  if (isExpanded) row.setAttribute('data-expanded', '');
607
632
  else row.removeAttribute('data-expanded');
633
+ } else {
634
+ row.removeAttribute('data-expanded');
608
635
  }
609
636
 
610
637
  bodyChildren.push(row);
611
638
 
612
639
  // Detail row (expansion)
613
- if (this.expandable && this.#expanded.has(idx)) {
640
+ if (this.#isRowExpandable(idx) && this.#expanded.has(idx)) {
614
641
  let detail = body.querySelector(`:scope > [data-detail-row][data-for="${idx}"]`);
615
642
  if (!detail) {
616
643
  detail = document.createElement('div');
@@ -779,19 +806,23 @@ export class UITable extends UIElement {
779
806
 
780
807
  const cells = [];
781
808
 
782
- // Expand toggle cell
809
+ // Expand toggle cell. The reserved column cell is always emitted (so the
810
+ // grid stays aligned), but the caret button is stamped only when this row
811
+ // passes the per-row expand gate — non-expandable rows show an empty cell.
783
812
  if (this.expandable) {
784
813
  const cell = document.createElement('div');
785
814
  cell.setAttribute('role', 'gridcell');
786
815
  cell.setAttribute('data-expand-col', '');
787
- const btn = document.createElement('button');
788
- btn.setAttribute('data-expand-toggle', '');
789
- btn.setAttribute('aria-label', 'Expand row');
790
- const icon = document.createElement('icon-ui');
791
- icon.setAttribute('name', 'caret-right');
792
- icon.setAttribute('size', 'xs');
793
- btn.appendChild(icon);
794
- cell.appendChild(btn);
816
+ if (this.#isRowExpandable(dataIndex)) {
817
+ const btn = document.createElement('button');
818
+ btn.setAttribute('data-expand-toggle', '');
819
+ btn.setAttribute('aria-label', 'Expand row');
820
+ const icon = document.createElement('icon-ui');
821
+ icon.setAttribute('name', 'caret-right');
822
+ icon.setAttribute('size', 'xs');
823
+ btn.appendChild(icon);
824
+ cell.appendChild(btn);
825
+ }
795
826
  cells.push(cell);
796
827
  }
797
828
 
@@ -336,3 +336,56 @@ describe('table-ui — raw mode passthrough (FB-53 §2)', () => {
336
336
  expect(el.querySelector(':scope > [data-body]')).not.toBeNull();
337
337
  });
338
338
  });
339
+
340
+ // ── F6 (upstream): per-row expand gate via `rowExpandable` ───────────────────
341
+ describe('table-ui — F6 per-row expand suppression (rowExpandable)', () => {
342
+ beforeEach(() => { document.body.innerHTML = ''; });
343
+
344
+ const bodyRows = (el) =>
345
+ el.querySelectorAll(':scope > [data-body] > [role="row"]:not([data-detail-row])');
346
+
347
+ it('default (no predicate): every row carries an expand caret', async () => {
348
+ const el = mount('<table-ui expandable></table-ui>');
349
+ el.columns = COLS;
350
+ el.data = ROWS;
351
+ await raf();
352
+ const rows = bodyRows(el);
353
+ expect(rows.length).toBe(2);
354
+ for (const row of rows) {
355
+ expect(row.querySelector('[data-expand-toggle]')).not.toBeNull();
356
+ }
357
+ });
358
+
359
+ it('predicate suppresses the caret on gated rows but keeps the reserved cell', async () => {
360
+ const el = mount('<table-ui expandable></table-ui>');
361
+ el.columns = COLS;
362
+ el.data = ROWS;
363
+ el.rowExpandable = (row) => row.id === 2; // only Bob expandable
364
+ await raf();
365
+ const rows = bodyRows(el);
366
+
367
+ // Alice (id 1) — reserved cell present, NO caret button.
368
+ expect(rows[0].querySelector(':scope > [data-expand-col]')).not.toBeNull();
369
+ expect(rows[0].querySelector('[data-expand-toggle]')).toBeNull();
370
+
371
+ // Bob (id 2) — caret button present.
372
+ expect(rows[1].querySelector('[data-expand-toggle]')).not.toBeNull();
373
+ });
374
+
375
+ it('toggleExpand is a no-op on a gated row (no detail row renders)', async () => {
376
+ const el = mount('<table-ui expandable></table-ui>');
377
+ el.columns = COLS;
378
+ el.data = ROWS;
379
+ el.rowExpandable = (row) => row.id === 2;
380
+ await raf();
381
+
382
+ el.toggleExpand(0); // Alice — gated out
383
+ await raf();
384
+ expect(el.querySelector('[data-detail-row][data-for="0"]')).toBeNull();
385
+ expect(el.expanded).not.toContain(0);
386
+
387
+ el.toggleExpand(1); // Bob — allowed
388
+ await raf();
389
+ expect(el.expanded).toContain(1);
390
+ });
391
+ });
package/core/icons.js CHANGED
@@ -135,6 +135,20 @@ let registryReady = false;
135
135
  let resolveRegistryReady;
136
136
  export const whenIconRegistryReady = new Promise((r) => { resolveRegistryReady = r; });
137
137
 
138
+ /**
139
+ * True when NO icons are registered AND no loaders were ever installed — i.e.
140
+ * the icon subsystem was never wired. The usual cause: the zero-config
141
+ * `icons-phosphor.js` `import.meta.glob('/node_modules/...')` path is a
142
+ * build-time macro that does not transform from inside a published dependency
143
+ * under Vite 8 / rolldown, so `installIconLoaders` never ran and the registry
144
+ * stays empty. Distinct from a single bad icon name (registry non-empty, that
145
+ * one name absent). `<icon-ui>` uses this to fail loud once rather than render
146
+ * blank silently. See `USAGE.md` § Icons.
147
+ */
148
+ export function iconRegistryUnwired() {
149
+ return registry.size === 0 && !registryReady;
150
+ }
151
+
138
152
  /**
139
153
  * Install a per-weight loader map and mark the registry ready. The map
140
154
  * shape matches `import.meta.glob` output: `{ '/path/to/star.svg': loader }`.