import React, { useEffect } from "react"; import { View } from "react-native"; import Animated, { Easing, useAnimatedStyle, useSharedValue, withTiming, } from "react-native-reanimated"; import type { ProgressIndicatorProps } from "./types"; import { useStyles } from "./ProgressIndicator.style"; import { sizeHeights, sizeStyles } from "./ProgressIndicator.size.style"; import { useAtlantisI18n } from "../hooks/useAtlantisI18n"; import { tokens } from "../utils/design"; type RendererProps = Required< Pick >; export function ProgressIndicator({ value, max = 100, pendingValue = 0, variation = "continuous", size = "base", accessibilityLabel, style, }: ProgressIndicatorProps) { const styles = useStyles(); const { t } = useAtlantisI18n(); const label = accessibilityLabel ?? getA11yLabel(); const radius = sizeHeights[size] / 2; return ( {variation === "stepped" ? ( ) : ( )} ); function getA11yLabel(): string { if (pendingValue > 0) { return t("ProgressIndicator.ariaLabelPending", { value: String(value), max: String(max), pendingValue: String(pendingValue), }); } if (max === 100) { return t("ProgressIndicator.ariaLabelPercent", { value: String(value) }); } return t("ProgressIndicator.ariaLabelRatio", { value: String(value), max: String(max), }); } } /** * Clamp a raw progress amount to a percentage of `max` in the range 0..100. */ function toPercent(amount: number, max: number): number { if (max <= 0) return 0; return Math.min(100, Math.max(0, (amount / max) * 100)); } /** * File-private renderer for `variation="continuous"`. Renders the fill and, * when `pendingValue > 0`, a pending band. The outer `ProgressIndicator` shell * owns the accessibility contract; this renderer emits presentational children * only. */ function ProgressIndicatorContinuous({ value, max, pendingValue, size, }: RendererProps) { const styles = useStyles(); const radius = sizeHeights[size] / 2; const fillPercent = toPercent(value, max); const pendingPercent = toPercent(value + pendingValue, max); // The width transition is intentionally preserved regardless of the user's // reduce-motion preference: the slide between progress values conveys // functional progress and is exempt under WCAG 2.3.3 ("Animation from // Interactions"). See design.md. const fill = useSharedValue(fillPercent); const pending = useSharedValue(pendingPercent); useEffect(() => { fill.value = withTiming(fillPercent, { duration: tokens["timing-base"], easing: Easing.out(Easing.ease), }); }, [fillPercent]); useEffect(() => { pending.value = withTiming(pendingPercent, { duration: tokens["timing-base"], easing: Easing.out(Easing.ease), }); }, [pendingPercent]); const fillStyle = useAnimatedStyle(() => ({ width: `${fill.value}%` })); const pendingStyle = useAnimatedStyle(() => ({ width: `${pending.value}%` })); return ( <> {pendingValue > 0 && ( )} ); } /** * File-private renderer for `variation="stepped"`. Renders `max` presentational * segments, each classified filled / pending / empty. No accessibility props on * the segments — the outer shell announces once. */ function ProgressIndicatorStepped({ value, max, pendingValue, size, }: RendererProps) { const styles = useStyles(); const radius = sizeHeights[size] / 2; const segmentCount = Math.max(0, Math.floor(max)); return ( <> {Array.from({ length: segmentCount }).map((_, index) => { const step = index + 1; const isFilled = step <= value; const isPending = !isFilled && step <= value + pendingValue; return ( ); })} ); }