import {
createContext,
forwardRef,
useCallback,
useContext,
useId,
useMemo,
useState,
type HTMLAttributes,
type ReactNode,
} from 'react'
import { cn } from '@/lib/utils'
const ChevronIcon = () => (
)
// --- Context ---
interface AccordionContextValue {
openItems: Set
toggle: (value: string) => void
}
const AccordionContext = createContext(null)
function useAccordionContext() {
const ctx = useContext(AccordionContext)
if (!ctx) throw new Error('Accordion components must be used within an Accordion')
return ctx
}
interface AccordionItemContextValue {
value: string
isOpen: boolean
triggerId: string
contentId: string
disabled: boolean
}
const AccordionItemContext = createContext(null)
function useAccordionItemContext() {
const ctx = useContext(AccordionItemContext)
if (!ctx) throw new Error('AccordionItem sub-components must be used within an AccordionItem')
return ctx
}
// --- Accordion ---
export interface AccordionProps extends HTMLAttributes {
type?: 'single' | 'multiple'
collapsible?: boolean
defaultValue?: string | string[]
value?: string | string[]
onValueChange?: (value: string | string[]) => void
}
export const Accordion = forwardRef(
(
{ type = 'single', collapsible = false, defaultValue, value, onValueChange, className, children, ...props },
ref,
) => {
const [internalOpen, setInternalOpen] = useState>(() => {
if (defaultValue) return new Set(Array.isArray(defaultValue) ? defaultValue : [defaultValue])
return new Set()
})
const isControlled = value !== undefined
const openItems = isControlled
? new Set(Array.isArray(value) ? value : value ? [value] : [])
: internalOpen
const toggle = useCallback(
(itemValue: string) => {
const compute = (prev: Set): Set => {
const next = new Set(prev)
if (next.has(itemValue)) {
if (type === 'single' && !collapsible) return prev
next.delete(itemValue)
} else {
if (type === 'single') next.clear()
next.add(itemValue)
}
return next
}
if (isControlled) {
const next = compute(openItems)
if (next !== openItems) {
onValueChange?.(type === 'single' ? ([...next][0] ?? '') : [...next])
}
} else {
setInternalOpen((prev) => {
const next = compute(prev)
if (next !== prev) {
onValueChange?.(type === 'single' ? ([...next][0] ?? '') : [...next])
}
return next
})
}
},
[type, collapsible, isControlled, openItems, onValueChange],
)
const contextValue = useMemo(() => ({ openItems, toggle }), [openItems, toggle])
return (
{children}
)
},
)
Accordion.displayName = 'Accordion'
// --- AccordionItem ---
export interface AccordionItemProps extends HTMLAttributes {
value: string
disabled?: boolean
}
export const AccordionItem = forwardRef(
({ value, disabled = false, className, children, ...props }, ref) => {
const { openItems } = useAccordionContext()
const isOpen = openItems.has(value)
const id = useId()
const triggerId = `${id}-trigger`
const contentId = `${id}-content`
const itemContext = useMemo(
() => ({ value, isOpen, triggerId, contentId, disabled }),
[value, isOpen, triggerId, contentId, disabled],
)
return (
{children}
)
},
)
AccordionItem.displayName = 'AccordionItem'
// --- AccordionTrigger ---
export interface AccordionTriggerProps extends Omit, 'children'> {
icon?: ReactNode
children: ReactNode
}
export const AccordionTrigger = forwardRef(
({ icon, className, children, ...props }, ref) => {
const { toggle } = useAccordionContext()
const { value, isOpen, triggerId, contentId, disabled } = useAccordionItemContext()
return (
)
},
)
AccordionTrigger.displayName = 'AccordionTrigger'
// --- AccordionContent ---
export interface AccordionContentProps extends HTMLAttributes {}
export const AccordionContent = forwardRef(
({ className, children, ...props }, ref) => {
const { isOpen, triggerId, contentId } = useAccordionItemContext()
return (
)
},
)
AccordionContent.displayName = 'AccordionContent'