Timeline Scrubber

Curseur de plage pour parcourir la lecture des etats du canvas, avec reperes de jalons optionnels. Installez Timeline Scrubber depuis le registre VLLNT UI avec la CLI shadcn.

Signaler un bug

Curseur de plage pour parcourir la lecture des etats du canvas, avec reperes de jalons optionnels. Fait partie de la famille form dans VLLNT UI, il est publie comme entree de registre lisible par machine — copiez la source directement dans votre application avec la CLI shadcn et possedez-la, sans dependance d'execution a une bibliotheque de composants.

Apercu

Basculez entre clair et sombre pour inspecter l'apercu Storybook integre.

Installation

Ajoutez Timeline Scrubber a votre projet avec la CLI shadcn. La source arrive dans votre code, prete a etre adaptee :

pnpm dlx shadcn@latest add https://ui.vllnt.ai/r/timeline-scrubber.json

Source

"use client"; import { type ChangeEvent, type ComponentPropsWithoutRef, type ReactNode, useId, } from "react"; import { cn } from "../../lib/utils"; /** * One milestone tick rendered along the scrubber track. * * @public */ export type TimelineTick = { /** Stable identifier — used as the React key + analytics hook. */ id: string; /** Optional accessible label (e.g. `"deploy"`, `"alert"`). */ label?: ReactNode; /** Optional tone for the tick. Defaults to `"neutral"`. */ tone?: TimelineScrubberTone; /** Time value of the tick. */ value: number; }; /** * Tone of the scrubber's filled track + handle. * * @public */ export type TimelineScrubberTone = | "danger" | "neutral" | "primary" | "success" | "warn"; const TONE_FILL: Record<TimelineScrubberTone, string> = { danger: "bg-red-500", neutral: "bg-foreground", primary: "bg-blue-500", success: "bg-emerald-500", warn: "bg-amber-500", }; /** * Localizable strings. * * @public */ export type TimelineScrubberLabels = { /** Aria-label for the slider. Defaults to `"Timeline scrubber"`. */ region?: string; }; const DEFAULT_LABELS = { region: "Timeline scrubber", } as const satisfies Required<TimelineScrubberLabels>; /** * Props for {@link TimelineScrubber}. * * @public */ export type TimelineScrubberProps = { /** End of the time range. Must be `> start`. */ end: number; /** Optional formatter for the cursor + endpoint labels. Receives the raw value. */ formatValue?: (value: number) => ReactNode; /** Localizable strings. */ labels?: TimelineScrubberLabels; /** Change handler — receives the new clamped value. */ onValueChange: (value: number) => void; /** Start of the time range. */ start: number; /** Step granularity for the underlying range input. Defaults to `1`. */ step?: number; /** Optional milestone ticks rendered along the track. */ ticks?: TimelineTick[]; /** Tone of the filled track + handle. Defaults to `"primary"`. */ tone?: TimelineScrubberTone; /** Current scrub value `start..end`. */ value: number; } & Omit<ComponentPropsWithoutRef<"div">, "onChange">; const clamp = (v: number, min: number, max: number): number => { if (v < min) { return min; } if (v > max) { return max; } return v; }; type LabelsRow = { clamped: number; end: number; formatValue?: (value: number) => ReactNode; start: number; }; const Labels = (props: LabelsRow): React.ReactElement => { const fmt = props.formatValue; return ( <div className="flex items-baseline justify-between gap-2"> <span data-timeline-scrubber-start> {fmt ? fmt(props.start) : props.start} </span> <span className="font-semibold text-foreground" data-timeline-scrubber-cursor > {fmt ? fmt(props.clamped) : props.clamped} </span> <span data-timeline-scrubber-end>{fmt ? fmt(props.end) : props.end}</span> </div> ); }; type TrackInput = { ariaLabel: string; inputId: string; max: number; min: number; onChange: (event: ChangeEvent<HTMLInputElement>) => void; ratio: number; scrubberId: string; span: number; start: number; step: number; ticks?: TimelineTick[]; tone: TimelineScrubberTone; value: number; }; const Track = (props: TrackInput): React.ReactElement => ( <div className="relative h-6"> <span aria-hidden="true" className="absolute left-0 right-0 top-1/2 h-1 -translate-y-1/2 rounded-full bg-muted" /> <span aria-hidden="true" className={cn( "absolute left-0 top-1/2 h-1 -translate-y-1/2 rounded-full", TONE_FILL[props.tone], )} data-timeline-scrubber-fill style={{ width: `${props.ratio * 100}%` }} /> {props.ticks?.map((tick) => ( <TickMark key={tick.id} scrubberId={props.scrubberId} span={props.span} start={props.start} tick={tick} /> ))} <input aria-label={props.ariaLabel} className="absolute inset-0 h-full w-full cursor-pointer appearance-none bg-transparent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" data-timeline-scrubber-input id={props.inputId} max={props.max} min={props.min} onChange={props.onChange} step={props.step} type="range" value={props.value} /> </div> ); const TickMark = (props: { scrubberId: string; span: number; start: number; tick: TimelineTick; }): React.ReactElement => { const { scrubberId, span, start, tick } = props; const ratio = clamp((tick.value - start) / span, 0, 1); const tone = tick.tone ?? "neutral"; return ( <span aria-hidden="true" className={cn( "absolute top-1/2 h-2.5 w-px -translate-y-1/2 rounded-full", TONE_FILL[tone], )} data-scrubber-tick={tick.id} data-scrubber-tick-of={scrubberId} data-scrubber-tick-tone={tone} style={{ left: `${ratio * 100}%` }} title={typeof tick.label === "string" ? tick.label : undefined} /> ); }; /** * Range slider for scrubbing through canvas state playback. Renders a * thin track with optional milestone ticks plus the current value * cursor; the underlying `<input type="range">` keeps keyboard + * pointer + screen-reader semantics for free. * * Pure presentation; the host owns the value + drives playback in its * own loop. Pair with {@link "../playback-ghost/playback-ghost".PlaybackGhost} to fade the canvas * back to historical state as the user scrubs. * * @example * ```tsx * <TimelineScrubber * start={0} end={3600} * value={cursor} * onValueChange={setCursor} * ticks={milestones} * formatValue={(v) => formatDuration(v)} * /> * ``` * * @public */ export const TimelineScrubber = ({ ref, ...props }: TimelineScrubberProps & { ref?: React.Ref<HTMLDivElement> }) => { const { className, end, formatValue, labels, onValueChange, start, step = 1, ticks, tone = "primary", value, ...rest } = props; const resolvedLabels = { ...DEFAULT_LABELS, ...labels }; const inputId = useId(); const safeEnd = end <= start ? start + 1 : end; const span = safeEnd - start; const clamped = clamp(value, start, safeEnd); const ratio = clamp((clamped - start) / span, 0, 1); const handleScrubberValueChange = ( event: ChangeEvent<HTMLInputElement>, ): void => { onValueChange(clamp(Number(event.target.value), start, safeEnd)); }; return ( <div aria-label={resolvedLabels.region} className={cn( "flex w-full flex-col gap-1 text-[11px] text-muted-foreground", className, )} data-timeline-scrubber data-timeline-tone={tone} ref={ref} role="group" {...rest} > <Labels clamped={clamped} end={safeEnd} formatValue={formatValue} start={start} /> <Track ariaLabel={resolvedLabels.region} inputId={inputId} max={safeEnd} min={start} onChange={handleScrubberValueChange} ratio={ratio} scrubberId={inputId} span={span} start={start} step={step} ticks={ticks} tone={tone} value={clamped} /> </div> ); }; TimelineScrubber.displayName = "TimelineScrubber";

Stories

Explorez chaque variante et etat dans le Storybook interactif :

Apercu

Basculez entre clair et sombre pour inspecter l'apercu Storybook integre.

Dependances

  • @vllnt/ui@^0.3.0