From 3df99c6a0d6d72047abada7e0b1a3a5a12ba8fad Mon Sep 17 00:00:00 2001 From: songsang Date: Thu, 12 Mar 2026 00:48:15 +0900 Subject: [PATCH 1/7] feat(calendar): add selection models and date matching utilities Add Selection type definitions (Matcher, DateRange, CalendarBodyCell, SelectionCellProps, RangeSelectionCellProps) and matchDate utility for flexible date matching patterns. Unify DateCell and CalendarBodyCell by making CalendarBodyCell extend DateCell, establishing an explicit type contract between calendar core and selection layers. --- packages/calendar/src/models/Calendar.ts | 6 +- packages/calendar/src/models/Selection.ts | 103 ++++++++++++++++++++++ packages/calendar/src/models/index.ts | 1 + packages/calendar/src/utils/matchDate.ts | 71 +++++++++++++++ 4 files changed, 178 insertions(+), 3 deletions(-) create mode 100644 packages/calendar/src/models/Selection.ts create mode 100644 packages/calendar/src/utils/matchDate.ts diff --git a/packages/calendar/src/models/Calendar.ts b/packages/calendar/src/models/Calendar.ts index 652ffe79..5a11b26f 100644 --- a/packages/calendar/src/models/Calendar.ts +++ b/packages/calendar/src/models/Calendar.ts @@ -1,11 +1,11 @@ -export interface DateCell extends Record { +export interface DateCell { value: Date; } -export interface WeekRow extends Record { +export interface WeekRow { value: DateCell[]; } -export interface MonthMatrix extends Record { +export interface MonthMatrix { value: WeekRow[]; } diff --git a/packages/calendar/src/models/Selection.ts b/packages/calendar/src/models/Selection.ts new file mode 100644 index 00000000..7ab2d03d --- /dev/null +++ b/packages/calendar/src/models/Selection.ts @@ -0,0 +1,103 @@ +import type { DateCell } from "./Calendar"; + +export type Matcher = + | Date + | Date[] + | { before: Date } + | { after: Date } + | { from: Date; to: Date } + | { dayOfWeek: number[] } + | ((date: Date) => boolean); + +export interface DateRange { + from: Date; + to?: Date; +} + +// ─── Calendar Body Types ──────────────────────────────── + +export interface CalendarBodyCell extends DateCell { + key: string; +} + +export interface CalendarBodyWeek { + key: string; + value: C[]; +} + +export interface CalendarBody { + value: CalendarBodyWeek[]; +} + +export interface SelectionCellProps { + isSelected: boolean; + isDisabled: boolean; +} + +export interface RangeSelectionCellProps extends SelectionCellProps { + isRangeStart: boolean; + isRangeEnd: boolean; + isInRange: boolean; +} + +// ─── Options ──────────────────────────────────────────── + +export interface SingleSelectionOptions { + mode: "single"; + required?: boolean; + disabled?: Matcher | Matcher[]; + body: CalendarBody; +} + +export interface RangeSelectionOptions { + mode: "range"; + min?: number; + max?: number; + disabled?: Matcher | Matcher[]; + body: CalendarBody; +} + +export interface MultipleSelectionOptions { + mode: "multiple"; + min?: number; + max?: number; + disabled?: Matcher | Matcher[]; + body: CalendarBody; +} + +export type SelectionOptions = + | SingleSelectionOptions + | RangeSelectionOptions + | MultipleSelectionOptions; + +// ─── Return Types ─────────────────────────────────────── + +export interface SingleSelectionReturn { + selected: Date | undefined; + select: (date: Date) => void; + deselect: () => void; + isSelected: (date: Date) => boolean; + isDisabled: (date: Date) => boolean; + body: CalendarBody; +} + +export interface RangeSelectionReturn { + selected: DateRange | undefined; + select: (date: Date) => void; + deselect: () => void; + isSelected: (date: Date) => boolean; + isDisabled: (date: Date) => boolean; + isRangeStart: (date: Date) => boolean; + isRangeEnd: (date: Date) => boolean; + isInRange: (date: Date) => boolean; + body: CalendarBody; +} + +export interface MultipleSelectionReturn { + selected: Date[]; + select: (date: Date) => void; + deselect: (date: Date) => void; + isSelected: (date: Date) => boolean; + isDisabled: (date: Date) => boolean; + body: CalendarBody; +} diff --git a/packages/calendar/src/models/index.ts b/packages/calendar/src/models/index.ts index 25decb11..9c553fc5 100644 --- a/packages/calendar/src/models/index.ts +++ b/packages/calendar/src/models/index.ts @@ -1,3 +1,4 @@ export * from "./Calendar"; export { default as CalendarViewType } from "./CalendarViewType"; +export * from "./Selection"; export type { default as WeekDayType } from "./WeekDayType"; diff --git a/packages/calendar/src/utils/matchDate.ts b/packages/calendar/src/utils/matchDate.ts new file mode 100644 index 00000000..6c0b338d --- /dev/null +++ b/packages/calendar/src/utils/matchDate.ts @@ -0,0 +1,71 @@ +import type { Matcher } from "../models/Selection"; +import isSameDate from "./isSameDate"; + +function isBeforeMatcher(m: Matcher): m is { before: Date } { + return typeof m === "object" && !Array.isArray(m) && "before" in m; +} + +function isAfterMatcher(m: Matcher): m is { after: Date } { + return typeof m === "object" && !Array.isArray(m) && "after" in m; +} + +function isFromToMatcher(m: Matcher): m is { from: Date; to: Date } { + return typeof m === "object" && !Array.isArray(m) && "from" in m && "to" in m; +} + +function isDayOfWeekMatcher(m: Matcher): m is { dayOfWeek: number[] } { + return typeof m === "object" && !Array.isArray(m) && "dayOfWeek" in m; +} + +export function matchDate(date: Date, matcher: Matcher): boolean { + if (typeof matcher === "function") { + return matcher(date); + } + + if (matcher instanceof Date) { + return isSameDate(date, matcher); + } + + if (Array.isArray(matcher)) { + return matcher.some((d) => isSameDate(date, d)); + } + + if (isBeforeMatcher(matcher)) { + return date < matcher.before; + } + + if (isAfterMatcher(matcher)) { + return date > matcher.after; + } + + if (isFromToMatcher(matcher)) { + return date >= matcher.from && date <= matcher.to; + } + + if (isDayOfWeekMatcher(matcher)) { + return matcher.dayOfWeek.includes(date.getDay()); + } + + return false; +} + +export function matchDateArray( + date: Date, + matchers: Matcher | Matcher[] | undefined, +): boolean { + if (matchers === undefined) { + return false; + } + + if (!Array.isArray(matchers)) { + return matchDate(date, matchers); + } + + // Date[] (all elements are Date instances) → single Matcher + if (matchers.every((m) => m instanceof Date)) { + return matchDate(date, matchers as Date[]); + } + + // Matcher[] → check each matcher individually + return (matchers as Matcher[]).some((m) => matchDate(date, m)); +} From 615b45a0c8a5505f970d15c0ca51052e6bde81e1 Mon Sep 17 00:00:00 2001 From: songsang Date: Thu, 12 Mar 2026 00:48:23 +0900 Subject: [PATCH 2/7] feat(calendar): add useSelection hook for date selection Implement useSelection hook supporting three selection modes: - single: select one date with optional required constraint - range: select date range with min/max day constraints - multiple: toggle multiple dates with min/max count constraints Each mode enriches the calendar body cells with selection state (isSelected, isDisabled, isRangeStart, isRangeEnd, isInRange), enabling headless rendering of selection UI. Internally split into useSingleSelection, useRangeSelection, and useMultipleSelection to comply with Rules of Hooks while exposing a single useSelection entry point. --- packages/calendar/src/index.ts | 1 + packages/calendar/src/useSelection.test.ts | 599 +++++++++++++++++++++ packages/calendar/src/useSelection.ts | 288 ++++++++++ 3 files changed, 888 insertions(+) create mode 100644 packages/calendar/src/useSelection.test.ts create mode 100644 packages/calendar/src/useSelection.ts diff --git a/packages/calendar/src/index.ts b/packages/calendar/src/index.ts index 600da1a2..a8e52c2f 100644 --- a/packages/calendar/src/index.ts +++ b/packages/calendar/src/index.ts @@ -1,4 +1,5 @@ export * from "./models"; export * from "./plugins"; export * from "./useCalendar"; +export { useSelection } from "./useSelection"; export * from "./utils"; diff --git a/packages/calendar/src/useSelection.test.ts b/packages/calendar/src/useSelection.test.ts new file mode 100644 index 00000000..ae247fcc --- /dev/null +++ b/packages/calendar/src/useSelection.test.ts @@ -0,0 +1,599 @@ +import { act, renderHook } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; + +import type { CalendarBody } from "./models/Selection"; +import { useSelection } from "./useSelection"; +import { matchDate, matchDateArray } from "./utils/matchDate"; + +const emptyBody: CalendarBody = { value: [] }; + +function createMockBody(dates: Date[][]): CalendarBody { + return { + value: dates.map((week, wi) => ({ + key: `week-${wi}`, + value: week.map((date) => ({ + value: date, + key: date.toISOString(), + date: date.getDate(), + isCurrentMonth: true, + isCurrentDate: false, + isWeekend: date.getDay() === 0 || date.getDay() === 6, + })), + })), + }; +} + +describe("matchDate", () => { + it("matches exact Date", () => { + const date = new Date(2024, 0, 15); + expect(matchDate(date, new Date(2024, 0, 15))).toBe(true); + expect(matchDate(date, new Date(2024, 0, 16))).toBe(false); + }); + + it("matches Date[]", () => { + const date = new Date(2024, 0, 15); + const dates = [new Date(2024, 0, 10), new Date(2024, 0, 15)]; + expect(matchDate(date, dates)).toBe(true); + expect(matchDate(new Date(2024, 0, 1), dates)).toBe(false); + }); + + it("matches { before }", () => { + const jan10 = new Date(2024, 0, 10); + expect(matchDate(new Date(2024, 0, 5), { before: jan10 })).toBe(true); + expect(matchDate(new Date(2024, 0, 15), { before: jan10 })).toBe(false); + }); + + it("matches { after }", () => { + const jan10 = new Date(2024, 0, 10); + expect(matchDate(new Date(2024, 0, 15), { after: jan10 })).toBe(true); + expect(matchDate(new Date(2024, 0, 5), { after: jan10 })).toBe(false); + }); + + it("matches { from, to }", () => { + const range = { from: new Date(2024, 0, 10), to: new Date(2024, 0, 20) }; + expect(matchDate(new Date(2024, 0, 15), range)).toBe(true); + expect(matchDate(new Date(2024, 0, 10), range)).toBe(true); + expect(matchDate(new Date(2024, 0, 20), range)).toBe(true); + expect(matchDate(new Date(2024, 0, 5), range)).toBe(false); + }); + + it("matches { dayOfWeek }", () => { + // 2024-01-15 is Monday (1) + const monday = new Date(2024, 0, 15); + expect(matchDate(monday, { dayOfWeek: [1, 3, 5] })).toBe(true); + expect(matchDate(monday, { dayOfWeek: [0, 6] })).toBe(false); + }); + + it("matches function matcher", () => { + const isWeekend = (d: Date) => d.getDay() === 0 || d.getDay() === 6; + // 2024-01-14 is Sunday + expect(matchDate(new Date(2024, 0, 14), isWeekend)).toBe(true); + // 2024-01-15 is Monday + expect(matchDate(new Date(2024, 0, 15), isWeekend)).toBe(false); + }); +}); + +describe("matchDateArray", () => { + it("returns false for undefined", () => { + expect(matchDateArray(new Date(), undefined)).toBe(false); + }); + + it("handles single Matcher", () => { + expect(matchDateArray(new Date(2024, 0, 5), { before: new Date(2024, 0, 10) })).toBe(true); + }); + + it("handles Matcher[]", () => { + const matchers = [ + { before: new Date(2024, 0, 5) }, + { after: new Date(2024, 0, 20) }, + ]; + expect(matchDateArray(new Date(2024, 0, 3), matchers)).toBe(true); + expect(matchDateArray(new Date(2024, 0, 25), matchers)).toBe(true); + expect(matchDateArray(new Date(2024, 0, 10), matchers)).toBe(false); + }); +}); + +describe("useSelection - single mode", () => { + it("starts with undefined selected", () => { + // Given + const { result } = renderHook(() => useSelection({ mode: "single", body: emptyBody })); + // Then + expect(result.current.selected).toBeUndefined(); + }); + + it("selects a date", () => { + // Given + const date = new Date(2024, 0, 15); + const { result } = renderHook(() => useSelection({ mode: "single", body: emptyBody })); + // When + act(() => { + result.current.select(date); + }); + // Then + expect(result.current.selected).toEqual(date); + expect(result.current.isSelected(date)).toBe(true); + }); + + it("replaces selection when selecting a different date", () => { + // Given + const date1 = new Date(2024, 0, 15); + const date2 = new Date(2024, 0, 20); + const { result } = renderHook(() => useSelection({ mode: "single", body: emptyBody })); + // When + act(() => { + result.current.select(date1); + }); + act(() => { + result.current.select(date2); + }); + // Then + expect(result.current.selected).toEqual(date2); + expect(result.current.isSelected(date1)).toBe(false); + expect(result.current.isSelected(date2)).toBe(true); + }); + + it("toggles off when selecting the same date", () => { + // Given + const date = new Date(2024, 0, 15); + const { result } = renderHook(() => useSelection({ mode: "single", body: emptyBody })); + // When + act(() => { + result.current.select(date); + }); + act(() => { + result.current.select(new Date(2024, 0, 15)); + }); + // Then + expect(result.current.selected).toBeUndefined(); + }); + + it("does not toggle off when required is true", () => { + // Given + const date = new Date(2024, 0, 15); + const { result } = renderHook(() => + useSelection({ mode: "single", required: true, body: emptyBody }), + ); + // When + act(() => { + result.current.select(date); + }); + act(() => { + result.current.select(new Date(2024, 0, 15)); + }); + // Then + expect(result.current.selected).toEqual(date); + }); + + it("ignores disabled dates", () => { + // Given + const disabled = new Date(2024, 0, 15); + const { result } = renderHook(() => + useSelection({ mode: "single", disabled, body: emptyBody }), + ); + // When + act(() => { + result.current.select(new Date(2024, 0, 15)); + }); + // Then + expect(result.current.selected).toBeUndefined(); + expect(result.current.isDisabled(new Date(2024, 0, 15))).toBe(true); + }); + + it("deselects via deselect()", () => { + // Given + const { result } = renderHook(() => useSelection({ mode: "single", body: emptyBody })); + act(() => { + result.current.select(new Date(2024, 0, 15)); + }); + // When + act(() => { + result.current.deselect(); + }); + // Then + expect(result.current.selected).toBeUndefined(); + }); +}); + +describe("useSelection - range mode", () => { + it("starts with undefined selected", () => { + // Given + const { result } = renderHook(() => useSelection({ mode: "range", body: emptyBody })); + // Then + expect(result.current.selected).toBeUndefined(); + }); + + it("sets from on first click", () => { + // Given + const date = new Date(2024, 0, 10); + const { result } = renderHook(() => useSelection({ mode: "range", body: emptyBody })); + // When + act(() => { + result.current.select(date); + }); + // Then + expect(result.current.selected).toEqual({ from: date }); + expect(result.current.isRangeStart(date)).toBe(true); + }); + + it("completes range on second click", () => { + // Given + const from = new Date(2024, 0, 10); + const to = new Date(2024, 0, 20); + const { result } = renderHook(() => useSelection({ mode: "range", body: emptyBody })); + // When + act(() => { + result.current.select(from); + }); + act(() => { + result.current.select(to); + }); + // Then + expect(result.current.selected).toEqual({ from, to }); + expect(result.current.isRangeStart(from)).toBe(true); + expect(result.current.isRangeEnd(to)).toBe(true); + }); + + it("swaps from/to when from > to", () => { + // Given + const earlier = new Date(2024, 0, 10); + const later = new Date(2024, 0, 20); + const { result } = renderHook(() => useSelection({ mode: "range", body: emptyBody })); + // When — select later first, then earlier + act(() => { + result.current.select(later); + }); + act(() => { + result.current.select(earlier); + }); + // Then + expect(result.current.selected).toEqual({ from: earlier, to: later }); + }); + + it("isInRange returns true for dates between from and to", () => { + // Given + const from = new Date(2024, 0, 10); + const to = new Date(2024, 0, 20); + const middle = new Date(2024, 0, 15); + const { result } = renderHook(() => useSelection({ mode: "range", body: emptyBody })); + // When + act(() => { + result.current.select(from); + }); + act(() => { + result.current.select(to); + }); + // Then + expect(result.current.isInRange(middle)).toBe(true); + expect(result.current.isInRange(from)).toBe(false); + expect(result.current.isInRange(to)).toBe(false); + expect(result.current.isInRange(new Date(2024, 0, 5))).toBe(false); + }); + + it("isSelected includes from, to, and in-range dates", () => { + // Given + const from = new Date(2024, 0, 10); + const to = new Date(2024, 0, 20); + const { result } = renderHook(() => useSelection({ mode: "range", body: emptyBody })); + // When + act(() => { + result.current.select(from); + }); + act(() => { + result.current.select(to); + }); + // Then + expect(result.current.isSelected(from)).toBe(true); + expect(result.current.isSelected(to)).toBe(true); + expect(result.current.isSelected(new Date(2024, 0, 15))).toBe(true); + expect(result.current.isSelected(new Date(2024, 0, 5))).toBe(false); + }); + + it("respects min constraint", () => { + // Given — min 5 days + const from = new Date(2024, 0, 10); + const tooClose = new Date(2024, 0, 12); // 3 days + const { result } = renderHook(() => + useSelection({ mode: "range", min: 5, body: emptyBody }), + ); + // When + act(() => { + result.current.select(from); + }); + act(() => { + result.current.select(tooClose); + }); + // Then — range stays incomplete + expect(result.current.selected).toEqual({ from }); + }); + + it("respects max constraint", () => { + // Given — max 5 days + const from = new Date(2024, 0, 10); + const tooFar = new Date(2024, 0, 20); // 11 days + const { result } = renderHook(() => + useSelection({ mode: "range", max: 5, body: emptyBody }), + ); + // When + act(() => { + result.current.select(from); + }); + act(() => { + result.current.select(tooFar); + }); + // Then — range stays incomplete + expect(result.current.selected).toEqual({ from }); + }); + + it("ignores disabled dates", () => { + // Given + const disabled = new Date(2024, 0, 15); + const { result } = renderHook(() => + useSelection({ mode: "range", disabled, body: emptyBody }), + ); + // When + act(() => { + result.current.select(new Date(2024, 0, 15)); + }); + // Then + expect(result.current.selected).toBeUndefined(); + }); + + it("starts new range after completing one", () => { + // Given + const { result } = renderHook(() => useSelection({ mode: "range", body: emptyBody })); + act(() => { + result.current.select(new Date(2024, 0, 10)); + }); + act(() => { + result.current.select(new Date(2024, 0, 20)); + }); + // When — third click starts new range + const newFrom = new Date(2024, 1, 1); + act(() => { + result.current.select(newFrom); + }); + // Then + expect(result.current.selected).toEqual({ from: newFrom }); + }); + + it("deselects via deselect()", () => { + // Given + const { result } = renderHook(() => useSelection({ mode: "range", body: emptyBody })); + act(() => { + result.current.select(new Date(2024, 0, 10)); + }); + // When + act(() => { + result.current.deselect(); + }); + // Then + expect(result.current.selected).toBeUndefined(); + }); +}); + +describe("useSelection - multiple mode", () => { + it("starts with empty array", () => { + // Given + const { result } = renderHook(() => useSelection({ mode: "multiple", body: emptyBody })); + // Then + expect(result.current.selected).toEqual([]); + }); + + it("adds dates to selection", () => { + // Given + const date1 = new Date(2024, 0, 10); + const date2 = new Date(2024, 0, 15); + const { result } = renderHook(() => useSelection({ mode: "multiple", body: emptyBody })); + // When + act(() => { + result.current.select(date1); + }); + act(() => { + result.current.select(date2); + }); + // Then + expect(result.current.selected).toEqual([date1, date2]); + expect(result.current.isSelected(date1)).toBe(true); + expect(result.current.isSelected(date2)).toBe(true); + }); + + it("toggles off already selected date via select()", () => { + // Given + const date = new Date(2024, 0, 10); + const { result } = renderHook(() => useSelection({ mode: "multiple", body: emptyBody })); + act(() => { + result.current.select(date); + }); + // When + act(() => { + result.current.select(new Date(2024, 0, 10)); + }); + // Then + expect(result.current.selected).toEqual([]); + }); + + it("respects max constraint", () => { + // Given — max 2 + const { result } = renderHook(() => + useSelection({ mode: "multiple", max: 2, body: emptyBody }), + ); + act(() => { + result.current.select(new Date(2024, 0, 10)); + }); + act(() => { + result.current.select(new Date(2024, 0, 15)); + }); + // When — try to add third + act(() => { + result.current.select(new Date(2024, 0, 20)); + }); + // Then + expect(result.current.selected).toHaveLength(2); + }); + + it("respects min constraint on deselect", () => { + // Given — min 1 + const date = new Date(2024, 0, 10); + const { result } = renderHook(() => + useSelection({ mode: "multiple", min: 1, body: emptyBody }), + ); + act(() => { + result.current.select(date); + }); + // When — try to deselect the only item + act(() => { + result.current.deselect(date); + }); + // Then + expect(result.current.selected).toHaveLength(1); + }); + + it("ignores disabled dates", () => { + // Given + const disabled = new Date(2024, 0, 15); + const { result } = renderHook(() => + useSelection({ mode: "multiple", disabled, body: emptyBody }), + ); + // When + act(() => { + result.current.select(new Date(2024, 0, 15)); + }); + // Then + expect(result.current.selected).toEqual([]); + expect(result.current.isDisabled(new Date(2024, 0, 15))).toBe(true); + }); + + it("deselects specific date via deselect()", () => { + // Given + const date1 = new Date(2024, 0, 10); + const date2 = new Date(2024, 0, 15); + const { result } = renderHook(() => useSelection({ mode: "multiple", body: emptyBody })); + act(() => { + result.current.select(date1); + }); + act(() => { + result.current.select(date2); + }); + // When + act(() => { + result.current.deselect(date1); + }); + // Then + expect(result.current.selected).toEqual([date2]); + }); +}); + +describe("useSelection - body enrichment", () => { + const jan15 = new Date(2024, 0, 15); // Monday + const jan16 = new Date(2024, 0, 16); // Tuesday + const jan17 = new Date(2024, 0, 17); // Wednesday + const jan18 = new Date(2024, 0, 18); // Thursday + const jan14 = new Date(2024, 0, 14); // Sunday + const mockBody = createMockBody([[jan14, jan15, jan16, jan17, jan18]]); + + it("single mode: enriches cells with isSelected and isDisabled", () => { + const { result } = renderHook(() => + useSelection({ + mode: "single", + body: mockBody, + disabled: { dayOfWeek: [0] }, // Sunday disabled + }), + ); + + // Before selection — no cell is selected, Sunday is disabled + const week = result.current.body.value[0]; + expect(week.value[0].isDisabled).toBe(true); // Sunday + expect(week.value[0].isSelected).toBe(false); + expect(week.value[1].isDisabled).toBe(false); // Monday + expect(week.value[1].isSelected).toBe(false); + + // Select jan15 + act(() => { + result.current.select(jan15); + }); + + const weekAfter = result.current.body.value[0]; + expect(weekAfter.value[1].isSelected).toBe(true); // jan15 + expect(weekAfter.value[2].isSelected).toBe(false); // jan16 + }); + + it("range mode: enriches cells with range props", () => { + const { result } = renderHook(() => + useSelection({ mode: "range", body: mockBody }), + ); + + // Select range: jan15 → jan18 + act(() => { + result.current.select(jan15); + }); + act(() => { + result.current.select(jan18); + }); + + const week = result.current.body.value[0]; + // jan14 — not in range + expect(week.value[0].isSelected).toBe(false); + expect(week.value[0].isInRange).toBe(false); + // jan15 — range start + expect(week.value[1].isSelected).toBe(true); + expect(week.value[1].isRangeStart).toBe(true); + expect(week.value[1].isRangeEnd).toBe(false); + expect(week.value[1].isInRange).toBe(false); + // jan16 — in range + expect(week.value[2].isSelected).toBe(true); + expect(week.value[2].isInRange).toBe(true); + expect(week.value[2].isRangeStart).toBe(false); + expect(week.value[2].isRangeEnd).toBe(false); + // jan17 — in range + expect(week.value[3].isInRange).toBe(true); + // jan18 — range end + expect(week.value[4].isSelected).toBe(true); + expect(week.value[4].isRangeEnd).toBe(true); + expect(week.value[4].isRangeStart).toBe(false); + expect(week.value[4].isInRange).toBe(false); + }); + + it("multiple mode: enriched body updates on toggle", () => { + const { result } = renderHook(() => + useSelection({ mode: "multiple", body: mockBody }), + ); + + // Select jan15 and jan17 + act(() => { + result.current.select(jan15); + }); + act(() => { + result.current.select(jan17); + }); + + let week = result.current.body.value[0]; + expect(week.value[1].isSelected).toBe(true); // jan15 + expect(week.value[2].isSelected).toBe(false); // jan16 + expect(week.value[3].isSelected).toBe(true); // jan17 + + // Toggle off jan15 + act(() => { + result.current.select(new Date(2024, 0, 15)); + }); + + week = result.current.body.value[0]; + expect(week.value[1].isSelected).toBe(false); // jan15 toggled off + expect(week.value[3].isSelected).toBe(true); // jan17 still on + }); + + it("preserves original body cell properties", () => { + const { result } = renderHook(() => + useSelection({ mode: "single", body: mockBody }), + ); + + const cell = result.current.body.value[0].value[0]; + // Original properties preserved + expect(cell.key).toBe(jan14.toISOString()); + expect(cell.date).toBe(14); + expect(cell.isCurrentMonth).toBe(true); + expect(cell.isWeekend).toBe(true); + // Enriched properties added + expect(typeof cell.isSelected).toBe("boolean"); + expect(typeof cell.isDisabled).toBe("boolean"); + }); +}); diff --git a/packages/calendar/src/useSelection.ts b/packages/calendar/src/useSelection.ts new file mode 100644 index 00000000..41759fc9 --- /dev/null +++ b/packages/calendar/src/useSelection.ts @@ -0,0 +1,288 @@ +import { useCallback, useMemo, useState } from "react"; + +import type { + CalendarBodyCell, + DateRange, + MultipleSelectionOptions, + MultipleSelectionReturn, + RangeSelectionOptions, + RangeSelectionReturn, + SingleSelectionOptions, + SingleSelectionReturn, +} from "./models/Selection"; +import isSameDate from "./utils/isSameDate"; +import { matchDateArray } from "./utils/matchDate"; + +function diffInDays(a: Date, b: Date): number { + const msPerDay = 1000 * 60 * 60 * 24; + return Math.round(Math.abs(a.getTime() - b.getTime()) / msPerDay) + 1; +} + +function useDisabled(disabled: SingleSelectionOptions["disabled"]) { + return useCallback( + (date: Date) => matchDateArray(date, disabled), + [disabled], + ); +} + +// ─── Single Selection ─────────────────────────────────── + +export function useSingleSelection( + options: Omit, "mode">, +): SingleSelectionReturn { + const { disabled } = options; + const isDisabled = useDisabled(disabled); + const [selected, setSelected] = useState(undefined); + + const select = useCallback( + (date: Date) => { + if (isDisabled(date)) return; + + setSelected((prev) => { + if (prev && isSameDate(prev, date)) { + return options.required ? prev : undefined; + } + return date; + }); + }, + [isDisabled, options.required], + ); + + const deselect = useCallback(() => { + if (!options.required) { + setSelected(undefined); + } + }, [options.required]); + + const isSelected = useCallback( + (date: Date) => (selected ? isSameDate(selected, date) : false), + [selected], + ); + + const body = useMemo(() => ({ + value: options.body.value.map((week) => ({ + ...week, + value: week.value.map((cell) => ({ + ...cell, + isSelected: selected ? isSameDate(selected, cell.value) : false, + isDisabled: isDisabled(cell.value), + })), + })), + }), [options.body, selected, isDisabled]); + + return { selected, select, deselect, isSelected, isDisabled, body }; +} + +// ─── Range Selection ──────────────────────────────────── + +export function useRangeSelection( + options: Omit, "mode">, +): RangeSelectionReturn { + const { disabled } = options; + const isDisabled = useDisabled(disabled); + const [selected, setSelected] = useState(undefined); + + const select = useCallback( + (date: Date) => { + if (isDisabled(date)) return; + + setSelected((prev) => { + // No selection yet or range is complete → start new range + if (!prev || prev.to) { + return { from: date }; + } + + // First click already set, now set the end + let from = prev.from; + let to = date; + + // Swap if from > to + if (from > to) { + [from, to] = [to, from]; + } + + const days = diffInDays(from, to); + + if (options.min !== undefined && days < options.min) { + return prev; + } + + if (options.max !== undefined && days > options.max) { + return prev; + } + + return { from, to }; + }); + }, + [isDisabled, options.min, options.max], + ); + + const deselect = useCallback(() => { + setSelected(undefined); + }, []); + + const isSelected = useCallback( + (date: Date) => { + if (!selected) return false; + if (isSameDate(selected.from, date)) return true; + if (selected.to && isSameDate(selected.to, date)) return true; + if (selected.to && date > selected.from && date < selected.to) return true; + return false; + }, + [selected], + ); + + const isRangeStart = useCallback( + (date: Date) => (selected ? isSameDate(selected.from, date) : false), + [selected], + ); + + const isRangeEnd = useCallback( + (date: Date) => + selected?.to ? isSameDate(selected.to, date) : false, + [selected], + ); + + const isInRange = useCallback( + (date: Date) => { + if (!selected?.to) return false; + return date > selected.from && date < selected.to; + }, + [selected], + ); + + const body = useMemo(() => ({ + value: options.body.value.map((week) => ({ + ...week, + value: week.value.map((cell) => { + const cellDate = cell.value; + let cellIsSelected = false; + let cellIsRangeStart = false; + let cellIsRangeEnd = false; + let cellIsInRange = false; + + if (selected) { + cellIsRangeStart = isSameDate(selected.from, cellDate); + cellIsRangeEnd = selected.to ? isSameDate(selected.to, cellDate) : false; + cellIsInRange = selected.to ? cellDate > selected.from && cellDate < selected.to : false; + cellIsSelected = cellIsRangeStart || cellIsRangeEnd || cellIsInRange; + } + + return { + ...cell, + isSelected: cellIsSelected, + isDisabled: isDisabled(cellDate), + isRangeStart: cellIsRangeStart, + isRangeEnd: cellIsRangeEnd, + isInRange: cellIsInRange, + }; + }), + })), + }), [options.body, selected, isDisabled]); + + return { + selected, + select, + deselect, + isSelected, + isDisabled, + isRangeStart, + isRangeEnd, + isInRange, + body, + }; +} + +// ─── Multiple Selection ───────────────────────────────── + +export function useMultipleSelection( + options: Omit, "mode">, +): MultipleSelectionReturn { + const { disabled } = options; + const isDisabled = useDisabled(disabled); + const [selected, setSelected] = useState([]); + + const select = useCallback( + (date: Date) => { + if (isDisabled(date)) return; + + setSelected((prev) => { + const existing = prev.findIndex((d) => isSameDate(d, date)); + + // Already selected → toggle off (respect min) + if (existing !== -1) { + if (options.min !== undefined && prev.length <= options.min) { + return prev; + } + return prev.filter((_, i) => i !== existing); + } + + // Not selected → add (respect max) + if (options.max !== undefined && prev.length >= options.max) { + return prev; + } + + return [...prev, date]; + }); + }, + [isDisabled, options.min, options.max], + ); + + const deselect = useCallback( + (date: Date) => { + setSelected((prev) => { + if (options.min !== undefined && prev.length <= options.min) { + return prev; + } + return prev.filter((d) => !isSameDate(d, date)); + }); + }, + [options.min], + ); + + const isSelected = useCallback( + (date: Date) => selected.some((d) => isSameDate(d, date)), + [selected], + ); + + const body = useMemo(() => ({ + value: options.body.value.map((week) => ({ + ...week, + value: week.value.map((cell) => ({ + ...cell, + isSelected: selected.some((d) => isSameDate(d, cell.value)), + isDisabled: isDisabled(cell.value), + })), + })), + }), [options.body, selected, isDisabled]); + + return { selected, select, deselect, isSelected, isDisabled, body }; +} + +// ─── Convenience Wrapper ──────────────────────────────── +// Note: `mode` must remain constant across renders (Rules of Hooks). + +export function useSelection( + options: SingleSelectionOptions, +): SingleSelectionReturn; +export function useSelection( + options: RangeSelectionOptions, +): RangeSelectionReturn; +export function useSelection( + options: MultipleSelectionOptions, +): MultipleSelectionReturn; +export function useSelection( + options: SingleSelectionOptions | RangeSelectionOptions | MultipleSelectionOptions, +): SingleSelectionReturn | RangeSelectionReturn | MultipleSelectionReturn { + const { mode, ...rest } = options; + + if (mode === "single") { + return useSingleSelection(rest as Omit, "mode">); + } + + if (mode === "range") { + return useRangeSelection(rest as Omit, "mode">); + } + + return useMultipleSelection(rest as Omit, "mode">); +} From f3a78cc4ec9a5be91b10b22797f7e90d5726f9a8 Mon Sep 17 00:00:00 2001 From: songsang Date: Thu, 12 Mar 2026 00:49:32 +0900 Subject: [PATCH 3/7] style(calendar): apply lint fixes to useSelection --- packages/calendar/src/useSelection.test.ts | 49 ++------ packages/calendar/src/useSelection.ts | 133 ++++++++++----------- 2 files changed, 73 insertions(+), 109 deletions(-) diff --git a/packages/calendar/src/useSelection.test.ts b/packages/calendar/src/useSelection.test.ts index ae247fcc..be4bd0b9 100644 --- a/packages/calendar/src/useSelection.test.ts +++ b/packages/calendar/src/useSelection.test.ts @@ -83,10 +83,7 @@ describe("matchDateArray", () => { }); it("handles Matcher[]", () => { - const matchers = [ - { before: new Date(2024, 0, 5) }, - { after: new Date(2024, 0, 20) }, - ]; + const matchers = [{ before: new Date(2024, 0, 5) }, { after: new Date(2024, 0, 20) }]; expect(matchDateArray(new Date(2024, 0, 3), matchers)).toBe(true); expect(matchDateArray(new Date(2024, 0, 25), matchers)).toBe(true); expect(matchDateArray(new Date(2024, 0, 10), matchers)).toBe(false); @@ -150,9 +147,7 @@ describe("useSelection - single mode", () => { it("does not toggle off when required is true", () => { // Given const date = new Date(2024, 0, 15); - const { result } = renderHook(() => - useSelection({ mode: "single", required: true, body: emptyBody }), - ); + const { result } = renderHook(() => useSelection({ mode: "single", required: true, body: emptyBody })); // When act(() => { result.current.select(date); @@ -167,9 +162,7 @@ describe("useSelection - single mode", () => { it("ignores disabled dates", () => { // Given const disabled = new Date(2024, 0, 15); - const { result } = renderHook(() => - useSelection({ mode: "single", disabled, body: emptyBody }), - ); + const { result } = renderHook(() => useSelection({ mode: "single", disabled, body: emptyBody })); // When act(() => { result.current.select(new Date(2024, 0, 15)); @@ -292,9 +285,7 @@ describe("useSelection - range mode", () => { // Given — min 5 days const from = new Date(2024, 0, 10); const tooClose = new Date(2024, 0, 12); // 3 days - const { result } = renderHook(() => - useSelection({ mode: "range", min: 5, body: emptyBody }), - ); + const { result } = renderHook(() => useSelection({ mode: "range", min: 5, body: emptyBody })); // When act(() => { result.current.select(from); @@ -310,9 +301,7 @@ describe("useSelection - range mode", () => { // Given — max 5 days const from = new Date(2024, 0, 10); const tooFar = new Date(2024, 0, 20); // 11 days - const { result } = renderHook(() => - useSelection({ mode: "range", max: 5, body: emptyBody }), - ); + const { result } = renderHook(() => useSelection({ mode: "range", max: 5, body: emptyBody })); // When act(() => { result.current.select(from); @@ -327,9 +316,7 @@ describe("useSelection - range mode", () => { it("ignores disabled dates", () => { // Given const disabled = new Date(2024, 0, 15); - const { result } = renderHook(() => - useSelection({ mode: "range", disabled, body: emptyBody }), - ); + const { result } = renderHook(() => useSelection({ mode: "range", disabled, body: emptyBody })); // When act(() => { result.current.select(new Date(2024, 0, 15)); @@ -414,9 +401,7 @@ describe("useSelection - multiple mode", () => { it("respects max constraint", () => { // Given — max 2 - const { result } = renderHook(() => - useSelection({ mode: "multiple", max: 2, body: emptyBody }), - ); + const { result } = renderHook(() => useSelection({ mode: "multiple", max: 2, body: emptyBody })); act(() => { result.current.select(new Date(2024, 0, 10)); }); @@ -434,9 +419,7 @@ describe("useSelection - multiple mode", () => { it("respects min constraint on deselect", () => { // Given — min 1 const date = new Date(2024, 0, 10); - const { result } = renderHook(() => - useSelection({ mode: "multiple", min: 1, body: emptyBody }), - ); + const { result } = renderHook(() => useSelection({ mode: "multiple", min: 1, body: emptyBody })); act(() => { result.current.select(date); }); @@ -451,9 +434,7 @@ describe("useSelection - multiple mode", () => { it("ignores disabled dates", () => { // Given const disabled = new Date(2024, 0, 15); - const { result } = renderHook(() => - useSelection({ mode: "multiple", disabled, body: emptyBody }), - ); + const { result } = renderHook(() => useSelection({ mode: "multiple", disabled, body: emptyBody })); // When act(() => { result.current.select(new Date(2024, 0, 15)); @@ -518,9 +499,7 @@ describe("useSelection - body enrichment", () => { }); it("range mode: enriches cells with range props", () => { - const { result } = renderHook(() => - useSelection({ mode: "range", body: mockBody }), - ); + const { result } = renderHook(() => useSelection({ mode: "range", body: mockBody })); // Select range: jan15 → jan18 act(() => { @@ -554,9 +533,7 @@ describe("useSelection - body enrichment", () => { }); it("multiple mode: enriched body updates on toggle", () => { - const { result } = renderHook(() => - useSelection({ mode: "multiple", body: mockBody }), - ); + const { result } = renderHook(() => useSelection({ mode: "multiple", body: mockBody })); // Select jan15 and jan17 act(() => { @@ -582,9 +559,7 @@ describe("useSelection - body enrichment", () => { }); it("preserves original body cell properties", () => { - const { result } = renderHook(() => - useSelection({ mode: "single", body: mockBody }), - ); + const { result } = renderHook(() => useSelection({ mode: "single", body: mockBody })); const cell = result.current.body.value[0].value[0]; // Original properties preserved diff --git a/packages/calendar/src/useSelection.ts b/packages/calendar/src/useSelection.ts index 41759fc9..20f806d6 100644 --- a/packages/calendar/src/useSelection.ts +++ b/packages/calendar/src/useSelection.ts @@ -19,10 +19,7 @@ function diffInDays(a: Date, b: Date): number { } function useDisabled(disabled: SingleSelectionOptions["disabled"]) { - return useCallback( - (date: Date) => matchDateArray(date, disabled), - [disabled], - ); + return useCallback((date: Date) => matchDateArray(date, disabled), [disabled]); } // ─── Single Selection ─────────────────────────────────── @@ -54,21 +51,21 @@ export function useSingleSelection( } }, [options.required]); - const isSelected = useCallback( - (date: Date) => (selected ? isSameDate(selected, date) : false), - [selected], - ); + const isSelected = useCallback((date: Date) => (selected ? isSameDate(selected, date) : false), [selected]); - const body = useMemo(() => ({ - value: options.body.value.map((week) => ({ - ...week, - value: week.value.map((cell) => ({ - ...cell, - isSelected: selected ? isSameDate(selected, cell.value) : false, - isDisabled: isDisabled(cell.value), + const body = useMemo( + () => ({ + value: options.body.value.map((week) => ({ + ...week, + value: week.value.map((cell) => ({ + ...cell, + isSelected: selected ? isSameDate(selected, cell.value) : false, + isDisabled: isDisabled(cell.value), + })), })), - })), - }), [options.body, selected, isDisabled]); + }), + [options.body, selected, isDisabled], + ); return { selected, select, deselect, isSelected, isDisabled, body }; } @@ -132,16 +129,9 @@ export function useRangeSelection( [selected], ); - const isRangeStart = useCallback( - (date: Date) => (selected ? isSameDate(selected.from, date) : false), - [selected], - ); + const isRangeStart = useCallback((date: Date) => (selected ? isSameDate(selected.from, date) : false), [selected]); - const isRangeEnd = useCallback( - (date: Date) => - selected?.to ? isSameDate(selected.to, date) : false, - [selected], - ); + const isRangeEnd = useCallback((date: Date) => (selected?.to ? isSameDate(selected.to, date) : false), [selected]); const isInRange = useCallback( (date: Date) => { @@ -151,34 +141,37 @@ export function useRangeSelection( [selected], ); - const body = useMemo(() => ({ - value: options.body.value.map((week) => ({ - ...week, - value: week.value.map((cell) => { - const cellDate = cell.value; - let cellIsSelected = false; - let cellIsRangeStart = false; - let cellIsRangeEnd = false; - let cellIsInRange = false; - - if (selected) { - cellIsRangeStart = isSameDate(selected.from, cellDate); - cellIsRangeEnd = selected.to ? isSameDate(selected.to, cellDate) : false; - cellIsInRange = selected.to ? cellDate > selected.from && cellDate < selected.to : false; - cellIsSelected = cellIsRangeStart || cellIsRangeEnd || cellIsInRange; - } + const body = useMemo( + () => ({ + value: options.body.value.map((week) => ({ + ...week, + value: week.value.map((cell) => { + const cellDate = cell.value; + let cellIsSelected = false; + let cellIsRangeStart = false; + let cellIsRangeEnd = false; + let cellIsInRange = false; + + if (selected) { + cellIsRangeStart = isSameDate(selected.from, cellDate); + cellIsRangeEnd = selected.to ? isSameDate(selected.to, cellDate) : false; + cellIsInRange = selected.to ? cellDate > selected.from && cellDate < selected.to : false; + cellIsSelected = cellIsRangeStart || cellIsRangeEnd || cellIsInRange; + } - return { - ...cell, - isSelected: cellIsSelected, - isDisabled: isDisabled(cellDate), - isRangeStart: cellIsRangeStart, - isRangeEnd: cellIsRangeEnd, - isInRange: cellIsInRange, - }; - }), - })), - }), [options.body, selected, isDisabled]); + return { + ...cell, + isSelected: cellIsSelected, + isDisabled: isDisabled(cellDate), + isRangeStart: cellIsRangeStart, + isRangeEnd: cellIsRangeEnd, + isInRange: cellIsInRange, + }; + }), + })), + }), + [options.body, selected, isDisabled], + ); return { selected, @@ -240,21 +233,21 @@ export function useMultipleSelection( [options.min], ); - const isSelected = useCallback( - (date: Date) => selected.some((d) => isSameDate(d, date)), - [selected], - ); + const isSelected = useCallback((date: Date) => selected.some((d) => isSameDate(d, date)), [selected]); - const body = useMemo(() => ({ - value: options.body.value.map((week) => ({ - ...week, - value: week.value.map((cell) => ({ - ...cell, - isSelected: selected.some((d) => isSameDate(d, cell.value)), - isDisabled: isDisabled(cell.value), + const body = useMemo( + () => ({ + value: options.body.value.map((week) => ({ + ...week, + value: week.value.map((cell) => ({ + ...cell, + isSelected: selected.some((d) => isSameDate(d, cell.value)), + isDisabled: isDisabled(cell.value), + })), })), - })), - }), [options.body, selected, isDisabled]); + }), + [options.body, selected, isDisabled], + ); return { selected, select, deselect, isSelected, isDisabled, body }; } @@ -262,12 +255,8 @@ export function useMultipleSelection( // ─── Convenience Wrapper ──────────────────────────────── // Note: `mode` must remain constant across renders (Rules of Hooks). -export function useSelection( - options: SingleSelectionOptions, -): SingleSelectionReturn; -export function useSelection( - options: RangeSelectionOptions, -): RangeSelectionReturn; +export function useSelection(options: SingleSelectionOptions): SingleSelectionReturn; +export function useSelection(options: RangeSelectionOptions): RangeSelectionReturn; export function useSelection( options: MultipleSelectionOptions, ): MultipleSelectionReturn; From 48ddfebd0d7fb2e401a760e484833f07fb288b24 Mon Sep 17 00:00:00 2001 From: songsang Date: Thu, 12 Mar 2026 00:49:39 +0900 Subject: [PATCH 4/7] feat(calendar): add selection storybook examples Add storybook stories demonstrating three selection modes: - Single: select one date, weekends disabled - Range: select date range with min/max constraints - Multiple: toggle up to 5 dates, past dates disabled --- .../src/stories/Selection.stories.tsx | 22 ++ .../src/stories/SelectionCalendar.tsx | 205 ++++++++++++++++++ 2 files changed, 227 insertions(+) create mode 100644 packages/calendar/src/stories/Selection.stories.tsx create mode 100644 packages/calendar/src/stories/SelectionCalendar.tsx diff --git a/packages/calendar/src/stories/Selection.stories.tsx b/packages/calendar/src/stories/Selection.stories.tsx new file mode 100644 index 00000000..dfeafac4 --- /dev/null +++ b/packages/calendar/src/stories/Selection.stories.tsx @@ -0,0 +1,22 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import React from "react"; + +import { MultipleSelectionCalendar, RangeSelectionCalendar, SingleSelectionCalendar } from "./SelectionCalendar"; + +const meta: Meta = { + title: "Calendar/useSelection", +}; + +export default meta; + +export const Single: StoryObj = { + render: () => , +}; + +export const Range: StoryObj = { + render: () => , +}; + +export const Multiple: StoryObj = { + render: () => , +}; diff --git a/packages/calendar/src/stories/SelectionCalendar.tsx b/packages/calendar/src/stories/SelectionCalendar.tsx new file mode 100644 index 00000000..afc4b1a1 --- /dev/null +++ b/packages/calendar/src/stories/SelectionCalendar.tsx @@ -0,0 +1,205 @@ +import { useCalendar } from "../useCalendar"; +import { useSelection } from "../useSelection"; + +const WEEKDAY_NAMES = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]; + +function formatYearMonth(date: Date): string { + return `${date.getFullYear()}. ${String(date.getMonth() + 1).padStart(2, "0")}`; +} + +function formatDate(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}`; +} + +export function SingleSelectionCalendar() { + const calendar = useCalendar(); + const { body, select, selected } = useSelection({ + mode: "single", + body: calendar.body, + disabled: { dayOfWeek: [0, 6] }, + }); + + return ( +
+

Selected: {selected ? formatDate(selected) : "None"}

+ + + + + {calendar.headers.weekdays.map(({ key, value }) => ( + + ))} + + + + {body.value.map((week) => ( + + {week.value.map((day) => ( + + ))} + + ))} + +
+ +
{WEEKDAY_NAMES[value.getDay()]}
!day.isDisabled && select(day.value)} + onKeyDown={(e) => e.key === "Enter" && !day.isDisabled && select(day.value)} + > + {day.date} +
+
+ ); +} + +export function RangeSelectionCalendar() { + const calendar = useCalendar(); + const { body, select, selected, deselect } = useSelection({ + mode: "range", + body: calendar.body, + min: 2, + max: 14, + disabled: { dayOfWeek: [0] }, + }); + + const rangeText = selected + ? `${formatDate(selected.from)}${selected.to ? ` → ${formatDate(selected.to)}` : " (picking end...)"}` + : "None"; + + return ( +
+

Range: {rangeText}

+ + + + + + {calendar.headers.weekdays.map(({ key, value }) => ( + + ))} + + + + {body.value.map((week) => ( + + {week.value.map((day) => ( + + ))} + + ))} + +
+ +
{WEEKDAY_NAMES[value.getDay()]}
!day.isDisabled && select(day.value)} + onKeyDown={(e) => e.key === "Enter" && !day.isDisabled && select(day.value)} + > + {day.date} +
+
+ ); +} + +export function MultipleSelectionCalendar() { + const calendar = useCalendar(); + const { body, select, selected } = useSelection({ + mode: "multiple", + body: calendar.body, + max: 5, + disabled: (date: Date) => date < new Date(new Date().setHours(0, 0, 0, 0)), + }); + + return ( +
+

+ Selected ({selected.length}/5): {selected.length > 0 ? selected.map((d) => formatDate(d)).join(", ") : "None"} +

+ + + + + {calendar.headers.weekdays.map(({ key, value }) => ( + + ))} + + + + {body.value.map((week) => ( + + {week.value.map((day) => ( + + ))} + + ))} + +
+ +
{WEEKDAY_NAMES[value.getDay()]}
!day.isDisabled && select(day.value)} + onKeyDown={(e) => e.key === "Enter" && !day.isDisabled && select(day.value)} + > + {day.date} +
+
+ ); +} From 5a2e0c1a2f0518236dfe426bc256beeb4559a4b3 Mon Sep 17 00:00:00 2001 From: songsang Date: Thu, 12 Mar 2026 00:49:45 +0900 Subject: [PATCH 5/7] feat(examples): add selection demo page with Chakra UI Add React example page showcasing useSelection hook with three tabbed demos (Single, Range, Multiple) using Chakra UI Table components, consistent with the existing calendar example style. --- examples/react/src/pages/selection/index.tsx | 289 +++++++++++++++++++ 1 file changed, 289 insertions(+) create mode 100644 examples/react/src/pages/selection/index.tsx diff --git a/examples/react/src/pages/selection/index.tsx b/examples/react/src/pages/selection/index.tsx new file mode 100644 index 00000000..e1b9d47a --- /dev/null +++ b/examples/react/src/pages/selection/index.tsx @@ -0,0 +1,289 @@ +import { ChevronLeftIcon, ChevronRightIcon } from "@chakra-ui/icons"; +import { + Badge, + Box, + Button, + Flex, + Heading, + IconButton, + Stack, + Tab, + TabList, + TabPanel, + TabPanels, + Table, + Tabs, + Tbody, + Td, + Text, + Th, + Thead, + Tr, +} from "@chakra-ui/react"; +import type { DateRange } from "@h6s/calendar"; +import { useCalendar, useSelection } from "@h6s/calendar"; +import { format } from "date-fns"; +import locale from "date-fns/locale/en-US"; + +import { Container } from "../../components/Container"; + +// ─── Single Mode ───────────────────────────────────────── + +function SingleDemo() { + const { cursorDate, headers, body, navigation } = useCalendar(); + const selection = useSelection({ + mode: "single", + body, + disabled: { dayOfWeek: [0, 6] }, + }); + + return ( + + + Click a weekday to select. Weekends (Sat/Sun) are disabled. + + + } onClick={navigation.toPrev} /> + {format(cursorDate, "yyyy. MM")} + } onClick={navigation.toNext} /> + + + + + {headers.weekdays.map(({ key, value }) => ( + + ))} + + + + {selection.body.value.map((week) => ( + + {week.value.map((day) => ( + + ))} + + ))} + +
{format(value, "E", { locale })}
!day.isDisabled && selection.select(day.value)} + > + + {day.date} + +
+ + + Selected:{" "} + {selection.selected ? format(selection.selected, "yyyy-MM-dd (EEE)", { locale }) : "None"} + + +
+ ); +} + +// ─── Range Mode ────────────────────────────────────────── + +function RangeDemo() { + const { cursorDate, headers, body, navigation } = useCalendar(); + const selection = useSelection({ + mode: "range", + body, + min: 2, + max: 14, + disabled: { dayOfWeek: [0] }, + }); + + const rangeText = (sel: DateRange | undefined) => { + if (!sel) return "None"; + const from = format(sel.from, "yyyy-MM-dd"); + if (!sel.to) return `${from} → (pick end date)`; + const to = format(sel.to, "yyyy-MM-dd"); + const days = Math.round((sel.to.getTime() - sel.from.getTime()) / 86400000) + 1; + return `${from} → ${to} (${days} days)`; + }; + + return ( + + + Click start → end. Min 2 days, max 14 days. Sundays disabled. + + + } onClick={navigation.toPrev} /> + {format(cursorDate, "yyyy. MM")} + } onClick={navigation.toNext} /> + + + + + {headers.weekdays.map(({ key, value }) => ( + + ))} + + + + {selection.body.value.map((week) => ( + + {week.value.map((day) => ( + + ))} + + ))} + +
{format(value, "E", { locale })}
!day.isDisabled && selection.select(day.value)} + > + + {day.date} + +
+ + + Range: {rangeText(selection.selected)} + + + +
+ ); +} + +// ─── Multiple Mode ─────────────────────────────────────── + +function MultipleDemo() { + const { cursorDate, headers, body, navigation } = useCalendar(); + const today = new Date(new Date().setHours(0, 0, 0, 0)); + const selection = useSelection({ + mode: "multiple", + body, + max: 5, + disabled: (date: Date) => date < today, + }); + + return ( + + + Click to toggle. Max 5 dates. Past dates disabled. + + + } onClick={navigation.toPrev} /> + {format(cursorDate, "yyyy. MM")} + } onClick={navigation.toNext} /> + + + + + {headers.weekdays.map(({ key, value }) => ( + + ))} + + + + {selection.body.value.map((week) => ( + + {week.value.map((day) => ( + + ))} + + ))} + +
{format(value, "E", { locale })}
!day.isDisabled && selection.select(day.value)} + > + + {day.date} + +
+ + + Selected ({selection.selected.length}/5):{" "} + {selection.selected.length > 0 + ? selection.selected.map((d) => format(d, "MM/dd")).join(", ") + : "None"} + + +
+ ); +} + +// ─── Page ──────────────────────────────────────────────── + +export default function SelectionExample() { + return ( + + + + + useSelection + + + @h6s/calendar + + + Selection Demo — Single, Range, Multiple + + + + + + Single + Range + Multiple + + + + + + + + + + + + + + + + ); +} From 98c700c4cbc06fb23db5339bd7b1a09ea256e116 Mon Sep 17 00:00:00 2001 From: songsang Date: Thu, 12 Mar 2026 00:50:55 +0900 Subject: [PATCH 6/7] style(calendar): apply lint formatting to matchDate utility --- packages/calendar/src/utils/matchDate.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/packages/calendar/src/utils/matchDate.ts b/packages/calendar/src/utils/matchDate.ts index 6c0b338d..826076b8 100644 --- a/packages/calendar/src/utils/matchDate.ts +++ b/packages/calendar/src/utils/matchDate.ts @@ -49,10 +49,7 @@ export function matchDate(date: Date, matcher: Matcher): boolean { return false; } -export function matchDateArray( - date: Date, - matchers: Matcher | Matcher[] | undefined, -): boolean { +export function matchDateArray(date: Date, matchers: Matcher | Matcher[] | undefined): boolean { if (matchers === undefined) { return false; } From 0058661f2c4133f67809adee752500b4c83f6259 Mon Sep 17 00:00:00 2001 From: songsang Date: Thu, 12 Mar 2026 01:06:22 +0900 Subject: [PATCH 7/7] test(calendar): extract matchDate tests and add edge cases Move matchDate/matchDateArray tests from useSelection.test.ts into a dedicated matchDate.test.ts, consistent with other utility tests. Add edge case coverage: - empty Date[] and { dayOfWeek: [] } - { before }/{ after } boundary exclusivity - { from, to } with same-day range - time-insensitive Date comparison - always true/false function matchers - Date[] treated as single Matcher vs Matcher[] - mixed Matcher array with multiple matcher types --- packages/calendar/src/useSelection.test.ts | 68 --------- packages/calendar/src/utils/matchDate.test.ts | 139 ++++++++++++++++++ 2 files changed, 139 insertions(+), 68 deletions(-) create mode 100644 packages/calendar/src/utils/matchDate.test.ts diff --git a/packages/calendar/src/useSelection.test.ts b/packages/calendar/src/useSelection.test.ts index be4bd0b9..8514377d 100644 --- a/packages/calendar/src/useSelection.test.ts +++ b/packages/calendar/src/useSelection.test.ts @@ -3,7 +3,6 @@ import { describe, expect, it } from "vitest"; import type { CalendarBody } from "./models/Selection"; import { useSelection } from "./useSelection"; -import { matchDate, matchDateArray } from "./utils/matchDate"; const emptyBody: CalendarBody = { value: [] }; @@ -23,73 +22,6 @@ function createMockBody(dates: Date[][]): CalendarBody { }; } -describe("matchDate", () => { - it("matches exact Date", () => { - const date = new Date(2024, 0, 15); - expect(matchDate(date, new Date(2024, 0, 15))).toBe(true); - expect(matchDate(date, new Date(2024, 0, 16))).toBe(false); - }); - - it("matches Date[]", () => { - const date = new Date(2024, 0, 15); - const dates = [new Date(2024, 0, 10), new Date(2024, 0, 15)]; - expect(matchDate(date, dates)).toBe(true); - expect(matchDate(new Date(2024, 0, 1), dates)).toBe(false); - }); - - it("matches { before }", () => { - const jan10 = new Date(2024, 0, 10); - expect(matchDate(new Date(2024, 0, 5), { before: jan10 })).toBe(true); - expect(matchDate(new Date(2024, 0, 15), { before: jan10 })).toBe(false); - }); - - it("matches { after }", () => { - const jan10 = new Date(2024, 0, 10); - expect(matchDate(new Date(2024, 0, 15), { after: jan10 })).toBe(true); - expect(matchDate(new Date(2024, 0, 5), { after: jan10 })).toBe(false); - }); - - it("matches { from, to }", () => { - const range = { from: new Date(2024, 0, 10), to: new Date(2024, 0, 20) }; - expect(matchDate(new Date(2024, 0, 15), range)).toBe(true); - expect(matchDate(new Date(2024, 0, 10), range)).toBe(true); - expect(matchDate(new Date(2024, 0, 20), range)).toBe(true); - expect(matchDate(new Date(2024, 0, 5), range)).toBe(false); - }); - - it("matches { dayOfWeek }", () => { - // 2024-01-15 is Monday (1) - const monday = new Date(2024, 0, 15); - expect(matchDate(monday, { dayOfWeek: [1, 3, 5] })).toBe(true); - expect(matchDate(monday, { dayOfWeek: [0, 6] })).toBe(false); - }); - - it("matches function matcher", () => { - const isWeekend = (d: Date) => d.getDay() === 0 || d.getDay() === 6; - // 2024-01-14 is Sunday - expect(matchDate(new Date(2024, 0, 14), isWeekend)).toBe(true); - // 2024-01-15 is Monday - expect(matchDate(new Date(2024, 0, 15), isWeekend)).toBe(false); - }); -}); - -describe("matchDateArray", () => { - it("returns false for undefined", () => { - expect(matchDateArray(new Date(), undefined)).toBe(false); - }); - - it("handles single Matcher", () => { - expect(matchDateArray(new Date(2024, 0, 5), { before: new Date(2024, 0, 10) })).toBe(true); - }); - - it("handles Matcher[]", () => { - const matchers = [{ before: new Date(2024, 0, 5) }, { after: new Date(2024, 0, 20) }]; - expect(matchDateArray(new Date(2024, 0, 3), matchers)).toBe(true); - expect(matchDateArray(new Date(2024, 0, 25), matchers)).toBe(true); - expect(matchDateArray(new Date(2024, 0, 10), matchers)).toBe(false); - }); -}); - describe("useSelection - single mode", () => { it("starts with undefined selected", () => { // Given diff --git a/packages/calendar/src/utils/matchDate.test.ts b/packages/calendar/src/utils/matchDate.test.ts new file mode 100644 index 00000000..ebafc9d2 --- /dev/null +++ b/packages/calendar/src/utils/matchDate.test.ts @@ -0,0 +1,139 @@ +import { describe, expect, it } from "vitest"; + +import { matchDate, matchDateArray } from "./matchDate"; + +describe("matchDate", () => { + // ─── Exact Date ────────────────────────────────────── + it("matches exact Date", () => { + const date = new Date(2024, 0, 15); + expect(matchDate(date, new Date(2024, 0, 15))).toBe(true); + expect(matchDate(date, new Date(2024, 0, 16))).toBe(false); + }); + + it("ignores time when comparing exact Date", () => { + const date = new Date(2024, 0, 15, 10, 30); + expect(matchDate(date, new Date(2024, 0, 15, 23, 59))).toBe(true); + }); + + // ─── Date[] ────────────────────────────────────────── + it("matches Date[]", () => { + const date = new Date(2024, 0, 15); + const dates = [new Date(2024, 0, 10), new Date(2024, 0, 15)]; + expect(matchDate(date, dates)).toBe(true); + expect(matchDate(new Date(2024, 0, 1), dates)).toBe(false); + }); + + it("returns false for empty Date[]", () => { + expect(matchDate(new Date(2024, 0, 15), [])).toBe(false); + }); + + // ─── { before } ───────────────────────────────────── + it("matches { before }", () => { + const jan10 = new Date(2024, 0, 10); + expect(matchDate(new Date(2024, 0, 5), { before: jan10 })).toBe(true); + expect(matchDate(new Date(2024, 0, 15), { before: jan10 })).toBe(false); + }); + + it("{ before } is exclusive (same day returns false)", () => { + const jan10 = new Date(2024, 0, 10); + expect(matchDate(new Date(2024, 0, 10), { before: jan10 })).toBe(false); + }); + + // ─── { after } ────────────────────────────────────── + it("matches { after }", () => { + const jan10 = new Date(2024, 0, 10); + expect(matchDate(new Date(2024, 0, 15), { after: jan10 })).toBe(true); + expect(matchDate(new Date(2024, 0, 5), { after: jan10 })).toBe(false); + }); + + it("{ after } is exclusive (same day returns false)", () => { + const jan10 = new Date(2024, 0, 10); + expect(matchDate(new Date(2024, 0, 10), { after: jan10 })).toBe(false); + }); + + // ─── { from, to } ─────────────────────────────────── + it("matches { from, to } inclusive on boundaries", () => { + const range = { from: new Date(2024, 0, 10), to: new Date(2024, 0, 20) }; + expect(matchDate(new Date(2024, 0, 10), range)).toBe(true); + expect(matchDate(new Date(2024, 0, 20), range)).toBe(true); + expect(matchDate(new Date(2024, 0, 15), range)).toBe(true); + expect(matchDate(new Date(2024, 0, 5), range)).toBe(false); + expect(matchDate(new Date(2024, 0, 25), range)).toBe(false); + }); + + it("matches { from, to } when from === to (single day range)", () => { + const sameDay = { from: new Date(2024, 0, 15), to: new Date(2024, 0, 15) }; + expect(matchDate(new Date(2024, 0, 15), sameDay)).toBe(true); + expect(matchDate(new Date(2024, 0, 14), sameDay)).toBe(false); + expect(matchDate(new Date(2024, 0, 16), sameDay)).toBe(false); + }); + + // ─── { dayOfWeek } ────────────────────────────────── + it("matches { dayOfWeek }", () => { + // 2024-01-15 is Monday (1) + const monday = new Date(2024, 0, 15); + expect(matchDate(monday, { dayOfWeek: [1, 3, 5] })).toBe(true); + expect(matchDate(monday, { dayOfWeek: [0, 6] })).toBe(false); + }); + + it("returns false for empty { dayOfWeek: [] }", () => { + expect(matchDate(new Date(2024, 0, 15), { dayOfWeek: [] })).toBe(false); + }); + + // ─── Function matcher ─────────────────────────────── + it("matches function matcher", () => { + const isWeekend = (d: Date) => d.getDay() === 0 || d.getDay() === 6; + // 2024-01-14 is Sunday + expect(matchDate(new Date(2024, 0, 14), isWeekend)).toBe(true); + // 2024-01-15 is Monday + expect(matchDate(new Date(2024, 0, 15), isWeekend)).toBe(false); + }); + + it("matches function that always returns true", () => { + expect(matchDate(new Date(2024, 0, 15), () => true)).toBe(true); + }); + + it("matches function that always returns false", () => { + expect(matchDate(new Date(2024, 0, 15), () => false)).toBe(false); + }); +}); + +describe("matchDateArray", () => { + it("returns false for undefined", () => { + expect(matchDateArray(new Date(), undefined)).toBe(false); + }); + + it("handles single Matcher", () => { + expect(matchDateArray(new Date(2024, 0, 5), { before: new Date(2024, 0, 10) })).toBe(true); + }); + + it("handles Matcher[]", () => { + const matchers = [{ before: new Date(2024, 0, 5) }, { after: new Date(2024, 0, 20) }]; + expect(matchDateArray(new Date(2024, 0, 3), matchers)).toBe(true); + expect(matchDateArray(new Date(2024, 0, 25), matchers)).toBe(true); + expect(matchDateArray(new Date(2024, 0, 10), matchers)).toBe(false); + }); + + it("handles Date[] as a single Matcher (not Matcher[])", () => { + const dates = [new Date(2024, 0, 10), new Date(2024, 0, 15)]; + expect(matchDateArray(new Date(2024, 0, 10), dates)).toBe(true); + expect(matchDateArray(new Date(2024, 0, 15), dates)).toBe(true); + expect(matchDateArray(new Date(2024, 0, 12), dates)).toBe(false); + }); + + it("handles empty Matcher[] (returns false)", () => { + expect(matchDateArray(new Date(2024, 0, 15), [])).toBe(false); + }); + + it("returns true if any matcher in array matches", () => { + const matchers = [new Date(2024, 0, 10), { dayOfWeek: [0, 6] }, (d: Date) => d.getDate() === 25]; + // exact date match + expect(matchDateArray(new Date(2024, 0, 10), matchers)).toBe(true); + // dayOfWeek match (2024-01-14 is Sunday) + expect(matchDateArray(new Date(2024, 0, 14), matchers)).toBe(true); + // function match + expect(matchDateArray(new Date(2024, 0, 25), matchers)).toBe(true); + // no match + expect(matchDateArray(new Date(2024, 0, 16), matchers)).toBe(false); + }); +});