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:
parent
1106c2d020
commit
8a248958f5
48
packages/core/src/components/calendar/calendar-cell.tsx
Normal file
48
packages/core/src/components/calendar/calendar-cell.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
38
packages/core/src/components/calendar/calendar-context.ts
Normal file
38
packages/core/src/components/calendar/calendar-context.ts
Normal 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;
|
||||
47
packages/core/src/components/calendar/calendar-grid-body.tsx
Normal file
47
packages/core/src/components/calendar/calendar-grid-body.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
23
packages/core/src/components/calendar/calendar-grid-head.tsx
Normal file
23
packages/core/src/components/calendar/calendar-grid-head.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
13
packages/core/src/components/calendar/calendar-grid.tsx
Normal file
13
packages/core/src/components/calendar/calendar-grid.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
13
packages/core/src/components/calendar/calendar-header.tsx
Normal file
13
packages/core/src/components/calendar/calendar-header.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
19
packages/core/src/components/calendar/calendar-heading.tsx
Normal file
19
packages/core/src/components/calendar/calendar-heading.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
52
packages/core/src/components/calendar/calendar-nav.tsx
Normal file
52
packages/core/src/components/calendar/calendar-nav.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
129
packages/core/src/components/calendar/calendar-root.tsx
Normal file
129
packages/core/src/components/calendar/calendar-root.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
38
packages/core/src/components/calendar/calendar.props.ts
Normal file
38
packages/core/src/components/calendar/calendar.props.ts
Normal 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;
|
||||
11
packages/core/src/components/calendar/index.ts
Normal file
11
packages/core/src/components/calendar/index.ts
Normal 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 });
|
||||
134
packages/core/tests/components/calendar/calendar.test.tsx
Normal file
134
packages/core/tests/components/calendar/calendar.test.tsx
Normal 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user