Collapsible.tsx 2.0 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364
  1. import { useState } from 'react';
  2. import type { ReactNode } from 'react';
  3. import { ChevronDown } from 'lucide-react';
  4. interface CollapsibleProps {
  5. summary: ReactNode;
  6. children: ReactNode;
  7. defaultOpen?: boolean;
  8. className?: string;
  9. summaryClassName?: string;
  10. /** When provided, the component is controlled — parent owns the open state. */
  11. open?: boolean;
  12. /** Called when the user clicks the toggle. Use with `open` for controlled mode. */
  13. onToggle?: (open: boolean) => void;
  14. }
  15. /**
  16. * Lightweight disclosure widget.
  17. * Renders a clickable summary row and conditionally displays children.
  18. *
  19. * The toggle region is a plain <div> with role="button" so that the summary
  20. * slot may safely contain interactive elements (buttons, links) without
  21. * nesting a <button> inside a <button>.
  22. *
  23. * Supports both uncontrolled (internal state) and controlled (`open`/`onToggle`) modes.
  24. */
  25. export function Collapsible({
  26. summary,
  27. children,
  28. defaultOpen = false,
  29. className = '',
  30. summaryClassName = '',
  31. open: controlledOpen,
  32. onToggle,
  33. }: CollapsibleProps) {
  34. const [internalOpen, setInternalOpen] = useState(defaultOpen);
  35. const isControlled = controlledOpen !== undefined;
  36. const isOpen = isControlled ? controlledOpen : internalOpen;
  37. const handleToggle = () => {
  38. const next = !isOpen;
  39. if (!isControlled) setInternalOpen(next);
  40. onToggle?.(next);
  41. };
  42. return (
  43. <div className={className}>
  44. <div
  45. role="button"
  46. tabIndex={0}
  47. onClick={handleToggle}
  48. onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); handleToggle(); } }}
  49. className={`w-full flex items-center justify-between gap-2 text-left cursor-pointer ${summaryClassName}`}
  50. aria-expanded={isOpen}
  51. >
  52. <div className="flex-1 min-w-0">{summary}</div>
  53. <ChevronDown
  54. className={`w-4 h-4 text-bambu-gray flex-shrink-0 transition-transform ${isOpen ? 'rotate-180' : ''}`}
  55. />
  56. </div>
  57. {isOpen && <div className="mt-3">{children}</div>}
  58. </div>
  59. );
  60. }