diff --git a/packages/core/src/components/toggle/index.ts b/packages/core/src/components/toggle/index.ts new file mode 100644 index 0000000..b9937d7 --- /dev/null +++ b/packages/core/src/components/toggle/index.ts @@ -0,0 +1,2 @@ +export { Toggle } from "./toggle"; +export type { ToggleProps } from "./toggle"; diff --git a/packages/core/src/components/toggle/toggle.tsx b/packages/core/src/components/toggle/toggle.tsx new file mode 100644 index 0000000..a94776e --- /dev/null +++ b/packages/core/src/components/toggle/toggle.tsx @@ -0,0 +1,57 @@ +import type { JSX } from "solid-js"; +import { splitProps } from "solid-js"; +import { createControllableSignal } from "../../primitives/create-controllable-signal"; + +export interface ToggleProps extends JSX.ButtonHTMLAttributes { + /** Controlled pressed state. */ + pressed?: boolean; + /** Default pressed state (uncontrolled). @default false */ + defaultPressed?: boolean; + /** Called when pressed state changes. */ + onPressedChange?: (pressed: boolean) => void; + children?: JSX.Element; +} + +/** + * A two-state button that can be toggled on and off. + * Uses aria-pressed to communicate state to assistive technology. + */ +export function Toggle(props: ToggleProps): JSX.Element { + const [local, rest] = splitProps(props, [ + "pressed", + "defaultPressed", + "onPressedChange", + "disabled", + "children", + "onClick", + ]); + + const options: Parameters>[0] = { + value: () => local.pressed, + defaultValue: () => local.defaultPressed ?? false, + }; + + if (local.onPressedChange) { + options.onChange = local.onPressedChange; + } + + const [isPressed, setPressed] = createControllableSignal(options); + + const handleClick: JSX.EventHandler = (e) => { + if (typeof local.onClick === "function") local.onClick(e); + if (!local.disabled) setPressed(!isPressed()); + }; + + return ( + + ); +} diff --git a/packages/core/tests/components/toggle/toggle.test.tsx b/packages/core/tests/components/toggle/toggle.test.tsx new file mode 100644 index 0000000..9ea8831 --- /dev/null +++ b/packages/core/tests/components/toggle/toggle.test.tsx @@ -0,0 +1,40 @@ +import { fireEvent, render, screen } from "@solidjs/testing-library"; +import { describe, expect, it } from "vitest"; +import { Toggle } from "../../../src/components/toggle/index"; + +describe("Toggle", () => { + it("is off by default", () => { + render(() => Bold); + expect(screen.getByRole("button").getAttribute("aria-pressed")).toBe("false"); + }); + + it("toggles on click", () => { + render(() => Bold); + fireEvent.click(screen.getByRole("button")); + expect(screen.getByRole("button").getAttribute("aria-pressed")).toBe("true"); + }); + + it("respects defaultPressed=true", () => { + render(() => Bold); + expect(screen.getByRole("button").getAttribute("aria-pressed")).toBe("true"); + }); + + it("controlled: stays at given value", () => { + render(() => {}}>Bold); + fireEvent.click(screen.getByRole("button")); + expect(screen.getByRole("button").getAttribute("aria-pressed")).toBe("false"); + }); + + it("data-state reflects pressed state", () => { + render(() => Bold); + expect(screen.getByRole("button").getAttribute("data-state")).toBe("off"); + fireEvent.click(screen.getByRole("button")); + expect(screen.getByRole("button").getAttribute("data-state")).toBe("on"); + }); + + it("does not toggle when disabled", () => { + render(() => Bold); + fireEvent.click(screen.getByRole("button")); + expect(screen.getByRole("button").getAttribute("aria-pressed")).toBe("false"); + }); +});