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
| Prop | Type | Default | Description |
|---|---|---|---|
onDateRangeChange* | (range: DateRange) => void | — | |
initialRange | DateRange | — | |
className | string | '' |