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).
This commit is contained in:
Mats Bosson 2026-03-29 08:00:12 +07:00
parent 315f5e4ae2
commit 7359cd8d8f
6 changed files with 252 additions and 0 deletions

View File

@ -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<HTMLDivElement> {
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 (
<div
id={ctx.contentId()}
hidden={!ctx.isOpen() || undefined}
data-state={ctx.isOpen() ? "open" : "closed"}
{...rest}
>
{local.children}
</div>
);
}

View File

@ -0,0 +1,27 @@
import type { Accessor } from "solid-js";
import { createContext, useContext } from "solid-js";
export interface CollapsibleContextValue {
isOpen: Accessor<boolean>;
setOpen: (open: boolean) => void;
contentId: Accessor<string>;
disabled: Accessor<boolean>;
}
const CollapsibleContext = createContext<CollapsibleContextValue>();
/**
* Returns the Collapsible context. Throws if used outside <Collapsible>.
*/
export function useCollapsibleContext(): CollapsibleContextValue {
const ctx = useContext(CollapsibleContext);
if (!ctx) {
throw new Error(
"[PettyUI] Collapsible parts must be used inside <Collapsible>.\n" +
" Fix: Wrap Collapsible.Trigger and Collapsible.Content inside <Collapsible>.",
);
}
return ctx;
}
export const CollapsibleContextProvider = CollapsibleContext.Provider;

View File

@ -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<HTMLDivElement> {
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 (
<CollapsibleContextProvider value={ctx}>
<div
data-state={disclosure.isOpen() ? "open" : "closed"}
data-disabled={local.disabled || undefined}
{...rest}
>
{local.children}
</div>
</CollapsibleContextProvider>
);
}

View File

@ -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<HTMLButtonElement> {
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 (
<button
type="button"
aria-expanded={ctx.isOpen() ? "true" : "false"}
aria-controls={ctx.contentId()}
data-state={ctx.isOpen() ? "open" : "closed"}
disabled={ctx.disabled()}
{...rest}
onClick={(e) => {
if (typeof rest.onClick === "function") rest.onClick(e);
if (!ctx.disabled()) ctx.setOpen(!ctx.isOpen());
}}
>
{local.children}
</button>
);
}

View File

@ -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";

View File

@ -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(() => (
<Collapsible>
<Collapsible.Trigger>Toggle</Collapsible.Trigger>
<Collapsible.Content data-testid="content">Hidden</Collapsible.Content>
</Collapsible>
));
expect(screen.getByTestId("content")).toHaveAttribute("hidden");
});
it("content is shown when defaultOpen=true", () => {
render(() => (
<Collapsible defaultOpen>
<Collapsible.Trigger>Toggle</Collapsible.Trigger>
<Collapsible.Content data-testid="content">Visible</Collapsible.Content>
</Collapsible>
));
expect(screen.getByTestId("content")).not.toHaveAttribute("hidden");
});
it("trigger click opens content", () => {
render(() => (
<Collapsible>
<Collapsible.Trigger>Toggle</Collapsible.Trigger>
<Collapsible.Content data-testid="content">Content</Collapsible.Content>
</Collapsible>
));
fireEvent.click(screen.getByRole("button"));
expect(screen.getByTestId("content")).not.toHaveAttribute("hidden");
});
it("trigger click closes open content", () => {
render(() => (
<Collapsible defaultOpen>
<Collapsible.Trigger>Toggle</Collapsible.Trigger>
<Collapsible.Content data-testid="content">Content</Collapsible.Content>
</Collapsible>
));
fireEvent.click(screen.getByRole("button"));
expect(screen.getByTestId("content")).toHaveAttribute("hidden");
});
it("trigger has aria-expanded reflecting open state", () => {
render(() => (
<Collapsible>
<Collapsible.Trigger>Toggle</Collapsible.Trigger>
<Collapsible.Content>Content</Collapsible.Content>
</Collapsible>
));
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(() => (
<Collapsible>
<Collapsible.Trigger>Toggle</Collapsible.Trigger>
<Collapsible.Content data-testid="content">Content</Collapsible.Content>
</Collapsible>
));
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(() => (
<Collapsible disabled>
<Collapsible.Trigger>Toggle</Collapsible.Trigger>
<Collapsible.Content data-testid="content">Content</Collapsible.Content>
</Collapsible>
));
fireEvent.click(screen.getByRole("button"));
expect(screen.getByTestId("content")).toHaveAttribute("hidden");
});
it("controlled open state", () => {
render(() => (
<Collapsible open={true} onOpenChange={() => {}}>
<Collapsible.Trigger>Toggle</Collapsible.Trigger>
<Collapsible.Content data-testid="content">Content</Collapsible.Content>
</Collapsible>
));
expect(screen.getByTestId("content")).not.toHaveAttribute("hidden");
});
});