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 (
+
+ );
+}
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);
+ }
+ });
+});