@vmz/ui 0.1.0 → 0.1.2

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 +8 -0
  2. package/contracts/token-requirements.v0.json +2 -6
  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 +90 -75
  14. package/src/components/Callout.vmz +61 -61
  15. package/src/components/Card.vmz +58 -51
  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 +613 -536
  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 -290
  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 +1130 -1151
@@ -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,288 +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 t = ev?.target as Element | null;
248
- const el =
249
- (t && typeof (t as HTMLElement).closest === 'function'
250
- ? ((t as HTMLElement).closest('[data-vmz-option]') as HTMLElement | null)
251
- : null) || (ev?.currentTarget as HTMLElement | null);
252
- const id = el && typeof el.getAttribute === 'function' ? el.getAttribute('data-vmz-option') : null;
253
- 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);
254
224
  }
255
225
 
256
- onTriggerKeyDown(ev: KeyboardEvent) {
257
- if (!ev || this.disabled) return;
258
- if ((ev.key === 'ArrowDown' || ev.key === 'Enter' || ev.key === ' ') && !this.open) {
259
- ev.preventDefault();
260
- this.open = true;
261
- } else if (ev.key === 'Escape' && this.open) {
262
- ev.preventDefault();
263
- this.close();
264
- }
226
+ .vmz-ui-select__option.is-on {
227
+ background: color-mix(in srgb, var(--vmz-action-primary-background) 28%, transparent);
228
+ font-weight: 600;
265
229
  }
266
230
 
267
- onListKeyDown(ev: KeyboardEvent) {
268
- if (!ev) return;
269
- if (ev.key === 'Escape') {
270
- ev.preventDefault();
271
- this.close();
272
- return;
273
- }
274
- const root = this.__vmzDomRoot;
275
- const list =
276
- root && typeof root.querySelector === 'function'
277
- ? root.querySelector(`[data-vmz-overlay="select"]`)
278
- : null;
279
- if (!list) return;
280
- const nodes = [...list.querySelectorAll('[role="option"]')].filter(
281
- (n) => n instanceof HTMLElement && n.offsetParent !== null,
282
- );
283
- if (!nodes.length) return;
284
- const idx = nodes.indexOf(document.activeElement as Element);
285
- if (ev.key === 'ArrowDown') {
286
- ev.preventDefault();
287
- (nodes[(idx + 1 + nodes.length) % nodes.length] as HTMLElement).focus();
288
- } else if (ev.key === 'ArrowUp') {
289
- ev.preventDefault();
290
- (nodes[(idx - 1 + nodes.length) % nodes.length] as HTMLElement).focus();
291
- } else if (ev.key === 'Home') {
292
- ev.preventDefault();
293
- (nodes[0] as HTMLElement).focus();
294
- } else if (ev.key === 'End') {
295
- ev.preventDefault();
296
- (nodes[nodes.length - 1] as HTMLElement).focus();
297
- } else if (ev.key === 'Enter' || ev.key === ' ') {
298
- const active = document.activeElement as HTMLElement | null;
299
- const id = active?.getAttribute?.('data-vmz-option');
300
- if (id) {
301
- ev.preventDefault();
302
- this.choose(id);
303
- }
304
- }
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);
305
236
  }
306
237
 
307
- _syncOverlay() {
308
- const root = this.__vmzDomRoot;
309
- const panel =
310
- root && typeof root.querySelector === 'function'
311
- ? root.querySelector('[data-vmz-overlay="select"][data-vmz-focus="enter"]')
312
- : null;
313
- if (panel && this.open) this._bindDoc(panel as HTMLElement);
314
- else if (!panel) this._teardownDoc();
315
- 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;
316
247
  }
317
248
 
318
- _bindDoc(panel: HTMLElement) {
319
- if (typeof document === 'undefined') return;
320
- if (!this._docKeyHandler) {
321
- this._docKeyHandler = (ev: KeyboardEvent) => {
322
- if (ev.key === 'Escape') {
323
- ev.preventDefault();
324
- this.close();
325
- }
326
- };
327
- document.addEventListener('keydown', this._docKeyHandler);
328
- }
329
- if (!this._docPointerHandler) {
330
- const root = this.__vmzDomRoot;
331
- this._docPointerHandler = (ev: Event) => {
332
- const t = ev.target;
333
- if (!(t instanceof Node)) return;
334
- if (root && typeof root.contains === 'function' && root.contains(t)) return;
335
- this.close();
336
- };
337
- document.addEventListener('pointerdown', this._docPointerHandler, true);
338
- }
339
- const first = panel.querySelector('[role="option"]');
340
- if (first instanceof HTMLElement) first.focus();
341
- else panel.focus();
249
+ .vmz-ui-select__description {
250
+ margin: 0;
251
+ opacity: 0.75;
252
+ font-size: 0.9em;
342
253
  }
343
254
 
344
- _teardownDoc() {
345
- if (typeof document !== 'undefined') {
346
- if (this._docKeyHandler) document.removeEventListener('keydown', this._docKeyHandler);
347
- if (this._docPointerHandler) {
348
- document.removeEventListener('pointerdown', this._docPointerHandler, true);
349
- }
350
- }
351
- this._docKeyHandler = null;
352
- this._docPointerHandler = null;
255
+ .vmz-ui-select__error {
256
+ margin: 0;
257
+ color: var(--vmz-status-danger-foreground);
258
+ font-size: 0.9em;
353
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
+ }
354
526
  }
355
527
  </script>