v1.0

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

PropTypeDefaultDescription
value*string | null | undefined—YYYY-MM, or null/undefined for no selection.
onChange*(value: string | null) => void—
placeholderstring'Select month'
classNamestring''
allowClearbooleantrueShow the "X" clear affordance / "Clear" footer action. Turn off when the consumer always needs a month selected (e.g. a required field).
disabledbooleanfalseLocks 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) => booleanisFutureBusinessMonthWhich 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.