@vmz/ui 0.0.4 → 0.1.1

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