import { useId, useRef, useState, type ComponentProps, type MouseEvent, type ReactNode, } from "react"; import { Loader2Icon } from "lucide-react"; import { Switch as SwitchPrimitive } from "radix-ui"; import { cn } from "../lib/cn"; import { DisabledReasonTooltip, joinIds, useSettingRowControl } from "./setting-row"; /** * - `brand` (default): the primary fill and edge when on, with a primary-ink * thumb; a solid switch-track when off, so the off state is visible in both * themes. * - `neutral`: foreground track when on. Quieter, for dense admin pages. */ export type SwitchVariant = "brand" | "neutral"; /** `md` is 36x20 for settings and sheets; `sm` is 28x16 for menus. Both have a 44px hit area. */ export type SwitchSize = "md" | "sm"; export interface SwitchProps extends Omit< ComponentProps, "asChild" | "children" > { variant?: SwitchVariant | undefined; size?: SwitchSize | undefined; /** * Saving. Shows a spinner in the thumb and ignores clicks, but stays * focusable so keyboard focus isn't lost. Pass the new value as `checked`. */ pending?: boolean | undefined; /** * Why the switch is disabled and who can fix it. Only used with `disabled`: * the switch stays focusable (`aria-disabled`) and shows the reason in a * tooltip on hover, focus and tap. */ disabledReason?: ReactNode | undefined; /** Adds a small On or Off label before the switch, for dense rows. */ showStateText?: boolean | undefined; /** Overrides the On and Off words. */ stateLabels?: { on: ReactNode; off: ReactNode }; } const DEFAULT_STATE_LABELS = { on: "On", off: "Off" }; export function Switch({ variant = "brand", size = "md", pending = false, disabled = false, disabledReason, showStateText = false, stateLabels = DEFAULT_STATE_LABELS, checked: checkedProp, defaultChecked, onCheckedChange, onClick, className, id: idProp, "aria-label": ariaLabel, "aria-labelledby": ariaLabelledByProp, "aria-describedby": ariaDescribedByProp, ...props }: SwitchProps) { // Takes the row's control id unless given its own, so clicking the row label toggles it. const field = useSettingRowControl(idProp === undefined); const reasonId = useId(); const rootRef = useRef(null); const [uncontrolled, setUncontrolled] = useState(defaultChecked ?? false); const checked = checkedProp ?? uncontrolled; const hasReason = disabled && disabledReason !== undefined && disabledReason !== null; // A reason keeps the switch focusable; without one it is natively disabled. const blocked = hasReason || pending; const handleCheckedChange = (next: boolean) => { if (blocked) return; if (checkedProp === undefined) setUncontrolled(next); onCheckedChange?.(next); }; const handleClick = (event: MouseEvent) => { onClick?.(event); if (pending) event.preventDefault(); }; const root = ( {pending ? ( ); const control = hasReason ? ( {root} ) : ( root ); return ( <> {showStateText ? ( {control} ) : ( control )} {hasReason ? ( ) : null} ); }