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@latest add https://uipkge.dev/r/react/advance-select.json$npx shadcn@latest add https://uipkge.dev/r/react/advance-select.json$yarn dlx shadcn@latest add https://uipkge.dev/r/react/advance-select.json$bunx shadcn@latest add https://uipkge.dev/r/react/advance-select.jsonnpx shadcn@latest add @uipkge-react/advance-selectInstalls to:components/ui/advance-select/Examples
Props
| Name | Type / Values | Default | Required |
|---|---|---|---|
value | unknown | unknown[] | — | optional |
onValueChange | (value: unknown | unknown[], option: T | T[]) => void | — | optional |
options | T[] | — | required |
modeMode | 'single' | 'multiple' | 'tags' | — | optional |
fieldNamesField mapping | AdvanceSelectFieldNames | — | 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 |
onSearchChange | (value: string) => void | — | 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 |
onOpenChange | (open: boolean) => void | — | optional |
defaultOpen | boolean | — | optional |
defaultActiveFirstOption | boolean | — | optional |
notFoundContentCustomization | React.ReactNode | — | optional |
loadingText | React.ReactNode | — | optional |
listHeight | number | — | optional |
virtual | boolean | — | optional |
className | string | — | optional |
prefixRender slots | React.ReactNode | — | optional |
suffix | React.ReactNode | — | optional |
suffixIcon | React.ReactNode | — | optional |
clearIcon | React.ReactNode | — | optional |
renderLabel | (info: { value: unknown; label: string }) => React.ReactNode | — | optional |
renderTag | (info: { | — | optional |
label | string | — | required |
closable | boolean | — | required |
onClose | (e: React.MouseEvent | Event) => void | — | required |
renderOption | (info: { option: T; index: number }) => React.ReactNode | — | optional |
emptyContent | React.ReactNode | — | optional |
onSelectEvents | (value: unknown, option: T) => void | — | optional |
onDeselect | (value: unknown, option: T) => void | — | optional |
onClear | () => void | — | optional |
onFocus | (event: React.FocusEvent) => void | — | optional |
onBlur | (event: React.FocusEvent) => void | — | optional |
onPopupScroll | (event: React.UIEvent) => void | — | optional |
onInputKeyDown | (event: React.KeyboardEvent) => void | — | 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 (3)
components/ui/advance-select/advance-select.tsx20.3 kB
'use client' import * as React from 'react' import { Check, ChevronDown, Loader2, X } from 'lucide-react' import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, CommandSeparator, } from '@/components/ui/command' import { Badge } from '@/components/ui/badge' import { cn } from '@/lib/utils' import { readKey } from './types' import type { AdvanceSelectFieldNames } from './types' export interface AdvanceSelectProps<T extends Record<string, unknown> | string | number> { value?: unknown | unknown[] onValueChange?: (value: unknown | unknown[], option: T | T[]) => void 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 onSearchChange?: (value: string) => void 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 onOpenChange?: (open: boolean) => void defaultOpen?: boolean defaultActiveFirstOption?: boolean // Customization notFoundContent?: React.ReactNode loadingText?: React.ReactNode listHeight?: number virtual?: boolean className?: string // Render slots prefix?: React.ReactNode suffix?: React.ReactNode suffixIcon?: React.ReactNode clearIcon?: React.ReactNode renderLabel?: (info: { value: unknown; label: string }) => React.ReactNode renderTag?: (info: { value: unknown label: string closable: boolean onClose: (e: React.MouseEvent | Event) => void }) => React.ReactNode renderOption?: (info: { option: T; index: number }) => React.ReactNode emptyContent?: React.ReactNode // Events onSelect?: (value: unknown, option: T) => void onDeselect?: (value: unknown, option: T) => void onClear?: () => void onFocus?: (event: React.FocusEvent) => void onBlur?: (event: React.FocusEvent) => void onPopupScroll?: (event: React.UIEvent) => void onInputKeyDown?: (event: React.KeyboardEvent) => void } 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', } function AdvanceSelect<T extends Record<string, unknown> | string | number>({ value: modelValue, onValueChange, options, mode = 'single', fieldNames = {}, size = 'default', variant = 'outlined', status = 'default', placeholder = 'Select...', showSearch = false, searchValue, onSearchChange, autoClearSearchValue = true, filterOption = true, optionFilterProp = 'label', filterSort, maxCount, maxTagCount, maxTagTextLength, maxTagPlaceholder, tokenSeparators = [], hideSelected = false, allowCreate = false, disabled = false, loading = false, allowClear = true, open, onOpenChange, defaultOpen = false, defaultActiveFirstOption = true, notFoundContent = 'No results.', loadingText = 'Loading...', listHeight = 300, virtual = true, className, prefix, suffix, suffixIcon, clearIcon, renderLabel, renderTag, renderOption, emptyContent, onSelect, onDeselect, onClear, onFocus, onBlur, onPopupScroll, onInputKeyDown, }: AdvanceSelectProps<T>) { const isOpenControlled = open !== undefined const [internalOpen, setInternalOpen] = React.useState<boolean>(defaultOpen) const isOpen = isOpenControlled ? open : internalOpen function setOpen(v: boolean) { if (!isOpenControlled) setInternalOpen(v) onOpenChange?.(v) } const isQueryControlled = searchValue !== undefined const [internalQuery, setInternalQuery] = React.useState('') const query = isQueryControlled ? (searchValue as string) : internalQuery function setQuery(v: string) { if (!isQueryControlled) setInternalQuery(v) onSearchChange?.(v) } const valueKey = fieldNames.value ?? 'value' const labelKey = fieldNames.label ?? 'label' const groupKey = fieldNames.group ?? 'group' const disabledKey = fieldNames.disabled ?? 'disabled' const getValue = React.useCallback((o: T): unknown => readKey(o, valueKey, o), [valueKey]) const getLabel = React.useCallback((o: T): string => String(readKey(o, labelKey, '')), [labelKey]) const getGroup = React.useCallback( (o: T): string | undefined => { const g = readKey(o, groupKey) return g == null ? undefined : String(g) }, [groupKey], ) const isDisabledOption = React.useCallback((o: T): boolean => Boolean(readKey(o, disabledKey, false)), [disabledKey]) const isMultiple = mode === 'multiple' || mode === 'tags' const selectedValues = React.useMemo<unknown[]>(() => { if (modelValue == null) return [] if (isMultiple) { return Array.isArray(modelValue) ? modelValue : [] } return [modelValue] }, [modelValue, isMultiple]) const selectedSet = React.useMemo(() => new Set(selectedValues), [selectedValues]) const selectedOptions = React.useMemo<T[]>(() => { return selectedValues.map((v) => { const found = options.find((o) => getValue(o) === v) if (found) return found // For created tags not in options, create a minimal option object return { [labelKey]: String(v), [valueKey]: v } as T }) }, [selectedValues, options, getValue, labelKey, valueKey]) function getOptionByValue(v: unknown): T | undefined { return options.find((o) => getValue(o) === v) } function matchesFilter(o: T, q: string): boolean { if (typeof filterOption === 'function') { return filterOption(q, o) } if (filterOption === false) return true const label = getLabel(o).toLowerCase() const search = q.toLowerCase() const propsToSearch = Array.isArray(optionFilterProp) ? optionFilterProp : [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 = React.useMemo<T[]>(() => { let result = options const q = query.trim() if (q) { result = result.filter((o) => matchesFilter(o, q)) } if (hideSelected && isMultiple) { result = result.filter((o) => !selectedSet.has(getValue(o))) } if (q && filterSort) { result = [...result].sort((a, b) => filterSort(a, b, { searchValue: q })) } return result // eslint-disable-next-line react-hooks/exhaustive-deps }, [options, query, hideSelected, isMultiple, selectedSet, getValue, filterSort, filterOption, optionFilterProp]) const grouped = React.useMemo(() => { const groups = new Map<string, T[]>() for (const opt of filteredOptions) { const key = getGroup(opt) ?? '' if (!groups.has(key)) groups.set(key, []) groups.get(key)!.push(opt) } return Array.from(groups, ([heading, items]) => ({ heading, items })) }, [filteredOptions, getGroup]) const atMax = React.useMemo(() => { if (typeof maxCount !== 'number') return false const count = Array.isArray(modelValue) ? modelValue.length : modelValue ? 1 : 0 return count >= maxCount }, [maxCount, modelValue]) const visibleTags = React.useMemo<T[]>(() => { if (!isMultiple) return [] const opts = selectedOptions if (typeof maxTagCount === 'number') { return opts.slice(0, maxTagCount) } return opts }, [isMultiple, selectedOptions, maxTagCount]) const hiddenTagCount = React.useMemo(() => { if (!isMultiple) return 0 const opts = selectedOptions if (typeof maxTagCount === 'number') { return Math.max(0, opts.length - maxTagCount) } return 0 }, [isMultiple, selectedOptions, maxTagCount]) function displayLabel(o: T): string { let label = getLabel(o) if (maxTagTextLength && label.length > maxTagTextLength) { label = label.slice(0, maxTagTextLength) + '...' } return label } function selectOption(option: T) { if (isDisabledOption(option)) return const v = getValue(option) if (!isMultiple) { onValueChange?.(v, option) onSelect?.(v, option) setOpen(false) if (autoClearSearchValue) setQuery('') return } const current = Array.isArray(modelValue) ? [...modelValue] : [] if (selectedSet.has(v)) { const next = current.filter((x) => x !== v) onValueChange?.(next, option) onDeselect?.(v, option) } else { if (atMax) return const next = [...current, v] onValueChange?.(next, option) onSelect?.(v, option) } if (autoClearSearchValue) setQuery('') } function removeTag(value: unknown, event: React.SyntheticEvent | Event) { event.stopPropagation() if (disabled) return const current = Array.isArray(modelValue) ? [...modelValue] : [] const next = current.filter((x) => x !== value) const option = getOptionByValue(value) onValueChange?.(next, option as T) if (option) onDeselect?.(value, option) } function clearAll(event?: React.SyntheticEvent | Event) { event?.stopPropagation() if (disabled) return onClear?.() if (isMultiple) { onValueChange?.([], []) } else { onValueChange?.(null, undefined as unknown as T) } setQuery('') } function createTag() { if (!allowCreate && mode !== 'tags') return const q = query.trim() if (!q) return const exists = options.some((o) => getLabel(o) === q || String(getValue(o)) === q) if (exists) return if (!isMultiple) { const newOption = { [labelKey]: q, [valueKey]: q } as T onValueChange?.(q, newOption) onSelect?.(q, newOption) setOpen(false) setQuery('') return } if (atMax) return const current = Array.isArray(modelValue) ? [...modelValue] : [] const next = [...current, q] const newOption = { [labelKey]: q, [valueKey]: q } as T onValueChange?.(next, newOption) onSelect?.(q, newOption) setQuery('') } function handleInputKeydown(event: React.KeyboardEvent) { onInputKeyDown?.(event) if (event.key === 'Enter' && query.trim() && mode === 'tags') { event.preventDefault() createTag() } if (tokenSeparators.length && mode === 'tags') { if (tokenSeparators.includes(event.key)) { event.preventDefault() createTag() } } } // Clear query when the popover closes. React.useEffect(() => { if (!isOpen && autoClearSearchValue) setQuery('') // eslint-disable-next-line react-hooks/exhaustive-deps }, [isOpen]) const triggerBaseClasses = 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[size], variantClasses[variant], statusClasses[status], className, ) const isEmpty = isMultiple ? !Array.isArray(modelValue) || modelValue.length === 0 : modelValue == null || modelValue === '' const showClear = allowClear && !isEmpty && !disabled && !loading const showSearchInput = showSearch || mode === 'tags' return ( <Popover open={isOpen} onOpenChange={setOpen}> <PopoverTrigger asChild> <button type="button" role="combobox" aria-expanded={isOpen} aria-invalid={status === 'error' ? true : undefined} disabled={disabled || loading} data-uipkge="" data-slot="advance-select" className={triggerBaseClasses} onFocus={onFocus} onBlur={onBlur} > {/* Prefix */} {prefix && <span className="shrink-0">{prefix}</span>} {/* Multiple mode tags */} {isMultiple ? ( <div className="flex flex-1 flex-nowrap items-center gap-1 overflow-x-auto" style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' } as React.CSSProperties} > {selectedOptions.length ? ( <> {visibleTags.map((opt) => renderTag ? ( <React.Fragment key={String(getValue(opt))}> {renderTag({ value: getValue(opt), label: displayLabel(opt), closable: !disabled, onClose: (e) => removeTag(getValue(opt), e), })} </React.Fragment> ) : ( <Badge key={String(getValue(opt))} variant="secondary" className="bg-muted text-foreground h-6 gap-1 pr-1 pl-2 text-xs font-normal" > <span className="truncate">{displayLabel(opt)}</span> {!disabled && ( <span role="button" tabIndex={0} className="hover:bg-muted-foreground/20 inline-flex items-center justify-center rounded-full p-0.5 transition-colors" aria-label={`Remove ${getLabel(opt)}`} onClick={(e) => removeTag(getValue(opt), e)} onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault() removeTag(getValue(opt), e) } }} > <X className="size-3" /> </span> )} </Badge> ), )} {hiddenTagCount > 0 && ( <Badge variant="secondary" className="bg-muted text-foreground h-6 text-xs font-normal"> {typeof maxTagPlaceholder === 'function' ? maxTagPlaceholder(selectedOptions.slice(maxTagCount ?? 0)) : maxTagPlaceholder ? maxTagPlaceholder : `+${hiddenTagCount}`} </Badge> )} </> ) : ( <span className="text-muted-foreground truncate">{placeholder}</span> )} </div> ) : /* Single mode display */ renderLabel ? ( renderLabel({ value: modelValue, label: selectedOptions[0] ? getLabel(selectedOptions[0]) : '', }) ) : ( <span className={cn( 'flex-1 truncate text-left', selectedOptions.length ? 'text-foreground' : 'text-muted-foreground', )} > {selectedOptions[0] ? getLabel(selectedOptions[0]) : placeholder} </span> )} {/* Suffix area */} <span className="flex shrink-0 items-center gap-1"> {suffix} {loading ? ( <Loader2 className="text-muted-foreground size-4 animate-spin" /> ) : showClear ? ( <span role="button" tabIndex={0} className="text-muted-foreground hover:text-foreground inline-flex cursor-pointer items-center rounded transition-colors" aria-label="Clear selection" onClick={clearAll} onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault() clearAll(e) } }} > {clearIcon ?? <X className="size-4" aria-hidden="true" />} </span> ) : ( (suffixIcon ?? <ChevronDown className="text-muted-foreground size-4 opacity-50" />) )} </span> </button> </PopoverTrigger> <PopoverContent className="p-0" align="start" sideOffset={4} style={{ width: 'var(--radix-popover-trigger-width)', maxHeight: `${listHeight}px` }} onScroll={onPopupScroll} > <Command shouldFilter={false} className="flex flex-col overflow-hidden"> {showSearchInput && ( <CommandInput value={query} onValueChange={setQuery} placeholder={placeholder} onKeyDown={handleInputKeydown} /> )} <CommandList className="flex-1 overflow-y-auto"> {!loading && filteredOptions.length === 0 && <CommandEmpty>{emptyContent ?? notFoundContent}</CommandEmpty>} {loading && filteredOptions.length === 0 && <div className="py-6 text-center text-sm">{loadingText}</div>} {grouped.map((group, gi) => ( <React.Fragment key={group.heading || gi}> {gi > 0 && <CommandSeparator />} <CommandGroup heading={group.heading || undefined}> {group.items.map((opt, idx) => ( <CommandItem key={String(getValue(opt))} value={String(getValue(opt))} disabled={isDisabledOption(opt) || (atMax && !selectedSet.has(getValue(opt)))} data-active={gi === 0 && idx === 0 && defaultActiveFirstOption ? 'true' : undefined} style={ virtual && options.length > 100 ? ({ contentVisibility: 'auto' } as React.CSSProperties) : undefined } onSelect={() => selectOption(opt)} > <Check className={cn( 'mr-2 size-4 shrink-0', selectedSet.has(getValue(opt)) ? 'opacity-100' : 'opacity-0', )} /> {renderOption ? renderOption({ option: opt, index: idx }) : getLabel(opt)} </CommandItem> ))} </CommandGroup> </React.Fragment> ))} {/* Create new option in tags / allowCreate modes */} {query.trim() && (allowCreate || mode === 'tags') && !options.some((o) => getLabel(o) === query.trim()) && ( <CommandItem value={`create:${query}`} onSelect={createTag}> <Check className="mr-2 size-4 opacity-0" /> Create "{query.trim()}" </CommandItem> )} </CommandList> {/* Footer for multiple mode */} {isMultiple && selectedOptions.length > 0 && ( <div className="flex items-center justify-between border-t px-2 py-1.5 text-xs"> <span className="text-muted-foreground">{selectedOptions.length} selected</span> <button type="button" className="text-muted-foreground hover:text-foreground" onClick={clearAll}> Clear all </button> </div> )} </Command> </PopoverContent> </Popover> ) } AdvanceSelect.displayName = 'AdvanceSelect' export { AdvanceSelect }components/ui/advance-select/types.ts0.9 kB
/** * Shared option shape for `AdvanceSelect` and any future options-driven select. * Components accept this canonical shape OR any object shape if `fieldNames` * value/label keys are provided. */ export interface SelectOption<V = string> { label: string value: V disabled?: boolean /** Items sharing this key render under one heading. */ group?: string } export interface AdvanceSelectFieldNames { label?: string value?: string group?: string disabled?: string } /** * Resolve `option[key]` with a default fallback. Used by AdvanceSelect so every * accessor obeys the configured value/label keys. */ export function readKey<T>(option: T, key: string, fallback?: unknown): unknown { if (option == null || typeof option !== 'object') return fallback const v = (option as Record<string, unknown>)[key] return v === undefined ? fallback : v }components/ui/advance-select/index.ts0.2 kB
export { AdvanceSelect, type AdvanceSelectProps } from './advance-select' export { readKey, type AdvanceSelectFieldNames, type SelectOption } from './types'
Raw manifest:https://uipkge.dev/r/react/advance-select.json