@vmz/ui 0.3.4 → 0.3.6

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 (43) hide show
  1. package/README.md +51 -51
  2. package/package.json +2 -2
  3. package/presets/web-surface.v0.json +19 -7
  4. package/src/components/Accordion.vmz +94 -94
  5. package/src/components/AppShell.vmz +77 -33
  6. package/src/components/Autocomplete.vmz +154 -154
  7. package/src/components/Badge.vmz +70 -70
  8. package/src/components/Breadcrumb.vmz +72 -72
  9. package/src/components/BulkActions.vmz +51 -51
  10. package/src/components/Button.vmz +211 -192
  11. package/src/components/Callout.vmz +77 -77
  12. package/src/components/Card.vmz +120 -106
  13. package/src/components/Checkbox.vmz +55 -55
  14. package/src/components/CodeBlock.vmz +77 -18
  15. package/src/components/ConsoleShell.vmz +251 -251
  16. package/src/components/DataTable.vmz +194 -194
  17. package/src/components/DatePicker.vmz +674 -674
  18. package/src/components/Dialog.vmz +10 -7
  19. package/src/components/Drawer.vmz +427 -427
  20. package/src/components/Dropdown.vmz +135 -16
  21. package/src/components/Empty.vmz +54 -54
  22. package/src/components/Field.vmz +115 -115
  23. package/src/components/FormItem.vmz +79 -79
  24. package/src/components/Link.vmz +34 -4
  25. package/src/components/List.vmz +102 -102
  26. package/src/components/Menu.vmz +243 -243
  27. package/src/components/Pagination.vmz +69 -69
  28. package/src/components/Popover.vmz +223 -223
  29. package/src/components/Prose.vmz +81 -81
  30. package/src/components/RadioGroup.vmz +124 -124
  31. package/src/components/Segmented.vmz +51 -51
  32. package/src/components/Select.vmz +533 -533
  33. package/src/components/Steps.vmz +103 -103
  34. package/src/components/Switch.vmz +98 -98
  35. package/src/components/Table.vmz +165 -133
  36. package/src/components/Tabs.vmz +110 -110
  37. package/src/components/Tag.vmz +43 -40
  38. package/src/components/TextArea.vmz +118 -118
  39. package/src/components/Timeline.vmz +76 -76
  40. package/src/components/Toc.vmz +74 -74
  41. package/src/components/Tooltip.vmz +58 -58
  42. package/src/components/Tree.vmz +191 -191
  43. package/src/components/Upload.vmz +1289 -1289
@@ -1,533 +1,533 @@
1
- <template>
2
- <div data-vmz-ui="select" :data-mode="mode" :data-searchable="searchableAttr" :data-invalid="invalidAttr" :class='"vmz-ui-select" + (open ? " is-opened" : "") + (isMultiple ? " is-multiple" : "")'>
3
- <label v-if="label" :for="controlId" class="vmz-ui-select__label">
4
- {{ label }}
5
- </label>
6
- <button type="button" :id="controlId" role="combobox" :disabled="disabled" aria-haspopup="listbox" :aria-expanded="open ? 'true' : 'false'" :aria-controls="listId" :aria-invalid="error ? 'true' : 'false'" :aria-describedby="describedBy" :aria-multiselectable="isMultiple ? 'true' : 'false'" @click="toggle" :onKeyDown="onTriggerKeyDown" class="vmz-ui-select__control">
7
- <span class="vmz-ui-select__value">
8
- {{ displayLabel }}
9
- </span>
10
- <span aria-hidden="true" class="vmz-ui-select__chev" />
11
- </button>
12
- <div v-if="open" :id="listId" data-vmz-overlay="select" data-vmz-overlay-owner="select" data-vmz-focus="enter" class="vmz-ui-select__panel">
13
- <div v-if="searchable" class="vmz-ui-select__search">
14
- <input type="search" data-vmz-select="search" :value="searchQuery" :placeholder="searchPlaceholder" :aria-label="searchPlaceholder" @input="onSearchInput" :onKeyDown="onSearchKeyDown" class="vmz-ui-select__search-input" />
15
- </div>
16
- <ul role="listbox" :aria-multiselectable="isMultiple ? 'true' : 'false'" tabindex="-1" :onKeyDown="onListKeyDown" class="vmz-ui-select__list">
17
- <li v-if="showEmpty" role="presentation" class="vmz-ui-select__empty">
18
- {{ emptyText }}
19
- </li>
20
- <li v-for="opt in visibleOptions" :key="opt.value" role="presentation">
21
- <button type="button" role="option" :data-vmz-option="opt.value" :aria-selected="opt.selected ? 'true' : 'false'" @click="onOptionClick" :class='"vmz-ui-select__option" + (opt.selected ? " is-on" : "")'>
22
- {{ opt.label }}
23
- </button>
24
- </li>
25
- </ul>
26
- <div v-if="isMultiple" class="vmz-ui-select__footer">
27
- <button type="button" data-vmz-select="done" @click="close" class="vmz-ui-select__done">
28
- Done
29
- </button>
30
- </div>
31
- </div>
32
- <p v-if="description" :id="descriptionId" class="vmz-ui-select__description">
33
- {{ description }}
34
- </p>
35
- <p v-if="error" :id="errorId" role="alert" class="vmz-ui-select__error">
36
- {{ error }}
37
- </p>
38
- </div>
39
- </template>
40
- <style>
41
- .vmz-ui-select {
42
- position: relative;
43
- display: flex;
44
- flex-direction: column;
45
- gap: 0.35rem;
46
- max-width: 28rem;
47
- }
48
-
49
- .vmz-ui-select__label {
50
- font: inherit;
51
- font-weight: 500;
52
- font-size: 0.875rem;
53
- }
54
-
55
- .vmz-ui-select__control {
56
- display: inline-flex;
57
- align-items: center;
58
- justify-content: space-between;
59
- gap: 0.55rem;
60
- width: 100%;
61
- min-height: var(--vmz-control-height, 2rem);
62
- box-sizing: border-box;
63
- font: inherit;
64
- font-size: 0.875rem;
65
- text-align: left;
66
- padding: var(--vmz-density-control-padding-y) var(--vmz-density-control-padding-x);
67
- border: 1px solid var(--vmz-line-default, #d9d9d9);
68
- border-radius: var(--vmz-radius-md, 6px);
69
- background: var(--vmz-surface-paper, #fff);
70
- color: inherit;
71
- outline: none;
72
- cursor: pointer;
73
- transition:
74
- border-color var(--vmz-motion-control-duration, 120ms)
75
- var(--vmz-motion-control-easing, linear),
76
- box-shadow var(--vmz-motion-control-duration, 120ms)
77
- var(--vmz-motion-control-easing, linear);
78
- }
79
-
80
- .vmz-ui-select[data-invalid="true"] .vmz-ui-select__control {
81
- border-color: var(--vmz-status-danger-accent);
82
- }
83
-
84
- .vmz-ui-select__control:hover:not(:disabled) {
85
- border-color: var(--vmz-action-primary-background);
86
- }
87
-
88
- .vmz-ui-select__control:focus-visible,
89
- .vmz-ui-select.is-opened .vmz-ui-select__control {
90
- border-color: var(--vmz-action-primary-background);
91
- box-shadow: 0 0 0 2px var(--vmz-focus-ring);
92
- }
93
-
94
- .vmz-ui-select__control:disabled {
95
- opacity: 0.55;
96
- cursor: not-allowed;
97
- }
98
-
99
- .vmz-ui-select__value {
100
- flex: 1 1 auto;
101
- min-width: 0;
102
- overflow: hidden;
103
- text-overflow: ellipsis;
104
- white-space: nowrap;
105
- }
106
-
107
- .vmz-ui-select__chev {
108
- flex: 0 0 auto;
109
- width: 0.45rem;
110
- height: 0.45rem;
111
- border-right: 1.5px solid currentColor;
112
- border-bottom: 1.5px solid currentColor;
113
- transform: rotate(45deg) translate(-1px, -2px);
114
- opacity: 0.7;
115
- }
116
-
117
- .vmz-ui-select.is-opened .vmz-ui-select__chev {
118
- transform: rotate(225deg) translate(-1px, -1px);
119
- }
120
-
121
- .vmz-ui-select__panel {
122
- position: absolute;
123
- z-index: 40;
124
- top: calc(100% + 0.25rem);
125
- left: 0;
126
- right: 0;
127
- display: flex;
128
- flex-direction: column;
129
- border: 1px solid var(--vmz-line-default, #d9d9d9);
130
- border-radius: var(--vmz-radius-md, 6px);
131
- background: var(--vmz-surface-paper, #fff);
132
- color: inherit;
133
- box-shadow: var(
134
- --vmz-shadow-popup,
135
- 0 6px 16px 0 rgba(0, 0, 0, 0.08),
136
- 0 3px 6px -4px rgba(0, 0, 0, 0.12)
137
- );
138
- outline: none;
139
- }
140
-
141
- .vmz-ui-select__search {
142
- padding: 0.4rem 0.45rem 0.15rem;
143
- }
144
-
145
- .vmz-ui-select__search-input {
146
- width: 100%;
147
- box-sizing: border-box;
148
- font: inherit;
149
- font-size: 0.875rem;
150
- padding: 0.25rem 0.55rem;
151
- border: 1px solid var(--vmz-line-default, #d9d9d9);
152
- border-radius: var(--vmz-radius-md, 6px);
153
- background: var(--vmz-surface-paper, #fff);
154
- color: inherit;
155
- outline: none;
156
- }
157
-
158
- .vmz-ui-select__search-input:focus-visible {
159
- box-shadow: 0 0 0 2px var(--vmz-focus-ring);
160
- }
161
-
162
- .vmz-ui-select__list {
163
- margin: 0;
164
- padding: 0.25rem;
165
- list-style: none;
166
- max-height: 16rem;
167
- overflow: auto;
168
- outline: none;
169
- }
170
-
171
- .vmz-ui-select__empty {
172
- padding: 0.55rem 0.65rem;
173
- opacity: 0.7;
174
- font-size: 0.92em;
175
- }
176
-
177
- .vmz-ui-select__option {
178
- display: block;
179
- width: 100%;
180
- box-sizing: border-box;
181
- text-align: left;
182
- font: inherit;
183
- padding: 0.4rem 0.65rem;
184
- border: 0;
185
- border-radius: var(--vmz-radius-md, 6px);
186
- background: transparent;
187
- color: inherit;
188
- cursor: pointer;
189
- outline: none;
190
- }
191
-
192
- .vmz-ui-select__option:hover,
193
- .vmz-ui-select__option:focus-visible {
194
- background: color-mix(in srgb, var(--vmz-action-primary-background) 18%, transparent);
195
- }
196
-
197
- .vmz-ui-select__option.is-on {
198
- background: color-mix(in srgb, var(--vmz-action-primary-background) 28%, transparent);
199
- font-weight: 600;
200
- }
201
-
202
- .vmz-ui-select__footer {
203
- display: flex;
204
- justify-content: flex-end;
205
- padding: 0.35rem 0.45rem 0.45rem;
206
- border-top: 1px solid var(--vmz-line-default, #d9dee3);
207
- }
208
-
209
- .vmz-ui-select__done {
210
- font: inherit;
211
- font-weight: 600;
212
- padding: 0.3rem 0.7rem;
213
- border: 1px solid var(--vmz-line-default, #d9dee3);
214
- border-radius: var(--vmz-radius-md, 6px);
215
- background: var(--vmz-surface-paper, #fff);
216
- color: inherit;
217
- cursor: pointer;
218
- }
219
-
220
- .vmz-ui-select__description {
221
- margin: 0;
222
- opacity: 0.75;
223
- font-size: 0.9em;
224
- }
225
-
226
- .vmz-ui-select__error {
227
- margin: 0;
228
- color: var(--vmz-status-danger-foreground);
229
- font-size: 0.9em;
230
- }
231
- </style>
232
- <script client>
233
- /**
234
- * VMZ UI — Select.
235
- * Owned listbox (trigger + panel + role=option). Never native <select> popup.
236
- * Parent owns value (single) or values (multiple) + options.
237
- * mode=multiple keeps panel open until Done/Escape; searchable filters visible options.
238
- */
239
- export default class Select {
240
- public label: string = "";
241
- public description: string = "";
242
- public error: string = "";
243
- /** single mode */
244
- public value: string = "";
245
- /** multiple mode — parent owns array */
246
- public values: string[] = [];
247
- public options: Array<{
248
- value: string;
249
- label: string;
250
- }> = [];
251
- /** "single" | "multiple" */
252
- public mode: string = "single";
253
- public searchable: boolean = false;
254
- public searchPlaceholder: string = "Search";
255
- public emptyText: string = "No matches";
256
- public disabled: boolean = false;
257
- public invalidAttr: string = "false";
258
- public controlId: string = "vmz-select";
259
- public listId: string = "vmz-select-list";
260
- public descriptionId: string = "vmz-select-desc";
261
- public errorId: string = "vmz-select-err";
262
- public describedBy: string = "";
263
- public onChange:
264
- | ((e: {
265
- target: {
266
- value: string;
267
- values: string[];
268
- };
269
- }) => void)
270
- | null = null;
271
- open = false;
272
- displayLabel = "";
273
- searchQuery = "";
274
- isMultiple = false;
275
- searchableAttr = "false";
276
- showEmpty = false;
277
- visibleOptions: Array<{
278
- value: string;
279
- label: string;
280
- selected: boolean;
281
- }> = [];
282
- _docKeyHandler = null;
283
- _docPointerHandler = null;
284
- _observer = null;
285
- onMount() {
286
- this.listId = `${this.controlId}-list`;
287
- this._syncMode();
288
- this._rebuildVisible();
289
- if (typeof document === "undefined" || typeof MutationObserver === "undefined") return;
290
- const root = this.__vmzDomRoot;
291
- if (!root || typeof root.querySelector !== "function") return;
292
- this._observer = new MutationObserver(() => this._syncOverlay());
293
- this._observer.observe(root, {
294
- childList: true,
295
- subtree: true,
296
- });
297
- }
298
- onDestroy() {
299
- if (this._observer) {
300
- this._observer.disconnect();
301
- this._observer = null;
302
- }
303
- this._teardownDoc();
304
- }
305
- _syncMode() {
306
- this.isMultiple = String(this.mode || "single") === "multiple";
307
- this.searchableAttr = this.searchable ? "true" : "false";
308
- }
309
- _selectedSet() {
310
- if (this.isMultiple) {
311
- return new Set((this.values || []).map((v) => String(v)));
312
- }
313
- return new Set(this.value ? [String(this.value)] : []);
314
- }
315
- _rebuildVisible() {
316
- this._syncMode();
317
- const q = String(this.searchQuery || "")
318
- .trim()
319
- .toLowerCase();
320
- const selected = this._selectedSet();
321
- const all = this.options || [];
322
- const filtered = !q
323
- ? all
324
- : all.filter((o) => {
325
- const label = String(o.label || "").toLowerCase();
326
- const value = String(o.value || "").toLowerCase();
327
- return label.includes(q) || value.includes(q);
328
- });
329
- this.visibleOptions = filtered.map((o) => ({
330
- value: String(o.value),
331
- label: String(o.label),
332
- selected: selected.has(String(o.value)),
333
- }));
334
- this.showEmpty = this.visibleOptions.length === 0;
335
- this._syncLabel();
336
- }
337
- _syncLabel() {
338
- const opts = this.options || [];
339
- if (this.isMultiple) {
340
- const selected = this._selectedSet();
341
- const labels = opts.filter((o) => selected.has(String(o.value))).map((o) => o.label);
342
- if (!labels.length) this.displayLabel = "—";
343
- else if (labels.length <= 2) this.displayLabel = labels.join(", ");
344
- else this.displayLabel = `${labels[0]}, ${labels[1]} +${labels.length - 2}`;
345
- return;
346
- }
347
- const hit = opts.find((o) => String(o.value) === String(this.value));
348
- this.displayLabel = hit ? hit.label : this.value || "—";
349
- }
350
- toggle() {
351
- if (this.disabled) return;
352
- if (this.open) this.close();
353
- else this.openPanel();
354
- }
355
- openPanel() {
356
- this.searchQuery = "";
357
- this._rebuildVisible();
358
- this.open = true;
359
- }
360
- close() {
361
- this.open = false;
362
- this.searchQuery = "";
363
- this._rebuildVisible();
364
- }
365
- _emit(nextValue: string, nextValues: string[]) {
366
- const payload = {
367
- target: {
368
- value: nextValue,
369
- values: nextValues,
370
- },
371
- };
372
- this.emit("change", payload);
373
- if (typeof this.onChange === "function") {
374
- this.onChange(payload);
375
- }
376
- }
377
- /** Parent-owned prop writes (bindComponentProp) — keep derived label/options in sync. */
378
- __vmzOnParentProp(name: string) {
379
- if (name === "value" || name === "values" || name === "options") this._rebuildVisible();
380
- }
381
- choose(next: string) {
382
- if (!next && next !== "") return;
383
- if (this.isMultiple) {
384
- const cur = [...(this.values || [])].map(String);
385
- const idx = cur.indexOf(String(next));
386
- if (idx >= 0) cur.splice(idx, 1);
387
- else cur.push(String(next));
388
- this.values = cur;
389
- this._rebuildVisible();
390
- this._emit(cur.join(","), cur);
391
- return;
392
- }
393
- const nextStr = String(next);
394
- if (nextStr === String(this.value)) {
395
- this.close();
396
- return;
397
- }
398
- this.value = nextStr;
399
- this._rebuildVisible();
400
- this._emit(nextStr, nextStr ? [nextStr] : []);
401
- this.close();
402
- }
403
- onSearchInput(ev: Event) {
404
- const t = ev?.target as HTMLInputElement | null;
405
- this.searchQuery = t ? String(t.value || "") : "";
406
- this._rebuildVisible();
407
- }
408
- onSearchKeyDown(ev: KeyboardEvent) {
409
- if (!ev) return;
410
- if (ev.key === "Escape") {
411
- ev.preventDefault();
412
- this.close();
413
- } else if (ev.key === "ArrowDown") {
414
- ev.preventDefault();
415
- const root = this.__vmzDomRoot;
416
- const first =
417
- root && typeof root.querySelector === "function"
418
- ? root.querySelector('[data-vmz-overlay="select"] [role="option"]')
419
- : null;
420
- if (first instanceof HTMLElement) first.focus();
421
- }
422
- }
423
- onOptionClick(ev: Event) {
424
- const t = ev?.target as Element | null;
425
- const el =
426
- (t && typeof (t as HTMLElement).closest === "function"
427
- ? ((t as HTMLElement).closest("[data-vmz-option]") as HTMLElement | null)
428
- : null) || (ev?.currentTarget as HTMLElement | null);
429
- const id =
430
- el && typeof el.getAttribute === "function" ? el.getAttribute("data-vmz-option") : null;
431
- if (id != null) this.choose(id);
432
- }
433
- onTriggerKeyDown(ev: KeyboardEvent) {
434
- if (!ev || this.disabled) return;
435
- if ((ev.key === "ArrowDown" || ev.key === "Enter" || ev.key === " ") && !this.open) {
436
- ev.preventDefault();
437
- this.openPanel();
438
- } else if (ev.key === "Escape" && this.open) {
439
- ev.preventDefault();
440
- this.close();
441
- }
442
- }
443
- onListKeyDown(ev: KeyboardEvent) {
444
- if (!ev) return;
445
- if (ev.key === "Escape") {
446
- ev.preventDefault();
447
- this.close();
448
- return;
449
- }
450
- const root = this.__vmzDomRoot;
451
- const list =
452
- root && typeof root.querySelector === "function"
453
- ? root.querySelector(`[data-vmz-overlay="select"] [role="listbox"]`)
454
- : null;
455
- if (!list) return;
456
- const nodes = [...list.querySelectorAll('[role="option"]')].filter(
457
- (n) => n instanceof HTMLElement && n.offsetParent !== null,
458
- );
459
- if (!nodes.length) return;
460
- const idx = nodes.indexOf(document.activeElement as Element);
461
- if (ev.key === "ArrowDown") {
462
- ev.preventDefault();
463
- (nodes[(idx + 1 + nodes.length) % nodes.length] as HTMLElement).focus();
464
- } else if (ev.key === "ArrowUp") {
465
- ev.preventDefault();
466
- (nodes[(idx - 1 + nodes.length) % nodes.length] as HTMLElement).focus();
467
- } else if (ev.key === "Home") {
468
- ev.preventDefault();
469
- (nodes[0] as HTMLElement).focus();
470
- } else if (ev.key === "End") {
471
- ev.preventDefault();
472
- (nodes[nodes.length - 1] as HTMLElement).focus();
473
- } else if (ev.key === "Enter" || ev.key === " ") {
474
- const active = document.activeElement as HTMLElement | null;
475
- const id = active?.getAttribute?.("data-vmz-option");
476
- if (id) {
477
- ev.preventDefault();
478
- this.choose(id);
479
- }
480
- }
481
- }
482
- _syncOverlay() {
483
- const root = this.__vmzDomRoot;
484
- const panel =
485
- root && typeof root.querySelector === "function"
486
- ? root.querySelector('[data-vmz-overlay="select"][data-vmz-focus="enter"]')
487
- : null;
488
- if (panel && this.open) this._bindDoc(panel as HTMLElement);
489
- else if (!panel) this._teardownDoc();
490
- this._rebuildVisible();
491
- }
492
- _bindDoc(panel: HTMLElement) {
493
- if (typeof document === "undefined") return;
494
- if (!this._docKeyHandler) {
495
- this._docKeyHandler = (ev: KeyboardEvent) => {
496
- if (ev.key === "Escape") {
497
- ev.preventDefault();
498
- this.close();
499
- }
500
- };
501
- document.addEventListener("keydown", this._docKeyHandler);
502
- }
503
- if (!this._docPointerHandler) {
504
- const root = this.__vmzDomRoot;
505
- this._docPointerHandler = (ev: Event) => {
506
- const t = ev.target;
507
- if (!(t instanceof Node)) return;
508
- if (root && typeof root.contains === "function" && root.contains(t)) return;
509
- this.close();
510
- };
511
- document.addEventListener("pointerdown", this._docPointerHandler, true);
512
- }
513
- const search = panel.querySelector('[data-vmz-select="search"]');
514
- if (search instanceof HTMLElement) {
515
- search.focus();
516
- return;
517
- }
518
- const first = panel.querySelector('[role="option"]');
519
- if (first instanceof HTMLElement) first.focus();
520
- else panel.focus();
521
- }
522
- _teardownDoc() {
523
- if (typeof document !== "undefined") {
524
- if (this._docKeyHandler) document.removeEventListener("keydown", this._docKeyHandler);
525
- if (this._docPointerHandler) {
526
- document.removeEventListener("pointerdown", this._docPointerHandler, true);
527
- }
528
- }
529
- this._docKeyHandler = null;
530
- this._docPointerHandler = null;
531
- }
532
- }
533
- </script>
1
+ <template>
2
+ <div data-vmz-ui="select" :data-mode="mode" :data-searchable="searchableAttr" :data-invalid="invalidAttr" :class='"vmz-ui-select" + (open ? " is-opened" : "") + (isMultiple ? " is-multiple" : "")'>
3
+ <label v-if="label" :for="controlId" class="vmz-ui-select__label">
4
+ {{ label }}
5
+ </label>
6
+ <button type="button" :id="controlId" role="combobox" :disabled="disabled" aria-haspopup="listbox" :aria-expanded="open ? 'true' : 'false'" :aria-controls="listId" :aria-invalid="error ? 'true' : 'false'" :aria-describedby="describedBy" :aria-multiselectable="isMultiple ? 'true' : 'false'" @click="toggle" :onKeyDown="onTriggerKeyDown" class="vmz-ui-select__control">
7
+ <span class="vmz-ui-select__value">
8
+ {{ displayLabel }}
9
+ </span>
10
+ <span aria-hidden="true" class="vmz-ui-select__chev" />
11
+ </button>
12
+ <div v-if="open" :id="listId" data-vmz-overlay="select" data-vmz-overlay-owner="select" data-vmz-focus="enter" class="vmz-ui-select__panel">
13
+ <div v-if="searchable" class="vmz-ui-select__search">
14
+ <input type="search" data-vmz-select="search" :value="searchQuery" :placeholder="searchPlaceholder" :aria-label="searchPlaceholder" @input="onSearchInput" :onKeyDown="onSearchKeyDown" class="vmz-ui-select__search-input" />
15
+ </div>
16
+ <ul role="listbox" :aria-multiselectable="isMultiple ? 'true' : 'false'" tabindex="-1" :onKeyDown="onListKeyDown" class="vmz-ui-select__list">
17
+ <li v-if="showEmpty" role="presentation" class="vmz-ui-select__empty">
18
+ {{ emptyText }}
19
+ </li>
20
+ <li v-for="opt in visibleOptions" :key="opt.value" role="presentation">
21
+ <button type="button" role="option" :data-vmz-option="opt.value" :aria-selected="opt.selected ? 'true' : 'false'" @click="onOptionClick" :class='"vmz-ui-select__option" + (opt.selected ? " is-on" : "")'>
22
+ {{ opt.label }}
23
+ </button>
24
+ </li>
25
+ </ul>
26
+ <div v-if="isMultiple" class="vmz-ui-select__footer">
27
+ <button type="button" data-vmz-select="done" @click="close" class="vmz-ui-select__done">
28
+ Done
29
+ </button>
30
+ </div>
31
+ </div>
32
+ <p v-if="description" :id="descriptionId" class="vmz-ui-select__description">
33
+ {{ description }}
34
+ </p>
35
+ <p v-if="error" :id="errorId" role="alert" class="vmz-ui-select__error">
36
+ {{ error }}
37
+ </p>
38
+ </div>
39
+ </template>
40
+ <style>
41
+ .vmz-ui-select {
42
+ position: relative;
43
+ display: flex;
44
+ flex-direction: column;
45
+ gap: 0.375rem;
46
+ max-width: 28rem;
47
+ }
48
+
49
+ .vmz-ui-select__label {
50
+ font: inherit;
51
+ font-weight: 500;
52
+ font-size: 0.875rem;
53
+ }
54
+
55
+ .vmz-ui-select__control {
56
+ display: inline-flex;
57
+ align-items: center;
58
+ justify-content: space-between;
59
+ gap: 0.5rem;
60
+ width: 100%;
61
+ min-height: var(--vmz-control-height, 2rem);
62
+ box-sizing: border-box;
63
+ font: inherit;
64
+ font-size: 0.875rem;
65
+ text-align: left;
66
+ padding: var(--vmz-density-control-padding-y) var(--vmz-density-control-padding-x);
67
+ border: 1px solid var(--vmz-line-default, #d9d9d9);
68
+ border-radius: var(--vmz-radius-md, 6px);
69
+ background: var(--vmz-surface-paper, #fff);
70
+ color: inherit;
71
+ outline: none;
72
+ cursor: pointer;
73
+ transition:
74
+ border-color var(--vmz-motion-control-duration, 120ms)
75
+ var(--vmz-motion-control-easing, cubic-bezier(0.4, 0, 0.2, 1)),
76
+ box-shadow var(--vmz-motion-control-duration, 120ms)
77
+ var(--vmz-motion-control-easing, cubic-bezier(0.4, 0, 0.2, 1));
78
+ }
79
+
80
+ .vmz-ui-select[data-invalid="true"] .vmz-ui-select__control {
81
+ border-color: var(--vmz-status-danger-accent);
82
+ }
83
+
84
+ .vmz-ui-select__control:hover:not(:disabled) {
85
+ border-color: var(--vmz-action-primary-background);
86
+ }
87
+
88
+ .vmz-ui-select__control:focus-visible,
89
+ .vmz-ui-select.is-opened .vmz-ui-select__control {
90
+ border-color: var(--vmz-action-primary-background);
91
+ box-shadow: 0 0 0 3px var(--vmz-focus-ring);
92
+ }
93
+
94
+ .vmz-ui-select__control:disabled {
95
+ opacity: 0.55;
96
+ cursor: not-allowed;
97
+ }
98
+
99
+ .vmz-ui-select__value {
100
+ flex: 1 1 auto;
101
+ min-width: 0;
102
+ overflow: hidden;
103
+ text-overflow: ellipsis;
104
+ white-space: nowrap;
105
+ }
106
+
107
+ .vmz-ui-select__chev {
108
+ flex: 0 0 auto;
109
+ width: 0.45rem;
110
+ height: 0.45rem;
111
+ border-right: 1.5px solid currentColor;
112
+ border-bottom: 1.5px solid currentColor;
113
+ transform: rotate(45deg) translate(-1px, -2px);
114
+ opacity: 0.7;
115
+ }
116
+
117
+ .vmz-ui-select.is-opened .vmz-ui-select__chev {
118
+ transform: rotate(225deg) translate(-1px, -1px);
119
+ }
120
+
121
+ .vmz-ui-select__panel {
122
+ position: absolute;
123
+ z-index: 40;
124
+ top: calc(100% + 0.25rem);
125
+ left: 0;
126
+ right: 0;
127
+ display: flex;
128
+ flex-direction: column;
129
+ border: 1px solid var(--vmz-line-default, #d9d9d9);
130
+ border-radius: var(--vmz-radius-md, 6px);
131
+ background: var(--vmz-surface-paper, #fff);
132
+ color: inherit;
133
+ box-shadow: var(
134
+ --vmz-shadow-popup,
135
+ 0 6px 16px 0 rgba(0, 0, 0, 0.08),
136
+ 0 3px 6px -4px rgba(0, 0, 0, 0.12)
137
+ );
138
+ outline: none;
139
+ }
140
+
141
+ .vmz-ui-select__search {
142
+ padding: 0.375rem 0.5rem 0.125rem;
143
+ }
144
+
145
+ .vmz-ui-select__search-input {
146
+ width: 100%;
147
+ box-sizing: border-box;
148
+ font: inherit;
149
+ font-size: 0.875rem;
150
+ padding: 0.25rem 0.5rem;
151
+ border: 1px solid var(--vmz-line-default, #d9d9d9);
152
+ border-radius: var(--vmz-radius-md, 6px);
153
+ background: var(--vmz-surface-paper, #fff);
154
+ color: inherit;
155
+ outline: none;
156
+ }
157
+
158
+ .vmz-ui-select__search-input:focus-visible {
159
+ box-shadow: 0 0 0 3px var(--vmz-focus-ring);
160
+ }
161
+
162
+ .vmz-ui-select__list {
163
+ margin: 0;
164
+ padding: 0.25rem;
165
+ list-style: none;
166
+ max-height: 16rem;
167
+ overflow: auto;
168
+ outline: none;
169
+ }
170
+
171
+ .vmz-ui-select__empty {
172
+ padding: 0.5rem 0.625rem;
173
+ opacity: 0.7;
174
+ font-size: 0.92em;
175
+ }
176
+
177
+ .vmz-ui-select__option {
178
+ display: block;
179
+ width: 100%;
180
+ box-sizing: border-box;
181
+ text-align: left;
182
+ font: inherit;
183
+ padding: 0.375rem 0.625rem;
184
+ border: 0;
185
+ border-radius: var(--vmz-radius-md, 6px);
186
+ background: transparent;
187
+ color: inherit;
188
+ cursor: pointer;
189
+ outline: none;
190
+ }
191
+
192
+ .vmz-ui-select__option:hover,
193
+ .vmz-ui-select__option:focus-visible {
194
+ background: color-mix(in srgb, var(--vmz-action-primary-background) 18%, transparent);
195
+ }
196
+
197
+ .vmz-ui-select__option.is-on {
198
+ background: color-mix(in srgb, var(--vmz-action-primary-background) 28%, transparent);
199
+ font-weight: 600;
200
+ }
201
+
202
+ .vmz-ui-select__footer {
203
+ display: flex;
204
+ justify-content: flex-end;
205
+ padding: 0.375rem 0.5rem 0.5rem;
206
+ border-top: 1px solid var(--vmz-line-default, #d9dee3);
207
+ }
208
+
209
+ .vmz-ui-select__done {
210
+ font: inherit;
211
+ font-weight: 600;
212
+ padding: 0.25rem 0.7rem;
213
+ border: 1px solid var(--vmz-line-default, #d9dee3);
214
+ border-radius: var(--vmz-radius-md, 6px);
215
+ background: var(--vmz-surface-paper, #fff);
216
+ color: inherit;
217
+ cursor: pointer;
218
+ }
219
+
220
+ .vmz-ui-select__description {
221
+ margin: 0;
222
+ opacity: 0.75;
223
+ font-size: 0.9em;
224
+ }
225
+
226
+ .vmz-ui-select__error {
227
+ margin: 0;
228
+ color: var(--vmz-status-danger-foreground);
229
+ font-size: 0.9em;
230
+ }
231
+ </style>
232
+ <script client>
233
+ /**
234
+ * VMZ UI — Select.
235
+ * Owned listbox (trigger + panel + role=option). Never native <select> popup.
236
+ * Parent owns value (single) or values (multiple) + options.
237
+ * mode=multiple keeps panel open until Done/Escape; searchable filters visible options.
238
+ */
239
+ export default class Select {
240
+ public label: string = "";
241
+ public description: string = "";
242
+ public error: string = "";
243
+ /** single mode */
244
+ public value: string = "";
245
+ /** multiple mode — parent owns array */
246
+ public values: string[] = [];
247
+ public options: Array<{
248
+ value: string;
249
+ label: string;
250
+ }> = [];
251
+ /** "single" | "multiple" */
252
+ public mode: string = "single";
253
+ public searchable: boolean = false;
254
+ public searchPlaceholder: string = "Search";
255
+ public emptyText: string = "No matches";
256
+ public disabled: boolean = false;
257
+ public invalidAttr: string = "false";
258
+ public controlId: string = "vmz-select";
259
+ public listId: string = "vmz-select-list";
260
+ public descriptionId: string = "vmz-select-desc";
261
+ public errorId: string = "vmz-select-err";
262
+ public describedBy: string = "";
263
+ public onChange:
264
+ | ((e: {
265
+ target: {
266
+ value: string;
267
+ values: string[];
268
+ };
269
+ }) => void)
270
+ | null = null;
271
+ open = false;
272
+ displayLabel = "";
273
+ searchQuery = "";
274
+ isMultiple = false;
275
+ searchableAttr = "false";
276
+ showEmpty = false;
277
+ visibleOptions: Array<{
278
+ value: string;
279
+ label: string;
280
+ selected: boolean;
281
+ }> = [];
282
+ _docKeyHandler = null;
283
+ _docPointerHandler = null;
284
+ _observer = null;
285
+ onMount() {
286
+ this.listId = `${this.controlId}-list`;
287
+ this._syncMode();
288
+ this._rebuildVisible();
289
+ if (typeof document === "undefined" || typeof MutationObserver === "undefined") return;
290
+ const root = this.__vmzDomRoot;
291
+ if (!root || typeof root.querySelector !== "function") return;
292
+ this._observer = new MutationObserver(() => this._syncOverlay());
293
+ this._observer.observe(root, {
294
+ childList: true,
295
+ subtree: true,
296
+ });
297
+ }
298
+ onDestroy() {
299
+ if (this._observer) {
300
+ this._observer.disconnect();
301
+ this._observer = null;
302
+ }
303
+ this._teardownDoc();
304
+ }
305
+ _syncMode() {
306
+ this.isMultiple = String(this.mode || "single") === "multiple";
307
+ this.searchableAttr = this.searchable ? "true" : "false";
308
+ }
309
+ _selectedSet() {
310
+ if (this.isMultiple) {
311
+ return new Set((this.values || []).map((v) => String(v)));
312
+ }
313
+ return new Set(this.value ? [String(this.value)] : []);
314
+ }
315
+ _rebuildVisible() {
316
+ this._syncMode();
317
+ const q = String(this.searchQuery || "")
318
+ .trim()
319
+ .toLowerCase();
320
+ const selected = this._selectedSet();
321
+ const all = this.options || [];
322
+ const filtered = !q
323
+ ? all
324
+ : all.filter((o) => {
325
+ const label = String(o.label || "").toLowerCase();
326
+ const value = String(o.value || "").toLowerCase();
327
+ return label.includes(q) || value.includes(q);
328
+ });
329
+ this.visibleOptions = filtered.map((o) => ({
330
+ value: String(o.value),
331
+ label: String(o.label),
332
+ selected: selected.has(String(o.value)),
333
+ }));
334
+ this.showEmpty = this.visibleOptions.length === 0;
335
+ this._syncLabel();
336
+ }
337
+ _syncLabel() {
338
+ const opts = this.options || [];
339
+ if (this.isMultiple) {
340
+ const selected = this._selectedSet();
341
+ const labels = opts.filter((o) => selected.has(String(o.value))).map((o) => o.label);
342
+ if (!labels.length) this.displayLabel = "—";
343
+ else if (labels.length <= 2) this.displayLabel = labels.join(", ");
344
+ else this.displayLabel = `${labels[0]}, ${labels[1]} +${labels.length - 2}`;
345
+ return;
346
+ }
347
+ const hit = opts.find((o) => String(o.value) === String(this.value));
348
+ this.displayLabel = hit ? hit.label : this.value || "—";
349
+ }
350
+ toggle() {
351
+ if (this.disabled) return;
352
+ if (this.open) this.close();
353
+ else this.openPanel();
354
+ }
355
+ openPanel() {
356
+ this.searchQuery = "";
357
+ this._rebuildVisible();
358
+ this.open = true;
359
+ }
360
+ close() {
361
+ this.open = false;
362
+ this.searchQuery = "";
363
+ this._rebuildVisible();
364
+ }
365
+ _emit(nextValue: string, nextValues: string[]) {
366
+ const payload = {
367
+ target: {
368
+ value: nextValue,
369
+ values: nextValues,
370
+ },
371
+ };
372
+ this.emit("change", payload);
373
+ if (typeof this.onChange === "function") {
374
+ this.onChange(payload);
375
+ }
376
+ }
377
+ /** Parent-owned prop writes (bindComponentProp) — keep derived label/options in sync. */
378
+ __vmzOnParentProp(name: string) {
379
+ if (name === "value" || name === "values" || name === "options") this._rebuildVisible();
380
+ }
381
+ choose(next: string) {
382
+ if (!next && next !== "") return;
383
+ if (this.isMultiple) {
384
+ const cur = [...(this.values || [])].map(String);
385
+ const idx = cur.indexOf(String(next));
386
+ if (idx >= 0) cur.splice(idx, 1);
387
+ else cur.push(String(next));
388
+ this.values = cur;
389
+ this._rebuildVisible();
390
+ this._emit(cur.join(","), cur);
391
+ return;
392
+ }
393
+ const nextStr = String(next);
394
+ if (nextStr === String(this.value)) {
395
+ this.close();
396
+ return;
397
+ }
398
+ this.value = nextStr;
399
+ this._rebuildVisible();
400
+ this._emit(nextStr, nextStr ? [nextStr] : []);
401
+ this.close();
402
+ }
403
+ onSearchInput(ev: Event) {
404
+ const t = ev?.target as HTMLInputElement | null;
405
+ this.searchQuery = t ? String(t.value || "") : "";
406
+ this._rebuildVisible();
407
+ }
408
+ onSearchKeyDown(ev: KeyboardEvent) {
409
+ if (!ev) return;
410
+ if (ev.key === "Escape") {
411
+ ev.preventDefault();
412
+ this.close();
413
+ } else if (ev.key === "ArrowDown") {
414
+ ev.preventDefault();
415
+ const root = this.__vmzDomRoot;
416
+ const first =
417
+ root && typeof root.querySelector === "function"
418
+ ? root.querySelector('[data-vmz-overlay="select"] [role="option"]')
419
+ : null;
420
+ if (first instanceof HTMLElement) first.focus();
421
+ }
422
+ }
423
+ onOptionClick(ev: Event) {
424
+ const t = ev?.target as Element | null;
425
+ const el =
426
+ (t && typeof (t as HTMLElement).closest === "function"
427
+ ? ((t as HTMLElement).closest("[data-vmz-option]") as HTMLElement | null)
428
+ : null) || (ev?.currentTarget as HTMLElement | null);
429
+ const id =
430
+ el && typeof el.getAttribute === "function" ? el.getAttribute("data-vmz-option") : null;
431
+ if (id != null) this.choose(id);
432
+ }
433
+ onTriggerKeyDown(ev: KeyboardEvent) {
434
+ if (!ev || this.disabled) return;
435
+ if ((ev.key === "ArrowDown" || ev.key === "Enter" || ev.key === " ") && !this.open) {
436
+ ev.preventDefault();
437
+ this.openPanel();
438
+ } else if (ev.key === "Escape" && this.open) {
439
+ ev.preventDefault();
440
+ this.close();
441
+ }
442
+ }
443
+ onListKeyDown(ev: KeyboardEvent) {
444
+ if (!ev) return;
445
+ if (ev.key === "Escape") {
446
+ ev.preventDefault();
447
+ this.close();
448
+ return;
449
+ }
450
+ const root = this.__vmzDomRoot;
451
+ const list =
452
+ root && typeof root.querySelector === "function"
453
+ ? root.querySelector(`[data-vmz-overlay="select"] [role="listbox"]`)
454
+ : null;
455
+ if (!list) return;
456
+ const nodes = [...list.querySelectorAll('[role="option"]')].filter(
457
+ (n) => n instanceof HTMLElement && n.offsetParent !== null,
458
+ );
459
+ if (!nodes.length) return;
460
+ const idx = nodes.indexOf(document.activeElement as Element);
461
+ if (ev.key === "ArrowDown") {
462
+ ev.preventDefault();
463
+ (nodes[(idx + 1 + nodes.length) % nodes.length] as HTMLElement).focus();
464
+ } else if (ev.key === "ArrowUp") {
465
+ ev.preventDefault();
466
+ (nodes[(idx - 1 + nodes.length) % nodes.length] as HTMLElement).focus();
467
+ } else if (ev.key === "Home") {
468
+ ev.preventDefault();
469
+ (nodes[0] as HTMLElement).focus();
470
+ } else if (ev.key === "End") {
471
+ ev.preventDefault();
472
+ (nodes[nodes.length - 1] as HTMLElement).focus();
473
+ } else if (ev.key === "Enter" || ev.key === " ") {
474
+ const active = document.activeElement as HTMLElement | null;
475
+ const id = active?.getAttribute?.("data-vmz-option");
476
+ if (id) {
477
+ ev.preventDefault();
478
+ this.choose(id);
479
+ }
480
+ }
481
+ }
482
+ _syncOverlay() {
483
+ const root = this.__vmzDomRoot;
484
+ const panel =
485
+ root && typeof root.querySelector === "function"
486
+ ? root.querySelector('[data-vmz-overlay="select"][data-vmz-focus="enter"]')
487
+ : null;
488
+ if (panel && this.open) this._bindDoc(panel as HTMLElement);
489
+ else if (!panel) this._teardownDoc();
490
+ this._rebuildVisible();
491
+ }
492
+ _bindDoc(panel: HTMLElement) {
493
+ if (typeof document === "undefined") return;
494
+ if (!this._docKeyHandler) {
495
+ this._docKeyHandler = (ev: KeyboardEvent) => {
496
+ if (ev.key === "Escape") {
497
+ ev.preventDefault();
498
+ this.close();
499
+ }
500
+ };
501
+ document.addEventListener("keydown", this._docKeyHandler);
502
+ }
503
+ if (!this._docPointerHandler) {
504
+ const root = this.__vmzDomRoot;
505
+ this._docPointerHandler = (ev: Event) => {
506
+ const t = ev.target;
507
+ if (!(t instanceof Node)) return;
508
+ if (root && typeof root.contains === "function" && root.contains(t)) return;
509
+ this.close();
510
+ };
511
+ document.addEventListener("pointerdown", this._docPointerHandler, true);
512
+ }
513
+ const search = panel.querySelector('[data-vmz-select="search"]');
514
+ if (search instanceof HTMLElement) {
515
+ search.focus();
516
+ return;
517
+ }
518
+ const first = panel.querySelector('[role="option"]');
519
+ if (first instanceof HTMLElement) first.focus();
520
+ else panel.focus();
521
+ }
522
+ _teardownDoc() {
523
+ if (typeof document !== "undefined") {
524
+ if (this._docKeyHandler) document.removeEventListener("keydown", this._docKeyHandler);
525
+ if (this._docPointerHandler) {
526
+ document.removeEventListener("pointerdown", this._docPointerHandler, true);
527
+ }
528
+ }
529
+ this._docKeyHandler = null;
530
+ this._docPointerHandler = null;
531
+ }
532
+ }
533
+ </script>