|
@@ -0,0 +1,345 @@
|
|
|
|
|
+<template>
|
|
|
|
|
+ <div class="choice-combobox" ref="rootRef">
|
|
|
|
|
+ <input
|
|
|
|
|
+ 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"
|
|
|
|
|
+ @focus="handleFocus"
|
|
|
|
|
+ @input="handleSearchInput"
|
|
|
|
|
+ @keydown="handleKeydown"
|
|
|
|
|
+ @blur="handleBlur"
|
|
|
|
|
+ />
|
|
|
|
|
+ <input
|
|
|
|
|
+ :id="id"
|
|
|
|
|
+ :name="name"
|
|
|
|
|
+ type="text"
|
|
|
|
|
+ class="choice-combobox-hidden-value"
|
|
|
|
|
+ tabindex="-1"
|
|
|
|
|
+ :value="modelValue"
|
|
|
|
|
+ :required="required"
|
|
|
|
|
+ readonly
|
|
|
|
|
+ aria-hidden="true"
|
|
|
|
|
+ />
|
|
|
|
|
+ <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="index === highlightedIndex"
|
|
|
|
|
+ :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()
|
|
|
|
|
+ openList()
|
|
|
|
|
+ moveHighlight(1)
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if (event.key === 'ArrowUp') {
|
|
|
|
|
+ event.preventDefault()
|
|
|
|
|
+ openList()
|
|
|
|
|
+ moveHighlight(-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-hidden-value {
|
|
|
|
|
+ position: absolute;
|
|
|
|
|
+ width: 1px;
|
|
|
|
|
+ height: 1px;
|
|
|
|
|
+ padding: 0;
|
|
|
|
|
+ margin: -1px;
|
|
|
|
|
+ overflow: hidden;
|
|
|
|
|
+ clip: rect(0, 0, 0, 0);
|
|
|
|
|
+ white-space: nowrap;
|
|
|
|
|
+ border: 0;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.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>
|