{"$schema":"https://ui.shadcn.com/schema/registry-item.json","author":"Eduardo Calvo <educlopez93@gmail.com>","css":{},"dependencies":["motion"],"description":"A DurationPicker component for SmoothUI.","devDependencies":[],"files":[{"content":"\"use client\";\n\nimport { cn } from \"@/lib/utils\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport {\n  type KeyboardEvent,\n  type PointerEvent as ReactPointerEvent,\n  useCallback,\n  useEffect,\n  useId,\n  useMemo,\n  useRef,\n  useState,\n} from \"react\";\n\nexport type DurationUnit = \"hours\" | \"minutes\" | \"seconds\";\n\nexport type DurationPickerProps = {\n  /** Additional CSS classes */\n  className?: string;\n  /** Controlled value in seconds */\n  value?: number;\n  /** Default value (uncontrolled) in seconds */\n  defaultValue?: number;\n  /** Called whenever the duration changes, with the new value in seconds */\n  onValueChange?: (value: number) => void;\n  /** Which unit segments to render, most-significant first */\n  units?: DurationUnit[];\n  /** Minimum total duration in seconds */\n  min?: number;\n  /** Maximum total duration in seconds */\n  max?: number;\n  /** Increment applied per arrow key press, in the focused unit's own scale */\n  step?: number;\n  /** Enable horizontal pointer drag to scrub a segment's value */\n  scrub?: boolean;\n  /** Disable all interaction */\n  disabled?: boolean;\n  /** Visible label rendered above the picker */\n  label?: string;\n};\n\nconst UNIT_ORDER: DurationUnit[] = [\"hours\", \"minutes\", \"seconds\"];\nconst UNIT_DIVISOR: Record<DurationUnit, number> = {\n  hours: 3600,\n  minutes: 60,\n  seconds: 1,\n};\nconst UNIT_LABEL: Record<DurationUnit, string> = {\n  hours: \"hour\",\n  minutes: \"minute\",\n  seconds: \"second\",\n};\n\nconst DEFAULT_UNITS: DurationUnit[] = [\"hours\", \"minutes\", \"seconds\"];\nconst DEFAULT_MIN = 0;\nconst DEFAULT_MAX = 359_999;\nconst DEFAULT_STEP = 1;\nconst SHIFT_MULTIPLIER = 10;\nconst PAGE_STEP_MULTIPLIER = 5;\nconst TYPE_BUFFER_TIMEOUT_MS = 600;\nconst PX_PER_DRAG_STEP = 6;\nconst DIGIT_HEIGHT = 24;\nconst DIGIT_ROLL_DISTANCE = 12;\nconst DIGIT_KEY_PATTERN = /^[0-9]$/;\n\nconst PAD_FORMATTER = new Intl.NumberFormat(undefined, {\n  minimumIntegerDigits: 2,\n  useGrouping: false,\n});\n\nconst clamp = (val: number, min: number, max: number) =>\n  Math.min(Math.max(val, min), max);\n\nconst sortUnits = (units: DurationUnit[]): DurationUnit[] => {\n  const sorted = UNIT_ORDER.filter((unit) => units.includes(unit));\n  return sorted.length > 0 ? sorted : DEFAULT_UNITS;\n};\n\nconst decomposeDuration = (\n  totalSeconds: number,\n  units: DurationUnit[]\n): number[] => {\n  let remaining = Math.max(0, Math.trunc(totalSeconds));\n  const values: number[] = [];\n  for (const unit of units) {\n    const divisor = UNIT_DIVISOR[unit];\n    const segmentValue = Math.floor(remaining / divisor);\n    values.push(segmentValue);\n    remaining -= segmentValue * divisor;\n  }\n  return values;\n};\n\nconst getSegmentMax = (\n  index: number,\n  units: DurationUnit[],\n  overallMax: number\n): number => {\n  if (index === 0) {\n    return Math.floor(overallMax / UNIT_DIVISOR[units[0]]);\n  }\n  const higherDivisor = UNIT_DIVISOR[units[index - 1]];\n  const thisDivisor = UNIT_DIVISOR[units[index]];\n  return Math.floor(higherDivisor / thisDivisor) - 1;\n};\n\nconst pluralize = (count: number, unit: DurationUnit) =>\n  `${count} ${UNIT_LABEL[unit]}${count === 1 ? \"\" : \"s\"}`;\n\n/** Formats a duration in seconds into a padded, colon-separated string for the given units. */\nexport const formatDuration = (\n  totalSeconds: number,\n  units: DurationUnit[] = DEFAULT_UNITS\n): string => {\n  const sortedUnits = sortUnits(units);\n  const values = decomposeDuration(totalSeconds, sortedUnits);\n  return values.map((value) => PAD_FORMATTER.format(value)).join(\":\");\n};\n\ntype AnimatedDigitProps = {\n  char: string;\n  reduceMotion: boolean;\n};\n\nconst AnimatedDigit = ({ char, reduceMotion }: AnimatedDigitProps) => (\n  <span\n    className=\"relative inline-block w-[0.62em] text-center\"\n    style={{ height: DIGIT_HEIGHT }}\n  >\n    <AnimatePresence initial={false} mode=\"popLayout\">\n      <motion.span\n        animate={reduceMotion ? { opacity: 1, y: 0 } : { opacity: 1, y: 0 }}\n        className=\"absolute inset-0 flex items-center justify-center\"\n        exit={\n          reduceMotion\n            ? { opacity: 0, transition: { duration: 0 } }\n            : { opacity: 0, y: DIGIT_ROLL_DISTANCE }\n        }\n        initial={\n          reduceMotion\n            ? { opacity: 1, y: 0 }\n            : { opacity: 0, y: -DIGIT_ROLL_DISTANCE }\n        }\n        key={char}\n        transition={\n          reduceMotion\n            ? { duration: 0 }\n            : { bounce: 0.1, duration: 0.25, type: \"spring\" }\n        }\n      >\n        {char}\n      </motion.span>\n    </AnimatePresence>\n  </span>\n);\n\ntype TypingState = { index: number; text: string };\n\nconst DurationPicker = ({\n  className,\n  value: controlledValue,\n  defaultValue = 0,\n  onValueChange,\n  units = DEFAULT_UNITS,\n  min = DEFAULT_MIN,\n  max = DEFAULT_MAX,\n  step = DEFAULT_STEP,\n  scrub = true,\n  disabled = false,\n  label,\n}: DurationPickerProps) => {\n  const shouldReduceMotion = useReducedMotion();\n  const labelId = useId();\n  const isControlled = controlledValue !== undefined;\n  const [internalValue, setInternalValue] = useState(\n    clamp(defaultValue, min, max)\n  );\n  const value = isControlled ? controlledValue : internalValue;\n\n  const sortedUnits = useMemo(() => sortUnits(units), [units]);\n  const values = useMemo(\n    () => decomposeDuration(value, sortedUnits),\n    [value, sortedUnits]\n  );\n\n  const segmentRefs = useRef<(HTMLDivElement | null)[]>([]);\n  const dragStateRef = useRef<{\n    index: number;\n    startX: number;\n    startValue: number;\n  } | null>(null);\n  const typingRef = useRef<TypingState | null>(null);\n  const typingTimeoutRef = useRef<ReturnType<typeof setTimeout> | undefined>(\n    undefined\n  );\n  const [draggingIndex, setDraggingIndex] = useState<number | null>(null);\n\n  const setTotal = useCallback(\n    (next: number) => {\n      const clamped = clamp(Math.round(next), min, max);\n      if (!isControlled) {\n        setInternalValue(clamped);\n      }\n      onValueChange?.(clamped);\n    },\n    [isControlled, min, max, onValueChange]\n  );\n\n  const commitSegmentValue = useCallback(\n    (index: number, rawValue: number) => {\n      const divisor = UNIT_DIVISOR[sortedUnits[index]];\n      const currentValues = decomposeDuration(value, sortedUnits);\n      const delta = (rawValue - currentValues[index]) * divisor;\n      setTotal(value + delta);\n    },\n    [sortedUnits, value, setTotal]\n  );\n\n  const clearTypingTimeout = useCallback(() => {\n    if (typingTimeoutRef.current !== undefined) {\n      clearTimeout(typingTimeoutRef.current);\n      typingTimeoutRef.current = undefined;\n    }\n  }, []);\n\n  useEffect(() => clearTypingTimeout, [clearTypingTimeout]);\n\n  const focusSegment = useCallback((index: number) => {\n    segmentRefs.current[index]?.focus();\n  }, []);\n\n  const commitTyped = useCallback(\n    (index: number, text: string, advance: boolean) => {\n      commitSegmentValue(index, Number.parseInt(text, 10));\n      typingRef.current = null;\n      clearTypingTimeout();\n      if (advance) {\n        focusSegment(index + 1);\n      }\n    },\n    [commitSegmentValue, clearTypingTimeout, focusSegment]\n  );\n\n  const handleDigit = useCallback(\n    (index: number, digit: string) => {\n      const isLast = index === sortedUnits.length - 1;\n      const { current } = typingRef;\n      clearTypingTimeout();\n\n      if (current && current.index === index && current.text.length === 1) {\n        commitTyped(index, current.text + digit, !isLast);\n        return;\n      }\n\n      typingRef.current = { index, text: digit };\n      commitSegmentValue(index, Number.parseInt(digit, 10));\n      typingTimeoutRef.current = setTimeout(() => {\n        commitTyped(index, digit, !isLast);\n      }, TYPE_BUFFER_TIMEOUT_MS);\n    },\n    [sortedUnits, clearTypingTimeout, commitTyped, commitSegmentValue]\n  );\n\n  const handleSegmentKeyDown = useCallback(\n    (event: KeyboardEvent<HTMLDivElement>, index: number) => {\n      if (disabled) {\n        return;\n      }\n      const unit = sortedUnits[index];\n      const divisor = UNIT_DIVISOR[unit];\n      const multiplier = event.shiftKey ? SHIFT_MULTIPLIER : 1;\n\n      if (DIGIT_KEY_PATTERN.test(event.key)) {\n        event.preventDefault();\n        handleDigit(index, event.key);\n        return;\n      }\n\n      switch (event.key) {\n        case \"ArrowUp\":\n          event.preventDefault();\n          setTotal(value + step * multiplier * divisor);\n          break;\n        case \"ArrowDown\":\n          event.preventDefault();\n          setTotal(value - step * multiplier * divisor);\n          break;\n        case \"PageUp\":\n          event.preventDefault();\n          setTotal(value + step * PAGE_STEP_MULTIPLIER * divisor);\n          break;\n        case \"PageDown\":\n          event.preventDefault();\n          setTotal(value - step * PAGE_STEP_MULTIPLIER * divisor);\n          break;\n        case \"Home\":\n          event.preventDefault();\n          commitSegmentValue(index, 0);\n          break;\n        case \"End\":\n          event.preventDefault();\n          commitSegmentValue(index, getSegmentMax(index, sortedUnits, max));\n          break;\n        case \"ArrowRight\":\n          event.preventDefault();\n          focusSegment(index + 1);\n          break;\n        case \"ArrowLeft\":\n          event.preventDefault();\n          focusSegment(index - 1);\n          break;\n        default:\n          break;\n      }\n    },\n    [\n      disabled,\n      sortedUnits,\n      step,\n      value,\n      max,\n      setTotal,\n      commitSegmentValue,\n      focusSegment,\n      handleDigit,\n    ]\n  );\n\n  const handleSegmentBlur = useCallback(\n    (index: number) => {\n      if (typingRef.current?.index === index) {\n        clearTypingTimeout();\n        typingRef.current = null;\n      }\n    },\n    [clearTypingTimeout]\n  );\n\n  const handlePointerDown = useCallback(\n    (event: ReactPointerEvent<HTMLDivElement>, index: number) => {\n      if (!scrub || disabled) {\n        return;\n      }\n      event.currentTarget.setPointerCapture(event.pointerId);\n      dragStateRef.current = {\n        index,\n        startValue: value,\n        startX: event.clientX,\n      };\n      setDraggingIndex(index);\n    },\n    [scrub, disabled, value]\n  );\n\n  const handlePointerMove = useCallback(\n    (event: ReactPointerEvent<HTMLDivElement>) => {\n      const drag = dragStateRef.current;\n      if (!drag) {\n        return;\n      }\n      const divisor = UNIT_DIVISOR[sortedUnits[drag.index]];\n      const deltaSteps = Math.round(\n        (event.clientX - drag.startX) / PX_PER_DRAG_STEP\n      );\n      setTotal(drag.startValue + deltaSteps * step * divisor);\n    },\n    [sortedUnits, step, setTotal]\n  );\n\n  const handlePointerUp = useCallback(() => {\n    dragStateRef.current = null;\n    setDraggingIndex(null);\n  }, []);\n\n  return (\n    <div className={cn(\"inline-flex flex-col gap-1.5\", className)}>\n      {label ? (\n        <span\n          className=\"font-medium text-muted-foreground text-xs\"\n          id={labelId}\n        >\n          {label}\n        </span>\n      ) : null}\n      <div\n        aria-label={label ? undefined : \"Duration\"}\n        aria-labelledby={label ? labelId : undefined}\n        className={cn(\n          \"inline-flex items-center gap-0.5 rounded-lg border border-border bg-muted/40 px-2 py-1.5\",\n          disabled && \"opacity-50\"\n        )}\n        role=\"group\"\n      >\n        {sortedUnits.map((unit, index) => {\n          const text = PAD_FORMATTER.format(values[index]);\n          const isDragging = draggingIndex === index;\n          return (\n            // biome-ignore lint/suspicious/noArrayIndexKey: segments are stable per unit\n            <span className=\"flex items-center\" key={unit}>\n              {index > 0 && (\n                <span aria-hidden=\"true\" className=\"mx-0.5 text-foreground/40\">\n                  :\n                </span>\n              )}\n              <div\n                aria-disabled={disabled || undefined}\n                aria-label={UNIT_LABEL[unit]}\n                aria-valuemax={getSegmentMax(index, sortedUnits, max)}\n                aria-valuemin={0}\n                aria-valuenow={values[index]}\n                aria-valuetext={pluralize(values[index], unit)}\n                className={cn(\n                  \"relative flex select-none items-center justify-center rounded-md px-1 py-0.5 font-medium text-foreground tabular-nums outline-none\",\n                  \"focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2\",\n                  scrub && !disabled && \"cursor-ew-resize\",\n                  disabled && \"pointer-events-none\",\n                  isDragging && \"bg-foreground/10\"\n                )}\n                onBlur={() => handleSegmentBlur(index)}\n                onKeyDown={(event) => handleSegmentKeyDown(event, index)}\n                onPointerDown={(event) => handlePointerDown(event, index)}\n                onPointerMove={handlePointerMove}\n                onPointerUp={handlePointerUp}\n                ref={(el) => {\n                  segmentRefs.current[index] = el;\n                }}\n                role=\"spinbutton\"\n                style={{ fontSize: 18, touchAction: \"none\" }}\n                tabIndex={disabled ? -1 : 0}\n              >\n                {text.split(\"\").map((char, charIndex) => (\n                  <AnimatedDigit\n                    char={char}\n                    // biome-ignore lint/suspicious/noArrayIndexKey: digit position within a segment is stable\n                    key={charIndex}\n                    reduceMotion={Boolean(shouldReduceMotion)}\n                  />\n                ))}\n              </div>\n            </span>\n          );\n        })}\n      </div>\n    </div>\n  );\n};\n\nexport default DurationPicker;\n","path":"index.tsx","target":"components/smoothui/duration-picker/index.tsx","type":"registry:ui"}],"name":"duration-picker","registryDependencies":[],"title":"Duration Picker","type":"registry:ui"}