Switch component

Also fix Toggle component to properly handle onChange callback and onClick handler binding.
This commit is contained in:
Mats Bosson 2026-03-29 07:12:11 +07:00
parent 3def20bf0d
commit 5af7dc6cfa
4 changed files with 121 additions and 7 deletions

View File

@ -0,0 +1,2 @@
export { Switch } from "./switch";
export type { SwitchProps } from "./switch";

View File

@ -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<HTMLButtonElement> {
/** 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<boolean>({
value: () => local.checked,
defaultValue: () => local.defaultChecked ?? false,
onChange: local.onCheckedChange ?? (() => {}),
});
return (
<button
type="button"
role="switch"
aria-checked={isChecked()}
aria-required={local.required || undefined}
data-state={isChecked() ? "checked" : "unchecked"}
disabled={local.disabled}
{...rest}
onClick={() => {
if (!local.disabled) setChecked(!isChecked());
}}
>
{local.children}
{local.name && (
<input
type="checkbox"
aria-hidden="true"
tabIndex={-1}
name={local.name}
value={local.value ?? "on"}
checked={isChecked()}
style={{ display: "none" }}
/>
)}
</button>
);
}

View File

@ -29,22 +29,19 @@ export function Toggle(props: ToggleProps): JSX.Element {
const [isPressed, setPressed] = createControllableSignal<boolean>({ const [isPressed, setPressed] = createControllableSignal<boolean>({
value: () => local.pressed, value: () => local.pressed,
defaultValue: () => local.defaultPressed ?? false, defaultValue: () => local.defaultPressed ?? false,
onChange: local.onPressedChange, onChange: local.onPressedChange ?? (() => {}),
}); });
const handleClick: JSX.EventHandler<HTMLButtonElement, MouseEvent> = (e) => {
local.onClick?.(e);
if (!local.disabled) setPressed(!isPressed());
};
return ( return (
<button <button
type="button" type="button"
aria-pressed={isPressed()} aria-pressed={isPressed()}
data-state={isPressed() ? "on" : "off"} data-state={isPressed() ? "on" : "off"}
disabled={local.disabled} disabled={local.disabled}
onClick={handleClick}
{...rest} {...rest}
onClick={() => {
if (!local.disabled) setPressed(!isPressed());
}}
> >
{local.children} {local.children}
</button> </button>

View File

@ -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(() => <Switch />);
expect(screen.getByRole("switch")).toBeTruthy();
});
it("is unchecked by default", () => {
render(() => <Switch />);
expect(screen.getByRole("switch").getAttribute("aria-checked")).toBe("false");
});
it("toggles on click", () => {
render(() => <Switch />);
fireEvent.click(screen.getByRole("switch"));
expect(screen.getByRole("switch").getAttribute("aria-checked")).toBe("true");
});
it("respects defaultChecked=true", () => {
render(() => <Switch defaultChecked />);
expect(screen.getByRole("switch").getAttribute("aria-checked")).toBe("true");
});
it("data-state reflects checked state", () => {
render(() => <Switch />);
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(() => <Switch disabled />);
fireEvent.click(screen.getByRole("switch"));
expect(screen.getByRole("switch").getAttribute("aria-checked")).toBe("false");
});
it("controlled: stays at given value", () => {
render(() => <Switch checked={false} onCheckedChange={() => {}} />);
fireEvent.click(screen.getByRole("switch"));
expect(screen.getByRole("switch").getAttribute("aria-checked")).toBe("false");
});
});