183 lines
4.9 KiB
TypeScript
183 lines
4.9 KiB
TypeScript
"use client";
|
|
|
|
import * as React from "react";
|
|
import { ChevronDownIcon } from "lucide-react";
|
|
import { cn } from "@/lib/utils";
|
|
|
|
interface AccordionContextValue {
|
|
type: "single" | "multiple";
|
|
collapsible?: boolean;
|
|
value?: string | string[];
|
|
onValueChange?: (value: string | string[]) => void;
|
|
}
|
|
|
|
const AccordionContext = React.createContext<AccordionContextValue | null>(null);
|
|
|
|
interface AccordionItemContextValue {
|
|
value: string;
|
|
}
|
|
|
|
const AccordionItemContext = React.createContext<AccordionItemContextValue | null>(null);
|
|
|
|
interface AccordionProps {
|
|
type: "single" | "multiple";
|
|
collapsible?: boolean;
|
|
value?: string | string[];
|
|
defaultValue?: string | string[];
|
|
onValueChange?: (value: string | string[]) => void;
|
|
className?: string;
|
|
children: React.ReactNode;
|
|
onClick?: (e: React.MouseEvent) => void;
|
|
}
|
|
|
|
function Accordion({
|
|
type,
|
|
collapsible = false,
|
|
value: controlledValue,
|
|
defaultValue,
|
|
onValueChange,
|
|
className,
|
|
children,
|
|
onClick,
|
|
...props
|
|
}: AccordionProps) {
|
|
const [uncontrolledValue, setUncontrolledValue] = React.useState<string | string[]>(
|
|
defaultValue || (type === "multiple" ? [] : ""),
|
|
);
|
|
|
|
const value = controlledValue !== undefined ? controlledValue : uncontrolledValue;
|
|
|
|
const handleValueChange = React.useCallback(
|
|
(newValue: string | string[]) => {
|
|
if (controlledValue === undefined) {
|
|
setUncontrolledValue(newValue);
|
|
}
|
|
onValueChange?.(newValue);
|
|
},
|
|
[controlledValue, onValueChange],
|
|
);
|
|
|
|
const contextValue = React.useMemo(
|
|
() => ({
|
|
type,
|
|
collapsible,
|
|
value,
|
|
onValueChange: handleValueChange,
|
|
}),
|
|
[type, collapsible, value, handleValueChange],
|
|
);
|
|
|
|
return (
|
|
<AccordionContext.Provider value={contextValue}>
|
|
<div className={cn("space-y-2", className)} onClick={onClick} {...props}>
|
|
{children}
|
|
</div>
|
|
</AccordionContext.Provider>
|
|
);
|
|
}
|
|
|
|
interface AccordionItemProps {
|
|
value: string;
|
|
className?: string;
|
|
children: React.ReactNode;
|
|
}
|
|
|
|
function AccordionItem({ value, className, children, ...props }: AccordionItemProps) {
|
|
return (
|
|
<div className={cn("rounded-md border", className)} data-value={value} {...props}>
|
|
{children}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
interface AccordionTriggerProps {
|
|
className?: string;
|
|
children: React.ReactNode;
|
|
}
|
|
|
|
function AccordionTrigger({ className, children, ...props }: AccordionTriggerProps) {
|
|
const context = React.useContext(AccordionContext);
|
|
const parent = React.useContext(AccordionItemContext);
|
|
|
|
if (!context || !parent) {
|
|
throw new Error("AccordionTrigger must be used within AccordionItem");
|
|
}
|
|
|
|
const isOpen =
|
|
context.type === "multiple"
|
|
? Array.isArray(context.value) && context.value.includes(parent.value)
|
|
: context.value === parent.value;
|
|
|
|
const handleClick = () => {
|
|
if (!context.onValueChange) return;
|
|
|
|
if (context.type === "multiple") {
|
|
const currentValue = Array.isArray(context.value) ? context.value : [];
|
|
const newValue = isOpen ? currentValue.filter((v) => v !== parent.value) : [...currentValue, parent.value];
|
|
context.onValueChange(newValue);
|
|
} else {
|
|
const newValue = isOpen && context.collapsible ? "" : parent.value;
|
|
context.onValueChange(newValue);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<button
|
|
className={cn(
|
|
"flex w-full items-center justify-between p-4 text-left font-medium transition-colors hover:bg-gray-50 focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 focus:outline-none",
|
|
className,
|
|
)}
|
|
onClick={handleClick}
|
|
type="button"
|
|
{...props}
|
|
>
|
|
{children}
|
|
<ChevronDownIcon className={cn("h-4 w-4 transition-transform duration-200", isOpen && "rotate-180")} />
|
|
</button>
|
|
);
|
|
}
|
|
|
|
interface AccordionContentProps {
|
|
className?: string;
|
|
children: React.ReactNode;
|
|
}
|
|
|
|
function AccordionContent({ className, children, ...props }: AccordionContentProps) {
|
|
const context = React.useContext(AccordionContext);
|
|
const parent = React.useContext(AccordionItemContext);
|
|
|
|
if (!context || !parent) {
|
|
throw new Error("AccordionContent must be used within AccordionItem");
|
|
}
|
|
|
|
const isOpen =
|
|
context.type === "multiple"
|
|
? Array.isArray(context.value) && context.value.includes(parent.value)
|
|
: context.value === parent.value;
|
|
|
|
if (!isOpen) return null;
|
|
|
|
return (
|
|
<div className={cn("px-4 pb-4 text-sm text-gray-600", className)} {...props}>
|
|
{children}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// AccordionItem을 래핑하여 컨텍스트 제공
|
|
const AccordionItemWithContext = React.forwardRef<HTMLDivElement, AccordionItemProps>(
|
|
({ value, children, ...props }, ref) => {
|
|
return (
|
|
<AccordionItemContext.Provider value={{ value }}>
|
|
<AccordionItem ref={ref} value={value} {...props}>
|
|
{children}
|
|
</AccordionItem>
|
|
</AccordionItemContext.Provider>
|
|
);
|
|
},
|
|
);
|
|
|
|
AccordionItemWithContext.displayName = "AccordionItem";
|
|
|
|
export { Accordion, AccordionItemWithContext as AccordionItem, AccordionTrigger, AccordionContent };
|