Fazekit

Code

"use client";
import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { CalendarDays, ChevronLeft, ChevronRight, X } from "lucide-react";
import { cn } from "@/lib/utils";

/* ----------------------------------------------------------------------------
 * Types
 * ------------------------------------------------------------------------- */

export type DateRange = { from?: Date; to?: Date };

export type DateRangePreset = {
  id: string;
  label: string;
  range: (today: Date) => { from: Date; to: Date };
};

export interface DateRangePickerProps {
  /** Controlled value. */
  value?: DateRange;
  /** Uncontrolled initial value. */
  defaultValue?: DateRange;
  /** Id of a preset applied as the initial value once "today" is known (client side). */
  defaultPreset?: string;
  onChange?: (range: DateRange) => void;
  /** Earliest selectable day. */
  min?: Date;
  /** Latest selectable day. */
  max?: Date;
  /** Block every day after today (evaluated on the client). */
  disableFuture?: boolean;
  /** Extra predicate for blocked days (holidays, weekends…). */
  isDateDisabled?: (date: Date) => boolean;
  /** BCP-47 locale used for every label and caption. */
  locale?: string;
  /** 0 = Sunday … 6 = Saturday. Defaults to the locale's first day of week. */
  weekStartsOn?: number;
  presets?: DateRangePreset[];
  /** Months shown side by side on wide screens (1 on phones). */
  numberOfMonths?: 1 | 2;
  placeholder?: string;
  label?: string;
  /** Emits two hidden inputs `${name}From` / `${name}To` (ISO dates) for native forms. */
  name?: string;
  defaultOpen?: boolean;
  align?: "start" | "end";
  className?: string;
}

/* ----------------------------------------------------------------------------
 * Date helpers (local time, day precision)
 * ------------------------------------------------------------------------- */

const DAY_MS = 86_400_000;
const startOfDay = (d: Date) => new Date(d.getFullYear(), d.getMonth(), d.getDate());
const startOfMonth = (d: Date) => new Date(d.getFullYear(), d.getMonth(), 1);
const endOfMonth = (d: Date) => new Date(d.getFullYear(), d.getMonth() + 1, 0);
const addDays = (d: Date, n: number) => new Date(d.getFullYear(), d.getMonth(), d.getDate() + n);
const addMonths = (d: Date, n: number) => {
  const first = new Date(d.getFullYear(), d.getMonth() + n, 1);
  return new Date(first.getFullYear(), first.getMonth(), Math.min(d.getDate(), endOfMonth(first).getDate()));
};
const cmp = (a: Date, b: Date) => startOfDay(a).getTime() - startOfDay(b).getTime();
const sameDay = (a?: Date, b?: Date) => !!a && !!b && cmp(a, b) === 0;
const monthIndex = (d: Date) => d.getFullYear() * 12 + d.getMonth();
const dayKey = (d: Date) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
const daysBetween = (a: Date, b: Date) => Math.round((startOfDay(b).getTime() - startOfDay(a).getTime()) / DAY_MS) + 1;

export const DEFAULT_PRESETS: DateRangePreset[] = [
  { id: "today", label: "Today", range: (t) => ({ from: t, to: t }) },
  { id: "yesterday", label: "Yesterday", range: (t) => ({ from: addDays(t, -1), to: addDays(t, -1) }) },
  { id: "last7", label: "Last 7 days", range: (t) => ({ from: addDays(t, -6), to: t }) },
  { id: "last30", label: "Last 30 days", range: (t) => ({ from: addDays(t, -29), to: t }) },
  { id: "thisMonth", label: "This month", range: (t) => ({ from: startOfMonth(t), to: endOfMonth(t) }) },
  {
    id: "lastMonth",
    label: "Last month",
    range: (t) => {
      const m = addMonths(startOfMonth(t), -1);
      return { from: m, to: endOfMonth(m) };
    },
  },
  { id: "ytd", label: "Year to date", range: (t) => ({ from: new Date(t.getFullYear(), 0, 1), to: t }) },
];

/* "Today" is read on the client only, so server and client markup always agree. */
const noopSubscribe = () => () => {};
function useTodayKey() {
  return React.useSyncExternalStore(
    noopSubscribe,
    () => dayKey(new Date()),
    () => null,
  );
}
function useMediaQuery(query: string) {
  return React.useSyncExternalStore(
    (cb) => {
      const m = window.matchMedia(query);
      m.addEventListener("change", cb);
      return () => m.removeEventListener("change", cb);
    },
    () => window.matchMedia(query).matches,
    () => true,
  );
}

function localeWeekStart(locale: string): number {
  try {
    const loc = new Intl.Locale(locale) as Intl.Locale & {
      getWeekInfo?: () => { firstDay: number };
      weekInfo?: { firstDay: number };
    };
    const info = loc.getWeekInfo?.() ?? loc.weekInfo;
    if (info) return info.firstDay % 7;
  } catch {
    /* unsupported → fall through */
  }
  return /^en-(US|CA)|^ja|^he|^pt-BR/.test(locale) ? 0 : 1;
}

/* ----------------------------------------------------------------------------
 * Component
 * ------------------------------------------------------------------------- */

export function DateRangePicker({
  value,
  defaultValue,
  defaultPreset,
  onChange,
  min,
  max,
  isDateDisabled,
  disableFuture = false,
  locale = "en-US",
  weekStartsOn,
  presets = DEFAULT_PRESETS,
  numberOfMonths = 2,
  placeholder = "Pick a date range",
  label = "Date range",
  name,
  defaultOpen = false,
  align = "start",
  className,
}: DateRangePickerProps) {
  const uid = React.useId();
  const reduce = useReducedMotion();
  const todayKey = useTodayKey();
  const today = React.useMemo(() => {
    if (!todayKey) return null;
    const [y, m, d] = todayKey.split("-").map(Number);
    return new Date(y, m - 1, d);
  }, [todayKey]);
  const wide = useMediaQuery("(min-width: 640px)");
  const monthsShown = wide ? numberOfMonths : 1;
  const weekStart = weekStartsOn ?? localeWeekStart(locale);

  /* committed value */
  const [inner, setInner] = React.useState<DateRange | undefined>(defaultValue);
  const presetInitial = React.useMemo(() => {
    const p = presets.find((x) => x.id === defaultPreset);
    return p && today ? p.range(today) : undefined;
  }, [presets, defaultPreset, today]);
  const committed: DateRange = value ?? inner ?? presetInitial ?? {};

  /* popover state */
  const [open, setOpen] = React.useState(defaultOpen);
  const [draft, setDraft] = React.useState<DateRange | null>(null);
  const [hover, setHover] = React.useState<Date | null>(null);
  const [view, setView] = React.useState<Date | null>(null);
  const [focusDay, setFocusDay] = React.useState<Date | null>(null);
  const [dir, setDir] = React.useState(1);
  const current = draft ?? committed;

  const rootRef = React.useRef<HTMLDivElement>(null);
  const triggerRef = React.useRef<HTMLButtonElement>(null);
  const popRef = React.useRef<HTMLDivElement>(null);
  const gridsRef = React.useRef<HTMLDivElement>(null);
  const shouldFocusDay = React.useRef(false);

  const anchor = current.from ?? today ?? new Date(2026, 0, 1);
  const viewMonth = view ?? startOfMonth(current.to && monthsShown === 1 ? current.to : anchor);
  const activeDay = focusDay ?? current.from ?? today ?? viewMonth;

  const maxDay = disableFuture && today && (!max || cmp(today, max) < 0) ? today : max;
  const isDisabled = React.useCallback(
    (d: Date) => (!!min && cmp(d, min) < 0) || (!!maxDay && cmp(d, maxDay) > 0) || !!isDateDisabled?.(d),
    [min, maxDay, isDateDisabled],
  );

  /* formatters */
  const fmt = React.useMemo(() => {
    const short = new Intl.DateTimeFormat(locale, { month: "short", day: "numeric", year: "numeric" });
    return {
      short,
      caption: new Intl.DateTimeFormat(locale, { month: "long", year: "numeric" }),
      full: new Intl.DateTimeFormat(locale, { weekday: "long", month: "long", day: "numeric", year: "numeric" }),
      wdShort: new Intl.DateTimeFormat(locale, { weekday: "short" }),
      wdLong: new Intl.DateTimeFormat(locale, { weekday: "long" }),
      num: new Intl.NumberFormat(locale),
      range(r: DateRange) {
        if (!r.from) return "";
        if (!r.to || sameDay(r.from, r.to)) return short.format(r.from);
        try {
          return short.formatRange(r.from, r.to);
        } catch {
          return `${short.format(r.from)} – ${short.format(r.to)}`;
        }
      },
    };
  }, [locale]);

  const weekdays = React.useMemo(
    () =>
      Array.from({ length: 7 }, (_, i) => {
        const d = new Date(2023, 0, 1 + ((weekStart + i) % 7)); // 1 Jan 2023 was a Sunday
        return { short: fmt.wdShort.format(d), long: fmt.wdLong.format(d) };
      }),
    [fmt, weekStart],
  );

  /* ---------------- open / close ---------------- */

  const openPicker = () => {
    setDraft(committed);
    setView(null);
    setFocusDay(null);
    setHover(null);
    shouldFocusDay.current = true;
    setOpen(true);
  };
  const close = React.useCallback((restoreFocus = true) => {
    setOpen(false);
    setDraft(null);
    setHover(null);
    if (restoreFocus) triggerRef.current?.focus();
  }, []);

  const commit = (r: DateRange) => {
    if (value === undefined) setInner(r);
    onChange?.(r);
  };
  const apply = () => {
    if (!current.from) return;
    commit({ from: current.from, to: current.to ?? current.from });
    close();
  };

  // outside press closes (discarding the draft)
  React.useEffect(() => {
    if (!open) return;
    const onDown = (e: PointerEvent) => {
      if (!rootRef.current?.contains(e.target as Node)) close(false);
    };
    const onKey = (e: KeyboardEvent) => {
      if (e.key === "Escape") close(rootRef.current?.contains(document.activeElement) || document.activeElement === document.body);
    };
    document.addEventListener("pointerdown", onDown);
    document.addEventListener("keydown", onKey);
    return () => {
      document.removeEventListener("pointerdown", onDown);
      document.removeEventListener("keydown", onKey);
    };
  }, [open, close]);

  // keep the popover inside the viewport (flip up / shift left) — direct style writes, no re-render
  React.useLayoutEffect(() => {
    const pop = popRef.current;
    const trig = triggerRef.current;
    if (!open || !pop || !trig) return;
    const place = () => {
      pop.style.left = "";
      pop.style.right = "";
      pop.style.top = "";
      pop.style.bottom = "";
      const t = trig.getBoundingClientRect();
      const p = { width: pop.offsetWidth, height: pop.offsetHeight };
      const vw = document.documentElement.clientWidth;
      const below = window.innerHeight - t.bottom;
      if (below < p.height + 12 && t.top > below) {
        pop.style.top = "auto";
        pop.style.bottom = "calc(100% + 8px)";
      }
      if (align === "start") {
        const overflow = t.left + p.width - (vw - 8);
        pop.style.left = `${-Math.max(0, Math.min(overflow, t.left - 8))}px`;
      } else {
        const overflow = 8 - (t.right - p.width);
        pop.style.right = `${-Math.max(0, Math.min(overflow, vw - t.right - 8))}px`;
      }
    };
    place();
    window.addEventListener("resize", place);
    return () => window.removeEventListener("resize", place);
  }, [open, align, monthsShown]);

  // move DOM focus with the roving day
  React.useEffect(() => {
    if (!open || !shouldFocusDay.current) return;
    const btn = gridsRef.current?.querySelector<HTMLButtonElement>(`[data-day="${dayKey(activeDay)}"]`);
    if (btn) {
      btn.focus({ preventScroll: true });
      shouldFocusDay.current = false;
    }
  }, [open, activeDay, viewMonth]);

  /* ---------------- navigation ---------------- */

  const showMonth = (month: Date, direction: number) => {
    setDir(direction);
    setView(startOfMonth(month));
  };

  const moveFocus = (next: Date) => {
    let target = next;
    if (min && cmp(target, min) < 0) target = startOfDay(min);
    if (maxDay && cmp(target, maxDay) > 0) target = startOfDay(maxDay);
    shouldFocusDay.current = true;
    setFocusDay(target);
    const first = monthIndex(viewMonth);
    const idx = monthIndex(target);
    if (idx < first) showMonth(target, -1);
    else if (idx > first + monthsShown - 1) showMonth(addMonths(startOfMonth(target), -(monthsShown - 1)), 1);
  };

  const onGridKey = (e: React.KeyboardEvent) => {
    const d = activeDay;
    const col = (d.getDay() - weekStart + 7) % 7;
    const map: Record<string, () => Date> = {
      ArrowLeft: () => addDays(d, -1),
      ArrowRight: () => addDays(d, 1),
      ArrowUp: () => addDays(d, -7),
      ArrowDown: () => addDays(d, 7),
      Home: () => addDays(d, -col),
      End: () => addDays(d, 6 - col),
      PageUp: () => addMonths(d, e.shiftKey ? -12 : -1),
      PageDown: () => addMonths(d, e.shiftKey ? 12 : 1),
    };
    const fn = map[e.key];
    if (!fn) return;
    e.preventDefault();
    moveFocus(fn());
  };

  const pick = (d: Date) => {
    if (isDisabled(d)) return;
    setFocusDay(d);
    const base = draft ?? committed;
    if (!base.from || base.to) setDraft({ from: d, to: undefined });
    else if (cmp(d, base.from) < 0) setDraft({ from: d, to: base.from });
    else setDraft({ from: base.from, to: d });
  };

  const choosePreset = (p: DateRangePreset) => {
    if (!today) return;
    const r = p.range(today);
    setDraft(r);
    setFocusDay(r.to);
    shouldFocusDay.current = false;
    const target = addMonths(startOfMonth(r.to), -(monthsShown - 1));
    if (monthIndex(target) !== monthIndex(viewMonth)) showMonth(target, monthIndex(target) < monthIndex(viewMonth) ? -1 : 1);
  };

  const activePreset = today
    ? presets.find((p) => {
        const r = p.range(today);
        return sameDay(r.from, current.from) && sameDay(r.to, current.to);
      })?.id
    : undefined;

  /* range preview while choosing the end */
  const lo = current.from;
  const hi = current.to ?? (current.from && hover ? hover : undefined);
  const [rangeA, rangeB] = lo && hi && cmp(hi, lo) < 0 ? [hi, lo] : [lo, hi];

  const months = Array.from({ length: monthsShown }, (_, i) => addMonths(viewMonth, i));
  const weeks = Math.max(...months.map((m) => Math.ceil((((m.getDay() - weekStart + 7) % 7) + endOfMonth(m).getDate()) / 7)));
  const canPrev = !min || monthIndex(viewMonth) > monthIndex(min);
  const canNext = !maxDay || monthIndex(viewMonth) + monthsShown - 1 < monthIndex(maxDay);
  const summary = fmt.range(current);
  const count = current.from ? daysBetween(current.from, current.to ?? current.from) : 0;
  const triggerText = fmt.range(committed);

  return (
    <div ref={rootRef} className={cn("relative inline-block w-full max-w-sm", className)}>
      <span id={`${uid}-label`} className="mb-1.5 block text-sm font-medium text-foreground">
        {label}
      </span>
      <div className="relative">
        <button
          ref={triggerRef}
          type="button"
          aria-haspopup="dialog"
          aria-expanded={open}
          aria-controls={open ? `${uid}-pop` : undefined}
          aria-labelledby={`${uid}-label ${uid}-value`}
          onClick={() => (open ? close() : openPicker())}
          onKeyDown={(e) => {
            if (e.key === "ArrowDown" && !open) {
              e.preventDefault();
              openPicker();
            }
          }}
          className={cn(
            "group flex h-11 w-full items-center gap-2.5 rounded-xl border bg-background pl-3 pr-10 text-left text-sm shadow-xs outline-none transition",
            "hover:border-foreground/20 focus-visible:border-ring focus-visible:ring-4 focus-visible:ring-ring/20",
            open && "border-ring ring-4 ring-ring/15",
          )}
        >
          <CalendarDays className="size-4 shrink-0 text-muted-foreground transition group-hover:text-foreground" aria-hidden />
          <span id={`${uid}-value`} className={cn("truncate tabular-nums", !triggerText && "text-muted-foreground")}>
            {triggerText || placeholder}
          </span>
        </button>
        {committed.from && (
          <button
            type="button"
            aria-label="Clear date range"
            onClick={() => {
              commit({});
              triggerRef.current?.focus();
            }}
            className="absolute right-2 top-1/2 grid size-7 -translate-y-1/2 place-items-center rounded-lg text-muted-foreground outline-none transition hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring/40"
          >
            <X className="size-3.5" />
          </button>
        )}

        <AnimatePresence>
          {open && today && (
            <motion.div
              ref={popRef}
              id={`${uid}-pop`}
              role="dialog"
              aria-modal="false"
              aria-label={`${label}: choose start and end dates`}
              initial={reduce ? { opacity: 0 } : { opacity: 0, y: -6, scale: 0.97 }}
              animate={{ opacity: 1, y: 0, scale: 1 }}
              exit={reduce ? { opacity: 0 } : { opacity: 0, y: -4, scale: 0.98, transition: { duration: 0.12 } }}
              transition={{ type: "spring", stiffness: 520, damping: 34, mass: 0.7 }}
              className={cn(
                "absolute top-[calc(100%+8px)] z-50 flex w-max max-w-[calc(100vw-1rem)] flex-col overflow-hidden rounded-2xl border bg-popover text-popover-foreground shadow-2xl shadow-black/10 dark:shadow-black/40 sm:flex-row",
                align === "start" ? "left-0 origin-top-left" : "right-0 origin-top-right",
              )}
            >
              {/* presets */}
              {presets.length > 0 && (
                <div
                  role="group"
                  aria-label="Quick ranges"
                  className="flex gap-1 overflow-x-auto border-b p-2 [scrollbar-width:none] sm:w-40 sm:flex-col sm:overflow-visible sm:border-b-0 sm:border-r sm:p-2.5"
                >
                  {presets.map((p) => {
                    const on = activePreset === p.id;
                    return (
                      <button
                        key={p.id}
                        type="button"
                        aria-pressed={on}
                        onClick={() => choosePreset(p)}
                        className={cn(
                          "relative shrink-0 rounded-lg px-2.5 py-1.5 text-left text-[13px] outline-none transition focus-visible:ring-2 focus-visible:ring-ring/40",
                          on ? "text-primary-foreground" : "text-muted-foreground hover:bg-muted hover:text-foreground",
                        )}
                      >
                        {on && (
                          <motion.span
                            layoutId={`${uid}-preset`}
                            className="absolute inset-0 rounded-lg bg-primary"
                            transition={{ type: "spring", stiffness: 500, damping: 38 }}
                          />
                        )}
                        <span className="relative">{p.label}</span>
                      </button>
                    );
                  })}
                </div>
              )}

              <div className="flex flex-col">
                <div ref={gridsRef} className="relative overflow-hidden p-3 pb-2" onKeyDown={onGridKey}>
                  <AnimatePresence mode="popLayout" initial={false} custom={dir}>
                    <motion.div
                      key={dayKey(viewMonth)}
                      custom={dir}
                      variants={{
                        enter: (d: number) => ({ x: reduce ? 0 : d * 40, opacity: 0 }),
                        center: { x: 0, opacity: 1 },
                        exit: (d: number) => ({ x: reduce ? 0 : d * -40, opacity: 0 }),
                      }}
                      initial="enter"
                      animate="center"
                      exit="exit"
                      transition={{ type: "spring", stiffness: 420, damping: 36 }}
                      className="flex gap-5"
                    >
                      {months.map((m, i) => (
                        <MonthGrid
                          key={dayKey(m)}
                          uid={uid}
                          month={m}
                          caption={fmt.caption.format(m)}
                          weekdays={weekdays}
                          weekStart={weekStart}
                          weeks={weeks}
                          today={today}
                          activeDay={activeDay}
                          from={current.from}
                          to={current.to}
                          rangeA={rangeA}
                          rangeB={rangeB}
                          fullFormat={fmt.full}
                          isDisabled={isDisabled}
                          onPick={pick}
                          onHover={setHover}
                          prev={i === 0 ? { can: canPrev, go: () => showMonth(addMonths(viewMonth, -1), -1) } : undefined}
                          next={i === months.length - 1 ? { can: canNext, go: () => showMonth(addMonths(viewMonth, 1), 1) } : undefined}
                        />
                      ))}
                    </motion.div>
                  </AnimatePresence>
                </div>

                <div className="flex flex-wrap items-center justify-between gap-2 border-t bg-muted/40 px-3 py-2.5">
                  <p className="min-w-0 text-xs text-muted-foreground" aria-live="polite">
                    {current.from ? (
                      <>
                        <span className="font-medium text-foreground tabular-nums">{summary}</span>
                        <span className="mx-1.5 opacity-50">·</span>
                        {current.to ? `${fmt.num.format(count)} ${count === 1 ? "day" : "days"}` : "Pick an end date"}
                      </>
                    ) : (
                      "Pick a start date"
                    )}
                  </p>
                  <div className="ml-auto flex gap-2">
                    <button
                      type="button"
                      onClick={() => close()}
                      className="h-8 rounded-lg px-3 text-sm text-muted-foreground outline-none transition hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring/40"
                    >
                      Cancel
                    </button>
                    <button
                      type="button"
                      disabled={!current.from}
                      onClick={apply}
                      className="h-8 rounded-lg bg-primary px-3.5 text-sm font-medium text-primary-foreground shadow-sm outline-none transition hover:opacity-90 focus-visible:ring-2 focus-visible:ring-ring/50 focus-visible:ring-offset-2 focus-visible:ring-offset-background active:scale-[0.97] disabled:opacity-40"
                    >
                      Apply
                    </button>
                  </div>
                </div>
              </div>
            </motion.div>
          )}
        </AnimatePresence>
      </div>
      {name && (
        <>
          <input type="hidden" name={`${name}From`} value={committed.from ? dayKey(committed.from) : ""} />
          <input type="hidden" name={`${name}To`} value={committed.to ? dayKey(committed.to) : ""} />
        </>
      )}
    </div>
  );
}

/* ----------------------------------------------------------------------------
 * Month grid
 * ------------------------------------------------------------------------- */

type Nav = { can: boolean; go: () => void };

function MonthGrid({
  uid,
  month,
  caption,
  weekdays,
  weekStart,
  weeks,
  today,
  activeDay,
  from,
  to,
  rangeA,
  rangeB,
  fullFormat,
  isDisabled,
  onPick,
  onHover,
  prev,
  next,
}: {
  uid: string;
  month: Date;
  caption: string;
  weekdays: { short: string; long: string }[];
  weekStart: number;
  weeks: number;
  today: Date;
  activeDay: Date;
  from?: Date;
  to?: Date;
  rangeA?: Date;
  rangeB?: Date;
  fullFormat: Intl.DateTimeFormat;
  isDisabled: (d: Date) => boolean;
  onPick: (d: Date) => void;
  onHover: (d: Date | null) => void;
  prev?: Nav;
  next?: Nav;
}) {
  const captionId = `${uid}-${dayKey(month)}-caption`;
  const offset = (month.getDay() - weekStart + 7) % 7;
  const daysIn = endOfMonth(month).getDate();
  const cells: (Date | null)[] = Array.from({ length: weeks * 7 }, (_, i) => {
    const n = i - offset + 1;
    return n >= 1 && n <= daysIn ? new Date(month.getFullYear(), month.getMonth(), n) : null;
  });
  const rows = Array.from({ length: weeks }, (_, r) => cells.slice(r * 7, r * 7 + 7));
  const activeInMonth = monthIndex(activeDay) === monthIndex(month);

  const navBtn =
    "grid size-8 place-items-center rounded-lg text-muted-foreground outline-none transition hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring/40 disabled:pointer-events-none disabled:opacity-30";

  return (
    <div className="w-[266px] shrink-0">
      <div className="mb-2 flex h-8 items-center justify-between">
        {prev ? (
          <button type="button" className={navBtn} onClick={prev.go} disabled={!prev.can} aria-label="Previous month">
            <ChevronLeft className="size-4" />
          </button>
        ) : (
          <span className="size-8" />
        )}
        <h2 id={captionId} className="text-sm font-semibold capitalize tracking-tight" aria-live="polite">
          {caption}
        </h2>
        {next ? (
          <button type="button" className={navBtn} onClick={next.go} disabled={!next.can} aria-label="Next month">
            <ChevronRight className="size-4" />
          </button>
        ) : (
          <span className="size-8" />
        )}
      </div>
      <table role="grid" aria-labelledby={captionId} className="w-full border-collapse" onMouseLeave={() => onHover(null)}>
        <thead>
          <tr>
            {weekdays.map((w) => (
              <th key={w.long} scope="col" abbr={w.long} className="h-7 text-[11px] font-medium uppercase tracking-wide text-muted-foreground/80">
                {w.short.slice(0, 2)}
              </th>
            ))}
          </tr>
        </thead>
        <tbody>
          {rows.map((row, r) => (
            <tr key={r}>
              {row.map((d, c) => {
                if (!d) return <td key={c} className="h-9 p-0" />;
                const disabled = isDisabled(d);
                const isFrom = sameDay(d, from);
                const isTo = sameDay(d, to);
                const isEnd = isFrom || isTo;
                const inRange = !!rangeA && !!rangeB && cmp(d, rangeA) >= 0 && cmp(d, rangeB) <= 0;
                const bandStart = sameDay(d, rangeA);
                const bandEnd = sameDay(d, rangeB);
                const single = bandStart && bandEnd;
                const rowStart = c === 0 || d.getDate() === 1;
                const rowEnd = c === 6 || d.getDate() === endOfMonth(d).getDate();
                const isToday = sameDay(d, today);
                const tabbable = activeInMonth ? sameDay(d, activeDay) : d.getDate() === 1;
                return (
                  <td key={c} role="gridcell" aria-selected={isEnd || (inRange && !!to)} className="relative h-9 p-0 text-center">
                    {inRange && !single && (
                      <span
                        aria-hidden
                        className={cn(
                          "absolute inset-y-0.5 bg-primary/10 dark:bg-primary/15",
                          bandStart ? "left-1/2" : "left-0",
                          bandEnd ? "right-1/2" : "right-0",
                          rowStart && !bandStart && "left-0.5 rounded-l-lg",
                          rowEnd && !bandEnd && "right-0.5 rounded-r-lg",
                        )}
                      />
                    )}
                    <button
                      type="button"
                      data-day={dayKey(d)}
                      tabIndex={tabbable ? 0 : -1}
                      aria-disabled={disabled || undefined}
                      aria-label={`${fullFormat.format(d)}${isToday ? ", today" : ""}${isFrom ? ", range start" : ""}${isTo ? ", range end" : ""}`}
                      onClick={() => onPick(d)}
                      onMouseEnter={() => onHover(d)}
                      onFocus={() => onHover(d)}
                      className={cn(
                        "relative mx-auto grid size-9 place-items-center rounded-lg text-[13px] tabular-nums outline-none transition-colors",
                        "focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-popover",
                        disabled
                          ? "cursor-not-allowed text-muted-foreground/35 line-through decoration-muted-foreground/30"
                          : isEnd
                            ? "font-semibold text-primary-foreground"
                            : inRange
                              ? "text-foreground hover:bg-primary/15"
                              : "text-foreground hover:bg-muted",
                      )}
                    >
                      {isEnd && (
                        <motion.span
                          key={dayKey(d)}
                          aria-hidden
                          initial={{ scale: 0.55, opacity: 0 }}
                          animate={{ scale: 1, opacity: 1 }}
                          transition={{ type: "spring", stiffness: 600, damping: 28 }}
                          className="absolute inset-0 rounded-lg bg-primary shadow-sm shadow-primary/30"
                        />
                      )}
                      <span className="relative">{d.getDate()}</span>
                      {isToday && (
                        <span
                          aria-hidden
                          className={cn("absolute bottom-1 left-1/2 size-1 -translate-x-1/2 rounded-full", isEnd ? "bg-primary-foreground" : "bg-primary")}
                        />
                      )}
                    </button>
                  </td>
                );
              })}
            </tr>
          ))}
        </tbody>
      </table>
    </div>
  );
}

More in Forms & Inputs

View all →