"use client"; import React, { useEffect, useId, useRef, useState, useMemo } from "react"; import { AnimatePresence, motion, useReducedMotion, type Transition, } from "motion/react"; import { cn } from "@/lib/utils"; export type SpotlightFilterId = "apps" | "folders" | "layers" | "docs"; export interface SpotlightItem { id: string; title: string; category: SpotlightFilterId; subtitle?: string; shortcut?: string; } export interface SpotlightSearchProps { value?: string; defaultValue?: string; onChange?: (value: string) => void; onSubmit?: (value: string, activeFilter: SpotlightFilterId | null) => void; activeFilter?: SpotlightFilterId | null; onFilterChange?: (filter: SpotlightFilterId | null) => void; placeholder?: string; items?: SpotlightItem[]; showResults?: boolean; className?: string; disabled?: boolean; } const DEFAULT_ITEMS: SpotlightItem[] = [ { id: "1", title: "Xcode", category: "apps", subtitle: "Developer Tools" }, { id: "2", title: "Safari", category: "apps", subtitle: "Web Browser" }, { id: "3", title: "Figma", category: "apps", subtitle: "Vector Graphics" }, { id: "4", title: "Terminal", category: "apps", subtitle: "Utilities" }, { id: "5", title: "Music", category: "apps", subtitle: "Entertainment" }, { id: "6", title: "Projects", category: "folders", subtitle: "~/Developer/Projects", }, { id: "7", title: "Documents", category: "folders", subtitle: "~/Documents" }, { id: "8", title: "Downloads", category: "folders", subtitle: "~/Downloads" }, { id: "9", title: "Quick Note", category: "layers", subtitle: "Shortcut", shortcut: "⌘⇧N", }, { id: "10", title: "Color Picker", category: "layers", subtitle: "Utility", shortcut: "⌥⌘C", }, { id: "11", title: "design-system.fig", category: "docs", subtitle: "Figma Document", }, { id: "12", title: "roadmap-2026.pdf", category: "docs", subtitle: "PDF Document", }, { id: "13", title: "architecture.md", category: "docs", subtitle: "Markdown File", }, ]; const fluidSpring: Transition = { type: "spring", stiffness: 170, damping: 24, mass: 1.0, }; const microSpring: Transition = { type: "spring", stiffness: 380, damping: 26, mass: 0.8, }; const instantTransition: Transition = { duration: 0 }; const MAX_WIDTH = 480; const BAR_HEIGHT = 48; const MIN_INPUT_WIDTH = 220; function AppsIcon() { return ( ); } function FolderIcon() { return ( ); } function LayersIcon() { return ( ); } function DocsIcon() { return ( ); } function SearchIcon() { return ( ); } const FILTER_BUTTONS = [ { id: "apps" as const, label: "Applications", icon: AppsIcon }, { id: "folders" as const, label: "Folders", icon: FolderIcon }, { id: "layers" as const, label: "Workspaces", icon: LayersIcon }, { id: "docs" as const, label: "Documents", icon: DocsIcon }, ]; export const SpotlightSearch: React.FC = ({ value: controlledValue, defaultValue = "", onChange, onSubmit, activeFilter: controlledFilter, onFilterChange, placeholder = "Spotlight Search", items = DEFAULT_ITEMS, showResults = true, className, disabled = false, }) => { const generatedId = useId(); const filterId = `spotlight-gooey-${generatedId.replace(/:/g, "")}`; const inputId = useId(); const listboxId = `${inputId}-results`; const inputRef = useRef(null); const containerRef = useRef(null); const reduceMotion = useReducedMotion(); const [width, setWidth] = useState(MAX_WIDTH); const [isHovered, setIsHovered] = useState(false); const [isFocused, setIsFocused] = useState(false); const [hoveredButtonId, setHoveredButtonId] = useState(null); const isControlled = controlledValue !== undefined; const [uncontrolledValue, setUncontrolledValue] = useState(defaultValue); const query = isControlled ? controlledValue : uncontrolledValue; const isControlledFilter = controlledFilter !== undefined; const [uncontrolledFilter, setUncontrolledFilter] = useState(null); const currentFilter = isControlledFilter ? controlledFilter : uncontrolledFilter; const [highlightedIndex, setHighlightedIndex] = useState(0); const isExpanded = isHovered || isFocused || query.length > 0; const isCompact = width < MAX_WIDTH; const buttonSize = isCompact ? 40 : BAR_HEIGHT; const buttonGap = isCompact ? 6 : 10; const buttonStep = buttonSize + buttonGap; const buttonOffsetY = (BAR_HEIGHT - buttonSize) / 2; const stackedFilters = width - FILTER_BUTTONS.length * buttonStep < MIN_INPUT_WIDTH; const expandedWidth = stackedFilters ? width : width - FILTER_BUTTONS.length * buttonStep; const filterOffsetX = (index: number) => stackedFilters ? index * buttonStep : expandedWidth + buttonGap + index * buttonStep; const layoutTransition = reduceMotion ? instantTransition : fluidSpring; useEffect(() => { const container = containerRef.current; if (!container) return; const measure = () => setWidth(Math.min(MAX_WIDTH, container.clientWidth)); measure(); const observer = new ResizeObserver(measure); observer.observe(container); return () => observer.disconnect(); }, []); const filteredItems = useMemo(() => { return items.filter((item) => { const matchesFilter = !currentFilter || item.category === currentFilter; const clean = query.trim().toLowerCase(); if (!clean) return matchesFilter; const matchesQuery = item.title.toLowerCase().includes(clean) || (item.subtitle && item.subtitle.toLowerCase().includes(clean)); return matchesFilter && matchesQuery; }); }, [items, currentFilter, query]); const safeHighlightedIndex = filteredItems.length > 0 ? Math.min(Math.max(0, highlightedIndex), filteredItems.length - 1) : 0; const isResultsOpen = showResults && isFocused && (query.trim().length > 0 || !!currentFilter); const hasOptions = isResultsOpen && filteredItems.length > 0; const activeOptionId = hasOptions ? `${inputId}-option-${filteredItems[safeHighlightedIndex].id}` : undefined; const handleInputChange = (e: React.ChangeEvent) => { const val = e.target.value; if (!isControlled) { setUncontrolledValue(val); } setHighlightedIndex(0); onChange?.(val); }; const handleFilterToggle = (filterIdSelected: SpotlightFilterId) => { const nextFilter = currentFilter === filterIdSelected ? null : filterIdSelected; if (!isControlledFilter) { setUncontrolledFilter(nextFilter); } setHighlightedIndex(0); onFilterChange?.(nextFilter); inputRef.current?.focus(); }; const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === "Escape") { if (query) { if (!isControlled) setUncontrolledValue(""); onChange?.(""); } else { inputRef.current?.blur(); } } else if (e.key === "ArrowDown") { e.preventDefault(); setHighlightedIndex((prev) => filteredItems.length ? (prev + 1) % filteredItems.length : 0, ); } else if (e.key === "ArrowUp") { e.preventDefault(); setHighlightedIndex((prev) => filteredItems.length ? (prev - 1 + filteredItems.length) % filteredItems.length : 0, ); } else if (e.key === "Enter") { e.preventDefault(); const selected = filteredItems[safeHighlightedIndex]; if (selected) { onSubmit?.(selected.title, currentFilter); } else { onSubmit?.(query, currentFilter); } } }; const dynamicPlaceholder = useMemo(() => { if (!currentFilter) return placeholder; switch (currentFilter) { case "apps": return "Search Applications..."; case "folders": return "Search Folders..."; case "layers": return "Search Workspaces..."; case "docs": return "Search Documents..."; default: return placeholder; } }, [currentFilter, placeholder]); return (
setIsHovered(true)} onMouseLeave={() => { setIsHovered(false); setHoveredButtonId(null); }} onFocus={() => setIsFocused(true)} onBlur={(e) => { if (!e.currentTarget.contains(e.relatedTarget)) setIsFocused(false); }} className={cn( "relative flex flex-col items-center select-none", disabled && "opacity-50 pointer-events-none", className, )} style={{ width: MAX_WIDTH, maxWidth: "100%" }} >
{FILTER_BUTTONS.map((btn, idx) => ( ))}
inputRef.current?.focus()} className={cn( "absolute left-0 top-0 h-12 flex items-center px-4 rounded-full border transition-colors cursor-text select-none", "bg-white/90 backdrop-blur-2xl border-zinc-200 dark:bg-[#1c1c1f]/85 dark:border-white/12", "shadow-[0_8px_32px_rgba(0,0,0,0.08),inset_0_1px_1.5px_rgba(255,255,255,0.8)] dark:shadow-[0_8px_32px_rgba(0,0,0,0.6),inset_0_1px_1.5px_rgba(255,255,255,0.12)]", "focus-within:border-zinc-400 focus-within:ring-1 focus-within:ring-zinc-300 dark:focus-within:border-white/30 dark:focus-within:ring-white/20", )} >
{currentFilter && ( { e.stopPropagation(); handleFilterToggle(currentFilter); }} className="ml-1.5 px-2 py-0.5 rounded-full text-[10px] font-mono uppercase bg-zinc-100 hover:bg-zinc-200 text-zinc-700 dark:bg-white/10 dark:hover:bg-white/20 dark:text-zinc-300 transition-colors" > {currentFilter} )}
{FILTER_BUTTONS.map((btn, idx) => { const IconComponent = btn.icon; const isActive = currentFilter === btn.id; const isHoveredBtn = hoveredButtonId === btn.id; const targetX = filterOffsetX(idx); return ( setHoveredButtonId(btn.id)} onMouseLeave={() => setHoveredButtonId(null)} onClick={() => handleFilterToggle(btn.id)} className={cn( "w-full h-full rounded-full flex items-center justify-center cursor-pointer outline-none transition-colors focus-visible:ring-2 focus-visible:ring-zinc-400 dark:focus-visible:ring-white/40", "bg-white/90 backdrop-blur-2xl border border-zinc-200 dark:bg-[#1c1c1f]/85 dark:border-white/12", "shadow-[0_8px_32px_rgba(0,0,0,0.08),inset_0_1px_1.5px_rgba(255,255,255,0.8)] dark:shadow-[0_8px_32px_rgba(0,0,0,0.6),inset_0_1px_1.5px_rgba(255,255,255,0.12)]", isActive ? "border-zinc-900/40 bg-zinc-100 text-zinc-950 shadow-[0_0_16px_rgba(0,0,0,0.1)] dark:border-white/40 dark:bg-white/20 dark:text-white dark:shadow-[0_0_16px_rgba(255,255,255,0.2)]" : "text-zinc-500 hover:text-zinc-900 hover:border-zinc-300 dark:text-zinc-400 dark:hover:text-white dark:hover:border-white/25", )} tabIndex={isExpanded ? 0 : -1} aria-label={btn.label} aria-pressed={isActive} title={btn.label} > {isHoveredBtn && isExpanded && ( {btn.label} )} ); })}
{isResultsOpen && ( {filteredItems.length === 0 ? (
No matching results found
) : (
{filteredItems.map((item, idx) => { const isHighlighted = idx === safeHighlightedIndex; return ( ); })}
)}
)}
); };