From 7359cd8d8fd9eaa64a4d643b9a41e5e227264512 Mon Sep 17 00:00:00 2001 From: Mats Bosson Date: Sun, 29 Mar 2026 08:00:12 +0700 Subject: [PATCH] Collapsible component Implements headless Collapsible with Root, Trigger, and Content parts. Supports controlled/uncontrolled open state, disabled state, and full ARIA attributes (aria-expanded, aria-controls, hidden). --- .../collapsible/collapsible-content.tsx | 27 ++++++ .../collapsible/collapsible-context.ts | 27 ++++++ .../collapsible/collapsible-root.tsx | 61 ++++++++++++ .../collapsible/collapsible-trigger.tsx | 30 ++++++ .../core/src/components/collapsible/index.ts | 15 +++ .../collapsible/collapsible.test.tsx | 92 +++++++++++++++++++ 6 files changed, 252 insertions(+) create mode 100644 packages/core/src/components/collapsible/collapsible-content.tsx create mode 100644 packages/core/src/components/collapsible/collapsible-context.ts create mode 100644 packages/core/src/components/collapsible/collapsible-root.tsx create mode 100644 packages/core/src/components/collapsible/collapsible-trigger.tsx create mode 100644 packages/core/src/components/collapsible/index.ts create mode 100644 packages/core/tests/components/collapsible/collapsible.test.tsx diff --git a/packages/core/src/components/collapsible/collapsible-content.tsx b/packages/core/src/components/collapsible/collapsible-content.tsx new file mode 100644 index 0000000..6efcbcf --- /dev/null +++ b/packages/core/src/components/collapsible/collapsible-content.tsx @@ -0,0 +1,27 @@ +import type { JSX } from "solid-js"; +import { splitProps } from "solid-js"; +import { useCollapsibleContext } from "./collapsible-context"; + +export interface CollapsibleContentProps extends JSX.HTMLAttributes { + children?: JSX.Element; +} + +/** + * The content panel. Always stays in the DOM (uses hidden attribute) so CSS + * transitions have access to the element when animating in/out. + */ +export function CollapsibleContent(props: CollapsibleContentProps): JSX.Element { + const [local, rest] = splitProps(props, ["children"]); + const ctx = useCollapsibleContext(); + + return ( + + ); +} diff --git a/packages/core/src/components/collapsible/collapsible-context.ts b/packages/core/src/components/collapsible/collapsible-context.ts new file mode 100644 index 0000000..3eb2ef6 --- /dev/null +++ b/packages/core/src/components/collapsible/collapsible-context.ts @@ -0,0 +1,27 @@ +import type { Accessor } from "solid-js"; +import { createContext, useContext } from "solid-js"; + +export interface CollapsibleContextValue { + isOpen: Accessor; + setOpen: (open: boolean) => void; + contentId: Accessor; + disabled: Accessor; +} + +const CollapsibleContext = createContext(); + +/** + * Returns the Collapsible context. Throws if used outside . + */ +export function useCollapsibleContext(): CollapsibleContextValue { + const ctx = useContext(CollapsibleContext); + if (!ctx) { + throw new Error( + "[PettyUI] Collapsible parts must be used inside .\n" + + " Fix: Wrap Collapsible.Trigger and Collapsible.Content inside .", + ); + } + return ctx; +} + +export const CollapsibleContextProvider = CollapsibleContext.Provider; diff --git a/packages/core/src/components/collapsible/collapsible-root.tsx b/packages/core/src/components/collapsible/collapsible-root.tsx new file mode 100644 index 0000000..2e501bd --- /dev/null +++ b/packages/core/src/components/collapsible/collapsible-root.tsx @@ -0,0 +1,61 @@ +import type { JSX } from "solid-js"; +import { createUniqueId, splitProps } from "solid-js"; +import { + type CreateDisclosureStateOptions, + createDisclosureState, +} from "../../primitives/create-disclosure-state"; +import { CollapsibleContextProvider, type CollapsibleContextValue } from "./collapsible-context"; + +export interface CollapsibleRootProps extends JSX.HTMLAttributes { + open?: boolean; + defaultOpen?: boolean; + onOpenChange?: (open: boolean) => void; + disabled?: boolean; + children: JSX.Element; +} + +/** + * Root container for a collapsible section. Manages open/close state. + */ +export function CollapsibleRoot(props: CollapsibleRootProps): JSX.Element { + const [local, rest] = splitProps(props, [ + "open", + "defaultOpen", + "onOpenChange", + "disabled", + "children", + ]); + + const disclosure = createDisclosureState({ + get open() { + return local.open; + }, + get defaultOpen() { + return local.defaultOpen; + }, + get onOpenChange() { + return local.onOpenChange; + }, + } as CreateDisclosureStateOptions); + + const contentId = createUniqueId(); + + const ctx: CollapsibleContextValue = { + isOpen: disclosure.isOpen, + setOpen: (open) => (open ? disclosure.open() : disclosure.close()), + contentId: () => contentId, + disabled: () => local.disabled ?? false, + }; + + return ( + +
+ {local.children} +
+
+ ); +} diff --git a/packages/core/src/components/collapsible/collapsible-trigger.tsx b/packages/core/src/components/collapsible/collapsible-trigger.tsx new file mode 100644 index 0000000..948ea19 --- /dev/null +++ b/packages/core/src/components/collapsible/collapsible-trigger.tsx @@ -0,0 +1,30 @@ +import type { JSX } from "solid-js"; +import { splitProps } from "solid-js"; +import { useCollapsibleContext } from "./collapsible-context"; + +export interface CollapsibleTriggerProps extends JSX.ButtonHTMLAttributes { + children?: JSX.Element; +} + +/** Button that toggles the Collapsible open/closed. */ +export function CollapsibleTrigger(props: CollapsibleTriggerProps): JSX.Element { + const [local, rest] = splitProps(props, ["children"]); + const ctx = useCollapsibleContext(); + + return ( + + ); +} diff --git a/packages/core/src/components/collapsible/index.ts b/packages/core/src/components/collapsible/index.ts new file mode 100644 index 0000000..8ea6c55 --- /dev/null +++ b/packages/core/src/components/collapsible/index.ts @@ -0,0 +1,15 @@ +import { CollapsibleContent } from "./collapsible-content"; +import { useCollapsibleContext } from "./collapsible-context"; +import { CollapsibleRoot } from "./collapsible-root"; +import { CollapsibleTrigger } from "./collapsible-trigger"; + +export const Collapsible = Object.assign(CollapsibleRoot, { + Trigger: CollapsibleTrigger, + Content: CollapsibleContent, + useContext: useCollapsibleContext, +}); + +export type { CollapsibleRootProps } from "./collapsible-root"; +export type { CollapsibleTriggerProps } from "./collapsible-trigger"; +export type { CollapsibleContentProps } from "./collapsible-content"; +export type { CollapsibleContextValue } from "./collapsible-context"; diff --git a/packages/core/tests/components/collapsible/collapsible.test.tsx b/packages/core/tests/components/collapsible/collapsible.test.tsx new file mode 100644 index 0000000..d73138d --- /dev/null +++ b/packages/core/tests/components/collapsible/collapsible.test.tsx @@ -0,0 +1,92 @@ +import { fireEvent, render, screen } from "@solidjs/testing-library"; +import { describe, expect, it } from "vitest"; +import { Collapsible } from "../../../src/components/collapsible/index"; + +describe("Collapsible", () => { + it("content is hidden by default", () => { + render(() => ( + + Toggle + Hidden + + )); + expect(screen.getByTestId("content")).toHaveAttribute("hidden"); + }); + + it("content is shown when defaultOpen=true", () => { + render(() => ( + + Toggle + Visible + + )); + expect(screen.getByTestId("content")).not.toHaveAttribute("hidden"); + }); + + it("trigger click opens content", () => { + render(() => ( + + Toggle + Content + + )); + fireEvent.click(screen.getByRole("button")); + expect(screen.getByTestId("content")).not.toHaveAttribute("hidden"); + }); + + it("trigger click closes open content", () => { + render(() => ( + + Toggle + Content + + )); + fireEvent.click(screen.getByRole("button")); + expect(screen.getByTestId("content")).toHaveAttribute("hidden"); + }); + + it("trigger has aria-expanded reflecting open state", () => { + render(() => ( + + Toggle + Content + + )); + expect(screen.getByRole("button").getAttribute("aria-expanded")).toBe("false"); + fireEvent.click(screen.getByRole("button")); + expect(screen.getByRole("button").getAttribute("aria-expanded")).toBe("true"); + }); + + it("trigger aria-controls matches content id", () => { + render(() => ( + + Toggle + Content + + )); + const trigger = screen.getByRole("button"); + const content = screen.getByTestId("content"); + expect(trigger.getAttribute("aria-controls")).toBe(content.id); + }); + + it("disabled trigger does not toggle", () => { + render(() => ( + + Toggle + Content + + )); + fireEvent.click(screen.getByRole("button")); + expect(screen.getByTestId("content")).toHaveAttribute("hidden"); + }); + + it("controlled open state", () => { + render(() => ( + {}}> + Toggle + Content + + )); + expect(screen.getByTestId("content")).not.toHaveAttribute("hidden"); + }); +});