Compare commits

...
Author SHA1 Message Date
Ken 068fa89ba8 fix(datepicker): Respect from/to year when updating calendar navigation 2026-10-06 13:28:53 +02:00
7 changed files with 189 additions and 12 deletions

No files matched your search

+5
View File
@@ -0,0 +1,5 @@
---
"@navikt/ds-react": patch
---
DatePicker: Typing a date with an unreasonable year (more than ~150 years from today, or outside `fromDate`/`toDate`) no longer navigates the calendar to that year.
@@ -564,6 +564,89 @@ export const HookFallbackToDate: Story = {
},
};
const navigationToday = new Date("Jun 1 2025");
export const HookIgnoresUnreasonableYear: Story = {
render: () => {
const { datepickerProps, inputProps } = useDatepicker({
today: navigationToday,
});
return (
<DatePicker {...datepickerProps}>
<DatePicker.Input {...inputProps} label="Velg dato" />
</DatePicker>
);
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.type(canvas.getByLabelText("Velg dato"), "07.09.4582");
await userEvent.click(canvas.getByTitle("Åpne datovelger"));
const dialog = canvas.getByRole("dialog");
expect(
within(dialog).getByText(
format(navigationToday, "LLLL y", { locale: nb }),
),
).toBeInTheDocument();
},
};
export const HookNavigatesToReasonableYear: Story = {
render: () => {
const { datepickerProps, inputProps } = useDatepicker({
today: navigationToday,
});
return (
<DatePicker {...datepickerProps}>
<DatePicker.Input {...inputProps} label="Velg dato" />
</DatePicker>
);
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.type(canvas.getByLabelText("Velg dato"), "07.09.2030");
await userEvent.click(canvas.getByTitle("Åpne datovelger"));
const dialog = canvas.getByRole("dialog");
expect(within(dialog).getByText("september 2030")).toBeInTheDocument();
},
};
export const RangeHookIgnoresUnreasonableYear: Story = {
render: () => {
const { datepickerProps, fromInputProps, toInputProps } =
useRangeDatepicker({ today: navigationToday });
return (
<DatePicker {...datepickerProps} open>
<HStack gap="space-16">
<DatePicker.Input {...fromInputProps} label="Fra" />
<DatePicker.Input {...toInputProps} label="Til" />
</HStack>
</DatePicker>
);
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(canvas.getByLabelText("Fra"));
await userEvent.paste("07.09.4582");
await userEvent.click(canvas.getByLabelText("Til"));
await userEvent.paste("08.09.4582");
const dialog = canvas.getByRole("dialog");
expect(
within(dialog).getByText(
format(navigationToday, "LLLL y", { locale: nb }),
),
).toBeInTheDocument();
},
};
export const SelectSameRangedDate: Story = {
render: () => {
const { datepickerProps, toInputProps, fromInputProps } =
@@ -3,7 +3,12 @@ import React, { useCallback, useState } from "react";
import { type DayEventHandler, dateMatchModifiers } from "react-day-picker";
import { getLocaleFromString } from "../../utils/components/date/Date.locale";
import type { DateInputProps } from "../../utils/components/date/input/DateInput";
import { formatDateForInput, isValidDate, parseDate } from "../../utils/date";
import {
formatDateForInput,
isNavigableYear,
isValidDate,
parseDate,
} from "../../utils/date";
import { focusElement } from "../../utils/helpers/focus";
import { useDateLocale } from "../../utils/i18n/i18n.hooks";
import type { DatePickerProps } from "./DatePickerRoot";
@@ -173,10 +178,18 @@ export const useDatepicker = (
(newOpen: boolean) => {
setOpen(newOpen);
if (newOpen) {
setMonth(selectedDay ?? defaultSelected ?? defaultMonth ?? today);
const navigableSelectedDay =
selectedDay &&
isNavigableYear({ day: selectedDay, today, fromDate, toDate })
? selectedDay
: undefined;
setMonth(
navigableSelectedDay ?? defaultSelected ?? defaultMonth ?? today,
);
}
},
[defaultMonth, defaultSelected, selectedDay, today],
[defaultMonth, defaultSelected, fromDate, selectedDay, toDate, today],
);
const updateDate = (date?: Date) => {
@@ -221,7 +234,10 @@ export const useDatepicker = (
const isAfter =
toDate && day && differenceInCalendarDays(day, toDate) > 0;
!isBefore && !isAfter && setMonth(day);
!isBefore &&
!isAfter &&
isNavigableYear({ day, today, fromDate, toDate }) &&
setMonth(day);
}
};
@@ -313,7 +329,9 @@ export const useDatepicker = (
}
updateDate(day);
updateValidation();
setMonth(defaultMonth ?? day);
if (defaultMonth || isNavigableYear({ day, today, fromDate, toDate })) {
setMonth(defaultMonth ?? day);
}
};
const datepickerProps = {
@@ -4,7 +4,12 @@ import { dateMatchModifiers } from "react-day-picker";
import { getLocaleFromString } from "../../utils/components/date/Date.locale";
import type { DateRange } from "../../utils/components/date/Date.typeutils";
import type { DateInputProps } from "../../utils/components/date/input/DateInput";
import { formatDateForInput, isValidDate, parseDate } from "../../utils/date";
import {
formatDateForInput,
isNavigableYear,
isValidDate,
parseDate,
} from "../../utils/date";
import { focusElement } from "../../utils/helpers/focus";
import { useDateLocale } from "../../utils/i18n/i18n.hooks";
import type { DatePickerProps } from "./DatePickerRoot";
@@ -316,6 +321,11 @@ export const useRangeDatepicker = (
setValidation(initialValidation(range, opt));
};
/* Avoid navigating the calendar to unreasonable years, e.g. while typing */
const navigateToMonth = (day: Date) => {
isNavigableYear({ day, today, fromDate, toDate }) && setMonth(day);
};
const handleFocus = (e, src: RangeT) => {
if (e.target.readOnly) {
return;
@@ -339,7 +349,7 @@ export const useRangeDatepicker = (
const isAfter =
toDate && day && differenceInCalendarDays(day, toDate) > 0;
!isBefore && !isAfter && setMonth(day);
!isBefore && !isAfter && navigateToMonth(day);
}
};
@@ -438,7 +448,7 @@ export const useRangeDatepicker = (
) {
updateRange({ to: day, from: day });
setToInputValue(formatDateForInput(day, locale, "date", inputFormat));
setMonth(day);
navigateToMonth(day);
updateValidation();
return;
}
@@ -453,14 +463,14 @@ export const useRangeDatepicker = (
);
if (validateDay(toDay)) {
updateRange({ from: day, to: toDay });
setMonth(day);
navigateToMonth(day);
updateValidation();
return;
}
}
updateRange({ ...selectedRange, from: day });
updateValidation({}, validation.to);
setMonth(day);
navigateToMonth(day);
};
const toChange = (
@@ -508,7 +518,7 @@ export const useRangeDatepicker = (
}
updateRange({ from: selectedRange?.from, to: day });
updateValidation(validation.from, {});
setMonth(day);
navigateToMonth(day);
};
/* live-update datepicker based on changes in inputfields */
@@ -1,6 +1,10 @@
import { setYear } from "date-fns";
import { describe, expect, test } from "vitest";
import { dateIsInCurrentMonth, isValidDate } from "./check-dates";
import {
dateIsInCurrentMonth,
isNavigableYear,
isValidDate,
} from "./check-dates";
describe("dateIsInCurrentMonth", () => {
test("should return true if the date is in the same month and year as the date to compare", () => {
@@ -47,3 +51,29 @@ describe("isValidDate", () => {
expect(isValidDate(undefined)).toBe(false);
});
});
describe("isNavigableYear", () => {
const today = new Date(2025, 5, 1);
test("should allow years within ±150 years of today", () => {
expect(isNavigableYear({ day: new Date(1925, 0, 1), today })).toBe(true);
expect(isNavigableYear({ day: new Date(2125, 11, 31), today })).toBe(true);
});
test("should not allow years more than 150 years from today", () => {
expect(isNavigableYear({ day: new Date(1824, 11, 31), today })).toBe(false);
expect(isNavigableYear({ day: new Date(4582, 8, 7), today })).toBe(false);
});
test("should use fromDate and toDate as bounds when set", () => {
const fromDate = new Date(1900, 0, 1);
const toDate = new Date(2030, 0, 1);
expect(
isNavigableYear({ day: new Date(1910, 0, 1), today, fromDate, toDate }),
).toBe(true);
expect(
isNavigableYear({ day: new Date(2031, 0, 1), today, fromDate, toDate }),
).toBe(false);
});
});
@@ -17,6 +17,36 @@ export function isValidDate(day?: Date): boolean {
return !!(day && !Number.isNaN(day.getTime()) && day.getFullYear() > 999);
}
const NAVIGABLE_YEAR_OFFSET = 150;
/**
* Checks if the calendar should navigate to the year of the given date.
* Allowed years are limited by `fromDate`/`toDate` when set, otherwise ±150 years from `today`.
* Avoids navigating to unreasonable years (e.g. 4582) when typing in the input.
* @private
*/
export function isNavigableYear({
day,
today,
fromDate,
toDate,
}: {
day: Date;
today: Date;
fromDate?: Date;
toDate?: Date;
}): boolean {
const year = day.getFullYear();
const minYear = fromDate
? fromDate.getFullYear()
: today.getFullYear() - NAVIGABLE_YEAR_OFFSET;
const maxYear = toDate
? toDate.getFullYear()
: today.getFullYear() + NAVIGABLE_YEAR_OFFSET;
return year >= minYear && year <= maxYear;
}
export function isDateOutsideRange({
day,
fromDate,
@@ -9,6 +9,7 @@ export {
dateIsInCurrentMonth,
isValidDate,
isDateOutsideRange,
isNavigableYear,
} from "./check-dates";
export { isMatch, isDateInRange } from "./is-match";
export { clampDisplayMonth, clampDisplayYear } from "./clamp-dates";