{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"duration-picker","type":"registry:ui","title":"Duration Picker","description":"Hours, minutes and a save button fused into one bar that pulls apart on a liquid thread while you edit.","dependencies":["motion","figma-squircle","react-use-measure"],"registryDependencies":["utils"],"files":[{"path":"registry/ui/duration-picker.tsx","type":"registry:ui","content":"\"use client\";\n\n// Adapted from Calamansi UI by fujiDevv (MIT License).\n// https://github.com/fujiDevv/calamansi-ui\n\nimport {\n  useEffect,\n  useId,\n  useLayoutEffect,\n  useMemo,\n  useRef,\n  useState,\n  type ChangeEvent,\n  type ComponentProps,\n  type ReactNode,\n  type RefObject,\n} from \"react\";\nimport {\n  motion,\n  useReducedMotion,\n  useSpring,\n  useTransform,\n  useVelocity,\n  type MotionValue,\n  type Transition,\n} from \"motion/react\";\nimport { getSvgPath } from \"figma-squircle\";\nimport useMeasure from \"react-use-measure\";\nimport { cn } from \"@/lib/utils\";\n\n/* ─── Liquid bar primitives (inlined from Calamansi's lib/liquid) ────────────\n   Surfaces are painted on layers inset by springs, so seams open without the\n   layout moving. The stage is blurred, pushed through a steep alpha ramp so any\n   bleed between neighbours becomes solid matter (a metaball thread), and the\n   crisp stage is drawn back on top so labels stay sharp. */\n\nconst LIQUID_SPRING = { type: \"spring\", stiffness: 200, damping: 28, mass: 1 } as const;\n/** Where a blurred thread gives up, as a multiple of the blur radius. */\nconst LIQUID_SEVER = 1.234;\n/** Default blur as a share of the gap. */\nconst LIQUID_VISCOSITY = 0.55;\n/** Sealed extension, in corner radii: enough to hide a neighbour's corner. */\nconst LIQUID_SEAL = 2;\nconst BEAD_SIZE = 0.18;\nconst BEAD_FADE = 0.22;\nconst BEAD_PEAK_MAX = 0.92;\nconst SWALLOW_REACH = 0.25;\n\nfunction beadPercent(gap: number, sever: number, open: number) {\n  if (!Number.isFinite(gap) || open <= 0) return 0;\n  const travel = gap / open;\n  if (travel <= 0) return 0;\n  const peak = Math.min(sever / open, BEAD_PEAK_MAX);\n  if (travel < peak) return BEAD_SIZE * 100 * (travel / peak) ** 1.6;\n  const fade = Math.min(BEAD_FADE, 1 - peak);\n  const past = (travel - peak) / fade;\n  return past >= 1 ? 0 : BEAD_SIZE * 100 * (1 - past);\n}\n\nfunction swallowShare(retracted: number, pull: number) {\n  if (pull <= 0) return 1;\n  const reach = SWALLOW_REACH * pull;\n  const s = Math.min(1, Math.max(0, (reach - retracted) / reach));\n  return s * s * (3 - 2 * s);\n}\n\nfunction FuseFilter({ id, blur, threshold }: { id: string; blur: number; threshold: number }) {\n  return (\n    <svg aria-hidden=\"true\" className=\"pointer-events-none absolute size-0\">\n      <defs>\n        <filter id={id} x=\"-8%\" y=\"-60%\" width=\"116%\" height=\"220%\" colorInterpolationFilters=\"sRGB\">\n          <feGaussianBlur in=\"SourceGraphic\" stdDeviation={blur} result=\"blur\" />\n          <feColorMatrix\n            in=\"blur\"\n            type=\"matrix\"\n            values={`1 0 0 0 0  0 1 0 0 0  0 0 1 0 0  0 0 0 ${threshold} ${-threshold / 2}`}\n            result=\"goo\"\n          />\n          <feComposite in=\"SourceGraphic\" in2=\"goo\" operator=\"over\" />\n        </filter>\n      </defs>\n    </svg>\n  );\n}\n\nfunction Squircle({ className, radius }: { className?: string; radius: number }) {\n  const [ref, bounds] = useMeasure({ offsetSize: true });\n  const path = useMemo(\n    () =>\n      bounds.width > 0 && bounds.height > 0\n        ? getSvgPath({ width: bounds.width, height: bounds.height, cornerRadius: radius, cornerSmoothing: 1 })\n        : null,\n    [bounds.width, bounds.height, radius],\n  );\n  return (\n    <span aria-hidden=\"true\" className=\"pointer-events-none absolute inset-0 block\">\n      <span\n        ref={ref}\n        style={path ? { clipPath: `path('${path}')` } : { borderRadius: radius }}\n        className={cn(\"relative block size-full overflow-hidden\", className)}\n      />\n    </span>\n  );\n}\n\ntype LiquidSegmentProps = {\n  seamLeft: boolean;\n  seamRight: boolean;\n  atStart: boolean;\n  atEnd: boolean;\n  radius: number;\n  pull: number;\n  seal: number;\n  sever: number;\n  bead: boolean;\n  beadFill: string;\n  paint: string;\n  reduced: boolean;\n  children: ReactNode;\n};\n\nfunction LiquidSegment({\n  seamLeft,\n  seamRight,\n  atStart,\n  atEnd,\n  radius,\n  pull,\n  seal,\n  sever,\n  bead,\n  beadFill,\n  paint,\n  reduced,\n  children,\n}: LiquidSegmentProps) {\n  const retractLeft = useSpring(!atStart && seamLeft ? pull : 0, LIQUID_SPRING);\n  const retractRight = useSpring(!atEnd && seamRight ? pull : 0, LIQUID_SPRING);\n\n  useEffect(() => {\n    const left = !atStart && seamLeft ? pull : 0;\n    const right = !atEnd && seamRight ? pull : 0;\n    if (reduced) {\n      retractLeft.jump(left);\n      retractRight.jump(right);\n    } else {\n      retractLeft.set(left);\n      retractRight.set(right);\n    }\n  }, [atStart, atEnd, seamLeft, seamRight, pull, reduced, retractLeft, retractRight]);\n\n  // the swallow under the neighbour waits for the retraction to come home, then eases in\n  const left = useTransform(() => {\n    const r = retractLeft.get();\n    return r - (atStart ? 0 : seal * swallowShare(r, pull));\n  });\n  const right = useTransform(() => {\n    const r = retractRight.get();\n    return r - (atEnd ? 0 : seal * swallowShare(r, pull));\n  });\n  // content rides with the visible edge only\n  const shift = useTransform(() => (Math.max(0, left.get()) - Math.max(0, right.get())) / 2);\n  const beadHeight = useTransform(() => `${beadPercent(left.get() * 2, sever, pull * 2)}%`);\n\n  return (\n    <li className=\"relative flex\">\n      <motion.span aria-hidden=\"true\" className=\"pointer-events-none absolute inset-y-0 block\" style={{ left, right }}>\n        <Squircle radius={radius} className={cn(\"transition-colors duration-300 ease-out\", paint)} />\n      </motion.span>\n      {bead && !atStart && (\n        <motion.span\n          aria-hidden=\"true\"\n          className={cn(\"pointer-events-none absolute top-1/2 -translate-x-1/2 -translate-y-1/2 rounded-full bg-current\", beadFill)}\n          style={{ left: 0, height: beadHeight, aspectRatio: 1 }}\n        />\n      )}\n      <motion.div className=\"relative z-10 flex min-w-0\" style={{ x: shift }}>\n        {children}\n      </motion.div>\n    </li>\n  );\n}\n\n/* ─── Duration picker ────────────────────────────────────────────────────── */\n\nconst useIsoLayoutEffect = typeof window !== \"undefined\" ? useLayoutEffect : useEffect;\n\nconst WIDTH_SPRING = { stiffness: 250, damping: 31 } as const;\nconst ERROR_SPRING = { stiffness: 700, damping: 9 } as const;\nconst SWAY_SPRING = { stiffness: 200, damping: 24 } as const;\nconst ICON_SPRING = { stiffness: 300, damping: 30 } as const;\n\n/** How far a field is nudged when it is handed a value out of range. */\nconst REFUSAL = 6;\n\nconst PEN_PATH =\n  \"M3.78181 16.3092L3 21L7.69086 20.2182C8.50544 20.0825 9.25725 19.6956 9.84119 19.1116L20.4198 8.53288C21.1934 7.75922 21.1934 6.5049 20.4197 5.73126L18.2687 3.58024C17.495 2.80658 16.2406 2.80659 15.4669 3.58027L4.88841 14.159C4.30447 14.7429 3.91757 15.4947 3.78181 16.3092Z\";\nconst TICK_PATH = \"M4.6 12.4 9.6 17.4 19.4 7.6\";\n\nexport type DurationValue = { hours: number; minutes: number };\nexport type DurationPickerVariant = \"white\" | \"calamansi\" | \"slate\" | \"citrus\";\nexport type DurationPickerSize = \"sm\" | \"md\" | \"lg\";\n\nconst VARIANTS: Record<DurationPickerVariant, { paint: string; juice: string; ink: string }> = {\n  white: { paint: \"bg-white dark:bg-[#1c1c1f]\", juice: \"text-white dark:text-[#1c1c1f]\", ink: \"text-foreground\" },\n  calamansi: { paint: \"bg-[#5c7a67] dark:bg-[#16221a]\", juice: \"text-[#5c7a67] dark:text-[#16221a]\", ink: \"text-white\" },\n  slate: { paint: \"bg-[#687396] dark:bg-[#1e293b]\", juice: \"text-[#687396] dark:text-[#1e293b]\", ink: \"text-white\" },\n  citrus: { paint: \"bg-[#b87152] dark:bg-[#22120b]\", juice: \"text-[#b87152] dark:text-[#22120b]\", ink: \"text-white\" },\n};\n\n/** The tray. Must be opaque: the fuse's alpha ramp erases translucent paint. */\nconst TRAY = \"bg-muted\";\nconst TRAY_INK = \"text-muted\";\n\nconst SIZES = {\n  sm: { bar: \"h-10\", radius: 16, gap: 10, field: \"px-3.5\", toggle: \"size-10\", icon: \"size-4\", text: \"text-sm\", unit: \"text-[11px]\", box: 40 },\n  md: { bar: \"h-12\", radius: 20, gap: 12, field: \"px-4\", toggle: \"size-12\", icon: \"size-[18px]\", text: \"text-base\", unit: \"text-xs\", box: 44 },\n  lg: { bar: \"h-14\", radius: 24, gap: 14, field: \"px-5\", toggle: \"size-14\", icon: \"size-5\", text: \"text-lg\", unit: \"text-sm\", box: 48 },\n} as const;\n\nexport type DurationPickerProps = Omit<ComponentProps<\"div\">, \"onChange\" | \"defaultValue\"> & {\n  /** Controlled value. */\n  value?: DurationValue;\n  /** Value on mount when uncontrolled. */\n  defaultValue?: DurationValue;\n  /** Fired on every keystroke with the clamped value. */\n  onChange?: (value: DurationValue) => void;\n  /** Fired when the bar is committed (the tick, or Enter in a field). */\n  onConfirm?: (value: DurationValue) => void;\n  /** Fired when the pieces split apart or merge back. */\n  onEditingChange?: (editing: boolean) => void;\n  /** Hold the bar open or shut yourself. */\n  editing?: boolean;\n  /** Open the bar on mount when uncontrolled. */\n  defaultEditing?: boolean;\n  /** Ceiling for the hours field. */\n  maxHours?: number;\n  /** Ceiling for the minutes field. */\n  maxMinutes?: number;\n  /** Unit after the hours field. */\n  hoursLabel?: string;\n  /** Unit after the minutes field. */\n  minutesLabel?: string;\n  /** Palette the save button and the bead wear. */\n  variant?: DurationPickerVariant;\n  /** Bar height, and with it the corner, travel and field widths. */\n  size?: DurationPickerSize;\n  /** Override how far the seams open on each side, in px. */\n  gap?: number;\n  /** Override the corner radius, in px. */\n  radius?: number;\n  /** SVG blur behind the fuse, in px. Defaults to 0.55 of the gap. */\n  viscosity?: number;\n  /** Alpha ramp slope: how hard the fused edge is. */\n  threshold?: number;\n  /** Run the fuse at all. Off, the pieces simply slide apart. */\n  gooey?: boolean;\n  /** Leave a bead of juice behind when the thread severs. */\n  bead?: boolean;\n  disabled?: boolean;\n};\n\nfunction fieldText(value: DurationValue | undefined, field: keyof DurationValue) {\n  const n = value?.[field];\n  return n === undefined || n === 0 ? \"\" : String(n);\n}\n\nconst clampField = (raw: number, max: number) => Math.min(max, Math.max(0, Math.trunc(raw) || 0));\n\nconst DEFAULT_HOURS = \"Hr.\";\nconst DEFAULT_MINUTES = \"Min.\";\n\ntype DurationFieldProps = {\n  value: string;\n  onValueChange: (value: string) => void;\n  onCommit: () => void;\n  max: number;\n  label: string;\n  editing: boolean;\n  reduced: boolean;\n  disabled?: boolean;\n  sway: MotionValue<number>;\n  size: DurationPickerSize;\n  inputRef?: RefObject<HTMLInputElement | null>;\n};\n\n/** One number field: collapsed to its digits at rest, springs to a fixed width when live. */\nfunction DurationField({ value, onValueChange, onCommit, max, label, editing, reduced, disabled, sway, size, inputRef }: DurationFieldProps) {\n  const preset = SIZES[size];\n  const measureRef = useRef<HTMLSpanElement>(null);\n  const [textWidth, setTextWidth] = useState(0);\n  const refusal = useSpring(0, ERROR_SPRING);\n  const x = useTransform(() => sway.get() + refusal.get());\n\n  useIsoLayoutEffect(() => {\n    if (measureRef.current) setTextWidth(measureRef.current.offsetWidth);\n  }, [value, preset.text]);\n\n  const handleChange = (event: ChangeEvent<HTMLInputElement>) => {\n    const next = event.target.value;\n    // out of range: take the nearest legal value and shake the field\n    if (next !== \"\" && (Number(next) > max || Number(next) < 0)) {\n      onValueChange(String(clampField(Number(next), max)));\n      if (!reduced) {\n        refusal.jump(REFUSAL);\n        refusal.set(0);\n      }\n      return;\n    }\n    onValueChange(next);\n  };\n\n  const collapsed = Math.max(textWidth + 12, 22);\n\n  return (\n    <>\n      <motion.input\n        ref={inputRef}\n        type=\"number\"\n        inputMode=\"numeric\"\n        value={value}\n        onChange={handleChange}\n        onKeyDown={(event) => {\n          if (event.key === \"Enter\") {\n            event.preventDefault();\n            onCommit();\n          }\n        }}\n        placeholder={editing ? \"\" : \"0\"}\n        readOnly={!editing}\n        disabled={disabled}\n        aria-label={label}\n        min={0}\n        max={max}\n        style={{ x }}\n        animate={{ width: editing ? preset.box : collapsed }}\n        transition={reduced ? { duration: 0 } : { type: \"spring\", ...WIDTH_SPRING }}\n        className={cn(\n          \"h-full shrink-0 bg-transparent text-center font-semibold tabular-nums text-foreground outline-none\",\n          \"selection:bg-primary/30\",\n          \"[appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none\",\n          preset.text,\n        )}\n      />\n      {/* same font as the input, so the collapsed width is the digits' width */}\n      <span\n        ref={measureRef}\n        aria-hidden=\"true\"\n        className={cn(\"pointer-events-none invisible absolute whitespace-pre font-semibold tabular-nums\", preset.text)}\n      >\n        {value || \"0\"}\n      </span>\n    </>\n  );\n}\n\n/**\n * Hours, minutes and a save button fused into one bar. Press the pen and the pieces\n * pull apart on a metaball thread; commit and they merge back.\n */\nexport function DurationPicker({\n  value,\n  defaultValue,\n  onChange,\n  onConfirm,\n  onEditingChange,\n  editing: editingProp,\n  defaultEditing = false,\n  maxHours = 24,\n  maxMinutes = 60,\n  hoursLabel = DEFAULT_HOURS,\n  minutesLabel = DEFAULT_MINUTES,\n  variant = \"calamansi\",\n  size = \"md\",\n  gap: gapProp,\n  radius: radiusProp,\n  viscosity,\n  threshold = 19,\n  gooey = true,\n  bead = true,\n  disabled = false,\n  className,\n  ...props\n}: DurationPickerProps) {\n  const reduced = useReducedMotion() ?? false;\n  const filterId = `duration-picker-${useId().replace(/:/g, \"\")}`;\n  const preset = SIZES[size] ?? SIZES.md;\n  const palette = VARIANTS[variant] ?? VARIANTS.calamansi;\n\n  const gap = gapProp ?? preset.gap;\n  const corner = radiusProp ?? preset.radius;\n  const blur = viscosity ?? gap * LIQUID_VISCOSITY;\n  const sever = LIQUID_SEVER * blur;\n  const seal = LIQUID_SEAL * corner;\n  const pull = gap / 2;\n  const fused = gooey && !reduced;\n\n  const [uncontrolledEditing, setUncontrolledEditing] = useState(defaultEditing);\n  const editing = editingProp ?? uncontrolledEditing;\n  const [hours, setHours] = useState(() => fieldText(value ?? defaultValue, \"hours\"));\n  const [minutes, setMinutes] = useState(() => fieldText(value ?? defaultValue, \"minutes\"));\n  const hoursRef = useRef<HTMLInputElement>(null);\n\n  // adopt a changed outside value in render, without fighting the caret\n  const [seen, setSeen] = useState(() => [value?.hours, value?.minutes] as const);\n  if (value && (value.hours !== seen[0] || value.minutes !== seen[1])) {\n    setSeen([value.hours, value.minutes]);\n    if (clampField(Number(hours), maxHours) !== clampField(value.hours, maxHours)) setHours(fieldText(value, \"hours\"));\n    if (clampField(Number(minutes), maxMinutes) !== clampField(value.minutes, maxMinutes)) setMinutes(fieldText(value, \"minutes\"));\n  }\n\n  // the fields lean into the split and settle out of it, driven by the seam spring's velocity\n  const open = useSpring(defaultEditing ? 1 : 0, LIQUID_SPRING);\n  useEffect(() => {\n    const to = editing ? 1 : 0;\n    if (reduced) open.jump(to);\n    else open.set(to);\n  }, [editing, reduced, open]);\n\n  const velocity = useVelocity(open);\n  const swayRaw = useTransform(velocity, [-3, 0, 3], [-3, 0, 3], { clamp: true });\n  const sway = useSpring(swayRaw, SWAY_SPRING);\n\n  const toValue = (h: string, m: string): DurationValue => ({\n    hours: clampField(Number(h), maxHours),\n    minutes: clampField(Number(m), maxMinutes),\n  });\n\n  const applyEditing = (next: boolean) => {\n    if (editingProp === undefined) setUncontrolledEditing(next);\n    onEditingChange?.(next);\n  };\n\n  const commit = () => {\n    applyEditing(false);\n    onConfirm?.(toValue(hours, minutes));\n  };\n\n  const toggleEditing = () => {\n    if (disabled) return;\n    if (editing) return commit();\n    applyEditing(true);\n    hoursRef.current?.focus();\n  };\n\n  const handleHours = (text: string) => {\n    setHours(text);\n    onChange?.(toValue(text, minutes));\n  };\n  const handleMinutes = (text: string) => {\n    setMinutes(text);\n    onChange?.(toValue(hours, text));\n  };\n\n  const transition: Transition = reduced ? { duration: 0 } : ICON_SPRING;\n  const segment = { radius: corner, pull, seal, sever, bead, reduced };\n\n  return (\n    <div\n      data-editing={editing || undefined}\n      data-variant={variant}\n      className={cn(\"relative inline-flex\", disabled && \"opacity-50\", className)}\n      {...props}\n    >\n      <FuseFilter id={filterId} blur={blur} threshold={threshold} />\n      <ul className={cn(\"relative flex items-stretch\", preset.bar)} style={fused ? { filter: `url(#${filterId})` } : undefined}>\n        <LiquidSegment {...segment} seamLeft={false} seamRight={editing} atStart atEnd={false} beadFill={palette.juice} paint={TRAY}>\n          <div className={cn(\"flex h-full items-center gap-1\", preset.field)}>\n            <DurationField\n              value={hours}\n              onValueChange={handleHours}\n              onCommit={commit}\n              max={maxHours}\n              label={hoursLabel === DEFAULT_HOURS ? \"Hours\" : hoursLabel}\n              editing={editing}\n              reduced={reduced}\n              disabled={disabled}\n              sway={sway}\n              size={size}\n              inputRef={hoursRef}\n            />\n            <motion.span style={{ x: sway }} className={cn(\"shrink-0 text-muted-foreground\", preset.unit)}>\n              {hoursLabel}\n            </motion.span>\n          </div>\n        </LiquidSegment>\n\n        <LiquidSegment {...segment} seamLeft={editing} seamRight={editing} atStart={false} atEnd={false} beadFill={TRAY_INK} paint={TRAY}>\n          <div className={cn(\"flex h-full items-center gap-1\", preset.field)}>\n            <DurationField\n              value={minutes}\n              onValueChange={handleMinutes}\n              onCommit={commit}\n              max={maxMinutes}\n              label={minutesLabel === DEFAULT_MINUTES ? \"Minutes\" : minutesLabel}\n              editing={editing}\n              reduced={reduced}\n              disabled={disabled}\n              sway={sway}\n              size={size}\n            />\n            <motion.span style={{ x: sway }} className={cn(\"shrink-0 text-muted-foreground\", preset.unit)}>\n              {minutesLabel}\n            </motion.span>\n          </div>\n        </LiquidSegment>\n\n        <LiquidSegment\n          {...segment}\n          seamLeft={editing}\n          seamRight={false}\n          atStart={false}\n          atEnd\n          beadFill={palette.juice}\n          paint={cn(TRAY, editing && palette.paint)}\n        >\n          <button\n            type=\"button\"\n            onClick={toggleEditing}\n            disabled={disabled}\n            aria-label={editing ? \"Save duration\" : \"Edit duration\"}\n            aria-pressed={editing}\n            className={cn(\n              \"flex cursor-pointer items-center justify-center rounded-full outline-none transition-transform duration-200 focus-visible:ring-2 focus-visible:ring-ring active:scale-90 disabled:cursor-not-allowed disabled:active:scale-100\",\n              preset.toggle,\n              editing ? palette.ink : \"text-muted-foreground hover:text-foreground\",\n            )}\n          >\n            <svg viewBox=\"0 0 24 24\" aria-hidden=\"true\" className={cn(\"overflow-visible\", preset.icon)}>\n              {/* the pen puts itself away as the tick draws itself on */}\n              <motion.path\n                d={PEN_PATH}\n                fill=\"currentColor\"\n                initial={false}\n                animate={{ opacity: editing ? 0 : 1, y: editing ? -3 : 0 }}\n                transition={transition}\n              />\n              <motion.path\n                d={TICK_PATH}\n                fill=\"none\"\n                stroke=\"currentColor\"\n                strokeWidth={2.4}\n                strokeLinecap=\"round\"\n                strokeLinejoin=\"round\"\n                initial={false}\n                animate={{ pathLength: editing ? 1 : 0, opacity: editing ? 1 : 0 }}\n                transition={transition}\n              />\n            </svg>\n          </button>\n        </LiquidSegment>\n      </ul>\n    </div>\n  );\n}\n"}]}