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