1"use client";
2
3import { useState, useCallback, useId, ReactNode } from "react";
4
5interface AccordionItem {
6 key: string;
7 title: string;
8 content: ReactNode;
9 defaultOpen?: boolean;
10}
11
12interface AccordionProps {
13 items: AccordionItem[];
14 allowMultiple?: boolean;
15 className?: string;
16}
17
18export function Accordion({ items, allowMultiple = false, className }: AccordionProps) {
19 const [openKeys, setOpenKeys] = useState<Set<string>>(() => {
20 const defaults = new Set<string>();
21 for (const item of items) {
22 if (item.defaultOpen) {
23 defaults.add(item.key);
24 }
25 }
26 return defaults;
27 });
28
29 const toggle = useCallback(
30 (key: string) => {
31 setOpenKeys((prev) => {
32 const next = new Set(prev);
33 if (next.has(key)) {
34 next.delete(key);
35 } else {
36 if (allowMultiple) {
37 next.add(key);
38 } else {
39 next.clear();
40 next.add(key);
41 }
42 }
43 return next;
44 });
45 },
46 [allowMultiple]
47 );
48 const uid = useId();
49
50 return (
51 <div
52 className={["rounded-[18px] p-1.5", className].filter(Boolean).join(" ")}
53 style={{ background: "var(--panel-bezel)", boxShadow: "var(--panel-shadow)" }}
54 >
55 <div
56 className="bg-white rounded-[13px] overflow-hidden p-1"
57 style={{ boxShadow: "var(--panel-face-shadow)" }}
58 >
59 {items.map((item, index) => {
60 const isOpen = openKeys.has(item.key);
61 const isLast = index === items.length - 1;
62 const headerId = `${uid}-${item.key}-header`;
63 const panelId = `${uid}-${item.key}-panel`;
64
65 return (
66 <div
67 key={item.key}
68 className={[
69 "relative",
70 // Hairline between rows, inset so it doesn't touch the rounded corners
71 !isLast &&
72 "after:absolute after:left-3 after:right-3 after:bottom-0 after:h-px after:bg-neutral-100",
73 ]
74 .filter(Boolean)
75 .join(" ")}
76 >
77 <h3>
78 <button
79 type="button"
80 id={headerId}
81 aria-expanded={isOpen}
82 aria-controls={panelId}
83 onClick={() => toggle(item.key)}
84 className={[
85 "group/acc w-full flex items-center justify-between gap-3 py-3 px-3 text-left text-[13.5px] font-medium cursor-pointer rounded-[10px]",
86 "transition-colors duration-150",
87 isOpen ? "text-neutral-900" : "text-neutral-700 hover:text-neutral-900 hover:bg-neutral-50",
88 ].join(" ")}
89 >
90 <span>{item.title}</span>
91 {/* Plus that turns into a minus: says "more inside", not "navigate" */}
92 <span
93 aria-hidden
94 className="relative shrink-0 w-6 h-6 rounded-[7px] flex items-center justify-center text-neutral-500 group-hover/acc:text-neutral-900 transition-colors"
95 style={{ background: "var(--face-light)", boxShadow: "var(--face-light-shadow)" }}
96 >
97 <span className="absolute w-2.5 h-[1.5px] rounded-full bg-current" />
98 <span
99 className="absolute w-2.5 h-[1.5px] rounded-full bg-current"
100 style={{
101 transform: isOpen ? "rotate(0deg)" : "rotate(90deg)",
102 transition: "transform 300ms var(--ease-spring)",
103 }}
104 />
105 </span>
106 </button>
107 </h3>
108
109 <div
110 id={panelId}
111 role="region"
112 aria-labelledby={headerId}
113 // Closed panels are removed from the tab order and the a11y tree
114 inert={!isOpen}
115 style={{
116 display: "grid",
117 gridTemplateRows: isOpen ? "1fr" : "0fr",
118 transition: "grid-template-rows 320ms var(--ease-out-soft)",
119 }}
120 >
121 <div className="overflow-hidden">
122 <div
123 className="px-3 pb-4 pt-0.5 text-[13px] text-neutral-600 leading-relaxed"
124 style={{
125 opacity: isOpen ? 1 : 0,
126 transform: isOpen ? "translateY(0)" : "translateY(-4px)",
127 transition: "opacity 240ms ease-out, transform 320ms var(--ease-out-soft)",
128 }}
129 >
130 {item.content}
131 </div>
132 </div>
133 </div>
134 </div>
135 );
136 })}
137 </div>
138 </div>
139 );
140}