Files
lti-web-client/src/components/Collapse.tsx
T
2025-10-02 11:46:09 +07:00

132 lines
3.4 KiB
TypeScript

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',
!open && 'w-fit',
className
);
const titleNode = useMemo(() => {
if (subtitle) {
return (
<div className='flex flex-col gap-0.5'>
<span>{title}</span>
<span className='text-sm opacity-70'>{subtitle}</span>
</div>
);
}
return title;
}, [title, subtitle]);
return (
<div className={rootClass} data-open={isOpen}>
<input
id={inputId}
type={inputType}
name={asRadio ? name : undefined}
className='peer p-0 hidden'
checked={isOpen}
onChange={(e) => handleChange(e.currentTarget.checked)}
aria-controls={`${inputId}-content`}
disabled={disabled}
/>
<div
role='button'
tabIndex={0}
className={cn(
'collapse-title w-fit p-0',
'focus:outline-none focus-visible:ring focus-visible:ring-primary/40',
titleClassName
)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handleChange(!isOpen);
}
}}
onClick={() => handleChange(!isOpen)}
>
{titleNode}
</div>
<div
id={`${inputId}-content`}
className={cn('collapse-content p-0!', contentClassName)}
>
{children}
</div>
</div>
);
};
export default Collapse;