ERP-node/frontend/components/ui/accordion.tsx

216 lines
6.1 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(className)} onClick={onClick} {...props}>
{children}
</div>
</AccordionContext.Provider>
);
}
interface AccordionItemProps {
value: string;
className?: string;
children: React.ReactNode;
}
function AccordionItem({ value, className, children, ...props }: AccordionItemProps) {
const context = React.useContext(AccordionContext);
const handleClick = (e: React.MouseEvent) => {
if (!context?.onValueChange) return;
const target = e.target as HTMLElement;
if (target.closest('button[type="button"]') && !target.closest(".accordion-trigger")) {
return;
}
const isOpen =
context.type === "multiple"
? Array.isArray(context.value) && context.value.includes(value)
: context.value === value;
if (context.type === "multiple") {
const currentValue = Array.isArray(context.value) ? context.value : [];
const newValue = isOpen ? currentValue.filter((v) => v !== value) : [...currentValue, value];
context.onValueChange(newValue);
} else {
const newValue = isOpen && context.collapsible ? "" : value;
context.onValueChange(newValue);
}
};
return (
<div className={cn("cursor-pointer", className)} data-value={value} onClick={handleClick} {...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(
"accordion-trigger flex w-full cursor-pointer items-center justify-between p-4 text-left font-medium transition-colors 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);
const contentRef = React.useRef<HTMLDivElement>(null);
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;
return (
<div
ref={contentRef}
className={cn(
"text-muted-foreground overflow-hidden text-sm transition-all duration-300 ease-in-out",
isOpen ? "max-h-[2000px] opacity-100" : "max-h-0 opacity-0",
className,
)}
onClick={(e) => e.stopPropagation()}
{...props}
>
<div className="cursor-default">{children}</div>
</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 };