{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"time-range-picker","type":"registry:component","title":"Time range picker","description":"\"Last 15 minutes\" for logs, metrics and traces: type 2h or yesterday 14:00–16:00, step by the window, pause live, or drag across a chart to zoom.","author":"Hairline UI","dependencies":[],"registryDependencies":["https://hairlineui.com/r/text-morph.json?as=dependency"],"files":[{"path":"registry/time-range-picker.tsx","type":"registry:component","content":"// Hairline UI · time-range-picker · MIT License\n// Copyright (c) 2026 Hairline UI · https://hairlineui.com/license#components\n\n\"use client\";\n\nimport * as React from \"react\";\nimport { createPortal } from \"react-dom\";\nimport { TextMorph } from \"./text-morph\";\nimport { TimeBrush, type TimeBrushProps } from \"./time-brush\";\nimport {\n  DAY,\n  HOUR,\n  MINUTE,\n  durationShort,\n  formatTimeRange,\n  isLive,\n  parseTimeRange,\n  resolveTimeRange,\n  sameRange,\n  stepTimeRange,\n  type TimeRange,\n} from \"./time-range-parse\";\n\n/* ─────────────────────────────────────────────────────────\n * TIME RANGE PICKER: \"Last 15 minutes ▾\" for logs and metrics\n *\n *   plate    one hairline plate: ‹ earlier, the range, › later,\n *            and Live. The range's words morph as it changes\n *   step     ‹ and › move the window by its own length; stepping\n *            back from \"Last 1 hour\" fixes it to the hour before,\n *            and stepping forward into now makes it live again\n *   live     the dot breathes while the range follows now;\n *            Paused freezes it where it is\n *   open     the panel unfolds from the plate's edge with the\n *            field focused: type 45m, yesterday, since 9 or\n *            14:00–16:00 and the line under it says what it\n *            means before you press Enter\n *   quick    a highlight glides over the quick ranges; the\n *            current one carries a drawn check; ranges you typed\n *            come back as Recent\n *\n * Pair it with Time brush, which turns a drag across any chart\n * into a range.\n * ───────────────────────────────────────────────────────── */\n\nexport type { TimeRange };\nexport { TimeBrush, type TimeBrushProps };\nexport { formatTimeRange, parseTimeRange, resolveTimeRange, stepTimeRange };\n\nexport interface TimeRangePreset {\n  label: string;\n  /** A range, or one worked out from now (Today, Yesterday). */\n  range: TimeRange | ((now: Date, timeZone?: string) => TimeRange);\n}\n\nexport interface TimeRangePickerProps extends Omit<React.HTMLAttributes<HTMLDivElement>, \"defaultValue\" | \"onChange\"> {\n  value?: TimeRange;\n  defaultValue?: TimeRange;\n  /** The range, and the start and end it covers right now. */\n  onValueChange?: (range: TimeRange, resolved: { start: Date; end: Date }) => void;\n  presets?: TimeRangePreset[];\n  /** Read and show times in this IANA zone; the viewer's own by default. */\n  timeZone?: string;\n  hour12?: boolean;\n  /** Which edge of the plate the panel lines up with. */\n  align?: \"start\" | \"end\";\n  open?: boolean;\n  defaultOpen?: boolean;\n  onOpenChange?: (open: boolean) => void;\n  /** Accessible name, e.g. \"Time range for logs\". */\n  label?: string;\n  disabled?: boolean;\n}\n\nconst rel = (duration: number): TimeRange => ({ type: \"relative\", duration });\nexport const TIME_RANGE_PRESETS: TimeRangePreset[] = [\n  { label: \"Last 5 minutes\", range: rel(5 * MINUTE) },\n  { label: \"Last 15 minutes\", range: rel(15 * MINUTE) },\n  { label: \"Last 1 hour\", range: rel(HOUR) },\n  { label: \"Last 4 hours\", range: rel(4 * HOUR) },\n  { label: \"Last 24 hours\", range: rel(DAY) },\n  { label: \"Last 7 days\", range: rel(7 * DAY) },\n  { label: \"Today so far\", range: (now, tz) => parseTimeRange(\"today\", { now, timeZone: tz })! },\n  { label: \"Yesterday\", range: (now, tz) => parseTimeRange(\"yesterday\", { now, timeZone: tz })! },\n];\n\nconst EASE = \"cubic-bezier(0.16,1,0.3,1)\";\nconst FOCUS = \"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/50\";\nconst TOKENS = [\"--background\", \"--foreground\", \"--popover\", \"--popover-foreground\", \"--border\", \"--accent\", \"--accent-foreground\", \"--primary\", \"--primary-foreground\", \"--muted\", \"--muted-foreground\", \"--ring\"];\nconst HINT = \"Try 45m, yesterday, since 9 or 14:00–16:00\";\n\nconst reducedQuery = \"(prefers-reduced-motion: reduce)\";\nconst subscribeReduced = (onChange: () => void) => {\n  const query = window.matchMedia(reducedQuery);\n  query.addEventListener(\"change\", onChange);\n  return () => query.removeEventListener(\"change\", onChange);\n};\nconst useReducedMotion = () =>\n  React.useSyncExternalStore(subscribeReduced, () => window.matchMedia(reducedQuery).matches, () => false);\n\n// Phones get the tighter label and a plate without the clock or the word Live.\nconst narrowQuery = \"(max-width: 480px)\";\nconst subscribeNarrow = (onChange: () => void) => {\n  const query = window.matchMedia(narrowQuery);\n  query.addEventListener(\"change\", onChange);\n  return () => query.removeEventListener(\"change\", onChange);\n};\nconst useNarrow = () => React.useSyncExternalStore(subscribeNarrow, () => window.matchMedia(narrowQuery).matches, () => false);\n\nfunction zoneLabel(timeZone?: string) {\n  try {\n    const zone = timeZone ?? Intl.DateTimeFormat().resolvedOptions().timeZone;\n    const off = new Intl.DateTimeFormat(\"en-US\", { timeZone: zone, timeZoneName: \"shortOffset\" }).formatToParts(new Date()).find((p) => p.type === \"timeZoneName\")?.value.replace(\"GMT\", \"UTC\");\n    return `${zone === \"UTC\" ? \"UTC\" : (zone.split(\"/\").pop() ?? zone).replace(/_/g, \" \")}${off && zone !== \"UTC\" ? ` · ${off}` : \"\"}`;\n  } catch {\n    return \"\";\n  }\n}\n\nfunction Arrow({ dir }: { dir: -1 | 1 }) {\n  return (\n    <svg viewBox=\"0 0 16 16\" fill=\"none\" aria-hidden=\"true\" className=\"size-3.5\">\n      <path d={dir < 0 ? \"M9.75 4.5 6.25 8l3.5 3.5\" : \"M6.25 4.5 9.75 8l-3.5 3.5\"} stroke=\"currentColor\" strokeWidth=\"1.6\" strokeLinecap=\"round\" strokeLinejoin=\"round\" />\n    </svg>\n  );\n}\n\n/** A sentence that changes crossfades whole. */\nfunction Line({ text, reduced, className = \"\" }: { text: string; reduced: boolean; className?: string }) {\n  const [shown, setShown] = React.useState({ text, key: 0 });\n  const [leaving, setLeaving] = React.useState<{ text: string; key: number }[]>([]);\n  if (text !== shown.text) {\n    if (shown.text && !reduced) setLeaving((l) => [...l.slice(-2), shown]);\n    setShown({ text, key: shown.key + 1 });\n  }\n  return (\n    <span className={`relative block ${className}`}>\n      <span\n        key={shown.key}\n        className=\"block truncate\"\n        ref={(el) => {\n          if (!el || reduced || shown.key === 0 || el.dataset.in) return;\n          el.dataset.in = \"1\";\n          el.animate([{ opacity: 0, transform: \"translateY(0.3em)\", filter: \"blur(4px)\" }, { opacity: 1, transform: \"none\", filter: \"blur(0px)\" }], { duration: 340, delay: 40, easing: EASE, fill: \"backwards\" });\n        }}\n      >\n        {shown.text || \" \"}\n      </span>\n      {leaving.map((l) => (\n        <span\n          key={l.key}\n          aria-hidden=\"true\"\n          className=\"pointer-events-none absolute inset-x-0 top-0 block truncate\"\n          ref={(el) => {\n            if (!el || el.dataset.out) return;\n            el.dataset.out = \"1\";\n            el.animate([{ opacity: 1, filter: \"blur(0px)\" }, { opacity: 0, transform: \"translateY(-0.3em)\", filter: \"blur(4px)\" }], { duration: 160, easing: EASE, fill: \"forwards\" }).onfinish = () =>\n              setLeaving((all) => all.filter((x) => x.key !== l.key));\n          }}\n        >\n          {l.text}\n        </span>\n      ))}\n    </span>\n  );\n}\n\ntype Phase = \"closed\" | \"opening\" | \"open\" | \"closing\";\n\nexport function TimeRangePicker({\n  value,\n  defaultValue = rel(HOUR),\n  onValueChange,\n  presets = TIME_RANGE_PRESETS,\n  timeZone,\n  hour12 = false,\n  align = \"end\",\n  open: openProp,\n  defaultOpen = false,\n  onOpenChange,\n  label = \"Time range\",\n  disabled = false,\n  className = \"\",\n  ...props\n}: TimeRangePickerProps) {\n  const uid = React.useId();\n  const reduced = useReducedMotion();\n  const narrow = useNarrow();\n  const [own, setOwn] = React.useState(defaultValue);\n  const range = value ?? own;\n  const [now, setNow] = React.useState(() => new Date());\n  const [phase, setPhase] = React.useState<Phase>(() => ((openProp ?? defaultOpen) ? \"opening\" : \"closed\"));\n  const [fit, setFit] = React.useState({ x: 0, y: 0, w: 320, up: false, theme: {} as Record<string, string> });\n  const [typed, setTyped] = React.useState(\"\");\n  const [active, setActive] = React.useState(-1);\n  const [mark, setMark] = React.useState({ x: 0, y: 0, w: 0, h: 0, on: false, slide: false });\n  const [recent, setRecent] = React.useState<TimeRange[]>([]);\n  const [refused, setRefused] = React.useState(false);\n  const [announce, setAnnounce] = React.useState(\"\");\n  const anchorRef = React.useRef<HTMLDivElement>(null);\n  const triggerRef = React.useRef<HTMLButtonElement>(null);\n  const surfaceRef = React.useRef<HTMLDivElement>(null);\n  const inputRef = React.useRef<HTMLInputElement>(null);\n  const fieldRef = React.useRef<HTMLDivElement>(null);\n  const presetRefs = React.useRef<(HTMLButtonElement | null)[]>([]);\n  const timer = React.useRef(0);\n  const mounted = phase !== \"closed\";\n  const expanded = phase === \"open\";\n  const live = isLive(range);\n\n  // \"Today\" and \"Last 1 hour\" are about now: look at the clock once a minute.\n  React.useEffect(() => {\n    let t = 0;\n    const tick = () => {\n      setNow(new Date());\n      t = window.setTimeout(tick, 60_000 - (Date.now() % 60_000) + 50);\n    };\n    t = window.setTimeout(tick, 60_000 - (Date.now() % 60_000) + 50);\n    return () => window.clearTimeout(t);\n  }, []);\n\n  const words = formatTimeRange(range, { now, timeZone, hour12, compact: narrow });\n  const resolved = resolveTimeRange(range, now);\n  const span = resolved.end.getTime() - resolved.start.getTime();\n  const presetRanges = React.useMemo(() => presets.map((p) => (typeof p.range === \"function\" ? p.range(now, timeZone) : p.range)), [presets, now, timeZone]);\n  const currentPreset = presetRanges.findIndex((r) => sameRange(r, range));\n  const parsed = typed.trim() ? parseTimeRange(typed, { now, timeZone }) : null;\n\n  const commit = (next: TimeRange, how?: \"typed\") => {\n    const at = new Date();\n    if (value === undefined) setOwn(next);\n    onValueChange?.(next, resolveTimeRange(next, at));\n    setAnnounce(formatTimeRange(next, { now: at, timeZone, hour12 }));\n    if (how === \"typed\" && !presetRanges.some((r) => sameRange(r, next))) setRecent((list) => [next, ...list.filter((r) => !sameRange(r, next))].slice(0, 3));\n  };\n\n  /* ── Panel ── */\n\n  const place = React.useCallback(() => {\n    const anchor = anchorRef.current;\n    if (!anchor) return;\n    const r = anchor.getBoundingClientRect();\n    const vw = document.documentElement.clientWidth;\n    const vh = window.innerHeight;\n    const w = Math.min(320, vw - 16);\n    const x = Math.min(Math.max(8, align === \"end\" ? r.right - w : r.left), vw - 8 - w);\n    const up = vh - r.bottom < 380 && r.top > vh - r.bottom;\n    const cs = getComputedStyle(anchor);\n    const theme: Record<string, string> = { colorScheme: cs.colorScheme, fontFamily: cs.fontFamily };\n    for (const t of TOKENS) {\n      const v = cs.getPropertyValue(t);\n      if (v) theme[t] = v;\n    }\n    setFit({ x, y: up ? vh - r.top + 6 : r.bottom + 6, w, up, theme });\n  }, [align]);\n\n  const begin = () => {\n    window.clearTimeout(timer.current);\n    place();\n    setTyped(\"\");\n    setRefused(false);\n    setActive(-1);\n    setMark((m) => ({ ...m, on: false, slide: false }));\n    setPhase(reduced ? \"open\" : \"opening\");\n  };\n  const end = React.useCallback(\n    (refocus = true) => {\n      window.clearTimeout(timer.current);\n      if (refocus) triggerRef.current?.focus({ preventScroll: true });\n      if (reduced) return setPhase(\"closed\");\n      setPhase((p) => (p === \"closed\" ? p : \"closing\"));\n      timer.current = window.setTimeout(() => setPhase(\"closed\"), 240);\n    },\n    [reduced],\n  );\n  const request = (next: boolean, refocus = true) => {\n    if (openProp === undefined) (next ? begin : () => end(refocus))();\n    onOpenChange?.(next);\n  };\n\n  React.useEffect(() => {\n    if (openProp === undefined) return;\n    if (openProp && !mounted) begin();\n    if (!openProp && mounted) end(false);\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [openProp]);\n\n  React.useLayoutEffect(() => {\n    if (phase !== \"opening\") return;\n    place();\n    inputRef.current?.focus({ preventScroll: true });\n    let inner = 0;\n    const outer = requestAnimationFrame(() => (inner = requestAnimationFrame(() => setPhase(\"open\"))));\n    return () => {\n      cancelAnimationFrame(outer);\n      cancelAnimationFrame(inner);\n    };\n  }, [phase, place]);\n  React.useEffect(() => () => window.clearTimeout(timer.current), []);\n\n  React.useEffect(() => {\n    if (!mounted) return;\n    const inside = (n: Node) => Boolean(anchorRef.current?.contains(n) || surfaceRef.current?.contains(n));\n    const onDown = (e: PointerEvent) => {\n      if (!inside(e.target as Node)) request(false, false);\n    };\n    let frame = 0;\n    const follow = (e: Event) => {\n      if (e.target instanceof Node && surfaceRef.current?.contains(e.target)) return;\n      cancelAnimationFrame(frame);\n      frame = requestAnimationFrame(place);\n    };\n    document.addEventListener(\"pointerdown\", onDown);\n    window.addEventListener(\"scroll\", follow, true);\n    window.addEventListener(\"resize\", follow);\n    return () => {\n      cancelAnimationFrame(frame);\n      document.removeEventListener(\"pointerdown\", onDown);\n      window.removeEventListener(\"scroll\", follow, true);\n      window.removeEventListener(\"resize\", follow);\n    };\n  });\n\n  // The highlight glides to the quick range under the pointer or the arrows.\n  React.useLayoutEffect(() => {\n    if (!mounted) return;\n    const el = presetRefs.current[active];\n    setMark((m) => (el ? (m.x === el.offsetLeft && m.y === el.offsetTop && m.on ? m : { x: el.offsetLeft, y: el.offsetTop, w: el.offsetWidth, h: el.offsetHeight, on: true, slide: m.on }) : m.on ? { ...m, on: false, slide: false } : m));\n  }, [active, mounted]);\n\n  const choose = (i: number) => {\n    commit(presetRanges[i]);\n    request(false);\n  };\n  const applyTyped = () => {\n    if (!parsed) {\n      setRefused(true);\n      if (!reduced)\n        fieldRef.current?.animate([{ transform: \"none\" }, { transform: \"translateX(-4px)\" }, { transform: \"translateX(4px)\" }, { transform: \"translateX(-2px)\" }, { transform: \"none\" }], { duration: 380, easing: \"ease-out\" });\n      return;\n    }\n    commit(parsed, \"typed\");\n    request(false);\n  };\n\n  const onInputKey = (e: React.KeyboardEvent<HTMLInputElement>) => {\n    const n = presets.length;\n    const go = (i: number) => {\n      e.preventDefault();\n      setActive(i);\n    };\n    if (e.key === \"ArrowDown\") go(active < 0 ? 0 : Math.min(n - 1, active + 2));\n    else if (e.key === \"ArrowUp\") go(active - 2 < 0 ? -1 : active - 2);\n    else if (e.key === \"ArrowRight\" && active >= 0) go(Math.min(n - 1, active + 1));\n    else if (e.key === \"ArrowLeft\" && active >= 0) go(Math.max(0, active - 1));\n    else if (e.key === \"Enter\") {\n      e.preventDefault();\n      if (active >= 0 && !typed.trim()) choose(active);\n      else applyTyped();\n    } else if (e.key === \"Escape\") {\n      e.preventDefault();\n      e.stopPropagation();\n      request(false);\n    } else if (e.key === \"Tab\") {\n      e.preventDefault();\n      request(false);\n    }\n  };\n\n  const preview = !typed.trim()\n    ? HINT\n    : parsed\n      ? (() => {\n          const r = resolveTimeRange(parsed, now);\n          return `${formatTimeRange(parsed, { now, timeZone, hour12 })} · ${durationShort(r.end.getTime() - r.start.getTime())}${isLive(parsed) ? \" · live\" : \"\"}`;\n        })()\n      : refused\n        ? `Couldn’t read that. ${HINT}.`\n        : HINT;\n\n  const segment = `flex shrink-0 items-center justify-center text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:cursor-default disabled:opacity-35 disabled:hover:bg-transparent ${FOCUS}`;\n\n  return (\n    <div ref={anchorRef} className={`relative inline-flex max-w-full ${className}`} {...props}>\n      <div role=\"group\" aria-label={label} className={`inline-flex h-8 max-w-full items-stretch overflow-hidden rounded-full border border-border bg-background text-[12.5px] ${disabled ? \"opacity-60\" : \"\"}`}>\n        <button\n          type=\"button\"\n          aria-label={`Earlier: the ${durationShort(span)} before`}\n          title=\"Earlier\"\n          disabled={disabled}\n          onClick={() => commit(stepTimeRange(range, -1, new Date()))}\n          className={`w-8 cursor-pointer max-[480px]:w-7 ${segment}`}\n        >\n          <Arrow dir={-1} />\n        </button>\n        <span aria-hidden=\"true\" className=\"my-2 w-px shrink-0 bg-border\" />\n        <button\n          ref={triggerRef}\n          type=\"button\"\n          disabled={disabled}\n          aria-haspopup=\"dialog\"\n          aria-expanded={expanded}\n          aria-controls={mounted ? `${uid}-panel` : undefined}\n          aria-label={`${label}: ${words}`}\n          onClick={() => request(!(phase === \"open\" || phase === \"opening\"))}\n          onKeyDown={(e) => {\n            if (e.key === \"ArrowDown\") {\n              e.preventDefault();\n              request(true);\n            }\n          }}\n          className={`flex min-w-0 cursor-pointer items-center gap-1.5 px-2.5 font-medium transition-colors hover:bg-accent disabled:cursor-default ${expanded ? \"bg-accent\" : \"\"} ${FOCUS}`}\n        >\n          <svg viewBox=\"0 0 16 16\" fill=\"none\" aria-hidden=\"true\" className=\"size-3.5 shrink-0 text-muted-foreground max-[480px]:hidden\">\n            <circle cx=\"8\" cy=\"8\" r=\"5.75\" stroke=\"currentColor\" strokeWidth=\"1.4\" />\n            <path d=\"M8 5.25V8l1.75 1.25\" stroke=\"currentColor\" strokeWidth=\"1.4\" strokeLinecap=\"round\" strokeLinejoin=\"round\" />\n          </svg>\n          <span className=\"min-w-0 overflow-hidden whitespace-nowrap\">\n            <TextMorph>{words}</TextMorph>\n          </span>\n          <svg viewBox=\"0 0 16 16\" fill=\"none\" aria-hidden=\"true\" className=\"size-3 shrink-0 text-muted-foreground\" style={{ transform: expanded ? \"rotate(180deg)\" : \"none\", transition: reduced ? \"none\" : `transform 320ms ${EASE}` }}>\n            <path d=\"M4.5 6.25 8 9.75l3.5-3.5\" stroke=\"currentColor\" strokeWidth=\"1.6\" strokeLinecap=\"round\" strokeLinejoin=\"round\" />\n          </svg>\n        </button>\n        <span aria-hidden=\"true\" className=\"my-2 w-px shrink-0 bg-border\" />\n        <button\n          type=\"button\"\n          aria-label={live ? \"Later: already showing the latest\" : `Later: the ${durationShort(span)} after`}\n          title={live ? \"Already showing the latest\" : \"Later\"}\n          disabled={disabled || live}\n          onClick={() => commit(stepTimeRange(range, 1, new Date()))}\n          className={`w-8 cursor-pointer max-[480px]:w-7 ${segment}`}\n        >\n          <Arrow dir={1} />\n        </button>\n        <span aria-hidden=\"true\" className=\"my-2 w-px shrink-0 bg-border\" />\n        <button\n          type=\"button\"\n          aria-pressed={live}\n          aria-label={live ? \"Live: pause\" : \"Paused: go live\"}\n          disabled={disabled}\n          onClick={() => {\n            const at = new Date();\n            const r = resolveTimeRange(range, at);\n            commit(live ? { type: \"absolute\", start: r.start, end: r.end } : rel(r.end.getTime() - r.start.getTime()));\n          }}\n          className={`cursor-pointer gap-1.5 pl-2.5 pr-3 font-medium max-[480px]:px-2.5 ${segment} ${live ? \"text-foreground\" : \"\"}`}\n        >\n          <span aria-hidden=\"true\" className=\"relative flex size-2 items-center justify-center\">\n            <span\n              className={`absolute inset-0 rounded-full bg-emerald-500 ${live && !reduced ? \"animate-[ui-breathe_2.4s_ease-in-out_infinite]\" : \"\"}`}\n              style={{ opacity: live ? 1 : 0, transform: live ? undefined : \"scale(0.4)\", transition: reduced ? \"none\" : `opacity 260ms ${EASE}, transform 380ms ${EASE}` }}\n            />\n            <span className=\"absolute inset-0 rounded-full shadow-[inset_0_0_0_1.5px_currentColor]\" style={{ opacity: live ? 0 : 0.8, transition: reduced ? \"none\" : `opacity 260ms ${EASE}` }} />\n          </span>\n          <span className=\"max-[480px]:hidden\">\n            <TextMorph>{live ? \"Live\" : \"Paused\"}</TextMorph>\n          </span>\n        </button>\n      </div>\n\n      {mounted &&\n        createPortal(\n          <div\n            ref={surfaceRef}\n            id={`${uid}-panel`}\n            role=\"dialog\"\n            aria-label={label}\n            onMouseDown={(e) => {\n              if (!(e.target as HTMLElement).matches(\"input\")) e.preventDefault();\n            }}\n            className=\"fixed z-[60] overflow-hidden rounded-[16px] border border-border bg-popover p-1.5 text-popover-foreground\"\n            style={{\n              ...fit.theme,\n              left: fit.x,\n              [fit.up ? \"bottom\" : \"top\"]: fit.y,\n              width: fit.w,\n              clipPath: expanded ? \"inset(0px 0px 0px 0px round 16px)\" : fit.up ? \"inset(100% 0px 0px 0px round 16px)\" : \"inset(0px 0px 100% 0px round 16px)\",\n              opacity: expanded ? 1 : 0,\n              transition: reduced ? \"none\" : phase === \"closing\" ? `clip-path 220ms ${EASE}, opacity 180ms ${EASE} 40ms` : `clip-path 380ms ${EASE}, opacity 200ms ${EASE}`,\n            }}\n          >\n            <div\n              ref={fieldRef}\n              className=\"flex h-9 items-center gap-2 rounded-[11px] px-2.5 transition-shadow duration-200\"\n              style={{ boxShadow: `inset 0 0 0 1px ${refused && !parsed ? \"color-mix(in oklab, #ef4444 45%, transparent)\" : \"color-mix(in oklab, var(--ring) 45%, var(--border))\"}` }}\n            >\n              <svg viewBox=\"0 0 16 16\" fill=\"none\" aria-hidden=\"true\" className=\"size-3.5 shrink-0 text-muted-foreground\">\n                <path d=\"M3 4.5h10M3 8h6M3 11.5h8\" stroke=\"currentColor\" strokeWidth=\"1.4\" strokeLinecap=\"round\" />\n              </svg>\n              <input\n                ref={inputRef}\n                value={typed}\n                onChange={(e) => {\n                  setTyped(e.target.value);\n                  setRefused(false);\n                  if (e.target.value) setActive(-1);\n                }}\n                onKeyDown={onInputKey}\n                placeholder=\"Type a range\"\n                aria-label=\"Type a range\"\n                aria-describedby={`${uid}-preview`}\n                aria-activedescendant={active >= 0 ? `${uid}-p${active}` : undefined}\n                autoComplete=\"off\"\n                spellCheck={false}\n                className=\"h-full min-w-0 flex-1 bg-transparent text-[13px] outline-none placeholder:text-muted-foreground [@media(pointer:coarse)]:text-[16px]\"\n              />\n              {parsed && <span className=\"shrink-0 rounded-full px-1.5 font-mono text-[10.5px] text-muted-foreground shadow-[inset_0_0_0_1px_var(--border)]\">↵</span>}\n            </div>\n            <p id={`${uid}-preview`} className={`px-1 pb-1 pt-1.5 text-[11.5px] transition-colors duration-200 ${refused && !parsed ? \"text-red-600 dark:text-red-400\" : parsed ? \"text-foreground\" : \"text-muted-foreground\"}`}>\n              <Line text={preview} reduced={reduced} />\n            </p>\n\n            <div role=\"listbox\" aria-label=\"Quick ranges\" className=\"relative mt-1 grid grid-cols-2 gap-0.5 border-t border-border pt-1.5\" onMouseLeave={() => setActive(-1)}>\n              <span\n                aria-hidden=\"true\"\n                className=\"pointer-events-none absolute left-0 top-0 rounded-[9px] bg-accent\"\n                style={{\n                  width: mark.w,\n                  height: mark.h,\n                  transform: `translate(${mark.x}px, ${mark.y}px)`,\n                  opacity: mark.on ? 1 : 0,\n                  transition: reduced ? \"none\" : `${mark.slide ? `transform 220ms ${EASE}, width 220ms ${EASE}, ` : \"\"}opacity 160ms ${EASE}`,\n                }}\n              />\n              {presets.map((p, i) => {\n                const on = i === currentPreset;\n                return (\n                  <button\n                    key={p.label}\n                    ref={(el) => {\n                      presetRefs.current[i] = el;\n                    }}\n                    id={`${uid}-p${i}`}\n                    type=\"button\"\n                    role=\"option\"\n                    aria-selected={on}\n                    tabIndex={-1}\n                    onPointerMove={() => active !== i && setActive(i)}\n                    onClick={() => choose(i)}\n                    className={`relative flex h-8 cursor-pointer items-center justify-between gap-2 rounded-[9px] px-2 text-left text-[12.5px] ${on ? \"font-medium text-foreground\" : \"text-muted-foreground\"}`}\n                  >\n                    <span className=\"truncate\">{p.label}</span>\n                    <svg viewBox=\"0 0 16 16\" fill=\"none\" aria-hidden=\"true\" className=\"size-3 shrink-0 text-foreground\">\n                      <path\n                        d=\"M3.5 8.5 6.5 11.5 12.5 4.5\"\n                        stroke=\"currentColor\"\n                        strokeWidth=\"1.9\"\n                        strokeLinecap=\"round\"\n                        strokeLinejoin=\"round\"\n                        pathLength={1}\n                        strokeDasharray={1}\n                        style={{ strokeDashoffset: on && expanded ? 0 : 1, transition: reduced ? \"none\" : `stroke-dashoffset 380ms ${EASE} 160ms` }}\n                      />\n                    </svg>\n                  </button>\n                );\n              })}\n            </div>\n\n            {recent.length > 0 && (\n              <div className=\"mt-1.5 border-t border-border px-1 pt-2\">\n                <p className=\"text-[11px] text-muted-foreground\">Recent</p>\n                <div className=\"mt-1.5 flex flex-wrap gap-1 pb-0.5\">\n                  {recent.map((r) => (\n                    <button\n                      key={formatTimeRange(r, { now, timeZone, hour12 })}\n                      type=\"button\"\n                      tabIndex={-1}\n                      onClick={() => {\n                        commit(r, \"typed\");\n                        request(false);\n                      }}\n                      className=\"h-6 max-w-full cursor-pointer truncate rounded-full px-2 text-[11.5px] text-foreground shadow-[inset_0_0_0_1px_var(--border)] transition-colors hover:bg-accent\"\n                    >\n                      {formatTimeRange(r, { now, timeZone, hour12 })}\n                    </button>\n                  ))}\n                </div>\n              </div>\n            )}\n\n            <p className=\"mt-1.5 border-t border-border px-1 pb-0.5 pt-2 text-[11px] text-muted-foreground\">Times in {zoneLabel(timeZone)}</p>\n          </div>,\n          document.body,\n        )}\n\n      <span className=\"sr-only\" role=\"status\" aria-live=\"polite\">\n        {announce}\n      </span>\n    </div>\n  );\n}\n"},{"path":"registry/time-range-parse.ts","type":"registry:component","content":"// Hairline UI · time-range-picker · MIT License\n// Copyright (c) 2026 Hairline UI · https://hairlineui.com/license#components\n\n/* ─────────────────────────────────────────────────────────\n * TIME RANGE PARSE: what \"2h\" or \"yesterday 14:00–16:00\" means\n *\n *   ranges   last N (live), since a moment (live), or a fixed\n *            start and end\n *   typed    15m · 2 hours · last 7 days · today · yesterday ·\n *            this week · last month · since 9 · since monday ·\n *            14:00–16:00 · 9am to 5pm · yesterday 14:00-16:00 ·\n *            16 oct 14:00 – 17 oct 09:00 · ISO dates\n *   zones    wall-clock times are read and written in the zone\n *            you pass (the viewer's own by default)\n * ───────────────────────────────────────────────────────── */\n\nexport type TimeRange =\n  /** The last `duration` ms, ending now. */\n  | { type: \"relative\"; duration: number }\n  /** From `start` until now. */\n  | { type: \"since\"; start: Date }\n  | { type: \"absolute\"; start: Date; end: Date };\n\nexport const MINUTE = 60_000;\nexport const HOUR = 60 * MINUTE;\nexport const DAY = 24 * HOUR;\n\ntype Wall = { year: number; month: number; day: number; hour: number; minute: number };\n\n/* ── Zones ─────────────────────────────────────────────── */\n\nconst formatters = new Map<string, Intl.DateTimeFormat>();\nfunction fmt(timeZone?: string) {\n  const key = timeZone ?? \"\";\n  let f = formatters.get(key);\n  if (!f) {\n    f = new Intl.DateTimeFormat(\"en-US\", { timeZone, hourCycle: \"h23\", year: \"numeric\", month: \"numeric\", day: \"numeric\", hour: \"numeric\", minute: \"numeric\", second: \"numeric\", weekday: \"short\" });\n    formatters.set(key, f);\n  }\n  return f;\n}\nconst WEEKDAY = { Sun: 0, Mon: 1, Tue: 2, Wed: 3, Thu: 4, Fri: 5, Sat: 6 } as Record<string, number>;\n\n/** The wall clock at an instant in a zone. */\nexport function wallAt(epoch: number, timeZone?: string) {\n  const p: Record<string, string> = {};\n  for (const part of fmt(timeZone).formatToParts(new Date(epoch))) p[part.type] = part.value;\n  return { year: +p.year, month: +p.month, day: +p.day, hour: +p.hour % 24, minute: +p.minute, second: +p.second, weekday: WEEKDAY[p.weekday] ?? 0 };\n}\n\n/** The instant a wall-clock time happens in a zone (the earlier one when clocks go back). */\nexport function fromWall(w: Wall, timeZone?: string) {\n  const guess = Date.UTC(w.year, w.month - 1, w.day, w.hour, w.minute);\n  const off = (t: number) => {\n    const a = wallAt(t, timeZone);\n    return Date.UTC(a.year, a.month - 1, a.day, a.hour, a.minute, a.second) - Math.floor(t / 1000) * 1000;\n  };\n  const a = guess - off(guess - 6 * HOUR);\n  const b = guess - off(guess + 6 * HOUR);\n  const fits = (t: number) => {\n    const x = wallAt(t, timeZone);\n    return x.day === w.day && x.hour === w.hour && x.minute === w.minute;\n  };\n  if (fits(a) && fits(b)) return Math.min(a, b);\n  if (fits(b)) return b;\n  // a fits, or the time falls in a gap where clocks jump: either way, the moment the clock passes it.\n  return a;\n}\n\nconst addDays = (w: Wall, n: number): Wall => {\n  const d = new Date(Date.UTC(w.year, w.month - 1, w.day + n));\n  return { ...w, year: d.getUTCFullYear(), month: d.getUTCMonth() + 1, day: d.getUTCDate() };\n};\nconst midnight = (w: Wall): Wall => ({ ...w, hour: 0, minute: 0 });\n\n/* ── Resolving and stepping ────────────────────────────── */\n\nexport function resolveTimeRange(range: TimeRange, now = new Date()): { start: Date; end: Date } {\n  if (range.type === \"relative\") return { start: new Date(now.getTime() - range.duration), end: now };\n  if (range.type === \"since\") return { start: range.start, end: now };\n  return { start: range.start, end: range.end };\n}\n\nexport const isLive = (range: TimeRange) => range.type !== \"absolute\";\n\n/** The same length of time, one window earlier (-1) or later (1). A window that would reach now becomes live again. */\nexport function stepTimeRange(range: TimeRange, dir: -1 | 1, now = new Date()): TimeRange {\n  const { start, end } = resolveTimeRange(range, now);\n  const span = end.getTime() - start.getTime();\n  const nextEnd = end.getTime() + dir * span;\n  if (dir === 1 && nextEnd >= now.getTime() - 30_000) return { type: \"relative\", duration: span };\n  return { type: \"absolute\", start: new Date(start.getTime() + dir * span), end: new Date(nextEnd) };\n}\n\nexport const sameRange = (a: TimeRange, b: TimeRange) =>\n  a.type === b.type &&\n  (a.type === \"relative\"\n    ? a.duration === (b as typeof a).duration\n    : a.type === \"since\"\n      ? a.start.getTime() === (b as typeof a).start.getTime()\n      : a.start.getTime() === (b as typeof a).start.getTime() && a.end.getTime() === (b as typeof a).end.getTime());\n\n/* ── Words ─────────────────────────────────────────────── */\n\nconst plural = (n: number, w: string) => `${n} ${w}${n === 1 ? \"\" : \"s\"}`;\n\n/** \"15 minutes\", \"4 hours\", \"7 days\". */\nexport function durationWords(ms: number) {\n  if (ms < HOUR || ms % HOUR) return plural(Math.round(ms / MINUTE), \"minute\");\n  if (ms < 2 * DAY || ms % DAY) return plural(Math.round(ms / HOUR), \"hour\");\n  if (ms % (7 * DAY) === 0 && ms / DAY >= 14) return plural(ms / (7 * DAY), \"week\");\n  return plural(Math.round(ms / DAY), \"day\");\n}\n\n/** \"15m\", \"2h\", \"3d\", \"1h 30m\". */\nexport function durationShort(ms: number) {\n  const m = Math.round(ms / MINUTE);\n  if (m < 60) return `${m}m`;\n  if (m < 48 * 60) return m % 60 ? `${Math.floor(m / 60)}h ${m % 60}m` : `${m / 60}h`;\n  const d = Math.floor(m / 1440);\n  const h = Math.round((m % 1440) / 60);\n  return h ? `${d}d ${h}h` : `${d}d`;\n}\n\nconst pad = (n: number) => String(n).padStart(2, \"0\");\nconst MONTHS = [\"Jan\", \"Feb\", \"Mar\", \"Apr\", \"May\", \"Jun\", \"Jul\", \"Aug\", \"Sep\", \"Oct\", \"Nov\", \"Dec\"];\nconst DAYS = [\"Sun\", \"Mon\", \"Tue\", \"Wed\", \"Thu\", \"Fri\", \"Sat\"];\n\n/** What the picker's pill says. */\nexport function formatTimeRange(range: TimeRange, { now = new Date(), timeZone, hour12 = false, compact = false }: { now?: Date; timeZone?: string; hour12?: boolean; compact?: boolean } = {}) {\n  const time = (w: { hour: number; minute: number }) => (hour12 ? `${w.hour % 12 || 12}${w.minute ? `:${pad(w.minute)}` : \"\"}${w.hour < 12 ? \"am\" : \"pm\"}` : `${pad(w.hour)}:${pad(w.minute)}`);\n  const today = wallAt(now.getTime(), timeZone);\n  const yesterday = addDays(today, -1);\n  const dayName = (w: Wall & { weekday?: number }) => {\n    if (w.year === today.year && w.month === today.month && w.day === today.day) return \"Today\";\n    if (w.year === yesterday.year && w.month === yesterday.month && w.day === yesterday.day) return \"Yesterday\";\n    const date = `${w.day} ${MONTHS[w.month - 1]}${w.year !== today.year ? ` ${w.year}` : \"\"}`;\n    return compact ? date : `${DAYS[new Date(Date.UTC(w.year, w.month - 1, w.day)).getUTCDay()]} ${date}`;\n  };\n  // Narrow places drop the spaces round the dash and the comma after the day.\n  const dash = compact ? \"–\" : \" – \";\n  const comma = compact ? \" \" : \", \";\n  if (range.type === \"relative\") return `Last ${durationWords(range.duration)}`;\n  const s = wallAt(range.start.getTime(), timeZone);\n  if (range.type === \"since\") {\n    if (!s.hour && !s.minute) return dayName(s) === \"Today\" ? \"Today so far\" : `Since ${dayName(s)}`;\n    return dayName(s) === \"Today\" ? `Since ${time(s)}` : `Since ${dayName(s)}${comma}${time(s)}`;\n  }\n  const e = wallAt(range.end.getTime(), timeZone);\n  const sameDay = s.year === e.year && s.month === e.month && s.day === e.day;\n  const nextDay = addDays(s, 1);\n  // Whole days read as days: \"Yesterday\", \"Mon 12 Oct – Wed 14 Oct\".\n  if (!s.hour && !s.minute && !e.hour && !e.minute) {\n    if (nextDay.year === e.year && nextDay.month === e.month && nextDay.day === e.day) return dayName(s);\n    return `${dayName(s)}${dash}${dayName(addDays(e, -1))}`;\n  }\n  if (sameDay) return `${dayName(s)}${comma}${time(s)}${dash}${time(e)}`;\n  return `${dayName(s)} ${time(s)}${dash}${dayName(e)} ${time(e)}`;\n}\n\n/* ── Typed ranges ──────────────────────────────────────── */\n\nconst UNIT: Record<string, number> = {\n  s: 1000, sec: 1000, secs: 1000, second: 1000, seconds: 1000,\n  m: MINUTE, min: MINUTE, mins: MINUTE, minute: MINUTE, minutes: MINUTE,\n  h: HOUR, hr: HOUR, hrs: HOUR, hour: HOUR, hours: HOUR,\n  d: DAY, day: DAY, days: DAY,\n  w: 7 * DAY, wk: 7 * DAY, week: 7 * DAY, weeks: 7 * DAY,\n};\nconst DAY_WORDS: Record<string, number> = { sun: 0, sunday: 0, mon: 1, monday: 1, tue: 2, tues: 2, tuesday: 2, wed: 3, wednesday: 3, thu: 4, thur: 4, thurs: 4, thursday: 4, fri: 5, friday: 5, sat: 6, saturday: 6 };\nconst MONTH_WORDS: Record<string, number> = Object.fromEntries(\n  [\"january\", \"february\", \"march\", \"april\", \"may\", \"june\", \"july\", \"august\", \"september\", \"october\", \"november\", \"december\"].flatMap((m, i) => [\n    [m, i + 1],\n    [m.slice(0, 3), i + 1],\n  ]).concat([[\"sept\", 9]]),\n);\n\nfunction readTime(t: string): { hour: number; minute: number } | null {\n  if (t === \"noon\" || t === \"midday\") return { hour: 12, minute: 0 };\n  if (t === \"midnight\") return { hour: 0, minute: 0 };\n  const m = t.match(/^(\\d{1,2})(?:[:.h](\\d{2}))?\\s?(am|pm)?$/);\n  if (!m) return null;\n  let hour = +m[1];\n  const minute = m[2] ? +m[2] : 0;\n  if (m[3]) {\n    if (hour < 1 || hour > 12) return null;\n    hour = (hour % 12) + (m[3] === \"pm\" ? 12 : 0);\n  } else if (!m[2] && hour > 24) return null;\n  if (hour > 23 || minute > 59) return null;\n  return { hour, minute };\n}\n\n/** A date word or numbers, relative to today. */\nfunction readDate(t: string, today: Wall & { weekday: number }): Wall | null {\n  if (!t || t === \"today\") return midnight(today);\n  if (t === \"yesterday\") return midnight(addDays(today, -1));\n  if (t in DAY_WORDS) {\n    const back = (today.weekday - DAY_WORDS[t] + 7) % 7;\n    return midnight(addDays(today, -back));\n  }\n  let m = t.match(/^(\\d{4})-(\\d{2})-(\\d{2})$/);\n  if (m) return { year: +m[1], month: +m[2], day: +m[3], hour: 0, minute: 0 };\n  m = t.match(/^(\\d{1,2})(?:st|nd|rd|th)? (?:of )?([a-z]+)(?: (\\d{4}))?$/) ?? null;\n  if (m && MONTH_WORDS[m[2]]) return { year: m[3] ? +m[3] : today.year, month: MONTH_WORDS[m[2]], day: +m[1], hour: 0, minute: 0 };\n  m = t.match(/^([a-z]+) (\\d{1,2})(?:st|nd|rd|th)?(?:,? (\\d{4}))?$/);\n  if (m && MONTH_WORDS[m[1]]) return { year: m[3] ? +m[3] : today.year, month: MONTH_WORDS[m[1]], day: +m[2], hour: 0, minute: 0 };\n  return null;\n}\n\nconst TIME_TAIL = /\\s*(noon|midday|midnight|\\d{1,2}(?:[:.h]\\d{2})?\\s?(?:am|pm)?)$/;\nconst ISO_MOMENT = /^\\d{4}-\\d{2}-\\d{2}[t ]\\d{2}:\\d{2}/;\n\n/** \"yesterday 14:00\", \"16 oct\", \"9am\", \"2026-10-16 14:00\" as a wall time. */\nfunction readMoment(text: string, today: Wall & { weekday: number }, fallbackDate?: Wall): { wall: Wall; dated: boolean; timed: boolean } | null {\n  const t = text.trim();\n  if (ISO_MOMENT.test(t)) {\n    const [d, tm] = t.split(/[t ]/);\n    const date = readDate(d, today);\n    const time = readTime(tm.slice(0, 5));\n    return date && time ? { wall: { ...date, ...time }, dated: true, timed: true } : null;\n  }\n  const tail = t.match(TIME_TAIL);\n  let datePart = t;\n  let time: { hour: number; minute: number } | null = null;\n  if (tail) {\n    const candidate = readTime(tail[1].replace(/\\s/g, \"\"));\n    // \"16 oct\" ends in digits that aren't a time; only take the tail when what's left is a date (or nothing).\n    const rest = t.slice(0, tail.index).trim().replace(/,$/, \"\");\n    if (candidate && (rest === \"\" || readDate(rest, today))) {\n      time = candidate;\n      datePart = rest;\n    }\n  }\n  const date = datePart ? readDate(datePart, today) : (fallbackDate ?? midnight(today));\n  if (!date) return null;\n  return { wall: { ...date, ...(time ?? { hour: 0, minute: 0 }) }, dated: Boolean(datePart), timed: Boolean(time) };\n}\n\n/** Reads a typed range, or null. */\nexport function parseTimeRange(input: string, { now = new Date(), timeZone }: { now?: Date; timeZone?: string } = {}): TimeRange | null {\n  const t = input.toLowerCase().trim().replace(/\\s+/g, \" \").replace(/[–—]/g, \"-\");\n  if (!t) return null;\n  const today = wallAt(now.getTime(), timeZone);\n  const at = (w: Wall) => new Date(fromWall(w, timeZone));\n\n  // Durations: 15m, 2 hours, last 7 days.\n  const dur = t.match(/^(?:last |past )?(\\d+(?:\\.\\d+)?) ?([a-z]+)$/);\n  if (dur && UNIT[dur[2]]) return { type: \"relative\", duration: Math.round(+dur[1] * UNIT[dur[2]]) };\n  const bare = t.match(/^(?:last (minute|hour|day)|past (minute|hour|day|week))$/);\n  if (bare) return { type: \"relative\", duration: UNIT[bare[1] ?? bare[2]] };\n\n  if (t === \"today\") return { type: \"since\", start: at(midnight(today)) };\n  if (t === \"yesterday\") return { type: \"absolute\", start: at(midnight(addDays(today, -1))), end: at(midnight(today)) };\n  if (t === \"this week\") return { type: \"since\", start: at(midnight(addDays(today, -((today.weekday + 6) % 7)))) };\n  if (t === \"last week\") {\n    const monday = midnight(addDays(today, -((today.weekday + 6) % 7)));\n    return { type: \"absolute\", start: at(addDays(monday, -7)), end: at(monday) };\n  }\n  if (t === \"this month\") return { type: \"since\", start: at({ ...midnight(today), day: 1 }) };\n  if (t === \"last month\") {\n    const first = { ...midnight(today), day: 1 };\n    const prev = new Date(Date.UTC(first.year, first.month - 2, 1));\n    return { type: \"absolute\", start: at({ ...first, year: prev.getUTCFullYear(), month: prev.getUTCMonth() + 1 }), end: at(first) };\n  }\n\n  // Since a moment: since 9, since monday, since 16 oct 14:00.\n  const since = t.match(/^(?:since|from) (.+)$/);\n  if (since && !/ (?:to|until|-) /.test(since[1]) && !/\\d-\\d/.test(since[1])) {\n    const m = readMoment(since[1], today);\n    if (!m) return null;\n    const start = at(m.wall);\n    return start.getTime() < now.getTime() ? { type: \"since\", start } : null;\n  }\n\n  // A span: \"a - b\", \"a to b\", or two times joined by a bare hyphen (\"yesterday 14:00-16:00\").\n  const body = t.replace(/^(?:from|between) /, \"\");\n  let span = body.split(/ (?:-|to|until|and) /);\n  if (span.length !== 2) {\n    const tight = body.match(/^(.*?\\d{1,2}(?:[:.]\\d{2})?\\s?(?:am|pm)?)-(\\d{1,2}(?:[:.]\\d{2})?\\s?(?:am|pm)?)$/);\n    span = tight ? [tight[1], tight[2]] : [];\n  }\n  if (span.length === 2) {\n    // \"yesterday 14:00-16:00\": the date on the left carries over to a time-only right side.\n    const a = readMoment(span[0], today);\n    if (!a) return null;\n    const b = readMoment(span[1], today, midnight(a.wall));\n    if (!b) return null;\n    let start = a.wall;\n    let end = b.wall;\n    if (!b.timed && b.dated) end = addDays(end, 1); // \"16 oct - 17 oct\" includes the 17th\n    if (fromWall(end, timeZone) <= fromWall(start, timeZone)) end = addDays(end, 1); // 22:00-02:00 runs past midnight\n    // Times without a date that haven't happened yet today mean yesterday's.\n    if (!a.dated && fromWall(start, timeZone) > now.getTime()) {\n      start = addDays(start, -1);\n      end = addDays(end, -1);\n    }\n    return { type: \"absolute\", start: at(start), end: at(end) };\n  }\n\n  // A single day: monday, 16 oct.\n  const day = readMoment(t, today);\n  if (day && day.dated && !day.timed) return { type: \"absolute\", start: at(day.wall), end: at(addDays(day.wall, 1)) };\n  if (day && day.timed && !day.dated) return { type: \"since\", start: at(day.wall) };\n  return null;\n}\n"},{"path":"registry/time-brush.tsx","type":"registry:component","content":"// Hairline UI · time-range-picker · MIT License\n// Copyright (c) 2026 Hairline UI · https://hairlineui.com/license#components\n\n\"use client\";\n\nimport * as React from \"react\";\nimport { durationShort, wallAt } from \"./time-range-parse\";\n\n/* ─────────────────────────────────────────────────────────\n * TIME BRUSH: drag across any chart to pick the time under it\n *\n *   drag     a band follows the pointer from where you pressed,\n *            with its start, end and length written on it\n *   release  the band's span is passed to onSelect (zoom in),\n *            and the band fades as the chart redraws\n *   cancel   Escape, or a drag too short to mean anything\n *\n * Wrap a chart whose x axis runs from `start` to `end`. Nothing\n * re-renders while you drag: the band is moved directly.\n * ───────────────────────────────────────────────────────── */\n\nexport interface TimeBrushProps extends Omit<React.HTMLAttributes<HTMLDivElement>, \"onSelect\"> {\n  /** The time at the chart's left edge. */\n  start: Date;\n  /** The time at its right edge. */\n  end: Date;\n  onSelect: (range: { start: Date; end: Date }) => void;\n  /** The shortest span worth selecting, in ms. */\n  minSpan?: number;\n  timeZone?: string;\n  disabled?: boolean;\n}\n\nconst pad = (n: number) => String(n).padStart(2, \"0\");\n\nexport function TimeBrush({ start, end, onSelect, minSpan = 60_000, timeZone, disabled = false, className = \"\", children, ...props }: TimeBrushProps) {\n  const rootRef = React.useRef<HTMLDivElement>(null);\n  const bandRef = React.useRef<HTMLDivElement>(null);\n  const fillRef = React.useRef<HTMLSpanElement>(null);\n  const leftRef = React.useRef<HTMLSpanElement>(null);\n  const rightRef = React.useRef<HTMLSpanElement>(null);\n  const tagRef = React.useRef<HTMLSpanElement>(null);\n  const drag = React.useRef<{ x0: number; id: number; left: number; width: number; from: number; to: number } | null>(null);\n\n  const timeAt = (x: number, width: number) => start.getTime() + (Math.min(Math.max(x, 0), width) / width) * (end.getTime() - start.getTime());\n  const label = (t: number) => {\n    const w = wallAt(t, timeZone);\n    return `${pad(w.hour)}:${pad(w.minute)}`;\n  };\n\n  const paint = (a: number, b: number, width: number) => {\n    const band = bandRef.current;\n    const tag = tagRef.current;\n    const d = drag.current;\n    if (!band || !tag || !d) return;\n    const left = Math.max(0, Math.min(a, b));\n    const right = Math.min(width, Math.max(a, b));\n    d.from = timeAt(left, width);\n    d.to = timeAt(right, width);\n    // Transforms only: a 1px fill scaled to the span, and two hairline edges moved into place.\n    fillRef.current!.style.transform = `translateX(${left}px) scaleX(${right - left})`;\n    leftRef.current!.style.transform = `translateX(${left}px)`;\n    rightRef.current!.style.transform = `translateX(${right - 1}px)`;\n    band.style.opacity = right - left > 2 ? \"1\" : \"0\";\n    band.style.transition = \"none\";\n    tag.textContent = `${label(d.from)} – ${label(d.to)} · ${durationShort(Math.max(60_000, d.to - d.from))}`;\n    // The tag stays on the chart even when the band is near an edge.\n    const tw = tag.offsetWidth;\n    tag.style.transform = `translateX(${Math.min(Math.max(left + (right - left) / 2 - tw / 2, 0), width - tw)}px)`;\n  };\n\n  const finish = (commit: boolean) => {\n    const d = drag.current;\n    const band = bandRef.current;\n    drag.current = null;\n    if (band) {\n      band.style.transition = \"opacity 360ms cubic-bezier(0.16,1,0.3,1)\";\n      band.style.opacity = \"0\";\n    }\n    if (d && commit && d.to - d.from >= minSpan) onSelect({ start: new Date(d.from), end: new Date(d.to) });\n  };\n\n  React.useEffect(() => {\n    const onKey = (e: KeyboardEvent) => {\n      if (e.key === \"Escape\" && drag.current) finish(false);\n    };\n    window.addEventListener(\"keydown\", onKey);\n    return () => window.removeEventListener(\"keydown\", onKey);\n  });\n\n  return (\n    <div\n      ref={rootRef}\n      className={`relative select-none ${disabled ? \"\" : \"cursor-crosshair\"} ${className}`}\n      style={{ touchAction: \"pan-y\" }}\n      onPointerDown={(e) => {\n        if (disabled || e.button !== 0) return;\n        const r = e.currentTarget.getBoundingClientRect();\n        drag.current = { x0: e.clientX - r.left, id: e.pointerId, left: r.left, width: r.width, from: 0, to: 0 };\n      }}\n      onPointerMove={(e) => {\n        const d = drag.current;\n        if (!d || e.pointerId !== d.id) return;\n        const x = e.clientX - d.left;\n        if (Math.abs(x - d.x0) > 3 && !e.currentTarget.hasPointerCapture(e.pointerId)) e.currentTarget.setPointerCapture(e.pointerId);\n        paint(d.x0, x, d.width);\n      }}\n      onPointerUp={(e) => {\n        const d = drag.current;\n        if (!d || e.pointerId !== d.id) return;\n        finish(Math.abs(e.clientX - d.left - d.x0) > 6);\n      }}\n      onPointerCancel={() => finish(false)}\n      {...props}\n    >\n      {children}\n      <div ref={bandRef} aria-hidden=\"true\" className=\"pointer-events-none absolute inset-0 opacity-0\">\n        <span ref={fillRef} className=\"absolute inset-y-0 left-0 w-px origin-left bg-primary/[0.09] will-change-transform\" />\n        <span ref={leftRef} className=\"absolute inset-y-0 left-0 w-px bg-primary/70 will-change-transform\" />\n        <span ref={rightRef} className=\"absolute inset-y-0 left-0 w-px bg-primary/70 will-change-transform\" />\n        <span ref={tagRef} className=\"absolute -top-6 left-0 whitespace-nowrap rounded-full bg-background px-2 py-0.5 font-mono text-[10.5px] tabular-nums text-foreground shadow-[inset_0_0_0_1px_var(--border)] will-change-transform\" />\n      </div>\n    </div>\n  );\n}\n"}],"css":{"@keyframes ui-breathe":{"0%, 100%":{"transform":"scale(0.82)","opacity":"0.55"},"50%":{"transform":"scale(1)","opacity":"1"}}},"docs":"Docs and live preview: https://hairlineui.com/ui/time-range-picker. MIT licensed: https://hairlineui.com/license#components","meta":{"license":"MIT"}}