182 lines
6.1 KiB
Vue
182 lines
6.1 KiB
Vue
<template>
|
|
<div class="dropdown" :class="{ 'is-active': showList && filteredOptions.length }" style="width:100%;">
|
|
<div class="control" style="width:100%;">
|
|
<input v-model="model" @focus="onFocus" @blur="hideList" @keydown="handleKeydown" @input="onInput" class="input"
|
|
:placeholder="placeholder" autocomplete="new-password" style="width:100%; position:relative; z-index:2;"
|
|
:disabled="disabled" :id="id" />
|
|
</div>
|
|
<div class="dropdown-menu" role="menu" style="width:100%; z-index:3;">
|
|
<div class="dropdown-content" style="width:100%; max-height:10em; overflow-y:auto;">
|
|
<a v-for="(option, idx) in filteredOptions" :key="option.value" @mousedown.prevent="selectOption(option.value)"
|
|
:class="['dropdown-item', { 'is-active': idx === highlightedIndex }]"
|
|
style="display:flex; justify-content:space-between;" :ref="el => setDropdownItemRef(el, idx)">
|
|
<span class="has-text-weight-bold">{{ option.value }}</span>
|
|
<abbr class="has-text-grey-light is-text-overflow" :title="option.description" style="margin-left:1em;">
|
|
{{ option.description }}
|
|
</abbr>
|
|
</a>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { ref, watch, computed, defineModel, defineProps, nextTick } from 'vue'
|
|
import type { AutoCompleteOptions } from '~/types/autocomplete'
|
|
|
|
const props = defineProps<{
|
|
options: AutoCompleteOptions
|
|
placeholder?: string
|
|
disabled?: boolean,
|
|
id?: string
|
|
}>()
|
|
|
|
const model = defineModel<string>()
|
|
|
|
const onInput = () => {
|
|
showList.value = isFlagTrigger.value && filteredOptions.value.length > 0
|
|
highlightedIndex.value = showList.value ? 0 : -1
|
|
}
|
|
|
|
const showList = ref(false)
|
|
const highlightedIndex = ref(-1)
|
|
const dropdownItemRefs = ref<(HTMLElement | null)[]>([])
|
|
|
|
// Extract the last non-space token and its bounds
|
|
const getLastToken = (value: string) => {
|
|
const m = (value || '').match(/(\S+)$/)
|
|
const token: string = m?.[1] ?? ''
|
|
const start = m ? (m.index as number) : value.length
|
|
const end = m ? start + token.length : value.length
|
|
return { token, start, end }
|
|
}
|
|
|
|
const filteredOptions = computed(() => {
|
|
const value = model.value || ''
|
|
if (!value) {
|
|
return props.options
|
|
}
|
|
const { token } = getLastToken(value)
|
|
// Hide suggestions when token has '=' or doesn't start with '--'
|
|
if (!token || token.includes('=') || !token.startsWith('--')) {
|
|
return []
|
|
}
|
|
// Hide suggestions if token exactly matches an option value
|
|
if (props.options.some(opt => opt.value === token)) {
|
|
return []
|
|
}
|
|
const val = token.toLowerCase()
|
|
const startsWithFlag = []
|
|
const includesFlag = []
|
|
const includesDesc = []
|
|
for (const opt of props.options) {
|
|
const flag = opt.value.toLowerCase()
|
|
const desc = opt.description.toLowerCase()
|
|
if (flag.startsWith(val)) {
|
|
startsWithFlag.push(opt)
|
|
} else if (flag.includes(val)) {
|
|
includesFlag.push(opt)
|
|
} else if (desc.includes(val)) {
|
|
includesDesc.push(opt)
|
|
}
|
|
}
|
|
return [...startsWithFlag, ...includesFlag, ...includesDesc]
|
|
})
|
|
|
|
const selectOption = (val: string) => {
|
|
const value = model.value || ''
|
|
const { token, start, end } = getLastToken(value)
|
|
if (token) {
|
|
// Preserve any '=value' suffix already typed for this token
|
|
const eqPos = token.indexOf('=')
|
|
const after = eqPos !== -1 ? token.slice(eqPos) : ''
|
|
model.value = value.slice(0, start) + val + after + value.slice(end)
|
|
} else {
|
|
model.value = val
|
|
}
|
|
showList.value = false
|
|
highlightedIndex.value = -1
|
|
}
|
|
|
|
const hideList = () => {
|
|
setTimeout(() => {
|
|
showList.value = false
|
|
highlightedIndex.value = -1
|
|
dropdownItemRefs.value = []
|
|
}, 100)
|
|
}
|
|
|
|
const onFocus = () => {
|
|
showList.value = isFlagTrigger.value && filteredOptions.value.length > 0
|
|
highlightedIndex.value = showList.value ? 0 : -1
|
|
}
|
|
|
|
const setDropdownItemRef = (el: Element | ComponentPublicInstance | null, idx: number) => {
|
|
dropdownItemRefs.value[idx] = el instanceof HTMLElement ? el : null
|
|
}
|
|
|
|
watch(filteredOptions, () => {
|
|
highlightedIndex.value = filteredOptions.value.length ? 0 : -1
|
|
dropdownItemRefs.value = Array(filteredOptions.value.length).fill(null)
|
|
nextTick(() => {
|
|
const dropdown = document.querySelector('.dropdown-content')
|
|
if (dropdown) {
|
|
dropdown.scrollTop = 0
|
|
}
|
|
})
|
|
})
|
|
|
|
const isFlagTrigger = computed(() => {
|
|
const { token } = getLastToken(model.value || '')
|
|
if (!token || !token.startsWith('--') || token.includes('=')) return false
|
|
// Suppress if exact match
|
|
return !props.options.some(opt => opt.value === token)
|
|
})
|
|
|
|
const handleKeydown = (e: KeyboardEvent) => {
|
|
// Escape closes dropdown and lets arrows navigate the input
|
|
if (e.key === 'Escape') {
|
|
showList.value = false
|
|
highlightedIndex.value = -1
|
|
return
|
|
}
|
|
if (!showList.value || !filteredOptions.value.length) {
|
|
return
|
|
}
|
|
|
|
const pageSize = 5
|
|
|
|
if (e.key === 'ArrowDown') {
|
|
e.preventDefault()
|
|
highlightedIndex.value = Math.min(highlightedIndex.value + 1, filteredOptions.value.length - 1)
|
|
nextTick(() => scrollHighlightedIntoView())
|
|
} else if (e.key === 'ArrowUp') {
|
|
e.preventDefault()
|
|
highlightedIndex.value = Math.max(highlightedIndex.value - 1, 0)
|
|
nextTick(() => scrollHighlightedIntoView())
|
|
} else if (e.key === 'PageDown') {
|
|
e.preventDefault()
|
|
highlightedIndex.value = Math.min(highlightedIndex.value + pageSize, filteredOptions.value.length - 1)
|
|
nextTick(() => scrollHighlightedIntoView())
|
|
} else if (e.key === 'PageUp') {
|
|
e.preventDefault()
|
|
highlightedIndex.value = Math.max(highlightedIndex.value - pageSize, 0)
|
|
nextTick(() => scrollHighlightedIntoView())
|
|
} else if (e.key === 'Enter' || e.key === 'Tab') {
|
|
const selected = highlightedIndex.value >= 0 && highlightedIndex.value < filteredOptions.value.length ?
|
|
filteredOptions.value[highlightedIndex.value] : undefined
|
|
if (selected && isFlagTrigger.value) {
|
|
e.preventDefault()
|
|
selectOption(selected.value)
|
|
}
|
|
}
|
|
}
|
|
|
|
function scrollHighlightedIntoView() {
|
|
const el = dropdownItemRefs.value[highlightedIndex.value]
|
|
if (!el) {
|
|
return
|
|
}
|
|
el.scrollIntoView({ block: 'nearest' })
|
|
}
|
|
</script>
|