'use client'; /** * Header search with autocomplete — Atelier treatment. * * Same contract as the base island (see `base/src/ui/layout/header-search.tsx`): * `useSearch()` owns the debounce / minimum-length / abort behaviour, this * component owns the input and the dropdown. Submitting lands on * `/products?search=…`, which `use-product-listing` already reads back. * * On mobile the field collapses to a single icon button that expands the * input over the bar, so the pill row stays uncrowded next to the account and * cart controls. */ import * as React from 'react'; import { useRouter } from '@/core/lib/navigation'; import { useSearch } from '@/core/hooks/use-search'; import { useTranslations } from '@/core/lib/translations'; import { cn } from '@/core/lib/utils'; import { IconSearch, IconX } from '@/ui/shared/icons'; interface HeaderSearchProps { className?: string; } /** One flattened dropdown row — products first, then categories. */ type Option = { kind: 'product' | 'category'; id: string; label: string; href: string; }; export function HeaderSearch({ className }: HeaderSearchProps) { const router = useRouter(); const t = useTranslations('nav'); const tc = useTranslations('common'); const tp = useTranslations('products'); const [query, setQuery] = React.useState(''); const [open, setOpen] = React.useState(false); const [expanded, setExpanded] = React.useState(false); const [activeIndex, setActiveIndex] = React.useState(-1); const { suggestions, loading } = useSearch(query); const rootRef = React.useRef(null); const inputRef = React.useRef(null); const listboxId = React.useId(); const options: Option[] = React.useMemo(() => { if (!suggestions) return []; const rows: Option[] = []; for (const product of suggestions.products) { rows.push({ kind: 'product', id: product.id, label: product.name, // `slug` is nullable on a ProductSuggestion — fall back to the id, // the same way the product cards do. href: `/products/${product.slug || product.id}`, }); } for (const category of suggestions.categories) { rows.push({ kind: 'category', id: category.id, label: category.name, // CategorySuggestion carries no slug, so filter the listing by id. href: `/products?category=${encodeURIComponent(category.id)}`, }); } return rows; }, [suggestions]); React.useEffect(() => { setActiveIndex(-1); }, [options]); React.useEffect(() => { if (!open && !expanded) return; function onPointerDown(event: PointerEvent) { if (!rootRef.current) return; if (rootRef.current.contains(event.target as Node)) return; setOpen(false); setExpanded(false); } document.addEventListener('pointerdown', onPointerDown); return () => document.removeEventListener('pointerdown', onPointerDown); }, [open, expanded]); function go(href: string) { setOpen(false); setExpanded(false); setActiveIndex(-1); router.push(href); } function submit() { const trimmed = query.trim(); if (!trimmed) return; go(`/products?search=${encodeURIComponent(trimmed)}`); } function handleKeyDown(event: React.KeyboardEvent) { if (event.key === 'Escape') { setOpen(false); setActiveIndex(-1); return; } if (event.key === 'ArrowDown' || event.key === 'ArrowUp') { if (options.length === 0) return; event.preventDefault(); setOpen(true); setActiveIndex((prev) => { const step = event.key === 'ArrowDown' ? 1 : -1; const next = prev + step; if (next < 0) return options.length - 1; if (next >= options.length) return 0; return next; }); return; } if (event.key === 'Enter') { event.preventDefault(); const active = activeIndex >= 0 ? options[activeIndex] : undefined; if (active) { go(active.href); } else { submit(); } } } const showPanel = open && (loading || suggestions !== null); const activeOptionId = activeIndex >= 0 ? `${listboxId}-${activeIndex}` : undefined; const productCount = suggestions?.products.length ?? 0; return (
{/* Mobile trigger — the field itself is hidden until this is pressed. */}
{ event.preventDefault(); submit(); }} className={cn( // Mobile: an overlay panel under the bar. Desktop: an inline pill. expanded ? 'absolute end-0 top-12 z-50 w-[min(18rem,calc(100vw-2rem))]' : 'hidden', 'sm:relative sm:end-auto sm:top-auto sm:z-auto sm:block sm:w-56 lg:w-64' )} >
{ setQuery(event.target.value); setOpen(true); }} onFocus={() => setOpen(true)} onKeyDown={handleKeyDown} className="border-border bg-background text-foreground placeholder:text-muted-foreground focus-visible:ring-primary h-10 w-full rounded-full border pe-4 ps-9 text-sm shadow-sm focus-visible:outline-none focus-visible:ring-2" />
{showPanel && (
{loading && options.length === 0 ? (

{tc('loading')}

) : options.length === 0 ? (

{tc('noResults')}

) : (
    {options.map((option, index) => ( {index === 0 && productCount > 0 && (
  • {tp('pageTitle')}
  • )} {index === productCount && option.kind === 'category' && (
  • {t('categories')}
  • )}
  • { // Keep focus in the input so the outside-click handler // cannot close the panel before the navigation runs. event.preventDefault(); go(option.href); }} onMouseEnter={() => setActiveIndex(index)} > {option.label}
  • ))}
)}
)}
); }