@adia-ai/web-components 0.2.2 → 0.2.4
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/agent-trace/agent-trace.css +24 -3
- package/components/button/button.js +3 -0
- package/components/demo-toggle/demo-toggle.a2ui.json +144 -0
- package/components/demo-toggle/demo-toggle.css +120 -0
- package/components/demo-toggle/demo-toggle.js +144 -0
- package/components/demo-toggle/demo-toggle.test.js +102 -0
- package/components/demo-toggle/demo-toggle.yaml +144 -0
- package/components/index.js +1 -0
- package/components/input/input.js +11 -0
- package/components/list/list.css +66 -3
- package/components/nav-group/nav-group.a2ui.json +1 -1
- package/components/nav-group/nav-group.css +5 -5
- package/components/nav-group/nav-group.yaml +1 -1
- package/components/nav-item/nav-item.a2ui.json +1 -1
- package/components/nav-item/nav-item.css +3 -4
- package/components/nav-item/nav-item.yaml +1 -1
- package/components/textarea/textarea.js +10 -0
- package/core/icons.js +13 -1
- package/package.json +1 -1
- package/styles/components.css +1 -0
- package/styles/typography.css +1 -1
- package/traits/_catalog.json +258 -5
- package/traits/active-state.test.js +1 -1
- package/traits/anchor-positioning.js +205 -52
- package/traits/anchor-positioning.test.js +77 -4
- package/traits/announcer-stage.js +157 -0
- package/traits/announcer.js +145 -0
- package/traits/announcer.test.js +268 -0
- package/traits/arrow-grid-nav.js +234 -0
- package/traits/arrow-grid-nav.test.js +375 -0
- package/traits/attention-pulse.js +1 -1
- package/traits/attention-pulse.test.js +1 -1
- package/traits/confetti-burst.js +90 -60
- package/traits/confetti-burst.test.js +16 -8
- package/traits/confetti-stage.js +143 -0
- package/traits/confetti.js +44 -47
- package/traits/confetti.test.js +24 -5
- package/traits/count-up.js +31 -6
- package/traits/count-up.test.js +1 -1
- package/traits/declarative.test.js +1 -1
- package/traits/dirty-state.test.js +1 -1
- package/traits/drag-ghost.js +55 -3
- package/traits/drag-ghost.test.js +1 -1
- package/traits/draggable-list-item.js +279 -0
- package/traits/draggable-list-item.test.js +51 -0
- package/traits/draggable.js +14 -4
- package/traits/draggable.test.js +1 -1
- package/traits/drop-target.js +223 -0
- package/traits/drop-target.test.js +241 -0
- package/traits/droppable-collection.js +89 -0
- package/traits/droppable-collection.test.js +99 -0
- package/traits/droppable.js +125 -0
- package/traits/droppable.test.js +54 -0
- package/traits/error-shake.js +157 -0
- package/traits/error-shake.test.js +114 -0
- package/traits/fade-presence.test.js +1 -1
- package/traits/focus-restore.js +135 -0
- package/traits/focus-restore.test.js +202 -0
- package/traits/focus-trap.test.js +1 -1
- package/traits/focusable.test.js +1 -1
- package/traits/glow-focus.js +1 -1
- package/traits/glow-focus.test.js +1 -1
- package/traits/gradient-shift.js +1 -1
- package/traits/gradient-shift.test.js +1 -1
- package/traits/haptic-feedback.test.js +1 -1
- package/traits/hotkey.test.js +1 -1
- package/traits/hoverable.test.js +1 -1
- package/traits/index.js +15 -0
- package/traits/inertia-drag.js +9 -0
- package/traits/inertia-drag.test.js +1 -1
- package/traits/input-mask.js +328 -0
- package/traits/input-mask.test.js +151 -0
- package/traits/intersection-observer.test.js +1 -1
- package/traits/keyboard-nav.test.js +1 -1
- package/traits/keyboard-reorderable.js +254 -0
- package/traits/keyboard-reorderable.test.js +45 -0
- package/traits/layout-animation.js +229 -0
- package/traits/layout-animation.test.js +114 -0
- package/traits/long-press.js +212 -0
- package/traits/long-press.test.js +244 -0
- package/traits/magnetic-hover.js +1 -1
- package/traits/magnetic-hover.test.js +1 -1
- package/traits/noise-texture.js +7 -3
- package/traits/noise-texture.test.js +1 -1
- package/traits/parallax.js +1 -1
- package/traits/parallax.test.js +1 -1
- package/traits/portal.test.js +1 -1
- package/traits/pressable.test.js +1 -1
- package/traits/resettable.js +29 -3
- package/traits/resettable.test.js +34 -1
- package/traits/resizable.test.js +1 -1
- package/traits/resize-observer.test.js +1 -1
- package/traits/ripple.js +1 -1
- package/traits/ripple.test.js +1 -1
- package/traits/roving-tabindex.test.js +1 -1
- package/traits/scale-press.test.js +1 -1
- package/traits/scroll-lock.test.js +1 -1
- package/traits/scroll-progress.js +201 -0
- package/traits/scroll-progress.test.js +182 -0
- package/traits/shimmer-loading.js +1 -1
- package/traits/shimmer-loading.test.js +1 -1
- package/traits/{_smoke.test.js → smoke.test.js} +1 -1
- package/traits/snap-to-grid.test.js +1 -1
- package/traits/sound-feedback.test.js +1 -1
- package/traits/spring-animate.js +8 -3
- package/traits/spring-animate.test.js +1 -1
- package/traits/success-checkmark.js +222 -0
- package/traits/success-checkmark.test.js +120 -0
- package/traits/tilt-hover.js +1 -1
- package/traits/tilt-hover.test.js +1 -1
- package/traits/tossable.js +9 -0
- package/traits/tossable.test.js +1 -1
- package/traits/traits-host.test.js +1 -1
- package/traits/typeahead.test.js +1 -1
- package/traits/typewriter.js +1 -1
- package/traits/typewriter.test.js +1 -1
- package/traits/validation.test.js +1 -1
- package/traits/view-transition.js +140 -0
- package/traits/view-transition.test.js +268 -0
- /package/traits/{_motion.js → motion.js} +0 -0
- /package/traits/{_test-helpers.js → test-helpers.js} +0 -0
|
@@ -18,7 +18,11 @@
|
|
|
18
18
|
--agent-trace-padding-y: var(--a-space-2);
|
|
19
19
|
/* Component-intrinsic measurement; no --a-space-* equivalent */
|
|
20
20
|
--agent-trace-dot-size: 6px;
|
|
21
|
-
|
|
21
|
+
/* STAGE column width — `max-content` lets the longest label set the
|
|
22
|
+
track so multi-word labels ("Rows returned", "Query duration",
|
|
23
|
+
"Drift vs. SFDC") stay on one line; the `7rem` floor stops the
|
|
24
|
+
column from collapsing when only short labels are present. */
|
|
25
|
+
--agent-trace-row-label-col: minmax(7rem, max-content);
|
|
22
26
|
/* Shared across every detail DL so values tabulate at the same x. */
|
|
23
27
|
--agent-trace-detail-label-col: 9rem;
|
|
24
28
|
|
|
@@ -113,6 +117,17 @@
|
|
|
113
117
|
right of "7%"'s detail column. Subgrid pulls the track widths up
|
|
114
118
|
to the parent so every row sees the same column stops. */
|
|
115
119
|
[data-trace-rows] {
|
|
120
|
+
/* Track plan:
|
|
121
|
+
[STAGE max-content] ← label column; widened from 80px so multi-word
|
|
122
|
+
labels ("Rows returned", "Query duration",
|
|
123
|
+
"Drift vs. SFDC") stay on one line
|
|
124
|
+
[SCORE max-content] ← value column, right-aligned
|
|
125
|
+
[DETAIL 1fr] ← detail column, right-aligned content
|
|
126
|
+
The DETAIL column still takes the leftover width, but its text is
|
|
127
|
+
right-anchored ([data-trace-aux] { text-align: end }) so the
|
|
128
|
+
previously-empty right edge becomes the alignment edge for detail
|
|
129
|
+
text. The whitespace between SCORE and DETAIL reads as breathing
|
|
130
|
+
room between key-value and qualifier rather than dead air. */
|
|
116
131
|
--trace-row-cols: var(--agent-trace-row-label-col) max-content 1fr;
|
|
117
132
|
display: grid;
|
|
118
133
|
grid-template-columns: var(--trace-row-cols);
|
|
@@ -157,8 +172,9 @@
|
|
|
157
172
|
color: var(--agent-trace-fg-subtle);
|
|
158
173
|
}
|
|
159
174
|
|
|
160
|
-
[data-trace-header]:nth-of-type(2)
|
|
161
|
-
|
|
175
|
+
[data-trace-header]:nth-of-type(2),
|
|
176
|
+
[data-trace-header]:nth-of-type(3) {
|
|
177
|
+
text-align: end;
|
|
162
178
|
}
|
|
163
179
|
|
|
164
180
|
/* Rows */
|
|
@@ -198,10 +214,15 @@
|
|
|
198
214
|
}
|
|
199
215
|
|
|
200
216
|
[data-trace-aux] {
|
|
217
|
+
/* Right-anchor the detail text so it sits flush with the right edge
|
|
218
|
+
of the row. Without this, the 1fr detail column left-aligns its
|
|
219
|
+
content (typically a 1-3 word qualifier like "warehouse" or
|
|
220
|
+
"reconciled") and the rest of the column reads as dead width. */
|
|
201
221
|
color: var(--agent-trace-fg-muted);
|
|
202
222
|
overflow: hidden;
|
|
203
223
|
text-overflow: ellipsis;
|
|
204
224
|
white-space: nowrap;
|
|
225
|
+
text-align: end;
|
|
205
226
|
}
|
|
206
227
|
|
|
207
228
|
/* Chevron in the trailing column */
|
|
@@ -43,6 +43,9 @@ class UIButton extends UIElement {
|
|
|
43
43
|
if (this.type === 'submit') {
|
|
44
44
|
const form = this.closest('form');
|
|
45
45
|
if (form?.requestSubmit) form.requestSubmit();
|
|
46
|
+
} else if (this.type === 'reset') {
|
|
47
|
+
const form = this.closest('form');
|
|
48
|
+
if (form?.reset) form.reset();
|
|
46
49
|
}
|
|
47
50
|
this.dispatchEvent(new Event('press', { bubbles: true }));
|
|
48
51
|
};
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "https://adiaui.dev/a2ui/v0_9/components/DemoToggle.json",
|
|
4
|
+
"title": "DemoToggle",
|
|
5
|
+
"description": "Side-by-side comparison primitive — header bar with a switch + two child slots (\"on\" / \"off\"); toggling the switch swaps which slot is visible. Used on trait detail pages to show \"with trait\" vs \"without trait\" on the same chrome. data-mode=\"overlay\" stacks the slots on the same coordinates so layout never shifts.",
|
|
6
|
+
"type": "object",
|
|
7
|
+
"allOf": [
|
|
8
|
+
{
|
|
9
|
+
"$ref": "common_types.json#/$defs/ComponentCommon"
|
|
10
|
+
},
|
|
11
|
+
{
|
|
12
|
+
"$ref": "common_types.json#/$defs/CatalogComponentCommon"
|
|
13
|
+
}
|
|
14
|
+
],
|
|
15
|
+
"properties": {
|
|
16
|
+
"component": {
|
|
17
|
+
"const": "DemoToggle"
|
|
18
|
+
},
|
|
19
|
+
"initial": {
|
|
20
|
+
"description": "Initial state when [state] is not set on connect (\"on\" | \"off\").",
|
|
21
|
+
"type": "string",
|
|
22
|
+
"enum": [
|
|
23
|
+
"on",
|
|
24
|
+
"off"
|
|
25
|
+
],
|
|
26
|
+
"default": "off"
|
|
27
|
+
},
|
|
28
|
+
"labelOff": {
|
|
29
|
+
"description": "Header label rendered when state is \"off\".",
|
|
30
|
+
"type": "string",
|
|
31
|
+
"default": "Off"
|
|
32
|
+
},
|
|
33
|
+
"labelOn": {
|
|
34
|
+
"description": "Header label rendered when state is \"on\".",
|
|
35
|
+
"type": "string",
|
|
36
|
+
"default": "On"
|
|
37
|
+
},
|
|
38
|
+
"state": {
|
|
39
|
+
"description": "Current toggle state (\"on\" | \"off\"). Reflected as data-state on the host.",
|
|
40
|
+
"type": "string",
|
|
41
|
+
"enum": [
|
|
42
|
+
"",
|
|
43
|
+
"on",
|
|
44
|
+
"off"
|
|
45
|
+
],
|
|
46
|
+
"default": ""
|
|
47
|
+
}
|
|
48
|
+
},
|
|
49
|
+
"required": [
|
|
50
|
+
"component"
|
|
51
|
+
],
|
|
52
|
+
"unevaluatedProperties": false,
|
|
53
|
+
"x-adiaui": {
|
|
54
|
+
"anti_patterns": [],
|
|
55
|
+
"category": "container",
|
|
56
|
+
"events": {
|
|
57
|
+
"change": {
|
|
58
|
+
"description": "Fired when the toggle flips. detail contains { state }."
|
|
59
|
+
}
|
|
60
|
+
},
|
|
61
|
+
"examples": [
|
|
62
|
+
{
|
|
63
|
+
"description": "Compare a trait-bearing surface against the bare control on the same chrome.",
|
|
64
|
+
"a2ui": "[\n {\n \"id\": \"root\",\n \"component\": \"DemoToggle\",\n \"labelOn\": \"With shimmer-loading\",\n \"labelOff\": \"Without shimmer-loading\",\n \"initial\": \"off\",\n \"children\": [\"off\", \"on\"]\n },\n {\n \"id\": \"off\",\n \"component\": \"Card\",\n \"slot\": \"off\",\n \"children\": [\"off-section\"]\n },\n {\n \"id\": \"off-section\",\n \"component\": \"Section\",\n \"children\": [\"off-text\"]\n },\n {\n \"id\": \"off-text\",\n \"component\": \"Text\",\n \"textContent\": \"Bare surface — no trait attached.\"\n },\n {\n \"id\": \"on\",\n \"component\": \"Card\",\n \"slot\": \"on\",\n \"children\": [\"on-section\"]\n },\n {\n \"id\": \"on-section\",\n \"component\": \"Section\",\n \"children\": [\"on-text\"]\n },\n {\n \"id\": \"on-text\",\n \"component\": \"Text\",\n \"textContent\": \"Same surface, with trait attached.\"\n }\n]",
|
|
65
|
+
"name": "with-without-trait"
|
|
66
|
+
}
|
|
67
|
+
],
|
|
68
|
+
"keywords": [
|
|
69
|
+
"demo",
|
|
70
|
+
"toggle",
|
|
71
|
+
"compare",
|
|
72
|
+
"comparison",
|
|
73
|
+
"trait",
|
|
74
|
+
"before-after",
|
|
75
|
+
"a-b",
|
|
76
|
+
"switch"
|
|
77
|
+
],
|
|
78
|
+
"name": "UIDemoToggle",
|
|
79
|
+
"related": [
|
|
80
|
+
"Switch",
|
|
81
|
+
"Card"
|
|
82
|
+
],
|
|
83
|
+
"slots": {
|
|
84
|
+
"bar": {
|
|
85
|
+
"description": "Internal header bar (auto-stamped). Hosts the embedded switch + label."
|
|
86
|
+
},
|
|
87
|
+
"off": {
|
|
88
|
+
"description": "Surface rendered when state is \"off\" (the \"without trait\" variant)."
|
|
89
|
+
},
|
|
90
|
+
"on": {
|
|
91
|
+
"description": "Surface rendered when state is \"on\" (the \"with trait\" variant)."
|
|
92
|
+
}
|
|
93
|
+
},
|
|
94
|
+
"states": [
|
|
95
|
+
{
|
|
96
|
+
"description": "Default, ready for interaction.",
|
|
97
|
+
"name": "idle"
|
|
98
|
+
},
|
|
99
|
+
{
|
|
100
|
+
"description": "On slot is active; bar leans accent.",
|
|
101
|
+
"attribute": "data-state",
|
|
102
|
+
"name": "on"
|
|
103
|
+
},
|
|
104
|
+
{
|
|
105
|
+
"description": "Off slot is active; bar is neutral.",
|
|
106
|
+
"attribute": "data-state",
|
|
107
|
+
"name": "off"
|
|
108
|
+
}
|
|
109
|
+
],
|
|
110
|
+
"synonyms": {
|
|
111
|
+
"compare": [
|
|
112
|
+
"demo-toggle",
|
|
113
|
+
"segmented"
|
|
114
|
+
],
|
|
115
|
+
"switch": [
|
|
116
|
+
"demo-toggle",
|
|
117
|
+
"toggle-group"
|
|
118
|
+
]
|
|
119
|
+
},
|
|
120
|
+
"tag": "demo-toggle-ui",
|
|
121
|
+
"tokens": {
|
|
122
|
+
"--demo-toggle-bar-bg": {
|
|
123
|
+
"description": "Header bar background (accent when state=on)"
|
|
124
|
+
},
|
|
125
|
+
"--demo-toggle-bar-fg": {
|
|
126
|
+
"description": "Header bar foreground"
|
|
127
|
+
},
|
|
128
|
+
"--demo-toggle-bg": {
|
|
129
|
+
"description": "Stage background"
|
|
130
|
+
},
|
|
131
|
+
"--demo-toggle-border": {
|
|
132
|
+
"description": "Border color"
|
|
133
|
+
},
|
|
134
|
+
"--demo-toggle-radius": {
|
|
135
|
+
"description": "Outer border-radius"
|
|
136
|
+
},
|
|
137
|
+
"--demo-toggle-stage-padding": {
|
|
138
|
+
"description": "Inner padding around each slot"
|
|
139
|
+
}
|
|
140
|
+
},
|
|
141
|
+
"traits": [],
|
|
142
|
+
"version": 1
|
|
143
|
+
}
|
|
144
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
@scope (demo-toggle-ui) {
|
|
2
|
+
:where(:scope) {
|
|
3
|
+
/* ── Layout ── */
|
|
4
|
+
--demo-toggle-radius: var(--a-radius-lg);
|
|
5
|
+
--demo-toggle-gap: var(--a-space-3);
|
|
6
|
+
--demo-toggle-stage-padding: var(--a-space-4);
|
|
7
|
+
--demo-toggle-bar-px: var(--a-space-3);
|
|
8
|
+
--demo-toggle-bar-py: var(--a-space-2);
|
|
9
|
+
|
|
10
|
+
/* ── Colors ── */
|
|
11
|
+
--demo-toggle-bg: var(--a-canvas-1);
|
|
12
|
+
--demo-toggle-border: var(--a-border);
|
|
13
|
+
--demo-toggle-bar-bg: var(--a-bg-muted);
|
|
14
|
+
--demo-toggle-bar-fg: var(--a-fg);
|
|
15
|
+
|
|
16
|
+
/* ── Typography ── */
|
|
17
|
+
--demo-toggle-label-size: var(--a-ui-size);
|
|
18
|
+
--demo-toggle-label-weight: var(--a-weight-medium, 500);
|
|
19
|
+
|
|
20
|
+
/* ── Transition ── */
|
|
21
|
+
--demo-toggle-duration: var(--a-duration-fast);
|
|
22
|
+
--demo-toggle-easing: var(--a-easing);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
:scope {
|
|
26
|
+
/* ── Base ── */
|
|
27
|
+
box-sizing: border-box;
|
|
28
|
+
display: flex;
|
|
29
|
+
flex-direction: column;
|
|
30
|
+
gap: 0;
|
|
31
|
+
width: 100%;
|
|
32
|
+
|
|
33
|
+
background: var(--demo-toggle-bg);
|
|
34
|
+
border: 1px solid var(--demo-toggle-border);
|
|
35
|
+
border-radius: var(--demo-toggle-radius);
|
|
36
|
+
overflow: hidden;
|
|
37
|
+
isolation: isolate;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/* -- Header bar -- */
|
|
41
|
+
[slot="bar"] {
|
|
42
|
+
width: 100%;
|
|
43
|
+
padding: var(--demo-toggle-bar-py) var(--demo-toggle-bar-px);
|
|
44
|
+
background: var(--demo-toggle-bar-bg);
|
|
45
|
+
color: var(--demo-toggle-bar-fg);
|
|
46
|
+
border-bottom: 1px solid var(--demo-toggle-border);
|
|
47
|
+
justify-content: space-between;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
[data-demo-toggle-label] {
|
|
51
|
+
font-size: var(--demo-toggle-label-size);
|
|
52
|
+
font-weight: var(--demo-toggle-label-weight);
|
|
53
|
+
color: var(--demo-toggle-bar-fg);
|
|
54
|
+
transition: color var(--demo-toggle-duration) var(--demo-toggle-easing);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/* -- Stage area (where the slotted content lives) -- */
|
|
58
|
+
/* Slotted children are anything that isn't the [slot="bar"]. They sit in
|
|
59
|
+
the second flex row; default mode shows only the active slot. */
|
|
60
|
+
:scope > [slot="on"],
|
|
61
|
+
:scope > [slot="off"] {
|
|
62
|
+
box-sizing: border-box;
|
|
63
|
+
width: 100%;
|
|
64
|
+
padding: var(--demo-toggle-stage-padding);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
:scope[data-state="on"] > [slot="off"] { display: none; }
|
|
68
|
+
:scope[data-state="off"] > [slot="on"] { display: none; }
|
|
69
|
+
|
|
70
|
+
/* Overlay mode — both slots stack on the same coordinates so toggling
|
|
71
|
+
never reflows the surrounding layout. The inactive slot is
|
|
72
|
+
visibility-hidden (still occupying space → still measurable for the
|
|
73
|
+
active one). The stack is a 1-cell grid; both slots target row 1
|
|
74
|
+
col 1 via grid-area. */
|
|
75
|
+
:scope[data-mode="overlay"] {
|
|
76
|
+
/* The bar is row 1; the stage cell is row 2. The cell's children all
|
|
77
|
+
claim the same grid-area so they overlap. */
|
|
78
|
+
}
|
|
79
|
+
:scope[data-mode="overlay"] > [slot="on"],
|
|
80
|
+
:scope[data-mode="overlay"] > [slot="off"] {
|
|
81
|
+
display: block;
|
|
82
|
+
}
|
|
83
|
+
:scope[data-mode="overlay"] {
|
|
84
|
+
/* Use a wrapper-less stack: rely on the parent flex column for the
|
|
85
|
+
bar/stage stack and turn the stage region into a relative anchor
|
|
86
|
+
via the second slot's positioning. */
|
|
87
|
+
position: relative;
|
|
88
|
+
}
|
|
89
|
+
:scope[data-mode="overlay"] > [slot="on"] {
|
|
90
|
+
position: absolute;
|
|
91
|
+
inset: 0;
|
|
92
|
+
/* Bar height — the slot starts beneath the bar. We can't query the
|
|
93
|
+
bar's height in CSS, so the stage region uses the larger of the
|
|
94
|
+
two slot intrinsic heights (default flow), and the active
|
|
95
|
+
slot positions on top via inset:0 starting after the bar. The
|
|
96
|
+
enclosing :scope provides padding-top via the bar; the slot's
|
|
97
|
+
own padding still applies. */
|
|
98
|
+
top: auto;
|
|
99
|
+
height: auto;
|
|
100
|
+
}
|
|
101
|
+
:scope[data-mode="overlay"][data-state="on"] > [slot="off"] {
|
|
102
|
+
visibility: hidden;
|
|
103
|
+
}
|
|
104
|
+
:scope[data-mode="overlay"][data-state="off"] > [slot="on"] {
|
|
105
|
+
visibility: hidden;
|
|
106
|
+
}
|
|
107
|
+
/* Reset display:none branches above for overlay — already handled by
|
|
108
|
+
visibility:hidden, but the default-mode display:none rules above
|
|
109
|
+
would otherwise fight overlay. Override here. */
|
|
110
|
+
:scope[data-mode="overlay"][data-state="on"] > [slot="off"],
|
|
111
|
+
:scope[data-mode="overlay"][data-state="off"] > [slot="on"] {
|
|
112
|
+
display: block;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/* State-themed bar — when on, lean accent; when off, leave neutral. */
|
|
116
|
+
:scope[data-state="on"] {
|
|
117
|
+
--demo-toggle-bar-bg: var(--a-accent-muted);
|
|
118
|
+
--demo-toggle-bar-fg: var(--a-accent-strong);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* <demo-toggle-ui>
|
|
3
|
+
* <section slot="off">…without trait…</section>
|
|
4
|
+
* <section slot="on">…with trait…</section>
|
|
5
|
+
* </demo-toggle-ui>
|
|
6
|
+
*
|
|
7
|
+
* Side-by-side "with trait / without trait" comparison primitive.
|
|
8
|
+
* Renders a header bar with a switch-ui that flips which child slot is
|
|
9
|
+
* visible. Reflects [data-state="on"|"off"] on the host so consumer CSS
|
|
10
|
+
* can theme the bar around the active variant.
|
|
11
|
+
*
|
|
12
|
+
* Two layout modes:
|
|
13
|
+
* default — only the active slot is in the layout (the other is `display: none`).
|
|
14
|
+
* data-mode="overlay" — both slots stack on the same coordinates; inactive
|
|
15
|
+
* slot is `visibility: hidden` so layout never shifts.
|
|
16
|
+
*
|
|
17
|
+
* Authoring API:
|
|
18
|
+
* [label-on] — header label when state=on (default: "On")
|
|
19
|
+
* [label-off] — header label when state=off (default: "Off")
|
|
20
|
+
* [initial] — "on" | "off"; only consulted when [state] is unset on connect
|
|
21
|
+
* [state] — "on" | "off"; reflected; the live state attribute
|
|
22
|
+
*
|
|
23
|
+
* Events:
|
|
24
|
+
* change — { detail: { state: "on" | "off" } }
|
|
25
|
+
*
|
|
26
|
+
* Keyboard:
|
|
27
|
+
* The embedded <switch-ui> handles space + enter via its own keydown wiring.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import { UIElement } from '../../core/element.js';
|
|
31
|
+
import '../switch/switch.js';
|
|
32
|
+
import '../row/row.js';
|
|
33
|
+
import '../text/text.js';
|
|
34
|
+
|
|
35
|
+
class UIDemoToggle extends UIElement {
|
|
36
|
+
static properties = {
|
|
37
|
+
labelOn: { type: String, default: 'On', attribute: 'label-on', reflect: true },
|
|
38
|
+
labelOff: { type: String, default: 'Off', attribute: 'label-off', reflect: true },
|
|
39
|
+
initial: { type: String, default: 'off', reflect: true },
|
|
40
|
+
state: { type: String, default: '', reflect: true },
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
// Header bar with a switch + active label. Stamped once per class via
|
|
44
|
+
// static parts; live label text is updated in render() so the same
|
|
45
|
+
// template covers every (label-on, label-off, state) tuple.
|
|
46
|
+
static parts = {
|
|
47
|
+
bar: `
|
|
48
|
+
<row-ui slot="bar" gap="3" align="center" data-demo-toggle-bar>
|
|
49
|
+
<text-ui slot="label" data-demo-toggle-label></text-ui>
|
|
50
|
+
<switch-ui slot="switch" data-demo-toggle-switch></switch-ui>
|
|
51
|
+
</row-ui>
|
|
52
|
+
`,
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
static template = () => null;
|
|
56
|
+
|
|
57
|
+
#bar = null;
|
|
58
|
+
#switch = null;
|
|
59
|
+
#label = null;
|
|
60
|
+
#bound = false;
|
|
61
|
+
|
|
62
|
+
connected() {
|
|
63
|
+
// Resolve the initial state. Honor [state] if explicitly authored;
|
|
64
|
+
// otherwise fall back to [initial] (default "off"). Anything other
|
|
65
|
+
// than the literal "on" coerces to "off" so authors can't poison
|
|
66
|
+
// the data-state attribute with arbitrary values.
|
|
67
|
+
if (!this.state) {
|
|
68
|
+
const seed = (this.initial === 'on') ? 'on' : 'off';
|
|
69
|
+
this.state = seed;
|
|
70
|
+
} else {
|
|
71
|
+
this.state = (this.state === 'on') ? 'on' : 'off';
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Stamp the header bar lazily — `this.ensure('bar')` clones the
|
|
75
|
+
// blueprint into the light DOM the first time it's called.
|
|
76
|
+
this.#bar = this.ensure('bar');
|
|
77
|
+
this.#switch = this.#bar.querySelector('[data-demo-toggle-switch]');
|
|
78
|
+
this.#label = this.#bar.querySelector('[data-demo-toggle-label]');
|
|
79
|
+
|
|
80
|
+
if (!this.#bound) {
|
|
81
|
+
this.#bound = true;
|
|
82
|
+
this.#switch.addEventListener('change', this.#onSwitchChange);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
disconnected() {
|
|
87
|
+
if (this.#switch) {
|
|
88
|
+
this.#switch.removeEventListener('change', this.#onSwitchChange);
|
|
89
|
+
}
|
|
90
|
+
this.#bar = null;
|
|
91
|
+
this.#switch = null;
|
|
92
|
+
this.#label = null;
|
|
93
|
+
this.#bound = false;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
render() {
|
|
97
|
+
if (!this.#bar) return;
|
|
98
|
+
const on = this.state === 'on';
|
|
99
|
+
|
|
100
|
+
// Sync the embedded switch to the host's state. checked is a
|
|
101
|
+
// reflected property; the assignment fires no event because
|
|
102
|
+
// syncValue() short-circuits when the next state matches.
|
|
103
|
+
if (this.#switch.checked !== on) {
|
|
104
|
+
this.#switch.checked = on;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (this.#label) {
|
|
108
|
+
this.#label.textContent = on ? this.labelOn : this.labelOff;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Public toggle method — flips the host between on and off and emits
|
|
114
|
+
* a `change` event. Mirrors the click-on-switch path so consumers
|
|
115
|
+
* can drive the state from outside without touching the embedded
|
|
116
|
+
* switch directly.
|
|
117
|
+
*/
|
|
118
|
+
toggle() {
|
|
119
|
+
this.state = (this.state === 'on') ? 'off' : 'on';
|
|
120
|
+
this.dispatchEvent(new CustomEvent('change', {
|
|
121
|
+
bubbles: true,
|
|
122
|
+
detail: { state: this.state },
|
|
123
|
+
}));
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
#onSwitchChange = (e) => {
|
|
127
|
+
// The embedded <switch-ui> fires a bubbling `change`. Stop it at the
|
|
128
|
+
// host boundary — we re-dispatch our own `change` with detail.state
|
|
129
|
+
// so consumers see exactly one event per toggle, not the inner
|
|
130
|
+
// switch's bare event AND our annotated one.
|
|
131
|
+
e.stopPropagation();
|
|
132
|
+
const next = this.#switch.checked ? 'on' : 'off';
|
|
133
|
+
if (next === this.state) return;
|
|
134
|
+
this.state = next;
|
|
135
|
+
this.dispatchEvent(new CustomEvent('change', {
|
|
136
|
+
bubbles: true,
|
|
137
|
+
detail: { state: this.state },
|
|
138
|
+
}));
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
customElements.define('demo-toggle-ui', UIDemoToggle);
|
|
143
|
+
|
|
144
|
+
export { UIDemoToggle };
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from 'vitest';
|
|
2
|
+
import '../../core/element.js';
|
|
3
|
+
import './demo-toggle.js';
|
|
4
|
+
|
|
5
|
+
const tick = () => new Promise((r) => queueMicrotask(r));
|
|
6
|
+
|
|
7
|
+
function mount(html) {
|
|
8
|
+
const wrap = document.createElement('div');
|
|
9
|
+
wrap.innerHTML = html;
|
|
10
|
+
document.body.appendChild(wrap);
|
|
11
|
+
return wrap.firstElementChild;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
describe('demo-toggle-ui', () => {
|
|
15
|
+
beforeEach(() => { document.body.innerHTML = ''; });
|
|
16
|
+
|
|
17
|
+
it('registers demo-toggle-ui as a custom element', () => {
|
|
18
|
+
expect(customElements.get('demo-toggle-ui')).toBeDefined();
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('defaults to state="off" when [initial] is not set', async () => {
|
|
22
|
+
const el = mount('<demo-toggle-ui></demo-toggle-ui>');
|
|
23
|
+
await tick();
|
|
24
|
+
expect(el.state).toBe('off');
|
|
25
|
+
expect(el.getAttribute('state')).toBe('off');
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('honors [initial="on"] on connect', async () => {
|
|
29
|
+
const el = mount('<demo-toggle-ui initial="on"></demo-toggle-ui>');
|
|
30
|
+
await tick();
|
|
31
|
+
expect(el.state).toBe('on');
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('renders the auto-stamped header bar with switch + label', async () => {
|
|
35
|
+
const el = mount('<demo-toggle-ui label-on="Foo" label-off="Bar"></demo-toggle-ui>');
|
|
36
|
+
await tick();
|
|
37
|
+
const bar = el.querySelector('[slot="bar"]');
|
|
38
|
+
const sw = el.querySelector('[data-demo-toggle-switch]');
|
|
39
|
+
const label = el.querySelector('[data-demo-toggle-label]');
|
|
40
|
+
expect(bar).toBeTruthy();
|
|
41
|
+
expect(sw).toBeTruthy();
|
|
42
|
+
expect(label).toBeTruthy();
|
|
43
|
+
// state defaults to "off"; label-off should be active
|
|
44
|
+
expect(label.textContent).toBe('Bar');
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('toggle() flips state and emits change with detail.state', async () => {
|
|
48
|
+
const el = mount('<demo-toggle-ui></demo-toggle-ui>');
|
|
49
|
+
await tick();
|
|
50
|
+
const events = [];
|
|
51
|
+
el.addEventListener('change', (e) => events.push(e.detail));
|
|
52
|
+
|
|
53
|
+
el.toggle();
|
|
54
|
+
expect(el.state).toBe('on');
|
|
55
|
+
expect(events).toHaveLength(1);
|
|
56
|
+
expect(events[0]).toEqual({ state: 'on' });
|
|
57
|
+
|
|
58
|
+
el.toggle();
|
|
59
|
+
expect(el.state).toBe('off');
|
|
60
|
+
expect(events).toHaveLength(2);
|
|
61
|
+
expect(events[1]).toEqual({ state: 'off' });
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('clicking the embedded switch flips host state and bubbles change', async () => {
|
|
65
|
+
const el = mount('<demo-toggle-ui></demo-toggle-ui>');
|
|
66
|
+
await tick();
|
|
67
|
+
const sw = el.querySelector('[data-demo-toggle-switch]');
|
|
68
|
+
|
|
69
|
+
const events = [];
|
|
70
|
+
el.addEventListener('change', (e) => events.push(e.detail));
|
|
71
|
+
|
|
72
|
+
sw.click();
|
|
73
|
+
await tick();
|
|
74
|
+
expect(el.state).toBe('on');
|
|
75
|
+
expect(events).toHaveLength(1);
|
|
76
|
+
expect(events[0]).toEqual({ state: 'on' });
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('reflects state to the [state] attribute', async () => {
|
|
80
|
+
const el = mount('<demo-toggle-ui initial="on"></demo-toggle-ui>');
|
|
81
|
+
await tick();
|
|
82
|
+
expect(el.getAttribute('state')).toBe('on');
|
|
83
|
+
el.toggle();
|
|
84
|
+
expect(el.getAttribute('state')).toBe('off');
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('coerces an invalid [state] attribute to "off"', async () => {
|
|
88
|
+
const el = mount('<demo-toggle-ui state="garbage"></demo-toggle-ui>');
|
|
89
|
+
await tick();
|
|
90
|
+
expect(el.state).toBe('off');
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('updates the active label when state flips', async () => {
|
|
94
|
+
const el = mount('<demo-toggle-ui label-on="With trait" label-off="Without trait"></demo-toggle-ui>');
|
|
95
|
+
await tick();
|
|
96
|
+
const label = el.querySelector('[data-demo-toggle-label]');
|
|
97
|
+
expect(label.textContent).toBe('Without trait');
|
|
98
|
+
el.toggle();
|
|
99
|
+
await tick();
|
|
100
|
+
expect(label.textContent).toBe('With trait');
|
|
101
|
+
});
|
|
102
|
+
});
|