{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "duration-input",
  "title": "Duration Input",
  "description": "A text field that takes a length of time written the way people actually write one — 90m, 1h30m, 1h 30m, 2d 4h 15m, 1:30, 1.5h, 500ms, \"90 minutes\" — reads it into milliseconds, and echoes the reading back in words underneath it (\"1 hour 30 minutes\") so the interpretation is never left to be guessed at. Reach for it wherever a form asks how long rather than when: a request timeout or deadline, a cache TTL or expiry, session and token lifetimes, a retry or backoff interval, a polling or refresh interval, an SLA target, a job or cron timeout, an auto-logout window, a rate-limit window, a task estimate, a video or audio length, a snooze or reminder delay. Common asks it answers: \"duration input\", \"duration picker\", \"time duration field\", \"timeout input\", \"TTL input\", \"interval input\", \"parse 1h30m\", \"hh:mm:ss duration input\", \"humanize duration\" — the field otherwise assembled from a number box beside a unit <select>, or from parse-duration / pretty-ms / ms / humanize-duration. Official shadcn/ui has no duration component of any kind: input is a bare text box you would still have to parse, input-otp is for codes, and calendar answers which day, not how long. It settles the two things hand-rolled duration parsers get wrong. First, m versus ms: the whole run of letters is read before anything is looked up, so 500ms can never come out as 500 minutes. Second, what 1:30 means: two colon fields are read as mm:ss and three as hh:mm:ss, the way stopwatches and media players write them, and blur rewrites the entry into its canonical short form so 1:30 visibly becomes 1m 30s — a clock time is the other component's job, so 9:30 here is nine and a half minutes of elapsed time and time-input is where you type half past nine. Months and years are refused by name instead of being given an invented length, which also settles the usual M/m argument — parsing is case-insensitive and M is minutes. Beyond parsing: minMs/maxMs mark the field aria-invalid with a polite live message naming the bound in words, a value that is unusable or out of range is withheld from onValueChange so nothing handed to the caller needs validating twice, text that does not parse stays on screen instead of being deleted out from under the reader, and giving the field a name posts the milliseconds through a hidden input so the server is never handed prose. parseDuration and formatDuration are exported as plain functions for the rest of the app to share. One file, themed with shadcn tokens, no dependencies beyond React.",
  "files": [
    {
      "path": "registry/ui/duration-input.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\n\nimport { cn } from \"@/lib/utils\"\n\n/**\n * Why a text field and not a number box beside a unit <select>: people already\n * know how to write a duration — 90m, 1h30m, 1:30, 500ms — and the two-control\n * version makes them translate it on the way in and every later reader\n * translate it back on the way out. The price of accepting what they type is a\n * parser that is exactly right about the two things that bite:\n *\n *   - `m` vs `ms`. The scanner takes the whole run of letters before looking\n *     anything up, so \"5m\" and \"5ms\" can never be confused by a prefix match.\n *   - what `1:30` means. Two colon fields are read as mm:ss and three as\n *     hh:mm:ss, the way stopwatches and media players write them.\n *\n * Neither reading is left to be guessed at: the field echoes the duration back\n * in words underneath (\"1 minute 30 seconds\"), and on blur it rewrites what was\n * typed into its canonical short form, so `1:30` visibly becomes `1m 30s`.\n */\n\n/** The units this field understands. Months and years are refused — see `parseDuration`. */\nexport type DurationUnit = \"ms\" | \"s\" | \"m\" | \"h\" | \"d\" | \"w\"\n\nconst MS: Record<DurationUnit, number> = {\n  ms: 1,\n  s: 1000,\n  m: 60_000,\n  h: 3_600_000,\n  d: 86_400_000,\n  w: 604_800_000,\n}\n\nconst UNIT_ALIASES: Record<string, DurationUnit> = {\n  ms: \"ms\",\n  msec: \"ms\",\n  msecs: \"ms\",\n  millisecond: \"ms\",\n  milliseconds: \"ms\",\n  s: \"s\",\n  sec: \"s\",\n  secs: \"s\",\n  second: \"s\",\n  seconds: \"s\",\n  m: \"m\",\n  min: \"m\",\n  mins: \"m\",\n  minute: \"m\",\n  minutes: \"m\",\n  h: \"h\",\n  hr: \"h\",\n  hrs: \"h\",\n  hour: \"h\",\n  hours: \"h\",\n  d: \"d\",\n  day: \"d\",\n  days: \"d\",\n  w: \"w\",\n  week: \"w\",\n  weeks: \"w\",\n}\n\n/**\n * Named and refused rather than quietly given a length. A month is 28–31 days\n * and a year 365 or 366, so \"1mo\" can only be answered by a calendar and a\n * start date, neither of which a duration has. Note that this also settles the\n * usual `m`/`M` argument: parsing is case-insensitive and `M` is minutes,\n * because months are not on the menu at all.\n */\nconst CALENDAR_UNITS = new Set([\n  \"mo\",\n  \"mos\",\n  \"month\",\n  \"months\",\n  \"y\",\n  \"yr\",\n  \"yrs\",\n  \"year\",\n  \"years\",\n])\n\n/** Why a string was rejected. Stable codes so the text can be translated. */\nexport type DurationErrorCode =\n  | \"invalid\"\n  | \"missing-unit\"\n  | \"unknown-unit\"\n  | \"calendar-unit\"\n  | \"duplicate-unit\"\n  | \"negative\"\n  | \"clock-field\"\n  | \"too-large\"\n\n/** Every message the field can show, including the two range failures it adds itself. */\nexport type DurationMessageCode = DurationErrorCode | \"below-min\" | \"above-max\"\n\nexport type DurationParseResult =\n  /** `ms` is null for an empty string: nothing entered is not the same as a bad entry. */\n  | { ok: true; ms: number | null }\n  | { ok: false; code: DurationErrorCode }\n\nconst SCALE: { unit: DurationUnit; word: string }[] = [\n  { unit: \"w\", word: \"week\" },\n  { unit: \"d\", word: \"day\" },\n  { unit: \"h\", word: \"hour\" },\n  { unit: \"m\", word: \"minute\" },\n  { unit: \"s\", word: \"second\" },\n  { unit: \"ms\", word: \"millisecond\" },\n]\n\n// Rounds to whole milliseconds — \"1.5h\" is exact, \"0.4ms\" is not — and refuses\n// anything past the safe-integer range, where further arithmetic on the value\n// would silently stop being exact.\nfunction toMilliseconds(ms: number): DurationParseResult {\n  const rounded = Math.round(ms)\n  if (!Number.isSafeInteger(rounded)) return { ok: false, code: \"too-large\" }\n  return { ok: true, ms: rounded }\n}\n\n// mm:ss or hh:mm:ss. The leading field is free to run past 59 the way a\n// stopwatch does (\"90:00\" is ninety minutes); every field after a colon is a\n// clock field, so one or two digits and under 60.\nfunction parseClock(text: string): DurationParseResult {\n  const fields = text.split(\":\")\n  if (fields.length > 3) return { ok: false, code: \"clock-field\" }\n  if (!/^\\d+$/.test(fields[0])) return { ok: false, code: \"clock-field\" }\n  for (const field of fields.slice(1)) {\n    if (!/^\\d{1,2}$/.test(field) || Number(field) > 59)\n      return { ok: false, code: \"clock-field\" }\n  }\n  const n = fields.map(Number)\n  return toMilliseconds(\n    fields.length === 2\n      ? n[0] * MS.m + n[1] * MS.s\n      : n[0] * MS.h + n[1] * MS.m + n[2] * MS.s\n  )\n}\n\nfunction parseUnitForm(\n  text: string,\n  defaultUnit: DurationUnit\n): DurationParseResult {\n  // Sticky, so every character has to be accounted for by some token: a stray\n  // \"1h x\" fails at the \"x\" instead of being read as \"1h\".\n  const token = /\\s*(\\d+(?:\\.\\d+)?|\\.\\d+)\\s*([a-z]*)\\s*,?/y\n  const parts: { value: number; unit: string }[] = []\n  while (token.lastIndex < text.length) {\n    const match = token.exec(text)\n    if (!match) return { ok: false, code: \"invalid\" }\n    parts.push({ value: Number(match[1]), unit: match[2] })\n  }\n\n  const seen = new Set<DurationUnit>()\n  let ms = 0\n  for (const part of parts) {\n    let unit: DurationUnit\n    if (part.unit === \"\") {\n      // A bare number is only an answer when it is the whole input; in \"1h 30\"\n      // the 30 is a slip, and guessing at it is how a timeout ends up 1800×\n      // wrong.\n      if (parts.length > 1) return { ok: false, code: \"missing-unit\" }\n      unit = defaultUnit\n    } else if (CALENDAR_UNITS.has(part.unit)) {\n      return { ok: false, code: \"calendar-unit\" }\n    } else {\n      const resolved = UNIT_ALIASES[part.unit]\n      if (!resolved) return { ok: false, code: \"unknown-unit\" }\n      unit = resolved\n    }\n    // \"1m 30m\" is a typo far more often than it is a deliberate sum, and the\n    // sum would be accepted in silence.\n    if (seen.has(unit)) return { ok: false, code: \"duplicate-unit\" }\n    seen.add(unit)\n    ms += part.value * MS[unit]\n  }\n  return toMilliseconds(ms)\n}\n\n/**\n * Reads a typed duration into milliseconds.\n *\n * Accepts unit form (\"90m\", \"1h30m\", \"1h 30m\", \"2d 4h 15m\", \"1.5h\", \"500ms\"),\n * clock form (\"1:30\" = mm:ss, \"1:30:00\" = hh:mm:ss) and a bare number, which is\n * read in `defaultUnit`. Case and spacing are free; the long spellings\n * (\"minutes\", \"hrs\") work too.\n */\nexport function parseDuration(\n  input: string,\n  options: { defaultUnit?: DurationUnit } = {}\n): DurationParseResult {\n  const { defaultUnit = \"m\" } = options\n  const text = input.trim().toLowerCase()\n  if (text === \"\") return { ok: true, ms: null }\n  if (text.startsWith(\"-\")) return { ok: false, code: \"negative\" }\n  if (text.includes(\":\")) return parseClock(text)\n  return parseUnitForm(text, defaultUnit)\n}\n\n/**\n * Writes milliseconds back out, either in words (\"1 hour 30 minutes\") or in the\n * short form the field normalizes to (\"1h 30m\"). Exact — every remainder is\n * carried down to the next unit — so it can be shown beside the value it\n * describes without the two disagreeing. A negative or non-finite input is\n * treated as zero rather than throwing: one bad number should not take the\n * layout with it.\n */\nexport function formatDuration(\n  ms: number,\n  style: \"long\" | \"short\" = \"long\"\n): string {\n  const total = Number.isFinite(ms) && ms > 0 ? Math.round(ms) : 0\n  if (total === 0) return style === \"long\" ? \"0 seconds\" : \"0s\"\n  const out: string[] = []\n  let rest = total\n  for (const step of SCALE) {\n    const count = Math.floor(rest / MS[step.unit])\n    if (count === 0) continue\n    rest -= count * MS[step.unit]\n    out.push(\n      style === \"long\"\n        ? `${count} ${step.word}${count === 1 ? \"\" : \"s\"}`\n        : `${count}${step.unit}`\n    )\n  }\n  return out.join(\" \")\n}\n\n/** Default copy, keyed by code so a caller can replace any single line. */\nexport const durationMessages: Record<DurationMessageCode, string> = {\n  invalid: \"Enter a duration like 1h 30m.\",\n  \"missing-unit\": \"Every part needs a unit — try 1h 30m.\",\n  \"unknown-unit\": \"Unknown unit. Use ms, s, m, h, d or w.\",\n  \"calendar-unit\": \"Months and years vary in length — use days or weeks.\",\n  \"duplicate-unit\": \"Each unit can only be given once.\",\n  negative: \"A duration cannot be negative.\",\n  \"clock-field\": \"Use mm:ss or hh:mm:ss, with 00–59 after each colon.\",\n  \"too-large\": \"That duration is too large.\",\n  \"below-min\": \"Minimum is {value}.\",\n  \"above-max\": \"Maximum is {value}.\",\n}\n\n// Out-of-range values keep their parsed `ms` so the hint can talk about what was\n// actually entered; whether they are handed to the caller is decided below.\nfunction evaluate(\n  text: string,\n  options: { defaultUnit: DurationUnit; minMs?: number; maxMs?: number }\n): { ms: number | null; code?: DurationMessageCode } {\n  const parsed = parseDuration(text, { defaultUnit: options.defaultUnit })\n  if (!parsed.ok) return { ms: null, code: parsed.code }\n  if (parsed.ms === null) return { ms: null }\n  if (options.minMs !== undefined && parsed.ms < options.minMs)\n    return { ms: parsed.ms, code: \"below-min\" }\n  if (options.maxMs !== undefined && parsed.ms > options.maxMs)\n    return { ms: parsed.ms, code: \"above-max\" }\n  return { ms: parsed.ms }\n}\n\nexport interface DurationInputProps\n  extends Omit<\n    React.ComponentPropsWithoutRef<\"input\">,\n    \"value\" | \"defaultValue\" | \"onChange\" | \"type\"\n  > {\n  /** Controlled duration in milliseconds. `null` clears the field. */\n  valueMs?: number | null\n  /** Initial duration in milliseconds, for uncontrolled use. */\n  defaultValueMs?: number | null\n  /**\n   * Fires with the duration in milliseconds. `null` while the field is empty\n   * *or* while what is typed is unusable, so a value handed over here never has\n   * to be validated again.\n   */\n  onValueChange?: (ms: number | null) => void\n  /** How to read a bare number with no unit (default \"m\", so \"30\" is 30 minutes). */\n  defaultUnit?: DurationUnit\n  /** Shortest allowed duration in milliseconds; below it the field goes invalid. */\n  minMs?: number\n  /** Longest allowed duration in milliseconds; above it the field goes invalid. */\n  maxMs?: number\n  /** Show the echo/error line under the field (default true). */\n  showHint?: boolean\n  /** Replace any default message. \"{value}\" in below-min/above-max is the bound, in words. */\n  messages?: Partial<Record<DurationMessageCode, string>>\n  /** Class for the wrapper; `className` goes to the input itself. */\n  containerClassName?: string\n}\n\nexport const DurationInput = React.forwardRef<\n  HTMLInputElement,\n  DurationInputProps\n>(function DurationInput(\n  {\n    className,\n    containerClassName,\n    valueMs,\n    defaultValueMs,\n    onValueChange,\n    defaultUnit = \"m\",\n    minMs,\n    maxMs,\n    showHint = true,\n    messages,\n    id,\n    name,\n    disabled,\n    placeholder = \"1h 30m\",\n    onBlur,\n    \"aria-describedby\": ariaDescribedBy,\n    ...props\n  },\n  ref\n) {\n  const generatedId = React.useId()\n  const inputId = id ?? generatedId\n  const hintId = `${inputId}-hint`\n\n  const isControlled = valueMs !== undefined\n  // What was typed is the state; the number is derived from it. Keeping the\n  // text means an unparseable entry survives a blur — the reader can see and\n  // fix what they wrote instead of watching it disappear.\n  const [text, setText] = React.useState(() => {\n    const seed = isControlled ? valueMs : defaultValueMs\n    return seed === null || seed === undefined ? \"\" : formatDuration(seed, \"short\")\n  })\n\n  const evaluated = evaluate(text, { defaultUnit, minMs, maxMs })\n  const invalid = evaluated.code !== undefined\n  const committed = invalid ? null : evaluated.ms\n\n  // What the parent was last told, so the effect below can tell a genuinely new\n  // controlled value from the echo of our own emit — without this, every\n  // keystroke would be overwritten by the value it just produced.\n  const lastEmitted = React.useRef<number | null>(committed)\n\n  React.useEffect(() => {\n    if (!isControlled) return\n    const next = valueMs ?? null\n    if (next === lastEmitted.current) return\n    lastEmitted.current = next\n    setText(next === null ? \"\" : formatDuration(next, \"short\"))\n  }, [isControlled, valueMs])\n\n  function commit(next: string) {\n    setText(next)\n    const result = evaluate(next, { defaultUnit, minMs, maxMs })\n    const ms = result.code === undefined ? result.ms : null\n    if (ms !== lastEmitted.current) {\n      lastEmitted.current = ms\n      onValueChange?.(ms)\n    }\n  }\n\n  // Normalizing on blur is what makes the colon rule visible: \"1:30\" is rewritten\n  // to \"1m 30s\". It only ever restates the same number, so nothing is emitted.\n  function handleBlur(event: React.FocusEvent<HTMLInputElement>) {\n    if (!invalid && evaluated.ms !== null) {\n      const canonical = formatDuration(evaluated.ms, \"short\")\n      if (canonical !== text) setText(canonical)\n    }\n    onBlur?.(event)\n  }\n\n  const say = (code: DurationMessageCode) =>\n    messages?.[code] ?? durationMessages[code]\n\n  let hint = \"\"\n  if (evaluated.code === \"below-min\")\n    hint = say(\"below-min\").replace(\"{value}\", formatDuration(minMs ?? 0))\n  else if (evaluated.code === \"above-max\")\n    hint = say(\"above-max\").replace(\"{value}\", formatDuration(maxMs ?? 0))\n  else if (evaluated.code) hint = say(evaluated.code)\n  else if (evaluated.ms !== null) hint = formatDuration(evaluated.ms)\n\n  const describedBy =\n    [ariaDescribedBy, showHint ? hintId : null].filter(Boolean).join(\" \") ||\n    undefined\n\n  return (\n    <div className={cn(\"flex flex-col gap-1.5\", containerClassName)}>\n      <input\n        ref={ref}\n        id={inputId}\n        type=\"text\"\n        autoComplete=\"off\"\n        spellCheck={false}\n        disabled={disabled}\n        placeholder={placeholder}\n        value={text}\n        onChange={(event) => commit(event.target.value)}\n        onBlur={handleBlur}\n        aria-invalid={invalid || undefined}\n        aria-describedby={describedBy}\n        className={cn(\n          \"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors\",\n          \"placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring\",\n          invalid && \"border-destructive focus-visible:ring-destructive\",\n          \"disabled:cursor-not-allowed disabled:opacity-50\",\n          className\n        )}\n        {...props}\n      />\n      {/* A form gets the milliseconds, not the prose: \"1h 30m\" is for the person. */}\n      {name ? (\n        <input\n          type=\"hidden\"\n          name={name}\n          disabled={disabled}\n          value={committed === null ? \"\" : String(committed)}\n        />\n      ) : null}\n      {/* Describes the field and announces itself. One element does both because\n          the two would otherwise read the same sentence twice; it re-announces\n          only when the text really changes, not on every keystroke. */}\n      {showHint ? (\n        <p\n          id={hintId}\n          aria-live=\"polite\"\n          className={cn(\n            \"min-h-4 text-xs\",\n            invalid ? \"text-destructive\" : \"text-muted-foreground\"\n          )}\n        >\n          {hint}\n        </p>\n      ) : null}\n    </div>\n  )\n})\n",
      "type": "registry:ui"
    }
  ],
  "type": "registry:ui"
}