v1.0

DatePicker

Preview

Basic

Loading…

Preview

Code

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

src/components/form/DatePicker.tsx

AI prompt

text
Build a single-date picker (field trigger + calendar popover) component in React + TypeScript + Tailwind CSS, using date-fns.

## Look
- Trigger: a button with the house input recipe, `pr-8 text-left`: "August 15, 2026" (`MMMM d, yyyy`) or the placeholder in slate-400 (dark slate-500), truncating; a 14px lucide Calendar in slate-400 pinned at the right (`pr-3`). Disabled: `opacity-60 cursor-not-allowed` and it won't open.
- Popover: absolute `z-50 mt-1 w-full min-w-[16rem]`, opaque floating surface, `p-3`, 200ms fade-in.
- Header (`mb-3 flex items-center justify-between`): prev/next month 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; next disabled at `opacity-30` with no hover — around "August 2026" in `text-sm font-semibold text-slate-800 dark:text-slate-200`.
- Weekday row Su Mo Tu We Th Fr Sa: `grid grid-cols-7 gap-1 mb-1 text-center text-[10px] font-semibold uppercase tracking-wider text-slate-400 dark:text-slate-500`, each `py-1`.
- Day grid `grid-cols-7 gap-1`: whole weeks, Sunday first, from the week holding the 1st to the week holding the last day (5–6 rows). Each day is a 32px circle, `h-8 w-8 rounded-full text-xs justify-self-center transition-all active:scale-90`:
  - in-month slate-700 / dark slate-300; padding days from the neighbouring months slate-300 / dark slate-600 but still clickable;
  - disabled in-month days `opacity-40 cursor-not-allowed`;
  - today (when not selected) `border border-indigo-500 font-semibold`;
  - selected `bg-indigo-600 text-white font-semibold`;
  - otherwise `hover:bg-slate-100 dark:hover:bg-slate-700`.

## Behaviour
- Opens on the month of `value` (or the current month). Clicking an enabled day calls `onChange(day)` and closes; disabled days do nothing.
- `isDateDisabled` defaults to "no future days". The next-month arrow disables once the next month is wholly in the future — no browsing into future months at all, not just greyed days.
- "Today" is taken in a fixed business timezone (UTC+8, Asia/Singapore) rather than the browser's, so every viewer agrees which day is today; compare calendar dates only, never clock time, so today itself is never disabled. Days are local-field `Date`s at midnight.

## API
`value: Date | null`, `onChange(date: Date | null)`, `placeholder = 'Select a date'`, `className`, `disabled = false`, `isDateDisabled?(date) => boolean`. Named and default export.

## Demo
A "Pick a date" field seeded with Aug 15, 2026.

## 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 } from 'react';
import { format, isSameDay, addMonths, subMonths } from 'date-fns';
import { Calendar as CalendarIcon, ChevronLeft, ChevronRight } from 'lucide-react';
import { DayGrid } from './DayGrid';
import { isFutureBusinessDay, isFutureBusinessMonth } from '@/lib/dateUtils';
import { useDismiss } from '@/lib/use-dismiss';

interface DatePickerProps {
  value: Date | null;
  onChange: (date: Date | null) => void;
  placeholder?: string;
  className?: string;
  disabled?: boolean;
  // Which days are greyed out in the grid. Defaults to "no future days", the
  // rule every date field in this app uses.
  isDateDisabled?: (date: Date) => boolean;
}

export function DatePicker({
  value,
  onChange,
  placeholder = 'Select a date',
  className = '',
  disabled = false,
  isDateDisabled = isFutureBusinessDay,
}: DatePickerProps) {
  const [isOpen, setIsOpen] = useState(false);
  const [displayMonth, setDisplayMonth] = useState(value || new Date());
  const popupRef = useRef<HTMLDivElement>(null);
  // Caps the "next month" arrow at the current GMT+8 month — no browsing into a
  // future month at all, not just greying out its individual days.
  const nextMonthDate = addMonths(displayMonth, 1);
  const isNextMonthDisabled = isFutureBusinessMonth(nextMonthDate.getFullYear(), nextMonthDate.getMonth());

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

  const handleDateSelect = (day: Date) => {
    if (isDateDisabled(day)) return;
    onChange(day);
    setIsOpen(false);
  };

  const nextMonth = () => setDisplayMonth(nextMonthDate);
  const prevMonth = () => setDisplayMonth(subMonths(displayMonth, 1));

  return (
    <div className={`relative ${className}`} ref={popupRef}>
      <button
        type="button"
        onClick={() => !disabled && setIsOpen(!isOpen)}
        className={`relative field-input pr-8 text-left ${disabled ? 'cursor-not-allowed opacity-60' : ''}`}
        disabled={disabled}
      >
        <span className={`block truncate ${value ? '' : 'text-slate-400 dark:text-slate-500'}`}>{value ? format(value, 'MMMM d, yyyy') : placeholder}</span>
        <span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-3">
          <CalendarIcon className="h-3.5 w-3.5 text-slate-400" aria-hidden="true" />
        </span>
      </button>

      {isOpen && (
        <div data-overlay="picker" className="absolute z-50 mt-1 w-full min-w-[16rem] panel panel-solid p-3 animate-fade-in">
          <div className="flex items-center justify-between mb-3">
            <button
              type="button"
              onClick={prevMonth}
              aria-label="Previous month"
              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>
            <div className="text-sm font-semibold text-slate-800 dark:text-slate-200">
              {format(displayMonth, 'MMMM yyyy')}
            </div>
            <button
              type="button"
              onClick={nextMonth}
              disabled={isNextMonthDisabled}
              aria-label="Next month"
              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>

          <DayGrid
            month={displayMonth}
            onPick={handleDateSelect}
            isDayDisabled={isDateDisabled}
            dayState={(day) => ({ selected: value ? isSameDay(day, value) : false })}
          />
        </div>
      )}
    </div>
  );
}

export default DatePicker;

Props

PropTypeDefaultDescription
value*Date | null—
onChange*(date: Date | null) => void—
placeholderstring'Select a date'
classNamestring''
disabledbooleanfalse
isDateDisabled(date: Date) => booleanisFutureBusinessDayWhich days are greyed out in the grid. Defaults to "no future days", the rule every date field in this app uses.