diff --git a/.changeset/neat-pumas-3.md b/.changeset/neat-pumas-3.md new file mode 100644 index 0000000000..63999ebc81 --- /dev/null +++ b/.changeset/neat-pumas-3.md @@ -0,0 +1,5 @@ +--- +"@navikt/ds-react": minor +--- + +DatePicker: Refactored locally to support `react-day-picker v9`. No external API has been changed. diff --git a/.changeset/neat-pumas-4.md b/.changeset/neat-pumas-4.md new file mode 100644 index 0000000000..a2247be5c7 --- /dev/null +++ b/.changeset/neat-pumas-4.md @@ -0,0 +1,5 @@ +--- +"@navikt/ds-react": minor +--- + +MonthPicker: Refactored locally and no longer depends on `react-day-picker v9`. No external API has been changed. diff --git a/.changeset/neat-pumas-5.md b/.changeset/neat-pumas-5.md new file mode 100644 index 0000000000..1a69eedd62 --- /dev/null +++ b/.changeset/neat-pumas-5.md @@ -0,0 +1,5 @@ +--- +"@navikt/ds-css": minor +--- + +DatePicker: Updated `date.css` to support `react-day-picker v9`. diff --git a/.changeset/neat-pumas-sort.md b/.changeset/neat-pumas-sort.md new file mode 100644 index 0000000000..4d2e0ae9c6 --- /dev/null +++ b/.changeset/neat-pumas-sort.md @@ -0,0 +1,5 @@ +--- +"@navikt/ds-react": minor +--- + +Dependencies: `react-day-picker` bumped to `v9.5.0`. diff --git a/.changeset/neat-pumas-tros.md b/.changeset/neat-pumas-tros.md new file mode 100644 index 0000000000..4593349a4a --- /dev/null +++ b/.changeset/neat-pumas-tros.md @@ -0,0 +1,5 @@ +--- +"@navikt/ds-react": minor +--- + +Dependencies: `date-fns` bumped to `^4.0.0`. diff --git a/@navikt/core/css/date.css b/@navikt/core/css/date.css index 0a6a6f7453..88233f24fe 100644 --- a/@navikt/core/css/date.css +++ b/@navikt/core/css/date.css @@ -22,30 +22,13 @@ font-size: var(--a-font-size-small); } -.navds-date .rdp-weeknumber { - font-size: var(--a-font-size-small); - display: flex; - align-items: center; - justify-content: center; - width: 2rem; - height: 2rem; - border-radius: var(--a-border-radius-medium); - margin: var(--a-spacing-2); - color: var(--a-text-subtle); -} - -.navds-date .rdp-weeknumber.rdp-button { - width: 2rem; - height: 2rem; - box-shadow: 0 0 0 1px var(--a-border-default); +.navds-date__weeknumber-number { + font-size: 0.875rem; color: var(--a-text-subtle); - font-size: var(--a-font-size-small); } -.navds-date .rdp-weeknumber.rdp-button:active { - background-color: var(--a-surface-action-active); - color: var(--a-text-on-action); - box-shadow: none; +.navds-date__weeknumber:active .navds-date__weeknumber-number { + color: var(--a-text-on-neutral); } .navds-date__caption__month .navds-select__container select { @@ -303,6 +286,13 @@ margin: 0; } +span.rdp-weeknumber { + display: grid; + place-content: center; + width: 2rem; + height: 2rem; +} + .navds-date__modal.navds-date { padding: 0; } diff --git a/@navikt/core/react/package.json b/@navikt/core/react/package.json index 372fd84225..ec9ed02488 100644 --- a/@navikt/core/react/package.json +++ b/@navikt/core/react/package.json @@ -628,8 +628,8 @@ "@navikt/aksel-icons": "^7.12.2", "@navikt/ds-tokens": "^7.12.2", "clsx": "^2.1.0", - "date-fns": "^3.0.0", - "react-day-picker": "8.10.1" + "date-fns": "^4.0.0", + "react-day-picker": "9.5.0" }, "devDependencies": { "@testing-library/dom": "10.4.0", diff --git a/@navikt/core/react/src/date/parts/DateWrapper.tsx b/@navikt/core/react/src/date/Date.Dialog.tsx similarity index 82% rename from @navikt/core/react/src/date/parts/DateWrapper.tsx rename to @navikt/core/react/src/date/Date.Dialog.tsx index 332a29c29e..42e50c03cf 100644 --- a/@navikt/core/react/src/date/parts/DateWrapper.tsx +++ b/@navikt/core/react/src/date/Date.Dialog.tsx @@ -1,13 +1,13 @@ import cl from "clsx"; import React, { useRef } from "react"; -import { Button } from "../../button"; -import { Modal } from "../../modal"; -import { useModalContext } from "../../modal/Modal.context"; -import { Popover } from "../../popover"; -import { useMedia } from "../../util/hooks"; -import { useI18n } from "../../util/i18n/i18n.hooks"; -import { TFunction } from "../../util/i18n/i18n.types"; -import { getGlobalTranslations } from "../utils"; +import { Button } from "../button"; +import { Modal } from "../modal"; +import { useModalContext } from "../modal/Modal.context"; +import { Popover } from "../popover"; +import { useMedia } from "../util/hooks"; +import { useI18n } from "../util/i18n/i18n.hooks"; +import { TFunction } from "../util/i18n/i18n.types"; +import { getGlobalTranslations } from "./Date.locale"; const variantToLabel = { single: "chooseDate", @@ -31,7 +31,7 @@ type DateWrapperProps = { }; }; -export const DateWrapper = ({ +const DateDialog = ({ open, children, onClose, @@ -66,6 +66,7 @@ export const DateWrapper = ({ ); } + return (
{children} @@ -95,3 +97,4 @@ export const DateWrapper = ({ ); }; +export { DateDialog }; diff --git a/@navikt/core/react/src/date/parts/DateInput.tsx b/@navikt/core/react/src/date/Date.Input.tsx similarity index 84% rename from @navikt/core/react/src/date/parts/DateInput.tsx rename to @navikt/core/react/src/date/Date.Input.tsx index 2ae265b192..b9e7bebb65 100644 --- a/@navikt/core/react/src/date/parts/DateInput.tsx +++ b/@navikt/core/react/src/date/Date.Input.tsx @@ -1,11 +1,36 @@ import cl from "clsx"; import React, { InputHTMLAttributes, forwardRef, useRef } from "react"; import { CalendarIcon } from "@navikt/aksel-icons"; -import { ReadOnlyIcon } from "../../form/ReadOnlyIcon"; -import { FormFieldProps, useFormField } from "../../form/useFormField"; -import { BodyShort, ErrorMessage, Label } from "../../typography"; -import { omit } from "../../util"; -import { useDateInputContext, useDateTranslationContext } from "../context"; +import { ReadOnlyIcon } from "../form/ReadOnlyIcon"; +import { FormFieldProps, useFormField } from "../form/useFormField"; +import { BodyShort, ErrorMessage, Label } from "../typography"; +import { omit } from "../util"; +import { createContext } from "../util/create-context"; +import { useDateTranslationContext } from "./Date.locale"; + +interface DateInputContextProps { + /** + * Open state for popover + */ + open: boolean; + /** + * Callback for onOpen toggle + */ + onOpen: () => void; + /** + * Aria-connected ID + */ + ariaId?: string; + /** + * Flag for enabled-check + */ + defined: boolean; +} + +export const [DateInputContextProvider, useDateInputContext] = + createContext({ + errorMessage: "useDateInputContext must be used with DateInputContext", + }); export interface DateInputProps extends FormFieldProps, diff --git a/@navikt/core/react/src/date/utils/locale.ts b/@navikt/core/react/src/date/Date.locale.ts similarity index 71% rename from @navikt/core/react/src/date/utils/locale.ts rename to @navikt/core/react/src/date/Date.locale.ts index 95425cd019..5d09917421 100644 --- a/@navikt/core/react/src/date/utils/locale.ts +++ b/@navikt/core/react/src/date/Date.locale.ts @@ -1,6 +1,8 @@ import { enGB, nb, nn } from "date-fns/locale"; -import en_translations from "../../util/i18n/locales/en"; -import nn_translations from "../../util/i18n/locales/nn"; +import { createContext } from "../util/create-context"; +import { TFunction } from "../util/i18n/i18n.types"; +import en_translations from "../util/i18n/locales/en"; +import nn_translations from "../util/i18n/locales/nn"; /** @private */ export const getLocaleFromString = (locale: "nb" | "nn" | "en" = "nb") => { @@ -45,3 +47,10 @@ export const getGlobalTranslations = (locale: string | undefined) => { return undefined; } }; + +interface DateTranslationContextProps { + translate: TFunction<"DatePicker">; +} + +export const [DateTranslationContextProvider, useDateTranslationContext] = + createContext(); diff --git a/@navikt/core/react/src/date/Date.typeutils.ts b/@navikt/core/react/src/date/Date.typeutils.ts new file mode 100644 index 0000000000..1172fd6dbc --- /dev/null +++ b/@navikt/core/react/src/date/Date.typeutils.ts @@ -0,0 +1,32 @@ +export type DateRange = { + from: Date | undefined; + to?: Date | undefined; +}; + +export type DateBefore = { + before: Date; +}; + +export type DateAfter = { + after: Date; +}; + +export function isDateAfterType(value: unknown): value is DateAfter { + return Boolean(value && typeof value === "object" && "after" in value); +} + +export function isDateBeforeType(value: unknown): value is DateBefore { + return Boolean(value && typeof value === "object" && "before" in value); +} + +export function isDateRange(value: unknown): value is DateRange { + return Boolean(value && typeof value === "object" && "from" in value); +} + +export type Matcher = + | ((date: Date) => boolean) + | Date + | Date[] + | DateRange + | DateBefore + | DateAfter; diff --git a/@navikt/core/react/src/date/context/index.ts b/@navikt/core/react/src/date/context/index.ts deleted file mode 100644 index 7e0c0d71ca..0000000000 --- a/@navikt/core/react/src/date/context/index.ts +++ /dev/null @@ -1,9 +0,0 @@ -export { useDateInputContext, DateInputContext } from "./useDateInputContext"; -export { - useSharedMonthContext, - SharedMonthProvider, -} from "./useSharedMonthContext"; -export { - useDateTranslationContext, - DateTranslationContextProvider, -} from "./useDateTranslationContext"; diff --git a/@navikt/core/react/src/date/context/useDateInputContext.tsx b/@navikt/core/react/src/date/context/useDateInputContext.tsx deleted file mode 100644 index ea49cf5c49..0000000000 --- a/@navikt/core/react/src/date/context/useDateInputContext.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import { createContext, useContext } from "react"; - -interface DateInputContextProps { - /** - * Open state for popover - */ - open: boolean; - /** - * Callback for opOpen toggle - */ - onOpen: () => void; - /** - * Aria-connected ID - */ - ariaId?: string; - /** - * Flag for enabled-check - */ - defined: boolean; -} - -export const DateInputContext = createContext( - null, -); - -export const useDateInputContext = () => { - const context = useContext(DateInputContext); - - if (!context) { - console.warn("useDateInputContext must be used with DateInputContext"); - } - - return context; -}; diff --git a/@navikt/core/react/src/date/context/useDateTranslationContext.ts b/@navikt/core/react/src/date/context/useDateTranslationContext.ts deleted file mode 100644 index 384d8dcba4..0000000000 --- a/@navikt/core/react/src/date/context/useDateTranslationContext.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { createContext } from "../../util/create-context"; -import { TFunction } from "../../util/i18n/i18n.types"; - -interface DateTranslationContextProps { - translate: TFunction<"DatePicker">; -} - -export const [DateTranslationContextProvider, useDateTranslationContext] = - createContext(); diff --git a/@navikt/core/react/src/date/context/useSharedMonthContext.tsx b/@navikt/core/react/src/date/context/useSharedMonthContext.tsx deleted file mode 100644 index d3d5cce8da..0000000000 --- a/@navikt/core/react/src/date/context/useSharedMonthContext.tsx +++ /dev/null @@ -1,68 +0,0 @@ -import { setYear, startOfMonth } from "date-fns"; -import React, { createContext, useContext, useState } from "react"; -import { useDayPicker } from "react-day-picker"; -import { Matcher, getInitialYear } from "../utils"; - -export type SharedMonthContextType = { - hasDropdown: boolean; - year: Date; - toYear: (date: Date) => void; - disabled: Matcher[]; - selected?: Date; - onSelect: (v?: Date) => void; -}; - -export const SharedMonthContext = createContext({ - hasDropdown: false, - year: new Date(), - toYear: () => null, - disabled: [], - onSelect: () => null, -}); - -export const useSharedMonthContext = () => useContext(SharedMonthContext); - -export const SharedMonthProvider = ({ - children, - dropdownCaption, - disabled, - selected, - onSelect, - year: _year, - onYearChange, -}) => { - const context = useDayPicker(); - - const [year, toYear] = useState(getInitialYear(context)); - - const hasDropdown = !!(dropdownCaption && context.fromDate && context.toDate); - - if ( - context.fromDate && - context.toDate && - context?.fromDate >= context?.toDate - ) { - console.warn("fromDate needs to be before toDate - MonthPicker"); - } - - return ( - { - toYear(y); - onYearChange?.(y); - }, - hasDropdown, - disabled, - selected, - onSelect: (v?: Date) => - v - ? onSelect(setYear(startOfMonth(v), (_year ?? year).getFullYear())) - : onSelect(undefined), - }} - > - {children} - - ); -}; diff --git a/@navikt/core/react/src/date/date-utils/calendar-range.test.ts b/@navikt/core/react/src/date/date-utils/calendar-range.test.ts new file mode 100644 index 0000000000..5d0384a039 --- /dev/null +++ b/@navikt/core/react/src/date/date-utils/calendar-range.test.ts @@ -0,0 +1,54 @@ +import { + addYears, + endOfMonth, + endOfYear, + startOfDay, + startOfMonth, + startOfYear, +} from "date-fns"; +import { describe, expect, test } from "vitest"; +import { calendarRange } from "./calendar-range"; + +describe("calendarRange", () => { + test("should return start and end of the given months", () => { + const startMonth = new Date(2023, 0, 15); + const endMonth = new Date(2023, 11, 15); + const result = calendarRange({ startMonth, endMonth }); + + expect(result[0]).toEqual(startOfDay(startOfMonth(startMonth))); + expect(result[1]).toEqual(startOfDay(endOfMonth(endMonth))); + }); + + test("should return start of the year 100 years ago and end of the current year if captionLayout is dropdown", () => { + const today = new Date(2023, 6, 15); + const result = calendarRange({ captionLayout: "dropdown", today }); + + expect(result[0]).toEqual(startOfDay(startOfYear(addYears(today, -100)))); + expect(result[1]).toEqual(startOfDay(endOfYear(today))); + }); + + test("should return undefined if no startMonth and endMonth are provided", () => { + const today = new Date(2023, 6, 15); + const result = calendarRange({ today }); + + expect(result[0]).toBeUndefined(); + expect(result[1]).toBeUndefined(); + }); + + test("should handle undefined today date", () => { + const result = calendarRange({ captionLayout: "dropdown" }); + + const today = new Date(); + expect(result[0]).toEqual(startOfDay(startOfYear(addYears(today, -100)))); + expect(result[1]).toEqual(startOfDay(endOfYear(today))); + }); + + test("should handle undefined captionLayout", () => { + const startMonth = new Date(2023, 0, 15); + const endMonth = new Date(2023, 11, 15); + const result = calendarRange({ startMonth, endMonth }); + + expect(result[0]).toEqual(startOfDay(startOfMonth(startMonth))); + expect(result[1]).toEqual(startOfDay(endOfMonth(endMonth))); + }); +}); diff --git a/@navikt/core/react/src/date/date-utils/calendar-range.ts b/@navikt/core/react/src/date/date-utils/calendar-range.ts new file mode 100644 index 0000000000..0437e6015a --- /dev/null +++ b/@navikt/core/react/src/date/date-utils/calendar-range.ts @@ -0,0 +1,46 @@ +import { + addYears, + endOfMonth, + endOfYear, + startOfDay, + startOfMonth, + startOfYear, +} from "date-fns"; + +/** + * Generates the min and max-dates possible to show and navigate to in the calendar. + * In the cases there is not a startMonth or endMonth, and layout is "label" no min or max dates are set. + * @returns Return the start and end months for the calendar navigation. + */ +export function calendarRange({ + captionLayout, + startMonth, + endMonth, + today, +}: { + captionLayout?: "label" | "dropdown"; + startMonth?: Date; + endMonth?: Date; + today?: Date; +}) { + const hasYearDropdown = captionLayout === "dropdown"; + + const todayDate = today ?? new Date(); + + if (startMonth) { + startMonth = startOfMonth(startMonth); + } else if (!startMonth && hasYearDropdown) { + startMonth = startOfYear(addYears(todayDate, -100)); + } + + if (endMonth) { + endMonth = endOfMonth(endMonth); + } else if (!endMonth && hasYearDropdown) { + endMonth = endOfYear(todayDate); + } + + return [ + startMonth ? startOfDay(startMonth) : startMonth, + endMonth ? startOfDay(endMonth) : endMonth, + ]; +} diff --git a/@navikt/core/react/src/date/date-utils/check-dates.test.ts b/@navikt/core/react/src/date/date-utils/check-dates.test.ts new file mode 100644 index 0000000000..0cf330de9c --- /dev/null +++ b/@navikt/core/react/src/date/date-utils/check-dates.test.ts @@ -0,0 +1,49 @@ +import { setYear } from "date-fns"; +import { describe, expect, test } from "vitest"; +import { dateIsInCurrentMonth, 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", () => { + const date = new Date(); + const dateToCompare = new Date(); + expect(dateIsInCurrentMonth(date, dateToCompare)).toBe(true); + }); + + test("should return false if the date is not in the same month as the date to compare", () => { + const date = new Date(); + const dateToCompare = new Date(2023, 9, 1); // October 1, 2023 + expect(dateIsInCurrentMonth(date, dateToCompare)).toBe(false); + }); + + test("should return false if the date is in the same month but different year as the date to compare", () => { + const date = new Date(); + const dateToCompare = new Date(); + expect( + dateIsInCurrentMonth( + date, + setYear(dateToCompare, dateToCompare.getFullYear() + 1), + ), + ).toBe(false); + }); +}); + +describe("isValidDate", () => { + test("should return true for a valid date", () => { + const date = new Date(2023, 9, 15); // October 15, 2023 + expect(isValidDate(date)).toBe(true); + }); + + test("should return false for an invalid date", () => { + const date = new Date("invalid date"); + expect(isValidDate(date)).toBe(false); + }); + + test("should return false for a date with year less than 1000", () => { + const date = new Date(999, 9, 15); // October 15, 999 + expect(isValidDate(date)).toBe(false); + }); + + test("should return false for undefined", () => { + expect(isValidDate(undefined)).toBe(false); + }); +}); diff --git a/@navikt/core/react/src/date/utils/check-dates.ts b/@navikt/core/react/src/date/date-utils/check-dates.ts similarity index 100% rename from @navikt/core/react/src/date/utils/check-dates.ts rename to @navikt/core/react/src/date/date-utils/check-dates.ts diff --git a/@navikt/core/react/src/date/date-utils/clamp-dates.test.ts b/@navikt/core/react/src/date/date-utils/clamp-dates.test.ts new file mode 100644 index 0000000000..229e0501b5 --- /dev/null +++ b/@navikt/core/react/src/date/date-utils/clamp-dates.test.ts @@ -0,0 +1,73 @@ +import { startOfMonth, startOfYear } from "date-fns"; +import { describe, expect, test } from "vitest"; +import { clampDisplayMonth, clampDisplayYear } from "./clamp-dates"; + +describe("clampDisplayMonth", () => { + test("should return undefined if month is not provided", () => { + expect( + clampDisplayMonth({ start: new Date(), end: new Date() }), + ).toBeUndefined(); + }); + + test("should return the start of the month if month is before start", () => { + const start = new Date(2023, 5, 1); + const month = new Date(2023, 4, 1); + expect(clampDisplayMonth({ month, start, end: new Date() })).toEqual( + startOfMonth(start), + ); + }); + + test("should return the start of end month if month is after end", () => { + const end = new Date(2023, 5, 1); + const month = new Date(2023, 6, 1); + expect(clampDisplayMonth({ month, start: new Date(), end })).toEqual( + startOfMonth(end), + ); + }); + + test("should return the start of the month if month is within range", () => { + const month = new Date(2023, 5, 1); + expect( + clampDisplayMonth({ + month, + start: new Date(2023, 4, 1), + end: new Date(2023, 6, 1), + }), + ).toEqual(startOfMonth(month)); + }); +}); + +describe("clampDisplayYear", () => { + test("should return undefined if month is not provided", () => { + expect( + clampDisplayYear({ start: new Date(), end: new Date() }), + ).toBeUndefined(); + }); + + test("should return the start of the year if month is before start year", () => { + const start = new Date(2023, 0, 1); + const month = new Date(2022, 11, 1); + expect(clampDisplayYear({ month, start, end: new Date() })).toEqual( + startOfYear(start), + ); + }); + + test("should return the start of the year if month is after end year", () => { + const end = new Date(2023, 0, 1); + const month = new Date(2024, 0, 1); + expect(clampDisplayYear({ month, start: new Date(), end })).toEqual( + startOfYear(end), + ); + }); + + test("should return the start of the year if month is within range", () => { + const month = new Date(2023, 5, 1); + expect( + clampDisplayYear({ + month, + start: new Date(2022, 0, 1), + end: new Date(2024, 0, 1), + }), + ).toEqual(startOfYear(month)); + }); +}); diff --git a/@navikt/core/react/src/date/date-utils/clamp-dates.ts b/@navikt/core/react/src/date/date-utils/clamp-dates.ts new file mode 100644 index 0000000000..8da9656517 --- /dev/null +++ b/@navikt/core/react/src/date/date-utils/clamp-dates.ts @@ -0,0 +1,63 @@ +import { isAfter, isBefore, startOfMonth, startOfYear } from "date-fns"; + +/** + * Makes sure the month is within the min and max daterange to avoid showing disabled months + * @note We do not warn the user if start > end now + */ +const clampDisplayMonth = ({ + month, + start, + end, +}: { + month?: Date; + start?: Date; + end?: Date; +}): Date | undefined => { + if (!month) { + return undefined; + } + + let monthToShow = month; + + if (start && isBefore(monthToShow, start)) { + monthToShow = start; + } + + if (end && isAfter(monthToShow, end)) { + monthToShow = end; + } + + return startOfMonth(monthToShow); +}; + +/** + * Makes sure the month is within the min and max daterange to avoid showing disabled months + * @note We do not warn the user if start > end now + */ +const clampDisplayYear = ({ + month, + start, + end, +}: { + month?: Date; + start?: Date; + end?: Date; +}): Date | undefined => { + if (!month) { + return undefined; + } + + let monthToShow = month; + + if (start && monthToShow.getFullYear() < start.getFullYear()) { + monthToShow = start; + } + + if (end && monthToShow.getFullYear() > end.getFullYear()) { + monthToShow = end; + } + + return startOfYear(monthToShow); +}; + +export { clampDisplayYear, clampDisplayMonth }; diff --git a/@navikt/core/react/src/date/utils/__tests__/dates-disabled.test.ts b/@navikt/core/react/src/date/date-utils/dates-disabled.test.ts similarity index 97% rename from @navikt/core/react/src/date/utils/__tests__/dates-disabled.test.ts rename to @navikt/core/react/src/date/date-utils/dates-disabled.test.ts index ecea881249..5a03e3ae18 100644 --- a/@navikt/core/react/src/date/utils/__tests__/dates-disabled.test.ts +++ b/@navikt/core/react/src/date/date-utils/dates-disabled.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "vitest"; -import { disableDate } from ".."; +import { disableDate } from "./dates-disabled"; describe("Returns if date should be disabled", () => { test("Should be disabled using Date (true)", () => { diff --git a/@navikt/core/react/src/date/utils/dates-disabled.ts b/@navikt/core/react/src/date/date-utils/dates-disabled.ts similarity index 89% rename from @navikt/core/react/src/date/utils/dates-disabled.ts rename to @navikt/core/react/src/date/date-utils/dates-disabled.ts index 6315a385e3..ac370f34f1 100644 --- a/@navikt/core/react/src/date/utils/dates-disabled.ts +++ b/@navikt/core/react/src/date/date-utils/dates-disabled.ts @@ -1,7 +1,6 @@ import { isSameDay } from "date-fns"; -import { isDateRange } from "react-day-picker"; +import { isDateRange } from "../Date.typeutils"; -// TODO: ((date: Date) => boolean) export const disableDate = ( disabledSelection: Date | any[], date: Date, diff --git a/@navikt/core/react/src/date/date-utils/dropdown-options.test.ts b/@navikt/core/react/src/date/date-utils/dropdown-options.test.ts new file mode 100644 index 0000000000..44d2fc5e08 --- /dev/null +++ b/@navikt/core/react/src/date/date-utils/dropdown-options.test.ts @@ -0,0 +1,143 @@ +import { nb } from "date-fns/locale"; +import { describe, expect, test } from "vitest"; +import { getMonthOptions, getYearOptions } from "./dropdown-options"; + +describe("getYearOptions", () => { + test("should return undefined if navStart is undefined", () => { + const result = getYearOptions(undefined, new Date(), nb); + expect(result).toBeUndefined(); + }); + + test("should return undefined if navEnd is undefined", () => { + const result = getYearOptions(new Date(), undefined, nb); + expect(result).toBeUndefined(); + }); + + test("should return the correct year options within the interval", () => { + const navStart = new Date(2020, 0, 1); // Januar 1, 2020 + const navEnd = new Date(2022, 11, 31); // Desember 31, 2022 + const result = getYearOptions(navStart, navEnd, nb); + + const expected = [ + { value: 2020, label: "2020", disabled: false }, + { value: 2021, label: "2021", disabled: false }, + { value: 2022, label: "2022", disabled: false }, + ]; + + expect(result).toEqual(expected); + }); + + test("should return the correct year options for a single year", () => { + const navStart = new Date(2021, 0, 1); // Januar 1, 2021 + const navEnd = new Date(2021, 11, 31); // Desember 31, 2021 + const result = getYearOptions(navStart, navEnd, nb); + + const expected = [{ value: 2021, label: "2021", disabled: false }]; + + expect(result).toEqual(expected); + }); + + test("should return the correct year options when navStart and navEnd are the same date", () => { + const navStart = new Date(2021, 0, 1); // Januar 1, 2021 + const navEnd = new Date(2021, 0, 1); // Januar 1, 2021 + const result = getYearOptions(navStart, navEnd, nb); + + const expected = [{ value: 2021, label: "2021", disabled: false }]; + + expect(result).toEqual(expected); + }); +}); + +describe("getMonthOptions", () => { + test("should return the correct month options for the given year", () => { + const displayMonth = new Date(2021, 0, 1); // Januar 1, 2021 + const result = getMonthOptions(displayMonth, undefined, undefined, nb); + + const expected = [ + { value: 0, label: "januar", disabled: false }, + { value: 1, label: "februar", disabled: false }, + { value: 2, label: "mars", disabled: false }, + { value: 3, label: "april", disabled: false }, + { value: 4, label: "mai", disabled: false }, + { value: 5, label: "juni", disabled: false }, + { value: 6, label: "juli", disabled: false }, + { value: 7, label: "august", disabled: false }, + { value: 8, label: "september", disabled: false }, + { value: 9, label: "oktober", disabled: false }, + { value: 10, label: "november", disabled: false }, + { value: 11, label: "desember", disabled: false }, + ]; + + expect(result).toEqual(expected); + }); + + test("should disable months before navStart", () => { + const displayMonth = new Date(2021, 0, 1); // Januar 1, 2021 + const navStart = new Date(2021, 5, 1); // Juni 1, 2021 + const result = getMonthOptions(displayMonth, navStart, undefined, nb); + + const expected = [ + { value: 0, label: "januar", disabled: true }, + { value: 1, label: "februar", disabled: true }, + { value: 2, label: "mars", disabled: true }, + { value: 3, label: "april", disabled: true }, + { value: 4, label: "mai", disabled: true }, + { value: 5, label: "juni", disabled: false }, + { value: 6, label: "juli", disabled: false }, + { value: 7, label: "august", disabled: false }, + { value: 8, label: "september", disabled: false }, + { value: 9, label: "oktober", disabled: false }, + { value: 10, label: "november", disabled: false }, + { value: 11, label: "desember", disabled: false }, + ]; + + expect(result).toEqual(expected); + }); + + test("should disable months after navEnd", () => { + const displayMonth = new Date(2021, 0, 1); // Januar 1, 2021 + const navEnd = new Date(2021, 5, 1); // Juni 1, 2021 + const result = getMonthOptions(displayMonth, undefined, navEnd, nb); + + const expected = [ + { value: 0, label: "januar", disabled: false }, + { value: 1, label: "februar", disabled: false }, + { value: 2, label: "mars", disabled: false }, + { value: 3, label: "april", disabled: false }, + { value: 4, label: "mai", disabled: false }, + { value: 5, label: "juni", disabled: false }, + { value: 6, label: "juli", disabled: true }, + { value: 7, label: "august", disabled: true }, + { value: 8, label: "september", disabled: true }, + { value: 9, label: "oktober", disabled: true }, + { value: 10, label: "november", disabled: true }, + { value: 11, label: "desember", disabled: true }, + ]; + + expect(result).toEqual(expected); + }); + + test("should disable months before navStart and after navEnd", () => { + const displayMonth = new Date(2021, 0, 1); // Januar 1, 2021 + const navStart = new Date(2021, 3, 1); // April 1, 2021 + const navEnd = new Date(2021, 8, 1); // September 1, 2021 + const result = getMonthOptions(displayMonth, navStart, navEnd, nb); + + const expected = [ + { value: 0, label: "januar", disabled: true }, + { value: 1, label: "februar", disabled: true }, + { value: 2, label: "mars", disabled: true }, + { value: 3, label: "april", disabled: false }, + { value: 4, label: "mai", disabled: false }, + { value: 5, label: "juni", disabled: false }, + { value: 6, label: "juli", disabled: false }, + { value: 7, label: "august", disabled: false }, + { value: 8, label: "september", disabled: false }, + { value: 9, label: "oktober", disabled: true }, + { value: 10, label: "november", disabled: true }, + { value: 11, label: "desember", disabled: true }, + ]; + + expect(result).toEqual(expected); + }); +}); diff --git a/@navikt/core/react/src/date/date-utils/dropdown-options.ts b/@navikt/core/react/src/date/date-utils/dropdown-options.ts new file mode 100644 index 0000000000..71222e79c5 --- /dev/null +++ b/@navikt/core/react/src/date/date-utils/dropdown-options.ts @@ -0,0 +1,79 @@ +import { + Locale, + addYears, + eachMonthOfInterval, + endOfYear, + format, + getMonth, + getYear, + isBefore, + isSameYear, + startOfMonth, + startOfYear, +} from "date-fns"; + +/** Return the months to show in the dropdown. */ +export function getMonthOptions( + displayMonth: Date, + navStart: Date | undefined, + navEnd: Date | undefined, + locale: Locale, +): + | { + value: number; + label: string; + disabled: boolean; + }[] + | undefined { + const months = eachMonthOfInterval({ + start: startOfYear(displayMonth), + end: endOfYear(displayMonth), + }); + + const options = months.map((month) => { + const label = format(month, "LLLL", { locale }); + const value = getMonth(month); + const disabled = + (navStart && month < startOfMonth(navStart)) || + (navEnd && month > startOfMonth(navEnd)) || + false; + return { value, label, disabled }; + }); + + return options; +} + +/** Return the years to show in the dropdown. */ +export function getYearOptions( + navStart: Date | undefined, + navEnd: Date | undefined, + locale: Locale, +): + | { + value: number; + label: string; + disabled: boolean; + }[] + | undefined { + if (!navStart) return undefined; + if (!navEnd) return undefined; + + const firstNavYear = startOfYear(navStart); + const lastNavYear = endOfYear(navEnd); + const years: Date[] = []; + + let year = firstNavYear; + while (isBefore(year, lastNavYear) || isSameYear(year, lastNavYear)) { + years.push(year); + year = addYears(year, 1); + } + + return years.map((_year) => { + const label = format(_year, "yyyy", { locale }); + return { + value: getYear(_year), + label, + disabled: false, + }; + }); +} diff --git a/@navikt/core/react/src/date/utils/format-date.ts b/@navikt/core/react/src/date/date-utils/format-date.ts similarity index 100% rename from @navikt/core/react/src/date/utils/format-date.ts rename to @navikt/core/react/src/date/date-utils/format-date.ts diff --git a/@navikt/core/react/src/date/utils/__tests__/format-dates.test.ts b/@navikt/core/react/src/date/date-utils/format-dates.test.ts similarity index 91% rename from @navikt/core/react/src/date/utils/__tests__/format-dates.test.ts rename to @navikt/core/react/src/date/date-utils/format-dates.test.ts index 97f97ff23b..6c59ebd12d 100644 --- a/@navikt/core/react/src/date/utils/__tests__/format-dates.test.ts +++ b/@navikt/core/react/src/date/date-utils/format-dates.test.ts @@ -1,7 +1,7 @@ import { nb } from "date-fns/locale"; import { describe, expect, test } from "vitest"; -import { formatDateForInput } from "../format-date"; -import { parseDate } from "../parse-date"; +import { formatDateForInput } from "./format-date"; +import { parseDate } from "./parse-date"; const parse = (inp: string) => parseDate(inp, new Date(), nb, "date", false); const parseTwoDigit = (inp: string) => diff --git a/@navikt/core/react/src/date/date-utils/index.ts b/@navikt/core/react/src/date/date-utils/index.ts new file mode 100644 index 0000000000..df6c9bf6cc --- /dev/null +++ b/@navikt/core/react/src/date/date-utils/index.ts @@ -0,0 +1,12 @@ +export { formatDateForInput } from "./format-date"; +export { + INPUT_DATE_STRING_FORMAT_DATE, + INPUT_DATE_STRING_FORMAT_MONTH, + parseDate, +} from "./parse-date"; +export { disableDate } from "./dates-disabled"; +export { dateIsInCurrentMonth, isValidDate } from "./check-dates"; +export { isMatch, isDateInRange } from "./is-match"; +export { clampDisplayMonth, clampDisplayYear } from "./clamp-dates"; +export { getMonthOptions, getYearOptions } from "./dropdown-options"; +export { calendarRange } from "./calendar-range"; diff --git a/@navikt/core/react/src/date/utils/__tests__/is-match.test.ts b/@navikt/core/react/src/date/date-utils/is-match.test.ts similarity index 97% rename from @navikt/core/react/src/date/utils/__tests__/is-match.test.ts rename to @navikt/core/react/src/date/date-utils/is-match.test.ts index 6eb4661043..9e913d87a9 100644 --- a/@navikt/core/react/src/date/utils/__tests__/is-match.test.ts +++ b/@navikt/core/react/src/date/date-utils/is-match.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "vitest"; -import { isMatch } from ".."; +import { isMatch } from "./is-match"; const disabled = [ new Date("Sep 8 2022"), diff --git a/@navikt/core/react/src/date/utils/is-match.ts b/@navikt/core/react/src/date/date-utils/is-match.ts similarity index 91% rename from @navikt/core/react/src/date/utils/is-match.ts rename to @navikt/core/react/src/date/date-utils/is-match.ts index 7e499b501a..119050d2f7 100644 --- a/@navikt/core/react/src/date/utils/is-match.ts +++ b/@navikt/core/react/src/date/date-utils/is-match.ts @@ -5,21 +5,12 @@ import { isSameMonth, } from "date-fns"; import { - DateAfter, - DateBefore, DateRange, + Matcher, isDateAfterType, isDateBeforeType, isDateRange, -} from "react-day-picker"; - -export type Matcher = - | ((date: Date) => boolean) - | Date - | Date[] - | DateRange - | DateBefore - | DateAfter; +} from "../Date.typeutils"; function isDateType(value: unknown): value is Date { return isDate(value); diff --git a/@navikt/core/react/src/date/utils/parse-date.ts b/@navikt/core/react/src/date/date-utils/parse-date.ts similarity index 100% rename from @navikt/core/react/src/date/utils/parse-date.ts rename to @navikt/core/react/src/date/date-utils/parse-date.ts diff --git a/@navikt/core/react/src/date/utils/__tests__/parse-dates.test.ts b/@navikt/core/react/src/date/date-utils/parse-dates.test.ts similarity index 97% rename from @navikt/core/react/src/date/utils/__tests__/parse-dates.test.ts rename to @navikt/core/react/src/date/date-utils/parse-dates.test.ts index 5000611724..5eb269019a 100644 --- a/@navikt/core/react/src/date/utils/__tests__/parse-dates.test.ts +++ b/@navikt/core/react/src/date/date-utils/parse-dates.test.ts @@ -1,7 +1,8 @@ import { getMonth } from "date-fns"; import { nb } from "date-fns/locale"; import { describe, expect, test } from "vitest"; -import { isValidDate, parseDate } from ".."; +import { isValidDate } from "./check-dates"; +import { parseDate } from "./parse-date"; const check = (inp: string) => // eslint-disable-next-line @vitest/valid-expect diff --git a/@navikt/core/react/src/date/datepicker/datepicker.stories.tsx b/@navikt/core/react/src/date/datepicker/DatePicker.stories.tsx similarity index 87% rename from @navikt/core/react/src/date/datepicker/datepicker.stories.tsx rename to @navikt/core/react/src/date/datepicker/DatePicker.stories.tsx index 0483885452..a3cffc5fcf 100644 --- a/@navikt/core/react/src/date/datepicker/datepicker.stories.tsx +++ b/@navikt/core/react/src/date/datepicker/DatePicker.stories.tsx @@ -1,14 +1,13 @@ import { Meta, StoryObj } from "@storybook/react"; -import { expect, userEvent, within } from "@storybook/test"; import { isSameDay } from "date-fns"; import React, { useId, useState } from "react"; import { Button } from "../../button"; import { HGrid } from "../../layout/grid"; -import { VStack } from "../../layout/stack"; import Modal from "../../modal/Modal"; import { BodyLong } from "../../typography"; -import { useDatepicker, useRangeDatepicker } from "../hooks"; import DatePicker, { DatePickerProps } from "./DatePicker"; +import { useDatepicker } from "./hooks/useDatepicker"; +import { useRangeDatepicker } from "./hooks/useRangeDatepicker"; const disabledDays = [ new Date("Oct 10 2022"), @@ -147,14 +146,10 @@ export const DisabledDays = () => ( /> ); -export const ShowWeekNumber = () => ( - -); - export const UseDatepicker = () => { const { datepickerProps, inputProps } = useDatepicker({ fromDate: new Date("Aug 23 2019"), - toDate: new Date("Feb 23 2024"), + toDate: new Date("Feb 23 2029"), onDateChange: console.log, onValidate: console.log, }); @@ -200,7 +195,10 @@ export const EN = () => ( ); export const Standalone = () => ( - + ); export const StandaloneRange = () => ( @@ -298,22 +296,6 @@ export const UseRangedDatepickerValidation = () => { ); }; -export const DefaultShownMonth = () => { - const { datepickerProps, inputProps } = useDatepicker({ - fromDate: new Date("Aug 23 2019"), - onDateChange: console.log, - defaultMonth: new Date("Oct 23 2022"), - }); - - return ( -
- - - -
- ); -}; - export const Size = () => { const { datepickerProps, inputProps } = useDatepicker({ fromDate: new Date("Aug 23 2019"), @@ -399,7 +381,7 @@ export const StandaloneOptions = () => { ); }; -export const WeekDayClick = () => { +export const WeekNumber = () => { const [days, setDays] = useState([]); const handleWeekClick = (dates: Date[]) => { @@ -414,7 +396,8 @@ export const WeekDayClick = () => { }; return ( - + + { onSelect={(dates) => dates && setDays(dates)} selected={days} today={new Date("Nov 23 2022")} + toDate={new Date("Nov 19 2023")} /> { today={new Date("Nov 23 2022")} disableWeekends /> - + + + + handleWeekClick(dates)} + selected={days} + > + + + ); }; -export const Required = { - render: () => { - const { datepickerProps } = useDatepicker({ - defaultSelected: new Date("Apr 10 2024"), - required: true, - }); - - return ( -
- -
- ); - }, - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - - const button10 = canvas.getByRole("button", { pressed: true }); - - await userEvent.click(button10); - - expect(button10.ariaPressed).toBe("true"); - - const button17 = canvas.getByText("17").closest("button"); - - expect(button17?.ariaPressed).toBe("false"); - - button17 && (await userEvent.click(button17)); - - expect(button17?.ariaPressed).toBe("true"); - expect(button10.ariaPressed).toBe("false"); - }, -}; - export const ModalDemo = () => { const { datepickerProps, inputProps } = useDatepicker({ fromDate: new Date("Aug 23 2019"), @@ -520,10 +490,6 @@ export const Chromatic: Story = {

DisabledDays

-
-

ShowWeekNumber

- -

UseDatepicker

@@ -576,10 +542,6 @@ export const Chromatic: Story = {

UseRangedDatepickerValidation

-
-

DefaultShownMonth

- -

Size

@@ -593,8 +555,8 @@ export const Chromatic: Story = {
-

WeekDayClick

- +

WeekNumber

+
), diff --git a/@navikt/core/react/src/date/datepicker/DatePicker.tests.stories.tsx b/@navikt/core/react/src/date/datepicker/DatePicker.tests.stories.tsx new file mode 100644 index 0000000000..2d8110e768 --- /dev/null +++ b/@navikt/core/react/src/date/datepicker/DatePicker.tests.stories.tsx @@ -0,0 +1,682 @@ +import { Meta, StoryObj } from "@storybook/react"; +import { expect, userEvent, within } from "@storybook/test"; +import { addMonths, format } from "date-fns"; +import { nb } from "date-fns/locale"; +import React from "react"; +import DatePicker from "./DatePicker"; +import { useDatepicker } from "./hooks/useDatepicker"; + +export default { + title: "ds-react/DatePicker/Tests", + component: DatePicker, + parameters: { + chromatic: { disable: true }, + }, +} satisfies Meta; + +type Story = StoryObj; + +const currentDate = new Date(); +const previousMonth = addMonths(currentDate, -1); +const nextMonth = addMonths(currentDate, 1); + +/** + * Validate that the monthpicker shows the current year by default + * and that the navigation buttons work as expected + */ +export const LabelCaptionNavigation: Story = { + render: () => , + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + const label = canvas.getByText( + format(currentDate, "LLLL y", { locale: nb }), + ); + + expect(label).toBeInTheDocument(); + + const todayButton = canvas.getByRole("button", { + name: format(currentDate, "cccc d", { + locale: nb, + }), + }); + + expect(todayButton.getAttribute("data-today")).toBe("true"); + + await userEvent.tab(); + await userEvent.keyboard("{Enter}"); + + expect( + canvas.getByText(format(previousMonth, "LLLL y", { locale: nb })), + ).toBeInTheDocument(); + + await userEvent.tab(); + await userEvent.keyboard("{Enter}"); + await userEvent.keyboard("{Enter}"); + + expect( + canvas.getByText(format(nextMonth, "LLLL y", { locale: nb })), + ).toBeInTheDocument(); + }, +}; + +/** + * Validate that the monthpicker shows the current year by default + * and that the navigation selects work as expected + */ +export const DropdownCaptionNavigation: Story = { + render: () => ( + + ), + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + let dropdown = canvas.getByDisplayValue("2025"); + + await userEvent.selectOptions(dropdown, "2021"); + + expect( + canvas.getByRole("button", { + name: format(new Date("04 May 2021"), "cccc d", { locale: nb }), + }), + ).toBeDisabled(); + + dropdown = canvas.getByDisplayValue("2021"); + + await userEvent.selectOptions(dropdown, "2029"); + + expect( + canvas.getByRole("button", { + name: format(new Date("06 Apr 2029"), "cccc d", { locale: nb }), + }), + ).toBeDisabled(); + }, +}; + +export const Required: Story = { + render: () => { + const { datepickerProps } = useDatepicker({ + defaultSelected: new Date("Apr 10 2024"), + required: true, + }); + + return ( +
+ +
+ ); + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + const button10 = canvas.getByRole("button", { pressed: true }); + + await userEvent.click(button10); + + expect(button10.ariaPressed).toBe("true"); + + const button17 = canvas.getByText("17").closest("button"); + + expect(button17?.ariaPressed).toBe("false"); + + if (button17) { + await userEvent.click(button17); + } + + expect(canvas.getByText("17").closest("button")?.ariaPressed).toBe("true"); + expect(canvas.getByText("10").closest("button")?.ariaPressed).toBe("false"); + }, +}; + +export const DomStructure: Story = { + render: () => ( + + ), + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + const picker = canvas.getByTestId("date-table"); + + expect(picker.className).toEqual("rdp navds-date"); + + const months = picker.firstChild; + expect(months).toBeInTheDocument(); + + if (months) { + expect( + (months as HTMLDivElement).className.startsWith("rdp-months"), + ).toBeTruthy(); + expect(months.nodeName).toBe("DIV"); + } + + const month = months?.firstChild; + expect(month).toBeInTheDocument(); + + if (month) { + expect( + (month as HTMLDivElement).className.startsWith("rdp-month"), + ).toBeTruthy(); + expect(month.nodeName).toBe("DIV"); + } + + const table = month?.lastChild; + expect(table).toBeInTheDocument(); + + if (table) { + const typedTable = table as HTMLTableElement; + expect(typedTable.className.startsWith("rdp-table")).toBeTruthy(); + expect((table as HTMLTableElement).nodeName).toBe("TABLE"); + + expect(typedTable.role).toEqual("grid"); + } + + const tHead = table?.firstChild; + expect(tHead).toBeInTheDocument(); + + if (tHead) { + expect(tHead.nodeName).toBe("THEAD"); + expect((tHead as HTMLElement).className).toEqual("rdp-head"); + expect((tHead as HTMLElement).ariaHidden).toBe("true"); + } + + const tBody = table?.lastChild; + expect(tBody).toBeInTheDocument(); + + if (tBody) { + expect(tBody.nodeName).toBe("TBODY"); + expect((tBody as HTMLElement).className).toEqual("rdp-tbody"); + } + + const row = tBody?.firstChild; + expect(row).toBeInTheDocument(); + + if (row) { + expect(row.nodeName).toBe("TR"); + expect((row as HTMLElement).className).toEqual("rdp-row"); + } + + const cells = row?.childNodes; + + /** + * Since default selected date is 3 Jan 2025: + * - node 0 and 1 is "invisible" + * - node 2 and 3 is unselected + * - node 4 is selected + * - node 5 and 6 is unselected + */ + if (cells) { + expect(cells.length).toBe(7); + + cells.forEach((cell, index) => { + expect(cell.nodeName).toBe("TD"); + expect((cell as HTMLElement).className).toEqual("rdp-cell"); + + const button = cell.firstChild; + + expect(button).toBeInTheDocument(); + + if (button) { + const typedButton = button as HTMLButtonElement; + expect(button.nodeName).toBe("BUTTON"); + expect(typedButton.type).toBe("button"); + expect(typedButton.classList.contains("rdp-button")).toBeTruthy(); + expect(typedButton.ariaLabel).toBeTruthy(); + + if (index <= 1) { + expect(typedButton.ariaHidden).toBe("true"); + } + + if (index !== 4) { + expect(typedButton.ariaPressed).toBe("false"); + expect(typedButton.tabIndex).toBe(-1); + } else { + expect( + typedButton.classList.contains("rdp-day_selected"), + ).toBeTruthy(); + expect(typedButton.ariaPressed).toBe("true"); + expect(typedButton.tabIndex).toBe(0); + } + } + }); + } + }, +}; + +const oldDate = new Date("Oct 23 2022"); + +export const HookDefaultMonth: Story = { + render: () => { + const { datepickerProps, inputProps } = useDatepicker({ + fromDate: new Date("Aug 23 2019"), + onDateChange: console.log, + defaultMonth: oldDate, + }); + + return ( +
+ + + +
+ ); + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + const button = canvas.getByLabelText("Åpne datovelger"); + + expect(button).toBeInTheDocument(); + + await userEvent.click(button); + const dialog = canvas.getByRole("dialog"); + + expect(dialog).toBeVisible(); + expect(dialog.ariaHidden).toBe("false"); + + const label = within(dialog).getByText( + format(oldDate, "LLLL y", { locale: nb }), + {}, + ); + + expect(label).toBeInTheDocument(); + }, +}; + +export const HookDefaultMonthWhenSelected: Story = { + render: () => { + const { datepickerProps, inputProps } = useDatepicker({ + fromDate: new Date("Aug 23 2019"), + onDateChange: console.log, + defaultSelected: oldDate, + }); + + return ( +
+ + + +
+ ); + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + const button = canvas.getByLabelText("Åpne datovelger"); + + expect(button).toBeInTheDocument(); + + await userEvent.click(button); + const dialog = canvas.getByRole("dialog"); + + expect(dialog).toBeVisible(); + expect(dialog.ariaHidden).toBe("false"); + + const label = within(dialog).getByText( + format(oldDate, "LLLL y", { locale: nb }), + {}, + ); + + expect(label).toBeInTheDocument(); + + const selectedButton = within(dialog).getByRole("button", { + pressed: true, + }); + + expect(selectedButton.ariaLabel).toEqual("søndag 23"); + }, +}; + +const fallbackToFromDate = new Date("Aug 23 2019"); + +export const HookFallbackToFromDate: Story = { + render: () => { + const { datepickerProps, inputProps } = useDatepicker({ + fromDate: fallbackToFromDate, + onDateChange: console.log, + defaultMonth: new Date("Oct 23 2010"), + }); + + return ( +
+ + + +
+ ); + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + const button = canvas.getByLabelText("Åpne datovelger"); + + expect(button).toBeInTheDocument(); + + await userEvent.click(button); + const dialog = canvas.getByRole("dialog"); + + expect(dialog).toBeVisible(); + expect(dialog.ariaHidden).toBe("false"); + + const label = within(dialog).getByText( + format(fallbackToFromDate, "LLLL y", { locale: nb }), + {}, + ); + + expect(label).toBeInTheDocument(); + }, +}; + +const fallbackToToDate = new Date("Aug 23 2028"); + +export const HookFallbackToDate: Story = { + render: () => { + const { datepickerProps, inputProps } = useDatepicker({ + toDate: fallbackToToDate, + onDateChange: console.log, + defaultMonth: new Date("Oct 23 2030"), + }); + + return ( +
+ + + +
+ ); + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + const button = canvas.getByLabelText("Åpne datovelger"); + + expect(button).toBeInTheDocument(); + + await userEvent.click(button); + const dialog = canvas.getByRole("dialog"); + + expect(dialog).toBeVisible(); + expect(dialog.ariaHidden).toBe("false"); + + const label = within(dialog).getByText( + format(fallbackToToDate, "LLLL y", { locale: nb }), + {}, + ); + + expect(label).toBeInTheDocument(); + }, +}; + +// /** +// * Validate that the monthpicker shows the previous year when `year`-prop is set to the previous year +// */ +// export const PreviousYearView: Story = { +// render: () => , +// play: async ({ canvasElement }) => { +// const canvas = within(canvasElement); +// +// const label = canvas.getByText(`${previousYear.getFullYear()}`); +// +// expect(label).toBeInTheDocument(); +// +// const todayButton = canvas.getByRole("button", { +// name: format(previousYear, "LLLL", { locale: nb }), +// }); +// +// expect(todayButton.getAttribute("data-current-month")).toBe("false"); +// }, +// }; +// +// /** +// * Validate that the monthpicker shows the next year when `year`-prop is set to the next year +// */ +// export const NextYearView: Story = { +// render: () => , +// play: async ({ canvasElement }) => { +// const canvas = within(canvasElement); +// +// const label = canvas.getByText(`${nextYear.getFullYear()}`); +// +// expect(label).toBeInTheDocument(); +// +// const todayButton = canvas.getByRole("button", { +// name: format(nextYear, "LLLL", { locale: nb }), +// }); +// +// expect(todayButton.getAttribute("data-current-month")).toBe("false"); +// }, +// }; +// +// /** +// * Validate that the monthpicker shows the previous year when initial `selected` is set to the previous year +// */ +// export const InitialControlledView: Story = { +// render: () => , +// play: async ({ canvasElement }) => { +// const canvas = within(canvasElement); +// +// const label = canvas.getByText(`${previousYear.getFullYear()}`); +// +// expect(label).toBeInTheDocument(); +// +// const todayButton = canvas.getByRole("button", { +// name: format(previousYear, "LLLL", { locale: nb }), +// }); +// +// expect(todayButton.getAttribute("data-current-month")).toBe("false"); +// }, +// }; +// +// /** +// * Validate that the monthpicker shows the previous year when initial `defaultSelected` is set to the previous year +// */ +// export const InitialDefaultControlledView: Story = { +// render: () => , +// play: async ({ canvasElement }) => { +// const canvas = within(canvasElement); +// +// const label = canvas.getByText(`${previousYear.getFullYear()}`); +// +// expect(label).toBeInTheDocument(); +// +// const todayButton = canvas.getByRole("button", { +// name: format(previousYear, "LLLL", { locale: nb }), +// }); +// +// expect(todayButton.getAttribute("data-current-month")).toBe("false"); +// }, +// }; +// +// /** +// * Validate that the monthpicker handles `disabled` prop correctly +// */ +// export const DisabledMonths: Story = { +// render: () => ( +// +// ), +// play: async ({ canvasElement }) => { +// const canvas = within(canvasElement); +// +// const disabledButtons = [ +// canvas.getByRole("button", { +// name: format(new Date("Feb 1 2023"), "LLLL", { locale: nb }), +// }), +// canvas.getByRole("button", { +// name: format(new Date("Mar 1 2023"), "LLLL", { locale: nb }), +// }), +// canvas.getByRole("button", { +// name: format(new Date("Apr 1 2023"), "LLLL", { locale: nb }), +// }), +// canvas.getByRole("button", { +// name: format(new Date("May 1 2023"), "LLLL", { locale: nb }), +// }), +// canvas.getByRole("button", { +// name: format(new Date("Sep 1 2023"), "LLLL", { locale: nb }), +// }), +// ]; +// +// disabledButtons.forEach((button) => { +// expect(button).toBeDisabled(); +// }); +// }, +// }; +// +// /** +// * Validate that the monthpicker handles `dropdownCaption` prop correctly +// */ +// export const LabelCaptionView: Story = { +// render: () => { +// const [year, setLocalYear] = React.useState(new Date("2025")); +// +// return ( +// y && setLocalYear(y)} +// /> +// ); +// }, +// play: async ({ canvasElement }) => { +// const canvas = within(canvasElement); +// +// const label = canvas.getByText("2025"); +// +// expect(label).toBeInTheDocument(); +// +// await userEvent.keyboard("{Tab}"); +// await userEvent.keyboard("{Enter}"); +// +// expect(label.textContent).toBe("2024"); +// +// await userEvent.keyboard("{Tab}"); +// await userEvent.keyboard("{Enter}"); +// await userEvent.keyboard("{Enter}"); +// expect(label.textContent).toBe("2026"); +// }, +// }; +// +// /** +// * Validate that the monthpicker handles `dropdownCaption` prop correctly +// */ +// export const DropdownCaptionView: Story = { +// render: () => { +// const [year, setLocalYear] = React.useState(new Date("2025")); +// +// return ( +// y && setLocalYear(y)} +// /> +// ); +// }, +// play: async ({ canvasElement }) => { +// const canvas = within(canvasElement); +// +// const dropdown = canvas.getByDisplayValue("2025"); +// +// expect(dropdown).toBeInTheDocument(); +// +// await userEvent.selectOptions(dropdown, "2023"); +// expect((dropdown as HTMLSelectElement).value).toBe("2023"); +// +// await userEvent.keyboard("{Tab}"); +// await userEvent.keyboard("{Enter}"); +// +// expect((dropdown as HTMLSelectElement).value).toBe("2024"); +// +// await userEvent.selectOptions(dropdown, "2028"); +// +// expect((dropdown as HTMLSelectElement).value).toBe("2028"); +// +// await userEvent.keyboard("{Tab}"); +// await userEvent.keyboard("{Enter}"); +// +// expect((dropdown as HTMLSelectElement).value).toBe("2028"); +// +// await userEvent.selectOptions(dropdown, "2022"); +// expect((dropdown as HTMLSelectElement).value).toBe("2022"); +// +// await userEvent.tab({ shift: true }); +// await userEvent.keyboard("{Enter}"); +// expect((dropdown as HTMLSelectElement).value).toBe("2022"); +// }, +// }; +// +// /** +// * Validate that the monthpicker handles `dropdownCaption` prop correctly +// */ +// export const MonthSelect: StoryObj<{ +// onMonthSelect: ReturnType; +// }> = { +// args: { +// onMonthSelect: fn(), +// }, +// render: (props) => { +// const [year, setLocalYear] = React.useState(new Date("2025")); +// +// return ( +// y && setLocalYear(y)} +// {...props} +// /> +// ); +// }, +// play: async ({ canvasElement, args }) => { +// args.onMonthSelect.mockClear(); +// const canvas = within(canvasElement); +// +// const dropdown = canvas.getByDisplayValue("2025"); +// expect(dropdown).toBeInTheDocument(); +// +// let febButton = canvas.getByRole("button", { +// name: format(new Date("Feb 01 2025"), "LLLL", { locale: nb }), +// }); +// expect(febButton).toBeInTheDocument(); +// +// await userEvent.click(febButton); +// +// expect(args.onMonthSelect).toBeCalledTimes(1); +// expect(args.onMonthSelect.mock.calls[0][0].toString()).toEqual( +// new Date("Feb 01 2025").toString(), +// ); +// +// await userEvent.selectOptions(dropdown, "2023"); +// +// febButton = canvas.getByRole("button", { +// name: format(new Date("Feb 01 2023"), "LLLL", { locale: nb }), +// }); +// expect(febButton).toBeInTheDocument(); +// await userEvent.click(febButton); +// +// expect(args.onMonthSelect).toBeCalledTimes(2); +// expect(args.onMonthSelect.mock.calls[1][0].toString()).toEqual( +// new Date("Feb 01 2023").toString(), +// ); +// +// await userEvent.click(febButton); +// +// expect(args.onMonthSelect).toBeCalledTimes(3); +// expect(args.onMonthSelect.mock.calls[2][0]).toBeUndefined(); +// }, +// }; +// diff --git a/@navikt/core/react/src/date/datepicker/DatePicker.tsx b/@navikt/core/react/src/date/datepicker/DatePicker.tsx index 6b0c221f36..93ab876800 100644 --- a/@navikt/core/react/src/date/datepicker/DatePicker.tsx +++ b/@navikt/core/react/src/date/datepicker/DatePicker.tsx @@ -1,23 +1,23 @@ import cl from "clsx"; -import { isWeekend } from "date-fns"; +import { isSameDay } from "date-fns"; import React, { forwardRef, useState } from "react"; -import { DateRange, DayPicker, isMatch } from "react-day-picker"; -import { omit } from "../../util"; -import { useId } from "../../util/hooks"; +import { DateRange } from "react-day-picker"; +import { useControllableState, useId } from "../../util/hooks"; import { useMergeRefs } from "../../util/hooks/useMergeRefs"; -import { useDateLocale, useI18n } from "../../util/i18n/i18n.hooks"; -import { DateInputContext, DateTranslationContextProvider } from "../context"; -import { DatePickerInput } from "../parts/DateInput"; -import { DateWrapper } from "../parts/DateWrapper"; -import { getLocaleFromString, getTranslations } from "../utils"; -import DatePickerStandalone from "./DatePickerStandalone"; -import Caption from "./parts/Caption"; -import DropdownCaption from "./parts/DropdownCaption"; -import { HeadRow } from "./parts/HeadRow"; -import Row from "./parts/Row"; -import TableHead from "./parts/TableHead"; -import WeekNumber from "./parts/WeekNumber"; -import { ConditionalModeProps, DatePickerDefaultProps } from "./types"; +import { useI18n } from "../../util/i18n/i18n.hooks"; +import { DateDialog } from "../Date.Dialog"; +import { DateInputContextProvider, DatePickerInput } from "../Date.Input"; +import { + DateTranslationContextProvider, + getTranslations, +} from "../Date.locale"; +import { isDateRange } from "../Date.typeutils"; +import { + ConditionalModeProps, + DatePickerDefaultProps, +} from "./DatePicker.types"; +import { ReactDayPicker } from "./parts/DatePicker.RDP"; +import DatePickerStandalone from "./parts/DatePicker.Standalone"; export type DatePickerProps = DatePickerDefaultProps & ConditionalModeProps; @@ -69,20 +69,15 @@ export const DatePicker = forwardRef( children, locale, translations, - dropdownCaption, - disabled = [], - disableWeekends = false, - showWeekNumber = false, selected, id, defaultSelected, - className, wrapperClassName, open: _open, onClose, onOpenToggle, strategy, - onWeekNumberClick, + mode, ...rest }, ref, @@ -92,104 +87,84 @@ export const DatePicker = forwardRef( translations, getTranslations(locale), ); - const langProviderLocale = useDateLocale(); const ariaId = useId(id); - const [open, setOpen] = useState(_open ?? false); + + const [open, setOpen] = useControllableState({ + defaultValue: false, + value: _open, + }); /* We use state here to insure that anchor is defined if open is true on initial render */ const [wrapperRef, setWrapperRef] = useState(null); const mergedRef = useMergeRefs(setWrapperRef, ref); - const [selectedDates, setSelectedDates] = React.useState< + const [value, setValue] = useControllableState< Date | Date[] | DateRange | undefined - >(defaultSelected); - - const mode = rest.mode ?? ("single" as any); + >({ + defaultValue: defaultSelected, + value: selected, + onChange: (newValue) => { + let closeDialog = false; + if (mode === "single" && newValue) { + closeDialog = true; + } else if (isDateRange(newValue) && newValue.from && newValue.to) { + closeDialog = true; - /** - * @param newSelected Date | Date[] | DateRange | undefined - */ - const handleSelect = (newSelected) => { - setSelectedDates(newSelected); + if (isSameDay(newValue.from, newValue.to)) { + closeDialog = false; + } + } - if (rest.mode === "single") { - newSelected && (onClose?.() ?? setOpen(false)); - } else if (rest.mode === "range") { - newSelected?.from && newSelected?.to && (onClose?.() ?? setOpen(false)); - } - rest?.onSelect?.(newSelected); - }; + if (closeDialog) { + onClose?.(); + setOpen(false); + } - const DatePickerComponent = ( - { - return (disableWeekends && isWeekend(day)) || isMatch(day, disabled); - }} - weekStartsOn={1} - initialFocus={false} - modifiers={{ - weekend: (day) => disableWeekends && isWeekend(day), - }} - modifiersClassNames={{ - weekend: "rdp-day__weekend", - }} - showWeekNumber={showWeekNumber} - onWeekNumberClick={mode === "multiple" ? onWeekNumberClick : undefined} - fixedWeeks - showOutsideDays - {...omit(rest, ["onSelect"])} - /> - ); + rest?.onSelect?.(newValue as any); + }, + }); return ( - { - setOpen((x) => !x); - onOpenToggle?.(); - }, - ariaId, - defined: true, + { + setOpen((x) => !x); + onOpenToggle?.(); }} + ariaId={ariaId} + defined={true} >
{children} - onClose?.() ?? setOpen(false)} + onClose={() => { + onClose?.(); + open && setOpen(false); + }} locale={locale} translate={translate} - variant={mode} + variant={mode ?? "single"} popoverProps={{ id: ariaId, strategy, }} > - {DatePickerComponent} - + +
-
+
); }, diff --git a/@navikt/core/react/src/date/datepicker/types.ts b/@navikt/core/react/src/date/datepicker/DatePicker.types.ts similarity index 88% rename from @navikt/core/react/src/date/datepicker/types.ts rename to @navikt/core/react/src/date/datepicker/DatePicker.types.ts index bdfc0d4a78..1a267fa601 100644 --- a/@navikt/core/react/src/date/datepicker/types.ts +++ b/@navikt/core/react/src/date/datepicker/DatePicker.types.ts @@ -1,5 +1,6 @@ -import { DateRange, DayPickerBase, Matcher } from "react-day-picker"; +import { CalendarWeek, Matcher, PropsBase } from "react-day-picker"; import { ComponentTranslation } from "../../util/i18n/i18n.types"; +import { DateRange } from "../Date.typeutils"; export type SingleMode = { mode?: "single"; @@ -18,8 +19,10 @@ export type MultipleMode = { max?: number; /** * Allows selecting a week at a time. Only used with `mode` is set to "multiple". + * @param week Current week number + * @param days Dates in the week */ - onWeekNumberClick?: DayPickerBase["onWeekNumberClick"]; + onWeekNumberClick?: (week: CalendarWeek["weekNumber"], days: Date[]) => void; }; export type RangeMode = { @@ -34,10 +37,10 @@ export type RangeMode = { export type ConditionalModeProps = SingleMode | MultipleMode | RangeMode; -//github.com/gpbl/react-day-picker/blob/50b6dba/packages/react-day-picker/src/types/DayPickerBase.ts#L139 +// https://daypicker.dev/api/interfaces/PropsBase export interface DatePickerDefaultProps extends Omit, "onSelect">, - Pick { + Pick { /** * Element datepicker anchors to. Use for built-in toggle, or make your own with the open/onClose props */ diff --git a/@navikt/core/react/src/date/datepicker/DatePickerStandalone.tsx b/@navikt/core/react/src/date/datepicker/DatePickerStandalone.tsx deleted file mode 100644 index 2f1d01ec27..0000000000 --- a/@navikt/core/react/src/date/datepicker/DatePickerStandalone.tsx +++ /dev/null @@ -1,136 +0,0 @@ -import cl from "clsx"; -import { isWeekend } from "date-fns"; -import React, { forwardRef } from "react"; -import { DateRange, DayPicker, isMatch } from "react-day-picker"; -import { omit } from "../../util"; -import { useDateLocale, useI18n } from "../../util/i18n/i18n.hooks"; -import { DateTranslationContextProvider } from "../context"; -import { getLocaleFromString, getTranslations } from "../utils"; -import Caption from "./parts/Caption"; -import DropdownCaption from "./parts/DropdownCaption"; -import { HeadRow } from "./parts/HeadRow"; -import Row from "./parts/Row"; -import TableHead from "./parts/TableHead"; -import WeekNumber from "./parts/WeekNumber"; -import { - DatePickerDefaultProps, - MultipleMode, - RangeMode, - SingleMode, -} from "./types"; - -interface DatePickerStandaloneDefaultProps - extends Omit< - DatePickerDefaultProps, - "open" | "onClose" | "onOpenToggle" | "wrapperClassName" | "strategy" - > { - /** - * Datepicker classname - */ - className?: string; - /** - * If datepicker should be fixed to 6 weeks, regardless of actual weeks in month - * @default true - */ - fixedWeeks?: boolean; -} - -type StandaloneConditionalModeProps = SingleMode | MultipleMode | RangeMode; - -export type DatePickerStandaloneProps = DatePickerStandaloneDefaultProps & - StandaloneConditionalModeProps; - -export type DatePickerStandaloneType = React.ForwardRefExoticComponent< - DatePickerStandaloneProps & React.RefAttributes ->; - -export const DatePickerStandalone: DatePickerStandaloneType = forwardRef< - HTMLDivElement, - DatePickerStandaloneProps ->( - ( - { - className, - locale, - translations, - dropdownCaption, - disabled = [], - disableWeekends = false, - showWeekNumber = false, - selected, - defaultSelected, - onSelect, - fixedWeeks = false, - onWeekNumberClick, - ...rest - }, - ref, - ) => { - const translate = useI18n( - "DatePicker", - translations, - getTranslations(locale), - ); - const langProviderLocale = useDateLocale(); - const [selectedDates, setSelectedDates] = React.useState< - Date | Date[] | DateRange | undefined - >(defaultSelected); - - const mode = rest.mode ?? ("single" as any); - - /** - * @param newSelected Date | Date[] | DateRange | undefined - */ - const handleSelect = (newSelected) => { - setSelectedDates(newSelected); - onSelect?.(newSelected); - }; - - return ( -
- - { - return ( - (disableWeekends && isWeekend(day)) || isMatch(day, disabled) - ); - }} - weekStartsOn={1} - initialFocus={false} - modifiers={{ - weekend: (day) => disableWeekends && isWeekend(day), - }} - modifiersClassNames={{ - weekend: "rdp-day__weekend", - }} - showWeekNumber={showWeekNumber} - onWeekNumberClick={ - mode === "multiple" ? onWeekNumberClick : undefined - } - fixedWeeks={fixedWeeks} - showOutsideDays - {...omit(rest, ["children", "id"])} - /> - -
- ); - }, -); - -export default DatePickerStandalone; diff --git a/@navikt/core/react/src/date/datepicker/datepicker.test.tsx b/@navikt/core/react/src/date/datepicker/datepicker.test.tsx deleted file mode 100644 index ff4b564616..0000000000 --- a/@navikt/core/react/src/date/datepicker/datepicker.test.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import { render, screen } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import React from "react"; -import { describe, test } from "vitest"; -import { useDatepicker } from "../hooks"; -import DatePicker from "./DatePicker"; - -const App = () => { - const { datepickerProps, inputProps } = useDatepicker({ - fromDate: new Date("Aug 23 2019"), - onDateChange: console.info, - }); - - return ( - - - - ); -}; - -describe("Render datepicker", () => { - // eslint-disable-next-line @vitest/expect-expect - test("Should not crash when e.target is window", async () => { - render(); - - await userEvent.click(screen.getByText("Velg dato")); - }); -}); diff --git a/@navikt/core/react/src/date/hooks/useDatepicker.tsx b/@navikt/core/react/src/date/datepicker/hooks/useDatepicker.tsx similarity index 92% rename from @navikt/core/react/src/date/hooks/useDatepicker.tsx rename to @navikt/core/react/src/date/datepicker/hooks/useDatepicker.tsx index 6fc8b292e7..c5f301f779 100644 --- a/@navikt/core/react/src/date/hooks/useDatepicker.tsx +++ b/@navikt/core/react/src/date/datepicker/hooks/useDatepicker.tsx @@ -1,15 +1,11 @@ import { differenceInCalendarDays, isWeekend } from "date-fns"; import React, { useCallback, useState } from "react"; -import { DayClickEventHandler, isMatch } from "react-day-picker"; -import { useDateLocale } from "../../util/i18n/i18n.hooks"; -import { DatePickerProps } from "../datepicker/DatePicker"; -import { DateInputProps } from "../parts/DateInput"; -import { - formatDateForInput, - getLocaleFromString, - isValidDate, - parseDate, -} from "../utils"; +import { DayEventHandler, dateMatchModifiers } from "react-day-picker"; +import { useDateLocale } from "../../../util/i18n/i18n.hooks"; +import { DateInputProps } from "../../Date.Input"; +import { getLocaleFromString } from "../../Date.locale"; +import { formatDateForInput, isValidDate, parseDate } from "../../date-utils"; +import { DatePickerProps } from "../DatePicker"; export interface UseDatepickerOptions extends Pick< @@ -230,7 +226,10 @@ export const useDatepicker = ( }; /* Only allow de-selecting if not required */ - const handleDayClick: DayClickEventHandler = (day, { selected }) => { + const handleDayClick: DayEventHandler = ( + day, + { selected }, + ) => { if (selected && required) { return; } @@ -274,13 +273,13 @@ export const useDatepicker = ( if ( !isValidDate(day) || (disableWeekends && isWeekend(day)) || - (disabled && isMatch(day, disabled)) + (disabled && dateMatchModifiers(day, disabled)) ) { updateDate(undefined); updateValidation({ isInvalid: !isValidDate(day), isWeekend: disableWeekends && isWeekend(day), - isDisabled: disabled && isMatch(day, disabled), + isDisabled: disabled && dateMatchModifiers(day, disabled), isValidDate: false, isEmpty: !e.target.value, isBefore: isBefore ?? false, diff --git a/@navikt/core/react/src/date/hooks/useRangeDatepicker.test.tsx b/@navikt/core/react/src/date/datepicker/hooks/useRangeDatepicker.test.tsx similarity index 97% rename from @navikt/core/react/src/date/hooks/useRangeDatepicker.test.tsx rename to @navikt/core/react/src/date/datepicker/hooks/useRangeDatepicker.test.tsx index b9ea3353c0..4c1e80ef4a 100644 --- a/@navikt/core/react/src/date/hooks/useRangeDatepicker.test.tsx +++ b/@navikt/core/react/src/date/datepicker/hooks/useRangeDatepicker.test.tsx @@ -2,7 +2,7 @@ import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import React from "react"; import { describe, expect, test } from "vitest"; -import { DatePicker } from "../datepicker"; +import { DatePicker } from "../DatePicker"; import { useRangeDatepicker } from "./useRangeDatepicker"; const RangeDemo = () => { diff --git a/@navikt/core/react/src/date/hooks/useRangeDatepicker.tsx b/@navikt/core/react/src/date/datepicker/hooks/useRangeDatepicker.tsx similarity index 93% rename from @navikt/core/react/src/date/hooks/useRangeDatepicker.tsx rename to @navikt/core/react/src/date/datepicker/hooks/useRangeDatepicker.tsx index d59f763296..8d8bf73569 100644 --- a/@navikt/core/react/src/date/hooks/useRangeDatepicker.tsx +++ b/@navikt/core/react/src/date/datepicker/hooks/useRangeDatepicker.tsx @@ -1,19 +1,17 @@ import { isBefore as checkIsBefore, differenceInCalendarDays, + isSameDay, isWeekend, } from "date-fns"; import React, { useState } from "react"; -import { DateRange, isMatch } from "react-day-picker"; -import { useDateLocale } from "../../util/i18n/i18n.hooks"; -import { DatePickerProps } from "../datepicker/DatePicker"; -import { DateInputProps } from "../parts/DateInput"; -import { - formatDateForInput, - getLocaleFromString, - isValidDate, - parseDate, -} from "../utils"; +import { dateMatchModifiers } from "react-day-picker"; +import { useDateLocale } from "../../../util/i18n/i18n.hooks"; +import { DateInputProps } from "../../Date.Input"; +import { getLocaleFromString } from "../../Date.locale"; +import { DateRange } from "../../Date.typeutils"; +import { formatDateForInput, isValidDate, parseDate } from "../../date-utils"; +import { DatePickerProps } from "../DatePicker"; import { DateValidationT, UseDatepickerOptions } from "./useDatepicker"; export type RangeValidationT = { @@ -128,13 +126,13 @@ const fromValidation = (day: Date, opt?: UseRangeDatepickerOptions) => { if ( isValidDate(day) && !(opt?.disableWeekends && isWeekend(day)) && - !(opt?.disabled && isMatch(day, opt.disabled)) + !(opt?.disabled && dateMatchModifiers(day, opt.disabled)) ) { return { isValidDate: false, isInvalid: !isValidDate(day), isWeekend: opt?.disableWeekends && isWeekend(day), - isDisabled: opt?.disabled && isMatch(day, opt.disabled), + isDisabled: opt?.disabled && dateMatchModifiers(day, opt.disabled), isBefore, isAfter, }; @@ -164,13 +162,13 @@ const toValidation = ( if ( isValidDate(day) && !(opt?.disableWeekends && isWeekend(day)) && - !(opt?.disabled && isMatch(day, opt.disabled)) + !(opt?.disabled && dateMatchModifiers(day, opt.disabled)) ) { return { isValidDate: false, isInvalid: !isValidDate(day), isWeekend: opt?.disableWeekends && isWeekend(day), - isDisabled: opt?.disabled && isMatch(day, opt.disabled), + isDisabled: opt?.disabled && dateMatchModifiers(day, opt.disabled), isBefore, isAfter, isBeforeFrom, @@ -369,14 +367,16 @@ export const useRangeDatepicker = ( return ( isValidDate(day) && !(disableWeekends && isWeekend(day)) && - !(disabled && isMatch(day, disabled)) + !(disabled && dateMatchModifiers(day, disabled)) ); }; const handleSelect = (range) => { if (range?.from && range?.to) { - setOpen(false); - anchorRef?.focus(); + if (!isSameDay(range.from, range.to)) { + setOpen(false); + anchorRef?.focus(); + } } const prevToRange = !selectedRange?.from && selectedRange?.to ? selectedRange?.to : range?.to; @@ -415,7 +415,7 @@ export const useRangeDatepicker = ( isValidDate: false, isInvalid: !isValidDate(day), isWeekend: disableWeekends && isWeekend(day), - isDisabled: disabled && isMatch(day, disabled), + isDisabled: disabled && dateMatchModifiers(day, disabled), isBefore, isAfter, }, @@ -481,7 +481,7 @@ export const useRangeDatepicker = ( isValidDate: false, isInvalid: !isValidDate(day), isWeekend: disableWeekends && isWeekend(day), - isDisabled: disabled && isMatch(day, disabled), + isDisabled: disabled && dateMatchModifiers(day, disabled), isBefore, isAfter, }); diff --git a/@navikt/core/react/src/date/datepicker/index.ts b/@navikt/core/react/src/date/datepicker/index.ts index c44b99e110..6022bb478d 100644 --- a/@navikt/core/react/src/date/datepicker/index.ts +++ b/@navikt/core/react/src/date/datepicker/index.ts @@ -1,13 +1,12 @@ "use client"; export { default as DatePicker, type DatePickerProps } from "./DatePicker"; +export { useDatepicker, type DateValidationT } from "./hooks/useDatepicker"; export { - useDatepicker, useRangeDatepicker, - type DateValidationT, type RangeValidationT, -} from "../hooks"; +} from "./hooks/useRangeDatepicker"; export { DatePickerStandalone, type DatePickerStandaloneProps, -} from "./DatePickerStandalone"; -export { DatePickerInput, type DateInputProps } from "../parts/DateInput"; +} from "./parts/DatePicker.Standalone"; +export { DatePickerInput, type DateInputProps } from "../Date.Input"; diff --git a/@navikt/core/react/src/date/datepicker/parts/Caption.tsx b/@navikt/core/react/src/date/datepicker/parts/Caption.tsx deleted file mode 100644 index ea8a124f50..0000000000 --- a/@navikt/core/react/src/date/datepicker/parts/Caption.tsx +++ /dev/null @@ -1,54 +0,0 @@ -import React from "react"; -import { CaptionProps, useDayPicker, useNavigation } from "react-day-picker"; -import { ArrowLeftIcon, ArrowRightIcon } from "@navikt/aksel-icons"; -import { Button } from "../../../button"; -import { Label } from "../../../typography"; -import { useDateTranslationContext } from "../../context"; -import WeekRow from "./WeekRow"; - -/** - * https://github.com/gpbl/react-day-picker/tree/main/src/components/Caption - */ -export const DatePickerCaption = ({ displayMonth, id }: CaptionProps) => { - const { goToMonth, nextMonth, previousMonth } = useNavigation(); - const { - formatters: { formatCaption }, - locale, - } = useDayPicker(); - const translate = useDateTranslationContext().translate; - - return ( - <> -
-
- - - ); -}; - -export default DatePickerCaption; diff --git a/@navikt/core/react/src/date/datepicker/parts/DatePicker.DayButton.tsx b/@navikt/core/react/src/date/datepicker/parts/DatePicker.DayButton.tsx new file mode 100644 index 0000000000..47415fa6f8 --- /dev/null +++ b/@navikt/core/react/src/date/datepicker/parts/DatePicker.DayButton.tsx @@ -0,0 +1,56 @@ +import cl from "clsx"; +import { Locale, format } from "date-fns"; +import React, { useEffect, useRef } from "react"; +import { CalendarDay, Modifiers } from "react-day-picker"; + +const DatePickerDayButton = ({ + day, + modifiers, + locale, + children, + ...rest +}: { + day: CalendarDay; + modifiers: Modifiers; + locale: Locale; +} & React.ButtonHTMLAttributes) => { + const ref = useRef(null); + + useEffect(() => { + if (modifiers.focused) { + ref.current?.focus(); + } + }, [modifiers.focused]); + + if (modifiers.hidden) { + return <>; + } + + return ( + + ); +}; + +export { DatePickerDayButton }; diff --git a/@navikt/core/react/src/date/datepicker/parts/DatePicker.Months.tsx b/@navikt/core/react/src/date/datepicker/parts/DatePicker.Months.tsx new file mode 100644 index 0000000000..c1fed978a8 --- /dev/null +++ b/@navikt/core/react/src/date/datepicker/parts/DatePicker.Months.tsx @@ -0,0 +1,149 @@ +import { + Locale, + format, + getMonth, + getYear, + setMonth, + setYear, + startOfMonth, +} from "date-fns"; +import React, { ChangeEvent, useCallback } from "react"; +import { CalendarMonth, useDayPicker } from "react-day-picker"; +import { ArrowLeftIcon, ArrowRightIcon } from "@navikt/aksel-icons"; +import { Button } from "../../../button"; +import { Select } from "../../../form/select"; +import { BodyShort } from "../../../typography"; +import { omit } from "../../../util"; +import { useDateTranslationContext } from "../../Date.locale"; +import { + calendarRange, + getMonthOptions, + getYearOptions, +} from "../../date-utils"; +import { MultipleMode } from "../DatePicker.types"; +import { DatePickerWeekRow } from "./DatePicker.WeekRow"; + +const DatePickerMonths = ({ + children, + calendarMonth, + locale, + onWeekNumberClick, + ...rest +}: { + calendarMonth: CalendarMonth; + displayIndex: number; + locale: Locale; + onWeekNumberClick: MultipleMode["onWeekNumberClick"]; +} & React.HTMLAttributes) => { + const { dayPickerProps, goToMonth, previousMonth, nextMonth } = + useDayPicker(); + + const { captionLayout } = dayPickerProps; + + const translate = useDateTranslationContext().translate; + + const handleMonthChange = useCallback( + (date: Date, e: ChangeEvent) => { + const selectedMonth = Number(e.target.value); + const newMonth = setMonth(startOfMonth(date), selectedMonth); + goToMonth(newMonth); + }, + [goToMonth], + ); + + const handleYearChange = useCallback( + (date: Date, e: ChangeEvent) => { + const selectedYear = Number(e.target.value); + const newMonth = setYear(startOfMonth(date), selectedYear); + goToMonth(newMonth); + }, + [goToMonth], + ); + + const [navStart, navEnd] = calendarRange({ + captionLayout: captionLayout === "dropdown" ? "dropdown" : "label", + startMonth: dayPickerProps.startMonth, + endMonth: dayPickerProps.endMonth, + today: dayPickerProps.today, + }); + + const months = getMonthOptions(calendarMonth.date, navStart, navEnd, locale); + const dropdownYears = getYearOptions(navStart, navEnd, locale); + + return ( +
+
+ {captionLayout?.startsWith("dropdown") && ( + + {format(calendarMonth.date, "LLLL y", { locale })} + + )} +
+ + {children} +
+ ); +}; + +export { DatePickerMonths }; diff --git a/@navikt/core/react/src/date/datepicker/parts/DatePicker.RDP.tsx b/@navikt/core/react/src/date/datepicker/parts/DatePicker.RDP.tsx new file mode 100644 index 0000000000..689123c6da --- /dev/null +++ b/@navikt/core/react/src/date/datepicker/parts/DatePicker.RDP.tsx @@ -0,0 +1,175 @@ +import cl from "clsx"; +import { isAfter, isBefore, isWeekend } from "date-fns"; +import React, { useCallback } from "react"; +import { ClassNames, DayPicker, dateMatchModifiers } from "react-day-picker"; +import { Show } from "../../../layout/responsive"; +import { omit } from "../../../util"; +import { useDateLocale } from "../../../util/i18n/i18n.hooks"; +import { getLocaleFromString } from "../../Date.locale"; +import { clampDisplayMonth } from "../../date-utils"; +import { + ConditionalModeProps, + DatePickerDefaultProps, +} from "../DatePicker.types"; +import { DatePickerDayButton } from "./DatePicker.DayButton"; +import { DatePickerMonths } from "./DatePicker.Months"; +import { DatePickerWeekNumber } from "./DatePicker.WeekNumber"; + +/** + * To support backwards compatibility with the old datepicker, + * we need to provide a partial implementation of the classnames + */ +const LegacyClassNames: Partial = { + root: "rdp", + button_next: "button", + day: "rdp-cell", + day_button: "rdp-day rdp-button", + /* We set this directly on DayButton */ + disabled: "", + hidden: "rdp-day_hidden", + outside: "rdp-day_outside", + selected: "rdp-day_selected", + weekday: "rdp-head_cell", + weekdays: "rdp-head_row", + week: "rdp-row", + weeks: "rdp-tbody", + month_grid: "rdp-table", + week_number: "rdp-weeknumber", +}; + +type ReactDayPickerProps = DatePickerDefaultProps & + ConditionalModeProps & { + /** + * If datepicker should be fixed to 6 weeks, regardless of actual weeks in month + * @default false + */ + fixedWeeks?: boolean; + /** + * Update selected date + */ + handleSelect: (newSelected: any) => void; + }; + +const ReactDayPicker = ({ + className, + dropdownCaption, + disabled = [], + disableWeekends = false, + showWeekNumber = false, + selected, + fixedWeeks = false, + onWeekNumberClick, + fromDate, + toDate, + month, + mode: _mode, + handleSelect, + locale: _locale, + ...rest +}: ReactDayPickerProps) => { + const langProviderLocale = useDateLocale(); + const locale = _locale ? getLocaleFromString(_locale) : langProviderLocale; + + const mode = _mode ?? ("single" as any); + + return ( + <>, + DayButton: useCallback( + (props) => , + [locale], + ), + Month: useCallback( + (props) => ( + + ), + [locale, mode, onWeekNumberClick], + ), + Day: useCallback( + (props) => ( + + ), + [], + ), + WeekNumber: useCallback( + (props) => ( + + ), + [mode, onWeekNumberClick], + ), + /* On smaller screens we hide it to accomedate our custom week-selector */ + WeekNumberHeader: useCallback( + (props) => ( + + + + ), + [], + ), + Weekdays: useCallback( + (props) => ( + + {props.children} + + ), + [], + ), + }} + className={cl("navds-date", className)} + disabled={(day) => { + const isOutside = + (toDate && isAfter(day, toDate)) || + (fromDate && isBefore(day, fromDate)) || + false; + + return ( + (disableWeekends && isWeekend(day)) || + dateMatchModifiers(day, disabled) || + isOutside + ); + }} + weekStartsOn={1} + modifiers={{ + weekend: (day) => disableWeekends && isWeekend(day), + }} + modifiersClassNames={{ + weekend: "rdp-day__weekend", + }} + // eslint-disable-next-line jsx-a11y/no-autofocus + autoFocus={false} + showWeekNumber={showWeekNumber} + fixedWeeks={fixedWeeks} + showOutsideDays + startMonth={fromDate} + endMonth={toDate} + month={clampDisplayMonth({ month, start: fromDate, end: toDate })} + {...omit(rest, ["onSelect", "role", "id", "defaultSelected"])} + /> + ); +}; + +export { ReactDayPicker }; diff --git a/@navikt/core/react/src/date/datepicker/parts/DatePicker.Standalone.tsx b/@navikt/core/react/src/date/datepicker/parts/DatePicker.Standalone.tsx new file mode 100644 index 0000000000..bf157ad2b1 --- /dev/null +++ b/@navikt/core/react/src/date/datepicker/parts/DatePicker.Standalone.tsx @@ -0,0 +1,93 @@ +import cl from "clsx"; +import React, { forwardRef } from "react"; +import { DateRange } from "react-day-picker"; +import { useControllableState } from "../../../util/hooks"; +import { useI18n } from "../../../util/i18n/i18n.hooks"; +import { + DateTranslationContextProvider, + getTranslations, +} from "../../Date.locale"; +import { + DatePickerDefaultProps, + MultipleMode, + RangeMode, + SingleMode, +} from "../DatePicker.types"; +import { ReactDayPicker } from "./DatePicker.RDP"; + +interface DatePickerStandaloneDefaultProps + extends Omit< + DatePickerDefaultProps, + "open" | "onClose" | "onOpenToggle" | "wrapperClassName" | "strategy" + > { + /** + * Datepicker classname + */ + className?: string; + /** + * If datepicker should be fixed to 6 weeks, regardless of actual weeks in month + * @default true + */ + fixedWeeks?: boolean; +} + +type StandaloneConditionalModeProps = SingleMode | MultipleMode | RangeMode; + +export type DatePickerStandaloneProps = DatePickerStandaloneDefaultProps & + StandaloneConditionalModeProps; + +export type DatePickerStandaloneType = React.ForwardRefExoticComponent< + DatePickerStandaloneProps & React.RefAttributes +>; + +export const DatePickerStandalone: DatePickerStandaloneType = forwardRef< + HTMLDivElement, + DatePickerStandaloneProps +>( + ( + { + className, + locale, + translations, + selected, + defaultSelected, + onSelect, + mode, + ...rest + }, + ref, + ) => { + const translate = useI18n( + "DatePicker", + translations, + getTranslations(locale), + ); + + const [value, setValue] = useControllableState< + Date | Date[] | DateRange | undefined + >({ + defaultValue: defaultSelected, + value: selected, + onChange: (newValue) => onSelect?.(newValue as any), + }); + + return ( + +
+ +
+
+ ); + }, +); + +export default DatePickerStandalone; diff --git a/@navikt/core/react/src/date/datepicker/parts/DatePicker.WeekNumber.tsx b/@navikt/core/react/src/date/datepicker/parts/DatePicker.WeekNumber.tsx new file mode 100644 index 0000000000..421396bc46 --- /dev/null +++ b/@navikt/core/react/src/date/datepicker/parts/DatePicker.WeekNumber.tsx @@ -0,0 +1,86 @@ +import cl from "clsx"; +import React, { useMemo } from "react"; +import { CalendarWeek, useDayPicker } from "react-day-picker"; +import { Button } from "../../../button"; +import { Hide, Show } from "../../../layout/responsive"; +import { Detail } from "../../../typography"; +import { useDateTranslationContext } from "../../Date.locale"; +import { MultipleMode } from "../DatePicker.types"; + +const DatePickerWeekNumber = ({ + week: { weekNumber, days }, + onWeekNumberClick, + className, + style, + showOnDesktop, +}: { + week: CalendarWeek; + onWeekNumberClick: MultipleMode["onWeekNumberClick"]; + showOnDesktop: boolean; +} & React.ThHTMLAttributes) => { + const translate = useDateTranslationContext().translate; + + const { getModifiers } = useDayPicker(); + + const hideWeek = useMemo(() => { + if ( + days.filter((day) => { + const mods = getModifiers(day); + return !(mods.hidden || mods.outside || mods.disabled); + }).length === 0 + ) { + return true; + } + return false; + }, [days, getModifiers]); + + const DisplayMode = showOnDesktop ? Show : Hide; + + if (!onWeekNumberClick || hideWeek) { + return ( + + + + {weekNumber} + + + + ); + } + + return ( + + +