// ~/src/siteplane/components/switch/switch.tsx "use client"; import { Switch as SwitchPrimitive } from "@base-ui/react/switch"; import { cva, type VariantProps } from "class-variance-authority"; import type * as React from "react"; import { siteplaneUIDefaults, useResolvedControlSize, } from "../../system/provider/siteplane-ui-provider"; import { choiceMenuItemClassName, choiceMenuItemSwitchGridClassName, choiceMenuItemSwitchLabelClassName, menuRowLabel, } from "../../system/styles/choice-menu-item"; import { cn, type WithStringClassName } from "../../system/utils/cn"; import { baseClass, slotClass } from "../../system/utils/slot-class"; import type { KitSwitchProps } from "./switch-internal"; import "./switch.css"; export const switchVariants = cva( "sp-switch-motion inline-flex h-[calc(var(--thumb-size)+2px)] w-[calc(var(--thumb-size)*2-2px)] shrink-0 cursor-pointer items-center p-0.5 outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background data-checked:bg-primary data-unchecked:bg-input not-data-disabled:data-unchecked:hover:bg-input/80 dark:not-data-disabled:data-unchecked:hover:bg-white/12", { defaultVariants: { size: siteplaneUIDefaults.controlSize }, variants: { size: { md: "[--thumb-size:--spacing(5)] sm:[--thumb-size:--spacing(4)]", lg: "[--thumb-size:--spacing(6)] rounded-xl sm:[--thumb-size:--spacing(5)] sm:rounded-lg", sm: "[--thumb-size:--spacing(4)] sm:[--thumb-size:--spacing(3.5)]", xl: "[--thumb-size:--spacing(7)] sm:[--thumb-size:--spacing(6)]", }, }, }, ); // The thumb starts at the track's inline start and travels toward its end // when checked: in a right-to-left page it starts on the right and travels // left, and it stretches from its leading edge there too. export const switchThumbClassName = "pointer-events-none block aspect-square h-full origin-left rounded-[inherit] bg-background shadow-sm/5 will-change-transform in-[[data-foundation=switch][data-checked]]:origin-[var(--thumb-size)_50%] in-[[data-foundation=switch][data-checked]]:translate-x-[calc(var(--thumb-size)-4px)] rtl:origin-right rtl:in-[[data-foundation=switch][data-checked]]:origin-[calc(100%-var(--thumb-size))_50%] rtl:in-[[data-foundation=switch][data-checked]]:-translate-x-[calc(var(--thumb-size)-4px)]"; // The standalone control draws its track and thumb, their sizes and the // thumb's travel from design-rework.css ([data-slot="switch"]); the utilities // above style the menu tracks only. Both tracks take one edge from that // stylesheet, the standalone control's 3:1 edge, which a menu track finds by // its switch-track slot. Every thumb stretches from its leading edge while the // switch or its label is pressed, in either direction (design-rework.css). const switchControlClassName = "sp-switch-motion inline-flex shrink-0 cursor-pointer items-center data-disabled:cursor-not-allowed"; const switchControlThumbClassName = "pointer-events-none block aspect-square origin-left will-change-transform in-[[data-foundation=switch][data-checked]]:origin-right rtl:origin-right rtl:in-[[data-foundation=switch][data-checked]]:origin-left"; export const switchMenuTrackClassName = "col-start-2 in-[[data-foundation=switch][data-checked]]:bg-primary in-[[data-foundation=switch][data-unchecked]]:bg-input"; export interface SwitchProps extends Omit, "size">, VariantProps {} export function Switch(switchProps: SwitchProps): React.ReactElement { // Menu and drawer rows pass the internal menu-item profile through // KitSwitch (switch-internal.ts), the same function. const { children, className, profile = "control", size, ...props } = switchProps as KitSwitchProps; const resolvedSize = useResolvedControlSize(size); return ( {profile === "menu-item" ? ( <> {menuRowLabel(children)} ) : ( )} ); } export { SwitchPrimitive }; // ~/src/siteplane/components/switch/switch-internal.ts import type * as React from "react"; import { Switch, type SwitchProps } from "./switch"; /* * Kit-internal Switch. Not public API: the switch index does not export this * module, and its names and profiles may change in any release. Menu and * Drawer switch rows render a Switch with the menu-item profile. */ export type SwitchProfile = "control" | "menu-item"; export type KitSwitchProps = SwitchProps & { profile?: SwitchProfile }; /** Switch with the kit's composition profile; the same component. */ export const KitSwitch = Switch as ( props: KitSwitchProps, ) => React.ReactElement; // ~/src/siteplane/components/switch/switch.css @layer theme, base, components, siteplane, utilities; @layer components { .sp-switch-motion { --sp-semantic-color-background: var(--color-white); --sp-semantic-color-input: rgb(0 0 0 / 10%); --sp-semantic-color-primary: oklch(0.335 0.061 267.9); --sp-semantic-color-ring: var(--color-neutral-400); /* The track's fill changes on the state step, as Checkbox and Radio do, * so the track and the thumb finish together. */ transition-duration: var(--motion-duration-fast); transition-property: background-color, box-shadow; transition-timing-function: var(--motion-ease-state); } .dark .sp-switch-motion, [data-theme="dark"] .sp-switch-motion { --sp-semantic-color-background: color-mix( in srgb, var(--color-neutral-950) 95%, var(--color-white) ); --sp-semantic-color-input: rgb(255 255 255 / 8%); --sp-semantic-color-ring: var(--color-neutral-500); } /* The thumb of a menu row travels over the fast step on the browser's * default curve and stretches a micro step after a press. */ .sp-switch-motion .sp-switch-thumb { transition: translate var(--motion-duration-fast) var(--motion-ease-navigation), scale var(--motion-duration-micro) var(--motion-ease-navigation) var(--motion-duration-micro), transform-origin var(--motion-duration-fast) var(--motion-ease-navigation); } /* Rulebook I5: a switch before text centres on the first text line, also * when the text wraps, as the Checkbox does (checkbox.css). The lg and xl * switches are taller than the 21 px line, so they start at its top and a * one-line label keeps its height. */ :where(label, .sp-label, .sp-field-label) > .sp-base-switch[data-profile="control"]:first-child, :where(label, .sp-label, .sp-field-label) > :where(span, div) > .sp-base-switch[data-profile="control"]:first-child, .sp-base-switch[data-profile="control"]:first-child:has( ~ :is(.sp-label, .sp-field-label), ~ * :is(.sp-label, .sp-field-label) ) { align-self: flex-start; margin-block: max( 0px, calc((1lh - var(--sp-rw-switch-thumb) - var(--sp-rw-space-1)) / 2) ); } /* Beside its label, the switch does not inherit the label's line but its * container's. It takes the 21 px line of a label next to a choice control * (S13), so the 1lh above measures the label's first line. */ .sp-base-switch[data-profile="control"]:first-child:has( ~ :is(.sp-label, .sp-field-label), ~ * :is(.sp-label, .sp-field-label) ) { line-height: var(--sp-rw-line-21); } html[data-motion-reduce="true"] .sp-switch-motion, html[data-motion-reduce="true"] .sp-switch-motion .sp-switch-thumb { animation: none; transition-delay: 0ms; transition-duration: 1ms; } } /* The reduced-motion override sits in the kit layer, where it keeps its order * against design-rework.css. */ @layer siteplane { @media (prefers-reduced-motion: reduce) { .sp-switch-motion, .sp-switch-motion .sp-switch-thumb { animation: none; transition-delay: 0ms; transition-duration: 1ms; } } } // ~/src/siteplane/components/switch/index.ts export * from "./switch";