{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "duration-picker",
  "title": "Duration Picker",
  "description": "A gooey, spring-animated picker for entering a duration in hours and minutes.",
  "dependencies": [
    "motion",
    "figma-squircle",
    "flubber",
    "react-use-measure",
    "@radix-ui/react-slot"
  ],
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "components/ui/duration-picker.tsx",
      "content": "'use client'\n\nimport { Slot } from '@radix-ui/react-slot'\nimport { getSvgPath } from 'figma-squircle'\nimport { interpolate } from 'flubber'\nimport { motion, useMotionValue, useReducedMotion, useSpring, useTransform, useVelocity, type MotionStyle, type MotionValue } from 'motion/react'\nimport React, { useEffect, useLayoutEffect, useRef, useState } from 'react'\nimport useMeasure from 'react-use-measure'\n\nimport { cn } from '@/lib/utils'\n\nconst PEN_PATH = '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 = 'M7.959 20.513L1.592 12.872L3.128 11.592L8.041 17.487L20.947 3.587L22.413 4.948L7.959 20.513Z'\n\nconst OPEN_GAP = 8\nconst CORNER_RADIUS = 12\nconst GAP_SPRING = { stiffness: 200, damping: 28, mass: 1 }\nconst ICON_SPRING = { stiffness: 200, damping: 28 }\nconst WIDTH_SPRING = { stiffness: 250, damping: 31 }\nconst SWAY_SPRING = { stiffness: 200, damping: 24 }\nconst ERROR_SPRING = { stiffness: 700, damping: 9 }\n\nexport type DurationValue = {\n    hours: number\n    minutes: number\n}\n\nexport type DurationPickerProps = Omit<\n    React.ComponentProps<'div'>,\n    'onChange' | 'defaultValue' | 'onDrag' | 'onDragStart' | 'onDragEnd' | 'onAnimationStart' | 'onAnimationEnd'\n> & {\n    value?: DurationValue\n    defaultValue?: DurationValue\n    onChange?: (value: DurationValue) => void\n    onConfirm?: (value: DurationValue) => void\n    onEditingChange?: (editing: boolean) => void\n    defaultEditing?: boolean\n    maxHours?: number\n    maxMinutes?: number\n    hoursLabel?: string\n    minutesLabel?: string\n    disabled?: boolean\n}\n\ntype SquircleSegmentProps = {\n    asChild?: boolean\n    cornerSmoothing?: number\n    leftRadius: number | MotionValue<number>\n    rightRadius: number | MotionValue<number>\n    className?: string\n    style?: MotionStyle\n    children: React.ReactNode\n}\n\nconst MotionSlot = motion.create(Slot)\n\nconst radiusValue = (radius: number | MotionValue<number>) =>\n    typeof radius === 'number' ? radius : radius.get()\n\nconst SquircleSegment = ({ asChild, cornerSmoothing = 1, leftRadius, rightRadius, className, style, children }: SquircleSegmentProps) => {\n    const Component = asChild ? MotionSlot : motion.div\n    const [ref, bounds] = useMeasure()\n    const width = useMotionValue(0)\n    const height = useMotionValue(0)\n\n    useEffect(() => {\n        width.set(bounds.width)\n        height.set(bounds.height)\n    }, [bounds.width, bounds.height, width, height])\n\n    const clipPath = useTransform(() => {\n        const w = width.get()\n        const h = height.get()\n        if (w <= 0 || h <= 0) {\n            return 'none'\n        }\n        const left = radiusValue(leftRadius)\n        const right = radiusValue(rightRadius)\n        const path = getSvgPath({\n            width: w,\n            height: h,\n            topLeftCornerRadius: left,\n            bottomLeftCornerRadius: left,\n            topRightCornerRadius: right,\n            bottomRightCornerRadius: right,\n            cornerSmoothing,\n        })\n        return `path('${path}')`\n    })\n\n    return (\n        <Component data-slot=\"duration-picker-segment\" ref={ref} className={className} style={{ ...style, clipPath }}>\n            {children}\n        </Component>\n    )\n}\n\ntype DurationFieldProps = {\n    value: string\n    onValueChange: (value: string) => void\n    max: number\n    isEditing: boolean\n    shouldReduceMotion: boolean\n    disabled?: boolean\n    swayX: MotionValue<number>\n    inputRef?: React.RefObject<HTMLInputElement | null>\n}\n\nconst DurationField = ({ value, onValueChange, max, isEditing, shouldReduceMotion, disabled, swayX, inputRef }: DurationFieldProps) => {\n    const measureRef = useRef<HTMLSpanElement>(null)\n    const [textWidth, setTextWidth] = useState(0)\n    const errorX = useSpring(0, ERROR_SPRING)\n    const x = useTransform(() => swayX.get() + errorX.get())\n\n    useLayoutEffect(() => {\n        if (measureRef.current) {\n            setTextWidth(measureRef.current.offsetWidth)\n        }\n    }, [value])\n\n    const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {\n        const next = event.target.value\n        if (next !== '' && (Number(next) > max || Number(next) < 0)) {\n            onValueChange(String(Math.min(max, Math.max(0, Number(next)))))\n            if (!shouldReduceMotion) {\n                errorX.jump(6)\n                errorX.set(0)\n            }\n            return\n        }\n        onValueChange(next)\n    }\n\n    const collapsedWidth = Math.max(textWidth + 12, 22)\n\n    return (\n        <>\n            <motion.input\n                data-slot=\"duration-picker-input\"\n                ref={inputRef}\n                type=\"number\"\n                value={value}\n                onChange={handleChange}\n                placeholder={isEditing ? '' : '0'}\n                readOnly={!isEditing}\n                disabled={disabled}\n                style={{ x, width: isEditing ? 44 : collapsedWidth }}\n                animate={{ width: isEditing ? 44 : collapsedWidth }}\n                transition={shouldReduceMotion ? { duration: 0 } : { type: 'spring', ...WIDTH_SPRING }}\n                className='h-full text-center font-semibold text-black dark:text-white outline-none [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none'\n            />\n            <span ref={measureRef} aria-hidden className=\"invisible absolute whitespace-pre font-semibold\">{value || '0'}</span>\n        </>\n    )\n}\n\nconst clampField = (raw: number, max: number) => Math.min(max, Math.max(0, Math.trunc(raw) || 0))\n\nfunction DurationPicker({\n    value,\n    defaultValue,\n    onChange,\n    onConfirm,\n    onEditingChange,\n    defaultEditing = false,\n    maxHours = 24,\n    maxMinutes = 60,\n    hoursLabel = 'Hr.',\n    minutesLabel = 'Min.',\n    disabled = false,\n    className,\n    ...props\n}: DurationPickerProps) {\n    const isControlled = value !== undefined\n    const [isEditing, setIsEditing] = useState(defaultEditing)\n    const [hoursText, setHoursText] = useState(() => fieldText(value ?? defaultValue, 'hours'))\n    const [minutesText, setMinutesText] = useState(() => fieldText(value ?? defaultValue, 'minutes'))\n\n    useEffect(() => {\n        if (!isControlled) {\n            return\n        }\n        if (clampField(Number(hoursText), maxHours) !== clampField(value.hours, maxHours)) {\n            setHoursText(fieldText(value, 'hours'))\n        }\n        if (clampField(Number(minutesText), maxMinutes) !== clampField(value.minutes, maxMinutes)) {\n            setMinutesText(fieldText(value, 'minutes'))\n        }\n        // eslint-disable-next-line react-hooks/exhaustive-deps\n    }, [isControlled, value?.hours, value?.minutes, maxHours, maxMinutes])\n\n    const toValue = (hours: string, minutes: string): DurationValue => ({\n        hours: clampField(Number(hours), maxHours),\n        minutes: clampField(Number(minutes), maxMinutes),\n    })\n\n    const handleHoursChange = (text: string) => {\n        setHoursText(text)\n        onChange?.(toValue(text, minutesText))\n    }\n\n    const handleMinutesChange = (text: string) => {\n        setMinutesText(text)\n        onChange?.(toValue(hoursText, text))\n    }\n\n    const shouldReduceMotion = useReducedMotion()\n    const gap = useSpring(defaultEditing ? OPEN_GAP : 0, GAP_SPRING)\n    const openness = (v: number) => Math.min(OPEN_GAP, Math.max(0, v)) / OPEN_GAP\n    const segmentSpacing = useTransform(gap, (v) => `${Math.min(OPEN_GAP, Math.max(0, v)) - (1 - openness(v))}px`)\n    const innerRadius = useTransform(gap, (v) => CORNER_RADIUS * openness(v))\n    const innerPadRight = useTransform(gap, (v) => `${3 + 9 * openness(v)}px`)\n    const innerPadLeft = useTransform(gap, (v) => `${9 * openness(v)}px`)\n    const gapVelocity = useVelocity(gap)\n    const swayXRaw = useTransform(gapVelocity, [-70, 0, 70], [-3, 0, 3], { clamp: true })\n    const swayX = useSpring(swayXRaw, SWAY_SPRING)\n    const iconProgress = useSpring(defaultEditing ? 1 : 0, ICON_SPRING)\n    const iconPath = useTransform(iconProgress, [0, 1], [PEN_PATH, TICK_PATH], {\n        clamp: true,\n        mixer: (from, to) => interpolate(from, to, { maxSegmentLength: 1 }),\n    })\n    const iconStrokeWidth = useTransform(iconProgress, [0, 1], [0, 2.5], { clamp: true })\n    const iconStrokeOpacity = useTransform(iconProgress, [0, 1], [0, 1], { clamp: true })\n    const iconDashOpacity = useTransform(iconProgress, [0, 0.4], [1, 0], { clamp: true })\n    const hoursInputRef = useRef<HTMLInputElement>(null)\n\n    const toggleEdit = () => {\n        if (disabled) {\n            return\n        }\n        const next = !isEditing\n        const targetGap = next ? OPEN_GAP : 0\n        const targetIcon = next ? 1 : 0\n        if (shouldReduceMotion) {\n            gap.jump(targetGap)\n            iconProgress.jump(targetIcon)\n        } else {\n            gap.set(targetGap)\n            iconProgress.set(targetIcon)\n        }\n        setIsEditing(next)\n        onEditingChange?.(next)\n        if (next) {\n            hoursInputRef.current?.focus()\n        } else {\n            onConfirm?.(toValue(hoursText, minutesText))\n        }\n    }\n\n    return (\n        <motion.div\n            data-slot=\"duration-picker\"\n            data-editing={isEditing || undefined}\n            data-disabled={disabled || undefined}\n            className={cn('flex flex-row items-center justify-center text-card-foreground', disabled && 'opacity-50', className)}\n            {...props}\n        >\n            <SquircleSegment leftRadius={CORNER_RADIUS} rightRadius={innerRadius} style={{ paddingRight: innerPadRight }} className=\"bg-[#F4F4F9] dark:bg-[#262626] h-12 flex items-center gap-1 pl-2\">\n                <DurationField value={hoursText} onValueChange={handleHoursChange} max={maxHours} isEditing={isEditing} shouldReduceMotion={!!shouldReduceMotion} disabled={disabled} swayX={swayX} inputRef={hoursInputRef} />\n                <motion.span style={{ x: swayX }} className='text-[#868593]/70 font-semibold font-runde'>{hoursLabel}</motion.span>\n            </SquircleSegment>\n            <SquircleSegment leftRadius={innerRadius} rightRadius={innerRadius} style={{ marginLeft: segmentSpacing, paddingLeft: innerPadLeft, paddingRight: innerPadRight }} className=\"bg-[#F4F4F9] dark:bg-[#262626] h-12 flex items-center gap-1\">\n                <DurationField value={minutesText} onValueChange={handleMinutesChange} max={maxMinutes} isEditing={isEditing} shouldReduceMotion={!!shouldReduceMotion} disabled={disabled} swayX={swayX} />\n                <motion.span style={{ x: swayX }} className='text-[#868593]/70 font-medium font-runde'>{minutesLabel}</motion.span>\n            </SquircleSegment>\n            <SquircleSegment asChild leftRadius={innerRadius} rightRadius={CORNER_RADIUS} style={{ marginLeft: segmentSpacing }}>\n                <button\n                    data-slot=\"duration-picker-toggle\"\n                    type=\"button\"\n                    onClick={toggleEdit}\n                    disabled={disabled}\n                    aria-label={isEditing ? 'Save duration' : 'Edit duration'}\n                    className='w-12 h-12 bg-[#F4F4F9] dark:bg-[#262626] flex justify-center items-center active:scale-90 transition-transform disabled:active:scale-100'\n                >\n                    <svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\" width=\"18\" height=\"18\">\n                        <motion.path fill=\"#868593\" stroke=\"#868593\" strokeWidth={0} strokeLinejoin=\"round\" strokeLinecap=\"round\" style={{ strokeWidth: iconStrokeWidth, strokeOpacity: iconStrokeOpacity }} d={iconPath} />\n                        <motion.path d=\"M14 6L18 10\" fill=\"none\" strokeWidth={1.5} strokeLinecap=\"round\" className=\"stroke-[#F4F4F9] dark:stroke-[#262626]\" style={{ opacity: iconDashOpacity }} />\n                    </svg>\n                </button>\n            </SquircleSegment>\n        </motion.div>\n    )\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\nexport { DurationPicker }\nexport default DurationPicker\n",
      "type": "registry:ui"
    }
  ],
  "type": "registry:ui"
}