{"name":"date-wheel-picker","type":"registry:ui","dependencies":["framer-motion"],"registryDependencies":[],"files":[{"path":"/components/ui/date-wheel-picker.tsx","content":"import {\n  animate,\n  type MotionValue,\n  motion,\n  type PanInfo,\n  useMotionValue,\n  useTransform,\n} from \"framer-motion\";\nimport * as React from \"react\";\nimport { cn } from \"@/lib/utils\";\n\nexport interface DateWheelPickerProps\n  extends Omit<React.HTMLAttributes<HTMLDivElement>, \"onChange\"> {\n  value?: Date;\n  onChange: (date: Date) => void;\n  minYear?: number;\n  maxYear?: number;\n  size?: \"sm\" | \"md\" | \"lg\";\n  disabled?: boolean;\n  locale?: string;\n}\n\nconst ITEM_HEIGHT = 40;\nconst VISIBLE_ITEMS = 5;\nconst PERSPECTIVE_ORIGIN = ITEM_HEIGHT * 2;\n\nfunction getMonthNames(locale?: string): string[] {\n  const formatter = new Intl.DateTimeFormat(locale, { month: \"long\" });\n  return Array.from({ length: 12 }, (_, i) =>\n    formatter.format(new Date(2000, i, 1)),\n  );\n}\n\nconst sizeConfig = {\n  sm: {\n    height: ITEM_HEIGHT * VISIBLE_ITEMS * 0.8,\n    itemHeight: ITEM_HEIGHT * 0.8,\n    fontSize: \"text-sm\",\n    gap: \"gap-2\",\n  },\n  md: {\n    height: ITEM_HEIGHT * VISIBLE_ITEMS,\n    itemHeight: ITEM_HEIGHT,\n    fontSize: \"text-base\",\n    gap: \"gap-4\",\n  },\n  lg: {\n    height: ITEM_HEIGHT * VISIBLE_ITEMS * 1.2,\n    itemHeight: ITEM_HEIGHT * 1.2,\n    fontSize: \"text-lg\",\n    gap: \"gap-6\",\n  },\n};\n\ninterface WheelItemProps {\n  item: string | number;\n  index: number;\n  y: MotionValue<number>;\n  itemHeight: number;\n  visibleItems: number;\n  centerOffset: number;\n  isSelected: boolean;\n  disabled?: boolean;\n  onClick: () => void;\n}\n\nfunction WheelItem({\n  item,\n  index,\n  y,\n  itemHeight,\n  visibleItems,\n  centerOffset,\n  isSelected,\n  disabled,\n  onClick,\n}: WheelItemProps) {\n  const itemY = useTransform(y, (latest) => {\n    const offset = index * itemHeight + latest + centerOffset;\n    return offset;\n  });\n\n  const rotateX = useTransform(\n    itemY,\n    [0, centerOffset, itemHeight * visibleItems],\n    [45, 0, -45],\n  );\n\n  const scale = useTransform(\n    itemY,\n    [0, centerOffset, itemHeight * visibleItems],\n    [0.8, 1, 0.8],\n  );\n\n  const opacity = useTransform(\n    itemY,\n    [\n      0,\n      centerOffset * 0.5,\n      centerOffset,\n      centerOffset * 1.5,\n      itemHeight * visibleItems,\n    ],\n    [0.3, 0.6, 1, 0.6, 0.3],\n  );\n\n  return (\n    <motion.div\n      className=\"flex select-none items-center justify-center\"\n      style={{\n        height: itemHeight,\n        rotateX,\n        scale,\n        opacity,\n        transformStyle: \"preserve-3d\",\n        transformOrigin: `center center -${PERSPECTIVE_ORIGIN}px`,\n      }}\n      onClick={() => !disabled && onClick()}\n    >\n      <span\n        className={cn(\n          \"font-medium transition-colors\",\n          isSelected ? \"text-foreground\" : \"text-muted-foreground\",\n        )}\n      >\n        {item}\n      </span>\n    </motion.div>\n  );\n}\n\ninterface WheelColumnProps {\n  items: (string | number)[];\n  value: number;\n  onChange: (index: number) => void;\n  itemHeight: number;\n  visibleItems: number;\n  disabled?: boolean;\n  className?: string;\n  ariaLabel: string;\n}\n\nfunction WheelColumn({\n  items,\n  value,\n  onChange,\n  itemHeight,\n  visibleItems,\n  disabled,\n  className,\n  ariaLabel,\n}: WheelColumnProps) {\n  const containerRef = React.useRef<HTMLDivElement>(null);\n  const y = useMotionValue(-value * itemHeight);\n  const centerOffset = Math.floor(visibleItems / 2) * itemHeight;\n\n  const valueRef = React.useRef(value);\n  const onChangeRef = React.useRef(onChange);\n  const itemsLengthRef = React.useRef(items.length);\n\n  React.useEffect(() => {\n    valueRef.current = value;\n    onChangeRef.current = onChange;\n    itemsLengthRef.current = items.length;\n  });\n\n  React.useEffect(() => {\n    animate(y, -value * itemHeight, {\n      type: \"spring\",\n      stiffness: 300,\n      damping: 30,\n    });\n  }, [value, itemHeight, y]);\n\n  const handleDragEnd = (\n    _: MouseEvent | TouchEvent | PointerEvent,\n    info: PanInfo,\n  ) => {\n    if (disabled) return;\n\n    const currentY = y.get();\n    const velocity = info.velocity.y;\n    const projectedY = currentY + velocity * 0.2;\n\n    let newIndex = Math.round(-projectedY / itemHeight);\n    newIndex = Math.max(0, Math.min(items.length - 1, newIndex));\n\n    onChange(newIndex);\n  };\n\n  React.useEffect(() => {\n    const container = containerRef.current;\n    if (!container || disabled) return;\n\n    const handleWheel = (e: WheelEvent) => {\n      e.preventDefault();\n      e.stopPropagation();\n\n      const direction = e.deltaY > 0 ? 1 : -1;\n      const currentValue = valueRef.current;\n      const maxIndex = itemsLengthRef.current - 1;\n      const newIndex = Math.max(\n        0,\n        Math.min(maxIndex, currentValue + direction),\n      );\n\n      if (newIndex !== currentValue) {\n        onChangeRef.current(newIndex);\n      }\n    };\n\n    container.addEventListener(\"wheel\", handleWheel, { passive: false });\n\n    return () => {\n      container.removeEventListener(\"wheel\", handleWheel);\n    };\n  }, [disabled]);\n\n  const handleKeyDown = (e: React.KeyboardEvent) => {\n    if (disabled) return;\n\n    const maxIndex = items.length - 1;\n    let newIndex = value;\n\n    switch (e.key) {\n      case \"ArrowUp\":\n        e.preventDefault();\n        newIndex = Math.max(0, value - 1);\n        break;\n      case \"ArrowDown\":\n        e.preventDefault();\n        newIndex = Math.min(maxIndex, value + 1);\n        break;\n      case \"Home\":\n        e.preventDefault();\n        newIndex = 0;\n        break;\n      case \"End\":\n        e.preventDefault();\n        newIndex = maxIndex;\n        break;\n      case \"PageUp\":\n        e.preventDefault();\n        newIndex = Math.max(0, value - 5);\n        break;\n      case \"PageDown\":\n        e.preventDefault();\n        newIndex = Math.min(maxIndex, value + 5);\n        break;\n      default:\n        return;\n    }\n\n    if (newIndex !== value) {\n      onChange(newIndex);\n    }\n  };\n\n  const dragConstraints = React.useMemo(\n    () => ({\n      top: -(items.length - 1) * itemHeight,\n      bottom: 0,\n    }),\n    [items.length, itemHeight],\n  );\n\n  return (\n    <div\n      ref={containerRef}\n      className={cn(\n        \"relative overflow-hidden\",\n        disabled && \"pointer-events-none opacity-50\",\n        className,\n      )}\n      style={{ height: itemHeight * visibleItems }}\n      tabIndex={disabled ? -1 : 0}\n      onKeyDown={handleKeyDown}\n      role=\"spinbutton\"\n      aria-label={ariaLabel}\n      aria-valuenow={value}\n      aria-valuemin={0}\n      aria-valuemax={items.length - 1}\n      aria-valuetext={String(items[value])}\n      aria-disabled={disabled}\n    >\n      <div\n        className=\"pointer-events-none absolute inset-x-0 top-0 z-10\"\n        style={{\n          height: centerOffset,\n          background:\n            \"linear-gradient(to bottom, var(--background) 0%, transparent 100%)\",\n        }}\n        aria-hidden=\"true\"\n      />\n      <div\n        className=\"pointer-events-none absolute inset-x-0 bottom-0 z-10\"\n        style={{\n          height: centerOffset,\n          background:\n            \"linear-gradient(to top, var(--background) 0%, transparent 100%)\",\n        }}\n        aria-hidden=\"true\"\n      />\n\n      <div\n        className=\"pointer-events-none absolute inset-x-0 z-5 border-border border-y bg-muted/30\"\n        style={{\n          top: centerOffset,\n          height: itemHeight,\n        }}\n        aria-hidden=\"true\"\n      />\n\n      <motion.div\n        className=\"cursor-grab active:cursor-grabbing\"\n        style={{\n          y,\n          paddingTop: centerOffset,\n          paddingBottom: centerOffset,\n        }}\n        drag=\"y\"\n        dragConstraints={dragConstraints}\n        dragElastic={0.1}\n        onDragEnd={handleDragEnd}\n      >\n        {items.map((item, index) => (\n          <WheelItem\n            key={`${item}-${index}`}\n            item={item}\n            index={index}\n            y={y}\n            itemHeight={itemHeight}\n            visibleItems={visibleItems}\n            centerOffset={centerOffset}\n            isSelected={index === value}\n            disabled={disabled}\n            onClick={() => onChange(index)}\n          />\n        ))}\n      </motion.div>\n    </div>\n  );\n}\n\nfunction getDaysInMonth(year: number, month: number): number {\n  return new Date(year, month + 1, 0).getDate();\n}\n\nconst DateWheelPicker = React.forwardRef<HTMLDivElement, DateWheelPickerProps>(\n  (\n    {\n      value,\n      onChange,\n      minYear = 1920,\n      maxYear = new Date().getFullYear(),\n      size = \"md\",\n      disabled = false,\n      locale,\n      className,\n      ...props\n    },\n    ref,\n  ) => {\n    const config = sizeConfig[size];\n\n    const months = React.useMemo(() => getMonthNames(locale), [locale]);\n\n    const years = React.useMemo(() => {\n      const arr: number[] = [];\n      for (let y = maxYear; y >= minYear; y--) {\n        arr.push(y);\n      }\n      return arr;\n    }, [minYear, maxYear]);\n\n    const [dateState, setDateState] = React.useState(() => {\n      const currentDate = value || new Date();\n      return {\n        day: currentDate.getDate(),\n        month: currentDate.getMonth(),\n        year: currentDate.getFullYear(),\n      };\n    });\n\n    const isInternalChange = React.useRef(false);\n\n    const days = React.useMemo(() => {\n      const daysInMonth = getDaysInMonth(dateState.year, dateState.month);\n      return Array.from({ length: daysInMonth }, (_, i) => i + 1);\n    }, [dateState.month, dateState.year]);\n\n    const handleDayChange = React.useCallback((dayIndex: number) => {\n      isInternalChange.current = true;\n      setDateState((prev) => ({ ...prev, day: dayIndex + 1 }));\n    }, []);\n\n    const handleMonthChange = React.useCallback((monthIndex: number) => {\n      isInternalChange.current = true;\n      setDateState((prev) => {\n        const daysInNewMonth = getDaysInMonth(prev.year, monthIndex);\n        const adjustedDay = Math.min(prev.day, daysInNewMonth);\n        return { ...prev, month: monthIndex, day: adjustedDay };\n      });\n    }, []);\n\n    const handleYearChange = React.useCallback(\n      (yearIndex: number) => {\n        isInternalChange.current = true;\n        setDateState((prev) => {\n          const newYear = years[yearIndex] ?? prev.year;\n          const daysInNewMonth = getDaysInMonth(newYear, prev.month);\n          const adjustedDay = Math.min(prev.day, daysInNewMonth);\n          return { ...prev, year: newYear, day: adjustedDay };\n        });\n      },\n      [years],\n    );\n\n    React.useEffect(() => {\n      if (isInternalChange.current) {\n        const newDate = new Date(\n          dateState.year,\n          dateState.month,\n          dateState.day,\n        );\n        onChange(newDate);\n        isInternalChange.current = false;\n      }\n    }, [dateState, onChange]);\n\n    React.useEffect(() => {\n      if (value && !isInternalChange.current) {\n        const valueDay = value.getDate();\n        const valueMonth = value.getMonth();\n        const valueYear = value.getFullYear();\n\n        if (\n          valueDay !== dateState.day ||\n          valueMonth !== dateState.month ||\n          valueYear !== dateState.year\n        ) {\n          setDateState({\n            day: valueDay,\n            month: valueMonth,\n            year: valueYear,\n          });\n        }\n      }\n    }, [value, dateState.day, dateState.month, dateState.year]);\n\n    const yearIndex = years.indexOf(dateState.year);\n\n    return (\n      <div\n        ref={ref}\n        className={cn(\n          \"flex items-center justify-center\",\n          config.gap,\n          config.fontSize,\n          disabled && \"pointer-events-none opacity-50\",\n          className,\n        )}\n        style={{ perspective: \"1000px\" }}\n        role=\"group\"\n        aria-label=\"Date picker\"\n        {...props}\n      >\n        <WheelColumn\n          items={days}\n          value={dateState.day - 1}\n          onChange={handleDayChange}\n          itemHeight={config.itemHeight}\n          visibleItems={VISIBLE_ITEMS}\n          disabled={disabled}\n          className=\"w-16\"\n          ariaLabel=\"Select day\"\n        />\n\n        <WheelColumn\n          items={months}\n          value={dateState.month}\n          onChange={handleMonthChange}\n          itemHeight={config.itemHeight}\n          visibleItems={VISIBLE_ITEMS}\n          disabled={disabled}\n          className=\"w-28\"\n          ariaLabel=\"Select month\"\n        />\n\n        <WheelColumn\n          items={years}\n          value={yearIndex >= 0 ? yearIndex : 0}\n          onChange={handleYearChange}\n          itemHeight={config.itemHeight}\n          visibleItems={VISIBLE_ITEMS}\n          disabled={disabled}\n          className=\"w-20\"\n          ariaLabel=\"Select year\"\n        />\n      </div>\n    );\n  },\n);\n\nDateWheelPicker.displayName = \"DateWheelPicker\";\n\nexport { DateWheelPicker };\n","type":"registry:ui","target":""}],"tailwind":{"config":{}}}