'use client' import * as React from 'react' import { cn } from '../../utils/cn' import { useDebounce } from '../../hooks/ui/use-debounce' import { SearchIcon } from '../icons-v2-generated' export interface ChatHeaderSearchFieldProps { /** Seeds the field on mount (the current server-side search term). */ initialValue?: string /** Emits the DEBOUNCED term. The host owns the query and refetches the * dialog list server-side — the field never filters locally. */ onSearchChange: (query: string) => void /** Collapse the field back to the title. Fired on Escape and on blur while * the field is empty (an accidental open closes itself). */ onCollapse?: () => void /** Focus the input on mount (default true — the field only mounts when the * user opens search, so we drop them straight into typing). */ autoFocus?: boolean /** Appended to the root element (e.g. to tweak the open animation). */ className?: string } /** * Inline chat-header search field (Figma node 116:51217). When search is * toggled on, the panel header's title area is REPLACED in place by this * full-height field — a leading magnifier + a bare, borderless input — instead * of dropping a separate search bar into the list body. Holds its own text for * snappy typing and emits the debounced term via `onSearchChange`. */ export function ChatHeaderSearchField({ initialValue, onSearchChange, onCollapse, autoFocus = true, className, }: ChatHeaderSearchFieldProps) { const [value, setValue] = React.useState(initialValue ?? '') const debounced = useDebounce(value, 300) const lastEmitted = React.useRef(initialValue ?? '') // Exit animation: collapsing plays `animate-out` (slide/fade back toward the // right) BEFORE the parent unmounts us. We stay mounted through the leave — // the parent only drops the field once we call `onCollapse`, which we defer // to the animation's end. const [exiting, setExiting] = React.useState(false) const collapsedRef = React.useRef(false) React.useEffect(() => { // Once the collapse starts, no further emits: Escape already flushed the // cleared term synchronously, and a still-pending debounce of the typed // text ("abc" → Escape before 300ms) must not resurrect a stale query // while the field animates out. if (exiting) return if (debounced === lastEmitted.current) return lastEmitted.current = debounced onSearchChange(debounced) }, [debounced, exiting, onSearchChange]) const finishCollapse = React.useCallback(() => { if (collapsedRef.current) return collapsedRef.current = true onCollapse?.() }, [onCollapse]) const beginCollapse = React.useCallback(() => { setExiting(true) }, []) // Fallback for when `animationend` never fires (reduced motion, or the // animation utilities are disabled) — otherwise the field would hang open. React.useEffect(() => { if (!exiting) return const t = setTimeout(finishCollapse, 240) return () => clearTimeout(t) }, [exiting, finishCollapse]) return (
{ // Only the root's OWN leave animation collapses — ignore the child // icon's animations bubbling up, and the enter animation. if (e.target === e.currentTarget && exiting) finishCollapse() }} className={cn( 'flex min-w-0 flex-1 items-center gap-[var(--spacing-system-xs)] px-[var(--spacing-system-sf)]', exiting ? // Elegant leave: sweep back out to the right + fade, holding the end // frame (`fill-mode-forwards`) so it stays hidden until unmount. 'animate-out fade-out-0 slide-out-to-right-4 duration-200 ease-in fill-mode-forwards pointer-events-none' : // Elegant open: sweep in from the right (where the magnifier toggle // sat) and fade up as it takes over the title area. 'animate-in fade-in-0 slide-in-from-right-4 duration-200 ease-out', className, )} > {/* The magnifier eases in a touch later + scales up, so the icon reads as "growing" into the field rather than hard-cutting in. */} {/* biome-ignore lint/a11y/noAutofocus: the field only mounts on an explicit search toggle, so focusing it is the intended behaviour. */} setValue(e.target.value)} onKeyDown={(e) => { if (e.key === 'Escape') { e.preventDefault() setValue('') // Emit the cleared term immediately AND record it as the last emit, // so the 300ms debounce effect doesn't fire a second, redundant // `onSearchChange('')` (extra server round-trip) before we unmount. lastEmitted.current = '' onSearchChange('') beginCollapse() } }} onBlur={() => { // An accidental open (no query typed) collapses itself; a field with // an active query stays open so the filtered state stays visible. if (!value.trim()) { // Flush the cleared term synchronously (mirrors Escape) — the // debounce effect is muted once `exiting` flips, so a just-deleted // query ("abc" → "" → immediate blur) must emit its reset here or // the host would keep filtering by the stale term. if (lastEmitted.current !== '') { lastEmitted.current = '' onSearchChange('') } beginCollapse() } }} placeholder="Search for Chats" aria-label="Search chats" className="min-w-0 flex-1 bg-transparent text-h4 text-ods-text-primary placeholder:text-ods-text-secondary focus:outline-none" />
) }