@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.
Files changed (121) hide show
  1. package/components/agent-trace/agent-trace.css +24 -3
  2. package/components/button/button.js +3 -0
  3. package/components/demo-toggle/demo-toggle.a2ui.json +144 -0
  4. package/components/demo-toggle/demo-toggle.css +120 -0
  5. package/components/demo-toggle/demo-toggle.js +144 -0
  6. package/components/demo-toggle/demo-toggle.test.js +102 -0
  7. package/components/demo-toggle/demo-toggle.yaml +144 -0
  8. package/components/index.js +1 -0
  9. package/components/input/input.js +11 -0
  10. package/components/list/list.css +66 -3
  11. package/components/nav-group/nav-group.a2ui.json +1 -1
  12. package/components/nav-group/nav-group.css +5 -5
  13. package/components/nav-group/nav-group.yaml +1 -1
  14. package/components/nav-item/nav-item.a2ui.json +1 -1
  15. package/components/nav-item/nav-item.css +3 -4
  16. package/components/nav-item/nav-item.yaml +1 -1
  17. package/components/textarea/textarea.js +10 -0
  18. package/core/icons.js +13 -1
  19. package/package.json +1 -1
  20. package/styles/components.css +1 -0
  21. package/styles/typography.css +1 -1
  22. package/traits/_catalog.json +258 -5
  23. package/traits/active-state.test.js +1 -1
  24. package/traits/anchor-positioning.js +205 -52
  25. package/traits/anchor-positioning.test.js +77 -4
  26. package/traits/announcer-stage.js +157 -0
  27. package/traits/announcer.js +145 -0
  28. package/traits/announcer.test.js +268 -0
  29. package/traits/arrow-grid-nav.js +234 -0
  30. package/traits/arrow-grid-nav.test.js +375 -0
  31. package/traits/attention-pulse.js +1 -1
  32. package/traits/attention-pulse.test.js +1 -1
  33. package/traits/confetti-burst.js +90 -60
  34. package/traits/confetti-burst.test.js +16 -8
  35. package/traits/confetti-stage.js +143 -0
  36. package/traits/confetti.js +44 -47
  37. package/traits/confetti.test.js +24 -5
  38. package/traits/count-up.js +31 -6
  39. package/traits/count-up.test.js +1 -1
  40. package/traits/declarative.test.js +1 -1
  41. package/traits/dirty-state.test.js +1 -1
  42. package/traits/drag-ghost.js +55 -3
  43. package/traits/drag-ghost.test.js +1 -1
  44. package/traits/draggable-list-item.js +279 -0
  45. package/traits/draggable-list-item.test.js +51 -0
  46. package/traits/draggable.js +14 -4
  47. package/traits/draggable.test.js +1 -1
  48. package/traits/drop-target.js +223 -0
  49. package/traits/drop-target.test.js +241 -0
  50. package/traits/droppable-collection.js +89 -0
  51. package/traits/droppable-collection.test.js +99 -0
  52. package/traits/droppable.js +125 -0
  53. package/traits/droppable.test.js +54 -0
  54. package/traits/error-shake.js +157 -0
  55. package/traits/error-shake.test.js +114 -0
  56. package/traits/fade-presence.test.js +1 -1
  57. package/traits/focus-restore.js +135 -0
  58. package/traits/focus-restore.test.js +202 -0
  59. package/traits/focus-trap.test.js +1 -1
  60. package/traits/focusable.test.js +1 -1
  61. package/traits/glow-focus.js +1 -1
  62. package/traits/glow-focus.test.js +1 -1
  63. package/traits/gradient-shift.js +1 -1
  64. package/traits/gradient-shift.test.js +1 -1
  65. package/traits/haptic-feedback.test.js +1 -1
  66. package/traits/hotkey.test.js +1 -1
  67. package/traits/hoverable.test.js +1 -1
  68. package/traits/index.js +15 -0
  69. package/traits/inertia-drag.js +9 -0
  70. package/traits/inertia-drag.test.js +1 -1
  71. package/traits/input-mask.js +328 -0
  72. package/traits/input-mask.test.js +151 -0
  73. package/traits/intersection-observer.test.js +1 -1
  74. package/traits/keyboard-nav.test.js +1 -1
  75. package/traits/keyboard-reorderable.js +254 -0
  76. package/traits/keyboard-reorderable.test.js +45 -0
  77. package/traits/layout-animation.js +229 -0
  78. package/traits/layout-animation.test.js +114 -0
  79. package/traits/long-press.js +212 -0
  80. package/traits/long-press.test.js +244 -0
  81. package/traits/magnetic-hover.js +1 -1
  82. package/traits/magnetic-hover.test.js +1 -1
  83. package/traits/noise-texture.js +7 -3
  84. package/traits/noise-texture.test.js +1 -1
  85. package/traits/parallax.js +1 -1
  86. package/traits/parallax.test.js +1 -1
  87. package/traits/portal.test.js +1 -1
  88. package/traits/pressable.test.js +1 -1
  89. package/traits/resettable.js +29 -3
  90. package/traits/resettable.test.js +34 -1
  91. package/traits/resizable.test.js +1 -1
  92. package/traits/resize-observer.test.js +1 -1
  93. package/traits/ripple.js +1 -1
  94. package/traits/ripple.test.js +1 -1
  95. package/traits/roving-tabindex.test.js +1 -1
  96. package/traits/scale-press.test.js +1 -1
  97. package/traits/scroll-lock.test.js +1 -1
  98. package/traits/scroll-progress.js +201 -0
  99. package/traits/scroll-progress.test.js +182 -0
  100. package/traits/shimmer-loading.js +1 -1
  101. package/traits/shimmer-loading.test.js +1 -1
  102. package/traits/{_smoke.test.js → smoke.test.js} +1 -1
  103. package/traits/snap-to-grid.test.js +1 -1
  104. package/traits/sound-feedback.test.js +1 -1
  105. package/traits/spring-animate.js +8 -3
  106. package/traits/spring-animate.test.js +1 -1
  107. package/traits/success-checkmark.js +222 -0
  108. package/traits/success-checkmark.test.js +120 -0
  109. package/traits/tilt-hover.js +1 -1
  110. package/traits/tilt-hover.test.js +1 -1
  111. package/traits/tossable.js +9 -0
  112. package/traits/tossable.test.js +1 -1
  113. package/traits/traits-host.test.js +1 -1
  114. package/traits/typeahead.test.js +1 -1
  115. package/traits/typewriter.js +1 -1
  116. package/traits/typewriter.test.js +1 -1
  117. package/traits/validation.test.js +1 -1
  118. package/traits/view-transition.js +140 -0
  119. package/traits/view-transition.test.js +268 -0
  120. /package/traits/{_motion.js → motion.js} +0 -0
  121. /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
- --agent-trace-row-label-col: 80px;
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
- text-align: right;
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
+ });