@getdom/studio 0.2.1 → 0.2.3

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 (30) hide show
  1. package/CHANGELOG.md +93 -0
  2. package/LICENSE.md +23 -9
  3. package/package.json +1 -1
  4. package/src/pages/_layout/componentCatalog.generated.js +115 -13
  5. package/src/pages/components/command-palette/DomCommandPalette.vue +6 -1
  6. package/src/pages/data/agent-chat/DomAgentChatShell.vue +52 -53
  7. package/src/pages/data/agent-chat/DomAgentConversation.vue +163 -154
  8. package/src/pages/data/agent-chat/DomAgentConversationList.vue +53 -57
  9. package/src/pages/data/month-calendar/DomMonthCalendar.vue +8 -9
  10. package/src/pages/forms/_shared/DomOptionStatus.vue +18 -0
  11. package/src/pages/forms/_shared/useOptionMenu.js +18 -2
  12. package/src/pages/forms/_shared/useOptionPaging.js +42 -0
  13. package/src/pages/forms/_shared/useSelectedOptions.js +25 -0
  14. package/src/pages/forms/autocomplete/DomAutocomplete.vue +36 -8
  15. package/src/pages/forms/combobox/DomCombobox.vue +44 -7
  16. package/src/pages/forms/font-picker/DomFontPicker.vue +416 -0
  17. package/src/pages/forms/font-picker/FontPreview.vue +39 -0
  18. package/src/pages/forms/font-picker/fontPicker.js +102 -0
  19. package/src/pages/forms/font-picker/googleFonts.js +1951 -0
  20. package/src/pages/forms/select/DomSelect.vue +118 -29
  21. package/src/pages/forms/tag-combobox/DomTagCombobox.vue +67 -15
  22. package/src/pages/lib/headless/autocomplete.js +5 -0
  23. package/src/pages/lib/headless/combobox.js +129 -25
  24. package/src/pages/lib/headless/dropdown.js +3 -2
  25. package/src/pages/lib/headless/listbox.d.ts +1 -0
  26. package/src/pages/lib/headless/listbox.js +41 -5
  27. package/src/pages/lib/vue/index.d.ts +136 -6
  28. package/src/pages/lib/vue/index.js +1 -0
  29. package/src/tailwind.css +5 -1
  30. package/src/theme.css +4 -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 inside the floating list. optionId identifies the focusable option button for external floating UI.' },
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: '(value: string)', description: 'Emitted when the selected option changes.' },
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 selectedOption = computed(() => optionList.value.find((option) => String(option.value) === String(field.value.value)) || null);
126
- const selectedLabel = computed(() => selectedOption.value?.label ?? '');
127
- const selectedValue = computed(() => String(field.value.value ?? ''));
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 justify-between gap-3 py-2 pr-3 text-left',
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', resetSearch);
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 value = event.detail.value;
227
- const option = optionList.value.find((item) => String(item.value) === String(value)) || null;
228
- field.onInput(value);
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
- if (!isSearchable.value) return;
245
- nextTick(() => searchInput.value?.focus());
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[0]);
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-if="field.htmlName.value"
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="field.value.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
- :value="selectedValue"
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 shrink-0 text-muted-fg" fill="none" aria-hidden="true">
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) === selectedValue"
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) === selectedValue"
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
- <div v-if="isSearchable && !filteredOptions.length" class="px-3 py-6 text-center text-sm text-muted-fg">
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 = computed(() => selectedValues.value.map((value) => optionForValue(value) || customOption(value)));
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 hasEmptyState = computed(() => !isLoading.value && !menuItems.value.length && trimmedQuery.value && !isCustomAllowed.value);
154
- const showMenu = computed(() => isOpen.value && (menuItems.value.length > 0 || hasEmptyState.value) && !field.disabled.value && !field.readOnly.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
- :aria-selected="activeIndex === index"
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