From 8a248958f55403526b4a19311b80d200a33bebd0 Mon Sep 17 00:00:00 2001 From: Mats Bosson Date: Sun, 29 Mar 2026 21:15:19 +0700 Subject: [PATCH] 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. --- .../src/components/calendar/calendar-cell.tsx | 48 +++++++ .../components/calendar/calendar-context.ts | 38 +++++ .../calendar/calendar-grid-body.tsx | 47 ++++++ .../calendar/calendar-grid-head.tsx | 23 +++ .../src/components/calendar/calendar-grid.tsx | 13 ++ .../components/calendar/calendar-header.tsx | 13 ++ .../components/calendar/calendar-heading.tsx | 19 +++ .../src/components/calendar/calendar-nav.tsx | 52 +++++++ .../src/components/calendar/calendar-root.tsx | 129 +++++++++++++++++ .../src/components/calendar/calendar.props.ts | 38 +++++ .../core/src/components/calendar/index.ts | 11 ++ .../components/calendar/calendar.test.tsx | 134 ++++++++++++++++++ 12 files changed, 565 insertions(+) create mode 100644 packages/core/src/components/calendar/calendar-cell.tsx create mode 100644 packages/core/src/components/calendar/calendar-context.ts create mode 100644 packages/core/src/components/calendar/calendar-grid-body.tsx create mode 100644 packages/core/src/components/calendar/calendar-grid-head.tsx create mode 100644 packages/core/src/components/calendar/calendar-grid.tsx create mode 100644 packages/core/src/components/calendar/calendar-header.tsx create mode 100644 packages/core/src/components/calendar/calendar-heading.tsx create mode 100644 packages/core/src/components/calendar/calendar-nav.tsx create mode 100644 packages/core/src/components/calendar/calendar-root.tsx create mode 100644 packages/core/src/components/calendar/calendar.props.ts create mode 100644 packages/core/src/components/calendar/index.ts create mode 100644 packages/core/tests/components/calendar/calendar.test.tsx diff --git a/packages/core/src/components/calendar/calendar-cell.tsx b/packages/core/src/components/calendar/calendar-cell.tsx new file mode 100644 index 0000000..2a3464d --- /dev/null +++ b/packages/core/src/components/calendar/calendar-cell.tsx @@ -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 ( + + {local.children} + + ); +} diff --git a/packages/core/src/components/calendar/calendar-context.ts b/packages/core/src/components/calendar/calendar-context.ts new file mode 100644 index 0000000..7b0935a --- /dev/null +++ b/packages/core/src/components/calendar/calendar-context.ts @@ -0,0 +1,38 @@ +import type { Accessor } from "solid-js"; +import { createContext, useContext } from "solid-js"; + +export interface CalendarContextValue { + focusedDate: Accessor; + selectedDate: Accessor; + displayMonth: Accessor; + displayYear: Accessor; + locale: Accessor; + weekStartsOn: Accessor; + minDate: Accessor; + maxDate: Accessor; + disabled: Accessor; + 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(); + +/** 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 . Fix: Wrap your Calendar.Grid, Calendar.Cell, etc. inside .", + ); + } + return ctx; +} + +export const CalendarContextProvider = CalendarContext.Provider; diff --git a/packages/core/src/components/calendar/calendar-grid-body.tsx b/packages/core/src/components/calendar/calendar-grid-body.tsx new file mode 100644 index 0000000..d2eaa40 --- /dev/null +++ b/packages/core/src/components/calendar/calendar-grid-body.tsx @@ -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 ( + + + {(week) => ( + + + {(day) => ( + + {day.getDate()} + + )} + + + )} + + + ); +} diff --git a/packages/core/src/components/calendar/calendar-grid-head.tsx b/packages/core/src/components/calendar/calendar-grid-head.tsx new file mode 100644 index 0000000..7b08129 --- /dev/null +++ b/packages/core/src/components/calendar/calendar-grid-head.tsx @@ -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 ( + + + + {(name) => ( + + {name} + + )} + + + + ); +} diff --git a/packages/core/src/components/calendar/calendar-grid.tsx b/packages/core/src/components/calendar/calendar-grid.tsx new file mode 100644 index 0000000..2068fa7 --- /dev/null +++ b/packages/core/src/components/calendar/calendar-grid.tsx @@ -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 ( + + {local.children} +
+ ); +} diff --git a/packages/core/src/components/calendar/calendar-header.tsx b/packages/core/src/components/calendar/calendar-header.tsx new file mode 100644 index 0000000..8bfb2de --- /dev/null +++ b/packages/core/src/components/calendar/calendar-header.tsx @@ -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 ( +
+ {local.children} +
+ ); +} diff --git a/packages/core/src/components/calendar/calendar-heading.tsx b/packages/core/src/components/calendar/calendar-heading.tsx new file mode 100644 index 0000000..137d929 --- /dev/null +++ b/packages/core/src/components/calendar/calendar-heading.tsx @@ -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 ( +

+ {label()} +

+ ); +} diff --git a/packages/core/src/components/calendar/calendar-nav.tsx b/packages/core/src/components/calendar/calendar-nav.tsx new file mode 100644 index 0000000..7cd0071 --- /dev/null +++ b/packages/core/src/components/calendar/calendar-nav.tsx @@ -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 ( +
+ {local.children} +
+ ); +} + +/** 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 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 ( + + ); +} diff --git a/packages/core/src/components/calendar/calendar-root.tsx b/packages/core/src/components/calendar/calendar-root.tsx new file mode 100644 index 0000000..ced7744 --- /dev/null +++ b/packages/core/src/components/calendar/calendar-root.tsx @@ -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({ + 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(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 ( + + {props.children} + + ); +} diff --git a/packages/core/src/components/calendar/calendar.props.ts b/packages/core/src/components/calendar/calendar.props.ts new file mode 100644 index 0000000..4d18a81 --- /dev/null +++ b/packages/core/src/components/calendar/calendar.props.ts @@ -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 { + onValueChange?: (date: string) => void; + onMonthChange?: (month: number, year: number) => void; + children: JSX.Element; +} + +export interface CalendarHeaderProps extends JSX.HTMLAttributes { children: JSX.Element; } +export interface CalendarHeadingProps extends JSX.HTMLAttributes {} +export interface CalendarNavProps extends JSX.HTMLAttributes { children: JSX.Element; } +export interface CalendarPrevProps extends JSX.ButtonHTMLAttributes { children?: JSX.Element; } +export interface CalendarNextProps extends JSX.ButtonHTMLAttributes { children?: JSX.Element; } +export interface CalendarGridProps extends JSX.HTMLAttributes { children: JSX.Element; } +export interface CalendarGridHeadProps extends JSX.HTMLAttributes {} +export interface CalendarGridBodyProps extends JSX.HTMLAttributes {} +export interface CalendarCellProps extends JSX.HTMLAttributes { 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; diff --git a/packages/core/src/components/calendar/index.ts b/packages/core/src/components/calendar/index.ts new file mode 100644 index 0000000..fdbc707 --- /dev/null +++ b/packages/core/src/components/calendar/index.ts @@ -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 }); diff --git a/packages/core/tests/components/calendar/calendar.test.tsx b/packages/core/tests/components/calendar/calendar.test.tsx new file mode 100644 index 0000000..66ee537 --- /dev/null +++ b/packages/core/tests/components/calendar/calendar.test.tsx @@ -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 = {}) { + return render(() => ( + + + + + + + + + + + + + + )); +} + +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); + } + }); +});