Skip to content

Commit 8646e04

Browse files
committed
Add Autocomplete component
1 parent 558f23b commit 8646e04

File tree

5 files changed

+420
-5
lines changed

5 files changed

+420
-5
lines changed

src/components/Autocomplete.vue

Lines changed: 284 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,284 @@
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>

src/components/TextLink.vue

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ const props = withDefaults(defineProps<{
1313
const colors = {
1414
blue: 'text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-200',
1515
purple: 'text-purple-600 dark:text-purple-400 hover:text-purple-800 dark:hover:text-purple-200',
16-
red: 'text-red-600 dark:text-red-400 hover:text-red-800 dark:hover:text-red-200',
16+
red: 'text-red-700 dark:text-red-400 hover:text-red-900 dark:hover:text-red-200',
1717
green: 'text-green-600 dark:text-green-400 hover:text-green-800 dark:hover:text-green-200',
1818
sky: 'text-sky-600 dark:text-sky-400 hover:text-sky-800 dark:hover:text-sky-200',
1919
cyan: 'text-cyan-600 dark:text-cyan-400 hover:text-cyan-800 dark:hover:text-cyan-200',

src/components/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import NavList from './NavList.vue'
2626
import NavListItem from './NavListItem.vue'
2727
import TagInput from './TagInput.vue'
2828
import TextLink from './TextLink.vue'
29+
import Autocomplete from './Autocomplete.vue'
2930

3031
export default {
3132
CheckboxInput,
@@ -55,4 +56,5 @@ export default {
5556
NavListItem,
5657
TagInput,
5758
TextLink,
59+
Autocomplete,
5860
}

src/components/utils.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,3 +37,19 @@ export function parseHtml(html:string) {
3737
attrs: elAttrs,
3838
}
3939
}
40+
41+
export function focusNextElement() {
42+
let elActive = document.activeElement as HTMLInputElement
43+
let form = elActive && elActive.form
44+
if (form) {
45+
let sel = ':not([disabled]):not([tabindex="-1"])'
46+
let els = form.querySelectorAll(`a:not([disabled]), button${sel}, input[type=text]${sel}, [tabindex]${sel}`)
47+
let focusable = Array.prototype.filter.call(els,
48+
el => el.offsetWidth > 0 || el.offsetHeight > 0 || el === elActive);
49+
let index = focusable.indexOf(elActive);
50+
if (index > -1) {
51+
let elNext = focusable[index + 1] || focusable[0];
52+
elNext.focus();
53+
}
54+
}
55+
}

0 commit comments

Comments
 (0)