/** * ProgressBar — determinate or indeterminate progress with optional chrome. * Uses Tamagui stacks/tokens only (no raw CSS gradients) for consistent theming and RN compatibility. * * Slots (all optional): `header`, `footer` (e.g. wrap your own ScrollView for history), `leftSlot` / `rightSlot` * for inline actions, `label` centered above the track in the main column. * * @typedef {'determinate' | 'indeterminate'} ProgressMode * @typedef {'inline' | 'fixedTop'} ProgressVariant */ import React, { useEffect, useMemo, useState } from 'react'; import { Text, XStack, YStack } from 'tamagui'; import { scheduleInterval, scheduleTimeout } from '../../platform/compat.js'; function clamp01(n) { const x = Number(n); if (!Number.isFinite(x)) { return 0; } return Math.max(0, Math.min(100, x)); } function renderNode(node) { if (node === null || node === undefined || node === false) { return null; } return node; } function hasSlot(node) { return node !== null && node !== undefined && node !== false; } export function ProgressBar({ mode = 'indeterminate', value = 0, active = false, /** Delay before unmount after `active` becomes false (smooth fade-out). Inline only; `fixedTop` hides immediately to avoid layout jump (label removed) during opacity animation. */ retainAfterInactiveMs = 420, label, header, footer, leftSlot, rightSlot, variant = 'inline', trackHeight = 4, zIndex = 20000 }) { const [offset, setOffset] = useState(-45); const [mounted, setMounted] = useState(active); /** `fixedTop` follows `active` only — no delayed unmount, so the track cannot re-anchor to the top when the parent clears `label` on the same tick as `active`. */ const useRetainAfterInactive = variant !== 'fixedTop'; const visible = useRetainAfterInactive ? mounted : active; const determinateWidth = useMemo(() => `${clamp01(value)}%`, [value]); useEffect(() => { if (active) { setMounted(true); } }, [active]); useEffect(() => { if (!visible || mode !== 'indeterminate') { return undefined; } return scheduleInterval(() => { setOffset((v) => (v >= 100 ? -45 : v + 4)); }, 80); }, [visible, mode]); useEffect(() => { if (!useRetainAfterInactive || active || !mounted) { return undefined; } return scheduleTimeout(() => { setMounted(false); }, retainAfterInactiveMs); }, [active, mounted, retainAfterInactiveMs, useRetainAfterInactive]); if (!visible) { return null; } const track = ( {mode === 'determinate' ? ( ) : ( )} ); const centerBlock = ( {label !== null && label !== undefined && label !== '' ? ( typeof label === 'string' ? ( {label} ) : ( label ) ) : null} {track} ); const body = ( {hasSlot(header) ? {renderNode(header)} : null} {hasSlot(leftSlot) ? ( {renderNode(leftSlot)} ) : null} {centerBlock} {hasSlot(rightSlot) ? ( {renderNode(rightSlot)} ) : null} {hasSlot(footer) ? ( {renderNode(footer)} ) : null} ); if (variant === 'fixedTop') { return ( {body} ); } return {body}; } export default ProgressBar;