1"use client";2
3import React, { useCallback, useId, useRef, useState } from "react";4import { AnimatePresence, motion, useReducedMotion } from "motion/react";5import { cn } from "@/lib/utils";6
7export type SpringAccordionItem = {8 value: string;9 title: string;10 11 subtitle?: string;12 icon?: React.ReactNode;13 children: React.ReactNode;14 disabled?: boolean;15};16
17type SpringAccordionBase = {18 items: SpringAccordionItem[];19 className?: string;20};21
22type SpringAccordionSingle = {23 type?: "single";24 value?: string;25 defaultValue?: string;26 onValueChange?: (value: string) => void;27};28
29type SpringAccordionMultiple = {30 type: "multiple";31 value?: string[];32 defaultValue?: string[];33 onValueChange?: (value: string[]) => void;34};35
36export type SpringAccordionProps = SpringAccordionBase &37 (SpringAccordionSingle | SpringAccordionMultiple);38
39const HEIGHT_SPRING = { type: "spring" as const, bounce: 0.12, duration: 0.5 };40const EASE_OUT = [0.23, 1, 0.32, 1] as const;41
42function toArray(v: string | string[] | undefined): string[] {43 if (v === undefined || v === "") return [];44 return Array.isArray(v) ? v : [v];45}46
4748function PlusMinus({ open, reduce }: { open: boolean; reduce: boolean }) {49 return (50 <svg viewBox="0 0 16 16" className="size-3.5" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" aria-hidden>51 <path d="M3 8h10" />52 <motion.path53 d="M8 3v10"54 initial={false}55 animate={{ rotate: open ? 90 : 0, opacity: open ? 0 : 1 }}56 transition={reduce ? { duration: 0 } : { type: "spring", bounce: 0.3, duration: 0.45 }}57 style={{ originX: "50%", originY: "50%" }}58 />59 </svg>60 );61}62
63export function SpringAccordion(props: SpringAccordionProps) {64 const { items, className } = props;65 const type = props.type ?? "single";66 const valueProp = props.value;67 68 const onValueChange = props.onValueChange as ((value: string | string[]) => void) | undefined;69 const reduce = !!useReducedMotion();70 const baseId = useId();71 const groupRef = useRef<HTMLDivElement>(null);72 const isControlled = valueProp !== undefined;73 const [uncontrolled, setUncontrolled] = useState<string[]>(() => toArray(props.defaultValue));74 const open = isControlled ? toArray(valueProp) : uncontrolled;75
76 const setOpen = useCallback(77 (next: string[]) => {78 if (!isControlled) setUncontrolled(next);79 onValueChange?.(type === "single" ? (next[0] ?? "") : next);80 },81 [isControlled, onValueChange, type],82 );83
84 const toggle = (itemValue: string) => {85 const isOpen = open.includes(itemValue);86 if (type === "single") setOpen(isOpen ? [] : [itemValue]);87 else setOpen(isOpen ? open.filter((v) => v !== itemValue) : [...open, itemValue]);88 };89
90 91 const onHeaderKeyDown = (event: React.KeyboardEvent<HTMLButtonElement>) => {92 const headers = Array.from(93 groupRef.current?.querySelectorAll<HTMLButtonElement>("[data-accordion-trigger]:not(:disabled)") ?? [],94 );95 const index = headers.indexOf(event.currentTarget);96 if (index < 0) return;97 const map: Record<string, number> = {98 ArrowDown: (index + 1) % headers.length,99 ArrowUp: (index - 1 + headers.length) % headers.length,100 Home: 0,101 End: headers.length - 1,102 };103 if (!(event.key in map)) return;104 event.preventDefault();105 headers[map[event.key]].focus();106 };107
108 return (109 <div ref={groupRef} className={cn("flex w-full flex-col gap-1.5", className)}>110 {items.map((item, i) => {111 const isOpen = open.includes(item.value);112 const triggerId = `${baseId}-t-${item.value}`;113 const panelId = `${baseId}-p-${item.value}`;114 const prevOpen = i > 0 && open.includes(items[i - 1].value);115 const nextOpen = i < items.length - 1 && open.includes(items[i + 1].value);116
117 return (118 <motion.div119 key={item.value}120 layout={reduce ? false : "position"}121 transition={HEIGHT_SPRING}122 className={cn(123 "relative overflow-hidden bg-(--color-paper) transition-[box-shadow,border-radius,margin] duration-300 ease-out",124 isOpen125 ? "my-1 rounded-[18px] shadow-[inset_0_0_0_1px_var(--color-rule),0_1px_2px_rgba(0,0,0,0.04),0_14px_32px_-14px_rgba(0,0,0,0.2)]"126 : "rounded-[12px] shadow-[inset_0_0_0_1px_var(--color-rule)]",127 !isOpen && !prevOpen && i > 0 && "rounded-t-[8px]",128 !isOpen && !nextOpen && i < items.length - 1 && "rounded-b-[8px]",129 isOpen && i === 0 && "mt-0",130 isOpen && i === items.length - 1 && "mb-0",131 )}132 >133 <button134 type="button"135 id={triggerId}136 data-accordion-trigger137 aria-expanded={isOpen}138 aria-controls={panelId}139 disabled={item.disabled}140 onClick={() => toggle(item.value)}141 onKeyDown={onHeaderKeyDown}142 className={cn(143 "group flex w-full items-center gap-3 px-4 py-3.5 text-left outline-none select-none",144 "transition-colors duration-150 hover:bg-(--color-paper-2)/60",145 "focus-visible:ring-2 focus-visible:ring-(--color-focus) focus-visible:ring-inset",146 "disabled:cursor-not-allowed disabled:opacity-40",147 )}148 >149 {item.icon ? (150 <span151 aria-hidden152 className={cn(153 "flex size-8 shrink-0 items-center justify-center rounded-[10px] transition-colors duration-300 [&_svg]:size-4 [&_svg]:stroke-[1.75]",154 isOpen ? "bg-(--color-ink) text-(--color-paper)" : "bg-(--color-paper-2) text-(--color-ink-2)",155 )}156 >157 {item.icon}158 </span>159 ) : null}160 <span className="min-w-0 flex-1">161 <span className="block text-[14px] leading-snug font-medium text-balance text-(--color-ink)">{item.title}</span>162 {item.subtitle ? (163 <span className="mt-0.5 block text-[12.5px] text-(--color-neutral)">{item.subtitle}</span>164 ) : null}165 </span>166 <span167 aria-hidden168 className={cn(169 "flex size-7 shrink-0 items-center justify-center rounded-full transition-[background-color,color] duration-300",170 isOpen171 ? "bg-(--color-ink) text-(--color-paper)"172 : "bg-(--color-paper-2) text-(--color-ink-2) group-hover:text-(--color-ink)",173 )}174 >175 <PlusMinus open={isOpen} reduce={reduce} />176 </span>177 </button>178
179 <AnimatePresence initial={false}>180 {isOpen ? (181 <motion.div182 key="panel"183 id={panelId}184 role="region"185 aria-labelledby={triggerId}186 initial={{ height: 0 }}187 animate={{ height: "auto" }}188 exit={{ height: 0 }}189 transition={reduce ? { duration: 0 } : HEIGHT_SPRING}190 className="overflow-hidden"191 >192 <motion.div193 initial={reduce ? { opacity: 0 } : { opacity: 0, y: -6, filter: "blur(4px)" }}194 animate={{ opacity: 1, y: 0, filter: "blur(0px)", transition: { duration: 0.35, ease: EASE_OUT, delay: 0.05 } }}195 exit={reduce ? { opacity: 0 } : { opacity: 0, y: -4, filter: "blur(4px)", transition: { duration: 0.15 } }}196 className={cn(197 "pr-4 pb-4 text-[13.5px] leading-relaxed text-pretty text-(--color-ink-2)",198 item.icon ? "pl-[3.75rem]" : "pl-4",199 )}200 >201 {item.children}202 </motion.div>203 </motion.div>204 ) : null}205 </AnimatePresence>206 </motion.div>207 );208 })}209 </div>210 );211}