@vmz/ui 0.0.2 → 0.0.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 (53) hide show
  1. package/README.md +25 -3
  2. package/conformance/fixtures/button-control-motion.json +22 -0
  3. package/conformance/fixtures/dialog-overlay-motion.json +27 -0
  4. package/conformance/fixtures/drawer-overlay-motion.json +27 -0
  5. package/conformance/pack.v0.json +7 -0
  6. package/contracts/token-requirements.v0.json +604 -2
  7. package/package.json +50 -2
  8. package/presets/web-surface.v0.json +34 -0
  9. package/src/components/Accordion.vmz +112 -0
  10. package/src/components/Alert.vmz +84 -0
  11. package/src/components/AppShell.vmz +97 -0
  12. package/src/components/Autocomplete.vmz +176 -0
  13. package/src/components/Badge.vmz +63 -0
  14. package/src/components/Breadcrumb.vmz +66 -0
  15. package/src/components/BulkActions.vmz +52 -0
  16. package/src/components/Button.vmz +71 -4
  17. package/src/components/Callout.vmz +80 -0
  18. package/src/components/Card.vmz +81 -0
  19. package/src/components/Checkbox.vmz +69 -0
  20. package/src/components/CodeBlock.vmz +67 -0
  21. package/src/components/ConsoleShell.vmz +127 -0
  22. package/src/components/DataTable.vmz +220 -0
  23. package/src/components/DatePicker.vmz +93 -0
  24. package/src/components/Dialog.vmz +442 -0
  25. package/src/components/Drawer.vmz +435 -0
  26. package/src/components/Empty.vmz +52 -0
  27. package/src/components/Field.vmz +88 -0
  28. package/src/components/FilterBar.vmz +27 -0
  29. package/src/components/Form.vmz +72 -0
  30. package/src/components/FormItem.vmz +78 -0
  31. package/src/components/Link.vmz +48 -0
  32. package/src/components/List.vmz +116 -0
  33. package/src/components/Menu.vmz +270 -0
  34. package/src/components/Notification.vmz +83 -0
  35. package/src/components/Pagination.vmz +81 -0
  36. package/src/components/Popover.vmz +253 -0
  37. package/src/components/Prose.vmz +83 -0
  38. package/src/components/QueryForm.vmz +39 -0
  39. package/src/components/RadioGroup.vmz +135 -0
  40. package/src/components/Result.vmz +84 -0
  41. package/src/components/Select.vmz +351 -0
  42. package/src/components/Skeleton.vmz +80 -0
  43. package/src/components/Spinner.vmz +45 -0
  44. package/src/components/Steps.vmz +107 -0
  45. package/src/components/Switch.vmz +109 -0
  46. package/src/components/Table.vmz +134 -0
  47. package/src/components/Tabs.vmz +132 -0
  48. package/src/components/TextArea.vmz +91 -0
  49. package/src/components/Timeline.vmz +67 -0
  50. package/src/components/Toc.vmz +77 -0
  51. package/src/components/Tooltip.vmz +68 -0
  52. package/src/components/Tree.vmz +215 -0
  53. package/src/components/Upload.vmz +102 -0
@@ -0,0 +1,351 @@
1
+ <template>
2
+ <div
3
+ class={'vmz-ui-select' + (open ? ' is-opened' : '')}
4
+ data-vmz-ui="select"
5
+ data-invalid={invalidAttr}
6
+ >
7
+ <label if={label} class="vmz-ui-select__label" for={controlId}>{label}</label>
8
+ <button
9
+ type="button"
10
+ id={controlId}
11
+ class="vmz-ui-select__control"
12
+ disabled={disabled}
13
+ aria-haspopup="listbox"
14
+ aria-expanded={open ? 'true' : 'false'}
15
+ aria-controls={listId}
16
+ aria-invalid={error ? 'true' : 'false'}
17
+ aria-describedby={describedBy}
18
+ onClick={toggle}
19
+ onKeyDown={onTriggerKeyDown}
20
+ >
21
+ <span class="vmz-ui-select__value">{displayLabel}</span>
22
+ <span class="vmz-ui-select__chev" aria-hidden="true"></span>
23
+ </button>
24
+ <ul
25
+ if={open}
26
+ id={listId}
27
+ class="vmz-ui-select__list"
28
+ role="listbox"
29
+ tabindex="-1"
30
+ data-vmz-overlay="select"
31
+ data-vmz-overlay-owner="select"
32
+ data-vmz-focus="enter"
33
+ onKeyDown={onListKeyDown}
34
+ >
35
+ <li each={options} as="opt" key={opt.value} role="presentation">
36
+ <button
37
+ type="button"
38
+ class={'vmz-ui-select__option' + (opt.value === value ? ' is-on' : '')}
39
+ role="option"
40
+ data-vmz-option={opt.value}
41
+ aria-selected={opt.value === value ? 'true' : 'false'}
42
+ onClick={onOptionClick}
43
+ >{opt.label}</button>
44
+ </li>
45
+ </ul>
46
+ <p if={description} id={descriptionId} class="vmz-ui-select__description">{description}</p>
47
+ <p if={error} id={errorId} class="vmz-ui-select__error" role="alert">{error}</p>
48
+ </div>
49
+ </template>
50
+
51
+ <style>
52
+ .vmz-ui-select {
53
+ position: relative;
54
+ display: flex;
55
+ flex-direction: column;
56
+ gap: 0.35rem;
57
+ max-width: 28rem;
58
+ }
59
+
60
+ .vmz-ui-select__label {
61
+ font: inherit;
62
+ font-weight: 600;
63
+ }
64
+
65
+ .vmz-ui-select__control {
66
+ display: inline-flex;
67
+ align-items: center;
68
+ justify-content: space-between;
69
+ gap: 0.55rem;
70
+ width: 100%;
71
+ box-sizing: border-box;
72
+ font: inherit;
73
+ text-align: left;
74
+ padding: var(--vmz-density-control-padding-y) var(--vmz-density-control-padding-x);
75
+ border: 1px solid var(--vmz-action-primary-background);
76
+ border-radius: 2px;
77
+ background: var(--vmz-surface-paper, transparent);
78
+ color: inherit;
79
+ outline: none;
80
+ cursor: pointer;
81
+ }
82
+
83
+ .vmz-ui-select[data-invalid='true'] .vmz-ui-select__control {
84
+ border-color: var(--vmz-status-danger-accent);
85
+ }
86
+
87
+ .vmz-ui-select__control:focus-visible,
88
+ .vmz-ui-select.is-opened .vmz-ui-select__control {
89
+ box-shadow: 0 0 0 2px var(--vmz-focus-ring);
90
+ }
91
+
92
+ .vmz-ui-select__control:disabled {
93
+ opacity: 0.55;
94
+ cursor: not-allowed;
95
+ }
96
+
97
+ .vmz-ui-select__value {
98
+ flex: 1 1 auto;
99
+ min-width: 0;
100
+ overflow: hidden;
101
+ text-overflow: ellipsis;
102
+ white-space: nowrap;
103
+ }
104
+
105
+ .vmz-ui-select__chev {
106
+ flex: 0 0 auto;
107
+ width: 0.45rem;
108
+ height: 0.45rem;
109
+ border-right: 1.5px solid currentColor;
110
+ border-bottom: 1.5px solid currentColor;
111
+ transform: rotate(45deg) translate(-1px, -2px);
112
+ opacity: 0.7;
113
+ }
114
+
115
+ .vmz-ui-select.is-opened .vmz-ui-select__chev {
116
+ transform: rotate(225deg) translate(-1px, -1px);
117
+ }
118
+
119
+ .vmz-ui-select__list {
120
+ position: absolute;
121
+ z-index: 40;
122
+ top: calc(100% + 0.25rem);
123
+ left: 0;
124
+ right: 0;
125
+ margin: 0;
126
+ padding: 0.25rem;
127
+ list-style: none;
128
+ max-height: 16rem;
129
+ overflow: auto;
130
+ border: 1px solid var(--vmz-action-primary-background);
131
+ border-radius: 2px;
132
+ background: var(--vmz-surface-paper, #fff);
133
+ color: inherit;
134
+ box-shadow: 0 10px 24px color-mix(in srgb, #000 16%, transparent);
135
+ outline: none;
136
+ }
137
+
138
+ .vmz-ui-select__option {
139
+ display: block;
140
+ width: 100%;
141
+ box-sizing: border-box;
142
+ text-align: left;
143
+ font: inherit;
144
+ padding: 0.4rem 0.65rem;
145
+ border: 0;
146
+ border-radius: 2px;
147
+ background: transparent;
148
+ color: inherit;
149
+ cursor: pointer;
150
+ outline: none;
151
+ }
152
+
153
+ .vmz-ui-select__option:hover,
154
+ .vmz-ui-select__option:focus-visible {
155
+ background: color-mix(in srgb, var(--vmz-action-primary-background) 18%, transparent);
156
+ }
157
+
158
+ .vmz-ui-select__option.is-on {
159
+ background: color-mix(in srgb, var(--vmz-action-primary-background) 28%, transparent);
160
+ font-weight: 600;
161
+ }
162
+
163
+ .vmz-ui-select__description {
164
+ margin: 0;
165
+ opacity: 0.75;
166
+ font-size: 0.9em;
167
+ }
168
+
169
+ .vmz-ui-select__error {
170
+ margin: 0;
171
+ color: var(--vmz-status-danger-foreground);
172
+ font-size: 0.9em;
173
+ }
174
+ </style>
175
+
176
+ <script client>
177
+ /**
178
+ * VMZ UI — Select.
179
+ * Owned listbox (button + options panel). Never native <select> option popup:
180
+ * OS popup cannot carry Style Theme / density / overlay ownership.
181
+ * Parent owns value + options; change via onChange Event-like { target: { value } }.
182
+ */
183
+ export default class Select {
184
+ public label: string = '';
185
+ public description: string = '';
186
+ public error: string = '';
187
+ public value: string = '';
188
+ public options: Array<{ value: string; label: string }> = [];
189
+ public disabled: boolean = false;
190
+ public invalidAttr: string = 'false';
191
+ public controlId: string = 'vmz-select';
192
+ public listId: string = 'vmz-select-list';
193
+ public descriptionId: string = 'vmz-select-desc';
194
+ public errorId: string = 'vmz-select-err';
195
+ public describedBy: string = '';
196
+ public onChange: ((e: { target: { value: string } }) => void) | null = null;
197
+
198
+ open = false;
199
+ displayLabel = '';
200
+
201
+ _docKeyHandler = null;
202
+ _docPointerHandler = null;
203
+ _observer = null;
204
+
205
+ onMount() {
206
+ this.syncLabel();
207
+ this.listId = `${this.controlId}-list`;
208
+ if (typeof document === 'undefined' || typeof MutationObserver === 'undefined') return;
209
+ const root = this.__vmzDomRoot;
210
+ if (!root || typeof root.querySelector !== 'function') return;
211
+ this._observer = new MutationObserver(() => this._syncOverlay());
212
+ this._observer.observe(root, { childList: true, subtree: true });
213
+ }
214
+
215
+ onDestroy() {
216
+ if (this._observer) {
217
+ this._observer.disconnect();
218
+ this._observer = null;
219
+ }
220
+ this._teardownDoc();
221
+ }
222
+
223
+ syncLabel() {
224
+ const hit = (this.options || []).find((o) => o.value === this.value);
225
+ this.displayLabel = hit ? hit.label : this.value || '—';
226
+ }
227
+
228
+ toggle() {
229
+ if (this.disabled) return;
230
+ if (this.open) this.close();
231
+ else this.open = true;
232
+ }
233
+
234
+ close() {
235
+ this.open = false;
236
+ }
237
+
238
+ choose(next: string) {
239
+ this.close();
240
+ if (next === this.value) return;
241
+ if (typeof this.onChange === 'function') {
242
+ this.onChange({ target: { value: next } });
243
+ }
244
+ }
245
+
246
+ onOptionClick(ev: Event) {
247
+ const el = ev?.currentTarget as HTMLElement | null;
248
+ const id = el && typeof el.getAttribute === 'function' ? el.getAttribute('data-vmz-option') : null;
249
+ if (id) this.choose(id);
250
+ }
251
+
252
+ onTriggerKeyDown(ev: KeyboardEvent) {
253
+ if (!ev || this.disabled) return;
254
+ if ((ev.key === 'ArrowDown' || ev.key === 'Enter' || ev.key === ' ') && !this.open) {
255
+ ev.preventDefault();
256
+ this.open = true;
257
+ } else if (ev.key === 'Escape' && this.open) {
258
+ ev.preventDefault();
259
+ this.close();
260
+ }
261
+ }
262
+
263
+ onListKeyDown(ev: KeyboardEvent) {
264
+ if (!ev) return;
265
+ if (ev.key === 'Escape') {
266
+ ev.preventDefault();
267
+ this.close();
268
+ return;
269
+ }
270
+ const root = this.__vmzDomRoot;
271
+ const list =
272
+ root && typeof root.querySelector === 'function'
273
+ ? root.querySelector(`[data-vmz-overlay="select"]`)
274
+ : null;
275
+ if (!list) return;
276
+ const nodes = [...list.querySelectorAll('[role="option"]')].filter(
277
+ (n) => n instanceof HTMLElement && n.offsetParent !== null,
278
+ );
279
+ if (!nodes.length) return;
280
+ const idx = nodes.indexOf(document.activeElement as Element);
281
+ if (ev.key === 'ArrowDown') {
282
+ ev.preventDefault();
283
+ (nodes[(idx + 1 + nodes.length) % nodes.length] as HTMLElement).focus();
284
+ } else if (ev.key === 'ArrowUp') {
285
+ ev.preventDefault();
286
+ (nodes[(idx - 1 + nodes.length) % nodes.length] as HTMLElement).focus();
287
+ } else if (ev.key === 'Home') {
288
+ ev.preventDefault();
289
+ (nodes[0] as HTMLElement).focus();
290
+ } else if (ev.key === 'End') {
291
+ ev.preventDefault();
292
+ (nodes[nodes.length - 1] as HTMLElement).focus();
293
+ } else if (ev.key === 'Enter' || ev.key === ' ') {
294
+ const active = document.activeElement as HTMLElement | null;
295
+ const id = active?.getAttribute?.('data-vmz-option');
296
+ if (id) {
297
+ ev.preventDefault();
298
+ this.choose(id);
299
+ }
300
+ }
301
+ }
302
+
303
+ _syncOverlay() {
304
+ const root = this.__vmzDomRoot;
305
+ const panel =
306
+ root && typeof root.querySelector === 'function'
307
+ ? root.querySelector('[data-vmz-overlay="select"][data-vmz-focus="enter"]')
308
+ : null;
309
+ if (panel && this.open) this._bindDoc(panel as HTMLElement);
310
+ else if (!panel) this._teardownDoc();
311
+ this.syncLabel();
312
+ }
313
+
314
+ _bindDoc(panel: HTMLElement) {
315
+ if (typeof document === 'undefined') return;
316
+ if (!this._docKeyHandler) {
317
+ this._docKeyHandler = (ev: KeyboardEvent) => {
318
+ if (ev.key === 'Escape') {
319
+ ev.preventDefault();
320
+ this.close();
321
+ }
322
+ };
323
+ document.addEventListener('keydown', this._docKeyHandler);
324
+ }
325
+ if (!this._docPointerHandler) {
326
+ const root = this.__vmzDomRoot;
327
+ this._docPointerHandler = (ev: Event) => {
328
+ const t = ev.target;
329
+ if (!(t instanceof Node)) return;
330
+ if (root && typeof root.contains === 'function' && root.contains(t)) return;
331
+ this.close();
332
+ };
333
+ document.addEventListener('pointerdown', this._docPointerHandler, true);
334
+ }
335
+ const first = panel.querySelector('[role="option"]');
336
+ if (first instanceof HTMLElement) first.focus();
337
+ else panel.focus();
338
+ }
339
+
340
+ _teardownDoc() {
341
+ if (typeof document !== 'undefined') {
342
+ if (this._docKeyHandler) document.removeEventListener('keydown', this._docKeyHandler);
343
+ if (this._docPointerHandler) {
344
+ document.removeEventListener('pointerdown', this._docPointerHandler, true);
345
+ }
346
+ }
347
+ this._docKeyHandler = null;
348
+ this._docPointerHandler = null;
349
+ }
350
+ }
351
+ </script>
@@ -0,0 +1,80 @@
1
+ <template>
2
+ <div
3
+ class="vmz-ui-skeleton"
4
+ data-vmz-ui="skeleton"
5
+ data-loading={loadingAttr}
6
+ role="status"
7
+ aria-live="polite"
8
+ aria-busy={loadingAttr}
9
+ >
10
+ <div if={loading} class="vmz-ui-skeleton__block" data-vmz-skeleton="block"></div>
11
+ <div if={loading} class="vmz-ui-skeleton__line" data-vmz-skeleton="line"></div>
12
+ <div if={loading} class="vmz-ui-skeleton__line" data-vmz-skeleton="line"></div>
13
+ <div if={!loading} class="vmz-ui-skeleton__content">
14
+ <slot />
15
+ </div>
16
+ </div>
17
+ </template>
18
+
19
+ <style>
20
+ .vmz-ui-skeleton {
21
+ display: flex;
22
+ flex-direction: column;
23
+ gap: var(--vmz-density-compact-gap);
24
+ min-height: 4.5rem;
25
+ }
26
+
27
+ .vmz-ui-skeleton__block,
28
+ .vmz-ui-skeleton__line {
29
+ border-radius: 2px;
30
+ background: color-mix(in srgb, var(--vmz-action-primary-background) 18%, transparent);
31
+ animation: vmz-ui-skeleton-pulse var(--vmz-motion-overlay-duration) var(--vmz-motion-overlay-easing) infinite alternate;
32
+ }
33
+
34
+ .vmz-ui-skeleton__block {
35
+ width: 100%;
36
+ height: 2.5rem;
37
+ }
38
+
39
+ .vmz-ui-skeleton__line {
40
+ width: 72%;
41
+ height: 0.75rem;
42
+ }
43
+
44
+ .vmz-ui-skeleton__line + .vmz-ui-skeleton__line {
45
+ width: 48%;
46
+ }
47
+
48
+ .vmz-ui-skeleton__content {
49
+ display: flex;
50
+ flex-direction: column;
51
+ gap: inherit;
52
+ }
53
+
54
+ @keyframes vmz-ui-skeleton-pulse {
55
+ from {
56
+ opacity: 0.55;
57
+ }
58
+ to {
59
+ opacity: 1;
60
+ }
61
+ }
62
+
63
+ @media (prefers-reduced-motion: reduce) {
64
+ .vmz-ui-skeleton__block,
65
+ .vmz-ui-skeleton__line {
66
+ animation: none;
67
+ }
68
+ }
69
+ </style>
70
+
71
+ <script client>
72
+ /**
73
+ * VMZ UI — Skeleton (UI5 Console Surface).
74
+ * Loading placeholder with stable min-height (CLS guard); parent owns loading.
75
+ */
76
+ export default class Skeleton {
77
+ public loading: boolean = true;
78
+ public loadingAttr: string = 'true';
79
+ }
80
+ </script>
@@ -0,0 +1,45 @@
1
+ <template>
2
+ <span
3
+ class="vmz-ui-spinner"
4
+ data-vmz-ui="spinner"
5
+ role="status"
6
+ aria-label={label}
7
+ ></span>
8
+ </template>
9
+
10
+ <style>
11
+ .vmz-ui-spinner {
12
+ display: inline-block;
13
+ width: 1rem;
14
+ height: 1rem;
15
+ border: 2px solid color-mix(in srgb, var(--vmz-action-primary-background) 25%, transparent);
16
+ border-top-color: var(--vmz-action-primary-background);
17
+ border-radius: 50%;
18
+ vertical-align: -0.15em;
19
+ animation: vmz-ui-spin var(--vmz-motion-overlay-duration) var(--vmz-motion-overlay-easing) infinite;
20
+ }
21
+
22
+ @keyframes vmz-ui-spin {
23
+ to {
24
+ transform: rotate(360deg);
25
+ }
26
+ }
27
+
28
+ @media (prefers-reduced-motion: reduce) {
29
+ .vmz-ui-spinner {
30
+ animation: none;
31
+ border-color: var(--vmz-action-primary-background);
32
+ opacity: 0.55;
33
+ }
34
+ }
35
+ </style>
36
+
37
+ <script client>
38
+ /**
39
+ * VMZ UI — Spinner (Foundation).
40
+ * Loading indicator; reduced-motion keeps static feedback.
41
+ */
42
+ export default class Spinner {
43
+ public label: string = 'Loading';
44
+ }
45
+ </script>
@@ -0,0 +1,107 @@
1
+ <template>
2
+ <ol class="vmz-ui-steps" data-vmz-ui="steps" aria-label={label}>
3
+ <li
4
+ each={items}
5
+ as="item"
6
+ key={item.id}
7
+ class="vmz-ui-steps__item"
8
+ data-vmz-step={item.id}
9
+ data-status={item.status}
10
+ aria-current={current === item.id ? 'step' : 'false'}
11
+ >
12
+ <span class="vmz-ui-steps__index" data-vmz-step-index={item.id}>{item.index}</span>
13
+ <span class="vmz-ui-steps__body">
14
+ <span class="vmz-ui-steps__title">{item.title}</span>
15
+ <span if={item.description} class="vmz-ui-steps__description">{item.description}</span>
16
+ </span>
17
+ </li>
18
+ </ol>
19
+ </template>
20
+
21
+ <style>
22
+ .vmz-ui-steps {
23
+ margin: 0;
24
+ padding: 0;
25
+ list-style: none;
26
+ display: flex;
27
+ flex-direction: column;
28
+ gap: var(--vmz-density-control-gap);
29
+ max-width: 36rem;
30
+ }
31
+
32
+ .vmz-ui-steps__item {
33
+ display: flex;
34
+ align-items: flex-start;
35
+ gap: 0.75rem;
36
+ color: var(--vmz-text-steel);
37
+ }
38
+
39
+ .vmz-ui-steps__item[data-status='done'] {
40
+ color: var(--vmz-status-success-foreground);
41
+ }
42
+
43
+ .vmz-ui-steps__item[data-status='current'] {
44
+ color: var(--vmz-action-primary-active);
45
+ }
46
+
47
+ .vmz-ui-steps__index {
48
+ flex: 0 0 auto;
49
+ min-width: 1.75rem;
50
+ height: 1.75rem;
51
+ display: inline-flex;
52
+ align-items: center;
53
+ justify-content: center;
54
+ border: 1px solid var(--vmz-line-default);
55
+ border-radius: 999px;
56
+ font-weight: 700;
57
+ font-size: 0.85rem;
58
+ color: inherit;
59
+ }
60
+
61
+ .vmz-ui-steps__item[data-status='current'] .vmz-ui-steps__index {
62
+ border-color: var(--vmz-action-primary-background);
63
+ background: var(--vmz-action-primary-background);
64
+ color: var(--vmz-action-primary-foreground);
65
+ }
66
+
67
+ .vmz-ui-steps__item[data-status='done'] .vmz-ui-steps__index {
68
+ border-color: var(--vmz-status-success-accent);
69
+ background: color-mix(in srgb, var(--vmz-status-success-accent) 18%, transparent);
70
+ }
71
+
72
+ .vmz-ui-steps__body {
73
+ display: flex;
74
+ flex-direction: column;
75
+ gap: 0.2rem;
76
+ padding-top: 0.15rem;
77
+ }
78
+
79
+ .vmz-ui-steps__title {
80
+ font-weight: 700;
81
+ }
82
+
83
+ .vmz-ui-steps__description {
84
+ font-size: 0.9em;
85
+ opacity: 0.9;
86
+ }
87
+ </style>
88
+
89
+ <script client>
90
+ /**
91
+ * VMZ UI — Steps.
92
+ * Parent owns `current` id and item `status` (upcoming|current|done).
93
+ * Navigation actions stay on the page (no private wizard store).
94
+ */
95
+ export default class Steps {
96
+ public label: string = 'Steps';
97
+ public current: string = '';
98
+ /** @type {{ id: string, index: string, title: string, description?: string, status: string }[]} */
99
+ public items: {
100
+ id: string;
101
+ index: string;
102
+ title: string;
103
+ description?: string;
104
+ status: string;
105
+ }[] = [];
106
+ }
107
+ </script>
@@ -0,0 +1,109 @@
1
+ <template>
2
+ <label class="vmz-ui-switch" data-vmz-ui="switch" for={controlId}>
3
+ <button
4
+ id={controlId}
5
+ type="button"
6
+ class="vmz-ui-switch__control"
7
+ role="switch"
8
+ aria-checked={checked}
9
+ aria-describedby={describedBy}
10
+ disabled={disabled}
11
+ onClick={toggle}
12
+ onKeyDown={onKeyDown}
13
+ >
14
+ <span class="vmz-ui-switch__thumb" aria-hidden="true"></span>
15
+ </button>
16
+ <span class="vmz-ui-switch__label">{label}</span>
17
+ </label>
18
+ </template>
19
+
20
+ <style>
21
+ .vmz-ui-switch {
22
+ display: inline-flex;
23
+ align-items: center;
24
+ gap: 0.55rem;
25
+ font: inherit;
26
+ cursor: pointer;
27
+ }
28
+
29
+ .vmz-ui-switch__control {
30
+ position: relative;
31
+ width: 2.4rem;
32
+ height: 1.35rem;
33
+ padding: 0;
34
+ border: 1px solid var(--vmz-action-primary-background);
35
+ border-radius: 999px;
36
+ background: transparent;
37
+ cursor: pointer;
38
+ outline: none;
39
+ }
40
+
41
+ .vmz-ui-switch__control[aria-checked='true'],
42
+ .vmz-ui-switch__control[aria-checked=''] {
43
+ background: var(--vmz-action-primary-background);
44
+ }
45
+
46
+ .vmz-ui-switch__control:focus-visible {
47
+ box-shadow: 0 0 0 2px var(--vmz-focus-ring);
48
+ }
49
+
50
+ .vmz-ui-switch__control:disabled {
51
+ opacity: 0.55;
52
+ cursor: not-allowed;
53
+ }
54
+
55
+ .vmz-ui-switch__thumb {
56
+ position: absolute;
57
+ top: 0.12rem;
58
+ left: 0.12rem;
59
+ width: 1rem;
60
+ height: 1rem;
61
+ border-radius: 999px;
62
+ background: var(--vmz-action-primary-foreground);
63
+ transition: transform var(--vmz-motion-control-duration) var(--vmz-motion-control-easing);
64
+ }
65
+
66
+ .vmz-ui-switch__control[aria-checked='true'] .vmz-ui-switch__thumb,
67
+ .vmz-ui-switch__control[aria-checked=''] .vmz-ui-switch__thumb {
68
+ transform: translateX(1.05rem);
69
+ }
70
+
71
+ .vmz-ui-switch__label {
72
+ font-weight: 600;
73
+ }
74
+
75
+ @media (prefers-reduced-motion: reduce) {
76
+ .vmz-ui-switch__thumb {
77
+ transition: none;
78
+ }
79
+ }
80
+ </style>
81
+
82
+ <script client>
83
+ /**
84
+ * VMZ UI — Switch (UI2).
85
+ * role=switch + aria-checked; parent owns checked (no parallel form store).
86
+ */
87
+ export default class Switch {
88
+ public label: string = '';
89
+ public checked: boolean = false;
90
+ public disabled: boolean = false;
91
+ public controlId: string = 'vmz-switch';
92
+ public describedBy: string = '';
93
+ public onChange: ((checked: boolean) => void) | null = null;
94
+
95
+ toggle() {
96
+ if (this.disabled) return;
97
+ const next = !this.checked;
98
+ if (typeof this.onChange === 'function') this.onChange(next);
99
+ }
100
+
101
+ onKeyDown(ev) {
102
+ if (!ev) return;
103
+ if (ev.key === ' ' || ev.key === 'Enter') {
104
+ ev.preventDefault();
105
+ this.toggle();
106
+ }
107
+ }
108
+ }
109
+ </script>