Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 91 additions & 0 deletions packages/ui/src/components/collapsible.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import { describe, expect, it, vi } from "vitest"
import { render, screen } from "@testing-library/react"
import userEvent from "@testing-library/user-event"

import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "./collapsible"

describe("Collapsible", () => {
it("connects the trigger to its region with aria relationships", () => {
render(
<Collapsible defaultOpen>
<CollapsibleTrigger>Advanced settings</CollapsibleTrigger>
<CollapsibleContent>
<button type="button">Nested action</button>
</CollapsibleContent>
</Collapsible>
)

const trigger = screen.getByRole("button", { name: "Advanced settings" })
const contentId = trigger.getAttribute("aria-controls")
const content = document.getElementById(contentId ?? "")

expect(trigger).toHaveAttribute("aria-expanded", "true")
expect(content?.tagName).toBe("SECTION")
expect(content).toHaveAttribute("aria-labelledby", trigger.id)
expect(content).toHaveAttribute("aria-hidden", "false")
})

it("toggles open and closed with pointer and keyboard input", async () => {
const user = userEvent.setup()
render(
<Collapsible>
<CollapsibleTrigger>Advanced settings</CollapsibleTrigger>
<CollapsibleContent>
<button type="button">Nested action</button>
</CollapsibleContent>
</Collapsible>
)

const trigger = screen.getByRole("button", { name: "Advanced settings" })

await user.click(trigger)
expect(trigger).toHaveAttribute("aria-expanded", "true")

trigger.focus()
await user.keyboard("{Enter}")
expect(trigger).toHaveAttribute("aria-expanded", "false")
})

it("supports controlled state", async () => {
const user = userEvent.setup()
const onOpenChange = vi.fn()

render(
<Collapsible open={false} onOpenChange={onOpenChange}>
<CollapsibleTrigger>Advanced settings</CollapsibleTrigger>
<CollapsibleContent>
<button type="button">Nested action</button>
</CollapsibleContent>
</Collapsible>
)

await user.click(screen.getByRole("button", { name: "Advanced settings" }))

expect(onOpenChange).toHaveBeenCalledWith(true)
})

it("keeps focusable content available without stealing focus from the trigger", async () => {
const user = userEvent.setup()
render(
<Collapsible defaultOpen>
<CollapsibleTrigger>Advanced settings</CollapsibleTrigger>
<CollapsibleContent>
<input aria-label="Fee override" />
</CollapsibleContent>
</Collapsible>
)

const trigger = screen.getByRole("button", { name: "Advanced settings" })
const input = screen.getByLabelText("Fee override")

trigger.focus()
await user.click(input)

expect(input).toHaveFocus()
expect(trigger).toHaveAttribute("aria-expanded", "true")
})
})
175 changes: 175 additions & 0 deletions packages/ui/src/components/collapsible.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
"use client"

import * as React from "react"

import { ChevronDownIcon } from "@hugeicons/core-free-icons"
import { HugeiconsIcon } from "@hugeicons/react"
import { cn } from "@workspace/ui/lib/utils"

type CollapsibleLayout = "default" | "compact"

type CollapsibleProps = Omit<React.ComponentProps<"div">, "onChange"> & {
open?: boolean
defaultOpen?: boolean
onOpenChange?: (open: boolean) => void
}

type CollapsibleContextValue = {
open: boolean
triggerId: string
contentId: string
toggleOpen: () => void
}

const CollapsibleContext = React.createContext<CollapsibleContextValue | null>(null)

function useCollapsibleContext(component: string) {
const context = React.useContext(CollapsibleContext)
if (!context) {
throw new Error(`${component} must be used within Collapsible`)
}
return context
}

function Collapsible({
className,
children,
open,
defaultOpen = false,
onOpenChange,
...props
}: CollapsibleProps) {
const generatedId = React.useId()
const isControlled = open !== undefined
const [uncontrolledOpen, setUncontrolledOpen] = React.useState(defaultOpen)

const isOpen = isControlled ? open : uncontrolledOpen
const triggerId = `${generatedId}-trigger`
const contentId = `${generatedId}-content`

const toggleOpen = React.useCallback(() => {
const nextOpen = !isOpen
if (!isControlled) {
setUncontrolledOpen(nextOpen)
}
onOpenChange?.(nextOpen)
}, [isControlled, isOpen, onOpenChange])

const contextValue = React.useMemo<CollapsibleContextValue>(
() => ({ open: isOpen, triggerId, contentId, toggleOpen }),
[contentId, isOpen, toggleOpen, triggerId]
)

return (
<CollapsibleContext.Provider value={contextValue}>
<div
data-slot="collapsible"
data-state={isOpen ? "open" : "closed"}
className={cn("w-full", className)}
{...props}
>
{children}
</div>
</CollapsibleContext.Provider>
)
}

type CollapsibleTriggerProps = React.ComponentProps<"button"> & {
layout?: CollapsibleLayout
}

function CollapsibleTrigger({
className,
children,
layout = "default",
onClick,
onKeyDown,
...props
}: CollapsibleTriggerProps) {
const { open, triggerId, contentId, toggleOpen } = useCollapsibleContext(
"CollapsibleTrigger"
)
const suppressNextClickRef = React.useRef(false)

return (
<button
id={triggerId}
type="button"
aria-expanded={open}
aria-controls={contentId}
data-slot="collapsible-trigger"
data-state={open ? "open" : "closed"}
className={cn(
"group flex w-full items-center justify-between gap-3 text-left outline-none transition-colors hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring/30",
layout === "compact"
? "py-2 text-sm leading-5"
: "py-3 text-sm leading-6 font-medium",
className
)}
onClick={(event) => {
onClick?.(event)
if (event.defaultPrevented) return
if (suppressNextClickRef.current) {
suppressNextClickRef.current = false
return
}
toggleOpen()
}}
onKeyDown={(event) => {
onKeyDown?.(event)
if (event.defaultPrevented) return

if (event.key === "Enter" || event.key === " " || event.key === "Spacebar") {
event.preventDefault()
suppressNextClickRef.current = true
toggleOpen()
}
}}
{...props}
>
<span>{children}</span>
<HugeiconsIcon
aria-hidden="true"
icon={ChevronDownIcon}
strokeWidth={2.5}
className={cn(
"size-3.5 shrink-0 text-muted-foreground transition-transform duration-200 group-aria-expanded:rotate-180 motion-reduce:transition-none",
open ? "rotate-180" : "rotate-0"
)}
/>
</button>
)
}

type CollapsibleContentProps = React.ComponentProps<"div">

function CollapsibleContent({
className,
children,
...props
}: CollapsibleContentProps) {
const { open, triggerId, contentId } = useCollapsibleContext(
"CollapsibleContent"
)

return (
<section
id={contentId}
aria-labelledby={triggerId}
aria-hidden={!open}
data-slot="collapsible-content"
data-state={open ? "open" : "closed"}
className={cn(
"grid overflow-hidden text-sm leading-relaxed text-muted-foreground transition-[grid-template-rows,padding-bottom] duration-200 motion-reduce:transition-none",
open ? "grid-rows-[1fr] pb-3" : "grid-rows-[0fr] pb-0",
className
)}
{...props}
>
<div className="min-h-0 overflow-hidden">{children}</div>
</section>
)
}

export { Collapsible, CollapsibleTrigger, CollapsibleContent }
export type { CollapsibleProps, CollapsibleTriggerProps, CollapsibleContentProps }
Loading