|
| 1 | +<template> |
| 2 | + |
| 3 | +<div :id="`${id}-autocomplete`"> |
| 4 | + <label v-if="useLabel" :for="id" class="block text-sm font-medium text-gray-700 dark:text-gray-300">{{ useLabel }}</label> |
| 5 | + |
| 6 | + <div class="relative mt-1"> |
| 7 | + |
| 8 | + <input ref="txtInput" :id="id" |
| 9 | + type="text" role="combobox" aria-controls="options" aria-expanded="false" autocomplete="off" spellcheck="false" |
| 10 | + v-model="inputValue" |
| 11 | + :class="cls" |
| 12 | + :placeholder="multiple || !modelValue ? placeholder : ''" |
| 13 | + @keydown="keyDown" |
| 14 | + @keyup="keyUp" |
| 15 | + @click="update" |
| 16 | + @onpaste="onPaste" |
| 17 | + v-bind="remaining"> |
| 18 | + |
| 19 | + <button type="button" @click="toggle" class="absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none" tabindex="-1"> |
| 20 | + <svg class="h-5 w-5 text-gray-400 dark:text-gray-500" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true"> |
| 21 | + <path fill-rule="evenodd" d="M10 3a.75.75 0 01.55.24l3.25 3.5a.75.75 0 11-1.1 1.02L10 4.852 7.3 7.76a.75.75 0 01-1.1-1.02l3.25-3.5A.75.75 0 0110 3zm-3.76 9.2a.75.75 0 011.06.04l2.7 2.908 2.7-2.908a.75.75 0 111.1 1.02l-3.25 3.5a.75.75 0 01-1.1 0l-3.25-3.5a.75.75 0 01.04-1.06z" clip-rule="evenodd" /> |
| 22 | + </svg> |
| 23 | + </button> |
| 24 | + |
| 25 | + <ul v-if="showPopup" class="absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white dark:bg-black py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm" |
| 26 | + @keydown="keyDown" :id="`${id}-options`" role="listbox"> |
| 27 | + <li v-for="option in filteredValues" |
| 28 | + :class="[option === active ? 'active bg-indigo-600 text-white' : 'text-gray-900 dark:text-gray-100', 'relative cursor-default select-none py-2 pl-3 pr-9']" |
| 29 | + @mouseover="setActive(option)" @click="select(option)" role="option" tabindex="-1"> |
| 30 | + |
| 31 | + <slot name="item" v-bind="option"></slot> |
| 32 | + |
| 33 | + <span v-if="hasOption(option)" :class="['absolute inset-y-0 right-0 flex items-center pr-4', option === active ? 'text-white' : 'text-indigo-600']"> |
| 34 | + <svg class="h-5 w-5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true"> |
| 35 | + <path fill-rule="evenodd" d="M16.704 4.153a.75.75 0 01.143 1.052l-8 10.5a.75.75 0 01-1.127.075l-4.5-4.5a.75.75 0 011.06-1.06l3.894 3.893 7.48-9.817a.75.75 0 011.05-.143z" clip-rule="evenodd" /> |
| 36 | + </svg> |
| 37 | + </span> |
| 38 | + </li> |
| 39 | + </ul> |
| 40 | + <div v-else-if="!multiple && modelValue" @keydown="keyDown" @click="toggle" class="h-8 -mt-8 ml-3 pt-0.5"> |
| 41 | + <slot name="item" v-bind="modelValue"></slot> |
| 42 | + </div> |
| 43 | + |
| 44 | + <div v-if="errorField" class="absolute inset-y-0 right-0 pr-3 flex items-center pointer-events-none" tabindex="-1"> |
| 45 | + <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"> |
| 46 | + <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" /> |
| 47 | + </svg> |
| 48 | + </div> |
| 49 | + </div> |
| 50 | + |
| 51 | + <p v-if="errorField" class="mt-2 text-sm text-red-500" :id="`${id}-error`">{{ errorField }}</p> |
| 52 | + <p v-else-if="help" class="mt-2 text-sm text-gray-500" :id="`${id}-description`">{{ help }}</p> |
| 53 | +</div> |
| 54 | + |
| 55 | +</template> |
| 56 | + |
| 57 | +<script setup lang="ts"> |
| 58 | +import { $1, errorResponse, humanize, omit, ResponseStatus, toPascalCase } from "@servicestack/client" |
| 59 | +import { computed, inject, onMounted, ref, useAttrs, watch } from "vue" |
| 60 | +import type { ApiState } from "../types" |
| 61 | +import { focusNextElement } from "./utils"; |
| 62 | +
|
| 63 | +const showPopup = ref(false) |
| 64 | +
|
| 65 | +const props = withDefaults(defineProps<{ |
| 66 | + status?: ResponseStatus|null |
| 67 | + id: string |
| 68 | + type?: string |
| 69 | + label?: string |
| 70 | + help?: string |
| 71 | + placeholder?: string |
| 72 | + multiple?: boolean |
| 73 | + options?: any[] |
| 74 | + modelValue?: any |
| 75 | + match:(item:any,value:string) => boolean |
| 76 | + viewCount?: number |
| 77 | + pageSize?: number |
| 78 | +}>(), { |
| 79 | + multiple: false, |
| 80 | + options: () => [], |
| 81 | + viewCount: 100, |
| 82 | + pageSize: 8, |
| 83 | +}) |
| 84 | +
|
| 85 | +const emit = defineEmits<{ |
| 86 | + (e: "update:modelValue", value: any[]|any): void |
| 87 | +}>() |
| 88 | +
|
| 89 | +function hasOption(option:any) { |
| 90 | + return Array.isArray(props.modelValue) && props.modelValue.indexOf(option) >= 0 |
| 91 | +} |
| 92 | +
|
| 93 | +const useLabel = computed(() => props.label ?? humanize(toPascalCase(props.id))) |
| 94 | +const remaining = computed(() => omit(useAttrs(), [...Object.keys(props)])) |
| 95 | +
|
| 96 | +let ctx: ApiState|undefined = inject('ApiState', undefined) |
| 97 | +const errorField = computed(() => errorResponse.call({ responseStatus: props.status ?? ctx?.error.value }, props.id)) |
| 98 | +
|
| 99 | +const cls = computed(() => ['block w-full sm:text-sm rounded-md dark:text-white dark:bg-gray-900', errorField.value |
| 100 | + ? 'pr-10 border-red-300 text-red-900 placeholder-red-300 focus:outline-none focus:ring-red-500 focus:border-red-500' |
| 101 | + : 'shadow-sm focus:ring-indigo-500 focus:border-indigo-500 border-gray-300 dark:border-gray-600']) |
| 102 | +
|
| 103 | +const txtInput = ref<HTMLInputElement|null>(null) |
| 104 | +const inputValue = ref('') |
| 105 | +const active = ref<any|null>(null) |
| 106 | +const take = ref(props.viewCount) |
| 107 | +const filteredValues = ref<any[]>([]) |
| 108 | +
|
| 109 | +const filteredOptions = computed(() => { |
| 110 | + try { |
| 111 | + let ret = !inputValue.value |
| 112 | + ? props.options |
| 113 | + : props.options.filter(x => props.match(x, inputValue.value)).slice(0,take.value) |
| 114 | + return ret |
| 115 | + } catch(e) { |
| 116 | + console.log('filteredOptions error', e) |
| 117 | + } |
| 118 | + return [] |
| 119 | +}) |
| 120 | +
|
| 121 | +const navKeys = ['Tab', 'Escape', 'ArrowDown', 'ArrowUp', 'Enter', 'PageUp', 'PageDown', 'Home', 'End'] |
| 122 | +
|
| 123 | +function setActive(option:any) { |
| 124 | + active.value = option |
| 125 | + const currIndex = filteredValues.value.indexOf(option) |
| 126 | + if (currIndex > Math.floor(take.value * .9)) { |
| 127 | + take.value += props.viewCount |
| 128 | + refresh() |
| 129 | + } |
| 130 | +} |
| 131 | +
|
| 132 | +const delims = [',','\n','\t'] |
| 133 | +
|
| 134 | +function onPaste(e:ClipboardEvent) { |
| 135 | + const text = e.clipboardData?.getData('Text') |
| 136 | + handlePastedText(text) |
| 137 | +} |
| 138 | +function handlePastedText(txt?:string) { |
| 139 | + if (!txt) return |
| 140 | +
|
| 141 | + const multipleValues = delims.some(x => txt.includes(x)) |
| 142 | + if (!props.multiple || !multipleValues) { |
| 143 | + const matches = props.options.filter(x => props.match(x,txt)) |
| 144 | + if (matches.length == 1) { |
| 145 | + select(matches[0]) |
| 146 | + showPopup.value = false |
| 147 | + focusNextElement() |
| 148 | + } |
| 149 | + } else if (multipleValues) { |
| 150 | + const re = new RegExp(`\\n|\\t|,`) |
| 151 | + const values = txt.split(re).filter(x => x.trim()) |
| 152 | + const matches = values.map(value => props.options.find(x => props.match(x,value))).filter(x => !!x) |
| 153 | + if (matches.length > 0) { |
| 154 | + matches.forEach(select) |
| 155 | + showPopup.value = false |
| 156 | + focusNextElement() |
| 157 | + } |
| 158 | + } |
| 159 | +} |
| 160 | +
|
| 161 | +function keyUp(e:KeyboardEvent) { |
| 162 | + if (navKeys.indexOf(e.code)) |
| 163 | + return |
| 164 | + update() |
| 165 | +} |
| 166 | +
|
| 167 | +function keyDown(e:KeyboardEvent) { |
| 168 | + if (e.shiftKey || e.ctrlKey || e.altKey) return |
| 169 | +
|
| 170 | + if (!showPopup.value) { |
| 171 | + if (e.code == 'ArrowDown') { |
| 172 | + showPopup.value = true |
| 173 | + active.value = filteredValues.value[0] |
| 174 | + } |
| 175 | + return |
| 176 | + } |
| 177 | +
|
| 178 | + if (e.code == 'Escape' || e.code == 'Tab') { |
| 179 | + showPopup.value = false |
| 180 | + } else if (e.code == 'Home') { |
| 181 | + active.value = filteredValues.value[0] |
| 182 | + scrollActiveIntoView() |
| 183 | + } else if (e.code == 'End') { |
| 184 | + active.value = filteredValues.value[filteredValues.value.length-1] |
| 185 | + scrollActiveIntoView() |
| 186 | + } else if (e.code == 'ArrowDown') { |
| 187 | + if (!active.value) { |
| 188 | + active.value = filteredValues.value[0] |
| 189 | + } else { |
| 190 | + const currIndex = filteredValues.value.indexOf(active.value) |
| 191 | + active.value = currIndex + 1 < filteredValues.value.length |
| 192 | + ? filteredValues.value[currIndex + 1] |
| 193 | + : filteredValues.value[0] |
| 194 | + } |
| 195 | + onlyScrollActiveIntoViewIfNeeded() |
| 196 | + } else if (e.code == 'ArrowUp') { |
| 197 | + if (!active.value) { |
| 198 | + active.value = filteredValues.value[filteredValues.value.length-1] |
| 199 | + } else { |
| 200 | + const currIndex = filteredValues.value.indexOf(active.value) |
| 201 | + active.value = currIndex - 1 >= 0 |
| 202 | + ? filteredValues.value[currIndex - 1] |
| 203 | + : filteredValues.value[filteredValues.value.length-1] |
| 204 | + } |
| 205 | + onlyScrollActiveIntoViewIfNeeded() |
| 206 | + } else if (e.code == 'Enter') { |
| 207 | + if (active.value) { |
| 208 | + select(active.value) |
| 209 | + if (!props.multiple) { |
| 210 | + focusNextElement() |
| 211 | + } |
| 212 | + } else { |
| 213 | + showPopup.value = false |
| 214 | + } |
| 215 | + } |
| 216 | +} |
| 217 | +
|
| 218 | +const scrollOptions = { behavior: "smooth", block: "nearest", inline: "nearest", scrollMode:'if-needed' } |
| 219 | +function scrollActiveIntoView() { |
| 220 | + setTimeout(() => { |
| 221 | + let el = $1(`#${props.id}-autocomplete li.active`) |
| 222 | + if (el) { |
| 223 | + el.scrollIntoView(scrollOptions) |
| 224 | + } |
| 225 | + },0) |
| 226 | +} |
| 227 | +function onlyScrollActiveIntoViewIfNeeded() { |
| 228 | + setTimeout(() => { |
| 229 | + let el = $1(`#${props.id}-autocomplete li.active`) |
| 230 | + if (el) { |
| 231 | + if ('scrollIntoViewIfNeeded' in el) { |
| 232 | + el.scrollIntoViewIfNeeded(scrollOptions) |
| 233 | + } else { |
| 234 | + el.scrollIntoView(scrollOptions) |
| 235 | + } |
| 236 | + } |
| 237 | + },0) |
| 238 | +} |
| 239 | +
|
| 240 | +function toggle() { |
| 241 | + if (showPopup.value) { |
| 242 | + showPopup.value = false |
| 243 | + return |
| 244 | + } |
| 245 | + update() |
| 246 | + txtInput.value?.focus() |
| 247 | +} |
| 248 | +
|
| 249 | +function update() { |
| 250 | + showPopup.value = true |
| 251 | + refresh() |
| 252 | +} |
| 253 | +
|
| 254 | +function select(option:any) { |
| 255 | + console.log('select', option) |
| 256 | +
|
| 257 | + inputValue.value = '' |
| 258 | + showPopup.value = false |
| 259 | + |
| 260 | + if (props.multiple) { |
| 261 | + let newValues = Array.from(props.modelValue || []) |
| 262 | + if (hasOption(option)) { |
| 263 | + newValues = newValues.filter(x => x != option) |
| 264 | + } else { |
| 265 | + newValues.push(option) |
| 266 | + } |
| 267 | + active.value = null |
| 268 | + emit('update:modelValue', newValues) |
| 269 | + } else { |
| 270 | + let value = option |
| 271 | + if (props.modelValue == option) { |
| 272 | + value = null |
| 273 | + } |
| 274 | + emit('update:modelValue', value) |
| 275 | + } |
| 276 | +} |
| 277 | +
|
| 278 | +
|
| 279 | +function refresh() { |
| 280 | + filteredValues.value = filteredOptions.value |
| 281 | +} |
| 282 | +
|
| 283 | +watch(inputValue, refresh) |
| 284 | +</script> |
0 commit comments