// ~/src/siteplane/components/progress/progress.tsx "use client"; import { Progress as ProgressPrimitive } from "@base-ui/react/progress"; import { cva, type VariantProps } from "class-variance-authority"; import * as React from "react"; import { type SiteplaneControlSize, siteplaneUIDefaults, useResolvedControlSize, } from "../../system/provider/siteplane-ui-provider"; import { linearIndicatorClassNames, linearIndicatorSizeClassNames, } from "../../system/styles/surface"; import { cn, type WithStringClassName } from "../../system/utils/cn"; import { slotClass } from "../../system/utils/slot-class"; import "./progress.css"; const PROGRESS_DEFAULT_LOCALE = "en-US"; // Progress interpolates incoming values evenly (linear, 380 ms, the kit's // linger token) so a stream of updates never brakes at every step; the // transition is progress.css's, and so is the moving fill of an indeterminate // bar (`value={null}`). Meter keeps the shared indicator transition. const progressIndicatorMotionClassName = "min-w-0"; const progressRootVariants = cva(linearIndicatorClassNames.root, { defaultVariants: { size: siteplaneUIDefaults.controlSize }, variants: { size: linearIndicatorSizeClassNames.root }, }); const progressLabelVariants = cva(linearIndicatorClassNames.label, { defaultVariants: { size: siteplaneUIDefaults.controlSize }, variants: { size: linearIndicatorSizeClassNames.label }, }); const progressTrackVariants = cva(linearIndicatorClassNames.track, { defaultVariants: { size: siteplaneUIDefaults.controlSize }, variants: { size: linearIndicatorSizeClassNames.track }, }); const progressValueVariants = cva(linearIndicatorClassNames.value, { defaultVariants: { size: siteplaneUIDefaults.controlSize }, variants: { size: linearIndicatorSizeClassNames.value }, }); const progressCircleVariants = cva("relative inline-flex shrink-0", { defaultVariants: { size: siteplaneUIDefaults.controlSize }, variants: { size: { md: "size-7", lg: "size-8", sm: "size-5", xl: "size-10" }, }, }); const ProgressSizeContext = React.createContext( null, ); export interface ProgressProps extends Omit, "size">, VariantProps {} export function Progress({ className, children, locale = PROGRESS_DEFAULT_LOCALE, size, ...props }: ProgressProps): React.ReactElement { const resolvedSize = useResolvedControlSize(size); return ( {children ? ( children ) : ( )} ); } export interface ProgressLabelProps extends Omit, "size">, VariantProps {} export function ProgressLabel({ className, size, ...props }: ProgressLabelProps): React.ReactElement { const groupSize = React.useContext(ProgressSizeContext); const resolvedSize = useResolvedControlSize(size ?? groupSize); return ( ); } export interface ProgressTrackProps extends Omit, "size">, VariantProps {} export function ProgressTrack({ className, size, ...props }: ProgressTrackProps): React.ReactElement { const groupSize = React.useContext(ProgressSizeContext); const resolvedSize = useResolvedControlSize(size ?? groupSize); return ( ); } export function ProgressIndicator({ className, ...props }: WithStringClassName): React.ReactElement { return ( ); } export interface ProgressValueProps extends Omit, "size">, VariantProps {} export function ProgressValue({ className, size, ...props }: ProgressValueProps): React.ReactElement { const groupSize = React.useContext(ProgressSizeContext); const resolvedSize = useResolvedControlSize(size ?? groupSize); return ( ); } export interface ProgressCircleProps extends Omit< WithStringClassName, "children" | "size" | "value" >, VariantProps { /** * The completion between `min` and `max`. `null` shows an indeterminate * circle, an arc that travels around it, while completion is unknown. */ value: number | null; } export function ProgressCircle({ className, locale = PROGRESS_DEFAULT_LOCALE, max = 100, min = 0, size, value, ...props }: ProgressCircleProps): React.ReactElement { const resolvedSize = useResolvedControlSize(size); const range = max - min; const numericValue = value === null ? null : Number.isFinite(value) ? value : min; const percentage = numericValue === null || range <= 0 ? 0 : Math.min(100, Math.max(0, ((numericValue - min) / range) * 100)); return ( ); } export { ProgressPrimitive }; // ~/src/siteplane/components/progress/progress.css @layer theme, base, components, siteplane, utilities; @layer siteplane { /* The value transitions: the bar moves linearly over the linger time, the * circle's arc over the slow time. Zero specificity, so the reduced-motion * rules below and a consumer's transition utility both win. */ :where(.sp-progress-indicator) { transition-property: width; transition-timing-function: var(--motion-ease-linear); transition-duration: var(--motion-duration-linger); } :where(.sp-progress-circle circle:last-child) { transition-property: stroke-dashoffset; transition-timing-function: var(--motion-ease-linear); transition-duration: var(--motion-duration-slow); } /* Indeterminate progress (`value={null}`): the bar fills from its start * again and again, and an arc of a quarter circle travels around the circle. * Base UI marks every part data-indeterminate. The continuous-fill class * draws the same fill on an indicator of its own. */ :where(.sp-progress-indicator[data-indeterminate]), :where(.sp-progress-continuous-fill) { height: 100%; animation: sp-progress-continuous-fill calc(var(--motion-duration-linger) * 8) var(--motion-ease-linear) infinite; transform: scaleX(0); transform-origin: left center; will-change: transform; } /* A right-to-left page: the dir attribute is named beside :dir(rtl), which a * build for older browsers turns into a list of right-to-left languages, as * Tailwind's rtl variant does. */ :where( .sp-progress-indicator[data-indeterminate]:is( :dir(rtl), [dir="rtl"], [dir="rtl"] * ) ), :where( .sp-progress-continuous-fill:is(:dir(rtl), [dir="rtl"], [dir="rtl"] *) ) { transform-origin: right center; } @keyframes sp-progress-continuous-fill { from { transform: scaleX(0); } to { transform: scaleX(1); } } :where(.sp-progress-circle[data-indeterminate] circle:last-child) { stroke-dasharray: 25 75; animation: sp-progress-circle-indeterminate calc(var(--motion-duration-loop) * 2) var(--motion-ease-linear) infinite; } @keyframes sp-progress-circle-indeterminate { from { stroke-dashoffset: 0; } to { stroke-dashoffset: -100; } } /* Reduced motion moves the fill and the arc to each new value at once and * holds the indeterminate state still (M5): the bar keeps a segment in its * middle and the circle an arc centred on its top, so neither reads as a * value or as done. The document mode, the provider preset and the system * setting each switch it. */ html[data-motion-reduce="true"] .sp-progress-indicator, html[data-motion-reduce="true"] .sp-progress-circle circle, [data-sp-motion="reduced"] .sp-progress-indicator, [data-sp-motion="reduced"] .sp-progress-circle circle { transition: none; } html[data-motion-reduce="true"] .sp-progress-indicator[data-indeterminate], html[data-motion-reduce="true"] .sp-progress-continuous-fill, [data-sp-motion="reduced"] .sp-progress-indicator[data-indeterminate], [data-sp-motion="reduced"] .sp-progress-continuous-fill { animation: none; transform: none; clip-path: inset(0 30% round 9999px); } html[data-motion-reduce="true"] .sp-progress-circle[data-indeterminate] circle:last-child, [data-sp-motion="reduced"] .sp-progress-circle[data-indeterminate] circle:last-child { animation: none; stroke-dashoffset: -87.5; } @media (prefers-reduced-motion: reduce) { :root .sp-progress-indicator, :root .sp-progress-circle circle { transition: none; } :root .sp-progress-indicator[data-indeterminate], :root .sp-progress-continuous-fill { animation: none; transform: none; clip-path: inset(0 30% round 9999px); } :root .sp-progress-circle[data-indeterminate] circle:last-child { animation: none; stroke-dashoffset: -87.5; } } } // ~/src/siteplane/components/progress/index.ts export * from "./progress";