Skip to content

Instantly share code, notes, and snippets.

@misbahansori
Last active June 24, 2026 03:01
Show Gist options
  • Select an option

  • Save misbahansori/f588790fca6b1da55809d95ba6f61959 to your computer and use it in GitHub Desktop.

Select an option

Save misbahansori/f588790fca6b1da55809d95ba6f61959 to your computer and use it in GitHub Desktop.
<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