1/** @jsxImportSource @alchemy.run/sigil */
2import type { ReactNode } from "react";
3import { statusPaint, type StatusVariant } from "../../../Util/Theme.ts";
4import { useCliEnvironment } from "./Environment.tsx";
5import { Box } from "./Layout.tsx";
6import { Text } from "./Typography.tsx";
7
8export interface ProgressBarProps {
9 /** Completion ratio. Values outside 0..1 are clamped. */
10 readonly value: number;
11 /** Width in terminal cells (default: 24). */
12 readonly width?: number;
13 readonly showPercent?: boolean;
14 readonly label?: ReactNode;
15 readonly detail?: ReactNode;
16 readonly variant?: StatusVariant;
17}
18
19/** Left-to-right progress using half/full Braille cells and an ASCII fallback. */
20export function ProgressBar({
21 value,
22 width = 24,
23 showPercent = true,
24 label,
25 detail,
26 variant = "success",
27}: ProgressBarProps) {
28 const { unicode } = useCliEnvironment();
29 const ratio = Math.max(0, Math.min(1, value));
30 const cells = Math.max(1, Math.floor(width));
31 const steps = Math.floor(cells * ratio * (unicode ? 2 : 1));
32 const filled = unicode ? Math.floor(steps / 2) : steps;
33 const partial = unicode && steps % 2 !== 0 ? "⡇" : "";
34 const remaining = cells - filled - (partial === "" ? 0 : 1);
35 return (
36 <Box
37 gap={1}
38 aria-role="progressbar"
39 aria-label={`${Math.round(ratio * 100)}%`}
40 aria-state={{ busy: ratio < 1 }}
41 >
42 <Text>
43 <Text tone="muted">[</Text>
44 <Text color={statusPaint(variant)}>
45 {(unicode ? "⣿" : "#").repeat(filled)}
46 {partial}
47 </Text>
48 <Text tone="muted">{(unicode ? " " : ".").repeat(remaining)}]</Text>
49 </Text>
50 {showPercent ? (
51 <Text tone="muted">{`${Math.round(ratio * 100)}%`.padStart(4)}</Text>
52 ) : null}
53 {label === undefined ? null : <Text>{label}</Text>}
54 {detail === undefined ? null : <Text tone="muted">{detail}</Text>}
55 </Box>
56 );
57}