"use client";
import * as React from "react";
import { AnimatePresence, motion } from "motion/react";
function subscribeReducedMotion(callback: () => void) {
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
mq.addEventListener("change", callback);
return () => mq.removeEventListener("change", callback);
}
/** SSR-safe: hydrates as "no preference", then follows the media query. */
function usePrefersReducedMotion() {
return React.useSyncExternalStore(
subscribeReducedMotion,
() => window.matchMedia("(prefers-reduced-motion: reduce)").matches,
() => false,
);
}
export interface ExpandingSearchSpring {
stiffness: number;
damping: number;
mass: number;
}
export interface ExpandingSearchProps
extends Omit<React.FormHTMLAttributes<HTMLFormElement>, "onSubmit" | "children" | "onChange"> {
/** Called with the trimmed query when the user submits or picks a suggestion. */
onSearch?: (query: string) => void;
/** Called on every keystroke. */
onQueryChange?: (query: string) => void;
/** Suggestions, filtered by case-insensitive substring as the user types. */
suggestions?: string[];
/** Initial recent searches (most recent first). New searches are added to the front. */
defaultRecent?: string[];
/** Show and record recent searches. */
showRecent?: boolean;
/** How many recent searches to keep. */
maxRecent?: number;
/** Called whenever the recent list changes, e.g. to persist it. */
onRecentChange?: (recent: string[]) => void;
/** Visually hidden label for the field; also names the collapsed button. */
label?: string;
placeholder?: string;
/** Single key that opens the search from anywhere on the page. Empty string disables it. */
shortcut?: string;
/** Width of the open field in px (capped by the parent's width). */
expandedWidth?: number;
/** Spring for the width morph. */
spring?: ExpandingSearchSpring;
/** Collapse back to the icon when focus leaves an empty field. */
collapseOnBlur?: boolean;
/** Start expanded (without stealing focus). */
defaultOpen?: boolean;
/** Focus ring, caret and highlight colour (any CSS colour). */
accent?: string;
}
type Item = { text: string; kind: "recent" | "suggestion" };
const COLLAPSED = 40;
const isEditable = (el: EventTarget | null) => {
if (!(el instanceof HTMLElement)) return false;
const tag = el.tagName;
return tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT" || el.isContentEditable;
};
function Highlight({ text, query }: { text: string; query: string }) {
const at = query ? text.toLowerCase().indexOf(query) : -1;
if (at === -1) return <>{text}</>;
return (
<>
{text.slice(0, at)}
<mark className="bg-transparent font-semibold text-zinc-950 underline decoration-[var(--es-accent)] decoration-2 underline-offset-[3px] dark:text-white">
{text.slice(at, at + query.length)}
</mark>
{text.slice(at + query.length)}
</>
);
}
/**
* A search icon that springs open into a full search field. It is a `role="search"` landmark with
* a visually hidden label; the field is an ARIA 1.2 combobox whose listbox offers recent searches
* and filtered suggestions via aria-activedescendant. A single-key shortcut ("/" by default)
* opens it unless the user is typing elsewhere; Escape collapses it and returns focus.
*/
export function ExpandingSearch({
onSearch,
onQueryChange,
suggestions = [],
defaultRecent = [],
showRecent = true,
maxRecent = 5,
onRecentChange,
label = "Search",
placeholder = "Search components…",
shortcut = "/",
expandedWidth = 320,
spring = { stiffness: 420, damping: 34, mass: 0.8 },
collapseOnBlur = true,
defaultOpen = false,
accent = "#FF7448",
className = "",
style,
...props
}: ExpandingSearchProps) {
const reduceMotion = usePrefersReducedMotion();
const uid = React.useId();
const inputId = `es-${uid}`;
const listboxId = `${inputId}-listbox`;
const optionId = (i: number) => `${inputId}-opt-${i}`;
const [open, setOpen] = React.useState(defaultOpen);
const [query, setQuery] = React.useState("");
const [panelOpen, setPanelOpen] = React.useState(false);
const [activeIndex, setActiveIndex] = React.useState(-1);
const [recent, setRecent] = React.useState<string[]>(defaultRecent);
const [announcement, setAnnouncement] = React.useState("");
const formRef = React.useRef<HTMLFormElement>(null);
const inputRef = React.useRef<HTMLInputElement>(null);
const triggerRef = React.useRef<HTMLButtonElement>(null);
const listRef = React.useRef<HTMLDivElement>(null);
/** Where focus should land after the next open/close render. */
const pendingFocus = React.useRef<"input" | "trigger" | null>(null);
/* ------------------------------- derived items ------------------------------ */
const q = query.trim().toLowerCase();
const recentItems: Item[] = showRecent
? recent.filter((r) => !q || r.toLowerCase().includes(q)).map((text) => ({ text, kind: "recent" }))
: [];
const suggestionItems: Item[] = suggestions
.filter((s) => (!q || s.toLowerCase().includes(q)) && !recentItems.some((r) => r.text.toLowerCase() === s.toLowerCase()))
.slice(0, q ? 6 : 4)
.map((text) => ({ text, kind: "suggestion" }));
const items = [...recentItems, ...suggestionItems];
const listOpen = open && panelOpen && items.length > 0;
const active = listOpen && activeIndex < items.length ? activeIndex : -1;
/* --------------------------------- actions -------------------------------- */
const updateRecent = (next: string[]) => {
setRecent(next);
onRecentChange?.(next);
};
const expand = (focus: boolean) => {
if (focus) pendingFocus.current = "input";
setOpen(true);
setPanelOpen(true);
if (focus && open) inputRef.current?.focus();
};
const collapse = (returnFocus: boolean) => {
if (returnFocus) pendingFocus.current = "trigger";
setOpen(false);
setPanelOpen(false);
setActiveIndex(-1);
setQuery("");
onQueryChange?.("");
};
const run = (text: string) => {
const value = text.trim();
if (!value) return;
setQuery(value);
setPanelOpen(false);
setActiveIndex(-1);
if (showRecent) updateRecent([value, ...recent.filter((r) => r.toLowerCase() !== value.toLowerCase())].slice(0, maxRecent));
setAnnouncement(`Searching for ${value}.`);
onSearch?.(value);
};
const scrollToOption = (index: number) => {
const list = listRef.current;
const el = list?.querySelector<HTMLElement>(`#${CSS.escape(optionId(index))}`);
if (!list || !el) return;
// Scroll the listbox only — scrollIntoView would also move the page.
if (el.offsetTop < list.scrollTop) list.scrollTop = el.offsetTop - 4;
else if (el.offsetTop + el.offsetHeight > list.scrollTop + list.clientHeight)
list.scrollTop = el.offsetTop + el.offsetHeight - list.clientHeight + 4;
};
const move = (delta: 1 | -1) => {
if (!items.length) return;
const next = active === -1 ? (delta === 1 ? 0 : items.length - 1) : (active + delta + items.length) % items.length;
setActiveIndex(next);
requestAnimationFrame(() => scrollToOption(next));
};
// Move focus once the open/closed markup exists.
React.useEffect(() => {
const target = pendingFocus.current;
if (!target) return;
pendingFocus.current = null;
if (target === "input" && open) inputRef.current?.focus();
if (target === "trigger" && !open) triggerRef.current?.focus();
}, [open]);
// Global single-key shortcut, ignored while typing in any other field or with modifiers.
const openFromShortcut = React.useEffectEvent((event: KeyboardEvent) => {
if (!shortcut || event.key !== shortcut || event.metaKey || event.ctrlKey || event.altKey) return;
if (event.defaultPrevented || isEditable(event.target)) return;
event.preventDefault();
expand(true);
});
React.useEffect(() => {
if (!shortcut) return;
const onKey = (event: KeyboardEvent) => openFromShortcut(event);
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [shortcut]);
/* --------------------------------- keyboard -------------------------------- */
const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
if (event.nativeEvent.isComposing) return;
switch (event.key) {
case "ArrowDown":
event.preventDefault();
if (!panelOpen) setPanelOpen(true);
else move(1);
break;
case "ArrowUp":
event.preventDefault();
if (!panelOpen) setPanelOpen(true);
else move(-1);
break;
case "Enter":
if (active !== -1) {
event.preventDefault();
run(items[active].text);
}
break;
case "Delete":
// Shift+Delete forgets the highlighted recent search.
if (event.shiftKey && active !== -1 && items[active].kind === "recent") {
event.preventDefault();
const text = items[active].text;
updateRecent(recent.filter((r) => r !== text));
setAnnouncement(`${text} removed from recent searches.`);
setActiveIndex(-1);
}
break;
case "Escape":
event.preventDefault();
collapse(true);
break;
}
};
/* ---------------------------------- render --------------------------------- */
const transition = reduceMotion ? { duration: 0 } : { type: "spring" as const, ...spring };
const statusText = listOpen
? `${items.length} ${items.length === 1 ? "suggestion" : "suggestions"}. Use the arrow keys to choose.`
: "";
const renderOption = (item: Item, index: number) => (
<div
key={`${item.kind}-${item.text}`}
id={optionId(index)}
role="option"
aria-selected={index === active}
onClick={() => run(item.text)}
onPointerMove={(e) => {
if (e.pointerType === "mouse" && index !== active) setActiveIndex(index);
}}
className={`group/opt flex cursor-pointer items-center gap-2.5 rounded-lg px-2.5 py-2 text-[13px] transition-colors duration-100 ${
index === active
? "bg-[color-mix(in_srgb,var(--es-accent)_12%,transparent)] text-zinc-950 dark:text-white"
: "text-zinc-700 dark:text-zinc-300"
}`}
>
<span aria-hidden="true" className={`grid size-4 shrink-0 place-items-center ${index === active ? "text-[var(--es-accent)]" : "text-zinc-400 dark:text-zinc-500"}`}>
{item.kind === "recent" ? (
<svg viewBox="0 0 16 16" className="size-3.5" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round">
<circle cx="8" cy="8" r="5.75" />
<path d="M8 5v3.2l2 1.3" />
</svg>
) : (
<svg viewBox="0 0 16 16" className="size-3.5" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round">
<path d="M3 13L13 3M6 3h7v7" />
</svg>
)}
</span>
<span className="min-w-0 flex-1 truncate">
<Highlight text={item.text} query={q} />
</span>
{index === active && (
<span aria-hidden="true" className="font-mono text-[10px] uppercase tracking-[0.14em] text-zinc-400 dark:text-zinc-500">
Enter
</span>
)}
</div>
);
return (
<form
ref={formRef}
role="search"
{...props}
onSubmit={(event) => {
event.preventDefault();
run(query);
}}
onBlur={(event) => {
// Focus left the whole search (not just moved between its parts).
if (formRef.current?.contains(event.relatedTarget as Node | null)) return;
setPanelOpen(false);
setActiveIndex(-1);
if (collapseOnBlur && !query.trim()) collapse(false);
}}
className={`relative max-w-full ${className}`}
style={{ ["--es-accent" as string]: accent, ...style }}
>
<motion.div
initial={false}
animate={{ width: open ? expandedWidth : COLLAPSED }}
transition={transition}
className={`group/es relative flex h-10 max-w-full items-center overflow-hidden rounded-full border bg-white transition-[border-color,box-shadow] duration-150 motion-reduce:transition-none dark:bg-zinc-950 ${
open
? "border-zinc-900/15 focus-within:border-[var(--es-accent)] focus-within:shadow-[0_0_0_3px_color-mix(in_srgb,var(--es-accent)_22%,transparent)] dark:border-white/15"
: "border-zinc-900/15 hover:border-zinc-900/35 dark:border-white/15 dark:hover:border-white/35"
}`}
>
{open ? (
<>
<span aria-hidden="true" className="grid size-10 shrink-0 place-items-center text-zinc-500 dark:text-zinc-400">
<svg viewBox="0 0 16 16" className="size-4" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round">
<circle cx="7" cy="7" r="4.75" />
<path d="M10.5 10.5L14 14" />
</svg>
</span>
<label htmlFor={inputId} className="sr-only">
{label}
</label>
<input
ref={inputRef}
id={inputId}
type="search"
role="combobox"
aria-expanded={listOpen}
aria-controls={listboxId}
aria-autocomplete="list"
aria-activedescendant={active !== -1 ? optionId(active) : undefined}
aria-keyshortcuts={shortcut || undefined}
enterKeyHint="search"
autoComplete="off"
spellCheck={false}
value={query}
placeholder={placeholder}
onChange={(event) => {
setQuery(event.target.value);
onQueryChange?.(event.target.value);
setPanelOpen(true);
setActiveIndex(-1);
setAnnouncement("");
}}
onFocus={() => setPanelOpen(true)}
onClick={() => setPanelOpen(true)}
onKeyDown={handleKeyDown}
className="h-full min-w-0 flex-1 bg-transparent pr-2 text-[14px] text-zinc-950 caret-[var(--es-accent)] outline-hidden placeholder:text-zinc-400 dark:text-zinc-50 dark:placeholder:text-zinc-500 [&::-webkit-search-cancel-button]:appearance-none"
/>
{query ? (
<button
type="button"
aria-label="Clear search"
onClick={() => {
setQuery("");
onQueryChange?.("");
setPanelOpen(true);
setActiveIndex(-1);
inputRef.current?.focus();
}}
className="mr-1.5 grid size-7 shrink-0 place-items-center rounded-full text-zinc-500 outline-hidden transition-colors hover:bg-zinc-900/[0.06] hover:text-zinc-900 focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-[var(--es-accent)] dark:text-zinc-400 dark:hover:bg-white/[0.08] dark:hover:text-white"
>
<svg aria-hidden="true" viewBox="0 0 12 12" className="size-3" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round">
<path d="M3 3l6 6M9 3l-6 6" />
</svg>
</button>
) : (
<kbd
aria-hidden="true"
className="mr-2 shrink-0 rounded-md border border-zinc-900/10 px-1.5 py-0.5 font-mono text-[10px] uppercase tracking-[0.1em] text-zinc-400 opacity-0 transition-opacity group-focus-within/es:opacity-100 dark:border-white/10 dark:text-zinc-500"
>
Esc
</kbd>
)}
</>
) : (
<button
ref={triggerRef}
type="button"
aria-label={label}
aria-keyshortcuts={shortcut || undefined}
title={shortcut ? `${label} (${shortcut})` : label}
onClick={() => expand(true)}
className="grid size-10 shrink-0 place-items-center rounded-full text-zinc-700 outline-hidden transition-colors hover:text-zinc-950 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-solid focus-visible:outline-[var(--es-accent)] dark:text-zinc-300 dark:hover:text-white"
>
<svg aria-hidden="true" viewBox="0 0 16 16" className="size-4" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round">
<circle cx="7" cy="7" r="4.75" />
<path d="M10.5 10.5L14 14" />
</svg>
</button>
)}
</motion.div>
<AnimatePresence>
{listOpen && (
<motion.div
key="panel"
initial={reduceMotion ? { opacity: 0 } : { opacity: 0, y: -6, scale: 0.98 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={reduceMotion ? { opacity: 0, transition: { duration: 0 } } : { opacity: 0, y: -4, transition: { duration: 0.12 } }}
transition={reduceMotion ? { duration: 0 } : { type: "spring", stiffness: 560, damping: 38, mass: 0.6 }}
style={{ transformOrigin: "top", width: expandedWidth }}
className="absolute left-0 top-[calc(100%+6px)] z-30 max-w-full overflow-hidden rounded-xl border border-zinc-900/10 bg-white shadow-[0_16px_40px_-16px_rgba(0,0,0,0.3)] dark:border-white/10 dark:bg-zinc-900"
onMouseDown={(e) => e.preventDefault()}
>
<div ref={listRef} id={listboxId} role="listbox" aria-label={`${label} suggestions`} className="max-h-72 overflow-y-auto overscroll-contain p-1">
{recentItems.length > 0 && (
<div role="group" aria-labelledby={`${inputId}-recent`}>
<div
id={`${inputId}-recent`}
role="presentation"
className="px-2.5 pb-1 pt-1.5 font-mono text-[10px] uppercase tracking-[0.14em] text-zinc-400 dark:text-zinc-500"
>
Recent
</div>
{recentItems.map((item, i) => renderOption(item, i))}
</div>
)}
{suggestionItems.length > 0 && (
<div
role="group"
aria-labelledby={`${inputId}-suggest`}
className={recentItems.length ? "mt-1 border-t border-zinc-900/[0.06] pt-1 dark:border-white/[0.06]" : ""}
>
<div id={`${inputId}-suggest`} role="presentation" className="px-2.5 pb-1 pt-1.5 font-mono text-[10px] uppercase tracking-[0.14em] text-zinc-400 dark:text-zinc-500">
{q ? "Suggestions" : "Popular"}
</div>
{suggestionItems.map((item, i) => renderOption(item, recentItems.length + i))}
</div>
)}
</div>
{showRecent && recent.length > 0 && (
<div className="flex items-center justify-between gap-3 border-t border-zinc-900/[0.06] px-3.5 py-2 font-mono text-[10px] uppercase tracking-[0.14em] text-zinc-400 dark:border-white/[0.06] dark:text-zinc-500">
<span aria-hidden="true">Shift + Del forgets</span>
<button
type="button"
tabIndex={-1}
onClick={() => {
updateRecent([]);
setActiveIndex(-1);
setAnnouncement("Recent searches cleared.");
}}
className="uppercase tracking-[0.14em] transition-colors hover:text-zinc-900 dark:hover:text-white"
>
Clear recent
</button>
</div>
)}
</motion.div>
)}
</AnimatePresence>
<span className="sr-only" aria-live="polite" aria-atomic="true">
{announcement || statusText}
</span>
</form>
);
}