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:
parent
4c3005aa74
commit
94822186c2
29
packages/core/src/components/accordion/accordion-content.tsx
Normal file
29
packages/core/src/components/accordion/accordion-content.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
40
packages/core/src/components/accordion/accordion-context.ts
Normal file
40
packages/core/src/components/accordion/accordion-context.ts
Normal 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;
|
||||
19
packages/core/src/components/accordion/accordion-header.tsx
Normal file
19
packages/core/src/components/accordion/accordion-header.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
36
packages/core/src/components/accordion/accordion-item.tsx
Normal file
36
packages/core/src/components/accordion/accordion-item.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
70
packages/core/src/components/accordion/accordion-root.tsx
Normal file
70
packages/core/src/components/accordion/accordion-root.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
55
packages/core/src/components/accordion/accordion-trigger.tsx
Normal file
55
packages/core/src/components/accordion/accordion-trigger.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
21
packages/core/src/components/accordion/index.ts
Normal file
21
packages/core/src/components/accordion/index.ts
Normal 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";
|
||||
114
packages/core/tests/components/accordion/accordion.test.tsx
Normal file
114
packages/core/tests/components/accordion/accordion.test.tsx
Normal 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]);
|
||||
});
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user