338 lines
7.0 KiB
Vue
338 lines
7.0 KiB
Vue
<template>
|
|
<div class="choice-combobox" ref="rootRef">
|
|
<input
|
|
ref="searchInputRef"
|
|
:id="id"
|
|
type="text"
|
|
class="choice-combobox-input"
|
|
role="combobox"
|
|
autocomplete="off"
|
|
:aria-expanded="isOpen"
|
|
:aria-controls="listboxId"
|
|
:aria-activedescendant="activeDescendantId"
|
|
:placeholder="placeholderText"
|
|
:value="query"
|
|
:required="required"
|
|
@focus="handleFocus"
|
|
@input="handleSearchInput"
|
|
@keydown="handleKeydown"
|
|
@blur="handleBlur"
|
|
/>
|
|
<input
|
|
:name="name"
|
|
type="hidden"
|
|
:value="modelValue"
|
|
/>
|
|
<ul
|
|
v-if="isOpen && filteredChoices.length > 0"
|
|
:id="listboxId"
|
|
role="listbox"
|
|
class="choice-combobox-list"
|
|
>
|
|
<li
|
|
v-for="(choice, index) in filteredChoices"
|
|
:id="`${listboxId}-option-${index}`"
|
|
:key="choice.value"
|
|
role="option"
|
|
:aria-selected="choice.value === modelValue"
|
|
:class="{
|
|
highlighted: index === highlightedIndex,
|
|
selected: choice.value === modelValue
|
|
}"
|
|
@mousedown.prevent="selectChoice(choice)"
|
|
>
|
|
{{ choiceLabel(choice) }}
|
|
</li>
|
|
</ul>
|
|
<div v-else-if="isOpen && query" class="choice-combobox-list choice-combobox-empty">
|
|
No matching options
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
|
|
|
const props = defineProps({
|
|
id: {
|
|
type: String,
|
|
required: true
|
|
},
|
|
name: {
|
|
type: String,
|
|
required: true
|
|
},
|
|
choices: {
|
|
type: Array,
|
|
required: true
|
|
},
|
|
modelValue: {
|
|
type: String,
|
|
default: ''
|
|
},
|
|
required: {
|
|
type: Boolean,
|
|
default: false
|
|
}
|
|
})
|
|
|
|
const emit = defineEmits(['update:modelValue'])
|
|
|
|
const closeOthersEvent = 'olivetin-choice-combobox-close-others'
|
|
|
|
const rootRef = ref(null)
|
|
const searchInputRef = ref(null)
|
|
const isOpen = ref(false)
|
|
const query = ref('')
|
|
const highlightedIndex = ref(0)
|
|
|
|
const listboxId = computed(() => `${props.id}-listbox`)
|
|
|
|
const activeDescendantId = computed(() => {
|
|
if (!isOpen.value || filteredChoices.value.length === 0) {
|
|
return undefined
|
|
}
|
|
|
|
return `${listboxId.value}-option-${highlightedIndex.value}`
|
|
})
|
|
|
|
const placeholderText = computed(() => {
|
|
if (props.required) {
|
|
return 'Search and select...'
|
|
}
|
|
|
|
return 'Search options...'
|
|
})
|
|
|
|
const filteredChoices = computed(() => {
|
|
const search = query.value.trim().toLowerCase()
|
|
if (!search) {
|
|
return props.choices
|
|
}
|
|
|
|
return props.choices.filter(choice => {
|
|
const label = choiceLabel(choice).toLowerCase()
|
|
const value = String(choice.value).toLowerCase()
|
|
return label.includes(search) || value.includes(search)
|
|
})
|
|
})
|
|
|
|
watch(() => props.modelValue, () => {
|
|
if (!isOpen.value) {
|
|
query.value = selectedLabel()
|
|
}
|
|
}, { immediate: true })
|
|
|
|
function choiceLabel(choice) {
|
|
return choice.title || choice.value
|
|
}
|
|
|
|
function selectedLabel() {
|
|
const match = props.choices.find(choice => choice.value === props.modelValue)
|
|
if (!match) {
|
|
return props.modelValue || ''
|
|
}
|
|
|
|
return choiceLabel(match)
|
|
}
|
|
|
|
function openList() {
|
|
document.dispatchEvent(new CustomEvent(closeOthersEvent, { detail: { id: props.id } }))
|
|
isOpen.value = true
|
|
highlightedIndex.value = 0
|
|
}
|
|
|
|
function closeList() {
|
|
isOpen.value = false
|
|
query.value = selectedLabel()
|
|
}
|
|
|
|
function emitValue(value) {
|
|
emit('update:modelValue', value)
|
|
}
|
|
|
|
function selectChoice(choice) {
|
|
emitValue(choice.value)
|
|
closeList()
|
|
}
|
|
|
|
function handleFocus() {
|
|
query.value = isOpen.value ? query.value : selectedLabel()
|
|
openList()
|
|
}
|
|
|
|
function handleSearchInput(event) {
|
|
query.value = event.target.value
|
|
openList()
|
|
highlightedIndex.value = 0
|
|
}
|
|
|
|
function moveHighlight(delta) {
|
|
if (filteredChoices.value.length === 0) {
|
|
return
|
|
}
|
|
|
|
const nextIndex = highlightedIndex.value + delta
|
|
if (nextIndex < 0) {
|
|
highlightedIndex.value = filteredChoices.value.length - 1
|
|
return
|
|
}
|
|
|
|
if (nextIndex >= filteredChoices.value.length) {
|
|
highlightedIndex.value = 0
|
|
return
|
|
}
|
|
|
|
highlightedIndex.value = nextIndex
|
|
}
|
|
|
|
function handleKeydown(event) {
|
|
if (event.key === 'ArrowDown') {
|
|
event.preventDefault()
|
|
const wasOpen = isOpen.value
|
|
openList()
|
|
if (wasOpen) {
|
|
moveHighlight(1)
|
|
}
|
|
return
|
|
}
|
|
|
|
if (event.key === 'ArrowUp') {
|
|
event.preventDefault()
|
|
const wasOpen = isOpen.value
|
|
openList()
|
|
if (wasOpen) {
|
|
moveHighlight(-1)
|
|
} else if (filteredChoices.value.length > 0) {
|
|
highlightedIndex.value = filteredChoices.value.length - 1
|
|
}
|
|
return
|
|
}
|
|
|
|
if (event.key === 'Enter') {
|
|
if (!isOpen.value || filteredChoices.value.length === 0) {
|
|
return
|
|
}
|
|
|
|
event.preventDefault()
|
|
selectChoice(filteredChoices.value[highlightedIndex.value])
|
|
return
|
|
}
|
|
|
|
if (event.key === 'Escape') {
|
|
event.preventDefault()
|
|
closeList()
|
|
searchInputRef.value?.blur()
|
|
}
|
|
}
|
|
|
|
function handleBlur() {
|
|
closeList()
|
|
}
|
|
|
|
function handleCloseOthers(event) {
|
|
if (event.detail.id !== props.id) {
|
|
closeList()
|
|
}
|
|
}
|
|
|
|
function handleOutsideMouseDown(event) {
|
|
if (!isOpen.value || rootRef.value?.contains(event.target)) {
|
|
return
|
|
}
|
|
|
|
closeList()
|
|
}
|
|
|
|
watch(isOpen, open => {
|
|
if (open) {
|
|
document.addEventListener('mousedown', handleOutsideMouseDown, true)
|
|
return
|
|
}
|
|
|
|
document.removeEventListener('mousedown', handleOutsideMouseDown, true)
|
|
})
|
|
|
|
onMounted(() => {
|
|
document.addEventListener(closeOthersEvent, handleCloseOthers)
|
|
})
|
|
|
|
onBeforeUnmount(() => {
|
|
document.removeEventListener('mousedown', handleOutsideMouseDown, true)
|
|
document.removeEventListener(closeOthersEvent, handleCloseOthers)
|
|
})
|
|
</script>
|
|
|
|
<style scoped>
|
|
.choice-combobox {
|
|
position: relative;
|
|
width: 100%;
|
|
}
|
|
|
|
.choice-combobox:focus-within {
|
|
z-index: 11;
|
|
}
|
|
|
|
.choice-combobox-input {
|
|
width: 100%;
|
|
}
|
|
|
|
.choice-combobox-list {
|
|
position: absolute;
|
|
z-index: 10;
|
|
left: 0;
|
|
right: 0;
|
|
max-height: 12rem;
|
|
overflow-y: auto;
|
|
margin: 0.125rem 0 0;
|
|
padding: 0;
|
|
list-style: none;
|
|
border: 1px solid var(--border-color, #ccc);
|
|
border-radius: 0.25rem;
|
|
background: var(--standout-bg-color, #fff);
|
|
color: var(--text-color, inherit);
|
|
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.12);
|
|
}
|
|
|
|
.choice-combobox-list li {
|
|
padding: 0.375rem 0.5rem;
|
|
cursor: pointer;
|
|
}
|
|
|
|
.choice-combobox-list li.highlighted,
|
|
.choice-combobox-list li:hover {
|
|
background: var(--hover-background-color, #eef3ff);
|
|
color: var(--hover-text-color, inherit);
|
|
}
|
|
|
|
.choice-combobox-list li.selected {
|
|
font-weight: 600;
|
|
}
|
|
|
|
.choice-combobox-empty {
|
|
padding: 0.375rem 0.5rem;
|
|
color: var(--disabled-text-color, #666);
|
|
font-size: 0.875rem;
|
|
}
|
|
|
|
@media (prefers-color-scheme: dark) {
|
|
.choice-combobox-list,
|
|
.choice-combobox-empty {
|
|
background-color: #4e4e4e;
|
|
color: #ddd;
|
|
border-color: var(--border-color, #595959);
|
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.45);
|
|
}
|
|
|
|
.choice-combobox-list li.highlighted,
|
|
.choice-combobox-list li:hover {
|
|
background-color: var(--hover-background-color, #1d345c);
|
|
color: #fff;
|
|
}
|
|
|
|
.choice-combobox-empty {
|
|
color: var(--disabled-text-color, #999);
|
|
}
|
|
}
|
|
</style>
|