Files
bface/src/ui/components/ProgressBar.jsx
Amer Agovic 94a9f32969 Initial commit: bface library, build fixes, and refreshed docs
- 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.
2026-04-18 10:43:52 -05:00

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;