{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "week-strip",
  "type": "registry:ui",
  "title": "Week Strip",
  "description": "Phone-style day selector with a sprung pill, swipeable weeks and event dots.",
  "categories": [
    "dates"
  ],
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "https://www.stealth.pm/r/tokens.json",
    "https://www.stealth.pm/r/cn.json",
    "https://www.stealth.pm/r/icons.json",
    "https://www.stealth.pm/r/motion.json",
    "https://www.stealth.pm/r/use-controllable-state.json"
  ],
  "files": [
    {
      "path": "components/ui/week-strip.tsx",
      "content": "\"use client\";\nimport { AnimatePresence, motion, useReducedMotion, type PanInfo } from \"motion/react\";\nimport { useEffect, useId, useMemo, useRef, useState, useSyncExternalStore } from \"react\";\nimport { cn } from \"@/lib/cn\";\nimport { ChevronLeft, ChevronRight } from \"@/lib/icons\";\nimport { ease, spring } from \"@/lib/motion\";\nimport { useControllableState } from \"@/lib/use-controllable-state\";\n\ntype Weekday = 0 | 1 | 2 | 3 | 4 | 5 | 6;\n\n/* ------------------------------------------------------------------------ */\n/* Calendar days in local time. Everything is a Date at local midnight.      */\n\nconst day = (d: Date) => new Date(d.getFullYear(), d.getMonth(), d.getDate());\nconst addDays = (d: Date, n: number) => new Date(d.getFullYear(), d.getMonth(), d.getDate() + n);\nconst keyOf = (d: Date) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, \"0\")}-${String(d.getDate()).padStart(2, \"0\")}`;\nconst startOfWeek = (d: Date, first: Weekday) => addDays(day(d), -((d.getDay() - first + 7) % 7));\n\n// Where Intl can't say (older engines), these regions start the week on Sunday.\nconst SUNDAY_FIRST = new Set([\n  \"US\",\n  \"CA\",\n  \"MX\",\n  \"BR\",\n  \"AR\",\n  \"CO\",\n  \"PE\",\n  \"VE\",\n  \"GT\",\n  \"PR\",\n  \"JP\",\n  \"KR\",\n  \"TW\",\n  \"HK\",\n  \"MO\",\n  \"PH\",\n  \"TH\",\n  \"ID\",\n  \"IN\",\n  \"IL\",\n  \"SA\",\n  \"ZA\",\n  \"KE\",\n]);\nfunction firstDayOf(locale: string): Weekday {\n  try {\n    const l = new Intl.Locale(locale) as Intl.Locale & { getWeekInfo?: () => { firstDay: number }; weekInfo?: { firstDay: number } };\n    const info = l.getWeekInfo?.() ?? l.weekInfo;\n    if (info?.firstDay) return (info.firstDay % 7) as Weekday;\n    return SUNDAY_FIRST.has(l.maximize().region ?? \"\") ? 0 : 1;\n  } catch {\n    return 1;\n  }\n}\n\n/* ------------------------------------------------------------------------ */\n/* The server knows neither the reader's day nor their locale; both arrive   */\n/* on the client right after hydration, without a mismatch.                  */\n\nconst noop = () => () => {};\nlet todayCache: { key: string; date: Date } | null = null;\nfunction readToday() {\n  const now = day(new Date());\n  const key = keyOf(now);\n  if (todayCache?.key !== key) todayCache = { key, date: now };\n  return todayCache.date;\n}\n// Rolls over at midnight so \"today\" is never stale in a tab left open overnight.\nfunction subscribeToday(notify: () => void) {\n  let timer = 0;\n  const arm = () => {\n    const now = new Date();\n    timer = window.setTimeout(\n      () => {\n        notify();\n        arm();\n      },\n      addDays(day(now), 1).getTime() - now.getTime() + 50,\n    );\n  };\n  arm();\n  const onVisible = () => !document.hidden && notify();\n  document.addEventListener(\"visibilitychange\", onVisible);\n  return () => {\n    window.clearTimeout(timer);\n    document.removeEventListener(\"visibilitychange\", onVisible);\n  };\n}\nfunction useToday() {\n  return useSyncExternalStore(subscribeToday, readToday, () => null);\n}\nfunction useLocale(locale?: string) {\n  const detected = useSyncExternalStore(\n    noop,\n    () => Intl.DateTimeFormat().resolvedOptions().locale,\n    () => \"en-US\",\n  );\n  return locale ?? detected;\n}\n\n/* ------------------------------------------------------------------------ */\n\nexport type WeekStripProps = Omit<React.ComponentProps<\"div\">, \"defaultValue\" | \"onChange\" | \"children\"> & {\n  /** The selected day. Any time of day is ignored. */\n  value?: Date | null;\n  defaultValue?: Date | null;\n  onValueChange?: (date: Date) => void;\n  /** How many dots to show under a day (up to 3), for days with events. */\n  markers?: (date: Date) => number;\n  /** 0 is Sunday. By default it follows the locale. */\n  weekStartsOn?: Weekday;\n  /** Earliest selectable day. */\n  min?: Date;\n  /** Latest selectable day. */\n  max?: Date;\n  isDateDisabled?: (date: Date) => boolean;\n  /** How a day's dots are read out: \"3 events\". */\n  markerLabel?: (count: number) => string;\n  /** Month label, week arrows and a Today button above the days. */\n  header?: boolean;\n  locale?: string;\n};\n\ntype Nav = { source: \"key\" | \"pointer\"; tick: number };\n\nexport function WeekStrip({\n  value,\n  defaultValue = null,\n  onValueChange,\n  markers,\n  weekStartsOn,\n  min,\n  max,\n  isDateDisabled,\n  markerLabel = (n) => (n === 1 ? \"1 event\" : `${n} events`),\n  header = true,\n  locale: localeProp,\n  className,\n  ...rest\n}: WeekStripProps) {\n  const id = useId();\n  const reduce = useReducedMotion();\n  const locale = useLocale(localeProp);\n  const today = useToday();\n  const first = weekStartsOn ?? firstDayOf(locale);\n\n  const [picked, setPicked] = useControllableState<Date | null>({\n    value,\n    defaultValue,\n    onChange: (d) => d && onValueChange?.(d),\n  });\n  const selected = picked ? day(picked) : today;\n  const weekStart = selected ? startOfWeek(selected, first) : null;\n  const weekKey = weekStart ? keyOf(weekStart) : \"pending\";\n\n  const [nav, setNav] = useState<Nav>({ source: \"pointer\", tick: 0 });\n  // Direction of the last week change, so the new week slides in from the side it came from.\n  const [view, setView] = useState({ key: weekKey, dir: 0 });\n  if (view.key !== weekKey) setView({ key: weekKey, dir: view.key === \"pending\" || weekKey === \"pending\" ? 0 : weekKey > view.key ? 1 : -1 });\n  const instant = nav.source === \"key\" || !!reduce;\n\n  const rowRef = useRef<HTMLDivElement>(null);\n  const dragged = useRef(false);\n\n  const disabled = (d: Date) => (min != null && d < day(min)) || (max != null && d > day(max)) || !!isDateDisabled?.(d);\n\n  const fmt = useMemo(\n    () => ({\n      weekday: new Intl.DateTimeFormat(locale, { weekday: \"short\" }),\n      full: new Intl.DateTimeFormat(locale, { weekday: \"long\", day: \"numeric\", month: \"long\", year: \"numeric\" }),\n      num: new Intl.DateTimeFormat(locale, { day: \"numeric\" }),\n      month: new Intl.DateTimeFormat(locale, { month: \"long\", year: \"numeric\" }),\n      range: new Intl.DateTimeFormat(locale, { month: \"short\", year: \"numeric\" }),\n    }),\n    [locale],\n  );\n\n  const days = weekStart ? Array.from({ length: 7 }, (_, i) => addDays(weekStart, i)) : [];\n  const monthLabel = weekStart ? (weekStart.getMonth() === days[6].getMonth() ? fmt.month.format(weekStart) : fmt.range.formatRange(weekStart, days[6])) : \" \";\n\n  function select(next: Date, source: Nav[\"source\"]) {\n    if (disabled(next)) return;\n    setNav((n) => ({ source, tick: n.tick + 1 }));\n    if (!selected || keyOf(next) !== keyOf(selected)) setPicked(next);\n  }\n\n  /** Step by `by` days, skipping disabled ones, for arrows and paging. */\n  function move(by: number, source: Nav[\"source\"]) {\n    if (!selected) return;\n    let next = addDays(selected, by);\n    const unit = Math.sign(by);\n    for (let i = 0; i < 14 && disabled(next); i++) next = addDays(next, unit);\n    if (!disabled(next)) select(next, source);\n  }\n\n  /** Previous or next week, keeping the weekday like a phone calendar does. */\n  function page(dir: 1 | -1, source: Nav[\"source\"]) {\n    if (!selected || !weekStart) return;\n    const target = addDays(selected, dir * 7);\n    if (!disabled(target)) return select(target, source);\n    const week = Array.from({ length: 7 }, (_, i) => addDays(weekStart, dir * 7 + i));\n    const open = week.filter((d) => !disabled(d));\n    if (open.length) select(dir > 0 ? open[0] : open[open.length - 1], source);\n  }\n  const canPage = (dir: 1 | -1) => !!weekStart && Array.from({ length: 7 }, (_, i) => addDays(weekStart, dir * 7 + i)).some((d) => !disabled(d));\n\n  // Keyboard moves render instantly; once the new day exists, focus follows it.\n  const selectedKey = selected ? keyOf(selected) : null;\n  useEffect(() => {\n    if (nav.source !== \"key\" || !selectedKey) return;\n    rowRef.current?.querySelector<HTMLElement>(`[data-date=\"${selectedKey}\"]`)?.focus();\n  }, [nav, selectedKey]);\n\n  function onKeyDown(e: React.KeyboardEvent) {\n    const map: Record<string, () => void> = {\n      ArrowRight: () => move(1, \"key\"),\n      ArrowLeft: () => move(-1, \"key\"),\n      PageDown: () => page(1, \"key\"),\n      PageUp: () => page(-1, \"key\"),\n      Home: () => weekStart && select(days.find((d) => !disabled(d)) ?? weekStart, \"key\"),\n      End: () => weekStart && select([...days].reverse().find((d) => !disabled(d)) ?? days[6], \"key\"),\n    };\n    const run = map[e.key];\n    if (!run) return;\n    e.preventDefault();\n    e.stopPropagation();\n    run();\n  }\n\n  function onDragEnd(_: unknown, info: PanInfo) {\n    const swipe = info.offset.x + info.velocity.x * 0.15;\n    if (swipe < -60 && canPage(1)) page(1, \"pointer\");\n    else if (swipe > 60 && canPage(-1)) page(-1, \"pointer\");\n  }\n\n  const showToday = !!today && !!selected && keyOf(today) !== keyOf(selected);\n  const slide = {\n    enter: (c: { dir: number; instant: boolean }) => (c.instant || !c.dir ? { x: 0, opacity: 1 } : { x: `${c.dir * 36}%`, opacity: 0 }),\n    center: { x: 0, opacity: 1 },\n    exit: (c: { dir: number; instant: boolean }) =>\n      c.instant || !c.dir ? { opacity: 0, transition: { duration: 0 } } : { x: `${c.dir * -36}%`, opacity: 0, transition: { duration: 0.2, ease: ease.out } },\n  };\n  const custom = { dir: view.dir, instant };\n  const iconButton = cn(\n    \"relative grid size-7 place-items-center rounded-md text-fg-2 outline-none\",\n    \"transition-[background-color,color,scale,opacity] duration-150 ease-out hover:bg-fg/6 hover:text-fg active:scale-[0.92] active:duration-75\",\n    \"focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-1 focus-visible:outline-fg-3\",\n    \"disabled:pointer-events-none disabled:opacity-35 after:absolute after:-inset-1.5 after:content-['']\",\n  );\n\n  return (\n    <div data-state={selected ? \"ready\" : \"pending\"} className={cn(\"flex w-full min-w-0 flex-col gap-2\", className)} {...rest}>\n      {header && (\n        <div className=\"flex h-7 items-center justify-between gap-2\">\n          <div className=\"relative min-w-0 flex-1 overflow-hidden\">\n            <AnimatePresence initial={false} mode=\"popLayout\" custom={custom}>\n              <motion.h3\n                key={monthLabel}\n                id={`${id}-label`}\n                custom={custom}\n                variants={{\n                  enter: (c: typeof custom) => (c.instant || !c.dir ? { opacity: 1 } : { y: c.dir * 8, opacity: 0, filter: \"blur(2px)\" }),\n                  center: { y: 0, opacity: 1, filter: \"blur(0px)\" },\n                  exit: (c: typeof custom) =>\n                    c.instant || !c.dir\n                      ? { opacity: 0, transition: { duration: 0 } }\n                      : { y: c.dir * -8, opacity: 0, filter: \"blur(2px)\", transition: { duration: 0.15 } },\n                }}\n                initial=\"enter\"\n                animate=\"center\"\n                exit=\"exit\"\n                transition={{ duration: 0.24, ease: ease.out }}\n                className=\"truncate text-[14px] font-medium tracking-[-0.015em] text-fg\"\n              >\n                {monthLabel}\n              </motion.h3>\n            </AnimatePresence>\n          </div>\n          <div className=\"flex shrink-0 items-center gap-1\">\n            <AnimatePresence initial={false}>\n              {showToday && (\n                <motion.button\n                  key=\"today\"\n                  type=\"button\"\n                  onClick={() => today && select(today, \"pointer\")}\n                  initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.9, filter: \"blur(2px)\" }}\n                  animate={{ opacity: 1, scale: 1, filter: \"blur(0px)\" }}\n                  exit={{ opacity: 0, scale: reduce ? 1 : 0.95, transition: { duration: 0.12 } }}\n                  transition={spring.pop}\n                  className={cn(\n                    \"mr-1 h-7 rounded-md border border-line-2 bg-raised px-2.5 text-[12px] font-medium text-fg shadow-[var(--shadow)] outline-none\",\n                    \"transition-[background-color,border-color] duration-150 hover:border-fg-4 hover:bg-hover active:scale-[0.97]\",\n                    \"focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-fg-3\",\n                  )}\n                >\n                  Today\n                </motion.button>\n              )}\n            </AnimatePresence>\n            <button type=\"button\" aria-label=\"Previous week\" disabled={!canPage(-1)} onClick={() => page(-1, \"pointer\")} className={iconButton}>\n              <ChevronLeft />\n            </button>\n            <button type=\"button\" aria-label=\"Next week\" disabled={!canPage(1)} onClick={() => page(1, \"pointer\")} className={iconButton}>\n              <ChevronRight />\n            </button>\n          </div>\n        </div>\n      )}\n\n      <div ref={rowRef} className=\"relative -mx-1 overflow-hidden px-1 py-1\">\n        <AnimatePresence initial={false} mode=\"popLayout\" custom={custom}>\n          <motion.div\n            key={weekKey}\n            role=\"radiogroup\"\n            aria-labelledby={header ? `${id}-label` : undefined}\n            aria-label={header ? undefined : \"Week\"}\n            custom={custom}\n            variants={slide}\n            initial=\"enter\"\n            animate=\"center\"\n            exit=\"exit\"\n            transition={{ x: reduce ? { duration: 0 } : spring.soft, opacity: { duration: 0.18 } }}\n            drag={selected ? \"x\" : false}\n            dragConstraints={{ left: 0, right: 0 }}\n            dragElastic={0.35}\n            dragMomentum={false}\n            onDragStart={() => {\n              dragged.current = true;\n            }}\n            onDragEnd={onDragEnd}\n            onPointerDownCapture={() => {\n              dragged.current = false;\n            }}\n            onKeyDown={onKeyDown}\n            className=\"grid touch-pan-y grid-cols-7 gap-1\"\n          >\n            {selected\n              ? days.map((d) => {\n                  const k = keyOf(d);\n                  const isSelected = k === keyOf(selected);\n                  const isToday = !!today && k === keyOf(today);\n                  const isPast = !!today && d < today;\n                  const off = disabled(d);\n                  const count = Math.min(3, Math.max(0, markers?.(d) ?? 0));\n                  return (\n                    <button\n                      key={k}\n                      type=\"button\"\n                      role=\"radio\"\n                      aria-checked={isSelected}\n                      aria-current={isToday ? \"date\" : undefined}\n                      aria-label={count ? `${fmt.full.format(d)}, ${markerLabel(count)}` : fmt.full.format(d)}\n                      tabIndex={isSelected ? 0 : -1}\n                      disabled={off}\n                      data-date={k}\n                      data-selected={isSelected || undefined}\n                      data-today={isToday || undefined}\n                      onClick={() => {\n                        if (!dragged.current) select(d, \"pointer\");\n                      }}\n                      className={cn(\n                        \"group/day relative flex h-[60px] min-w-0 select-none flex-col items-center justify-center gap-1 rounded-xl outline-none\",\n                        \"transition-[scale,opacity] duration-150 ease-out active:scale-[0.95] active:duration-75\",\n                        \"focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-fg-3\",\n                        \"disabled:pointer-events-none disabled:opacity-35\",\n                      )}\n                    >\n                      {isSelected && (\n                        <motion.span\n                          layoutId={`${id}-pill-${weekKey}`}\n                          transition={instant ? { duration: 0 } : spring.snappy}\n                          className=\"absolute inset-0 rounded-xl bg-fg shadow-[var(--shadow)]\"\n                        />\n                      )}\n                      {!isSelected && (\n                        <span\n                          className={cn(\n                            \"absolute inset-0 rounded-xl transition-colors duration-150 group-hover/day:bg-fg/6\",\n                            isToday && \"border border-line-2\",\n                          )}\n                        />\n                      )}\n                      <span\n                        className={cn(\n                          \"relative text-[10.5px] font-medium uppercase leading-none tracking-[0.06em] transition-colors duration-150\",\n                          isSelected ? \"text-frame/70\" : isToday ? \"text-fg\" : \"text-fg-3\",\n                        )}\n                      >\n                        {fmt.weekday.format(d)}\n                      </span>\n                      <span\n                        className={cn(\n                          \"relative text-[16px] font-medium leading-none tracking-[-0.01em] tabular transition-colors duration-150\",\n                          isSelected ? \"text-frame\" : isPast ? \"text-fg-3\" : \"text-fg\",\n                        )}\n                      >\n                        {fmt.num.format(d)}\n                      </span>\n                      <span aria-hidden className=\"relative flex h-1 items-center gap-[3px]\">\n                        {/* A dot added while you watch pops in; dots already there when a week arrives don't. */}\n                        <AnimatePresence initial={false}>\n                          {Array.from({ length: count }, (_, i) => (\n                            <motion.span\n                              key={i}\n                              initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.4 }}\n                              animate={{ opacity: 1, scale: 1 }}\n                              exit={{ opacity: 0, transition: { duration: 0.1 } }}\n                              transition={spring.pop}\n                              className={cn(\"size-1 rounded-full transition-colors duration-150\", isSelected ? \"bg-frame/60\" : \"bg-fg-3\")}\n                            />\n                          ))}\n                        </AnimatePresence>\n                      </span>\n                    </button>\n                  );\n                })\n              : Array.from({ length: 7 }, (_, i) => <span key={i} aria-hidden className=\"h-[60px] rounded-xl bg-fg/[0.03]\" />)}\n          </motion.div>\n        </AnimatePresence>\n      </div>\n    </div>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "docs": "https://www.stealth.pm/ui/week-strip"
}
