Toggle
v0.5.0StableA bare toggle/switch control (built on Radix's `Switch`) — the Radix switch button is this component's own root element. It has no built-in label/hint/error; wrap it in `FormField` for that chrome.
registry/behivetech/atoms/toggle
toggle.tsx
1import * as RadixSwitch from "@radix-ui/react-switch";2import { useId, useState } from "react";3import { getClassName } from "@bht-component-registry/class-names";4import styles from "./toggle.module.scss";56export interface ToggleProps extends Omit<7 RadixSwitch.SwitchProps,8 "className"9> {10 /** Additional class names to merge with the component root element */11 className?: string;12 /** Marks the field as invalid: adds the `--invalid` modifier and sets `aria-invalid="true"` */13 invalid?: boolean;14}1516/**17 * Bare toggle/switch control — the Radix switch button is this component's18 * own root element, no built-in label/hint/error. Wrap it in `FormField` for19 * that chrome: `<FormField label="..." htmlFor="x"><Toggle id="x" /></FormField>`.20 */21export const Toggle = ({22 className,23 id,24 checked,25 defaultChecked = false,26 onCheckedChange,27 invalid = false,28 "aria-invalid": ariaInvalid,29 ...rest30}: ToggleProps) => {31 const generatedId = useId();32 const toggleId = id ?? generatedId;3334 const isInvalid = invalid || ariaInvalid === true || ariaInvalid === "true";3536 // The controlled `checked` wins; otherwise the component tracks its own37 // state so `--checked` works uncontrolled too.38 const [internalChecked, setInternalChecked] = useState(defaultChecked);39 const currentChecked = checked ?? internalChecked;4041 const handleCheckedChange = (next: boolean) => {42 if (checked === undefined) {43 setInternalChecked(next);44 }45 onCheckedChange?.(next);46 };4748 const [rootClass, getChildClass] = getClassName({49 className,50 modifiers: { checked: currentChecked, invalid: isInvalid },51 rootClass: "BHT__Toggle",52 styles,53 });5455 return (56 <RadixSwitch.Root57 {...rest}58 id={toggleId}59 checked={currentChecked}60 onCheckedChange={handleCheckedChange}61 className={rootClass}62 aria-invalid={isInvalid ? true : ariaInvalid}63 >64 <RadixSwitch.Thumb className={getChildClass("thumb")} />65 </RadixSwitch.Root>66 );67};
toggle.module.scss
1// Material Design 3 switch.2//3// The unchecked track's 2px outline is an inset shadow rather than a border so4// it adds no width — a real border would shrink the padding box the thumb is5// positioned in and throw off the travel calculation below.6//7// `color` on the root is not used for text — it is the thumb state layer's8// tint (`currentColor`), so hover reads on-surface-variant off and primary on.910// Computed: (1.5rem - 1.125rem) / 2 = 0.1875rem11$toggle-width: 2.75rem;12$toggle-height: 1.5rem;13$thumb-size: 1.125rem;14$thumb-offset: 0.1875rem;1516.BHT__Toggle {17 // Radix Switch.Root renders as <button role="switch">18 position: relative;19 width: $toggle-width;20 height: $toggle-height;21 background: var(22 --md-sys-color-surface-container-highest,23 var(--color-surface-light)24 );25 border: none;26 box-shadow: inset 0 0 0 2px27 var(--md-sys-color-outline, var(--color-border-strong));28 border-radius: var(--md-sys-shape-corner-full, 9999px);29 color: var(--md-sys-color-on-surface-variant, var(--color-text-muted));30 cursor: pointer;31 padding: 0;32 flex-shrink: 0;33 transition:34 background var(--md-sys-motion-duration-short-3, 150ms)35 var(--md-sys-motion-easing-standard, ease),36 box-shadow var(--md-sys-motion-duration-short-3, 150ms)37 var(--md-sys-motion-easing-standard, ease);3839 // Touch target: extends the hit area to 48px tall without changing the40 // drawn 24px track. px on purpose — a finger doesn't scale with type.41 &::before {42 content: "";43 position: absolute;44 left: 0;45 right: 0;46 top: 50%;47 translate: 0 -50%;48 height: 100%;49 min-height: 48px;50 }5152 &--checked {53 &.BHT__Toggle {54 background: var(--md-sys-color-primary, var(--color-primary));55 box-shadow: none;56 color: var(--md-sys-color-primary, var(--color-primary));57 }58 }5960 &:hover:not(:disabled) {61 .BHT__Toggle {62 &__thumb::after {63 opacity: var(--md-sys-state-hover-opacity, 0.08);64 }65 }66 }6768 &:focus-visible {69 outline: 3px solid var(--md-sys-color-secondary, var(--color-border-focus));70 outline-offset: 2px;7172 .BHT__Toggle {73 &__thumb::after {74 opacity: var(--md-sys-state-focus-opacity, 0.1);75 }76 }77 }7879 &:active:not(:disabled) {80 .BHT__Toggle {81 &__thumb::after {82 opacity: var(--md-sys-state-pressed-opacity, 0.1);83 }84 }85 }8687 &:disabled {88 opacity: 0.5;89 cursor: not-allowed;90 }9192 &--invalid {93 &.BHT__Toggle {94 outline: 1px solid var(--md-sys-color-error, var(--color-error));95 outline-offset: 1px;96 color: var(--md-sys-color-error, var(--color-error));9798 &.BHT__Toggle--checked {99 background: var(--md-sys-color-error, var(--color-error));100101 .BHT__Toggle {102 &__thumb {103 background: var(--md-sys-color-on-error, var(--color-on-primary));104 }105 }106 }107 }108 }109110 &__thumb {111 // Radix Switch.Thumb112 display: block;113 position: absolute;114 top: $thumb-offset;115 left: $thumb-offset;116 width: $thumb-size;117 height: $thumb-size;118 background: var(--md-sys-color-outline, var(--color-border-strong));119 border-radius: var(--md-sys-shape-corner-full, 9999px);120 pointer-events: none;121 transition:122 transform var(--md-sys-motion-duration-short-3, 150ms)123 var(--md-sys-motion-easing-standard, ease),124 background var(--md-sys-motion-duration-short-3, 150ms)125 var(--md-sys-motion-easing-standard, ease);126127 // State layer: MD3 draws it as a circle around the handle, not the track.128 &::after {129 content: "";130 position: absolute;131 top: 50%;132 left: 50%;133 translate: -50% -50%;134 width: 2.5rem;135 height: 2.5rem;136 border-radius: var(--md-sys-shape-corner-full, 9999px);137 background: currentColor;138 opacity: 0;139 transition: opacity var(--md-sys-motion-duration-short-2, 100ms)140 var(--md-sys-motion-easing-standard, ease);141 }142 }143144 // The thumb follows the root's checked state, so it needs no modifier of its own.145 &--checked &__thumb {146 background: var(--md-sys-color-on-primary, var(--color-on-primary));147 transform: translateX(148 calc($toggle-width - $thumb-size - ($thumb-offset * 2))149 );150 }151}
index.ts
1export { Toggle } from "./toggle.js";2export type { ToggleProps } from "./toggle.js";
toggle.example.tsx
1import { useState } from "react";2import { FormField } from "@behivetech/forms.form-field";3import { Toggle } from "./toggle.js";45export const BasicToggle = () => {6 const [checked, setChecked] = useState(false);7 return <Toggle checked={checked} onCheckedChange={setChecked} />;8};910export const ToggleWithFormField = () => {11 const [checked, setChecked] = useState(false);12 return (13 <FormField14 label="Dark mode"15 htmlFor="dark-mode"16 hint="Switch between light and dark color themes"17 >18 <Toggle id="dark-mode" checked={checked} onCheckedChange={setChecked} />19 </FormField>20 );21};2223export const ToggleWithError = () => (24 <FormField25 label="Required setting"26 htmlFor="required-setting"27 error="This setting must be enabled"28 >29 <Toggle30 id="required-setting"31 invalid32 checked={false}33 onCheckedChange={() => {}}34 />35 </FormField>36);3738export const DisabledToggle = () => (39 <Toggle disabled checked={false} onCheckedChange={() => {}} />40);
toggle.spec.tsx
1import { render, screen } from "@testing-library/react";2import userEvent from "@testing-library/user-event";3import { Toggle } from "./toggle.js";45describe("Toggle", () => {6 it("renders as a switch role", () => {7 render(<Toggle checked={false} onCheckedChange={() => {}} />);8 expect(screen.getByRole("switch")).toBeTruthy();9 });1011 it("calls onCheckedChange when clicked", async () => {12 const handleChange = vi.fn();13 render(<Toggle checked={false} onCheckedChange={handleChange} />);14 await userEvent.click(screen.getByRole("switch"));15 expect(handleChange).toHaveBeenCalledWith(true);16 });1718 it("does not fire onCheckedChange when disabled", async () => {19 const handleChange = vi.fn();20 render(<Toggle disabled checked={false} onCheckedChange={handleChange} />);21 await userEvent.click(screen.getByRole("switch"));22 expect(handleChange).not.toHaveBeenCalled();23 });2425 it("passes aria-invalid through to the root element", () => {26 render(27 <Toggle aria-invalid="true" checked={false} onCheckedChange={() => {}} />,28 );29 expect(screen.getByRole("switch").getAttribute("aria-invalid")).toBe(30 "true",31 );32 });3334 it("adds the --invalid modifier and aria-invalid for the invalid prop", () => {35 render(<Toggle invalid checked={false} onCheckedChange={() => {}} />);36 const el = screen.getByRole("switch");37 expect(el.className).toContain("--invalid");38 expect(el.getAttribute("aria-invalid")).toBe("true");39 });4041 it("adds the --invalid modifier for a direct aria-invalid", () => {42 render(43 <Toggle aria-invalid="true" checked={false} onCheckedChange={() => {}} />,44 );45 expect(screen.getByRole("switch").className).toContain("--invalid");46 });4748 it("has no --invalid modifier by default", () => {49 render(<Toggle checked={false} onCheckedChange={() => {}} />);50 expect(screen.getByRole("switch").className).not.toContain("--invalid");51 });5253 it("reflects checked state via data-state", () => {54 render(<Toggle checked={true} onCheckedChange={() => {}} />);55 expect(screen.getByRole("switch").getAttribute("data-state")).toBe(56 "checked",57 );58 });5960 it("adds --checked and aria-checked when controlled", () => {61 render(<Toggle checked onCheckedChange={() => {}} />);62 const el = screen.getByRole("switch");63 expect(el.className).toContain("--checked");64 expect(el.getAttribute("aria-checked")).toBe("true");65 expect(el.getAttribute("data-state")).toBe("checked");66 });6768 it("has no --checked modifier when controlled off", () => {69 render(<Toggle checked={false} onCheckedChange={() => {}} />);70 expect(screen.getByRole("switch").className).not.toContain("--checked");71 });7273 it("adds --checked from defaultChecked and drops it when toggled off", async () => {74 const handleChange = vi.fn();75 render(<Toggle defaultChecked onCheckedChange={handleChange} />);76 const el = screen.getByRole("switch");77 expect(el.className).toContain("--checked");78 await userEvent.click(el);79 expect(handleChange).toHaveBeenCalledWith(false);80 expect(el.className).not.toContain("--checked");81 expect(el.getAttribute("aria-checked")).toBe("false");82 });8384 it("adds --checked after an uncontrolled click and forwards the change", async () => {85 const handleChange = vi.fn();86 render(<Toggle onCheckedChange={handleChange} />);87 const el = screen.getByRole("switch");88 await userEvent.click(el);89 expect(handleChange).toHaveBeenCalledWith(true);90 expect(el.className).toContain("--checked");91 expect(el.getAttribute("data-state")).toBe("checked");92 });9394 it("applies additional className", () => {95 render(96 <Toggle className="custom" checked={false} onCheckedChange={() => {}} />,97 );98 expect(screen.getByRole("switch").className).toContain("custom");99 });100});