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>
);
}
|