import { type ReactNode, useCallback } from 'react'; import { Pressable, type StyleProp, type ViewStyle } from 'react-native'; import Svg, { Path } from 'react-native-svg'; import { usePressState } from '../../../hooks/use-press-state'; import { useReducedMotion } from '../../../hooks/use-reduced-motion'; import { cn } from '../../../lib/cn'; import { SPRING_PRESS } from '../../../lib/ease'; import { FOCUS_VISIBLE_RING } from '../../../lib/focus-ring'; import { hitSlopFor } from '../../../lib/radius'; 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 { Text } from '../../typography/Text/text'; /** * Checkmark and indeterminate-dash glyphs, drawn in a 32×32 viewBox rendered at * 16×16 (0.5× scale) so placement has half-pixel granularity. Both glyphs are * nudged one half-unit (half a pixel) up-left of the viewBox centre — the check * because its bottom vertex and the long upper diagonal pull its optical centre * down-right of the stroke bbox, the dash so it shares the check's resting * centre during the cross-fade. All coordinates stay whole numbers and the SVG * stays a fixed 16×16, so flex centring adds no sub-pixel offset. */ const GLYPH_VIEWBOX = '0 0 32 32'; const CHECK_PATH = 'M6 13L14 21L26 9'; const INDETERMINATE_PATH = 'M6 15H26'; /** * Checked border class per tone. A static map (not a `border-${tone}` template * literal) so UniWind can statically resolve every member at build time. */ const BOX_TONE_BORDER: Record = { primary: 'border-primary', secondary: 'border-secondary', accent: 'border-accent', success: 'border-success', warning: 'border-warning', info: 'border-info', danger: 'border-danger', }; /** Filled accent tokens — each has a matching `-foreground` for the mark. */ export type CheckboxTone = 'primary' | 'secondary' | 'accent' | 'success' | 'warning' | 'info' | 'danger'; export type CheckboxBoxProps = { checked: boolean; indeterminate?: boolean; disabled?: boolean; /** Whether the surrounding control is pressed — drives the box squish. */ pressed?: boolean; /** Accent token for the fill, border and mark. Defaults to `primary`. */ tone?: CheckboxTone; /** Resolved check animation (already merged with any override). */ transition?: MotiTransitionProp; checkIcon?: ReactNode; /** The box and mark derive `-control` / `-check` from this. */ testID?: string; }; /** * The animated box + check/dash mark. Split out so `Checkbox` and `CheckboxCard` * share one implementation: `Checkbox` wraps it in a Pressable, while * `CheckboxCard` renders it directly because the whole card is the checkbox. */ export function CheckboxBox({ checked, indeterminate, disabled, pressed = false, tone = 'primary', transition, checkIcon, testID, }: CheckboxBoxProps) { const reduce = useReducedMotion(); // Resolve the accent and its mark colour through the token bridge so they // adapt to consumer @theme overrides. const accent = useThemeColor(tone); const checkColor = useThemeColor(`${tone}-foreground`); const surfaceColor = useThemeColor('surface-3'); const showMark = checked || Boolean(indeterminate); const path = indeterminate ? INDETERMINATE_PATH : CHECK_PATH; const ct = reduce ? TIMING_INSTANT : (transition ?? TIMING_FAST); return ( // The box is the surface: it springs down while pressed and cross-fades its // own background between the surface and accent fills. Animating the box // (rather than an absolutely-positioned fill overlay) keeps the selected // background inside the border, so it never needs to overlap the border. // `relative` makes the box the containing block for the absolutely-positioned // mark below, so its `inset-0` stays anchored here on web (native already // anchors absolute children to their parent). {/* The check/dash cross-fade in place: the mark is absolutely positioned so the outgoing and incoming icons overlap during the swap instead of stacking as flex siblings. */} {showMark ? ( {checkIcon ?? ( )} ) : null} ); } export type CheckboxProps = { checked: boolean; onCheckedChange: (checked: boolean) => void; disabled?: boolean; indeterminate?: boolean; label?: string; /** Accent token for the box fill, border and mark. Defaults to `primary`. */ tone?: CheckboxTone; /** Additional UniWind class names merged onto the outer row. */ className?: string; style?: StyleProp; accessibilityLabel?: string; testID?: string; /** * Override the check-mark animation. Partial — only the fields you pass are changed. * Default: `TIMING_FAST` (150 ms timing). */ checkTransition?: Partial; /** Replace the check-mark icon. Default: ``. */ checkIcon?: ReactNode; }; export function Checkbox({ checked, onCheckedChange, disabled, indeterminate, label, tone = 'primary', className, style, accessibilityLabel, testID, checkTransition, checkIcon, }: CheckboxProps) { const { pressed, pressHandlers } = usePressState(); const ct = mergeTransition(TIMING_FAST, checkTransition); const handlePress = useCallback(() => { if (!disabled) onCheckedChange(!checked); }, [disabled, onCheckedChange, checked]); return ( {label ? {label} : null} ); }