|
| 1 | +<template> |
| 2 | +<div> |
| 3 | + <label v-if="useLabel" :for="id" class="block text-sm font-medium text-gray-700 dark:text-gray-300">{{ useLabel }}</label> |
| 4 | + <div class="mt-1 relative rounded-md shadow-sm"> |
| 5 | + <button :class="cls" @click.prevent="handleClick"> |
| 6 | + <div class="flex flex-wrap pb-1.5"> |
| 7 | + <div v-for="tag in modelValue" class="pt-1.5 pl-1"> |
| 8 | + <span class="inline-flex rounded-full items-center py-0.5 pl-2.5 pr-1 text-sm font-medium bg-indigo-100 dark:bg-indigo-800 text-indigo-700 dark:text-indigo-300"> |
| 9 | + {{tag}} |
| 10 | + <button type="button" @click="removeTag(tag)" class="flex-shrink-0 ml-1 h-4 w-4 rounded-full inline-flex items-center justify-center text-indigo-400 dark:text-indigo-500 hover:bg-indigo-200 dark:hover:bg-indigo-800 hover:text-indigo-500 dark:hover:text-indigo-400 focus:outline-none focus:bg-indigo-500 focus:text-white dark:focus:text-black"> |
| 11 | + <svg class="h-2 w-2" stroke="currentColor" fill="none" viewBox="0 0 8 8"><path stroke-linecap="round" stroke-width="1.5" d="M1 1l6 6m0-6L1 7"></path></svg> |
| 12 | + </button> |
| 13 | + </span> |
| 14 | + </div> |
| 15 | + <div class="pt-1.5 pl-1 shrink"> |
| 16 | + <input ref="txtInput" :type="useType" |
| 17 | + :name="id" |
| 18 | + :id="id" |
| 19 | + class="p-0 dark:bg-transparent rounded-md border-none focus:!border-none focus:!outline-none" |
| 20 | + :style="`box-shadow:none !important;width:${inputValue.length + 1}ch`" |
| 21 | + v-model="inputValue" |
| 22 | + :aria-invalid="errorField != null" |
| 23 | + :aria-describedby="`${id}-error`" |
| 24 | + @keydown="keyDown" |
| 25 | + @keypress="keyPress" |
| 26 | + @paste.prevent.stop="onPaste" |
| 27 | + @blur="onBlur" |
| 28 | + v-bind="remaining"> |
| 29 | + </div> |
| 30 | + </div> |
| 31 | + </button> |
| 32 | + |
| 33 | + <div v-if="errorField" class="absolute inset-y-0 right-0 pr-3 flex items-center pointer-events-none"> |
| 34 | + <svg class="h-5 w-5 text-red-500" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true"> |
| 35 | + <path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z" clip-rule="evenodd" /> |
| 36 | + </svg> |
| 37 | + </div> |
| 38 | + </div> |
| 39 | + |
| 40 | + <p v-if="errorField" class="mt-2 text-sm text-red-500" :id="`${id}-error`">{{ errorField }}</p> |
| 41 | + <p v-else-if="help" class="mt-2 text-sm text-gray-500" :id="`${id}-description`">{{ help }}</p> |
| 42 | +</div> |
| 43 | +</template> |
| 44 | + |
| 45 | +<script setup lang="ts"> |
| 46 | +import { errorResponse, humanize, omit, ResponseStatus, toPascalCase, trimEnd } from "@servicestack/client" |
| 47 | +import { computed, inject, ref, useAttrs } from "vue" |
| 48 | +import type { ApiState } from "../types" |
| 49 | +
|
| 50 | +//const value = (e:EventTarget|null) => (e as HTMLInputElement).value //workaround IDE type-check error |
| 51 | +
|
| 52 | +const props = withDefaults(defineProps<{ |
| 53 | + status?: ResponseStatus|null |
| 54 | + id: string |
| 55 | + type?: string |
| 56 | + label?: string |
| 57 | + help?: string |
| 58 | + modelValue?: string[], |
| 59 | + delimiters?: string[], |
| 60 | +}>(), { |
| 61 | + modelValue: () => [], |
| 62 | + delimiters: () => [','], |
| 63 | +}) |
| 64 | +
|
| 65 | +const emit = defineEmits<{ |
| 66 | + (e: "update:modelValue", value: string[]): void |
| 67 | +}>() |
| 68 | +
|
| 69 | +const txtInput = ref<HTMLInputElement|null>(null) |
| 70 | +const inputValue = ref('') |
| 71 | +
|
| 72 | +const useType = computed(() => props.type || 'text') |
| 73 | +const useLabel = computed(() => props.label ?? humanize(toPascalCase(props.id))) |
| 74 | +
|
| 75 | +const remaining = computed(() => omit(useAttrs(), [...Object.keys(props)])) |
| 76 | +
|
| 77 | +let ctx: ApiState|undefined = inject('ApiState', undefined) |
| 78 | +const errorField = computed(() => errorResponse.call({ responseStatus: props.status ?? ctx?.error.value }, props.id)) |
| 79 | +
|
| 80 | +const cls = computed(() => ['w-full cursor-text flex flex-wrap sm:text-sm rounded-md dark:text-white dark:bg-gray-900 border focus-within:border-transparent focus-within:ring-1 focus-within:outline-none', errorField.value |
| 81 | + ? 'pr-10 border-red-300 text-red-900 placeholder-red-300 focus-within:outline-none focus-within:ring-red-500 focus-within:border-red-500' |
| 82 | + : 'shadow-sm border-gray-300 dark:border-gray-600 focus-within:ring-indigo-500 focus-within:border-indigo-500']) |
| 83 | +
|
| 84 | +const handleClick = (e:MouseEvent) => txtInput.value?.focus() |
| 85 | +const updateValue = (newValue:string[]) => emit('update:modelValue', newValue) |
| 86 | +const removeTag = (tag:string) => updateValue(props.modelValue.filter(x => x != tag)) |
| 87 | +const onBlur = () => keyPress({ key:'Enter' } as any) |
| 88 | +
|
| 89 | +function keyDown(e:KeyboardEvent) { |
| 90 | + if (e.key == "Backspace" && inputValue.value.length == 0) { |
| 91 | + if (props.modelValue.length > 0) { |
| 92 | + removeTag(props.modelValue[props.modelValue.length - 1]) |
| 93 | + } |
| 94 | + } |
| 95 | +} |
| 96 | +
|
| 97 | +function keyPress(e:KeyboardEvent) { |
| 98 | + if (inputValue.value.length == 0) return |
| 99 | + const tag = trimEnd(inputValue.value.trim(), ',').trim() |
| 100 | + if (tag.length == 0) return |
| 101 | + const isEnter = e.key == "Enter" || e.key == "NumpadEnter" |
| 102 | + if (isEnter || (e.key.length == 1 && props.delimiters.some(x => x == e.key))) { |
| 103 | + const newValue = Array.from(props.modelValue) |
| 104 | + if (newValue.indexOf(tag) == -1) { |
| 105 | + newValue.push(tag) |
| 106 | + } |
| 107 | + updateValue(newValue) |
| 108 | + inputValue.value = '' |
| 109 | + } |
| 110 | +} |
| 111 | +
|
| 112 | +function onPaste(e:ClipboardEvent) { |
| 113 | + const text = e.clipboardData?.getData('Text') |
| 114 | + handlePastedText(text) |
| 115 | +} |
| 116 | +
|
| 117 | +function handlePastedText(txt?:string) { |
| 118 | + if (!txt) return |
| 119 | + const re = new RegExp(`\\n|\\t|${props.delimiters.join('|')}`) |
| 120 | + const newTags = Array.from(props.modelValue) |
| 121 | + const tags = txt.split(re).map(x => x.trim()) |
| 122 | + tags.forEach(tag => { |
| 123 | + if (newTags.indexOf(tag) == -1) { |
| 124 | + newTags.push(tag) |
| 125 | + } |
| 126 | + }) |
| 127 | + updateValue(newTags) |
| 128 | + inputValue.value = '' |
| 129 | +} |
| 130 | +
|
| 131 | +
|
| 132 | +</script> |
0 commit comments