/**
* RadioGroup — one choice from a set.
*
* The group owns the value and hands each item its selected state through
* context, so an item is a thin pressable that reports its own value up and
* reads selection back down.
*
* Two shapes, chosen on the group and inherited by every item: `dot` is the
* classic label-beside-a-disc row; `card` turns the whole surface into the
* target — for pickable options where the disc is a confirmation rather than
* the thing you aim at. The card is the same treatment `Checkbox` offers, so a
* form mixing single- and multi-select choices reads as one family.
*
* ```tsx
*
*
*
*
* ```
*/
import {
createContext,
forwardRef,
useContext,
useEffect,
useMemo,
type ReactNode,
} from 'react';
import { Pressable, View, type ViewProps } from 'react-native';
import Animated, {
useAnimatedStyle,
useSharedValue,
withSpring,
withTiming,
} from 'react-native-reanimated';
import { tv, type VariantProps } from 'tailwind-variants';
import { cn } from '../../utils/cn';
import { Text, textChildren } from '../../primitives/text';
import { requestRadioValueChange } from './radio-group-value';
type RadioVariant = 'dot' | 'card';
type RadioOrientation = 'vertical' | 'horizontal';
const itemVariants = tv({
slots: {
// `self-start` matters: the group is a column, so without it Yoga stretches
// the row to the full width and the dead space to the right of the label
// becomes part of the target. Tapping nothing would select the option.
row: 'min-h-12 min-w-12 flex-row items-center gap-2.5 self-start',
indicator:
'h-5 w-5 items-center justify-center rounded-full border border-input bg-background',
label: 'text-sm text-foreground',
description: 'text-xs text-muted-foreground',
},
variants: {
variant: {
dot: {},
card: {
// The whole surface is the target; the disc moves to the trailing edge
// as a confirmation of the row, not the affordance for it. Here the
// full width is deliberate, so it takes `self-stretch` back.
row: 'w-full self-stretch items-start justify-between gap-3 rounded-xl border border-border bg-card p-4',
label: 'text-base font-medium',
},
},
selected: {
true: {},
},
disabled: {
true: { row: 'opacity-50' },
},
/**
* A card in a row shares the width rather than filling it, so two or three
* options sit side by side instead of each taking the whole line.
*/
horizontal: {
true: {},
},
},
compoundVariants: [
{ variant: 'card', selected: true, class: { row: 'border-primary bg-accent' } },
// Share the row instead of filling it, keeping the disc on the trailing
// edge of each narrower card.
{ variant: 'card', horizontal: true, class: { row: 'w-auto flex-1' } },
],
defaultVariants: {
variant: 'dot',
},
});
interface RadioGroupContextValue {
value: string | undefined;
onValueChange: (value: string) => void;
disabled?: boolean;
variant: RadioVariant;
orientation: RadioOrientation;
}
const RadioGroupContext = createContext(null);
export interface RadioGroupProps extends ViewProps {
className?: string;
value?: string;
onValueChange: (value: string) => void;
disabled?: boolean;
/**
* `dot` is the label-beside-a-disc row. `card` makes the whole surface the
* target and highlights the selected option — for a plan picker or a
* settings choice where each option carries a description.
*/
variant?: RadioVariant;
/**
* `horizontal` lays the options out along a row that wraps — for two or
* three short choices, where a stacked list wastes the width and reads as
* longer than it is.
*/
orientation?: RadioOrientation;
children: ReactNode;
}
const RadioGroupRoot = forwardRef(
(
{
className,
value,
onValueChange,
disabled,
variant = 'dot',
orientation = 'vertical',
children,
...props
},
ref
) => {
const context = useMemo(
() => ({ value, onValueChange, disabled, variant, orientation }),
[value, onValueChange, disabled, variant, orientation]
);
return (
{textChildren(children)}
);
}
);
RadioGroupRoot.displayName = 'RadioGroup';
export interface RadioGroupItemProps {
className?: string;
value: string;
label?: string;
/** Secondary line under the label. Most at home in the `card` variant. */
description?: string;
disabled?: boolean;
/** Hide the disc entirely — for a card whose selected fill is enough. */
hideIndicator?: boolean;
children?: ReactNode;
}
const RadioGroupItem = forwardRef(
(
{ className, value, label, description, disabled: itemDisabled, hideIndicator, children },
ref
) => {
const context = useContext(RadioGroupContext);
if (!context) {
throw new Error('RadioGroup.Item must be used within a ');
}
const selected = context.value === value;
const disabled = itemDisabled || context.disabled;
const variant = context.variant;
const progress = useSharedValue(selected ? 1 : 0);
useEffect(() => {
progress.value = selected
? withSpring(1, { damping: 15, stiffness: 300, mass: 0.5 })
: withTiming(0, { duration: 120 });
}, [selected, progress]);
const dotStyle = useAnimatedStyle(() => ({
opacity: progress.value,
transform: [{ scale: progress.value }],
}));
const slots = itemVariants({
variant,
selected,
disabled: !!disabled,
horizontal: context.orientation === 'horizontal',
});
const indicator = hideIndicator ? null : (
);
// Either way the label is a string in a row that is otherwise all views,
// so children given instead of the prop are dressed the same way rather
// than reaching the pressable bare.
const labelled = label ? (
{label}
) : (
textChildren(children, (text) => {text})
);
// The card lays its text out first so the disc sits at the trailing edge;
// the dot row keeps the classic disc-then-label order.
const body =
variant === 'card' ? (
<>
{labelled}
{description ? (
{description}
) : null}
{indicator}
>
) : (
<>
{indicator}
{labelled}
>
);
return (
requestRadioValueChange(context.value, value, context.onValueChange)
}
className={slots.row({ className })}
>
{body}
);
}
);
RadioGroupItem.displayName = 'RadioGroup.Item';
export const RadioGroup = Object.assign(RadioGroupRoot, {
Item: RadioGroupItem,
});
export type { RadioVariant };