"use client"; import { type LucideIcon, Search } from "lucide-react"; import { AnimatePresence, LayoutGroup, motion, type Transition, useReducedMotion, } from "motion/react"; import { type KeyboardEvent as ReactKeyboardEvent, useCallback, useEffect, useId, useMemo, useRef, useState, } from "react"; import { createPortal } from "react-dom"; import { EASE_OUT, SPRING_LAYOUT } from "@/lib/ease"; import { cn } from "@/lib/utils"; // Keeps the Wallet Card feel with a little more time to read the morph. const SEARCH_MORPH: Transition = { type: "spring", duration: 0.58, bounce: 0.22, }; // Keep the spring on the shell, but unfold complex clip-path values with the // same progressive tween as Morph Popover so the content never snaps ahead. const SEARCH_CLIP_TRANSITION: Transition = { duration: 0.32, ease: EASE_OUT, }; export type MorphingSearchItem = { id: string; title: string; description?: string; keywords?: string[]; icon?: LucideIcon; onSelect?: () => void; }; export interface MorphingSearchProps { items: MorphingSearchItem[]; placeholder?: string; shortcut?: string; /** Render the closed trigger as a compact search icon. */ iconOnly?: boolean; emptyMessage?: string; open?: boolean; defaultOpen?: boolean; onOpenChange?: (open: boolean) => void; onQueryChange?: (query: string) => void; onSelect?: (item: MorphingSearchItem) => void; className?: string; } type AnchorRect = { top: number; left: number; width: number; }; function isEditableTarget(target: EventTarget | null) { if (!(target instanceof HTMLElement)) return false; return ( target.isContentEditable || target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement || target instanceof HTMLSelectElement ); } export function MorphingSearch({ items, placeholder = "Search", shortcut = "f", iconOnly = false, emptyMessage = "No results found.", open: controlledOpen, defaultOpen = false, onOpenChange, onQueryChange, onSelect, className, }: MorphingSearchProps) { const [internalOpen, setInternalOpen] = useState(defaultOpen); const [query, setQuery] = useState(""); const [activeIndex, setActiveIndex] = useState(0); const [mounted, setMounted] = useState(false); const [backgroundScrollLocked, setBackgroundScrollLocked] = useState(defaultOpen); const [anchorRect, setAnchorRect] = useState({ top: 16, left: 16, width: 288, }); const open = controlledOpen ?? internalOpen; const controlled = controlledOpen !== undefined; const reduce = useReducedMotion(); const uid = useId(); const anchorRef = useRef(null); const triggerRef = useRef(null); const inputRef = useRef(null); const dialogRef = useRef(null); const listRef = useRef(null); const previousFocusRef = useRef(null); const wasOpenRef = useRef(open); const transition: Transition = reduce ? { duration: 0 } : SPRING_LAYOUT; const morphTransition: Transition = reduce ? { duration: 0 } : SEARCH_MORPH; const setOpen = useCallback( (next: boolean) => { if (!controlled) setInternalOpen(next); onOpenChange?.(next); }, [controlled, onOpenChange], ); const measureAnchor = useCallback(() => { const rect = anchorRef.current?.getBoundingClientRect(); if (!rect || rect.width === 0) return; setAnchorRect({ top: rect.top, left: rect.left, width: rect.width }); }, []); const openSearch = useCallback(() => { measureAnchor(); setBackgroundScrollLocked(true); previousFocusRef.current = document.activeElement instanceof HTMLElement ? document.activeElement : null; setOpen(true); }, [measureAnchor, setOpen]); const updateQuery = useCallback( (next: string) => { setQuery(next); setActiveIndex(0); onQueryChange?.(next); }, [onQueryChange], ); const closeSearch = useCallback(() => { updateQuery(""); setOpen(false); }, [setOpen, updateQuery]); useEffect(() => setMounted(true), []); useEffect(() => { if (open) setBackgroundScrollLocked(true); }, [open]); useEffect(() => { measureAnchor(); const anchor = anchorRef.current; const observer = anchor && typeof ResizeObserver !== "undefined" ? new ResizeObserver(measureAnchor) : null; if (anchor) observer?.observe(anchor); window.addEventListener("resize", measureAnchor); document.addEventListener("scroll", measureAnchor, true); window.visualViewport?.addEventListener("resize", measureAnchor); window.visualViewport?.addEventListener("scroll", measureAnchor); return () => { observer?.disconnect(); window.removeEventListener("resize", measureAnchor); document.removeEventListener("scroll", measureAnchor, true); window.visualViewport?.removeEventListener("resize", measureAnchor); window.visualViewport?.removeEventListener("scroll", measureAnchor); }; }, [measureAnchor]); useEffect(() => { if (!backgroundScrollLocked) return; const preventBackgroundWheel = (event: WheelEvent) => { const target = event.target; if (target instanceof Node && listRef.current?.contains(target)) return; event.preventDefault(); }; const preventBackgroundTouch = (event: TouchEvent) => { const target = event.target; if (target instanceof Node && listRef.current?.contains(target)) return; event.preventDefault(); }; document.addEventListener("wheel", preventBackgroundWheel, { passive: false, }); document.addEventListener("touchmove", preventBackgroundTouch, { passive: false, }); return () => { document.removeEventListener("wheel", preventBackgroundWheel); document.removeEventListener("touchmove", preventBackgroundTouch); }; }, [backgroundScrollLocked]); useEffect(() => { const handleShortcut = (event: KeyboardEvent) => { if (event.key === "Escape" && open) { event.preventDefault(); closeSearch(); return; } if ( !open && shortcut && event.key.toLowerCase() === shortcut.toLowerCase() && !event.repeat && !event.metaKey && !event.ctrlKey && !event.altKey && !event.shiftKey && !isEditableTarget(event.target) ) { event.preventDefault(); openSearch(); } }; window.addEventListener("keydown", handleShortcut); return () => window.removeEventListener("keydown", handleShortcut); }, [closeSearch, open, openSearch, shortcut]); useEffect(() => { if (open) { updateQuery(""); const frame = requestAnimationFrame(() => inputRef.current?.focus()); return () => cancelAnimationFrame(frame); } if (wasOpenRef.current) { const frame = requestAnimationFrame(() => { const previousFocus = previousFocusRef.current; const focusTarget = previousFocus?.isConnected ? previousFocus : triggerRef.current; focusTarget?.focus(); }); return () => cancelAnimationFrame(frame); } }, [open, updateQuery]); useEffect(() => { wasOpenRef.current = open; }, [open]); const filteredItems = useMemo(() => { const needle = query.trim().toLowerCase(); if (!needle) return items; return items.filter((item) => [item.title, item.description ?? "", ...(item.keywords ?? [])] .join(" ") .toLowerCase() .includes(needle), ); }, [items, query]); useEffect(() => { if (activeIndex < filteredItems.length) return; setActiveIndex(Math.max(0, filteredItems.length - 1)); }, [activeIndex, filteredItems.length]); useEffect(() => { if (!open) return; listRef.current ?.querySelector(`[data-index="${activeIndex}"]`) ?.scrollIntoView({ block: "nearest" }); }, [activeIndex, open]); const selectItem = useCallback( (item: MorphingSearchItem) => { item.onSelect?.(); onSelect?.(item); closeSearch(); }, [closeSearch, onSelect], ); const handleDialogKeyDown = (event: ReactKeyboardEvent) => { if (event.key === "ArrowDown") { event.preventDefault(); if (filteredItems.length === 0) return; setActiveIndex((current) => Math.min(current + 1, filteredItems.length - 1), ); return; } if (event.key === "ArrowUp") { event.preventDefault(); setActiveIndex((current) => Math.max(current - 1, 0)); return; } if (event.key === "Enter") { event.preventDefault(); const item = filteredItems[activeIndex]; if (item) selectItem(item); return; } if (event.key !== "Tab" || !dialogRef.current) return; const focusable = Array.from( dialogRef.current.querySelectorAll( 'input, button:not([disabled]), [tabindex]:not([tabindex="-1"])', ), ); const first = focusable[0]; const last = focusable.at(-1); if (!first || !last) return; if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last.focus(); } else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first.focus(); } }; const shellLayoutId = `${uid}-shell`; const listboxId = `${uid}-results`; const panelWidth = mounted ? Math.max( anchorRect.width, Math.min(448, window.innerWidth - anchorRect.left - 16), ) : anchorRect.width; const resultsHeight = mounted ? Math.max(96, Math.min(288, window.innerHeight - anchorRect.top - 80)) : 288; const collapsedContentClip = `inset(0px ${Math.max( 0, panelWidth - anchorRect.width, )}px ${resultsHeight}px 0px round 12px)`; const expandedContentClip = "inset(0px 0px 0px 0px round 12px)"; const overlay = mounted ? createPortal(
setBackgroundScrollLocked(false)} > {open ? ( ); }) ) : (

{emptyMessage}

)}
) : null}
, document.body, ) : null; return (
{!open ? ( ) : null}
{overlay}
); }