Skip to content
Open
29 changes: 29 additions & 0 deletions packages/core/src/isDateWithinRange.ts
Comment thread
Zertz marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { Temporal } from "@js-temporal/polyfill";

export function isDateWithinRange(
date: Temporal.PlainDate,
rangeValue:
| [undefined, undefined]
| [Temporal.PlainDate, undefined]
| [Temporal.PlainDate, Temporal.PlainDate]
| [Temporal.PlainDateTime, undefined]
| [Temporal.PlainDateTime, Temporal.PlainDateTime]
| undefined
): boolean {
const rangeStart =
rangeValue?.[0] instanceof Temporal.PlainDateTime
? rangeValue[0].toPlainDate()
: rangeValue?.[0];

const rangeEnd =
rangeValue?.[1] instanceof Temporal.PlainDateTime
? rangeValue[1].toPlainDate()
: rangeValue?.[1];

return (
!!rangeStart &&
!!rangeEnd &&
Temporal.PlainDate.compare(date, rangeStart) >= 0 &&
Temporal.PlainDate.compare(date, rangeEnd) <= 0
);
}
1 change: 1 addition & 0 deletions packages/core/tempocal-core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ export * from "./src/getMinutes";
export * from "./src/getMonthEndDate";
export * from "./src/getMonths";
export * from "./src/getMonthStartDate";
export * from "./src/isDateWithinRange";
export * from "./src/getWeekdays";
export * from "./src/getYears";
export * from "./src/temporalToDate";
109 changes: 109 additions & 0 deletions packages/core/test/isDateWithinRange.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import { Temporal } from "@js-temporal/polyfill";
import { expect, test } from "vitest";
import { isDateWithinRange } from "../src/isDateWithinRange";

// helpers
const date = (year: number, month: number, day: number) =>
Temporal.PlainDate.from({ year, month, day });

const dt = (year: number, month: number, day: number) =>
Temporal.PlainDateTime.from({ year, month, day, hour: 0 });

// ── closed range (isRangeSelected use case) ──────────────────────────────────

test("isDateWithinRange: false when rangeValue is undefined", () => {
expect(isDateWithinRange(date(2022, 3, 5), undefined)).toBe(false);
});

test("isDateWithinRange: false when only start is set", () => {
expect(
isDateWithinRange(date(2022, 3, 5), [date(2022, 3, 1), undefined])
).toBe(false);
});

test("isDateWithinRange: true for date inside range", () => {
expect(
isDateWithinRange(date(2022, 3, 5), [date(2022, 3, 1), date(2022, 3, 10)])
).toBe(true);
});

test("isDateWithinRange: true for date on range start", () => {
expect(
isDateWithinRange(date(2022, 3, 1), [date(2022, 3, 1), date(2022, 3, 10)])
).toBe(true);
});

test("isDateWithinRange: true for date on range end", () => {
expect(
isDateWithinRange(date(2022, 3, 10), [date(2022, 3, 1), date(2022, 3, 10)])
).toBe(true);
});

test("isDateWithinRange: false for date before range start", () => {
expect(
isDateWithinRange(date(2022, 2, 28), [date(2022, 3, 1), date(2022, 3, 10)])
).toBe(false);
});

test("isDateWithinRange: false for date after range end", () => {
expect(
isDateWithinRange(date(2022, 3, 11), [date(2022, 3, 1), date(2022, 3, 10)])
).toBe(false);
});

test("isDateWithinRange: works with PlainDateTime range", () => {
expect(
isDateWithinRange(date(2022, 3, 5), [dt(2022, 3, 1), dt(2022, 3, 10)])
).toBe(true);
});

// ── bidirectional hover interval (isRangeHovered use case) ───────────────────
// In Calendar.tsx: isRangeHovered uses
// isDateWithinRange(date, [rangeStart, hoverValue]) ||
// isDateWithinRange(date, [hoverValue, rangeStart])

test("isDateWithinRange: forward hover — date inside [start, hover]", () => {
// start=Mar 1, hover=Mar 10, date=Mar 5 → in [1, 10]
expect(
isDateWithinRange(date(2022, 3, 5), [date(2022, 3, 1), date(2022, 3, 10)])
).toBe(true);
});

test("isDateWithinRange: backward hover — date inside [hover, start]", () => {
// start=Mar 10, hover=Mar 1 → check [hover=1, start=10]
expect(
isDateWithinRange(date(2022, 3, 5), [date(2022, 3, 1), date(2022, 3, 10)])
).toBe(true);
});

test("isDateWithinRange: forward hover — date on start boundary", () => {
expect(
isDateWithinRange(date(2022, 3, 1), [date(2022, 3, 1), date(2022, 3, 10)])
).toBe(true);
});

test("isDateWithinRange: forward hover — date on hover boundary", () => {
expect(
isDateWithinRange(date(2022, 3, 10), [date(2022, 3, 1), date(2022, 3, 10)])
).toBe(true);
});

test("isDateWithinRange: false for date outside forward hover interval", () => {
expect(
isDateWithinRange(date(2022, 3, 11), [date(2022, 3, 1), date(2022, 3, 10)])
).toBe(false);
});

test("isDateWithinRange: false for date outside backward hover interval", () => {
// start=Mar 10, hover=Mar 1 → check [hover=1, start=10], date=Mar 11 outside
expect(
isDateWithinRange(date(2022, 3, 11), [date(2022, 3, 1), date(2022, 3, 10)])
).toBe(false);
});

test("isDateWithinRange: works with PlainDateTime start in hover range", () => {
// PlainDateTime start converts to PlainDate for comparison
expect(
isDateWithinRange(date(2022, 3, 5), [dt(2022, 3, 1), dt(2022, 3, 10)])
).toBe(true);
});
44 changes: 41 additions & 3 deletions packages/react/src/Calendar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,12 @@ import { Temporal } from "@js-temporal/polyfill";
import {
getCalendarMonthDateRange,
getMonthStartDate,
isDateWithinRange,
getWeekdays,
} from "@tempocal/core";
import * as React from "react";
import { CSSProperties } from "react";
import { Locale } from "./useTempocal";
import { DateRange, DateTimeRange, Locale } from "./useTempocal";

type Value = Temporal.PlainDate | Temporal.PlainDateTime;

Expand Down Expand Up @@ -47,9 +48,13 @@ type MonthProps = {
shortName: string;
narrowName: string;
}) => React.ReactNode;
hoverValue?: Temporal.PlainDate;
rangeValue?: DateRange | DateTimeRange;
Comment thread
Zertz marked this conversation as resolved.
dayProps?: (props: {
date: Temporal.PlainDate;
disabled: boolean;
isRangeHovered: boolean;
isRangeSelected: boolean;
plainDateLike: Temporal.PlainDateLike;
}) => Omit<
React.DetailedHTMLProps<
Expand All @@ -61,6 +66,8 @@ type MonthProps = {
renderDay?: (props: {
date: Temporal.PlainDate;
disabled: boolean;
isRangeHovered: boolean;
isRangeSelected: boolean;
plainDateLike: Temporal.PlainDateLike;
}) => React.ReactNode;
footerProps?: (props: {
Expand All @@ -84,6 +91,8 @@ export function Calendar({
value,
calendarProps,
headerProps,
hoverValue,
rangeValue,
renderHeader,
weekdayProps,
renderWeekday,
Expand Down Expand Up @@ -114,6 +123,8 @@ export function Calendar({
value={value.add({ months: month - monthsBefore })}
calendarProps={calendarProps}
headerProps={headerProps}
hoverValue={hoverValue}
rangeValue={rangeValue}
renderHeader={renderHeader}
weekdayProps={weekdayProps}
renderWeekday={renderWeekday}
Expand All @@ -137,6 +148,8 @@ function Month({
value,
calendarProps,
headerProps,
hoverValue,
rangeValue,
renderHeader,
weekdayProps,
renderWeekday,
Expand Down Expand Up @@ -210,6 +223,8 @@ function Month({
(!!maxValue && Temporal.PlainDate.compare(date, maxValue) > 0)
}
dayProps={dayProps}
hoverValue={hoverValue}
rangeValue={rangeValue}
style={day === 0 ? { gridColumnStart } : undefined}
renderDay={renderDay}
/>
Expand Down Expand Up @@ -255,9 +270,11 @@ function Day({
date,
disabled,
dayProps,
hoverValue,
rangeValue,
renderDay,
style,
}: Pick<MonthProps, "dayProps" | "renderDay"> & {
}: Pick<MonthProps, "dayProps" | "hoverValue" | "rangeValue" | "renderDay"> & {
date: Temporal.PlainDate;
disabled: boolean;
style: CSSProperties | undefined;
Expand All @@ -270,12 +287,33 @@ function Day({
day: date.day,
};

const rangeStart =
rangeValue?.[0] instanceof Temporal.PlainDateTime
? rangeValue[0].toPlainDate()
: rangeValue?.[0];

const rangeEnd =
rangeValue?.[1] instanceof Temporal.PlainDateTime
? rangeValue[1].toPlainDate()
: rangeValue?.[1];

const isRangeSelected = isDateWithinRange(date, rangeValue);

const isRangeHovered =
!!rangeStart &&
!rangeEnd &&
!!hoverValue &&
(isDateWithinRange(date, [rangeStart, hoverValue]) ||
isDateWithinRange(date, [hoverValue, rangeStart]));

return {
date,
disabled,
isRangeHovered,
isRangeSelected,
plainDateLike,
};
}, [date, disabled]);
}, [date, disabled, hoverValue, rangeValue]);

return (
<li {...dayProps?.(props)} style={style}>
Expand Down
7 changes: 7 additions & 0 deletions packages/react/src/useTempocal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,8 @@ export function useTempocal<
setValue: (value: RequiredValue<Mode>) => void;
value: RequiredValue<Mode> | undefined;
}) {
const [hoverValue, setHoverValue] = React.useState<Temporal.PlainDate>();

const [calendarValue, setCalendarValue] = React.useState(() => {
if (!value || (Array.isArray(value) && !value[0])) {
return Temporal.Now.plainDateISO();
Expand Down Expand Up @@ -327,12 +329,17 @@ export function useTempocal<
? minValue.toPlainDate()
: minValue,
value: calendarValue,
hoverValue,
rangeValue: Array.isArray(value)
? (value as DateRange | DateTimeRange)
: undefined,
},
years,
months,
hours,
minutes,
onChangeCalendarValue,
onChangeHoverValue: setHoverValue,
onChangeSelectedValue,
};
}
4 changes: 2 additions & 2 deletions packages/react/test/useTempocal.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,8 @@ test("useTempocal", () => {
})
);

expect(Object.keys(result.current).length).toBe(7);
expect(Object.keys(result.current.calendarProps).length).toBe(4);
expect(Object.keys(result.current).length).toBe(8);
expect(Object.keys(result.current.calendarProps).length).toBe(6);
Comment thread
Zertz marked this conversation as resolved.

expect(result.current.calendarProps.locale).toBe("en-US");
expect(result.current.calendarProps.maxValue).toBeUndefined();
Expand Down
1 change: 1 addition & 0 deletions packages/www/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ const documentation = [
"getMonthStartDate",
"getWeekdays",
"getYears",
"isDateWithinRange",
"temporalToDate",
],
},
Expand Down
Loading