376 lines
7.8 KiB
Vue
376 lines
7.8 KiB
Vue
<template>
|
|
<div
|
|
ref="rootRef"
|
|
class="choice-combobox"
|
|
>
|
|
<input
|
|
:id="id"
|
|
ref="searchInputRef"
|
|
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="listboxOptionId(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'
|
|
import {
|
|
choiceDisplayLabel,
|
|
syncStateFromModelValue
|
|
} from './choiceComboboxHelpers.js'
|
|
import {
|
|
argumentFieldListboxId,
|
|
argumentFieldListboxOptionId
|
|
} from '../utils/argumentFieldIds.js'
|
|
|
|
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 isUserFiltering = ref(false)
|
|
const highlightedIndex = ref(0)
|
|
|
|
const listboxId = computed(() => argumentFieldListboxId(props.name))
|
|
|
|
const activeDescendantId = computed(() => {
|
|
if (!isOpen.value || filteredChoices.value.length === 0) {
|
|
return undefined
|
|
}
|
|
|
|
return listboxOptionId(highlightedIndex.value)
|
|
})
|
|
|
|
function listboxOptionId (index) {
|
|
return argumentFieldListboxOptionId(props.name, index)
|
|
}
|
|
|
|
const placeholderText = computed(() => {
|
|
if (props.required) {
|
|
return 'Search and select...'
|
|
}
|
|
|
|
return 'Search options...'
|
|
})
|
|
|
|
const filteredChoices = computed(() => {
|
|
if (!isUserFiltering.value) {
|
|
return props.choices
|
|
}
|
|
|
|
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, () => props.choices], () => {
|
|
if (!isOpen.value) {
|
|
syncFromModelValue()
|
|
}
|
|
}, { immediate: true })
|
|
|
|
function choiceLabel (choice) {
|
|
return choiceDisplayLabel(choice)
|
|
}
|
|
|
|
function syncFromModelValue () {
|
|
const next = syncStateFromModelValue(props.choices, props.modelValue)
|
|
query.value = next.query
|
|
|
|
if (next.modelValue !== props.modelValue) {
|
|
emitValue(next.modelValue)
|
|
}
|
|
}
|
|
|
|
function selectedChoiceIndex (choices) {
|
|
const index = choices.findIndex(choice => choice.value === props.modelValue)
|
|
return index >= 0 ? index : 0
|
|
}
|
|
|
|
function openList () {
|
|
document.dispatchEvent(new CustomEvent(closeOthersEvent, { detail: { id: props.id } }))
|
|
const wasClosed = !isOpen.value
|
|
isOpen.value = true
|
|
if (wasClosed) {
|
|
highlightedIndex.value = selectedChoiceIndex(filteredChoices.value)
|
|
}
|
|
}
|
|
|
|
function closeList () {
|
|
isOpen.value = false
|
|
isUserFiltering.value = false
|
|
syncFromModelValue()
|
|
}
|
|
|
|
function emitValue (value) {
|
|
emit('update:modelValue', value)
|
|
}
|
|
|
|
function selectChoice (choice) {
|
|
emitValue(choice.value)
|
|
query.value = choiceLabel(choice)
|
|
isOpen.value = false
|
|
}
|
|
|
|
function handleFocus () {
|
|
if (!isOpen.value) {
|
|
syncFromModelValue()
|
|
isUserFiltering.value = false
|
|
}
|
|
|
|
openList()
|
|
}
|
|
|
|
function handleSearchInput (event) {
|
|
isUserFiltering.value = true
|
|
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>
|