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
| Prop | Type | Default | Description |
|---|---|---|---|
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) => boolean | isFutureBusinessDay | Which days are greyed out. Defaults to "no future days", the rule every date field in this app uses. |
dayState | (day: Date) => DayState | () => NONE | Per-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. |
title | boolean | false | Show "July 2026" above the grid. Off when the caller has one header over several grids. |