{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "eased-calendar-core",
  "title": "Calendar core primitive",
  "description": "Month, week and day grids, click-click and drag range selection over days or time slots with disabled units and length limits, WAI-ARIA grid keyboard movement, timed-item placement with overlap columns, and availability maths (free time, everyone-free overlap, slot starts). Native Date helpers with date-fns names.",
  "registryDependencies": [
    "https://eased.dev/r/eased-tokens.json"
  ],
  "files": [
    {
      "path": "src/registry/eased/_primitives/calendar-core/availability.ts",
      "content": "/** A half-open time interval [start, end). */\nexport type Interval = { start: Date | number; end: Date | number };\ntype Ms = { start: number; end: number };\n\nconst ms = (i: Interval): Ms => ({ start: +new Date(i.start), end: +new Date(i.end) });\nconst out = (i: Ms) => ({ start: new Date(i.start), end: new Date(i.end) });\n\n/** Sorts and merges overlapping or touching intervals. */\nexport function mergeIntervals(intervals: readonly Interval[]): { start: Date; end: Date }[] {\n  const sorted = intervals.map(ms).filter((i) => i.end > i.start).sort((a, b) => a.start - b.start);\n  const merged: Ms[] = [];\n  for (const i of sorted) {\n    const last = merged[merged.length - 1];\n    if (last && i.start <= last.end) last.end = Math.max(last.end, i.end);\n    else merged.push({ ...i });\n  }\n  return merged.map(out);\n}\n\n/** The free time inside `within` once `busy` is taken out. */\nexport function subtractBusy(within: Interval, busy: readonly Interval[]): { start: Date; end: Date }[] {\n  const w = ms(within);\n  const free: Ms[] = [];\n  let cursor = w.start;\n  for (const b of mergeIntervals(busy).map(ms)) {\n    if (b.end <= cursor) continue;\n    if (b.start >= w.end) break;\n    if (b.start > cursor) free.push({ start: cursor, end: Math.min(b.start, w.end) });\n    cursor = Math.max(cursor, b.end);\n  }\n  if (cursor < w.end) free.push({ start: cursor, end: w.end });\n  return free.map(out);\n}\n\n/**\n * When everyone is free: the intersection of each person's free intervals. The Interview\n * Scheduler's highlighted overlap.\n */\nexport function intersectAvailability(people: readonly (readonly Interval[])[]): { start: Date; end: Date }[] {\n  if (!people.length) return [];\n  let acc: Ms[] = mergeIntervals(people[0]).map(ms);\n  for (const person of people.slice(1)) {\n    const other = mergeIntervals(person).map(ms);\n    const next: Ms[] = [];\n    let i = 0;\n    let j = 0;\n    while (i < acc.length && j < other.length) {\n      const start = Math.max(acc[i].start, other[j].start);\n      const end = Math.min(acc[i].end, other[j].end);\n      if (end > start) next.push({ start, end });\n      if (acc[i].end < other[j].end) i++;\n      else j++;\n    }\n    acc = next;\n  }\n  return acc.map(out);\n}\n\n/** Bookable slot starts of `durationMinutes` inside free intervals, every `stepMinutes` on the clock (local). */\nexport function slotsIn(free: readonly Interval[], { durationMinutes = 30, stepMinutes = durationMinutes }: { durationMinutes?: number; stepMinutes?: number } = {}): Date[] {\n  const step = stepMinutes * 60_000;\n  const dur = durationMinutes * 60_000;\n  const slots: Date[] = [];\n  for (const f of free.map(ms)) {\n    const offset = new Date(f.start).getTimezoneOffset() * 60_000;\n    let t = Math.ceil((f.start - offset) / step) * step + offset;\n    for (; t + dur <= f.end; t += step) slots.push(new Date(t));\n  }\n  return slots;\n}\n",
      "type": "registry:lib",
      "target": "@components/eased/_primitives/calendar-core/availability.ts"
    },
    {
      "path": "src/registry/eased/_primitives/calendar-core/calendar-core.ts",
      "content": "/**\n * calendar-core: month, week and day grids, range selection (click-click and drag), keyboard\n * movement and availability maths for bookings, legal deadlines, shifts and schedulers.\n * Import from here; the files behind it may move.\n */\nexport {\n  addDays,\n  addMinutes,\n  addMonths,\n  addWeeks,\n  differenceInCalendarDays,\n  endOfDay,\n  endOfMonth,\n  endOfWeek,\n  fromDayKey,\n  getDaysInMonth,\n  isAfter,\n  isBefore,\n  isSameDay,\n  isSameMonth,\n  isWeekend,\n  localeWeekStart,\n  startOfDay,\n  startOfMonth,\n  startOfWeek,\n  toDayKey,\n  type WeekStart,\n} from \"./dates\";\nexport {\n  itemsByDay,\n  monthGrid,\n  placeDayItems,\n  timeGrid,\n  weekdays,\n  type CalendarDay,\n  type GridOptions,\n  type PlacedItem,\n  type TimedItem,\n  type TimeGridOptions,\n  type TimeSlot,\n} from \"./grid\";\nexport {\n  constrainRange,\n  isInRange,\n  nextFocusDate,\n  normalizeRange,\n  rangeLength,\n  rangePosition,\n  useRangeSelection,\n  type CellState,\n  type DateRange,\n  type Granularity,\n  type RangeConstraints,\n  type RangePosition,\n  type UseRangeSelectionOptions,\n} from \"./range\";\nexport { intersectAvailability, mergeIntervals, slotsIn, subtractBusy, type Interval } from \"./availability\";\n",
      "type": "registry:lib",
      "target": "@components/eased/_primitives/calendar-core/calendar-core.ts"
    },
    {
      "path": "src/registry/eased/_primitives/calendar-core/dates.ts",
      "content": "/**\n * Local-time date math for calendar-core. The names and semantics match date-fns\n * (`addDays`, `startOfWeek({ weekStartsOn })`, `differenceInCalendarDays` …) so this file can\n * be swapped for `date-fns` imports one-for-one. Every function returns a new Date.\n */\n\nexport type WeekStart = 0 | 1 | 2 | 3 | 4 | 5 | 6;\n\nconst clone = (d: Date | number) => new Date(typeof d === \"number\" ? d : d.getTime());\n\nexport function startOfDay(d: Date | number): Date {\n  const x = clone(d);\n  x.setHours(0, 0, 0, 0);\n  return x;\n}\nexport function endOfDay(d: Date | number): Date {\n  const x = clone(d);\n  x.setHours(23, 59, 59, 999);\n  return x;\n}\n/** Calendar days, so a DST change keeps the wall-clock time. */\nexport function addDays(d: Date | number, n: number): Date {\n  const x = clone(d);\n  x.setDate(x.getDate() + n);\n  return x;\n}\nexport const addWeeks = (d: Date | number, n: number) => addDays(d, n * 7);\n/** Clamps to the last day of a shorter month (31 Jan + 1 month → 28/29 Feb), like date-fns. */\nexport function addMonths(d: Date | number, n: number): Date {\n  const x = clone(d);\n  const day = x.getDate();\n  x.setDate(1);\n  x.setMonth(x.getMonth() + n);\n  x.setDate(Math.min(day, getDaysInMonth(x)));\n  return x;\n}\nexport function addMinutes(d: Date | number, n: number): Date {\n  return new Date(clone(d).getTime() + n * 60_000);\n}\nexport function getDaysInMonth(d: Date | number): number {\n  const x = clone(d);\n  return new Date(x.getFullYear(), x.getMonth() + 1, 0).getDate();\n}\nexport function startOfMonth(d: Date | number): Date {\n  const x = startOfDay(d);\n  x.setDate(1);\n  return x;\n}\nexport function endOfMonth(d: Date | number): Date {\n  const x = startOfMonth(d);\n  x.setMonth(x.getMonth() + 1, 0);\n  return endOfDay(x);\n}\nexport function startOfWeek(d: Date | number, { weekStartsOn = 0 }: { weekStartsOn?: WeekStart } = {}): Date {\n  const x = startOfDay(d);\n  const diff = (x.getDay() - weekStartsOn + 7) % 7;\n  return addDays(x, -diff);\n}\nexport function endOfWeek(d: Date | number, options: { weekStartsOn?: WeekStart } = {}): Date {\n  return endOfDay(addDays(startOfWeek(d, options), 6));\n}\nexport const isSameDay = (a: Date | number, b: Date | number) => startOfDay(a).getTime() === startOfDay(b).getTime();\nexport function isSameMonth(a: Date | number, b: Date | number): boolean {\n  const x = clone(a);\n  const y = clone(b);\n  return x.getFullYear() === y.getFullYear() && x.getMonth() === y.getMonth();\n}\nexport const isWeekend = (d: Date | number) => {\n  const day = clone(d).getDay();\n  return day === 0 || day === 6;\n};\n/** Whole calendar days from `b` to `a` (a − b), ignoring time of day and DST. */\nexport function differenceInCalendarDays(a: Date | number, b: Date | number): number {\n  const x = startOfDay(a);\n  const y = startOfDay(b);\n  const utc = (d: Date) => Date.UTC(d.getFullYear(), d.getMonth(), d.getDate());\n  return Math.round((utc(x) - utc(y)) / 86_400_000);\n}\nexport const isBefore = (a: Date | number, b: Date | number) => clone(a).getTime() < clone(b).getTime();\nexport const isAfter = (a: Date | number, b: Date | number) => clone(a).getTime() > clone(b).getTime();\nexport const min = (a: Date, b: Date) => (a.getTime() <= b.getTime() ? a : b);\nexport const max = (a: Date, b: Date) => (a.getTime() >= b.getTime() ? a : b);\n\n/** `YYYY-MM-DD` in local time: a stable React key and `data-date` for a day cell. */\nexport function toDayKey(d: Date | number): string {\n  const x = clone(d);\n  const p = (n: number) => String(n).padStart(2, \"0\");\n  return `${x.getFullYear()}-${p(x.getMonth() + 1)}-${p(x.getDate())}`;\n}\n/** Local midnight of a `YYYY-MM-DD` key. */\nexport function fromDayKey(key: string): Date {\n  const [y, m, d] = key.split(\"-\").map(Number);\n  return new Date(y, m - 1, d);\n}\n\n/** The locale's first day of the week (Intl `weekInfo` where supported), else Monday, or Sunday for en-US. */\nexport function localeWeekStart(locale?: string): WeekStart {\n  try {\n    const loc = new Intl.Locale(locale ?? Intl.DateTimeFormat().resolvedOptions().locale) as Intl.Locale & {\n      weekInfo?: { firstDay: number };\n      getWeekInfo?: () => { firstDay: number };\n    };\n    const info = loc.getWeekInfo?.() ?? loc.weekInfo;\n    if (info) return (info.firstDay % 7) as WeekStart;\n    return loc.region === \"US\" || loc.baseName === \"en\" ? 0 : 1;\n  } catch {\n    return 1;\n  }\n}\n",
      "type": "registry:lib",
      "target": "@components/eased/_primitives/calendar-core/dates.ts"
    },
    {
      "path": "src/registry/eased/_primitives/calendar-core/grid.ts",
      "content": "import {\n  addDays,\n  addMinutes,\n  differenceInCalendarDays,\n  endOfMonth,\n  isSameDay,\n  isSameMonth,\n  isWeekend,\n  startOfDay,\n  startOfMonth,\n  startOfWeek,\n  toDayKey,\n  type WeekStart,\n} from \"./dates\";\n\nexport type CalendarDay = {\n  date: Date;\n  /** `YYYY-MM-DD`, local: key cells by it. */\n  key: string;\n  /** Day of month, 1–31. */\n  day: number;\n  /** 0 Sunday … 6 Saturday. */\n  weekday: number;\n  /** False for the leading/trailing days of neighbouring months in a month grid. */\n  inMonth: boolean;\n  isToday: boolean;\n  isWeekend: boolean;\n};\n\nexport type GridOptions = {\n  /** 0 Sunday … 6 Saturday. Default 1 (Monday); `localeWeekStart(locale)` reads the locale's. */\n  weekStartsOn?: WeekStart;\n  /** \"Today\", for the today mark. Pass a fixed date for deterministic demos; default none. */\n  today?: Date | number;\n  locale?: string;\n};\n\nconst dayOf = (date: Date, month: Date | null, today: Date | number | undefined): CalendarDay => ({\n  date,\n  key: toDayKey(date),\n  day: date.getDate(),\n  weekday: date.getDay(),\n  inMonth: month ? isSameMonth(date, month) : true,\n  isToday: today !== undefined && isSameDay(date, today),\n  isWeekend: isWeekend(date),\n});\n\n/** Weekday headers in display order: `{ narrow: \"M\", short: \"Mon\", long: \"Monday\" }`. */\nexport function weekdays({ weekStartsOn = 1, locale }: Pick<GridOptions, \"weekStartsOn\" | \"locale\"> = {}) {\n  // 4 Jan 1970 was a Sunday.\n  const base = new Date(1970, 0, 4 + weekStartsOn);\n  const f = (weekday: \"narrow\" | \"short\" | \"long\") => new Intl.DateTimeFormat(locale, { weekday });\n  const [n, s, l] = [f(\"narrow\"), f(\"short\"), f(\"long\")];\n  return Array.from({ length: 7 }, (_, i) => {\n    const d = addDays(base, i);\n    return { weekday: d.getDay(), narrow: n.format(d), short: s.format(d), long: l.format(d) };\n  });\n}\n\n/**\n * A month as weeks of days, padded with the neighbouring months' days. `fixedWeeks` (default\n * true) always returns six weeks, so paging months never changes the grid's height.\n *\n *   const { title, weeks, weekdays } = monthGrid(new Date(2026, 9), { today: FIXTURE_NOW });\n */\nexport function monthGrid(month: Date | number, { weekStartsOn = 1, today, locale, fixedWeeks = true }: GridOptions & { fixedWeeks?: boolean } = {}) {\n  const first = startOfMonth(month);\n  const start = startOfWeek(first, { weekStartsOn });\n  const span = differenceInCalendarDays(endOfMonth(first), start) + 1;\n  const weekCount = fixedWeeks ? 6 : Math.ceil(span / 7);\n  const weeks: CalendarDay[][] = Array.from({ length: weekCount }, (_, w) => Array.from({ length: 7 }, (_, d) => dayOf(addDays(start, w * 7 + d), first, today)));\n  return {\n    title: new Intl.DateTimeFormat(locale, { month: \"long\", year: \"numeric\" }).format(first),\n    month: first,\n    weeks,\n    days: weeks.flat(),\n    weekdays: weekdays({ weekStartsOn, locale }),\n    start,\n    end: addDays(start, weekCount * 7 - 1),\n  };\n}\n\nexport type TimeGridOptions = GridOptions & {\n  /** Days shown. Default 7 (a week grid); 5 for a work week; 1 for a day. */\n  days?: number;\n  /** First and last hour shown (end exclusive). Default 8 → 20. */\n  startHour?: number;\n  endHour?: number;\n  /** Slot length. Default 30. */\n  slotMinutes?: number;\n  /** Start on `anchor` itself instead of its week start (day views, rolling 3-day views). */\n  fromAnchor?: boolean;\n};\n\nexport type TimeSlot = {\n  index: number;\n  /** Minutes after midnight. */\n  minutes: number;\n  /** Localised label (\"09:00\" or \"9 AM\"), on whole hours only; empty otherwise. */\n  label: string;\n  hourStart: boolean;\n};\n\n/**\n * A week (or day) time grid: day columns and time slots, plus `slotStart(day, slot)` to turn\n * a cell back into a Date. The basis of Week Calendar, Tour and Interview Schedulers.\n */\nexport function timeGrid(anchor: Date | number, options: TimeGridOptions = {}) {\n  const { weekStartsOn = 1, today, locale, days = 7, startHour = 8, endHour = 20, slotMinutes = 30, fromAnchor = false } = options;\n  const first = fromAnchor ? startOfDay(anchor) : startOfWeek(anchor, { weekStartsOn });\n  const dayList = Array.from({ length: days }, (_, i) => dayOf(addDays(first, i), null, today));\n  const hourFmt = new Intl.DateTimeFormat(locale, { hour: \"numeric\", minute: \"2-digit\" });\n  const slotCount = Math.max(0, Math.round(((endHour - startHour) * 60) / slotMinutes));\n  const slots: TimeSlot[] = Array.from({ length: slotCount }, (_, index) => {\n    const minutes = startHour * 60 + index * slotMinutes;\n    const hourStart = minutes % 60 === 0;\n    return { index, minutes, hourStart, label: hourStart ? hourFmt.format(new Date(1970, 0, 1, minutes / 60)) : \"\" };\n  });\n  const last = dayList[dayList.length - 1]?.date ?? first;\n  const rangeFmt = new Intl.DateTimeFormat(locale, { day: \"numeric\", month: \"short\", year: \"numeric\" });\n  return {\n    title: days === 1 ? new Intl.DateTimeFormat(locale, { weekday: \"long\", day: \"numeric\", month: \"long\" }).format(first) : rangeFmt.formatRange(first, last),\n    days: dayList,\n    slots,\n    slotMinutes,\n    startHour,\n    endHour,\n    /** The Date a cell starts at. */\n    slotStart: (dayIndex: number, slotIndex: number) => addMinutes(startOfDay(dayList[dayIndex].date), startHour * 60 + slotIndex * slotMinutes),\n    /** The cell a Date falls in, or null when it is outside the grid. */\n    cellOf: (t: Date | number) => {\n      const d = new Date(t);\n      const dayIndex = dayList.findIndex((x) => isSameDay(x.date, d));\n      const m = d.getHours() * 60 + d.getMinutes() - startHour * 60;\n      if (dayIndex < 0 || m < 0 || m >= slotCount * slotMinutes) return null;\n      return { dayIndex, slotIndex: Math.floor(m / slotMinutes) };\n    },\n  };\n}\n\nexport type TimedItem = { id: string; start: Date | number; end: Date | number };\n\nexport type PlacedItem<T> = {\n  item: T;\n  /** 0–1 share of the visible hours: `top: ${top * 100}%`. */\n  top: number;\n  height: number;\n  /** Side-by-side columns for overlapping items. */\n  column: number;\n  columns: number;\n};\n\n/**\n * Places one day's timed items (appointments, shifts, tours) on a time grid: top and height as\n * shares of the visible hours (clipped to them), and overlapping items in side-by-side columns\n * whose count is per cluster, not per day.\n */\nexport function placeDayItems<T extends TimedItem>(items: readonly T[], day: Date | number, { startHour = 8, endHour = 20 }: { startHour?: number; endHour?: number } = {}): PlacedItem<T>[] {\n  const dayStart = startOfDay(day).getTime();\n  const lo = dayStart + startHour * 3_600_000;\n  const hi = dayStart + endHour * 3_600_000;\n  const span = hi - lo;\n  const visible = items\n    .map((item) => ({ item, s: Math.max(lo, +new Date(item.start)), e: Math.min(hi, +new Date(item.end)) }))\n    .filter((x) => x.e > x.s)\n    .sort((a, b) => a.s - b.s || b.e - a.e);\n  const out: PlacedItem<T>[] = [];\n  let cluster: { x: (typeof visible)[number]; column: number }[] = [];\n  let clusterEnd = -Infinity;\n  const flush = () => {\n    const columns = Math.max(1, ...cluster.map((c) => c.column + 1));\n    for (const c of cluster) out.push({ item: c.x.item, top: (c.x.s - lo) / span, height: (c.x.e - c.x.s) / span, column: c.column, columns });\n    cluster = [];\n  };\n  for (const x of visible) {\n    if (x.s >= clusterEnd) {\n      flush();\n      clusterEnd = -Infinity;\n    }\n    const taken = new Set(cluster.filter((c) => c.x.e > x.s).map((c) => c.column));\n    let column = 0;\n    while (taken.has(column)) column++;\n    cluster.push({ x, column });\n    clusterEnd = Math.max(clusterEnd, x.e);\n  }\n  flush();\n  return out;\n}\n\n/**\n * Items bucketed by the local days they touch, for month grids: a three-day leave shows on\n * each of its days. Keys are `YYYY-MM-DD`.\n */\nexport function itemsByDay<T extends TimedItem>(items: readonly T[]): Map<string, T[]> {\n  const map = new Map<string, T[]>();\n  for (const item of items) {\n    const s = startOfDay(item.start);\n    // An item ending exactly at midnight doesn't touch that day; a zero-length item still shows on its day.\n    const days = Math.max(0, differenceInCalendarDays(+new Date(item.end) - 1, s));\n    for (let i = 0; i <= days; i++) {\n      const k = toDayKey(addDays(s, i));\n      const list = map.get(k);\n      if (list) list.push(item);\n      else map.set(k, [item]);\n    }\n  }\n  return map;\n}\n",
      "type": "registry:lib",
      "target": "@components/eased/_primitives/calendar-core/grid.ts"
    },
    {
      "path": "src/registry/eased/_primitives/calendar-core/range.ts",
      "content": "\"use client\";\n\nimport { useCallback, useEffect, useState, type KeyboardEvent, type PointerEvent } from \"react\";\nimport { addDays, addMinutes, differenceInCalendarDays, startOfDay, toDayKey } from \"./dates\";\n\n/** Whole days (bookings, leave, deadlines) or fixed-length time slots (tours, shifts). */\nexport type Granularity = \"day\" | { minutes: number };\n\n/** An inclusive range of units: `end` is the last selected day, or the start of the last selected slot. */\nexport type DateRange = { start: Date; end: Date };\n\nconst isDay = (g: Granularity): g is \"day\" => g === \"day\";\nconst unitStart = (d: Date | number, g: Granularity) => {\n  if (isDay(g)) return startOfDay(d);\n  const ms = g.minutes * 60_000;\n  const t = +new Date(d);\n  const local = t - new Date(t).getTimezoneOffset() * 60_000;\n  return new Date(t - (((local % ms) + ms) % ms));\n};\nconst unitAdd = (d: Date, n: number, g: Granularity) => (isDay(g) ? addDays(d, n) : addMinutes(d, n * g.minutes));\nconst unitDiff = (a: Date, b: Date, g: Granularity) => (isDay(g) ? differenceInCalendarDays(a, b) : Math.round((+a - +b) / (g.minutes * 60_000)));\nconst sameUnit = (a: Date, b: Date, g: Granularity) => unitDiff(a, b, g) === 0;\n\n/** Orders two dates into a range, snapped to whole units. */\nexport function normalizeRange(a: Date | number, b: Date | number, granularity: Granularity = \"day\"): DateRange {\n  const x = unitStart(a, granularity);\n  const y = unitStart(b, granularity);\n  return +x <= +y ? { start: x, end: y } : { start: y, end: x };\n}\n\n/** Units in a range, inclusive: a one-night booking selected as two days is 2. */\nexport function rangeLength(r: DateRange, granularity: Granularity = \"day\"): number {\n  return unitDiff(r.end, r.start, granularity) + 1;\n}\n\nexport function isInRange(d: Date | number, r: DateRange | null | undefined, granularity: Granularity = \"day\"): boolean {\n  if (!r) return false;\n  const u = unitStart(d, granularity);\n  return +u >= +r.start && +u <= +r.end;\n}\n\nexport type RangePosition = \"single\" | \"start\" | \"middle\" | \"end\";\n\n/** Where a cell sits in a range, for the pill shape: rounded ends, square middle. */\nexport function rangePosition(d: Date | number, r: DateRange | null | undefined, granularity: Granularity = \"day\"): RangePosition | null {\n  if (!r || !isInRange(d, r, granularity)) return null;\n  const u = unitStart(d, granularity);\n  const s = sameUnit(u, r.start, granularity);\n  const e = sameUnit(u, r.end, granularity);\n  return s && e ? \"single\" : s ? \"start\" : e ? \"end\" : \"middle\";\n}\n\nexport type RangeConstraints = {\n  granularity?: Granularity;\n  /** Units that can't be selected (booked nights, weekends, past days). A range never spans one. */\n  isDisabled?: (d: Date) => boolean;\n  /** Fewest units in a committed range. Default 1. */\n  minLength?: number;\n  /** Most units. Default unlimited. */\n  maxLength?: number;\n};\n\n/**\n * The range from `anchor` towards `target` that the constraints allow: it stops before the\n * first disabled unit (a stay can't span a booked night), caps at `maxLength`, and stretches\n * to `minLength` in the drag's direction when it can. Null when the anchor itself is disabled\n * or `minLength` can't be met.\n */\nexport function constrainRange(anchor: Date | number, target: Date | number, { granularity = \"day\", isDisabled, minLength = 1, maxLength = Infinity }: RangeConstraints = {}): DateRange | null {\n  const a = unitStart(anchor, granularity);\n  const t = unitStart(target, granularity);\n  if (isDisabled?.(a)) return null;\n  const dir = +t >= +a ? 1 : -1;\n  const want = Math.max(minLength, Math.min(maxLength, Math.abs(unitDiff(t, a, granularity)) + 1));\n  let end = a;\n  let n = 1;\n  while (n < want) {\n    const next = unitAdd(end, dir, granularity);\n    if (isDisabled?.(next)) break;\n    end = next;\n    n++;\n  }\n  if (n < minLength) return null;\n  return normalizeRange(a, end, granularity);\n}\n\nexport type UseRangeSelectionOptions = RangeConstraints & {\n  /** Controlled committed range. */\n  value?: DateRange | null;\n  defaultValue?: DateRange | null;\n  onChange?: (range: DateRange | null) => void;\n};\n\nexport type CellState = {\n  /** In the committed range. */\n  selected: boolean;\n  /** In the range being chosen (between the anchor and the pointer). */\n  previewing: boolean;\n  /** Position in whichever range is showing (the preview while choosing): drives the pill shape. */\n  position: RangePosition | null;\n  disabled: boolean;\n  /** The first click / drag start of the range being chosen. */\n  anchor: boolean;\n};\n\ntype Pending = { anchor: Date | null; hover: Date | null; dragging: boolean; moved: boolean };\nconst idle: Pending = { anchor: null, hover: null, dragging: false, moved: false };\n\n/**\n * Range selection for day grids and time grids. Click a start and an end, or press and drag\n * across cells (touch too: give the grid `touch-action: none`); Enter or Space on a focused cell\n * acts as a click, Escape cancels. While choosing, `preview` follows the pointer and respects\n * the constraints, so a guest sees exactly the stay they would get.\n *\n *   const sel = useRangeSelection({ isDisabled: (d) => booked.has(toDayKey(d)), maxLength: 14 });\n *   <button {...sel.cellProps(day.date)} data-position={sel.stateOf(day.date).position ?? undefined} />\n */\nexport function useRangeSelection(options: UseRangeSelectionOptions = {}) {\n  const { value: controlled, defaultValue = null, onChange, granularity = \"day\", isDisabled, minLength, maxLength } = options;\n  const [uncontrolled, setUncontrolled] = useState<DateRange | null>(defaultValue);\n  const [pending, setPending] = useState<Pending>(idle);\n  const value = controlled !== undefined ? controlled : uncontrolled;\n  const constraints: RangeConstraints = { granularity, isDisabled, minLength, maxLength };\n\n  const commit = (range: DateRange | null) => {\n    if (controlled === undefined) setUncontrolled(range);\n    onChange?.(range);\n    setPending(idle);\n  };\n\n  const preview = pending.anchor ? constrainRange(pending.anchor, pending.hover ?? pending.anchor, constraints) : null;\n\n  /** A click (or Enter/Space): starts a range, or ends the one being chosen. */\n  const select = (d: Date) => {\n    if (isDisabled?.(unitStart(d, granularity))) return;\n    if (!pending.anchor) return setPending({ anchor: unitStart(d, granularity), hover: null, dragging: false, moved: false });\n    const range = constrainRange(pending.anchor, d, constraints);\n    if (range) commit(range);\n  };\n  const hover = (d: Date | null) => setPending((p) => (p.anchor ? { ...p, hover: d, moved: p.moved || (d !== null && !sameUnit(d, p.anchor, granularity)) } : p));\n  const cancel = () => setPending(idle);\n  const clear = () => commit(null);\n\n  // A drag that ends outside the grid still ends.\n  const dragging = pending.dragging;\n  const endDrag = useCallback(() => {\n    setPending((p) => (p.dragging ? { ...p, dragging: false } : p));\n  }, []);\n  useEffect(() => {\n    if (!dragging) return;\n    window.addEventListener(\"pointerup\", endDrag);\n    window.addEventListener(\"pointercancel\", endDrag);\n    return () => {\n      window.removeEventListener(\"pointerup\", endDrag);\n      window.removeEventListener(\"pointercancel\", endDrag);\n    };\n  }, [dragging, endDrag]);\n\n  const cellProps = (date: Date) => ({\n    \"data-date\": isDay(granularity) ? toDayKey(date) : unitStart(date, granularity).toISOString(),\n    \"aria-selected\": isInRange(date, value, granularity),\n    \"aria-disabled\": isDisabled?.(unitStart(date, granularity)) || undefined,\n    onPointerDown: (e: PointerEvent<HTMLElement>) => {\n      if (e.button !== 0) return;\n      // Release touch's implicit capture so the cells under the finger get pointerenter.\n      if (e.currentTarget.hasPointerCapture?.(e.pointerId)) e.currentTarget.releasePointerCapture(e.pointerId);\n      if (pending.anchor && !pending.dragging) return select(date);\n      if (isDisabled?.(unitStart(date, granularity))) return;\n      setPending({ anchor: unitStart(date, granularity), hover: date, dragging: true, moved: false });\n    },\n    onPointerEnter: () => {\n      if (pending.anchor) hover(date);\n    },\n    onPointerUp: () => {\n      if (!pending.dragging || !pending.anchor) return;\n      // A press without movement leaves the anchor for click-click; a drag commits.\n      if (!pending.moved && sameUnit(date, pending.anchor, granularity)) return setPending((p) => ({ ...p, dragging: false }));\n      const range = constrainRange(pending.anchor, date, constraints);\n      if (range) commit(range);\n      else cancel();\n    },\n    onKeyDown: (e: KeyboardEvent<HTMLElement>) => {\n      if (e.key === \"Enter\" || e.key === \" \") {\n        e.preventDefault();\n        select(date);\n      } else if (e.key === \"Escape\" && pending.anchor) {\n        e.preventDefault();\n        cancel();\n      }\n    },\n    onFocus: () => {\n      if (pending.anchor) hover(date);\n    },\n  });\n\n  const stateOf = (date: Date): CellState => {\n    const showing = preview ?? value;\n    return {\n      selected: isInRange(date, value, granularity),\n      previewing: isInRange(date, preview, granularity),\n      position: rangePosition(date, showing, granularity),\n      disabled: !!isDisabled?.(unitStart(date, granularity)),\n      anchor: !!pending.anchor && sameUnit(date, pending.anchor, granularity),\n    };\n  };\n\n  return { value, preview, anchor: pending.anchor, selecting: !!pending.anchor, select, hover, cancel, clear, setValue: commit, cellProps, stateOf };\n}\n\n/**\n * The date a roving-focus grid moves to for a key (WAI-ARIA date grid): arrows ±1 day / ±1\n * week, Home/End to the week's ends, PageUp/PageDown ±1 month (Shift: ±1 year). Null for other keys.\n */\nexport function nextFocusDate(date: Date, key: string, { weekStartsOn = 1, shift = false }: { weekStartsOn?: number; shift?: boolean } = {}): Date | null {\n  const month = (n: number) => {\n    const d = new Date(date);\n    const day = d.getDate();\n    d.setDate(1);\n    d.setMonth(d.getMonth() + n);\n    d.setDate(Math.min(day, new Date(d.getFullYear(), d.getMonth() + 1, 0).getDate()));\n    return d;\n  };\n  const offset = (date.getDay() - weekStartsOn + 7) % 7;\n  switch (key) {\n    case \"ArrowLeft\":\n      return addDays(date, -1);\n    case \"ArrowRight\":\n      return addDays(date, 1);\n    case \"ArrowUp\":\n      return addDays(date, -7);\n    case \"ArrowDown\":\n      return addDays(date, 7);\n    case \"Home\":\n      return addDays(date, -offset);\n    case \"End\":\n      return addDays(date, 6 - offset);\n    case \"PageUp\":\n      return month(shift ? -12 : -1);\n    case \"PageDown\":\n      return month(shift ? 12 : 1);\n    default:\n      return null;\n  }\n}\n",
      "type": "registry:lib",
      "target": "@components/eased/_primitives/calendar-core/range.ts"
    }
  ],
  "type": "registry:lib"
}