@getdom/studio 0.2.2 → 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 +52 -0
- package/package.json +1 -1
- package/src/pages/_layout/componentCatalog.generated.js +114 -12
- 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 +133 -3
- package/src/pages/lib/vue/index.js +1 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,57 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.2.3 - 2026-09-27
|
|
4
|
+
|
|
5
|
+
Searchable Google Fonts previews, shared paginated option lists, and more reliable
|
|
6
|
+
selection controls.
|
|
7
|
+
|
|
8
|
+
### Added
|
|
9
|
+
|
|
10
|
+
- `DomFontPicker` with searchable family previews, incremental browsing and a
|
|
11
|
+
lightweight snapshot of 1,946 Google Fonts families. Font files are not bundled;
|
|
12
|
+
the mounted picker requests only selected and visible preview families, with
|
|
13
|
+
text subsetting and component-owned stylesheet cleanup.
|
|
14
|
+
- Multiple selection in `DomSelect`, with an array model, checked options and a
|
|
15
|
+
clear-all action. Selected labels are retained when searches replace remote pages.
|
|
16
|
+
- Shared incremental loading for Select, Combobox, Autocomplete and Tag combobox,
|
|
17
|
+
using `hasMore` and `load-more` for scroll, keyboard and explicit-button paging.
|
|
18
|
+
Documentation includes real HTTP examples with retry and stale-request handling.
|
|
19
|
+
- Query and selected-state slot data for custom option rendering, plus a matched-text
|
|
20
|
+
highlighting example that keeps HTML-like labels as plain text.
|
|
21
|
+
|
|
22
|
+
### Improved
|
|
23
|
+
|
|
24
|
+
- Opening a picker shows the loaded choices without treating its committed value
|
|
25
|
+
as a permanent search filter. Selected checkmarks remain distinct from keyboard
|
|
26
|
+
navigation, and loaded single selections open in context.
|
|
27
|
+
- Select and Tag combobox documentation explains compact multi-selection versus
|
|
28
|
+
removable tokens, optional custom entries and paste support, alongside their
|
|
29
|
+
shared option, field-state and paging foundations.
|
|
30
|
+
- Public TypeScript declarations cover the font picker, multiple selection,
|
|
31
|
+
retained selected records and paginated option lists.
|
|
32
|
+
|
|
33
|
+
### Fixed
|
|
34
|
+
|
|
35
|
+
- Dropdowns reopen reliably while an earlier close animation finishes, and late
|
|
36
|
+
option updates no longer reopen a dismissed combobox or autocomplete.
|
|
37
|
+
- Selected options remain visible and checked in combobox and tag suggestions.
|
|
38
|
+
Disabled and read-only controls do not commit selections through their action buttons.
|
|
39
|
+
- Select keeps its arrow at the right edge and centers the clear button to its
|
|
40
|
+
left. Long selections truncate within narrow containers, and clearing returns
|
|
41
|
+
keyboard focus to the trigger.
|
|
42
|
+
|
|
43
|
+
### Upgrade notes
|
|
44
|
+
|
|
45
|
+
- Single-select models remain scalar values. Use an array model when enabling
|
|
46
|
+
`multiple`; Tag combobox continues to use an array of selected values.
|
|
47
|
+
- For server-filtered lists, set `filterOptions` to false and handle `query` and
|
|
48
|
+
`load-more` in the application. Supply `selectedOptions` for initial selected
|
|
49
|
+
records outside the loaded page. Seeking directly to a later remote page remains
|
|
50
|
+
application-owned; controls do not fetch or paginate an endpoint themselves.
|
|
51
|
+
- Google Fonts previews need no API key. Applications own loading the selected
|
|
52
|
+
font for content outside the picker. A live Developer API catalogue should be
|
|
53
|
+
fetched and cached server-side or at build time, with its key kept out of client code.
|
|
54
|
+
|
|
3
55
|
## 0.2.2 - 2026-09-26
|
|
4
56
|
|
|
5
57
|
More flexible calendar highlights, refined agent chat, and updated website
|
package/package.json
CHANGED
|
@@ -3090,8 +3090,8 @@ export const componentCatalog = [
|
|
|
3090
3090
|
"slots": [
|
|
3091
3091
|
{
|
|
3092
3092
|
"name": "item",
|
|
3093
|
-
"payload": "{ item, index }",
|
|
3094
|
-
"description": "Replaces
|
|
3093
|
+
"payload": "{ item, index, selected, query }",
|
|
3094
|
+
"description": "Replaces each suggestion body without replacing the selection checkmark."
|
|
3095
3095
|
}
|
|
3096
3096
|
],
|
|
3097
3097
|
"events": [
|
|
@@ -3109,6 +3109,19 @@ export const componentCatalog = [
|
|
|
3109
3109
|
"name": "commit",
|
|
3110
3110
|
"payload": "({ value, label, custom })",
|
|
3111
3111
|
"description": "Fired when the current text is committed with Enter."
|
|
3112
|
+
},
|
|
3113
|
+
{
|
|
3114
|
+
"name": "load-more",
|
|
3115
|
+
"payload": "{ query }",
|
|
3116
|
+
"description": "Request the next page when hasMore is true."
|
|
3117
|
+
},
|
|
3118
|
+
{
|
|
3119
|
+
"name": "open",
|
|
3120
|
+
"description": "Fired when suggestions open."
|
|
3121
|
+
},
|
|
3122
|
+
{
|
|
3123
|
+
"name": "close",
|
|
3124
|
+
"description": "Fired when suggestions close."
|
|
3112
3125
|
}
|
|
3113
3126
|
]
|
|
3114
3127
|
},
|
|
@@ -3308,8 +3321,8 @@ export const componentCatalog = [
|
|
|
3308
3321
|
"slots": [
|
|
3309
3322
|
{
|
|
3310
3323
|
"name": "item",
|
|
3311
|
-
"payload": "{ item, index }",
|
|
3312
|
-
"description": "Replaces
|
|
3324
|
+
"payload": "{ item, index, selected, query }",
|
|
3325
|
+
"description": "Replaces each option body. The control keeps the selected checkmark."
|
|
3313
3326
|
}
|
|
3314
3327
|
],
|
|
3315
3328
|
"events": [
|
|
@@ -3322,6 +3335,24 @@ export const componentCatalog = [
|
|
|
3322
3335
|
"name": "select",
|
|
3323
3336
|
"payload": "({ item, value, label })",
|
|
3324
3337
|
"description": "Fired when an option is selected. item is the original option object."
|
|
3338
|
+
},
|
|
3339
|
+
{
|
|
3340
|
+
"name": "list-scroll",
|
|
3341
|
+
"payload": "(event: Event)",
|
|
3342
|
+
"description": "Fired when the floating option list scrolls."
|
|
3343
|
+
},
|
|
3344
|
+
{
|
|
3345
|
+
"name": "load-more",
|
|
3346
|
+
"payload": "{ query }",
|
|
3347
|
+
"description": "Request the next page when hasMore is true. The parent owns fetching and appending options."
|
|
3348
|
+
},
|
|
3349
|
+
{
|
|
3350
|
+
"name": "open",
|
|
3351
|
+
"description": "Fired when the options open with an empty transient query."
|
|
3352
|
+
},
|
|
3353
|
+
{
|
|
3354
|
+
"name": "close",
|
|
3355
|
+
"description": "Fired when the options close."
|
|
3325
3356
|
}
|
|
3326
3357
|
]
|
|
3327
3358
|
},
|
|
@@ -3637,6 +3668,49 @@ export const componentCatalog = [
|
|
|
3637
3668
|
},
|
|
3638
3669
|
"studio": {}
|
|
3639
3670
|
},
|
|
3671
|
+
{
|
|
3672
|
+
"id": "forms/font-picker",
|
|
3673
|
+
"path": "../forms/font-picker/DomFontPicker.vue",
|
|
3674
|
+
"section": "forms",
|
|
3675
|
+
"slug": "font-picker",
|
|
3676
|
+
"exportName": "DomFontPicker",
|
|
3677
|
+
"route": "/forms/font-picker",
|
|
3678
|
+
"doc": {
|
|
3679
|
+
"name": "Font picker",
|
|
3680
|
+
"tag": "<DomFontPicker>",
|
|
3681
|
+
"description": "Searchable Google Fonts picker that previews only visible families and keeps font network loading inside the mounted control.",
|
|
3682
|
+
"icon": "M5 5h14M12 5v14M8 19h8",
|
|
3683
|
+
"playground": {
|
|
3684
|
+
"initial": {
|
|
3685
|
+
"label": "Font family",
|
|
3686
|
+
"modelValue": "Lato",
|
|
3687
|
+
"placeholder": "Search Google Fonts",
|
|
3688
|
+
"category": "",
|
|
3689
|
+
"pageSize": 24,
|
|
3690
|
+
"previewText": "",
|
|
3691
|
+
"loadFonts": true
|
|
3692
|
+
}
|
|
3693
|
+
},
|
|
3694
|
+
"events": [
|
|
3695
|
+
{
|
|
3696
|
+
"name": "update:modelValue",
|
|
3697
|
+
"payload": "string",
|
|
3698
|
+
"description": "Fired with the selected font value."
|
|
3699
|
+
},
|
|
3700
|
+
{
|
|
3701
|
+
"name": "select",
|
|
3702
|
+
"payload": "{ item, value, label }",
|
|
3703
|
+
"description": "Fired when a font family is selected."
|
|
3704
|
+
},
|
|
3705
|
+
{
|
|
3706
|
+
"name": "query",
|
|
3707
|
+
"payload": "string",
|
|
3708
|
+
"description": "Fired as the user searches the catalog."
|
|
3709
|
+
}
|
|
3710
|
+
]
|
|
3711
|
+
},
|
|
3712
|
+
"studio": {}
|
|
3713
|
+
},
|
|
3640
3714
|
{
|
|
3641
3715
|
"id": "forms/form",
|
|
3642
3716
|
"path": "../forms/form/DomForm.vue",
|
|
@@ -4053,13 +4127,13 @@ export const componentCatalog = [
|
|
|
4053
4127
|
"slots": [
|
|
4054
4128
|
{
|
|
4055
4129
|
"name": "value",
|
|
4056
|
-
"payload": "{ option, value, label, placeholder }",
|
|
4057
|
-
"description": "Custom selected-value markup inside the trigger."
|
|
4130
|
+
"payload": "{ option, options, value, label, placeholder }",
|
|
4131
|
+
"description": "Custom selected-value markup inside the trigger. options contains all selected records in multiple mode."
|
|
4058
4132
|
},
|
|
4059
4133
|
{
|
|
4060
4134
|
"name": "option",
|
|
4061
|
-
"payload": "{ option, index, selected, optionId }",
|
|
4062
|
-
"description": "Custom option markup
|
|
4135
|
+
"payload": "{ option, index, selected, query, optionId }",
|
|
4136
|
+
"description": "Custom option markup with the transient query for safe text highlighting. optionId identifies the focusable row."
|
|
4063
4137
|
},
|
|
4064
4138
|
{
|
|
4065
4139
|
"name": "group",
|
|
@@ -4070,13 +4144,36 @@ export const componentCatalog = [
|
|
|
4070
4144
|
"events": [
|
|
4071
4145
|
{
|
|
4072
4146
|
"name": "update:modelValue",
|
|
4073
|
-
"payload": "
|
|
4074
|
-
"description": "Emitted
|
|
4147
|
+
"payload": "string | number | Array<string | number>",
|
|
4148
|
+
"description": "Emitted with one committed value or an array in multiple mode."
|
|
4075
4149
|
},
|
|
4076
4150
|
{
|
|
4077
4151
|
"name": "select",
|
|
4078
4152
|
"payload": "({ option, value, label })",
|
|
4079
4153
|
"description": "Emitted with the selected option record."
|
|
4154
|
+
},
|
|
4155
|
+
{
|
|
4156
|
+
"name": "query",
|
|
4157
|
+
"payload": "string",
|
|
4158
|
+
"description": "Emitted on open with an empty query and as the user types in the separate search input."
|
|
4159
|
+
},
|
|
4160
|
+
{
|
|
4161
|
+
"name": "load-more",
|
|
4162
|
+
"payload": "{ query }",
|
|
4163
|
+
"description": "Request another page when hasMore is true. The application owns cursors, requests, errors and appending options."
|
|
4164
|
+
},
|
|
4165
|
+
{
|
|
4166
|
+
"name": "list-scroll",
|
|
4167
|
+
"payload": "Event",
|
|
4168
|
+
"description": "Scroll event for optional visibility-based previews."
|
|
4169
|
+
},
|
|
4170
|
+
{
|
|
4171
|
+
"name": "open",
|
|
4172
|
+
"description": "Emitted when the panel opens."
|
|
4173
|
+
},
|
|
4174
|
+
{
|
|
4175
|
+
"name": "close",
|
|
4176
|
+
"description": "Emitted when the panel closes."
|
|
4080
4177
|
}
|
|
4081
4178
|
],
|
|
4082
4179
|
"keyboard": [
|
|
@@ -4129,8 +4226,8 @@ export const componentCatalog = [
|
|
|
4129
4226
|
"slots": [
|
|
4130
4227
|
{
|
|
4131
4228
|
"name": "item",
|
|
4132
|
-
"payload": "{ item, index, active, custom }",
|
|
4133
|
-
"description": "Replaces each suggestion row."
|
|
4229
|
+
"payload": "{ item, index, active, selected, query, custom }",
|
|
4230
|
+
"description": "Replaces each suggestion row while retaining the selected checkmark."
|
|
4134
4231
|
},
|
|
4135
4232
|
{
|
|
4136
4233
|
"name": "tag",
|
|
@@ -4163,6 +4260,11 @@ export const componentCatalog = [
|
|
|
4163
4260
|
"name": "change",
|
|
4164
4261
|
"payload": "Array",
|
|
4165
4262
|
"description": "Fired after the selected values array changes."
|
|
4263
|
+
},
|
|
4264
|
+
{
|
|
4265
|
+
"name": "load-more",
|
|
4266
|
+
"payload": "{ query }",
|
|
4267
|
+
"description": "Request the next server page when hasMore is true."
|
|
4166
4268
|
}
|
|
4167
4269
|
]
|
|
4168
4270
|
},
|
|
@@ -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>
|