203 lines
5.0 KiB
TypeScript
203 lines
5.0 KiB
TypeScript
"use client";
|
|
|
|
import * as React from "react";
|
|
import { ChevronDown } from "lucide-react";
|
|
import { cn } from "@/lib/utils";
|
|
|
|
interface AccordionContextValue {
|
|
value: string[];
|
|
onValueChange: (value: string[]) => void;
|
|
}
|
|
|
|
const AccordionContext = React.createContext<AccordionContextValue | undefined>(
|
|
undefined
|
|
);
|
|
|
|
interface AccordionProps {
|
|
type?: "single" | "multiple";
|
|
defaultValue?: string | string[];
|
|
value?: string | string[];
|
|
onValueChange?: (value: string | string[]) => void;
|
|
children: React.ReactNode;
|
|
className?: string;
|
|
}
|
|
|
|
const Accordion = React.forwardRef<HTMLDivElement, AccordionProps>(
|
|
({ type = "single", defaultValue, value, onValueChange, children, className }, ref) => {
|
|
const [internalValue, setInternalValue] = React.useState<string[]>(
|
|
defaultValue
|
|
? Array.isArray(defaultValue)
|
|
? defaultValue
|
|
: [defaultValue]
|
|
: []
|
|
);
|
|
|
|
const controlledValue = value
|
|
? Array.isArray(value)
|
|
? value
|
|
: [value]
|
|
: undefined;
|
|
|
|
const currentValue = controlledValue ?? internalValue;
|
|
|
|
const handleValueChange = React.useCallback(
|
|
(newValue: string[]) => {
|
|
if (!controlledValue) {
|
|
setInternalValue(newValue);
|
|
}
|
|
if (onValueChange) {
|
|
onValueChange(type === "single" ? newValue[0] || "" : newValue);
|
|
}
|
|
},
|
|
[controlledValue, onValueChange, type]
|
|
);
|
|
|
|
const contextValue = React.useMemo(
|
|
() => ({
|
|
value: currentValue,
|
|
onValueChange: handleValueChange,
|
|
}),
|
|
[currentValue, handleValueChange]
|
|
);
|
|
|
|
return (
|
|
<AccordionContext.Provider value={contextValue}>
|
|
<div ref={ref} className={cn("space-y-2", className)}>
|
|
{children}
|
|
</div>
|
|
</AccordionContext.Provider>
|
|
);
|
|
}
|
|
);
|
|
Accordion.displayName = "Accordion";
|
|
|
|
interface AccordionItemProps {
|
|
value: string;
|
|
children: React.ReactNode;
|
|
className?: string;
|
|
}
|
|
|
|
const AccordionItem = React.forwardRef<HTMLDivElement, AccordionItemProps>(
|
|
({ value, children, className }, ref) => {
|
|
return (
|
|
<div
|
|
ref={ref}
|
|
className={cn("rounded-xl border border-gray-200 overflow-hidden", className)}
|
|
data-value={value}
|
|
>
|
|
{children}
|
|
</div>
|
|
);
|
|
}
|
|
);
|
|
AccordionItem.displayName = "AccordionItem";
|
|
|
|
interface AccordionTriggerProps {
|
|
children: React.ReactNode;
|
|
className?: string;
|
|
}
|
|
|
|
const AccordionTrigger = React.forwardRef<
|
|
HTMLButtonElement,
|
|
AccordionTriggerProps
|
|
>(({ children, className }, ref) => {
|
|
const context = React.useContext(AccordionContext);
|
|
if (!context) {
|
|
throw new Error("AccordionTrigger must be used within Accordion");
|
|
}
|
|
|
|
const item = React.useContext(ItemContext);
|
|
if (!item) {
|
|
throw new Error("AccordionTrigger must be used within AccordionItem");
|
|
}
|
|
|
|
const isOpen = context.value.includes(item.value);
|
|
|
|
const handleClick = () => {
|
|
const newValue = isOpen
|
|
? context.value.filter((v) => v !== item.value)
|
|
: [...context.value, item.value];
|
|
context.onValueChange(newValue);
|
|
};
|
|
|
|
return (
|
|
<button
|
|
ref={ref}
|
|
type="button"
|
|
onClick={handleClick}
|
|
className={cn(
|
|
"flex w-full items-center justify-between p-4 text-left font-medium text-primary transition-all hover:bg-secondary [&[data-state=open]>svg]:rotate-180",
|
|
className
|
|
)}
|
|
data-state={isOpen ? "open" : "closed"}
|
|
>
|
|
{children}
|
|
<ChevronDown className="h-5 w-5 shrink-0 transition-transform duration-200" />
|
|
</button>
|
|
);
|
|
});
|
|
AccordionTrigger.displayName = "AccordionTrigger";
|
|
|
|
interface AccordionContentProps {
|
|
children: React.ReactNode;
|
|
className?: string;
|
|
}
|
|
|
|
const ItemContext = React.createContext<{ value: string } | undefined>(
|
|
undefined
|
|
);
|
|
|
|
const AccordionContent = React.forwardRef<
|
|
HTMLDivElement,
|
|
AccordionContentProps
|
|
>(({ children, className }, ref) => {
|
|
const context = React.useContext(AccordionContext);
|
|
if (!context) {
|
|
throw new Error("AccordionContent must be used within Accordion");
|
|
}
|
|
|
|
const item = React.useContext(ItemContext);
|
|
if (!item) {
|
|
throw new Error("AccordionContent must be used within AccordionItem");
|
|
}
|
|
|
|
const isOpen = context.value.includes(item.value);
|
|
|
|
return (
|
|
<div
|
|
ref={ref}
|
|
className={cn(
|
|
"overflow-hidden transition-all",
|
|
isOpen ? "max-h-[1000px] opacity-100" : "max-h-0 opacity-0"
|
|
)}
|
|
>
|
|
<div className={cn("p-4 pt-0 text-gray-700", className)}>
|
|
{children}
|
|
</div>
|
|
</div>
|
|
);
|
|
});
|
|
AccordionContent.displayName = "AccordionContent";
|
|
|
|
const AccordionItemWithContext = React.forwardRef<
|
|
HTMLDivElement,
|
|
AccordionItemProps
|
|
>(({ value, children, ...props }, ref) => {
|
|
return (
|
|
<ItemContext.Provider value={{ value }}>
|
|
<AccordionItem ref={ref} value={value} {...props}>
|
|
{children}
|
|
</AccordionItem>
|
|
</ItemContext.Provider>
|
|
);
|
|
});
|
|
AccordionItemWithContext.displayName = "AccordionItem";
|
|
|
|
export {
|
|
Accordion,
|
|
AccordionItemWithContext as AccordionItem,
|
|
AccordionTrigger,
|
|
AccordionContent,
|
|
};
|
|
|