"use client" import { type ChangeEvent, type ForwardedRef, type KeyboardEvent, type MouseEvent as ReactMouseEvent, type ReactElement, type ReactNode, forwardRef, useEffect, useImperativeHandle, useMemo, useRef, useState, } from "react" import * as PopoverPrimitive from "@radix-ui/react-popover" import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area" import { CheckIcon } from "../icons-v2-generated/signs-and-symbols/check-icon" import { TrashIcon } from "../icons-v2-generated/interface/trash-icon" import { XmarkCircleIcon } from "../icons-v2-generated/signs-and-symbols/xmark-circle-icon" import { Chevron02DownIcon } from "../icons-v2-generated/arrows/chevron-02-down-icon" import { Loader2 } from "lucide-react" import { cn } from "../../utils/cn" import { TruncateText } from "./truncate-text" import { useAutoLimitTags } from "../../hooks/ui/use-auto-limit-tags" import { useKeyboardCollisionPadding } from "../../hooks/ui/use-keyboard-collision-padding" import { FieldWrapper } from "./field-wrapper" import { HiddenTagsPopup } from "./hidden-tags-popup" import { Tag } from "./tag" export interface AutocompleteOption { label: string value: T } export type AutocompleteInputChangeReason = 'input' | 'reset' | 'clear' interface AutocompleteBaseProps { /** Available options to select from */ options: AutocompleteOption[] /** Placeholder text */ placeholder?: string /** Whether the component is disabled */ disabled?: boolean /** Element displayed at the start of the input */ startAdornment?: ReactNode /** Whether to show clear button */ showClearAll?: boolean /** Custom className for the container */ className?: string /** Custom className for the dropdown */ dropdownClassName?: string /** When true, allows creating new options by typing */ freeSolo?: boolean /** Label for the input */ label?: string /** Label scale forwarded to FieldWrapper ("large" = text-h4 for designs with body-scale field titles) */ labelVariant?: "default" | "large" /** Error message displayed below the field */ error?: string /** Custom filter function */ filterOptions?: (options: AutocompleteOption[], inputValue: string) => AutocompleteOption[] /** Render custom option content */ renderOption?: (option: AutocompleteOption, isSelected: boolean) => ReactNode /** When true, shows validation error styling */ invalid?: boolean /** No options text */ noOptionsText?: string /** Controlled input value. When provided, the component won't manage input state internally. */ inputValue?: string /** Callback when input value changes (typing, selection, clearing). Fires in both controlled and uncontrolled modes. */ onInputChange?: (value: string, reason: AutocompleteInputChangeReason) => void /** Loading state */ loading?: boolean /** Loading text */ loadingText?: string /** When true, shows a clickable "+ Create" option when no results match the input */ creatable?: boolean /** Callback fired after a new option is created via creatable. Use it to persist the new option server-side, etc. */ onCreateOption?: (inputValue: string) => void /** Max length for a created option. When exceeded, creation is blocked and a hint is shown. Omit for no limit. */ maxCreateLength?: number /** When set, each unselected option shows a hover trash button that calls this. Omit to hide delete. */ onDeleteOption?: (value: T) => void isDeletingOption?: boolean /** When true, disables built-in client-side filtering (useful when options are filtered server-side via onInputChange) */ disableClientFilter?: boolean /** Whether to show the chevron icon. Default true */ showChevron?: boolean /** Whether to clear the input when the dropdown opens (single mode only). Default true */ clearOnOpen?: boolean } export interface AutocompleteSingleProps extends AutocompleteBaseProps { /** Single-select mode (default) */ multiple?: false /** Currently selected value */ value: T | null /** Callback when selection changes */ onChange: (value: T | null) => void } export interface AutocompleteMultipleProps extends AutocompleteBaseProps { /** Enable multi-select mode */ multiple: true /** Currently selected values */ value: T[] /** Callback when selection changes */ onChange: (value: T[]) => void /** Maximum number of items that can be selected */ maxItems?: number /** Render custom tag content */ renderTag?: (option: AutocompleteOption) => ReactNode /** Maximum number of visible tags. Set to "auto" for automatic calculation based on available width. Default "auto" */ limitTags?: number | "auto" /** Custom render function for the "+N" overflow chip */ getLimitTagsText?: (more: number) => ReactNode } export type AutocompleteProps = AutocompleteSingleProps | AutocompleteMultipleProps // Inner input styles matching Input component const innerInputStyles = cn( "flex-1 min-w-[60px] bg-transparent border-none outline-none", "text-h4", "text-ods-text-primary placeholder:text-ods-text-secondary", // Disabled - match Input exactly (value greys out, placeholder dims further) "disabled:cursor-not-allowed disabled:text-ods-text-disabled disabled:placeholder:text-ods-border" ) function AutocompleteInner( props: AutocompleteProps, ref: ForwardedRef ) { const { options, disabled = false, startAdornment, showClearAll = true, className, dropdownClassName, freeSolo = false, label, labelVariant, error, filterOptions, renderOption, invalid = false, noOptionsText = "No options", inputValue: inputValueProp, onInputChange, loading = false, loadingText = "Loading...", creatable = false, onCreateOption, maxCreateLength, onDeleteOption, isDeletingOption = false, disableClientFilter = false, showChevron = true, clearOnOpen = true, } = props const multiple = props.multiple ?? false const placeholder = props.placeholder ?? (multiple ? "Add More..." : "Select...") // Multiple-only props const maxItems = multiple ? (props as AutocompleteMultipleProps).maxItems : undefined const renderTag = multiple ? (props as AutocompleteMultipleProps).renderTag : undefined const limitTagsProp = multiple ? ((props as AutocompleteMultipleProps).limitTags ?? "auto") : "auto" const getLimitTagsText = multiple ? ((props as AutocompleteMultipleProps).getLimitTagsText ?? ((more: number) => `+${more}`)) : ((more: number) => `+${more}`) // Normalize value to array for internal use const valueArray: T[] = multiple ? (props.value as T[]) : (props.value != null ? [props.value as T] : []) const [internalInputValue, setInternalInputValue] = useState("") const isInputControlled = inputValueProp !== undefined const inputValue = isInputControlled ? inputValueProp : internalInputValue const updateInputValue = (value: string, reason: AutocompleteInputChangeReason) => { if (!isInputControlled) { setInternalInputValue(value) } onInputChange?.(value, reason) } const [isOpen, setIsOpen] = useState(false) const [highlightedIndex, setHighlightedIndex] = useState(-1) const keyboardPadding = useKeyboardCollisionPadding() const containerRef = useRef(null) const hiddenTagsPopupRef = useRef(null) const isInvalid = invalid || !!error // Combine refs useImperativeHandle(ref, () => containerRef.current as HTMLDivElement) // Get selected options const selectedOptions = useMemo(() => { return valueArray.map(v => options.find(opt => opt.value === v) ?? { label: String(v), value: v }) }, [valueArray, options]) // Single mode: the currently selected option const selectedOption = !multiple && selectedOptions.length > 0 ? selectedOptions[0] : null // Placeholder logic const inputPlaceholder = multiple ? (valueArray.length === 0 ? placeholder : "Add More...") : placeholder // ---- Auto limit tags via shared hook ---- const autoLimitTags = useAutoLimitTags({ count: multiple ? selectedOptions.length : 0, limitTags: multiple ? limitTagsProp : 0, placeholder: inputPlaceholder, }) const visibleCount = multiple ? autoLimitTags.visibleCount : 0 const visibleTags = multiple ? selectedOptions.slice(0, visibleCount) : [] const hiddenTags = multiple ? selectedOptions.slice(visibleCount) : [] const hiddenTagsCount = multiple ? selectedOptions.length - visibleCount : 0 const [showHiddenTags, setShowHiddenTags] = useState(false) const hiddenTagsRef = useRef(null) // Close hidden tags list on outside click useEffect(() => { if (!showHiddenTags) return const handleClick = (e: MouseEvent) => { const target = e.target as Node const inButton = hiddenTagsRef.current?.contains(target) const inPopup = hiddenTagsPopupRef.current?.contains(target) if (!inButton && !inPopup) { setShowHiddenTags(false) } } document.addEventListener("mousedown", handleClick) return () => document.removeEventListener("mousedown", handleClick) }, [showHiddenTags]) // Synchronously open dropdown, pre-filling input when clearOnOpen is false const openDropdown = () => { if (!multiple && !clearOnOpen && selectedOption) { updateInputValue(selectedOption.label, 'reset') } setShowHiddenTags(false) setIsOpen(true) } // Input display value: // - Single mode, closed, has selection → show selected label // - Single mode, open, clearOnOpen=false → show inputValue (pre-filled with label) // - Otherwise → show inputValue (what user is typing) const inputDisplayValue = !multiple && !isOpen && selectedOption ? selectedOption.label : inputValue // Filter options based on inputValue const filteredOptions = useMemo(() => { if (disableClientFilter) { return options } if (filterOptions) { return filterOptions(options, inputValue) } if (!inputValue.trim()) { return options } const lowerInput = inputValue.toLowerCase() return options.filter(opt => opt.label.toLowerCase().includes(lowerInput) ) }, [options, inputValue, filterOptions, disableClientFilter]) // Show "+ Create" option when creatable is on, user typed something, and nothing matched const showCreateOption = creatable && inputValue.trim().length > 0 && filteredOptions.length === 0 const isCreateTooLong = maxCreateLength != null && inputValue.trim().length > maxCreateLength // Handle creating a new option const handleCreate = () => { const trimmed = inputValue.trim() if (!trimmed || (maxCreateLength != null && trimmed.length > maxCreateLength)) return const newValue = trimmed as T if (multiple) { if (maxItems && valueArray.length >= maxItems) return (props as AutocompleteMultipleProps).onChange([...valueArray, newValue]) } else { (props as AutocompleteSingleProps).onChange(newValue) } updateInputValue("", 'reset') setIsOpen(false) onCreateOption?.(trimmed) } // Reset highlighted index when options change useEffect(() => { setHighlightedIndex(-1) }, [filteredOptions.length]) // Handle input change const handleInputChange = (e: ChangeEvent) => { updateInputValue(e.target.value, 'input') if (!isOpen) { setIsOpen(true) } setHighlightedIndex(-1) } // Handle option selection const handleSelect = (option: AutocompleteOption) => { if (multiple) { // Multiple mode: toggle selection const isSelected = valueArray.includes(option.value) if (isSelected) { (props as AutocompleteMultipleProps).onChange(valueArray.filter(v => v !== option.value)) } else { if (maxItems && valueArray.length >= maxItems) { return } (props as AutocompleteMultipleProps).onChange([...valueArray, option.value]) } updateInputValue("", 'reset') autoLimitTags.inputRef.current?.focus() } else { // Single mode: select and close ;(props as AutocompleteSingleProps).onChange(option.value) // When clearOnOpen is false, keep the label as inputValue so // filteredOptions is pre-computed before the next open (prevents flicker) updateInputValue(clearOnOpen ? "" : option.label, 'reset') setIsOpen(false) } } // Handle clear const handleClearAll = (e: ReactMouseEvent) => { e.stopPropagation() e.preventDefault() if (multiple) { ;(props as AutocompleteMultipleProps).onChange([]) } else { ;(props as AutocompleteSingleProps).onChange(null) } if (!isInputControlled) { setInternalInputValue("") } onInputChange?.("", 'clear') setIsOpen(false) } // Handle keyboard navigation const handleKeyDown = (e: KeyboardEvent) => { switch (e.key) { case "ArrowDown": e.preventDefault() if (!isOpen) { openDropdown() } setHighlightedIndex(prev => prev < filteredOptions.length - 1 ? prev + 1 : 0 ) break case "ArrowUp": e.preventDefault() setHighlightedIndex(prev => prev > 0 ? prev - 1 : filteredOptions.length - 1 ) break case "Enter": e.preventDefault() if (showCreateOption) { handleCreate() } else if (highlightedIndex >= 0 && filteredOptions[highlightedIndex]) { handleSelect(filteredOptions[highlightedIndex]) } else if (freeSolo && inputValue.trim()) { const newOption: AutocompleteOption = { label: inputValue.trim(), value: inputValue.trim() as T } handleSelect(newOption) } break case "Escape": { // Restore label when clearOnOpen is false, same as handleOpenChange close const resetValue = !multiple && !clearOnOpen && selectedOption ? selectedOption.label : "" updateInputValue(resetValue, 'reset') setIsOpen(false) setHighlightedIndex(-1) break } case "Backspace": break } } // Handle popover open/close const handleOpenChange = (open: boolean) => { if (open) { openDropdown() } else { // When clearOnOpen is false and there's a selection, restore the label // so filteredOptions stays pre-computed for the next open const resetValue = !multiple && !clearOnOpen && selectedOption ? selectedOption.label : "" updateInputValue(resetValue, 'reset') setIsOpen(false) } } const canAddMore = multiple ? (!maxItems || valueArray.length < maxItems) : true const hasValue = valueArray.length > 0 const popover = (
span]:text-ods-text-disabled has-[:disabled]:[&_svg]:text-ods-text-disabled", isOpen && !isInvalid && "border-ods-accent hover:border-ods-accent", isInvalid && "border-ods-error hover:border-ods-error" )} onClick={() => { if (!disabled) { autoLimitTags.inputRef.current?.focus() openDropdown() } }} > {/* Start Adornment */} {startAdornment && ( {startAdornment} )} {/* Middle zone: tags + input — single line with overflow */}
{/* Tags (multiple mode only) */} {multiple && visibleTags.map((option) => ( { ;(props as AutocompleteMultipleProps).onChange(valueArray.filter(v => v !== option.value)) } : undefined} /> ))} {/* Overflow indicator button (multiple mode only) */} {multiple && hiddenTagsCount > 0 && (
)} {/* Input */} {canAddMore && ( openDropdown()} placeholder={inputPlaceholder} disabled={disabled} className={innerInputStyles} /> )}
{/* Clear / Chevron — pinned right */}
{showClearAll && (hasValue || inputValue.length > 0) && !disabled && isOpen && ( )} {loading ? ( ) : showChevron && ( )}
{ e.preventDefault() autoLimitTags.inputRef.current?.focus() }} onInteractOutside={(e) => { // Don't close if clicking inside the anchor/input container if (containerRef.current?.contains(e.target as Node)) { e.preventDefault() } }} >
{loading ? (
{loadingText}
) : filteredOptions.length === 0 ? ( showCreateOption ? ( isCreateTooLong ? (
Maximum {maxCreateLength} characters
) : (
{/* text-current: the row owns the typography/color (accent), not the TruncateText defaults. */} {`+ Create "${inputValue.trim()}"`}
) ) : (
{freeSolo && inputValue.trim() ? ( Press Enter to add "{inputValue}" ) : ( noOptionsText )}
) ) : ( filteredOptions.map((option, index) => { const isSelected = valueArray.includes(option.value) const isHighlighted = index === highlightedIndex return (
handleSelect(option)} onMouseEnter={() => setHighlightedIndex(index)} > {renderOption ? renderOption(option, isSelected) : (
{/* text-current: selection state colors the row (accent vs primary); inherit it. */} {option.label}
{isSelected && ( )} {onDeleteOption && !isSelected && ( )}
)}
) }) )}
) return (
{popover} {/* Hidden tags popup — outside overflow-hidden; right-anchored to the field */} {multiple && showHiddenTags && hiddenTagsCount > 0 && ( { const newValue = valueArray.filter(v => v !== value) ;(props as AutocompleteMultipleProps).onChange(newValue) if (typeof limitTagsProp === "number" && newValue.length <= limitTagsProp) setShowHiddenTags(false) }} /> )} {/* Off-screen measurement containers for auto-limit */} {multiple && ( <> )}
) } // Use overloaded signatures so TS can narrow single vs multiple based on the `multiple` prop type AutocompleteComponent = { (props: AutocompleteMultipleProps & { ref?: ForwardedRef }): ReactElement (props: AutocompleteSingleProps & { ref?: ForwardedRef }): ReactElement } export const Autocomplete = forwardRef(AutocompleteInner) as AutocompleteComponent