"use client"; import { ChevronDown } from "lucide-react"; import { createContext, type KeyboardEvent, type ReactNode, useCallback, useContext, useId, useMemo, useState, } from "react"; import { cn } from "@/lib/utils"; type AccordionContextValue = { isOpen: (value: string) => boolean; toggle: (value: string) => void; }; type ItemContextValue = { value: string; open: boolean; triggerId: string; panelId: string }; const AccordionContext = createContext(null); const ItemContext = createContext(null); function useAccordionPart(context: React.Context, part: string): T { const value = useContext(context); if (!value) throw new Error(`${part} must be used inside an Accordion`); return value; } export interface AccordionProps { /** Let several items stay open at once. Default false. */ multiple?: boolean; /** Open items when uncontrolled. */ defaultValue?: string[]; /** Open items when controlled. */ value?: string[]; /** Called with the open items after each change. */ onValueChange?: (value: string[]) => void; className?: string; children: ReactNode; } /** Arrow keys, Home, and End move between triggers, like a list of tabs. */ function moveFocus(event: KeyboardEvent) { const keys = ["ArrowDown", "ArrowUp", "Home", "End"]; const target = event.target as HTMLElement; if (!keys.includes(event.key) || !target.matches("[data-accordion-trigger]")) return; const triggers = Array.from( event.currentTarget.querySelectorAll("[data-accordion-trigger]:not(:disabled)"), ); const index = triggers.indexOf(target as HTMLButtonElement); const next = event.key === "Home" ? 0 : event.key === "End" ? triggers.length - 1 : (index + (event.key === "ArrowDown" ? 1 : -1) + triggers.length) % triggers.length; event.preventDefault(); triggers[next]?.focus(); } export function Accordion({ multiple = false, defaultValue = [], value, onValueChange, className, children, }: AccordionProps) { const [uncontrolled, setUncontrolled] = useState(defaultValue); const openValues = value ?? uncontrolled; const toggle = useCallback( (item: string) => { const isOpen = openValues.includes(item); const next = isOpen ? openValues.filter((v) => v !== item) : multiple ? [...openValues, item] : [item]; if (value === undefined) setUncontrolled(next); onValueChange?.(next); }, [multiple, onValueChange, openValues, value], ); const context = useMemo( () => ({ isOpen: (item: string) => openValues.includes(item), toggle }), [openValues, toggle], ); return ( {/* biome-ignore lint/a11y/noStaticElementInteractions: key handling only moves focus between the trigger buttons inside. */}
{children}
); } export interface AccordionItemProps { /** Unique id for this item within the accordion. */ value: string; className?: string; children: ReactNode; } export function AccordionItem({ value, className, children }: AccordionItemProps) { const { isOpen } = useAccordionPart(AccordionContext, "AccordionItem"); const id = useId(); const open = isOpen(value); const item = useMemo( () => ({ value, open, triggerId: `${id}-trigger`, panelId: `${id}-panel` }), [id, open, value], ); return (
{children}
); } export interface AccordionTriggerProps { className?: string; disabled?: boolean; children: ReactNode; } export function AccordionTrigger({ className, disabled, children }: AccordionTriggerProps) { const { toggle } = useAccordionPart(AccordionContext, "AccordionTrigger"); const { value, open, triggerId, panelId } = useAccordionPart(ItemContext, "AccordionTrigger"); return (

); } export interface AccordionContentProps { className?: string; children: ReactNode; } /** * The panel grows from zero rows to its full height with a grid transition, * so there is nothing to measure. Closed panels are inert, so Tab skips them. */ export function AccordionContent({ className, children }: AccordionContentProps) { const { open, triggerId, panelId } = useAccordionPart(ItemContext, "AccordionContent"); return (
{children}
); }