All files / src/components/ui progress.tsx

100% Statements 9/9
96.42% Branches 27/28
100% Functions 2/2
100% Lines 8/8

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128                                                                                    2x                                         26x 20x                       17x 17x 17x 17x         17x                                                                                      
import { useId } from "react";
import { cn } from "../../lib/utils";
 
/**
 * Progress — the bar printed across the foot of a thumb (`.progress`,
 * mockups §7.8), and the same bar used on its own for a longer job.
 *
 * A hairline-thin rule that fills with laminate teal. No gradient, no stripe
 * animation, no rounded capsule: a printed bar, 2px radius, filling left to
 * right.
 *
 * ACCESSIBILITY CONTRACT
 *   - `role="progressbar"` with `aria-valuemin` / `aria-valuemax` and
 *     `aria-valuenow` for a determinate bar
 *   - `aria-valuetext` carries the percentage, because "41 of 100" is not what
 *     a user wants read out
 *   - `indeterminate` omits `aria-valuenow` and `aria-valuetext` entirely,
 *     which is exactly how a screen reader is told "running, length unknown";
 *     a `aria-valuenow="0"` would claim it has not started
 *   - the bar is named by its visible `label`, or by `label` as an
 *     `aria-label` when `showLabel` is false. A progressbar with no name is
 *     "progress bar, 41%" of nothing
 *   - `value` is clamped into range, so a caller that computes 103% from a
 *     byte count cannot emit an out-of-range ARIA value
 *
 * CONTRAST
 * The fill is `--primary-600` on a `--background-muted` track: 5.47:1 light and
 * 6.05:1 dark — both above the 3:1 WCAG 1.4.11 floor for a non-text indicator.
 *
 * `primary-600` rather than the mockup's `--action` for the reason spelled out
 * in `avatar.tsx`: the legacy palette override themed one and not the other.
 * Both are the same laminate teal now that the override is gone.
 */
 
type ProgressSize = "sm" | "md";
 
/**
 * The mockup's bar is 3px, which is not a spacing token. `h-0.5` (2px) and
 * `h-1` (4px) are, and the design system's rule is tokens over pixel
 * fidelity — so the thumb-foot bar is the 2px one and a standalone bar the 4px
 * one.
 */
const sizeClasses: Record<ProgressSize, string> = {
	sm: "h-0.5",
	md: "h-1",
};
 
interface ProgressProps {
	/** Current value, clamped to 0…max. Ignored when indeterminate. */
	value?: number;
	max?: number;
	/** What is making progress. Required: a progressbar needs a name. */
	label: string;
	/** Print the label (and the percentage) above the bar. */
	showLabel?: boolean;
	/** Running, with no known length. */
	indeterminate?: boolean;
	size?: ProgressSize;
	className?: string;
}
 
/** Percent complete, rounded, clamped — never NaN, never out of range. */
export function progressPercent(value: number, max: number): number {
	if (!Number.isFinite(value) || !Number.isFinite(max) || max <= 0) return 0;
	return Math.round((Math.min(Math.max(value, 0), max) / max) * 100);
}
 
export function Progress({
	value = 0,
	max = 100,
	label,
	showLabel = false,
	indeterminate = false,
	size = "md",
	className,
}: ProgressProps) {
	const labelId = useId();
	const percent = progressPercent(value, max);
	const safeMax = Number.isFinite(max) && max > 0 ? max : 100;
	const clamped = Math.min(
		Math.max(Number.isFinite(value) ? value : 0, 0),
		safeMax,
	);
 
	return (
		<div className={cn("w-full", className)}>
			{showLabel && (
				<div className="mb-1 flex items-baseline justify-between gap-2">
					<span
						id={labelId}
						className="text-xs font-semibold uppercase tracking-wider text-foreground-muted"
					>
						{label}
					</span>
					{!indeterminate && (
						<span className="text-xs tabular-nums text-foreground-muted">
							{percent}%
						</span>
					)}
				</div>
			)}
			<div
				role="progressbar"
				aria-valuemin={0}
				aria-valuemax={safeMax}
				{...(indeterminate
					? {}
					: { "aria-valuenow": clamped, "aria-valuetext": `${percent}%` })}
				{...(showLabel
					? { "aria-labelledby": labelId }
					: { "aria-label": label })}
				className={cn(
					"w-full overflow-hidden rounded-sm bg-background-muted",
					sizeClasses[size],
				)}
			>
				<div
					className={cn(
						"h-full bg-primary-600 transition-[width] duration-200 ease-out",
						indeterminate && "w-1/3 animate-pulse",
					)}
					{...(indeterminate ? {} : { style: { width: `${percent}%` } })}
				/>
			</div>
		</div>
	);
}