Calendar component

Compound component with Root, Header, Heading, Nav, PrevButton, NextButton, Grid, GridHead, GridBody, and Cell parts. Uses Intl.DateTimeFormat for locale-aware month/year heading and weekday names, min/max date constraints, and Enter/Space keyboard selection. 16 tests passing.
This commit is contained in:
Mats Bosson 2026-03-29 21:15:19 +07:00
parent 1106c2d020
commit 8a248958f5
12 changed files with 565 additions and 0 deletions

View File

@ -0,0 +1,48 @@
import type { JSX } from "solid-js";
import { splitProps } from "solid-js";
import { useCalendarContext } from "./calendar-context";
import type { CalendarCellProps } from "./calendar.props";
/** A single day cell in the calendar grid. Handles selection, disabled, today, and outside-month states. */
export function CalendarCell(props: CalendarCellProps): JSX.Element {
const [local, rest] = splitProps(props, ["date", "children"]);
const ctx = useCalendarContext();
const isOutsideMonth = () => {
const d = new Date(local.date + "T00:00:00");
return d.getMonth() !== ctx.displayMonth();
};
const handleClick = () => {
if (!ctx.isDateDisabled(local.date)) {
ctx.selectDate(local.date);
}
};
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
handleClick();
}
};
return (
<td
data-scope="calendar"
data-part="cell"
data-selected={ctx.isDateSelected(local.date) ? "" : undefined}
data-today={ctx.isDateToday(local.date) ? "" : undefined}
data-disabled={ctx.isDateDisabled(local.date) ? "" : undefined}
data-outside-month={isOutsideMonth() ? "" : undefined}
aria-selected={ctx.isDateSelected(local.date)}
aria-disabled={ctx.isDateDisabled(local.date)}
tabindex={ctx.isDateDisabled(local.date) ? -1 : 0}
role="gridcell"
onClick={handleClick}
onKeyDown={handleKeyDown}
{...rest}
>
{local.children}
</td>
);
}

View File

@ -0,0 +1,38 @@
import type { Accessor } from "solid-js";
import { createContext, useContext } from "solid-js";
export interface CalendarContextValue {
focusedDate: Accessor<Date>;
selectedDate: Accessor<string | undefined>;
displayMonth: Accessor<number>;
displayYear: Accessor<number>;
locale: Accessor<string>;
weekStartsOn: Accessor<number>;
minDate: Accessor<string | undefined>;
maxDate: Accessor<string | undefined>;
disabled: Accessor<boolean>;
selectDate: (date: string) => void;
focusDate: (date: Date) => void;
goToPrevMonth: () => void;
goToNextMonth: () => void;
isDateDisabled: (date: string) => boolean;
isDateSelected: (date: string) => boolean;
isDateToday: (date: string) => boolean;
getDaysInMonth: () => Date[];
getWeekDayNames: () => string[];
}
const CalendarContext = createContext<CalendarContextValue>();
/** Returns the Calendar context. Throws if used outside a Calendar root. */
export function useCalendarContext(): CalendarContextValue {
const ctx = useContext(CalendarContext);
if (!ctx) {
throw new Error(
"[PettyUI] Calendar parts must be used within <Calendar>. Fix: Wrap your Calendar.Grid, Calendar.Cell, etc. inside <Calendar>.",
);
}
return ctx;
}
export const CalendarContextProvider = CalendarContext.Provider;

View File

@ -0,0 +1,47 @@
import type { JSX } from "solid-js";
import { For } from "solid-js";
import { useCalendarContext } from "./calendar-context";
import { CalendarCell } from "./calendar-cell";
import type { CalendarGridBodyProps } from "./calendar.props";
/** Splits a flat array of dates into rows of 7 (one per week). */
function chunkWeeks(days: Date[]): Date[][] {
const weeks: Date[][] = [];
for (let i = 0; i < days.length; i += 7) {
weeks.push(days.slice(i, i + 7));
}
return weeks;
}
/** Formats a Date as YYYY-MM-DD for use as an ISO date key. */
function toISO(date: Date): string {
const y = date.getFullYear();
const m = String(date.getMonth() + 1).padStart(2, "0");
const d = String(date.getDate()).padStart(2, "0");
return `${y}-${m}-${d}`;
}
/** Renders the tbody of the calendar grid, one tr per week and one CalendarCell per day. */
export function CalendarGridBody(props: CalendarGridBodyProps): JSX.Element {
const ctx = useCalendarContext();
const weeks = () => chunkWeeks(ctx.getDaysInMonth());
return (
<tbody data-scope="calendar" data-part="grid-body" {...props}>
<For each={weeks()}>
{(week) => (
<tr>
<For each={week}>
{(day) => (
<CalendarCell date={toISO(day)}>
{day.getDate()}
</CalendarCell>
)}
</For>
</tr>
)}
</For>
</tbody>
);
}

View File

@ -0,0 +1,23 @@
import type { JSX } from "solid-js";
import { For } from "solid-js";
import { useCalendarContext } from "./calendar-context";
import type { CalendarGridHeadProps } from "./calendar.props";
/** Renders a thead row with localised weekday short names derived from the calendar locale. */
export function CalendarGridHead(props: CalendarGridHeadProps): JSX.Element {
const ctx = useCalendarContext();
return (
<thead data-scope="calendar" data-part="grid-head" {...props}>
<tr>
<For each={ctx.getWeekDayNames()}>
{(name) => (
<th scope="col" aria-label={name}>
{name}
</th>
)}
</For>
</tr>
</thead>
);
}

View File

@ -0,0 +1,13 @@
import type { JSX } from "solid-js";
import { splitProps } from "solid-js";
import type { CalendarGridProps } from "./calendar.props";
/** Table element with role="grid" wrapping the calendar head and body. */
export function CalendarGrid(props: CalendarGridProps): JSX.Element {
const [local, rest] = splitProps(props, ["children"]);
return (
<table data-scope="calendar" data-part="grid" role="grid" {...rest}>
{local.children}
</table>
);
}

View File

@ -0,0 +1,13 @@
import type { JSX } from "solid-js";
import { splitProps } from "solid-js";
import type { CalendarHeaderProps } from "./calendar.props";
/** Header section of the Calendar, typically containing the heading and navigation. */
export function CalendarHeader(props: CalendarHeaderProps): JSX.Element {
const [local, rest] = splitProps(props, ["children"]);
return (
<div data-scope="calendar" data-part="header" {...rest}>
{local.children}
</div>
);
}

View File

@ -0,0 +1,19 @@
import type { JSX } from "solid-js";
import { useCalendarContext } from "./calendar-context";
import type { CalendarHeadingProps } from "./calendar.props";
/** Displays the current month and year using Intl.DateTimeFormat with the calendar locale. */
export function CalendarHeading(props: CalendarHeadingProps): JSX.Element {
const ctx = useCalendarContext();
const label = () => {
const date = new Date(ctx.displayYear(), ctx.displayMonth(), 1);
return new Intl.DateTimeFormat(ctx.locale(), { month: "long", year: "numeric" }).format(date);
};
return (
<h2 data-scope="calendar" data-part="heading" aria-live="polite" {...props}>
{label()}
</h2>
);
}

View File

@ -0,0 +1,52 @@
import type { JSX } from "solid-js";
import { splitProps } from "solid-js";
import { useCalendarContext } from "./calendar-context";
import type { CalendarNavProps, CalendarPrevProps, CalendarNextProps } from "./calendar.props";
/** Container for the calendar's previous/next month navigation buttons. */
export function CalendarNav(props: CalendarNavProps): JSX.Element {
const [local, rest] = splitProps(props, ["children"]);
return (
<div data-scope="calendar" data-part="nav" {...rest}>
{local.children}
</div>
);
}
/** Button that navigates the calendar to the previous month. */
export function CalendarPrevButton(props: CalendarPrevProps): JSX.Element {
const [local, rest] = splitProps(props, ["children"]);
const ctx = useCalendarContext();
return (
<button
data-scope="calendar"
data-part="prev-button"
type="button"
aria-label="Go to previous month"
disabled={ctx.disabled()}
onClick={ctx.goToPrevMonth}
{...rest}
>
{local.children ?? "<"}
</button>
);
}
/** Button that navigates the calendar to the next month. */
export function CalendarNextButton(props: CalendarNextProps): JSX.Element {
const [local, rest] = splitProps(props, ["children"]);
const ctx = useCalendarContext();
return (
<button
data-scope="calendar"
data-part="next-button"
type="button"
aria-label="Go to next month"
disabled={ctx.disabled()}
onClick={ctx.goToNextMonth}
{...rest}
>
{local.children ?? ">"}
</button>
);
}

View File

@ -0,0 +1,129 @@
import { createSignal } from "solid-js";
import type { JSX } from "solid-js";
import { createControllableSignal } from "../../primitives/create-controllable-signal";
import { CalendarContextProvider } from "./calendar-context";
import type { CalendarRootProps } from "./calendar.props";
/** Converts an ISO date string to a Date at midnight local time. */
function parseISO(iso: string): Date {
const [y, m, d] = iso.split("-").map(Number);
return new Date(y, m - 1, d);
}
/** Formats a Date as YYYY-MM-DD. */
function toISO(date: Date): string {
const y = date.getFullYear();
const m = String(date.getMonth() + 1).padStart(2, "0");
const d = String(date.getDate()).padStart(2, "0");
return `${y}-${m}-${d}`;
}
/** Returns all Date objects to fill the calendar grid for a given month, padded to complete weeks. */
function buildMonthGrid(year: number, month: number, weekStartsOn: number): Date[] {
const firstDay = new Date(year, month, 1);
const lastDay = new Date(year, month + 1, 0);
const startOffset = (firstDay.getDay() - weekStartsOn + 7) % 7;
const endOffset = (6 - lastDay.getDay() + weekStartsOn) % 7;
const days: Date[] = [];
for (let i = startOffset; i > 0; i--) {
days.push(new Date(year, month, 1 - i));
}
for (let d = 1; d <= lastDay.getDate(); d++) {
days.push(new Date(year, month, d));
}
for (let i = 1; i <= endOffset; i++) {
days.push(new Date(year, month + 1, i));
}
return days;
}
/** Returns ordered weekday short names using Intl, starting from weekStartsOn. */
function buildWeekDayNames(locale: string, weekStartsOn: number): string[] {
const names: string[] = [];
for (let i = 0; i < 7; i++) {
const day = (weekStartsOn + i) % 7;
const date = new Date(2017, 0, day + 1);
names.push(new Intl.DateTimeFormat(locale, { weekday: "short" }).format(date));
}
return names;
}
/**
* Root component for Calendar. Manages selected date and display month/year state.
* Provides context to all Calendar sub-components. Renders no DOM elements.
*/
export function CalendarRoot(props: CalendarRootProps): JSX.Element {
const today = new Date();
const initialDate = props.defaultValue ? parseISO(props.defaultValue) : today;
const [selectedDate, setSelectedDate] = createControllableSignal<string | undefined>({
value: () => props.value,
defaultValue: () => props.defaultValue,
onChange: props.onValueChange,
});
const [displayMonth, setDisplayMonth] = createSignal(props.month ?? initialDate.getMonth());
const [displayYear, setDisplayYear] = createSignal(props.year ?? initialDate.getFullYear());
const [focusedDate, setFocusedDate] = createSignal<Date>(initialDate);
const locale = () => props.locale ?? "en-US";
const weekStartsOn = () => props.weekStartsOn ?? 0;
const isDateDisabled = (iso: string): boolean => {
if (props.disabled) return true;
if (props.minDate && iso < props.minDate) return true;
if (props.maxDate && iso > props.maxDate) return true;
return false;
};
const goToPrevMonth = () => {
const m = displayMonth();
const y = displayYear();
const newMonth = m === 0 ? 11 : m - 1;
const newYear = m === 0 ? y - 1 : y;
setDisplayMonth(newMonth);
setDisplayYear(newYear);
props.onMonthChange?.(newMonth, newYear);
};
const goToNextMonth = () => {
const m = displayMonth();
const y = displayYear();
const newMonth = m === 11 ? 0 : m + 1;
const newYear = m === 11 ? y + 1 : y;
setDisplayMonth(newMonth);
setDisplayYear(newYear);
props.onMonthChange?.(newMonth, newYear);
};
const selectDate = (iso: string) => {
if (!isDateDisabled(iso)) setSelectedDate(iso);
};
const ctx = {
focusedDate,
selectedDate,
displayMonth,
displayYear,
locale,
weekStartsOn,
minDate: () => props.minDate,
maxDate: () => props.maxDate,
disabled: () => props.disabled ?? false,
selectDate,
focusDate: setFocusedDate,
goToPrevMonth,
goToNextMonth,
isDateDisabled,
isDateSelected: (iso: string) => selectedDate() === iso,
isDateToday: (iso: string) => iso === toISO(today),
getDaysInMonth: () => buildMonthGrid(displayYear(), displayMonth(), weekStartsOn()),
getWeekDayNames: () => buildWeekDayNames(locale(), weekStartsOn()),
};
return (
<CalendarContextProvider value={ctx}>
{props.children}
</CalendarContextProvider>
);
}

View File

@ -0,0 +1,38 @@
import { z } from "zod/v4";
import type { JSX } from "solid-js";
import type { ComponentMeta } from "../../meta";
export const CalendarRootPropsSchema = z.object({
value: z.string().optional().describe("Controlled selected date as ISO string (YYYY-MM-DD)"),
defaultValue: z.string().optional().describe("Initial selected date (uncontrolled)"),
month: z.number().optional().describe("Controlled displayed month (0-11)"),
year: z.number().optional().describe("Controlled displayed year"),
minDate: z.string().optional().describe("Earliest selectable date as ISO string"),
maxDate: z.string().optional().describe("Latest selectable date as ISO string"),
disabled: z.boolean().optional().describe("Whether the calendar is disabled"),
locale: z.string().optional().describe("BCP 47 locale string for day/month names. Defaults to 'en-US'"),
weekStartsOn: z.number().optional().describe("Day the week starts on (0=Sunday, 1=Monday). Defaults to 0"),
});
export interface CalendarRootProps extends z.infer<typeof CalendarRootPropsSchema> {
onValueChange?: (date: string) => void;
onMonthChange?: (month: number, year: number) => void;
children: JSX.Element;
}
export interface CalendarHeaderProps extends JSX.HTMLAttributes<HTMLDivElement> { children: JSX.Element; }
export interface CalendarHeadingProps extends JSX.HTMLAttributes<HTMLHeadingElement> {}
export interface CalendarNavProps extends JSX.HTMLAttributes<HTMLDivElement> { children: JSX.Element; }
export interface CalendarPrevProps extends JSX.ButtonHTMLAttributes<HTMLButtonElement> { children?: JSX.Element; }
export interface CalendarNextProps extends JSX.ButtonHTMLAttributes<HTMLButtonElement> { children?: JSX.Element; }
export interface CalendarGridProps extends JSX.HTMLAttributes<HTMLTableElement> { children: JSX.Element; }
export interface CalendarGridHeadProps extends JSX.HTMLAttributes<HTMLTableSectionElement> {}
export interface CalendarGridBodyProps extends JSX.HTMLAttributes<HTMLTableSectionElement> {}
export interface CalendarCellProps extends JSX.HTMLAttributes<HTMLTableCellElement> { date: string; children?: JSX.Element; }
export const CalendarMeta: ComponentMeta = {
name: "Calendar",
description: "Month grid for date selection with keyboard navigation, locale support, and min/max constraints",
parts: ["Root", "Header", "Heading", "Nav", "PrevButton", "NextButton", "Grid", "GridHead", "GridBody", "Cell"] as const,
requiredParts: ["Root", "Grid", "GridBody", "Cell"] as const,
} as const;

View File

@ -0,0 +1,11 @@
import { CalendarRoot } from "./calendar-root";
import { CalendarHeader } from "./calendar-header";
import { CalendarHeading } from "./calendar-heading";
import { CalendarNav, CalendarPrevButton, CalendarNextButton } from "./calendar-nav";
import { CalendarGrid } from "./calendar-grid";
import { CalendarGridHead } from "./calendar-grid-head";
import { CalendarGridBody } from "./calendar-grid-body";
import { CalendarCell } from "./calendar-cell";
export { CalendarRootPropsSchema, CalendarMeta } from "./calendar.props";
export type { CalendarRootProps, CalendarHeaderProps, CalendarHeadingProps, CalendarNavProps, CalendarPrevProps, CalendarNextProps, CalendarGridProps, CalendarGridHeadProps, CalendarGridBodyProps, CalendarCellProps } from "./calendar.props";
export const Calendar = Object.assign(CalendarRoot, { Header: CalendarHeader, Heading: CalendarHeading, Nav: CalendarNav, PrevButton: CalendarPrevButton, NextButton: CalendarNextButton, Grid: CalendarGrid, GridHead: CalendarGridHead, GridBody: CalendarGridBody, Cell: CalendarCell });

View File

@ -0,0 +1,134 @@
import { fireEvent, render, screen } from "@solidjs/testing-library";
import { describe, expect, it, vi } from "vitest";
import { Calendar } from "../../../src/components/calendar/index";
import { CalendarRootPropsSchema, CalendarMeta } from "../../../src/components/calendar/index";
function renderCalendar(props: Record<string, unknown> = {}) {
return render(() => (
<Calendar year={2024} month={0} {...props}>
<Calendar.Header>
<Calendar.Heading />
<Calendar.Nav>
<Calendar.PrevButton />
<Calendar.NextButton />
</Calendar.Nav>
</Calendar.Header>
<Calendar.Grid>
<Calendar.GridHead />
<Calendar.GridBody />
</Calendar.Grid>
</Calendar>
));
}
describe("Calendar — rendering", () => {
it("renders month grid heading", () => {
renderCalendar();
expect(screen.getByText(/january/i)).toBeTruthy();
});
it("renders a table with role=grid", () => {
renderCalendar();
expect(screen.getByRole("grid")).toBeTruthy();
});
it("renders 7 weekday column headers", () => {
renderCalendar();
const cols = screen.getAllByRole("columnheader");
expect(cols.length).toBe(7);
});
it("renders day cells with role=gridcell", () => {
renderCalendar();
const cells = screen.getAllByRole("gridcell");
expect(cells.length).toBeGreaterThanOrEqual(28);
});
it("marks today cell with data-today attribute", () => {
renderCalendar();
const todayCells = document.querySelectorAll("[data-today]");
expect(todayCells.length).toBeGreaterThanOrEqual(0);
});
});
describe("Calendar — selection", () => {
it("calls onValueChange when a day cell is clicked", () => {
const onChange = vi.fn();
renderCalendar({ onValueChange: onChange });
const cells = screen.getAllByRole("gridcell");
const inMonth = Array.from(cells).find((c) => !c.hasAttribute("data-outside-month"));
if (inMonth) fireEvent.click(inMonth);
expect(onChange).toHaveBeenCalled();
});
it("marks selected cell with data-selected and aria-selected", () => {
renderCalendar({ defaultValue: "2024-01-15" });
const selected = document.querySelector("[data-selected]");
expect(selected).toBeTruthy();
expect(selected?.getAttribute("aria-selected")).toBe("true");
});
it("does not call onValueChange for disabled dates", () => {
const onChange = vi.fn();
renderCalendar({ onValueChange: onChange, minDate: "2024-01-20", maxDate: "2024-01-31" });
const cells = screen.getAllByRole("gridcell");
const disabled = Array.from(cells).find((c) => c.hasAttribute("data-disabled"));
if (disabled) fireEvent.click(disabled);
expect(onChange).not.toHaveBeenCalled();
});
it("Enter key selects a cell", () => {
const onChange = vi.fn();
renderCalendar({ onValueChange: onChange });
const cells = screen.getAllByRole("gridcell");
const inMonth = Array.from(cells).find((c) => !c.hasAttribute("data-outside-month"));
if (inMonth) fireEvent.keyDown(inMonth, { key: "Enter" });
expect(onChange).toHaveBeenCalled();
});
});
describe("Calendar — navigation", () => {
it("prev button navigates to previous month", () => {
renderCalendar();
expect(screen.getByText(/january/i)).toBeTruthy();
fireEvent.click(screen.getByLabelText("Go to previous month"));
expect(screen.getByText(/december/i)).toBeTruthy();
});
it("next button navigates to next month", () => {
renderCalendar();
fireEvent.click(screen.getByLabelText("Go to next month"));
expect(screen.getByText(/february/i)).toBeTruthy();
});
it("calls onMonthChange when navigating", () => {
const onMonthChange = vi.fn();
renderCalendar({ onMonthChange });
fireEvent.click(screen.getByLabelText("Go to next month"));
expect(onMonthChange).toHaveBeenCalledWith(1, 2024);
});
});
describe("Calendar — schema and meta", () => {
it("schema accepts valid ISO date strings", () => {
expect(CalendarRootPropsSchema.safeParse({ value: "2024-01-15" }).success).toBe(true);
});
it("schema rejects invalid minDate/maxDate types", () => {
expect(CalendarRootPropsSchema.safeParse({ minDate: 12345 }).success).toBe(false);
});
it("meta has required fields", () => {
expect(CalendarMeta.name).toBe("Calendar");
expect(CalendarMeta.parts).toContain("Root");
expect(CalendarMeta.parts).toContain("Grid");
expect(CalendarMeta.parts).toContain("Cell");
});
it("meta lists all expected parts", () => {
const required = ["Root", "Header", "Heading", "Nav", "Grid", "GridHead", "GridBody", "Cell"];
for (const part of required) {
expect(CalendarMeta.parts).toContain(part);
}
});
});