feat: add collapsible support to PanelCard component (#23366)

* feat: add collapsible support to PanelCard component

- Add collapsible and defaultCollapsed props to PanelCard
- When collapsible is enabled, title becomes clickable with chevron icon
- Chevron icon indicates collapse state and rotates on toggle
- Body content is hidden when collapsed
- Maintains backward compatibility with existing usage
- Follows FormCard's collapsible pattern implementation

Co-Authored-By: eunjae@cal.com <hey@eunjae.dev>

^ Conflicts:
^	packages/ui/components/card/PanelCard.tsx

* update style

* address feedback

* animate expanding & collapsing panel
This commit is contained in:
Eunjae Lee
2025-09-01 15:16:58 +00:00
committed by GitHub
parent e530ad5412
commit 17ef0dc12b
3 changed files with 103 additions and 38 deletions
@@ -3580,6 +3580,8 @@
"free": "Free",
"user_email": "User Email",
"user_name": "User Name",
"expand_panel": "Expand Panel",
"collapse_panel": "Collapse Panel",
"you_have_one_team": "You have one team",
"consider_consolidating_one_team_org": "Consider setting up an organization to unify billing, admin tools, and analytics across your team.",
"consider_consolidating_multi_team_org": "Consider setting up an organization to unify billing, admin tools, and analytics across your teams.",
@@ -6,6 +6,8 @@ import classNames from "@calcom/ui/classNames";
import { PanelCard } from "@calcom/ui/components/card";
import { Tooltip } from "@calcom/ui/components/tooltip";
type PanelCardProps = React.ComponentProps<typeof PanelCard>;
type LegendItem = {
label: string;
color: string; // hex format
@@ -14,27 +16,16 @@ type LegendItem = {
export type LegendSize = "sm" | "default";
export function ChartCard({
title,
subtitle,
cta,
legend,
legendSize,
enabledLegend,
onSeriesToggle,
children,
className,
titleTooltip,
}: {
title: string | ReactNode;
subtitle?: string;
cta?: { label: string; onClick: () => void };
...panelCardProps
}: PanelCardProps & {
legend?: Array<LegendItem>;
legendSize?: LegendSize;
enabledLegend?: Array<LegendItem>;
onSeriesToggle?: (label: string) => void;
className?: string;
titleTooltip?: string;
children: ReactNode;
}) {
const legendComponent =
legend && legend.length > 0 ? (
@@ -43,13 +34,18 @@ export function ChartCard({
return (
<PanelCard
title={title}
subtitle={subtitle}
cta={cta}
headerContent={legendComponent}
className={className}
titleTooltip={titleTooltip}>
{children}
{...panelCardProps}
headerContent={
panelCardProps.headerContent ? (
<div className="flex items-center gap-2">
{panelCardProps.headerContent}
{legendComponent}
</div>
) : (
legendComponent
)
}>
{panelCardProps.children}
</PanelCard>
);
}
+85 -18
View File
@@ -1,8 +1,14 @@
import type { ReactNode } from "react";
"use client";
import { useAutoAnimate } from "@formkit/auto-animate/react";
import type { ReactNode } from "react";
import { useId, useState } from "react";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import classNames from "@calcom/ui/classNames";
import { InfoBadge } from "@calcom/ui/components/badge";
import { Button } from "@calcom/ui/components/button";
import { Icon } from "@calcom/ui/components/icon";
export function PanelCard({
title,
@@ -12,6 +18,8 @@ export function PanelCard({
className,
titleTooltip,
children,
collapsible = false,
defaultCollapsed = false,
}: {
title: string | ReactNode;
subtitle?: string;
@@ -20,22 +28,75 @@ export function PanelCard({
className?: string;
titleTooltip?: string;
children: ReactNode;
collapsible?: boolean;
defaultCollapsed?: boolean;
}) {
const { t } = useLocale();
const [isCollapsed, setIsCollapsed] = useState(defaultCollapsed);
const contentId = useId();
const titleId = useId();
const [animationParent] = useAutoAnimate<HTMLDivElement>();
const toggleCollapse = () => {
setIsCollapsed((prev) => !prev);
};
const isStringTitle = typeof title === "string";
return (
<div
ref={animationParent}
className={classNames(
"bg-muted group relative flex w-full flex-col items-center rounded-2xl px-1 pb-1",
"bg-muted group relative flex w-full flex-col items-center rounded-2xl px-1",
!isCollapsed && "pb-1",
className
)}>
<div className="flex h-11 w-full shrink-0 items-center justify-between gap-2 px-4">
{typeof title === "string" ? (
<div className="mr-4 flex shrink-0 items-center gap-1">
<h2 className="text-emphasis shrink-0 text-sm font-semibold">{title}</h2>
{titleTooltip && <InfoBadge content={titleTooltip} />}
</div>
) : (
title
)}
<div className="flex shrink-0 items-center gap-1">
{collapsible && (
<Button
size="sm"
variant="icon"
color="minimal"
CustomStartIcon={
<Icon
name="chevron-up"
className={classNames(
"text-default h-4 w-4 transition-transform",
isCollapsed && "rotate-180"
)}
/>
}
onClick={toggleCollapse}
className="text-muted -ml-2"
aria-expanded={!isCollapsed}
aria-controls={contentId}
aria-label={isCollapsed ? t("expand_panel") : t("collapse_panel")}
/>
)}
{isStringTitle ? (
<div className="mr-4 flex shrink-0 items-center gap-1">
<h2 id={titleId} className="text-emphasis shrink-0 text-sm font-semibold">
{collapsible ? (
<button
type="button"
onClick={toggleCollapse}
className="text-left transition-opacity hover:opacity-80"
aria-expanded={!isCollapsed}
aria-controls={contentId}
aria-label={isCollapsed ? t("expand_panel") : t("collapse_panel")}>
{title as string}
</button>
) : (
(title as string)
)}
</h2>
{titleTooltip && <InfoBadge content={titleTooltip} />}
</div>
) : (
title
)}
</div>
<div className="no-scrollbar flex items-center gap-2 overflow-x-auto">
{headerContent}
{cta && (
@@ -45,14 +106,20 @@ export function PanelCard({
)}
</div>
</div>
<div className="bg-default border-muted w-full grow gap-3 rounded-xl border">
{subtitle && (
<h3 className="text-subtle border-muted border-b p-3 text-sm font-medium leading-none">
{subtitle}
</h3>
)}
{children}
</div>
{!(isCollapsed && collapsible) && (
<div
id={contentId}
role="region"
aria-labelledby={isStringTitle ? titleId : undefined}
className="bg-default border-muted w-full grow gap-3 rounded-xl border">
{subtitle && (
<h3 className="text-subtle border-muted border-b p-3 text-sm font-medium leading-none">
{subtitle}
</h3>
)}
{children}
</div>
)}
</div>
);
}