import { cva } from 'class-variance-authority'; import { createContext, type ReactNode, useCallback, useContext, useState } from 'react'; import { Pressable, type StyleProp, View, type ViewStyle } from 'react-native'; import { useReducedMotion } from '../../../hooks/use-reduced-motion'; import { cn } from '../../../lib/cn'; import { cssColorToOklch, oklchToSrgb } from '../../../lib/color'; import type { SurfaceElevation } from '../../../lib/elevated'; import { MotiView } from '../../../moti/components/view'; import { AnimatePresence } from '../../../moti/presence/animate-presence'; import { type MotiTransitionProp, mergeTransition, TIMING_FAST, TIMING_INSTANT } from '../../../theme/motion'; import { useThemeColor } from '../../../theme/use-theme-color'; import { Surface } from '../../display/Surface/surface'; import { Text } from '../../typography/Text/text'; /** * The selection accent at a given alpha, derived from the live token so a * consumer `@theme` override carries into the tint. * * Both ends of the background cross-fade are this same hue — the unselected end * is simply alpha 0. Animating from a literal `transparent` instead would * interpolate through `rgba(0,0,0,0)` and wash the card grey on the way in. */ function tintAt(color: string, alpha: number) { const oklch = cssColorToOklch(color); // Unparseable token (a named color, `currentColor`): skip the tint rather than // guess at a fill, which would land opaque. Both ends of the cross-fade take // this branch together, so the literal here only ever interpolates against // itself — the grey wash described above needs two *different* ends to appear. if (!oklch) return 'transparent'; return oklchToSrgb(oklch.lightness, oklch.chroma, oklch.hue, alpha); } type RadioCardRingProps = { selected: boolean; /** Resolved selection accent color — the token already mapped through `TONE_TOKEN`. */ accent: string; /** Resolved selection animation, already merged with the group/card overrides. */ transition: MotiTransitionProp; /** The card's resolved testID; the ring and dot derive from it. */ testID?: string; }; /** * The radio ring and its dot. Kept private to this file because only `RadioCard` * renders it, and split out of the card body so the card stays readable. * * The ring's border cross-fades between `border` and the card's accent as a real * color interpolation; the dot fades and scales in place. Nothing is measured — * each card owns its own dot, so there is no cross-card geometry to resolve. */ function RadioCardRing({ selected, accent, transition, testID }: RadioCardRingProps) { const reduce = useReducedMotion(); // Resolve the unselected end of the border cross-fade through the token bridge // so it follows consumer @theme overrides; the selected end is the accent the // caller resolved, so the ring and the card tint can never drift apart. const border = useThemeColor('border'); const t = reduce ? TIMING_INSTANT : transition; return ( {selected ? ( ) : null} ); } /** Visual variant: `"radio"` shows the ring + dot; `"card"` uses only the border * and background tint to indicate selection. */ type RadioCardVariant = 'radio' | 'card'; /** Selection accent. `"neutral"` (default) is the primary token — the same * near-black/near-white fill a default `Radio` uses — and `"info"` is the blue * status token. */ type RadioCardTone = 'neutral' | 'info'; /** Token resolved for a tone; `neutral` maps to the monochrome `primary` token. */ const TONE_TOKEN: Record = { neutral: 'primary', info: 'info' }; /** Selected background wash alpha per tone. `neutral` carries twice the wash of * `info` because `primary` is achromatic and reads fainter at the same alpha. */ const TONE_TINT_ALPHA: Record = { neutral: 0.1, info: 0.05 }; /** * Wash alpha for the selected background: zero on a light surface (the selection * reads through the border + dot alone, and a grey wash would muddy the white), * the per-tone alpha on a dark surface where a light tint lifts the selected * card off its neighbours. Keyed off the resolved surface rather than * `useColorScheme` so it follows the manual `.dark`/`.light` toggles that * `useThemeColor` already subscribes to. */ function tintAlphaFor(tone: RadioCardTone, surfaceColor: string): number { const isDark = (cssColorToOklch(surfaceColor)?.lightness ?? 1) < 0.5; return isDark ? TONE_TINT_ALPHA[tone] : 0; } /** Border-width class for the card surface: the resting hairline border steps * up to 3px when selected so the highlight reads stronger than the resting edge. */ function cardBorderWidth(selected: boolean) { return selected ? 'border-[3px]' : 'hairline'; } /** * How a card arranges its own contents. `"stacked"` puts the ring on a row of * its own above the text; `"inline"` moves it to the trailing edge, top-aligned * against the text beside it. * * Distinct from the group's `orientation`, which lays the *cards* out relative * to each other — the two compose freely. Under `variant="card"` there is no * ring to place, so the layout only decides where the badge sits. */ type RadioCardLayout = 'stacked' | 'inline'; type RadioCardCtx = { value: string; setValue: (value: string) => void; /** Group-level selection animation. A card can override it. */ transition?: Partial; /** The group's own testID, used to derive per-card ones. */ testID?: string; /** Group-level variant. A card can override it. */ variant?: RadioCardVariant; /** Group-level selection accent. A card can override it. */ tone?: RadioCardTone; /** Group-level elevation. A card can override it. */ elevation: SurfaceElevation; /** Group-level floating halo. A card can override it. */ floating: boolean; /** Group-level card layout. A card can override it. */ layout: RadioCardLayout; /** Group-level orientation — whether the cards sit side by side or stacked. */ orientation: 'vertical' | 'horizontal'; }; const RadioCardContext = createContext(null); // biome-ignore lint/style/useExportsLast: props type before layout constants — collocated for readability export type RadioCardGroupProps = { value?: string; defaultValue?: string; onValueChange?: (value: string) => void; children: ReactNode; /** * How the *cards* are laid out relative to each other — side by side or * stacked. Independent of each card's own `layout`. @default 'horizontal' */ orientation?: 'vertical' | 'horizontal'; /** * How every card arranges its own contents. `"stacked"` (default) puts the * ring on a row above the text; `"inline"` moves it to the trailing edge, * top-aligned against the text. A card can override it with its own `layout`. */ layout?: RadioCardLayout; /** Additional UniWind class names merged onto the group container. */ className?: string; style?: StyleProp; testID?: string; /** * Override the selection animation (border cross-fade + dot fade/scale) for * every card. Partial — only the fields you pass are changed. * Default: `TIMING_FAST` (150 ms timing). */ transition?: Partial; /** * Visual variant for every card in the group. `"radio"` (default) shows the * ring + dot indicator; `"card"` uses only the animated border and background * tint. A card can override it with its own `variant`. */ variant?: RadioCardVariant; /** * Selection accent for every card in the group. `"neutral"` (default) fills * the dot and tints the border with the primary token; `"info"` uses the blue * status token. A card can override it with its own `tone`. */ tone?: RadioCardTone; /** * Swap the card's ladder shadow for the input field's large, diffuse halo * (`shadow-floating`). It replaces the `shadow-elevated-N` rung rather than * adding to it, so the card keeps its `elevation` tint but trades the * layered drop for the halo. A card can override it with its own `floating`. * @default false */ floating?: boolean; /** * Surface elevation for every card in the group (0–3). Controls the * background tint (`bg-surface-N`) and the drop shadow (`shadow-elevated-N`). * `0` is the flat resting surface — a `surface-3` fill with no shadow or * border. Default: `3` (the standard card level). A card can override it with * its own `elevation`. */ elevation?: SurfaceElevation; }; // Layout swaps the flex direction; cards keep flex-1 so a row shares width evenly. const group = cva('gap-3', { variants: { orientation: { vertical: 'flex-col', horizontal: 'flex-row', }, }, defaultVariants: { orientation: 'horizontal' }, }); /** * A single-select group of ``s. Owns the selected value; each card * animates its own border and dot from it. */ export function RadioCardGroup({ value, defaultValue = '', onValueChange, children, orientation = 'horizontal', layout = 'stacked', className, style, testID, transition, variant, tone, floating = false, elevation = 0, }: RadioCardGroupProps) { const [internal, setInternal] = useState(defaultValue); const controlled = value !== undefined; const current = controlled ? value : internal; const setValue = useCallback( (next: string) => { if (!controlled) setInternal(next); onValueChange?.(next); }, [controlled, onValueChange], ); return ( {children} ); } type RadioCardBodyProps = { title: string; subtitle?: string; numeric: boolean; /** The already-built badge, or `null` when the card has none. */ badge: ReactNode; inline: boolean; /** Whether the group lays its cards side by side — the badge drops below the * title when this is true, beside it when false. */ horizontal: boolean; /** No indicator (`variant="card"`): keep the badge in the body rather than on * a leading indicator row. */ card: boolean; children?: ReactNode; }; /** * The card's text column — title, optional subtitle, any custom content. Inline, * the badge sits beside the title in a vertical group but drops below it in a * horizontal one, where the narrow card has no room beside; stacked, it rides * the ring's row instead and only the title lands here. Under `variant="card"` * there is no ring, so the badge stays in the body the same way. * * Private to this file; split out so `RadioCard` stays under the complexity cap. * `flex-1` when inline so the column claims the width the ring isn't using. */ function RadioCardBody({ title, subtitle, numeric, badge, inline, horizontal, card, children }: RadioCardBodyProps) { const badgeInBody = inline || card; return ( {badgeInBody && !horizontal ? ( {title} {badge} ) : ( {title} )} {badgeInBody && horizontal && badge ? {badge} : null} {subtitle ? ( {subtitle} ) : null} {children} ); } export type RadioCardProps = { title: string; subtitle?: string; /** Short text shown in a pill in the top-right corner (e.g. a savings badge). */ badge?: string; /** Custom content rendered below the title/subtitle. */ children?: ReactNode; /** * Additional UniWind class names merged onto the card surface — the padded * box that carries the selected styling. Pass a `border-*` color here to give * the card a resting outline; by default it has none until selected. */ className?: string; style?: StyleProp; /** * Applies tabular figures to the subtitle (useful when it's a money amount or * other number that should align). Matches the repo's `fontVariant` convention. */ numeric?: boolean; /** * Inside a ``, the value this card selects. The card is * selected whenever it matches the group's current value. */ value?: string; /** Standalone selected state. Ignored inside a ``. */ selected?: boolean; /** Standalone press handler. Ignored inside a ``. */ onPress?: () => void; /** Accessible name for the card. Defaults to `title`. */ accessibilityLabel?: string; /** * Inside a ``, defaults to * `${group testID ?? 'radio-card-group'}-card-${value}`, so every card is * addressable without threading ids through the tree. Pass one to override it * — required to address the card standalone, where there is no value to key on. * The ring, its dot and the badge derive from the resolved id (`-ring`, `-dot`, * `-badge`). */ testID?: string; /** * Override the selection animation. Partial — only the fields you pass are * changed. Inherits the group's value when unset. Default: `TIMING_FAST`. */ transition?: Partial; /** * Visual variant. `"radio"` (default) shows the ring + dot indicator; * `"card"` uses only the animated border and background tint. Inherits the * group's value when unset. */ variant?: RadioCardVariant; /** * Selection accent. `"neutral"` (default) fills the dot and tints the border * with the primary token (the same fill a default `Radio` uses); `"info"` uses * the blue status token. Inherits the group's value when unset. */ tone?: RadioCardTone; /** * How the card arranges its contents. `"stacked"` (default) puts the ring on * a row above the text; `"inline"` moves it to the trailing edge, top-aligned * against the text, and the `badge` joins the title — beside it in a vertical * group, below it in a horizontal one. Inherits the group's value when unset. */ layout?: RadioCardLayout; /** * Swap the card's ladder shadow for the input field's large, diffuse halo * (`shadow-floating`). It replaces the `shadow-elevated-N` rung rather than * adding to it, so the card keeps its `elevation` tint but trades the * layered drop for the halo. Inherits the group's value when unset. * @default false */ floating?: boolean; /** * Surface elevation (0–3). Controls the background tint (`bg-surface-N`) and * the drop shadow (`shadow-elevated-N`). `0` is the flat resting surface (a * `surface-3` fill, no shadow or border). Inherits the group's value when * unset. Default: `3` (the standard card level). */ elevation?: SurfaceElevation; }; /** * A selectable card with a radio indicator, title/subtitle and an optional badge. * Use inside a row/grid for single-select choices (e.g. billing periods, plans). * * Standalone, drive it with `selected` + `onPress`. Wrapped in a * `` and given a `value`, the group owns the selection and the * card reads its state from it. * * Each card animates its own selection: the border and background tint * cross-fade in from nothing — a resting card has no outline of its own and * reads as a plain surface — and the dot fades and scales in place. Nothing * travels between cards, so no geometry is measured. */ export function RadioCard({ value, selected: selectedProp, onPress, title, subtitle, badge, children, className, style, numeric = false, accessibilityLabel, testID, transition, variant, tone, layout, floating, elevation, }: RadioCardProps) { const groupCtx = useContext(RadioCardContext); if (value !== undefined && groupCtx === null) throw new Error('RadioCardItem with a `value` prop must be used inside '); const inGroup = groupCtx !== null && value !== undefined; const reduce = useReducedMotion(); const selected = inGroup ? groupCtx.value === value : Boolean(selectedProp); const resolvedVariant = variant ?? groupCtx?.variant ?? 'radio'; const card = resolvedVariant === 'card'; const resolvedTone = tone ?? groupCtx?.tone ?? 'neutral'; const accent = useThemeColor(TONE_TOKEN[resolvedTone]); const tintAlpha = tintAlphaFor(resolvedTone, useThemeColor('surface-3')); const resolvedFloating = floating ?? groupCtx?.floating ?? false; const resolvedElevation = elevation ?? groupCtx?.elevation ?? 3; const inline = (layout ?? groupCtx?.layout ?? 'stacked') === 'inline'; // Inline + horizontal = narrow cards side by side, where the badge has no room // beside the title; it drops below instead. Standalone there is no group to // read orientation from, so `horizontal` is false and the badge stays beside. const horizontal = groupCtx?.orientation === 'horizontal'; const t = mergeTransition(TIMING_FAST, transition ?? groupCtx?.transition); const ct = reduce ? TIMING_INSTANT : t; // Derive from the group so cards are addressable without threading a testID // through every child; an explicit prop still wins. Falls back to the // component name when the group has no testID. // Standalone there is no value to key on, so only an explicit prop applies. const cardTestID = testID ?? (inGroup ? `${groupCtx.testID ?? 'radio-card-group'}-card-${value}` : undefined); const handlePress = useCallback(() => { if (groupCtx && value !== undefined) groupCtx.setValue(value); else onPress?.(); }, [groupCtx, value, onPress]); // The ring and the badge move between rows with the layout, so both are built // once here and placed by the tree below rather than spelled twice. Under // `variant="card"` there is no ring at all — the border and tint carry the // selection on their own. const ring = resolvedVariant === 'radio' ? : null; const badgeNode = badge ? ( {badge} ) : null; return ( {/* The elevated surface wrapper carries the surface background and drop shadow. The shadow must render outside the Pressable's clip region, so it lives on a dedicated View rather than on the animated surface below. Both `className` and `style` land here so consumer overrides all target one element. */} {/* The animated surface. A Pressable can't be animated directly (motify is only applied to host primitives, and MotiPressable nests a MotiView the same way), so the border/tint live here and the Pressable above keeps the role and press handling. The border is purely the selection affordance: unselected it is the accent at alpha 0, so the resting card shows no outline and leans on the wrapper's surface fill instead. Both ends run through `tintAt` for the reason spelled out on that helper — a literal `transparent` end would interpolate through transparent black and darken the edge on the way in. Selecting steps the border from the hairline border to hairline + 0.5px so the highlight reads stronger than the resting edge; the half-pixel content shift is negligible. */} {/* Stacked, the ring leads a row of its own and the badge rides its far end. Inline and `variant="card"` have no reason for that row — the ring moves to the trailing edge (inline) or disappears (card), and the badge joins the title in the body. */} {inline || card ? null : ( {ring} {badgeNode} )} {children} {inline ? ring : null} ); }