v1.0

MonthRangePicker

Preview

Basic

Loading…

Preview

Code

ts
import MonthRangePicker from '@/components/form/MonthRangePicker';

src/components/form/MonthRangePicker.tsx

AI prompt

text
Build a month-range picker (From / To month grids with quick presets, Apply/Cancel) component in React + TypeScript + Tailwind CSS.

## Look
- Trigger: a button with the house input recipe: 14px CalendarDays (slate-400), then "Mar 2026 - Aug 2026" or "Select month range" in slate-400, truncating; a 14px ChevronDown at the right rotating 180° over 200ms while open.
- Popover: absolute `right-0 top-full z-50 mt-1 origin-top-right`, opaque floating surface, `p-4`, a springy scale-in (0.22s `cubic-bezier(0.34, 1.56, 0.64, 1)`); `w-[300px]`, `sm:w-auto sm:min-w-[560px]`.
- Heading (`mb-3 pb-3 border-b border-slate-200 dark:border-slate-700`): `text-sm font-semibold text-slate-800 dark:text-slate-100` — "Mar 2026 – Aug 2026" or "Select a month range".
- Body `flex flex-col sm:flex-row gap-4 sm:gap-8`:
  - Left (`sm:w-36`): section title "Quick Selection", then preset chips (`flex flex-wrap sm:flex-col gap-1.5`): `rounded-lg bg-slate-100 dark:bg-slate-800 px-3 py-1.5 text-left text-xs font-medium text-slate-600 dark:text-slate-300 hover:bg-indigo-50 hover:text-indigo-700` (dark `hover:bg-indigo-500/10 hover:text-indigo-300`). This Month, Last Month, Last 3 Months, Last 6 Months (both counting the current month), Year to Date, Last Year.
  - Right: `grid grid-cols-1 sm:grid-cols-2 gap-6` of two month grids titled "From" and "To" (section-title style, `mb-2`).
- Month grid: a year header — prev/next icon buttons (`p-1.5 rounded-lg text-slate-400 hover:bg-slate-100 hover:text-indigo-600`, `active:scale-90`; next disabled at 30% at the current year) around the year (sm semibold) — over `grid-cols-3 gap-1.5` of Jan…Dec, `h-8 text-xs rounded-lg active:scale-95`: selected `bg-indigo-600 text-white font-semibold`; future months `text-slate-300 dark:text-slate-600 cursor-not-allowed`; otherwise slate-700, hover slate-100 / dark slate-700.
- Footer (`mt-4 pt-4 border-t`, reversed column on mobile): "Clear Selection" text button (xs slate-600) left; Cancel (bordered, xs medium) and primary Apply (`px-4`) right.

## Behaviour
- Picking on From sets the start to that month's 1st; on To, the end to the last millisecond of that month. Stepping a grid's year also moves that side's pick to the same month in the new year. A preset sets both ends and moves both grids to them.
- Apply needs both ends; it calls `onDateRangeChange({ startDate, endDate })` and closes. Clear Selection empties the draft (Apply stays disabled until both are picked again). Cancel, outside click and Escape just close without emitting.
- Boundaries are UTC-anchored: build with `Date.UTC`, read with `getUTC*`, and label with `toLocaleDateString('en-US', { month: 'short', year: 'numeric', timeZone: 'UTC' })` — mixing local and UTC fields shifts a month for viewers away from UTC. "This month" and the future guard come from a fixed business timezone (UTC+8), not the browser.
- `initialRange` may arrive with ISO strings (rehydrated from JSON storage) — coerce to Date. Re-sync when it changes.

## API
`onDateRangeChange(range: { startDate: Date | null; endDate: Date | null })`, `initialRange?` (same shape), `className`. Default export.

## Demo
Seeded with Mar 2026 – Aug 2026, feeding the applied range back in as `initialRange`.

## House style (applies to everything above)
- Stack: React 19 + TypeScript + Tailwind CSS v4, icons from lucide-react. One self-contained file; default-export the component and named-export its types. `'use client'` if it has state, refs or handlers.
- Font Inter; palette indigo on slate. Primary accent indigo-600 (hover indigo-700, dark mode indigo-400). Body text slate-700 / dark slate-200; secondary slate-500 / dark slate-400.
- Dark mode is a `.dark` class on <html> (not prefers-color-scheme). Every colour needs its `dark:` pair.
- Compact admin scale: text-xs (12px) for controls and body, 10–11px for meta, rounded-lg (8px) controls, rounded-2xl (16px) cards.
- Card surface ("panel"): `bg-white/60 dark:bg-slate-800/60 backdrop-blur-xl border border-white/60 dark:border-slate-700/60 rounded-2xl shadow-lg`, on a soft slate gradient page background.
- Floating surfaces (dropdowns, popovers, menus) are OPAQUE: `bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-2xl shadow-lg`, no backdrop blur (it creates a stacking context that traps the popover's z-index). In-flow popovers are z-50; portalled overlays z-200.
- Text inputs and select triggers: `w-full px-3 py-2 text-xs rounded-lg border border-slate-300 dark:border-slate-700 bg-white/80 dark:bg-slate-900/60 placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-indigo-500/40 focus:border-indigo-500`.
- Field labels: 11px semibold slate-600. Section titles: 10px semibold uppercase wide-tracking slate-500.
- Primary button: indigo-600 fill, white 12px semibold text, rounded-lg, px-3 py-2, disabled at 50% opacity. Ghost button: slate-600 text, hover slate-100.
- Popovers close on outside click AND on Escape (listen to both; include the portalled panel's element in the outside-click check).
- Don't nest scroll containers around popovers: an ancestor with overflow hidden/auto clips an absolutely-positioned dropdown. Portal the panel to <body> when it must escape a scroller, and reposition it on scroll and resize.
- Accessible by default: visible focus rings, keyboard support that matches the WAI-ARIA pattern for the widget, `aria-label` on icon-only buttons, `min-w-0` so text truncates instead of overflowing.

Source

tsx
'use client';

/* Origin: marketing-stats (96S1), verbatim. */

import { useState, useRef, useEffect } from 'react';
import { CalendarDays, ChevronDown } from 'lucide-react';
import { MonthGrid } from './MonthGrid';
import {
  businessToday,
  formatUtcMonthLabel,
  isFutureBusinessMonth,
  utcMonthStart,
  utcMonthEnd,
  utcYearStart,
} from '@/lib/dateUtils';
import { useDismiss } from '@/lib/use-dismiss';

interface DateRange {
  startDate: Date | null;
  endDate: Date | null;
}

interface MonthRangePickerProps {
  onDateRangeChange: (range: DateRange) => void;
  initialRange?: DateRange;
  className?: string;
}

type QuickOption = 'thisMonth' | 'lastMonth' | 'last3Months' | 'last6Months' | 'yearToDate' | 'lastYear';

// initialRange can come from a Zustand `persist` store — after rehydrating
// from localStorage, Date fields arrive as plain ISO strings (JSON has no
// Date type), not real Date instances. Coerce defensively before calling any
// Date method on them.
function toDateOrNull(value: Date | string | null | undefined): Date | null {
  if (!value) return null;
  return value instanceof Date ? value : new Date(value);
}

export default function MonthRangePicker({ onDateRangeChange, initialRange, className = '' }: MonthRangePickerProps) {
  const [isOpen, setIsOpen] = useState(false);
  const normalizedInitialRange = initialRange
    ? { startDate: toDateOrNull(initialRange.startDate), endDate: toDateOrNull(initialRange.endDate) }
    : { startDate: null, endDate: null };
  const [range, setRange] = useState<DateRange>(normalizedInitialRange);
  // The month/year currently highlighted in each picker column, independent of
  // an already-applied range so browsing doesn't commit until Apply.
  // Read with getUTC* — the range's UTC fields are the intended calendar month
  // — falling back to the current GMT+8 month rather than the browser's.
  const [startYear, setStartYear] = useState(() => normalizedInitialRange.startDate?.getUTCFullYear() ?? businessToday().year);
  const [startMonth, setStartMonth] = useState(() => normalizedInitialRange.startDate?.getUTCMonth() ?? businessToday().month);
  const [endYear, setEndYear] = useState(() => normalizedInitialRange.endDate?.getUTCFullYear() ?? businessToday().year);
  const [endMonth, setEndMonth] = useState(() => normalizedInitialRange.endDate?.getUTCMonth() ?? businessToday().month);

  const popupRef = useRef<HTMLDivElement>(null);

  useEffect(() => {
    if (initialRange) {
      const startDate = toDateOrNull(initialRange.startDate);
      const endDate = toDateOrNull(initialRange.endDate);
      setRange({ startDate, endDate });
      if (startDate) {
        setStartYear(startDate.getUTCFullYear());
        setStartMonth(startDate.getUTCMonth());
      }
      if (endDate) {
        setEndYear(endDate.getUTCFullYear());
        setEndMonth(endDate.getUTCMonth());
      }
    }
  }, [initialRange]);

  useDismiss(popupRef, isOpen, () => setIsOpen(false));

  // Every preset is expressed in whole GMT+8 calendar months and built with the
  // UTC-anchored constructors — no date-fns arithmetic, which would operate on
  // the browser's local fields. Date.UTC normalizes out-of-range months, so
  // `month - 5` rolling into the previous year needs no special case.
  const handleQuickSelect = (option: QuickOption) => {
    const { year, month } = businessToday();
    let start: Date;
    let end: Date;

    switch (option) {
      case 'thisMonth':
        start = utcMonthStart(year, month);
        end = utcMonthEnd(year, month);
        break;
      case 'lastMonth':
        start = utcMonthStart(year, month - 1);
        end = utcMonthEnd(year, month - 1);
        break;
      case 'last3Months':
        start = utcMonthStart(year, month - 2);
        end = utcMonthEnd(year, month);
        break;
      case 'last6Months':
        start = utcMonthStart(year, month - 5);
        end = utcMonthEnd(year, month);
        break;
      case 'yearToDate':
        start = utcYearStart(year);
        end = utcMonthEnd(year, month);
        break;
      case 'lastYear':
        start = utcYearStart(year - 1);
        end = utcMonthEnd(year - 1, 11);
        break;
      default:
        return;
    }

    setRange({ startDate: start, endDate: end });
    setStartYear(start.getUTCFullYear());
    setStartMonth(start.getUTCMonth());
    setEndYear(end.getUTCFullYear());
    setEndMonth(end.getUTCMonth());
  };

  const handlePickStart = (year: number, month: number) => {
    setStartYear(year);
    setStartMonth(month);
    setRange(prev => ({ ...prev, startDate: utcMonthStart(year, month) }));
  };

  const handlePickEnd = (year: number, month: number) => {
    setEndYear(year);
    setEndMonth(month);
    setRange(prev => ({ ...prev, endDate: utcMonthEnd(year, month) }));
  };

  const handleApply = () => {
    if (!range.startDate || !range.endDate) return;
    onDateRangeChange(range);
    setIsOpen(false);
  };

  const handleClear = () => {
    setRange({ startDate: null, endDate: null });
  };

  const formatDisplay = () => {
    if (range.startDate && range.endDate) {
      return `${formatUtcMonthLabel(range.startDate)} - ${formatUtcMonthLabel(range.endDate)}`;
    }
    return 'Select month range';
  };

  const quickOptions: { key: QuickOption; label: string }[] = [
    { key: 'thisMonth', label: 'This Month' },
    { key: 'lastMonth', label: 'Last Month' },
    { key: 'last3Months', label: 'Last 3 Months' },
    { key: 'last6Months', label: 'Last 6 Months' },
    { key: 'yearToDate', label: 'Year to Date' },
    { key: 'lastYear', label: 'Last Year' },
  ];

  return (
    <div className={`relative ${className}`} ref={popupRef}>
      <button
        type="button"
        onClick={() => setIsOpen(!isOpen)}
        className="field-input text-left"
      >
        <div className="flex items-center justify-between gap-2">
          <span className="flex min-w-0 items-center gap-2 truncate">
            <CalendarDays className="h-3.5 w-3.5 shrink-0 text-slate-400" aria-hidden />
            <span className={`truncate ${range.startDate && range.endDate ? '' : 'text-slate-400 dark:text-slate-500'}`}>{formatDisplay()}</span>
          </span>
          <ChevronDown className={`h-3.5 w-3.5 shrink-0 text-slate-400 transition-transform duration-200 ${isOpen ? 'rotate-180' : ''}`} />
        </div>
      </button>

      {isOpen && (
        <div data-overlay="picker" className="absolute right-0 top-full z-50 mt-1 w-[300px] origin-top-right panel panel-solid p-4 animate-scale-in sm:w-auto sm:min-w-[560px]">
          <div className="flex items-baseline justify-between mb-3 pb-3 border-b border-slate-200 dark:border-slate-700">
            <h2 className="text-sm font-semibold text-slate-800 dark:text-slate-100">
              {range.startDate && range.endDate
                ? `${formatUtcMonthLabel(range.startDate)} – ${formatUtcMonthLabel(range.endDate)}`
                : 'Select a month range'}
            </h2>
          </div>

          <div className="flex flex-col sm:flex-row gap-4 sm:gap-8">
            <div className="w-full sm:w-36 flex-shrink-0">
              <h4 className="panel-title mb-2">Quick Selection</h4>
              <div className="flex flex-wrap sm:flex-col gap-1.5">
                {quickOptions.map(option => (
                  <button
                    key={option.key}
                    type="button"
                    onClick={() => handleQuickSelect(option.key)}
                    className="rounded-lg bg-slate-100 dark:bg-slate-800 px-3 py-1.5 text-left text-xs font-medium text-slate-600 dark:text-slate-300 transition-colors hover:bg-indigo-50 hover:text-indigo-700 dark:hover:bg-indigo-500/10 dark:hover:text-indigo-300"
                  >
                    {option.label}
                  </button>
                ))}
              </div>
            </div>

            <div className="flex-1 grid grid-cols-1 sm:grid-cols-2 gap-6">
              <div>
                <h3 className="panel-title mb-2">From</h3>
                <MonthGrid
                  year={startYear}
                  selectedYear={startYear}
                  selectedMonth={startMonth}
                  onYearChange={(y) => handlePickStart(y, startMonth)}
                  onPick={handlePickStart}
                  isMonthDisabled={isFutureBusinessMonth}
                />
              </div>
              <div>
                <h3 className="panel-title mb-2">To</h3>
                <MonthGrid
                  year={endYear}
                  selectedYear={endYear}
                  selectedMonth={endMonth}
                  onYearChange={(y) => handlePickEnd(y, endMonth)}
                  onPick={handlePickEnd}
                  isMonthDisabled={isFutureBusinessMonth}
                />
              </div>
            </div>
          </div>

          <div className="flex flex-col-reverse sm:flex-row justify-between items-center mt-4 pt-4 border-t border-slate-200 dark:border-slate-700 gap-4 sm:gap-0">
            <button
              type="button"
              onClick={handleClear}
              className="px-3 py-2 text-xs text-slate-600 dark:text-slate-400 hover:text-slate-800 dark:hover:text-slate-200 transition-colors"
            >
              Clear Selection
            </button>

            <div className="flex gap-3 w-full sm:w-auto justify-end">
              <button
                type="button"
                onClick={() => setIsOpen(false)}
                className="px-3 py-2 text-xs font-medium rounded-lg border border-slate-300 dark:border-slate-600 text-slate-700 dark:text-slate-200 hover:bg-slate-50 dark:hover:bg-slate-700 transition-colors"
              >
                Cancel
              </button>
              <button
                type="button"
                onClick={handleApply}
                disabled={!range.startDate || !range.endDate}
                className="btn-primary px-4"
              >
                Apply
              </button>
            </div>
          </div>
        </div>
      )}
    </div>
  );
}

Props

PropTypeDefaultDescription
onDateRangeChange*(range: DateRange) => void—
initialRangeDateRange—
classNamestring''