feat: add searchable prop to AppSelect for filtering long option lists
This commit is contained in:
@@ -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)"
|
||||
|
||||
Reference in New Issue
Block a user