/** * StarRating * * An animated star-rating input. Tapping commits a rating with a squash-and- * stretch pop and a sparkle burst; tapping the committed star clears it * (allowClear). An optional rolling value label tracks the current rating. * Works controlled or uncontrolled, supports fractional read-only display, and * honours prefers-reduced-motion. Exposes radiogroup / radio semantics with * increment / decrement accessibility actions. * * Dependencies: react-native-reanimated, react-native-svg */ /** biome-ignore-all lint/style/noExcessiveLinesPerFile: complex logic */ import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { type AccessibilityActionEvent, Pressable, type StyleProp, View, type ViewStyle } from 'react-native'; import Animated, { useAnimatedStyle, useSharedValue, withSequence, withSpring, withTiming } from 'react-native-reanimated'; 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 { fireHapticFeedback } from '../../../lib/haptics'; import type { HapticFeedbackVariant } from '../../../lib/haptics-types'; import { hitSlopFor } from '../../../lib/radius'; import { MotiView } from '../../../moti/components/view'; import { AnimatePresence } from '../../../moti/presence/animate-presence'; import { TIMING_INSTANT } from '../../../theme/motion'; import { useThemeColor } from '../../../theme/use-theme-color'; import { Text } from '../../typography/Text/text'; // ─── Types ─────────────────────────────────────────────────────────────────── export type StarRenderProps = { size: number; color: string; filled: boolean }; // biome-ignore lint/style/useExportsLast: props type before internal constants — collocated for readability export type StarRatingProps = { /** Controlled rating value. Leave undefined for uncontrolled usage. */ value?: number; /** Initial rating when uncontrolled. Default 0 */ defaultValue?: number; /** Fires with the next rating on every commit. */ onValueChange?: (value: number) => void; /** Number of stars rendered. Default 5 */ max?: number; /** Visual size of the stars. Default "md" */ size?: 'sm' | 'md' | 'lg'; /** Tapping the committed star clears the rating to 0. Default true */ allowClear?: boolean; /** Display-only; supports fractional values like 4.3. Default false */ readOnly?: boolean; /** Show a rolling "4 / 5" value label next to the stars. Default false */ showValue?: boolean; /** Accessible name of the rating group. Default "Rating" */ label?: string; /** * Haptic fired when a star is tapped (including the tap that clears it). * Defaults to `'Selection'`. Pass `'None'` to disable. */ hapticFeedback?: HapticFeedbackVariant; /** Additional UniWind class names merged onto the outer row. */ className?: string; style?: StyleProp; /** * Root testID. Each star takes `-star-`, 1-based — under `readOnly` * too, so a test selects the same node whichever mode it renders in. */ testID?: string; /** * Color of the filled/active stars and the sparkle burst. Defaults to a * theme-independent gold so stars always read as stars. */ activeStarColor?: string; /** Color of the empty/inactive stars. Defaults to the theme `accent` color. */ inactiveStarColor?: string; /** Round the star's stroke caps and joins. Set false for sharp points. Default true */ round?: boolean; /** * Custom star shape. Receives `{ size, color, filled }` and should return a * ReactNode sized to `size × size`. Default: the built-in ``. */ renderStar?: (props: StarRenderProps) => ReactNode; }; // ─── Constants ─────────────────────────────────────────────────────────────── const SPARKLE_COUNT = 5; const BURST_DURATION_MS = 500; /** Theme-independent gold used for filled stars unless overridden via props. */ const DEFAULT_ACTIVE_STAR_COLOR = '#fec700'; /* theme-exempt: fixed gold, reads as a star across every theme */ /** Snappy spring for star fills and press feedback. */ const FILL_SPRING = { type: 'spring' as const, stiffness: 500, damping: 30 }; /** Quick tween for un-fills so the active overlay never overshoots back. */ const UNFILL_TIMING = { type: 'timing' as const, duration: 150 }; /** Spring for the rolling value-label digits. */ const VALUE_SPRING = { type: 'spring' as const, stiffness: 400, damping: 30 }; const SIZES = { sm: { icon: 16, pad: 'p-1', valueGap: 'ml-1.5', lineH: 16, valueLabel: 'text-xs tabular-nums text-muted-foreground', }, md: { icon: 22, pad: 'p-0.5', valueGap: 'ml-2', lineH: 20, valueLabel: 'text-sm tabular-nums text-muted-foreground', }, lg: { icon: 28, pad: 'p-0.5', valueGap: 'ml-2.5', lineH: 20, valueLabel: 'text-sm tabular-nums text-muted-foreground', }, } as const; const STAR_PATH = 'M12 2L14.65 8.36L21.51 8.91L16.28 13.39L17.88 20.09L12 16.5L6.12 20.09L7.72 13.39L2.49 8.91L9.35 8.36Z'; // Pre-computed per-sparkle configs — angles are stable identifiers so the map // never needs to touch the array index, keeping the key free of index values. const SPARKLES = Array.from({ length: SPARKLE_COUNT }, (_, i) => { const angle = (i / SPARKLE_COUNT) * Math.PI * 2 - Math.PI / 2; return { angle, key: `a${angle.toFixed(4)}` }; }); function formatValue(v: number) { return Number.isInteger(v) ? String(v) : v.toFixed(1); } // ─── StarSvg ───────────────────────────────────────────────────────────────── type StarSvgProps = { size: number; color: string; filled?: boolean; round?: boolean }; export function StarSvg({ size, color, filled, round = true }: StarSvgProps) { return ( ); } // ─── SparklesBurst ─────────────────────────────────────────────────────────── type SparklesBurstProps = { icon: number; burstKey: number; color: string }; export function SparklesBurst({ icon, burstKey, color }: SparklesBurstProps) { return ( {SPARKLES.map(({ angle, key }) => { const dist = icon * 1.2; const dotSize = Math.max(2, Math.round(icon * 0.16)); return ( ); })} ); } // ─── StarButton ────────────────────────────────────────────────────────────── type StarButtonProps = { starValue: number; filled: boolean; isBursting: boolean; burstKey: number; isSelected: boolean; icon: number; padClass: string; reduce: boolean; activeStarColor: string; inactiveStarColor: string; round: boolean; onSelect: (starValue: number) => void; renderStar?: (props: StarRenderProps) => ReactNode; testID?: string; }; export function StarButton({ starValue, filled, isBursting, burstKey, isSelected, icon, padClass, reduce, activeStarColor, inactiveStarColor, round, onSelect, renderStar, testID, }: StarButtonProps) { const { pressed, pressHandlers } = usePressState(); const popScale = useSharedValue(1); const popStyle = useAnimatedStyle(() => ({ transform: [{ scale: popScale.value }] })); const previousBurstKeyRef = useRef(-1); // biome-ignore lint/plugin: triggering a Reanimated withSequence on a shared value in response to a prop change requires a side effect useEffect(() => { if (isBursting && !reduce && burstKey !== previousBurstKeyRef.current) { previousBurstKeyRef.current = burstKey; popScale.value = withSequence(withTiming(0.7, { duration: 110 }), withSpring(1.0, { stiffness: 500, damping: 14 })); } }, [isBursting, burstKey, reduce, popScale]); const handlePress = useCallback(() => onSelect(starValue), [onSelect, starValue]); return ( {/* Unfilled base star */} {renderStar ? ( renderStar({ size: icon, color: inactiveStarColor, filled: false }) ) : ( )} {/* Filled active overlay */} {renderStar ? ( renderStar({ size: icon, color: activeStarColor, filled: true }) ) : ( )} {/* Sparkle burst */} {isBursting && !reduce ? : null} ); } // ─── StarRating ─────────────────────────────────────────────────────────────── export function StarRating({ value: valueProp, defaultValue = 0, onValueChange, max = 5, size = 'md', allowClear = true, readOnly = false, showValue = false, label = 'Rating', hapticFeedback = 'Selection', className, activeStarColor = DEFAULT_ACTIVE_STAR_COLOR, inactiveStarColor, round = true, renderStar, ...props }: StarRatingProps) { const reduce = useReducedMotion(); const themeAccent = useThemeColor('accent'); // Inactive stars fall back to the theme `accent` color when no prop is given. const inactiveColor = inactiveStarColor ?? themeAccent; const [internal, setInternal] = useState(defaultValue); const [burst, setBurst] = useState<{ key: number; index: number } | null>(null); const value = valueProp ?? internal; const { icon, pad, valueGap, lineH, valueLabel } = SIZES[size]; // Build a stable array of star values (1…max) so map() never exposes an index. const starValues = useMemo(() => Array.from({ length: max }, (_, i) => i + 1), [max]); // Track scroll direction for the rolling value label — computed during render // so both the entering and exiting digit animate in the same direction. const prevValueRef = useRef(value); const direction = value >= prevValueRef.current ? 1 : -1; // biome-ignore lint/plugin: storing previous value in a ref for direction tracking requires a post-render side effect useEffect(() => { prevValueRef.current = value; }, [value]); const commitValue = useCallback( (next: number) => { if (valueProp === undefined) setInternal(next); onValueChange?.(next); }, [valueProp, onValueChange], ); const handleSelect = useCallback( (starValue: number) => { if (hapticFeedback !== 'None') fireHapticFeedback(hapticFeedback); const next = allowClear && starValue === value ? 0 : starValue; commitValue(next); if (next > 0 && !reduce) setBurst((prev) => ({ key: (prev?.key ?? 0) + 1, index: starValue - 1 })); }, [allowClear, value, commitValue, reduce, hapticFeedback], ); const handleAccessibilityAction = useCallback( (e: AccessibilityActionEvent) => { const { actionName } = e.nativeEvent; if (actionName === 'increment') commitValue(Math.min(value + 1, max)); else if (actionName === 'decrement') commitValue(Math.max(value - 1, 0)); }, [value, max, commitValue], ); if (readOnly) return ( {starValues.map((starValue) => { const fillPercent = Math.max(0, Math.min(1, value - (starValue - 1))) * 100; return ( {fillPercent > 0 ? ( ) : null} ); })} ); return ( {starValues.map((starValue) => ( ))} {showValue ? ( {/* Rolling value digit. A hidden sizer reserves the digit's width so the slot stays stable; the animated label is absolutely positioned so the entering and exiting digits overlap instead of sitting side-by-side and shoving the /max label sideways on every change. Mirrors TextRolling. */} {formatValue(value)} {formatValue(value)} {`/${max}`} ) : null} ); }