/** * @title Accordion * @description 여러 개의 관련된 콘텐츠 섹션을 수직으로 나열하고, 각 섹션을 펼치거나 접어 정보를 탐색할 수 있는 컴포넌트예요. */import * as React from 'react';import { Accordion as AccordionPrimitive } from 'radix-ui';import { cva, type VariantProps } from 'class-variance-authority';import { ChevronDown } from 'lucide-react';import { cn } from '@/lib/utils';// SEED Rootage spec: accordion.yaml, accordion-item.yamlconst accordionVariants = cva('flex w-full flex-col', { variants: { variant: { inline: '', separated: '', }, size: { medium: '', large: '', responsive: '', }, }, compoundVariants: [ { variant: 'separated', size: 'medium', className: 'gap-x3' }, { variant: 'separated', size: 'large', className: 'gap-x4' }, { variant: 'separated', size: 'responsive', className: 'gap-x3 md:gap-x4' }, ], defaultVariants: { variant: 'inline', size: 'medium', },});const accordionItemVariants = cva('flex w-full flex-col', { variants: { variant: { // 아이템 사이 구분선 (dividerPaddingX: x3, dividerColor: stroke.neutral-subtle) inline: "relative not-last:after:absolute not-last:after:inset-x-x3 not-last:after:bottom-0 not-last:after:h-px not-last:after:bg-stroke-neutral-subtle not-last:after:content-['']", // 개별 카드 (strokeWidth 1px, strokeColor: stroke.neutral-weak, cornerRadius: r3) separated: 'overflow-hidden rounded-r3 shadow-[inset_0_0_0_1px_var(--rb-color-stroke-neutral-weak)]', }, }, defaultVariants: { variant: 'inline', },});const accordionTriggerVariants = cva( [ 'group/accordion-trigger relative isolate flex w-full cursor-pointer items-center border-none bg-transparent px-spacing-x-global-gutter text-start font-[inherit]', 'outline-2 -outline-offset-2 outline-transparent transition-[outline-color] focus-visible:outline-stroke-focus-ring', 'disabled:cursor-not-allowed', // 눌림 배경 레이어 "before:absolute before:inset-0 before:-z-1 before:content-[''] before:duration-(--rb-duration-color-transition) before:ease-easing", 'enabled:active:before:bg-bg-transparent-pressed', ], { variants: { variant: { // pressed: marginX x1_5, cornerRadius x2_5 inline: 'before:transition-[background-color,inset-inline,border-radius] enabled:active:before:inset-x-x1_5 enabled:active:before:rounded-r2_5', separated: 'before:transition-[background-color]', }, size: { medium: 'py-x4', large: 'py-x5', responsive: 'py-x4 md:py-x5', }, }, defaultVariants: { variant: 'inline', size: 'medium', }, },);const prefixSize = { medium: 'mr-x3 [&_svg]:size-[22px]', large: 'mr-x3 [&_svg]:size-[22px]', responsive: 'mr-x3 [&_svg]:size-[22px]',} as const;const titleSize = { medium: 'text-t5', large: 'text-t7', responsive: 'text-t5 md:text-t7',} as const;const descriptionSize = { medium: 'text-t3', large: 'text-t5', responsive: 'text-t3 md:text-t5',} as const;const suffixIconSize = { medium: 'ml-x3 [&_svg]:size-x5', large: 'ml-x3 [&_svg]:size-x6', responsive: 'ml-x3 [&_svg]:size-x5 md:[&_svg]:size-x6',} as const;type AccordionVariant = NonNullable<VariantProps<typeof accordionVariants>['variant']>;type AccordionSize = NonNullable<VariantProps<typeof accordionVariants>['size']>;const AccordionContext = React.createContext<{ variant: AccordionVariant; size: AccordionSize }>({ variant: 'inline', size: 'medium',});export interface AccordionProps extends Omit< React.ComponentProps<typeof AccordionPrimitive.Root>, 'type' | 'value' | 'defaultValue' | 'onValueChange' | 'collapsible' | 'dir' >, VariantProps<typeof accordionVariants> { /** 여러 항목을 동시에 펼칠 수 있게 해요. 기본값은 한 번에 하나만 펼쳐져요. */ multiple?: boolean; /** 열려 있는 항목의 value 목록 (controlled) */ values?: string[]; /** 처음에 열려 있을 항목의 value 목록 (uncontrolled) */ defaultValues?: string[]; /** 열림 상태가 바뀔 때 호출돼요. */ onValuesChange?: (values: string[]) => void; dir?: 'ltr' | 'rtl';}function Accordion({ className, variant, size, multiple = false, values: valuesProp, defaultValues, onValuesChange, ...props}: AccordionProps) { const [uncontrolled, setUncontrolled] = React.useState<string[]>(defaultValues ?? []); const values = valuesProp ?? uncontrolled; const handleValueChange = (next: string[]) => { // 단일 펼침: 새로 열린 항목만 남겨요. const resolved = !multiple && next.length > 1 ? next.filter((v) => !values.includes(v)) : next; if (valuesProp === undefined) setUncontrolled(resolved); onValuesChange?.(resolved); }; const contextValue = React.useMemo( () => ({ variant: variant ?? 'inline', size: size ?? 'medium' }), [variant, size], ); return ( <AccordionContext.Provider value={contextValue}> <AccordionPrimitive.Root data-slot="accordion" data-variant={contextValue.variant} data-size={contextValue.size} type="multiple" value={values} onValueChange={handleValueChange} className={cn(accordionVariants({ variant, size }), className)} {...props} /> </AccordionContext.Provider> );}export interface AccordionItemProps extends React.ComponentProps<typeof AccordionPrimitive.Item> {}function AccordionItem({ className, ...props }: AccordionItemProps) { const { variant } = React.useContext(AccordionContext); return ( <AccordionPrimitive.Item data-slot="accordion-item" className={cn(accordionItemVariants({ variant }), className)} {...props} /> );}export interface AccordionTriggerProps extends Omit<React.ComponentProps<typeof AccordionPrimitive.Trigger>, 'children' | 'asChild' | 'title' | 'prefix'> { title: React.ReactNode; /** 트리거에 붙는 부가 설명 */ description?: React.ReactNode; /** 좌측 앞쪽 요소 (아이콘 등) */ prefix?: React.ReactNode; /** 우측 아이콘. 기본은 아래 방향 Chevron 이에요. */ suffixIcon?: React.ReactNode; /** 트리거를 감싸는 heading level. 기본값은 3 이에요. */ headingLevel?: 1 | 2 | 3 | 4 | 5 | 6;}function AccordionTrigger({ className, title, description, prefix, suffixIcon, headingLevel = 3, ...props}: AccordionTriggerProps) { const { variant, size } = React.useContext(AccordionContext); const Heading = `h${headingLevel}` as const; return ( <AccordionPrimitive.Header asChild> <Heading data-slot="accordion-header" className="m-0 flex p-0 font-[inherit]"> <AccordionPrimitive.Trigger data-slot="accordion-trigger" className={cn(accordionTriggerVariants({ variant, size }), className)} {...props} > {/* scaleScope: content — 눌렀을 때 배경은 그대로, 내용만 살짝 축소돼요. */} <span className="flex w-full min-w-0 items-center transition-[scale] duration-(--rb-duration-pressed-scale) ease-pressed-scale group-[:enabled:active]/accordion-trigger:scale-[0.97]"> {prefix && ( <span data-slot="accordion-prefix" className={cn( 'inline-flex shrink-0 items-center text-fg-neutral group-disabled/accordion-trigger:text-fg-disabled', prefixSize[size], )} > {prefix} </span> )} <span data-slot="accordion-body" className="flex min-w-0 flex-1 flex-col gap-x0_5"> <span data-slot="accordion-title" className={cn( 'font-medium text-fg-neutral group-disabled/accordion-trigger:text-fg-disabled', titleSize[size], )} > {title} </span> {description && ( <span data-slot="accordion-description" className={cn( 'font-medium text-fg-neutral-subtle group-disabled/accordion-trigger:text-fg-disabled', descriptionSize[size], )} > {description} </span> )} </span> <span data-slot="accordion-suffix-icon" aria-hidden className={cn( 'ml-auto inline-flex shrink-0 items-center text-fg-neutral-subtle transition-transform duration-(--rb-duration-d6) ease-easing', 'group-data-[state=open]/accordion-trigger:rotate-180 group-disabled/accordion-trigger:text-fg-disabled', suffixIconSize[size], )} > {suffixIcon ?? <ChevronDown />} </span> </span> </AccordionPrimitive.Trigger> </Heading> </AccordionPrimitive.Header> );}export interface AccordionContentProps extends React.ComponentProps<typeof AccordionPrimitive.Content> {}/** 열림/닫힘 애니메이션 컨테이너예요. 패딩·배경·타이포그래피는 내부에서 직접 구성해요. */function AccordionContent({ className, ...props }: AccordionContentProps) { return ( <AccordionPrimitive.Content data-slot="accordion-content" className={cn( 'overflow-hidden duration-(--rb-duration-d6) ease-easing', 'data-[state=open]:animate-accordion-down data-[state=closed]:animate-accordion-up', className, )} {...props} /> );}export { Accordion, AccordionItem, AccordionTrigger, AccordionContent, accordionVariants };