diff --git a/packages/core/src/components/accordion/accordion-content.tsx b/packages/core/src/components/accordion/accordion-content.tsx new file mode 100644 index 0000000..da019a1 --- /dev/null +++ b/packages/core/src/components/accordion/accordion-content.tsx @@ -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 { + 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 ( + + ); +} diff --git a/packages/core/src/components/accordion/accordion-context.ts b/packages/core/src/components/accordion/accordion-context.ts new file mode 100644 index 0000000..2c54638 --- /dev/null +++ b/packages/core/src/components/accordion/accordion-context.ts @@ -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; +} + +export interface AccordionItemContextValue { + value: string; + isExpanded: Accessor; + triggerId: string; + contentId: string; +} + +const AccordionRootContext = createContext(); +const AccordionItemContext = createContext(); + +/** + * Returns the Accordion root context. Throws if used outside . + */ +export function useAccordionRootContext(): AccordionRootContextValue { + const ctx = useContext(AccordionRootContext); + if (!ctx) throw new Error("[PettyUI] Accordion parts must be used inside ."); + return ctx; +} + +/** + * Returns the AccordionItem context. Throws if used outside . + */ +export function useAccordionItemContext(): AccordionItemContextValue { + const ctx = useContext(AccordionItemContext); + if (!ctx) + throw new Error("[PettyUI] Accordion.Trigger/Content must be used inside ."); + return ctx; +} + +export const AccordionRootContextProvider = AccordionRootContext.Provider; +export const AccordionItemContextProvider = AccordionItemContext.Provider; diff --git a/packages/core/src/components/accordion/accordion-header.tsx b/packages/core/src/components/accordion/accordion-header.tsx new file mode 100644 index 0000000..40ab31f --- /dev/null +++ b/packages/core/src/components/accordion/accordion-header.tsx @@ -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 { + /** 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 ( + + {local.children} + + ); +} diff --git a/packages/core/src/components/accordion/accordion-item.tsx b/packages/core/src/components/accordion/accordion-item.tsx new file mode 100644 index 0000000..fdb202d --- /dev/null +++ b/packages/core/src/components/accordion/accordion-item.tsx @@ -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 { + 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 ( + +
+ {local.children} +
+
+ ); +} diff --git a/packages/core/src/components/accordion/accordion-root.tsx b/packages/core/src/components/accordion/accordion-root.tsx new file mode 100644 index 0000000..62cbd01 --- /dev/null +++ b/packages/core/src/components/accordion/accordion-root.tsx @@ -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 { + /** "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(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 ( + +
+ {local.children} +
+
+ ); +} diff --git a/packages/core/src/components/accordion/accordion-trigger.tsx b/packages/core/src/components/accordion/accordion-trigger.tsx new file mode 100644 index 0000000..d73ff9a --- /dev/null +++ b/packages/core/src/components/accordion/accordion-trigger.tsx @@ -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 { + 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 ( + + ); +} diff --git a/packages/core/src/components/accordion/index.ts b/packages/core/src/components/accordion/index.ts new file mode 100644 index 0000000..ea51fcd --- /dev/null +++ b/packages/core/src/components/accordion/index.ts @@ -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"; diff --git a/packages/core/tests/components/accordion/accordion.test.tsx b/packages/core/tests/components/accordion/accordion.test.tsx new file mode 100644 index 0000000..3bba495 --- /dev/null +++ b/packages/core/tests/components/accordion/accordion.test.tsx @@ -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(() => ( + + + A + Content A + + + )); + expect(screen.getByTestId("content-a")).toHaveAttribute("hidden"); + }); + + it("clicking trigger opens item", () => { + render(() => ( + + + A + Content A + + + )); + fireEvent.click(screen.getByRole("button")); + expect(screen.getByTestId("content-a")).not.toHaveAttribute("hidden"); + }); + + it("single mode: opening one closes another", () => { + render(() => ( + + + A + Content A + + + B + Content B + + + )); + 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(() => ( + + + A + Content A + + + )); + fireEvent.click(screen.getByRole("button")); + expect(screen.getByTestId("content-a")).toHaveAttribute("hidden"); + }); + + it("multiple mode: multiple items can be open", () => { + render(() => ( + + + A + Content A + + + B + Content B + + + )); + 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(() => ( + + + A + Content A + + + )); + 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(() => ( + + + A + Content A + + + B + Content B + + + )); + const [first] = screen.getAllByRole("button"); + first.focus(); + fireEvent.keyDown(first, { key: "ArrowDown" }); + expect(document.activeElement).toBe(screen.getAllByRole("button")[1]); + }); +});