@adia-ai/web-components 0.5.6 → 0.5.7
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/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/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
|
@@ -34,12 +34,29 @@
|
|
|
34
34
|
"anti_patterns": [],
|
|
35
35
|
"category": "layout",
|
|
36
36
|
"composes": [],
|
|
37
|
-
"events": {
|
|
37
|
+
"events": {
|
|
38
|
+
"toggle": {
|
|
39
|
+
"description": "Fired when the section opens or closes.",
|
|
40
|
+
"detail": {
|
|
41
|
+
"open": {
|
|
42
|
+
"description": "New open state.",
|
|
43
|
+
"type": "boolean"
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
},
|
|
38
48
|
"examples": [],
|
|
39
49
|
"keywords": [],
|
|
40
50
|
"name": "UIAccordionItem",
|
|
41
51
|
"related": [],
|
|
42
|
-
"slots": {
|
|
52
|
+
"slots": {
|
|
53
|
+
"action": {
|
|
54
|
+
"description": "§206 (v0.5.7): action buttons inside a custom header (e.g. Copy /\nDownload / settings). Children placed at `[slot=\"action\"]` (or\n`[slot=\"actions\"]`, or marked `[data-no-toggle]`) are excluded from\nthe toggle-on-click cascade — clicking them fires their own handler\nwithout also toggling the section."
|
|
55
|
+
},
|
|
56
|
+
"header": {
|
|
57
|
+
"description": "Custom header content. By default `[text]` renders as a plain header\nlabel, but a `[slot=\"header\"]` override lets consumers author rich\nheaders (icon + title + action buttons + caret)."
|
|
58
|
+
}
|
|
59
|
+
},
|
|
43
60
|
"states": [],
|
|
44
61
|
"synonyms": {},
|
|
45
62
|
"tag": "accordion-item-ui",
|
|
@@ -25,3 +25,23 @@ props:
|
|
|
25
25
|
description: Whether the section is expanded.
|
|
26
26
|
type: boolean
|
|
27
27
|
default: false
|
|
28
|
+
slots:
|
|
29
|
+
header:
|
|
30
|
+
description: |-
|
|
31
|
+
Custom header content. By default `[text]` renders as a plain header
|
|
32
|
+
label, but a `[slot="header"]` override lets consumers author rich
|
|
33
|
+
headers (icon + title + action buttons + caret).
|
|
34
|
+
action:
|
|
35
|
+
description: |-
|
|
36
|
+
§206 (v0.5.7): action buttons inside a custom header (e.g. Copy /
|
|
37
|
+
Download / settings). Children placed at `[slot="action"]` (or
|
|
38
|
+
`[slot="actions"]`, or marked `[data-no-toggle]`) are excluded from
|
|
39
|
+
the toggle-on-click cascade — clicking them fires their own handler
|
|
40
|
+
without also toggling the section.
|
|
41
|
+
events:
|
|
42
|
+
toggle:
|
|
43
|
+
description: Fired when the section opens or closes.
|
|
44
|
+
detail:
|
|
45
|
+
open:
|
|
46
|
+
type: boolean
|
|
47
|
+
description: New open state.
|
|
@@ -128,6 +128,11 @@ export class UIAccordionItem extends UIElement {
|
|
|
128
128
|
#onClick = (e) => {
|
|
129
129
|
const header = this.querySelector('[slot="header"]');
|
|
130
130
|
if (!header || !header.contains(e.target)) return;
|
|
131
|
+
// FEEDBACK-16 §2 (v0.5.7 §206): skip toggle when click originates inside an
|
|
132
|
+
// action slot or an opt-out marker. Lets consumers author action buttons in
|
|
133
|
+
// custom headers without bubbling-toggle UX. Slot vocabulary matches
|
|
134
|
+
// drawer-ui + pane-ui.
|
|
135
|
+
if (e.target.closest('[slot="action"], [slot="actions"], [data-no-toggle]')) return;
|
|
131
136
|
|
|
132
137
|
this.open = !this.open;
|
|
133
138
|
this.dispatchEvent(new CustomEvent('toggle', {
|
|
@@ -58,6 +58,11 @@ export class UIAgentArtifact extends UIElement {
|
|
|
58
58
|
tone: { type: String, default: 'neutral', reflect: true },
|
|
59
59
|
};
|
|
60
60
|
|
|
61
|
+
// §205 (v0.5.7): dynamic chevron icons stamped on collapse/expand state
|
|
62
|
+
// transition (class.js:119+188). Per FEEDBACK-16 §1 + §209 slot-11 ternary-
|
|
63
|
+
// walker discovery. Note: `this.icon` consumer-supplied — not declared here.
|
|
64
|
+
static requiredIcons = ['caret-right', 'caret-down'];
|
|
65
|
+
|
|
61
66
|
static template = () => null;
|
|
62
67
|
|
|
63
68
|
#headerEl = null;
|
|
@@ -17,6 +17,16 @@ export class UICalendarPicker extends UIFormElement {
|
|
|
17
17
|
value: string;
|
|
18
18
|
/** Open/closed reflected state. */
|
|
19
19
|
open: boolean;
|
|
20
|
+
/** §207 (v0.5.7): label rendered above the trigger. */
|
|
21
|
+
label: string;
|
|
22
|
+
/** §207 (v0.5.7): placeholder text when no date is selected. */
|
|
23
|
+
placeholder: string;
|
|
24
|
+
/** §207 (v0.5.7): display format for the trigger label (e.g. `"YYYY-MM-DD"`). */
|
|
25
|
+
format: string;
|
|
26
|
+
/** §207 (v0.5.7): earliest selectable ISO-date; `null` to disable. */
|
|
27
|
+
min: string | null;
|
|
28
|
+
/** §207 (v0.5.7): latest selectable ISO-date; `null` to disable. */
|
|
29
|
+
max: string | null;
|
|
20
30
|
|
|
21
31
|
addEventListener<K extends keyof HTMLElementEventMap>(
|
|
22
32
|
type: K,
|
|
@@ -56,6 +56,14 @@ export class UICode extends UIFormElement {
|
|
|
56
56
|
/** Drop chrome (border, background) for inline composition. */
|
|
57
57
|
bare: boolean;
|
|
58
58
|
placeholder: string;
|
|
59
|
+
/** §207 (v0.5.7): native form-element name for `<form>` submission. */
|
|
60
|
+
name: string;
|
|
61
|
+
/** §207 (v0.5.7): form-validity `required` flag. */
|
|
62
|
+
required: boolean;
|
|
63
|
+
/** §207 (v0.5.7): native `disabled` flag — suppresses input + form submission. */
|
|
64
|
+
disabled: boolean;
|
|
65
|
+
/** §207 (v0.5.7): readonly mode — user cannot type but value is form-submitted. */
|
|
66
|
+
readonly: boolean;
|
|
59
67
|
|
|
60
68
|
addEventListener<K extends keyof HTMLElementEventMap>(
|
|
61
69
|
type: K,
|
|
@@ -127,7 +127,13 @@ export class UIColorPicker extends UIFormElement {
|
|
|
127
127
|
// consumer markup). Aggregated by installIconLoadersForRegistered()
|
|
128
128
|
// across all defined elements. Audited by check-required-icons.mjs
|
|
129
129
|
// (slot 11). Per FEEDBACK-06 §4 + FEEDBACK-07 §4.
|
|
130
|
-
static requiredIcons = ['copy'];
|
|
130
|
+
static requiredIcons = ['copy', 'check', 'warning'];
|
|
131
|
+
|
|
132
|
+
// §201 (v0.5.7): once-per-element warn dedup for #parseValue malformed
|
|
133
|
+
// input. Matches the v0.5.5 §184 §8 button-ui icon-only safety-net pattern
|
|
134
|
+
// + v0.5.7 §215 select-ui parseOptions pattern. Per FEEDBACK-13 §1 +
|
|
135
|
+
// FEEDBACK-14 §2 (co-credited).
|
|
136
|
+
static #warnedBadParse = new WeakSet();
|
|
131
137
|
|
|
132
138
|
static properties = {
|
|
133
139
|
...UIFormElement.properties,
|
|
@@ -418,9 +424,41 @@ export class UIColorPicker extends UIFormElement {
|
|
|
418
424
|
if (val.startsWith('#')) {
|
|
419
425
|
const o = hexToOklch(val);
|
|
420
426
|
this.#L = o.L; this.#C = o.C; this.#H = o.H;
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
427
|
+
return;
|
|
428
|
+
}
|
|
429
|
+
if (val.startsWith('oklch(')) {
|
|
430
|
+
// §201 (v0.5.7, FEEDBACK-13 §1 + FEEDBACK-14 §2): accept floats, percent
|
|
431
|
+
// on L, NaN (culori chromaless convention), and 'none' (CSS Color L4
|
|
432
|
+
// powerless-component syntax). Channel-level normalization: NaN/none → 0;
|
|
433
|
+
// percent on L → divide by 100. Per the CSS Color L4 "powerless" cascade
|
|
434
|
+
// (https://www.w3.org/TR/css-color-4/#powerless), zeroing the hue channel
|
|
435
|
+
// is the spec-defined resolution when chroma is 0 or near-0.
|
|
436
|
+
const m = val.match(
|
|
437
|
+
/oklch\(\s*([\d.]+%?|NaN|none)\s+([\d.]+%?|NaN|none)\s+([\d.]+|NaN|none)/i,
|
|
438
|
+
);
|
|
439
|
+
if (m) {
|
|
440
|
+
const parseChan = (s, isL) => {
|
|
441
|
+
if (s === 'none' || /^NaN$/i.test(s)) return 0;
|
|
442
|
+
if (isL && s.endsWith('%')) return +s.slice(0, -1) / 100;
|
|
443
|
+
if (s.endsWith('%')) return +s.slice(0, -1) / 100;
|
|
444
|
+
return +s;
|
|
445
|
+
};
|
|
446
|
+
this.#L = parseChan(m[1], true);
|
|
447
|
+
this.#C = parseChan(m[2], false);
|
|
448
|
+
this.#H = parseChan(m[3], false);
|
|
449
|
+
return;
|
|
450
|
+
}
|
|
451
|
+
// No regex match — warn once per element so consumers can correct.
|
|
452
|
+
// WeakSet dedup matches v0.5.5 §184 §8 button-ui icon-only pattern.
|
|
453
|
+
if (!UIColorPicker.#warnedBadParse.has(this)) {
|
|
454
|
+
UIColorPicker.#warnedBadParse.add(this);
|
|
455
|
+
// eslint-disable-next-line no-console
|
|
456
|
+
console.warn(
|
|
457
|
+
`<color-picker-ui>: could not parse value=${JSON.stringify(val)}. ` +
|
|
458
|
+
`Expected #rrggbb or oklch(L C H) with numeric, NaN, 'none' (CSS L4 ` +
|
|
459
|
+
`powerless), or % channels. Picker is keeping prior state.`,
|
|
460
|
+
);
|
|
461
|
+
}
|
|
424
462
|
}
|
|
425
463
|
}
|
|
426
464
|
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* color-picker-ui #parseValue tests — §201 (v0.5.7, FEEDBACK-13 §1 +
|
|
3
|
+
* FEEDBACK-14 §2 co-credited).
|
|
4
|
+
*
|
|
5
|
+
* Verifies the relaxed `oklch(L C H)` parser accepts:
|
|
6
|
+
* - Numeric channels (regression — the v0.5.6 baseline)
|
|
7
|
+
* - `NaN` on any channel (culori chromaless convention; coerces to 0)
|
|
8
|
+
* - `none` on any channel (CSS Color L4 powerless syntax; coerces to 0)
|
|
9
|
+
* - Percent on L (e.g. `oklch(53% 0.05 240)`; divides by 100)
|
|
10
|
+
*
|
|
11
|
+
* Plus:
|
|
12
|
+
* - Truly malformed input emits ONE console.warn per element (WeakSet dedup),
|
|
13
|
+
* not one per #parseValue call.
|
|
14
|
+
* - Hex pass-through remains intact.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|
18
|
+
import '../../core/element.js';
|
|
19
|
+
import './color-picker.js';
|
|
20
|
+
|
|
21
|
+
const tick = () => new Promise((r) => queueMicrotask(r));
|
|
22
|
+
|
|
23
|
+
function mount(html) {
|
|
24
|
+
const wrap = document.createElement('div');
|
|
25
|
+
wrap.innerHTML = html;
|
|
26
|
+
document.body.appendChild(wrap);
|
|
27
|
+
return wrap.firstElementChild;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
describe('color-picker-ui #parseValue (§201)', () => {
|
|
31
|
+
beforeEach(() => { document.body.innerHTML = ''; });
|
|
32
|
+
|
|
33
|
+
it('accepts numeric oklch (baseline regression)', async () => {
|
|
34
|
+
const p = mount('<color-picker-ui format="oklch" value="oklch(0.6 0.15 240)"></color-picker-ui>');
|
|
35
|
+
await tick();
|
|
36
|
+
// Read back via the public value getter — round-tripped through the parser.
|
|
37
|
+
expect(p.value).toMatch(/oklch/);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('accepts NaN hue (culori chromaless convention)', async () => {
|
|
41
|
+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
42
|
+
const p = mount('<color-picker-ui format="oklch" value="oklch(0.53 0.01 NaN)"></color-picker-ui>');
|
|
43
|
+
await tick();
|
|
44
|
+
// No warn — NaN is accepted, coerced to 0.
|
|
45
|
+
expect(warn).not.toHaveBeenCalled();
|
|
46
|
+
warn.mockRestore();
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('accepts `none` (CSS Color L4 powerless)', async () => {
|
|
50
|
+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
51
|
+
const p = mount('<color-picker-ui format="oklch" value="oklch(0.5 0 none)"></color-picker-ui>');
|
|
52
|
+
await tick();
|
|
53
|
+
expect(warn).not.toHaveBeenCalled();
|
|
54
|
+
warn.mockRestore();
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('accepts percent on L', async () => {
|
|
58
|
+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
59
|
+
const p = mount('<color-picker-ui format="oklch" value="oklch(53% 0.05 240)"></color-picker-ui>');
|
|
60
|
+
await tick();
|
|
61
|
+
expect(warn).not.toHaveBeenCalled();
|
|
62
|
+
warn.mockRestore();
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('accepts hex (regression)', async () => {
|
|
66
|
+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
67
|
+
const p = mount('<color-picker-ui format="hex" value="#3b82f6"></color-picker-ui>');
|
|
68
|
+
await tick();
|
|
69
|
+
expect(warn).not.toHaveBeenCalled();
|
|
70
|
+
warn.mockRestore();
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('warns once per element on malformed input', async () => {
|
|
74
|
+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
75
|
+
const p = mount('<color-picker-ui format="oklch" value="oklch(banana)"></color-picker-ui>');
|
|
76
|
+
await tick();
|
|
77
|
+
// Drive multiple re-parses via setAttribute — should NOT compound the warn count.
|
|
78
|
+
p.setAttribute('value', 'oklch(orange juice)');
|
|
79
|
+
await tick();
|
|
80
|
+
p.setAttribute('value', 'oklch(still broken)');
|
|
81
|
+
await tick();
|
|
82
|
+
expect(warn).toHaveBeenCalledTimes(1);
|
|
83
|
+
expect(warn.mock.calls[0][0]).toMatch(/could not parse value/);
|
|
84
|
+
warn.mockRestore();
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('two independent pickers each get their own warn budget', async () => {
|
|
88
|
+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
89
|
+
const p1 = mount('<color-picker-ui format="oklch" value="oklch(broken-1)"></color-picker-ui>');
|
|
90
|
+
const p2 = mount('<color-picker-ui format="oklch" value="oklch(broken-2)"></color-picker-ui>');
|
|
91
|
+
await tick();
|
|
92
|
+
// Two separate instances — each gets their own first-warn.
|
|
93
|
+
expect(warn).toHaveBeenCalledTimes(2);
|
|
94
|
+
warn.mockRestore();
|
|
95
|
+
});
|
|
96
|
+
});
|
|
@@ -16,13 +16,25 @@
|
|
|
16
16
|
* Uses contenteditable for text entry, ElementInternals for form participation.
|
|
17
17
|
*
|
|
18
18
|
* Slots inside [slot="field"]:
|
|
19
|
-
* prefix → label → text → suffix → controls (number mode)
|
|
19
|
+
* prefix → [leading] → label → text → suffix → [trailing] → controls (number mode)
|
|
20
20
|
*
|
|
21
21
|
* <input-ui label="Email" placeholder="you@acme.com"></input-ui>
|
|
22
22
|
* <input-ui label="Email" prefix="user" placeholder="you@acme.com"></input-ui>
|
|
23
23
|
* <input-ui placeholder="Search" prefix="magnifying-glass"></input-ui>
|
|
24
24
|
* <input-ui prefix="@" value="kim"></input-ui>
|
|
25
25
|
*
|
|
26
|
+
* <!-- Trailing buttons inside the input chrome (§199 v0.5.7) -->
|
|
27
|
+
* <input-ui value="Theme 1" suffix="Light">
|
|
28
|
+
* <button-ui slot="trailing" icon="arrow-square-out"
|
|
29
|
+
* variant="ghost" size="sm"
|
|
30
|
+
* aria-label="Open theme browser"></button-ui>
|
|
31
|
+
* </input-ui>
|
|
32
|
+
*
|
|
33
|
+
* <!-- Leading button for inline actions before the value -->
|
|
34
|
+
* <input-ui value="https://...">
|
|
35
|
+
* <button-ui slot="leading" icon="link" variant="ghost" size="sm"></button-ui>
|
|
36
|
+
* </input-ui>
|
|
37
|
+
*
|
|
26
38
|
* <input-ui type="number" value="42" min="0" max="100" step="1"></input-ui>
|
|
27
39
|
* <input-ui type="number" value="9.99" step="0.01" precision="2" prefix="$"></input-ui>
|
|
28
40
|
*
|
|
@@ -118,9 +130,22 @@ export class UIInput extends UIFormElement {
|
|
|
118
130
|
super.connected();
|
|
119
131
|
this.setAttribute('role', this.#isNumberMode ? 'spinbutton' : 'textbox');
|
|
120
132
|
|
|
133
|
+
// §199 (v0.5.7): consumer-supplied leading/trailing buttons live as
|
|
134
|
+
// direct children of <input-ui> at author time. Capture references
|
|
135
|
+
// BEFORE innerHTML wipes them, so we can move them into [slot="field"]
|
|
136
|
+
// after the shell is built. Querying `:scope > [slot="leading|trailing"]`
|
|
137
|
+
// (direct-child) avoids matching anything we already moved on a prior
|
|
138
|
+
// re-render.
|
|
139
|
+
const leadingNodes = Array.from(this.querySelectorAll(':scope > [slot="leading"]'));
|
|
140
|
+
const trailingNodes = Array.from(this.querySelectorAll(':scope > [slot="trailing"]'));
|
|
141
|
+
|
|
121
142
|
if (!this.querySelector('[slot="text"]')) {
|
|
122
143
|
const labelId = this.label ? `input-label-${++UIInput.#labelSeq}` : '';
|
|
123
144
|
this.innerHTML = this.#shellHTML(labelId);
|
|
145
|
+
// innerHTML wiped the consumer-supplied leading/trailing nodes; re-attach
|
|
146
|
+
// them into the field at the right positions. Order matches the JSDoc:
|
|
147
|
+
// prefix → leading → label → text → suffix → trailing → controls
|
|
148
|
+
this.#installAffordances(leadingNodes, trailingNodes);
|
|
124
149
|
}
|
|
125
150
|
|
|
126
151
|
this.#textEl = this.querySelector('[slot="text"]');
|
|
@@ -208,6 +233,81 @@ export class UIInput extends UIFormElement {
|
|
|
208
233
|
`;
|
|
209
234
|
}
|
|
210
235
|
|
|
236
|
+
/**
|
|
237
|
+
* §199 (v0.5.7) — Move consumer-supplied [slot="leading"] +
|
|
238
|
+
* [slot="trailing"] nodes into the field at the right insertion points.
|
|
239
|
+
*
|
|
240
|
+
* Yaml has declared `leading` + `trailing` slots since v1 ("Leading/
|
|
241
|
+
* Trailing icon slot, sized to --content-height. Collapses text inline
|
|
242
|
+
* padding when present.") but the shell never rendered them, so any
|
|
243
|
+
* consumer-authored `<button-ui slot="trailing">` sat OUTSIDE the
|
|
244
|
+
* field chrome. §199 closes the schema-vs-impl gap by:
|
|
245
|
+
*
|
|
246
|
+
* 1. Capturing the consumer's [slot="leading|trailing"] direct
|
|
247
|
+
* children BEFORE `innerHTML = shellHTML` wipes them.
|
|
248
|
+
* 2. Re-inserting each into `[slot="field"]` at the canonical
|
|
249
|
+
* position: leading goes right after [slot="label"] (or after
|
|
250
|
+
* [slot="prefix"] when no label), trailing goes right after
|
|
251
|
+
* [slot="suffix"] (or after [slot="text"] when no suffix), but
|
|
252
|
+
* always BEFORE [slot="controls"] (number-mode stepper column).
|
|
253
|
+
*
|
|
254
|
+
* Consumers typically pass `<button-ui slot="trailing">` for inline
|
|
255
|
+
* actions (copy / clear / open-in-modal); CSS handles the chrome
|
|
256
|
+
* inheritance (border continuity + size token wiring) via
|
|
257
|
+
* input.css §199 rules.
|
|
258
|
+
*/
|
|
259
|
+
#installAffordances(leadingNodes, trailingNodes) {
|
|
260
|
+
const field = this.querySelector(':scope > [slot="field"]');
|
|
261
|
+
if (!field) return;
|
|
262
|
+
|
|
263
|
+
if (leadingNodes.length) {
|
|
264
|
+
// Insertion point: after [slot="label"] if present, else after
|
|
265
|
+
// [slot="prefix"] if present, else as the first child. Walk the
|
|
266
|
+
// anchor forward so author-order is preserved (first node lands
|
|
267
|
+
// earliest in DOM).
|
|
268
|
+
const start =
|
|
269
|
+
field.querySelector(':scope > [slot="label"]') ||
|
|
270
|
+
field.querySelector(':scope > [slot="prefix"]');
|
|
271
|
+
if (start) {
|
|
272
|
+
let anchor = start;
|
|
273
|
+
for (const node of leadingNodes) {
|
|
274
|
+
anchor.after(node);
|
|
275
|
+
anchor = node;
|
|
276
|
+
}
|
|
277
|
+
} else {
|
|
278
|
+
// No prefix/label — prepend in reverse so the FIRST author-order
|
|
279
|
+
// node ends up first after all prepends complete.
|
|
280
|
+
for (const node of leadingNodes.slice().reverse()) field.prepend(node);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
if (trailingNodes.length) {
|
|
285
|
+
// Insertion point: after [slot="suffix"] if present, else after
|
|
286
|
+
// [slot="text"], but always before [slot="controls"] (number-mode
|
|
287
|
+
// stepper column). Insert in author order: walk the anchor forward
|
|
288
|
+
// for each node so the first author-order node lands first in DOM.
|
|
289
|
+
const controls = field.querySelector(':scope > [slot="controls"]');
|
|
290
|
+
const after =
|
|
291
|
+
field.querySelector(':scope > [slot="suffix"]') ||
|
|
292
|
+
field.querySelector(':scope > [slot="text"]');
|
|
293
|
+
if (after) {
|
|
294
|
+
let anchor = after;
|
|
295
|
+
for (const node of trailingNodes) {
|
|
296
|
+
anchor.after(node);
|
|
297
|
+
anchor = node;
|
|
298
|
+
}
|
|
299
|
+
} else if (controls) {
|
|
300
|
+
let anchor = controls.previousElementSibling || controls;
|
|
301
|
+
for (const node of trailingNodes) {
|
|
302
|
+
anchor.after(node);
|
|
303
|
+
anchor = node;
|
|
304
|
+
}
|
|
305
|
+
} else {
|
|
306
|
+
for (const node of trailingNodes) field.appendChild(node);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
211
311
|
#promoteAffixes() {
|
|
212
312
|
if (!this.isConnected) return;
|
|
213
313
|
for (const which of ['prefix', 'suffix']) {
|
|
@@ -227,13 +227,13 @@
|
|
|
227
227
|
],
|
|
228
228
|
"slots": {
|
|
229
229
|
"leading": {
|
|
230
|
-
"description": "Leading
|
|
230
|
+
"description": "Leading affordance slot, inside the field chrome, before the\nvalue. Sized to chrome height. Author `<button-ui slot=\"leading\"\nicon=\"...\" variant=\"ghost\" size=\"sm\">` (or any inline element)\nfor inline actions before the value — e.g. a link-icon button\nnext to a URL input. Wired §199 v0.5.7."
|
|
231
231
|
},
|
|
232
232
|
"text": {
|
|
233
233
|
"description": "Contenteditable text surface for user input"
|
|
234
234
|
},
|
|
235
235
|
"trailing": {
|
|
236
|
-
"description": "Trailing
|
|
236
|
+
"description": "Trailing affordance slot, inside the field chrome, after the\nvalue (and after [slot=\"suffix\"] if present). Sized to chrome\nheight. Author `<button-ui slot=\"trailing\" icon=\"...\"\nvariant=\"ghost\" size=\"sm\" aria-label=\"...\">` for inline actions\nlike copy / clear / open-in-modal. For trailing text/icon\nlabels (e.g. \"Light\" in a theme picker), use the `suffix` prop\ninstead — affordance slots are for interactive buttons, not\ntext. Wired §199 v0.5.7."
|
|
237
237
|
}
|
|
238
238
|
},
|
|
239
239
|
"states": [
|
|
@@ -268,6 +268,63 @@ input-ui:not([disabled]) [slot="field"]:hover [slot="suffix"] {
|
|
|
268
268
|
margin-inline-start: auto;
|
|
269
269
|
}
|
|
270
270
|
|
|
271
|
+
/* §199 (v0.5.7) — Leading + trailing affordance slots inside the field.
|
|
272
|
+
Consumer authors `<button-ui slot="leading|trailing">` as a direct
|
|
273
|
+
child of <input-ui>; class.js#installAffordances moves it into
|
|
274
|
+
[slot="field"] at the right insertion point on connected(). CSS
|
|
275
|
+
normalizes the chrome here.
|
|
276
|
+
|
|
277
|
+
The yaml declared these slots since v1 ("sized to --content-height,
|
|
278
|
+
collapses text inline padding when present") but no shell rule ever
|
|
279
|
+
wired them — the canonical schema-vs-impl gap that motivated audit
|
|
280
|
+
slot 19 (§192 v0.5.6). §199 closes the gap for input-ui specifically. */
|
|
281
|
+
|
|
282
|
+
[slot="field"] > [slot="leading"],
|
|
283
|
+
[slot="field"] > [slot="trailing"] {
|
|
284
|
+
flex-shrink: 0;
|
|
285
|
+
/* Sized to chrome height per yaml contract. The button-ui or icon-ui
|
|
286
|
+
child receives the sizing tokens; we just constrain the slot box
|
|
287
|
+
and align it to the field's baseline. */
|
|
288
|
+
align-self: stretch;
|
|
289
|
+
display: inline-flex;
|
|
290
|
+
align-items: center;
|
|
291
|
+
/* Inline padding moves from [slot="text"] (handled by the field's px)
|
|
292
|
+
to the slot wrapper, so the button-ui sits flush with the field
|
|
293
|
+
chrome edge instead of inheriting the field's px gap. Authors who
|
|
294
|
+
want a gap between value and trailing affordance use [slot="suffix"]
|
|
295
|
+
for the text — the auto-margin on suffix pushes everything after it
|
|
296
|
+
to the inline-end edge naturally. */
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/* Default sizing for `<button-ui>` children of the affordance slots.
|
|
300
|
+
Token-driven so consumers can override via standard --button-* hooks.
|
|
301
|
+
Without these defaults, a vanilla `<button-ui slot="trailing" icon="...">`
|
|
302
|
+
would render at button-ui's default --button-height (40px md) and
|
|
303
|
+
blow out the input chrome. We bind to the field's content height
|
|
304
|
+
instead so the button visually matches the input's intrinsic size. */
|
|
305
|
+
[slot="field"] > [slot="leading"] button-ui,
|
|
306
|
+
[slot="field"] > [slot="trailing"] button-ui {
|
|
307
|
+
--button-height: calc(var(--input-height) - 4px);
|
|
308
|
+
--button-bg: transparent;
|
|
309
|
+
--button-border: transparent;
|
|
310
|
+
--button-fg: var(--input-affix-fg);
|
|
311
|
+
--button-px: var(--a-space-1);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
[slot="field"] > [slot="leading"] button-ui:hover,
|
|
315
|
+
[slot="field"] > [slot="trailing"] button-ui:hover {
|
|
316
|
+
--button-bg: var(--a-ui-bg-hover);
|
|
317
|
+
--button-fg: var(--a-fg);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/* When a trailing affordance is present in number mode, reserve room
|
|
321
|
+
for the stepper column so it doesn't overlap. (controls is
|
|
322
|
+
absolutely positioned at inset-inline-end: 0; trailing sits in the
|
|
323
|
+
flex flow.) Same pattern as [slot="suffix"] in number mode. */
|
|
324
|
+
[data-number]:has(> [slot="trailing"]) > [slot="trailing"] {
|
|
325
|
+
margin-inline-end: var(--input-controls-width, calc(var(--input-height) * 0.7));
|
|
326
|
+
}
|
|
327
|
+
|
|
271
328
|
/* Disabled */
|
|
272
329
|
:scope[disabled] [slot="field"] {
|
|
273
330
|
background: var(--input-bg-disabled);
|
|
@@ -58,4 +58,6 @@ export class UIInput extends UIFormElement {
|
|
|
58
58
|
): void;
|
|
59
59
|
addEventListener(type: 'change', listener: (ev: InputChangeEvent) => unknown, options?: boolean | AddEventListenerOptions): void;
|
|
60
60
|
addEventListener(type: 'input', listener: (ev: InputInputEvent) => unknown, options?: boolean | AddEventListenerOptions): void;
|
|
61
|
+
/** §207 (v0.5.7): Enter-key + native form-submit semantics; dispatched bubbling. */
|
|
62
|
+
addEventListener(type: 'submit', listener: (ev: Event) => unknown, options?: boolean | AddEventListenerOptions): void;
|
|
61
63
|
}
|
|
@@ -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.
|
|
@@ -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
|
};
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* toggle-scheme-ui §200 race-fix tests — v0.5.7 (FEEDBACK-10 §1).
|
|
3
|
+
*
|
|
4
|
+
* Verifies the post-connect attribute application is HONORED — not raced over
|
|
5
|
+
* by the synchronous #initState() call in connected(). Per the bug class:
|
|
6
|
+
*
|
|
7
|
+
* 1. template.js scan() strips placeholder attributes pre-insertion
|
|
8
|
+
* 2. replaceChildren() upgrades the element; connectedCallback fires
|
|
9
|
+
* 3. #initState() reads this.scheme (default 'auto')
|
|
10
|
+
* 4. template.js update() then setAttribute('scheme', 'dark')
|
|
11
|
+
* 5. attributeChangedCallback syncs to property — but #initState already ran
|
|
12
|
+
*
|
|
13
|
+
* Pre-§200, step 4 was a no-op for the scheme cascade. Post-§200,
|
|
14
|
+
* attributeChangedCallback re-runs #initState() (until user-touched).
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { describe, it, expect, beforeEach } from 'vitest';
|
|
18
|
+
import '../../core/element.js';
|
|
19
|
+
import './toggle-scheme.js';
|
|
20
|
+
|
|
21
|
+
const tick = () => new Promise((r) => queueMicrotask(r));
|
|
22
|
+
|
|
23
|
+
function mount(html) {
|
|
24
|
+
const wrap = document.createElement('div');
|
|
25
|
+
wrap.innerHTML = html;
|
|
26
|
+
document.body.appendChild(wrap);
|
|
27
|
+
return wrap.firstElementChild;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
describe('toggle-scheme-ui §200 race fix', () => {
|
|
31
|
+
beforeEach(() => {
|
|
32
|
+
document.body.innerHTML = '';
|
|
33
|
+
document.documentElement.style.removeProperty('color-scheme');
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('honors post-connect scheme attribute application (reactive consumer)', async () => {
|
|
37
|
+
// Simulate template.js's strip-then-restamp pattern: mount without scheme,
|
|
38
|
+
// then set the attribute immediately afterward (mimics post-connect apply).
|
|
39
|
+
const t = mount('<toggle-scheme-ui></toggle-scheme-ui>');
|
|
40
|
+
await tick();
|
|
41
|
+
t.setAttribute('scheme', 'dark');
|
|
42
|
+
await tick();
|
|
43
|
+
expect(t.activeScheme).toBe('dark');
|
|
44
|
+
expect(document.documentElement.style.colorScheme).toBe('dark');
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('honors the initial scheme attribute (no race scenario)', async () => {
|
|
48
|
+
const t = mount('<toggle-scheme-ui scheme="dark"></toggle-scheme-ui>');
|
|
49
|
+
await tick();
|
|
50
|
+
expect(t.activeScheme).toBe('dark');
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('user button press locks #userTouched — subsequent attr changes are ignored', async () => {
|
|
54
|
+
const t = mount('<toggle-scheme-ui scheme="dark"></toggle-scheme-ui>');
|
|
55
|
+
await tick();
|
|
56
|
+
expect(t.activeScheme).toBe('dark');
|
|
57
|
+
|
|
58
|
+
// User press: flips to light. #userTouched flips true.
|
|
59
|
+
const btn = t.querySelector(':scope > button-ui');
|
|
60
|
+
btn.dispatchEvent(new CustomEvent('press', { bubbles: true }));
|
|
61
|
+
await tick();
|
|
62
|
+
expect(t.activeScheme).toBe('light');
|
|
63
|
+
|
|
64
|
+
// Consumer re-renders with scheme="dark" — should NOT clobber user choice.
|
|
65
|
+
t.setAttribute('scheme', 'dark');
|
|
66
|
+
await tick();
|
|
67
|
+
expect(t.activeScheme).toBe('light'); // user choice survives
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('programmatic setScheme also locks #userTouched', async () => {
|
|
71
|
+
const t = mount('<toggle-scheme-ui scheme="auto"></toggle-scheme-ui>');
|
|
72
|
+
await tick();
|
|
73
|
+
|
|
74
|
+
t.setScheme('dark');
|
|
75
|
+
await tick();
|
|
76
|
+
expect(t.activeScheme).toBe('dark');
|
|
77
|
+
|
|
78
|
+
// Consumer reactive write should NOT override.
|
|
79
|
+
t.setAttribute('scheme', 'light');
|
|
80
|
+
await tick();
|
|
81
|
+
expect(t.activeScheme).toBe('dark');
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('toggle() also locks #userTouched', async () => {
|
|
85
|
+
const t = mount('<toggle-scheme-ui scheme="auto"></toggle-scheme-ui>');
|
|
86
|
+
await tick();
|
|
87
|
+
const initial = t.activeScheme; // depends on prefers-color-scheme (likely light in test env)
|
|
88
|
+
|
|
89
|
+
t.toggle();
|
|
90
|
+
await tick();
|
|
91
|
+
expect(t.activeScheme).not.toBe(initial);
|
|
92
|
+
|
|
93
|
+
const afterToggle = t.activeScheme;
|
|
94
|
+
t.setAttribute('scheme', initial === 'dark' ? 'dark' : 'light');
|
|
95
|
+
await tick();
|
|
96
|
+
expect(t.activeScheme).toBe(afterToggle); // user choice survives
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('removing the attribute (back to auto) is honored pre-touch', async () => {
|
|
100
|
+
const t = mount('<toggle-scheme-ui scheme="dark"></toggle-scheme-ui>');
|
|
101
|
+
await tick();
|
|
102
|
+
expect(t.activeScheme).toBe('dark');
|
|
103
|
+
|
|
104
|
+
t.removeAttribute('scheme');
|
|
105
|
+
await tick();
|
|
106
|
+
// After removal, attr is null; #initState falls to auto → resolves
|
|
107
|
+
// prefers-color-scheme. Test env should resolve to light by default.
|
|
108
|
+
expect(['light', 'dark']).toContain(t.activeScheme);
|
|
109
|
+
});
|
|
110
|
+
});
|
|
@@ -17,6 +17,12 @@ export type UploadChangeEvent = CustomEvent<UploadChangeEventDetail>;
|
|
|
17
17
|
export class UIUpload extends UIFormElement {
|
|
18
18
|
/** Files currently selected. */
|
|
19
19
|
readonly files: FileList | File[];
|
|
20
|
+
/** §207 (v0.5.7): label rendered above the dropzone. */
|
|
21
|
+
label: string;
|
|
22
|
+
/** §207 (v0.5.7): MIME-type accept filter (e.g. `"image/*"` or `".pdf,.txt"`). */
|
|
23
|
+
accept: string;
|
|
24
|
+
/** §207 (v0.5.7): allow multi-file selection. */
|
|
25
|
+
multiple: boolean;
|
|
20
26
|
|
|
21
27
|
addEventListener<K extends keyof HTMLElementEventMap>(
|
|
22
28
|
type: K,
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@adia-ai/web-components",
|
|
3
|
-
"version": "0.5.
|
|
4
|
-
"description": "AdiaUI web components
|
|
3
|
+
"version": "0.5.7",
|
|
4
|
+
"description": "AdiaUI web components — vanilla custom elements. A2UI runtime (renderer, registry, streams, wiring) lives in @adia-ai/a2ui-runtime.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"types": "./index.d.ts",
|
|
7
7
|
"exports": {
|
|
@@ -28,6 +28,8 @@
|
|
|
28
28
|
"default": "./components/*/class.js"
|
|
29
29
|
},
|
|
30
30
|
"./components/*.css": "./components/*/*.css",
|
|
31
|
+
"./components/*/*.css": "./components/*/*.css",
|
|
32
|
+
"./components/*/css/*.css": "./components/*/css/*.css",
|
|
31
33
|
"./styles/*": "./styles/*",
|
|
32
34
|
"./traits": "./traits/index.js",
|
|
33
35
|
"./traits/*": "./traits/*.js",
|
package/styles/components.css
CHANGED
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
/* ── Components ── */
|
|
14
14
|
@import "../components/icon/icon.css";
|
|
15
15
|
@import "../components/button/button.css";
|
|
16
|
+
@import "../components/link/link.css";
|
|
16
17
|
@import "../components/input/input.css";
|
|
17
18
|
@import "../components/textarea/textarea.css";
|
|
18
19
|
@import "../components/check/check.css";
|
|
@@ -84,6 +85,7 @@
|
|
|
84
85
|
@import "../components/block/block.css";
|
|
85
86
|
@import "../components/text/text.css";
|
|
86
87
|
@import "../components/toggle-group/toggle-group.css";
|
|
88
|
+
@import "../components/toggle-scheme/toggle-scheme.css";
|
|
87
89
|
@import "../components/demo-toggle/demo-toggle.css";
|
|
88
90
|
@import "../components/richtext/richtext.css";
|
|
89
91
|
@import "../components/stream/stream.css";
|