@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
@@ -46,14 +46,15 @@ export const agentConversationListProps = {
46
46
  <script setup>
47
47
  import { computed, useId } from 'vue';
48
48
  import DomIconButton from '../../components/icon-button/DomIconButton.vue';
49
- import DomStatusPill from '../../visual/status-pill/DomStatusPill.vue';
49
+ import DomAvatar from '../../visual/avatar/DomAvatar.vue';
50
+ import DomSkeleton from '../../visual/skeleton/DomSkeleton.vue';
50
51
 
51
52
  defineOptions({
52
53
  __doc: {
53
54
  nav: { section: 'ai' },
54
55
  name: 'Agent conversation list',
55
56
  tag: '<DomAgentConversationList>',
56
- description: 'A reusable AI conversation navigation list with search, agent labels, unread counts, status pills, and active-thread selection.',
57
+ description: 'A reusable AI conversation navigation list with search, agent labels, unread counts, status indicators, and active-thread selection.',
57
58
  icon: 'M5 6h14M5 12h14M5 18h8',
58
59
  events: [
59
60
  { name: 'update:search', payload: 'string', description: 'Fired when the sidebar search changes.' },
@@ -148,20 +149,13 @@ function conversationSummary(conversation) {
148
149
  }
149
150
 
150
151
  /**
151
- * Returns a compact label for the agent avatar.
152
+ * Returns the agent name used for a conversation avatar.
152
153
  *
153
154
  * @param {Record<string, unknown>} conversation Conversation summary.
154
- * @returns {string} Initials.
155
+ * @returns {string} Agent name.
155
156
  */
156
- function agentInitials(conversation) {
157
- const name = agentForConversation(conversation)?.name || 'AI';
158
- return name
159
- .split(/\s+/)
160
- .filter(Boolean)
161
- .slice(0, 2)
162
- .map(firstCharacter)
163
- .join('')
164
- .toUpperCase() || 'AI';
157
+ function agentName(conversation) {
158
+ return agentForConversation(conversation)?.name || 'AI';
165
159
  }
166
160
 
167
161
  /**
@@ -182,18 +176,18 @@ function updatedLabel(conversation) {
182
176
  }
183
177
 
184
178
  /**
185
- * Resolves a status pill tone for a conversation.
179
+ * Resolves the status dot class for a conversation.
186
180
  *
187
181
  * @param {Record<string, unknown>} conversation Conversation summary.
188
- * @returns {'neutral'|'info'|'success'|'warning'|'danger'} Status tone.
182
+ * @returns {string} Token-based dot classes.
189
183
  */
190
- function statusTone(conversation) {
184
+ function statusDotClass(conversation) {
191
185
  return {
192
- streaming: 'info',
193
- waiting: 'warning',
194
- error: 'danger',
195
- complete: 'success',
196
- }[conversation.status] || 'neutral';
186
+ streaming: 'bg-primary animate-pulse',
187
+ waiting: 'bg-warning',
188
+ error: 'bg-destructive',
189
+ complete: 'bg-success',
190
+ }[conversation.status] || 'bg-muted-fg/50';
197
191
  }
198
192
 
199
193
  /**
@@ -218,37 +212,27 @@ function conversationMatchesQuery(conversation, query) {
218
212
  .toLowerCase()
219
213
  .includes(query);
220
214
  }
221
-
222
- /**
223
- * Returns the first character from a word.
224
- *
225
- * @param {string} value Source word.
226
- * @returns {string} First character.
227
- */
228
- function firstCharacter(value) {
229
- return value.charAt(0);
230
- }
231
215
  </script>
232
216
 
233
217
  <template>
234
218
  <aside class="flex h-full min-h-0 w-full min-w-0 flex-col bg-canvas text-canvas-fg">
235
- <header class="shrink-0 border-b border-border bg-secondary/25 p-3">
236
- <div class="flex items-center justify-between gap-3">
237
- <h2 class="truncate text-sm font-semibold text-canvas-fg">{{ title }}</h2>
219
+ <header class="shrink-0 px-3 pb-2">
220
+ <div class="flex h-14 items-center justify-between gap-3 px-1">
221
+ <h2 class="truncate text-sm font-semibold tracking-tight text-canvas-fg">{{ title }}</h2>
238
222
  <div class="flex shrink-0 items-center gap-1">
239
223
  <slot name="actions" :conversations="conversations" />
240
224
  <DomIconButton
241
225
  v-if="showNewButton"
242
226
  label="New conversation"
243
227
  size="sm"
244
- variant="secondary"
228
+ variant="ghost"
245
229
  icon="M12 5v14M5 12h14"
246
230
  @click="createConversation"
247
231
  />
248
232
  </div>
249
233
  </div>
250
234
  <label :for="searchId" class="sr-only">Search conversations</label>
251
- <div class="mt-3 flex items-center gap-2 rounded-md border border-border bg-canvas px-3 py-2">
235
+ <div class="skin-input flex items-center gap-2 px-2.5 py-1.5 focus-within:border-ring focus-within:ring-2 focus-within:ring-ring/30">
252
236
  <svg viewBox="0 0 24 24" class="size-4 shrink-0 text-muted-fg" fill="none" aria-hidden="true">
253
237
  <path d="M11 5a6 6 0 1 0 0 12 6 6 0 0 0 0-12Zm4.5 10.5L20 20" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" />
254
238
  </svg>
@@ -263,18 +247,19 @@ function firstCharacter(value) {
263
247
  </div>
264
248
  </header>
265
249
 
266
- <div class="min-h-0 flex-1 overflow-y-auto p-2">
267
- <div v-if="loading" class="space-y-2">
268
- <div v-for="index in 5" :key="index" class="h-20 animate-pulse rounded-lg border border-border bg-secondary/35"></div>
250
+ <div class="min-h-0 flex-1 overflow-y-auto px-2 pb-2">
251
+ <div v-if="loading" class="space-y-1">
252
+ <div v-for="index in 5" :key="index" class="flex gap-2.5 px-2.5 py-2.5">
253
+ <DomSkeleton variant="circle" width="2rem" height="2rem" class="shrink-0" />
254
+ <DomSkeleton class="flex-1" variant="text" :lines="2" />
255
+ </div>
269
256
  </div>
270
257
 
271
258
  <slot v-else-if="!filteredConversations.length" name="empty">
272
- <div class="grid min-h-40 place-items-center rounded-lg border border-dashed border-border p-4 text-center">
273
- <p class="text-sm leading-6 text-muted-fg">{{ emptyText }}</p>
274
- </div>
259
+ <p class="px-3 py-8 text-center text-sm leading-6 text-muted-fg">{{ emptyText }}</p>
275
260
  </slot>
276
261
 
277
- <div v-else class="space-y-1">
262
+ <div v-else class="space-y-0.5">
278
263
  <slot
279
264
  v-for="conversation in filteredConversations"
280
265
  :key="conversation.id"
@@ -285,27 +270,38 @@ function firstCharacter(value) {
285
270
  >
286
271
  <button
287
272
  type="button"
288
- class="w-full rounded-lg border p-3 text-left transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
289
- :class="isActiveConversation(conversation)
290
- ? 'border-primary/40 bg-primary/10 shadow-sm'
291
- : 'border-transparent hover:border-border hover:bg-secondary/45'"
273
+ class="w-full rounded-lg px-2.5 py-2.5 text-left transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
274
+ :class="isActiveConversation(conversation) ? 'bg-secondary' : 'hover:bg-secondary/50'"
275
+ :aria-current="isActiveConversation(conversation) ? 'true' : undefined"
292
276
  @click="selectConversation(conversation)"
293
277
  >
294
- <div class="flex items-start gap-3">
295
- <div class="grid size-9 shrink-0 place-items-center rounded-full border border-border bg-canvas text-xs font-semibold text-muted-fg">
296
- {{ agentInitials(conversation) }}
297
- </div>
278
+ <div class="flex items-start gap-2.5">
279
+ <span class="relative shrink-0">
280
+ <DomAvatar
281
+ size="sm"
282
+ shape="rounded"
283
+ :src="agentForConversation(conversation)?.avatarUrl || ''"
284
+ :name="agentName(conversation)"
285
+ />
286
+ <span
287
+ class="absolute -bottom-0.5 -right-0.5 size-2.5 rounded-full ring-2"
288
+ :class="[statusDotClass(conversation), isActiveConversation(conversation) ? 'ring-secondary' : 'ring-canvas']"
289
+ :title="conversation.status || 'idle'"
290
+ aria-hidden="true"
291
+ ></span>
292
+ </span>
298
293
  <div class="min-w-0 flex-1">
299
- <div class="flex min-w-0 items-start justify-between gap-2">
300
- <p class="truncate text-sm font-semibold text-canvas-fg">{{ conversationTitle(conversation) }}</p>
294
+ <div class="flex min-w-0 items-baseline justify-between gap-2">
295
+ <p class="truncate text-sm font-medium text-canvas-fg">{{ conversationTitle(conversation) }}</p>
301
296
  <span class="shrink-0 text-[11px] text-muted-fg">{{ updatedLabel(conversation) }}</span>
302
297
  </div>
303
- <p class="mt-1 line-clamp-2 text-xs leading-5 text-muted-fg">{{ conversationSummary(conversation) }}</p>
304
- <div class="mt-2 flex items-center justify-between gap-2">
305
- <DomStatusPill size="sm" :tone="statusTone(conversation)" variant="soft">{{ conversation.status || 'idle' }}</DomStatusPill>
298
+ <div class="mt-0.5 flex min-w-0 items-center gap-2">
299
+ <p class="min-w-0 flex-1 truncate text-xs leading-5 text-muted-fg">
300
+ <span class="sr-only">{{ conversation.status || 'idle' }}. </span>{{ conversationSummary(conversation) }}
301
+ </p>
306
302
  <span
307
303
  v-if="conversation.unreadCount"
308
- class="grid min-w-5 place-items-center rounded-full bg-primary px-1.5 py-0.5 text-[11px] font-semibold text-primary-fg"
304
+ class="grid h-4.5 min-w-4.5 shrink-0 place-items-center rounded-full bg-primary px-1 text-[10px] font-semibold text-primary-fg"
309
305
  >
310
306
  {{ conversation.unreadCount }}
311
307
  </span>
@@ -264,8 +264,8 @@ export const monthCalendarProps = {
264
264
  * @param {string} value Requested indicator style.
265
265
  * @returns {boolean} Whether the indicator is supported.
266
266
  */
267
- validator: (value) => ['cell', 'circle', 'none'].includes(value),
268
- _edit: { group: 'Styling', options: ['cell', 'circle', 'none'], description: 'Highlight today with a cell outline, a coloured circle behind the date, or no visual indicator.' },
267
+ validator: (value) => ['cell', 'circle', 'both', 'none'].includes(value),
268
+ _edit: { group: 'Styling', options: ['cell', 'circle', 'both', 'none'], description: 'Highlight today with a cell outline, a coloured circle behind the date, both, or no visual indicator.' },
269
269
  },
270
270
  /**
271
271
  * @type {MonthCalendarDayClass}
@@ -273,7 +273,7 @@ export const monthCalendarProps = {
273
273
  todayDateClass: {
274
274
  type: [String, Array, Object, Function],
275
275
  default: 'bg-primary text-primary-fg',
276
- _edit: { group: 'Styling', description: 'Classes for today’s date circle. Replaces the default primary background and foreground colours when todayIndicator is circle.' },
276
+ _edit: { group: 'Styling', description: 'Classes for today’s date circle. Replaces the default primary background and foreground colours when todayIndicator is circle or both.' },
277
277
  },
278
278
  /**
279
279
  * @type {MonthCalendarDayClass}
@@ -1632,7 +1632,7 @@ function onDayClick(cell, event) {
1632
1632
  v-if="hasRowRail"
1633
1633
  role="rowheader"
1634
1634
  :aria-label="rowHeaderLabelFor(week)"
1635
- class="dom-month-calendar__row-rail flex gap-3 px-3 py-2 text-xs font-semibold uppercase tracking-wide text-muted-fg"
1635
+ class="dom-month-calendar__row-rail flex gap-3 px-3 py-[var(--dom-month-calendar-row-rail-padding)] text-xs font-semibold uppercase tracking-wide text-muted-fg"
1636
1636
  :class="[
1637
1637
  week.index > 0 && 'border-t border-border',
1638
1638
  section.continuous ? 'bg-canvas' : 'bg-secondary/35',
@@ -1648,7 +1648,7 @@ function onDayClick(cell, event) {
1648
1648
  {{ label }}
1649
1649
  </span>
1650
1650
  </div>
1651
- <span v-if="showWeekNumbers" class="text-base leading-none tabular-nums">{{ week.weekNumber }}</span>
1651
+ <span v-if="showWeekNumbers" class="order-[var(--dom-month-calendar-week-number-order)] inline-flex size-7 shrink-0 items-center justify-center text-base leading-none tabular-nums">{{ week.weekNumber }}</span>
1652
1652
  </div>
1653
1653
  <component
1654
1654
  :is="clickable ? 'button' : 'div'"
@@ -1681,7 +1681,7 @@ function onDayClick(cell, event) {
1681
1681
  clickable && !cell.disabled && hoverableDays && 'hover:bg-secondary/50',
1682
1682
  clickable && cell.disabled && 'cursor-not-allowed opacity-55',
1683
1683
  !clickable && 'cursor-default',
1684
- cell.today && !cell.hidden && todayIndicator === 'cell' && 'ring-2 ring-inset ring-ring/40',
1684
+ cell.today && !cell.hidden && (todayIndicator === 'cell' || todayIndicator === 'both') && 'ring-2 ring-inset ring-ring/40',
1685
1685
  dayClassFor(cell),
1686
1686
  dragAndDrop && dragState.active && canDropOnCell(cell) && !isDropTarget(cell) && 'bg-primary/5',
1687
1687
  dragAndDrop && isDropTarget(cell) && 'bg-primary/10 ring-2 ring-inset ring-ring/60',
@@ -1698,10 +1698,9 @@ function onDayClick(cell, event) {
1698
1698
  <template v-if="!cell.hidden">
1699
1699
  <div class="flex items-center justify-between gap-2">
1700
1700
  <span
1701
- class="text-base font-bold leading-none tabular-nums"
1701
+ class="inline-flex size-7 shrink-0 items-center justify-center rounded-full text-base font-bold leading-none tabular-nums"
1702
1702
  :class="[
1703
- todayIndicator === 'circle' && 'inline-flex size-7 shrink-0 items-center justify-center rounded-full',
1704
- cell.today && todayIndicator === 'circle' && resolveDayClass(todayDateClass, cell),
1703
+ cell.today && (todayIndicator === 'circle' || todayIndicator === 'both') && resolveDayClass(todayDateClass, cell),
1705
1704
  ]"
1706
1705
  >{{ cell.day }}</span>
1707
1706
  <span v-if="showDayNames" class="text-base lowercase leading-none tabular-nums">{{ cell.weekday }}</span>
@@ -0,0 +1,18 @@
1
+ <script setup>
2
+ defineProps({
3
+ as: { type: String, default: 'div' },
4
+ loading: Boolean,
5
+ hasMore: Boolean,
6
+ empty: Boolean,
7
+ emptyText: { type: String, default: 'No options found' },
8
+ });
9
+ defineEmits(['load-more']);
10
+ </script>
11
+
12
+ <template>
13
+ <component :is="as" v-if="loading || hasMore || empty" role="presentation" class="px-3 py-3 text-center text-sm text-muted-fg">
14
+ <span v-if="loading" role="status">Loading options…</span>
15
+ <button v-else-if="hasMore" type="button" class="rounded-lg px-3 py-1 underline underline-offset-4 focus-visible:ring-2 focus-visible:ring-ring" @mousedown.prevent @click.stop="$emit('load-more')">Load more options</button>
16
+ <span v-else role="status">{{ emptyText }}</span>
17
+ </component>
18
+ </template>
@@ -1,5 +1,6 @@
1
- import { computed, ref, watch } from 'vue';
1
+ import { computed, onBeforeUnmount, ref, watch } from 'vue';
2
2
 
3
+ /** Manage transient token-query navigation independently from committed selections. */
3
4
  export function useOptionMenu(items, options = {}) {
4
5
  const {
5
6
  emitQuery,
@@ -11,6 +12,9 @@ export function useOptionMenu(items, options = {}) {
11
12
  const isOpen = ref(false);
12
13
  const activeIndex = ref(0);
13
14
  const trimmedQuery = computed(() => query.value.trim());
15
+ let closeTimer = null;
16
+ onBeforeUnmount(() => clearTimeout(closeTimer));
17
+ watch(isOpen, (open) => { if (open) clearTimeout(closeTimer); });
14
18
 
15
19
  watch(query, (value) => {
16
20
  emitQuery?.(value);
@@ -24,28 +28,39 @@ export function useOptionMenu(items, options = {}) {
24
28
  }
25
29
  });
26
30
 
31
+ /** Wrap keyboard navigation within the currently available entries. */
27
32
  function wrapIndex(index) {
28
33
  if (!items.value.length) return 0;
29
34
  return (index + items.value.length) % items.value.length;
30
35
  }
31
36
 
37
+ /** Open the menu and move its active descendant by one step. */
32
38
  function move(delta) {
33
39
  isOpen.value = true;
34
40
  activeIndex.value = wrapIndex(activeIndex.value + delta);
35
41
  }
36
42
 
43
+ /** Reset transient input without changing any committed tokens. */
37
44
  function reset() {
45
+ clearTimeout(closeTimer);
38
46
  query.value = '';
39
47
  isOpen.value = false;
40
48
  activeIndex.value = 0;
41
49
  }
42
50
 
51
+ /** Defer blur closure to allow a pointer-driven option click to commit. */
43
52
  function closeSoon() {
44
- window.setTimeout(() => {
53
+ clearTimeout(closeTimer);
54
+ closeTimer = window.setTimeout(() => {
45
55
  isOpen.value = false;
46
56
  }, closeDelay);
47
57
  }
48
58
 
59
+ /** Cancel an outstanding blur when focus immediately returns to the input. */
60
+ function cancelClose() {
61
+ clearTimeout(closeTimer);
62
+ }
63
+
49
64
  return {
50
65
  query,
51
66
  isOpen,
@@ -55,5 +70,6 @@ export function useOptionMenu(items, options = {}) {
55
70
  move,
56
71
  reset,
57
72
  closeSoon,
73
+ cancelClose,
58
74
  };
59
75
  }
@@ -0,0 +1,42 @@
1
+ import { nextTick, watch } from 'vue';
2
+ import { booleanProp } from './options.js';
3
+
4
+ /**
5
+ * Share incremental-list requests without owning the application's cursor or fetch.
6
+ * One automatic request is allowed per query/result length; the explicit button
7
+ * can retry a failed page. Checking after render also fills short scroll viewports.
8
+ *
9
+ * @param {Object} props Reactive loading and hasMore props.
10
+ * @param {Function} emit Component event emitter.
11
+ * @param {Object} state Refs for list, options, query and open state.
12
+ * @returns {{ onScroll: Function, requestMore: Function }} Paging event handlers.
13
+ */
14
+ export function useOptionPaging(props, emit, { list, options, query, open }) {
15
+ let requestedKey = null;
16
+
17
+ /** Request the next application-owned page, suppressing duplicate automatic requests. */
18
+ function requestMore(force = false) {
19
+ if (!open.value || booleanProp(props.loading) || !booleanProp(props.hasMore)) return;
20
+ const key = `${query.value}\u0000${options.value.length}`;
21
+ if (!force && requestedKey === key) return;
22
+ requestedKey = key;
23
+ emit('load-more', { query: query.value });
24
+ }
25
+
26
+ /** Ask for another page only while the open list is near its scroll boundary. */
27
+ function checkBoundary() {
28
+ const element = list.value;
29
+ if (!element || element.clientHeight <= 0) return;
30
+ if (element.scrollHeight - element.scrollTop - element.clientHeight <= 64) requestMore();
31
+ }
32
+
33
+ /** Forward scroll details for custom previews, then check the shared paging threshold. */
34
+ function onScroll(event) {
35
+ emit('list-scroll', event);
36
+ checkBoundary();
37
+ }
38
+
39
+ watch(query, () => { requestedKey = null; });
40
+ watch([open, () => props.loading, () => props.hasMore, () => options.value.length], () => nextTick(checkBoundary));
41
+ return { onScroll, requestMore };
42
+ }
@@ -0,0 +1,25 @@
1
+ import { computed, shallowRef, watch } from 'vue';
2
+ import { normalizeOption } from './options.js';
3
+
4
+ /**
5
+ * Retain only selected records when a remote query replaces the current page.
6
+ * Applications can supply selectedOptions for initial values outside loaded pages;
7
+ * this does not insert, reorder or seek options in the application's result set.
8
+ *
9
+ * @param {import('vue').ComputedRef<Array>} options Current normalized page records.
10
+ * @param {import('vue').ComputedRef<Array>} values Committed selected values.
11
+ * @param {Function} supplied Getter for optional externally resolved selected records.
12
+ * @returns {import('vue').ComputedRef<Array>} Resolved selections in model order.
13
+ */
14
+ export function useSelectedOptions(options, values, supplied = () => []) {
15
+ const cache = shallowRef(new Map());
16
+ watch([options, values, supplied], () => {
17
+ const records = new Map(cache.value);
18
+ for (const row of [...supplied(), ...options.value]) {
19
+ const option = normalizeOption(row, { allowEmptyValue: true });
20
+ if (option) records.set(String(option.value), option);
21
+ }
22
+ cache.value = new Map(values.value.map((value) => [String(value), records.get(String(value))]));
23
+ }, { immediate: true, flush: 'sync' });
24
+ return computed(() => values.value.map((value) => cache.value.get(String(value)) || { value, label: String(value ?? '') }));
25
+ }
@@ -1,8 +1,10 @@
1
1
  <script setup>
2
- import { computed, onMounted, ref, shallowRef, useId, watch } from 'vue';
2
+ import { computed, nextTick, onMounted, ref, shallowRef, useId, watch } from 'vue';
3
3
  import { resolveFloatingTeleportTarget } from '../../lib/vue/floatingTeleportTarget.js';
4
4
  import DomFieldLoadingSpinner from '../_shared/DomFieldLoadingSpinner.vue';
5
- import { booleanProp, normalizeOption } from '../_shared/options.js';
5
+ import { booleanProp, normalizeOption, searchableText } from '../_shared/options.js';
6
+ import DomOptionStatus from '../_shared/DomOptionStatus.vue';
7
+ import { useOptionPaging } from '../_shared/useOptionPaging.js';
6
8
  import DomField from '../field/DomField.vue';
7
9
  import { fieldProps } from '../field/fieldProps.js';
8
10
  import { useField } from '../field/useField.js';
@@ -15,12 +17,15 @@ defineOptions({
15
17
  tag: '<DomAutocomplete>',
16
18
  description: 'Free-text autocomplete with optional suggestions, floating positioning, keyboard navigation, and query events for async lookups.',
17
19
  slots: [
18
- { name: 'item', payload: '{ item, index }', description: 'Replaces the rendering of each suggestion.' },
20
+ { name: 'item', payload: '{ item, index, selected, query }', description: 'Replaces each suggestion body without replacing the selection checkmark.' },
19
21
  ],
20
22
  events: [
21
23
  { name: 'query', payload: '(query: string)', description: 'Fired as the user types. Use it to fetch or replace suggestions.' },
22
24
  { name: 'select', payload: '({ item, value, label, text, query })', description: 'Fired when a suggestion is chosen. item is the original option object.' },
23
25
  { name: 'commit', payload: '({ value, label, custom })', description: 'Fired when the current text is committed with Enter.' },
26
+ { name: 'load-more', payload: '{ query }', description: 'Request the next page when hasMore is true.' },
27
+ { name: 'open', description: 'Fired when suggestions open.' },
28
+ { name: 'close', description: 'Fired when suggestions close.' },
24
29
  ],
25
30
  },
26
31
  });
@@ -68,11 +73,16 @@ const props = defineProps({
68
73
  default: false,
69
74
  _edit: { group: 'Control props', description: 'Show an inline spinner while async suggestions are being fetched.' },
70
75
  },
76
+ filterOptions: { type: [Boolean, String], default: true },
77
+ hasMore: { type: [Boolean, String], default: false },
71
78
  });
72
- const emit = defineEmits(['update:modelValue', 'query', 'select', 'commit', 'focus', 'blur']);
79
+ const emit = defineEmits(['update:modelValue', 'query', 'select', 'commit', 'load-more', 'list-scroll', 'open', 'close', 'focus', 'blur']);
73
80
 
74
81
  const root = ref(null);
75
82
  const inputEl = ref(null);
83
+ const list = ref(null);
84
+ const query = ref('');
85
+ const isOpen = ref(false);
76
86
  const isMounted = ref(false);
77
87
  const teleportTarget = shallowRef(null);
78
88
  const currentText = ref('');
@@ -80,6 +90,10 @@ const field = useField(props, emit, { idPrefix: 'dom-autocomplete' });
80
90
  const isLoading = computed(() => booleanProp(props.loading, false));
81
91
 
82
92
  const items = computed(() => props.options.map((option) => normalizeOption(option)).filter(Boolean));
93
+ const hasMoreOptions = computed(() => booleanProp(props.hasMore));
94
+ const filterLocally = computed(() => booleanProp(props.filterOptions, true));
95
+ const matchingOptions = computed(() => items.value.filter((option) => !filterLocally.value || searchableText(option, ['label', 'value', 'description', 'group', 'email', 'role']).includes(query.value.trim().toLowerCase())));
96
+ const paging = useOptionPaging(props, emit, { list, options: items, query, open: isOpen });
83
97
 
84
98
  /**
85
99
  * Synchronize the free-text input from the Vue model value.
@@ -94,13 +108,17 @@ function syncInputFromModel() {
94
108
  onMounted(async () => {
95
109
  teleportTarget.value = resolveFloatingTeleportTarget(root.value);
96
110
  isMounted.value = true;
111
+ await nextTick();
97
112
  await import('../../lib/headless/autocomplete.js');
98
113
  syncInputFromModel();
99
114
  root.value?.addEventListener('dom:input', (event) => {
100
115
  currentText.value = event.detail.value;
101
116
  field.onInput(event.detail.value);
102
117
  });
103
- root.value?.addEventListener('dom:query', (event) => emit('query', event.detail.query));
118
+ root.value?.addEventListener('dom:query', (event) => { query.value = event.detail.query; emit('query', event.detail.query); });
119
+ root.value?.addEventListener('dom:open', () => { isOpen.value = true; emit('open'); });
120
+ root.value?.addEventListener('dom:close', () => { isOpen.value = false; emit('close'); });
121
+ root.value?.addEventListener('dom:load-more', () => paging.requestMore());
104
122
  root.value?.addEventListener('dom:select', (event) => {
105
123
  const index = Number(event.detail.option?.dataset.index);
106
124
  const item = Number.isFinite(index) ? items.value[index] : null;
@@ -131,6 +149,8 @@ watch(field.value, syncInputFromModel);
131
149
  :value="field.value.value ?? null"
132
150
  :placement="placement"
133
151
  :floating-mode="floatingMode"
152
+ :filter-options="String(filterLocally)"
153
+ :has-more="hasMoreOptions ? '' : null"
134
154
  class="relative block"
135
155
  >
136
156
  <input
@@ -152,7 +172,7 @@ watch(field.value, syncInputFromModel);
152
172
  type="button"
153
173
  class="absolute right-1 top-1 inline-flex size-8 items-center justify-center rounded-md text-muted-fg transition hover:text-canvas-fg focus:outline-none focus-visible:ring-2 focus-visible:ring-ring/60 disabled:cursor-not-allowed disabled:opacity-50"
154
174
  aria-label="Show suggestions"
155
- :disabled="props.disabled || props.readOnly || undefined"
175
+ :disabled="field.disabled.value || field.readOnly.value || undefined"
156
176
  >
157
177
  <svg viewBox="0 0 20 20" class="size-4" fill="none" aria-hidden="true">
158
178
  <path d="M6 8l4 4 4-4" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" />
@@ -161,6 +181,10 @@ watch(field.value, syncInputFromModel);
161
181
  <Teleport :to="teleportTarget || 'body'" :disabled="!isMounted || !teleportTarget">
162
182
  <ul
163
183
  :id="listId"
184
+ ref="list"
185
+ hidden
186
+ :aria-busy="isLoading"
187
+ @scroll="paging.onScroll"
164
188
  class="dom-floating-transition skin-popover z-50 max-h-[min(15rem,var(--dom-floating-available-height))] overflow-auto rounded-2xl p-1"
165
189
  >
166
190
  <li
@@ -169,10 +193,14 @@ watch(field.value, syncInputFromModel);
169
193
  :data-value="option.value"
170
194
  :data-label="option.label"
171
195
  :data-index="index"
172
- class="cursor-pointer rounded-xl px-3 py-2 text-sm text-canvas-fg transition hover:bg-accent hover:text-accent-fg data-[active]:bg-accent data-[active]:text-accent-fg aria-selected:bg-accent aria-selected:text-accent-fg"
196
+ :data-search="searchableText(option, ['label', 'value', 'description', 'group', 'email', 'role'])"
197
+ :aria-disabled="option.disabled || undefined"
198
+ class="flex cursor-pointer items-center gap-3 rounded-xl px-3 py-2 text-sm text-canvas-fg transition hover:bg-accent hover:text-accent-fg data-[active]:bg-accent data-[active]:text-accent-fg aria-selected:bg-accent aria-selected:text-accent-fg aria-disabled:cursor-not-allowed aria-disabled:opacity-50"
173
199
  >
174
- <slot name="item" :item="option" :index="index">{{ option.label }}</slot>
200
+ <span class="min-w-0 flex-1"><slot name="item" :item="option" :index="index" :selected="option.label === field.value.value" :query="query">{{ option.label }}</slot></span>
201
+ <svg v-if="option.label === field.value.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>
175
202
  </li>
203
+ <DomOptionStatus as="li" :loading="isLoading" :has-more="hasMoreOptions" :empty="!matchingOptions.length" @load-more="paging.requestMore(true)" />
176
204
  </ul>
177
205
  </Teleport>
178
206
  </dom-autocomplete>