@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.
- package/CHANGELOG.md +23 -0
- package/USAGE.md +56 -11
- package/components/accordion/accordion.css +5 -7
- package/components/button/button.a2ui.json +5 -0
- package/components/button/button.class.js +21 -1
- package/components/button/button.css +12 -6
- package/components/button/button.yaml +8 -0
- package/components/display-field/display-field.a2ui.json +185 -0
- package/components/display-field/display-field.css +91 -0
- package/components/display-field/display-field.d.ts +26 -0
- package/components/display-field/display-field.examples.md +44 -0
- package/components/display-field/display-field.js +141 -0
- package/components/display-field/display-field.test.js +115 -0
- package/components/display-field/display-field.yaml +191 -0
- package/components/icon/icon.class.js +34 -1
- package/components/index.js +1 -0
- package/components/table/table.class.js +42 -11
- package/components/table/table.test.js +53 -0
- package/core/icons.js +14 -0
- package/custom-elements.json +5722 -2864
- package/dist/theme-provider.min.js +1 -1
- package/dist/web-components.min.css +1 -1
- package/dist/web-components.min.js +87 -79
- package/dist/web-components.sheet.js +1 -1
- package/package.json +3 -2
- package/styles/components.css +1 -0
|
@@ -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/
|
package/components/index.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
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 }`.
|