@getdom/studio 0.2.2 → 0.2.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +84 -0
- package/package.json +1 -1
- package/src/pages/_layout/componentCatalog.generated.js +114 -12
- package/src/pages/charts/_shared/CompactChartTooltip.vue +39 -0
- package/src/pages/charts/_shared/XYChart.vue +7 -2
- package/src/pages/charts/_shared/XYRenderer.vue +18 -11
- package/src/pages/charts/sparkline/DomSparkline.vue +1 -0
- package/src/pages/forms/_shared/DomOptionStatus.vue +18 -0
- package/src/pages/forms/_shared/useOptionMenu.js +18 -2
- package/src/pages/forms/_shared/useOptionPaging.js +42 -0
- package/src/pages/forms/_shared/useSelectedOptions.js +25 -0
- package/src/pages/forms/autocomplete/DomAutocomplete.vue +36 -8
- package/src/pages/forms/combobox/DomCombobox.vue +44 -7
- package/src/pages/forms/font-picker/DomFontPicker.vue +416 -0
- package/src/pages/forms/font-picker/FontPreview.vue +39 -0
- package/src/pages/forms/font-picker/fontPicker.js +102 -0
- package/src/pages/forms/font-picker/googleFonts.js +1951 -0
- package/src/pages/forms/select/DomSelect.vue +118 -29
- package/src/pages/forms/tag-combobox/DomTagCombobox.vue +67 -15
- package/src/pages/lib/headless/autocomplete.js +5 -0
- package/src/pages/lib/headless/combobox.js +129 -25
- package/src/pages/lib/headless/dropdown.js +3 -2
- package/src/pages/lib/headless/listbox.d.ts +1 -0
- package/src/pages/lib/headless/listbox.js +41 -5
- package/src/pages/lib/vue/index.d.ts +137 -3
- package/src/pages/lib/vue/index.js +1 -0
|
@@ -22,6 +22,7 @@ import {
|
|
|
22
22
|
|
|
23
23
|
const OBSERVED = [
|
|
24
24
|
'value',
|
|
25
|
+
'value-label',
|
|
25
26
|
'open',
|
|
26
27
|
'align',
|
|
27
28
|
'offset',
|
|
@@ -30,24 +31,29 @@ const OBSERVED = [
|
|
|
30
31
|
'floating-mode',
|
|
31
32
|
];
|
|
32
33
|
|
|
34
|
+
/** Read an option's plain-text label when no explicit label was supplied. */
|
|
33
35
|
function textOf(option) {
|
|
34
36
|
return option.textContent.trim();
|
|
35
37
|
}
|
|
36
38
|
|
|
39
|
+
/** Resolve an option's stable committed value. */
|
|
37
40
|
function valueOf(option) {
|
|
38
41
|
return option.dataset.value ?? option.getAttribute('value') ?? option.value ?? textOf(option);
|
|
39
42
|
}
|
|
40
43
|
|
|
44
|
+
/** Resolve display text independently of rich option markup. */
|
|
41
45
|
function labelOf(option) {
|
|
42
46
|
return option.dataset.label ?? option.getAttribute('label') ?? textOf(option);
|
|
43
47
|
}
|
|
44
48
|
|
|
49
|
+
/** Match literal search text without interpreting HTML or regular expressions. */
|
|
45
50
|
function matchesOption(option, query) {
|
|
46
51
|
if (!query) return true;
|
|
47
|
-
const haystack = `${option.dataset.value || ''} ${option.dataset.label || ''} ${option.textContent}
|
|
52
|
+
const haystack = (option.dataset.search ?? `${option.dataset.value || ''} ${option.dataset.label || ''} ${option.textContent}`).toLowerCase();
|
|
48
53
|
return haystack.includes(query);
|
|
49
54
|
}
|
|
50
55
|
|
|
56
|
+
/** Dispatch a cancelable custom-value request before committing free text. */
|
|
51
57
|
function dispatchCancelable(host, type, detail) {
|
|
52
58
|
const event = new CustomEvent(type, {
|
|
53
59
|
detail,
|
|
@@ -102,11 +108,14 @@ function hideListPopover(list) {
|
|
|
102
108
|
export class ComboBase extends DomElementBase {
|
|
103
109
|
static get observedAttributes() { return OBSERVED; }
|
|
104
110
|
|
|
111
|
+
/** Initialize committed-selection, transient-query and owned listener state. */
|
|
105
112
|
constructor() {
|
|
106
113
|
super();
|
|
107
114
|
this._active = -1;
|
|
108
115
|
this._open = false;
|
|
109
116
|
this._options = [];
|
|
117
|
+
this._query = '';
|
|
118
|
+
this._selection = null;
|
|
110
119
|
this._unbindFloating = null;
|
|
111
120
|
this._mutationObserver = null;
|
|
112
121
|
this._wiredList = null;
|
|
@@ -120,6 +129,7 @@ export class ComboBase extends DomElementBase {
|
|
|
120
129
|
get _valueMode() { return 'option'; }
|
|
121
130
|
get _activeOnFilter() { return true; }
|
|
122
131
|
|
|
132
|
+
/** Read the shared floating placement contract from host attributes. */
|
|
123
133
|
_positionOpts() {
|
|
124
134
|
return {
|
|
125
135
|
align: this.getAttribute('align') || 'left',
|
|
@@ -130,6 +140,7 @@ export class ComboBase extends DomElementBase {
|
|
|
130
140
|
};
|
|
131
141
|
}
|
|
132
142
|
|
|
143
|
+
/** Wire the input and optional external list after the custom element connects. */
|
|
133
144
|
connectedCallback() {
|
|
134
145
|
this._input = this.querySelector('[slot="input"], [data-input]');
|
|
135
146
|
this._toggle = this.querySelector('[slot="toggle"], [data-toggle]');
|
|
@@ -149,12 +160,18 @@ export class ComboBase extends DomElementBase {
|
|
|
149
160
|
if (this._list) this._wireList();
|
|
150
161
|
|
|
151
162
|
this.on(this._input, 'input', () => this._handleInput());
|
|
152
|
-
this.on(this._input, 'focus', () =>
|
|
163
|
+
this.on(this._input, 'focus', () => {
|
|
164
|
+
if (!this._open) this._filter('', { open: true, preferSelected: true });
|
|
165
|
+
});
|
|
166
|
+
this.on(this._input, 'click', () => {
|
|
167
|
+
if (!this._open) this._filter('', { open: true, preferSelected: true });
|
|
168
|
+
});
|
|
153
169
|
this.on(this._input, 'keydown', (e) => this._onKey(e));
|
|
154
170
|
if (this.hasAttribute('value')) this._syncInputFromValue();
|
|
155
171
|
if (this.hasAttribute('open')) this._setOpen(true);
|
|
156
172
|
}
|
|
157
173
|
|
|
174
|
+
/** Release document listeners, observers, floating effects and list ownership. */
|
|
158
175
|
disconnectedCallback() {
|
|
159
176
|
this._setOpen(false);
|
|
160
177
|
clearFloatingEffectTimer(this._list);
|
|
@@ -163,11 +180,14 @@ export class ComboBase extends DomElementBase {
|
|
|
163
180
|
this._mutationObserver = null;
|
|
164
181
|
this._wiredList?.removeEventListener('mousedown', this._onListPointerDown);
|
|
165
182
|
this._wiredList?.removeEventListener('mousemove', this._onListPointerMove);
|
|
183
|
+
if (this._wiredList?.__elementComboOwner === this) delete this._wiredList.__elementComboOwner;
|
|
166
184
|
this._wiredList = null;
|
|
185
|
+
this._list = null;
|
|
167
186
|
super.disconnectedCallback();
|
|
168
187
|
document.removeEventListener('mousedown', this._onDocDown);
|
|
169
188
|
}
|
|
170
189
|
|
|
190
|
+
/** Resolve either a slotted list or its application-provided teleported target. */
|
|
171
191
|
_resolveList() {
|
|
172
192
|
const inner = this.querySelector('[slot="list"], [data-list]');
|
|
173
193
|
if (inner) return inner;
|
|
@@ -175,6 +195,7 @@ export class ComboBase extends DomElementBase {
|
|
|
175
195
|
return id ? document.getElementById(id) : null;
|
|
176
196
|
}
|
|
177
197
|
|
|
198
|
+
/** Reconnect the list after a framework replaces or teleports its element. */
|
|
178
199
|
_ensureList() {
|
|
179
200
|
if (!this._list || !this._list.isConnected) {
|
|
180
201
|
this._list = this._resolveList();
|
|
@@ -183,6 +204,7 @@ export class ComboBase extends DomElementBase {
|
|
|
183
204
|
return this._list;
|
|
184
205
|
}
|
|
185
206
|
|
|
207
|
+
/** Own one list's pointer events and observe async option changes without reopening it. */
|
|
186
208
|
_wireList() {
|
|
187
209
|
if (!this._list || this._list.__elementComboOwner === this) return;
|
|
188
210
|
const id = uid('cmb');
|
|
@@ -202,27 +224,46 @@ export class ComboBase extends DomElementBase {
|
|
|
202
224
|
this._list.addEventListener('mousemove', this._onListPointerMove);
|
|
203
225
|
this._mutationObserver?.disconnect();
|
|
204
226
|
this._mutationObserver = new MutationObserver(() => {
|
|
205
|
-
this.
|
|
206
|
-
|
|
207
|
-
this._filter(this._input.value, { open: true, emitQuery: false });
|
|
227
|
+
if (this._open) {
|
|
228
|
+
this._filter(this._query, { open: true, emitQuery: false, preserveActive: true });
|
|
208
229
|
this._positionList();
|
|
209
|
-
}
|
|
230
|
+
} else this._refreshOptions();
|
|
231
|
+
});
|
|
232
|
+
this._mutationObserver.observe(this._list, {
|
|
233
|
+
childList: true,
|
|
234
|
+
subtree: true,
|
|
235
|
+
characterData: true,
|
|
236
|
+
attributes: true,
|
|
237
|
+
attributeFilter: ['data-value', 'data-label', 'data-search', 'aria-disabled', 'disabled'],
|
|
210
238
|
});
|
|
211
|
-
this._mutationObserver.observe(this._list, { childList: true, subtree: true, characterData: true });
|
|
212
239
|
this._refreshOptions();
|
|
213
240
|
this._syncInputFromValue();
|
|
214
241
|
}
|
|
215
242
|
|
|
243
|
+
/** Refresh selectable rows while keeping status and paging controls out of navigation. */
|
|
216
244
|
_refreshOptions() {
|
|
217
245
|
if (!this._list) return;
|
|
218
246
|
const id = this._list.id || uid('cmb-list');
|
|
219
|
-
this._options = Array.from(this._list.children).filter((option) =>
|
|
247
|
+
this._options = Array.from(this._list.children).filter((option) => option.hasAttribute('data-value'));
|
|
220
248
|
this._options.forEach((option, index) => {
|
|
221
249
|
option.setAttribute('role', 'option');
|
|
222
250
|
option.id = option.id || `${id}-opt-${index}`;
|
|
251
|
+
option.setAttribute('aria-selected', String(this._isSelected(option)));
|
|
223
252
|
});
|
|
224
253
|
}
|
|
225
254
|
|
|
255
|
+
/** Test committed selection independently from the keyboard-active row. */
|
|
256
|
+
_isSelected(option) {
|
|
257
|
+
if (this._valueMode === 'text') return labelOf(option) === this.value && Boolean(this.value);
|
|
258
|
+
return String(valueOf(option)) === this.value;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** Keep disabled rows visible but exclude them from navigation and commitment. */
|
|
262
|
+
_isDisabled(option) {
|
|
263
|
+
return option.hasAttribute('disabled') || option.hasAttribute('data-disabled') || option.getAttribute('aria-disabled') === 'true';
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/** Emit intentional typing separately from the committed option value. */
|
|
226
267
|
_handleInput() {
|
|
227
268
|
const value = this._input.value;
|
|
228
269
|
this.emit('dom:input', { value });
|
|
@@ -230,24 +271,29 @@ export class ComboBase extends DomElementBase {
|
|
|
230
271
|
if (this._freeText) this.setAttribute('value', value);
|
|
231
272
|
}
|
|
232
273
|
|
|
274
|
+
/** Restore the committed label, including a selection outside the current page. */
|
|
233
275
|
_syncInputFromValue() {
|
|
234
276
|
if (!this._input || !this.hasAttribute('value')) return;
|
|
235
277
|
const value = this.getAttribute('value') || '';
|
|
278
|
+
this._refreshOptions();
|
|
236
279
|
if (this._valueMode === 'text') {
|
|
237
280
|
if (this._input.value !== value) this._input.value = value;
|
|
238
281
|
return;
|
|
239
282
|
}
|
|
240
283
|
const selected = this._options.find((option) => valueOf(option) === value);
|
|
241
|
-
|
|
284
|
+
if (selected) this._selection = { value, label: labelOf(selected) };
|
|
285
|
+
const display = selected ? labelOf(selected) : this.getAttribute('value-label') ?? (this._selection?.value === value ? this._selection.label : value);
|
|
242
286
|
if (this._input.value !== display) this._input.value = display;
|
|
243
287
|
}
|
|
244
288
|
|
|
289
|
+
/** Close only when pointer interaction is outside both the host and its popup. */
|
|
245
290
|
_onDocDown(e) {
|
|
246
291
|
if (this.contains(e.target)) return;
|
|
247
292
|
if (this._list && this._list.contains(e.target)) return;
|
|
248
293
|
this._setOpen(false);
|
|
249
294
|
}
|
|
250
295
|
|
|
296
|
+
/** Size and position the floating list relative to its input. */
|
|
251
297
|
_positionList() {
|
|
252
298
|
if (!this._input || !this._list || this._list.hidden) return;
|
|
253
299
|
const rect = this._input.getBoundingClientRect();
|
|
@@ -255,16 +301,19 @@ export class ComboBase extends DomElementBase {
|
|
|
255
301
|
applyFloatingPosition(this._input, this._list, this._positionOpts());
|
|
256
302
|
}
|
|
257
303
|
|
|
304
|
+
/** Track viewport, content and anchor changes while the popup is open. */
|
|
258
305
|
_bindFloating() {
|
|
259
306
|
this._unbindFloating?.();
|
|
260
307
|
this._unbindFloating = autoUpdateFloating(this._input, this._list, () => this._positionList());
|
|
261
308
|
}
|
|
262
309
|
|
|
310
|
+
/** Stop all floating-position observers owned by this control. */
|
|
263
311
|
_unbindFloating() {
|
|
264
312
|
this._unbindFloating?.();
|
|
265
313
|
this._unbindFloating = null;
|
|
266
314
|
}
|
|
267
315
|
|
|
316
|
+
/** Hide the native popover and clear its completed animation state. */
|
|
268
317
|
_finishListClose(list = this._list) {
|
|
269
318
|
if (!list) return;
|
|
270
319
|
hideListPopover(list);
|
|
@@ -272,6 +321,7 @@ export class ComboBase extends DomElementBase {
|
|
|
272
321
|
resetFloatingEffectState(list);
|
|
273
322
|
}
|
|
274
323
|
|
|
324
|
+
/** Promote the list to the top layer and start the shared enter effect. */
|
|
275
325
|
_showList() {
|
|
276
326
|
const list = this._list;
|
|
277
327
|
list.hidden = false;
|
|
@@ -282,6 +332,7 @@ export class ComboBase extends DomElementBase {
|
|
|
282
332
|
});
|
|
283
333
|
}
|
|
284
334
|
|
|
335
|
+
/** Run the leave effect without allowing a stale close to hide a reopened list. */
|
|
285
336
|
_hideList() {
|
|
286
337
|
const list = this._list;
|
|
287
338
|
beginFloatingEffectLeave(list, {
|
|
@@ -290,8 +341,11 @@ export class ComboBase extends DomElementBase {
|
|
|
290
341
|
});
|
|
291
342
|
}
|
|
292
343
|
|
|
344
|
+
/** Synchronize logical state, ARIA, listeners and lifecycle events exactly once. */
|
|
293
345
|
_setOpen(open) {
|
|
294
346
|
if (!this._ensureList()) return;
|
|
347
|
+
if (open && (this._input.disabled || this._input.readOnly)) return;
|
|
348
|
+
const changed = open !== this._open;
|
|
295
349
|
if (open === this._open && this._list.hidden === !open) {
|
|
296
350
|
if (open) this._positionList();
|
|
297
351
|
return;
|
|
@@ -310,72 +364,104 @@ export class ComboBase extends DomElementBase {
|
|
|
310
364
|
} else {
|
|
311
365
|
this._unbindFloating();
|
|
312
366
|
this._setActive(-1);
|
|
367
|
+
this._query = '';
|
|
368
|
+
if (!this._freeText) this._syncInputFromValue();
|
|
313
369
|
document.removeEventListener('mousedown', this._onDocDown);
|
|
314
370
|
}
|
|
371
|
+
if (changed) this.emit(open ? 'dom:open' : 'dom:close');
|
|
315
372
|
}
|
|
316
373
|
|
|
317
|
-
|
|
374
|
+
/** Filter transient input only; selection labels never become a reopening query. */
|
|
375
|
+
_filter(value, { open = true, emitQuery = true, preserveActive = false, preferSelected = false } = {}) {
|
|
376
|
+
this._query = value;
|
|
318
377
|
if (!this._ensureList()) {
|
|
319
378
|
if (emitQuery) this.emit('dom:query', { query: value });
|
|
320
379
|
return;
|
|
321
380
|
}
|
|
322
381
|
if (emitQuery) this.emit('dom:query', { query: value });
|
|
323
382
|
|
|
383
|
+
const activeOption = preserveActive ? this._options[this._active] : null;
|
|
324
384
|
this._refreshOptions();
|
|
325
385
|
const query = value.trim().toLowerCase();
|
|
326
386
|
let firstVisible = -1;
|
|
327
387
|
|
|
328
388
|
this._options.forEach((option, index) => {
|
|
329
|
-
const match = matchesOption(option, query);
|
|
389
|
+
const match = this.getAttribute('filter-options') === 'false' || matchesOption(option, query);
|
|
330
390
|
option.hidden = !match;
|
|
331
|
-
if (match && firstVisible === -1) firstVisible = index;
|
|
391
|
+
if (match && !this._isDisabled(option) && firstVisible === -1) firstVisible = index;
|
|
332
392
|
});
|
|
333
393
|
|
|
334
394
|
this._list.toggleAttribute('data-empty', firstVisible < 0);
|
|
335
|
-
this._setOpen(open
|
|
336
|
-
this.
|
|
395
|
+
this._setOpen(open);
|
|
396
|
+
const selected = preferSelected ? this._options.findIndex((option) => !option.hidden && !this._isDisabled(option) && this._isSelected(option)) : -1;
|
|
397
|
+
const previous = activeOption && !activeOption.hidden ? this._options.indexOf(activeOption) : -1;
|
|
398
|
+
this._setActive(previous >= 0 ? previous : selected >= 0 ? selected : this._activeOnFilter ? firstVisible : -1);
|
|
399
|
+
if (preferSelected && selected >= 0) this._scrollActive();
|
|
400
|
+
else if (!preserveActive && this._list) this._list.scrollTop = 0;
|
|
337
401
|
this._positionList();
|
|
338
402
|
}
|
|
339
403
|
|
|
404
|
+
/** Mark navigation without changing aria-selected, which describes commitment. */
|
|
340
405
|
_setActive(index) {
|
|
341
406
|
this._active = index;
|
|
342
407
|
this._options.forEach((option, optionIndex) => {
|
|
343
408
|
const active = optionIndex === index;
|
|
344
409
|
option.toggleAttribute('data-active', active);
|
|
345
|
-
option.setAttribute('aria-selected', String(
|
|
410
|
+
option.setAttribute('aria-selected', String(this._isSelected(option)));
|
|
346
411
|
});
|
|
347
412
|
if (index >= 0 && this._options[index]) this._input.setAttribute('aria-activedescendant', this._options[index].id);
|
|
348
413
|
else this._input.removeAttribute('aria-activedescendant');
|
|
349
414
|
}
|
|
350
415
|
|
|
416
|
+
/** Scroll within the list only, without moving the containing page. */
|
|
417
|
+
_scrollActive() {
|
|
418
|
+
const option = this._options[this._active];
|
|
419
|
+
if (!option || !this._list) return;
|
|
420
|
+
const row = option.getBoundingClientRect();
|
|
421
|
+
const list = this._list.getBoundingClientRect();
|
|
422
|
+
const scale = list.height / this._list.offsetHeight || 1;
|
|
423
|
+
if (row.top < list.top) this._list.scrollTop += (row.top - list.top) / scale;
|
|
424
|
+
else if (row.bottom > list.bottom) this._list.scrollTop += (row.bottom - list.bottom) / scale;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/** Return enabled visible rows with their stable positions in the rendered list. */
|
|
351
428
|
_visible() {
|
|
352
429
|
this._refreshOptions();
|
|
353
430
|
return this._options
|
|
354
431
|
.map((option, index) => ({ option, index }))
|
|
355
|
-
.filter(({ option }) => !option.hidden);
|
|
432
|
+
.filter(({ option }) => !option.hidden && !this._isDisabled(option));
|
|
356
433
|
}
|
|
357
434
|
|
|
435
|
+
/** Move the active descendant, scrolling it into view or requesting another page. */
|
|
358
436
|
_move(delta) {
|
|
359
437
|
const visible = this._visible();
|
|
360
438
|
if (!visible.length) return;
|
|
361
439
|
const current = visible.findIndex(({ index }) => index === this._active);
|
|
440
|
+
if (delta > 0 && current === visible.length - 1 && this.hasAttribute('has-more')) {
|
|
441
|
+
this.emit('dom:load-more', { query: this._query });
|
|
442
|
+
return;
|
|
443
|
+
}
|
|
362
444
|
if (current === -1) {
|
|
363
445
|
this._setActive(delta > 0 ? visible[0].index : visible[visible.length - 1].index);
|
|
446
|
+
this._scrollActive();
|
|
364
447
|
return;
|
|
365
448
|
}
|
|
366
449
|
const next = visible[(current + delta + visible.length) % visible.length];
|
|
367
450
|
this._setActive(next.index);
|
|
451
|
+
this._scrollActive();
|
|
368
452
|
}
|
|
369
453
|
|
|
454
|
+
/** Handle selection, free-text commitment, dismissal and paginated navigation. */
|
|
370
455
|
_onKey(e) {
|
|
456
|
+
if (this._input.disabled || this._input.readOnly || e.isComposing) return;
|
|
371
457
|
if (e.key === 'ArrowDown') {
|
|
372
458
|
e.preventDefault();
|
|
373
|
-
if (!this._open) this._filter(
|
|
374
|
-
this._move(1);
|
|
459
|
+
if (!this._open) this._filter('', { open: true, preferSelected: true });
|
|
460
|
+
else this._move(1);
|
|
375
461
|
} else if (e.key === 'ArrowUp') {
|
|
376
462
|
e.preventDefault();
|
|
377
|
-
if (!this._open) this._filter(
|
|
378
|
-
this._move(-1);
|
|
463
|
+
if (!this._open) this._filter('', { open: true, preferSelected: true });
|
|
464
|
+
else this._move(-1);
|
|
379
465
|
} else if (e.key === 'Enter') {
|
|
380
466
|
if (this._active >= 0) {
|
|
381
467
|
e.preventDefault();
|
|
@@ -387,33 +473,41 @@ export class ComboBase extends DomElementBase {
|
|
|
387
473
|
} else if (e.key === 'Escape') {
|
|
388
474
|
e.preventDefault();
|
|
389
475
|
this._setOpen(false);
|
|
476
|
+
if (!this._freeText) this._syncInputFromValue();
|
|
477
|
+
} else if (e.key === 'Tab') {
|
|
478
|
+
this._setOpen(false);
|
|
390
479
|
}
|
|
391
480
|
}
|
|
392
481
|
|
|
482
|
+
/** Commit only an enabled option while keeping focus on the input. */
|
|
393
483
|
_onListPointerDown(e) {
|
|
394
484
|
const option = e.target.closest?.('[role="option"]');
|
|
395
|
-
if (!option || !this._list.contains(option) || option.hidden) return;
|
|
485
|
+
if (!option || !this._list.contains(option) || option.hidden || this._isDisabled(option)) return;
|
|
396
486
|
e.preventDefault();
|
|
397
487
|
this._commit(option);
|
|
398
488
|
}
|
|
399
489
|
|
|
490
|
+
/** Update pointer activity independently of committed selection. */
|
|
400
491
|
_onListPointerMove(e) {
|
|
401
492
|
const option = e.target.closest?.('[role="option"]');
|
|
402
|
-
if (!option || !this._list.contains(option) || option.hidden) return;
|
|
493
|
+
if (!option || !this._list.contains(option) || option.hidden || this._isDisabled(option)) return;
|
|
403
494
|
const index = this._options.indexOf(option);
|
|
404
495
|
if (index >= 0) this._setActive(index);
|
|
405
496
|
}
|
|
406
497
|
|
|
498
|
+
/** Toggle from committed state without using its display label as a search query. */
|
|
407
499
|
_toggleList() {
|
|
500
|
+
if (this._input.disabled || this._input.readOnly) return;
|
|
408
501
|
if (!this._ensureList()) return;
|
|
409
502
|
const wasOpen = this._open;
|
|
410
503
|
this._input.focus();
|
|
411
504
|
if (wasOpen) this._setOpen(false);
|
|
412
|
-
else this._filter('', { open: true });
|
|
505
|
+
else if (!this._open) this._filter('', { open: true, preferSelected: true });
|
|
413
506
|
}
|
|
414
507
|
|
|
508
|
+
/** Commit one enabled option, clear transient query state and notify consumers. */
|
|
415
509
|
_commit(option) {
|
|
416
|
-
if (!option) return;
|
|
510
|
+
if (!option || this._isDisabled(option) || this._input.disabled || this._input.readOnly) return;
|
|
417
511
|
const value = valueOf(option);
|
|
418
512
|
const detail = {
|
|
419
513
|
value,
|
|
@@ -423,6 +517,8 @@ export class ComboBase extends DomElementBase {
|
|
|
423
517
|
};
|
|
424
518
|
const inputValue = this._valueMode === 'text' ? detail.label : detail.label;
|
|
425
519
|
const hostValue = this._valueMode === 'text' ? detail.label : value;
|
|
520
|
+
this._selection = { value: hostValue, label: detail.label };
|
|
521
|
+
this._query = '';
|
|
426
522
|
|
|
427
523
|
this._input.value = inputValue;
|
|
428
524
|
this.setAttribute('value', hostValue);
|
|
@@ -431,6 +527,7 @@ export class ComboBase extends DomElementBase {
|
|
|
431
527
|
this.emit('dom:change', detail);
|
|
432
528
|
}
|
|
433
529
|
|
|
530
|
+
/** Commit permitted free text after a cancelable application validation event. */
|
|
434
531
|
_commitCustom(rawValue) {
|
|
435
532
|
const value = rawValue.trim();
|
|
436
533
|
if (!value) return;
|
|
@@ -443,9 +540,10 @@ export class ComboBase extends DomElementBase {
|
|
|
443
540
|
this.emit('dom:change', detail);
|
|
444
541
|
}
|
|
445
542
|
|
|
543
|
+
/** Apply external value, open-state and floating-placement updates. */
|
|
446
544
|
attributeChangedCallback(name, _old, value) {
|
|
447
545
|
if (!this.isConnected || !this._input) return;
|
|
448
|
-
if (name === 'value') this._syncInputFromValue();
|
|
546
|
+
if (name === 'value' || (name === 'value-label' && !this._open)) this._syncInputFromValue();
|
|
449
547
|
if (name === 'open') this._setOpen(value !== null);
|
|
450
548
|
if (['align', 'offset', 'placement', 'collision-padding', 'floating-mode'].includes(name) && this._open) this._positionList();
|
|
451
549
|
}
|
|
@@ -469,12 +567,18 @@ export class DomComboboxElement extends ComboBase {
|
|
|
469
567
|
{ name: 'placement', type: 'string', description: 'Preferred floating placement before collision handling.' },
|
|
470
568
|
{ name: 'floating-mode', type: "'viewport' | 'anchor'", description: 'viewport keeps the list inside the browser; anchor keeps it attached to the input while scrolling.' },
|
|
471
569
|
{ name: 'data-menu-id', type: 'string', description: 'Id of an external or teleported list element.' },
|
|
570
|
+
{ name: 'filter-options', type: 'string', description: 'Set false for server-filtered results. Query events still fire.' },
|
|
571
|
+
{ name: 'has-more', type: 'boolean', description: 'Request another application-owned page when navigating beyond the loaded options.' },
|
|
572
|
+
{ name: 'value-label', type: 'string', description: 'Resolved label for a committed value outside the current page.' },
|
|
472
573
|
],
|
|
473
574
|
events: [
|
|
474
575
|
{ name: 'dom:input', payload: '{ value }', description: 'Fired whenever the text value changes.' },
|
|
475
576
|
{ name: 'dom:query', payload: '{ query }', description: 'Fired whenever the user types. Useful for server lookups.' },
|
|
476
577
|
{ name: 'dom:select', payload: '{ value, label, option }', description: 'Fired when an option is selected.' },
|
|
477
578
|
{ name: 'dom:change', payload: '{ value, option, custom }', description: 'Fired when an option is committed.' },
|
|
579
|
+
{ name: 'dom:open', description: 'Fired when the list opens, including an empty/loading list.' },
|
|
580
|
+
{ name: 'dom:close', description: 'Fired when the list closes. Later option updates do not reopen it.' },
|
|
581
|
+
{ name: 'dom:load-more', payload: '{ query }', description: 'Fired at the loaded keyboard boundary when has-more is set.' },
|
|
478
582
|
],
|
|
479
583
|
keyboard: [
|
|
480
584
|
{ keys: '↑ / ↓', action: 'Move active option.' },
|
|
@@ -130,7 +130,7 @@ export class DomDropdownElement extends DomElementBase {
|
|
|
130
130
|
if (e.key === 'ArrowDown' || e.key === 'ArrowUp' || e.key === 'Enter' || e.key === ' ') {
|
|
131
131
|
e.preventDefault();
|
|
132
132
|
const position = e.key === 'ArrowUp' ? 'last' : 'preferred';
|
|
133
|
-
if (this._open
|
|
133
|
+
if (this._open) {
|
|
134
134
|
this._focusMenuStart(position, true);
|
|
135
135
|
return;
|
|
136
136
|
}
|
|
@@ -363,9 +363,10 @@ export class DomDropdownElement extends DomElementBase {
|
|
|
363
363
|
this._syncOpen(next, false);
|
|
364
364
|
}
|
|
365
365
|
|
|
366
|
+
/** Toggle logical state, including while a previous close animation is finishing. */
|
|
366
367
|
toggle() {
|
|
367
368
|
if (!this._ensureMenu()) return;
|
|
368
|
-
this._syncOpen(!
|
|
369
|
+
this._syncOpen(!this._open, false);
|
|
369
370
|
}
|
|
370
371
|
|
|
371
372
|
_select(el, ev) {
|
|
@@ -5,6 +5,7 @@ export declare class DomListboxElement extends DomElementBase {
|
|
|
5
5
|
static readonly observedAttributes?: Array<string>;
|
|
6
6
|
static readonly __doc?: HeadlessElementDoc;
|
|
7
7
|
value: string;
|
|
8
|
+
readonly values: Array<string>;
|
|
8
9
|
readonly options: Array<HTMLElement>;
|
|
9
10
|
}
|
|
10
11
|
export interface HeadlessElementDoc {
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import { DomElementBase, createRoving, defineDomElement } from './base.js';
|
|
2
2
|
|
|
3
|
+
/** Resolve a listbox row's stable value, independent of its rich rendering. */
|
|
3
4
|
function valueOf(option) {
|
|
4
5
|
return option.dataset.value ?? option.textContent.trim();
|
|
5
6
|
}
|
|
6
7
|
|
|
7
8
|
export class DomListboxElement extends DomElementBase {
|
|
8
|
-
static get observedAttributes() { return ['value', 'orientation']; }
|
|
9
|
+
static get observedAttributes() { return ['value', 'orientation', 'multiple']; }
|
|
9
10
|
|
|
10
11
|
static __doc = {
|
|
11
12
|
name: 'dom-listbox',
|
|
@@ -14,10 +15,13 @@ export class DomListboxElement extends DomElementBase {
|
|
|
14
15
|
attributes: [
|
|
15
16
|
{ name: 'value', type: 'string', description: 'Selected option value.' },
|
|
16
17
|
{ name: 'orientation', type: "'vertical' | 'horizontal'", description: 'Arrow-key direction. Defaults to vertical.' },
|
|
18
|
+
{ name: 'multiple', type: 'boolean', description: 'Toggle multiple choices. value is then a JSON array; values returns the parsed selected strings.' },
|
|
19
|
+
{ name: 'has-more', type: 'boolean', description: 'Emit dom:load-more instead of wrapping beyond the final loaded option.' },
|
|
17
20
|
],
|
|
18
21
|
events: [
|
|
19
22
|
{ name: 'dom:change', payload: '{ value, option }', description: 'Fired when selection changes.' },
|
|
20
23
|
{ name: 'dom:select', payload: '{ value, option }', description: 'Fired when an option is selected.' },
|
|
24
|
+
{ name: 'dom:load-more', description: 'Fired when ArrowDown reaches the last loaded option and has-more is set.' },
|
|
21
25
|
],
|
|
22
26
|
keyboard: [
|
|
23
27
|
{ keys: '↑ / ↓', action: 'Move active option.' },
|
|
@@ -26,6 +30,7 @@ export class DomListboxElement extends DomElementBase {
|
|
|
26
30
|
],
|
|
27
31
|
};
|
|
28
32
|
|
|
33
|
+
/** Initialize roving navigation and coalesced option refresh state. */
|
|
29
34
|
constructor() {
|
|
30
35
|
super();
|
|
31
36
|
this._roving = null;
|
|
@@ -36,6 +41,7 @@ export class DomListboxElement extends DomElementBase {
|
|
|
36
41
|
this._scheduleRefresh = this._scheduleRefresh.bind(this);
|
|
37
42
|
}
|
|
38
43
|
|
|
44
|
+
/** Observe dynamic option pages and attach list-level keyboard and pointer handlers. */
|
|
39
45
|
connectedCallback() {
|
|
40
46
|
this.setAttribute('role', this.getAttribute('role') || 'listbox');
|
|
41
47
|
this._refresh();
|
|
@@ -50,6 +56,7 @@ export class DomListboxElement extends DomElementBase {
|
|
|
50
56
|
});
|
|
51
57
|
}
|
|
52
58
|
|
|
59
|
+
/** Refresh selection and navigation when the public host state changes. */
|
|
53
60
|
attributeChangedCallback() {
|
|
54
61
|
this._refresh();
|
|
55
62
|
}
|
|
@@ -60,11 +67,23 @@ export class DomListboxElement extends DomElementBase {
|
|
|
60
67
|
else this.setAttribute('value', value);
|
|
61
68
|
}
|
|
62
69
|
|
|
70
|
+
/** Read selected values; multiple lists serialize the value attribute as JSON. */
|
|
71
|
+
get values() {
|
|
72
|
+
if (!this.hasAttribute('multiple')) return [this.value];
|
|
73
|
+
try {
|
|
74
|
+
const values = JSON.parse(this.value || '[]');
|
|
75
|
+
return Array.isArray(values) ? values.map(String) : [];
|
|
76
|
+
} catch {
|
|
77
|
+
return [];
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
63
81
|
get options() {
|
|
64
82
|
return Array.from(this.querySelectorAll('[role="option"]'))
|
|
65
83
|
.filter((option) => !option.hasAttribute('disabled') && option.getAttribute('aria-disabled') !== 'true');
|
|
66
84
|
}
|
|
67
85
|
|
|
86
|
+
/** Coalesce option mutations into one animation-frame navigation refresh. */
|
|
68
87
|
_scheduleRefresh() {
|
|
69
88
|
if (this._refreshFrame) return;
|
|
70
89
|
this._refreshFrame = requestAnimationFrame(() => {
|
|
@@ -73,37 +92,53 @@ export class DomListboxElement extends DomElementBase {
|
|
|
73
92
|
});
|
|
74
93
|
}
|
|
75
94
|
|
|
95
|
+
/** Preserve focused rows across page appends and expose committed ARIA selection. */
|
|
76
96
|
_refresh() {
|
|
77
97
|
const options = this.options;
|
|
78
98
|
const orientation = this.getAttribute('orientation') || 'vertical';
|
|
79
99
|
const focused = options.findIndex((option) => option === document.activeElement || option.contains(document.activeElement));
|
|
80
|
-
const
|
|
100
|
+
const values = new Set(this.values);
|
|
101
|
+
const selected = options.findIndex((option) => values.has(valueOf(option)));
|
|
81
102
|
const active = focused >= 0 ? focused : Math.max(0, selected);
|
|
82
103
|
this.setAttribute('aria-orientation', orientation);
|
|
104
|
+
this.setAttribute('aria-multiselectable', String(this.hasAttribute('multiple')));
|
|
83
105
|
options.forEach((option, index) => {
|
|
84
106
|
option.tabIndex = index === active ? 0 : -1;
|
|
85
|
-
option.setAttribute('aria-selected', String(valueOf(option)
|
|
107
|
+
option.setAttribute('aria-selected', String(values.has(valueOf(option))));
|
|
86
108
|
});
|
|
87
109
|
this._roving = createRoving({ items: options, orientation, loop: true });
|
|
88
110
|
this._roving.setActive(active, { focus: false, select: false });
|
|
89
111
|
}
|
|
90
112
|
|
|
113
|
+
/** Commit or toggle an enabled option while preserving multi-selection values. */
|
|
91
114
|
_select(option, event) {
|
|
115
|
+
if (!this.options.includes(option)) return;
|
|
92
116
|
const value = valueOf(option);
|
|
93
117
|
const changed = value !== this.value;
|
|
94
|
-
this.
|
|
118
|
+
if (this.hasAttribute('multiple')) {
|
|
119
|
+
const values = this.values;
|
|
120
|
+
this.value = JSON.stringify(values.includes(value) ? values.filter((item) => item !== value) : [...values, value]);
|
|
121
|
+
} else this.value = value;
|
|
95
122
|
this._refresh();
|
|
96
|
-
this.emit('dom:select', { value, option, event });
|
|
123
|
+
this.emit('dom:select', { value, values: this.values, option, event });
|
|
97
124
|
if (changed) this.emit('dom:change', { value, option, event });
|
|
98
125
|
}
|
|
99
126
|
|
|
127
|
+
/** Route clicks from rich option descendants to the containing selectable row. */
|
|
100
128
|
_onClick(event) {
|
|
101
129
|
const option = event.target.closest('[role="option"]');
|
|
102
130
|
if (!option || !this.contains(option)) return;
|
|
103
131
|
this._select(option, event);
|
|
104
132
|
}
|
|
105
133
|
|
|
134
|
+
/** Navigate enabled options and request more at a paginated keyboard boundary. */
|
|
106
135
|
_onKey(event) {
|
|
136
|
+
const options = this.options;
|
|
137
|
+
if (event.key === 'ArrowDown' && document.activeElement === options.at(-1) && this.hasAttribute('has-more')) {
|
|
138
|
+
event.preventDefault();
|
|
139
|
+
this.emit('dom:load-more');
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
107
142
|
this._roving?.onKey(event);
|
|
108
143
|
if (event.key !== 'Enter' && event.key !== ' ') return;
|
|
109
144
|
const option = document.activeElement?.closest?.('[role="option"]');
|
|
@@ -112,6 +147,7 @@ export class DomListboxElement extends DomElementBase {
|
|
|
112
147
|
this._select(option, event);
|
|
113
148
|
}
|
|
114
149
|
|
|
150
|
+
/** Release the option observer, pending frame and registered event handlers. */
|
|
115
151
|
disconnectedCallback() {
|
|
116
152
|
this._observer?.disconnect();
|
|
117
153
|
this._observer = null;
|