Skip to content

Instantly share code, notes, and snippets.

@moritzsalla
Created March 18, 2024 16:15
Show Gist options
  • Save moritzsalla/f7f2c58cedaab7089d19c1d12455e33a to your computer and use it in GitHub Desktop.
Save moritzsalla/f7f2c58cedaab7089d19c1d12455e33a to your computer and use it in GitHub Desktop.
Collapsible primitive
@use "styles/includes.module" as *;
.trigger {
cursor: pointer;
}
.content {
display: grid;
grid-template-rows: 0fr;
overflow: hidden;
transition: 250ms grid-template-rows ease;
> div {
overflow: hidden;
}
&[data-visibility="visible"] {
grid-template-rows: 1fr;
}
}
@use "styles/includes.module" as *;
.container {
width: 700px;
}
.content {
@include body-1;
padding-top: 1rem;
text-wrap: balance;
p + p {
margin-top: 0.5rem;
}
}
import type { Meta, StoryObj } from "@storybook/react";
import PrimaryButtonLayout from "components/elements/PrimaryButtonLayout";
import Collapsible from "./Collapsible";
import styles from "./Collapsible.stories.module.scss";
const meta: Meta<typeof Collapsible> = {
component: Collapsible,
tags: ["autodocs"],
render: () => {
return (
<div className={styles.container}>
<Collapsible>
<Collapsible.Trigger>
<PrimaryButtonLayout>Trigger</PrimaryButtonLayout>
</Collapsible.Trigger>
<Collapsible.Content>
<div className={styles.content}>
{Array.from({ length: 3 }, (_, i) => (
<p key={i}>
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed
do eiusmod tempor incididunt ut labore et dolore magna aliqua.
</p>
))}
</div>
</Collapsible.Content>
</Collapsible>
</div>
);
},
};
type Story = StoryObj<typeof Collapsible>;
export const Default: Story = {};
export default meta;
import React from "react";
import type { RenderResult } from "@testing-library/react";
import { fireEvent, render } from "@testing-library/react";
import { axe } from "jest-axe";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "./Collapsible";
// Loosely adapted from: https://github.com/radix-ui/primitives/blob/main/packages/react/collapsible/src/Collapsible.test.tsx
const TEST_ID_TRIGGER = "trigger";
const TEST_ID_CONTENT = "content";
const CollapsibleTest = (props: React.ComponentProps<typeof Collapsible>) => (
<Collapsible {...props}>
<CollapsibleTrigger data-testid={TEST_ID_TRIGGER}>
Trigger
</CollapsibleTrigger>
<CollapsibleContent data-testid={TEST_ID_CONTENT}>
Content
</CollapsibleContent>
</Collapsible>
);
const getTestElement = (rendered: RenderResult, testId: string) => {
const element = rendered.getByTestId(testId);
if (!element) throw new Error(`Element with test ID "${testId}" not found`);
return element;
};
describe("given a default Collapsible", () => {
let rendered: RenderResult;
let trigger: HTMLElement;
let content: HTMLElement;
beforeEach(() => {
rendered = render(<CollapsibleTest />);
trigger = getTestElement(rendered, TEST_ID_TRIGGER);
});
it("should have no accessibility violations", async () => {
expect(await axe(rendered.container)).toHaveNoViolations();
});
it("should match the snapshot", () => {
expect(rendered.asFragment()).toMatchSnapshot();
});
describe("when clicking the trigger", () => {
beforeEach(async () => {
fireEvent.click(trigger);
content = getTestElement(rendered, TEST_ID_CONTENT);
});
it("should open the content", () => {
expect(content).toHaveAttribute("data-visibility", "visible");
});
describe("and clicking the trigger again", () => {
beforeEach(() => {
fireEvent.click(trigger);
});
it("should visually hide the content", () => {
expect(content).toHaveAttribute("data-visibility", "hidden");
});
});
});
});
describe("given an open controlled Collapsible", () => {
let rendered: RenderResult;
let trigger: HTMLElement;
let content: HTMLElement;
const onOpenChange = jest.fn();
beforeEach(() => {
rendered = render(
<CollapsibleTest defaultOpen onOpenChange={onOpenChange} />,
);
});
describe("when clicking the trigger", () => {
beforeEach(async () => {
trigger = getTestElement(rendered, TEST_ID_TRIGGER);
content = getTestElement(rendered, TEST_ID_CONTENT);
fireEvent.click(trigger);
});
it("should hide the content", () => {
expect(content).toHaveAttribute("data-visibility", "hidden");
});
it("should call `onOpenChange` prop with `false` value", () => {
expect(onOpenChange).toHaveBeenCalledWith(false);
});
it("should match the snapshot", () => {
expect(rendered.asFragment()).toMatchSnapshot();
});
});
});
"use client";
import React, {
createContext,
forwardRef,
useEffect,
useId,
useState,
} from "react";
import Button, { type ButtonProps } from "components/primitives/Button";
import { mergeClassNames } from "functions/styles/mergeClassNames";
import { generateSafeContext } from "hooks/context/generateSafeContext";
import styles from "./Collapsible.module.scss";
/* -------------------------------------------------------------------
* Collapsible
* -----------------------------------------------------------------*/
type InternalCollapsibleState = {
isOpen: boolean;
setIsOpen: (isOpen: boolean) => void;
id: string;
};
const CollapsibleContext = createContext<InternalCollapsibleState | undefined>(
undefined,
);
const useCollapsible = generateSafeContext(CollapsibleContext);
export type CollapsibleRootProps = React.PropsWithChildren<{
id?: string;
defaultOpen?: boolean;
onOpenChange?: (isOpen: boolean) => void;
}>;
/**
* *Controlled* collapsible component that doesn't hide content from the DOM when collapsed.
*
* This implementation is roughly based on radix-ui's Collapsible component,
* yet doesn't hide content from the DOM when collapsed.
*
* As opposed to radix-ui's Collapsible component, this implementation doesn't
* support the `open` prop, as it's a controlled component.
*
* See Radix UI's reference:
* https://www.radix-ui.com/primitives/docs/components/collapsible
*
* See @brunotomedev's reference:
* https://dev.to/francescovetere/css-trick-transition-from-height-0-to-auto-21de
*
* ```tsx
* <Collapsible>
* <Collapsible.Trigger className="trigger">
* <span className="triggerLayout">Toggle</span>
* </Collapsible.Trigger>
* <Collapsible.Content>
* <div className="content">
* Hello, world!
* </div>
* </Collapsible.Content>
* </Collapsible>
* ```
*
* You may pass a custom ID to the `Collapsible` component if necessary.
* This will override the internal ID generated by the component.
*
* Pass `defaultOpen` to set the initial state of the collapsible.
*
* You can access the internal state in css by using the `data-state` attribute.
*
* ```css
* .trigger[data-state="open"] .triggerLayout {
* transform: rotate(180deg);
* }
* ```
*/
export const Collapsible = ({
children,
id,
defaultOpen = false,
onOpenChange,
}: CollapsibleRootProps) => {
// Generate a unique ID if none is provided
// eslint-disable-next-line react-hooks/rules-of-hooks
const internalId = id ?? useId();
const [isOpen, setIsOpen] = useState(defaultOpen);
// Notify the parent component of the open state change
useEffect(() => {
onOpenChange?.(isOpen);
}, [isOpen, onOpenChange]);
return (
<CollapsibleContext.Provider value={{ isOpen, setIsOpen, id: internalId }}>
{children}
</CollapsibleContext.Provider>
);
};
/* -------------------------------------------------------------------
* CollapsibleTrigger
* -----------------------------------------------------------------*/
const DEBUG_NAME = "CollapsibleTrigger";
// These props are necessary for the component to function
// and should not be overridden by the user.
const TRIGGER_INTERNAL_PROPS = [
"aria-controls",
"aria-expanded",
"data-state",
] as const;
type TriggerInternalProp = (typeof TRIGGER_INTERNAL_PROPS)[number];
export type CollapsibleTriggerProps = Omit<ButtonProps, TriggerInternalProp>;
type CollapsibleTriggerElement = React.ElementRef<typeof Button>;
export const CollapsibleTrigger = forwardRef<
CollapsibleTriggerElement,
CollapsibleTriggerProps
>((props, forwardedRef) => {
const { children, className, onClick, ...userProps } = props;
shieldInternalProps(TRIGGER_INTERNAL_PROPS, userProps, DEBUG_NAME);
const { isOpen, setIsOpen, id } = useCollapsible();
return (
<Button
{...userProps}
ref={forwardedRef}
aria-controls={id}
aria-expanded={isOpen}
data-state={isOpen ? "open" : "closed"}
className={mergeClassNames(className, styles.trigger)}
onClick={(e) => {
setIsOpen(!isOpen);
// Propagate the event to the user's onClick handler
onClick?.(e);
}}
>
{children}
</Button>
);
});
CollapsibleTrigger.displayName = DEBUG_NAME;
/* -------------------------------------------------------------------
* CollapsibleContent
* -----------------------------------------------------------------*/
const DEBUG_CONTENT_NAME = "CollapsibleContent";
// These props are necessary for the component to function
// and should not be overridden by the user.
const CONTENT_INTERNAL_PROPS = ["data-visibility", "data-state"] as const;
type ContentInternalProp = (typeof CONTENT_INTERNAL_PROPS)[number];
export type CollapsibleContentProps = Omit<
React.DetailsHTMLAttributes<HTMLDivElement>,
ContentInternalProp
>;
type CollapsibleContentElement = React.ElementRef<"div">;
export const CollapsibleContent = forwardRef<
CollapsibleContentElement,
CollapsibleContentProps
>((props, forwardedRef) => {
const { children, className, ...userProps } = props;
shieldInternalProps(CONTENT_INTERNAL_PROPS, userProps, DEBUG_CONTENT_NAME);
const { isOpen } = useCollapsible();
return (
<div
{...userProps}
ref={forwardedRef}
data-state={isOpen ? "open" : "closed"}
data-visibility={isOpen ? "visible" : "hidden"}
className={mergeClassNames(className, styles.content)}
>
<div>{children}</div>
</div>
);
});
CollapsibleContent.displayName = DEBUG_CONTENT_NAME;
/* -----------------------------------------------------------------*/
// Warn the user if they are trying to override internal props.
const shieldInternalProps = (
internalProps: ReadonlyArray<string>,
userProps: Record<string, unknown>,
debugName: string,
) => {
if (Object.keys(userProps).some((prop) => internalProps.includes(prop))) {
const error = new Error(
`${debugName}: You may not use the following props as they are used internally: ${internalProps.join(
", ",
)}`,
);
if (Error.captureStackTrace) {
Error.captureStackTrace(error, shieldInternalProps);
}
throw error;
}
};
Collapsible.Trigger = CollapsibleTrigger;
Collapsible.Content = CollapsibleContent;
CollapsibleContext.displayName = "CollapsibleContext";
export default Collapsible;
export {
type CollapsibleContentProps,
type CollapsibleRootProps,
type CollapsibleTriggerProps,
default,
} from "./Collapsible";
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment