{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "px-date",
  "title": "Date picker",
  "description": "Month-grid date picker for single dates or ranges, with a springing pixel selection block, a dotted range band and a mark on today.",
  "registryDependencies": [
    "https://elements.lumesec.ai/r/lumesec-theme.json",
    "https://elements.lumesec.ai/r/lumesec-motion.json",
    "https://elements.lumesec.ai/r/lumesec-pixel-canvas.json",
    "https://elements.lumesec.ai/r/lumesec-roll-text.json",
    "https://elements.lumesec.ai/r/lumesec-use-controllable-state.json",
    "utils"
  ],
  "files": [
    {
      "path": "registry/components/lumesec/px-date.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\n\nimport { prefersReducedMotion, springEuler } from \"@/lib/lumesec/motion\";\nimport { usePixelCanvas, type PixelFrame } from \"@/lib/lumesec/pixel-canvas\";\nimport { RollText } from \"@/lib/lumesec/roll-text\";\nimport { useControllableState, useFormReset } from \"@/lib/lumesec/use-controllable-state\";\nimport { cn } from \"@/lib/utils\";\n\n/** First day of the week: 0 is Sunday, 1 Monday, up to 6 Saturday. */\nexport type PxDateWeekday = 0 | 1 | 2 | 3 | 4 | 5 | 6;\n\n/** Props of `PxDate`. Other `div` props go to the root. */\nexport interface PxDateProps extends Omit<React.ComponentProps<\"div\">, \"children\" | \"defaultValue\" | \"onChange\"> {\n  /**\n   * Controlled selection: `YYYY-MM-DD`, or `YYYY-MM-DD/YYYY-MM-DD` with `range`. Use with `onValueChange`.\n   * A new value also shows its month.\n   */\n  value?: string;\n  /** Initial selection when uncontrolled; also restored on form reset. It sets the month shown. */\n  defaultValue?: string;\n  /** Called when a date is picked; in range mode only when the end date is picked. */\n  onValueChange?: (value: string) => void;\n  /** Pick a start and an end date instead of a single date. */\n  range?: boolean;\n  /** First day of the week. */\n  firstDay?: PxDateWeekday;\n  /** Form field name. A range submits as `start/end` and stays empty until both ends are picked. */\n  name?: string;\n  /** Dims the calendar and disables its buttons. */\n  disabled?: boolean;\n}\n\ninterface Selection {\n  start: Date | null;\n  end: Date | null;\n}\n\ninterface Box {\n  x: number;\n  y: number;\n  width: number;\n  height: number;\n}\n\nconst MONTHS = [\n  \"January\",\n  \"February\",\n  \"March\",\n  \"April\",\n  \"May\",\n  \"June\",\n  \"July\",\n  \"August\",\n  \"September\",\n  \"October\",\n  \"November\",\n  \"December\",\n];\nconst SHORT_MONTHS = [\"Jan\", \"Feb\", \"Mar\", \"Apr\", \"May\", \"Jun\", \"Jul\", \"Aug\", \"Sept\", \"Oct\", \"Nov\", \"Dec\"];\nconst WEEKDAYS = [\"Sunday\", \"Monday\", \"Tuesday\", \"Wednesday\", \"Thursday\", \"Friday\", \"Saturday\"];\nconst SHORT_WEEKDAYS = [\"Sun\", \"Mon\", \"Tue\", \"Wed\", \"Thu\", \"Fri\", \"Sat\"];\nconst HEADER_WEEKDAYS = [\"Su\", \"Mo\", \"Tu\", \"We\", \"Th\", \"Fr\", \"Sa\"];\nconst DAY_MS = 864e5;\n\nconst ICON_BUTTON =\n  \"grid size-7 flex-none cursor-pointer place-items-center rounded-[7px] border-0 bg-transparent p-0 text-muted-foreground select-none outline-none hover:bg-foreground/7 hover:text-foreground focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-offset-2 focus-visible:outline-lumesec\";\n\n/** Today, as `YYYY-MM-DD`. It is read on the client only, so server and client markup agree. */\nconst subscribeToNothing = () => () => {};\nconst todayKey = () => ymd(new Date());\nconst noTodayOnServer = () => null;\n\n/**\n * Month-grid date picker for single dates or ranges. The selection is a pixel block that springs between\n * days, a range is a dotted band (previewed on hover), and today carries a small mark. Arrow keys move by\n * day and week, Home and End to the ends of the week, Page Up and Page Down by month (with Shift by year);\n * the grid slides when the month changes.\n *\n * @example\n * <PxDate name=\"trip\" range defaultValue=\"2026-10-20/2026-10-24\" />\n */\nexport function PxDate({\n  value: valueProp,\n  defaultValue = \"\",\n  onValueChange,\n  range = false,\n  firstDay = 1,\n  name,\n  disabled = false,\n  className,\n  ...props\n}: PxDateProps) {\n  const titleId = React.useId();\n  // values outside 0 to 6 (from untyped callers) still give a valid week\n  const weekStart = ((Math.trunc(firstDay) % 7) + 7) % 7 || 0;\n  const [value, setValue] = useControllableState({ value: valueProp, defaultValue, onChange: onValueChange });\n  const [selection, setSelection] = React.useState(() => parseSelection(value, range));\n  const [view, setView] = React.useState<Date | null>(() => monthOf(selection.start));\n  const [focusDate, setFocusDate] = React.useState<Date | null>(() => selection.start);\n  // every month change by the user gets an id, so the grid slides once per change\n  const [nav, setNav] = React.useState({ id: 0, direction: 0, animate: false });\n  const [summaryAnimate, setSummaryAnimate] = React.useState(false);\n  const todayValue = React.useSyncExternalStore<string | null>(subscribeToNothing, todayKey, noTodayOnServer);\n  const today = todayValue ? parseYmd(todayValue) : null;\n\n  // a value from outside (controlled) or a switch of `range` replaces the selection and shows its month\n  const [synced, setSynced] = React.useState({ value, range });\n  if (value !== synced.value || range !== synced.range) {\n    setSynced({ value, range });\n    if (formatSelection(selection, range) !== value) {\n      const next = parseSelection(value, range);\n      setSelection(next);\n      if (next.start) {\n        setView(monthOf(next.start));\n        setFocusDate(next.start);\n      }\n    }\n  }\n\n  const shownView = view ?? monthOf(today);\n  const days = shownView ? monthDays(shownView, weekStart) : null;\n  const tabDate = shownView ? clampToMonth(focusDate ?? today ?? shownView, shownView) : null;\n\n  const gridRef = React.useRef<HTMLDivElement>(null);\n  const cellRefs = React.useRef<(HTMLButtonElement | null)[]>([]);\n  const hover = React.useRef<Date | null>(null);\n  const block = React.useRef({ placed: false, x: { position: 0, velocity: 0 }, y: { position: 0, velocity: 0 } });\n  const focusRequest = React.useRef<string | null>(null);\n\n  const { ref: canvasRef, redraw } = usePixelCanvas((frame) => {\n    if (!days) return false;\n    const { clear, dot, palette, delta } = frame;\n    const reduced = prefersReducedMotion();\n    const { start, end } = selection;\n    const cellBox = (index: number): Box | null => {\n      const cell = cellRefs.current[index];\n      return cell\n        ? { x: cell.offsetLeft, y: cell.offsetTop, width: cell.offsetWidth, height: cell.offsetHeight }\n        : null;\n    };\n    clear();\n    let moving = false;\n    if (range && start) {\n      // the band between the ends, or up to the hovered day while picking the end\n      const until = end ?? (hover.current && hover.current.getTime() > start.getTime() ? hover.current : null);\n      if (until) {\n        days.forEach((day, index) => {\n          if (day.getTime() <= start.getTime() || day.getTime() >= until.getTime()) return;\n          const box = cellBox(index);\n          if (!box) return;\n          for (let y = box.y + 6; y < box.y + box.height - 6; y += 4) {\n            for (let x = box.x; x < box.x + box.width; x += 4) dot(x, y, palette.accent, end ? 0.2 : 0.12, 2);\n          }\n        });\n      }\n    }\n    const anchor = range && end ? end : start;\n    const anchorIndex = days.findIndex((day) => sameDay(day, anchor));\n    const anchorBox = anchorIndex >= 0 ? cellBox(anchorIndex) : null;\n    if (anchorBox) {\n      const current = block.current;\n      if (!current.placed || reduced) {\n        current.x = { position: anchorBox.x, velocity: 0 };\n        current.y = { position: anchorBox.y, velocity: 0 };\n        current.placed = true;\n      } else {\n        springEuler(current.x, anchorBox.x, delta, 300, 0.72);\n        springEuler(current.y, anchorBox.y, delta, 300, 0.72);\n      }\n      if (Math.abs(anchorBox.x - current.x.position) + Math.abs(anchorBox.y - current.y.position) > 0.3) moving = true;\n      paintBlock(frame, { ...anchorBox, x: current.x.position, y: current.y.position });\n    }\n    if (range && end) {\n      const startIndex = days.findIndex((day) => sameDay(day, start));\n      const startBox = startIndex >= 0 ? cellBox(startIndex) : null;\n      if (startBox) paintBlock(frame, startBox);\n    }\n    const todayIndex = days.findIndex((day) => sameDay(day, today));\n    const todayBox = todayIndex >= 0 ? cellBox(todayIndex) : null;\n    if (todayBox) {\n      // on a selected day the mark sits on the block, so it takes the card colour\n      const onBlock = sameDay(days[todayIndex], start) || sameDay(days[todayIndex], end);\n      const color = onBlock ? palette.card : palette.accent;\n      for (let k = -1; k <= 1; k++)\n        dot(todayBox.x + todayBox.width / 2 + k * 3 - 1, todayBox.y + todayBox.height - 6, color, 1, 2);\n    }\n    return moving;\n  });\n\n  const viewKey = shownView ? ymd(shownView) : \"\";\n\n  // a new month places the selection block without springing\n  React.useLayoutEffect(() => {\n    block.current.placed = false;\n    redraw();\n  }, [viewKey, redraw]);\n\n  // the days slide in from the side the user moved to\n  React.useLayoutEffect(() => {\n    if (nav.direction === 0 || !nav.animate || prefersReducedMotion()) return;\n    const offset = nav.direction < 0 ? -10 : 10;\n    cellRefs.current.forEach((cell, index) => {\n      if (!cell || typeof cell.animate !== \"function\") return;\n      cell.animate(\n        [\n          { opacity: 0, transform: `translateX(${offset}px)` },\n          { opacity: 1, transform: \"none\" },\n        ],\n        {\n          duration: 300,\n          easing: \"cubic-bezier(.2,.8,.2,1)\",\n          delay: (index % 7) * 12,\n          fill: \"backwards\",\n        },\n      );\n    });\n  }, [nav.id, nav.direction, nav.animate]);\n\n  // keyboard moves focus to the new day once it is rendered\n  React.useLayoutEffect(() => {\n    const key = focusRequest.current;\n    if (!key) return;\n    focusRequest.current = null;\n    gridRef.current?.querySelector<HTMLButtonElement>(`[data-date=\"${key}\"]`)?.focus();\n  });\n\n  React.useEffect(redraw, [selection, todayValue, redraw]);\n\n  useFormReset(canvasRef, () => {\n    const next = parseSelection(defaultValue, range);\n    setSelection(next);\n    setValue(defaultValue);\n    setView(monthOf(next.start));\n    setFocusDate(next.start);\n    setNav((previous) => ({ id: previous.id + 1, direction: 0, animate: false }));\n    setSummaryAnimate(false);\n  });\n\n  const showMonth = (month: Date, direction: number) => {\n    setView(month);\n    setNav((previous) => ({ id: previous.id + 1, direction, animate: true }));\n  };\n\n  const pick = (day: Date) => {\n    let next: Selection;\n    if (!range) next = { start: day, end: null };\n    else if (!selection.start || selection.end) next = { start: day, end: null };\n    else if (day.getTime() < selection.start.getTime()) next = { start: day, end: selection.start };\n    else next = { start: selection.start, end: day };\n    setSelection(next);\n    setFocusDate(day);\n    setSummaryAnimate(true);\n    if (!range || next.end) setValue(formatSelection(next, range));\n    redraw();\n  };\n\n  // Enter and Space pick through the day button's own click\n  const onDayKeyDown = (event: React.KeyboardEvent<HTMLButtonElement>, day: Date) => {\n    if (!shownView) return;\n    const target = keyTarget(event, day, weekStart);\n    if (!target) return;\n    event.preventDefault();\n    setFocusDate(target);\n    if (target.getMonth() !== shownView.getMonth() || target.getFullYear() !== shownView.getFullYear()) {\n      showMonth(monthOf(target) ?? shownView, target.getTime() > shownView.getTime() ? 1 : -1);\n    }\n    focusRequest.current = ymd(target);\n  };\n\n  const title = shownView ? `${MONTHS[shownView.getMonth()]} ${shownView.getFullYear()}` : \"\";\n  const weekdays = Array.from({ length: 7 }, (_, i) => HEADER_WEEKDAYS[(i + weekStart) % 7]);\n  const weeks = days ? Array.from({ length: 6 }, (_, week) => days.slice(week * 7, week * 7 + 7)) : null;\n\n  return (\n    <div\n      data-slot=\"px-date\"\n      data-disabled={disabled ? \"\" : undefined}\n      className={cn(\"block leading-[1.5] text-foreground\", disabled && \"pointer-events-none opacity-55\", className)}\n      {...props}\n    >\n      <div className=\"mb-2 flex items-center gap-1.5\">\n        <button\n          type=\"button\"\n          aria-label=\"Previous month\"\n          disabled={disabled}\n          onClick={() => shownView && showMonth(addMonths(shownView, -1), -1)}\n          className={ICON_BUTTON}\n        >\n          <ChevronIcon path=\"M10 3.5L5.5 8l4.5 4.5\" />\n        </button>\n        <span id={titleId} className=\"flex-1 text-[14px] font-semibold\">\n          <RollText text={title} direction={nav.direction || 1} animate={nav.animate} aria-live=\"polite\" />\n        </span>\n        <button\n          type=\"button\"\n          aria-label=\"Next month\"\n          disabled={disabled}\n          onClick={() => shownView && showMonth(addMonths(shownView, 1), 1)}\n          className={ICON_BUTTON}\n        >\n          <ChevronIcon path=\"M6 3.5L10.5 8 6 12.5\" />\n        </button>\n      </div>\n      <div aria-hidden=\"true\" className=\"grid grid-cols-7\">\n        {weekdays.map((weekday) => (\n          <span key={weekday} className=\"pb-1 text-center font-mono text-[11px] text-muted-foreground\">\n            {weekday}\n          </span>\n        ))}\n      </div>\n      <div className=\"relative\">\n        <canvas\n          ref={canvasRef}\n          aria-hidden=\"true\"\n          className=\"pointer-events-none absolute inset-0 block size-full [image-rendering:pixelated]\"\n        />\n        <div ref={gridRef} role=\"grid\" aria-labelledby={titleId}>\n          {weeks && shownView\n            ? weeks.map((week, row) => (\n                <div key={row} role=\"row\" className=\"grid grid-cols-7\">\n                  {week.map((day, column) => {\n                    const index = row * 7 + column;\n                    const key = ymd(day);\n                    const selected = sameDay(day, selection.start) || sameDay(day, selection.end);\n                    return (\n                      <button\n                        key={key}\n                        ref={(node) => {\n                          cellRefs.current[index] = node;\n                        }}\n                        type=\"button\"\n                        role=\"gridcell\"\n                        data-date={key}\n                        aria-label={formatLong(day)}\n                        aria-selected={selected}\n                        aria-current={sameDay(day, today) ? \"date\" : undefined}\n                        tabIndex={sameDay(day, tabDate) ? 0 : -1}\n                        disabled={disabled}\n                        onClick={() => pick(day)}\n                        onPointerEnter={() => {\n                          hover.current = day;\n                          redraw();\n                        }}\n                        onPointerLeave={() => {\n                          hover.current = null;\n                          redraw();\n                        }}\n                        onKeyDown={(event) => onDayKeyDown(event, day)}\n                        className={cn(\n                          \"relative z-[1] h-9 cursor-pointer rounded-lg border-0 bg-transparent p-0 font-[inherit] text-[13px] text-inherit tabular-nums outline-none select-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-lumesec focus-visible:outline-solid\",\n                          day.getMonth() !== shownView.getMonth() && \"text-muted-foreground opacity-45\",\n                          selected ? \"font-semibold text-lumesec-foreground\" : \"hover:bg-foreground/6\",\n                        )}\n                      >\n                        {day.getDate()}\n                      </button>\n                    );\n                  })}\n                </div>\n              ))\n            : Array.from({ length: 6 }, (_, row) => <div key={row} role=\"row\" className=\"h-9\" />)}\n        </div>\n      </div>\n      <div className=\"mt-2.5 flex items-center justify-between gap-2 text-[13px]\">\n        <RollText text={summarize(selection, range)} animate={summaryAnimate} className=\"text-muted-foreground\" />\n        <button\n          type=\"button\"\n          disabled={disabled}\n          onClick={() => {\n            const now = new Date();\n            const day = new Date(now.getFullYear(), now.getMonth(), now.getDate());\n            setView(monthOf(day));\n            setNav((previous) => ({ id: previous.id + 1, direction: 0, animate: true }));\n            pick(day);\n          }}\n          className=\"inline-flex h-[30px] cursor-pointer items-center justify-center gap-1.5 rounded-lg border border-border bg-card px-3 font-[inherit] text-[12.5px] font-medium whitespace-nowrap text-foreground outline-none select-none [transition:transform_.15s_cubic-bezier(.2,.8,.2,1),background-color_.2s,border-color_.2s,color_.2s] hover:bg-muted focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-lumesec focus-visible:outline-solid active:[transform:scale(.95)]\"\n        >\n          Today\n        </button>\n      </div>\n      {name ? <input type=\"hidden\" name={name} value={formatSelection(selection, range)} disabled={disabled} /> : null}\n    </div>\n  );\n}\n\nconst DAY_STEPS: Record<string, number | undefined> = { ArrowLeft: -1, ArrowRight: 1, ArrowUp: -7, ArrowDown: 7 };\n\n/** The day a navigation key moves to from `day`, or null when the key does not navigate. */\nfunction keyTarget(event: React.KeyboardEvent, day: Date, weekStart: number): Date | null {\n  const step = DAY_STEPS[event.key];\n  if (step !== undefined) return addDays(day, step);\n  const column = (day.getDay() - weekStart + 7) % 7;\n  if (event.key === \"Home\") return addDays(day, -column);\n  if (event.key === \"End\") return addDays(day, 6 - column);\n  if (event.key === \"PageUp\" || event.key === \"PageDown\") {\n    // the same day next or last month (year with Shift), clamped: Jan 31 moves to Feb 28, not into March\n    const months = (event.key === \"PageDown\" ? 1 : -1) * (event.shiftKey ? 12 : 1);\n    return clampToMonth(day, addMonths(day, months));\n  }\n  return null;\n}\n\nfunction ChevronIcon({ path }: { path: string }) {\n  return (\n    <svg\n      viewBox=\"0 0 16 16\"\n      aria-hidden=\"true\"\n      className=\"size-4\"\n      fill=\"none\"\n      stroke=\"currentColor\"\n      strokeWidth={1.6}\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n    >\n      <path d={path} />\n    </svg>\n  );\n}\n\n/** Fill a day cell with the selection's dot pattern. */\nfunction paintBlock({ dot, palette }: PixelFrame, box: Box) {\n  for (let y = box.y + 2; y < box.y + box.height - 2; y += 3) {\n    for (let x = box.x + 4; x < box.x + box.width - 4; x += 3) dot(x, y, palette.accent, 1, 2.9);\n  }\n}\n\nconst pad = (n: number) => String(n).padStart(2, \"0\");\n\n/** `YYYY-MM-DD` in local time. */\nconst ymd = (date: Date) => `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;\n\n/** A local date from `YYYY-MM-DD`; null for anything else, including days that do not exist (2026-02-31). */\nfunction parseYmd(text: string | undefined) {\n  const match = /^(\\d{4})-(\\d{2})-(\\d{2})$/.exec(text ?? \"\");\n  if (!match) return null;\n  const [year, month, day] = [Number(match[1]), Number(match[2]) - 1, Number(match[3])];\n  const date = new Date(year, month, day);\n  return date.getMonth() === month && date.getDate() === day ? date : null;\n}\n\nconst sameDay = (a: Date | null, b: Date | null) =>\n  !!a && !!b && a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate();\n\n/** The first day of the month `date` is in. */\nfunction monthOf(date: Date | null) {\n  return date ? new Date(date.getFullYear(), date.getMonth(), 1) : null;\n}\n\nconst addMonths = (month: Date, count: number) => new Date(month.getFullYear(), month.getMonth() + count, 1);\n\nconst addDays = (date: Date, count: number) => new Date(date.getFullYear(), date.getMonth(), date.getDate() + count);\n\n/** The 42 days of a six-week grid showing `month`, starting on `firstDay`. */\nfunction monthDays(month: Date, firstDay: number) {\n  const year = month.getFullYear();\n  const index = month.getMonth();\n  const offset = (new Date(year, index, 1).getDay() - firstDay + 7) % 7;\n  return Array.from({ length: 42 }, (_, i) => new Date(year, index, 1 - offset + i));\n}\n\n/** The same day of the month in `month`, clamped to its length, unless `date` is already in it. */\nfunction clampToMonth(date: Date, month: Date) {\n  if (date.getMonth() === month.getMonth() && date.getFullYear() === month.getFullYear()) return date;\n  const length = new Date(month.getFullYear(), month.getMonth() + 1, 0).getDate();\n  return new Date(month.getFullYear(), month.getMonth(), Math.min(date.getDate(), length));\n}\n\n/** The selection a value describes. A range written end first is put in order. */\nfunction parseSelection(value: string, range: boolean): Selection {\n  const [first, second] = value.split(\"/\");\n  const start = parseYmd(first);\n  const end = range && start ? parseYmd(second) : null;\n  if (start && end && end.getTime() < start.getTime()) return { start: end, end: start };\n  return { start, end };\n}\n\nfunction formatSelection({ start, end }: Selection, range: boolean) {\n  if (range) return start && end ? `${ymd(start)}/${ymd(end)}` : \"\";\n  return start ? ymd(start) : \"\";\n}\n\n/** \"Saturday, 24 October 2026\", like `toLocaleDateString(\"en-GB\")`, but the same on server and client. */\nconst formatLong = (date: Date) =>\n  `${WEEKDAYS[date.getDay()]}, ${date.getDate()} ${MONTHS[date.getMonth()]} ${date.getFullYear()}`;\n\n/** \"Sat, 24 Oct 2026\". */\nconst formatShort = (date: Date) =>\n  `${SHORT_WEEKDAYS[date.getDay()]}, ${date.getDate()} ${SHORT_MONTHS[date.getMonth()]} ${date.getFullYear()}`;\n\nfunction summarize({ start, end }: Selection, range: boolean) {\n  if (!start) return range ? \"Pick a start date\" : \"No date picked\";\n  if (range && !end) return `From ${formatShort(start)} · pick an end`;\n  if (range && end) {\n    const nights = Math.round((end.getTime() - start.getTime()) / DAY_MS);\n    return `${start.getDate()} ${SHORT_MONTHS[start.getMonth()]} – ${formatShort(end)} · ${nights} night${nights === 1 ? \"\" : \"s\"}`;\n  }\n  return formatShort(start);\n}\n",
      "type": "registry:component",
      "target": "@components/lumesec/px-date.tsx"
    }
  ],
  "meta": {
    "tag": "px-date",
    "collection": "pixel-ui",
    "react": "PxDate",
    "kind": "react"
  },
  "docs": "Date picker docs: https://elements.lumesec.ai/components/pixel-ui/date",
  "categories": [
    "pixel-ui",
    "forms"
  ],
  "type": "registry:component"
}