{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "date-picker",
  "type": "registry:ui",
  "title": "Date picker",
  "description": "A day or a range from a month calendar, as YYYY-MM-DD strings. A field in the Select trigger's look opens the calendar in a popover, a bottom sheet on a phone, with optional presets. Day numbers in mono, the picked day filled with the foreground, today ringed, six weeks always, the full date-grid keyboard, and months that slide in from the side they come from.",
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "https://littmd.vercel.app/r/litt.json",
    "https://littmd.vercel.app/r/button.json",
    "https://littmd.vercel.app/r/icons.json",
    "https://littmd.vercel.app/r/motion.json",
    "https://littmd.vercel.app/r/popover.json"
  ],
  "files": [
    {
      "path": "registry/litt/ui/date-picker.tsx",
      "type": "registry:ui",
      "content": "\"use client\";\n// @litt/date-picker 2026.11.06. What changed: https://littmd.vercel.app/changelog.md\n\n/**\n * Date picker: a day, or a range of days, from a month calendar (components.md, \"Date picker\"). Calendar is the\n * grid on its own; DatePicker is a field that opens it in a Popover.\n *\n * - Values are YYYY-MM-DD strings, never Date objects. The date math here runs on whole days counted from\n *   1970-01-01 in UTC, and Intl formats with timeZone \"UTC\", so no time zone can move a day. Today is the\n *   viewer's own date. It is read after hydration (null on the server), so Next.js never renders a stale\n *   ring. The locale defaults to en-GB and the week to Monday, both props.\n * - Calendar: a header with the month and year at the body size in --litt-fg and previous and next as icon-only\n *   ghost sm Buttons (44px on a coarse pointer). Weekday names in caption --litt-fg-subtle, from Intl. Always six\n *   weeks, so the height never changes. Days are 40px cells (44 on a coarse pointer) at the control radius,\n *   numbers at the small size with tabular figures. Days of the last and next month are\n *   --litt-fg-disabled and inert: they carry no state and take no pointer, and the keys page to them. Disabled\n *   days (min, max, isDisabled) are --litt-fg-disabled too, take no hover and stay reachable by the keys, as\n *   aria-disabled buttons.\n * - Day states: hover fills with fg 6% on the instant tier (a finger's press too). Today has an inset 1px\n *   --litt-line-strong ring. A picked day is an --litt-fg fill with --litt-canvas text, never the accent (R6). In a range the\n *   two ends are filled and the days between sit on an fg 8% band that runs square across each row; the band\n *   covers the end cells too, rounded on their outer side, so it only shows its round end under a preview.\n *   While the second end is chosen, the pointer (or the focused day) previews the band. Picking the second\n *   end before the first swaps them.\n * - Flat (proposals/flat-pill.md): days are circles, a picked day or a range end an ink circle with\n *   --litt-canvas on it, the days between a --litt-surface-3 band. The field is a grey pill with no\n *   outline (ink ring on focus, danger ring on an error). The presets column loses its divider and the chosen\n *   preset is a grey bubble.\n * - Keyboard: the date grid pattern. One day is the tab stop (the picked day, else today, kept inside min and\n *   max). Arrows move a day or a week, Home and End go to the week's ends, PageUp and PageDown a month (the\n *   same day, or the month's last), with Shift a year. Enter or Space picks (the day is a real button). Focus\n *   is an inset 2px --litt-focus ring. Paging past min or max stops at them, and the previous and next buttons\n *   disable there and hand focus to each other.\n * - Motion: a new month slides half of the travel token in from the side it comes from and cross-fades, on\n *   the responsive tier, as Tabs panels do: the old month leaves a quarter of it the other way on the exit\n *   token, taken out of the flow (popLayout) so the grid never jumps. The month name cross-fades with it.\n *   Nothing else moves. The hooks return 0 under reduced motion.\n * - Accessibility: the grid is labelled by the month, a polite live region that also announces paging.\n *   Each day's button reads in full (\"Saturday 17 October 2026, today, selected\", the date as Intl writes it for\n *   the locale), and its gridcell carries aria-selected. Another month's day is an aria-disabled gridcell with a\n *   hidden number. A leaving month is inert.\n *\n * DatePicker:\n * - Field: the Select's raised trigger, its look and sizes (copied: select.tsx does not export them). You press\n *   it, so it is a block, not a cut (R3), and it stays pressed while the calendar is open. It shows the date in\n *   words from Intl (\"17 Oct 2026\"; a range is \"3 Oct to 17 Oct 2026\") or a placeholder in --litt-fg-subtle, with\n *   a 16px calendar icon at the end. It is a button with aria-haspopup=\"dialog\", named by its label and its\n *   value. Label above, hint or error below, as SelectField's.\n * - It opens the Calendar in a PopoverContent, which is a bottom sheet on compact screens. Focus lands on the\n *   tab-stop day. Picking a day closes it and focus returns to the field at once; in a range it closes after\n *   the second day. Presets sit in a column of 36px rows beside the calendar, and above it as a row of sm\n *   secondary Buttons on compact screens. A preset is a pick, so it closes the popover too.\n * - PopoverContent's widths (256, 320, 384) cannot hold a 280px calendar and a presets column, so on the\n *   desktop the panel makes the popover surface hug it (one inline width, set before Radix measures it).\n * - A form gets the value through a hidden field named `name`, or `name-start` and `name-end` for a range.\n */\nimport {\n  createContext,\n  useContext,\n  useId,\n  useLayoutEffect,\n  useMemo,\n  useRef,\n  useState,\n  useSyncExternalStore,\n  type KeyboardEvent,\n  type ReactNode,\n  type Ref,\n} from \"react\";\nimport { AnimatePresence, motion, useIsPresent, type Variants } from \"motion/react\";\nimport { useDevice, useExit, useTransition, useTravel } from \"../../lib/litt/motion\";\nimport { Button } from \"./button\";\nimport { CalendarIcon, ChevronLeftIcon, ChevronRightIcon } from \"./icons\";\nimport { Popover, PopoverContent, PopoverTrigger } from \"./popover\";\n\n/* Radix's asChild, spread in: shadcn's CLI rewrites a written asChild to Base UI's render prop in base-* hosts. */\nconst asChildProps = { asChild: true } as const;\n\n/* ------------------------------------------------------------- date math */\n\n/** \"single\" picks one day, \"range\" a start and an end. */\nexport type CalendarMode = \"single\" | \"range\";\n/** Two days as YYYY-MM-DD, start on or before end. */\nexport type DateRange = { start: string; end: string };\n/** What a calendar holds: a YYYY-MM-DD string in single mode, a DateRange in range mode. */\nexport type CalendarValue<M extends CalendarMode = CalendarMode> = M extends \"range\" ? DateRange : string;\n/** A day of the week, as Date.getDay counts it: 0 is Sunday, 1 is Monday. */\nexport type Weekday = 0 | 1 | 2 | 3 | 4 | 5 | 6;\n\nconst DAY_MS = 86_400_000;\nconst ISO = /^(\\d{4})-(\\d{2})-(\\d{2})$/;\nconst pad = (n: number, w = 2) => String(n).padStart(w, \"0\");\n\n/** A UTC Date for a year, a month (0 to 11, may overflow) and a day. setUTCFullYear keeps years under 100 as written. */\nfunction utcDate(y: number, m: number, d: number): Date {\n  const t = new Date(0);\n  t.setUTCFullYear(y, m, d);\n  return t;\n}\n\n/** Whole days since 1970-01-01 for a YYYY-MM-DD string, or NaN when it is not a real day (\"2026-02-30\"). */\nfunction toDays(date: string): number {\n  const m = ISO.exec(date);\n  if (!m) return NaN;\n  const y = Number(m[1]);\n  const mo = Number(m[2]) - 1;\n  const d = Number(m[3]);\n  const t = utcDate(y, mo, d);\n  if (t.getUTCFullYear() !== y || t.getUTCMonth() !== mo || t.getUTCDate() !== d) return NaN;\n  return Math.round(t.getTime() / DAY_MS);\n}\n\nconst fromDate = (t: Date) => `${pad(t.getUTCFullYear(), 4)}-${pad(t.getUTCMonth() + 1)}-${pad(t.getUTCDate())}`;\nconst fromDays = (n: number) => fromDate(new Date(n * DAY_MS));\nconst isDate = (v: unknown): v is string => typeof v === \"string\" && !Number.isNaN(toDays(v));\n/** The day as a Date at midnight UTC, for Intl with timeZone \"UTC\". */\nconst asUtc = (date: string) => new Date(toDays(date) * DAY_MS);\n\n/** The viewer's own date today, as YYYY-MM-DD (their time zone, not UTC). */\nexport function getToday(): string {\n  const now = new Date();\n  return `${pad(now.getFullYear(), 4)}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`;\n}\n\n/** A YYYY-MM-DD date moved by whole days; negative goes back. No time zone or daylight saving can shift it. */\nexport function addDays(date: string, days: number): string {\n  return fromDays(toDays(date) + days);\n}\n\n/** The same day a number of months away, or that month's last day when it is shorter (31 Jan plus one is 28 or 29 Feb). */\nfunction addMonths(date: string, months: number): string {\n  const [y, m, d] = date.split(\"-\").map(Number) as [number, number, number];\n  const first = utcDate(y, m - 1 + months, 1);\n  const last = utcDate(first.getUTCFullYear(), first.getUTCMonth() + 1, 0).getUTCDate();\n  return fromDate(utcDate(first.getUTCFullYear(), first.getUTCMonth(), Math.min(d, last)));\n}\n\n/** The day of the week of a YYYY-MM-DD date, as Date.getDay counts it: 0 is Sunday, 6 is Saturday. */\nexport function getWeekday(date: string): Weekday {\n  /* 1970-01-01 was a Thursday. */\n  return ((((toDays(date) + 4) % 7) + 7) % 7) as Weekday;\n}\n\nconst clampDay = (date: string, min?: string, max?: string) =>\n  min && date < min ? min : max && date > max ? max : date;\n\n/** The month a date sits in, as YYYY-MM. YYYY-MM-DD strings sort by time, so months compare as strings too. */\nconst monthOf = (date: string) => date.slice(0, 7);\n\n/** Six weeks of days for a month, starting on the week's first day. */\nfunction sixWeeks(month: string, weekStartsOn: Weekday): string[][] {\n  const first = `${month}-01`;\n  const start = toDays(first) - ((getWeekday(first) - weekStartsOn + 7) % 7);\n  return Array.from({ length: 6 }, (_, w) => Array.from({ length: 7 }, (_, i) => fromDays(start + w * 7 + i)));\n}\n\n/** A value read as a start and an end, both valid days or both null. Single mode is a range of one day. */\nfunction readValue(value: unknown): { start: string | null; end: string | null } {\n  if (isDate(value)) return { start: value, end: value };\n  if (value && typeof value === \"object\") {\n    const { start, end } = value as Partial<DateRange>;\n    if (isDate(start) && isDate(end)) return start <= end ? { start, end } : { start: end, end: start };\n  }\n  return { start: null, end: null };\n}\n\n/* ------------------------------------------------------------------ today */\n\nconst noSubscribe = () => () => {};\nconst serverToday = () => null;\n/** Today, read on every render. Null on the server and while hydrating, so the two renders always agree. */\nfunction useToday(): string | null {\n  return useSyncExternalStore(noSubscribe, getToday, serverToday);\n}\n\n/* ------------------------------------------------------------- formatting */\n\nfunction useFormats(locale: string) {\n  return useMemo(() => {\n    const make = (o: Intl.DateTimeFormatOptions) => new Intl.DateTimeFormat(locale, { timeZone: \"UTC\", ...o });\n    return {\n      month: make({ month: \"long\", year: \"numeric\" }),\n      weekday: make({ weekday: \"short\" }),\n      weekdayLong: make({ weekday: \"long\" }),\n      full: make({ weekday: \"long\", day: \"numeric\", month: \"long\", year: \"numeric\" }),\n      day: make({ day: \"numeric\" }),\n      field: make({ day: \"numeric\", month: \"short\", year: \"numeric\" }),\n      fieldNoYear: make({ day: \"numeric\", month: \"short\" }),\n    };\n  }, [locale]);\n}\n\n/** The field's words: \"17 Oct 2026\", or \"3 Oct to 17 Oct 2026\" for a range. Null when nothing is picked. */\nfunction fieldText(value: unknown, fmt: ReturnType<typeof useFormats>): string | null {\n  const { start, end } = readValue(value);\n  if (!start || !end) return null;\n  if (start === end) return fmt.field.format(asUtc(start));\n  const from = start.slice(0, 4) === end.slice(0, 4) ? fmt.fieldNoYear : fmt.field;\n  return `${from.format(asUtc(start))} to ${fmt.field.format(asUtc(end))}`;\n}\n\n/* ------------------------------------------------------------------ props */\n\n/* DatePicker's props come first, so its docs table starts with the label, the hint and the error. */\nexport type DatePickerSize = \"sm\" | \"md\" | \"lg\";\n\n/** A one-click choice beside the calendar, such as \"Last 7 days\". */\nexport type DatePreset<M extends CalendarMode = \"single\"> = {\n  /** What the row or button says. */\n  label: string;\n  /** What it picks: a YYYY-MM-DD string, or { start, end } in range mode. */\n  value: CalendarValue<M>;\n};\n\nexport type DatePickerProps<M extends CalendarMode = \"single\"> = Omit<CalendarProps<M>, \"label\" | \"className\"> & {\n  /** The label above the field. It also names the popover, and is the sheet's heading on a phone. Required, because a placeholder is not a label. */\n  label: string;\n  /** A hint under the field. An error replaces it. */\n  hint?: ReactNode;\n  /** Shown in danger color under the field and linked with aria-describedby. Also draws the danger edge. Say how to fix it. */\n  error?: ReactNode;\n  /** Shown while nothing is picked, in the subtle color. Defaults to \"Choose a date\", or \"Choose dates\" for a range. */\n  placeholder?: string;\n  /** Height on the shared control scale: sm is 32, md is 44, lg is 56. Use lg next to an lg button. */\n  size?: DatePickerSize;\n  /** The field name a form sends the value under, as YYYY-MM-DD. A range sends two: name-start and name-end. */\n  name?: string;\n  /** One-click choices, such as \"Last 7 days\". A column of rows beside the calendar, and a row of buttons above it on a phone. */\n  presets?: DatePreset<M>[];\n  /** Dims the field and keeps the calendar closed. */\n  disabled?: boolean;\n  /** The id of the field's button. One is made for you. */\n  id?: string;\n  /** Extra classes for the field's wrapper, such as a width. */\n  className?: string;\n};\n\n/* --------------------------------------------------------------- calendar */\n\nexport type CalendarProps<M extends CalendarMode = \"single\"> = {\n  /** \"single\" (the default) picks one day. \"range\" picks a start and then an end, and previews the band while the end is chosen. */\n  mode?: M;\n  /** The picked day as YYYY-MM-DD, or { start, end } in range mode. Pair it with onValueChange. Leave it out and the calendar keeps its own. */\n  value?: CalendarValue<M> | null;\n  /** The value to start with when you do not control it. */\n  defaultValue?: CalendarValue<M> | null;\n  /** Called with the picked day, or in range mode with { start, end } once the second end is picked. */\n  onValueChange?: (value: CalendarValue<M>) => void;\n  /** The earliest day that can be picked, as YYYY-MM-DD. Earlier days are dimmed, and the calendar does not page before its month. */\n  min?: string;\n  /** The latest day that can be picked, as YYYY-MM-DD. Later days are dimmed, and the calendar does not page past its month. */\n  max?: string;\n  /** Rules out more days, such as weekends: return true for a day that cannot be picked. It gets the day as YYYY-MM-DD. */\n  isDisabled?: (date: string) => boolean;\n  /** The locale for the month, the weekday names and each day's spoken name, as a BCP 47 tag. */\n  locale?: string;\n  /** The first column of each week: 0 is Sunday, 1 is Monday. */\n  weekStartsOn?: Weekday;\n  /** Names the calendar as a group for assistive tech, such as \"Studio schedule\". The grid itself is named by the month. */\n  label?: string;\n  /** Extra classes for the root, such as a margin. The calendar is as wide as its seven columns. */\n  className?: string;\n};\n\n/** Set by DatePicker: the tab-stop day is where focus lands when the popover opens. */\nconst InPicker = createContext(false);\n\n/** The fg 6% fill a day or a preset row takes under the pointer, and under a finger while it is pressed. */\nconst HOVER =\n  \"hover:bg-[color-mix(in_oklab,var(--litt-fg)_6%,transparent)] pointer-coarse:active:bg-[color-mix(in_oklab,var(--litt-fg)_6%,transparent)]\";\n/** Day cells: 40px, 44px on a finger. In a container narrower than seven of them the columns share what there is. */\nconst CELL = \"aspect-square w-full\";\nconst ROW = \"grid grid-cols-[repeat(7,minmax(0,2.5rem))] pointer-coarse:grid-cols-[repeat(7,minmax(0,2.75rem))]\";\n\nconst dayBase =\n  \"litt-target relative grid size-full place-items-center rounded-litt-control in-[[data-material=flat]]:rounded-litt-pill text-litt-small tabular-nums select-none \" +\n  \"[-webkit-tap-highlight-color:transparent] transition-[background-color,color] duration-(--litt-dur-instant) ease-litt \" +\n  \"focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-litt-focus\";\n\n/** One day as it is drawn and read. */\ntype Day = {\n  date: string;\n  /** Inside the month on show. Other days carry no state and take no pointer. */\n  inMonth: boolean;\n  disabled: boolean;\n  today: boolean;\n  /** Filled with --litt-fg: the picked day, a range's ends, or the first end while the second is chosen. */\n  filled: boolean;\n  /** Where the fg 8% band sits in this cell. */\n  band: \"none\" | \"mid\" | \"start\" | \"end\";\n  selected: boolean;\n  name: string;\n};\n\n/**\n * A month grid: the month and year, previous and next, the weekday names and six weeks of days. Pick one day, or a\n * range with mode=\"range\". Values are YYYY-MM-DD strings. Use it on its own beside a schedule or a list; use\n * DatePicker for a form field.\n */\nexport function Calendar<M extends CalendarMode = \"single\">({\n  mode,\n  value: valueProp,\n  defaultValue = null,\n  onValueChange,\n  min,\n  max,\n  isDisabled,\n  locale = \"en-GB\",\n  weekStartsOn = 1,\n  label,\n  className = \"\",\n}: CalendarProps<M>) {\n  const range = mode === \"range\";\n  const inPicker = useContext(InPicker);\n  const fmt = useFormats(locale);\n  const today = useToday();\n  const enter = useTransition(\"responsive\");\n  const exit = useExit(\"responsive\");\n  const travel = useTravel();\n  const titleId = useId();\n\n  const [inner, setInner] = useState<CalendarValue<M> | null>(defaultValue);\n  const value = valueProp === undefined ? inner : valueProp;\n  const picked = readValue(value);\n\n  /* The first end of a range while the second is being chosen. Nothing is reported until both are picked. */\n  const [pending, setPending] = useState<string | null>(null);\n  const [hover, setHover] = useState<string | null>(null);\n\n  /* The tab stop. It follows the value when the value changes from outside (a preset, a parent), and stays where it\n     is when the change came from a pick here, so a range picked across two months keeps showing the second. */\n  const [chosen, setChosen] = useState<string | null>(null);\n  const key = `${picked.start}/${picked.end}`;\n  const [seen, setSeen] = useState(key);\n  const [own, setOwn] = useState<string | null>(null);\n  if (seen !== key) {\n    setSeen(key);\n    if (key !== own) {\n      setChosen(picked.start);\n      setPending(null);\n    }\n  }\n  const focus = clampDay(chosen ?? picked.start ?? today ?? new Date().toISOString().slice(0, 10), min, max);\n  const month = monthOf(focus);\n\n  /* Which way time moved, derived while rendering so the new month gets it in the same pass. */\n  const [shown, setShown] = useState(month);\n  const [dir, setDir] = useState<1 | -1>(1);\n  if (shown !== month) {\n    setShown(month);\n    setDir(month > shown ? 1 : -1);\n  }\n\n  const canPrev = !min || monthOf(min) < month;\n  const canNext = !max || monthOf(max) > month;\n  const blocked = (date: string) => (min != null && date < min) || (max != null && date > max) || !!isDisabled?.(date);\n\n  const root = useRef<HTMLDivElement | null>(null);\n  const prevButton = useRef<HTMLButtonElement | null>(null);\n  const nextButton = useRef<HTMLButtonElement | null>(null);\n  /* A key moved the tab stop, so focus follows it into the (possibly new) month once it has rendered. */\n  const follow = useRef(false);\n  useLayoutEffect(() => {\n    if (!follow.current) return;\n    follow.current = false;\n    root.current?.querySelector<HTMLElement>(`[data-month=\"${month}\"] [data-day=\"${focus}\"]`)?.focus({ preventScroll: true });\n  }, [focus, month]);\n\n  const commit = (next: CalendarValue<M>) => {\n    const r = readValue(next);\n    setOwn(`${r.start}/${r.end}`);\n    setInner(next);\n    onValueChange?.(next);\n  };\n\n  const pick = (date: string) => {\n    setChosen(date);\n    if (blocked(date)) return;\n    if (!range) return commit(date as CalendarValue<M>);\n    if (!pending) return setPending(date);\n    setPending(null);\n    setHover(null);\n    commit((pending <= date ? { start: pending, end: date } : { start: date, end: pending }) as CalendarValue<M>);\n  };\n\n  /* Previous and next keep the tab stop on the same day of the month. A button that is about to be disabled hands\n     its focus to the other one, so focus never drops to the page. */\n  const page = (step: -1 | 1) => {\n    const next = clampDay(addMonths(focus, step), min, max);\n    setChosen(next);\n    const end = step < 0 ? !min || monthOf(min) < monthOf(next) : !max || monthOf(max) > monthOf(next);\n    if (!end) requestAnimationFrame(() => (step < 0 ? nextButton : prevButton).current?.focus());\n  };\n\n  const onKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {\n    if (!(e.target as HTMLElement).dataset.day) return;\n    const col = (getWeekday(focus) - weekStartsOn + 7) % 7;\n    const moves: Record<string, () => string> = {\n      ArrowLeft: () => addDays(focus, -1),\n      ArrowRight: () => addDays(focus, 1),\n      ArrowUp: () => addDays(focus, -7),\n      ArrowDown: () => addDays(focus, 7),\n      Home: () => addDays(focus, -col),\n      End: () => addDays(focus, 6 - col),\n      PageUp: () => addMonths(focus, e.shiftKey ? -12 : -1),\n      PageDown: () => addMonths(focus, e.shiftKey ? 12 : 1),\n    };\n    const move = moves[e.key];\n    if (!move || e.altKey || e.ctrlKey || e.metaKey) return;\n    e.preventDefault();\n    follow.current = true;\n    setChosen(clampDay(move(), min, max));\n  };\n\n  /* The band: a committed range, or the first end and the day under the pointer (or the focused day) while the second\n     end is chosen. */\n  const lead = pending ? (hover ?? focus) : null;\n  const [lo, hi] = pending\n    ? pending <= lead! ? [pending, lead!] : [lead!, pending]\n    : [range ? picked.start : null, range ? picked.end : null];\n\n  const weekdays = Array.from({ length: 7 }, (_, i) => {\n    /* 1970-01-04 was a Sunday. */\n    const sample = fromDays(3 + ((weekStartsOn + i) % 7));\n    return { short: fmt.weekday.format(asUtc(sample)), long: fmt.weekdayLong.format(asUtc(sample)) };\n  });\n\n  const describe = (date: string): Day => {\n    const inMonth = monthOf(date) === month;\n    const isToday = date === today;\n    let filled = false;\n    let band: Day[\"band\"] = \"none\";\n    let selected = false;\n    let state: string | null = null;\n    if (inMonth) {\n      if (pending) {\n        filled = date === pending;\n        if (filled) state = \"selected\";\n      } else if (!range) {\n        filled = selected = date === picked.start;\n        if (filled) state = \"selected\";\n      } else if (picked.start && picked.end) {\n        filled = date === picked.start || date === picked.end;\n        selected = date >= picked.start && date <= picked.end;\n        if (picked.start === picked.end) state = filled ? \"selected\" : null;\n        else state = date === picked.start ? \"start of range\" : date === picked.end ? \"end of range\" : selected ? \"in range\" : null;\n      }\n      if (lo && hi && lo !== hi && date >= lo && date <= hi) band = date === lo ? \"start\" : date === hi ? \"end\" : \"mid\";\n    }\n    const name = [fmt.full.format(asUtc(date)), isToday ? \"today\" : null, state].filter(Boolean).join(\", \");\n    return { date, inMonth, disabled: blocked(date), today: isToday, filled, band, selected, name };\n  };\n\n  const weeks = sixWeeks(month, weekStartsOn).map((week) => week.map(describe));\n  const title = fmt.month.format(asUtc(`${month}-01`));\n\n  const variants: Variants = {\n    in: (d: 1 | -1) => ({ opacity: 0, x: d * (travel / 2) }),\n    show: { opacity: 1, x: 0, transition: enter },\n    out: (d: 1 | -1) => ({ opacity: 0, x: -d * (travel / 4), transition: exit }),\n  };\n\n  const nav = \"pointer-coarse:h-litt-control-md pointer-coarse:w-litt-control-md\";\n\n  return (\n    <div\n      ref={root}\n      role={label ? \"group\" : undefined}\n      aria-label={label}\n      className={\"grid w-full max-w-max content-start gap-3 \" + className}\n    >\n      <div className=\"flex items-center justify-between gap-2\">\n        {/* The grid's name, and a polite live region, so paging is announced. The animated title is only seen. */}\n        <span id={titleId} aria-live=\"polite\" aria-atomic=\"true\" className=\"sr-only\">\n          {title}\n        </span>\n        <div aria-hidden=\"true\" className=\"relative min-w-0 flex-1\">\n          <AnimatePresence initial={false} mode=\"popLayout\">\n            <motion.span\n              key={month}\n              className=\"block truncate text-litt-body font-litt-title text-litt-fg\"\n              initial={{ opacity: 0 }}\n              animate={{ opacity: 1, transition: enter }}\n              exit={{ opacity: 0, transition: exit }}\n            >\n              {title}\n            </motion.span>\n          </AnimatePresence>\n        </div>\n        <div className=\"flex items-center gap-1\">\n          <Button\n            ref={prevButton}\n            iconOnly\n            size=\"sm\"\n            variant=\"ghost\"\n            aria-label=\"Previous month\"\n            disabled={!canPrev}\n            onClick={() => page(-1)}\n            className={nav}\n          >\n            <ChevronLeftIcon />\n          </Button>\n          <Button\n            ref={nextButton}\n            iconOnly\n            size=\"sm\"\n            variant=\"ghost\"\n            aria-label=\"Next month\"\n            disabled={!canNext}\n            onClick={() => page(1)}\n            className={nav}\n          >\n            <ChevronRightIcon />\n          </Button>\n        </div>\n      </div>\n\n      <div role=\"grid\" aria-labelledby={titleId} onKeyDown={onKeyDown} onPointerLeave={() => setHover(null)} className=\"grid gap-1\">\n        <div role=\"row\" className={ROW}>\n          {weekdays.map((w) => (\n            <div\n              key={w.long}\n              role=\"columnheader\"\n              aria-label={w.long}\n              className=\"grid h-6 w-full place-items-center text-litt-caption text-litt-fg-subtle\"\n            >\n              {w.short}\n            </div>\n          ))}\n        </div>\n        <div className=\"relative\">\n          <AnimatePresence initial={false} mode=\"popLayout\" custom={dir}>\n            <MonthRows\n              key={month}\n              month={month}\n              weeks={weeks}\n              focus={focus}\n              dir={dir}\n              variants={variants}\n              autoFocus={inPicker}\n              format={fmt.day}\n              onPick={pick}\n              onHover={pending ? setHover : undefined}\n            />\n          </AnimatePresence>\n        </div>\n      </div>\n    </div>\n  );\n}\n\ntype MonthRowsProps = {\n  ref?: Ref<HTMLDivElement>;\n  month: string;\n  weeks: Day[][];\n  focus: string;\n  dir: 1 | -1;\n  variants: Variants;\n  autoFocus: boolean;\n  format: Intl.DateTimeFormat;\n  onPick: (date: string) => void;\n  onHover?: (date: string) => void;\n};\n\n/** One month's six weeks. It forwards its ref, because AnimatePresence's popLayout measures the leaving month. */\nfunction MonthRows({ ref, month, weeks, focus, dir, variants, autoFocus, format, onPick, onHover }: MonthRowsProps) {\n  const present = useIsPresent();\n  return (\n    <motion.div\n      ref={ref}\n      role=\"rowgroup\"\n      data-month={month}\n      custom={dir}\n      variants={variants}\n      initial=\"in\"\n      animate=\"show\"\n      exit=\"out\"\n      inert={!present}\n      className=\"grid gap-1\"\n    >\n      {weeks.map((week) => (\n        <div key={week[0]!.date} role=\"row\" className={ROW}>\n          {week.map((d) => (\n            <DayCell key={d.date} day={d} tabStop={d.date === focus} autoFocus={autoFocus} format={format} onPick={onPick} onHover={onHover} />\n          ))}\n        </div>\n      ))}\n    </motion.div>\n  );\n}\n\nconst BAND: Record<Day[\"band\"], string> = {\n  none: \"\",\n  mid: \"\",\n  start: \"rounded-l-litt-control\",\n  end: \"rounded-r-litt-control\",\n};\n\nfunction DayCell({\n  day,\n  tabStop,\n  autoFocus,\n  format,\n  onPick,\n  onHover,\n}: {\n  day: Day;\n  tabStop: boolean;\n  autoFocus: boolean;\n  format: Intl.DateTimeFormat;\n  onPick: (date: string) => void;\n  onHover?: (date: string) => void;\n}) {\n  const number = format.format(asUtc(day.date));\n  if (!day.inMonth) {\n    /* Another month's day: inactive here (aria-disabled), so its dimmed number is exempt from contrast, as a disabled control's is. */\n    return (\n      <div role=\"gridcell\" aria-disabled=\"true\" className={\"grid place-items-center text-litt-small tabular-nums text-litt-fg-disabled select-none \" + CELL}>\n        <span aria-hidden=\"true\">{number}</span>\n      </div>\n    );\n  }\n  const look = day.filled\n    ? \"bg-litt-fg text-litt-canvas in-[[data-material=flat]]:bg-litt-fg in-[[data-material=flat]]:text-litt-canvas forced-colors:in-[[data-material=flat]]:[forced-color-adjust:none] forced-colors:in-[[data-material=flat]]:bg-[Highlight] forced-colors:in-[[data-material=flat]]:text-[HighlightText]\"\n    : day.disabled\n      ? \"text-litt-fg-disabled\"\n      : \"text-litt-fg \" + HOVER;\n  return (\n    <div role=\"gridcell\" aria-selected={day.selected} className={\"relative \" + CELL}>\n      {day.band !== \"none\" && (\n        <span aria-hidden=\"true\" className={\"absolute inset-0 bg-[color-mix(in_oklab,var(--litt-fg)_8%,transparent)] in-[[data-material=flat]]:bg-litt-surface-3 \" + BAND[day.band]} />\n      )}\n      <button\n        type=\"button\"\n        data-day={day.date}\n        data-autofocus={autoFocus && tabStop ? \"\" : undefined}\n        tabIndex={tabStop ? 0 : -1}\n        aria-label={day.name}\n        aria-disabled={day.disabled || undefined}\n        onClick={() => onPick(day.date)}\n        onPointerEnter={onHover && !day.disabled ? () => onHover(day.date) : undefined}\n        className={[\n          dayBase,\n          look,\n          day.disabled ? \"cursor-not-allowed\" : \"cursor-pointer\",\n          day.today && !day.filled ? \"shadow-[inset_0_0_0_1px_var(--litt-line-strong)]\" : \"\",\n        ].join(\" \")}\n      >\n        {number}\n      </button>\n    </div>\n  );\n}\n\n/* ------------------------------------------------------------ date picker */\n\n/* The Select trigger's look and sizes (select.tsx), which do not ship as exports. */\nconst field =\n  \"group/trigger flex w-full min-w-0 cursor-pointer items-center gap-litt-control rounded-litt-control bg-litt-block text-left font-normal text-litt-fg \" +\n  \"data-placeholder:text-litt-fg-subtle \" +\n  \"pointer-coarse:text-(length:--litt-control-text-lg) \" +\n  \"[-webkit-tap-highlight-color:transparent] \" +\n  \"shadow-(--litt-block-lift) enabled:active:shadow-(--litt-block-press) data-[state=open]:shadow-(--litt-block-press) \" +\n  \"hover:bg-(--litt-block-hover) enabled:pointer-coarse:active:bg-(--litt-block-hover) \" +\n  \"transition-[background-color,color,translate] duration-(--litt-dur-instant) ease-litt \" +\n  \"active:translate-y-(--litt-press-depth) disabled:active:translate-y-0 data-[state=open]:translate-y-(--litt-press-depth) \" +\n  \"focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-litt-focus \" +\n  \"aria-invalid:shadow-[inset_0_0_0_1px_var(--litt-danger),var(--litt-block-lift)] \" +\n  \"aria-invalid:enabled:active:shadow-[inset_0_0_0_1px_var(--litt-danger),var(--litt-block-press)] \" +\n  \"aria-invalid:data-[state=open]:shadow-[inset_0_0_0_1px_var(--litt-danger),var(--litt-block-press)] \" +\n  \"disabled:cursor-not-allowed disabled:bg-litt-surface-2 disabled:text-litt-fg-disabled disabled:data-placeholder:text-litt-fg-disabled \" +\n  \"disabled:shadow-[inset_0_0_0_1px_var(--litt-line)] \" +\n  /* Flat (rule 4): a grey pill with no outline, the ink ring for focus, the danger ring for an error, disabled flat. */\n  \"in-[[data-material=flat]]:focus-visible:outline-none in-[[data-material=flat]]:focus-visible:shadow-(--litt-focus-ring) \" +\n  \"in-[[data-material=flat]]:aria-invalid:shadow-[0_0_0_2px_var(--litt-canvas),0_0_0_4px_var(--litt-danger)] \" +\n  \"in-[[data-material=flat]]:aria-invalid:enabled:active:shadow-[0_0_0_2px_var(--litt-canvas),0_0_0_4px_var(--litt-danger)] \" +\n  \"in-[[data-material=flat]]:aria-invalid:data-[state=open]:shadow-[0_0_0_2px_var(--litt-canvas),0_0_0_4px_var(--litt-danger)] \" +\n  \"in-[[data-material=flat]]:disabled:shadow-none \" +\n  \"forced-colors:border\";\n\nconst sizes: Record<DatePickerSize, string> = {\n  sm: \"h-litt-control-sm px-litt-control-pad-sm text-litt-small pointer-coarse:h-litt-control-md\",\n  md: \"h-litt-control-md px-litt-control-pad-md text-litt-body\",\n  lg: \"h-litt-control-lg px-litt-control-pad-lg text-(length:--litt-control-text-lg)\",\n};\n\nconst presetRow =\n  \"flex h-9 w-full cursor-pointer items-center whitespace-nowrap rounded-litt-control px-3 text-left text-litt-small \" +\n  \"[-webkit-tap-highlight-color:transparent] transition-colors duration-(--litt-dur-instant) ease-litt \" +\n  \"hover:text-litt-fg pointer-coarse:active:text-litt-fg \" +\n  HOVER +\n  \" \" +\n  \"focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-litt-focus\";\n\nconst sameValue = (a: unknown, b: unknown) => {\n  const x = readValue(a);\n  const y = readValue(b);\n  return x.start !== null && x.start === y.start && x.end === y.end;\n};\n\n/**\n * A form field that opens a Calendar: the Select trigger's look, the date in words, and the calendar in a popover (a\n * bottom sheet on a phone). Picking closes it, after the second day in a range. Pass name and a form gets the value.\n */\nexport function DatePicker<M extends CalendarMode = \"single\">({\n  label,\n  hint,\n  error,\n  placeholder,\n  size = \"md\",\n  name,\n  presets,\n  disabled = false,\n  id: idProp,\n  className = \"\",\n  mode,\n  value: valueProp,\n  defaultValue = null,\n  onValueChange,\n  min,\n  max,\n  isDisabled,\n  locale = \"en-GB\",\n  weekStartsOn = 1,\n}: DatePickerProps<M>) {\n  const range = mode === \"range\";\n  const { compact } = useDevice();\n  const fmt = useFormats(locale);\n  const [inner, setInner] = useState<CalendarValue<M> | null>(defaultValue);\n  const value = valueProp === undefined ? inner : valueProp;\n  const [open, setOpen] = useState(false);\n  const trigger = useRef<HTMLButtonElement | null>(null);\n\n  const auto = useId();\n  const id = idProp ?? auto;\n  const labelId = `${id}-label`;\n  const valueId = `${id}-value`;\n  const noteId = `${id}-note`;\n  const note = error ?? hint;\n  const text = fieldText(value, fmt);\n  const picked = readValue(value);\n\n  /* A pick closes the popover and puts focus back on the field at once, not when the exit has played. */\n  const pick = (next: CalendarValue<M>) => {\n    setInner(next);\n    onValueChange?.(next);\n    setOpen(false);\n    trigger.current?.focus({ preventScroll: true });\n  };\n\n  const calendar = (\n    <InPicker value>\n      <Calendar<M>\n        mode={mode}\n        value={value}\n        onValueChange={pick}\n        min={min}\n        max={max}\n        isDisabled={isDisabled}\n        locale={locale}\n        weekStartsOn={weekStartsOn}\n      />\n    </InPicker>\n  );\n\n  return (\n    <div className={\"grid gap-2 \" + className}>\n      {/* The label points at the field, but a click only moves focus: a label click would otherwise open the calendar. */}\n      <label\n        id={labelId}\n        htmlFor={id}\n        onClick={(e) => {\n          e.preventDefault();\n          trigger.current?.focus();\n        }}\n        className={\"text-litt-small font-litt-title \" + (disabled ? \"text-litt-fg-disabled\" : \"text-litt-fg\")}\n      >\n        {label}\n      </label>\n      <Popover open={open} onOpenChange={setOpen}>\n        <PopoverTrigger {...asChildProps}>\n          <button\n            ref={trigger}\n            id={id}\n            type=\"button\"\n            disabled={disabled}\n            aria-labelledby={`${labelId} ${valueId}`}\n            aria-describedby={note ? noteId : undefined}\n            aria-invalid={error ? true : undefined}\n            data-placeholder={text ? undefined : \"\"}\n            className={field + \" \" + sizes[size]}\n          >\n            <span id={valueId} className=\"min-w-0 flex-1 truncate\">\n              {text ?? placeholder ?? (range ? \"Choose dates\" : \"Choose a date\")}\n            </span>\n            <CalendarIcon className=\"shrink-0 text-litt-fg-subtle group-disabled/trigger:text-litt-fg-disabled\" />\n          </button>\n        </PopoverTrigger>\n        <PopoverContent label={label} padded={compact}>\n          {compact ? (\n            <div className=\"grid gap-4\">\n              {presets && presets.length > 0 && (\n                <div className=\"flex flex-wrap gap-3\">\n                  {presets.map((p) => (\n                    <Button key={p.label} size=\"sm\" className=\"pointer-coarse:h-litt-control-md\" onClick={() => pick(p.value)}>\n                      {p.label}\n                    </Button>\n                  ))}\n                </div>\n              )}\n              {calendar}\n            </div>\n          ) : (\n            <Hug>\n              {presets && presets.length > 0 && (\n                <div className=\"grid min-w-32 content-start border-r border-litt-line pt-3 pr-1 in-[[data-material=flat]]:border-r-0\">\n                  {presets.map((p) => (\n                    <button\n                      key={p.label}\n                      type=\"button\"\n                      onClick={() => pick(p.value)}\n                      className={presetRow + \" \" + (sameValue(p.value, value) ? \"text-litt-fg in-[[data-material=flat]]:bg-litt-surface-3 forced-colors:underline\" : \"text-litt-fg-muted\")}\n                    >\n                      {p.label}\n                    </button>\n                  ))}\n                </div>\n              )}\n              <div className=\"p-3\">{calendar}</div>\n            </Hug>\n          )}\n        </PopoverContent>\n      </Popover>\n      {name && !range && <input type=\"hidden\" name={name} value={picked.start ?? \"\"} />}\n      {name && range && (\n        <>\n          <input type=\"hidden\" name={`${name}-start`} value={picked.start ?? \"\"} />\n          <input type=\"hidden\" name={`${name}-end`} value={picked.end ?? \"\"} />\n        </>\n      )}\n      {note && (\n        <p id={noteId} className={\"text-litt-small \" + (error ? \"text-litt-danger\" : \"text-litt-fg-subtle\")}>\n          {note}\n        </p>\n      )}\n    </div>\n  );\n}\n\n/**\n * The desktop panel. PopoverContent's widths are 256, 320 and 384, and a calendar with a presets column needs more,\n * so the surface hugs this panel instead. It is set in a layout effect, before Radix measures and places the surface.\n */\nfunction Hug({ children }: { children: ReactNode }) {\n  const ref = useRef<HTMLDivElement | null>(null);\n  useLayoutEffect(() => {\n    const surface = ref.current?.closest<HTMLElement>(\"[data-popover-surface]\");\n    if (surface) surface.style.width = \"auto\";\n  }, []);\n  return (\n    <div ref={ref} className=\"flex\">\n      {children}\n    </div>\n  );\n}\n",
      "target": "components/litt/date-picker.tsx"
    }
  ],
  "docs": "Import DatePicker and Calendar from components/litt/date-picker, plus getToday, addDays and getWeekday for date math on YYYY-MM-DD strings. Values are YYYY-MM-DD strings, or { start, end } with mode=\"range\". DatePicker needs a label; hold the value in state with value and onValueChange, and pass name for a form (a range sends name-start and name-end). min, max and isDisabled rule days out; presets take { label, value }.",
  "meta": {
    "version": "2026.11.06",
    "released": "2026-11-06",
    "changelog": "https://littmd.vercel.app/changelog.md"
  }
}
