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 dbea5658c2
commit ef5eecd711

View File

@@ -10,6 +10,7 @@ interface Props {
placeholder?: string placeholder?: string
disabled?: boolean disabled?: boolean
placeholderValue?: any placeholderValue?: any
searchable?: boolean
} }
const props = withDefaults(defineProps<Props>(), { const props = withDefaults(defineProps<Props>(), {
@@ -18,6 +19,7 @@ const props = withDefaults(defineProps<Props>(), {
placeholder: 'Select...', placeholder: 'Select...',
disabled: false, disabled: false,
placeholderValue: undefined, placeholderValue: undefined,
searchable: false,
}) })
const emit = defineEmits<{ const emit = defineEmits<{
@@ -29,6 +31,8 @@ const highlightedIndex = ref(-1)
const triggerRef = ref<HTMLButtonElement | null>(null) const triggerRef = ref<HTMLButtonElement | null>(null)
const panelRef = ref<HTMLDivElement | null>(null) const panelRef = ref<HTMLDivElement | null>(null)
const panelStyle = ref<Record<string, string>>({}) const panelStyle = ref<Record<string, string>>({})
const searchQuery = ref('')
const searchInputRef = ref<HTMLInputElement | null>(null)
// Build the full list: placeholder + real options // Build the full list: placeholder + real options
const allItems = computed(() => { const allItems = computed(() => {
@@ -40,6 +44,15 @@ const allItems = computed(() => {
return [placeholderItem, ...props.options] 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 selectedLabel = computed(() => {
const option = props.options.find( const option = props.options.find(
(o) => o[props.valueKey] === props.modelValue (o) => o[props.valueKey] === props.modelValue
@@ -103,6 +116,11 @@ function open() {
const selectedIdx = allItems.value.findIndex((item) => isSelected(item)) const selectedIdx = allItems.value.findIndex((item) => isSelected(item))
highlightedIndex.value = selectedIdx >= 0 ? selectedIdx : 0 highlightedIndex.value = selectedIdx >= 0 ? selectedIdx : 0
if (props.searchable) {
searchQuery.value = ''
nextTick(() => searchInputRef.value?.focus())
}
nextTick(() => { nextTick(() => {
scrollHighlightedIntoView() scrollHighlightedIntoView()
}) })
@@ -166,7 +184,7 @@ function onKeydown(e: KeyboardEvent) {
e.preventDefault() e.preventDefault()
highlightedIndex.value = Math.min( highlightedIndex.value = Math.min(
highlightedIndex.value + 1, highlightedIndex.value + 1,
allItems.value.length - 1 filteredItems.value.length - 1
) )
nextTick(() => scrollHighlightedIntoView()) nextTick(() => scrollHighlightedIntoView())
break break
@@ -179,7 +197,7 @@ function onKeydown(e: KeyboardEvent) {
case ' ': case ' ':
e.preventDefault() e.preventDefault()
if (highlightedIndex.value >= 0) { if (highlightedIndex.value >= 0) {
select(allItems.value[highlightedIndex.value]) select(filteredItems.value[highlightedIndex.value])
} }
break break
case 'Escape': 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(() => { onMounted(() => {
// Nothing needed on mount since listeners are added when opened // Nothing needed on mount since listeners are added when opened
}) })
@@ -250,9 +274,19 @@ onBeforeUnmount(() => {
:style="panelStyle" :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" 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 class="max-h-[240px] overflow-y-auto py-1">
<div <div
v-for="(item, index) in allItems" v-for="(item, index) in filteredItems"
:key="item._isPlaceholder ? '__placeholder__' : item[valueKey]" :key="item._isPlaceholder ? '__placeholder__' : item[valueKey]"
data-option data-option
@click="select(item)" @click="select(item)"