{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"cron-input","type":"registry:component","title":"Cron input","description":"A schedule field people can read: cron or plain English in, the meaning in words and the next runs out, with clock changes flagged.","author":"Hairline UI","dependencies":[],"registryDependencies":["https://hairlineui.com/r/text-morph.json?as=dependency","https://hairlineui.com/r/number-roll.json?as=dependency"],"files":[{"path":"registry/cron-input.tsx","type":"registry:component","content":"// Hairline UI · cron-input · MIT License\n// Copyright (c) 2026 Hairline UI · https://hairlineui.com/license#components\n\n\"use client\";\n\nimport * as React from \"react\";\nimport { CRON_FIELDS, cronTokens, describeCron, nextRuns, parseCron, parseSchedule, type CronParse, type CronSchedule } from \"./cron-parse\";\nimport { NumberRoll } from \"./number-roll\";\nimport { PillSelect } from \"./pill-select\";\nimport { TextMorph } from \"./text-morph\";\n\n/* ─────────────────────────────────────────────────────────\n * CRON INPUT: a schedule field people can read\n *\n *   type      a cron expression; each part has its name under\n *             it, and the part your caret is in lights up and\n *             gets a hairline that glides from part to part\n *   explain   the line below says it in words (\"At 09:00,\n *             Monday to Friday\") and crossfades as you type\n *   runs      the next five times it fires, in the schedule's\n *             time zone; hours and minutes roll as you edit\n *   english   \"every weekday at 9am\" works too: it's read as\n *             you type, and Enter turns the words into cron\n *   presets   a highlight slides to the preset that matches,\n *             and picking one morphs the expression into it:\n *             the parts both share stay where they are\n *   wrong     the part at fault turns red and says why; the last\n *             good runs stay, dimmed, so nothing jumps\n *   clocks    a run that a clock change skips or repeats says so\n * ───────────────────────────────────────────────────────── */\n\nexport interface CronPreset {\n  label: string;\n  expression: string;\n}\n\nexport interface CronInputProps extends Omit<React.HTMLAttributes<HTMLDivElement>, \"defaultValue\" | \"onChange\"> {\n  /** The schedule, controlled: a cron expression. */\n  value?: string;\n  /** A cron expression, or a schedule in English (\"every weekday at 9am\"). */\n  defaultValue?: string;\n  /** Called with each valid expression (tidied) and what it means. */\n  onValueChange?: (expression: string, schedule: CronSchedule) => void;\n  /** The zone the schedule runs in. Many schedulers only run in UTC. */\n  timeZone?: string;\n  defaultTimeZone?: string;\n  /** Zones to choose from; with two or more, the zone becomes a dropdown. */\n  timeZones?: string[];\n  onTimeZoneChange?: (timeZone: string) => void;\n  /** How many upcoming runs to list; 0 hides the list. */\n  runs?: number;\n  /** Shortcuts above the field; [] hides them. */\n  presets?: CronPreset[];\n  /** Read schedules typed in English. */\n  english?: boolean;\n  /** 1:30pm instead of 13:30. */\n  hour12?: boolean;\n  /** Accessible name of the field. */\n  label?: string;\n  locales?: string | string[];\n  disabled?: boolean;\n  name?: string;\n}\n\nexport const CRON_PRESETS: CronPreset[] = [\n  { label: \"Every 15 min\", expression: \"*/15 * * * *\" },\n  { label: \"Hourly\", expression: \"0 * * * *\" },\n  { label: \"Daily 09:00\", expression: \"0 9 * * *\" },\n  { label: \"Weekdays 09:00\", expression: \"0 9 * * 1-5\" },\n  { label: \"Mondays 08:00\", expression: \"0 8 * * 1\" },\n  { label: \"Monthly\", expression: \"0 0 1 * *\" },\n];\n\nconst EASE = \"cubic-bezier(0.16,1,0.3,1)\";\nconst FIELD_LABELS = [\"minute\", \"hour\", \"day\", \"month\", \"weekday\"];\nconst NAMES = new Set([\"JAN\", \"FEB\", \"MAR\", \"APR\", \"MAY\", \"JUN\", \"JUL\", \"AUG\", \"SEP\", \"OCT\", \"NOV\", \"DEC\", \"SUN\", \"MON\", \"TUE\", \"WED\", \"THU\", \"FRI\", \"SAT\", \"L\", \"W\"]);\n/** The field's type: one size for the input, its measuring copy and its morphing copy. */\nconst TYPE = \"font-mono text-[14.5px] tracking-[0.02em] [@media(pointer:coarse)]:text-[16px]\";\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\nconst tidy = (text: string) => text.trim().split(/\\s+/).join(\" \");\n/** English if any word isn't a cron name (MON, JAN, L). */\nconst looksEnglish = (text: string) => (text.match(/[a-z]+/gi) ?? ([] as string[])).some((w) => !NAMES.has(w.toUpperCase()) && !/^\\d*L$/i.test(w));\n\nfunction relative(ms: number) {\n  const min = Math.round(ms / 60000);\n  if (min < 1) return \"in under a minute\";\n  if (min < 60) return `in ${min} min`;\n  const h = Math.round(min / 60);\n  if (h < 36) return `in ${h} h`;\n  const d = Math.round(h / 24);\n  if (d < 14) return `in ${d} days`;\n  if (d < 60) return `in ${Math.round(d / 7)} weeks`;\n  const mo = Math.round(d / 30.4);\n  if (mo < 24) return `in ${mo} months`;\n  return `in ${Math.round(d / 365)} years`;\n}\n\nfunction zoneOffset(timeZone: string) {\n  try {\n    const part = new Intl.DateTimeFormat(\"en-US\", { timeZone, timeZoneName: \"shortOffset\" }).formatToParts(new Date()).find((p) => p.type === \"timeZoneName\");\n    return part?.value.replace(\"GMT\", \"UTC\") ?? \"\";\n  } catch {\n    return \"\";\n  }\n}\nconst cityOf = (tz: string) => (tz === \"UTC\" ? \"UTC\" : (tz.split(\"/\").pop() ?? tz).replace(/_/g, \" \"));\n\n/** A sentence that changes crossfades whole: the old one lifts away through a blur while the new one rises in. */\nfunction Line({ text, reduced }: { text: string; reduced: boolean }) {\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\">\n      <span\n        key={shown.key}\n        className=\"block\"\n        ref={(el) => {\n          if (!el || reduced || shown.key === 0 || el.dataset.in) return;\n          el.dataset.in = \"1\";\n          el.animate(\n            [\n              { opacity: 0, transform: \"translateY(0.3em)\", filter: \"blur(4px)\" },\n              { opacity: 1, transform: \"none\", filter: \"blur(0px)\" },\n            ],\n            { duration: 380, delay: 60, easing: EASE, fill: \"backwards\" },\n          );\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\"\n          ref={(el) => {\n            if (!el || el.dataset.out) return;\n            el.dataset.out = \"1\";\n            el.animate(\n              [\n                { opacity: 1, transform: \"none\", filter: \"blur(0px)\" },\n                { opacity: 0, transform: \"translateY(-0.3em)\", filter: \"blur(4px)\" },\n              ],\n              { duration: 180, easing: EASE, fill: \"forwards\" },\n            ).onfinish = () => setLeaving((all) => all.filter((x) => x.key !== l.key));\n          }}\n        >\n          {l.text}\n        </span>\n      ))}\n    </span>\n  );\n}\n\n/** The expression on its way from one text to another: TextMorph, started from what the field showed. */\nfunction MorphOnce({ from, to, onDone }: { from: string; to: string; onDone: () => void }) {\n  const [text, setText] = React.useState(from);\n  React.useEffect(() => {\n    let inner = 0;\n    const outer = requestAnimationFrame(() => (inner = requestAnimationFrame(() => setText(to))));\n    const timer = window.setTimeout(onDone, 820);\n    return () => {\n      cancelAnimationFrame(outer);\n      cancelAnimationFrame(inner);\n      window.clearTimeout(timer);\n    };\n  }, [to, onDone]);\n  return (\n    <TextMorph animateWidth={false} className=\"whitespace-pre\">\n      {text}\n    </TextMorph>\n  );\n}\n\nexport function CronInput({\n  value,\n  defaultValue = \"0 9 * * 1-5\",\n  onValueChange,\n  timeZone,\n  defaultTimeZone = \"UTC\",\n  timeZones,\n  onTimeZoneChange,\n  runs: runCount = 5,\n  presets = CRON_PRESETS,\n  english = true,\n  hour12 = false,\n  label = \"Schedule\",\n  locales,\n  disabled = false,\n  name,\n  className = \"\",\n  ...props\n}: CronInputProps) {\n  const uid = React.useId();\n  const reduced = useReducedMotion();\n  // A default written in English starts as those words, already read as cron.\n  const [own, setOwn] = React.useState(() => (parseCron(defaultValue).ok || !english ? tidy(defaultValue) : (parseSchedule(defaultValue) ?? tidy(defaultValue))));\n  const committed = value !== undefined ? tidy(value) : own;\n  const [text, setText] = React.useState(() => (value !== undefined ? tidy(value) : defaultValue.trim()));\n  const [ownZone, setOwnZone] = React.useState(defaultTimeZone);\n  const zone = timeZone ?? ownZone;\n  const [caret, setCaret] = React.useState<number | null>(null);\n  const [focused, setFocused] = React.useState(false);\n  const [morph, setMorph] = React.useState<{ from: string; to: string; key: number } | null>(null);\n  const [marks, setMarks] = React.useState<{ x: number; w: number }[]>([]);\n  const [labelX, setLabelX] = React.useState<number[]>([]);\n  const [now, setNow] = React.useState<Date | null>(null);\n  const [announce, setAnnounce] = React.useState(\"\");\n  const [presetMark, setPresetMark] = React.useState({ x: 0, w: 0, on: false, slide: false });\n  const inputRef = React.useRef<HTMLInputElement>(null);\n  const labelRefs = React.useRef<(HTMLSpanElement | null)[]>([]);\n  const labelRow = React.useRef<HTMLDivElement>(null);\n  const mirrorRef = React.useRef<HTMLSpanElement>(null);\n  const presetRefs = React.useRef<(HTMLButtonElement | null)[]>([]);\n  const morphKey = React.useRef(0);\n  const lastGood = React.useRef<CronSchedule | null>(null);\n\n  // What the text means: cron, English that reads as cron, or a problem with a part to point at.\n  const read = React.useMemo(() => {\n    const cron = parseCron(text);\n    if (cron.ok) return { kind: \"cron\" as const, schedule: cron.schedule };\n    if (english && looksEnglish(text)) {\n      const converted = parseSchedule(text);\n      const parsed = converted ? parseCron(converted) : null;\n      if (parsed?.ok) return { kind: \"english\" as const, schedule: parsed.schedule };\n      return { kind: \"error\" as const, message: \"Couldn’t read that. Try “every weekday at 9am” or cron like 0 9 * * 1-5.\", field: null as number | null };\n    }\n    const bad = cron as Extract<CronParse, { ok: false }>;\n    return { kind: \"error\" as const, message: bad.message, field: bad.field };\n  }, [text, english]);\n  const schedule = read.kind === \"error\" ? null : read.schedule;\n  if (schedule) lastGood.current = schedule;\n  const shownSchedule = schedule ?? lastGood.current;\n\n  // A new value from outside morphs in.\n  const [seen, setSeen] = React.useState(committed);\n  if (committed !== seen) {\n    setSeen(committed);\n    if (tidy(text) !== committed) {\n      if (!reduced) setMorph({ from: text, to: committed, key: ++morphKey.current });\n      setText(committed);\n    }\n  }\n\n  // Each valid cron expression is passed on as it's typed.\n  const report = (expression: string, s: CronSchedule) => {\n    if (expression === committed) return;\n    if (value === undefined) setOwn(expression);\n    setSeen(expression);\n    onValueChange?.(expression, s);\n  };\n  React.useEffect(() => {\n    if (read.kind === \"cron\") report(read.schedule.expression, read.schedule);\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [read]);\n\n  const morphTo = (next: string) => {\n    if (!reduced && next !== text) setMorph((m) => ({ from: m ? m.to : text, to: next, key: ++morphKey.current }));\n    setText(next);\n  };\n  const morphDone = React.useCallback(() => setMorph(null), []);\n\n  // English becomes cron on Enter or when you leave the field.\n  const settle = () => {\n    if (read.kind === \"english\") {\n      morphTo(read.schedule.expression);\n      setAnnounce(`Set to ${read.schedule.expression}: ${describeCron(read.schedule, { hour12 })}`);\n    }\n  };\n\n  // The clock for \"in 3 h\", re-read on each new minute (and only after mount, so the server's render matches).\n  React.useEffect(() => {\n    let timer = 0;\n    const tick = () => {\n      setNow(new Date());\n      timer = window.setTimeout(tick, 60_000 - (Date.now() % 60_000) + 50);\n    };\n    tick();\n    return () => window.clearTimeout(timer);\n  }, []);\n\n  const upcoming = React.useMemo(() => (shownSchedule && now && runCount ? nextRuns(shownSchedule, { from: now, count: runCount, timeZone: zone }) : []), [shownSchedule, now, runCount, zone]);\n\n  // Where each part sits in the field, measured from a copy of the text in the same type.\n  const tokens = read.kind === \"english\" ? [] : cronTokens(text);\n  React.useLayoutEffect(() => {\n    const mirror = mirrorRef.current;\n    const input = inputRef.current;\n    const node = mirror?.firstChild;\n    if (!mirror || !input) return;\n    const base = mirror.getBoundingClientRect().left;\n    const next = tokens.slice(0, 6).map((t) => {\n      if (!node) return { x: 0, w: 0 };\n      const range = document.createRange();\n      range.setStart(node, Math.min(t.start, node.textContent!.length));\n      range.setEnd(node, Math.min(t.end, node.textContent!.length));\n      const r = range.getBoundingClientRect();\n      return { x: r.left - base - input.scrollLeft, w: r.width };\n    });\n    setMarks((prev) => (prev.length === next.length && prev.every((p, i) => Math.abs(p.x - next[i].x) < 0.5 && Math.abs(p.w - next[i].w) < 0.5) ? prev : next));\n  });\n\n  // Each name sits centred under its part, nudged along so none overlap or run past the edges.\n  React.useLayoutEffect(() => {\n    const row = labelRow.current;\n    if (!row) return;\n    const ws = FIELD_LABELS.map((_, i) => labelRefs.current[i]?.offsetWidth ?? 0);\n    const xs = FIELD_LABELS.map((_, i) => (marks[i] ? marks[i].x + marks[i].w / 2 - ws[i] / 2 : 0));\n    const n = Math.min(marks.length, FIELD_LABELS.length);\n    for (let i = 0; i < n; i++) xs[i] = Math.max(xs[i], i ? xs[i - 1] + ws[i - 1] + 8 : 0);\n    for (let i = n - 1; i >= 0; i--) xs[i] = Math.min(xs[i], i === n - 1 ? row.offsetWidth - ws[i] : xs[i + 1] - 8 - ws[i]);\n    setLabelX((prev) => (prev.length === xs.length && prev.every((x, i) => Math.abs(x - xs[i]) < 0.5) ? prev : xs));\n  }, [marks]);\n\n  const activeField = caret === null || !focused ? -1 : tokens.findIndex((t) => caret >= t.start && caret <= t.end);\n  const badField = read.kind === \"error\" ? read.field : null;\n  const underline = badField !== null && badField < marks.length ? badField : activeField;\n\n  // The highlight slides to the preset the schedule matches.\n  const presetIndex = schedule ? presets.findIndex((p) => tidy(p.expression) === schedule.expression) : -1;\n  React.useLayoutEffect(() => {\n    const el = presetRefs.current[presetIndex];\n    setPresetMark((m) => (el ? (m.x === el.offsetLeft && m.w === el.offsetWidth && m.on ? m : { x: el.offsetLeft, w: el.offsetWidth, on: true, slide: m.on }) : m.on ? { ...m, on: false, slide: false } : m));\n  }, [presetIndex, presets]);\n\n  // The meaning, said once typing settles.\n  const sentence = schedule ? describeCron(schedule, { hour12 }) : \"\";\n  React.useEffect(() => {\n    if (!focused) return;\n    const t = window.setTimeout(() => setAnnounce(read.kind === \"error\" ? read.message : sentence), 900);\n    return () => window.clearTimeout(t);\n  }, [sentence, read, focused]);\n\n  const lineText =\n    read.kind === \"error\" ? read.message : read.kind === \"english\" ? `${sentence}. Press Enter to use ${read.schedule.expression}` : sentence;\n  const dateFmt = React.useMemo(() => new Intl.DateTimeFormat(locales ?? \"en-GB\", { weekday: \"short\", day: \"numeric\", month: \"short\", timeZone: \"UTC\" }), [locales]);\n  const yearFmt = React.useMemo(() => new Intl.DateTimeFormat(locales ?? \"en-GB\", { weekday: \"short\", day: \"numeric\", month: \"short\", year: \"numeric\", timeZone: \"UTC\" }), [locales]);\n  const zoneOptions = React.useMemo(() => (timeZones ?? []).map((tz) => ({ value: tz, label: cityOf(tz), description: `${tz} · ${zoneOffset(tz)}` })), [timeZones]);\n  const nowYear = now?.getUTCFullYear();\n  const errorTone = \"text-red-600 dark:text-red-400\";\n\n  return (\n    <div className={`w-full text-foreground ${className}`} {...props}>\n      {presets.length > 0 && (\n        <div\n          role=\"group\"\n          aria-label=\"Presets\"\n          className=\"relative -mx-1 mb-2.5 flex gap-1 overflow-x-auto px-1 pb-0.5 [mask-image:linear-gradient(to_right,transparent,black_6px,black_calc(100%_-_20px),transparent)] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden\"\n        >\n          <span\n            aria-hidden=\"true\"\n            className=\"pointer-events-none absolute left-0 top-0 h-7 rounded-full bg-accent shadow-[inset_0_0_0_1px_var(--border)]\"\n            style={{\n              width: presetMark.w,\n              transform: `translateX(${presetMark.x}px)`,\n              opacity: presetMark.on ? 1 : 0,\n              transition: reduced ? \"none\" : `${presetMark.slide ? `transform 380ms ${EASE}, width 380ms ${EASE}, ` : \"\"}opacity 200ms ${EASE}`,\n            }}\n          />\n          {presets.map((p, i) => (\n            <button\n              key={p.expression}\n              ref={(el) => {\n                presetRefs.current[i] = el;\n              }}\n              type=\"button\"\n              disabled={disabled}\n              aria-pressed={i === presetIndex}\n              onClick={() => {\n                morphTo(tidy(p.expression));\n                inputRef.current?.focus({ preventScroll: true });\n              }}\n              className={`relative h-7 shrink-0 cursor-pointer whitespace-nowrap rounded-full px-2.5 text-[12px] font-medium transition-colors duration-200 hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40 disabled:cursor-default ${\n                i === presetIndex ? \"text-foreground\" : \"text-muted-foreground\"\n              }`}\n            >\n              {p.label}\n            </button>\n          ))}\n        </div>\n      )}\n\n      <div\n        onMouseDown={(e) => {\n          if ((e.target as HTMLElement).closest(\"button, input\")) return;\n          e.preventDefault();\n          inputRef.current?.focus();\n        }}\n        className={`rounded-[14px] border bg-background px-3 pb-1.5 pt-2 transition-[border-color] duration-200 ${disabled ? \"opacity-60\" : \"cursor-text\"}`}\n        style={{\n          borderColor:\n            read.kind === \"error\" && !focused\n              ? \"color-mix(in oklab, #ef4444 45%, var(--border))\"\n              : focused\n                ? \"color-mix(in oklab, var(--ring) 55%, var(--border))\"\n                : \"var(--border)\",\n        }}\n      >\n        <div className=\"flex items-center gap-2\">\n          <div className=\"relative min-w-0 flex-1\">\n            <input\n              ref={inputRef}\n              value={text}\n              disabled={disabled}\n              aria-label={label}\n              aria-invalid={read.kind === \"error\" || undefined}\n              aria-describedby={`${uid}-line`}\n              autoComplete=\"off\"\n              autoCapitalize=\"off\"\n              spellCheck={false}\n              onChange={(e) => {\n                setText(e.target.value);\n                setCaret(e.target.selectionStart);\n              }}\n              onSelect={(e) => setCaret(e.currentTarget.selectionStart)}\n              onFocus={() => setFocused(true)}\n              onBlur={() => {\n                setFocused(false);\n                settle();\n              }}\n              onKeyDown={(e) => {\n                if (e.key === \"Enter\") {\n                  e.preventDefault();\n                  settle();\n                } else if (e.key === \"Escape\" && tidy(text) !== committed) {\n                  e.preventDefault();\n                  e.stopPropagation();\n                  morphTo(committed);\n                }\n              }}\n              placeholder=\"0 9 * * 1-5\"\n              className={`h-8 w-full min-w-0 bg-transparent outline-none placeholder:text-muted-foreground disabled:cursor-default ${TYPE}`}\n              style={{ wordSpacing: read.kind === \"english\" ? \"normal\" : \"1.1em\", color: morph ? \"transparent\" : undefined, caretColor: morph ? \"var(--foreground)\" : undefined }}\n            />\n            {/* A copy of the text in the same type, measured to place each part's name under it. */}\n            <span\n              ref={mirrorRef}\n              aria-hidden=\"true\"\n              className={`pointer-events-none invisible absolute left-0 top-0 whitespace-pre ${TYPE}`}\n              style={{ wordSpacing: read.kind === \"english\" ? \"normal\" : \"1.1em\" }}\n            >\n              {text}\n            </span>\n            {morph && (\n              <span aria-hidden=\"true\" className={`pointer-events-none absolute inset-y-0 left-0 flex items-center whitespace-pre ${TYPE}`} style={{ wordSpacing: \"1.1em\" }}>\n                <MorphOnce key={morph.key} from={morph.from} to={morph.to} onDone={morphDone} />\n              </span>\n            )}\n            {/* The part you're in, or the part at fault: a hairline that glides from part to part. */}\n            <span\n              aria-hidden=\"true\"\n              className={`pointer-events-none absolute -bottom-px left-0 h-px ${badField !== null ? \"bg-red-500\" : \"bg-primary\"}`}\n              style={{\n                width: marks[underline]?.w ?? 0,\n                transform: `translateX(${marks[underline]?.x ?? 0}px)`,\n                opacity: underline >= 0 && marks[underline] && !morph ? 1 : 0,\n                transition: reduced ? \"none\" : `transform 300ms ${EASE}, width 300ms ${EASE}, opacity 200ms ${EASE}`,\n              }}\n            />\n          </div>\n          {zoneOptions.length > 1 ? (\n            <PillSelect\n              label=\"Time zone\"\n              options={zoneOptions}\n              value={zone}\n              align=\"end\"\n              width={250}\n              display={(o) => o?.label ?? cityOf(zone)}\n              onValueChange={(tz) => {\n                if (timeZone === undefined) setOwnZone(tz);\n                onTimeZoneChange?.(tz);\n              }}\n            />\n          ) : (\n            <span className=\"shrink-0 text-[12px] font-medium text-muted-foreground\">{cityOf(zone)}</span>\n          )}\n        </div>\n\n        {/* The name of each part, under it; folds away while you write in English. */}\n        <div className=\"grid\" style={{ gridTemplateRows: read.kind === \"english\" ? \"0fr\" : \"1fr\", transition: reduced ? \"none\" : `grid-template-rows 320ms ${EASE}` }}>\n          <div className=\"min-h-0 overflow-hidden\">\n            <div ref={labelRow} aria-hidden=\"true\" className=\"relative mt-0.5 h-4 text-[10.5px] leading-4\">\n              {FIELD_LABELS.map((name, i) => {\n                const m = marks[i];\n                const tone = badField === i ? errorTone : activeField === i ? \"text-foreground\" : \"text-muted-foreground\";\n                return (\n                  <span\n                    key={name}\n                    ref={(el) => {\n                      labelRefs.current[i] = el;\n                    }}\n                    className={`absolute left-0 top-0 whitespace-nowrap transition-colors duration-200 ${tone}`}\n                    style={{\n                      transform: `translateX(${labelX[i] ?? i * 48}px)`,\n                      opacity: m && read.kind !== \"english\" ? 1 : 0,\n                      transition: reduced ? \"none\" : `transform 340ms ${EASE}, opacity 200ms ${EASE}, color 200ms`,\n                    }}\n                  >\n                    {name}\n                  </span>\n                );\n              })}\n            </div>\n          </div>\n        </div>\n      </div>\n\n      <p id={`${uid}-line`} className={`mt-2 min-h-[1.45em] pl-1 text-[12.5px] leading-[1.45] transition-colors duration-300 ${read.kind === \"error\" ? errorTone : \"text-muted-foreground\"}`}>\n        <Line text={lineText} reduced={reduced} />\n      </p>\n\n      {runCount > 0 && (\n        <div className=\"mt-3 border-t border-border pt-3\">\n          <div className=\"flex items-baseline justify-between gap-3 text-[11.5px] text-muted-foreground\">\n            <span className=\"font-medium\">Next runs</span>\n            <span className=\"truncate\">\n              {zone === \"UTC\" ? \"Times in UTC\" : `Times in ${cityOf(zone)} · ${zoneOffset(zone)}`}\n            </span>\n          </div>\n          {now && upcoming.length === 0 && shownSchedule && (\n            <p className={`mt-2 text-[12.5px] ${errorTone}`}>This never runs: no date in the next six years matches it.</p>\n          )}\n          <ol\n            aria-label=\"Next runs\"\n            className=\"mt-1.5 transition-opacity duration-300\"\n            style={{ opacity: schedule ? 1 : 0.45, transitionTimingFunction: EASE }}\n          >\n            {upcoming.map((r, i) => {\n              const w = r.wall;\n              const dayDate = new Date(Date.UTC(w.year, w.month - 1, w.day));\n              const day = (w.year === nowYear ? dateFmt : yearFmt).format(dayDate);\n              const h = hour12 ? w.hour % 12 || 12 : w.hour;\n              const note = r.skipped\n                ? `Clocks go forward that night: ${String(w.hour).padStart(2, \"0\")}:${String(w.minute).padStart(2, \"0\")} doesn’t happen in ${cityOf(zone)}.`\n                : r.repeated\n                  ? `Clocks go back that night: ${String(w.hour).padStart(2, \"0\")}:${String(w.minute).padStart(2, \"0\")} happens twice in ${cityOf(zone)}.`\n                  : null;\n              return (\n                <li key={i} className=\"border-b border-border/60 py-1.5 last:border-b-0\">\n                  <span className=\"sr-only\">\n                    {day}, {String(w.hour).padStart(2, \"0\")}:{String(w.minute).padStart(2, \"0\")}, {now ? relative(r.at.getTime() - now.getTime()) : \"\"}\n                    {note ? `. ${note}` : \"\"}\n                  </span>\n                  <span aria-hidden=\"true\" className=\"flex items-baseline gap-3 text-[13px]\">\n                    <span className=\"w-[6.5rem] shrink-0\">\n                      <TextMorph>{day}</TextMorph>\n                    </span>\n                    <span className=\"inline-flex font-medium tabular-nums\">\n                      <NumberRoll value={h} format={hour12 ? undefined : { minimumIntegerDigits: 2 }} duration={700} />\n                      <span>:</span>\n                      <NumberRoll value={w.minute} format={{ minimumIntegerDigits: 2 }} duration={700} />\n                      {hour12 && <TextMorph className=\"ml-0.5\">{w.hour < 12 ? \"am\" : \"pm\"}</TextMorph>}\n                    </span>\n                    {note && (\n                      <svg viewBox=\"0 0 16 16\" fill=\"none\" className=\"size-3.5 shrink-0 self-center text-amber-600 dark:text-amber-400\">\n                        <circle cx=\"8\" cy=\"8\" r=\"6.25\" stroke=\"currentColor\" strokeWidth=\"1.5\" />\n                        <path d=\"M8 4.75v3.75\" stroke=\"currentColor\" strokeWidth=\"1.75\" strokeLinecap=\"round\" />\n                        <circle cx=\"8\" cy=\"11\" r=\"1\" fill=\"currentColor\" />\n                      </svg>\n                    )}\n                    <span className=\"ml-auto truncate text-[12px] text-muted-foreground\">\n                      <TextMorph>{now ? relative(r.at.getTime() - now.getTime()) : \"\"}</TextMorph>\n                    </span>\n                  </span>\n                  {note && <span className=\"mt-0.5 block text-[11.5px] leading-snug text-amber-700 dark:text-amber-400\">{note}</span>}\n                </li>\n              );\n            })}\n          </ol>\n        </div>\n      )}\n\n      {name && <input type=\"hidden\" name={name} value={committed} />}\n      <span className=\"sr-only\" role=\"status\" aria-live=\"polite\">\n        {announce}\n      </span>\n    </div>\n  );\n}\n\nexport { describeCron, nextRuns, parseCron, parseSchedule };\nexport type { CronSchedule };\nexport { CRON_FIELDS };\n"},{"path":"registry/cron-parse.ts","type":"registry:component","content":"// Hairline UI · cron-input · MIT License\n// Copyright (c) 2026 Hairline UI · https://hairlineui.com/license#components\n\n/* ─────────────────────────────────────────────────────────\n * CRON PARSE: read, explain and run a cron expression\n *\n *   parseCron      five fields (minute hour day month weekday),\n *                  @macros, names (MON, JAN), ranges, lists,\n *                  steps, L for the last day of the month, and\n *                  FRI#2 / 5L for the nth or last weekday. Errors\n *                  say which field and why\n *   describeCron   the schedule in plain words: \"At 09:00,\n *                  Monday to Friday\"\n *   nextRuns       the next times it fires in a time zone,\n *                  flagging clock changes that skip or repeat one\n *   parseSchedule  plain English to cron: \"every weekday at 9am\",\n *                  \"every 15 minutes from 9 to 5\", \"last friday\n *                  of the month at 17:30\"\n *\n * Day and weekday follow Unix cron: when both are set, either\n * one matching is enough; when one starts with *, the other\n * decides. 0 and 7 are both Sunday.\n * ───────────────────────────────────────────────────────── */\n\nexport type CronFieldName = \"minute\" | \"hour\" | \"day\" | \"month\" | \"weekday\";\n\nexport const CRON_FIELDS: { name: CronFieldName; min: number; max: number }[] = [\n  { name: \"minute\", min: 0, max: 59 },\n  { name: \"hour\", min: 0, max: 23 },\n  { name: \"day\", min: 1, max: 31 },\n  { name: \"month\", min: 1, max: 12 },\n  { name: \"weekday\", min: 0, max: 7 },\n];\n\nconst MONTHS = [\"JAN\", \"FEB\", \"MAR\", \"APR\", \"MAY\", \"JUN\", \"JUL\", \"AUG\", \"SEP\", \"OCT\", \"NOV\", \"DEC\"];\nconst DAYS = [\"SUN\", \"MON\", \"TUE\", \"WED\", \"THU\", \"FRI\", \"SAT\"];\nconst MONTH_NAMES = [\"January\", \"February\", \"March\", \"April\", \"May\", \"June\", \"July\", \"August\", \"September\", \"October\", \"November\", \"December\"];\nconst DAY_NAMES = [\"Sunday\", \"Monday\", \"Tuesday\", \"Wednesday\", \"Thursday\", \"Friday\", \"Saturday\"];\nconst NTH = [\"\", \"first\", \"second\", \"third\", \"fourth\", \"fifth\"];\nconst MACROS: Record<string, string> = {\n  \"@yearly\": \"0 0 1 1 *\",\n  \"@annually\": \"0 0 1 1 *\",\n  \"@monthly\": \"0 0 1 * *\",\n  \"@weekly\": \"0 0 * * 0\",\n  \"@daily\": \"0 0 * * *\",\n  \"@midnight\": \"0 0 * * *\",\n  \"@hourly\": \"0 * * * *\",\n};\n\nexport interface CronSchedule {\n  /** The five fields, tidied: single spaces, macros expanded. */\n  expression: string;\n  minutes: number[];\n  hours: number[];\n  days: number[];\n  months: number[];\n  /** 0–6, Sunday first. */\n  weekdays: number[];\n  /** L in the day field. */\n  lastDay: boolean;\n  /** FRI#2 (nth 2) or 5L (nth -1). */\n  nthWeekdays: { weekday: number; nth: number }[];\n  /** The field started with * (or ?), so the other one decides. */\n  dayStar: boolean;\n  weekdayStar: boolean;\n}\n\nexport type CronParse = { ok: true; schedule: CronSchedule } | { ok: false; message: string; field: number | null };\n\n/** Where each field sits in the text, for pointing at it. */\nexport function cronTokens(text: string): { start: number; end: number; text: string }[] {\n  const out: { start: number; end: number; text: string }[] = [];\n  const re = /\\S+/g;\n  let m: RegExpExecArray | null;\n  while ((m = re.exec(text))) out.push({ start: m.index, end: m.index + m[0].length, text: m[0] });\n  return out;\n}\n\nconst range = (a: number, b: number, step = 1) => {\n  const out: number[] = [];\n  for (let i = a; i <= b; i += step) out.push(i);\n  return out;\n};\n\ntype Field = { values: Set<number>; star: boolean; last?: boolean; nth?: { weekday: number; nth: number }[] };\n\nfunction value(raw: string, f: number): number | string {\n  const { min, max, name } = CRON_FIELDS[f];\n  const up = raw.toUpperCase();\n  if (f === 3 && MONTHS.includes(up)) return MONTHS.indexOf(up) + 1;\n  if (f === 4 && DAYS.includes(up)) return DAYS.indexOf(up);\n  if (!/^\\d+$/.test(raw)) {\n    if (f === 3) return `“${raw}” isn’t a month. Use 1–12 or JAN–DEC.`;\n    if (f === 4) return `“${raw}” isn’t a weekday. Use 0–6 or SUN–SAT.`;\n    return `“${raw}” isn’t a number. The ${name} goes from ${min} to ${max}.`;\n  }\n  const n = Number(raw);\n  if (n < min || n > max) return `The ${name} goes from ${min} to ${max}${f === 4 ? \" (0 and 7 are Sunday)\" : \"\"}, not ${n}.`;\n  return n;\n}\n\nfunction field(token: string, f: number): Field | string {\n  const { min, max, name } = CRON_FIELDS[f];\n  const out: Field = { values: new Set(), star: token[0] === \"*\" || token[0] === \"?\" };\n  for (const part of token.split(\",\")) {\n    if (!part) return `There’s an empty item in the ${name} list.`;\n    if (f === 2 && /^L$/i.test(part)) {\n      out.last = true;\n      continue;\n    }\n    if (f === 4) {\n      const nth = part.match(/^(\\w+)(?:#(\\d)|(L))$/i);\n      if (nth) {\n        const day = value(nth[1], 4);\n        if (typeof day === \"string\") return day;\n        const n = nth[3] ? -1 : Number(nth[2]);\n        if (n === 0 || n > 5) return `#${nth[2]} can’t be: a weekday comes 1 to 5 times a month.`;\n        (out.nth ??= []).push({ weekday: day % 7, nth: n });\n        continue;\n      }\n    }\n    if (/W/i.test(part) && f === 2) return \"W (nearest weekday) isn’t supported. Use a day number or L.\";\n    const [base, stepText] = part.split(\"/\");\n    let step = 1;\n    if (stepText !== undefined) {\n      if (!/^\\d+$/.test(stepText) || Number(stepText) < 1) return `A step needs a number after the slash, like ${base || \"*\"}/5.`;\n      step = Number(stepText);\n      if (step > max) return `A ${name} step of ${step} is bigger than the ${name} range.`;\n    }\n    let lo: number;\n    let hi: number;\n    if (base === \"*\" || base === \"?\") {\n      lo = min;\n      hi = f === 4 ? 6 : max;\n    } else if (base.includes(\"-\")) {\n      const [a, b, extra] = base.split(\"-\");\n      if (extra !== undefined || !a || !b) return `“${base}” isn’t a range. Write it as low-high, like 1-5.`;\n      const va = value(a, f);\n      const vb = value(b, f);\n      if (typeof va === \"string\") return va;\n      if (typeof vb === \"string\") return vb;\n      if (va > vb && !(f === 4 && vb === 0)) return `Ranges go from low to high: ${b}-${a}, not ${base}.`;\n      lo = va;\n      hi = f === 4 && vb === 0 ? 7 : vb;\n    } else {\n      const v = value(base, f);\n      if (typeof v === \"string\") return v;\n      lo = v;\n      hi = stepText !== undefined ? (f === 4 ? 6 : max) : v;\n    }\n    for (const n of range(lo, hi, step)) out.values.add(f === 4 ? n % 7 : n);\n  }\n  return out;\n}\n\nexport function parseCron(text: string): CronParse {\n  const trimmed = text.trim();\n  if (!trimmed) return { ok: false, message: \"Type a cron expression, like 0 9 * * 1-5.\", field: null };\n  const macro = MACROS[trimmed.toLowerCase()];\n  const tokens = (macro ?? trimmed).split(/\\s+/);\n  if (trimmed.startsWith(\"@\") && !macro) return { ok: false, message: `${trimmed} isn’t a shortcut cron knows. Try @daily, @weekly or @hourly.`, field: 0 };\n  if (tokens.length !== 5)\n    return {\n      ok: false,\n      message: tokens.length < 5 ? `Cron has five parts: minute, hour, day, month, weekday. This has ${tokens.length}.` : `Cron has five parts; this has ${tokens.length}. Seconds and years aren’t supported.`,\n      field: tokens.length > 5 ? 5 : null,\n    };\n  const fields: Field[] = [];\n  for (let i = 0; i < 5; i++) {\n    const r = field(tokens[i], i);\n    if (typeof r === \"string\") return { ok: false, message: r, field: i };\n    fields.push(r);\n  }\n  const sorted = (f: Field) => [...f.values].sort((a, b) => a - b);\n  return {\n    ok: true,\n    schedule: {\n      expression: tokens.join(\" \"),\n      minutes: sorted(fields[0]),\n      hours: sorted(fields[1]),\n      days: sorted(fields[2]),\n      months: sorted(fields[3]),\n      weekdays: sorted(fields[4]),\n      lastDay: Boolean(fields[2].last),\n      nthWeekdays: fields[4].nth ?? [],\n      dayStar: fields[2].star,\n      weekdayStar: fields[4].star,\n    },\n  };\n}\n\n/* ── Plain words ──────────────────────────────────────── */\n\nconst pad = (n: number) => String(n).padStart(2, \"0\");\nconst ordinal = (n: number) => {\n  const s = n % 100 >= 11 && n % 100 <= 13 ? \"th\" : [\"th\", \"st\", \"nd\", \"rd\"][n % 10] ?? \"th\";\n  return `${n}${s}`;\n};\nconst list = (items: string[]) => (items.length <= 1 ? (items[0] ?? \"\") : `${items.slice(0, -1).join(\", \")} and ${items[items.length - 1]}`);\n/** [a..b] if the values run without a gap. */\nconst run = (v: number[]) => (v.length > 1 && v.every((n, i) => i === 0 || n === v[i - 1] + 1) ? [v[0], v[v.length - 1]] : null);\n/** k if the values are min, min+k, min+2k… to the end of the range. */\nconst stepOf = (v: number[], min: number, max: number) => {\n  if (v.length < 2 || v[0] !== min) return null;\n  const k = v[1] - v[0];\n  if (k < 2 || !v.every((n, i) => n === min + i * k)) return null;\n  return v[v.length - 1] + k > max ? k : null;\n};\n\nexport function describeCron(s: CronSchedule, { hour12 = false }: { hour12?: boolean } = {}): string {\n  const time = (h: number, m: number) => (hour12 ? `${h % 12 || 12}:${pad(m)}${h < 12 ? \"am\" : \"pm\"}` : `${pad(h)}:${pad(m)}`);\n  const M = s.minutes;\n  const H = s.hours;\n  const allM = M.length === 60;\n  const allH = H.length === 24;\n  const mStep = stepOf(M, 0, 59);\n  const hStep = stepOf(H, 0, 23);\n  const hRun = run(H);\n\n  let when: string;\n  if (allM && allH) when = \"Every minute\";\n  else if (allM && hRun) when = `Every minute from ${time(hRun[0], 0)} to ${time(hRun[1], 59)}`;\n  else if (mStep && allH) when = `Every ${mStep} minutes`;\n  else if (mStep && hRun) when = `Every ${mStep} minutes from ${time(hRun[0], 0)} to ${time(hRun[1], M[M.length - 1])}`;\n  else if (M.length === 1 && allH) when = M[0] === 0 ? \"Every hour\" : `Every hour at :${pad(M[0])}`;\n  else if (M.length === 1 && hStep) when = `Every ${hStep} hours${M[0] ? ` at :${pad(M[0])}` : \"\"}`;\n  else if (M.length === 1 && hRun && H.length >= 4) when = `Every hour from ${time(hRun[0], M[0])} to ${time(hRun[1], M[0])}`;\n  else if (M.length * H.length <= 6 && !allH && !allM) when = `At ${list(H.flatMap((h) => M.map((m) => time(h, m))))}`;\n  else if (M.length <= 4 && allH) when = `At ${list(M.map((m) => `:${pad(m)}`))} past every hour`;\n  else {\n    const part = (v: number[], min: number, max: number) => {\n      const k = stepOf(v, min, max);\n      const r = run(v);\n      return k ? `every ${k}` : r ? `${r[0]} to ${r[1]}` : list(v.map(String));\n    };\n    when = `At minute ${allM ? \"every\" : part(M, 0, 59)} past ${allH ? \"every hour\" : `hour ${part(H, 0, 23)}`}`;\n  }\n\n  // Which days.\n  const wd = s.weekdays;\n  const weekdayWords = () => {\n    const nth = s.nthWeekdays.map((n) => `the ${n.nth === -1 ? \"last\" : NTH[n.nth]} ${DAY_NAMES[n.weekday]} of the month`);\n    let plain = \"\";\n    if (wd.length && wd.length < 7) {\n      const r = run(wd);\n      if (wd.length === 5 && wd[0] === 1 && wd[4] === 5) plain = \"Monday to Friday\";\n      else if (wd.length === 2 && wd[0] === 0 && wd[1] === 6) plain = \"on weekends\";\n      else if (r && r[1] - r[0] >= 2) plain = `${DAY_NAMES[r[0]]} to ${DAY_NAMES[r[1]]}`;\n      else plain = wd.length === 1 ? `every ${DAY_NAMES[wd[0]]}` : `on ${list(wd.map((d) => `${DAY_NAMES[d]}s`))}`;\n    }\n    const nthText = nth.length ? `on ${list(nth)}` : \"\";\n    return [plain, nthText].filter(Boolean).join(\" and \");\n  };\n  const dayWords = () => {\n    const days = s.days.length === 31 ? [] : s.days;\n    const k = stepOf(s.days, 1, 31);\n    const parts: string[] = [];\n    if (k) parts.push(`every ${k} days of the month`);\n    else if (days.length) {\n      const r = run(days);\n      parts.push(r && r[1] - r[0] >= 2 ? `on days ${r[0]} to ${r[1]} of the month` : `on the ${list(days.map(ordinal))} of the month`);\n    }\n    if (s.lastDay) parts.push(\"on the last day of the month\");\n    return parts.join(\" and \");\n  };\n  const restrictedDay = s.lastDay || s.days.length < 31;\n  const restrictedWeekday = s.nthWeekdays.length > 0 || wd.length < 7;\n  let days = \"\";\n  // Both set: Unix cron runs on either (unless one starts with *, when both must match).\n  if (restrictedDay && restrictedWeekday) days = !s.dayStar && !s.weekdayStar ? `${dayWords()}, and ${weekdayWords()}` : `${dayWords()}, ${weekdayWords()}`;\n  else if (restrictedWeekday) days = weekdayWords();\n  else if (restrictedDay) days = dayWords();\n\n  // Which months; one day in one month reads as a date.\n  const mo = s.months;\n  let months = \"\";\n  if (mo.length < 12) {\n    const r = run(mo);\n    if (mo.length === 1 && s.days.length === 1 && !restrictedWeekday && !s.lastDay) {\n      days = `on ${s.days[0]} ${MONTH_NAMES[mo[0] - 1]}`;\n    } else months = r && r[1] - r[0] >= 2 ? `in ${MONTH_NAMES[r[0] - 1]} to ${MONTH_NAMES[r[1] - 1]}` : `in ${list(mo.map((m) => MONTH_NAMES[m - 1]))}`;\n  }\n\n  if (!days && !months) return when.startsWith(\"At \") && !when.includes(\"past\") ? `Every day at ${when.slice(3)}` : when;\n  const joiner = (phrase: string) => (phrase.startsWith(\"on \") || phrase.startsWith(\"in \") || phrase.startsWith(\"every \") ? \" \" : \", \");\n  let out = when;\n  if (days) out += joiner(days) + days;\n  if (months) out += (days ? \", \" : joiner(months)) + months;\n  return out;\n}\n\n/* ── Next runs ────────────────────────────────────────── */\n\nexport interface CronRun {\n  at: Date;\n  /** The wall-clock time it was asked for, in the time zone. */\n  wall: { year: number; month: number; day: number; hour: number; minute: number; weekday: number };\n  /** Clocks go forward over this time: it doesn't exist that day. */\n  skipped?: boolean;\n  /** Clocks go back over this time: it happens twice (this is the first). */\n  repeated?: boolean;\n}\n\nconst formatters = new Map<string, Intl.DateTimeFormat>();\nfunction wallAt(epoch: number, timeZone: string) {\n  let f = formatters.get(timeZone);\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\" });\n    formatters.set(timeZone, f);\n  }\n  const p: Record<string, number> = {};\n  for (const part of f.formatToParts(new Date(epoch))) if (part.type !== \"literal\") p[part.type] = Number(part.value);\n  return { year: p.year, month: p.month, day: p.day, hour: p.hour % 24, minute: p.minute, second: p.second };\n}\n/** Minutes the zone is ahead of UTC at an instant. */\nfunction offsetAt(epoch: number, timeZone: string) {\n  const w = wallAt(epoch, timeZone);\n  return Math.round((Date.UTC(w.year, w.month - 1, w.day, w.hour, w.minute, w.second) - Math.floor(epoch / 1000) * 1000) / 60000);\n}\n\n/** The instant a wall-clock time happens in a zone, and whether a clock change skips or repeats it. */\nexport function zonedTime(year: number, month: number, day: number, hour: number, minute: number, timeZone: string) {\n  const guess = Date.UTC(year, month - 1, day, hour, minute);\n  const before = offsetAt(guess - 6 * 3600_000, timeZone);\n  const after = offsetAt(guess + 6 * 3600_000, timeZone);\n  const fits = (t: number) => {\n    const w = wallAt(t, timeZone);\n    return w.year === year && w.month === month && w.day === day && w.hour === hour && w.minute === minute;\n  };\n  if (before === after) return { at: guess - before * 60000, skipped: false, repeated: false };\n  const a = guess - before * 60000;\n  const b = guess - after * 60000;\n  const fa = fits(a);\n  const fb = fits(b);\n  if (fa && fb && a !== b) return { at: Math.min(a, b), skipped: false, repeated: true };\n  if (fa || fb) return { at: fa ? a : b, skipped: false, repeated: false };\n  // In the gap: it would land an hour (the jump) later on the clock.\n  return { at: a, skipped: true, repeated: false };\n}\n\nconst daysIn = (y: number, m: number) => new Date(Date.UTC(y, m, 0)).getUTCDate();\n\n/** The next `count` times the schedule fires after `from`, in `timeZone` (UTC by default). Looks up to six years ahead. */\nexport function nextRuns(s: CronSchedule, { from = new Date(), count = 5, timeZone = \"UTC\" }: { from?: Date; count?: number; timeZone?: string } = {}): CronRun[] {\n  const out: CronRun[] = [];\n  const start = wallAt(from.getTime() + 60_000, timeZone);\n  const months = new Set(s.months);\n  const days = new Set(s.days);\n  const weekdays = new Set(s.weekdays);\n  const first = Date.UTC(start.year, start.month - 1, start.day);\n  for (let i = 0; i < 366 * 6 && out.length < count; i++) {\n    const date = new Date(first + i * 86_400_000);\n    const y = date.getUTCFullYear();\n    const m = date.getUTCMonth() + 1;\n    const d = date.getUTCDate();\n    const wd = date.getUTCDay();\n    if (!months.has(m)) continue;\n    const dim = daysIn(y, m);\n    const dayHit = days.has(d) || (s.lastDay && d === dim);\n    const weekdayHit = weekdays.has(wd) || s.nthWeekdays.some((n) => n.weekday === wd && (n.nth === -1 ? d + 7 > dim : Math.ceil(d / 7) === n.nth));\n    // Unix cron: if either field starts with *, both must match; if both are set, either one is enough.\n    if (s.dayStar || s.weekdayStar ? !(dayHit && weekdayHit) : !(dayHit || weekdayHit)) continue;\n    for (const h of s.hours) {\n      if (i === 0 && h < start.hour) continue;\n      for (const min of s.minutes) {\n        if (i === 0 && h === start.hour && min < start.minute) continue;\n        const z = zonedTime(y, m, d, h, min, timeZone);\n        if (z.at <= from.getTime()) continue;\n        out.push({ at: new Date(z.at), wall: { year: y, month: m, day: d, hour: h, minute: min, weekday: wd }, skipped: z.skipped || undefined, repeated: z.repeated || undefined });\n        if (out.length === count) return out;\n      }\n    }\n  }\n  return out;\n}\n\n/* ── Plain English in ─────────────────────────────────── */\n\nconst DAY_WORDS: Record<string, number> = {\n  sun: 0, sunday: 0, mon: 1, monday: 1, tue: 2, tues: 2, tuesday: 2, wed: 3, weds: 3, wednesday: 3,\n  thu: 4, thur: 4, thurs: 4, thursday: 4, fri: 5, friday: 5, sat: 6, saturday: 6,\n};\nconst MONTH_WORDS: Record<string, number> = Object.fromEntries(\n  MONTH_NAMES.flatMap((name, i) => [\n    [name.toLowerCase(), i + 1],\n    [name.slice(0, 3).toLowerCase(), i + 1],\n  ]).concat([[\"sept\", 9]]),\n);\nconst NTH_WORDS: Record<string, number> = { first: 1, \"1st\": 1, second: 2, \"2nd\": 2, third: 3, \"3rd\": 3, fourth: 4, \"4th\": 4, last: -1 };\nconst DAY_RE = `(?:${Object.keys(DAY_WORDS).sort((a, b) => b.length - a.length).join(\"|\")})`;\nconst MONTH_RE = `(?:${Object.keys(MONTH_WORDS).sort((a, b) => b.length - a.length).join(\"|\")})`;\nconst TIME_ANY = String.raw`(?:noon|midday|midnight|\\d{1,2}(?:[:.h]\\d{2})?\\s?(?:am|pm)?)`;\nconst TIME_STRICT = String.raw`(?:noon|midday|midnight|\\d{1,2}[:.h]\\d{2}\\s?(?:am|pm)?|\\d{1,2}\\s?(?:am|pm))`;\nconst FILLER = new Set([\"at\", \"on\", \"the\", \"of\", \"and\", \"every\", \"each\", \"in\", \"run\", \"runs\", \"it\", \"please\", \"a\", \"month\", \"day\", \"days\"]);\n\nfunction readTime(raw: string): { h: number; m: number } | null {\n  const t = raw.trim();\n  if (t === \"noon\" || t === \"midday\") return { h: 12, m: 0 };\n  if (t === \"midnight\") return { h: 0, m: 0 };\n  const m = t.match(/^(\\d{1,2})(?:[:.h](\\d{2}))?\\s?(am|pm)?$/);\n  if (!m) return null;\n  let h = Number(m[1]);\n  const min = m[2] ? Number(m[2]) : 0;\n  if (m[3]) {\n    if (h < 1 || h > 12) return null;\n    h = (h % 12) + (m[3] === \"pm\" ? 12 : 0);\n  }\n  if (h > 23 || min > 59) return null;\n  return { h, m: min };\n}\n\n/** Turns a schedule written in English into cron, or null if it can't be read. */\nexport function parseSchedule(text: string): string | null {\n  let s = ` ${text.toLowerCase().replace(/[,;]/g, \" \").replace(/\\b(through|thru|until|till)\\b/g, \"to\").replace(/\\s+/g, \" \").trim()} `;\n  const take = (re: RegExp) => {\n    const m = s.match(re);\n    if (m) s = s.replace(m[0], \" \");\n    return m;\n  };\n  let minute: string | null = null;\n  let hour: string | null = null;\n  let dom = \"*\";\n  let mon = \"*\";\n  let dow = \"*\";\n  let m: RegExpMatchArray | null;\n\n  if ((m = take(/ every (\\d+ )?(?:minutes?|mins?) /))) {\n    const n = m[1] ? Number(m[1]) : 1;\n    if (n < 1 || n > 59) return null;\n    minute = n === 1 ? \"*\" : `*/${n}`;\n    hour = \"*\";\n  } else if ((m = take(/ every (\\d+ )?(?:hours?|hrs?) /)) || (m = take(/ hourly /))) {\n    const n = m[1] ? Number(m[1]) : 1;\n    if (n < 1 || n > 23) return null;\n    minute = \"0\";\n    hour = n === 1 ? \"*\" : `*/${n}`;\n    const past = take(/ at :?(\\d{1,2})(?: minutes)?(?: past)? /);\n    if (past) {\n      if (Number(past[1]) > 59) return null;\n      minute = String(Number(past[1]));\n    }\n  }\n\n  // A window for steps: \"from 9am to 5pm\", \"between 9 and 17\".\n  if ((m = take(new RegExp(` (?:from|between) (${TIME_ANY}) (?:to|and|-) (${TIME_ANY}) `)))) {\n    const a = readTime(m[1]);\n    const b = readTime(m[2]);\n    if (!a || !b || minute === null || hour !== \"*\") return null;\n    // \"to 5pm\" means stop before 5pm.\n    const last = b.m === 0 ? b.h - 1 : b.h;\n    if (last < a.h) return null;\n    hour = last === a.h ? String(a.h) : `${a.h}-${last}`;\n  }\n\n  // Days of the week.\n  if ((m = take(new RegExp(` (?:on )?(?:the )?(first|1st|second|2nd|third|3rd|fourth|4th|last) (${DAY_RE}) (?:of )?(?:the |every |each )?(?:month )?`)))) {\n    const n = NTH_WORDS[m[1]];\n    const d = DAY_WORDS[m[2]];\n    dow = n === -1 ? `${d}L` : `${d}#${n}`;\n  } else if ((m = take(/ (?:every |on )?(?:weekdays?|workdays?|business days?) /))) dow = \"1-5\";\n  else if ((m = take(/ (?:every |on )?(?:the )?weekends? /))) dow = \"0,6\";\n  else if ((m = take(new RegExp(` (?:every |on )?(${DAY_RE})s? (?:to|-) (${DAY_RE})s? `)))) {\n    const a = DAY_WORDS[m[1]];\n    const b = DAY_WORDS[m[2]];\n    dow = a < b ? `${a}-${b}` : `${a}-${b === 0 ? 7 : b}`;\n  } else if ((m = take(new RegExp(` (?:every |on |each )?((?:${DAY_RE})s?(?:(?: and | & | |/)(?:${DAY_RE})s?)*) `)))) {\n    const found = m[1].split(/ and | & | |\\//).map((w) => DAY_WORDS[w.replace(/s$/, \"\")] ?? DAY_WORDS[w]);\n    if (found.some((d) => d === undefined)) return null;\n    dow = [...new Set(found)].sort((a, b) => a - b).join(\",\");\n  }\n\n  // Days of the month.\n  if ((m = take(/ (?:on )?(?:the )?last day (?:of )?(?:the |every |each )?(?:month )?/))) dom = \"L\";\n  else if ((m = take(new RegExp(` (?:on )?(?:the )?(${MONTH_RE}) (\\\\d{1,2})(?:st|nd|rd|th)? `))) || (m = take(new RegExp(` (?:on )?(?:the )?(\\\\d{1,2})(?:st|nd|rd|th)? (?:of )?(${MONTH_RE}) `)))) {\n    const monthFirst = isNaN(Number(m[1]));\n    const mo = MONTH_WORDS[monthFirst ? m[1] : m[2]];\n    const day = Number(monthFirst ? m[2] : m[1]);\n    if (day < 1 || day > daysIn(2024, mo)) return null;\n    mon = String(mo);\n    dom = String(day);\n  } else if ((m = take(/ (?:on )?(?:the )?((?:\\d{1,2}(?:st|nd|rd|th))(?:(?: and | & | )(?:the )?\\d{1,2}(?:st|nd|rd|th))*) (?:of )?(?:the |every |each )?(?:month )?/))) {\n    const nums = m[1].match(/\\d{1,2}/g)!.map(Number);\n    if (nums.some((n) => n < 1 || n > 31)) return null;\n    dom = [...new Set(nums)].sort((a, b) => a - b).join(\",\");\n  } else if (take(/ (?:monthly|every month|each month|once a month) /)) dom = \"1\";\n\n  // Months.\n  if ((m = take(new RegExp(` (?:in |every |each |during )?((?:${MONTH_RE})(?:(?: and | & | |-| to )(?:${MONTH_RE}))*) `)))) {\n    const range = m[1].match(new RegExp(`^(${MONTH_RE})(?:-| to )(${MONTH_RE})$`));\n    if (range) mon = `${MONTH_WORDS[range[1]]}-${MONTH_WORDS[range[2]]}`;\n    else mon = [...new Set(m[1].split(/ and | & | /).map((w) => MONTH_WORDS[w]))].sort((a, b) => a - b).join(\",\");\n    if (mon.includes(\"undefined\")) return null;\n  }\n  if (take(/ (?:yearly|annually|every year|each year|once a year) /)) {\n    if (dom === \"*\") dom = \"1\";\n    if (mon === \"*\") mon = \"1\";\n  }\n  if (take(/ (?:weekly|every week|each week|once a week) /) && dow === \"*\") dow = \"1\";\n  take(/ (?:daily|every ?day|each day|nightly|every night|every morning) /);\n\n  // Times of day.\n  if (minute === null) {\n    const times = take(new RegExp(` at (${TIME_ANY}(?:(?: and | & | )${TIME_ANY})*) `)) ?? take(new RegExp(` (${TIME_STRICT}(?:(?: and | & | )${TIME_STRICT})*) `));\n    if (times) {\n      const parsed = times[1].split(/ and | & | (?=\\d|noon|mid)/).map(readTime);\n      if (parsed.some((t) => !t)) return null;\n      const ts = parsed as { h: number; m: number }[];\n      const mins = [...new Set(ts.map((t) => t.m))];\n      const hours = [...new Set(ts.map((t) => t.h))].sort((a, b) => a - b);\n      // Cron can't say \"9:30 and 17:00\": every time must share its minute.\n      if (mins.length > 1) return null;\n      minute = String(mins[0]);\n      hour = hours.join(\",\");\n    } else {\n      minute = \"0\";\n      hour = \"0\";\n    }\n  }\n\n  const left = s.trim().split(\" \").filter((w) => w && !FILLER.has(w));\n  if (left.length) return null;\n  return `${minute} ${hour} ${dom} ${mon} ${dow}`;\n}\n"},{"path":"registry/pill-select.tsx","type":"registry:component","content":"// Hairline UI · cron-input · 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\";\n\n/* ─────────────────────────────────────────────────────────\n * PILL SELECT: a select that's the same surface as its pill\n *\n *   open      the pill grows into its list (size and corners\n *             ease) from the edge it's lined up with, while its\n *             label fades into the options; it opens upward when\n *             there's no room below, and long lists scroll\n *   pick      the list folds back into the pill and the label\n *             morphs to the new choice; the current option\n *             carries a drawn check\n *   keys      arrows move a highlight that glides between rows,\n *             Home and End jump, typing a few letters finds an\n *             option, Enter picks, Escape folds it back\n *\n * The list lives in a portal on the page's body, placed over\n * the pill, so a clipping toolbar, fold or scroller never cuts\n * it off; it carries the theme's colours with it. Scrolling\n * the page closes it.\n *\n * Used wherever a component needs a choice from a list; never\n * the browser's own dropdown, which can't follow the theme.\n * ───────────────────────────────────────────────────────── */\n\nexport interface PillOption {\n  value: string;\n  label: string;\n  /** A second line in the list, e.g. a sample value. */\n  description?: string;\n}\n\nexport interface PillSelectProps {\n  options: PillOption[];\n  value: string | null | undefined;\n  onValueChange: (value: string) => void;\n  /** Accessible name, e.g. \"Column for Email\". */\n  label: string;\n  /** Shown while nothing is chosen. */\n  placeholder?: string;\n  /** What the pill shows for a choice; the option's label by default. */\n  display?: (option: PillOption | undefined) => string;\n  /** The pill's contents, when it needs more than text (an icon that turns). */\n  renderValue?: (option: PillOption | undefined, open: boolean) => React.ReactNode;\n  /** Monospace labels, for code-like values (event names, column headers). */\n  mono?: boolean;\n  /** Which edge of the pill the list lines up with. */\n  align?: \"start\" | \"end\";\n  /** The list's width; never narrower than the pill. */\n  width?: number;\n  /** Fill the width it's given. */\n  block?: boolean;\n  /** Dims the pill's text, e.g. for \"Don't import\". */\n  muted?: boolean;\n  disabled?: boolean;\n  className?: string;\n}\n\ntype Phase = \"closed\" | \"opening\" | \"open\" | \"closing\";\n\nconst EASE = \"cubic-bezier(0.16,1,0.3,1)\";\nconst DURATION = 420;\nconst MAX_LIST = 280;\n// The theme travels with the list, so it matches the pill wherever the pill's colours were set.\nconst TOKENS = [\"--background\", \"--foreground\", \"--popover\", \"--popover-foreground\", \"--border\", \"--accent\", \"--accent-foreground\", \"--primary\", \"--primary-foreground\", \"--muted\", \"--muted-foreground\", \"--ring\"];\nconst FOCUS = \"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40\";\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\nfunction Chevron({ up, reduced }: { up: boolean; reduced: boolean }) {\n  return (\n    <svg viewBox=\"0 0 16 16\" fill=\"none\" aria-hidden=\"true\" className=\"size-3 shrink-0 text-muted-foreground\" style={{ transform: up ? \"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  );\n}\n\nexport function PillSelect({\n  options,\n  value,\n  onValueChange,\n  label,\n  placeholder = \"Choose\",\n  display,\n  renderValue,\n  mono = false,\n  align = \"start\",\n  width = 240,\n  block = false,\n  muted = false,\n  disabled = false,\n  className = \"\",\n}: PillSelectProps) {\n  const id = React.useId();\n  const reduced = useReducedMotion();\n  const [phase, setPhase] = React.useState<Phase>(\"closed\");\n  const [active, setActive] = React.useState(0);\n  const [pill, setPill] = React.useState({ width: 0, height: 28 });\n  const [panelHeight, setPanelHeight] = React.useState(0);\n  const [fit, setFit] = React.useState({ up: false, width, shift: 0, x: 0, y: 0, theme: {} as Record<string, string> });\n  const [highlight, setHighlight] = React.useState({ top: 0, height: 0, ready: false });\n  const [quiet, setQuiet] = React.useState(false);\n  const wrapRef = React.useRef<HTMLDivElement>(null);\n  const triggerRef = React.useRef<HTMLButtonElement>(null);\n  const panelRef = React.useRef<HTMLDivElement>(null);\n  const surfaceRef = React.useRef<HTMLDivElement>(null);\n  const listRef = React.useRef<HTMLDivElement>(null);\n  const rowRefs = React.useRef<(HTMLDivElement | null)[]>([]);\n  const timer = React.useRef(0);\n  const typed = React.useRef({ text: \"\", at: 0 });\n  const selected = options.find((o) => o.value === value);\n  const expanded = phase === \"open\";\n  const mounted = phase !== \"closed\";\n  const text = display ? display(selected) : (selected?.label ?? placeholder);\n\n  const measureFit = () => {\n    const wrap = wrapRef.current;\n    if (!wrap) return;\n    const rect = wrap.getBoundingClientRect();\n    const vw = document.documentElement.clientWidth;\n    const w = Math.min(Math.max(width, rect.width), vw - 24);\n    // Lined up with the pill's edge, nudged back on screen.\n    const shift = align === \"start\" ? Math.min(0, vw - 12 - (rect.left + w)) : Math.max(0, 12 - (rect.right - w));\n    const estimate = Math.min(MAX_LIST, options.length * (options.some((o) => o.description) ? 46 : 32)) + 12;\n    const vh = window.innerHeight;\n    const below = vh - 8 - rect.top;\n    const above = rect.bottom - 8;\n    const up = below < estimate + 8 && above > below;\n    // Fixed coordinates of the pill's corner the list grows from.\n    const x = align === \"start\" ? rect.left : vw - rect.right;\n    const y = up ? vh - rect.bottom : rect.top;\n    const cs = getComputedStyle(wrap);\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({ up, width: w, shift, x, y, theme });\n  };\n\n  const open = () => {\n    if (disabled || phase === \"open\" || phase === \"opening\") return;\n    window.clearTimeout(timer.current);\n    measureFit();\n    setActive(Math.max(0, options.findIndex((o) => o.value === value)));\n    setPhase(reduced ? \"open\" : \"opening\");\n  };\n\n  const close = 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(\"closing\");\n      timer.current = window.setTimeout(() => setPhase(\"closed\"), DURATION);\n    },\n    [reduced],\n  );\n\n  React.useEffect(() => {\n    if (phase !== \"opening\") return;\n    let inner = 0;\n    const outer = requestAnimationFrame(() => (inner = requestAnimationFrame(() => setPhase(\"open\"))));\n    return () => {\n      cancelAnimationFrame(outer);\n      cancelAnimationFrame(inner);\n    };\n  }, [phase]);\n  React.useEffect(() => () => window.clearTimeout(timer.current), []);\n\n  React.useLayoutEffect(() => {\n    const trigger = triggerRef.current;\n    if (!trigger) return;\n    const measure = () => setPill((p) => (p.width === trigger.offsetWidth && p.height === trigger.offsetHeight ? p : { width: trigger.offsetWidth, height: trigger.offsetHeight }));\n    measure();\n    const observer = new ResizeObserver(measure);\n    observer.observe(trigger);\n    return () => observer.disconnect();\n  }, []);\n\n  React.useLayoutEffect(() => {\n    const panel = panelRef.current;\n    if (panel) setPanelHeight(panel.offsetHeight);\n  }, [mounted, options]);\n\n  // The highlight glides to the active row, and the list scrolls to keep it in view.\n  React.useLayoutEffect(() => {\n    const row = rowRefs.current[active];\n    const list = listRef.current;\n    if (!row || !list) return;\n    setHighlight((h) => (h.top === row.offsetTop && h.height === row.offsetHeight && h.ready === (h.ready || expanded) ? h : { top: row.offsetTop, height: row.offsetHeight, ready: h.ready || expanded }));\n    const scroller = list.parentElement;\n    if (scroller && scroller.scrollHeight > scroller.clientHeight) {\n      if (row.offsetTop < scroller.scrollTop) scroller.scrollTop = row.offsetTop - 4;\n      else if (row.offsetTop + row.offsetHeight > scroller.scrollTop + scroller.clientHeight) scroller.scrollTop = row.offsetTop + row.offsetHeight - scroller.clientHeight + 4;\n    }\n  }, [active, expanded, mounted]);\n\n  React.useEffect(() => {\n    if (!mounted) setHighlight({ top: 0, height: 0, ready: false });\n  }, [mounted]);\n\n  React.useEffect(() => {\n    if (expanded) listRef.current?.focus({ preventScroll: true });\n  }, [expanded]);\n\n  React.useEffect(() => {\n    if (!expanded) return;\n    const inside = (n: Node) => Boolean(wrapRef.current?.contains(n) || surfaceRef.current?.contains(n));\n    const onDown = (e: PointerEvent) => {\n      if (!inside(e.target as Node)) close(false);\n    };\n    // The list sits where the pill was; if the page moves under it, it goes at once rather than fold in the wrong place.\n    const gone = () => {\n      window.clearTimeout(timer.current);\n      setPhase(\"closed\");\n    };\n    const onScroll = (e: Event) => {\n      if (!surfaceRef.current?.contains(e.target as Node)) gone();\n    };\n    const onResize = gone;\n    document.addEventListener(\"pointerdown\", onDown);\n    window.addEventListener(\"scroll\", onScroll, true);\n    window.addEventListener(\"resize\", onResize);\n    return () => {\n      document.removeEventListener(\"pointerdown\", onDown);\n      window.removeEventListener(\"scroll\", onScroll, true);\n      window.removeEventListener(\"resize\", onResize);\n    };\n  }, [expanded, close]);\n\n  const choose = (o: PillOption) => {\n    close();\n    if (o.value !== value) onValueChange(o.value);\n  };\n\n  const onListKey = (e: React.KeyboardEvent) => {\n    const last = options.length - 1;\n    const move = (to: number) => {\n      e.preventDefault();\n      setActive(Math.max(0, Math.min(last, to)));\n    };\n    if (e.key === \"ArrowDown\") move(active === last ? 0 : active + 1);\n    else if (e.key === \"ArrowUp\") move(active === 0 ? last : active - 1);\n    else if (e.key === \"Home\") move(0);\n    else if (e.key === \"End\") move(last);\n    else if (e.key === \"PageDown\") move(active + 6);\n    else if (e.key === \"PageUp\") move(active - 6);\n    else if (e.key === \"Enter\" || e.key === \" \") {\n      e.preventDefault();\n      if (options[active]) choose(options[active]);\n    } else if (e.key === \"Tab\") close(false);\n    else if (e.key.length === 1 && /\\S/.test(e.key)) {\n      // A few letters typed quickly find the option that starts with them.\n      const now = performance.now();\n      typed.current = { text: (now - typed.current.at < 700 ? typed.current.text : \"\") + e.key.toLowerCase(), at: now };\n      const hit = options.findIndex((o) => o.label.toLowerCase().startsWith(typed.current.text));\n      if (hit !== -1) setActive(hit);\n    }\n  };\n\n  const w = expanded ? fit.width : pill.width;\n  const h = expanded ? panelHeight : pill.height;\n  const listId = `${id}-list`;\n  const pillText = renderValue ? null : (\n    <>\n      <span className={`min-w-0 flex-1 truncate text-left ${mono ? \"font-mono text-[11.5px]\" : \"\"} ${muted || !selected ? \"text-muted-foreground\" : \"text-foreground\"}`}>\n        {block ? text : <TextMorph>{text}</TextMorph>}\n      </span>\n      <Chevron up={expanded} reduced={reduced} />\n    </>\n  );\n\n  return (\n    <div\n      ref={wrapRef}\n      className={`relative ${block ? \"flex w-full min-w-0\" : \"inline-flex\"} ${className}`}\n      onKeyDown={(e) => {\n        setQuiet(false);\n        if (e.key === \"Escape\" && expanded) {\n          e.preventDefault();\n          e.stopPropagation();\n          close();\n        }\n      }}\n      onBlur={(e) => {\n        const to = e.relatedTarget as Node | null;\n        if (expanded && document.hasFocus() && !(to && (wrapRef.current?.contains(to) || surfaceRef.current?.contains(to)))) close(false);\n      }}\n    >\n      <button\n        ref={triggerRef}\n        type=\"button\"\n        aria-haspopup=\"listbox\"\n        aria-expanded={expanded}\n        aria-controls={mounted ? listId : undefined}\n        aria-label={`${label}: ${selected?.label ?? placeholder}`}\n        disabled={disabled}\n        onClick={() => (expanded ? close() : open())}\n        onKeyDown={(e) => {\n          if (e.key === \"ArrowDown\" || e.key === \"ArrowUp\") {\n            e.preventDefault();\n            open();\n          }\n        }}\n        onPointerDown={() => setQuiet(true)}\n        className={`inline-flex h-7 min-w-0 cursor-pointer items-center gap-1.5 rounded-full px-2.5 text-[12px] font-medium whitespace-nowrap shadow-[inset_0_0_0_1px_var(--border)] transition-colors duration-150 hover:bg-accent disabled:cursor-default disabled:opacity-50 disabled:hover:bg-transparent ${block ? \"w-full\" : \"\"} ${quiet ? \"outline-none\" : FOCUS}`}\n      >\n        {renderValue ? renderValue(selected, expanded) : pillText}\n      </button>\n\n      {mounted &&\n        createPortal(\n        <div\n          ref={surfaceRef}\n          onKeyDown={(e) => {\n            if (e.key === \"Escape\") {\n              e.preventDefault();\n              e.stopPropagation();\n              close();\n            }\n          }}\n          onPointerDown={(e) => {\n            if (!(e.target as HTMLElement).closest(\"[data-scroll]\")) e.preventDefault();\n            setQuiet(true);\n          }}\n          className=\"fixed z-[60] overflow-hidden bg-popover text-popover-foreground\"\n          style={{\n            ...fit.theme,\n            [fit.up ? \"bottom\" : \"top\"]: fit.y,\n            [align === \"start\" ? \"left\" : \"right\"]: fit.x + (expanded ? (align === \"start\" ? fit.shift : -fit.shift) : 0),\n            width: w,\n            height: h,\n            borderRadius: expanded ? 16 : pill.height / 2,\n            // The pill's own hairline, the whole way: it is the same surface.\n            boxShadow: \"inset 0 0 0 1px var(--border)\",\n            transition: reduced ? \"none\" : `width ${DURATION}ms ${EASE}, height ${DURATION}ms ${EASE}, left ${DURATION}ms ${EASE}, right ${DURATION}ms ${EASE}, border-radius ${DURATION}ms ${EASE}`,\n          }}\n        >\n          {/* The pill's label, fading out as the surface grows and back in as it folds. */}\n          <span\n            aria-hidden=\"true\"\n            className={`pointer-events-none absolute inline-flex h-7 items-center gap-1.5 px-2.5 text-[12px] font-medium whitespace-nowrap ${fit.up ? \"bottom-0\" : \"top-0\"} ${align === \"start\" ? \"left-0\" : \"right-0\"}`}\n            style={{ width: pill.width, opacity: expanded ? 0 : 1, transition: reduced ? \"none\" : `opacity ${expanded ? 120 : 260}ms ${EASE} ${expanded ? 0 : 120}ms` }}\n          >\n            {renderValue ? renderValue(selected, expanded) : pillText}\n          </span>\n          <div\n            ref={panelRef}\n            className={`absolute p-1 ${fit.up ? \"bottom-0\" : \"top-0\"} ${align === \"start\" ? \"left-0\" : \"right-0\"}`}\n            style={{ width: fit.width, opacity: expanded ? 1 : 0, transition: reduced ? \"none\" : `opacity ${expanded ? 260 : 120}ms ${EASE} ${expanded ? 90 : 0}ms` }}\n          >\n            <div data-scroll=\"\" className=\"overflow-y-auto overscroll-contain\" style={{ maxHeight: MAX_LIST }}>\n              <div\n                ref={listRef}\n                id={listId}\n                role=\"listbox\"\n                tabIndex={-1}\n                aria-label={label}\n                aria-activedescendant={`${id}-o${active}`}\n                onKeyDown={onListKey}\n                className=\"relative outline-none\"\n              >\n                <span\n                  aria-hidden=\"true\"\n                  className=\"pointer-events-none absolute inset-x-0 top-0 rounded-[12px] bg-accent\"\n                  style={{\n                    height: highlight.height,\n                    transform: `translateY(${highlight.top}px)`,\n                    opacity: highlight.ready ? 1 : 0,\n                    transition: highlight.ready && !reduced ? `transform 260ms ${EASE}, height 260ms ${EASE}, opacity 200ms` : \"none\",\n                  }}\n                />\n                {options.map((o, i) => {\n                  const on = o.value === value;\n                  return (\n                    <div\n                      key={o.value}\n                      ref={(el) => {\n                        rowRefs.current[i] = el;\n                      }}\n                      id={`${id}-o${i}`}\n                      role=\"option\"\n                      aria-selected={on}\n                      onPointerMove={() => setActive(i)}\n                      onClick={() => choose(o)}\n                      className=\"relative flex cursor-pointer items-start gap-2 rounded-[12px] px-2.5 py-1.5\"\n                    >\n                      <span className=\"min-w-0 flex-1\">\n                        <span className={`block truncate ${mono ? \"font-mono text-[12px]\" : \"text-[12.5px] font-medium\"}`}>{o.label}</span>\n                        {o.description && <span className=\"mt-0.5 block truncate text-[11.5px] text-muted-foreground\">{o.description}</span>}\n                      </span>\n                      <svg viewBox=\"0 0 16 16\" fill=\"none\" aria-hidden=\"true\" className=\"mt-0.5 size-3.5 shrink-0\">\n                        <path\n                          d=\"M3.5 8.5 6.5 11.5 12.5 4.5\"\n                          stroke=\"currentColor\"\n                          strokeWidth=\"1.8\"\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                    </div>\n                  );\n                })}\n              </div>\n            </div>\n          </div>\n        </div>,\n          document.body,\n        )}\n    </div>\n  );\n}\n"}],"css":{"@keyframes ui-col-in":{"from":{"max-width":"0","opacity":"0"},"to":{"max-width":"1em","opacity":"1"}},"@keyframes ui-col-out":{"from":{"max-width":"1em","opacity":"1"},"to":{"max-width":"0","opacity":"0"}}},"docs":"Docs and live preview: https://hairlineui.com/ui/cron-input. MIT licensed: https://hairlineui.com/license#components","meta":{"license":"MIT"}}