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