sprintify-ui 0.0.97 → 0.0.100
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/dist/sprintify-ui.es.js +8010 -7924
- package/dist/types/src/components/BaseAutocomplete.vue.d.ts +31 -9
- package/dist/types/src/components/BaseAutocompleteDropdown.vue.d.ts +88 -0
- package/dist/types/src/components/BaseAutocompleteFetch.vue.d.ts +19 -4
- package/dist/types/src/components/BaseBelongsTo.vue.d.ts +17 -2
- package/dist/types/src/components/BaseHasMany.vue.d.ts +17 -0
- package/dist/types/src/components/BaseLoadingCover.vue.d.ts +1 -1
- package/dist/types/src/components/BaseSwitch.vue.d.ts +1 -1
- package/dist/types/src/components/BaseTagAutocomplete.vue.d.ts +66 -6
- package/dist/types/src/components/BaseTagAutocompleteFetch.vue.d.ts +19 -2
- package/dist/types/src/composables/clickOutside.d.ts +1 -1
- package/dist/types/src/index.d.ts +2 -0
- package/package.json +10 -10
- package/src/components/BaseAutocomplete.vue +53 -183
- package/src/components/BaseAutocompleteDropdown.vue +349 -0
- package/src/components/BaseAutocompleteFetch.vue +8 -3
- package/src/components/BaseDropdown.vue +2 -2
- package/src/components/BaseModalSide.stories.js +1 -3
- package/src/components/BaseModalSide.vue +5 -5
- package/src/components/BaseTagAutocomplete.stories.js +46 -1
- package/src/components/BaseTagAutocomplete.vue +184 -253
- package/src/components/BaseTagAutocompleteFetch.stories.js +4 -4
- package/src/components/BaseTagAutocompleteFetch.vue +10 -5
- package/src/composables/clickOutside.ts +4 -2
- package/src/index.ts +3 -1
|
@@ -61,92 +61,34 @@
|
|
|
61
61
|
<div class="relative">
|
|
62
62
|
<div
|
|
63
63
|
v-show="opened || dropdownShow == 'always'"
|
|
64
|
-
class="
|
|
64
|
+
class="w-full overflow-hidden"
|
|
65
65
|
:class="[
|
|
66
66
|
inline
|
|
67
|
-
? 'relative'
|
|
68
|
-
: 'absolute top-1 z-menu rounded border border-slate-300 bg-white shadow-md',
|
|
67
|
+
? 'relative mt-1'
|
|
68
|
+
: 'absolute top-1 z-menu min-h-[110px] w-full overflow-hidden rounded border border-slate-300 bg-white shadow-md',
|
|
69
69
|
]"
|
|
70
70
|
>
|
|
71
|
-
<
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
71
|
+
<BaseAutocompleteDropdown
|
|
72
|
+
:selected="normalizedModelValue"
|
|
73
|
+
:options="filteredNormalizedOptions"
|
|
74
|
+
:size="size"
|
|
75
|
+
:loading="loading"
|
|
76
|
+
:loading-bottom="loadingBottom"
|
|
77
|
+
:dropdown-class="inline ? '' : 'p-1'"
|
|
78
|
+
:keywords="keywords"
|
|
79
|
+
@select="onSelect"
|
|
80
|
+
@scroll-bottom="emit('scrollBottom')"
|
|
75
81
|
>
|
|
76
|
-
<
|
|
77
|
-
<
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
v-for="(option, index) in filteredNormalizedOptions"
|
|
87
|
-
:key="option.value"
|
|
88
|
-
class="block"
|
|
89
|
-
>
|
|
90
|
-
<button
|
|
91
|
-
class="block w-full cursor-pointer appearance-none border-none text-left focus:outline-none"
|
|
92
|
-
type="button"
|
|
93
|
-
tabindex="-1"
|
|
94
|
-
@click="onSelect(option)"
|
|
95
|
-
@mouseenter="selectionIndex = index"
|
|
96
|
-
>
|
|
97
|
-
<slot
|
|
98
|
-
name="option"
|
|
99
|
-
:option="option.option"
|
|
100
|
-
:selected="isSelected(option)"
|
|
101
|
-
:active="optionActive && optionActive.value == option.value"
|
|
102
|
-
>
|
|
103
|
-
<div
|
|
104
|
-
class="flex items-center rounded px-2 py-1 text-sm"
|
|
105
|
-
:class="[optionClass(option), optionSizeClass]"
|
|
106
|
-
>
|
|
107
|
-
<div class="grow">
|
|
108
|
-
{{ option.label }}
|
|
109
|
-
</div>
|
|
110
|
-
<div class="shrink-0">
|
|
111
|
-
<BaseIcon
|
|
112
|
-
v-if="isSelected(option)"
|
|
113
|
-
icon="heroicons:check-20-solid"
|
|
114
|
-
:class="iconClass"
|
|
115
|
-
></BaseIcon>
|
|
116
|
-
</div>
|
|
117
|
-
</div>
|
|
118
|
-
</slot>
|
|
119
|
-
</button>
|
|
120
|
-
</li>
|
|
121
|
-
</ul>
|
|
122
|
-
</div>
|
|
123
|
-
|
|
124
|
-
<div>
|
|
125
|
-
<div v-if="$slots.footer" class="bg-white">
|
|
126
|
-
<slot
|
|
127
|
-
:options="filteredNormalizedOptions"
|
|
128
|
-
:keywords="keywords"
|
|
129
|
-
name="footer"
|
|
130
|
-
/>
|
|
131
|
-
</div>
|
|
132
|
-
</div>
|
|
133
|
-
|
|
134
|
-
<div
|
|
135
|
-
v-if="loading"
|
|
136
|
-
class="absolute inset-0 h-full w-full space-y-1 bg-white p-2"
|
|
137
|
-
>
|
|
138
|
-
<div class="space-y-1">
|
|
139
|
-
<BaseSkeleton class="h-7 w-full" delay="0ms"></BaseSkeleton>
|
|
140
|
-
<BaseSkeleton
|
|
141
|
-
class="h-7 w-full opacity-70"
|
|
142
|
-
delay="50ms"
|
|
143
|
-
></BaseSkeleton>
|
|
144
|
-
<BaseSkeleton
|
|
145
|
-
class="h-7 w-full opacity-30"
|
|
146
|
-
delay="100ms"
|
|
147
|
-
></BaseSkeleton>
|
|
148
|
-
</div>
|
|
149
|
-
</div>
|
|
82
|
+
<template #empty="emptyProps">
|
|
83
|
+
<slot name="empty" v-bind="{ ...emptyProps, ...slotProps }" />
|
|
84
|
+
</template>
|
|
85
|
+
<template #option="optionProps">
|
|
86
|
+
<slot name="option" v-bind="{ ...optionProps, ...slotProps }" />
|
|
87
|
+
</template>
|
|
88
|
+
<template #footer="footerProps">
|
|
89
|
+
<slot name="footer" v-bind="{ ...footerProps, ...slotProps }" />
|
|
90
|
+
</template>
|
|
91
|
+
</BaseAutocompleteDropdown>
|
|
150
92
|
</div>
|
|
151
93
|
</div>
|
|
152
94
|
</div>
|
|
@@ -156,12 +98,11 @@
|
|
|
156
98
|
import { get } from 'lodash';
|
|
157
99
|
import { PropType, Ref, ComputedRef } from 'vue';
|
|
158
100
|
import { NormalizedOption, Option } from '@/types';
|
|
159
|
-
import { useInfiniteScroll } from '@vueuse/core';
|
|
160
|
-
import BaseSkeleton from '@/components/BaseSkeleton.vue';
|
|
161
101
|
import { useHasOptions } from '@/composables/hasOptions';
|
|
162
102
|
import { useField } from '@/composables/field';
|
|
163
103
|
import { BaseIcon } from './index';
|
|
164
104
|
import { useClickOutside } from '@/composables/clickOutside';
|
|
105
|
+
import BaseAutocompleteDropdown from './BaseAutocompleteDropdown.vue';
|
|
165
106
|
|
|
166
107
|
const props = defineProps({
|
|
167
108
|
modelValue: {
|
|
@@ -192,6 +133,10 @@ const props = defineProps({
|
|
|
192
133
|
default: false,
|
|
193
134
|
type: Boolean,
|
|
194
135
|
},
|
|
136
|
+
loadingBottom: {
|
|
137
|
+
default: false,
|
|
138
|
+
type: Boolean,
|
|
139
|
+
},
|
|
195
140
|
required: {
|
|
196
141
|
default: false,
|
|
197
142
|
type: Boolean,
|
|
@@ -233,12 +178,10 @@ const props = defineProps({
|
|
|
233
178
|
const emit = defineEmits([
|
|
234
179
|
'update:modelValue',
|
|
235
180
|
'typing',
|
|
236
|
-
'blur',
|
|
237
|
-
'focus',
|
|
238
|
-
'scrollBottom',
|
|
239
181
|
'clear',
|
|
240
182
|
'open',
|
|
241
183
|
'close',
|
|
184
|
+
'scrollBottom',
|
|
242
185
|
]);
|
|
243
186
|
|
|
244
187
|
const { hasErrorInternal, emitUpdate } = useField({
|
|
@@ -248,15 +191,6 @@ const { hasErrorInternal, emitUpdate } = useField({
|
|
|
248
191
|
emit: emit,
|
|
249
192
|
});
|
|
250
193
|
|
|
251
|
-
let timerId = 0;
|
|
252
|
-
const keywords = ref('');
|
|
253
|
-
const selectionIndex = ref(0);
|
|
254
|
-
const autocomplete = ref(null) as Ref<HTMLElement | null>;
|
|
255
|
-
const inputElement = ref(null) as Ref<HTMLInputElement | null>;
|
|
256
|
-
const dropdown = ref(null) as Ref<HTMLElement | null>;
|
|
257
|
-
const shouldFilter = ref(false);
|
|
258
|
-
const opened = ref(false);
|
|
259
|
-
|
|
260
194
|
const hasOptions = useHasOptions(
|
|
261
195
|
computed(() => props.modelValue),
|
|
262
196
|
computed(() => props.options),
|
|
@@ -265,7 +199,13 @@ const hasOptions = useHasOptions(
|
|
|
265
199
|
computed(() => false)
|
|
266
200
|
);
|
|
267
201
|
|
|
268
|
-
|
|
202
|
+
let timerId = 0;
|
|
203
|
+
const keywords = ref('');
|
|
204
|
+
const autocomplete = ref(null) as Ref<HTMLElement | null>;
|
|
205
|
+
const inputElement = ref(null) as Ref<HTMLInputElement | null>;
|
|
206
|
+
const shouldFilter = ref(false);
|
|
207
|
+
const opened = ref(false);
|
|
208
|
+
|
|
269
209
|
const normalizedOptions = hasOptions.normalizedOptions;
|
|
270
210
|
const normalizedModelValue =
|
|
271
211
|
hasOptions.normalizedModelValue as ComputedRef<NormalizedOption | null>;
|
|
@@ -285,30 +225,15 @@ const filteredNormalizedOptions = computed((): NormalizedOption[] => {
|
|
|
285
225
|
});
|
|
286
226
|
});
|
|
287
227
|
|
|
288
|
-
|
|
289
|
-
return (
|
|
290
|
-
filteredNormalizedOptions.value[
|
|
291
|
-
Math.min(selectionIndex.value, filteredNormalizedOptions.value.length - 1)
|
|
292
|
-
] ?? null
|
|
293
|
-
);
|
|
294
|
-
});
|
|
295
|
-
|
|
296
|
-
// Update the keywords + selectionIndex when the model value changes
|
|
228
|
+
// Update the keywords when the model value changes
|
|
297
229
|
watch(
|
|
298
230
|
() => normalizedModelValue.value,
|
|
299
231
|
() => {
|
|
300
232
|
if (normalizedModelValue.value) {
|
|
301
|
-
const index = filteredNormalizedOptions.value.findIndex(
|
|
302
|
-
(option) => option.value === normalizedModelValue.value?.value
|
|
303
|
-
);
|
|
304
|
-
if (index >= 0) {
|
|
305
|
-
selectionIndex.value = index;
|
|
306
|
-
}
|
|
307
233
|
if (props.modelValueShow) {
|
|
308
234
|
setKeywords(normalizedModelValue.value?.label);
|
|
309
235
|
}
|
|
310
236
|
} else {
|
|
311
|
-
selectionIndex.value = 0;
|
|
312
237
|
if (props.modelValueShow) {
|
|
313
238
|
setKeywords('');
|
|
314
239
|
}
|
|
@@ -317,10 +242,8 @@ watch(
|
|
|
317
242
|
{ immediate: true }
|
|
318
243
|
);
|
|
319
244
|
|
|
320
|
-
useClickOutside(autocomplete, (
|
|
321
|
-
|
|
322
|
-
close();
|
|
323
|
-
}
|
|
245
|
+
useClickOutside(autocomplete, () => {
|
|
246
|
+
close();
|
|
324
247
|
});
|
|
325
248
|
|
|
326
249
|
function open() {
|
|
@@ -335,6 +258,7 @@ function open() {
|
|
|
335
258
|
}
|
|
336
259
|
|
|
337
260
|
function close() {
|
|
261
|
+
shouldFilter.value = false;
|
|
338
262
|
opened.value = false;
|
|
339
263
|
blur();
|
|
340
264
|
timerId = setTimeout(() => {
|
|
@@ -349,13 +273,10 @@ function close() {
|
|
|
349
273
|
const onTextInput = (event: Event) => {
|
|
350
274
|
open();
|
|
351
275
|
shouldFilter.value = true;
|
|
352
|
-
selectionIndex.value = 0;
|
|
353
276
|
setKeywords(get(event, 'target.value') + '');
|
|
354
277
|
emit('typing', keywords.value);
|
|
355
|
-
dropdown.value?.scrollTo({
|
|
356
|
-
top: 0,
|
|
357
|
-
});
|
|
358
278
|
|
|
279
|
+
// If keywords is empty, emit null
|
|
359
280
|
if (keywords.value == '') {
|
|
360
281
|
update(null);
|
|
361
282
|
}
|
|
@@ -364,38 +285,15 @@ const onTextInput = (event: Event) => {
|
|
|
364
285
|
const onTextKeydown = (event: KeyboardEvent) => {
|
|
365
286
|
const key = event.key;
|
|
366
287
|
|
|
367
|
-
|
|
368
|
-
return;
|
|
369
|
-
}
|
|
370
|
-
|
|
371
|
-
if (key === 'ArrowDown') {
|
|
372
|
-
event.preventDefault();
|
|
373
|
-
if (selectionIndex.value < filteredNormalizedOptions.value.length - 1) {
|
|
374
|
-
selectionIndex.value++;
|
|
375
|
-
} else {
|
|
376
|
-
selectionIndex.value = 0;
|
|
377
|
-
}
|
|
378
|
-
return;
|
|
379
|
-
}
|
|
288
|
+
// Prevent default behavior for up/down arrows
|
|
380
289
|
|
|
381
290
|
if (key === 'ArrowUp') {
|
|
382
291
|
event.preventDefault();
|
|
383
|
-
if (selectionIndex.value > 0) {
|
|
384
|
-
selectionIndex.value--;
|
|
385
|
-
} else {
|
|
386
|
-
selectionIndex.value = Math.max(
|
|
387
|
-
0,
|
|
388
|
-
filteredNormalizedOptions.value.length - 1
|
|
389
|
-
);
|
|
390
|
-
}
|
|
391
292
|
return;
|
|
392
293
|
}
|
|
393
294
|
|
|
394
|
-
if (key === '
|
|
295
|
+
if (key === 'ArrowDown') {
|
|
395
296
|
event.preventDefault();
|
|
396
|
-
if (optionActive.value) {
|
|
397
|
-
onSelect(optionActive.value);
|
|
398
|
-
}
|
|
399
297
|
return;
|
|
400
298
|
}
|
|
401
299
|
};
|
|
@@ -414,14 +312,10 @@ function onSelect(normalizedModelValue: Option | null | undefined) {
|
|
|
414
312
|
}
|
|
415
313
|
|
|
416
314
|
function update(normalizedSelection: Option | null | undefined) {
|
|
417
|
-
|
|
418
|
-
if (selection) {
|
|
419
|
-
const index = filteredNormalizedOptions.value.findIndex(
|
|
420
|
-
(option) => option.value == selection.value
|
|
421
|
-
);
|
|
422
|
-
selectionIndex.value = index;
|
|
423
|
-
}
|
|
315
|
+
// Re-activate filter
|
|
424
316
|
shouldFilter.value = false;
|
|
317
|
+
// Emit update
|
|
318
|
+
const selection = normalizedSelection ? normalizedSelection.option : null;
|
|
425
319
|
emitUpdate(selection);
|
|
426
320
|
}
|
|
427
321
|
|
|
@@ -437,27 +331,15 @@ function blur() {
|
|
|
437
331
|
inputElement.value?.blur();
|
|
438
332
|
}
|
|
439
333
|
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
return 'bg-slate-200';
|
|
447
|
-
}
|
|
448
|
-
|
|
449
|
-
return 'bg-white';
|
|
334
|
+
const slotProps = {
|
|
335
|
+
focus,
|
|
336
|
+
blur,
|
|
337
|
+
open,
|
|
338
|
+
close,
|
|
339
|
+
keywords: computed(() => keywords.value),
|
|
450
340
|
};
|
|
451
341
|
|
|
452
|
-
|
|
453
|
-
if (props.size == 'xs') {
|
|
454
|
-
return 'text-xs';
|
|
455
|
-
}
|
|
456
|
-
if (props.size == 'sm') {
|
|
457
|
-
return 'text-sm';
|
|
458
|
-
}
|
|
459
|
-
return 'text-sm';
|
|
460
|
-
});
|
|
342
|
+
// Element Classes
|
|
461
343
|
|
|
462
344
|
const inputClass = computed(() => {
|
|
463
345
|
if (props.size == 'xs') {
|
|
@@ -489,18 +371,6 @@ const iconWrapClass = computed(() => {
|
|
|
489
371
|
return 'pl-2.5';
|
|
490
372
|
});
|
|
491
373
|
|
|
492
|
-
// Infinite scroll
|
|
493
|
-
|
|
494
|
-
onMounted(() => {
|
|
495
|
-
useInfiniteScroll(
|
|
496
|
-
dropdown.value,
|
|
497
|
-
() => {
|
|
498
|
-
emit('scrollBottom');
|
|
499
|
-
},
|
|
500
|
-
{ distance: 60 }
|
|
501
|
-
);
|
|
502
|
-
});
|
|
503
|
-
|
|
504
374
|
defineExpose({
|
|
505
375
|
focus,
|
|
506
376
|
blur,
|
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div class="relative w-full overflow-hidden">
|
|
3
|
+
<div
|
|
4
|
+
ref="dropdown"
|
|
5
|
+
data-scroll-lock-scrollable
|
|
6
|
+
class="max-h-[214px] w-full overflow-y-auto"
|
|
7
|
+
>
|
|
8
|
+
<!-- Slot empty -->
|
|
9
|
+
<slot v-if="options.length == 0" name="empty">
|
|
10
|
+
<div
|
|
11
|
+
class="flex items-center justify-center px-5 py-10 text-center text-slate-600"
|
|
12
|
+
>
|
|
13
|
+
{{ $t('sui.nothing_found') }}
|
|
14
|
+
</div>
|
|
15
|
+
</slot>
|
|
16
|
+
|
|
17
|
+
<!-- Option list -->
|
|
18
|
+
<ul v-else :class="dropdownClass">
|
|
19
|
+
<li
|
|
20
|
+
v-for="(option, index) in options"
|
|
21
|
+
:key="option.value"
|
|
22
|
+
ref="optionRefs"
|
|
23
|
+
class="block"
|
|
24
|
+
>
|
|
25
|
+
<button
|
|
26
|
+
class="block w-full cursor-pointer select-none appearance-none border-none text-left focus:outline-none"
|
|
27
|
+
type="button"
|
|
28
|
+
tabindex="-1"
|
|
29
|
+
@click="onSelect(option)"
|
|
30
|
+
@mouseenter="onOptionMouseEnter(index)"
|
|
31
|
+
@mousemove="onOptionMouseMove(index)"
|
|
32
|
+
>
|
|
33
|
+
<slot
|
|
34
|
+
name="option"
|
|
35
|
+
:option="option.option"
|
|
36
|
+
:selected="isSelected(option)"
|
|
37
|
+
:active="focusOption && focusOption.value == option.value"
|
|
38
|
+
>
|
|
39
|
+
<div
|
|
40
|
+
class="flex items-center rounded px-[0.75em] py-[0.5em]"
|
|
41
|
+
:class="[optionClass(option), optionSizeClass]"
|
|
42
|
+
>
|
|
43
|
+
<div class="grow">
|
|
44
|
+
{{ option.label }}
|
|
45
|
+
</div>
|
|
46
|
+
<div class="shrink-0">
|
|
47
|
+
<BaseIcon
|
|
48
|
+
v-if="isSelected(option)"
|
|
49
|
+
icon="heroicons:check-20-solid"
|
|
50
|
+
:class="optionIconClass"
|
|
51
|
+
></BaseIcon>
|
|
52
|
+
</div>
|
|
53
|
+
</div>
|
|
54
|
+
</slot>
|
|
55
|
+
</button>
|
|
56
|
+
</li>
|
|
57
|
+
</ul>
|
|
58
|
+
|
|
59
|
+
<!-- Loading bottom -->
|
|
60
|
+
<!-- This component should always take the same amount of space whether it's visible or not -->
|
|
61
|
+
<!-- This is to prevent infinite scroll jumps -->
|
|
62
|
+
<div
|
|
63
|
+
:class="[loadingBottom ? 'opacity-100' : 'opacity-0']"
|
|
64
|
+
class="relative py-3 text-center"
|
|
65
|
+
>
|
|
66
|
+
<div
|
|
67
|
+
class="absolute inset-0 h-full w-full bg-gradient-to-b from-transparent to-white"
|
|
68
|
+
></div>
|
|
69
|
+
<BaseSpinnerSmall
|
|
70
|
+
class="mx-auto h-7 w-7 text-slate-300"
|
|
71
|
+
></BaseSpinnerSmall>
|
|
72
|
+
</div>
|
|
73
|
+
</div>
|
|
74
|
+
|
|
75
|
+
<div ref="footer">
|
|
76
|
+
<div v-if="$slots.footer" class="bg-white">
|
|
77
|
+
<slot :options="options" name="footer" />
|
|
78
|
+
</div>
|
|
79
|
+
</div>
|
|
80
|
+
|
|
81
|
+
<div
|
|
82
|
+
v-if="loading"
|
|
83
|
+
class="absolute inset-0 h-full w-full space-y-1 bg-white p-2"
|
|
84
|
+
>
|
|
85
|
+
<div class="space-y-1">
|
|
86
|
+
<BaseSkeleton class="h-7 w-full" delay="0ms"></BaseSkeleton>
|
|
87
|
+
<BaseSkeleton class="h-7 w-full opacity-70" delay="50ms"></BaseSkeleton>
|
|
88
|
+
<BaseSkeleton
|
|
89
|
+
class="h-7 w-full opacity-30"
|
|
90
|
+
delay="100ms"
|
|
91
|
+
></BaseSkeleton>
|
|
92
|
+
</div>
|
|
93
|
+
</div>
|
|
94
|
+
</div>
|
|
95
|
+
</template>
|
|
96
|
+
|
|
97
|
+
<script lang="ts" setup>
|
|
98
|
+
import { Ref, PropType } from 'vue';
|
|
99
|
+
import { useInfiniteScroll } from '@vueuse/core';
|
|
100
|
+
import { NormalizedOption } from '@/types';
|
|
101
|
+
import { isArray, isObject, throttle } from 'lodash';
|
|
102
|
+
import BaseSkeleton from './BaseSkeleton.vue';
|
|
103
|
+
import { BaseIcon } from '.';
|
|
104
|
+
import BaseSpinnerSmall from '../svg/BaseSpinnerSmall.vue';
|
|
105
|
+
|
|
106
|
+
const props = defineProps({
|
|
107
|
+
selected: {
|
|
108
|
+
type: [Array, Object, null, undefined] as PropType<
|
|
109
|
+
NormalizedOption[] | NormalizedOption | null | undefined
|
|
110
|
+
>,
|
|
111
|
+
default: undefined,
|
|
112
|
+
},
|
|
113
|
+
options: {
|
|
114
|
+
type: Array as PropType<NormalizedOption[]>,
|
|
115
|
+
default() {
|
|
116
|
+
return [];
|
|
117
|
+
},
|
|
118
|
+
},
|
|
119
|
+
keywords: {
|
|
120
|
+
type: String,
|
|
121
|
+
default: '',
|
|
122
|
+
},
|
|
123
|
+
loading: {
|
|
124
|
+
type: Boolean,
|
|
125
|
+
default: false,
|
|
126
|
+
},
|
|
127
|
+
loadingBottom: {
|
|
128
|
+
type: Boolean,
|
|
129
|
+
default: false,
|
|
130
|
+
},
|
|
131
|
+
size: {
|
|
132
|
+
type: String as PropType<'xs' | 'sm' | 'base'>,
|
|
133
|
+
default: 'base',
|
|
134
|
+
},
|
|
135
|
+
dropdownClass: {
|
|
136
|
+
type: String,
|
|
137
|
+
default: '',
|
|
138
|
+
},
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
const emit = defineEmits(['scrollBottom', 'select']);
|
|
142
|
+
|
|
143
|
+
const dropdown = ref(null) as Ref<HTMLDivElement | null>;
|
|
144
|
+
const optionRefs = ref([]) as Ref<HTMLElement[]>;
|
|
145
|
+
|
|
146
|
+
let mouseIsMoving = false;
|
|
147
|
+
|
|
148
|
+
// Focus
|
|
149
|
+
|
|
150
|
+
const focusIndex = ref(0);
|
|
151
|
+
|
|
152
|
+
const focusOption = computed(() => {
|
|
153
|
+
return (
|
|
154
|
+
props.options[Math.min(focusIndex.value, props.options.length - 1)] ?? null
|
|
155
|
+
);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
// Selecting
|
|
159
|
+
|
|
160
|
+
function onSelect(option: NormalizedOption) {
|
|
161
|
+
emit('select', option);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function isSelected(option: NormalizedOption): boolean {
|
|
165
|
+
if (isArray(props.selected)) {
|
|
166
|
+
return props.selected.some((modelValue) => {
|
|
167
|
+
return modelValue.value === option.value;
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (isObject(props.selected)) {
|
|
172
|
+
return props.selected.value == option.value;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
return false;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Arrow actions
|
|
179
|
+
|
|
180
|
+
window.addEventListener('keydown', onKeyDown);
|
|
181
|
+
|
|
182
|
+
onBeforeUnmount(() => {
|
|
183
|
+
window.removeEventListener('keydown', onKeyDown);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
function onKeyDown(event: KeyboardEvent) {
|
|
187
|
+
const key = event.key;
|
|
188
|
+
|
|
189
|
+
mouseIsMoving = false;
|
|
190
|
+
|
|
191
|
+
if (props.loading) {
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if (key === 'ArrowDown') {
|
|
196
|
+
if (focusIndex.value < props.options.length - 1) {
|
|
197
|
+
focusIndex.value++;
|
|
198
|
+
} else {
|
|
199
|
+
focusIndex.value = 0;
|
|
200
|
+
}
|
|
201
|
+
scrollToFocus();
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
if (key === 'ArrowUp') {
|
|
206
|
+
if (focusIndex.value > 0) {
|
|
207
|
+
focusIndex.value--;
|
|
208
|
+
} else {
|
|
209
|
+
focusIndex.value = Math.max(0, props.options.length - 1);
|
|
210
|
+
}
|
|
211
|
+
scrollToFocus();
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
if (key === 'Enter') {
|
|
216
|
+
event.preventDefault();
|
|
217
|
+
if (focusOption.value) {
|
|
218
|
+
onSelect(focusOption.value);
|
|
219
|
+
}
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// Infinite scroll
|
|
225
|
+
|
|
226
|
+
onMounted(() => {
|
|
227
|
+
useInfiniteScroll(
|
|
228
|
+
dropdown.value,
|
|
229
|
+
() => {
|
|
230
|
+
emit('scrollBottom');
|
|
231
|
+
},
|
|
232
|
+
{ distance: 60 }
|
|
233
|
+
);
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
function scrollToFocus() {
|
|
237
|
+
if (!dropdown.value) {
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
if (!optionRefs.value) {
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const option = optionRefs.value[focusIndex.value] ?? null;
|
|
246
|
+
|
|
247
|
+
if (!option) {
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const dropdownHeight = dropdown.value.clientHeight;
|
|
252
|
+
const offsetTop = option.offsetTop;
|
|
253
|
+
const optionHeight = option.clientHeight;
|
|
254
|
+
|
|
255
|
+
dropdown.value.scrollTop = offsetTop - dropdownHeight / 2 + optionHeight / 2;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// Validate focus index
|
|
259
|
+
|
|
260
|
+
watch(
|
|
261
|
+
() => props.options,
|
|
262
|
+
() => {
|
|
263
|
+
focusIndex.value = Math.max(
|
|
264
|
+
0,
|
|
265
|
+
Math.min(focusIndex.value, props.options.length - 1)
|
|
266
|
+
);
|
|
267
|
+
},
|
|
268
|
+
{ immediate: true }
|
|
269
|
+
);
|
|
270
|
+
|
|
271
|
+
// Update the focusIndex when the selected value changes
|
|
272
|
+
|
|
273
|
+
watch(
|
|
274
|
+
() => props.selected,
|
|
275
|
+
() => {
|
|
276
|
+
if (
|
|
277
|
+
props.selected &&
|
|
278
|
+
isObject(props.selected) &&
|
|
279
|
+
!isArray(props.selected)
|
|
280
|
+
) {
|
|
281
|
+
const selected = props.selected as NormalizedOption;
|
|
282
|
+
const index = props.options.findIndex(
|
|
283
|
+
(option) => option.value === selected.value
|
|
284
|
+
);
|
|
285
|
+
if (index >= 0) {
|
|
286
|
+
focusIndex.value = index;
|
|
287
|
+
}
|
|
288
|
+
} else {
|
|
289
|
+
focusIndex.value = 0;
|
|
290
|
+
}
|
|
291
|
+
},
|
|
292
|
+
{ immediate: true }
|
|
293
|
+
);
|
|
294
|
+
|
|
295
|
+
// Scroll top when the keywords change
|
|
296
|
+
|
|
297
|
+
watch(
|
|
298
|
+
() => props.keywords,
|
|
299
|
+
() => {
|
|
300
|
+
if (dropdown.value) {
|
|
301
|
+
dropdown.value.scrollTop = 0;
|
|
302
|
+
}
|
|
303
|
+
// Reset the focusIndex
|
|
304
|
+
focusIndex.value = 0;
|
|
305
|
+
}
|
|
306
|
+
);
|
|
307
|
+
|
|
308
|
+
const onOptionMouseEnter = (index: number) => {
|
|
309
|
+
if (mouseIsMoving) {
|
|
310
|
+
focusIndex.value = index;
|
|
311
|
+
}
|
|
312
|
+
};
|
|
313
|
+
|
|
314
|
+
const onOptionMouseMove = throttle((index: number) => {
|
|
315
|
+
mouseIsMoving = true;
|
|
316
|
+
}, 10);
|
|
317
|
+
|
|
318
|
+
// Classes
|
|
319
|
+
|
|
320
|
+
const optionClass = (option: NormalizedOption) => {
|
|
321
|
+
const focus = focusOption.value && focusOption.value.value == option.value;
|
|
322
|
+
|
|
323
|
+
if (focus) {
|
|
324
|
+
return 'bg-slate-200';
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
return 'bg-white';
|
|
328
|
+
};
|
|
329
|
+
|
|
330
|
+
const optionSizeClass = computed(() => {
|
|
331
|
+
if (props.size == 'xs') {
|
|
332
|
+
return 'text-xs';
|
|
333
|
+
}
|
|
334
|
+
if (props.size == 'sm') {
|
|
335
|
+
return 'text-sm';
|
|
336
|
+
}
|
|
337
|
+
return 'text-sm';
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
const optionIconClass = computed(() => {
|
|
341
|
+
if (props.size == 'xs') {
|
|
342
|
+
return 'w-4 h-4';
|
|
343
|
+
}
|
|
344
|
+
if (props.size == 'sm') {
|
|
345
|
+
return 'w-5 h-5';
|
|
346
|
+
}
|
|
347
|
+
return 'w-5 h-5';
|
|
348
|
+
});
|
|
349
|
+
</script>
|