v1.0

DayGrid

Preview

Basic

Loading…

Preview

Code

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

src/components/form/DayGrid.tsx

AI prompt

text
Build a bare month-of-days calendar grid with a weekday header (the building block for date pickers) in React + TypeScript + Tailwind CSS, using date-fns.

## Look
- Full width. Optional title "July 2026" (`MMMM yyyy`): `mb-3 text-center text-sm font-semibold text-slate-800 dark:text-slate-200`.
- Weekday row Su Mo Tu We Th Fr Sa: `mb-1 grid grid-cols-7 gap-1 text-center text-[10px] font-semibold uppercase tracking-wider text-slate-400 dark:text-slate-500`, each `py-1`.
- Days `grid grid-cols-7 gap-1`: whole weeks, Sunday first, from the week containing the 1st to the week containing the last day (5–6 rows). Each is a button `flex h-8 w-8 items-center justify-center justify-self-center rounded-full text-xs transition-all active:scale-90`:
  - in-month `text-slate-700 dark:text-slate-300`; padding days from neighbouring months `text-slate-300 dark:text-slate-600`;
  - disabled in-month days `opacity-40`; disabled or inert days `cursor-not-allowed`;
  - today (when not selected) `border border-indigo-500 font-semibold`;
  - selected `bg-indigo-600 font-semibold text-white`;
  - in range `bg-indigo-100 text-indigo-800 dark:bg-indigo-900/50 dark:text-indigo-200`;
  - plain enabled days `hover:bg-slate-100 dark:hover:bg-slate-700`.

## Behaviour
- Stateless: the owner says which days are selected / in range through `dayState(day)` and receives `onPick(day)` and, as the pointer crosses days, `onHover(day)` (for a range preview).
- `outsideDays`: `'muted'` (default) keeps padding days clickable, so last month's 30th is one click away; `'disabled'` makes them inert AND never highlighted — for a two-month range picker, where the same day is already clickable on the neighbouring grid and highlighting both reads as two selections.
- `isDayDisabled` defaults to "no future days". "Today" is taken in a fixed business timezone (UTC+8) rather than the browser's, compared by calendar date only. Days in and out are local-field `Date`s at midnight.
- Also export `WEEKDAY_LABELS` and `calendarDays(month)` (every day of the Sunday-first whole-week grid).

## API
`month: Date` (any day in it), `onPick(day)`, `onHover?(day)`, `isDayDisabled?(day) => boolean`, `dayState?(day) => { selected?: boolean; inRange?: boolean }`, `outsideDays: 'muted' | 'disabled' = 'muted'`, `title = false`. Named export `DayGrid`.

## Demo
In a `max-w-xs` box: July 2026 with its title, every day enabled, July 14 selected; clicking a day 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';

import { format, startOfMonth, endOfMonth, eachDayOfInterval, isSameDay, isSameMonth, startOfWeek, endOfWeek } from 'date-fns';
import { cn } from '@/lib/cn';
import { businessTodayAsLocalFields, isFutureBusinessDay } from '@/lib/dateUtils';

// Shared by DatePicker (one grid) and DateRangePicker (one grid per month) —
// a weekday header plus the 5–6 week grid of day buttons for one month. The
// sibling of MonthGrid, and for the same reason: the two pickers each carried
// their own copy of this grid, and they had already drifted (one showed a
// "today" ring, one did not; their cell hover colours differed by a shade).
//
// The grid is laid out in LOCAL date fields — date-fns' startOfWeek /
// eachDayOfInterval / isSameDay all read local fields — so a caller feeds it
// local-fielded Dates and gets local-fielded Dates back. See dateUtils'
// "bridge" section for how those relate to the business timezone.

export const WEEKDAY_LABELS = ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa'];

/** Every day on the whole-week grid that shows `month`, Sunday first. */
export function calendarDays(month: Date): Date[] {
  return eachDayOfInterval({
    start: startOfWeek(startOfMonth(month)),
    end: endOfWeek(endOfMonth(month)),
  });
}

export interface DayState {
  /** A picked day — the single value, or either end of a range. */
  selected?: boolean;
  /** A day strictly between the two ends of a range. */
  inRange?: boolean;
}

interface DayGridProps {
  /** Any day inside the month to lay out. */
  month: Date;
  onPick: (day: Date) => void;
  /** Fires as the pointer crosses a day. DateRangePicker previews the range with it. */
  onHover?: (day: Date) => void;
  /** Which days are greyed out. Defaults to "no future days", the rule every date field in this app uses. */
  isDayDisabled?: (day: Date) => boolean;
  /** Per-day highlight: the picked day(s) and the days between two ends. */
  dayState?: (day: Date) => DayState;
  /**
   * The days that pad the grid out to whole weeks. `muted` keeps them
   * clickable, so a day at the end of last month is one click away rather
   * than a month step and a click; `disabled` makes them inert, which a
   * two-month range picker wants because the same day is already clickable on
   * the neighbouring grid.
   */
  outsideDays?: 'muted' | 'disabled';
  /** Show "July 2026" above the grid. Off when the caller has one header over several grids. */
  title?: boolean;
}

const NONE: DayState = {};

export function DayGrid({
  month,
  onPick,
  onHover,
  isDayDisabled = isFutureBusinessDay,
  dayState = () => NONE,
  outsideDays = 'muted',
  title = false,
}: DayGridProps) {
  const today = businessTodayAsLocalFields();

  return (
    <div className="w-full">
      {title && (
        <h3 className="mb-3 text-center text-sm font-semibold text-slate-800 dark:text-slate-200">
          {format(month, 'MMMM yyyy')}
        </h3>
      )}

      <div className="mb-1 grid grid-cols-7 gap-1 text-center text-[10px] font-semibold uppercase tracking-wider text-slate-400 dark:text-slate-500">
        {WEEKDAY_LABELS.map((day) => (
          <div key={day} className="py-1">{day}</div>
        ))}
      </div>

      <div className="grid grid-cols-7 gap-1">
        {calendarDays(month).map((day) => {
          const outside = !isSameMonth(day, month);
          const inert = outside && outsideDays === 'disabled';
          const greyed = isDayDisabled(day);
          // An inert padding day is a duplicate of a clickable one on the
          // neighbouring grid; highlighting it too reads as two selections.
          const { selected, inRange } = inert ? NONE : dayState(day);
          const isToday = isSameDay(day, today);

          return (
            <button
              key={day.getTime()}
              type="button"
              onClick={() => onPick(day)}
              onMouseEnter={onHover && (() => onHover(day))}
              disabled={inert || greyed}
              className={cn(
                'flex h-8 w-8 items-center justify-center justify-self-center rounded-full text-xs transition-all active:scale-90',
                outside ? 'text-slate-300 dark:text-slate-600' : 'text-slate-700 dark:text-slate-300',
                // Greyed days inside the month fade; padding days are already faint.
                greyed && !outside && 'opacity-40',
                (inert || greyed) && 'cursor-not-allowed',
                isToday && !selected && 'border border-indigo-500 font-semibold',
                selected && 'bg-indigo-600 font-semibold text-white',
                inRange && 'bg-indigo-100 text-indigo-800 dark:bg-indigo-900/50 dark:text-indigo-200',
                !inert && !greyed && !selected && !inRange && 'hover:bg-slate-100 dark:hover:bg-slate-700',
              )}
            >
              {format(day, 'd')}
            </button>
          );
        })}
      </div>
    </div>
  );
}

Props

PropTypeDefaultDescription
month*Date—Any day inside the month to lay out.
onPick*(day: Date) => void—
onHover(day: Date) => void—Fires as the pointer crosses a day. DateRangePicker previews the range with it.
isDayDisabled(day: Date) => booleanisFutureBusinessDayWhich days are greyed out. Defaults to "no future days", the rule every date field in this app uses.
dayState(day: Date) => DayState() => NONEPer-day highlight: the picked day(s) and the days between two ends.
outsideDays'muted' | 'disabled''muted'The days that pad the grid out to whole weeks. `muted` keeps them clickable, so a day at the end of last month is one click away rather than a month step and a click; `disabled` makes them inert, which a two-month range picker wants because the same day is already clickable on the neighbouring grid.
titlebooleanfalseShow "July 2026" above the grid. Off when the caller has one header over several grids.