{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "week-calendar",
  "type": "registry:ui",
  "registryDependencies": [],
  "dependencies": ["motion", "lucide-react", "date-fns"],
  "devDependencies": [],
  "files": [
    {
      "path": "week-calendar.tsx",
      "content": "\"use client\";\n\nimport {\n  addDays,\n  addMonths,\n  eachDayOfInterval,\n  endOfMonth,\n  endOfWeek,\n  format,\n  isBefore,\n  isSameDay,\n  isSameMonth,\n  startOfDay,\n  startOfMonth,\n  startOfWeek,\n  subMonths,\n} from \"date-fns\";\nimport { ChevronLeft, ChevronRight } from \"lucide-react\";\nimport {\n  AnimatePresence,\n  motion,\n  type PanInfo,\n  useMotionValue,\n  useReducedMotion,\n  useTransform,\n} from \"motion/react\";\nimport {\n  type ComponentPropsWithoutRef,\n  forwardRef,\n  useEffect,\n  useId,\n  useMemo,\n  useRef,\n  useState,\n} from \"react\";\n\nimport { cn } from \"@/lib/utils\";\n\nconst componentThemeClassName =\n  \"[--ic-background:#ffffff] [--ic-foreground:#111111] [--ic-primary:#111111] [--ic-secondary:#646b75] [--ic-surface-border:#e9edf2] [--ic-border:#e3e7ec] [--ic-card:#ffffff] [--ic-card-foreground:#111111] [--ic-muted:#f5f7fa] [--ic-muted-foreground:#6d7480] [--ic-accent:#f3f5f8] [--color-accent:var(--ic-accent)] [--color-accent-foreground:var(--ic-accent-foreground)] [--ic-accent-foreground:#111111] [--ic-input:#e3e7ec] [--ic-ring:rgba(17,17,17,0.16)] [--ic-destructive:#dc2626] [--ic-paper:#fcfcfd] [--ic-popover-foreground:#111111] [--ic-brand:#0ea5e9] [--ic-brand-soft:#bae6fd] [--ic-primary-foreground:#ffffff] [--ic-shadow-soft:0_1px_2px_-1px_rgba(15,23,42,0.08),0_2px_6px_-2px_rgba(15,23,42,0.08)] [--ic-chart-1:oklch(0.52_0.19_254)] [--ic-chart-2:oklch(0.74_0.11_232)] [--ic-chart-3:oklch(0.42_0.16_262)] [--ic-chart-4:oklch(0.84_0.07_228)] [--ic-chart-5:oklch(0.62_0.14_240)] [--color-background:var(--ic-background)] [--color-foreground:var(--ic-foreground)] [--color-primary:var(--ic-primary)] [--color-primary-foreground:var(--ic-primary-foreground)] [--color-secondary:var(--ic-secondary)] [--color-border:var(--ic-border)] [--color-card:var(--ic-card)] [--color-card-foreground:var(--ic-card-foreground)] [--color-muted:var(--ic-muted)] [--color-muted-foreground:var(--ic-muted-foreground)] [--color-accent:var(--ic-accent)] [--color-accent-foreground:var(--ic-accent-foreground)] [--color-input:var(--ic-input)] [--color-ring:var(--ic-ring)] [--color-destructive:var(--ic-destructive)] [--color-paper:var(--ic-paper)] [--color-popover-foreground:var(--ic-popover-foreground)] [--color-brand:var(--ic-brand)] [--color-brand-soft:var(--ic-brand-soft)] [--color-chart-1:var(--ic-chart-1)] [--color-chart-2:var(--ic-chart-2)] [--color-chart-3:var(--ic-chart-3)] [--color-chart-4:var(--ic-chart-4)] [--color-chart-5:var(--ic-chart-5)] dark:[--ic-background:#111111] dark:[--ic-foreground:#f6f3ec] dark:[--ic-primary:#f6f3ec] dark:[--ic-primary-foreground:#111111] dark:[--ic-secondary:#cbc6bb] dark:[--ic-surface-border:#2a2a25] dark:[--ic-border:#2b2a25] dark:[--ic-card:#111111] dark:[--ic-card-foreground:#f6f3ec] dark:[--ic-muted:#171716] dark:[--ic-muted-foreground:#9a958a] dark:[--ic-accent:#1a1a18] [--color-accent:var(--ic-accent)] [--color-accent-foreground:var(--ic-accent-foreground)] dark:[--ic-accent-foreground:#f6f3ec] dark:[--ic-input:#2b2a25] dark:[--ic-ring:rgba(246,243,236,0.18)] dark:[--ic-destructive:#f87171] dark:[--ic-paper:#171716] dark:[--ic-popover-foreground:#f6f3ec] dark:[--ic-brand:#38bdf8] dark:[--ic-brand-soft:#0c4a6e] dark:[--ic-shadow-soft:0_1px_2px_-1px_rgba(0,0,0,0.3),0_2px_6px_-2px_rgba(0,0,0,0.3)] dark:[--ic-chart-1:oklch(0.68_0.17_250)] dark:[--ic-chart-2:oklch(0.82_0.09_225)] dark:[--ic-chart-3:oklch(0.58_0.15_260)] dark:[--ic-chart-4:oklch(0.75_0.12_235)] dark:[--ic-chart-5:oklch(0.88_0.06_220)]\";\n\nconst controlCornerClassName =\n  \"supports-[corner-shape:squircle]:corner-squircle\";\n\nconst focusVisibleClassName =\n  \"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--ic-ring)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--ic-card)]\";\n\nconst PILL_SPRING = {\n  type: \"spring\",\n  stiffness: 480,\n  damping: 30,\n  mass: 0.8,\n} as const;\nconst ROW_SPRING = { type: \"spring\", stiffness: 340, damping: 32 } as const;\nconst DISSOLVE_SPRING = {\n  type: \"spring\",\n  stiffness: 280,\n  damping: 28,\n} as const;\nconst PRESS_SPRING = {\n  type: \"spring\",\n  stiffness: 520,\n  damping: 26,\n} as const;\n\nconst WEEK_LENGTH = 7;\nconst DAY_ROW_HEIGHT = 48;\nconst SWIPE_DISTANCE_THRESHOLD = 45;\nconst SWIPE_VELOCITY_THRESHOLD = 350;\nconst HANDLE_DRAG_THRESHOLD = 8;\nconst ROW_STAGGER_SECONDS = 0.045;\nconst TILT_RANGE_PX = 120;\nconst TILT_RANGE_DEG = 3.5;\nconst ROW_DISSOLVE_BLUR_PX = 8;\nconst LABEL_DISSOLVE_BLUR_PX = 4;\n\ntype WeekStartsOn = 0 | 1 | 2 | 3 | 4 | 5 | 6;\ntype RowCustom = { delay: number; direction: number };\n\nconst rowVariants = {\n  center: ({ delay, direction }: RowCustom) => ({\n    filter: \"blur(0px)\",\n    height: DAY_ROW_HEIGHT,\n    opacity: 1,\n    scale: 1,\n    transition: direction === 0 ? { ...ROW_SPRING, delay } : DISSOLVE_SPRING,\n  }),\n  enter: ({ direction }: RowCustom) =>\n    direction === 0\n      ? { filter: \"blur(0px)\", height: 0, opacity: 0, scale: 1 }\n      : {\n          filter: `blur(${ROW_DISSOLVE_BLUR_PX}px)`,\n          height: DAY_ROW_HEIGHT,\n          opacity: 0,\n          scale: 0.96,\n        },\n  exit: ({ direction }: RowCustom) =>\n    direction === 0\n      ? {\n          filter: \"blur(0px)\",\n          height: 0,\n          opacity: 0,\n          scale: 1,\n          transition: ROW_SPRING,\n        }\n      : {\n          filter: `blur(${ROW_DISSOLVE_BLUR_PX}px)`,\n          height: DAY_ROW_HEIGHT,\n          opacity: 0,\n          scale: 0.96,\n          transition: DISSOLVE_SPRING,\n        },\n};\n\nconst labelVariants = {\n  center: { filter: \"blur(0px)\", opacity: 1 },\n  enter: { filter: `blur(${LABEL_DISSOLVE_BLUR_PX}px)`, opacity: 0 },\n  exit: { filter: `blur(${LABEL_DISSOLVE_BLUR_PX}px)`, opacity: 0 },\n};\n\nfunction WeekCalendarDay({\n  date,\n  isCurrentToday,\n  isSelected,\n  motionId,\n  onSelect,\n  reduceMotion,\n}: {\n  date: Date;\n  isCurrentToday: boolean;\n  isSelected: boolean;\n  motionId: string;\n  onSelect: (date: Date) => void;\n  reduceMotion: boolean | null;\n}) {\n  return (\n    <motion.button\n      aria-current={isCurrentToday ? \"date\" : undefined}\n      aria-label={format(date, \"EEEE, MMMM d, yyyy\")}\n      aria-pressed={isSelected}\n      className=\"group relative flex h-12 cursor-pointer items-center justify-center outline-none\"\n      onClick={() => onSelect(date)}\n      transition={PRESS_SPRING}\n      type=\"button\"\n      whileHover={reduceMotion ? undefined : { scale: 1.06 }}\n      whileTap={reduceMotion ? undefined : { scale: 0.86 }}\n    >\n      {isCurrentToday && !isSelected ? (\n        <motion.span\n          className={cn(\n            controlCornerClassName,\n            \"absolute size-9 rounded-full border-[1.5px] border-[var(--ic-primary)]\"\n          )}\n          layoutId={reduceMotion ? undefined : `${motionId}-today`}\n          transition={PILL_SPRING}\n        />\n      ) : null}\n      {isSelected ? (\n        <motion.span\n          className={cn(\n            controlCornerClassName,\n            \"absolute size-9 rounded-full bg-[var(--ic-primary)] shadow-[0_3px_10px_-2px_var(--ic-ring),inset_0_1px_0_0_rgba(255,255,255,0.2)]\"\n          )}\n          layoutId={reduceMotion ? undefined : `${motionId}-selected`}\n          transition={PILL_SPRING}\n        />\n      ) : (\n        <span\n          className={cn(\n            controlCornerClassName,\n            \"absolute size-9 rounded-full transition-colors duration-150 group-hover:bg-[var(--ic-accent)]\"\n          )}\n        />\n      )}\n      <span className=\"relative flex size-9 items-center justify-center\">\n        <span\n          className={cn(\n            \"font-medium text-[13px] tabular-nums transition-colors duration-150\",\n            isSelected && \"font-semibold text-[var(--ic-primary-foreground)]\",\n            !isSelected &&\n              isCurrentToday &&\n              \"font-semibold text-[var(--ic-primary)]\",\n            !(isSelected || isCurrentToday) &&\n              \"text-[var(--ic-card-foreground)]\"\n          )}\n        >\n          {date.getDate()}\n        </span>\n      </span>\n    </motion.button>\n  );\n}\n\nfunction getMonthWeeks(monthAnchor: Date, weekStartsOn: WeekStartsOn) {\n  const start = startOfWeek(startOfMonth(monthAnchor), { weekStartsOn });\n  const end = endOfWeek(endOfMonth(monthAnchor), { weekStartsOn });\n  const days = eachDayOfInterval({ start, end });\n\n  const weeks: Date[][] = [];\n  for (let index = 0; index < days.length; index += WEEK_LENGTH) {\n    weeks.push(days.slice(index, index + WEEK_LENGTH));\n  }\n  return weeks;\n}\n\nexport type WeekCalendarProps = Readonly<\n  {\n    /** Controlled selected date. Pass `null` to clear the highlight. */\n    selected?: Date | null;\n    /** Initial selected day for uncontrolled usage. Defaults to today. */\n    defaultSelected?: Date | null;\n    /** Called when the user taps a day, including outside-month days. */\n    onSelect?: (date: Date) => void;\n    /** Controlled expanded state — collapsed shows a week, expanded shows the month. */\n    expanded?: boolean;\n    /** Initial expanded state for uncontrolled usage. @default false */\n    defaultExpanded?: boolean;\n    /** Called whenever the week/month morph toggles, from the grabber-handle drag or Enter/Space. */\n    onExpandedChange?: (expanded: boolean) => void;\n    /** Overrides the first day of the week. @default 0 (Sunday) */\n    weekStartsOn?: WeekStartsOn;\n  } & Omit<ComponentPropsWithoutRef<\"div\">, \"onSelect\">\n>;\n\n// Week strip that morphs into a full month grid, with spring physics,\n// swipe-to-change-week, and a draggable grabber handle.\nexport const WeekCalendar = forwardRef<HTMLDivElement, WeekCalendarProps>(\n  (\n    {\n      className,\n      selected: selectedProp,\n      defaultSelected,\n      onSelect,\n      expanded: expandedProp,\n      defaultExpanded = false,\n      onExpandedChange,\n      weekStartsOn = 0,\n      ...props\n    },\n    ref\n  ) => {\n    const motionId = useId();\n    const reduceMotion = useReducedMotion();\n    const today = useMemo(() => startOfDay(new Date()), []);\n\n    const [selectedState, setSelectedState] = useState<Date | null>(\n      defaultSelected !== undefined ? defaultSelected : today\n    );\n    const selected = selectedProp !== undefined ? selectedProp : selectedState;\n\n    const [expandedState, setExpandedState] = useState(defaultExpanded);\n    const expanded = expandedProp ?? expandedState;\n\n    const [anchor, setAnchor] = useState(() =>\n      selected ? startOfDay(selected) : today\n    );\n    const [direction, setDirection] = useState(0);\n\n    const dragX = useMotionValue(0);\n    const dragTilt = useTransform(\n      dragX,\n      [-TILT_RANGE_PX, TILT_RANGE_PX],\n      [-TILT_RANGE_DEG, TILT_RANGE_DEG]\n    );\n\n    useEffect(() => {\n      if (selectedProp) setAnchor(startOfDay(selectedProp));\n    }, [selectedProp]);\n\n    useEffect(() => {\n      if (expanded) dragX.set(0);\n    }, [expanded, dragX]);\n\n    const weeks = useMemo(\n      () => getMonthWeeks(anchor, weekStartsOn),\n      [anchor, weekStartsOn]\n    );\n\n    const weekdayLabels = useMemo(() => {\n      const base = startOfWeek(anchor, { weekStartsOn });\n      return Array.from({ length: WEEK_LENGTH }, (_, index) =>\n        format(addDays(base, index), \"EEEEE\")\n      );\n    }, [anchor, weekStartsOn]);\n\n    const anchorWeekStart = startOfWeek(anchor, { weekStartsOn });\n    const anchorWeekIndex = Math.max(\n      weeks.findIndex((week) => isSameDay(week[0], anchorWeekStart)),\n      0\n    );\n\n    const monthLabel = format(anchor, \"MMMM yyyy\");\n    const rangeLabel = expanded\n      ? monthLabel\n      : `${format(weeks[anchorWeekIndex]?.[0] ?? anchor, \"MMM d\")} – ${format(\n          weeks[anchorWeekIndex]?.[6] ?? anchor,\n          \"MMM d\"\n        )}`;\n\n    const isViewingCurrentPeriod = expanded\n      ? isSameMonth(anchor, today)\n      : (weeks[anchorWeekIndex]?.some((date) => isSameDay(date, today)) ??\n        false);\n\n    const setExpanded = (next: boolean) => {\n      if (expandedProp === undefined) setExpandedState(next);\n      onExpandedChange?.(next);\n    };\n\n    const selectDay = (date: Date) => {\n      const normalized = startOfDay(date);\n      if (selectedProp === undefined) setSelectedState(normalized);\n      onSelect?.(normalized);\n      setAnchor(normalized);\n    };\n\n    const goPrev = () => {\n      setDirection(-1);\n      setAnchor((prev) => (expanded ? subMonths(prev, 1) : addDays(prev, -7)));\n    };\n\n    const goNext = () => {\n      setDirection(1);\n      setAnchor((prev) => (expanded ? addMonths(prev, 1) : addDays(prev, 7)));\n    };\n\n    const jumpToToday = () => {\n      setDirection(isBefore(anchor, today) ? 1 : -1);\n      setAnchor(today);\n    };\n\n    const toggleExpanded = () => {\n      setDirection(0);\n      setExpanded(!expanded);\n    };\n\n    const handleSwipeEnd = (_event: unknown, info: PanInfo) => {\n      if (expanded) return;\n      if (\n        info.offset.x <= -SWIPE_DISTANCE_THRESHOLD ||\n        info.velocity.x <= -SWIPE_VELOCITY_THRESHOLD\n      ) {\n        goNext();\n      } else if (\n        info.offset.x >= SWIPE_DISTANCE_THRESHOLD ||\n        info.velocity.x >= SWIPE_VELOCITY_THRESHOLD\n      ) {\n        goPrev();\n      }\n    };\n\n    const handleDragTriggeredRef = useRef(false);\n\n    const handleHandleDragStart = () => {\n      handleDragTriggeredRef.current = false;\n    };\n\n    const handleHandleDrag = (_event: unknown, info: PanInfo) => {\n      if (handleDragTriggeredRef.current) return;\n      if (info.offset.y > HANDLE_DRAG_THRESHOLD && !expanded) {\n        handleDragTriggeredRef.current = true;\n        setExpanded(true);\n      } else if (info.offset.y < -HANDLE_DRAG_THRESHOLD && expanded) {\n        handleDragTriggeredRef.current = true;\n        setExpanded(false);\n      }\n    };\n\n    return (\n      <div\n        className={cn(\n          componentThemeClassName,\n          \"w-full max-w-[21rem] select-none rounded-2xl border border-[var(--ic-border)] bg-[var(--ic-card)] px-4 pt-4 pb-2 font-sans text-[var(--ic-card-foreground)] ring-1 ring-black/[0.03] dark:ring-white/[0.06]\",\n          \"shadow-[var(--ic-shadow-soft)]\",\n          className\n        )}\n        data-expanded={expanded}\n        data-slot=\"week-calendar\"\n        ref={ref}\n        {...props}\n      >\n        <span aria-live=\"polite\" className=\"sr-only\">\n          {selected\n            ? `Selected ${format(selected, \"EEEE, MMMM d, yyyy\")}`\n            : \"No date selected\"}\n        </span>\n\n        <div className=\"mb-2.5 flex items-center justify-between gap-2\">\n          <div className=\"flex min-w-0 items-center gap-2\">\n            <div className=\"min-w-0 overflow-hidden\">\n              <AnimatePresence initial={false} mode=\"popLayout\">\n                <motion.p\n                  animate=\"center\"\n                  className=\"truncate font-semibold text-[16px] tracking-tight\"\n                  exit=\"exit\"\n                  initial={reduceMotion ? false : \"enter\"}\n                  key={rangeLabel}\n                  transition={DISSOLVE_SPRING}\n                  variants={labelVariants}\n                >\n                  {rangeLabel}\n                </motion.p>\n              </AnimatePresence>\n            </div>\n            <AnimatePresence initial={false}>\n              {isViewingCurrentPeriod ? null : (\n                <motion.button\n                  animate={{ opacity: 1, scale: 1 }}\n                  aria-label=\"Jump to today\"\n                  className={cn(\n                    focusVisibleClassName,\n                    \"shrink-0 cursor-pointer rounded-full bg-[var(--ic-accent)] px-2.5 py-1 font-semibold text-[11px] text-[var(--ic-card-foreground)] transition-colors duration-150 hover:bg-[var(--ic-border)]\"\n                  )}\n                  exit={{ opacity: 0, scale: 0.8 }}\n                  initial={{ opacity: 0, scale: 0.8 }}\n                  onClick={jumpToToday}\n                  transition={PRESS_SPRING}\n                  type=\"button\"\n                  whileTap={{ scale: 0.9 }}\n                >\n                  Today\n                </motion.button>\n              )}\n            </AnimatePresence>\n          </div>\n\n          <div className=\"flex shrink-0 items-center gap-0.5\">\n            <motion.button\n              aria-label={expanded ? \"Previous month\" : \"Previous week\"}\n              className={cn(\n                controlCornerClassName,\n                focusVisibleClassName,\n                \"flex size-7 cursor-pointer items-center justify-center rounded-full text-[var(--ic-muted-foreground)] transition-colors duration-150 hover:bg-[var(--ic-accent)] hover:text-[var(--ic-card-foreground)]\"\n              )}\n              onClick={goPrev}\n              transition={PRESS_SPRING}\n              type=\"button\"\n              whileTap={{ scale: 0.82 }}\n            >\n              <ChevronLeft aria-hidden size={14} strokeWidth={2.25} />\n            </motion.button>\n            <motion.button\n              aria-label={expanded ? \"Next month\" : \"Next week\"}\n              className={cn(\n                controlCornerClassName,\n                focusVisibleClassName,\n                \"flex size-7 cursor-pointer items-center justify-center rounded-full text-[var(--ic-muted-foreground)] transition-colors duration-150 hover:bg-[var(--ic-accent)] hover:text-[var(--ic-card-foreground)]\"\n              )}\n              onClick={goNext}\n              transition={PRESS_SPRING}\n              type=\"button\"\n              whileTap={{ scale: 0.82 }}\n            >\n              <ChevronRight aria-hidden size={14} strokeWidth={2.25} />\n            </motion.button>\n          </div>\n        </div>\n\n        <div className=\"grid grid-cols-7 px-0.5 pb-1\">\n          {weekdayLabels.map((label, index) => (\n            <span\n              className=\"text-center font-semibold text-[10px] text-[var(--ic-muted-foreground)] uppercase tracking-wide\"\n              key={`${label}-${index}`}\n            >\n              {label}\n            </span>\n          ))}\n        </div>\n\n        <motion.div\n          className=\"flex touch-pan-y flex-col\"\n          drag={expanded ? false : \"x\"}\n          dragConstraints={{ left: 0, right: 0 }}\n          dragElastic={0.55}\n          onDragEnd={handleSwipeEnd}\n          style={{ rotate: reduceMotion ? 0 : dragTilt, x: dragX }}\n        >\n          <AnimatePresence initial={false} mode=\"popLayout\">\n            {weeks\n              .map((week, index) => ({ index, week }))\n              .filter(({ index }) => expanded || index === anchorWeekIndex)\n              .map(({ week, index }) => (\n                <motion.div\n                  animate=\"center\"\n                  className=\"grid grid-cols-7\"\n                  custom={\n                    {\n                      delay:\n                        expanded && direction === 0\n                          ? Math.abs(index - anchorWeekIndex) *\n                            ROW_STAGGER_SECONDS\n                          : 0,\n                      direction,\n                    } satisfies RowCustom\n                  }\n                  exit={reduceMotion ? undefined : \"exit\"}\n                  initial={reduceMotion ? false : \"enter\"}\n                  key={week[0].toISOString()}\n                  layout=\"position\"\n                  style={{ overflow: \"hidden\" }}\n                  variants={rowVariants}\n                >\n                  {week.map((date) => (\n                    <WeekCalendarDay\n                      date={date}\n                      isCurrentToday={isSameDay(date, today)}\n                      isSelected={selected ? isSameDay(date, selected) : false}\n                      key={date.toISOString()}\n                      motionId={motionId}\n                      onSelect={selectDay}\n                      reduceMotion={reduceMotion}\n                    />\n                  ))}\n                </motion.div>\n              ))}\n          </AnimatePresence>\n        </motion.div>\n\n        <div className=\"flex justify-center pt-1.5 pb-0.5\">\n          <motion.button\n            aria-expanded={expanded}\n            aria-label={\n              expanded ? \"Collapse to week view\" : \"Expand to month view\"\n            }\n            className={cn(\n              focusVisibleClassName,\n              \"h-[5px] w-9 cursor-grab touch-none rounded-full bg-[var(--ic-border)] outline-none transition-colors duration-150 hover:bg-[var(--ic-muted-foreground)]/40 active:cursor-grabbing\"\n            )}\n            drag=\"y\"\n            dragConstraints={{ top: 0, bottom: 0 }}\n            dragElastic={0}\n            dragMomentum={false}\n            onDrag={handleHandleDrag}\n            onDragStart={handleHandleDragStart}\n            onKeyDown={(event) => {\n              if (event.key === \"Enter\" || event.key === \" \") {\n                event.preventDefault();\n                toggleExpanded();\n              }\n            }}\n            transition={PRESS_SPRING}\n            type=\"button\"\n            whileHover={{ scaleX: 1.15 }}\n            whileTap={{ scaleX: 1.3 }}\n          />\n        </div>\n      </div>\n    );\n  }\n);\n\nWeekCalendar.displayName = \"WeekCalendar\";\n",
      "type": "registry:ui"
    }
  ],
  "title": "Week Calendar",
  "description": "Week strip that morphs into a full month grid, with a shared-layout spring pill and a draggable grabber handle."
}
