feat(combobox): add reusable combobox component with search and selection
Implement a combobox component with search functionality and item selection. The component supports displaying item labels with optional codes, maintains selected item state, and provides customizable placeholders. Items are sorted with selected items first followed by alphabetical order.
This commit is contained in:
@@ -0,0 +1,100 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '~/lib/utils'
|
||||
|
||||
interface Item {
|
||||
value: string
|
||||
label: string
|
||||
code?: string
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue?: string
|
||||
items: Item[]
|
||||
placeholder?: string
|
||||
searchPlaceholder?: string
|
||||
emptyMessage?: string
|
||||
class?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: string]
|
||||
}>()
|
||||
|
||||
const open = ref(false)
|
||||
|
||||
const selectedItem = computed(() =>
|
||||
props.items.find(item => item.value === props.modelValue),
|
||||
)
|
||||
|
||||
const displayText = computed(() =>
|
||||
selectedItem.value?.label || props.placeholder || '---pilih item',
|
||||
)
|
||||
|
||||
// Create searchable items with combined code and label for better search
|
||||
// Sort by:
|
||||
// 1. Selected item first (highest priority)
|
||||
// 2. Then by label alphabetically
|
||||
const searchableItems = computed(() => {
|
||||
const itemsWithSearch = props.items.map(item => ({
|
||||
...item,
|
||||
searchValue: `${item.code || ''} ${item.label}`.trim(),
|
||||
isSelected: item.value === props.modelValue,
|
||||
}))
|
||||
|
||||
return itemsWithSearch.sort((a, b) => {
|
||||
// Selected item always comes first
|
||||
if (a.isSelected && !b.isSelected) return -1
|
||||
if (!a.isSelected && b.isSelected) return 1
|
||||
|
||||
// If neither or both are selected, sort by label alphabetically
|
||||
return a.label.localeCompare(b.label)
|
||||
})
|
||||
})
|
||||
|
||||
function onSelect(item: Item) {
|
||||
emit('update:modelValue', item.value)
|
||||
open.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Popover v-model:open="open">
|
||||
<PopoverTrigger as-child>
|
||||
<Button
|
||||
variant="outline" role="combobox" :aria-expanded="open" :class="cn(
|
||||
'w-full justify-between border-black bg-white hover:bg-gray-50 text-sm font-normal',
|
||||
!modelValue && 'text-muted-foreground',
|
||||
props.class,
|
||||
)"
|
||||
>
|
||||
{{ displayText }}
|
||||
<Icon name="i-lucide-chevrons-up-down" class="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent class="w-full p-0">
|
||||
<Command>
|
||||
<CommandInput class="h-9" :placeholder="searchPlaceholder || 'Cari...'" />
|
||||
<CommandEmpty>{{ emptyMessage || 'Item tidak ditemukan.' }}</CommandEmpty>
|
||||
<CommandList>
|
||||
<CommandGroup>
|
||||
<CommandItem v-for="item in searchableItems" :key="item.value" :value="item.searchValue" @select="onSelect(item)">
|
||||
<div class="flex items-center justify-between w-full">
|
||||
<span>{{ item.label }}</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<span v-if="item.code" class="text-xs text-muted-foreground">{{ item.code }}</span>
|
||||
<Icon
|
||||
name="i-lucide-check"
|
||||
:class="cn(
|
||||
'h-4 w-4',
|
||||
modelValue === item.value ? 'opacity-100' : 'opacity-0',
|
||||
)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</template>
|
||||
Reference in New Issue
Block a user