- Externalize all @tamagui/* and tamagui subpaths so dist no longer vendors Tamagui. - Emit TypeScript declarations with vite-plugin-dts; fix package exports types for ui/*. - Align initEnv with profiles: displayName, brandLogo, api.baseURL, themeColor, uiShell. - Stabilize tests with Node localStorage file; env tests pass. - Update README and component docs for services, menus, API client, and development.
191 lines
5.0 KiB
JavaScript
191 lines
5.0 KiB
JavaScript
/**
|
|
* 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 = (
|
|
<YStack
|
|
position="relative"
|
|
width="100%"
|
|
height={trackHeight}
|
|
borderRadius="$1"
|
|
backgroundColor="$borderColor"
|
|
opacity={0.55}
|
|
overflow="hidden"
|
|
>
|
|
{mode === 'determinate' ? (
|
|
<YStack
|
|
position="absolute"
|
|
top={0}
|
|
left={0}
|
|
height="100%"
|
|
width={determinateWidth}
|
|
borderRadius="$1"
|
|
backgroundColor="$accentColor"
|
|
/>
|
|
) : (
|
|
<YStack
|
|
position="absolute"
|
|
top={0}
|
|
height="100%"
|
|
width="42%"
|
|
borderRadius="$1"
|
|
backgroundColor="$accentColor"
|
|
left={`${offset}%`}
|
|
/>
|
|
)}
|
|
</YStack>
|
|
);
|
|
|
|
const centerBlock = (
|
|
<YStack flex={1} minWidth={0} alignItems="center" gap="$1">
|
|
{label !== null && label !== undefined && label !== '' ? (
|
|
typeof label === 'string' ? (
|
|
<Text fontSize="$2" color="$colorSecondary" numberOfLines={1} textAlign="center" width="100%">
|
|
{label}
|
|
</Text>
|
|
) : (
|
|
label
|
|
)
|
|
) : null}
|
|
{track}
|
|
</YStack>
|
|
);
|
|
|
|
const body = (
|
|
<YStack
|
|
width="100%"
|
|
pointerEvents="box-none"
|
|
opacity={useRetainAfterInactive ? (active ? 1 : 0) : 1}
|
|
animation={useRetainAfterInactive ? 'medium' : undefined}
|
|
gap="$2"
|
|
>
|
|
{hasSlot(header) ? <YStack width="100%">{renderNode(header)}</YStack> : null}
|
|
|
|
<XStack width="100%" alignItems="center" justifyContent="center" gap="$2" pointerEvents="box-none">
|
|
{hasSlot(leftSlot) ? (
|
|
<XStack flexShrink={0} alignItems="center" pointerEvents="auto">
|
|
{renderNode(leftSlot)}
|
|
</XStack>
|
|
) : null}
|
|
{centerBlock}
|
|
{hasSlot(rightSlot) ? (
|
|
<XStack flexShrink={0} alignItems="center" pointerEvents="auto">
|
|
{renderNode(rightSlot)}
|
|
</XStack>
|
|
) : null}
|
|
</XStack>
|
|
|
|
{hasSlot(footer) ? (
|
|
<YStack width="100%" flexShrink={0}>
|
|
{renderNode(footer)}
|
|
</YStack>
|
|
) : null}
|
|
</YStack>
|
|
);
|
|
|
|
if (variant === 'fixedTop') {
|
|
return (
|
|
<YStack
|
|
position="fixed"
|
|
top={0}
|
|
left={0}
|
|
right={0}
|
|
zIndex={zIndex}
|
|
paddingHorizontal="$1"
|
|
paddingTop="$1"
|
|
pointerEvents="box-none"
|
|
backgroundColor="transparent"
|
|
>
|
|
{body}
|
|
</YStack>
|
|
);
|
|
}
|
|
|
|
return <YStack width="100%" pointerEvents="box-none">{body}</YStack>;
|
|
}
|
|
|
|
export default ProgressBar;
|