@adia-ai/web-components 0.5.6 → 0.5.8
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/components/accordion/accordion-item.a2ui.json +19 -2
- package/components/accordion/accordion-item.yaml +20 -0
- package/components/accordion/accordion.a2ui.json +1 -1
- package/components/accordion/accordion.yaml +1 -1
- package/components/accordion/class.js +5 -0
- package/components/agent-artifact/agent-artifact.yaml +3 -0
- package/components/agent-artifact/class.js +5 -0
- package/components/calendar-picker/calendar-picker.d.ts +10 -0
- package/components/code/code.d.ts +8 -0
- package/components/color-picker/class.js +42 -4
- package/components/color-picker/color-picker.test.js +96 -0
- package/components/color-picker/color-picker.yaml +2 -0
- package/components/demo-toggle/demo-toggle.a2ui.json +3 -1
- package/components/demo-toggle/demo-toggle.yaml +2 -0
- package/components/feed/feed.css +1 -1
- package/components/fields/fields.a2ui.json +3 -1
- package/components/fields/fields.yaml +2 -0
- package/components/input/class.js +101 -1
- package/components/input/input.a2ui.json +2 -2
- package/components/input/input.css +57 -0
- package/components/input/input.d.ts +2 -0
- package/components/input/input.test.js +123 -0
- package/components/input/input.yaml +15 -2
- package/components/nav/nav.a2ui.json +6 -1
- package/components/nav/nav.yaml +6 -0
- package/components/nav-group/nav-group.a2ui.json +5 -1
- package/components/nav-group/nav-group.css +1 -1
- package/components/nav-group/nav-group.yaml +5 -0
- package/components/nav-item/nav-item.a2ui.json +4 -1
- package/components/nav-item/nav-item.css +1 -1
- package/components/nav-item/nav-item.yaml +4 -0
- package/components/select/select.d.ts +2 -0
- package/components/slider/slider.d.ts +4 -0
- package/components/switch/switch.d.ts +2 -0
- package/components/table/class.js +9 -1
- package/components/table/table.yaml +4 -0
- package/components/table-toolbar/class.js +5 -0
- package/components/table-toolbar/table-toolbar.yaml +4 -0
- package/components/text/text.a2ui.json +1 -8
- package/components/text/text.css +13 -0
- package/components/text/text.d.ts +1 -1
- package/components/text/text.test.js +106 -0
- package/components/text/text.yaml +0 -7
- package/components/timeline/class.js +5 -0
- package/components/timeline/timeline.yaml +3 -0
- package/components/toggle-scheme/class.js +31 -0
- package/components/toggle-scheme/toggle-scheme.test.js +110 -0
- package/components/upload/upload.d.ts +6 -0
- package/package.json +4 -2
- package/styles/components.css +2 -0
- package/styles/typography.css +3 -1
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* input-ui — focused unit tests for the §199 (v0.5.7) leading/trailing
|
|
3
|
+
* affordance-slot wiring.
|
|
4
|
+
*
|
|
5
|
+
* Pre-§199 the yaml declared `slot="leading"` + `slot="trailing"` since
|
|
6
|
+
* v1 but nothing rendered them — consumer-authored buttons sat OUTSIDE
|
|
7
|
+
* the field chrome. §199 closes the schema-vs-impl gap by moving
|
|
8
|
+
* consumer-supplied affordance nodes into [slot="field"] at the right
|
|
9
|
+
* insertion points on connected().
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { describe, it, expect, beforeEach } from 'vitest';
|
|
13
|
+
import '../../core/element.js';
|
|
14
|
+
import './input.js';
|
|
15
|
+
|
|
16
|
+
const tick = () => new Promise((r) => queueMicrotask(r));
|
|
17
|
+
|
|
18
|
+
function mount(html) {
|
|
19
|
+
const wrap = document.createElement('div');
|
|
20
|
+
wrap.innerHTML = html;
|
|
21
|
+
document.body.appendChild(wrap);
|
|
22
|
+
return wrap.firstElementChild;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
describe('input-ui — §199 leading/trailing affordance slots', () => {
|
|
26
|
+
beforeEach(() => { document.body.innerHTML = ''; });
|
|
27
|
+
|
|
28
|
+
it('renders a baseline input without any affordances (no regression)', () => {
|
|
29
|
+
const el = mount('<input-ui value="hello"></input-ui>');
|
|
30
|
+
const field = el.querySelector(':scope > [slot="field"]');
|
|
31
|
+
expect(field).not.toBeNull();
|
|
32
|
+
expect(field.querySelector('[slot="text"]')).not.toBeNull();
|
|
33
|
+
expect(field.querySelector('[slot="leading"]')).toBeNull();
|
|
34
|
+
expect(field.querySelector('[slot="trailing"]')).toBeNull();
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('moves consumer [slot="trailing"] into [slot="field"]', () => {
|
|
38
|
+
const el = mount(`
|
|
39
|
+
<input-ui value="Theme 1" suffix="Light">
|
|
40
|
+
<button slot="trailing" data-test="open" aria-label="Open">↗</button>
|
|
41
|
+
</input-ui>
|
|
42
|
+
`);
|
|
43
|
+
const field = el.querySelector(':scope > [slot="field"]');
|
|
44
|
+
const trailing = field.querySelector(':scope > [slot="trailing"]');
|
|
45
|
+
expect(trailing).not.toBeNull();
|
|
46
|
+
expect(trailing.dataset.test).toBe('open');
|
|
47
|
+
// No stray trailing nodes left as direct children of <input-ui>
|
|
48
|
+
expect(el.querySelector(':scope > [slot="trailing"]')).toBeNull();
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('moves consumer [slot="leading"] into [slot="field"]', () => {
|
|
52
|
+
const el = mount(`
|
|
53
|
+
<input-ui value="https://...">
|
|
54
|
+
<button slot="leading" data-test="link" aria-label="Open link">↗</button>
|
|
55
|
+
</input-ui>
|
|
56
|
+
`);
|
|
57
|
+
const field = el.querySelector(':scope > [slot="field"]');
|
|
58
|
+
const leading = field.querySelector(':scope > [slot="leading"]');
|
|
59
|
+
expect(leading).not.toBeNull();
|
|
60
|
+
expect(leading.dataset.test).toBe('link');
|
|
61
|
+
expect(el.querySelector(':scope > [slot="leading"]')).toBeNull();
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('positions trailing after suffix and before controls (number mode)', () => {
|
|
65
|
+
const el = mount(`
|
|
66
|
+
<input-ui type="number" value="42" suffix="kg">
|
|
67
|
+
<button slot="trailing" data-test="reset">↺</button>
|
|
68
|
+
</input-ui>
|
|
69
|
+
`);
|
|
70
|
+
const field = el.querySelector(':scope > [slot="field"]');
|
|
71
|
+
const children = Array.from(field.children);
|
|
72
|
+
const suffixIdx = children.findIndex((c) => c.getAttribute('slot') === 'suffix');
|
|
73
|
+
const trailingIdx = children.findIndex((c) => c.getAttribute('slot') === 'trailing');
|
|
74
|
+
const controlsIdx = children.findIndex((c) => c.getAttribute('slot') === 'controls');
|
|
75
|
+
expect(suffixIdx).toBeGreaterThanOrEqual(0);
|
|
76
|
+
expect(trailingIdx).toBeGreaterThanOrEqual(0);
|
|
77
|
+
expect(controlsIdx).toBeGreaterThanOrEqual(0);
|
|
78
|
+
expect(suffixIdx).toBeLessThan(trailingIdx);
|
|
79
|
+
expect(trailingIdx).toBeLessThan(controlsIdx);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('positions leading after label (when label present)', () => {
|
|
83
|
+
const el = mount(`
|
|
84
|
+
<input-ui label="Theme" value="Theme 1">
|
|
85
|
+
<button slot="leading" data-test="picker">🎨</button>
|
|
86
|
+
</input-ui>
|
|
87
|
+
`);
|
|
88
|
+
const field = el.querySelector(':scope > [slot="field"]');
|
|
89
|
+
const children = Array.from(field.children);
|
|
90
|
+
const labelIdx = children.findIndex((c) => c.getAttribute('slot') === 'label');
|
|
91
|
+
const leadingIdx = children.findIndex((c) => c.getAttribute('slot') === 'leading');
|
|
92
|
+
const textIdx = children.findIndex((c) => c.getAttribute('slot') === 'text');
|
|
93
|
+
expect(labelIdx).toBeGreaterThanOrEqual(0);
|
|
94
|
+
expect(leadingIdx).toBeGreaterThanOrEqual(0);
|
|
95
|
+
expect(textIdx).toBeGreaterThanOrEqual(0);
|
|
96
|
+
expect(labelIdx).toBeLessThan(leadingIdx);
|
|
97
|
+
expect(leadingIdx).toBeLessThan(textIdx);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('supports multiple trailing nodes in author order', () => {
|
|
101
|
+
const el = mount(`
|
|
102
|
+
<input-ui value="hello">
|
|
103
|
+
<button slot="trailing" data-test="a">A</button>
|
|
104
|
+
<button slot="trailing" data-test="b">B</button>
|
|
105
|
+
</input-ui>
|
|
106
|
+
`);
|
|
107
|
+
const field = el.querySelector(':scope > [slot="field"]');
|
|
108
|
+
const trailings = Array.from(field.querySelectorAll(':scope > [slot="trailing"]'));
|
|
109
|
+
expect(trailings.map((n) => n.dataset.test)).toEqual(['a', 'b']);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('preserves contenteditable surface alongside affordances', () => {
|
|
113
|
+
const el = mount(`
|
|
114
|
+
<input-ui value="Theme 1" suffix="Light">
|
|
115
|
+
<button slot="trailing" data-test="open">↗</button>
|
|
116
|
+
</input-ui>
|
|
117
|
+
`);
|
|
118
|
+
const text = el.querySelector('[slot="text"]');
|
|
119
|
+
expect(text).not.toBeNull();
|
|
120
|
+
expect(text.getAttribute('contenteditable')).toBe('plaintext-only');
|
|
121
|
+
expect(text.textContent).toBe('Theme 1');
|
|
122
|
+
});
|
|
123
|
+
});
|
|
@@ -153,11 +153,24 @@ events:
|
|
|
153
153
|
description: Fired when Enter commits the value.
|
|
154
154
|
slots:
|
|
155
155
|
leading:
|
|
156
|
-
description:
|
|
156
|
+
description: |-
|
|
157
|
+
Leading affordance slot, inside the field chrome, before the
|
|
158
|
+
value. Sized to chrome height. Author `<button-ui slot="leading"
|
|
159
|
+
icon="..." variant="ghost" size="sm">` (or any inline element)
|
|
160
|
+
for inline actions before the value — e.g. a link-icon button
|
|
161
|
+
next to a URL input. Wired §199 v0.5.7.
|
|
157
162
|
text:
|
|
158
163
|
description: Contenteditable text surface for user input
|
|
159
164
|
trailing:
|
|
160
|
-
description:
|
|
165
|
+
description: |-
|
|
166
|
+
Trailing affordance slot, inside the field chrome, after the
|
|
167
|
+
value (and after [slot="suffix"] if present). Sized to chrome
|
|
168
|
+
height. Author `<button-ui slot="trailing" icon="..."
|
|
169
|
+
variant="ghost" size="sm" aria-label="...">` for inline actions
|
|
170
|
+
like copy / clear / open-in-modal. For trailing text/icon
|
|
171
|
+
labels (e.g. "Light" in a theme picker), use the `suffix` prop
|
|
172
|
+
instead — affordance slots are for interactive buttons, not
|
|
173
|
+
text. Wired §199 v0.5.7.
|
|
161
174
|
states:
|
|
162
175
|
- name: idle
|
|
163
176
|
description: Default, ready for interaction.
|
|
@@ -48,7 +48,12 @@
|
|
|
48
48
|
"x-adiaui": {
|
|
49
49
|
"anti_patterns": [],
|
|
50
50
|
"category": "layout",
|
|
51
|
-
"composes": [
|
|
51
|
+
"composes": [
|
|
52
|
+
"nav-group-ui",
|
|
53
|
+
"nav-item-ui",
|
|
54
|
+
"icon-ui",
|
|
55
|
+
"popover-ui"
|
|
56
|
+
],
|
|
52
57
|
"events": {
|
|
53
58
|
"nav-select": {
|
|
54
59
|
"description": "Bubbles from <nav-item-ui> children when one is selected. Detail: { item, text, value }.",
|
package/components/nav/nav.yaml
CHANGED
|
@@ -58,7 +58,11 @@
|
|
|
58
58
|
"x-adiaui": {
|
|
59
59
|
"anti_patterns": [],
|
|
60
60
|
"category": "layout",
|
|
61
|
-
"composes": [
|
|
61
|
+
"composes": [
|
|
62
|
+
"icon-ui",
|
|
63
|
+
"badge-ui",
|
|
64
|
+
"nav-item-ui"
|
|
65
|
+
],
|
|
62
66
|
"events": {
|
|
63
67
|
"group-toggle": {
|
|
64
68
|
"description": "Fired when the header toggles via click/keyboard. Detail: { text, open }.",
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
--nav-group-font-size: var(--a-ui-size);
|
|
10
10
|
--nav-group-font-size-sm: var(--a-ui-sm);
|
|
11
11
|
--nav-group-font-size-lg: var(--a-ui-lg);
|
|
12
|
-
--nav-group-font-weight: var(--a-
|
|
12
|
+
--nav-group-font-weight: var(--a-weight-normal);
|
|
13
13
|
--nav-group-fg: var(--a-fg);
|
|
14
14
|
--nav-group-fg-hover: var(--a-fg-strong);
|
|
15
15
|
--nav-group-fg-muted: var(--a-fg-muted);
|
|
@@ -63,7 +63,10 @@
|
|
|
63
63
|
"x-adiaui": {
|
|
64
64
|
"anti_patterns": [],
|
|
65
65
|
"category": "layout",
|
|
66
|
-
"composes": [
|
|
66
|
+
"composes": [
|
|
67
|
+
"icon-ui",
|
|
68
|
+
"badge-ui"
|
|
69
|
+
],
|
|
67
70
|
"events": {
|
|
68
71
|
"nav-select": {
|
|
69
72
|
"description": "Bubbles when the item is activated. Detail: { item, text, value }.",
|
|
@@ -39,7 +39,7 @@ nav-item-ui[selected] [slot="icon"]:empty::before {
|
|
|
39
39
|
--nav-item-font-size: var(--a-ui-size);
|
|
40
40
|
--nav-item-font-size-sm: var(--a-ui-sm);
|
|
41
41
|
--nav-item-font-size-lg: var(--a-ui-lg);
|
|
42
|
-
--nav-item-font-weight: var(--a-
|
|
42
|
+
--nav-item-font-weight: var(--a-weight-normal);
|
|
43
43
|
--nav-item-fg: var(--a-ui-text-subtle);
|
|
44
44
|
--nav-item-fg-hover: var(--a-ui-text-hover);
|
|
45
45
|
--nav-item-fg-selected: var(--a-ui-text-selected);
|
|
@@ -40,6 +40,8 @@ export class UISelect extends UIFormElement {
|
|
|
40
40
|
/** Allow values not in the option list (combobox mode). */
|
|
41
41
|
freeText: boolean;
|
|
42
42
|
divider: boolean;
|
|
43
|
+
/** §207 (v0.5.7): hint text below the field, wired to aria-describedby. */
|
|
44
|
+
hint: string;
|
|
43
45
|
|
|
44
46
|
/**
|
|
45
47
|
* Dynamic option list. Setting `.options = [...]` stamps option elements at
|
|
@@ -20,6 +20,10 @@ export class UISlider extends UIFormElement {
|
|
|
20
20
|
step: number;
|
|
21
21
|
label: string;
|
|
22
22
|
suffix: string;
|
|
23
|
+
/** §184 (v0.5.5, FEEDBACK-08 §4): debounce `input` event by this many ms. 0 = no throttle. */
|
|
24
|
+
throttle: number;
|
|
25
|
+
/** §184 (v0.5.5, FEEDBACK-08 §4): hint text rendered below the track, wired to aria-describedby. */
|
|
26
|
+
hint: string;
|
|
23
27
|
|
|
24
28
|
addEventListener<K extends keyof HTMLElementEventMap>(
|
|
25
29
|
type: K,
|
|
@@ -20,6 +20,8 @@ export class UISwitch extends UIFormElement {
|
|
|
20
20
|
label: string;
|
|
21
21
|
/** Size — `sm` / `md` / `lg`. */
|
|
22
22
|
size: '' | 'sm' | 'md' | 'lg';
|
|
23
|
+
/** §207 (v0.5.7): hint text rendered below the switch, wired to aria-describedby. */
|
|
24
|
+
hint: string;
|
|
23
25
|
|
|
24
26
|
addEventListener<K extends keyof HTMLElementEventMap>(
|
|
25
27
|
type: K,
|
|
@@ -95,7 +95,15 @@ export class UITable extends UIElement {
|
|
|
95
95
|
// consumer markup). Aggregated by installIconLoadersForRegistered()
|
|
96
96
|
// across all defined elements. Audited by check-required-icons.mjs
|
|
97
97
|
// (slot 11). Per FEEDBACK-06 §4 + FEEDBACK-07 §4.
|
|
98
|
-
static requiredIcons = [
|
|
98
|
+
static requiredIcons = [
|
|
99
|
+
'caret-right',
|
|
100
|
+
'caret-up-down',
|
|
101
|
+
'table',
|
|
102
|
+
'arrow-up',
|
|
103
|
+
'arrow-down',
|
|
104
|
+
'funnel-simple',
|
|
105
|
+
'funnel-simple-fill',
|
|
106
|
+
];
|
|
99
107
|
|
|
100
108
|
static properties = {
|
|
101
109
|
sortable: { type: Boolean, default: false, reflect: true },
|
|
@@ -124,6 +124,11 @@ export class UITableToolbar extends UIElement {
|
|
|
124
124
|
variant: { type: String, default: 'default', reflect: true },
|
|
125
125
|
};
|
|
126
126
|
|
|
127
|
+
// §205 (v0.5.7): dynamic sort-indicator icons (class.js:576 — nested ternary
|
|
128
|
+
// `dir === 'asc' ? 'arrow-up' : dir === 'desc' ? 'arrow-down' : 'caret-up-down'`).
|
|
129
|
+
// Per FEEDBACK-16 §1 + §209 slot-11 ternary-walker discovery.
|
|
130
|
+
static requiredIcons = ['arrow-up', 'arrow-down', 'caret-up-down'];
|
|
131
|
+
|
|
127
132
|
static template = () => null;
|
|
128
133
|
|
|
129
134
|
#target = null;
|
|
@@ -41,7 +41,6 @@
|
|
|
41
41
|
"enum": [
|
|
42
42
|
"body",
|
|
43
43
|
"heading",
|
|
44
|
-
"subheading",
|
|
45
44
|
"title",
|
|
46
45
|
"subsection",
|
|
47
46
|
"display",
|
|
@@ -51,13 +50,7 @@
|
|
|
51
50
|
"deck",
|
|
52
51
|
"section",
|
|
53
52
|
"metric",
|
|
54
|
-
"code"
|
|
55
|
-
"h1",
|
|
56
|
-
"h2",
|
|
57
|
-
"h3",
|
|
58
|
-
"h4",
|
|
59
|
-
"h5",
|
|
60
|
-
"h6"
|
|
53
|
+
"code"
|
|
61
54
|
],
|
|
62
55
|
"default": "body"
|
|
63
56
|
}
|
package/components/text/text.css
CHANGED
|
@@ -48,6 +48,19 @@
|
|
|
48
48
|
:scope[variant="kicker"] { --text-family: var(--a-kicker-family); --text-weight: var(--a-kicker-weight); --text-size: var(--a-kicker-size); --text-leading: var(--a-kicker-leading); --text-tracking: var(--a-kicker-tracking); --text-case: uppercase; --text-color: var(--a-kicker-color); }
|
|
49
49
|
:scope[variant="code"] { --text-family: var(--a-code-family); --text-weight: var(--a-code-weight); --text-size: var(--a-code-size); --text-leading: var(--a-code-leading); --text-tracking: var(--a-code-tracking); --text-case: var(--a-code-case); --text-color: var(--a-code-color); }
|
|
50
50
|
|
|
51
|
+
/* §210 (v0.5.7, FEEDBACK-17 §1): three token-backed variants whose
|
|
52
|
+
`--a-<role>-{family,weight,leading,tracking,case,color,size}` tokens
|
|
53
|
+
ship in `styles/typography.css` but had no matching :scope rule —
|
|
54
|
+
authoring `<text-ui variant="subsection|deck|metric">` per the
|
|
55
|
+
documented yaml enum silently rendered as `body` defaults. The
|
|
56
|
+
`subheading` + `h1`–`h6` enum entries are deliberately omitted from
|
|
57
|
+
this batch: their tokens don't ship anywhere (verified in this same
|
|
58
|
+
arc), so the enum entries are retired from text.yaml / text.d.ts /
|
|
59
|
+
text.a2ui.json. */
|
|
60
|
+
:scope[variant="subsection"] { --text-family: var(--a-subsection-family); --text-weight: var(--a-subsection-weight); --text-size: var(--a-subsection-size); --text-leading: var(--a-subsection-leading); --text-tracking: var(--a-subsection-tracking); --text-case: var(--a-subsection-case); --text-color: var(--a-subsection-color); }
|
|
61
|
+
:scope[variant="deck"] { --text-family: var(--a-deck-family); --text-weight: var(--a-deck-weight); --text-size: var(--a-deck-size); --text-leading: var(--a-deck-leading); --text-tracking: var(--a-deck-tracking); --text-case: var(--a-deck-case); --text-color: var(--a-deck-color); }
|
|
62
|
+
:scope[variant="metric"] { --text-family: var(--a-metric-family); --text-weight: var(--a-metric-weight); --text-size: var(--a-metric-size); --text-leading: var(--a-metric-leading); --text-tracking: var(--a-metric-tracking); --text-case: var(--a-metric-case); --text-color: var(--a-metric-color); }
|
|
63
|
+
|
|
51
64
|
/* ── Truncation (single-line) ── */
|
|
52
65
|
:scope[truncate] {
|
|
53
66
|
overflow: hidden;
|
|
@@ -22,5 +22,5 @@ export class UIText extends UIElement {
|
|
|
22
22
|
/** Single-line truncation with ellipsis. Ignored when `lines` is set. */
|
|
23
23
|
truncate: boolean;
|
|
24
24
|
/** Typography variant — sets role tokens (size/weight/tracking/color). */
|
|
25
|
-
variant: 'body' | 'heading' | '
|
|
25
|
+
variant: 'body' | 'heading' | 'title' | 'subsection' | 'display' | 'caption' | 'label' | 'kicker' | 'deck' | 'section' | 'metric' | 'code';
|
|
26
26
|
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* text-ui variant rendering tests — §210 (v0.5.7, FEEDBACK-17 §1).
|
|
3
|
+
*
|
|
4
|
+
* Verifies the 12 documented enum values in `text.yaml`/`text.d.ts`/
|
|
5
|
+
* `text.a2ui.json` all render distinctly per the `:scope[variant=…]`
|
|
6
|
+
* rules in `text.css`. Pre-§210 the three token-backed variants
|
|
7
|
+
* `subsection` / `deck` / `metric` silently rendered as `body`
|
|
8
|
+
* defaults because the matching `:scope` rules were missing — the
|
|
9
|
+
* yaml + d.ts advertised them but the CSS didn't consume the tokens.
|
|
10
|
+
*
|
|
11
|
+
* Plus a guard: the 6 phantom enum entries removed in §210
|
|
12
|
+
* (`subheading`, `h1`-`h6`) must NOT be in the .d.ts type union or
|
|
13
|
+
* the a2ui.json enum — they had no shipped tokens and rendering them
|
|
14
|
+
* was always body-defaults.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { describe, it, expect, beforeEach } from 'vitest';
|
|
18
|
+
import { readFileSync } from 'node:fs';
|
|
19
|
+
import { fileURLToPath } from 'node:url';
|
|
20
|
+
import { dirname, resolve } from 'node:path';
|
|
21
|
+
import '../../core/element.js';
|
|
22
|
+
import './text.js';
|
|
23
|
+
|
|
24
|
+
const tick = () => new Promise((r) => queueMicrotask(r));
|
|
25
|
+
|
|
26
|
+
function mount(html) {
|
|
27
|
+
const wrap = document.createElement('div');
|
|
28
|
+
wrap.innerHTML = html;
|
|
29
|
+
document.body.appendChild(wrap);
|
|
30
|
+
return wrap.firstElementChild;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// jsdom doesn't evaluate @scope rules in `getComputedStyle()`. We
|
|
34
|
+
// validate the CSS by reading the rule out of text.css text and
|
|
35
|
+
// asserting the variant-specific properties are present — coarse
|
|
36
|
+
// but catches the §210 regression class (rule missing entirely).
|
|
37
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
38
|
+
const TEXT_CSS = readFileSync(resolve(HERE, 'text.css'), 'utf8');
|
|
39
|
+
const TEXT_DTS = readFileSync(resolve(HERE, 'text.d.ts'), 'utf8');
|
|
40
|
+
const TEXT_A2UI = JSON.parse(readFileSync(resolve(HERE, 'text.a2ui.json'), 'utf8'));
|
|
41
|
+
|
|
42
|
+
describe('text-ui §210 — variant enum vs CSS rule completeness', () => {
|
|
43
|
+
beforeEach(() => { document.body.innerHTML = ''; });
|
|
44
|
+
|
|
45
|
+
// ── Mounting smoke — every documented variant constructs without crash ──
|
|
46
|
+
const documentedVariants = [
|
|
47
|
+
'body', 'heading', 'title', 'subsection', 'display', 'caption',
|
|
48
|
+
'label', 'kicker', 'deck', 'section', 'metric', 'code',
|
|
49
|
+
];
|
|
50
|
+
|
|
51
|
+
it.each(documentedVariants)('mounts <text-ui variant="%s"> without error', async (variant) => {
|
|
52
|
+
const t = mount(`<text-ui variant="${variant}">Sample</text-ui>`);
|
|
53
|
+
await tick();
|
|
54
|
+
expect(t).toBeDefined();
|
|
55
|
+
expect(t.getAttribute('variant')).toBe(variant);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
// ── CSS-side: every documented variant has a :scope rule ──
|
|
59
|
+
it.each(documentedVariants)('text.css ships a :scope[variant="%s"] rule', (variant) => {
|
|
60
|
+
expect(TEXT_CSS).toMatch(new RegExp(`:scope\\[variant="${variant}"\\]`));
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
// ── Three token-backed variants restored in §210 ──
|
|
64
|
+
it.each(['subsection', 'deck', 'metric'])(
|
|
65
|
+
'text.css :scope[variant="%s"] consumes role tokens (not body defaults)',
|
|
66
|
+
(variant) => {
|
|
67
|
+
// The rule must reference the role-specific token, not fall through
|
|
68
|
+
// to var(--a-body-*) defaults.
|
|
69
|
+
const ruleMatch = TEXT_CSS.match(
|
|
70
|
+
new RegExp(`:scope\\[variant="${variant}"\\]\\s*\\{([^}]+)\\}`)
|
|
71
|
+
);
|
|
72
|
+
expect(ruleMatch, `rule missing for variant="${variant}"`).toBeTruthy();
|
|
73
|
+
const rule = ruleMatch[1];
|
|
74
|
+
expect(rule).toMatch(new RegExp(`var\\(--a-${variant}-family\\)`));
|
|
75
|
+
expect(rule).toMatch(new RegExp(`var\\(--a-${variant}-weight\\)`));
|
|
76
|
+
expect(rule).toMatch(new RegExp(`var\\(--a-${variant}-size\\)`));
|
|
77
|
+
}
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
// ── Type-side: 6 phantom entries removed in §210 ──
|
|
81
|
+
const removedPhantoms = ['subheading', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6'];
|
|
82
|
+
|
|
83
|
+
it.each(removedPhantoms)('text.d.ts variant union does NOT contain phantom "%s"', (variant) => {
|
|
84
|
+
expect(TEXT_DTS).not.toMatch(new RegExp(`'${variant}'`));
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it.each(removedPhantoms)('text.a2ui.json variant enum does NOT contain phantom "%s"', (variant) => {
|
|
88
|
+
expect(TEXT_A2UI.properties.variant.enum).not.toContain(variant);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
// ── a2ui.json enum and .d.ts union and CSS rules are mutually consistent ──
|
|
92
|
+
it('a2ui.json variant enum matches .d.ts union and CSS rules 1:1', () => {
|
|
93
|
+
const a2uiVariants = TEXT_A2UI.properties.variant.enum;
|
|
94
|
+
const dtsUnionMatch = TEXT_DTS.match(/variant:\s*((?:'[^']+'\s*\|?\s*)+);/);
|
|
95
|
+
expect(dtsUnionMatch).toBeTruthy();
|
|
96
|
+
const dtsVariants = [...dtsUnionMatch[1].matchAll(/'([^']+)'/g)].map(m => m[1]);
|
|
97
|
+
|
|
98
|
+
expect(a2uiVariants.sort()).toEqual(dtsVariants.sort());
|
|
99
|
+
expect(a2uiVariants.sort()).toEqual([...documentedVariants].sort());
|
|
100
|
+
|
|
101
|
+
// Every variant has a CSS rule.
|
|
102
|
+
for (const v of a2uiVariants) {
|
|
103
|
+
expect(TEXT_CSS).toMatch(new RegExp(`:scope\\[variant="${v}"\\]`));
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
});
|
|
@@ -34,7 +34,6 @@ props:
|
|
|
34
34
|
enum:
|
|
35
35
|
- body
|
|
36
36
|
- heading
|
|
37
|
-
- subheading
|
|
38
37
|
- title
|
|
39
38
|
- subsection
|
|
40
39
|
- display
|
|
@@ -45,12 +44,6 @@ props:
|
|
|
45
44
|
- section
|
|
46
45
|
- metric
|
|
47
46
|
- code
|
|
48
|
-
- h1
|
|
49
|
-
- h2
|
|
50
|
-
- h3
|
|
51
|
-
- h4
|
|
52
|
-
- h5
|
|
53
|
-
- h6
|
|
54
47
|
events: {}
|
|
55
48
|
slots: {}
|
|
56
49
|
states:
|
|
@@ -87,6 +87,11 @@ export class UITimelineItem extends UIElement {
|
|
|
87
87
|
spinner: { type: Boolean, default: false, reflect: true },
|
|
88
88
|
};
|
|
89
89
|
|
|
90
|
+
// §205 (v0.5.7): dynamic chevron icons stamped on expanded-state ternary
|
|
91
|
+
// (class.js:167). Per FEEDBACK-16 §1 + §209 slot-11 ternary-walker discovery.
|
|
92
|
+
// Note: `this.icon` consumer-supplied — not declared here.
|
|
93
|
+
static requiredIcons = ['caret-down', 'caret-right'];
|
|
94
|
+
|
|
90
95
|
static template = () => null;
|
|
91
96
|
|
|
92
97
|
#outcomes = [];
|
|
@@ -78,6 +78,12 @@ export class UIToggleScheme extends UIElement {
|
|
|
78
78
|
#mqlHandler = null;
|
|
79
79
|
#onPress = null;
|
|
80
80
|
#stamped = false;
|
|
81
|
+
// §200 (v0.5.7, FEEDBACK-10 §1): set true after first user-driven scheme
|
|
82
|
+
// mutation (button press OR programmatic setScheme/toggle). Until then,
|
|
83
|
+
// the `scheme` attribute is treated as "reactive consumer-driven" — any
|
|
84
|
+
// post-connect attribute application re-runs #initState() so the reactive
|
|
85
|
+
// value wins over the template-strip race.
|
|
86
|
+
#userTouched = false;
|
|
81
87
|
|
|
82
88
|
connected() {
|
|
83
89
|
if (!this.#stamped) {
|
|
@@ -87,6 +93,26 @@ export class UIToggleScheme extends UIElement {
|
|
|
87
93
|
this.#initState();
|
|
88
94
|
}
|
|
89
95
|
|
|
96
|
+
attributeChangedCallback(name, oldVal, newVal) {
|
|
97
|
+
// §200 (v0.5.7, FEEDBACK-10 §1): UIElement.attributeChangedCallback syncs
|
|
98
|
+
// attr → property. After it runs, if the `scheme` attribute changed AFTER
|
|
99
|
+
// connect AND the user hasn't yet chosen explicitly, re-run #initState()
|
|
100
|
+
// so the reactive consumer value wins over the template-engine's strip-
|
|
101
|
+
// then-restamp race. Once the user clicks the button or calls
|
|
102
|
+
// setScheme()/toggle() programmatically, #userTouched flips true and we
|
|
103
|
+
// stop auto-reinit so user choice survives subsequent re-renders.
|
|
104
|
+
super.attributeChangedCallback(name, oldVal, newVal);
|
|
105
|
+
if (
|
|
106
|
+
name === 'scheme' &&
|
|
107
|
+
this.isConnected &&
|
|
108
|
+
this.#stamped &&
|
|
109
|
+
!this.#userTouched &&
|
|
110
|
+
oldVal !== newVal
|
|
111
|
+
) {
|
|
112
|
+
this.#initState();
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
90
116
|
disconnected() {
|
|
91
117
|
if (this.#button && this.#onPress) {
|
|
92
118
|
this.#button.removeEventListener('press', this.#onPress);
|
|
@@ -98,6 +124,7 @@ export class UIToggleScheme extends UIElement {
|
|
|
98
124
|
|
|
99
125
|
/** Flip between light and dark — defeats auto. */
|
|
100
126
|
toggle() {
|
|
127
|
+
this.#userTouched = true;
|
|
101
128
|
const next = this.activeScheme === DARK ? LIGHT : DARK;
|
|
102
129
|
this.#apply(next, 'programmatic');
|
|
103
130
|
}
|
|
@@ -106,6 +133,7 @@ export class UIToggleScheme extends UIElement {
|
|
|
106
133
|
* @param {"light"|"dark"|"auto"} s
|
|
107
134
|
*/
|
|
108
135
|
setScheme(s) {
|
|
136
|
+
this.#userTouched = true;
|
|
109
137
|
if (s === AUTO) {
|
|
110
138
|
this.#clearTargetOverride();
|
|
111
139
|
const resolved = this.#resolvePrefersScheme();
|
|
@@ -135,6 +163,9 @@ export class UIToggleScheme extends UIElement {
|
|
|
135
163
|
// so consumers see one semantic event, not the inner button's.
|
|
136
164
|
e.stopPropagation();
|
|
137
165
|
if (this.disabled) return;
|
|
166
|
+
// §200 (v0.5.7): mark user-touched so post-connect reactive `scheme`
|
|
167
|
+
// attribute writes don't override the user's explicit choice.
|
|
168
|
+
this.#userTouched = true;
|
|
138
169
|
const next = this.activeScheme === DARK ? LIGHT : DARK;
|
|
139
170
|
this.#apply(next, 'press');
|
|
140
171
|
};
|