@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.
- package/CHANGELOG.md +93 -0
- package/LICENSE.md +23 -9
- package/package.json +1 -1
- package/src/pages/_layout/componentCatalog.generated.js +115 -13
- package/src/pages/components/command-palette/DomCommandPalette.vue +6 -1
- package/src/pages/data/agent-chat/DomAgentChatShell.vue +52 -53
- package/src/pages/data/agent-chat/DomAgentConversation.vue +163 -154
- package/src/pages/data/agent-chat/DomAgentConversationList.vue +53 -57
- package/src/pages/data/month-calendar/DomMonthCalendar.vue +8 -9
- 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 +136 -6
- package/src/pages/lib/vue/index.js +1 -0
- package/src/tailwind.css +5 -1
- 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
|
|
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
|
|
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
|
|
152
|
+
* Returns the agent name used for a conversation avatar.
|
|
152
153
|
*
|
|
153
154
|
* @param {Record<string, unknown>} conversation Conversation summary.
|
|
154
|
-
* @returns {string}
|
|
155
|
+
* @returns {string} Agent name.
|
|
155
156
|
*/
|
|
156
|
-
function
|
|
157
|
-
|
|
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
|
|
179
|
+
* Resolves the status dot class for a conversation.
|
|
186
180
|
*
|
|
187
181
|
* @param {Record<string, unknown>} conversation Conversation summary.
|
|
188
|
-
* @returns {
|
|
182
|
+
* @returns {string} Token-based dot classes.
|
|
189
183
|
*/
|
|
190
|
-
function
|
|
184
|
+
function statusDotClass(conversation) {
|
|
191
185
|
return {
|
|
192
|
-
streaming: '
|
|
193
|
-
waiting: 'warning',
|
|
194
|
-
error: '
|
|
195
|
-
complete: 'success',
|
|
196
|
-
}[conversation.status] || '
|
|
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
|
|
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="
|
|
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="
|
|
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
|
|
267
|
-
<div v-if="loading" class="space-y-
|
|
268
|
-
<div v-for="index in 5" :key="index" class="
|
|
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
|
-
<
|
|
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-
|
|
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
|
|
289
|
-
:class="isActiveConversation(conversation)
|
|
290
|
-
|
|
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-
|
|
295
|
-
<
|
|
296
|
-
|
|
297
|
-
|
|
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-
|
|
300
|
-
<p class="truncate text-sm font-
|
|
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
|
-
<
|
|
304
|
-
|
|
305
|
-
|
|
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
|
|
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-
|
|
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'
|
|
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
|
-
|
|
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
|
|
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="
|
|
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
|
-
|
|
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>
|