@vue-interface/tag-field 1.0.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.
@@ -0,0 +1,624 @@
1
+ <script setup lang="ts" generic="ModelValue, Value">
2
+ import { PlusIcon, XMarkIcon } from '@heroicons/vue/24/outline';
3
+ import { ActivityIndicator } from '@vue-interface/activity-indicator';
4
+ import { Badge, type BadgeSize } from '@vue-interface/badge';
5
+ import type { FormControlEvents, FormControlProps, FormControlSlots } from '@vue-interface/form-control';
6
+ import { FormControlErrors, FormControlFeedback, useFormControl } from '@vue-interface/form-control';
7
+ import type { IFuseOptions } from 'fuse.js';
8
+ import Fuse from 'fuse.js';
9
+ import { isEqual } from 'lodash-es';
10
+ import { type HTMLAttributes, computed, onBeforeMount, onBeforeUnmount, ref, toRaw, unref, useTemplateRef, watch, type Ref } from 'vue';
11
+
12
+ export type TagFieldSizePrefix = 'form-control';
13
+
14
+ export type TagFieldProps<ModelValue, Value> = FormControlProps<
15
+ HTMLAttributes,
16
+ TagFieldSizePrefix,
17
+ ModelValue[],
18
+ Value[]
19
+ > & {
20
+ options?: ModelValue[];
21
+ fuseOptions?: IFuseOptions<ModelValue>;
22
+ display?: (option: ModelValue) => string;
23
+ format?: (value: string) => ModelValue;
24
+ allowCustom?: boolean;
25
+ addTagLabel?: string;
26
+ noResultsText?: string;
27
+ showNoResults?: boolean;
28
+ clearable?: boolean;
29
+ badgeClass?: string | string[] | Record<string, boolean>;
30
+ activeBadgeColor?: string | string[] | Record<string, boolean>;
31
+ badgeSize?: BadgeSize;
32
+ badgeCloseable?: boolean;
33
+ badgeCloseLeft?: boolean;
34
+ };
35
+
36
+ const props = withDefaults(defineProps<TagFieldProps<ModelValue, Value>>(), {
37
+ formControlClass: 'form-control',
38
+ labelClass: 'form-label',
39
+ size: 'form-control-md',
40
+ allowCustom: false,
41
+ addTagLabel: 'Add Tag',
42
+ noResultsText: 'No results found',
43
+ showNoResults: true,
44
+ clearable: false,
45
+ options: () => [],
46
+ badgeClass: 'badge-neutral-100 dark:badge-neutral-500',
47
+ activeBadgeColor: 'badge-blue-100! dark:badge-blue-600!',
48
+ badgeSize: 'badge-[.95em]',
49
+ badgeCloseable: true,
50
+ badgeCloseLeft: false,
51
+ });
52
+
53
+ defineOptions({
54
+ inheritAttrs: false
55
+ });
56
+
57
+ const model = defineModel<ModelValue[]>();
58
+
59
+ defineSlots<FormControlSlots<TagFieldSizePrefix, ModelValue[]> & {
60
+ default(props: { option: ModelValue; display?: (option: ModelValue) => string }): any;
61
+ 'no-results'(props: { input: string | undefined }): any;
62
+ }>();
63
+
64
+ const emit = defineEmits<FormControlEvents>();
65
+
66
+ const {
67
+ controlAttributes,
68
+ formGroupClasses,
69
+ listeners
70
+ } = useFormControl<HTMLAttributes, TagFieldSizePrefix, ModelValue[]|undefined, Value[]>({ model, props, emit });
71
+
72
+ const wrapperEl = useTemplateRef('wrapperEl');
73
+ const inputEl = useTemplateRef('inputEl');
74
+ const tagEl = useTemplateRef('tagEl');
75
+
76
+ const input = ref<string>();
77
+ const selected = ref<ModelValue[]>([]) as Ref<ModelValue[]>;
78
+ const hasFocus = ref(false);
79
+ const focusIndex = ref<number>();
80
+ const options = ref(props.options) as Ref<ModelValue[]>;
81
+
82
+ const isInteractive = computed(() => !props.disabled && !props.readonly);
83
+
84
+ const canClear = computed(() => {
85
+ return props.clearable && (!!input.value || !!model.value?.length) && isInteractive.value;
86
+ });
87
+
88
+ const keys = computed(() => {
89
+ return typeof props.options === 'object' && props.options?.[0]
90
+ ? Object.keys(props.options?.[0])
91
+ : [];
92
+ });
93
+
94
+ const fuse: Fuse<ModelValue> = createFuse(props.options);
95
+
96
+ const showOptions = computed(() => {
97
+ return isInteractive.value && hasFocus.value && (filtered.value.length || input.value);
98
+ });
99
+
100
+ const selectedIndexes = computed(() => {
101
+ return selected.value.map(tag => {
102
+ return (model.value ?? []).findIndex(item => isEqual(item, tag));
103
+ });
104
+ });
105
+
106
+ watch(() => props.options, () => {
107
+ options.value = props.options;
108
+ });
109
+
110
+ watch(input, () => {
111
+ hasFocus.value = true;
112
+ focusIndex.value = undefined;
113
+
114
+ deactivateTags();
115
+ });
116
+
117
+ function createFuse(items: ModelValue[]) {
118
+ return new Fuse(items, props.fuseOptions ?? {
119
+ includeScore: true,
120
+ threshold: .45,
121
+ keys: keys.value
122
+ });
123
+ }
124
+
125
+ const filtered = computed<ModelValue[]>(() => {
126
+ const items = options.value.filter(option => {
127
+ return !(model.value ?? []).find(item => {
128
+ return isEqual(item, toRaw(unref(option)));
129
+ });
130
+ });
131
+
132
+ if(!input.value) {
133
+ return items;
134
+ }
135
+
136
+ fuse.setCollection(items as ModelValue[]);
137
+
138
+ return fuse.search(input.value).map(({ item }) => item);
139
+ });
140
+
141
+ function addCustomTag(value: string) {
142
+ if(!isInteractive.value) return;
143
+
144
+ const tag = props.format?.(value) ?? value as ModelValue;
145
+
146
+ if(!options.value.find(option => isEqual(option, tag))) {
147
+ options.value.push(tag);
148
+
149
+ addTag(tag);
150
+
151
+ input.value = undefined;
152
+ }
153
+ }
154
+
155
+ function addTag(tag: ModelValue) {
156
+ if(!isInteractive.value) return;
157
+
158
+ model.value = [...(model.value ?? []), tag];
159
+ input.value = undefined;
160
+ focusIndex.value = undefined;
161
+ }
162
+
163
+ function removeTag(tag: ModelValue) {
164
+ if(!isInteractive.value) return;
165
+
166
+ const value = [...(model.value ?? [])];
167
+
168
+ value.splice(value.indexOf(tag), 1);
169
+
170
+ deactivateTag(tag);
171
+
172
+ model.value = value;
173
+ }
174
+
175
+ function toggleActiveTag(tag: ModelValue, multiple = false) {
176
+ if(!isInteractive.value) return;
177
+
178
+ if(!multiple) {
179
+ deactivateTags(tag);
180
+ }
181
+
182
+ if(!isTagActive(tag)) {
183
+ activateTag(tag);
184
+ }
185
+ else {
186
+ deactivateTag(tag);
187
+ }
188
+ }
189
+
190
+ function toggleActiveTagRange(tag: ModelValue) {
191
+ if(!isInteractive.value) return;
192
+
193
+ const items = model.value ?? [];
194
+ const index = items.indexOf(tag);
195
+ const lastSelectedIndex = selectedIndexes.value[selectedIndexes.value.length - 1];
196
+ const fn = !isTagActive(tag) ? activateTag : deactivateTag;
197
+
198
+ if(lastSelectedIndex === undefined) {
199
+ toggleActiveTag(tag);
200
+
201
+ return;
202
+ }
203
+
204
+ let range: ModelValue[] = [];
205
+
206
+ if(index > lastSelectedIndex) {
207
+ range = items.slice(lastSelectedIndex, index + 1);
208
+ }
209
+ else if(index < lastSelectedIndex) {
210
+ range = items.slice(index, lastSelectedIndex + 1);
211
+ }
212
+
213
+ for(const tag of range) {
214
+ fn(tag);
215
+ }
216
+ }
217
+
218
+ function selectAllTags() {
219
+ if(input.value) {
220
+ return;
221
+ }
222
+
223
+ selected.value = [...(model.value ?? [])];
224
+ }
225
+
226
+ function deactivateTags(omit?: ModelValue) {
227
+ if(!omit) {
228
+ selected.value = [];
229
+ }
230
+ else {
231
+ const tags = selected.value.filter(
232
+ item => !isEqual(item, omit)
233
+ );
234
+
235
+ for(const tag of tags) {
236
+ deactivateTag(tag);
237
+ }
238
+ }
239
+
240
+ blurTags();
241
+ }
242
+
243
+ function blurTags() {
244
+ if(!tagEl.value) {
245
+ return;
246
+ }
247
+
248
+ for(const tag of tagEl.value) {
249
+ (tag?.$el as HTMLElement | undefined)?.blur();
250
+ }
251
+ }
252
+
253
+ function isTagActive(tag: ModelValue) {
254
+ return !!selected.value.find(item => isEqual(item, tag));
255
+ }
256
+
257
+ function activateTag(tag: ModelValue) {
258
+ if(!isTagActive(tag)) {
259
+ selected.value.push(tag);
260
+ }
261
+ }
262
+
263
+ function deactivateTag(tag: ModelValue) {
264
+ if(isTagActive(tag)) {
265
+ selected.value.splice(selected.value.indexOf(tag), 1);
266
+ }
267
+
268
+ blurTags();
269
+ }
270
+
271
+ function removeActiveTags() {
272
+ model.value = (model.value ?? []).filter(tag => {
273
+ return !isTagActive(tag);
274
+ });
275
+
276
+ selected.value = [];
277
+ }
278
+
279
+ function onBackspace() {
280
+ inputEl.value?.focus();
281
+
282
+ if(input.value) {
283
+ return;
284
+ }
285
+
286
+ if(selected.value.length) {
287
+ removeActiveTags();
288
+ }
289
+ else if(model.value?.length) {
290
+ removeTag(model.value[model.value.length - 1]);
291
+ }
292
+ }
293
+
294
+ function onKeydownEnter(e: KeyboardEvent) {
295
+ if(focusIndex.value !== undefined) {
296
+ addTag(filtered.value[focusIndex.value]);
297
+ }
298
+ else if(filtered.value.length) {
299
+ addTag(filtered.value[0]);
300
+ }
301
+ else if(props.allowCustom && input.value) {
302
+ addCustomTag(input.value);
303
+ }
304
+
305
+ e.preventDefault();
306
+ }
307
+
308
+ function onKeydownSpace(e: KeyboardEvent) {
309
+ if(focusIndex.value === undefined) {
310
+ return;
311
+ }
312
+
313
+ addTag(filtered.value[focusIndex.value]);
314
+
315
+ e.preventDefault();
316
+ }
317
+
318
+ function onKeydownUp() {
319
+ if(!focusIndex.value) {
320
+ focusIndex.value = filtered.value.length - 1;
321
+ }
322
+ else {
323
+ focusIndex.value--;
324
+ }
325
+ }
326
+
327
+ function onKeydownDown() {
328
+ if(focusIndex.value === undefined || focusIndex.value === filtered.value.length - 1) {
329
+ focusIndex.value = 0;
330
+ }
331
+ else {
332
+ focusIndex.value++;
333
+ }
334
+ }
335
+
336
+ function onKeydownLeft(multiple: boolean = false) {
337
+ if(!model.value?.length || input.value) {
338
+ return;
339
+ }
340
+
341
+ const nextIndex = Math.min(...selectedIndexes.value, model.value.length) - 1;
342
+
343
+ if(model.value[nextIndex]) {
344
+ toggleActiveTag(model.value[nextIndex], multiple);
345
+ }
346
+ else {
347
+ deactivateTags();
348
+ }
349
+ }
350
+
351
+ function onKeydownRight(multiple: boolean = false) {
352
+ if(!model.value?.length || input.value) {
353
+ return;
354
+ }
355
+
356
+ const nextIndex = Math.max(...selectedIndexes.value, -1) + 1;
357
+
358
+ if(model.value[nextIndex]) {
359
+ toggleActiveTag(model.value[nextIndex], multiple);
360
+ }
361
+ else {
362
+ deactivateTags();
363
+ }
364
+ }
365
+
366
+ function onEscape() {
367
+ if(hasFocus.value) {
368
+ hasFocus.value = false;
369
+ }
370
+ else {
371
+ deactivateTags();
372
+ }
373
+ }
374
+
375
+ function onBlur(e: FocusEvent) {
376
+ if(props.allowCustom && input.value) {
377
+ addCustomTag(input.value);
378
+ }
379
+
380
+ hasFocus.value = false;
381
+
382
+ deactivateTags();
383
+
384
+ listeners.onBlur(e);
385
+ }
386
+
387
+ function onFocus(e: FocusEvent) {
388
+ hasFocus.value = true;
389
+
390
+ deactivateTags();
391
+
392
+ listeners.onFocus(e);
393
+ }
394
+
395
+ function onClickAddTag() {
396
+ if(input.value) {
397
+ addCustomTag(input.value);
398
+ }
399
+ }
400
+
401
+ function clear() {
402
+ if (!isInteractive.value) return;
403
+ input.value = undefined;
404
+ model.value = [];
405
+ }
406
+
407
+ function onClickOutsideWrapper(e: MouseEvent) {
408
+ if(!e.target) {
409
+ return;
410
+ }
411
+
412
+ if(!(wrapperEl.value == e.target || wrapperEl.value?.contains(e.target as Element))) {
413
+ deactivateTags();
414
+ }
415
+ }
416
+
417
+ function onDocumentKeydown(e: KeyboardEvent) {
418
+ switch (e.key) {
419
+ case 'Backspace':
420
+ if(selected.value.length) {
421
+ removeActiveTags();
422
+ e.preventDefault();
423
+ }
424
+ break;
425
+ case 'Escape':
426
+ if(!hasFocus.value) {
427
+ deactivateTags();
428
+ }
429
+ }
430
+ }
431
+
432
+ onBeforeMount(() => {
433
+ document.addEventListener('click', onClickOutsideWrapper);
434
+ document.addEventListener('keydown', onDocumentKeydown);
435
+ });
436
+
437
+ onBeforeUnmount(() => {
438
+ document.removeEventListener('click', onClickOutsideWrapper);
439
+ document.removeEventListener('keydown', onDocumentKeydown);
440
+ });
441
+ </script>
442
+
443
+ <template>
444
+ <div
445
+ ref="wrapperEl"
446
+ class="tag-field"
447
+ :class="[formGroupClasses, { 'has-clear-button': canClear }]">
448
+ <slot name="label">
449
+ <label
450
+ v-if="label"
451
+ :class="labelClass"
452
+ :for="controlAttributes.id">
453
+ {{ label }}
454
+ </label>
455
+ </slot>
456
+
457
+ <div class="form-control-inner">
458
+ <slot
459
+ name="control"
460
+ v-bind="{ controlAttributes, listeners }">
461
+ <div
462
+ v-if="$slots.icon"
463
+ class="form-control-inner-icon"
464
+ @click="inputEl?.focus()">
465
+ <slot name="icon" />
466
+ </div>
467
+ <div
468
+ v-bind="controlAttributes"
469
+ class="form-control flex"
470
+ :class="$attrs.class"
471
+ @click.self="inputEl?.focus()">
472
+ <div class="flex flex-wrap gap-2 mr-2 flex-1">
473
+ <Badge
474
+ v-for="(tag, i) in model"
475
+ ref="tagEl"
476
+ :key="`tag-${i}`"
477
+ tabindex="-1"
478
+ :size="badgeSize"
479
+ :class="[
480
+ badgeClass,
481
+ { 'pointer-events-none': !isInteractive },
482
+ isTagActive(tag) ? activeBadgeColor : undefined
483
+ ]"
484
+ :closeable="badgeCloseable"
485
+ :close-left="badgeCloseLeft"
486
+ @mousedown.prevent
487
+ @close="removeTag(tag)"
488
+ @focus="toggleActiveTag(tag)"
489
+ @blur="deactivateTag(tag)"
490
+ @click.exact.meta="toggleActiveTag(tag, true)"
491
+ @click.exact="toggleActiveTag(tag)"
492
+ @click.exact.shift="toggleActiveTagRange(tag)">
493
+ <slot :option="tag" :display="display">
494
+ {{ display?.(tag) ?? tag }}
495
+ </slot>
496
+ <template #close-icon>
497
+ <XMarkIcon class="size-[1.25em]" @mousedown.prevent />
498
+ </template>
499
+ </Badge>
500
+
501
+ <input
502
+ ref="inputEl"
503
+ v-model="input"
504
+ :placeholder="model?.length ? undefined : ($attrs.placeholder as string)"
505
+ :disabled="props.disabled"
506
+ :readonly="props.readonly"
507
+ class="bg-transparent outline-none flex-1 min-w-0"
508
+ @keydown.exact.delete="onBackspace"
509
+ @keydown.exact.meta.a="selectAllTags"
510
+ @keydown.exact.enter="onKeydownEnter"
511
+ @keydown.exact.space="onKeydownSpace"
512
+ @keydown.exact.arrow-up="onKeydownUp"
513
+ @keydown.exact.arrow-down="onKeydownDown"
514
+ @keydown.exact.arrow-left="onKeydownLeft()"
515
+ @keydown.exact.shift.arrow-left="onKeydownLeft(true)"
516
+ @keydown.exact.arrow-right="onKeydownRight()"
517
+ @keydown.exact.shift.arrow-right="onKeydownRight(true)"
518
+ @keydown.esc="onEscape"
519
+ @blur="onBlur"
520
+ @focus="onFocus">
521
+ </div>
522
+ </div>
523
+ </slot>
524
+
525
+ <div class="form-control-activity-indicator">
526
+ <slot name="activity" v-bind="{ canClear, clear, isInteractive }">
527
+ <button
528
+ v-if="canClear"
529
+ type="button"
530
+ class="tag-field-clear-button"
531
+ @click.stop="clear">
532
+ <XMarkIcon class="size-[1.25em]" />
533
+ </button>
534
+ <Transition name="tag-field-fade" v-else>
535
+ <ActivityIndicator
536
+ v-if="props.activity && props.indicator"
537
+ key="activity"
538
+ :type="props.indicator"
539
+ :size="props.indicatorSize" />
540
+ </Transition>
541
+ </slot>
542
+ </div>
543
+ </div>
544
+
545
+ <div
546
+ v-if="showOptions"
547
+ tabindex="-1"
548
+ class="tag-field-dropdown"
549
+ :class="size"
550
+ @mousedown.prevent.stop>
551
+ <button
552
+ v-for="(option, i) in filtered"
553
+ :key="`option-${JSON.stringify(option)}`"
554
+ type="button"
555
+ tabindex="-1"
556
+ :class="{
557
+ ['bg-neutral-100 dark:bg-neutral-800']: focusIndex === i
558
+ }"
559
+ @mousedown.prevent
560
+ @mouseup="addTag(option)">
561
+ <div class="truncate">
562
+ {{ display?.(option) ?? option }}
563
+ </div>
564
+ </button>
565
+ <button
566
+ v-if="allowCustom && input"
567
+ class="flex items-center gap-1"
568
+ type="button"
569
+ @mousedown.prevent
570
+ @mouseup="onClickAddTag">
571
+ <PlusIcon class="size-4" /> {{ addTagLabel }}
572
+ </button>
573
+ <div
574
+ v-if="showNoResults && !filtered.length && !allowCustom"
575
+ class="py-2 px-4 text-neutral-400 dark:text-neutral-500">
576
+ <slot name="no-results" :input="input">
577
+ {{ noResultsText }}
578
+ </slot>
579
+ </div>
580
+ </div>
581
+
582
+ <slot
583
+ name="errors"
584
+ v-bind="{ error, errors, id: $attrs.id, name: $attrs.name }">
585
+ <FormControlErrors
586
+ v-if="!!(error || errors)"
587
+ :id="id"
588
+ v-slot="{ error }"
589
+ :name="name"
590
+ :error="error"
591
+ :errors="errors">
592
+ <div
593
+ invalid
594
+ class="invalid-feedback">
595
+ {{ error }}
596
+ </div>
597
+ </FormControlErrors>
598
+ </slot>
599
+
600
+ <slot
601
+ name="feedback"
602
+ v-bind="{ feedback }">
603
+ <FormControlFeedback
604
+ v-slot="{ feedback }"
605
+ :feedback="feedback">
606
+ <div
607
+ valid
608
+ class="valid-feedback">
609
+ {{ feedback }}
610
+ </div>
611
+ </FormControlFeedback>
612
+ </slot>
613
+
614
+ <slot
615
+ name="help"
616
+ v-bind="{ helpText }">
617
+ <small
618
+ v-if="helpText"
619
+ class="form-help">
620
+ {{ helpText }}
621
+ </small>
622
+ </slot>
623
+ </div>
624
+ </template>