import { useUniqueId } from "@charcuterie/logic" import type { IntentName } from "@charcuterie/tokens" import type { ComponentPropsWithRef, ReactNode, } from "react" import { INTENT_SOLID_FILL_CLASS } from "../intentStyles.ts" import { toClassName } from "../toClassName.ts" import { VisuallyHidden } from "../VisuallyHidden/VisuallyHidden.tsx" import type { ProgressThreshold } from "./progressValue.ts" import { getProgressIntent, toProgressPercent, toProgressValue, } from "./progressValue.ts" export type ProgressBarSize = "lg" | "md" | "sm" export type ProgressBarProps = Omit< ComponentPropsWithRef<"div">, "children" > & { intent?: IntentName /** * Indeterminate means "working, no measurable progress yet" — not * zero and not unknown-therefore-full. rip-deck's is the * AACS/BD+ preamble: ~25 s of a real Blu-ray emitting nothing, in * which a full bar reads as a finished rip and an empty one as a * wedged drive. */ isIndeterminate?: boolean isLabelVisible?: boolean isValueShown?: boolean /** Required. A progressbar with no name is a grey rectangle. */ label: string max?: number /** Content above a full-height, muted track. It stays outside the ARIA widget. */ overlay?: ReactNode size?: ProgressBarSize thresholds?: readonly ProgressThreshold[] value?: number } const TRACK_SIZE_CLASS: Record = { sm: "h-1", md: "h-2", lg: "h-3", } /** * The union of what the fleet has: rip-deck's ARIA and its * indeterminate sweep, plus threshold colours nobody has yet. * * Five repos hand-roll a progress bar and **only rip-deck's has * `role="progressbar"`** — the other four are a `
` with a * percentage width, invisible to assistive technology and to * `getByRole("progressbar")`. * * Three corrections to rip-deck's version, which is otherwise the * best in the fleet: * * - **The role goes on the track, not the fill.** rip-deck puts * `role="progressbar"` on the inner filled element, so the * widget's box is the *filled portion* — a 4% rip is a * progressbar 4px wide as far as AT and a Playwright bounding * box are concerned. * - **The name comes from a real label**, wired with * `aria-labelledby` when it is visible and `VisuallyHidden` when * it is not, rather than a hardcoded `aria-label` string that * cannot be translated or reused. * - **`aria-valuenow` is omitted while indeterminate**, which is * what tells AT "unknown" rather than "zero". rip-deck gets this * right by rendering a separate element; stating it on one * element is the same answer without the duplication. */ export const ProgressBar = ({ className, intent = "accent", isIndeterminate = false, isLabelVisible = false, isValueShown = false, label, max = 100, overlay, size = "md", thresholds, value = 0, ...divProps }: ProgressBarProps): ReactNode => { const labelId = useUniqueId() const percent = toProgressPercent(value, max) const clampedValue = toProgressValue(value, max) const fillIntent = getProgressIntent( percent, intent, thresholds, ) // The grid wrapper exists only for an overlay. Without one the // track is a direct child again, as it was before `overlay` — // apps size `[role="progressbar"]` against its parent, and an // always-present wrapper with no height collapsed their fill. const track = (
{isIndeterminate ? ( ) : ( )}
) return (
{isLabelVisible || isValueShown ? (
{isLabelVisible ? ( {label} ) : ( {label} )} {isValueShown && !isIndeterminate ? ( {percent}% ) : null}
) : ( {label} )} {overlay == null ? ( track ) : (
{track}
{overlay}
)}
) }