MonthPicker
Preview
Basic
Loading…
Preview
Code
ts
import MonthPicker from '@/components/form/MonthPicker';src/components/form/MonthPicker.tsx
AI prompt
text
Build a single-month picker (a `YYYY-MM` value, clearable) component in React + TypeScript + Tailwind CSS.
## Look
- Trigger: a button with the house input recipe; inside `flex justify-between gap-2`. Left: 14px CalendarDays (slate-400) and "Aug 2026" (en-US short month + year) or the placeholder in slate-400 / dark slate-500, truncating. Right: a 14px X (slate-400, hover slate-600 / dark slate-300; only when clearable, set and not disabled — clears without opening) and a 14px ChevronDown rotating 180° over 200ms while open. Disabled: `opacity-60 cursor-not-allowed`.
- Popover: absolute `right-0 top-full z-50 mt-1 w-72 origin-top-right`, opaque floating surface, `p-3`, a springy scale-in (0.22s `cubic-bezier(0.34, 1.56, 0.64, 1)`, from opacity 0, scale 0.96, translateY 6px).
- Year header (`mb-3 flex items-center justify-between`): prev/next icon buttons (`p-1.5 rounded-lg text-slate-400 hover:bg-slate-100 hover:text-indigo-600`, dark `hover:bg-slate-700 hover:text-indigo-400`, `active:scale-90`, 16px chevrons) around the year in `text-sm font-semibold text-slate-800 dark:text-slate-100`. Next is disabled (`opacity-30`, no hover) at the current year.
- Month grid `grid grid-cols-3 gap-1.5`, Jan…Dec, each `h-8 text-xs rounded-lg transition-all active:scale-95`: selected `bg-indigo-600 text-white font-semibold`; disabled `text-slate-300 dark:text-slate-600 cursor-not-allowed`; otherwise `text-slate-700 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-700`.
- With `allowClear`, a footer (`mt-3 pt-3 border-t border-slate-200 dark:border-slate-700`) with a right-aligned "Clear" text button (`px-3 py-2 text-xs text-slate-600 hover:text-slate-800`, dark slate-400 → slate-200).
## Behaviour
- The value is `'YYYY-MM'` (1-based month) or null. One click on a month emits it and closes. Browsing years selects nothing; the browsed year follows the value when it changes and otherwise starts at the current year.
- `isMonthDisabled(year, month0)` defaults to "no future months"; a form can tighten it (e.g. also exclude the current month, whose figures aren't final). "Current" is taken in a fixed business timezone (UTC+8), so the year arrow and the disabled months unlock at the same moment for every viewer.
- `disabled` locks the field — no opening, no clearing (for read-only identity fields).
## API
`value: string | null | undefined`, `onChange(value: string | null)`, `placeholder = 'Select month'`, `className`, `allowClear = true`, `disabled = false`, `isMonthDisabled?(year, month) => boolean`. Default export.
## Demo
Seeded `'2026-08'` with `allowClear`, the applied value echoed below.
## 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, X } from 'lucide-react';
import { MonthGrid } from './MonthGrid';
import { businessToday, formatUtcMonthLabel, isFutureBusinessMonth, utcMonthStart } from '@/lib/dateUtils';
import { useDismiss } from '@/lib/use-dismiss';
interface MonthPickerProps {
// YYYY-MM, or null/undefined for no selection.
value: string | null | undefined;
onChange: (value: string | null) => void;
placeholder?: string;
className?: string;
// Show the "X" clear affordance / "Clear" footer action. Turn off when the
// consumer always needs a month selected (e.g. a required field).
allowClear?: boolean;
// Locks the field: the popup can't be opened and the value can't be cleared.
// For form fields that are part of a record's identity and therefore
// read-only while editing (see balances/BalanceForm.tsx).
disabled?: boolean;
// Which months are greyed out in the grid. Defaults to "no future months",
// the rule every filter on this app uses. A form can tighten it — e.g.
// balances also excludes the CURRENT month, since a month's closing balance
// isn't final until the month has ended.
isMonthDisabled?: (year: number, month: number) => boolean;
}
function parseMonthValue(value: string | null | undefined): { year: number; month: number } | null {
if (!value) return null;
const [y, m] = value.split('-').map(Number);
if (!y || !m) return null;
return { year: y, month: m - 1 };
}
function formatMonthValue(year: number, month: number): string {
return `${year}-${String(month + 1).padStart(2, '0')}`;
}
// The same formatter MonthRangePicker uses, fed a UTC-anchored month start so
// the two triggers cannot spell the same month differently.
function formatLabel(year: number, month: number): string {
return formatUtcMonthLabel(utcMonthStart(year, month));
}
export default function MonthPicker({
value,
onChange,
placeholder = 'Select month',
className = '',
allowClear = true,
disabled = false,
isMonthDisabled = isFutureBusinessMonth,
}: MonthPickerProps) {
const [isOpen, setIsOpen] = useState(false);
const parsed = parseMonthValue(value);
// The year currently browsed in the grid — independent of the applied
// value, so browsing to a different year doesn't select anything on its own.
// Defaults to the current GMT+8 year (the reporting timezone), not the
// browser's — see dateUtils' BUSINESS_TIMEZONE notes.
const [viewYear, setViewYear] = useState(parsed?.year ?? businessToday().year);
const popupRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (parsed) setViewYear(parsed.year);
}, [value]);
useDismiss(popupRef, isOpen, () => setIsOpen(false));
const handlePickMonth = (month: number) => {
if (isMonthDisabled(viewYear, month)) return;
onChange(formatMonthValue(viewYear, month));
setIsOpen(false);
};
const handleClear = () => {
onChange(null);
setIsOpen(false);
};
return (
<div className={`relative ${className}`} ref={popupRef}>
<button
type="button"
onClick={() => setIsOpen(!isOpen)}
disabled={disabled}
className={`field-input text-left ${disabled ? 'cursor-not-allowed opacity-60' : ''}`}
>
<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 ${!parsed ? 'text-slate-400 dark:text-slate-500' : ''}`}>
{parsed ? formatLabel(parsed.year, parsed.month) : placeholder}
</span>
</span>
<span className="flex items-center gap-1 flex-shrink-0">
{allowClear && parsed && !disabled && (
<X
className="h-3.5 w-3.5 text-slate-400 hover:text-slate-600 dark:hover:text-slate-300"
onClick={(e) => { e.stopPropagation(); onChange(null); }}
/>
)}
<ChevronDown className={`h-3.5 w-3.5 text-slate-400 transition-transform duration-200 ${isOpen ? 'rotate-180' : ''}`} />
</span>
</div>
</button>
{isOpen && !disabled && (
<div data-overlay="picker" className="absolute right-0 top-full z-50 mt-1 w-72 origin-top-right panel panel-solid p-3 animate-scale-in">
<MonthGrid
year={viewYear}
selectedYear={parsed?.year ?? -1}
selectedMonth={parsed?.month ?? -1}
onYearChange={setViewYear}
onPick={(_, m) => handlePickMonth(m)}
isMonthDisabled={isMonthDisabled}
/>
{allowClear && (
<div className="mt-3 pt-3 border-t border-slate-200 dark:border-slate-700 flex justify-end">
<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
</button>
</div>
)}
</div>
)}
</div>
);
}
Props
| Prop | Type | Default | Description |
|---|---|---|---|
value* | string | null | undefined | — | YYYY-MM, or null/undefined for no selection. |
onChange* | (value: string | null) => void | — | |
placeholder | string | 'Select month' | |
className | string | '' | |
allowClear | boolean | true | Show the "X" clear affordance / "Clear" footer action. Turn off when the consumer always needs a month selected (e.g. a required field). |
disabled | boolean | false | Locks the field: the popup can't be opened and the value can't be cleared. For form fields that are part of a record's identity and therefore read-only while editing (see balances/BalanceForm.tsx). |
isMonthDisabled | (year: number, month: number) => boolean | isFutureBusinessMonth | Which months are greyed out in the grid. Defaults to "no future months", the rule every filter on this app uses. A form can tighten it — e.g. balances also excludes the CURRENT month, since a month's closing balance isn't final until the month has ended. |