@signal24/vue-foundation 4.16.0 → 4.17.0
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/demo/components/demo-root.vue +21 -0
- package/demo/components/demo-vf-smart-select.vue +28 -0
- package/demo/index.html +14 -0
- package/demo/index.ts +10 -0
- package/demo/vite.config.ts +23 -0
- package/dist/demo/components/demo-root.vue.d.ts +2 -0
- package/dist/demo/components/demo-vf-smart-select.vue.d.ts +2 -0
- package/dist/demo/index.d.ts +1 -0
- package/dist/demo/vite.config.d.ts +2 -0
- package/dist/src/components/index.d.ts +5 -5
- package/dist/src/components/overlay-anchor.vue.d.ts +1 -1
- package/dist/src/components/overlay-container.d.ts +1 -1
- package/dist/src/components/toast-helpers.d.ts +1 -1
- package/dist/src/components/vf-ajax-select.vue.d.ts +26 -0
- package/dist/src/components/{alert-modal.vue.d.ts → vf-alert-modal.vue.d.ts} +1 -1
- package/dist/src/components/{ez-smart-select.vue.d.ts → vf-ez-smart-select.vue.d.ts} +13 -5
- package/dist/src/components/{modal.vue.d.ts → vf-modal.vue.d.ts} +1 -1
- package/dist/src/components/vf-smart-select.vue.d.ts +47 -0
- package/dist/src/components/{toast.vue.d.ts → vf-toast.vue.d.ts} +1 -1
- package/dist/vue-foundation.es.js +828 -893
- package/eslint.config.mjs +67 -0
- package/package.json +10 -8
- package/src/components/alert-helpers.ts +1 -1
- package/src/components/index.ts +5 -5
- package/src/components/overlay-container.ts +5 -1
- package/src/components/toast-helpers.ts +1 -1
- package/src/components/vf-ajax-select.vue +61 -0
- package/src/components/{alert-modal.vue → vf-alert-modal.vue} +5 -5
- package/src/components/{ez-smart-select.vue → vf-ez-smart-select.vue} +12 -8
- package/src/components/{modal.vue → vf-modal.vue} +3 -3
- package/src/components/vf-smart-select.vue +585 -0
- package/src/directives/duration.ts +3 -3
- package/src/directives/hotkey.ts +1 -0
- package/src/filters/index.ts +1 -0
- package/src/helpers/array.ts +1 -0
- package/src/helpers/error.ts +4 -0
- package/src/helpers/object.ts +1 -0
- package/tsconfig.app.json +1 -1
- package/tsconfig.node.json +1 -1
- package/tsconfig.vitest.json +2 -2
- package/.eslintrc.cjs +0 -35
- package/dist/src/components/ajax-select.vue.d.ts +0 -19
- package/dist/src/components/smart-select.vue.d.ts +0 -115
- package/src/components/ajax-select.vue +0 -75
- package/src/components/smart-select.vue +0 -609
- /package/src/components/{toast.vue → vf-toast.vue} +0 -0
|
@@ -0,0 +1,585 @@
|
|
|
1
|
+
<!-- eslint-disable vue/no-v-html -->
|
|
2
|
+
<template>
|
|
3
|
+
<div ref="el" class="vf-smart-select" :class="{ disabled: effectiveDisabled, open: shouldDisplayOptions }">
|
|
4
|
+
<input
|
|
5
|
+
ref="searchField"
|
|
6
|
+
v-model="searchText"
|
|
7
|
+
type="text"
|
|
8
|
+
:disabled="effectiveDisabled"
|
|
9
|
+
:class="{ nullable: !!nullTitle }"
|
|
10
|
+
:placeholder="effectivePlaceholder"
|
|
11
|
+
:required="required"
|
|
12
|
+
data-1p-ignore
|
|
13
|
+
@keydown="handleKeyDown"
|
|
14
|
+
@focus="handleInputFocused"
|
|
15
|
+
@blur="handleInputBlurred"
|
|
16
|
+
/>
|
|
17
|
+
<div v-if="shouldDisplayOptions" ref="optionsContainer" class="vf-smart-select-options">
|
|
18
|
+
<div v-if="!isLoaded" class="no-results">Loading...</div>
|
|
19
|
+
<template v-else>
|
|
20
|
+
<div
|
|
21
|
+
v-for="option in effectiveOptions"
|
|
22
|
+
:key="String(option.key)"
|
|
23
|
+
class="option"
|
|
24
|
+
:class="{
|
|
25
|
+
highlighted: highlightedOptionKey === option.key
|
|
26
|
+
}"
|
|
27
|
+
@mousemove="handleOptionHover(option)"
|
|
28
|
+
@mousedown="selectOption(option)"
|
|
29
|
+
>
|
|
30
|
+
<div class="title" v-html="option.title" />
|
|
31
|
+
<div v-if="option.subtitle" class="subtitle" v-html="option.subtitle" />
|
|
32
|
+
</div>
|
|
33
|
+
<div v-if="!effectiveOptions.length && searchText" class="no-results">
|
|
34
|
+
{{ effectiveNoResultsText }}
|
|
35
|
+
</div>
|
|
36
|
+
</template>
|
|
37
|
+
</div>
|
|
38
|
+
</div>
|
|
39
|
+
</template>
|
|
40
|
+
|
|
41
|
+
<script lang="ts" setup generic="T, V = T">
|
|
42
|
+
import { debounce, isEqual } from 'lodash';
|
|
43
|
+
import { computed, onMounted, type Ref, ref, watch } from 'vue';
|
|
44
|
+
|
|
45
|
+
import { escapeHtml } from '../helpers/string';
|
|
46
|
+
|
|
47
|
+
const NullSymbol = Symbol('null');
|
|
48
|
+
const CreateSymbol = Symbol('create');
|
|
49
|
+
|
|
50
|
+
const VALID_KEYS = `\`1234567890-=[]\\;',./~!@#$%^&*()_+{}|:"<>?qwertyuiopasdfghjklzxcvbnmQWERTYUIOPASDFGHJKLZXCVBNM`;
|
|
51
|
+
|
|
52
|
+
interface OptionDescriptor {
|
|
53
|
+
key: string | symbol;
|
|
54
|
+
title: string;
|
|
55
|
+
subtitle?: string | null;
|
|
56
|
+
searchContent?: string;
|
|
57
|
+
ref?: T;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const props = defineProps<{
|
|
61
|
+
modelValue: V | null;
|
|
62
|
+
loadOptions?: (searchText: string | null) => Promise<T[]>;
|
|
63
|
+
options?: T[];
|
|
64
|
+
prependOptions?: T[];
|
|
65
|
+
appendOptions?: T[];
|
|
66
|
+
onCreateItem?: (searchText: string) => void;
|
|
67
|
+
preload?: boolean;
|
|
68
|
+
remoteSearch?: boolean;
|
|
69
|
+
searchFields?: (keyof T)[];
|
|
70
|
+
placeholder?: string;
|
|
71
|
+
keyField?: keyof T;
|
|
72
|
+
keyExtractor?: (option: T) => string | symbol;
|
|
73
|
+
valueField?: keyof T;
|
|
74
|
+
valueExtractor?: (option: T) => V;
|
|
75
|
+
labelField?: keyof T;
|
|
76
|
+
formatter?: (option: T) => string;
|
|
77
|
+
subtitleFormatter?: (option: T) => string;
|
|
78
|
+
nullTitle?: string;
|
|
79
|
+
noResultsText?: string;
|
|
80
|
+
disabled?: boolean;
|
|
81
|
+
optionsListId?: string;
|
|
82
|
+
debug?: boolean;
|
|
83
|
+
required?: boolean;
|
|
84
|
+
}>();
|
|
85
|
+
|
|
86
|
+
const emit = defineEmits<{
|
|
87
|
+
optionsLoaded: [T[]];
|
|
88
|
+
'update:modelValue': [V];
|
|
89
|
+
}>();
|
|
90
|
+
|
|
91
|
+
defineExpose({
|
|
92
|
+
addRemoteOption
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
const el = ref<HTMLDivElement>();
|
|
96
|
+
const searchField = ref<HTMLInputElement>();
|
|
97
|
+
const optionsContainer = ref<HTMLDivElement>();
|
|
98
|
+
|
|
99
|
+
const isLoading = ref(false);
|
|
100
|
+
const isLoaded = ref(false);
|
|
101
|
+
const loadedOptions = ref<T[]>([]) as Ref<T[]>;
|
|
102
|
+
const isSearching = ref(false);
|
|
103
|
+
const searchText = ref('');
|
|
104
|
+
const selectedOption = ref<T | null>(null);
|
|
105
|
+
const selectedOptionTitle = ref<string | null>(null);
|
|
106
|
+
const shouldDisplayOptions = ref(false);
|
|
107
|
+
const highlightedOptionKey = ref<string | symbol | null>(null);
|
|
108
|
+
const shouldShowCreateOption = ref(false);
|
|
109
|
+
|
|
110
|
+
const effectivePrependOptions = computed(() => props.prependOptions ?? []);
|
|
111
|
+
const effectiveAppendOptions = computed(() => props.appendOptions ?? []);
|
|
112
|
+
const effectiveDisabled = computed(() => !!props.disabled);
|
|
113
|
+
const effectivePlaceholder = computed(() => {
|
|
114
|
+
if (!isLoaded.value && props.preload) return 'Loading...';
|
|
115
|
+
if (props.nullTitle) return props.nullTitle;
|
|
116
|
+
return props.placeholder || '';
|
|
117
|
+
});
|
|
118
|
+
const effectiveNoResultsText = computed(() => props.noResultsText || 'No options match your search.');
|
|
119
|
+
|
|
120
|
+
const effectiveValueExtractor = computed(() => {
|
|
121
|
+
if (props.valueExtractor) return props.valueExtractor;
|
|
122
|
+
if (props.valueField) return (option: T) => option[props.valueField!];
|
|
123
|
+
return null;
|
|
124
|
+
});
|
|
125
|
+
const effectiveKeyExtractor = computed(() => {
|
|
126
|
+
if (props.keyExtractor) return props.keyExtractor;
|
|
127
|
+
if (props.keyField) return (option: T) => String(option[props.keyField!]);
|
|
128
|
+
if (effectiveValueExtractor.value) return (option: T) => String(effectiveValueExtractor.value!(option));
|
|
129
|
+
return null;
|
|
130
|
+
});
|
|
131
|
+
const effectiveFormatter = computed(() => {
|
|
132
|
+
if (props.formatter) return props.formatter;
|
|
133
|
+
if (props.labelField) return (option: T) => String(option[props.labelField!]);
|
|
134
|
+
return (option: T) => String(option);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
const allOptions = computed(() => [...effectivePrependOptions.value, ...loadedOptions.value, ...effectiveAppendOptions.value]);
|
|
138
|
+
|
|
139
|
+
const optionsDescriptors = computed(() => {
|
|
140
|
+
return allOptions.value.map((option, index) => {
|
|
141
|
+
const title = effectiveFormatter.value(option);
|
|
142
|
+
const subtitle = props.subtitleFormatter?.(option);
|
|
143
|
+
const strippedTitle = title ? title.trim().toLowerCase() : '';
|
|
144
|
+
const strippedSubtitle = subtitle ? subtitle.trim().toLowerCase() : '';
|
|
145
|
+
|
|
146
|
+
const searchContent = [];
|
|
147
|
+
if (props.searchFields) {
|
|
148
|
+
props.searchFields.forEach(field => {
|
|
149
|
+
if (option[field]) {
|
|
150
|
+
searchContent.push(String(option[field]).toLowerCase());
|
|
151
|
+
}
|
|
152
|
+
});
|
|
153
|
+
} else {
|
|
154
|
+
searchContent.push(strippedTitle);
|
|
155
|
+
if (strippedSubtitle) {
|
|
156
|
+
searchContent.push(strippedSubtitle);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return {
|
|
161
|
+
key: effectiveKeyExtractor.value?.(option) ?? String(index),
|
|
162
|
+
title,
|
|
163
|
+
subtitle,
|
|
164
|
+
searchContent: searchContent.join(''),
|
|
165
|
+
ref: option
|
|
166
|
+
} as OptionDescriptor;
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
const effectiveOptions = computed(() => {
|
|
171
|
+
let options = [...optionsDescriptors.value];
|
|
172
|
+
|
|
173
|
+
if (isSearching.value) {
|
|
174
|
+
const strippedSearchText = searchText.value.trim().toLowerCase();
|
|
175
|
+
|
|
176
|
+
if (strippedSearchText.length) {
|
|
177
|
+
options = options.filter(option => option.searchContent!.includes(strippedSearchText));
|
|
178
|
+
|
|
179
|
+
const escapedSearchText = escapeHtml(searchText.value).replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&');
|
|
180
|
+
const searchRe = new RegExp(`(${escapedSearchText})`, 'ig');
|
|
181
|
+
|
|
182
|
+
options = options.map(option => ({
|
|
183
|
+
...option,
|
|
184
|
+
title: option.title.replace(searchRe, '<mark>$1</mark>'),
|
|
185
|
+
subtitle: option.subtitle?.replace(searchRe, '<mark>$1</mark>')
|
|
186
|
+
}));
|
|
187
|
+
|
|
188
|
+
if (shouldShowCreateOption.value) {
|
|
189
|
+
const hasExactMatch = options.find(option => option.searchContent === strippedSearchText) !== undefined;
|
|
190
|
+
if (!hasExactMatch) {
|
|
191
|
+
options.push({
|
|
192
|
+
key: CreateSymbol,
|
|
193
|
+
title: 'Create <strong>' + searchText.value.trim() + '</strong>...'
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
} else if (props.nullTitle) {
|
|
199
|
+
options.unshift({
|
|
200
|
+
key: NullSymbol,
|
|
201
|
+
title: props.nullTitle
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
return options;
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
// watch props
|
|
209
|
+
watch(() => props.modelValue, handleValueChanged);
|
|
210
|
+
watch(
|
|
211
|
+
() => props.options,
|
|
212
|
+
() => {
|
|
213
|
+
loadedOptions.value = props.options ?? [];
|
|
214
|
+
isLoaded.value = true;
|
|
215
|
+
}
|
|
216
|
+
);
|
|
217
|
+
|
|
218
|
+
// watch data
|
|
219
|
+
|
|
220
|
+
watch(optionsDescriptors, () => {
|
|
221
|
+
if (shouldDisplayOptions.value) {
|
|
222
|
+
setTimeout(highlightInitialOption, 0);
|
|
223
|
+
}
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
watch(searchText, () => {
|
|
227
|
+
// don't disable searching here if it's remote search, as that will need to be done after the fetch
|
|
228
|
+
if (isSearching.value && !props.remoteSearch && !searchText.value.trim().length) {
|
|
229
|
+
isSearching.value = false;
|
|
230
|
+
}
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
watch(shouldDisplayOptions, () => {
|
|
234
|
+
if (shouldDisplayOptions.value) {
|
|
235
|
+
setTimeout(handleOptionsDisplayed, 0);
|
|
236
|
+
} else {
|
|
237
|
+
isSearching.value = false;
|
|
238
|
+
searchText.value = selectedOptionTitle.value || '';
|
|
239
|
+
|
|
240
|
+
if (optionsContainer.value) {
|
|
241
|
+
optionsContainer.value.style.visibility = 'hidden';
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
watch(effectiveOptions, () => {
|
|
247
|
+
if (props.modelValue && !selectedOption.value) {
|
|
248
|
+
handleValueChanged();
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
if ((highlightedOptionKey.value || isSearching.value) && !effectiveOptions.value.find(option => option.key == highlightedOptionKey.value)) {
|
|
252
|
+
highlightedOptionKey.value = effectiveOptions.value[0]?.key ?? NullSymbol;
|
|
253
|
+
}
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
onMounted(async () => {
|
|
257
|
+
shouldShowCreateOption.value = props.onCreateItem !== undefined;
|
|
258
|
+
|
|
259
|
+
if (props.options) {
|
|
260
|
+
loadedOptions.value = [...props.options];
|
|
261
|
+
isLoaded.value = true;
|
|
262
|
+
} else if (props.preload) {
|
|
263
|
+
await loadRemoteOptions();
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
handleValueChanged();
|
|
267
|
+
|
|
268
|
+
watch(selectedOption, () => {
|
|
269
|
+
if (selectedOption.value !== props.modelValue) {
|
|
270
|
+
emit(
|
|
271
|
+
'update:modelValue',
|
|
272
|
+
selectedOption.value && effectiveValueExtractor.value ? effectiveValueExtractor.value(selectedOption.value) : selectedOption.value
|
|
273
|
+
);
|
|
274
|
+
}
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
if (props.remoteSearch) {
|
|
278
|
+
watch(searchText, debounce(reloadOptionsIfSearching, 250));
|
|
279
|
+
}
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
async function loadRemoteOptions() {
|
|
283
|
+
await reloadOptions();
|
|
284
|
+
if (loadedOptions.value) emit('optionsLoaded', loadedOptions.value);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
async function reloadOptions() {
|
|
288
|
+
const effectiveSearchText = props.remoteSearch && isSearching.value && searchText.value ? searchText.value : null;
|
|
289
|
+
isLoading.value = true;
|
|
290
|
+
loadedOptions.value = (await props.loadOptions?.(effectiveSearchText)) ?? [];
|
|
291
|
+
isLoading.value = false;
|
|
292
|
+
isLoaded.value = true;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function reloadOptionsIfSearching() {
|
|
296
|
+
if (isSearching.value) {
|
|
297
|
+
reloadOptions();
|
|
298
|
+
isSearching.value = searchText.value.trim().length > 0;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function handleKeyDown(e: KeyboardEvent) {
|
|
303
|
+
if (e.key == 'Escape') {
|
|
304
|
+
e.stopPropagation();
|
|
305
|
+
(e.target as HTMLInputElement).blur();
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
if (e.key == 'ArrowLeft' || e.key == 'ArrowRight') return;
|
|
310
|
+
if (e.key == 'Tab') return;
|
|
311
|
+
|
|
312
|
+
if (!isLoaded.value) {
|
|
313
|
+
if (!isSearching.value) e.preventDefault();
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
if (e.key == 'ArrowUp' || e.key == 'ArrowDown') {
|
|
318
|
+
e.preventDefault();
|
|
319
|
+
return incrementHighlightedOption(e.key == 'ArrowUp' ? -1 : 1);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
if (e.key == 'PageUp' || e.key == 'PageDown') {
|
|
323
|
+
e.preventDefault();
|
|
324
|
+
return incrementHighlightedOption(e.key == 'PageUp' ? -10 : 10);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
if (e.key == 'Home' || e.key == 'End') {
|
|
328
|
+
e.preventDefault();
|
|
329
|
+
return incrementHighlightedOption(e.key == 'Home' ? -Number.MAX_SAFE_INTEGER : Number.MAX_SAFE_INTEGER);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
if (e.key == 'Enter') {
|
|
333
|
+
e.preventDefault();
|
|
334
|
+
const highlightedOption = effectiveOptions.value.find(option => option.key == highlightedOptionKey.value);
|
|
335
|
+
if (highlightedOption) return selectOption(highlightedOption);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
if (e.key === 'Delete' || e.key === 'Backspace') {
|
|
339
|
+
if (searchText.value.length > 1) {
|
|
340
|
+
isSearching.value = true;
|
|
341
|
+
}
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
if (!e.metaKey && VALID_KEYS.includes(e.key)) {
|
|
346
|
+
isSearching.value = true;
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
function handleInputFocused() {
|
|
351
|
+
setHighlightedOptionKey();
|
|
352
|
+
shouldDisplayOptions.value = true;
|
|
353
|
+
setTimeout(() => searchField.value?.select(), 0);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function setHighlightedOptionKey(useFirstItemAsFallback?: boolean) {
|
|
357
|
+
if (selectedOption.value) {
|
|
358
|
+
highlightedOptionKey.value = getOptionKey(selectedOption.value);
|
|
359
|
+
} else if (useFirstItemAsFallback) {
|
|
360
|
+
highlightedOptionKey.value = effectiveOptions.value?.[0].key ?? NullSymbol;
|
|
361
|
+
} else if (props.nullTitle) {
|
|
362
|
+
highlightedOptionKey.value = NullSymbol;
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function getOptionKey(option: T): string | symbol {
|
|
367
|
+
if (effectiveKeyExtractor.value) {
|
|
368
|
+
return effectiveKeyExtractor.value(selectedOption.value);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
return getOptionDescriptor(option)?.key ?? '';
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
function getOptionDescriptor(option: T) {
|
|
375
|
+
const matchedRef = effectiveOptions.value.find(o => o.ref === option);
|
|
376
|
+
if (matchedRef) {
|
|
377
|
+
return matchedRef;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// didn't find an object match, so we'll try a content match. a couple reasons:
|
|
381
|
+
// 1) the initial selection may have come from an owning object and the object as a whole may differ from the full content list
|
|
382
|
+
// 2) for reasons I've yet to determine, the prepend options, although they are wrapped by proxies and have identical content,
|
|
383
|
+
// are not the same proxy object as selectedOption once assigned -- even though the loaded data *is* the same. I've tried
|
|
384
|
+
// setting them as reactive using the same method (via data props rather than computed) and it didn't change anything.
|
|
385
|
+
// therefore, falling back to an isEqual check here when there's no equal object
|
|
386
|
+
const matcher = props.keyExtractor ? (a: T, b: T) => props.keyExtractor!(a) === props.keyExtractor!(b) : isEqual;
|
|
387
|
+
const matchedObj = effectiveOptions.value.find(o => matcher(o.ref!, option));
|
|
388
|
+
if (matchedObj) {
|
|
389
|
+
return matchedObj;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
return null;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
function handleInputBlurred() {
|
|
396
|
+
if (props.debug) return;
|
|
397
|
+
|
|
398
|
+
if (!searchText.value.length && props.nullTitle) {
|
|
399
|
+
selectedOption.value = null;
|
|
400
|
+
selectedOptionTitle.value = null;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
shouldDisplayOptions.value = false;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
function handleOptionsDisplayed() {
|
|
407
|
+
if (!isLoaded.value) loadRemoteOptions();
|
|
408
|
+
if (props.optionsListId) optionsContainer.value?.setAttribute('id', props.optionsListId);
|
|
409
|
+
teleportOptionsContainer();
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
function teleportOptionsContainer() {
|
|
413
|
+
const elRect = el.value!.getBoundingClientRect();
|
|
414
|
+
const targetTop = elRect.y + elRect.height + 2;
|
|
415
|
+
const targetLeft = elRect.x;
|
|
416
|
+
|
|
417
|
+
const optionsEl = optionsContainer.value!;
|
|
418
|
+
const styles = window.getComputedStyle(el.value!);
|
|
419
|
+
|
|
420
|
+
for (let key in styles) {
|
|
421
|
+
if (!/^(font|text)/.test(key)) continue;
|
|
422
|
+
optionsEl.style[key] = styles[key];
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
optionsEl.style.top = targetTop + 'px';
|
|
426
|
+
optionsEl.style.left = targetLeft + 'px';
|
|
427
|
+
optionsEl.style.minWidth = elRect.width + 'px';
|
|
428
|
+
|
|
429
|
+
if (!styles.maxHeight || styles.maxHeight == 'none') {
|
|
430
|
+
const maxHeight = window.innerHeight - targetTop - 12;
|
|
431
|
+
optionsEl.style.maxHeight = maxHeight + 'px';
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
optionsEl.style.visibility = 'visible';
|
|
435
|
+
|
|
436
|
+
document.body.appendChild(optionsEl);
|
|
437
|
+
|
|
438
|
+
setTimeout(highlightInitialOption, 0);
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
function highlightInitialOption() {
|
|
442
|
+
if (!isLoaded.value) return;
|
|
443
|
+
if (!highlightedOptionKey.value) return;
|
|
444
|
+
const highlightedOptionIdx = effectiveOptions.value.findIndex(option => option.key == highlightedOptionKey.value);
|
|
445
|
+
const containerEl = optionsContainer.value!;
|
|
446
|
+
const highlightedOptionEl = containerEl.querySelectorAll('.option')[highlightedOptionIdx] as HTMLElement;
|
|
447
|
+
containerEl.scrollTop = highlightedOptionEl.offsetTop;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
function handleOptionHover(option: OptionDescriptor) {
|
|
451
|
+
highlightedOptionKey.value = option ? option.key : null;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
function incrementHighlightedOption(increment: number) {
|
|
455
|
+
const highlightedOptionIdx = effectiveOptions.value.findIndex(option => option.key == highlightedOptionKey.value);
|
|
456
|
+
let targetOptionIdx = highlightedOptionIdx + increment;
|
|
457
|
+
|
|
458
|
+
if (targetOptionIdx < 0) targetOptionIdx = 0;
|
|
459
|
+
else if (targetOptionIdx >= effectiveOptions.value.length) targetOptionIdx = effectiveOptions.value.length - 1;
|
|
460
|
+
|
|
461
|
+
if (highlightedOptionIdx == targetOptionIdx) return;
|
|
462
|
+
|
|
463
|
+
highlightedOptionKey.value = effectiveOptions.value[targetOptionIdx].key;
|
|
464
|
+
|
|
465
|
+
const containerEl = optionsContainer.value!;
|
|
466
|
+
const targetOptionEl = containerEl.querySelectorAll('.option')[targetOptionIdx] as HTMLElement;
|
|
467
|
+
|
|
468
|
+
if (targetOptionEl.offsetTop < containerEl.scrollTop) {
|
|
469
|
+
containerEl.scrollTop = targetOptionEl.offsetTop;
|
|
470
|
+
} else if (targetOptionEl.offsetTop + targetOptionEl.offsetHeight > containerEl.scrollTop + containerEl.clientHeight) {
|
|
471
|
+
containerEl.scrollTop = targetOptionEl.offsetTop + targetOptionEl.offsetHeight - containerEl.clientHeight;
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
function selectOption(option: OptionDescriptor) {
|
|
476
|
+
isSearching.value = false;
|
|
477
|
+
|
|
478
|
+
if (option.key == NullSymbol) {
|
|
479
|
+
searchText.value = '';
|
|
480
|
+
selectedOption.value = null;
|
|
481
|
+
selectedOptionTitle.value = null;
|
|
482
|
+
} else if (option.key === CreateSymbol) {
|
|
483
|
+
const createText = searchText.value.trim();
|
|
484
|
+
searchText.value = '';
|
|
485
|
+
selectedOption.value = null;
|
|
486
|
+
selectedOptionTitle.value = null;
|
|
487
|
+
props.onCreateItem?.(createText);
|
|
488
|
+
} else {
|
|
489
|
+
const selectedDecoratedOption = optionsDescriptors.value.find(decoratedOption => decoratedOption.key == option.key);
|
|
490
|
+
const realOption = selectedDecoratedOption!.ref;
|
|
491
|
+
selectedOption.value = realOption!;
|
|
492
|
+
selectedOptionTitle.value = effectiveFormatter.value(realOption!);
|
|
493
|
+
searchText.value = selectedOptionTitle.value || '';
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
searchField.value?.blur();
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
function handleValueChanged() {
|
|
500
|
+
if (props.modelValue) {
|
|
501
|
+
selectedOption.value = effectiveValueExtractor.value
|
|
502
|
+
? allOptions.value.find(o => props.modelValue === effectiveValueExtractor.value!(o))
|
|
503
|
+
: props.modelValue;
|
|
504
|
+
selectedOptionTitle.value = selectedOption.value ? effectiveFormatter.value(selectedOption.value) : null;
|
|
505
|
+
searchText.value = selectedOptionTitle.value || '';
|
|
506
|
+
} else {
|
|
507
|
+
selectedOption.value = null;
|
|
508
|
+
selectedOptionTitle.value = null;
|
|
509
|
+
searchText.value = '';
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
function addRemoteOption(option: T) {
|
|
514
|
+
loadedOptions.value.unshift(option);
|
|
515
|
+
}
|
|
516
|
+
</script>
|
|
517
|
+
|
|
518
|
+
<style lang="scss">
|
|
519
|
+
.vf-smart-select {
|
|
520
|
+
position: relative;
|
|
521
|
+
|
|
522
|
+
input {
|
|
523
|
+
width: 100%;
|
|
524
|
+
padding-right: 24px !important;
|
|
525
|
+
|
|
526
|
+
&.nullable::placeholder {
|
|
527
|
+
color: #000;
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
&:after {
|
|
532
|
+
content: ' ';
|
|
533
|
+
display: block;
|
|
534
|
+
position: absolute;
|
|
535
|
+
top: 50%;
|
|
536
|
+
right: 8px;
|
|
537
|
+
margin-top: -3px;
|
|
538
|
+
width: 0;
|
|
539
|
+
height: 0;
|
|
540
|
+
border-style: solid;
|
|
541
|
+
border-width: 5px 5px 0 5px;
|
|
542
|
+
border-color: #333333 transparent transparent transparent;
|
|
543
|
+
pointer-events: none;
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
&.open:after {
|
|
547
|
+
margin-top: -4px;
|
|
548
|
+
border-width: 0 5px 5px 5px;
|
|
549
|
+
border-color: transparent transparent #333333 transparent;
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
&:not(.disabled) {
|
|
553
|
+
input {
|
|
554
|
+
cursor: pointer;
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
&.disabled:after {
|
|
559
|
+
opacity: 0.4;
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
.vf-smart-select-options {
|
|
564
|
+
visibility: hidden;
|
|
565
|
+
position: absolute;
|
|
566
|
+
min-height: 20px;
|
|
567
|
+
border: 1px solid #e8e8e8;
|
|
568
|
+
background: white;
|
|
569
|
+
overflow: auto;
|
|
570
|
+
z-index: 101;
|
|
571
|
+
|
|
572
|
+
.option,
|
|
573
|
+
.no-results {
|
|
574
|
+
padding: 5px 8px;
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
.option {
|
|
578
|
+
cursor: pointer;
|
|
579
|
+
|
|
580
|
+
&.highlighted {
|
|
581
|
+
background-color: #f5f5f5;
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
</style>
|
|
@@ -62,13 +62,13 @@ function removeDuration(el: DurationElement) {
|
|
|
62
62
|
function secondsToString(seconds: number, shouldSkipSeconds?: boolean) {
|
|
63
63
|
const result = [];
|
|
64
64
|
const days = Math.floor(seconds / 86400);
|
|
65
|
-
days
|
|
65
|
+
if (days) result.push(days + 'd');
|
|
66
66
|
seconds -= days * 86400;
|
|
67
67
|
const hours = Math.floor(seconds / 3600);
|
|
68
|
-
(days || hours)
|
|
68
|
+
if (days || hours) result.push(hours + 'h');
|
|
69
69
|
seconds -= hours * 3600;
|
|
70
70
|
const minutes = Math.floor(seconds / 60);
|
|
71
|
-
(days || hours || minutes)
|
|
71
|
+
if (days || hours || minutes) result.push(minutes + 'm');
|
|
72
72
|
if (!shouldSkipSeconds) {
|
|
73
73
|
seconds -= minutes * 60;
|
|
74
74
|
result.push(seconds + 's');
|
package/src/directives/hotkey.ts
CHANGED
|
@@ -41,6 +41,7 @@ function reset(el: HTMLButtonElement, binding: DirectiveBinding<string>) {
|
|
|
41
41
|
}
|
|
42
42
|
|
|
43
43
|
function handleKeydown(event: KeyboardEvent) {
|
|
44
|
+
if (typeof event.key !== 'string') return;
|
|
44
45
|
const hotkey = event.key.toLowerCase();
|
|
45
46
|
const elements = ElementsByHotkey.get(hotkey);
|
|
46
47
|
if (elements) {
|
package/src/filters/index.ts
CHANGED
|
@@ -10,6 +10,7 @@ function bytes(value: number) {
|
|
|
10
10
|
return `${stringResult} ${unit}`;
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
13
14
|
function dash(value: any) {
|
|
14
15
|
return value !== null && value !== undefined && String(value).length ? value : '-';
|
|
15
16
|
}
|
package/src/helpers/array.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
type ArraySearchPredicate<T> = Parameters<T[]['findIndex']>[0];
|
|
2
2
|
export function replaceElement<T>(array: T[], oldElement: T | ArraySearchPredicate<T>, newElement: T) {
|
|
3
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
3
4
|
const index = typeof oldElement === 'function' ? array.findIndex(oldElement as any) : array.indexOf(oldElement);
|
|
4
5
|
if (index === -1) return false;
|
|
5
6
|
array.splice(index, 1, newElement);
|
package/src/helpers/error.ts
CHANGED
|
@@ -8,6 +8,7 @@ export class UserError extends Error {
|
|
|
8
8
|
}
|
|
9
9
|
}
|
|
10
10
|
|
|
11
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
11
12
|
export function formatError(err: any): string {
|
|
12
13
|
if (err instanceof UserError) {
|
|
13
14
|
return err.message;
|
|
@@ -17,6 +18,7 @@ export function formatError(err: any): string {
|
|
|
17
18
|
return `An application error has occurred:\n\n${errMessage}\n\nPlease refresh the page and try again. If this error persists, ${VfOptions.unhandledErrorSupportText}.`;
|
|
18
19
|
}
|
|
19
20
|
|
|
21
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
20
22
|
export function toError(err: any) {
|
|
21
23
|
return err instanceof Error ? err : new Error(String(err));
|
|
22
24
|
}
|
|
@@ -25,6 +27,7 @@ interface IErrorAlertOptions {
|
|
|
25
27
|
title?: string;
|
|
26
28
|
classes?: string[];
|
|
27
29
|
}
|
|
30
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
28
31
|
export async function handleErrorAndAlert(errIn: any, options?: IErrorAlertOptions) {
|
|
29
32
|
const err = toError(errIn);
|
|
30
33
|
|
|
@@ -39,6 +42,7 @@ export async function handleErrorAndAlert(errIn: any, options?: IErrorAlertOptio
|
|
|
39
42
|
});
|
|
40
43
|
}
|
|
41
44
|
|
|
45
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
42
46
|
export async function handleError(errIn: any) {
|
|
43
47
|
const err = toError(errIn);
|
|
44
48
|
|
package/src/helpers/object.ts
CHANGED
|
@@ -12,6 +12,7 @@ export function nullifyEmptyInputs<T extends Record<string, unknown>, K extends
|
|
|
12
12
|
const result = { ...obj };
|
|
13
13
|
for (const key of fields) {
|
|
14
14
|
if (result[key] === '') {
|
|
15
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
15
16
|
result[key] = null as any;
|
|
16
17
|
}
|
|
17
18
|
}
|
package/tsconfig.app.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"extends": "@vue/tsconfig/tsconfig.dom.json",
|
|
3
|
-
"include": ["src/**/*.ts", "src/**/*.vue"],
|
|
3
|
+
"include": ["src/**/*.ts", "src/**/*.vue", "demo/**/*.ts", "demo/**/*.vue"],
|
|
4
4
|
"exclude": ["src/**/__tests__/*", "src/vite-plugins/*"],
|
|
5
5
|
"compilerOptions": {
|
|
6
6
|
"noEmit": false,
|