Behive Tech Registry

Toggle

v0.4.0

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 · depends on @behivetech/get-class-name

toggle.tsx
1import * as RadixSwitch from "@radix-ui/react-switch";
2import { useId } from "react";
3import { getClassName } from "@behivetech/get-class-name";
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}
13
14/**
15 * Bare toggle/switch control — the Radix switch button is this component's
16 * own root element, no built-in label/hint/error. Wrap it in `FormField` for
17 * that chrome: `<FormField label="..." htmlFor="x"><Toggle id="x" /></FormField>`.
18 */
19export const Toggle = ({ className, id, ...rest }: ToggleProps) => {
20 const generatedId = useId();
21 const toggleId = id ?? generatedId;
22
23 const [rootClass, getChildClass] = getClassName({
24 className,
25 rootClass: "BHT__Toggle",
26 styles,
27 });
28
29 return (
30 <RadixSwitch.Root {...rest} id={toggleId} className={rootClass}>
31 <RadixSwitch.Thumb className={getChildClass("thumb")} />
32 </RadixSwitch.Root>
33 );
34};
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 &[data-state="checked"] {
53 background: var(--md-sys-color-primary, var(--color-primary));
54 box-shadow: none;
55 color: var(--md-sys-color-primary, var(--color-primary));
56 }
57
58 &:hover:not(:disabled) {
59 .BHT__Toggle {
60 &__thumb::after {
61 opacity: var(--md-sys-state-hover-opacity, 0.08);
62 }
63 }
64 }
65
66 &:focus-visible {
67 outline: 3px solid var(--md-sys-color-secondary, var(--color-border-focus));
68 outline-offset: 2px;
69
70 .BHT__Toggle {
71 &__thumb::after {
72 opacity: var(--md-sys-state-focus-opacity, 0.1);
73 }
74 }
75 }
76
77 &:active:not(:disabled) {
78 .BHT__Toggle {
79 &__thumb::after {
80 opacity: var(--md-sys-state-pressed-opacity, 0.1);
81 }
82 }
83 }
84
85 &:disabled {
86 opacity: 0.5;
87 cursor: not-allowed;
88 }
89
90 &[aria-invalid="true"] {
91 outline: 1px solid var(--md-sys-color-error, var(--color-error));
92 outline-offset: 1px;
93 color: var(--md-sys-color-error, var(--color-error));
94
95 &[data-state="checked"] {
96 background: var(--md-sys-color-error, var(--color-error));
97
98 .BHT__Toggle {
99 &__thumb {
100 background: var(--md-sys-color-on-error, var(--color-on-primary));
101 }
102 }
103 }
104 }
105
106 &__thumb {
107 // Radix Switch.Thumb
108 display: block;
109 position: absolute;
110 top: $thumb-offset;
111 left: $thumb-offset;
112 width: $thumb-size;
113 height: $thumb-size;
114 background: var(--md-sys-color-outline, var(--color-border-strong));
115 border-radius: var(--md-sys-shape-corner-full, 9999px);
116 pointer-events: none;
117 transition:
118 transform var(--md-sys-motion-duration-short-3, 150ms)
119 var(--md-sys-motion-easing-standard, ease),
120 background var(--md-sys-motion-duration-short-3, 150ms)
121 var(--md-sys-motion-easing-standard, ease);
122
123 // State layer: MD3 draws it as a circle around the handle, not the track.
124 &::after {
125 content: "";
126 position: absolute;
127 top: 50%;
128 left: 50%;
129 translate: -50% -50%;
130 width: 2.5rem;
131 height: 2.5rem;
132 border-radius: var(--md-sys-shape-corner-full, 9999px);
133 background: currentColor;
134 opacity: 0;
135 transition: opacity var(--md-sys-motion-duration-short-2, 100ms)
136 var(--md-sys-motion-easing-standard, ease);
137 }
138
139 &[data-state="checked"] {
140 background: var(--md-sys-color-on-primary, var(--color-on-primary));
141 transform: translateX(
142 calc($toggle-width - $thumb-size - ($thumb-offset * 2))
143 );
144 }
145 }
146}
index.ts
1export { Toggle } from './toggle.js';
2export type { ToggleProps } from './toggle.js';
toggle.composition.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 aria-invalid="true"
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("reflects checked state via data-state", () => {
35 render(<Toggle checked={true} onCheckedChange={() => {}} />);
36 expect(screen.getByRole("switch").getAttribute("data-state")).toBe(
37 "checked",
38 );
39 });
40
41 it("applies additional className", () => {
42 render(
43 <Toggle className="custom" checked={false} onCheckedChange={() => {}} />,
44 );
45 expect(screen.getByRole("switch").className).toContain("custom");
46 });
47});