mirror of
https://gitlab.com/mbugroup/lti-web-client.git
synced 2026-05-20 13:32:00 +00:00
132 lines
3.4 KiB
TypeScript
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;
|