'use client' import * as PopoverPrimitive from '@radix-ui/react-popover' import * as React from 'react' import { CheckIcon, SearchIcon } from '../icons-v2-generated' import { Chevron02DownIcon } from '../icons-v2-generated/arrows/chevron-02-down-icon' import { cn } from '../../utils/cn' import { useKeyboardCollisionPadding } from '../../hooks/ui/use-keyboard-collision-padding' import { Input } from './input' export interface SearchableSelectOption { value: string label: string /** Optional leading visual for the option row (avatar, icon, badge). */ icon?: React.ReactNode } export interface SearchableSelectProps { options: SearchableSelectOption[] /** Currently selected option value; null/undefined when nothing is selected. */ value?: string | null onValueChange: (value: string) => void /** Default trigger text while nothing is selected. */ placeholder?: string /** Placeholder of the search input rendered as the first dropdown item. */ searchPlaceholder?: string emptyText?: string loadingText?: string isLoading?: boolean disabled?: boolean /** * Custom trigger node (rendered via Popover.Trigger asChild). When omitted, * a select-like button (Input/SelectTrigger styling, chevron, placeholder) * is rendered. */ trigger?: React.ReactNode /** Custom option-row renderer; default is icon + label + selected check. */ renderOption?: (option: SearchableSelectOption, isSelected: boolean) => React.ReactNode align?: 'start' | 'center' | 'end' /** Classes for the default trigger button. */ className?: string /** Classes for the popover content (e.g. a fixed width for icon triggers). */ contentClassName?: string } /** * Select-style dropdown whose first item is a search field filtering the * option list — the pattern used by the ticket assignee picker ("Search * users...") generalized for any entity (devices, users, ...). Provide a * custom `trigger` for icon-button use cases; the default trigger looks and * behaves like `SelectTrigger`. */ export function SearchableSelect({ options, value = null, onValueChange, placeholder = 'Select...', searchPlaceholder = 'Search...', emptyText = 'No results found', loadingText = 'Loading…', isLoading = false, disabled = false, trigger, renderOption, align = 'start', className, contentClassName, }: SearchableSelectProps) { const [isOpen, setIsOpen] = React.useState(false) const [search, setSearch] = React.useState('') const keyboardPadding = useKeyboardCollisionPadding() React.useEffect(() => { if (!isOpen) setSearch('') }, [isOpen]) const filtered = React.useMemo(() => { const q = search.trim().toLowerCase() if (!q) return options return options.filter(o => o.label.toLowerCase().includes(q)) }, [options, search]) const selectedOption = React.useMemo( () => (value != null ? options.find(o => o.value === value) : undefined), [options, value], ) const handleSelect = (optionValue: string) => { onValueChange(optionValue) setIsOpen(false) } const defaultTrigger = ( ) return ( {trigger ?? defaultTrigger}
setSearch(e.target.value)} placeholder={searchPlaceholder} startAdornment={} className="rounded-none border-0" />
{isLoading ? (
{loadingText}
) : filtered.length === 0 ? (
{emptyText}
) : ( filtered.map(opt => { const isSelected = value === opt.value return ( ) }) )}
) }