Bin
2025-12-16 9e0b2ba2c317b1a86212f24cbae3195ad1f3dbfa
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
import { useState, type ReactNode } from "react";
import { cn } from "../../utils/utils";
import { IconChevronDown, IconChevron } from "@humansignal/icons";
import { Button } from "../button/button";
 
export type CollapsiblePanelProps = {
  /** Visual variant of the panel */
  variant?: "primary" | "default";
  /** Panel title */
  title: ReactNode;
  /** Optional count badge/number to display */
  count?: number;
  /** Action buttons or elements (e.g., Clear Selection) */
  actions?: ReactNode;
  /** Panel content */
  children?: ReactNode;
  /** Additional CSS classes */
  className?: string;
  /** Whether the panel starts expanded */
  defaultExpanded?: boolean;
  /** Controlled expanded state */
  expanded?: boolean;
  /** Callback when expanded state changes */
  onExpandedChange?: (expanded: boolean) => void;
  /** Disable the toggle button (prevents collapsing/expanding) */
  disableToggle?: boolean;
  /** Test ID for the panel container */
  dataTestId?: string;
};
 
export const CollapsiblePanel = ({
  variant = "default",
  title,
  count,
  actions,
  children,
  className,
  defaultExpanded = true,
  expanded: controlledExpanded,
  onExpandedChange,
  disableToggle = false,
  dataTestId,
}: CollapsiblePanelProps) => {
  const [internalExpanded, setInternalExpanded] = useState(defaultExpanded);
 
  // Use controlled state if provided, otherwise use internal state
  const isExpanded = controlledExpanded !== undefined ? controlledExpanded : internalExpanded;
  const isControlled = controlledExpanded !== undefined;
 
  const handleToggle = () => {
    if (disableToggle) return;
 
    const newExpanded = !isExpanded;
 
    if (isControlled && onExpandedChange) {
      onExpandedChange(newExpanded);
    } else {
      setInternalExpanded(newExpanded);
    }
  };
 
  // Variant styles
  const variantStyles = {
    primary: {
      container: "border-primary-border-subtlest",
      header: "bg-primary-background hover:bg-primary-background-hover",
    },
    default: {
      container: "border-neutral-border",
      header: "bg-neutral-surface hover:bg-neutral-surface-hover",
    },
  };
 
  const styles = variantStyles[variant];
 
  return (
    <div
      className={cn("flex flex-col border rounded-md overflow-hidden shrink-0", styles.container, className)}
      data-testid={dataTestId}
    >
      {/* Header - Always visible */}
      <div
        className={cn(
          "flex items-center gap-2 px-base py-tight",
          styles.header,
          !disableToggle && "cursor-pointer",
          "transition-colors",
        )}
        onClick={handleToggle}
        data-testid={dataTestId ? `${dataTestId}-header` : undefined}
      >
        <Button
          variant="neutral"
          look="string"
          size="small"
          className={cn(
            "flex items-center justify-center text-neutral-content-subtle transition-colors",
            !disableToggle && "hover:text-neutral-content",
            disableToggle && "opacity-40 cursor-not-allowed pointer-events-none",
          )}
          disabled={disableToggle}
          aria-label={isExpanded ? "Collapse panel" : "Expand panel"}
          aria-expanded={isExpanded}
          data-testid="collapsible-panel-toggle"
        >
          {isExpanded ? <IconChevron size={16} /> : <IconChevronDown size={16} />}
        </Button>
 
        <div className="flex-1 flex items-center gap-2 truncate" data-testid="collapsible-panel-title">
          <span className="text-sm font-medium truncate">{title}</span>
          {count !== undefined && count > 0 && <span className="text-sm text-neutral-content-subtle">({count})</span>}
        </div>
 
        {actions && (
          <div
            onClick={(e) => {
              e.stopPropagation();
            }}
            data-testid="collapsible-panel-actions"
          >
            {actions}
          </div>
        )}
      </div>
 
      {/* Content - Collapsible */}
      {isExpanded && children && (
        <div className="border-t border-inherit" data-testid="collapsible-panel-content">
          {children}
        </div>
      )}
    </div>
  );
};