Last active
June 24, 2026 03:01
-
-
Save misbahansori/f588790fca6b1da55809d95ba6f61959 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <script setup lang="ts" generic="T extends Record<string, unknown> = Record<string, unknown>"> | |
| import { Check, ChevronDown, Loader2, X } from "@lucide/vue"; | |
| import type { HTMLAttributes } from "vue"; | |
| import { | |
| Combobox, | |
| ComboboxAnchor, | |
| ComboboxEmpty, | |
| ComboboxGroup, | |
| ComboboxInput, | |
| ComboboxItem, | |
| ComboboxItemIndicator, | |
| ComboboxList, | |
| ComboboxTrigger, | |
| ComboboxViewport, | |
| } from "~/components/ui/combobox"; | |
| import { cn } from "~/utils/cn"; | |
| const props = withDefaults( | |
| defineProps<{ | |
| options: T[]; | |
| optionKey: Extract<keyof T, string>; | |
| optionValue: Extract<keyof T, string>; | |
| class?: HTMLAttributes["class"]; | |
| placeholder?: string; | |
| searchPlaceholder?: string; | |
| emptyText?: string; | |
| loading?: boolean; | |
| disabled?: boolean; | |
| ariaInvalid?: boolean; | |
| }>(), | |
| { | |
| placeholder: "Select...", | |
| searchPlaceholder: "Search...", | |
| emptyText: "No results found.", | |
| loading: false, | |
| disabled: false, | |
| ariaInvalid: false, | |
| }, | |
| ); | |
| const model = defineModel<T | null>(); | |
| const search = defineModel<string>("search", { default: "" }); | |
| const open = ref(false); | |
| watch(open, (isOpen) => { | |
| if (!isOpen) { | |
| search.value = ""; | |
| } | |
| }); | |
| function displaySelected(option: T) { | |
| const value = option[props.optionValue]; | |
| return value == null ? "" : String(value); | |
| } | |
| function getOptionKey(option: T) { | |
| return String(option[props.optionKey]); | |
| } | |
| function getOptionTextValue(option: T) { | |
| return displaySelected(option); | |
| } | |
| function clearSelection() { | |
| model.value = null; | |
| } | |
| </script> | |
| <template> | |
| <Combobox v-model="model" v-model:open="open" :by="optionKey" ignore-filter :disabled="disabled"> | |
| <ComboboxAnchor :class="cn('w-full', props.class)"> | |
| <ComboboxTrigger | |
| type="button" | |
| :aria-invalid="ariaInvalid" | |
| :disabled="disabled" | |
| class="bg-input/50 focus-visible:border-ring focus-visible:ring-ring/30 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 flex h-9 w-full items-center justify-between gap-1.5 rounded-3xl border border-transparent px-3 py-2 text-sm whitespace-nowrap transition-[color,box-shadow,background-color] outline-none focus-visible:ring-3 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:ring-3" | |
| > | |
| <span v-if="model" class="min-w-0 truncate"> | |
| <slot name="trigger" :option="model"> | |
| {{ displaySelected(model) }} | |
| </slot> | |
| </span> | |
| <span v-else class="text-muted-foreground truncate">{{ placeholder }}</span> | |
| <span class="ml-auto flex shrink-0 items-center gap-0.5"> | |
| <button | |
| v-if="model && !disabled" | |
| type="button" | |
| tabindex="-1" | |
| aria-label="Clear selection" | |
| class="text-muted-foreground hover:text-foreground rounded-full p-0.5" | |
| @click.prevent="clearSelection" | |
| > | |
| <X class="size-3.5" /> | |
| </button> | |
| <span | |
| class="text-muted-foreground inline-flex size-4 items-center justify-center" | |
| aria-hidden="true" | |
| > | |
| <ChevronDown class="size-4" /> | |
| </span> | |
| </span> | |
| </ComboboxTrigger> | |
| </ComboboxAnchor> | |
| <ComboboxList align="start" :side-offset="4"> | |
| <ComboboxInput | |
| v-model="search" | |
| :placeholder="searchPlaceholder" | |
| autocomplete="off" | |
| class="text-sm" | |
| /> | |
| <ComboboxViewport> | |
| <div v-if="loading" class="flex justify-center py-6"> | |
| <Loader2 class="text-muted-foreground size-4 animate-spin" /> | |
| </div> | |
| <template v-else> | |
| <ComboboxEmpty>{{ emptyText }}</ComboboxEmpty> | |
| <ComboboxGroup> | |
| <ComboboxItem | |
| v-for="option in options" | |
| :key="getOptionKey(option)" | |
| :value="option" | |
| :text-value="getOptionTextValue(option)" | |
| > | |
| <slot name="option" :option="option"> | |
| {{ option[optionValue] }} | |
| </slot> | |
| <ComboboxItemIndicator> | |
| <Check class="size-4" /> | |
| </ComboboxItemIndicator> | |
| </ComboboxItem> | |
| </ComboboxGroup> | |
| </template> | |
| </ComboboxViewport> | |
| </ComboboxList> | |
| </Combobox> | |
| </template> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment