v1.0

MonthGrid

Preview

Basic

Loading…

Preview

Code

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

src/components/form/MonthGrid.tsx

AI prompt

text
Build a bare 12-month grid with a year stepper (the building block for month pickers) in React + TypeScript + Tailwind CSS.

## Look
- Full width. Header `mb-3 flex items-center justify-between`: previous/next-year 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`, `transition-colors active:scale-90`, 16px ChevronLeft/Right — around the year in `text-sm font-semibold text-slate-800 dark:text-slate-100`. The next button is disabled at `year >= maxYear`: `disabled:opacity-30` with no hover change.
- Grid `grid grid-cols-3 gap-1.5` of the 12 short month names (en-US: Jan … Dec), each a button `h-8 text-xs rounded-lg transition-all active:scale-95`:
  - selected (the shown year equals `selectedYear` and the index equals `selectedMonth`): `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`.

## Behaviour
- Stateless and fully controlled: the BROWSED year (`year`) is separate from the selection, so stepping years never selects anything by itself — the owner decides. One click picks; no wheels or scrolling.
- `maxYear` defaults to the current year taken in a fixed business timezone (UTC+8), so the arrow unlocks the new year at the same moment for every viewer. Months are 0-indexed.
- Export the `MONTH_LABELS` array too.

## API
`year: number`, `selectedYear: number`, `selectedMonth: number` (pass -1 for none), `onYearChange(year)`, `onPick(year, month)`, `isMonthDisabled?(year, month) => boolean` (default none), `maxYear?`. Named export `MonthGrid`.

## Demo
In a `max-w-xs` box: browsing 2026 with August selected; clicking a month moves the selection.

## 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 { ChevronLeft, ChevronRight } from 'lucide-react';
import { businessToday } from '@/lib/dateUtils';

// Shared by MonthPicker (a single grid) and MonthRangePicker (one grid per
// From/To side) — a year header with prev/next arrows plus a 3x4 grid of
// month buttons. One click selects; no scrolling/wheels involved.
export const MONTH_LABELS = Array.from({ length: 12 }, (_, i) => new Date(2000, i, 1).toLocaleDateString('en-US', { month: 'short' }));

interface MonthGridProps {
  year: number;
  selectedYear: number;
  selectedMonth: number;
  onYearChange: (year: number) => void;
  onPick: (year: number, month: number) => void;
  // e.g. disable months later than today when browsing the current year.
  isMonthDisabled?: (year: number, month: number) => boolean;
  // Caps the "next year" arrow — defaults to the current GMT+8 year (no future
  // years). GMT+8 rather than the browser's zone so the arrow unlocks the new
  // year at the same moment for every viewer.
  maxYear?: number;
}

export function MonthGrid({
  year,
  selectedYear,
  selectedMonth,
  onYearChange,
  onPick,
  isMonthDisabled = () => false,
  maxYear = businessToday().year,
}: MonthGridProps) {
  return (
    <div className="w-full">
      <div className="flex items-center justify-between mb-3">
        <button
          type="button"
          onClick={() => onYearChange(year - 1)}
          aria-label="Previous year"
          className="p-1.5 rounded-lg text-slate-400 hover:bg-slate-100 hover:text-indigo-600 dark:hover:bg-slate-700 dark:hover:text-indigo-400 transition-colors active:scale-90"
        >
          <ChevronLeft className="h-4 w-4" />
        </button>
        <span className="text-sm font-semibold text-slate-800 dark:text-slate-100">{year}</span>
        <button
          type="button"
          onClick={() => onYearChange(year + 1)}
          disabled={year >= maxYear}
          aria-label="Next year"
          className="p-1.5 rounded-lg text-slate-400 hover:bg-slate-100 hover:text-indigo-600 dark:hover:bg-slate-700 dark:hover:text-indigo-400 disabled:opacity-30 disabled:hover:bg-transparent disabled:hover:text-slate-400 transition-colors active:scale-90"
        >
          <ChevronRight className="h-4 w-4" />
        </button>
      </div>
      <div className="grid grid-cols-3 gap-1.5">
        {MONTH_LABELS.map((label, i) => {
          const isSelected = selectedYear === year && selectedMonth === i;
          const disabled = isMonthDisabled(year, i);
          return (
            <button
              key={label}
              type="button"
              onClick={() => onPick(year, i)}
              disabled={disabled}
              className={`h-8 text-xs rounded-lg transition-all active:scale-95 ${
                isSelected
                  ? 'bg-indigo-600 text-white font-semibold'
                  : disabled
                  ? 'text-slate-300 dark:text-slate-600 cursor-not-allowed'
                  : 'text-slate-700 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-700'
              }`}
            >
              {label}
            </button>
          );
        })}
      </div>
    </div>
  );
}

Props

PropTypeDefaultDescription
year*number—
selectedYear*number—
selectedMonth*number—
onYearChange*(year: number) => void—
onPick*(year: number, month: number) => void—
isMonthDisabled(year: number, month: number) => boolean() => falsee.g. disable months later than today when browsing the current year.
maxYearnumberbusinessToday().yearCaps the "next year" arrow — defaults to the current GMT+8 year (no future years). GMT+8 rather than the browser's zone so the arrow unlocks the new year at the same moment for every viewer.