chore: create collapsible summary component (#16705)

This is based on the Figma designs here:
https://www.figma.com/design/WfqIgsTFXN2BscBSSyXWF8/Coder-kit?node-id=507-1525&m=dev

---------

Co-authored-by: Steven Masley <stevenmasley@gmail.com>
This commit is contained in:
Jaayden Halko
2025-02-27 12:20:33 -05:00
committed by GitHub
co-authored by Steven Masley
parent cccdf1ecac
commit 464fccd807
3 changed files with 224 additions and 35 deletions
@@ -0,0 +1,120 @@
import type { Meta, StoryObj } from "@storybook/react";
import { Button } from "../Button/Button";
import { CollapsibleSummary } from "./CollapsibleSummary";
const meta: Meta<typeof CollapsibleSummary> = {
title: "components/CollapsibleSummary",
component: CollapsibleSummary,
args: {
label: "Advanced options",
children: (
<>
<div className="p-2 border border-border rounded-md border-solid">
Option 1
</div>
<div className="p-2 border border-border rounded-md border-solid">
Option 2
</div>
<div className="p-2 border border-border rounded-md border-solid">
Option 3
</div>
</>
),
},
};
export default meta;
type Story = StoryObj<typeof CollapsibleSummary>;
export const Default: Story = {};
export const DefaultOpen: Story = {
args: {
defaultOpen: true,
},
};
export const MediumSize: Story = {
args: {
size: "md",
},
};
export const SmallSize: Story = {
args: {
size: "sm",
},
};
export const CustomClassName: Story = {
args: {
className: "text-blue-500 font-bold",
},
};
export const ManyChildren: Story = {
args: {
defaultOpen: true,
children: (
<>
{Array.from({ length: 10 }).map((_, i) => (
<div
key={`option-${i + 1}`}
className="p-2 border border-border rounded-md border-solid"
>
Option {i + 1}
</div>
))}
</>
),
},
};
export const NestedCollapsible: Story = {
args: {
defaultOpen: true,
children: (
<>
<div className="p-2 border border-border rounded-md border-solid">
Option 1
</div>
<CollapsibleSummary label="Nested options" size="sm">
<div className="p-2 border border-border rounded-md border-solid">
Nested Option 1
</div>
<div className="p-2 border border-border rounded-md border-solid">
Nested Option 2
</div>
</CollapsibleSummary>
<div className="p-2 border border-border rounded-md border-solid">
Option 3
</div>
</>
),
},
};
export const ComplexContent: Story = {
args: {
defaultOpen: true,
children: (
<div className="p-4 border border-border rounded-md bg-surface-secondary">
<h3 className="text-lg font-bold mb-2">Complex Content</h3>
<p className="mb-4">
This is a more complex content example with various elements.
</p>
<div className="flex gap-2">
<Button>Action 1</Button>
<Button>Action 2</Button>
</div>
</div>
),
},
};
export const LongLabel: Story = {
args: {
label:
"This is a very long label that might wrap or cause layout issues if not handled properly",
},
};
@@ -0,0 +1,91 @@
import { type VariantProps, cva } from "class-variance-authority";
import { ChevronRightIcon } from "lucide-react";
import { type FC, type ReactNode, useState } from "react";
import { cn } from "utils/cn";
const collapsibleSummaryVariants = cva(
`flex items-center gap-1 p-0 bg-transparent border-0 text-inherit cursor-pointer
transition-colors text-content-secondary hover:text-content-primary font-medium
whitespace-nowrap`,
{
variants: {
size: {
md: "text-sm",
sm: "text-xs",
},
},
defaultVariants: {
size: "md",
},
},
);
export interface CollapsibleSummaryProps
extends VariantProps<typeof collapsibleSummaryVariants> {
/**
* The label to display for the collapsible section
*/
label: string;
/**
* The content to show when expanded
*/
children: ReactNode;
/**
* Whether the section is initially expanded
*/
defaultOpen?: boolean;
/**
* Optional className for the button
*/
className?: string;
/**
* The size of the component
*/
size?: "md" | "sm";
}
export const CollapsibleSummary: FC<CollapsibleSummaryProps> = ({
label,
children,
defaultOpen = false,
className,
size,
}) => {
const [isOpen, setIsOpen] = useState(defaultOpen);
return (
<div className="flex flex-col gap-4">
<button
className={cn(
collapsibleSummaryVariants({ size }),
isOpen && "text-content-primary",
className,
)}
type="button"
onClick={() => {
setIsOpen((v) => !v);
}}
>
<div
className={cn(
"flex items-center justify-center transition-transform duration-200",
isOpen ? "rotate-90" : "rotate-0",
)}
>
<ChevronRightIcon
className={cn(
"p-0.5",
size === "sm" ? "size-icon-xs" : "size-icon-sm",
)}
/>
</div>
<span className="sr-only">
({isOpen ? "Hide" : "Show"}) {label}
</span>
<span className="[&:first-letter]:uppercase">{label}</span>
</button>
{isOpen && <div className="flex flex-col gap-4">{children}</div>}
</div>
);
};
@@ -3,6 +3,7 @@ import Checkbox from "@mui/material/Checkbox";
import Tooltip from "@mui/material/Tooltip";
import type { SlimRole } from "api/typesGenerated";
import { Button } from "components/Button/Button";
import { CollapsibleSummary } from "components/CollapsibleSummary/CollapsibleSummary";
import {
HelpTooltip,
HelpTooltipContent,
@@ -159,41 +160,18 @@ export const EditRolesButton: FC<EditRolesButtonProps> = ({
/>
))}
{advancedRoles.length > 0 && (
<>
<button
className={cn([
"flex items-center gap-1 p-0 bg-transparent border-0 text-inherit text-sm cursor-pointer",
"transition-colors text-content-secondary hover:text-content-primary font-medium whitespace-nowrap",
isAdvancedOpen && "text-content-primary",
])}
type="button"
onClick={() => {
setIsAdvancedOpen((v) => !v);
}}
>
{isAdvancedOpen ? (
<ChevronDownIcon className="size-icon-sm p-0.5" />
) : (
<ChevronRightIcon className="size-icon-sm p-0.5" />
)}
<span className="sr-only">
({isAdvancedOpen ? "Hide" : "Show advanced"})
</span>
<span className="[&:first-letter]:uppercase">Advanced</span>
</button>
{isAdvancedOpen &&
advancedRoles.map((role) => (
<Option
key={role.name}
onChange={handleChange}
isChecked={selectedRoleNames.has(role.name)}
value={role.name}
name={role.display_name || role.name}
description={roleDescriptions[role.name] ?? ""}
/>
))}
</>
<CollapsibleSummary label="advanced" defaultOpen={isAdvancedOpen}>
{advancedRoles.map((role) => (
<Option
key={role.name}
onChange={handleChange}
isChecked={selectedRoleNames.has(role.name)}
value={role.name}
name={role.display_name || role.name}
description={roleDescriptions[role.name] ?? ""}
/>
))}
</CollapsibleSummary>
)}
</div>
</fieldset>