Behive Tech Component Registry

Toggle

v0.5.0Stable

A 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.

pnpm add @behivetech/atoms.toggle

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";
5
6export 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}
15
16/**
17 * Bare toggle/switch control — the Radix switch button is this component's
18 * own root element, no built-in label/hint/error. Wrap it in `FormField` for
19 * 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 ...rest
30}: ToggleProps) => {
31 const generatedId = useId();
32 const toggleId = id ?? generatedId;
33
34 const isInvalid = invalid || ariaInvalid === true || ariaInvalid === "true";
35
36 // The controlled `checked` wins; otherwise the component tracks its own
37 // state so `--checked` works uncontrolled too.
38 const [internalChecked, setInternalChecked] = useState(defaultChecked);
39 const currentChecked = checked ?? internalChecked;
40
41 const handleCheckedChange = (next: boolean) => {
42 if (checked === undefined) {
43 setInternalChecked(next);
44 }
45 onCheckedChange?.(next);
46 };
47
48 const [rootClass, getChildClass] = getClassName({
49 className,
50 modifiers: { checked: currentChecked, invalid: isInvalid },
51 rootClass: "BHT__Toggle",
52 styles,
53 });
54
55 return (
56 <RadixSwitch.Root
57 {...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 so
4// it adds no width — a real border would shrink the padding box the thumb is
5// 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's
8// tint (`currentColor`), so hover reads on-surface-variant off and primary on.
9
10// Computed: (1.5rem - 1.125rem) / 2 = 0.1875rem
11$toggle-width: 2.75rem;
12$toggle-height: 1.5rem;
13$thumb-size: 1.125rem;
14$thumb-offset: 0.1875rem;
15
16.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 2px
27 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);
38
39 // Touch target: extends the hit area to 48px tall without changing the
40 // 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 }
51
52 &--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 }
59
60 &:hover:not(:disabled) {
61 .BHT__Toggle {
62 &__thumb::after {
63 opacity: var(--md-sys-state-hover-opacity, 0.08);
64 }
65 }
66 }
67
68 &:focus-visible {
69 outline: 3px solid var(--md-sys-color-secondary, var(--color-border-focus));
70 outline-offset: 2px;
71
72 .BHT__Toggle {
73 &__thumb::after {
74 opacity: var(--md-sys-state-focus-opacity, 0.1);
75 }
76 }
77 }
78
79 &:active:not(:disabled) {
80 .BHT__Toggle {
81 &__thumb::after {
82 opacity: var(--md-sys-state-pressed-opacity, 0.1);
83 }
84 }
85 }
86
87 &:disabled {
88 opacity: 0.5;
89 cursor: not-allowed;
90 }
91
92 &--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));
97
98 &.BHT__Toggle--checked {
99 background: var(--md-sys-color-error, var(--color-error));
100
101 .BHT__Toggle {
102 &__thumb {
103 background: var(--md-sys-color-on-error, var(--color-on-primary));
104 }
105 }
106 }
107 }
108 }
109
110 &__thumb {
111 // Radix Switch.Thumb
112 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);
126
127 // 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 }
143
144 // 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";
4
5export const BasicToggle = () => {
6 const [checked, setChecked] = useState(false);
7 return <Toggle checked={checked} onCheckedChange={setChecked} />;
8};
9
10export const ToggleWithFormField = () => {
11 const [checked, setChecked] = useState(false);
12 return (
13 <FormField
14 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};
22
23export const ToggleWithError = () => (
24 <FormField
25 label="Required setting"
26 htmlFor="required-setting"
27 error="This setting must be enabled"
28 >
29 <Toggle
30 id="required-setting"
31 invalid
32 checked={false}
33 onCheckedChange={() => {}}
34 />
35 </FormField>
36);
37
38export 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";
4
5describe("Toggle", () => {
6 it("renders as a switch role", () => {
7 render(<Toggle checked={false} onCheckedChange={() => {}} />);
8 expect(screen.getByRole("switch")).toBeTruthy();
9 });
10
11 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 });
17
18 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 });
24
25 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 });
33
34 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 });
40
41 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 });
47
48 it("has no --invalid modifier by default", () => {
49 render(<Toggle checked={false} onCheckedChange={() => {}} />);
50 expect(screen.getByRole("switch").className).not.toContain("--invalid");
51 });
52
53 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 });
59
60 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 });
67
68 it("has no --checked modifier when controlled off", () => {
69 render(<Toggle checked={false} onCheckedChange={() => {}} />);
70 expect(screen.getByRole("switch").className).not.toContain("--checked");
71 });
72
73 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 });
83
84 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 });
93
94 it("applies additional className", () => {
95 render(
96 <Toggle className="custom" checked={false} onCheckedChange={() => {}} />,
97 );
98 expect(screen.getByRole("switch").className).toContain("custom");
99 });
100});