{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "duration-input",
  "title": "Duration Input",
  "description": "Segmented duration editor with typed shorthand, unit navigation and milliseconds storage.",
  "dependencies": [],
  "registryDependencies": [
    "utils",
    "@drift-ui/use-controllable-state"
  ],
  "files": [
    {
      "path": "registry/default/ui/duration-input.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\n\nimport { useControllableState } from \"@/registry/default/hooks/use-controllable-state\"\nimport {\n  type DurationUnit,\n  parseDurationString,\n  readSegment,\n  roundToSmallestUnit,\n  segmentRange,\n  segmentText,\n  sortUnits,\n  stepSegment,\n  unitCountText,\n  unitLabel,\n  writeSegment,\n} from \"@/registry/default/lib/duration-input\"\nimport { cn } from \"@/registry/default/lib/utils\"\n\nconst TYPEAHEAD_RESET_MS = 1000\n\nconst DEFAULT_UNITS: DurationUnit[] = [\"h\", \"m\", \"s\"]\n\ntype TypedDigits = {\n  unit: DurationUnit\n  digits: string\n  typedAtMs: number\n}\n\ntype DigitEntry = {\n  base: number | null\n  draft: number\n  digits: string\n  typedAtMs: number\n}\n\ninterface DurationInputProps extends Omit<\n  React.ComponentProps<\"div\">,\n  \"defaultValue\" | \"onChange\"\n> {\n  value?: number | null\n  defaultValue?: number | null\n  onValueChange?: (value: number | null) => void\n  units?: DurationUnit[]\n  min?: number\n  max?: number\n  step?: number\n  disabled?: boolean\n  name?: string\n}\n\nfunction DurationInput({\n  value,\n  defaultValue = null,\n  onValueChange,\n  units = DEFAULT_UNITS,\n  min = 0,\n  max,\n  step = 1,\n  disabled,\n  name,\n  className,\n  ...props\n}: DurationInputProps) {\n  const { value: durationValue, setValue: setDurationValue } =\n    useControllableState<number | null>({\n      value,\n      defaultValue,\n      onChange: onValueChange,\n    })\n\n  const orderedUnits = React.useMemo(() => sortUnits(units), [units])\n  const segmentRefs = React.useRef<(HTMLSpanElement | null)[]>([])\n  const typedDigitsRef = React.useRef<TypedDigits | null>(null)\n  const digitEntryRef = React.useRef<DigitEntry | null>(null)\n\n  function commit(next: number | null) {\n    if (next === null) {\n      setDurationValue(null)\n      return\n    }\n    const clamped = Math.min(max ?? Infinity, Math.max(min, next))\n    setDurationValue(clamped)\n  }\n\n  function focusSegment(index: number) {\n    if (index >= 0 && index < orderedUnits.length) {\n      segmentRefs.current[index]?.focus()\n    }\n  }\n\n  function focusUnit(targetUnit: DurationUnit) {\n    const index = orderedUnits.indexOf(targetUnit)\n    if (index !== -1) {\n      focusSegment(index)\n    }\n  }\n\n  function typeDigit(\n    unit: DurationUnit,\n    index: number,\n    digit: string,\n    typedAtMs: number\n  ) {\n    const previous = typedDigitsRef.current\n    const isContinuing =\n      previous?.unit === unit &&\n      typedAtMs - previous.typedAtMs < TYPEAHEAD_RESET_MS\n    const combined = isContinuing ? previous.digits + digit : digit\n    const maxDigits = unit === \"ms\" ? 3 : 2\n    const isComplete = combined.length >= maxDigits\n\n    typedDigitsRef.current = isComplete\n      ? null\n      : { unit, digits: combined, typedAtMs }\n\n    const entry = digitEntryRef.current\n    const isEntryContinuing =\n      entry !== null && typedAtMs - entry.typedAtMs < TYPEAHEAD_RESET_MS\n    const draft = writeSegment(\n      isEntryContinuing ? entry.draft : durationValue,\n      unit,\n      Number(combined),\n      orderedUnits\n    )\n    digitEntryRef.current = {\n      base: isEntryContinuing ? entry.base : durationValue,\n      draft,\n      digits: (isEntryContinuing ? entry.digits : \"\") + digit,\n      typedAtMs,\n    }\n\n    commit(draft)\n\n    if (isComplete) {\n      focusSegment(index + 1)\n    }\n  }\n\n  function assignDigitsToUnit(targetUnit: DurationUnit, pressedAtMs: number) {\n    const entry = digitEntryRef.current\n    typedDigitsRef.current = null\n    digitEntryRef.current = null\n\n    if (entry && pressedAtMs - entry.typedAtMs < TYPEAHEAD_RESET_MS) {\n      commit(\n        writeSegment(entry.base, targetUnit, Number(entry.digits), orderedUnits)\n      )\n    }\n    focusUnit(targetUnit)\n  }\n\n  function handleKeyDown(\n    unit: DurationUnit,\n    index: number,\n    event: React.KeyboardEvent<HTMLSpanElement>\n  ) {\n    if (disabled || event.metaKey || event.ctrlKey || event.altKey) return\n\n    if (event.key.length > 1 && event.key !== \"Shift\") {\n      digitEntryRef.current = null\n    }\n\n    switch (event.key) {\n      case \"ArrowUp\":\n      case \"ArrowDown\": {\n        event.preventDefault()\n        const delta = event.key === \"ArrowUp\" ? step : -step\n        commit(stepSegment(durationValue, unit, delta, orderedUnits, max, min))\n        return\n      }\n      case \"PageUp\":\n      case \"PageDown\": {\n        event.preventDefault()\n        const delta = event.key === \"PageUp\" ? step * 10 : -step * 10\n        commit(stepSegment(durationValue, unit, delta, orderedUnits, max, min))\n        return\n      }\n      case \"Home\": {\n        event.preventDefault()\n        commit(writeSegment(durationValue, unit, 0, orderedUnits, max, min))\n        return\n      }\n      case \"End\": {\n        event.preventDefault()\n        const { max: segMax } = segmentRange(\n          unit,\n          orderedUnits,\n          max,\n          min,\n          durationValue\n        )\n        commit(\n          writeSegment(durationValue, unit, segMax, orderedUnits, max, min)\n        )\n        return\n      }\n      case \"ArrowLeft\":\n      case \"ArrowRight\": {\n        event.preventDefault()\n        focusSegment(event.key === \"ArrowLeft\" ? index - 1 : index + 1)\n        return\n      }\n      case \"Backspace\":\n      case \"Delete\": {\n        event.preventDefault()\n        if (typedDigitsRef.current) {\n          typedDigitsRef.current = null\n          commit(writeSegment(durationValue, unit, 0, orderedUnits, max, min))\n        } else {\n          commit(null)\n        }\n        return\n      }\n    }\n\n    const keyLower = event.key.toLowerCase() as DurationUnit\n    if (orderedUnits.includes(keyLower)) {\n      event.preventDefault()\n      assignDigitsToUnit(keyLower, event.timeStamp)\n      return\n    }\n\n    if (/^\\d$/.test(event.key)) {\n      event.preventDefault()\n      typeDigit(unit, index, event.key, event.timeStamp)\n    }\n  }\n\n  function handlePaste(\n    unit: DurationUnit,\n    event: React.ClipboardEvent<HTMLSpanElement>\n  ) {\n    if (disabled) return\n    const text = event.clipboardData.getData(\"text\")\n    const parsed = parseDurationString(text, unit)\n    if (parsed !== null) {\n      event.preventDefault()\n      typedDigitsRef.current = null\n      digitEntryRef.current = null\n      commit(roundToSmallestUnit(parsed, orderedUnits))\n    }\n  }\n\n  return (\n    <div\n      role=\"group\"\n      data-slot=\"duration-input\"\n      data-disabled={disabled ? \"\" : undefined}\n      className={cn(\n        \"inline-flex h-9 w-fit items-center rounded-md border border-input bg-transparent px-2 font-mono text-sm tabular-nums shadow-xs transition-[color,box-shadow] dark:bg-input/30\",\n        \"focus-within:border-ring focus-within:ring-[3px] focus-within:ring-ring/50\",\n        \"data-[disabled]:pointer-events-none data-[disabled]:opacity-50\",\n        className\n      )}\n      {...props}\n    >\n      {orderedUnits.map((unit, index) => {\n        const { min: segMin, max: segMax } = segmentRange(\n          unit,\n          orderedUnits,\n          max,\n          min,\n          durationValue\n        )\n        const text = segmentText(durationValue, unit, orderedUnits)\n        const currentVal =\n          durationValue !== null && durationValue !== undefined\n            ? readSegment(durationValue, unit, orderedUnits)\n            : undefined\n\n        return (\n          <React.Fragment key={unit}>\n            <span\n              ref={(node) => {\n                segmentRefs.current[index] = node\n              }}\n              role=\"spinbutton\"\n              tabIndex={disabled ? -1 : 0}\n              data-slot=\"duration-input-segment\"\n              data-placeholder={durationValue === null ? \"\" : undefined}\n              aria-label={unitLabel(unit)}\n              aria-valuemin={segMin}\n              aria-valuemax={segMax}\n              aria-valuenow={currentVal}\n              aria-valuetext={\n                durationValue !== null\n                  ? unitCountText(currentVal ?? 0, unit)\n                  : \"Empty\"\n              }\n              onKeyDown={(event) => handleKeyDown(unit, index, event)}\n              onPaste={(event) => handlePaste(unit, event)}\n              onBlur={(event) => {\n                typedDigitsRef.current = null\n                const nextFocus = event.relatedTarget as HTMLSpanElement | null\n                if (!segmentRefs.current.includes(nextFocus)) {\n                  digitEntryRef.current = null\n                }\n              }}\n              className=\"rounded-sm px-0.5 outline-none select-none focus:bg-accent focus:text-accent-foreground data-placeholder:text-muted-foreground\"\n            >\n              {text}\n            </span>\n            <span\n              aria-hidden=\"true\"\n              className={cn(\n                \"text-muted-foreground select-none\",\n                index < orderedUnits.length - 1 && \"mr-1.5\"\n              )}\n            >\n              {unit}\n            </span>\n          </React.Fragment>\n        )\n      })}\n      {name && (\n        <input\n          type=\"hidden\"\n          name={name}\n          disabled={disabled}\n          value={\n            durationValue !== null && durationValue !== undefined\n              ? durationValue\n              : \"\"\n          }\n        />\n      )}\n    </div>\n  )\n}\n\nexport { DurationInput, type DurationInputProps }\n",
      "type": "registry:ui"
    },
    {
      "path": "registry/default/lib/duration-input.ts",
      "content": "export type DurationUnit = \"w\" | \"d\" | \"h\" | \"m\" | \"s\" | \"ms\"\n\nexport const UNIT_ORDER: DurationUnit[] = [\"w\", \"d\", \"h\", \"m\", \"s\", \"ms\"]\n\nexport const UNIT_MS: Record<DurationUnit, number> = {\n  w: 7 * 24 * 60 * 60 * 1000,\n  d: 24 * 60 * 60 * 1000,\n  h: 60 * 60 * 1000,\n  m: 60 * 1000,\n  s: 1000,\n  ms: 1,\n}\n\nexport const UNIT_LABELS: Record<\n  DurationUnit,\n  { singular: string; plural: string }\n> = {\n  w: { singular: \"Week\", plural: \"Weeks\" },\n  d: { singular: \"Day\", plural: \"Days\" },\n  h: { singular: \"Hour\", plural: \"Hours\" },\n  m: { singular: \"Minute\", plural: \"Minutes\" },\n  s: { singular: \"Second\", plural: \"Seconds\" },\n  ms: { singular: \"Millisecond\", plural: \"Milliseconds\" },\n}\n\nexport function sortUnits(units: DurationUnit[]): DurationUnit[] {\n  return [...units].sort(\n    (a, b) => UNIT_ORDER.indexOf(a) - UNIT_ORDER.indexOf(b)\n  )\n}\n\nexport function unitLabel(unit: DurationUnit): string {\n  return UNIT_LABELS[unit]?.plural ?? unit\n}\n\nexport function unitCountText(count: number, unit: DurationUnit): string {\n  const { singular, plural } = UNIT_LABELS[unit]\n  return `${count} ${count === 1 ? singular : plural}`\n}\n\nexport function decomposeDuration(\n  ms: number | null | undefined,\n  units: DurationUnit[]\n): Record<DurationUnit, number> | null {\n  if (ms === null || ms === undefined || Number.isNaN(ms)) {\n    return null\n  }\n\n  const sorted = sortUnits(units)\n  const result = {} as Record<DurationUnit, number>\n  let remainder = Math.max(0, ms)\n\n  for (const unit of sorted) {\n    const unitSize = UNIT_MS[unit]\n    result[unit] = Math.floor(remainder / unitSize)\n    remainder = remainder % unitSize\n  }\n\n  return result\n}\n\nexport function composeDuration(\n  parts: Partial<Record<DurationUnit, number>>\n): number {\n  let total = 0\n  for (const [key, val] of Object.entries(parts)) {\n    const unit = key as DurationUnit\n    if (UNIT_MS[unit] && typeof val === \"number\" && !Number.isNaN(val)) {\n      total += Math.max(0, val) * UNIT_MS[unit]\n    }\n  }\n  return total\n}\n\nconst ISO_8601_REGEX =\n  /^P(?:(\\d+(?:[.,]\\d+)?)W)?(?:(\\d+(?:[.,]\\d+)?)D)?(?:T(?:(\\d+(?:[.,]\\d+)?)H)?(?:(\\d+(?:[.,]\\d+)?)M)?(?:(\\d+(?:[.,]\\d+)?)S)?)?$/i\n\nconst UNIT_TOKEN_REGEX =\n  /(\\d+(?:\\.\\d+|,\\d{1,2})?)\\s*(milliseconds?|millis?|ms|seconds?|secs?|s|minutes?|mins?|m|hours?|hrs?|h|days?|d|weeks?|w)(?![a-zA-Z])/gi\n\nfunction normalizeUnitName(raw: string): DurationUnit | null {\n  const lower = raw.toLowerCase()\n  if (lower === \"w\" || lower === \"week\" || lower === \"weeks\") return \"w\"\n  if (lower === \"d\" || lower === \"day\" || lower === \"days\") return \"d\"\n  if (\n    lower === \"h\" ||\n    lower === \"hr\" ||\n    lower === \"hrs\" ||\n    lower === \"hour\" ||\n    lower === \"hours\"\n  )\n    return \"h\"\n  if (\n    lower === \"m\" ||\n    lower === \"min\" ||\n    lower === \"mins\" ||\n    lower === \"minute\" ||\n    lower === \"minutes\"\n  )\n    return \"m\"\n  if (\n    lower === \"s\" ||\n    lower === \"sec\" ||\n    lower === \"secs\" ||\n    lower === \"second\" ||\n    lower === \"seconds\"\n  )\n    return \"s\"\n  if (\n    lower === \"ms\" ||\n    lower === \"milli\" ||\n    lower === \"millis\" ||\n    lower === \"millisecond\" ||\n    lower === \"milliseconds\"\n  )\n    return \"ms\"\n  return null\n}\n\nexport function parseDurationString(\n  input: string,\n  fallbackUnit: DurationUnit = \"m\"\n): number | null {\n  const trimmed = input.trim()\n  if (!trimmed) return null\n\n  const isoMatch = ISO_8601_REGEX.exec(trimmed)\n  if (\n    isoMatch &&\n    (isoMatch[1] || isoMatch[2] || isoMatch[3] || isoMatch[4] || isoMatch[5])\n  ) {\n    const weeks = isoMatch[1] ? Number(isoMatch[1].replace(\",\", \".\")) : 0\n    const days = isoMatch[2] ? Number(isoMatch[2].replace(\",\", \".\")) : 0\n    const hours = isoMatch[3] ? Number(isoMatch[3].replace(\",\", \".\")) : 0\n    const minutes = isoMatch[4] ? Number(isoMatch[4].replace(\",\", \".\")) : 0\n    const seconds = isoMatch[5] ? Number(isoMatch[5].replace(\",\", \".\")) : 0\n\n    return Math.round(\n      weeks * UNIT_MS.w +\n        days * UNIT_MS.d +\n        hours * UNIT_MS.h +\n        minutes * UNIT_MS.m +\n        seconds * UNIT_MS.s\n    )\n  }\n  if (/^p/i.test(trimmed)) return null\n\n  let matched = false\n  let totalMs = 0\n  let match: RegExpExecArray | null\n\n  UNIT_TOKEN_REGEX.lastIndex = 0\n  while ((match = UNIT_TOKEN_REGEX.exec(trimmed)) !== null) {\n    matched = true\n    const value = Number(match[1].replace(\",\", \".\"))\n    const unit = normalizeUnitName(match[2])\n    if (unit && !Number.isNaN(value)) {\n      totalMs += value * UNIT_MS[unit]\n    }\n  }\n\n  if (matched && !trimmed.replace(UNIT_TOKEN_REGEX, \"\").trim()) {\n    return Math.round(totalMs)\n  }\n\n  if (/^\\d+(?:\\.\\d+|,\\d{1,2})?$/.test(trimmed)) {\n    const value = Number(trimmed.replace(\",\", \".\"))\n    if (!Number.isNaN(value)) {\n      return Math.round(value * UNIT_MS[fallbackUnit])\n    }\n  }\n\n  return null\n}\n\nexport function roundToSmallestUnit(ms: number, units: DurationUnit[]): number {\n  const smallest = Math.min(...units.map((unit) => UNIT_MS[unit]))\n  return Math.round(ms / smallest) * smallest\n}\n\nexport function formatDuration(\n  ms: number | null | undefined,\n  units: DurationUnit[] = [\"h\", \"m\"]\n): string {\n  if (ms === null || ms === undefined || Number.isNaN(ms)) {\n    return \"\"\n  }\n\n  const parts = decomposeDuration(ms, units)\n  if (!parts) return \"\"\n\n  const sorted = sortUnits(units)\n  const nonZero = sorted.filter((unit) => (parts[unit] ?? 0) > 0)\n\n  if (nonZero.length === 0) {\n    const smallest = sorted[sorted.length - 1]\n    return `0${smallest}`\n  }\n\n  return nonZero.map((unit) => `${parts[unit]}${unit}`).join(\" \")\n}\n\nexport function segmentRange(\n  unit: DurationUnit,\n  units: DurationUnit[],\n  max?: number,\n  min: number = 0,\n  ms?: number | null\n): { min: number; max: number } {\n  const sorted = sortUnits(units)\n  const unitIndex = sorted.indexOf(unit)\n\n  if (unitIndex === 0) {\n    return {\n      min: Math.floor(min / UNIT_MS[unit]),\n      max:\n        max !== undefined\n          ? Math.floor(max / UNIT_MS[unit])\n          : Math.max(999, readSegment(ms, unit, sorted)),\n    }\n  }\n\n  const parentUnit = sorted[unitIndex - 1]\n  const parentRatio = Math.floor(UNIT_MS[parentUnit] / UNIT_MS[unit]) - 1\n\n  return {\n    min: 0,\n    max: Math.max(1, parentRatio),\n  }\n}\n\nexport function readSegment(\n  ms: number | null | undefined,\n  unit: DurationUnit,\n  units: DurationUnit[]\n): number {\n  if (ms === null || ms === undefined) return 0\n  const parts = decomposeDuration(ms, units)\n  return parts?.[unit] ?? 0\n}\n\nexport function writeSegment(\n  ms: number | null | undefined,\n  unit: DurationUnit,\n  segmentValue: number,\n  units: DurationUnit[],\n  max?: number,\n  min: number = 0\n): number {\n  const currentMs = ms ?? 0\n  const currentUnitVal = readSegment(currentMs, unit, units)\n  const delta = (segmentValue - currentUnitVal) * UNIT_MS[unit]\n  const nextMs = currentMs + delta\n\n  return Math.min(max ?? Infinity, Math.max(min, nextMs))\n}\n\nexport function stepSegment(\n  ms: number | null | undefined,\n  unit: DurationUnit,\n  delta: number,\n  units: DurationUnit[],\n  max?: number,\n  min: number = 0\n): number {\n  const currentMs = ms ?? 0\n  const nextMs = currentMs + delta * UNIT_MS[unit]\n  return Math.min(max ?? Infinity, Math.max(min, nextMs))\n}\n\nexport function segmentText(\n  ms: number | null | undefined,\n  unit: DurationUnit,\n  units: DurationUnit[]\n): string {\n  if (ms === null || ms === undefined) {\n    return \"--\"\n  }\n\n  const value = readSegment(ms, unit, units)\n\n  if (unit === \"ms\") {\n    return String(value).padStart(3, \"0\")\n  }\n  if (unit === \"w\" || unit === \"d\") {\n    return String(value)\n  }\n\n  return String(value).padStart(2, \"0\")\n}\n",
      "type": "registry:lib"
    }
  ],
  "type": "registry:ui"
}