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:
parent
315f5e4ae2
commit
7359cd8d8f
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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;
|
||||||
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
15
packages/core/src/components/collapsible/index.ts
Normal file
15
packages/core/src/components/collapsible/index.ts
Normal 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";
|
||||||
@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
x
Reference in New Issue
Block a user