아코디언 컴포넌트 생성
This commit is contained in:
182
frontend/components/ui/accordion.tsx
Normal file
182
frontend/components/ui/accordion.tsx
Normal file
@@ -0,0 +1,182 @@
|
||||
"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 };
|
||||
Reference in New Issue
Block a user