@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
|
@@ -2,6 +2,9 @@
|
|
|
2
2
|
import { computed, nextTick, onMounted, ref, shallowRef, useId, watch } from 'vue';
|
|
3
3
|
import { resolveFloatingTeleportTarget } from '../../lib/vue/floatingTeleportTarget.js';
|
|
4
4
|
import { booleanProp, normalizeOption, searchableText } from '../_shared/options.js';
|
|
5
|
+
import DomOptionStatus from '../_shared/DomOptionStatus.vue';
|
|
6
|
+
import { useOptionPaging } from '../_shared/useOptionPaging.js';
|
|
7
|
+
import { useSelectedOptions } from '../_shared/useSelectedOptions.js';
|
|
5
8
|
import DomField from '../field/DomField.vue';
|
|
6
9
|
import { fieldProps } from '../field/fieldProps.js';
|
|
7
10
|
import { useField } from '../field/useField.js';
|
|
@@ -16,13 +19,18 @@ defineOptions({
|
|
|
16
19
|
tag: '<DomSelect>',
|
|
17
20
|
description: 'A styled select control with a closed trigger, floating listbox, keyboard navigation, and rich option slots.',
|
|
18
21
|
slots: [
|
|
19
|
-
{ name: 'value', payload: '{ option, value, label, placeholder }', description: 'Custom selected-value markup inside the trigger.' },
|
|
20
|
-
{ name: 'option', payload: '{ option, index, selected, optionId }', description: 'Custom option markup
|
|
22
|
+
{ name: 'value', payload: '{ option, options, value, label, placeholder }', description: 'Custom selected-value markup inside the trigger. options contains all selected records in multiple mode.' },
|
|
23
|
+
{ name: 'option', payload: '{ option, index, selected, query, optionId }', description: 'Custom option markup with the transient query for safe text highlighting. optionId identifies the focusable row.' },
|
|
21
24
|
{ name: 'group', payload: '{ group, options, index }', description: 'Custom heading markup for options that share a group.' },
|
|
22
25
|
],
|
|
23
26
|
events: [
|
|
24
|
-
{ name: 'update:modelValue', payload: '
|
|
27
|
+
{ name: 'update:modelValue', payload: 'string | number | Array<string | number>', description: 'Emitted with one committed value or an array in multiple mode.' },
|
|
25
28
|
{ name: 'select', payload: '({ option, value, label })', description: 'Emitted with the selected option record.' },
|
|
29
|
+
{ name: 'query', payload: 'string', description: 'Emitted on open with an empty query and as the user types in the separate search input.' },
|
|
30
|
+
{ name: 'load-more', payload: '{ query }', description: 'Request another page when hasMore is true. The application owns cursors, requests, errors and appending options.' },
|
|
31
|
+
{ name: 'list-scroll', payload: 'Event', description: 'Scroll event for optional visibility-based previews.' },
|
|
32
|
+
{ name: 'open', description: 'Emitted when the panel opens.' },
|
|
33
|
+
{ name: 'close', description: 'Emitted when the panel closes.' },
|
|
26
34
|
],
|
|
27
35
|
keyboard: [
|
|
28
36
|
{ keys: 'Enter / Space / ↓', action: 'Open the listbox.' },
|
|
@@ -37,7 +45,7 @@ defineOptions({
|
|
|
37
45
|
const props = defineProps({
|
|
38
46
|
...fieldProps,
|
|
39
47
|
modelValue: {
|
|
40
|
-
type: [String, Number],
|
|
48
|
+
type: [String, Number, Array],
|
|
41
49
|
default: '',
|
|
42
50
|
_edit: { group: 'Control props', description: 'Selected option value.' },
|
|
43
51
|
},
|
|
@@ -98,35 +106,50 @@ const props = defineProps({
|
|
|
98
106
|
default: 'Search options...',
|
|
99
107
|
_edit: { group: 'Control props', description: 'Placeholder shown inside the panel search input.' },
|
|
100
108
|
},
|
|
109
|
+
multiple: { type: [Boolean, String], default: false, _edit: { description: 'Toggle multiple values without closing the panel. Use an array v-model.' } },
|
|
110
|
+
filterOptions: { type: [Boolean, String], default: true, _edit: { description: 'Disable local filtering for server-ranked options.' } },
|
|
111
|
+
loading: { type: [Boolean, String], default: false },
|
|
112
|
+
hasMore: { type: [Boolean, String], default: false },
|
|
113
|
+
selectedOptions: { type: Array, default: () => [], _edit: { description: 'Resolved records for initial values outside the currently loaded server page.' } },
|
|
114
|
+
clearable: { type: [Boolean, String], default: false },
|
|
101
115
|
});
|
|
102
|
-
const emit = defineEmits(['update:modelValue', 'select', 'focus', 'blur']);
|
|
116
|
+
const emit = defineEmits(['update:modelValue', 'select', 'query', 'load-more', 'list-scroll', 'open', 'close', 'focus', 'blur']);
|
|
103
117
|
|
|
104
118
|
const root = ref(null);
|
|
105
119
|
const listbox = ref(null);
|
|
106
120
|
const searchInput = ref(null);
|
|
107
121
|
const searchQuery = ref('');
|
|
122
|
+
const isOpen = ref(false);
|
|
108
123
|
const isMounted = ref(false);
|
|
109
124
|
const teleportTarget = shallowRef(null);
|
|
110
125
|
const field = useField(props, emit, { idPrefix: 'dom-select' });
|
|
111
126
|
const isSearchable = computed(() => booleanProp(props.searchable, false));
|
|
127
|
+
const isMultiple = computed(() => booleanProp(props.multiple));
|
|
128
|
+
const isLoading = computed(() => booleanProp(props.loading));
|
|
129
|
+
const hasMoreOptions = computed(() => booleanProp(props.hasMore));
|
|
130
|
+
const filterLocally = computed(() => booleanProp(props.filterOptions, true));
|
|
112
131
|
|
|
113
132
|
const optionList = computed(() => props.options.map((option) => normalizeOption(option, {
|
|
114
|
-
stringifyValue: true,
|
|
115
133
|
allowEmptyValue: true,
|
|
116
134
|
})).filter(Boolean));
|
|
117
135
|
const filteredOptions = computed(() => {
|
|
118
|
-
if (!isSearchable.value) return optionList.value;
|
|
136
|
+
if (!isSearchable.value || !filterLocally.value) return optionList.value;
|
|
119
137
|
const needle = searchQuery.value.trim().toLowerCase();
|
|
120
138
|
if (!needle) return optionList.value;
|
|
121
139
|
return optionList.value.filter((option) => searchableText(option).includes(needle));
|
|
122
140
|
});
|
|
123
141
|
const optionSections = computed(buildOptionSections);
|
|
124
142
|
const hasOptionGroups = computed(() => optionSections.value.some((section) => section.group));
|
|
125
|
-
const
|
|
126
|
-
const
|
|
127
|
-
const
|
|
143
|
+
const selectedValues = computed(() => isMultiple.value ? (Array.isArray(field.value.value) ? field.value.value : []) : [field.value.value ?? '']);
|
|
144
|
+
const selectedKeys = computed(() => new Set(selectedValues.value.map(String)));
|
|
145
|
+
const selectedItems = useSelectedOptions(optionList, selectedValues, () => props.selectedOptions);
|
|
146
|
+
const selectedOption = computed(() => selectedItems.value[0] && (selectedItems.value[0].value !== '' || optionList.value.some((option) => option.value === '')) ? selectedItems.value[0] : null);
|
|
147
|
+
const selectedLabel = computed(() => selectedItems.value.map((option) => option.label).join(', '));
|
|
148
|
+
const selectedValue = computed(() => isMultiple.value ? JSON.stringify(selectedValues.value.map(String)) : String(field.value.value ?? ''));
|
|
149
|
+
const paging = useOptionPaging(props, emit, { list: listbox, options: filteredOptions, query: searchQuery, open: isOpen });
|
|
128
150
|
const triggerClasses = computed(() => [
|
|
129
|
-
'skin-input flex h-auto min-h-10 cursor-pointer items-center
|
|
151
|
+
'skin-input relative flex h-auto min-h-10 cursor-pointer items-center py-2 text-left',
|
|
152
|
+
booleanProp(props.clearable) && selectedLabel.value ? 'pr-18' : 'pr-10',
|
|
130
153
|
(field.disabled.value || field.readOnly.value) && 'cursor-not-allowed opacity-60',
|
|
131
154
|
field.invalid.value && 'border-destructive focus:border-destructive focus:ring-destructive/25',
|
|
132
155
|
]);
|
|
@@ -205,7 +228,8 @@ onMounted(async () => {
|
|
|
205
228
|
]);
|
|
206
229
|
listbox.value?.addEventListener('dom:select', onSelect);
|
|
207
230
|
root.value?.addEventListener('dom:open', onOpen);
|
|
208
|
-
root.value?.addEventListener('dom:close',
|
|
231
|
+
root.value?.addEventListener('dom:close', onClose);
|
|
232
|
+
listbox.value?.addEventListener('dom:load-more', () => paging.requestMore());
|
|
209
233
|
});
|
|
210
234
|
|
|
211
235
|
watch(selectedValue, () => {
|
|
@@ -223,15 +247,19 @@ watch(isSearchable, () => {
|
|
|
223
247
|
*/
|
|
224
248
|
function onSelect(event) {
|
|
225
249
|
if (field.disabled.value || field.readOnly.value) return;
|
|
226
|
-
const
|
|
227
|
-
|
|
228
|
-
|
|
250
|
+
const option = optionList.value.find((item) => String(item.value) === String(event.detail.value)) || null;
|
|
251
|
+
if (!option || option.disabled) return;
|
|
252
|
+
const value = option.value;
|
|
253
|
+
const nextValue = isMultiple.value
|
|
254
|
+
? selectedKeys.value.has(String(value)) ? selectedValues.value.filter((item) => String(item) !== String(value)) : [...selectedValues.value, value]
|
|
255
|
+
: value;
|
|
256
|
+
field.onInput(nextValue);
|
|
229
257
|
emit('select', {
|
|
230
258
|
option,
|
|
231
259
|
value,
|
|
232
260
|
label: option?.label ?? value,
|
|
233
261
|
});
|
|
234
|
-
close();
|
|
262
|
+
if (!isMultiple.value) close();
|
|
235
263
|
}
|
|
236
264
|
|
|
237
265
|
/**
|
|
@@ -240,9 +268,49 @@ function onSelect(event) {
|
|
|
240
268
|
* @returns {void}
|
|
241
269
|
*/
|
|
242
270
|
function onOpen() {
|
|
271
|
+
isOpen.value = true;
|
|
272
|
+
resetSearch();
|
|
273
|
+
emit('query', '');
|
|
274
|
+
emit('open');
|
|
275
|
+
nextTick(() => {
|
|
276
|
+
if (isSearchable.value) searchInput.value?.focus({ preventScroll: true });
|
|
277
|
+
const selected = listbox.value?.querySelector('[aria-selected="true"]');
|
|
278
|
+
if (!isMultiple.value && selected && listbox.value) {
|
|
279
|
+
const row = selected.getBoundingClientRect();
|
|
280
|
+
const viewport = listbox.value.getBoundingClientRect();
|
|
281
|
+
// Enter animations scale the panel; scrollTop uses untransformed CSS pixels.
|
|
282
|
+
const scale = viewport.height / listbox.value.offsetHeight || 1;
|
|
283
|
+
listbox.value.scrollTop += (row.top - viewport.top) / scale - (listbox.value.clientHeight - selected.offsetHeight) / 2;
|
|
284
|
+
} else if (listbox.value) listbox.value.scrollTop = 0;
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/** Reset only transient search on close, without launching another server request. */
|
|
289
|
+
function onClose() {
|
|
290
|
+
isOpen.value = false;
|
|
243
291
|
resetSearch();
|
|
244
|
-
|
|
245
|
-
|
|
292
|
+
emit('close');
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Clear committed values while preserving the single/multiple value contract.
|
|
297
|
+
* Restore focus to the persistent trigger when the clear button disappears.
|
|
298
|
+
*
|
|
299
|
+
* @returns {void}
|
|
300
|
+
*/
|
|
301
|
+
function clearSelection() {
|
|
302
|
+
if (field.disabled.value || field.readOnly.value) return;
|
|
303
|
+
const value = isMultiple.value ? [] : '';
|
|
304
|
+
field.onInput(value);
|
|
305
|
+
emit('select', { option: null, value, label: '' });
|
|
306
|
+
close();
|
|
307
|
+
nextTick(() => root.value?.querySelector('[slot="trigger"]')?.focus({ preventScroll: true }));
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/** Forward intentional typing and reset the result viewport for the new query. */
|
|
311
|
+
function onQuery() {
|
|
312
|
+
if (listbox.value) listbox.value.scrollTop = 0;
|
|
313
|
+
emit('query', searchQuery.value);
|
|
246
314
|
}
|
|
247
315
|
|
|
248
316
|
/**
|
|
@@ -313,7 +381,7 @@ function onSearchKeydown(event) {
|
|
|
313
381
|
if (event.key === 'Enter') {
|
|
314
382
|
event.preventDefault();
|
|
315
383
|
event.stopPropagation();
|
|
316
|
-
selectOption(filteredOptions.value
|
|
384
|
+
selectOption(filteredOptions.value.find((option) => !option.disabled));
|
|
317
385
|
return;
|
|
318
386
|
}
|
|
319
387
|
if (event.key === 'Home' || event.key === 'End') {
|
|
@@ -323,12 +391,14 @@ function onSearchKeydown(event) {
|
|
|
323
391
|
</script>
|
|
324
392
|
|
|
325
393
|
<template>
|
|
326
|
-
<DomField v-bind="field.fieldAttrs.value" :chrome="chrome">
|
|
394
|
+
<DomField v-bind="field.fieldAttrs.value" :chrome="chrome" class="min-w-0">
|
|
327
395
|
<input
|
|
328
|
-
v-
|
|
396
|
+
v-for="(value, index) in (isMultiple ? selectedValues : [field.value.value ?? ''])"
|
|
397
|
+
:key="index"
|
|
329
398
|
type="hidden"
|
|
330
399
|
:name="field.htmlName.value"
|
|
331
|
-
:value="
|
|
400
|
+
:value="value"
|
|
401
|
+
:disabled="field.disabled.value"
|
|
332
402
|
/>
|
|
333
403
|
<dom-dropdown
|
|
334
404
|
ref="root"
|
|
@@ -358,7 +428,8 @@ function onSearchKeydown(event) {
|
|
|
358
428
|
<slot
|
|
359
429
|
name="value"
|
|
360
430
|
:option="selectedOption"
|
|
361
|
-
:
|
|
431
|
+
:options="selectedItems"
|
|
432
|
+
:value="field.value.value"
|
|
362
433
|
:label="selectedLabel"
|
|
363
434
|
:placeholder="placeholder"
|
|
364
435
|
>
|
|
@@ -367,10 +438,23 @@ function onSearchKeydown(event) {
|
|
|
367
438
|
</span>
|
|
368
439
|
</slot>
|
|
369
440
|
</span>
|
|
370
|
-
<svg viewBox="0 0 20 20" class="size-4
|
|
441
|
+
<svg viewBox="0 0 20 20" class="pointer-events-none absolute right-3 top-1/2 size-4 -translate-y-1/2 text-muted-fg" fill="none" aria-hidden="true">
|
|
371
442
|
<path d="M6 8l4 4 4-4" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" />
|
|
372
443
|
</svg>
|
|
373
444
|
</button>
|
|
445
|
+
<button
|
|
446
|
+
v-if="booleanProp(clearable) && selectedLabel"
|
|
447
|
+
type="button"
|
|
448
|
+
aria-label="Clear selection"
|
|
449
|
+
class="absolute right-9 top-1/2 inline-flex size-8 -translate-y-1/2 items-center justify-center rounded-full text-muted-fg hover:bg-secondary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-60"
|
|
450
|
+
:disabled="field.disabled.value || field.readOnly.value"
|
|
451
|
+
@click.stop="clearSelection"
|
|
452
|
+
@mousedown.prevent
|
|
453
|
+
>
|
|
454
|
+
<svg viewBox="0 0 20 20" class="size-4" fill="none" aria-hidden="true">
|
|
455
|
+
<path d="M6 6l8 8M14 6l-8 8" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" />
|
|
456
|
+
</svg>
|
|
457
|
+
</button>
|
|
374
458
|
<Teleport :to="teleportTarget || 'body'" :disabled="!isMounted || !teleportTarget">
|
|
375
459
|
<div
|
|
376
460
|
:id="panelId"
|
|
@@ -394,12 +478,17 @@ function onSearchKeydown(event) {
|
|
|
394
478
|
@focus="field.onFocus"
|
|
395
479
|
@blur="field.onBlur"
|
|
396
480
|
@keydown="onSearchKeydown"
|
|
481
|
+
@input="onQuery"
|
|
397
482
|
/>
|
|
398
483
|
</div>
|
|
399
484
|
<dom-listbox
|
|
400
485
|
:id="listId"
|
|
401
486
|
ref="listbox"
|
|
402
487
|
:value="selectedValue"
|
|
488
|
+
:multiple="isMultiple ? '' : null"
|
|
489
|
+
:has-more="hasMoreOptions ? '' : null"
|
|
490
|
+
:aria-busy="isLoading"
|
|
491
|
+
@scroll="paging.onScroll"
|
|
403
492
|
class="min-h-0 overflow-auto"
|
|
404
493
|
:class="hasOptionGroups ? 'px-1 pb-1' : 'p-1'"
|
|
405
494
|
>
|
|
@@ -431,6 +520,7 @@ function onSearchKeydown(event) {
|
|
|
431
520
|
role="option"
|
|
432
521
|
type="button"
|
|
433
522
|
:data-value="entry.option.value"
|
|
523
|
+
:aria-selected="selectedKeys.has(String(entry.option.value))"
|
|
434
524
|
:disabled="entry.option.disabled || undefined"
|
|
435
525
|
class="dom-select-option flex w-full cursor-pointer items-center justify-between gap-3 rounded-xl px-3 py-2 text-left text-sm text-canvas-fg outline-none transition disabled:cursor-not-allowed disabled:opacity-50"
|
|
436
526
|
>
|
|
@@ -439,7 +529,8 @@ function onSearchKeydown(event) {
|
|
|
439
529
|
name="option"
|
|
440
530
|
:option="entry.option"
|
|
441
531
|
:index="entry.index"
|
|
442
|
-
:selected="String(entry.option.value)
|
|
532
|
+
:selected="selectedKeys.has(String(entry.option.value))"
|
|
533
|
+
:query="searchQuery"
|
|
443
534
|
:option-id="`${listId}-option-${entry.index}`"
|
|
444
535
|
>
|
|
445
536
|
<span class="block font-medium">{{ entry.option.label }}</span>
|
|
@@ -447,7 +538,7 @@ function onSearchKeydown(event) {
|
|
|
447
538
|
</slot>
|
|
448
539
|
</span>
|
|
449
540
|
<svg
|
|
450
|
-
v-if="String(entry.option.value)
|
|
541
|
+
v-if="selectedKeys.has(String(entry.option.value))"
|
|
451
542
|
viewBox="0 0 20 20"
|
|
452
543
|
class="size-4 shrink-0"
|
|
453
544
|
fill="none"
|
|
@@ -457,9 +548,7 @@ function onSearchKeydown(event) {
|
|
|
457
548
|
</svg>
|
|
458
549
|
</button>
|
|
459
550
|
</div>
|
|
460
|
-
<
|
|
461
|
-
No options found
|
|
462
|
-
</div>
|
|
551
|
+
<DomOptionStatus :loading="isLoading" :has-more="hasMoreOptions" :empty="!filteredOptions.length" @load-more="paging.requestMore(true)" />
|
|
463
552
|
</dom-listbox>
|
|
464
553
|
</div>
|
|
465
554
|
</Teleport>
|
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
<script setup>
|
|
2
|
-
import { computed, nextTick, ref } from 'vue';
|
|
2
|
+
import { computed, nextTick, ref, watch } from 'vue';
|
|
3
3
|
import DomFieldLoadingSpinner from '../_shared/DomFieldLoadingSpinner.vue';
|
|
4
4
|
import { booleanProp, normalizeOption, optionKey, searchableText, splitTokens as splitOptionTokens } from '../_shared/options.js';
|
|
5
5
|
import { useOptionMenu } from '../_shared/useOptionMenu.js';
|
|
6
|
+
import { useOptionPaging } from '../_shared/useOptionPaging.js';
|
|
7
|
+
import { useSelectedOptions } from '../_shared/useSelectedOptions.js';
|
|
8
|
+
import DomOptionStatus from '../_shared/DomOptionStatus.vue';
|
|
6
9
|
import DomField from '../field/DomField.vue';
|
|
7
10
|
import { fieldProps } from '../field/fieldProps.js';
|
|
8
11
|
import { useField } from '../field/useField.js';
|
|
@@ -14,7 +17,7 @@ defineOptions({
|
|
|
14
17
|
description: 'A tokenizing multi-select combobox for class lists, recipients, labels, and other values that need autocomplete plus optional custom entries.',
|
|
15
18
|
icon: 'M7 5h5l5 5-7 7-5-5 7-7Zm1.5 4.5h.01M15 15h4M17 13v4',
|
|
16
19
|
slots: [
|
|
17
|
-
{ name: 'item', payload: '{ item, index, active, custom }', description: 'Replaces each suggestion row.' },
|
|
20
|
+
{ name: 'item', payload: '{ item, index, active, selected, query, custom }', description: 'Replaces each suggestion row while retaining the selected checkmark.' },
|
|
18
21
|
{ name: 'tag', payload: '{ item, value, label, remove }', description: 'Replaces each selected token.' },
|
|
19
22
|
],
|
|
20
23
|
events: [
|
|
@@ -23,6 +26,7 @@ defineOptions({
|
|
|
23
26
|
{ name: 'remove', payload: '({ item, value, label })', description: 'Fired when a tag is removed.' },
|
|
24
27
|
{ name: 'select', payload: '({ item, value, label, custom })', description: 'Fired when a suggestion or custom value is committed.' },
|
|
25
28
|
{ name: 'change', payload: 'Array', description: 'Fired after the selected values array changes.' },
|
|
29
|
+
{ name: 'load-more', payload: '{ query }', description: 'Request the next server page when hasMore is true.' },
|
|
26
30
|
],
|
|
27
31
|
},
|
|
28
32
|
});
|
|
@@ -95,17 +99,20 @@ const props = defineProps({
|
|
|
95
99
|
default: false,
|
|
96
100
|
_edit: { group: 'Control props', description: 'Show a clear-all button when tags are selected.' },
|
|
97
101
|
},
|
|
102
|
+
hasMore: { type: [Boolean, String], default: false },
|
|
103
|
+
selectedOptions: { type: Array, default: () => [] },
|
|
98
104
|
});
|
|
99
105
|
|
|
100
|
-
const emit = defineEmits(['update:modelValue', 'query', 'add', 'remove', 'select', 'change', 'focus', 'blur']);
|
|
106
|
+
const emit = defineEmits(['update:modelValue', 'query', 'add', 'remove', 'select', 'change', 'load-more', 'list-scroll', 'focus', 'blur']);
|
|
101
107
|
|
|
102
108
|
const inputEl = ref(null);
|
|
109
|
+
const list = ref(null);
|
|
103
110
|
const query = ref('');
|
|
104
111
|
const field = useField(props, emit, { idPrefix: 'dom-tag-combobox' });
|
|
105
112
|
|
|
106
113
|
const selectedValues = computed(() => Array.isArray(field.value.value) ? field.value.value : []);
|
|
107
114
|
const normalizedOptions = computed(() => props.options.map((option) => normalizeOption(option)).filter(Boolean));
|
|
108
|
-
const selectedItems =
|
|
115
|
+
const selectedItems = useSelectedOptions(normalizedOptions, selectedValues, () => props.selectedOptions);
|
|
109
116
|
const selectedValueKeys = computed(() => new Set(selectedValues.value.map(optionKey)));
|
|
110
117
|
const isClearable = computed(() => booleanProp(props.clearable, false));
|
|
111
118
|
const isCustomAllowed = computed(() => booleanProp(props.allowCustom, false));
|
|
@@ -126,12 +133,11 @@ const matchingOptions = computed(() => {
|
|
|
126
133
|
const needle = trimmedQuery.value.toLowerCase();
|
|
127
134
|
return normalizedOptions.value
|
|
128
135
|
.filter((option) => !option.disabled)
|
|
129
|
-
.filter((option) => !selectedValueKeys.value.has(optionKey(option.value)))
|
|
130
136
|
.filter((option) => {
|
|
131
137
|
if (!needle || !isFilteringOptions.value) return true;
|
|
132
138
|
return searchableText(option).includes(needle);
|
|
133
139
|
})
|
|
134
|
-
.slice(0, Math.max(1, props.maxOptions));
|
|
140
|
+
.slice(0, isFilteringOptions.value ? Math.max(1, props.maxOptions) : undefined);
|
|
135
141
|
});
|
|
136
142
|
const menuItems = computed(() => {
|
|
137
143
|
const items = matchingOptions.value.map((item) => ({ item, value: item.value, label: item.label, custom: false }));
|
|
@@ -150,23 +156,31 @@ const optionMenu = useOptionMenu(menuItems, {
|
|
|
150
156
|
emitQuery: (value) => emit('query', value),
|
|
151
157
|
});
|
|
152
158
|
const { activeIndex, isOpen } = optionMenu;
|
|
153
|
-
const
|
|
154
|
-
const showMenu = computed(() => isOpen.value &&
|
|
159
|
+
const hasMoreOptions = computed(() => booleanProp(props.hasMore));
|
|
160
|
+
const showMenu = computed(() => isOpen.value && !field.disabled.value && !field.readOnly.value);
|
|
155
161
|
const hasTags = computed(() => selectedValues.value.length > 0);
|
|
162
|
+
const paging = useOptionPaging(props, emit, { list, options: normalizedOptions, query, open: isOpen });
|
|
156
163
|
|
|
164
|
+
watch(activeIndex, () => nextTick(scrollActiveOption));
|
|
165
|
+
|
|
166
|
+
/** Build a plain-text record for an allowed free-entry token. */
|
|
157
167
|
function customOption(value) {
|
|
158
168
|
return { value, label: String(value), custom: true };
|
|
159
169
|
}
|
|
160
170
|
|
|
171
|
+
/** Resolve a loaded or retained selected record by token value. */
|
|
161
172
|
function optionForValue(value) {
|
|
162
|
-
return normalizedOptions.value.find((option) => optionKey(option.value) === optionKey(value));
|
|
173
|
+
return [...normalizedOptions.value, ...selectedItems.value].find((option) => optionKey(option.value) === optionKey(value));
|
|
163
174
|
}
|
|
164
175
|
|
|
176
|
+
/** Toggle the chosen suggestion; selected rows remain visible with a checkmark. */
|
|
165
177
|
function commitItem(entry) {
|
|
166
178
|
if (!entry) return;
|
|
179
|
+
if (selectedValueKeys.value.has(optionKey(entry.value))) { removeValue(entry.value); return; }
|
|
167
180
|
commitValue(entry.value, entry.item, entry.custom);
|
|
168
181
|
}
|
|
169
182
|
|
|
183
|
+
/** Add a unique token, preserving existing selections across remote page changes. */
|
|
170
184
|
function commitValue(value, item = null, custom = false) {
|
|
171
185
|
if (field.disabled.value || field.readOnly.value) return;
|
|
172
186
|
if (value == null || value === '') return;
|
|
@@ -185,6 +199,7 @@ function commitValue(value, item = null, custom = false) {
|
|
|
185
199
|
nextTick(() => inputEl.value?.focus());
|
|
186
200
|
}
|
|
187
201
|
|
|
202
|
+
/** Remove one committed token without altering the remaining values. */
|
|
188
203
|
function removeValue(value) {
|
|
189
204
|
if (field.disabled.value || field.readOnly.value) return;
|
|
190
205
|
const item = optionForValue(value) || customOption(value);
|
|
@@ -195,6 +210,7 @@ function removeValue(value) {
|
|
|
195
210
|
nextTick(() => inputEl.value?.focus());
|
|
196
211
|
}
|
|
197
212
|
|
|
213
|
+
/** Clear all tokens unless the field is disabled or read-only. */
|
|
198
214
|
function clearAll() {
|
|
199
215
|
if (field.disabled.value || field.readOnly.value) return;
|
|
200
216
|
field.setValue([]);
|
|
@@ -202,23 +218,49 @@ function clearAll() {
|
|
|
202
218
|
nextTick(() => inputEl.value?.focus());
|
|
203
219
|
}
|
|
204
220
|
|
|
221
|
+
/** Clear only transient search after adding a token and keep multi-selection open. */
|
|
205
222
|
function resetQuery() {
|
|
206
223
|
optionMenu.reset();
|
|
224
|
+
isOpen.value = true;
|
|
207
225
|
}
|
|
208
226
|
|
|
227
|
+
/** Open suggestions and request the initial remote page when focus enters. */
|
|
209
228
|
function onFocus(event) {
|
|
210
229
|
field.onFocus(event);
|
|
230
|
+
optionMenu.cancelClose();
|
|
231
|
+
openMenu();
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** Reopen on a click even when Escape left the input focused. */
|
|
235
|
+
function openMenu() {
|
|
236
|
+
if (field.disabled.value || field.readOnly.value) return;
|
|
237
|
+
if (!isOpen.value) emit('query', query.value);
|
|
211
238
|
isOpen.value = true;
|
|
212
239
|
}
|
|
213
240
|
|
|
241
|
+
/** Allow a pointer selection to finish before closing on blur. */
|
|
214
242
|
function onBlur(event) {
|
|
215
243
|
optionMenu.closeSoon();
|
|
216
244
|
field.onBlur(event);
|
|
217
245
|
}
|
|
218
246
|
|
|
247
|
+
/** Keep the active descendant visible without scrolling the outer document. */
|
|
248
|
+
function scrollActiveOption() {
|
|
249
|
+
const option = list.value?.querySelectorAll('[role="option"]')[activeIndex.value];
|
|
250
|
+
if (!option || !list.value) return;
|
|
251
|
+
const row = option.getBoundingClientRect();
|
|
252
|
+
const viewport = list.value.getBoundingClientRect();
|
|
253
|
+
const scale = viewport.height / list.value.offsetHeight || 1;
|
|
254
|
+
if (row.top < viewport.top) list.value.scrollTop += (row.top - viewport.top) / scale;
|
|
255
|
+
else if (row.bottom > viewport.bottom) list.value.scrollTop += (row.bottom - viewport.bottom) / scale;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** Navigate, toggle selections and request another page at the loaded boundary. */
|
|
219
259
|
function onKeydown(event) {
|
|
260
|
+
if (field.disabled.value || field.readOnly.value || event.isComposing) return;
|
|
220
261
|
if (event.key === 'ArrowDown') {
|
|
221
262
|
event.preventDefault();
|
|
263
|
+
if (isOpen.value && activeIndex.value === menuItems.value.length - 1 && hasMoreOptions.value) { paging.requestMore(); return; }
|
|
222
264
|
optionMenu.move(1);
|
|
223
265
|
return;
|
|
224
266
|
}
|
|
@@ -243,6 +285,7 @@ function onKeydown(event) {
|
|
|
243
285
|
}
|
|
244
286
|
}
|
|
245
287
|
|
|
288
|
+
/** Paste tokens through the same deduplication and permission checks as typing. */
|
|
246
289
|
function onPaste(event) {
|
|
247
290
|
const text = event.clipboardData?.getData('text') || '';
|
|
248
291
|
const tokens = splitTokens(text);
|
|
@@ -255,6 +298,7 @@ function onPaste(event) {
|
|
|
255
298
|
}
|
|
256
299
|
}
|
|
257
300
|
|
|
301
|
+
/** Split user text into literal tokens using the configured separators. */
|
|
258
302
|
function splitTokens(value) {
|
|
259
303
|
return splitOptionTokens(value, props.tokenSeparators);
|
|
260
304
|
}
|
|
@@ -279,6 +323,7 @@ function splitTokens(value) {
|
|
|
279
323
|
type="button"
|
|
280
324
|
class="-mr-1 inline-flex size-4 items-center justify-center rounded-full text-muted-fg hover:bg-canvas hover:text-canvas-fg focus:outline-none focus-visible:ring-2 focus-visible:ring-ring/60"
|
|
281
325
|
:aria-label="`Remove ${entry.label}`"
|
|
326
|
+
:disabled="field.disabled.value || field.readOnly.value"
|
|
282
327
|
@click.stop="removeValue(entry.value)"
|
|
283
328
|
@mousedown.prevent
|
|
284
329
|
>
|
|
@@ -296,9 +341,12 @@ function splitTokens(value) {
|
|
|
296
341
|
role="combobox"
|
|
297
342
|
:aria-expanded="showMenu"
|
|
298
343
|
:aria-controls="`${field.id.value}-listbox`"
|
|
344
|
+
:aria-activedescendant="showMenu && menuItems[activeIndex] ? `${field.id.value}-option-${activeIndex}` : undefined"
|
|
345
|
+
aria-autocomplete="list"
|
|
299
346
|
:placeholder="hasTags ? '' : placeholder"
|
|
300
347
|
class="min-w-28 flex-1 bg-transparent px-1 py-1 text-sm outline-none placeholder:text-muted-fg disabled:cursor-not-allowed"
|
|
301
348
|
@focus="onFocus"
|
|
349
|
+
@click="openMenu"
|
|
302
350
|
@blur="onBlur"
|
|
303
351
|
@keydown="onKeydown"
|
|
304
352
|
@paste="onPaste"
|
|
@@ -311,6 +359,7 @@ function splitTokens(value) {
|
|
|
311
359
|
type="button"
|
|
312
360
|
class="inline-flex size-7 shrink-0 items-center justify-center rounded-full text-muted-fg transition hover:bg-secondary hover:text-canvas-fg focus:outline-none focus-visible:ring-2 focus-visible:ring-ring/60"
|
|
313
361
|
aria-label="Clear tags"
|
|
362
|
+
:disabled="field.disabled.value || field.readOnly.value"
|
|
314
363
|
@click.stop="clearAll"
|
|
315
364
|
@mousedown.prevent
|
|
316
365
|
>
|
|
@@ -323,24 +372,26 @@ function splitTokens(value) {
|
|
|
323
372
|
<ul
|
|
324
373
|
v-if="showMenu"
|
|
325
374
|
:id="`${field.id.value}-listbox`"
|
|
375
|
+
ref="list"
|
|
326
376
|
role="listbox"
|
|
377
|
+
aria-multiselectable="true"
|
|
378
|
+
:aria-busy="isLoading"
|
|
379
|
+
@scroll="paging.onScroll"
|
|
327
380
|
class="skin-popover absolute z-40 mt-2 max-h-72 w-full overflow-auto rounded-2xl p-1"
|
|
328
381
|
>
|
|
329
|
-
<li v-if="hasEmptyState" class="rounded-xl px-3 py-2 text-sm text-muted-fg">
|
|
330
|
-
{{ emptyText }}
|
|
331
|
-
</li>
|
|
332
382
|
<li
|
|
333
383
|
v-for="(entry, index) in menuItems"
|
|
334
384
|
:key="`${entry.custom ? 'custom' : 'option'}-${optionKey(entry.value)}`"
|
|
335
385
|
role="option"
|
|
336
|
-
:
|
|
386
|
+
:id="`${field.id.value}-option-${index}`"
|
|
387
|
+
:aria-selected="selectedValueKeys.has(optionKey(entry.value))"
|
|
337
388
|
class="cursor-pointer rounded-xl px-3 py-2 text-sm transition"
|
|
338
389
|
:class="activeIndex === index ? 'bg-accent text-accent-fg' : 'hover:bg-accent/70'"
|
|
339
390
|
@mouseenter="activeIndex = index"
|
|
340
391
|
@mousedown.prevent
|
|
341
392
|
@click="commitItem(entry)"
|
|
342
393
|
>
|
|
343
|
-
<slot name="item" :item="entry.item" :index="index" :active="activeIndex === index" :custom="entry.custom">
|
|
394
|
+
<div class="flex items-center gap-3"><span class="min-w-0 flex-1"><slot name="item" :item="entry.item" :index="index" :active="activeIndex === index" :selected="selectedValueKeys.has(optionKey(entry.value))" :query="query" :custom="entry.custom">
|
|
344
395
|
<div class="flex min-w-0 items-center justify-between gap-3">
|
|
345
396
|
<span class="min-w-0">
|
|
346
397
|
<span class="block truncate font-medium">{{ entry.custom ? `Add "${entry.label}"` : entry.label }}</span>
|
|
@@ -350,8 +401,9 @@ function splitTokens(value) {
|
|
|
350
401
|
</span>
|
|
351
402
|
<span v-if="entry.custom" class="shrink-0 rounded-full bg-canvas/70 px-2 py-0.5 text-[11px] text-muted-fg ring-1 ring-border">custom</span>
|
|
352
403
|
</div>
|
|
353
|
-
</slot>
|
|
404
|
+
</slot></span><svg v-if="selectedValueKeys.has(optionKey(entry.value))" viewBox="0 0 20 20" class="size-4 shrink-0" fill="none" aria-hidden="true"><path d="M4.5 10.5 8 14l7.5-8" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" /></svg></div>
|
|
354
405
|
</li>
|
|
406
|
+
<DomOptionStatus as="li" :loading="isLoading" :has-more="hasMoreOptions" :empty="!menuItems.length" :empty-text="emptyText" @load-more="paging.requestMore(true)" />
|
|
355
407
|
</ul>
|
|
356
408
|
</div>
|
|
357
409
|
</DomField>
|
|
@@ -15,6 +15,8 @@ export class DomAutocompleteElement extends ComboBase {
|
|
|
15
15
|
{ name: 'placement', type: 'string', description: 'Preferred floating placement before collision handling.' },
|
|
16
16
|
{ name: 'floating-mode', type: "'viewport' | 'anchor'", description: 'viewport keeps suggestions inside the browser; anchor keeps them attached to the input while scrolling.' },
|
|
17
17
|
{ name: 'data-menu-id', type: 'string', description: 'Id of an external or teleported suggestion list.' },
|
|
18
|
+
{ name: 'filter-options', type: 'string', description: 'Set false for server-filtered suggestions.' },
|
|
19
|
+
{ name: 'has-more', type: 'boolean', description: 'Request another page at the loaded keyboard boundary.' },
|
|
18
20
|
],
|
|
19
21
|
events: [
|
|
20
22
|
{ name: 'dom:input', payload: '{ value }', description: 'Fired whenever the text value changes.' },
|
|
@@ -22,6 +24,9 @@ export class DomAutocompleteElement extends ComboBase {
|
|
|
22
24
|
{ name: 'dom:select', payload: '{ value, label, option }', description: 'Fired when a suggestion is selected.' },
|
|
23
25
|
{ name: 'dom:custom', payload: '{ value, label }', description: 'Cancelable event fired when Enter commits free text.' },
|
|
24
26
|
{ name: 'dom:change', payload: '{ value, option, custom }', description: 'Fired when text or a suggestion is committed.' },
|
|
27
|
+
{ name: 'dom:open', description: 'Fired when suggestions open.' },
|
|
28
|
+
{ name: 'dom:close', description: 'Fired when suggestions close.' },
|
|
29
|
+
{ name: 'dom:load-more', payload: '{ query }', description: 'Fired when keyboard navigation reaches the loaded page boundary.' },
|
|
25
30
|
],
|
|
26
31
|
};
|
|
27
32
|
|