From 978285021e1a19d13c03be28f8642550ed2637d1 Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Mon, 29 Sep 2025 11:58:57 +0700 Subject: [PATCH] feat: add Collapse component --- src/components/Collapse.tsx | 132 ++++++++++++++++++++++++++++++++++++ 1 file changed, 132 insertions(+) create mode 100644 src/components/Collapse.tsx diff --git a/src/components/Collapse.tsx b/src/components/Collapse.tsx new file mode 100644 index 00000000..d076888d --- /dev/null +++ b/src/components/Collapse.tsx @@ -0,0 +1,132 @@ +import React, { useCallback, useId, useMemo, useState } from 'react'; +import { cn } from '@/lib/helper'; + +export type CollapseVariant = 'default' | 'arrow' | 'plus'; + +export type CollapseProps = { + /** Unique name used when `asRadio` is true (Accordion single-open). */ + name?: string; + /** If provided, component is controlled. */ + open?: boolean; + /** Initial open state for uncontrolled usage. */ + defaultOpen?: boolean; + /** Callback when open state changes. */ + onOpenChange?: (open: boolean) => void; + /** Title row content. Accepts string or custom node. */ + title?: React.ReactNode; + /** Optional secondary text displayed under/next to title. */ + subtitle?: React.ReactNode; + /** Content of the panel. */ + children?: React.ReactNode; + /** Visual variant: default / arrow / plus */ + variant?: CollapseVariant; + /** Add a bordered look */ + bordered?: boolean; + /** Disable interactions */ + disabled?: boolean; + /** Allow only one open at a time by switching to radio input */ + asRadio?: boolean; + /** Extra classnames */ + className?: string; + titleClassName?: string; + contentClassName?: string; +}; + +export const Collapse = ({ + name, + open, + defaultOpen, + onOpenChange, + title, + subtitle, + children, + variant = 'default', + bordered, + disabled, + asRadio = false, + className, + titleClassName, + contentClassName, +}: CollapseProps) => { + const inputId = useId(); + const isControlled = typeof open === 'boolean'; + const [internalOpen, setInternalOpen] = useState(!!defaultOpen); + const isOpen = isControlled ? !!open : internalOpen; + + // Manage change from checkbox/radio + const handleChange = useCallback( + (next: boolean) => { + if (!isControlled) setInternalOpen(next); + onOpenChange?.(next); + }, + [isControlled, onOpenChange] + ); + + const inputType = asRadio ? 'radio' : 'checkbox'; + + const rootClass = cn( + 'collapse', + variant === 'arrow' && 'collapse-arrow', + variant === 'plus' && 'collapse-plus', + bordered && 'border base-content/20 border-opacity-20 rounded-box', + disabled && 'opacity-60 pointer-events-none', + className + ); + + const titleNode = useMemo(() => { + if (subtitle) { + return ( +
+ {title} + {subtitle} +
+ ); + } + return
{title}
; + }, [title, subtitle]); + + return ( +
+ handleChange(e.currentTarget.checked)} + aria-expanded={isOpen} + aria-controls={`${inputId}-content`} + disabled={disabled} + /> + +
{ + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + handleChange(!isOpen); + } + }} + onClick={() => handleChange(!isOpen)} + > + {titleNode} +
+ +
+ {children} +
+
+ ); +}; + +export default Collapse;