diff --git a/packages/core/src/components/switch/index.ts b/packages/core/src/components/switch/index.ts new file mode 100644 index 0000000..8820d0b --- /dev/null +++ b/packages/core/src/components/switch/index.ts @@ -0,0 +1,2 @@ +export { Switch } from "./switch"; +export type { SwitchProps } from "./switch"; diff --git a/packages/core/src/components/switch/switch.tsx b/packages/core/src/components/switch/switch.tsx new file mode 100644 index 0000000..9b50aa8 --- /dev/null +++ b/packages/core/src/components/switch/switch.tsx @@ -0,0 +1,70 @@ +import type { JSX } from "solid-js"; +import { splitProps } from "solid-js"; +import { createControllableSignal } from "../../primitives/create-controllable-signal"; + +export interface SwitchProps extends JSX.ButtonHTMLAttributes { + /** Controlled checked state. */ + checked?: boolean; + /** Default checked state (uncontrolled). @default false */ + defaultChecked?: boolean; + /** Called when checked state changes. */ + onCheckedChange?: (checked: boolean) => void; + /** Native form field name — renders a hidden checkbox input. */ + name?: string; + /** Value submitted with the form when checked. @default "on" */ + value?: string; + required?: boolean; + children?: JSX.Element; +} + +/** + * An on/off control. Semantically equivalent to a checkbox but visually + * represents a binary switch. Uses role="switch" and aria-checked. + */ +export function Switch(props: SwitchProps): JSX.Element { + const [local, rest] = splitProps(props, [ + "checked", + "defaultChecked", + "onCheckedChange", + "disabled", + "required", + "name", + "value", + "children", + "onClick", + ]); + + const [isChecked, setChecked] = createControllableSignal({ + value: () => local.checked, + defaultValue: () => local.defaultChecked ?? false, + onChange: local.onCheckedChange ?? (() => {}), + }); + + return ( + + ); +} diff --git a/packages/core/src/components/toggle/toggle.tsx b/packages/core/src/components/toggle/toggle.tsx index fbcdd63..da674af 100644 --- a/packages/core/src/components/toggle/toggle.tsx +++ b/packages/core/src/components/toggle/toggle.tsx @@ -29,22 +29,19 @@ export function Toggle(props: ToggleProps): JSX.Element { const [isPressed, setPressed] = createControllableSignal({ value: () => local.pressed, defaultValue: () => local.defaultPressed ?? false, - onChange: local.onPressedChange, + onChange: local.onPressedChange ?? (() => {}), }); - const handleClick: JSX.EventHandler = (e) => { - local.onClick?.(e); - if (!local.disabled) setPressed(!isPressed()); - }; - return ( diff --git a/packages/core/tests/components/switch/switch.test.tsx b/packages/core/tests/components/switch/switch.test.tsx new file mode 100644 index 0000000..31d2453 --- /dev/null +++ b/packages/core/tests/components/switch/switch.test.tsx @@ -0,0 +1,45 @@ +import { fireEvent, render, screen } from "@solidjs/testing-library"; +import { describe, expect, it } from "vitest"; +import { Switch } from "../../../src/components/switch/index"; + +describe("Switch", () => { + it("renders with role=switch", () => { + render(() => ); + expect(screen.getByRole("switch")).toBeTruthy(); + }); + + it("is unchecked by default", () => { + render(() => ); + expect(screen.getByRole("switch").getAttribute("aria-checked")).toBe("false"); + }); + + it("toggles on click", () => { + render(() => ); + fireEvent.click(screen.getByRole("switch")); + expect(screen.getByRole("switch").getAttribute("aria-checked")).toBe("true"); + }); + + it("respects defaultChecked=true", () => { + render(() => ); + expect(screen.getByRole("switch").getAttribute("aria-checked")).toBe("true"); + }); + + it("data-state reflects checked state", () => { + render(() => ); + expect(screen.getByRole("switch").getAttribute("data-state")).toBe("unchecked"); + fireEvent.click(screen.getByRole("switch")); + expect(screen.getByRole("switch").getAttribute("data-state")).toBe("checked"); + }); + + it("does not toggle when disabled", () => { + render(() => ); + fireEvent.click(screen.getByRole("switch")); + expect(screen.getByRole("switch").getAttribute("aria-checked")).toBe("false"); + }); + + it("controlled: stays at given value", () => { + render(() => {}} />); + fireEvent.click(screen.getByRole("switch")); + expect(screen.getByRole("switch").getAttribute("aria-checked")).toBe("false"); + }); +});