From 987b6834024bcb5e09c41143abcf2dbf98b20dca Mon Sep 17 00:00:00 2001 From: Oluwatomilola Date: Thu, 30 Jul 2026 01:21:49 +0100 Subject: [PATCH] Disclosure primitive implemented --- .../ui/src/components/collapsible.test.tsx | 91 +++++++++ packages/ui/src/components/collapsible.tsx | 175 ++++++++++++++++++ 2 files changed, 266 insertions(+) create mode 100644 packages/ui/src/components/collapsible.test.tsx create mode 100644 packages/ui/src/components/collapsible.tsx diff --git a/packages/ui/src/components/collapsible.test.tsx b/packages/ui/src/components/collapsible.test.tsx new file mode 100644 index 0000000..893634d --- /dev/null +++ b/packages/ui/src/components/collapsible.test.tsx @@ -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( + + Advanced settings + + + + + ) + + 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( + + Advanced settings + + + + + ) + + 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( + + Advanced settings + + + + + ) + + 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( + + Advanced settings + + + + + ) + + 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") + }) +}) diff --git a/packages/ui/src/components/collapsible.tsx b/packages/ui/src/components/collapsible.tsx new file mode 100644 index 0000000..09aeda5 --- /dev/null +++ b/packages/ui/src/components/collapsible.tsx @@ -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, "onChange"> & { + open?: boolean + defaultOpen?: boolean + onOpenChange?: (open: boolean) => void +} + +type CollapsibleContextValue = { + open: boolean + triggerId: string + contentId: string + toggleOpen: () => void +} + +const CollapsibleContext = React.createContext(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( + () => ({ open: isOpen, triggerId, contentId, toggleOpen }), + [contentId, isOpen, toggleOpen, triggerId] + ) + + return ( + +
+ {children} +
+
+ ) +} + +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 ( + + ) +} + +type CollapsibleContentProps = React.ComponentProps<"div"> + +function CollapsibleContent({ + className, + children, + ...props +}: CollapsibleContentProps) { + const { open, triggerId, contentId } = useCollapsibleContext( + "CollapsibleContent" + ) + + return ( +
+
{children}
+
+ ) +} + +export { Collapsible, CollapsibleTrigger, CollapsibleContent } +export type { CollapsibleProps, CollapsibleTriggerProps, CollapsibleContentProps }