"use client" import * as PopoverPrimitive from "@radix-ui/react-popover" import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area" import * as React from "react" import { cn } from "../../utils/cn" import { useDebounce } from "../../hooks/ui/use-debounce" import { useAutoLimitTags } from "../../hooks/ui/use-auto-limit-tags" import { useKeyboardCollisionPadding } from "../../hooks/ui/use-keyboard-collision-padding" import { SearchIcon } from "../icons-v2-generated" import { XmarkCircleIcon } from "../icons-v2-generated/signs-and-symbols/xmark-circle-icon" import { Tag } from "./tag" import { HiddenTagsPopup } from "./hidden-tags-popup" // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- export interface SearchResult { id: string title: string description?: string path?: string type?: string icon?: React.ReactNode metadata?: Record } export interface FilterChipData { id: string label: string variant?: "selected" | "category" | "subcategory" | "tag" } export interface SearchInputProps { /** Placeholder text shown in the input */ placeholder?: string /** Controlled value */ value?: string /** Default value for uncontrolled mode */ defaultValue?: string /** Called when input value changes (raw, not debounced) */ onChange?: (value: string) => void /** Called when user presses Enter */ onSubmit?: (value: string) => void /** Search results to display in the dropdown */ results?: SearchResult[] /** Whether results are loading */ isLoading?: boolean /** Called when a result row is selected. * * `modifiers` carries the click event's modifier-key state when the * user picked the row via mouse — pass through so the consumer can * honor cmd/ctrl/shift/middle-click for background-tab navigation * (the row is a `
` rather than an ``, so the * browser doesn't background-tab natively even with `target="_blank"`). * Empty `{}` when the row was selected via keyboard Enter. */ onResultSelect?: ( result: SearchResult, modifiers?: { metaKey?: boolean; ctrlKey?: boolean; shiftKey?: boolean; altKey?: boolean; button?: number }, ) => void /** Debounce delay in ms. 0 disables debounce. Default 300 */ debounceMs?: number /** Custom renderer for a single result row */ renderResult?: (result: SearchResult, isHighlighted: boolean) => React.ReactNode /** Group results by a key derived from each result */ groupBy?: (result: SearchResult) => string /** Text shown when query meets minQueryLength but no results */ emptyResultsText?: string /** Force-control dropdown visibility. Default: auto */ showDropdown?: boolean /** Filter chips rendered inline before the input */ filterChips?: FilterChipData[] /** Called when a filter chip is removed */ onFilterRemove?: (id: string) => void /** Element rendered before the input. Default: SearchIcon */ startAdornment?: React.ReactNode /** Element rendered after the input */ endAdornment?: React.ReactNode /** Extra class names for the outer container */ className?: string /** Extra class names for the dropdown */ dropdownClassName?: string /** Minimum characters before showing results. Default 2 */ minQueryLength?: number /** Maximum visible filter chips. "auto" measures available width. Default "auto" */ limitTags?: number | "auto" /** Custom render for the "+N" overflow text */ getLimitTagsText?: (more: number) => React.ReactNode } // --------------------------------------------------------------------------- // Shared styles (consistent with Autocomplete / Input) // --------------------------------------------------------------------------- const containerStyles = cn( // Layout & spacing — matches lib Input component "flex items-center gap-2 rounded-[6px] border px-3 h-11 md:h-12 cursor-text", "has-[:focus-visible]:outline-none", "group", "transition-colors duration-200", // Theme palette — matches lib Input component "bg-ods-card border-ods-border has-[:focus]:border-ods-accent" ) 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", "touch-manipulation" ) // --------------------------------------------------------------------------- // Helper: chip variant → Tag variant mapping // --------------------------------------------------------------------------- function chipVariantToTagVariant(variant?: FilterChipData["variant"]): "primary" | "outline" | "badge" { switch (variant) { case "selected": return "primary" // Content tags render with the unified badge skin (ods-card + ods-border, // mono uppercase) — identical to the public EntityTagBadges display. case "tag": return "badge" case "category": case "subcategory": default: return "outline" } } // --------------------------------------------------------------------------- // Component // --------------------------------------------------------------------------- export function SearchInput({ placeholder = "Search...", value, defaultValue = "", onChange, onSubmit, results = [], isLoading = false, onResultSelect, debounceMs = 300, renderResult, groupBy, emptyResultsText = "No results found", showDropdown: showDropdownProp, filterChips = [], onFilterRemove, startAdornment, endAdornment, className, dropdownClassName, minQueryLength = 2, limitTags = "auto", getLimitTagsText = (more: number) => `+${more}`, }: SearchInputProps) { // ---- Controlled / uncontrolled ---- const [internalValue, setInternalValue] = React.useState(defaultValue) const currentValue = onChange ? (value ?? "") : internalValue // ---- Debounce ---- const debouncedValue = useDebounce(currentValue, debounceMs) // ---- Popover state ---- const [isOpen, setIsOpen] = React.useState(false) const [highlightedIndex, setHighlightedIndex] = React.useState(-1) const keyboardPadding = useKeyboardCollisionPadding() const containerRef = React.useRef(null) // ---- Auto-limit tags ---- const currentPlaceholder = filterChips.length > 0 ? "Add filter..." : placeholder const { visibleCount: rawVisibleCount, middleRef, measureRef, textMeasureRef, badgeRef, inputRef, } = useAutoLimitTags({ count: filterChips.length, limitTags, // When chips exist, pass empty placeholder so the hook only reserves input minWidth, // not the full placeholder text width — gives more room for chips on narrow screens placeholder: filterChips.length > 0 ? "" : placeholder, }) // Always show at least 1 chip when chips exist (industry standard: Gmail, MUI, Ant Design) const visibleCount = filterChips.length > 0 ? Math.max(1, rawVisibleCount) : rawVisibleCount // ---- Hidden tags popup ---- const hiddenTagsRef = React.useRef(null) const hiddenTagsPopupRef = React.useRef(null) const [showHiddenTags, setShowHiddenTags] = React.useState(false) React.useEffect(() => { if (!showHiddenTags) return const handleClick = (e: MouseEvent) => { const target = e.target as Node if (!hiddenTagsRef.current?.contains(target) && !hiddenTagsPopupRef.current?.contains(target)) { setShowHiddenTags(false) } } document.addEventListener("mousedown", handleClick) return () => document.removeEventListener("mousedown", handleClick) }, [showHiddenTags]) // ---- Derived chip slicing ---- const hiddenCount = filterChips.length - visibleCount const visibleChips = filterChips.slice(0, visibleCount) const hiddenChips = filterChips.slice(visibleCount) // ---- Derive flat list (possibly grouped) ---- const { flatResults, groups } = React.useMemo(() => { if (!groupBy) return { flatResults: results, groups: null } const grouped = new Map() for (const r of results) { const key = groupBy(r) const arr = grouped.get(key) if (arr) { arr.push(r) } else { grouped.set(key, [r]) } } return { flatResults: results, groups: grouped } }, [results, groupBy]) // ---- Auto-show logic ---- const meetsMinQuery = debouncedValue.length >= minQueryLength const autoShow = meetsMinQuery const dropdownVisible = showDropdownProp ?? (isOpen && autoShow) // ---- Reset highlight when results change ---- React.useEffect(() => { setHighlightedIndex(-1) }, [flatResults.length]) // ---- Handlers ---- const handleChange = (e: React.ChangeEvent) => { const newVal = e.target.value if (onChange) { onChange(newVal) } else { setInternalValue(newVal) } if (!isOpen) setIsOpen(true) setHighlightedIndex(-1) } const handleClear = (e: React.MouseEvent) => { e.stopPropagation() e.preventDefault() if (onChange) { onChange("") } else { setInternalValue("") } inputRef.current?.focus() } const handleResultClick = ( result: SearchResult, e?: React.MouseEvent, ) => { onResultSelect?.( result, e ? { metaKey: e.metaKey, ctrlKey: e.ctrlKey, shiftKey: e.shiftKey, altKey: e.altKey, button: e.button, } : undefined, ) setIsOpen(false) } const handleKeyDown = (e: React.KeyboardEvent) => { switch (e.key) { case "ArrowDown": e.preventDefault() if (!isOpen) setIsOpen(true) setHighlightedIndex((prev) => prev < flatResults.length - 1 ? prev + 1 : 0 ) break case "ArrowUp": e.preventDefault() setHighlightedIndex((prev) => prev > 0 ? prev - 1 : flatResults.length - 1 ) break case "Enter": e.preventDefault() if (highlightedIndex >= 0 && flatResults[highlightedIndex]) { handleResultClick(flatResults[highlightedIndex]) } else { onSubmit?.(currentValue) } break case "Escape": setIsOpen(false) setHighlightedIndex(-1) break case "Backspace": if (!currentValue && filterChips.length > 0 && onFilterRemove) { onFilterRemove(filterChips[filterChips.length - 1].id) } break } } const handleOpenChange = (open: boolean) => { setIsOpen(open) } // ---- Default result renderer ---- const defaultRenderResult = (result: SearchResult, isHighlighted: boolean) => (
{result.icon && ( {result.icon} )}
{result.title}
{result.description && (
{result.description}
)}
{result.type && ( {result.type} )}
) // ---- Render a result row ---- const renderRow = (result: SearchResult, index: number) => { const isHighlighted = index === highlightedIndex return (
handleResultClick(result, e)} onMouseEnter={() => setHighlightedIndex(index)} > {renderResult ? renderResult(result, isHighlighted) : defaultRenderResult(result, isHighlighted)}
) } // ---- Dropdown content ---- const renderDropdownContent = () => { if (isLoading) { return (
Loading...
) } if (flatResults.length === 0) { return (
{emptyResultsText}
) } if (groups) { let globalIndex = 0 return Array.from(groups.entries()).map(([groupLabel, groupResults]) => (
{groupLabel}
{groupResults.map((result) => { const idx = globalIndex++ return renderRow(result, idx) })}
)) } return flatResults.map((result, index) => renderRow(result, index)) } // ---- Determine if we have a value worth clearing ---- const hasValue = currentValue.length > 0 return (
{ inputRef.current?.focus() setIsOpen(true) }} > {/* Start Adornment — pinned left, shrink-0 */} {startAdornment !== undefined ? startAdornment : } {/* Middle zone: chips + input — overflow hidden, single line */}
{/* Visible filter chips */} {visibleChips.map((chip) => ( onFilterRemove(chip.id) : undefined} /> ))} {/* "+N" overflow badge */} {hiddenCount > 0 && (
)} {/* Input */} { setIsOpen(true) setShowHiddenTags(false) }} placeholder={currentPlaceholder} className={innerInputStyles} />
{/* End adornment / Clear — pinned right, shrink-0 */}
{hasValue && ( )} {endAdornment}
{ e.preventDefault() inputRef.current?.focus() }} onInteractOutside={(e) => { if (containerRef.current?.contains(e.target as Node)) { e.preventDefault() } }} >
{renderDropdownContent()}
{/* Hidden tags popup — outside overflow-hidden, positioned under badge */} {showHiddenTags && hiddenCount > 0 && ( ({ label: chip.label, value: chip.id }))} style={{ left: badgeRef.current ? badgeRef.current.getBoundingClientRect().left - (containerRef.current?.getBoundingClientRect().left ?? 0) : 0, }} onRemove={(value) => { onFilterRemove?.(value as string) if (hiddenCount <= 1) setShowHiddenTags(false) }} /> )} {/* Off-screen measurement: placeholder text width — fixed positioning avoids scroll contribution */} {/* Off-screen measurement: all chip widths */}
) } export default SearchInput