Time Range Picker
A time range whose value is an expression, so it stays relative across a reload.
value = now-6h..now
Installation
pnpm dlx @dowel-ui/cli add time-range-pickerInstalls button, calendar, input and popover as well, because this component imports them.
npm packages installed: react-day-picker.
Accessibility
The trigger is named by the range it holds — "Last 6 hours" — and the window that resolves to is read after it, so the relative choice is not replaced by two timestamps. The popover carries role="dialog" and is named. Presets are buttons rather than radios because choosing one both selects and dismisses, with aria-pressed carrying which matches the current expression. The expression field reports why an unparseable entry is invalid through one aria-describedby element that the preview and the error share, so a reader hears the error replace the preview rather than both at once; an invalid expression is never applied, because a chart silently re-scoping itself is worse than one that refuses.
Props
TimeRange
| Prop | Type | Default |
|---|---|---|
children (required) | ReactNode | — |
defaultValue | string | "now-6h..now" |
locale | string | — |
nowThe instant relative expressions are measured from. Supplied by the consumer so nothing here reads the clock during render. Pass a value that changes when you refresh; a stable one holds the window still between them. | Date | — |
onOpenChange | (open: boolean) => void | — |
onValueChange | (expression: string) => void | — |
open | boolean | — |
presets | TimeRangePreset[] | DEFAULT_PRESETS |
timeZoneIANA zone the snapping happens in. Defaults to the runtime zone. | string | — |
valueThe expression, e.g. | string | — |
TimeRangeTrigger
| Prop | Type | Default |
|---|---|---|
showResolvedShows the resolved window under the label. Off by default; it is long. | boolean | false |
Plus every attribute of <button>.
TimeRangeContent
| Prop | Type | Default |
|---|---|---|
labelNames the dialog. | string | "Choose a time range" |
TimeRangePresets
Plus every attribute of <div> except children.
TimeRangeExpression
Plus every attribute of <div> except children.
TimeRangeCalendar
| Prop | Type | Default |
|---|---|---|
numberOfMonths | number | 1 |
Plus every attribute of <div> except children.
Quality
10/10 checks, measured from the source and its tests
- Tested — passes
- axe assertion — passes
- Keyboard tested — passes
- Storybook examples — passes
- Accessibility documented — passes
- Semantic tokens only — passes
- Motion from tokens — passes
- className merged — passes
- Visible focus — passes
- No fixed widths — passes
Source
This is exactly what dowel add time-range-picker writes into your project, with imports rewritten to your own path alias.
/**
* Time ranges as expressions, not as two frozen timestamps.
*
* `now-6h` is still the last six hours tomorrow; a resolved pair is six hours
* of last Tuesday forever. Storing the expression is what lets a dashboard URL
* survive being bookmarked and reloaded.
*
* The grammar is a deliberate subset of the one Grafana, Datadog and Kibana
* converged on, so the strings are already familiar:
*
* range := expr ".." expr
* expr := "now" offset* snap? | ISO-8601
* offset := ("-" | "+") digits unit
* snap := "/" unit
* unit := s | m | h | d | w | M | y
*
* now-6h..now the last six hours
* now-6h/h..now the last six hours, starting on the hour
* now/d..now/d today, all of it
* now-1d/d..now-1d/d yesterday
* 2026-01-01..now since a fixed instant
*
* Everything here is pure and takes `now` as an argument: reading the clock
* during render would disagree between the server and the browser.
*/
export type TimeUnit = "s" | "m" | "h" | "d" | "w" | "M" | "y";
export interface ResolveOptions {
/** The instant `now` refers to. Required, so nothing here reads the clock. */
now: Date;
/**
* IANA zone the snapping happens in — "start of day" is a different instant
* in Auckland than in Denver. Defaults to the runtime zone.
*/
timeZone?: string;
}
export interface ResolvedRange {
from: Date;
to: Date;
}
/** Why an expression could not be resolved. Shown to the user verbatim. */
export class TimeExpressionError extends Error {
constructor(
message: string,
/** The part of the input at fault, for pointing at it. */
readonly input: string,
) {
super(message);
this.name = "TimeExpressionError";
}
}
const UNIT_NAMES: Record<TimeUnit, string> = {
s: "second",
m: "minute",
h: "hour",
d: "day",
w: "week",
M: "month",
y: "year",
};
const UNITS = new Set(Object.keys(UNIT_NAMES) as TimeUnit[]);
/* ------------------------------------------------------------------ */
/* Zone arithmetic */
/* ------------------------------------------------------------------ */
interface Parts {
year: number;
month: number;
day: number;
hour: number;
minute: number;
second: number;
ms: number;
}
const PART_FORMATTERS = new Map<string, Intl.DateTimeFormat>();
function formatterFor(timeZone: string): Intl.DateTimeFormat {
let formatter = PART_FORMATTERS.get(timeZone);
if (!formatter) {
formatter = new Intl.DateTimeFormat("en-US", {
timeZone,
hour12: false,
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
});
PART_FORMATTERS.set(timeZone, formatter);
}
return formatter;
}
/** Wall-clock fields of an instant, as read in `timeZone`. */
function toParts(instant: Date, timeZone?: string): Parts {
if (!timeZone) {
return {
year: instant.getFullYear(),
month: instant.getMonth() + 1,
day: instant.getDate(),
hour: instant.getHours(),
minute: instant.getMinutes(),
second: instant.getSeconds(),
ms: instant.getMilliseconds(),
};
}
const found: Record<string, number> = {};
for (const part of formatterFor(timeZone).formatToParts(instant)) {
if (part.type !== "literal") found[part.type] = Number(part.value);
}
return {
year: found.year ?? 0,
month: found.month ?? 1,
day: found.day ?? 1,
// Midnight formats as hour 24 rather than 0 in some engines.
hour: (found.hour ?? 0) % 24,
minute: found.minute ?? 0,
second: found.second ?? 0,
ms: instant.getMilliseconds(),
};
}
function asUtc(parts: Parts): number {
return Date.UTC(
parts.year,
parts.month - 1,
parts.day,
parts.hour,
parts.minute,
parts.second,
parts.ms,
);
}
/**
* The instant at which `timeZone` reads these wall-clock fields.
*
* Two passes: guess the fields as UTC, measure the offset there, correct, and
* measure again in case the correction crossed a transition. A wall-clock time
* at a DST boundary can be ambiguous or absent; this picks one rather than
* throwing — an hour's difference once a year is a fair trade for not shipping
* a timezone database.
*/
function fromParts(parts: Parts, timeZone?: string): Date {
if (!timeZone) {
return new Date(
parts.year,
parts.month - 1,
parts.day,
parts.hour,
parts.minute,
parts.second,
parts.ms,
);
}
const wall = asUtc(parts);
let instant = new Date(wall);
for (let pass = 0; pass < 2; pass += 1) {
const offset = asUtc(toParts(instant, timeZone)) - instant.getTime();
const next = new Date(wall - offset);
if (next.getTime() === instant.getTime()) break;
instant = next;
}
return instant;
}
/* ------------------------------------------------------------------ */
/* Offsets and snapping */
/* ------------------------------------------------------------------ */
/** `month` is 1-based, matching `Parts`. */
function daysInMonth(year: number, month: number): number {
return new Date(Date.UTC(year, month, 0)).getUTCDate();
}
const FIXED_MS: Partial<Record<TimeUnit, number>> = {
s: 1000,
m: 60_000,
h: 3_600_000,
};
/**
* Applies `±N unit` to an instant.
*
* Seconds, minutes and hours are fixed durations. Days, weeks, months and years
* are calendar arithmetic: `now-1d` means the same wall-clock time yesterday,
* which on the day a zone changes offset is 23 or 25 hours, not 24. And
* `now-1M` from the 31st lands on the last day of a shorter month rather than
* overflowing into the next one.
*/
function applyOffset(instant: Date, amount: number, unit: TimeUnit, timeZone?: string): Date {
const fixed = FIXED_MS[unit];
if (fixed !== undefined) return new Date(instant.getTime() + amount * fixed);
const parts = { ...toParts(instant, timeZone) };
if (unit === "d") {
parts.day += amount;
} else if (unit === "w") {
parts.day += amount * 7;
} else {
if (unit === "y") {
parts.year += amount;
} else {
const target = parts.month - 1 + amount;
parts.year += Math.floor(target / 12);
parts.month = (((target % 12) + 12) % 12) + 1;
}
// Clamp instead of overflowing: 31 January minus one month is the end of
// February, not the third of March, and a year back from 29 February is
// the 28th rather than 1 March.
parts.day = Math.min(parts.day, daysInMonth(parts.year, parts.month));
}
return fromParts(parts, timeZone);
}
/**
* Rounds an instant to a unit boundary, `edge` deciding which one.
*
* The detail that catches every reimplementation: `now/d..now/d` means *all of
* today*, so the start floors to midnight and the end climbs to that day's last
* millisecond. Flooring both yields a zero-length range and an empty chart.
*/
function applySnap(
instant: Date,
unit: TimeUnit,
edge: "start" | "end",
timeZone?: string,
): Date {
const parts = { ...toParts(instant, timeZone) };
parts.ms = 0;
if (unit !== "s") parts.second = 0;
if (unit !== "s" && unit !== "m") parts.minute = 0;
if (unit === "d" || unit === "w" || unit === "M" || unit === "y") parts.hour = 0;
if (unit === "M" || unit === "y") parts.day = 1;
if (unit === "y") parts.month = 1;
if (unit === "w") {
// ISO weeks: Monday starts the week. Locale-varying week starts are a
// separate decision, and one an app should make once rather than per range.
//
// The weekday is read off the calendar date itself, not off the instant —
// asking a `Date` built from local fields for `getDay()` would answer for
// the runtime's zone rather than the one being snapped in.
const weekday = new Date(Date.UTC(parts.year, parts.month - 1, parts.day)).getUTCDay();
parts.day -= (weekday + 6) % 7;
}
const start = fromParts(parts, timeZone);
if (edge === "start") return start;
// The end of the unit is one millisecond before the next one begins, which
// keeps `to` inclusive without an off-by-one at every boundary.
const nextStart = applyOffset(start, 1, unit, timeZone);
return new Date(nextStart.getTime() - 1);
}
/* ------------------------------------------------------------------ */
/* Parsing */
/* ------------------------------------------------------------------ */
const ISO =
/^\d{4}-\d{2}-\d{2}(?:[T ]\d{2}:\d{2}(?::\d{2}(?:\.\d{1,3})?)?(?:Z|[+-]\d{2}:?\d{2})?)?$/;
const DATE_ONLY = /^(\d{4})-(\d{2})-(\d{2})$/;
const CALENDAR_DATE = /^(\d{4})-(\d{2})-(\d{2})/;
const OFFSET = /^([+-])(\d+)([smhdwMy])/;
/** Resolves a single side of a range. Exported for the input's live feedback. */
export function resolveExpression(
expression: string,
edge: "start" | "end",
options: ResolveOptions,
): Date {
const input = expression.trim();
if (input === "") throw new TimeExpressionError("Enter a time.", input);
if (ISO.test(input)) {
// `new Date` rolls an impossible day forward rather than refusing it —
// 30 February parses happily as 2 March — so the calendar date is checked
// against the month before the string is trusted.
const calendar = CALENDAR_DATE.exec(input);
if (
calendar &&
(Number(calendar[2]) < 1 ||
Number(calendar[2]) > 12 ||
Number(calendar[3]) < 1 ||
Number(calendar[3]) > daysInMonth(Number(calendar[1]), Number(calendar[2])))
) {
throw new TimeExpressionError(`${input} is not a real date.`, input);
}
const dateOnly = DATE_ONLY.exec(input);
if (dateOnly) {
// A bare date means a day in the reader's zone, not midnight UTC, and on
// the closing side it means the whole of that day. `2026-01-01..2026-01-31`
// that stops at the 31st's first millisecond silently drops a day of data.
const parts: Parts = {
year: Number(dateOnly[1]),
month: Number(dateOnly[2]),
day: Number(dateOnly[3]),
hour: 0,
minute: 0,
second: 0,
ms: 0,
};
const start = fromParts(parts, options.timeZone);
if (Number.isNaN(start.getTime())) {
throw new TimeExpressionError(`${input} is not a real date.`, input);
}
return edge === "start" ? start : applySnap(start, "d", "end", options.timeZone);
}
const parsed = new Date(input);
if (Number.isNaN(parsed.getTime())) {
throw new TimeExpressionError(`${input} is not a real date.`, input);
}
return parsed;
}
if (!input.startsWith("now")) {
throw new TimeExpressionError(
`Expected "now" or a date like 2026-01-31, not "${input}".`,
input,
);
}
let rest = input.slice(3);
let instant = options.now;
while (rest.length > 0 && rest[0] !== "/") {
const match = OFFSET.exec(rest);
if (!match) {
throw new TimeExpressionError(`Expected an offset like -6h, not "${rest}".`, rest);
}
const [whole, sign, digits, unit] = match;
const amount = Number(digits) * (sign === "-" ? -1 : 1);
instant = applyOffset(instant, amount, unit as TimeUnit, options.timeZone);
rest = rest.slice(whole.length);
}
if (rest.startsWith("/")) {
const unit = rest.slice(1);
if (!UNITS.has(unit as TimeUnit)) {
throw new TimeExpressionError(
`"${unit || "/"}" is not a unit. Use s, m, h, d, w, M or y.`,
rest,
);
}
instant = applySnap(instant, unit as TimeUnit, edge, options.timeZone);
}
return instant;
}
/**
* Turns a range expression into the two instants a query needs.
*
* Throws `TimeExpressionError` rather than returning a fallback range: a chart
* silently showing the wrong window is worse than one that says it cannot.
*/
export function resolveTimeRange(expression: string, options: ResolveOptions): ResolvedRange {
const separator = expression.indexOf("..");
if (separator === -1) {
throw new TimeExpressionError(
`Expected two times separated by "..", as in now-6h..now.`,
expression,
);
}
const from = resolveExpression(expression.slice(0, separator), "start", options);
const to = resolveExpression(expression.slice(separator + 2), "end", options);
if (from.getTime() > to.getTime()) {
throw new TimeExpressionError("The range ends before it starts.", expression);
}
return { from, to };
}
/** Whether an expression resolves, without the caller writing a try/catch. */
export function isValidTimeRange(expression: string, options: ResolveOptions): boolean {
try {
resolveTimeRange(expression, options);
return true;
} catch {
return false;
}
}
/* ------------------------------------------------------------------ */
/* Presets and labelling */
/* ------------------------------------------------------------------ */
export interface TimeRangePreset {
expression: string;
label: string;
/** Groups presets under a heading in the picker. */
group: string;
}
/** Relative windows first, for debugging; calendar periods after, for reporting. */
export const DEFAULT_PRESETS: TimeRangePreset[] = [
{ expression: "now-5m..now", label: "Last 5 minutes", group: "Relative" },
{ expression: "now-15m..now", label: "Last 15 minutes", group: "Relative" },
{ expression: "now-1h..now", label: "Last hour", group: "Relative" },
{ expression: "now-6h..now", label: "Last 6 hours", group: "Relative" },
{ expression: "now-24h..now", label: "Last 24 hours", group: "Relative" },
{ expression: "now-7d..now", label: "Last 7 days", group: "Relative" },
{ expression: "now-30d..now", label: "Last 30 days", group: "Relative" },
{ expression: "now/d..now/d", label: "Today", group: "Calendar" },
{ expression: "now-1d/d..now-1d/d", label: "Yesterday", group: "Calendar" },
{ expression: "now/w..now/w", label: "This week", group: "Calendar" },
{ expression: "now-1w/w..now-1w/w", label: "Last week", group: "Calendar" },
{ expression: "now/M..now/M", label: "This month", group: "Calendar" },
{ expression: "now-1M/M..now-1M/M", label: "Last month", group: "Calendar" },
];
const RELATIVE = /^now-(\d+)([smhdwMy])(?:\/[smhdwMy])?\.\.now$/;
/**
* A sentence for the trigger.
*
* An unnamed relative range is described in its own terms — "Last 90 minutes" —
* because showing it as two timestamps throws away what the user chose. Only an
* absolute range, which has no relative meaning left, is shown as dates.
*/
export function describeTimeRange(
expression: string,
options: ResolveOptions & { locale?: string; presets?: TimeRangePreset[] },
): string {
const presets = options.presets ?? DEFAULT_PRESETS;
const preset = presets.find((candidate) => candidate.expression === expression);
if (preset) return preset.label;
const relative = RELATIVE.exec(expression.trim());
if (relative) {
const amount = Number(relative[1]);
const unit = UNIT_NAMES[relative[2] as TimeUnit];
return `Last ${String(amount)} ${unit}${amount === 1 ? "" : "s"}`;
}
try {
const { from, to } = resolveTimeRange(expression, options);
const format = new Intl.DateTimeFormat(options.locale, {
dateStyle: "medium",
timeStyle: "short",
timeZone: options.timeZone,
});
return `${format.format(from)} – ${format.format(to)}`;
} catch {
return expression;
}
}
/** The resolved window, spelled out. Pairs with the relative label, not replaces it. */
export function formatResolvedRange(
range: ResolvedRange,
options: { locale?: string; timeZone?: string } = {},
): string {
const format = new Intl.DateTimeFormat(options.locale, {
dateStyle: "medium",
timeStyle: "medium",
timeZone: options.timeZone,
});
return `${format.format(range.from)} – ${format.format(range.to)}`;
}
/** Builds the expression for an absolute range chosen on a calendar. */
export function absoluteExpression(from: Date, to: Date): string {
return `${toLocalIso(from)}..${toLocalIso(to)}`;
}
function toLocalIso(date: Date): string {
const pad = (value: number, width = 2) => String(value).padStart(width, "0");
return (
`${String(date.getFullYear())}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}` +
`T${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`
);
}
"use client";
import {
createContext,
useCallback,
useContext,
useId,
useMemo,
useState,
useSyncExternalStore,
type ComponentPropsWithRef,
type ReactNode,
} from "react";
import type { DateRange } from "react-day-picker";
import { Button } from "@/components/button";
import { Calendar } from "@/components/calendar";
import { Input } from "@/components/input";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/popover";
import { focusRing } from "@/lib/styles";
import { cn } from "@/lib/utils";
import {
DEFAULT_PRESETS,
TimeExpressionError,
absoluteExpression,
describeTimeRange,
formatResolvedRange,
resolveTimeRange,
type ResolvedRange,
type TimeRangePreset,
} from "./time-expression";
/**
* The control every observability product builds for itself.
*
* Grafana, Datadog, Sentry, PostHog, Vercel, Honeycomb, Cloudflare and
* Amplitude each maintain a bespoke one, and no React package ships it, because
* it looks like a date picker and is not one — see `time-expression.ts` for the
* value model that makes the difference.
*
* Composed rather than reimplemented: Popover supplies the dialog semantics,
* Calendar the absolute path, Input the expression entry.
*
* Two omissions are deliberate. No timezone picker — that is a 400-entry
* combobox and a decision an app makes once, so `timeZone` is a prop. And no
* comparison range; a second window belongs to whatever renders the chart.
*/
interface TimeRangeContextValue {
value: string;
setValue: (expression: string) => void;
now: Date;
timeZone?: string;
locale?: string;
presets: TimeRangePreset[];
resolved: ResolvedRange | null;
error: string | null;
/** False until the clock is known, which on a server render is never. */
clockReady: boolean;
close: () => void;
labelId: string;
}
/** Stands in until the clock is known. Never shown; see `clockReady`. */
const EPOCH = new Date(0);
/**
* The clock, for callers who did not supply one.
*
* `useSyncExternalStore` rather than an effect, because this is the case it
* exists for: a value the server cannot know. The server snapshot is null, so a
* server render and the hydration render both commit to nothing clock-dependent
* and cannot disagree; the real instant arrives in the re-render after.
*
* `getSnapshot` has to be referentially stable, so the instant is created once
* for the page. That means every picker without a `now` prop shares it and none
* of them advance — which is the whole reason the prop is there.
*/
const subscribeToNothing = () => () => undefined;
let sharedClock: Date | null = null;
const readSharedClock = () => (sharedClock ??= new Date());
const readNoClock = () => null;
const TimeRangeContext = createContext<TimeRangeContextValue | null>(null);
function useTimeRange(component: string): TimeRangeContextValue {
const context = useContext(TimeRangeContext);
if (!context) {
throw new Error(`${component} must be used inside <TimeRange>.`);
}
return context;
}
export interface TimeRangeProps {
/** The expression, e.g. `now-6h..now`. Controlled. */
value?: string;
defaultValue?: string;
onValueChange?: (expression: string) => void;
/**
* The instant relative expressions are measured from. Supplied by the
* consumer so nothing here reads the clock during render. Pass a value that
* changes when you refresh; a stable one holds the window still between them.
*/
now?: Date;
/** IANA zone the snapping happens in. Defaults to the runtime zone. */
timeZone?: string;
locale?: string;
presets?: TimeRangePreset[];
open?: boolean;
onOpenChange?: (open: boolean) => void;
children: ReactNode;
}
export function TimeRange({
value: valueProp,
defaultValue = "now-6h..now",
onValueChange,
now,
timeZone,
locale,
presets = DEFAULT_PRESETS,
open: openProp,
onOpenChange,
children,
}: TimeRangeProps) {
const [uncontrolledValue, setUncontrolledValue] = useState(defaultValue);
const value = valueProp ?? uncontrolledValue;
const [uncontrolledOpen, setUncontrolledOpen] = useState(false);
const open = openProp ?? uncontrolledOpen;
const labelId = useId();
// Reading the clock during render is what the `now` prop exists to avoid: the
// server would render one instant and hydration another, and React would
// throw out the tree. Until the fallback arrives, nothing clock-dependent is
// rendered — the relative label, "Last 6 hours", does not need one, which is
// why the trigger still says something useful in the meantime.
const fallbackClock = useSyncExternalStore(subscribeToNothing, readSharedClock, readNoClock);
const clock = now ?? fallbackClock;
// A placeholder only for expressions that never mention `now`; anything that
// does is gated on `clock` below rather than resolved against a fake instant.
const effectiveNow = clock ?? EPOCH;
const setOpen = useCallback(
(next: boolean) => {
if (openProp === undefined) setUncontrolledOpen(next);
onOpenChange?.(next);
},
[openProp, onOpenChange],
);
const setValue = useCallback(
(expression: string) => {
if (valueProp === undefined) setUncontrolledValue(expression);
onValueChange?.(expression);
},
[valueProp, onValueChange],
);
const { resolved, error } = useMemo(() => {
// Claiming neither a window nor an error before the clock is known: an
// expression cannot be judged against an instant nobody has read yet.
if (!clock) return { resolved: null, error: null };
try {
return { resolved: resolveTimeRange(value, { now: clock, timeZone }), error: null };
} catch (thrown) {
return {
resolved: null,
error: thrown instanceof TimeExpressionError ? thrown.message : "Invalid time range.",
};
}
}, [value, clock, timeZone]);
const context = useMemo<TimeRangeContextValue>(
() => ({
value,
setValue,
now: effectiveNow,
timeZone,
locale,
presets,
resolved,
error,
clockReady: clock !== null,
close: () => {
setOpen(false);
},
labelId,
}),
[
value,
setValue,
effectiveNow,
clock,
timeZone,
locale,
presets,
resolved,
error,
setOpen,
labelId,
],
);
return (
<TimeRangeContext.Provider value={context}>
<Popover open={open} onOpenChange={setOpen}>
{children}
</Popover>
</TimeRangeContext.Provider>
);
}
function ClockIcon() {
return (
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true" className="size-4 opacity-70">
<circle cx="12" cy="12" r="9" stroke="currentColor" strokeWidth="2" />
<path d="M12 7v5l3 2" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
</svg>
);
}
export interface TimeRangeTriggerProps extends ComponentPropsWithRef<"button"> {
/** Shows the resolved window under the label. Off by default; it is long. */
showResolved?: boolean;
}
/**
* The button, named by the range it holds: "Last 6 hours", not two timestamps.
* The resolved window stays reachable as the title, and as `showResolved`.
*/
export function TimeRangeTrigger({
className,
showResolved = false,
...props
}: TimeRangeTriggerProps) {
const { value, now, timeZone, locale, presets, resolved, error } =
useTimeRange("TimeRangeTrigger");
const label = describeTimeRange(value, { now, timeZone, locale, presets });
const resolvedText = resolved ? formatResolvedRange(resolved, { locale, timeZone }) : null;
return (
<PopoverTrigger asChild>
<Button
data-slot="time-range-trigger"
variant="outline"
aria-invalid={error ? true : undefined}
title={resolvedText ?? undefined}
className={cn("justify-start gap-2 font-normal", className)}
{...props}
>
<ClockIcon />
<span className="flex flex-col items-start leading-tight">
<span>{label}</span>
{showResolved && resolvedText ? (
<span className="text-2xs font-normal text-muted-foreground">{resolvedText}</span>
) : null}
</span>
{/* The window the label stands for, for anyone who cannot see the
title attribute. Read after the label, not instead of it. */}
{resolvedText && !showResolved ? (
<span className="sr-only">{`, ${resolvedText}`}</span>
) : null}
</Button>
</PopoverTrigger>
);
}
export interface TimeRangeContentProps extends ComponentPropsWithRef<typeof PopoverContent> {
/** Names the dialog. */
label?: string;
}
export function TimeRangeContent({
className,
label = "Choose a time range",
children,
...props
}: TimeRangeContentProps) {
const { labelId } = useTimeRange("TimeRangeContent");
return (
<PopoverContent
data-slot="time-range-content"
aria-labelledby={labelId}
align="start"
className={cn("w-auto max-w-[min(38rem,calc(100vw-2rem))] p-0", className)}
{...props}
>
<h2 id={labelId} className="sr-only">
{label}
</h2>
<div className="flex flex-col gap-0 sm:flex-row sm:items-stretch">{children}</div>
</PopoverContent>
);
}
/**
* The named ranges, which is how most of these are actually used. Buttons
* rather than a radiogroup: choosing one both selects and dismisses, which is a
* command, and `aria-pressed` still carries which one matches.
*/
export function TimeRangePresets({
className,
...props
}: Omit<ComponentPropsWithRef<"div">, "children">) {
const { presets, value, setValue, close } = useTimeRange("TimeRangePresets");
const groups = useMemo(() => {
const collected = new Map<string, TimeRangePreset[]>();
for (const preset of presets) {
const existing = collected.get(preset.group);
if (existing) existing.push(preset);
else collected.set(preset.group, [preset]);
}
return [...collected];
}, [presets]);
return (
<div
data-slot="time-range-presets"
className={cn(
"max-h-80 min-w-52 overflow-y-auto border-border p-1.5 sm:border-e",
className,
)}
{...props}
>
{groups.map(([group, items]) => (
<div key={group} role="group" aria-label={group} className="pb-1 last:pb-0">
<p className="px-2 py-1 text-2xs font-medium tracking-wide text-muted-foreground uppercase">
{group}
</p>
{items.map((preset) => (
<button
key={preset.expression}
type="button"
aria-pressed={preset.expression === value}
onClick={() => {
setValue(preset.expression);
close();
}}
className={cn(
"flex w-full items-center rounded-md px-2 py-1.5 text-start text-sm transition-colors",
preset.expression === value
? "bg-accent font-medium text-accent-foreground"
: "hover:bg-accent hover:text-accent-foreground",
focusRing,
)}
>
{preset.label}
</button>
))}
</div>
))}
</div>
);
}
/**
* Raw expression entry, with the resolved window shown as you type — the escape
* hatch that keeps the presets from being a ceiling.
*
* An expression that does not parse says why and is not applied: a chart
* quietly re-scoping itself to a window nobody asked for is worse than one that
* refuses.
*/
export function TimeRangeExpression({
className,
...props
}: Omit<ComponentPropsWithRef<"div">, "children">) {
const { value, setValue, now, timeZone, locale, clockReady, close } =
useTimeRange("TimeRangeExpression");
const [draft, setDraft] = useState(value);
// The expression can change under the input — a preset click, or the
// consumer setting it. Adjusting during render rather than in an effect keeps
// the field from showing a stale value for a frame.
const [lastValue, setLastValue] = useState(value);
if (value !== lastValue) {
setLastValue(value);
setDraft(value);
}
const feedbackId = useId();
const preview = useMemo(() => {
// Before the clock is known there is nothing to preview and nothing to
// reject — saying "invalid" here would be a verdict reached without a fact.
if (!clockReady) return { text: null, error: null as string | null };
try {
return {
text: formatResolvedRange(resolveTimeRange(draft, { now, timeZone }), {
locale,
timeZone,
}),
error: null as string | null,
};
} catch (thrown) {
return {
text: null,
error: thrown instanceof TimeExpressionError ? thrown.message : "Invalid time range.",
};
}
}, [draft, now, timeZone, locale, clockReady]);
const apply = () => {
if (preview.error || !clockReady) return;
setValue(draft.trim());
close();
};
return (
<div
data-slot="time-range-expression"
className={cn(
"flex flex-col gap-1.5 border-t border-border p-3 sm:border-t-0",
className,
)}
{...props}
>
<label htmlFor={feedbackId + "-input"} className="text-xs font-medium">
Expression
</label>
<div className="flex gap-2">
<Input
id={feedbackId + "-input"}
inputSize="sm"
value={draft}
spellCheck={false}
autoComplete="off"
aria-invalid={preview.error ? true : undefined}
aria-describedby={feedbackId}
onChange={(event) => {
setDraft(event.target.value);
}}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault();
apply();
}
}}
className="font-mono"
/>
<Button size="sm" onClick={apply} disabled={Boolean(preview.error) || !clockReady}>
Apply
</Button>
</div>
{/* One element for both outcomes, so a screen reader hears the error
replace the preview rather than accumulating two descriptions. */}
<p
id={feedbackId}
className={cn(
"min-h-4 text-2xs",
preview.error ? "text-destructive" : "text-muted-foreground",
)}
>
{preview.error ?? preview.text}
</p>
</div>
);
}
/**
* The absolute path, for a window with no relative meaning. Picking dates
* writes a concrete expression: "1 to 7 March" does not become relative just
* because it was chosen here.
*/
export function TimeRangeCalendar({
className,
numberOfMonths = 1,
...props
}: Omit<ComponentPropsWithRef<"div">, "children"> & { numberOfMonths?: number }) {
const { setValue, resolved, close } = useTimeRange("TimeRangeCalendar");
const [selection, setSelection] = useState<DateRange | undefined>(() =>
resolved ? { from: resolved.from, to: resolved.to } : undefined,
);
return (
<div
data-slot="time-range-calendar"
className={cn("border-t border-border p-1 sm:border-s sm:border-t-0", className)}
{...props}
>
<Calendar
mode="range"
selected={selection}
defaultMonth={selection?.from}
numberOfMonths={numberOfMonths}
onSelect={(next) => {
setSelection(next);
// Only once both ends exist. One click is half an answer, and
// applying it would show a zero-length range mid-gesture.
if (next?.from && next.to) {
setValue(absoluteExpression(startOfDay(next.from), endOfDay(next.to)));
close();
}
}}
/>
</div>
);
}
function startOfDay(date: Date): Date {
const start = new Date(date);
start.setHours(0, 0, 0, 0);
return start;
}
/**
* A day picked as the end of a range means the whole of that day. Taking
* midnight instead silently drops the last day's data, which is the same
* off-by-one that `now/d..now/d` has to avoid.
*/
function endOfDay(date: Date): Date {
const end = new Date(date);
end.setHours(23, 59, 59, 999);
return end;
}