Accordion component

Headless accordion with single/multiple modes, collapsible support, keyboard navigation (ArrowDown/ArrowUp/Home/End), and full ARIA semantics.
This commit is contained in:
Mats Bosson 2026-03-29 08:06:47 +07:00
parent 4c3005aa74
commit 94822186c2
8 changed files with 384 additions and 0 deletions

View File

@ -0,0 +1,29 @@
import type { JSX } from "solid-js";
import { splitProps } from "solid-js";
import { useAccordionItemContext } from "./accordion-context";
export interface AccordionContentProps extends JSX.HTMLAttributes<HTMLDivElement> {
children?: JSX.Element;
}
/**
* Content panel for an Accordion item. Stays in DOM (uses hidden attribute)
* so CSS transitions work when animating open/closed.
*/
export function AccordionContent(props: AccordionContentProps): JSX.Element {
const [local, rest] = splitProps(props, ["children"]);
const itemCtx = useAccordionItemContext();
return (
<div
id={itemCtx.contentId}
role="region"
aria-labelledby={itemCtx.triggerId}
hidden={!itemCtx.isExpanded() || undefined}
data-state={itemCtx.isExpanded() ? "open" : "closed"}
{...rest}
>
{local.children}
</div>
);
}

View File

@ -0,0 +1,40 @@
import type { Accessor } from "solid-js";
import { createContext, useContext } from "solid-js";
export interface AccordionRootContextValue {
isExpanded: (value: string) => boolean;
toggleItem: (value: string) => void;
disabled: Accessor<boolean>;
}
export interface AccordionItemContextValue {
value: string;
isExpanded: Accessor<boolean>;
triggerId: string;
contentId: string;
}
const AccordionRootContext = createContext<AccordionRootContextValue>();
const AccordionItemContext = createContext<AccordionItemContextValue>();
/**
* Returns the Accordion root context. Throws if used outside <Accordion>.
*/
export function useAccordionRootContext(): AccordionRootContextValue {
const ctx = useContext(AccordionRootContext);
if (!ctx) throw new Error("[PettyUI] Accordion parts must be used inside <Accordion>.");
return ctx;
}
/**
* Returns the AccordionItem context. Throws if used outside <Accordion.Item>.
*/
export function useAccordionItemContext(): AccordionItemContextValue {
const ctx = useContext(AccordionItemContext);
if (!ctx)
throw new Error("[PettyUI] Accordion.Trigger/Content must be used inside <Accordion.Item>.");
return ctx;
}
export const AccordionRootContextProvider = AccordionRootContext.Provider;
export const AccordionItemContextProvider = AccordionItemContext.Provider;

View File

@ -0,0 +1,19 @@
import type { JSX } from "solid-js";
import { splitProps } from "solid-js";
import { Dynamic } from "solid-js/web";
export interface AccordionHeaderProps extends JSX.HTMLAttributes<HTMLHeadingElement> {
/** Heading level element. @default "h3" */
as?: string;
children?: JSX.Element;
}
/** Heading wrapper for an Accordion trigger. Defaults to h3. */
export function AccordionHeader(props: AccordionHeaderProps): JSX.Element {
const [local, rest] = splitProps(props, ["as", "children"]);
return (
<Dynamic component={local.as ?? "h3"} {...rest}>
{local.children}
</Dynamic>
);
}

View File

@ -0,0 +1,36 @@
import type { JSX } from "solid-js";
import { createUniqueId, splitProps } from "solid-js";
import { AccordionItemContextProvider, useAccordionRootContext } from "./accordion-context";
export interface AccordionItemProps extends JSX.HTMLAttributes<HTMLDivElement> {
value: string;
disabled?: boolean;
children: JSX.Element;
}
/** A single collapsible item within an Accordion. */
export function AccordionItem(props: AccordionItemProps): JSX.Element {
const [local, rest] = splitProps(props, ["value", "disabled", "children"]);
const rootCtx = useAccordionRootContext();
const triggerId = createUniqueId();
const contentId = createUniqueId();
const itemCtx = {
value: local.value,
isExpanded: () => rootCtx.isExpanded(local.value),
triggerId,
contentId,
};
return (
<AccordionItemContextProvider value={itemCtx}>
<div
data-state={rootCtx.isExpanded(local.value) ? "open" : "closed"}
data-disabled={local.disabled || undefined}
{...rest}
>
{local.children}
</div>
</AccordionItemContextProvider>
);
}

View File

@ -0,0 +1,70 @@
import type { JSX } from "solid-js";
import { createSignal, splitProps } from "solid-js";
import { AccordionRootContextProvider, type AccordionRootContextValue } from "./accordion-context";
export interface AccordionRootProps extends JSX.HTMLAttributes<HTMLDivElement> {
/** "single" allows one item open at a time; "multiple" allows any number. @default "single" */
type?: "single" | "multiple";
/** In single mode, whether the open item can be closed by clicking it again. @default false */
collapsible?: boolean;
value?: string | string[];
defaultValue?: string | string[];
onValueChange?: (value: string | string[]) => void;
disabled?: boolean;
children: JSX.Element;
}
/**
* Root container for an accordion. Manages which items are expanded.
*/
export function AccordionRoot(props: AccordionRootProps): JSX.Element {
const [local, rest] = splitProps(props, [
"type",
"collapsible",
"value",
"defaultValue",
"onValueChange",
"disabled",
"children",
]);
const type = () => local.type ?? "single";
const normalize = (v: string | string[] | undefined): string[] => {
if (v === undefined) return [];
return Array.isArray(v) ? v : [v];
};
const [expandedValues, setExpandedValues] = createSignal<string[]>(normalize(local.defaultValue));
const getValues = (): string[] =>
local.value !== undefined ? normalize(local.value) : expandedValues();
const ctx: AccordionRootContextValue = {
isExpanded: (value) => getValues().includes(value),
toggleItem: (value) => {
const current = getValues();
let next: string[];
if (type() === "single") {
if (current.includes(value)) {
next = local.collapsible ? [] : current;
} else {
next = [value];
}
} else {
next = current.includes(value) ? current.filter((v) => v !== value) : [...current, value];
}
if (local.value === undefined) setExpandedValues(next);
local.onValueChange?.(type() === "multiple" ? next : (next[0] ?? ""));
},
disabled: () => local.disabled ?? false,
};
return (
<AccordionRootContextProvider value={ctx}>
<div data-accordion-root data-disabled={local.disabled || undefined} {...rest}>
{local.children}
</div>
</AccordionRootContextProvider>
);
}

View File

@ -0,0 +1,55 @@
import type { JSX } from "solid-js";
import { splitProps } from "solid-js";
import { useAccordionItemContext, useAccordionRootContext } from "./accordion-context";
export interface AccordionTriggerProps extends JSX.ButtonHTMLAttributes<HTMLButtonElement> {
children?: JSX.Element;
}
/** Button that toggles an Accordion item open/closed. Supports ArrowDown/ArrowUp/Home/End navigation. */
export function AccordionTrigger(props: AccordionTriggerProps): JSX.Element {
const [local, rest] = splitProps(props, ["children"]);
const rootCtx = useAccordionRootContext();
const itemCtx = useAccordionItemContext();
return (
<button
type="button"
id={itemCtx.triggerId}
aria-expanded={itemCtx.isExpanded() ? "true" : "false"}
aria-controls={itemCtx.contentId}
data-state={itemCtx.isExpanded() ? "open" : "closed"}
data-accordion-trigger
disabled={rootCtx.disabled()}
{...rest}
onClick={(e) => {
if (typeof rest.onClick === "function") rest.onClick(e);
if (!rootCtx.disabled()) rootCtx.toggleItem(itemCtx.value);
}}
onKeyDown={(e) => {
if (typeof rest.onKeyDown === "function") rest.onKeyDown(e);
const accordion = (e.currentTarget as HTMLButtonElement).closest("[data-accordion-root]");
if (!accordion) return;
const triggers = Array.from(
accordion.querySelectorAll<HTMLButtonElement>("[data-accordion-trigger]"),
);
const index = triggers.indexOf(e.currentTarget as HTMLButtonElement);
if (e.key === "ArrowDown") {
e.preventDefault();
triggers[(index + 1) % triggers.length]?.focus();
} else if (e.key === "ArrowUp") {
e.preventDefault();
triggers[(index - 1 + triggers.length) % triggers.length]?.focus();
} else if (e.key === "Home") {
e.preventDefault();
triggers[0]?.focus();
} else if (e.key === "End") {
e.preventDefault();
triggers[triggers.length - 1]?.focus();
}
}}
>
{local.children}
</button>
);
}

View File

@ -0,0 +1,21 @@
import { AccordionContent } from "./accordion-content";
import { useAccordionRootContext } from "./accordion-context";
import { AccordionHeader } from "./accordion-header";
import { AccordionItem } from "./accordion-item";
import { AccordionRoot } from "./accordion-root";
import { AccordionTrigger } from "./accordion-trigger";
export const Accordion = Object.assign(AccordionRoot, {
Item: AccordionItem,
Header: AccordionHeader,
Trigger: AccordionTrigger,
Content: AccordionContent,
useContext: useAccordionRootContext,
});
export type { AccordionRootProps } from "./accordion-root";
export type { AccordionItemProps } from "./accordion-item";
export type { AccordionHeaderProps } from "./accordion-header";
export type { AccordionTriggerProps } from "./accordion-trigger";
export type { AccordionContentProps } from "./accordion-content";
export type { AccordionRootContextValue, AccordionItemContextValue } from "./accordion-context";

View File

@ -0,0 +1,114 @@
import { fireEvent, render, screen } from "@solidjs/testing-library";
import { describe, expect, it } from "vitest";
import { Accordion } from "../../../src/components/accordion/index";
describe("Accordion", () => {
it("all items closed by default", () => {
render(() => (
<Accordion>
<Accordion.Item value="a">
<Accordion.Header><Accordion.Trigger>A</Accordion.Trigger></Accordion.Header>
<Accordion.Content data-testid="content-a">Content A</Accordion.Content>
</Accordion.Item>
</Accordion>
));
expect(screen.getByTestId("content-a")).toHaveAttribute("hidden");
});
it("clicking trigger opens item", () => {
render(() => (
<Accordion>
<Accordion.Item value="a">
<Accordion.Header><Accordion.Trigger>A</Accordion.Trigger></Accordion.Header>
<Accordion.Content data-testid="content-a">Content A</Accordion.Content>
</Accordion.Item>
</Accordion>
));
fireEvent.click(screen.getByRole("button"));
expect(screen.getByTestId("content-a")).not.toHaveAttribute("hidden");
});
it("single mode: opening one closes another", () => {
render(() => (
<Accordion type="single" defaultValue="a">
<Accordion.Item value="a">
<Accordion.Header><Accordion.Trigger>A</Accordion.Trigger></Accordion.Header>
<Accordion.Content data-testid="content-a">Content A</Accordion.Content>
</Accordion.Item>
<Accordion.Item value="b">
<Accordion.Header><Accordion.Trigger>B</Accordion.Trigger></Accordion.Header>
<Accordion.Content data-testid="content-b">Content B</Accordion.Content>
</Accordion.Item>
</Accordion>
));
expect(screen.getByTestId("content-a")).not.toHaveAttribute("hidden");
fireEvent.click(screen.getAllByRole("button")[1]);
expect(screen.getByTestId("content-a")).toHaveAttribute("hidden");
expect(screen.getByTestId("content-b")).not.toHaveAttribute("hidden");
});
it("single mode with collapsible=true: clicking open item closes it", () => {
render(() => (
<Accordion type="single" collapsible defaultValue="a">
<Accordion.Item value="a">
<Accordion.Header><Accordion.Trigger>A</Accordion.Trigger></Accordion.Header>
<Accordion.Content data-testid="content-a">Content A</Accordion.Content>
</Accordion.Item>
</Accordion>
));
fireEvent.click(screen.getByRole("button"));
expect(screen.getByTestId("content-a")).toHaveAttribute("hidden");
});
it("multiple mode: multiple items can be open", () => {
render(() => (
<Accordion type="multiple">
<Accordion.Item value="a">
<Accordion.Header><Accordion.Trigger>A</Accordion.Trigger></Accordion.Header>
<Accordion.Content data-testid="content-a">Content A</Accordion.Content>
</Accordion.Item>
<Accordion.Item value="b">
<Accordion.Header><Accordion.Trigger>B</Accordion.Trigger></Accordion.Header>
<Accordion.Content data-testid="content-b">Content B</Accordion.Content>
</Accordion.Item>
</Accordion>
));
fireEvent.click(screen.getAllByRole("button")[0]);
fireEvent.click(screen.getAllByRole("button")[1]);
expect(screen.getByTestId("content-a")).not.toHaveAttribute("hidden");
expect(screen.getByTestId("content-b")).not.toHaveAttribute("hidden");
});
it("trigger has aria-expanded", () => {
render(() => (
<Accordion>
<Accordion.Item value="a">
<Accordion.Header><Accordion.Trigger>A</Accordion.Trigger></Accordion.Header>
<Accordion.Content>Content A</Accordion.Content>
</Accordion.Item>
</Accordion>
));
expect(screen.getByRole("button").getAttribute("aria-expanded")).toBe("false");
fireEvent.click(screen.getByRole("button"));
expect(screen.getByRole("button").getAttribute("aria-expanded")).toBe("true");
});
it("ArrowDown moves focus to next trigger", () => {
render(() => (
<Accordion>
<Accordion.Item value="a">
<Accordion.Header><Accordion.Trigger>A</Accordion.Trigger></Accordion.Header>
<Accordion.Content>Content A</Accordion.Content>
</Accordion.Item>
<Accordion.Item value="b">
<Accordion.Header><Accordion.Trigger>B</Accordion.Trigger></Accordion.Header>
<Accordion.Content>Content B</Accordion.Content>
</Accordion.Item>
</Accordion>
));
const [first] = screen.getAllByRole("button");
first.focus();
fireEvent.keyDown(first, { key: "ArrowDown" });
expect(document.activeElement).toBe(screen.getAllByRole("button")[1]);
});
});