feat: add searchable prop to AppSelect for filtering long option lists

This commit is contained in:
Your Name
2026-02-17 23:33:11 +02:00
parent 7fed47e54f
commit 06d646c8de

View File

@@ -10,6 +10,7 @@ interface Props {
placeholder?: string
disabled?: boolean
placeholderValue?: any
searchable?: boolean
}
const props = withDefaults(defineProps<Props>(), {
@@ -18,6 +19,7 @@ const props = withDefaults(defineProps<Props>(), {
placeholder: 'Select...',
disabled: false,
placeholderValue: undefined,
searchable: false,
})
const emit = defineEmits<{
@@ -29,6 +31,8 @@ const highlightedIndex = ref(-1)
const triggerRef = ref<HTMLButtonElement | null>(null)
const panelRef = ref<HTMLDivElement | null>(null)
const panelStyle = ref<Record<string, string>>({})
const searchQuery = ref('')
const searchInputRef = ref<HTMLInputElement | null>(null)
// Build the full list: placeholder + real options
const allItems = computed(() => {
@@ -40,6 +44,15 @@ const allItems = computed(() => {
return [placeholderItem, ...props.options]
})
const filteredItems = computed(() => {
if (!props.searchable || !searchQuery.value) return allItems.value
const q = searchQuery.value.toLowerCase()
return allItems.value.filter(item => {
if (item._isPlaceholder) return true
return getOptionLabel(item).toLowerCase().includes(q)
})
})
const selectedLabel = computed(() => {
const option = props.options.find(
(o) => o[props.valueKey] === props.modelValue
@@ -103,6 +116,11 @@ function open() {
const selectedIdx = allItems.value.findIndex((item) => isSelected(item))
highlightedIndex.value = selectedIdx >= 0 ? selectedIdx : 0
if (props.searchable) {
searchQuery.value = ''
nextTick(() => searchInputRef.value?.focus())
}
nextTick(() => {
scrollHighlightedIntoView()
})
@@ -166,7 +184,7 @@ function onKeydown(e: KeyboardEvent) {
e.preventDefault()
highlightedIndex.value = Math.min(
highlightedIndex.value + 1,
allItems.value.length - 1
filteredItems.value.length - 1
)
nextTick(() => scrollHighlightedIntoView())
break
@@ -179,7 +197,7 @@ function onKeydown(e: KeyboardEvent) {
case ' ':
e.preventDefault()
if (highlightedIndex.value >= 0) {
select(allItems.value[highlightedIndex.value])
select(filteredItems.value[highlightedIndex.value])
}
break
case 'Escape':
@@ -193,6 +211,12 @@ function onKeydown(e: KeyboardEvent) {
}
}
function onSearchKeydown(e: KeyboardEvent) {
if (['ArrowDown', 'ArrowUp', 'Enter', 'Escape'].includes(e.key)) {
onKeydown(e)
}
}
onMounted(() => {
// Nothing needed on mount since listeners are added when opened
})
@@ -250,9 +274,19 @@ onBeforeUnmount(() => {
:style="panelStyle"
class="bg-bg-surface border border-border-visible rounded-xl shadow-[0_4px_24px_rgba(0,0,0,0.4)] overflow-hidden animate-dropdown-enter"
>
<div v-if="searchable" class="px-2 pt-2 pb-1">
<input
ref="searchInputRef"
v-model="searchQuery"
type="text"
class="w-full px-2.5 py-1.5 bg-bg-inset border border-border-subtle rounded-lg text-[0.75rem] text-text-primary placeholder-text-tertiary focus:outline-none focus:border-border-visible"
placeholder="Search..."
@keydown="onSearchKeydown"
/>
</div>
<div class="max-h-[240px] overflow-y-auto py-1">
<div
v-for="(item, index) in allItems"
v-for="(item, index) in filteredItems"
:key="item._isPlaceholder ? '__placeholder__' : item[valueKey]"
data-option
@click="select(item)"