Created
March 18, 2024 16:15
-
-
Save moritzsalla/f7f2c58cedaab7089d19c1d12455e33a to your computer and use it in GitHub Desktop.
Collapsible primitive
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
@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; | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
@use "styles/includes.module" as *; | |
.container { | |
width: 700px; | |
} | |
.content { | |
@include body-1; | |
padding-top: 1rem; | |
text-wrap: balance; | |
p + p { | |
margin-top: 0.5rem; | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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(); | |
}); | |
}); | |
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
"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; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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