'use client'; import * as React from 'react'; import { cn } from '@djangocfg/ui-core/lib'; import { ChevronsUpDown, Check, X } from 'lucide-react'; import { Popover, PopoverTrigger, } from '@djangocfg/ui-core/components'; import { ComboboxProvider, useComboboxContext } from '../context'; import type { ComboboxProps, ComboboxTriggerProps, ComboboxInputProps, ComboboxListProps, ComboboxItemProps, ComboboxEmptyProps, ComboboxOption, } from '../types'; function defaultFilter(options: ComboboxOption[], search: string): ComboboxOption[] { const q = search.toLowerCase(); return options.filter( (o) => o.label.toLowerCase().includes(q) || o.value.toLowerCase().includes(q) || o.description?.toLowerCase().includes(q) ); } export function Combobox({ children, options, value: valueProp, defaultValue = null, multiple = false, placeholder = 'Select...', onChange, onFilter = defaultFilter, disabled = false, className, }: ComboboxProps) { const [open, setOpen] = React.useState(false); const [search, setSearch] = React.useState(''); const [highlightedIndex, setHighlightedIndex] = React.useState(0); const [internalValue, setInternalValue] = React.useState(defaultValue); const value = valueProp !== undefined ? valueProp : internalValue; const filteredOptions = React.useMemo( () => onFilter(options, search), [options, search, onFilter] ); const selectOption = React.useCallback( (option: ComboboxOption) => { if (multiple) { const current = Array.isArray(value) ? value : []; const exists = current.includes(option.value); const next = exists ? current.filter((v) => v !== option.value) : [...current, option.value]; setInternalValue(next); onChange?.(next); if (!exists) { setSearch(''); } } else { setInternalValue(option.value); onChange?.(option.value); setOpen(false); setSearch(''); } }, [multiple, value, onChange] ); const removeValue = React.useCallback( (val: string) => { if (multiple) { const current = Array.isArray(value) ? value : []; const next = current.filter((v) => v !== val); setInternalValue(next); onChange?.(next); } else { setInternalValue(null); onChange?.(null); } }, [multiple, value, onChange] ); const isSelected = React.useCallback( (val: string) => { if (multiple) { return Array.isArray(value) && value.includes(val); } return value === val; }, [multiple, value] ); const ctxValue = React.useMemo( () => ({ open, search, value, multiple, highlightedIndex, options, filteredOptions, setOpen, setSearch, setHighlightedIndex, selectOption, removeValue, isSelected, }), [ open, search, value, multiple, highlightedIndex, options, filteredOptions, selectOption, removeValue, isSelected, ] ); return (
{children ?? ( <> )}
); } Combobox.displayName = 'Combobox'; export function ComboboxTrigger({ children, className, placeholder = 'Select...', ...props }: ComboboxTriggerProps & { placeholder?: string }) { const { open, setOpen, value, multiple, options, removeValue } = useComboboxContext(); const selectedLabels = React.useMemo(() => { if (multiple) { const vals = Array.isArray(value) ? value : []; return vals .map((v) => options.find((o) => o.value === v)?.label ?? v) .filter(Boolean); } if (!value) return []; const opt = options.find((o) => o.value === value); return opt ? [opt.label] : [value]; }, [value, multiple, options]); return ( )} )) )} )} ); } ComboboxTrigger.displayName = 'ComboboxTrigger'; export function ComboboxInput({ className, onChange, onKeyDown, ...props }: ComboboxInputProps) { const { search, setSearch, setHighlightedIndex, highlightedIndex, filteredOptions, selectOption, setOpen, } = useComboboxContext(); const handleChange = React.useCallback( (e: React.ChangeEvent) => { setSearch(e.target.value); setHighlightedIndex(0); onChange?.(e); }, [setSearch, setHighlightedIndex, onChange] ); const handleKeyDown = React.useCallback( (e: React.KeyboardEvent) => { switch (e.key) { case 'ArrowDown': e.preventDefault(); setHighlightedIndex((prev: number) => prev < filteredOptions.length - 1 ? prev + 1 : 0 ); break; case 'ArrowUp': e.preventDefault(); setHighlightedIndex((prev: number) => prev > 0 ? prev - 1 : filteredOptions.length - 1 ); break; case 'Enter': e.preventDefault(); if (filteredOptions[highlightedIndex]) { selectOption(filteredOptions[highlightedIndex]); } break; case 'Escape': setOpen(false); break; default: onKeyDown?.(e); } }, [filteredOptions, highlightedIndex, selectOption, setOpen, setHighlightedIndex, onKeyDown] ); return ( ); } ComboboxInput.displayName = 'ComboboxInput'; export function ComboboxList({ children, className, ...props }: ComboboxListProps) { const { open, filteredOptions } = useComboboxContext(); if (!open) return null; return (
{children ?? filteredOptions.map((option, index) => ( ))}
); } ComboboxList.displayName = 'ComboboxList'; export function ComboboxItem({ option, index, className, onClick, ...props }: ComboboxItemProps) { const { highlightedIndex, selectOption, setHighlightedIndex, isSelected } = useComboboxContext(); const isHighlighted = highlightedIndex === index; const selected = isSelected(option.value); const handleClick = React.useCallback( (e: React.MouseEvent) => { e.preventDefault(); selectOption(option); onClick?.(e); }, [option, selectOption, onClick] ); const handlePointerEnter = React.useCallback(() => { setHighlightedIndex(index); }, [index, setHighlightedIndex]); return (
{selected && } {option.icon && {option.icon}}
{option.label} {option.description && ( {option.description} )}
); } ComboboxItem.displayName = 'ComboboxItem'; export function ComboboxEmpty({ children, className, ...props }: ComboboxEmptyProps) { const { filteredOptions } = useComboboxContext(); if (filteredOptions.length > 0) return null; return (
{children ?? 'No results found.'}
); } ComboboxEmpty.displayName = 'ComboboxEmpty';