"use client"; import { Plus, Search, X } from "lucide-react"; import { AnimatePresence, motion, type Transition, useReducedMotion, } from "motion/react"; import { type ReactNode, useCallback, useEffect, useId, useMemo, useRef, useState, } from "react"; import { EASE_OUT, SPRING_LAYOUT, SPRING_SWAP } from "@/lib/ease"; import { cn } from "@/lib/utils"; export interface FaqItem { /** Stable id. Also the element id / URL hash when `deepLink` is on. */ id: string; question: string; answer: ReactNode; /** * Plain-text version of `answer` used for search when `answer` is not a * string. String answers are searched (and highlighted) directly. */ searchText?: string; } export interface FaqSectionProps { items: FaqItem[]; eyebrow?: ReactNode; heading?: ReactNode; description?: ReactNode; /** "single" keeps one answer open at a time; "multiple" lets several stay open. */ type?: "single" | "multiple"; /** Controlled open item ids. */ value?: string[]; /** Uncontrolled initial open item ids. */ defaultValue?: string[]; onValueChange?: (value: string[]) => void; /** Show a search field that narrows and highlights questions. */ searchable?: boolean; searchPlaceholder?: string; /** Rendered when the search matches nothing. Receives the query. */ emptyState?: (query: string) => ReactNode; /** "Still have questions?" slot under the header column. */ cta?: ReactNode; /** * Give each item `id={item.id}`, open the item named by `location.hash` * (on mount and on hashchange) and write the hash when an item opens. */ deepLink?: boolean; className?: string; } const PANEL_TRANSITION: Transition = { duration: 0.32, ease: EASE_OUT }; const PANEL_EXIT_TRANSITION: Transition = { duration: 0.22, ease: EASE_OUT }; function escapeRegExp(value: string) { return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } /** Wrap every case-insensitive occurrence of `query` in a . */ function highlight(text: string, query: string): ReactNode { const q = query.trim(); if (!q) return text; const parts = text.split(new RegExp(`(${escapeRegExp(q)})`, "gi")); const lower = q.toLowerCase(); let offset = 0; return parts.map((part) => { const key = `${offset}-${part}`; offset += part.length; return part.toLowerCase() === lower ? ( {part} ) : ( part ); }); } function matches(item: FaqItem, query: string) { const q = query.trim().toLowerCase(); if (!q) return true; const answerText = typeof item.answer === "string" ? item.answer : (item.searchText ?? ""); return ( item.question.toLowerCase().includes(q) || answerText.toLowerCase().includes(q) ); } /** * FAQ page section: a header column (eyebrow, heading, description, optional * search and a CTA slot) beside an accordion of questions. Searching narrows * the list with layout animation and highlights matches; items can be * deep-linked by hash. Columns sit side by side once the section's own * container is wide enough and stack below that. */ export function FaqSection({ items, eyebrow = "FAQ", heading = "Frequently asked questions", description, type = "single", value, defaultValue = [], onValueChange, searchable = false, searchPlaceholder = "Search questions", emptyState, cta, deepLink = false, className, }: FaqSectionProps) { const reduce = useReducedMotion(); const baseId = useId(); const [internal, setInternal] = useState(defaultValue); const open = value ?? internal; const [query, setQuery] = useState(""); const setOpen = useCallback( (next: string[]) => { if (value === undefined) setInternal(next); onValueChange?.(next); }, [value, onValueChange], ); const toggle = useCallback( (id: string) => { const isOpen = open.includes(id); const next = isOpen ? open.filter((x) => x !== id) : type === "single" ? [id] : [...open, id]; setOpen(next); if (deepLink && !isOpen && typeof window !== "undefined") { window.history.replaceState(null, "", `#${id}`); } }, [open, type, setOpen, deepLink], ); // Latest state for the hash listener, read through a ref so the listener // subscribes once and never re-scrolls to the hash item on every toggle. const latest = useRef({ open, type, setOpen, reduce }); latest.current = { open, type, setOpen, reduce }; // Deep links: read the hash after mount (never during render) and follow // later hash changes. useEffect(() => { if (!deepLink) return; const openFromHash = () => { const id = decodeURIComponent(window.location.hash.slice(1)); if (!id || !items.some((item) => item.id === id)) return; const { open: current, type: mode, setOpen: set, reduce: still } = latest.current; if (!current.includes(id)) { set(mode === "single" ? [id] : [...current, id]); } document.getElementById(id)?.scrollIntoView({ block: "nearest", behavior: still ? "auto" : "smooth", }); }; openFromHash(); window.addEventListener("hashchange", openFromHash); return () => window.removeEventListener("hashchange", openFromHash); }, [deepLink, items]); const filtered = useMemo( () => items.filter((item) => matches(item, query)), [items, query], ); const layoutTransition = reduce ? { duration: 0 } : SPRING_LAYOUT; return (
{eyebrow ? (

{eyebrow}

) : null} {heading ? (

{heading}

) : null} {description ? (

{description}

) : null}
{searchable ? (
setQuery(event.target.value)} placeholder={searchPlaceholder} autoComplete="off" className="h-10 w-full rounded-full border border-border bg-card pr-10 pl-10 text-sm text-foreground outline-none transition-colors placeholder:text-muted-foreground focus-visible:border-border-strong focus-visible:ring-2 focus-visible:ring-ring/40 [&::-webkit-search-cancel-button]:appearance-none" /> {query ? ( setQuery("")} initial={{ opacity: 0, scale: reduce ? 1 : 0.6 }} animate={{ opacity: 1, scale: 1 }} exit={{ opacity: 0, scale: reduce ? 1 : 0.6 }} transition={SPRING_SWAP} className="absolute top-1/2 right-2 grid size-7 -translate-y-1/2 place-items-center rounded-full text-muted-foreground outline-none hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring/40" > ) : null}

{query ? `${filtered.length} of ${items.length} questions shown` : ""}

) : null} {cta ?
{cta}
: null}
{/* layoutRoot keeps the rows' layout animation measured relative to this list, so page scroll never replays as movement. */} {filtered.map((item) => ( toggle(item.id)} /> ))} {filtered.length === 0 ? ( {emptyState ? ( emptyState(query) ) : ( <> No questions match “{query.trim()}”.{" "} )} ) : null} {cta ?
{cta}
: null}
); } function FaqRow({ item, baseId, open, query, deepLink, reduce, layoutTransition, onToggle, }: { item: FaqItem; baseId: string; open: boolean; query: string; deepLink: boolean; reduce: boolean | null; layoutTransition: Transition; onToggle: () => void; }) { const triggerId = `${baseId}-${item.id}-trigger`; const panelId = `${baseId}-${item.id}-panel`; return ( // Layout animation is the point here: rows glide into the gaps left by // filtered-out questions and by answers opening/closing.

{/* Height is animated on purpose: the accordion's job is to disclose content in flow, which has to push the following rows down. */}
{typeof item.answer === "string" ? highlight(item.answer, query) : item.answer}
); }