// ~/src/siteplane/components/radio-group/radio-group.tsx "use client"; import { Radio as RadioPrimitive } from "@base-ui/react/radio"; import { RadioGroup as RadioGroupPrimitive } from "@base-ui/react/radio-group"; import { cva } from "class-variance-authority"; import type React from "react"; import { createContext, useContext } from "react"; import { type SiteplaneControlSize, useResolvedControlSize, } from "../../system/provider/siteplane-ui-provider"; import { ChoiceMenuItemCheck, choiceMenuItemCheckGridClassName, choiceMenuItemClassName, choiceMenuItemEndGridClassName, choiceMenuItemEndLabelClassName, choiceMenuItemLabelClassName, 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 { KitRadioGroupProps, KitRadioProps } from "./radio-group-internal"; import "./radio-group.css"; // The look and the size come from radio-group.css through the attributes the // Radio renders (data-size), so the helper takes no size. export const radioVariants = cva( "sp-radio-motion relative inline-flex shrink-0 cursor-pointer items-center justify-center before:pointer-events-none before:absolute before:inset-0 before:rounded-[inherit] data-disabled:cursor-not-allowed", ); const radioIndicatorClassName = "absolute flex items-center justify-center"; const RadioSizeContext = createContext(null); export interface RadioGroupProps extends Omit< WithStringClassName, "size" > { size?: SiteplaneControlSize; } export function RadioGroup(groupProps: RadioGroupProps): React.ReactElement { // Menu and drawer rows pass the internal menu-item profile through // KitRadioGroup (radio-group-internal.ts), the same function. const { className, profile = "control", size, ...props } = groupProps as KitRadioGroupProps; const resolvedSize = useResolvedControlSize(size); return ( ); } export interface RadioProps extends Omit< WithStringClassName, "size" > { size?: SiteplaneControlSize; } export function Radio(radioProps: RadioProps): React.ReactElement { // Menu and drawer rows pass the internal menu-item profile through KitRadio // (radio-group-internal.ts), the same function. const { children, className, indicatorPosition = "start", profile = "control", size, ...props } = radioProps as KitRadioProps; const groupSize = useContext(RadioSizeContext); const resolvedSize = useResolvedControlSize(size ?? groupSize); // An identity row of a menu, such as a workspace in a switcher, leads with // its logo or avatar and ends with its check, as MenuRadioItem's end rows. const end = profile === "menu-item" && indicatorPosition === "end"; return ( {end ? ( <> {menuRowLabel(children)} ) : profile === "menu-item" ? ( <> {children} ) : ( )} ); } export { RadioGroupPrimitive, RadioPrimitive }; // ~/src/siteplane/components/radio-group/radio-group-internal.ts import type * as React from "react"; import { Radio, RadioGroup, type RadioGroupProps, type RadioProps, } from "./radio-group"; /* * Kit-internal RadioGroup and Radio. Not public API: the radio-group index * does not export this module, and its names and profiles may change in any * release. Menu and Drawer radio rows render them with the menu-item profile. */ export type RadioProfile = "control" | "menu-item"; export type KitRadioGroupProps = RadioGroupProps & { profile?: RadioProfile }; export type KitRadioProps = RadioProps & { profile?: RadioProfile; /** * In the menu-item profile: where the row shows its check, in the leading * slot (`start`) or at the end of an identity row (`end`), as MenuRadioItem * does. */ indicatorPosition?: "start" | "end" | undefined; }; /** RadioGroup with the kit's composition profile; the same component. */ export const KitRadioGroup = RadioGroup as ( props: KitRadioGroupProps, ) => React.ReactElement; /** Radio with the kit's composition profile; the same component. */ export const KitRadio = Radio as (props: KitRadioProps) => React.ReactElement; // ~/src/siteplane/components/radio-group/radio-group.css @layer theme, base, components, siteplane, utilities; @layer components { /* Rulebook I5: a radio before text centres on the first text line, also * when the text wraps, as the Checkbox does (checkbox.css). A radio taller * than the line starts at the line's top. */ :where(label, .sp-label, .sp-field-label) > .sp-base-radio[data-profile="control"]:first-child, :where(label, .sp-label, .sp-field-label) > :where(span, div) > .sp-base-radio[data-profile="control"]:first-child, .sp-base-radio[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-choice-size)) / 2)); } /* Beside its label, the radio 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-radio[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); } /* The indicator pops and fades with the Checkbox's motion: the same slow * duration, standard curve and pop scale, and the same linger on the way * out (--motion-duration-linger). Every duration and curve is a * motion token (rulebook M1). */ .sp-radio-motion .sp-radio-indicator { background-color: transparent; opacity: 0; transform: scale(var(--motion-check-pop-scale)); transform-origin: center; transition-duration: var(--motion-duration-slow); transition-property: background-color, opacity, transform; transition-timing-function: var(--motion-ease-standard); } .sp-radio-motion .sp-radio-indicator[data-checked] { background-color: var(--sp-semantic-color-primary); opacity: 1; transform: scale(1); } .sp-radio-motion .sp-radio-indicator:is([data-starting-style], [data-ending-style]) { background-color: transparent; opacity: 0; transform: scale(var(--motion-check-pop-scale)); } .sp-radio-motion .sp-radio-indicator[data-ending-style] { transition-duration: var(--motion-duration-linger); } /* The dot grows after its box has started to pop: half a base duration. */ .sp-radio-motion .sp-radio-indicator::before { transform: scale(0); transform-origin: center; transition-duration: var(--motion-duration-slow); transition-delay: calc(var(--motion-duration-base) / 2); transition-property: transform; transition-timing-function: var(--motion-ease-standard); } .sp-radio-motion .sp-radio-indicator[data-checked]::before { transform: scale(1); } .sp-radio-motion .sp-radio-indicator:is([data-starting-style], [data-ending-style])::before { transform: scale(0); } .sp-radio-motion .sp-radio-indicator[data-ending-style]::before { transition-delay: 0ms; transition-duration: var(--motion-duration-linger); } html[data-motion-reduce="true"] .sp-radio-motion .sp-radio-indicator, html[data-motion-reduce="true"] .sp-radio-motion .sp-radio-indicator::before, [data-sp-motion="reduced"] .sp-radio-motion .sp-radio-indicator, [data-sp-motion="reduced"] .sp-radio-motion .sp-radio-indicator::before { animation: none; transform: 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-radio-motion .sp-radio-indicator, .sp-radio-motion .sp-radio-indicator::before { animation: none; transform: none; transition-delay: 0ms; transition-duration: 1ms; } } } // ~/src/siteplane/components/radio-group/index.ts export * from "./radio-group";