Advance Select
advance-selectuiSearchable, async-capable select with keyboard navigation, multi-select, and option grouping. Drop in when the native `<select>` or the basic Select primitive runs out of room — large lists, debounced server-side filtering, custom rendered items.
When to use which
Decision GuideSelect is the headless primitive for flat options. Advance Select is the searchable combobox. Cascade and Tree selectors are designed specifically for hierarchical and nested datasets.
| Component | Role | Use when |
|---|---|---|
| Select Short, flat option lists (< 20 items) where typing to filter is unnecessary. | Primitive Dropdown | |
| AdvanceSelectcurrent Large datasets requiring fuzzy search, multi-select tag pills, or remote option filtering. | Searchable Combobox | |
| CascadeSelect Sequential multi-level drilldowns (e.g. Country > State > City, Category > Subcategory). | Hierarchical Picker | |
| TreeSelect Deeply nested taxonomy structures, folder trees, or parent-child permission nodes. | Tree Picker |
Installation
$pnpm dlx shadcn-vue@latest add https://uipkge.dev/r/vue/advance-select.json$npx shadcn-vue@latest add https://uipkge.dev/r/vue/advance-select.json$yarn dlx shadcn-vue@latest add https://uipkge.dev/r/vue/advance-select.json$bunx shadcn-vue@latest add https://uipkge.dev/r/vue/advance-select.jsonnpx shadcn-vue@latest add @uipkge/advance-selectInstalls to:app/components/ui/advance-select/Examples
Props
| Name | Type / Values | Default | Required |
|---|---|---|---|
modelValue | unknown | unknown[] | — | optional |
options | T[] | — | required |
modeMode | 'single''multiple''tags' | 'single' | optional |
fieldNamesField mapping | AdvanceSelectFieldNames | () => ({}), size: 'default', variant: 'outlined', status:… | optional |
sizeAppearance | 'sm''default''lg' | — | optional |
variant | 'outlined''filled''borderless' | — | optional |
status | 'default''error''warning' | — | optional |
placeholder | string | — | optional |
showSearchSearch | boolean | — | optional |
searchValue | string | — | optional |
autoClearSearchValue | boolean | — | optional |
filterOption | boolean | ((input: string, option: T) => boolean) | — | optional |
optionFilterProp | string | string[] | — | optional |
filterSort | (optionA: T, optionB: T, info: { searchValue: string }) => number | — | optional |
maxCountMultiple/Tags | number | — | optional |
maxTagCount | number | — | optional |
maxTagTextLength | number | — | optional |
maxTagPlaceholder | string | ((omittedValues: T[]) => string) | — | optional |
tokenSeparators | string[] | — | optional |
hideSelected | boolean | — | optional |
allowCreate | boolean | — | optional |
disabledState | boolean | — | optional |
loading | boolean | — | optional |
allowClear | boolean | — | optional |
open | boolean | — | optional |
defaultOpen | boolean | — | optional |
defaultActiveFirstOption | boolean | — | optional |
notFoundContentCustomization | string | — | optional |
loadingText | string | — | optional |
listHeight | number | — | optional |
virtual | boolean | — | optional |
class | HTMLAttributes['class'] | — | optional |
Schema
Type aliases from this item's source — use them to shape the data you pass in.
AdvanceSelectFieldNamesinterface AdvanceSelectFieldNames {
label?: string
value?: string
group?: string
disabled?: string
}npm dependencies
Files installed (4)
app/components/ui/advance-select/AdvanceSelect.vue18.9 kB
<script setup lang="ts" generic="T extends Record<string, unknown> | string | number"> import type { HTMLAttributes } from 'vue' import { computed, ref, watch } from 'vue' import { Check, ChevronDown, Loader2, X } from 'lucide-vue-next' import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, CommandSeparator, } from '@/components/ui/command' import CommandSearchSync from './CommandSearchSync.vue' import { Badge } from '@/components/ui/badge' import { readKey } from '@/components/ui/select' import { cn } from '@/lib/utils' import type { AdvanceSelectFieldNames } from './types' interface Props { modelValue?: unknown | unknown[] options: T[] // Mode mode?: 'single' | 'multiple' | 'tags' // Field mapping fieldNames?: AdvanceSelectFieldNames // Appearance size?: 'sm' | 'default' | 'lg' variant?: 'outlined' | 'filled' | 'borderless' status?: 'default' | 'error' | 'warning' placeholder?: string // Search showSearch?: boolean searchValue?: string autoClearSearchValue?: boolean filterOption?: boolean | ((input: string, option: T) => boolean) optionFilterProp?: string | string[] filterSort?: (optionA: T, optionB: T, info: { searchValue: string }) => number // Multiple/Tags maxCount?: number maxTagCount?: number maxTagTextLength?: number maxTagPlaceholder?: string | ((omittedValues: T[]) => string) tokenSeparators?: string[] hideSelected?: boolean allowCreate?: boolean // State disabled?: boolean loading?: boolean allowClear?: boolean open?: boolean defaultOpen?: boolean defaultActiveFirstOption?: boolean // Customization notFoundContent?: string loadingText?: string listHeight?: number virtual?: boolean class?: HTMLAttributes['class'] } const props = withDefaults(defineProps<Props>(), { mode: 'single', fieldNames: () => ({}), size: 'default', variant: 'outlined', status: 'default', placeholder: 'Select...', showSearch: false, autoClearSearchValue: true, filterOption: true, optionFilterProp: 'label', maxTagCount: undefined, maxTagTextLength: undefined, tokenSeparators: () => [], hideSelected: false, allowCreate: false, disabled: false, loading: false, allowClear: true, defaultOpen: false, defaultActiveFirstOption: true, notFoundContent: 'No results.', loadingText: 'Loading...', listHeight: 300, virtual: true, }) const emits = defineEmits<{ 'update:modelValue': [value: unknown | unknown[]] 'update:open': [open: boolean] 'update:searchValue': [value: string] change: [value: unknown | unknown[], option: T | T[]] select: [value: unknown, option: T] deselect: [value: unknown, option: T] search: [value: string] clear: [] openChange: [open: boolean] focus: [event: FocusEvent] blur: [event: FocusEvent] popupScroll: [event: Event] inputKeyDown: [event: KeyboardEvent] }>() const isOpen = ref(props.defaultOpen) watch( () => props.open, (v) => { if (v !== undefined) isOpen.value = v }, ) watch(isOpen, (v) => { emits('update:open', v) emits('openChange', v) }) const internalQuery = ref('') const query = computed({ get: () => props.searchValue ?? internalQuery.value, set: (v) => { internalQuery.value = v emits('update:searchValue', v) emits('search', v) }, }) const inputRef = ref<HTMLInputElement>() const valueKey = computed(() => props.fieldNames.value ?? 'value') const labelKey = computed(() => props.fieldNames.label ?? 'label') const groupKey = computed(() => props.fieldNames.group ?? 'group') const disabledKey = computed(() => props.fieldNames.disabled ?? 'disabled') function getValue(o: T): unknown { return readKey(o, valueKey.value, o) } function getLabel(o: T): string { return String(readKey(o, labelKey.value, '')) } function getGroup(o: T): string | undefined { const g = readKey(o, groupKey.value) return g == null ? undefined : String(g) } function isDisabled(o: T): boolean { return Boolean(readKey(o, disabledKey.value, false)) } const isMultiple = computed(() => props.mode === 'multiple' || props.mode === 'tags') const selectedValues = computed(() => { if (props.modelValue == null) return [] if (isMultiple.value) { return Array.isArray(props.modelValue) ? props.modelValue : [] } return [props.modelValue] }) const selectedSet = computed(() => new Set(selectedValues.value)) const selectedOptions = computed(() => { return selectedValues.value.map((v) => { const found = props.options.find((o) => getValue(o) === v) if (found) return found // For created tags not in options, create a minimal option object return { [labelKey.value]: String(v), [valueKey.value]: v } as T }) }) function getOptionByValue(v: unknown): T | undefined { return props.options.find((o) => getValue(o) === v) } function matchesFilter(o: T, q: string): boolean { if (typeof props.filterOption === 'function') { return props.filterOption(q, o) } if (props.filterOption === false) return true const label = getLabel(o).toLowerCase() const search = q.toLowerCase() const propsToSearch = Array.isArray(props.optionFilterProp) ? props.optionFilterProp : [props.optionFilterProp] for (const prop of propsToSearch) { if (prop === 'label' && label.includes(search)) return true const val = String(readKey(o, prop, '')).toLowerCase() if (val.includes(search)) return true } return false } const filteredOptions = computed(() => { let result = props.options const q = query.value.trim() if (q) { result = result.filter((o) => matchesFilter(o, q)) } if (props.hideSelected && isMultiple.value) { result = result.filter((o) => !selectedSet.value.has(getValue(o))) } if (q && props.filterSort) { result = [...result].sort((a, b) => props.filterSort!(a, b, { searchValue: q })) } return result }) const grouped = computed(() => { const groups = new Map<string, T[]>() for (const opt of filteredOptions.value) { const key = getGroup(opt) ?? '' if (!groups.has(key)) groups.set(key, []) groups.get(key)!.push(opt) } return Array.from(groups, ([heading, items]) => ({ heading, items })) }) const atMax = computed(() => { if (typeof props.maxCount !== 'number') return false const count = Array.isArray(props.modelValue) ? props.modelValue.length : props.modelValue ? 1 : 0 return count >= props.maxCount }) const visibleTags = computed(() => { if (!isMultiple.value) return [] const opts = selectedOptions.value if (typeof props.maxTagCount === 'number') { return opts.slice(0, props.maxTagCount) } return opts }) const hiddenTagCount = computed(() => { if (!isMultiple.value) return 0 const opts = selectedOptions.value if (typeof props.maxTagCount === 'number') { return Math.max(0, opts.length - props.maxTagCount) } return 0 }) function displayLabel(o: T): string { let label = getLabel(o) if (props.maxTagTextLength && label.length > props.maxTagTextLength) { label = label.slice(0, props.maxTagTextLength) + '...' } return label } function selectOption(option: T) { if (isDisabled(option)) return const v = getValue(option) if (!isMultiple.value) { emits('update:modelValue', v) emits('change', v, option) emits('select', v, option) isOpen.value = false if (props.autoClearSearchValue) { query.value = '' } return } const current = Array.isArray(props.modelValue) ? [...props.modelValue] : [] if (selectedSet.value.has(v)) { const next = current.filter((x) => x !== v) emits('update:modelValue', next) emits('change', next, option) emits('deselect', v, option) } else { if (atMax.value) return const next = [...current, v] emits('update:modelValue', next) emits('change', next, option) emits('select', v, option) } if (props.autoClearSearchValue) { query.value = '' } } function removeTag(value: unknown, event: Event) { event.stopPropagation() if (props.disabled) return const current = Array.isArray(props.modelValue) ? [...props.modelValue] : [] const next = current.filter((x) => x !== value) const option = getOptionByValue(value) emits('update:modelValue', next) emits('change', next, option as T) if (option) emits('deselect', value, option) } function clearAll(event?: Event) { event?.stopPropagation() if (props.disabled) return emits('clear') if (isMultiple.value) { emits('update:modelValue', []) emits('change', [], []) } else { emits('update:modelValue', null) emits('change', null, undefined as unknown as T) } query.value = '' } function createTag() { if (!props.allowCreate && props.mode !== 'tags') return const q = query.value.trim() if (!q) return // Check if already exists const exists = props.options.some((o) => getLabel(o) === q || String(getValue(o)) === q) if (exists) return if (!isMultiple.value) { emits('update:modelValue', q) emits('change', q, { [labelKey.value]: q, [valueKey.value]: q } as T) emits('select', q, { [labelKey.value]: q, [valueKey.value]: q } as T) isOpen.value = false query.value = '' return } if (atMax.value) return const current = Array.isArray(props.modelValue) ? [...props.modelValue] : [] const next = [...current, q] const newOption = { [labelKey.value]: q, [valueKey.value]: q } as T emits('update:modelValue', next) emits('change', next, newOption) emits('select', q, newOption) query.value = '' } function handleInputKeydown(event: KeyboardEvent) { emits('inputKeyDown', event) if (event.key === 'Enter' && query.value.trim() && props.mode === 'tags') { event.preventDefault() createTag() } if (props.tokenSeparators.length && props.mode === 'tags') { if (props.tokenSeparators.includes(event.key)) { event.preventDefault() createTag() } } } function handleFocus(event: FocusEvent) { emits('focus', event) } function handleBlur(event: FocusEvent) { emits('blur', event) } function handlePopupScroll(event: Event) { emits('popupScroll', event) } // Watch modelValue to clear query when closed watch( () => props.modelValue, () => { if (!isOpen.value && props.autoClearSearchValue) { query.value = '' } }, ) watch( () => isOpen.value, (open) => { if (!open && props.autoClearSearchValue) { query.value = '' } }, ) const sizeClasses = { sm: 'h-8 text-xs px-2.5 py-1', default: 'h-9 text-sm px-3 py-1.5', lg: 'h-11 text-base px-4 py-2', } const variantClasses = { outlined: 'border-input bg-transparent shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]', filled: 'border-transparent bg-muted/50 shadow-none focus-visible:bg-muted focus-visible:ring-ring/50 focus-visible:ring-[3px]', borderless: 'border-transparent bg-transparent shadow-none focus-visible:bg-muted/30 focus-visible:ring-ring/50 focus-visible:ring-[3px]', } const statusClasses = { default: '', error: 'border-destructive focus-visible:border-destructive focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 aria-invalid:border-destructive', warning: 'border-warning focus-visible:border-warning focus-visible:ring-warning/20', } const triggerBaseClasses = computed(() => cn( 'flex w-full items-center justify-between gap-2 rounded-md border text-sm transition-[color,box-shadow] outline-none disabled:cursor-not-allowed disabled:opacity-50', sizeClasses[props.size], variantClasses[props.variant], statusClasses[props.status], props.class, ), ) const isEmpty = computed(() => { if (isMultiple.value) { return !Array.isArray(props.modelValue) || props.modelValue.length === 0 } return props.modelValue == null || props.modelValue === '' }) const showClear = computed(() => { return props.allowClear && !isEmpty.value && !props.disabled && !props.loading }) const showSearchInput = computed(() => { return props.showSearch || props.mode === 'tags' }) </script> <template> <Popover v-model:open="isOpen"> <PopoverTrigger as-child> <button type="button" role="combobox" :aria-expanded="isOpen" :aria-invalid="status === 'error' ? true : undefined" :disabled="disabled || loading" data-uipkge data-slot="advance-select" :class="triggerBaseClasses" @focus="handleFocus" @blur="handleBlur" > <!-- Prefix slot --> <span v-if="$slots.prefix" class="shrink-0"> <slot name="prefix" /> </span> <!-- Multiple mode tags --> <div v-if="isMultiple" class="flex flex-1 flex-nowrap items-center gap-1 overflow-x-auto" style="scrollbar-width: none; -ms-overflow-style: none" > <template v-if="selectedOptions.length"> <slot name="tag" v-for="opt in visibleTags" :key="String(getValue(opt))" :value="getValue(opt)" :label="displayLabel(opt)" :closable="!disabled" :on-close="(e: Event) => removeTag(getValue(opt), e)" > <Badge variant="secondary" class="bg-muted text-foreground h-6 gap-1 pr-1 pl-2 text-xs font-normal"> <span class="truncate">{{ displayLabel(opt) }}</span> <span v-if="!disabled" role="button" tabindex="0" class="hover:bg-muted-foreground/20 inline-flex cursor-pointer items-center justify-center rounded-full p-0.5 transition-colors" :aria-label="`Remove ${getLabel(opt)}`" @click="removeTag(getValue(opt), $event)" @keydown.enter.prevent="removeTag(getValue(opt), $event)" @keydown.space.prevent="removeTag(getValue(opt), $event)" > <X class="size-3" /> </span> </Badge> </slot> <Badge v-if="hiddenTagCount > 0" variant="secondary" class="bg-muted text-foreground h-6 text-xs font-normal" > <template v-if="typeof maxTagPlaceholder === 'function'"> {{ maxTagPlaceholder(selectedOptions.slice(maxTagCount ?? 0)) }} </template> <template v-else-if="maxTagPlaceholder"> {{ maxTagPlaceholder }} </template> <template v-else> +{{ hiddenTagCount }} </template> </Badge> </template> <span v-else class="text-muted-foreground truncate">{{ placeholder }}</span> </div> <!-- Single mode display --> <template v-else> <slot name="label" :value="props.modelValue" :label="selectedOptions[0] ? getLabel(selectedOptions[0]) : ''"> <span :class="[ 'flex-1 truncate text-left', selectedOptions.length ? 'text-foreground' : 'text-muted-foreground', ]" > {{ selectedOptions[0] ? getLabel(selectedOptions[0]) : placeholder }} </span> </slot> </template> <!-- Suffix area --> <span class="flex shrink-0 items-center gap-1"> <slot name="suffix" /> <Loader2 v-if="loading" class="text-muted-foreground size-4 animate-spin" /> <span v-else-if="showClear" role="button" tabindex="0" class="text-muted-foreground hover:text-foreground inline-flex cursor-pointer items-center rounded transition-colors" aria-label="Clear selection" @click="clearAll" @keydown.enter.prevent="clearAll" @keydown.space.prevent="clearAll" > <slot name="clearIcon"> <X class="size-4" aria-hidden="true" /> </slot> </span> <slot v-else name="suffixIcon"> <ChevronDown class="text-muted-foreground size-4 opacity-50" /> </slot> </span> </button> </PopoverTrigger> <PopoverContent class="p-0" align="start" :side-offset="4" :style="{ width: 'var(--reka-popover-trigger-width)', maxHeight: `${listHeight}px` }" @scroll="handlePopupScroll" > <Command :should-filter="false" class="flex flex-col overflow-hidden"> <CommandSearchSync @update:search="query = $event" /> <CommandInput v-if="showSearchInput" v-model="query" :placeholder="placeholder" @keydown="handleInputKeydown" /> <CommandList class="flex-1 overflow-y-auto"> <CommandEmpty v-if="!loading && filteredOptions.length === 0"> <slot name="empty"> {{ notFoundContent }} </slot> </CommandEmpty> <div v-if="loading && filteredOptions.length === 0" class="py-6 text-center text-sm"> {{ loadingText }} </div> <template v-for="(group, gi) in grouped" :key="group.heading || gi"> <CommandSeparator v-if="gi > 0" /> <CommandGroup :heading="group.heading || undefined"> <CommandItem v-for="(opt, idx) in group.items" :key="String(getValue(opt))" :value="String(getValue(opt))" :disabled="isDisabled(opt) || (atMax && !selectedSet.has(getValue(opt)))" :data-active="gi === 0 && idx === 0 && defaultActiveFirstOption ? 'true' : undefined" :style="virtual && props.options.length > 100 ? { contentVisibility: 'auto' } : undefined" @select="selectOption(opt)" > <Check :class="cn('mr-2 size-4 shrink-0', selectedSet.has(getValue(opt)) ? 'opacity-100' : 'opacity-0')" /> <slot name="option" :option="opt" :index="idx"> {{ getLabel(opt) }} </slot> </CommandItem> </CommandGroup> </template> <!-- Create new option in tags / allowCreate modes --> <CommandItem v-if=" query.trim() && (allowCreate || mode === 'tags') && !props.options.some((o) => getLabel(o) === query.trim()) " :value="`create:${query}`" @select="createTag" > <Check class="mr-2 size-4 opacity-0" /> Create "{{ query.trim() }}" </CommandItem> </CommandList> <!-- Footer for multiple mode --> <div v-if="isMultiple && selectedOptions.length" class="flex items-center justify-between border-t px-2 py-1.5 text-xs" > <span class="text-muted-foreground">{{ selectedOptions.length }} selected</span> <button type="button" class="text-muted-foreground hover:text-foreground" @click="clearAll">Clear all</button> </div> </Command> </PopoverContent> </Popover> </template> <style scoped> [data-slot='advance-select'] .overflow-x-auto::-webkit-scrollbar { display: none; } </style>app/components/ui/advance-select/CommandSearchSync.vue0.3 kB
<script setup lang="ts"> import { watch } from 'vue' import { useCommand } from '@/components/ui/command' const emits = defineEmits<{ 'update:search': [value: string] }>() const { filterState } = useCommand() watch( () => filterState.search, (v) => { emits('update:search', v) }, ) </script> <template> <slot /> </template>app/components/ui/advance-select/types.ts0.2 kB
export type { SelectOption } from '@/components/ui/select' export interface AdvanceSelectFieldNames { label?: string value?: string group?: string disabled?: string }app/components/ui/advance-select/index.ts0.1 kB
export { default as AdvanceSelect } from './AdvanceSelect.vue' export type { AdvanceSelectFieldNames, SelectOption } from './types'
Raw manifest:https://uipkge.dev/r/vue/advance-select.json