diff --git a/.gitignore b/.gitignore index 5564c6ad0..4c8bf87fc 100644 --- a/.gitignore +++ b/.gitignore @@ -24,3 +24,5 @@ build lerna-debug.log .vscode/settings.json +ti-*.log +.log* diff --git a/packages/calendar/package.json b/packages/calendar/package.json index d5a6cceeb..54d87e17e 100644 --- a/packages/calendar/package.json +++ b/packages/calendar/package.json @@ -36,6 +36,7 @@ "@stenajs-webui/core": "20.11.6", "@stenajs-webui/elements": "20.11.6", "@stenajs-webui/forms": "20.11.6", + "@stenajs-webui/input-mask": "20.11.6", "@stenajs-webui/theme": "20.11.6", "@stenajs-webui/tooltip": "20.11.6", "classnames": "^2.3.1", diff --git a/packages/calendar/src/components/calendar-types/travel-date-range-calendar/TravelDateRangeCalendar.stories.tsx b/packages/calendar/src/components/calendar-types/travel-date-range-calendar/TravelDateRangeCalendar.stories.tsx new file mode 100644 index 000000000..c4a064dbd --- /dev/null +++ b/packages/calendar/src/components/calendar-types/travel-date-range-calendar/TravelDateRangeCalendar.stories.tsx @@ -0,0 +1,135 @@ +import { StoryFn } from "@storybook/react"; +import * as React from "react"; +import { useState } from "react"; +import { TravelDateRangeCalendar } from "./TravelDateRangeCalendar"; +import { Column, Row, Spacing } from "@stenajs-webui/core"; +import { Banner, Label } from "@stenajs-webui/elements"; +import { TravelDateRangeInputValue } from "../../../features/travel-calendar/types"; +import { parseLocalizedDateString } from "../../../features/localize-date-format/LocalizedDateParser"; + +export default { + title: "calendar/Calendar/TravelDateRangeCalendar", + component: TravelDateRangeCalendar, + decorators: [ + (TheStory: StoryFn) => ( +
+ +
+ ), + ], +}; + +export const Standard = () => { + const [value, setValue] = useState( + undefined + ); + + return ( +
+ +
+ ); +}; + +export const WithHeading = () => { + const [value, setValue] = useState( + undefined + ); + + return ( +
+ +
+ ); +}; + +export const Locales = () => { + const locales = [ + "sv", + "da", + "en-GB", + "pl", + "nl", + "en-US", + "de-AT", + "de-DE", + "fr", + "de", + "es", + "nb", + ]; + + return ( + + {locales.map((locale) => ( + + ))} + + ); +}; + +const LocaleDemo = ({ localeCode }: { localeCode: string }) => { + const [value, setValue] = useState( + undefined + ); + + return ( + + + + + + + + + ); +}; + +export const ParseDate = () => { + const [startDate, setStartDate] = useState(undefined); + + const [endDate, setEndDate] = useState(undefined); + + const [value, setValue] = useState( + undefined + ); + + const setValueHandler = (value: TravelDateRangeInputValue) => { + setValue(value); + if (value.startDate) { + setStartDate(parseLocalizedDateString(value.startDate, "sv")); + } + if (value.endDate) { + setEndDate(parseLocalizedDateString(value.endDate, "sv")); + } + }; + + return ( +
+ + + + + + +
+ ); +}; diff --git a/packages/calendar/src/components/calendar-types/travel-date-range-calendar/TravelDateRangeCalendar.tsx b/packages/calendar/src/components/calendar-types/travel-date-range-calendar/TravelDateRangeCalendar.tsx new file mode 100644 index 000000000..3704324ed --- /dev/null +++ b/packages/calendar/src/components/calendar-types/travel-date-range-calendar/TravelDateRangeCalendar.tsx @@ -0,0 +1,98 @@ +import * as React from "react"; +import { Column, Heading, HeadingVariant } from "@stenajs-webui/core"; +import { ValueAndOnValueChangeProps } from "@stenajs-webui/forms"; +import { TravelDateTextInputFields } from "../../../features/travel-calendar/components/TravelDateTextInputFields"; +import { MonthPicker } from "../../../features/month-picker/MonthPicker"; +import { useTravelDateRangeInput } from "../../../features/travel-calendar/hooks/UseTravelDateRangeInput"; +import { MonthHeader } from "../../../features/travel-calendar/components/MonthHeader"; +import { TravelCalendar } from "../../../features/travel-calendar/components/TravelCalendar"; +import { TravelDateRangeInputValue } from "../../../features/travel-calendar/types"; + +export interface TravelDateRangeCalendarProps + extends ValueAndOnValueChangeProps { + localeCode?: string; + initialMonthInFocus?: Date; + startDateLabel?: string; + endDateLabel?: string; + previousMonthButtonAriaLabel?: string; + nextMonthButtonAriaLabel?: string; + heading?: string; + headingLevel?: HeadingVariant; + firstMonthInMonthPicker?: Date; + numMonthsInMonthPicker?: number; +} + +export const TravelDateRangeCalendar: React.FC< + TravelDateRangeCalendarProps +> = ({ + value, + onValueChange, + startDateLabel, + endDateLabel, + localeCode = "sv", + initialMonthInFocus, + previousMonthButtonAriaLabel = "Previous month", + nextMonthButtonAriaLabel = "Next month", + heading, + headingLevel, + numMonthsInMonthPicker = 12, + firstMonthInMonthPicker = new Date(), +}) => { + const inputProps = useTravelDateRangeInput( + value, + onValueChange, + localeCode, + initialMonthInFocus + ); + + const { + visiblePanel, + visibleMonth, + onValueChangeByInputs, + setVisibleMonth, + setVisiblePanel, + monthPickerButtonRef, + } = inputProps; + + return ( + + {heading && ( + + {heading} + + )} + + + + + {visiblePanel === "calendar" && } + + {visiblePanel === "month-picker" && ( + { + setVisibleMonth(v); + setVisiblePanel("calendar"); + monthPickerButtonRef.current?.focus(); + }} + onCancel={() => { + setVisiblePanel("calendar"); + monthPickerButtonRef.current?.focus(); + }} + /> + )} + + ); +}; diff --git a/packages/calendar/src/components/input-types/travel-date-range-input/TravelDateRangeInput.module.css b/packages/calendar/src/components/input-types/travel-date-range-input/TravelDateRangeInput.module.css new file mode 100644 index 000000000..f9fc273f7 --- /dev/null +++ b/packages/calendar/src/components/input-types/travel-date-range-input/TravelDateRangeInput.module.css @@ -0,0 +1,10 @@ +.travelDateRangeInput { + .overlay { + transition: opacity var(--swui-animation-time-fast) ease-in-out; + + opacity: 1; + &:not(&.calendarVisible) { + opacity: 0; + } + } +} diff --git a/packages/calendar/src/components/input-types/travel-date-range-input/TravelDateRangeInput.stories.tsx b/packages/calendar/src/components/input-types/travel-date-range-input/TravelDateRangeInput.stories.tsx new file mode 100644 index 000000000..9315d6aac --- /dev/null +++ b/packages/calendar/src/components/input-types/travel-date-range-input/TravelDateRangeInput.stories.tsx @@ -0,0 +1,209 @@ +import { StoryFn } from "@storybook/react"; +import * as React from "react"; +import { useState } from "react"; +import { TravelDateRangeInput } from "./TravelDateRangeInput"; +import { Column, Row, Spacing } from "@stenajs-webui/core"; +import { + Banner, + Label, + PrimaryButton, + SecondaryButton, +} from "@stenajs-webui/elements"; +import { TravelDateRangeInputValue } from "../../../features/travel-calendar/types"; +import { parseLocalizedDateString } from "../../../features/localize-date-format/LocalizedDateParser"; +import { formatLocalizedDate } from "../../../features/localize-date-format/LocalizedDateFormatter"; +import { addWeeks } from "date-fns"; + +export default { + title: "calendar/Input/TravelDateRangeInput", + component: TravelDateRangeInput, + decorators: [ + (TheStory: StoryFn) => ( +
+ +
+ ), + ], +}; + +export const Standard = () => { + const [value, setValue] = useState( + undefined + ); + + return ( +
+ +
+ ); +}; + +export const WithHeading = () => { + const [value, setValue] = useState( + undefined + ); + + return ( +
+ +
+ ); +}; + +export const ParseDate = () => { + const [startDate, setStartDate] = useState(undefined); + + const [endDate, setEndDate] = useState(undefined); + + const [value, setValue] = useState( + undefined + ); + + const setValueHandler = (value: TravelDateRangeInputValue) => { + setValue(value); + if (value.startDate) { + setStartDate(parseLocalizedDateString(value.startDate, "sv")); + } + if (value.endDate) { + setEndDate(parseLocalizedDateString(value.endDate, "sv")); + } + }; + + return ( +
+ + + + + + +
+ ); +}; + +export const Locales = () => { + const locales = [ + "sv", + "da", + "en-GB", + "pl", + "nl", + "en-US", + "de-AT", + "de-DE", + "fr", + "de", + "es", + "nb", + ]; + + return ( + + {locales.map((locale) => ( + + ))} + + ); +}; + +const LocaleDemo = ({ localeCode }: { localeCode: string }) => { + const [value, setValue] = useState( + undefined + ); + + return ( + + + + + + + + + ); +}; + +export const WithValidationAndCloseButton = () => { + const [value, setValue] = useState( + undefined + ); + + return ( +
+ { + return ( + + + + + ); + }} + /> +
+ ); +}; + +export const WithPresets = () => { + const [value, setValue] = useState( + undefined + ); + + return ( +
+ { + return ( + + + setValue({ + startDate: formatLocalizedDate(new Date(), "sv"), + endDate: formatLocalizedDate(addWeeks(new Date(), 1), "sv"), + }) + } + /> + + setValue({ + startDate: formatLocalizedDate(new Date(), "sv"), + endDate: formatLocalizedDate(addWeeks(new Date(), 2), "sv"), + }) + } + /> + + ); + }} + /> +
+ ); +}; diff --git a/packages/calendar/src/components/input-types/travel-date-range-input/TravelDateRangeInput.tsx b/packages/calendar/src/components/input-types/travel-date-range-input/TravelDateRangeInput.tsx new file mode 100644 index 000000000..ebbac7cb0 --- /dev/null +++ b/packages/calendar/src/components/input-types/travel-date-range-input/TravelDateRangeInput.tsx @@ -0,0 +1,218 @@ +import * as React from "react"; +import { + KeyboardEventHandler, + ReactNode, + useCallback, + useLayoutEffect, + useRef, + useState, +} from "react"; +import { + Box, + Heading, + HeadingVariant, + useOnClickOutside, +} from "@stenajs-webui/core"; +import { ValueAndOnValueChangeProps } from "@stenajs-webui/forms"; +import { TravelDateTextInputFields } from "../../../features/travel-calendar/components/TravelDateTextInputFields"; +import { CardBody } from "@stenajs-webui/elements"; +import { MonthPicker } from "../../../features/month-picker/MonthPicker"; +import { useTravelDateRangeInput } from "../../../features/travel-calendar/hooks/UseTravelDateRangeInput"; +import { MonthHeader } from "../../../features/travel-calendar/components/MonthHeader"; +import { TravelCalendar } from "../../../features/travel-calendar/components/TravelCalendar"; +import { TravelDateRangeInputValue } from "../../../features/travel-calendar/types"; +import styles from "./TravelDateRangeInput.module.css"; +import cx from "classnames"; + +export interface RenderBelowCalendarArgs { + hideCalendar: () => void; +} + +export interface TravelDateRangeInputProps + extends ValueAndOnValueChangeProps { + localeCode?: string; + initialMonthInFocus?: Date; + startDateLabel?: string; + endDateLabel?: string; + previousMonthButtonAriaLabel?: string; + nextMonthButtonAriaLabel?: string; + heading?: string; + headingLevel?: HeadingVariant; + firstMonthInMonthPicker?: Date; + numMonthsInMonthPicker?: number; + zIndex?: number; + zIndexWhenClosed?: number; + onHideCalendar?: () => void; + renderBelowCalendar?: (args: RenderBelowCalendarArgs) => ReactNode; +} + +export const TravelDateRangeInput: React.FC = ({ + value, + onValueChange, + startDateLabel, + endDateLabel, + localeCode = "sv", + initialMonthInFocus, + previousMonthButtonAriaLabel = "Previous month", + nextMonthButtonAriaLabel = "Next month", + heading, + headingLevel, + numMonthsInMonthPicker = 12, + firstMonthInMonthPicker = new Date(), + zIndex = 1000, + zIndexWhenClosed, + onHideCalendar, + renderBelowCalendar, +}) => { + const [calendarOpen, setCalendarOpen] = useState(false); + const [calendarInDom, setCalendarInDom] = useState(false); + const [size, setSize] = useState<{ height: number; width: number }>({ + // Sane defaults, this will be updated with actual data from DOM. + width: 336, + height: 66, + }); + + const calendarOpenRef = useRef(false); + + const showCalendar = useCallback(() => { + calendarOpenRef.current = true; + setCalendarInDom(true); + setTimeout(() => { + setCalendarOpen(true); + }, 10); + }, []); + + const hideCalendar = useCallback(() => { + if (!calendarInDom) { + return; + } + + setCalendarOpen(false); + calendarOpenRef.current = false; + onHideCalendar?.(); + + setTimeout(() => { + if (!calendarOpenRef.current) { + setCalendarInDom(false); + } + }, 120); + }, [calendarInDom, onHideCalendar]); + + const ref = useRef(null); + const sizeSourceRef = useRef(null); + + useOnClickOutside(ref, hideCalendar); + + useLayoutEffect(() => { + const width = sizeSourceRef.current?.offsetWidth; + const height = sizeSourceRef.current?.offsetHeight; + if (width != null && height != null) { + if (size.height !== height || size.width !== width) { + setSize({ width, height }); + } + } + }, [size.height, size.width]); + + const inputProps = useTravelDateRangeInput( + value, + onValueChange, + localeCode, + initialMonthInFocus + ); + + const { + visiblePanel, + visibleMonth, + onValueChangeByInputs, + setVisibleMonth, + setVisiblePanel, + monthPickerButtonRef, + } = inputProps; + + const onKeyDown = useCallback>( + (ev) => { + if (ev.key === "Escape") { + hideCalendar(); + } + }, + [hideCalendar] + ); + + return ( + + + + + + {calendarInDom && ( + + + + {heading && ( + + {heading} + + )} + + + + {visiblePanel === "calendar" && ( + + )} + + {visiblePanel === "month-picker" && ( + { + setVisibleMonth(v); + setVisiblePanel("calendar"); + monthPickerButtonRef.current?.focus(); + }} + onCancel={() => { + setVisiblePanel("calendar"); + monthPickerButtonRef.current?.focus(); + }} + /> + )} + {renderBelowCalendar?.({ hideCalendar })} + + + + )} + + ); +}; diff --git a/packages/calendar/src/features/calendar-with-month-year-pickers/CalendarWithMonthYearPickers.tsx b/packages/calendar/src/features/calendar-with-month-year-pickers/CalendarWithMonthYearPickers.tsx index 3a803036e..91f90c7a5 100644 --- a/packages/calendar/src/features/calendar-with-month-year-pickers/CalendarWithMonthYearPickers.tsx +++ b/packages/calendar/src/features/calendar-with-month-year-pickers/CalendarWithMonthYearPickers.tsx @@ -7,11 +7,7 @@ import { CalendarProps, RenderMonthPickerArgs, } from "../../types/CalendarTypes"; -import { - createFirstDate, - MonthPicker, - MonthPickerValue, -} from "../month-picker/MonthPicker"; +import { MonthPicker } from "../month-picker/MonthPicker"; import { CalendarPreset } from "../preset-picker/CalendarPreset"; import { PresetPicker } from "../preset-picker/PresetPicker"; import { CalendarPanelType } from "./CalendarPanelType"; @@ -37,16 +33,13 @@ export const CalendarWithMonthYearPickers = ...props }: CalendarWithMonthYearPickersProps) { const onChangeSelectedMonth = useCallback( - (selectedMonth: MonthPickerValue) => { - const newDate = dateInFocus ? new Date(dateInFocus) : new Date(); - newDate.setMonth(selectedMonth.month); - newDate.setFullYear(selectedMonth.year); + (selectedMonth: Date) => { if (setDateInFocus) { - setDateInFocus(newDate); + setDateInFocus(selectedMonth); } setCurrentPanel("calendar"); }, - [dateInFocus, setDateInFocus, setCurrentPanel] + [setDateInFocus, setCurrentPanel] ); const onClickMonth = useCallback(() => { @@ -66,7 +59,7 @@ export const CalendarWithMonthYearPickers = case "month": return renderMonthPicker ? ( renderMonthPicker({ - value: createFirstDate(dateInFocus), + value: dateInFocus, onValueChange: onChangeSelectedMonth, locale: locale, firstMonth: new Date(), @@ -75,7 +68,7 @@ export const CalendarWithMonthYearPickers = }) ) : ( => { + const l = splitByNewLetter(dateFormat); + return l.flatMap(tokenToMask); +}; + +const tokenToMask = (token: string): Array => { + switch (token) { + case "yyyy": + return yearMask; + case "mm": + case "MM": + return monthMask; + case "dd": + case "DD": + return dayMask; + default: + return [token]; + } +}; + +const splitByNewLetter = (str: string): Array => { + if (str.length === 0) return []; + + const result: Array = []; + let currentSegment = str[0]; + + for (let i = 1; i < str.length; i++) { + if (str[i] !== str[i - 1]) { + result.push(currentSegment); + currentSegment = str[i]; + } else { + currentSegment += str[i]; + } + } + + result.push(currentSegment); + return result; +}; diff --git a/packages/calendar/src/features/localize-date-format/LocaleMapper.ts b/packages/calendar/src/features/localize-date-format/LocaleMapper.ts index e70065ce9..55d5214fc 100644 --- a/packages/calendar/src/features/localize-date-format/LocaleMapper.ts +++ b/packages/calendar/src/features/localize-date-format/LocaleMapper.ts @@ -2,6 +2,7 @@ import { Locale } from "date-fns"; import { da, de, + deAT, enGB, enUS, es, @@ -10,7 +11,6 @@ import { nl, pl, sv, - deAT, } from "date-fns/locale"; type LocalesMap = { @@ -37,3 +37,7 @@ export const getLocaleForLocaleCode = ( ): Locale | undefined => { return locales[localeCode]; }; + +export const getDefaultLocaleForFormatting = (): Locale => { + return locales["sv"]; +}; diff --git a/packages/calendar/src/features/localize-date-format/LocalizedDateParser.ts b/packages/calendar/src/features/localize-date-format/LocalizedDateParser.ts index e8cf0f0c6..68d7326b3 100644 --- a/packages/calendar/src/features/localize-date-format/LocalizedDateParser.ts +++ b/packages/calendar/src/features/localize-date-format/LocalizedDateParser.ts @@ -5,8 +5,26 @@ import { getLocaleForLocaleCode } from "./LocaleMapper"; export const parseLocalizedDateString = ( dateString: string, localeCode: string, - referenceDate: Date = new Date() -): Date => - parse(dateString, getDateFormatForLocaleCode(localeCode), referenceDate, { - locale: getLocaleForLocaleCode(localeCode), - }); + referenceDate?: Date +): Date | undefined => { + const locale = getLocaleForLocaleCode(localeCode); + + if (locale == null) { + return undefined; + } + + const date = parse( + dateString, + getDateFormatForLocaleCode(localeCode), + referenceDate ?? new Date(), + { + locale: locale, + } + ); + + if (isNaN(date.getTime())) { + return undefined; + } + + return date; +}; diff --git a/packages/calendar/src/features/localize-date-format/LocalizedDateReformatter.ts b/packages/calendar/src/features/localize-date-format/LocalizedDateReformatter.ts new file mode 100644 index 000000000..9ca3721e2 --- /dev/null +++ b/packages/calendar/src/features/localize-date-format/LocalizedDateReformatter.ts @@ -0,0 +1,13 @@ +import { parseLocalizedDateString } from "./LocalizedDateParser"; +import { formatLocalizedDate } from "./LocalizedDateFormatter"; + +export const reformatLocalizedDateString = ( + dateString: string, + locale: string +): string | undefined => { + const d = parseLocalizedDateString(dateString, locale); + if (d == null) { + return undefined; + } + return formatLocalizedDate(d, locale); +}; diff --git a/packages/calendar/src/features/localize-date-format/__tests__/InputMaskProvider.test.ts b/packages/calendar/src/features/localize-date-format/__tests__/InputMaskProvider.test.ts new file mode 100644 index 000000000..3721ce175 --- /dev/null +++ b/packages/calendar/src/features/localize-date-format/__tests__/InputMaskProvider.test.ts @@ -0,0 +1,33 @@ +import { + createInputMaskForDateFormat, + dayMask, + monthMask, + yearMask, +} from "../InputMaskProvider"; + +describe("InputMaskProvider", () => { + describe("createInputMaskForDateFormat", () => { + describe("for Sweden", () => { + it("works", () => { + expect(createInputMaskForDateFormat("yyyy-MM-dd")).toEqual([ + ...yearMask, + "-", + ...monthMask, + "-", + ...dayMask, + ]); + }); + }); + describe("for UK", () => { + it("works", () => { + expect(createInputMaskForDateFormat("MM/dd/yyyy")).toEqual([ + ...monthMask, + "/", + ...dayMask, + "/", + ...yearMask, + ]); + }); + }); + }); +}); diff --git a/packages/calendar/src/features/localize-date-format/__tests__/LocalizedDateParser.test.ts b/packages/calendar/src/features/localize-date-format/__tests__/LocalizedDateParser.test.ts index 06f170a58..4c3a189df 100644 --- a/packages/calendar/src/features/localize-date-format/__tests__/LocalizedDateParser.test.ts +++ b/packages/calendar/src/features/localize-date-format/__tests__/LocalizedDateParser.test.ts @@ -3,9 +3,9 @@ import { parseLocalizedDateString } from "../LocalizedDateParser"; describe("LocalizedDateParser", () => { describe("parseLocalizedDateStringElseUndefined", () => { describe("for invalid localeCode", () => { - it("returns invalid date", () => { + it("returns undefined", () => { const d = parseLocalizedDateString("2024-03-12", "hejhej"); - expect(isNaN(d.getTime())).toBe(true); + expect(d).toBeUndefined(); }); }); describe("for Sweden", () => { diff --git a/packages/calendar/src/features/month-picker/MonthPicker.stories.tsx b/packages/calendar/src/features/month-picker/MonthPicker.stories.tsx index 4e3f768d2..7bf82beb8 100644 --- a/packages/calendar/src/features/month-picker/MonthPicker.stories.tsx +++ b/packages/calendar/src/features/month-picker/MonthPicker.stories.tsx @@ -1,6 +1,6 @@ import * as React from "react"; import { useState } from "react"; -import { MonthPicker, MonthPickerValue } from "./MonthPicker"; +import { MonthPicker } from "./MonthPicker"; export default { title: "calendar/Pickers/MonthPicker", @@ -8,7 +8,7 @@ export default { }; export const Standard = () => { - const [value, setValue] = useState(undefined); + const [value, setValue] = useState(undefined); return (
diff --git a/packages/calendar/src/features/month-picker/MonthPicker.tsx b/packages/calendar/src/features/month-picker/MonthPicker.tsx index 6f7aa6ea9..64c47ad75 100644 --- a/packages/calendar/src/features/month-picker/MonthPicker.tsx +++ b/packages/calendar/src/features/month-picker/MonthPicker.tsx @@ -1,21 +1,25 @@ import { enGB } from "date-fns/locale"; import * as React from "react"; -import { Month } from "../../util/calendar/CalendarDataFactory"; +import { + KeyboardEventHandler, + useCallback, + useEffect, + useId, + useMemo, + useState, +} from "react"; import { ValueAndOnValueChangeProps } from "@stenajs-webui/forms"; import { Column, Heading, Row } from "@stenajs-webui/core"; import { MonthPickerCell } from "./MonthPickerCell"; -import { Locale } from "date-fns"; +import { addMonths, isSameMonth, Locale } from "date-fns"; +import { createMonths } from "./MonthPickerDataFactory"; +import { useToday } from "../travel-calendar/util/UseToday"; -export interface MonthPickerValue { - month: Month; - year: number; -} - -export interface MonthPickerProps - extends ValueAndOnValueChangeProps { +export interface MonthPickerProps extends ValueAndOnValueChangeProps { locale?: Locale; firstMonth: Date; numMonths: number; + onCancel?: () => void; } export const MonthPicker: React.FC = ({ @@ -24,66 +28,70 @@ export const MonthPicker: React.FC = ({ locale = enGB, firstMonth, numMonths, + onCancel, }) => { - const input = createMonths(firstMonth, numMonths); - - return ( - - {input.years.map(({ year, months }) => ( - <> - {year} - - {months.map((month) => ( - onValueChange?.({ month, year })} - /> - ))} - - - ))} - - ); -}; + const monthPickerId = useId(); + const today = useToday(); -interface MonthInput { - years: Array; -} + const clampedNumMonths = numMonths > 0 ? numMonths : 12; -interface YearInput { - year: number; - months: Array; -} + const [inited, setInited] = useState(false); -const createMonths = (firstMonth: Date, numMonths: number): MonthInput => { - let currentYear = firstMonth.getFullYear(); - let currentMonth = firstMonth.getMonth(); + const input = createMonths(firstMonth, clampedNumMonths, 4); - const input: MonthInput = { - years: [{ year: currentYear, months: [currentMonth] }], - }; + const lastMonth = useMemo(() => { + return addMonths(firstMonth, clampedNumMonths); + }, [clampedNumMonths, firstMonth]); - for (let i = 1; i < numMonths; i++) { - if (currentMonth === Month.DECEMBER) { - currentYear++; - currentMonth = 0; - input.years.push({ year: currentYear, months: [currentMonth] }); - } else { - currentMonth++; - input.years[input.years.length - 1].months.push(currentMonth); - } - } + useEffect(() => { + setInited(true); + }, []); - return input; -}; + const onKeyDown = useCallback( + (ev) => { + if (ev.key === "Escape") { + onCancel?.(); + ev.preventDefault(); + ev.stopPropagation(); + } + }, + [onCancel] + ); -export const createFirstDate = (date: Date): MonthPickerValue => { - return { - year: date.getFullYear(), - month: date.getMonth(), - }; + return ( + + {input.yearOrder.map((year, yearIndex) => { + const { rows } = input.years[year]; + return ( + + {(yearIndex !== 0 || year !== today.getFullYear()) && ( + {year} + )} + {rows.map((r) => { + const { columns } = input.rows[r]; + return ( + + {columns.map(({ month, position }) => ( + + onValueChange?.(month)} + monthPickerId={monthPickerId} + position={position} + /> + + ))} + + ); + })} + + ); + })} + + ); }; diff --git a/packages/calendar/src/features/month-picker/MonthPickerCell.tsx b/packages/calendar/src/features/month-picker/MonthPickerCell.tsx index fde2ff675..3c74c7abb 100644 --- a/packages/calendar/src/features/month-picker/MonthPickerCell.tsx +++ b/packages/calendar/src/features/month-picker/MonthPickerCell.tsx @@ -1,37 +1,94 @@ import { startCase } from "lodash-es"; import * as React from "react"; -import { useMemo } from "react"; +import { + KeyboardEventHandler, + useCallback, + useEffect, + useMemo, + useRef, +} from "react"; import { Row } from "@stenajs-webui/core"; import { FlatButton, PrimaryButton } from "@stenajs-webui/elements"; import { format, Locale } from "date-fns"; -import { Month } from "../../util/calendar/CalendarDataFactory"; +import { + getDomIdForKeyboardKey, + getDomIdForMonth, +} from "./MonthPickerKeyboardNavigation"; +import { Position } from "./Position"; interface MonthPickerCellProps { - year: number; - month: Month; + month: Date; onClick: () => void; selected: boolean; locale: Locale; + autoFocus: boolean; + monthPickerId: string; + firstAvailableMonth: Date; + lastAvailableMonth: Date; + position: Position; } export const MonthPickerCell: React.FC = ({ month, - year, onClick, selected, locale, + autoFocus, + monthPickerId, + position, }) => { - const label = useMemo(() => { - const now = new Date(year, month, 1); - return startCase(format(now, "MMM", { locale })); - }, [locale, year, month]); + const label = useMemo( + () => startCase(format(month, "MMM", { locale })), + [locale, month] + ); + + const abbr = useMemo( + () => startCase(format(month, "MMMM", { locale })), + [locale, month] + ); + + const ref = useRef(null); + + const domId = getDomIdForMonth(position, monthPickerId); + + useEffect(() => { + ref.current?.focus(); + }, []); + + const onKeyDown = useCallback>( + (ev) => { + const nextDomId = getDomIdForKeyboardKey( + ev.key, + position, + monthPickerId, + 4 + ); + if (nextDomId) { + document.getElementById(nextDomId)?.focus(); + } + }, + [monthPickerId, position] + ); return ( - + {selected ? ( - + ) : ( - + )} ); diff --git a/packages/calendar/src/features/month-picker/MonthPickerDataFactory.ts b/packages/calendar/src/features/month-picker/MonthPickerDataFactory.ts new file mode 100644 index 000000000..e7317aa3a --- /dev/null +++ b/packages/calendar/src/features/month-picker/MonthPickerDataFactory.ts @@ -0,0 +1,91 @@ +import { addMonths } from "date-fns"; +import { Position } from "./Position"; + +export interface MonthInput { + yearOrder: Array; + years: Record; + rows: Array; + lastMonthRow: number; + lastMonthColumn: number; +} + +export interface YearInput { + year: number; + rows: Array; +} + +export interface RowInput { + rowIndex: number; + columns: Array; +} + +export interface Columns { + position: Position; + month: Date; +} + +export const createMonths = ( + firstMonth: Date, + numMonths: number, + numColumnsPerRow: number +): MonthInput => { + let currentYear = firstMonth.getFullYear(); + let currentRow = 0; + let currentColumn = 0; + let currentMonth = firstMonth; + + const input: MonthInput = { + yearOrder: [], + rows: [], + years: {}, + lastMonthColumn: -1, + lastMonthRow: -1, + }; + + for (let i = 0; i < numMonths; i++) { + if (input.years[currentYear] == null) { + input.yearOrder.push(currentYear); + input.years[currentYear] = { year: currentYear, rows: [] }; + } + + input.rows[currentRow] = input.rows[currentRow] ?? { + columns: [], + rowIndex: currentRow, + }; + + input.rows[currentRow].columns[currentColumn] = { + position: { + column: currentColumn, + row: currentRow, + }, + month: currentMonth, + }; + + if (i === numMonths - 1) { + // Last one, add it and exit + input.years[currentYear].rows.push(currentRow); + break; + } + + currentMonth = addMonths(currentMonth, 1); + + if (currentMonth.getFullYear() !== currentYear) { + input.years[currentYear].rows.push(currentRow); + currentYear++; + currentColumn = 0; + currentRow++; + } else { + currentColumn++; + if (currentColumn > numColumnsPerRow - 1) { + input.years[currentYear].rows.push(currentRow); + currentColumn = 0; + currentRow++; + } + } + } + + input.lastMonthColumn = currentColumn; + input.lastMonthRow = currentRow; + + return input; +}; diff --git a/packages/calendar/src/features/month-picker/MonthPickerKeyboardNavigation.ts b/packages/calendar/src/features/month-picker/MonthPickerKeyboardNavigation.ts new file mode 100644 index 000000000..ac99a0486 --- /dev/null +++ b/packages/calendar/src/features/month-picker/MonthPickerKeyboardNavigation.ts @@ -0,0 +1,57 @@ +import { Position } from "./Position"; + +export const getDomIdForMonth = ( + position: Position, + monthPickerId: string +): string => { + return `${position.row}-${position.column}-${monthPickerId}`; +}; + +export const getDomIdForKeyboardKey = ( + key: string, + currentPosition: Position, + monthPickerId: string, + numColumnsPerRow: number +): string | undefined => { + let next = currentPosition; + for (let i = 0; i < numColumnsPerRow; i++) { + next = movePositionByKey(next, key, numColumnsPerRow); + const id = getDomIdForMonth(next, monthPickerId); + if (document.getElementById(id)) { + return id; + } + } + return undefined; +}; + +export const movePositionByKey = ( + currentPosition: Position, + key: string, + numColumnsPerRow: number +): Position => { + let row = currentPosition.row; + let column = currentPosition.column; + if (key === "ArrowLeft") { + column--; + } else if (key === "ArrowUp") { + row--; + } else if (key === "ArrowRight") { + column++; + } else if (key === "ArrowDown") { + row++; + } + + if (column < 0) { + column = numColumnsPerRow - 1; + row--; + } + if (column > numColumnsPerRow - 1) { + column = 0; + row++; + } + + return { + column, + row, + }; +}; diff --git a/packages/calendar/src/features/month-picker/Position.ts b/packages/calendar/src/features/month-picker/Position.ts new file mode 100644 index 000000000..93fb91048 --- /dev/null +++ b/packages/calendar/src/features/month-picker/Position.ts @@ -0,0 +1,47 @@ +export interface Position { + row: number; + column: number; +} + +export const moveLeft = ( + position: Position, + numColumnsPerRow: number +): Position => { + if (position.column === 0 && position.row === 0) { + return position; + } + return { + column: + position.column - 1 < 0 ? numColumnsPerRow - 1 : position.column - 1, + row: position.column - 1 < 0 ? position.row - 1 : position.row, + }; +}; + +export const moveRight = ( + position: Position, + numColumnsPerRow: number +): Position => ({ + column: (position.column + 1) % numColumnsPerRow, + row: + (position.column + 1) / numColumnsPerRow === 1 + ? position.row + 1 + : position.row, +}); + +export const moveUp = (position: Position): Position => { + if (position.row === 0) { + return position; + } + return { + column: position.column, + row: position.row - 1, + }; +}; + +export const moveDown = (position: Position): Position => { + return { + column: position.column, + + row: position.row + 1, + }; +}; diff --git a/packages/calendar/src/features/month-picker/__tests__/MonthPickerDataFactory.test.ts b/packages/calendar/src/features/month-picker/__tests__/MonthPickerDataFactory.test.ts new file mode 100644 index 000000000..81639c63a --- /dev/null +++ b/packages/calendar/src/features/month-picker/__tests__/MonthPickerDataFactory.test.ts @@ -0,0 +1,20 @@ +import { createMonths } from "../MonthPickerDataFactory"; + +describe("MonthPickerDataFactory", () => { + describe("createMonths", () => { + describe("From Jan 2024 and 12 months", () => { + it("works", () => { + const input = createMonths(new Date(2024, 3), 12, 4); + expect(input.rows.length).toBe(4); + expect(input.years["2024"].rows).toEqual([0, 1, 2]); + expect(input.years["2025"].rows).toEqual([3]); + }); + it("sets last column and row", () => { + const input = createMonths(new Date(2024, 3), 12, 4); + expect(input.rows.length).toBe(4); + expect(input.lastMonthRow).toEqual(3); + expect(input.lastMonthColumn).toEqual(2); + }); + }); + }); +}); diff --git a/packages/calendar/src/features/month-picker/__tests__/Position.test.ts b/packages/calendar/src/features/month-picker/__tests__/Position.test.ts new file mode 100644 index 000000000..a198d0dc6 --- /dev/null +++ b/packages/calendar/src/features/month-picker/__tests__/Position.test.ts @@ -0,0 +1,76 @@ +import { moveDown, moveLeft, moveRight, moveUp } from "../Position"; + +describe("MonthPickerKeyboardNavigation", () => { + describe("moveRight", () => { + describe("in the middle", () => { + it("adds column", () => { + expect(moveRight({ column: 2, row: 2 }, 4)).toEqual({ + column: 3, + row: 2, + }); + }); + }); + describe("when passing right side", () => { + it("moves to first column on next row", () => { + expect(moveRight({ column: 3, row: 2 }, 4)).toEqual({ + column: 0, + row: 3, + }); + }); + }); + }); + describe("moveLeft", () => { + describe("in the middle", () => { + it("goes up a column", () => { + expect(moveLeft({ column: 2, row: 2 }, 4)).toEqual({ + column: 1, + row: 2, + }); + }); + }); + describe("when passing right side", () => { + it("moves to first column on next row", () => { + expect(moveLeft({ column: 0, row: 2 }, 4)).toEqual({ + column: 3, + row: 1, + }); + }); + }); + describe("when in top left corner", () => { + it("stays in same position", () => { + expect(moveLeft({ column: 0, row: 0 }, 4)).toEqual({ + column: 0, + row: 0, + }); + }); + }); + }); + describe("moveUp", () => { + describe("in the middle", () => { + it("goes up a row", () => { + expect(moveUp({ column: 2, row: 2 })).toEqual({ + column: 2, + row: 1, + }); + }); + }); + describe("when in first row", () => { + it("it stays in same position", () => { + expect(moveUp({ column: 2, row: 0 })).toEqual({ + column: 2, + row: 0, + }); + }); + }); + }); + describe("moveDown", () => { + describe("in the middle", () => { + it("goes up a row", () => { + expect(moveDown({ column: 2, row: 2 })).toEqual({ + column: 2, + row: 3, + }); + }); + }); + }); +}); diff --git a/packages/calendar/src/features/travel-calendar/components/MonthHeader.tsx b/packages/calendar/src/features/travel-calendar/components/MonthHeader.tsx new file mode 100644 index 000000000..e0f2fb32e --- /dev/null +++ b/packages/calendar/src/features/travel-calendar/components/MonthHeader.tsx @@ -0,0 +1,66 @@ +import * as React from "react"; +import { Ref } from "react"; +import { + FlatButton, + SecondaryButton, + stenaAngleDown, + stenaAngleUp, + stenaArrowLeft, + stenaArrowRight, +} from "@stenajs-webui/elements"; +import { Row } from "@stenajs-webui/core"; +import { addMonths, subMonths } from "date-fns"; +import { VisiblePanel } from "../hooks/UseTravelDateRangeInput"; + +export interface MonthHeaderProps { + monthPickerButtonLabel: string; + nextMonthButtonAriaLabel: string; + previousMonthButtonAriaLabel: string; + visiblePanel: VisiblePanel; + setVisiblePanel: (panel: VisiblePanel) => void; + monthPickerButtonRef: Ref; + visibleMonth: Date; + setVisibleMonth: (date: Date) => void; + prevMonthDisabled: boolean; +} + +export const MonthHeader: React.FC = ({ + previousMonthButtonAriaLabel, + nextMonthButtonAriaLabel, + monthPickerButtonLabel, + visiblePanel, + setVisiblePanel, + monthPickerButtonRef, + setVisibleMonth, + visibleMonth, + prevMonthDisabled, +}) => { + return ( + + + setVisiblePanel( + visiblePanel === "calendar" ? "month-picker" : "calendar" + ) + } + ref={monthPickerButtonRef} + /> + + setVisibleMonth(subMonths(visibleMonth, 1))} + disabled={prevMonthDisabled} + aria-label={previousMonthButtonAriaLabel} + /> + setVisibleMonth(addMonths(visibleMonth, 1))} + aria-label={nextMonthButtonAriaLabel} + /> + + + ); +}; diff --git a/packages/calendar/src/features/travel-calendar/components/TravelCalendar.module.css b/packages/calendar/src/features/travel-calendar/components/TravelCalendar.module.css new file mode 100644 index 000000000..69eb7a050 --- /dev/null +++ b/packages/calendar/src/features/travel-calendar/components/TravelCalendar.module.css @@ -0,0 +1,11 @@ +.travelCalendar { + border-spacing: 0 4px; + width: fit-content; + + th { + } + + td { + padding: 0; + } +} diff --git a/packages/calendar/src/features/travel-calendar/components/TravelCalendar.tsx b/packages/calendar/src/features/travel-calendar/components/TravelCalendar.tsx new file mode 100644 index 000000000..807d7e3c7 --- /dev/null +++ b/packages/calendar/src/features/travel-calendar/components/TravelCalendar.tsx @@ -0,0 +1,84 @@ +import * as React from "react"; +import { + DayData, + MonthData, + WeekData, +} from "../../../util/calendar/CalendarDataFactory"; +import { Text } from "@stenajs-webui/core"; +import { TravelDateCell } from "./TravelDateCell"; +import { isSameDay } from "date-fns"; +import { Dispatch, SetStateAction } from "react"; +import styles from "./TravelCalendar.module.css"; + +export interface TravelCalendarProps { + visibleMonthData: MonthData; + onClickDate: (date: Date) => void; + visibleMonth: Date; + setVisibleMonth: (visibleMonth: Date) => void; + isValidDateRange: boolean; + setHoverDate: Dispatch>; + selectedStartDate: Date | undefined; + selectedEndDate: Date | undefined; + hoverDate: Date | undefined; + today: Date; + isDateDisabled: (date: Date) => boolean; + calendarId: string; + todayIsInVisibleMonth: boolean; +} + +export const TravelCalendar: React.FC = ({ + visibleMonthData, + onClickDate, + setHoverDate, + setVisibleMonth, + visibleMonth, + isValidDateRange, + selectedStartDate, + selectedEndDate, + hoverDate, + today, + calendarId, + isDateDisabled, + todayIsInVisibleMonth, +}) => { + return ( + + + + {visibleMonthData.weeks[0].days.map((day: DayData) => ( + + ))} + + {visibleMonthData.weeks.map((week: WeekData) => ( + + + {week.days.map((day) => ( + onClickDate(d)} + key={day.dateString} + visibleMonth={visibleMonth} + onChangeVisibleMonth={setVisibleMonth} + isValidDateRange={isValidDateRange} + day={day} + onStartHover={(d) => setHoverDate(d)} + onEndHover={(d) => + setHoverDate((p) => (p && isSameDay(p, d) ? undefined : p)) + } + selectedStartDate={selectedStartDate} + selectedEndDate={selectedEndDate} + hoverDate={hoverDate} + today={today} + todayIsInVisibleMonth={todayIsInVisibleMonth} + calendarId={calendarId} + isDateDisabled={isDateDisabled} + /> + ))} + + + ))} + +
+ {day.name} +
+ ); +}; diff --git a/packages/calendar/src/features/travel-calendar/components/TravelDateCell.module.css b/packages/calendar/src/features/travel-calendar/components/TravelDateCell.module.css new file mode 100644 index 000000000..2612fb584 --- /dev/null +++ b/packages/calendar/src/features/travel-calendar/components/TravelDateCell.module.css @@ -0,0 +1,82 @@ +.travelDateCell { + position: relative; + width: 48px; + height: 48px; + + border-radius: var(--swui-max-border-radius); + + &:focus { + outline: none; + } + + &:focus-visible { + .outline { + outline: var(--swui-focus-outline); + outline-width: var(--swui-focus-outline-width); + background: transparent; + position: absolute; + border-radius: var(--swui-max-border-radius); + top: 0; + bottom: 0; + left: 0; + right: 0; + z-index: 10; + } + + .contentWrapper.contentWrapper.contentWrapper { + border-color: transparent; + } + } + + .contentWrapper { + cursor: pointer; + border-radius: var(--swui-max-border-radius); + border-width: 2px; + border-style: solid; + border-color: transparent; + background: transparent; + position: absolute; + display: flex; + align-items: center; + justify-content: center; + left: 0; + right: 0; + top: 0; + bottom: 0; + + &.disabled { + cursor: not-allowed; + } + &.isToday { + border-color: var(--lhds-color-ui-400); + } + + &.isSelectionStart, + &.isSelectionEnd { + border-color: var(--modern-red); + } + + &.isSelectionStart { + background: var(--modern-red); + span { + color: white; + } + } + + &.isSelectionEnd:not(.isSelectionStart) { + background: white; + } + + &.hover { + border-color: var(--modern-red); + + &.startSelected:not(.endSelected):not(.isSelectionStart) { + background: white; + + span { + color: var(--swui-text-primary-color); + } + } + } + } +} diff --git a/packages/calendar/src/features/travel-calendar/components/TravelDateCell.tsx b/packages/calendar/src/features/travel-calendar/components/TravelDateCell.tsx new file mode 100644 index 000000000..802a1179d --- /dev/null +++ b/packages/calendar/src/features/travel-calendar/components/TravelDateCell.tsx @@ -0,0 +1,190 @@ +import * as React from "react"; +import { KeyboardEventHandler, useCallback } from "react"; +import { Box, Row, Text } from "@stenajs-webui/core"; +import { DayData } from "../../../util/calendar/CalendarDataFactory"; +import styles from "./TravelDateCell.module.css"; +import cx from "classnames"; +import { isSameDay, isSameMonth } from "date-fns"; +import { getCellBackgroundColors } from "../util/CellBgColors"; +import { getDateToFocusOn } from "../util/KeyboardNavigation"; +import { createDayId } from "../util/DayIdGenerator"; +import { cssColor } from "@stenajs-webui/theme"; + +export interface TravelDateCellProps { + onClick: (date: Date) => void; + day: DayData; + visibleMonth: Date; + selectedStartDate: Date | undefined; + selectedEndDate: Date | undefined; + isValidDateRange: boolean; + onChangeVisibleMonth: (visibleMonth: Date) => void; + onStartHover: (date: Date) => void; + onEndHover: (date: Date) => void; + hoverDate: Date | undefined; + today: Date; + todayIsInVisibleMonth: boolean; + calendarId: string; + isDateDisabled: (date: Date) => boolean; +} + +export const TravelDateCell: React.FC = ({ + onClick, + visibleMonth, + onChangeVisibleMonth, + day, + isValidDateRange, + selectedStartDate, + selectedEndDate, + onStartHover, + onEndHover, + hoverDate, + today, + todayIsInVisibleMonth, + calendarId, + isDateDisabled, +}) => { + const onKeyDown = useCallback>( + async (e) => { + const nextDate = getDateToFocusOn(day.date, e.key); + if (nextDate && !isDateDisabled(nextDate)) { + onStartHover(nextDate); + if (!isSameMonth(day.date, nextDate)) { + onChangeVisibleMonth(nextDate); + setTimeout(() => { + document.getElementById(createDayId(nextDate, calendarId))?.focus(); + }, 10); + } else { + document.getElementById(createDayId(nextDate, calendarId))?.focus(); + } + } + + if (e.key === "Enter" || e.code === "Space") { + onClick(day.date); + } + }, + [ + calendarId, + day.date, + isDateDisabled, + onChangeVisibleMonth, + onClick, + onStartHover, + ] + ); + + const dayIsInMonth = day.month === visibleMonth.getMonth(); + + const disabled = isDateDisabled(day.date); + + const isSelectionStart = selectedStartDate + ? isSameDay(selectedStartDate, day.date) + : false; + + const isSelectionEnd = selectedEndDate + ? isSameDay(selectedEndDate, day.date) + : false; + + const isToday = isSameDay(day.date, today); + + const bgColors = getCellBackgroundColors( + day.date, + selectedStartDate, + selectedEndDate, + hoverDate, + dayIsInMonth, + isValidDateRange + ); + + return ( + onClick(day.date)} + onMouseOver={ + disabled ? undefined : () => dayIsInMonth && onStartHover(day.date) + } + onMouseOut={ + disabled ? undefined : () => dayIsInMonth && onEndHover(day.date) + } + tabIndex={ + disabled + ? undefined + : getTabIndex( + day, + selectedStartDate, + isToday, + visibleMonth, + todayIsInVisibleMonth + ) + } + id={disabled ? undefined : createDayId(day.date, calendarId)} + onKeyDown={disabled ? undefined : onKeyDown} + aria-selected={isSelectionStart || isSelectionEnd} + > +
+ + + + + + + {dayIsInMonth && ( +
+ + {day.dayOfMonth} + +
+ )} + + ); +}; + +const getTabIndex = ( + day: DayData, + selectedStartDate: Date | undefined, + isToday: boolean, + visibleMonth: Date, + todayIsInVisibleMonth: boolean +): number => { + const selectedStartDateIsVisible = selectedStartDate + ? isSameMonth(selectedStartDate, visibleMonth) + : false; + + /** + * If date has been selected that date should be tabIndex = 0. + * If no date has been selected, today's date should be tabIndex = 0. + * All else should be -1. + */ + if ( + selectedStartDate && selectedStartDateIsVisible + ? isSameDay(day.date, selectedStartDate) + : isToday + ) { + return 0; + } + + if ( + !selectedStartDateIsVisible && + !todayIsInVisibleMonth && + day.date.getDate() === 1 + ) { + return 0; + } + + return -1; +}; diff --git a/packages/calendar/src/features/travel-calendar/components/TravelDateTextInput.tsx b/packages/calendar/src/features/travel-calendar/components/TravelDateTextInput.tsx new file mode 100644 index 000000000..3e149528d --- /dev/null +++ b/packages/calendar/src/features/travel-calendar/components/TravelDateTextInput.tsx @@ -0,0 +1,57 @@ +import * as React from "react"; +import { + LabelledTextInput, + LabelledTextInputProps, +} from "@stenajs-webui/forms"; +import { useRef } from "react"; +import { + InputMask, + InputMaskPipe, + InputMaskProvider, + useMaskedInput, +} from "@stenajs-webui/input-mask"; + +export interface TravelDateTextInputProps extends LabelledTextInputProps { + mask: InputMask | InputMaskProvider; + pipe?: InputMaskPipe; + guide?: boolean; + keepCharPositions?: boolean; + placeholderChar?: string; + showMask?: boolean; +} + +export const TravelDateTextInput: React.FC = ({ + onChange, + onValueChange, + mask, + pipe, + value, + guide, + keepCharPositions, + placeholderChar, + showMask, + ...inputProps +}) => { + const inputRef = useRef(null); + const { onChange: maskedOnChange } = useMaskedInput( + inputRef, + onChange, + onValueChange, + mask, + pipe, + value, + guide, + keepCharPositions, + placeholderChar, + showMask + ); + + return ( + + ); +}; diff --git a/packages/calendar/src/features/travel-calendar/components/TravelDateTextInputFields.tsx b/packages/calendar/src/features/travel-calendar/components/TravelDateTextInputFields.tsx new file mode 100644 index 000000000..a19948711 --- /dev/null +++ b/packages/calendar/src/features/travel-calendar/components/TravelDateTextInputFields.tsx @@ -0,0 +1,81 @@ +import * as React from "react"; +import { useMemo } from "react"; +import { Row } from "@stenajs-webui/core"; +import { TravelDateTextInput } from "./TravelDateTextInput"; +import { createInputMaskForDateFormat } from "../../localize-date-format/InputMaskProvider"; +import { getDateFormatForLocaleCode } from "../../localize-date-format/DateFormatProvider"; +import { reformatLocalizedDateString } from "../../localize-date-format/LocalizedDateReformatter"; +import { TravelDateRangeInputValue } from "../types"; + +export interface TravelDateTextInputFieldsProps { + value: TravelDateRangeInputValue | undefined; + onValueChange: + | ((value: Partial) => void) + | undefined; + localeCode: string; + startDateLabel?: string; + endDateLabel?: string; + onFocus?: () => void; +} + +export const TravelDateTextInputFields: React.FC< + TravelDateTextInputFieldsProps +> = ({ + value, + onValueChange, + localeCode, + startDateLabel = "From", + endDateLabel = "To", + onFocus, +}) => { + const { mask, placeholder } = useMemo(() => { + const dateFormatForLocaleCode = getDateFormatForLocaleCode(localeCode); + return { + mask: createInputMaskForDateFormat(dateFormatForLocaleCode), + placeholder: dateFormatForLocaleCode.toLowerCase(), + }; + }, [localeCode]); + + return ( + + { + onValueChange?.({ startDate: v }); + }} + onBlur={(ev) => { + const startDate = reformatLocalizedDateString( + ev.target.value, + localeCode + ); + if (startDate && startDate !== value?.startDate) { + onValueChange?.({ startDate }); + } + }} + onFocus={onFocus} + label={startDateLabel} + borderRadiusVariant={"onlyLeft"} + placeholder={placeholder} + /> + onValueChange?.({ endDate: v })} + onBlur={(ev) => { + const endDate = reformatLocalizedDateString( + ev.target.value, + localeCode + ); + if (endDate && endDate !== value?.endDate) { + onValueChange?.({ endDate }); + } + }} + onFocus={onFocus} + label={endDateLabel} + borderRadiusVariant={"onlyRight"} + placeholder={placeholder} + /> + + ); +}; diff --git a/packages/calendar/src/features/travel-calendar/hooks/UseTravelDateRangeInput.ts b/packages/calendar/src/features/travel-calendar/hooks/UseTravelDateRangeInput.ts new file mode 100644 index 000000000..7638928db --- /dev/null +++ b/packages/calendar/src/features/travel-calendar/hooks/UseTravelDateRangeInput.ts @@ -0,0 +1,191 @@ +import { + getDefaultLocaleForFormatting, + getLocaleForLocaleCode, +} from "../../localize-date-format/LocaleMapper"; +import { useCallback, useId, useMemo, useRef, useState } from "react"; +import { useToday } from "../util/UseToday"; +import { getDateFormatForLocaleCode } from "../../localize-date-format/DateFormatProvider"; +import { parseLocalizedDateString } from "../../localize-date-format/LocalizedDateParser"; +import { format, isAfter, isBefore, isSameDay, isSameMonth } from "date-fns"; +import { getMonthInYear } from "../../../util/calendar/CalendarDataFactory"; +import { startCase } from "lodash-es"; +import { formatLocalizedDate } from "../../localize-date-format/LocalizedDateFormatter"; +import { TravelDateRangeInputValue } from "../types"; + +export type VisiblePanel = "calendar" | "month-picker"; + +export const useTravelDateRangeInput = ( + value: TravelDateRangeInputValue | undefined, + onValueChange: ((value: TravelDateRangeInputValue) => void) | undefined, + localeCode: string, + initialMonthInFocus: Date | undefined +) => { + const locale = + getLocaleForLocaleCode(localeCode) ?? getDefaultLocaleForFormatting(); + + const calendarId = useId(); + const today = useToday(); + + const monthPickerButtonRef = useRef(null); + + const dateFormat = useMemo( + () => getDateFormatForLocaleCode(localeCode), + [localeCode] + ); + + const selectedStartDate = useMemo( + () => + value?.startDate?.length === dateFormat.length + ? parseLocalizedDateString(value.startDate, localeCode) + : undefined, + [dateFormat.length, localeCode, value?.startDate] + ); + + const selectedEndDate = useMemo( + () => + value?.endDate?.length === dateFormat.length + ? parseLocalizedDateString(value.endDate, localeCode) + : undefined, + [dateFormat.length, localeCode, value?.endDate] + ); + + const [visibleMonth, setVisibleMonth] = useState( + initialMonthInFocus ?? selectedStartDate ?? new Date() + ); + + const setVisibleMonthClamped = useCallback( + (month: Date) => { + if (isSameMonth(month, today) || isAfter(month, today)) { + setVisibleMonth(month); + } else { + setVisibleMonth(today); + } + }, + [today] + ); + + const visibleMonthData = useMemo( + () => + getMonthInYear( + visibleMonth.getFullYear(), + visibleMonth.getMonth(), + locale + ), + [locale, visibleMonth] + ); + + const monthPickerButtonLabel = useMemo(() => { + return startCase(format(visibleMonth, "MMMM yyyy", { locale })); + }, [locale, visibleMonth]); + + const todayIsInVisibleMonth = useMemo(() => { + return isSameMonth(today, visibleMonth); + }, [today, visibleMonth]); + + const [hoverDate, setHoverDate] = useState(); + + const [visiblePanel, setVisiblePanel] = useState("calendar"); + + const onValueChangeByInputs = useCallback< + (value: TravelDateRangeInputValue) => void + >( + (v) => { + const startDate = + v?.startDate?.length === dateFormat.length + ? parseLocalizedDateString(v.startDate, localeCode) + : undefined; + + const endDate = + v?.endDate?.length === dateFormat.length + ? parseLocalizedDateString(v.endDate, localeCode) + : undefined; + + if (startDate) { + setVisibleMonthClamped(startDate); + } else if (endDate) { + setVisibleMonthClamped(endDate); + } + + onValueChange?.({ + ...value, + ...v, + }); + }, + [ + dateFormat.length, + localeCode, + onValueChange, + setVisibleMonthClamped, + value, + ] + ); + + const prevMonthDisabled = useMemo( + () => isSameMonth(today, visibleMonth) || isBefore(visibleMonth, today), + [today, visibleMonth] + ); + + const isValidDateRange = useMemo( + () => + (selectedStartDate && + selectedEndDate && + (isSameDay(selectedStartDate, selectedEndDate) || + isBefore(selectedStartDate, selectedEndDate))) ?? + false, + [selectedEndDate, selectedStartDate] + ); + + const isDateDisabled = useCallback<(date: Date) => boolean>( + (date) => !isSameDay(date, today) && isBefore(date, today), + [today] + ); + + const onClickDate = (date: Date) => { + const isSameMonthAndYear = + date.getFullYear() === visibleMonth.getFullYear() && + date.getMonth() === visibleMonth.getMonth(); + + if (isSameMonthAndYear) { + if (selectedStartDate && selectedEndDate == null) { + if (isBefore(date, selectedStartDate)) { + onValueChange?.({ + startDate: formatLocalizedDate(date, localeCode), + endDate: undefined, + }); + } else { + onValueChange?.({ + startDate: value?.startDate, + endDate: formatLocalizedDate(date, localeCode), + }); + } + } else { + onValueChange?.({ + startDate: formatLocalizedDate(date, localeCode), + endDate: undefined, + }); + } + } + }; + + return { + isDateDisabled, + onClickDate, + onValueChangeByInputs, + isValidDateRange, + prevMonthDisabled, + monthPickerButtonRef, + calendarId, + monthPickerButtonLabel, + visiblePanel, + setVisiblePanel, + setVisibleMonth, + visibleMonthData, + todayIsInVisibleMonth, + hoverDate, + setHoverDate, + selectedStartDate, + selectedEndDate, + today, + visibleMonth, + }; +}; diff --git a/packages/calendar/src/features/travel-calendar/types.ts b/packages/calendar/src/features/travel-calendar/types.ts new file mode 100644 index 000000000..ff0cc4311 --- /dev/null +++ b/packages/calendar/src/features/travel-calendar/types.ts @@ -0,0 +1,8 @@ +export interface TravelDateRangeInputValue { + /* + User input, so no specific format. + Need to be parsed with parseLocalizedDateString() + */ + startDate?: string; + endDate?: string; +} diff --git a/packages/calendar/src/features/travel-calendar/util/CellBgColors.ts b/packages/calendar/src/features/travel-calendar/util/CellBgColors.ts new file mode 100644 index 000000000..06ccabc1b --- /dev/null +++ b/packages/calendar/src/features/travel-calendar/util/CellBgColors.ts @@ -0,0 +1,110 @@ +import { cssColor } from "@stenajs-webui/theme"; +import { isAfter, isBefore, isSameDay } from "date-fns"; + +const rangeBgColor = cssColor("--lhds-color-red-100"); + +export const getCellBackgroundColors = ( + date: Date, + selectedStartDate: Date | undefined, + selectedEndDate: Date | undefined, + hoverDate: Date | undefined, + dayIsInMonth: boolean, + isValidDateRange: boolean +): { left: string; right: string } => { + if (!dayIsInMonth) { + return { + left: "transparent", + right: "transparent", + }; + } + + if ( + selectedStartDate && + selectedEndDate && + isSameDay(selectedStartDate, selectedEndDate) + ) { + return { + left: "transparent", + right: "transparent", + }; + } + + if (selectedStartDate && selectedEndDate && !isValidDateRange) { + return { + left: "transparent", + right: "transparent", + }; + } + + const isInSelectionRange = + selectedStartDate && selectedEndDate + ? isAfter(date, selectedStartDate) && + isBefore(date, selectedEndDate) && + !isSameDay(date, selectedStartDate) && + !isSameDay(date, selectedEndDate) + : false; + + const isInHoverRange = + selectedStartDate && hoverDate + ? isAfter(date, selectedStartDate) && + isBefore(date, hoverDate) && + !isSameDay(date, selectedStartDate) && + !isSameDay(date, hoverDate) + : false; + + if (isInSelectionRange) { + return { + left: rangeBgColor, + right: rangeBgColor, + }; + } + + if (selectedStartDate && selectedEndDate) { + if (isSameDay(date, selectedStartDate)) { + return { + left: "transparent", + right: rangeBgColor, + }; + } + + if (isSameDay(date, selectedEndDate)) { + return { + left: rangeBgColor, + right: "transparent", + }; + } + + return { + left: "transparent", + right: "transparent", + }; + } + + if (selectedStartDate && hoverDate && isAfter(hoverDate, selectedStartDate)) { + if (isSameDay(date, selectedStartDate)) { + return { + left: "transparent", + right: rangeBgColor, + }; + } + + if (isSameDay(date, hoverDate)) { + return { + left: rangeBgColor, + right: "transparent", + }; + } + } + + if (isInHoverRange) { + return { + left: rangeBgColor, + right: rangeBgColor, + }; + } + + return { + left: "transparent", + right: "transparent", + }; +}; diff --git a/packages/calendar/src/features/travel-calendar/util/DayIdGenerator.ts b/packages/calendar/src/features/travel-calendar/util/DayIdGenerator.ts new file mode 100644 index 000000000..034364e29 --- /dev/null +++ b/packages/calendar/src/features/travel-calendar/util/DayIdGenerator.ts @@ -0,0 +1,6 @@ +import { addHours, format } from "date-fns"; +import { DateFormats } from "../../../util/date/DateFormats"; + +export const createDayId = (date: Date, calendarId: string) => { + return format(addHours(date, 12), DateFormats.fullDate) + calendarId; +}; diff --git a/packages/calendar/src/features/travel-calendar/util/KeyboardNavigation.tsx b/packages/calendar/src/features/travel-calendar/util/KeyboardNavigation.tsx new file mode 100644 index 000000000..8cd3745c0 --- /dev/null +++ b/packages/calendar/src/features/travel-calendar/util/KeyboardNavigation.tsx @@ -0,0 +1,27 @@ +import { addDays, addMonths, startOfWeek, subDays, subMonths } from "date-fns"; + +export const getDateToFocusOn = ( + currentDate: Date, + key: string +): Date | undefined => { + switch (key) { + case "PageUp": + return subMonths(currentDate, 1); + case "PageDown": + return addMonths(currentDate, 1); + case "Home": + return startOfWeek(currentDate); + case "End": + return addDays(startOfWeek(currentDate), 6); + case "ArrowLeft": + return subDays(currentDate, 1); + case "ArrowUp": + return subDays(currentDate, 7); + case "ArrowRight": + return addDays(currentDate, 1); + case "ArrowDown": + return addDays(currentDate, 7); + default: + return undefined; + } +}; diff --git a/packages/calendar/src/features/travel-calendar/util/UseToday.ts b/packages/calendar/src/features/travel-calendar/util/UseToday.ts new file mode 100644 index 000000000..8771e3604 --- /dev/null +++ b/packages/calendar/src/features/travel-calendar/util/UseToday.ts @@ -0,0 +1,5 @@ +import { useMemo } from "react"; + +export const useToday = () => { + return useMemo(() => new Date(), []); +}; diff --git a/packages/calendar/src/features/travel-calendar/util/__tests__/KeyboardNavigation.test.ts b/packages/calendar/src/features/travel-calendar/util/__tests__/KeyboardNavigation.test.ts new file mode 100644 index 000000000..356f0c986 --- /dev/null +++ b/packages/calendar/src/features/travel-calendar/util/__tests__/KeyboardNavigation.test.ts @@ -0,0 +1,46 @@ +import { getDateToFocusOn } from "../KeyboardNavigation"; +import { Month } from "../../../../util/calendar/CalendarDataFactory"; + +describe("KeyboardNavigation", () => { + describe("getDateToFocusOn", () => { + describe("PageUp", () => { + describe("when day does not exist in previous month", () => { + describe("for not leap years", () => { + it("selects last day of month", () => { + const r = getDateToFocusOn( + new Date(2023, Month.MARCH, 30), + "PageUp" + ); + expect(r?.getFullYear()).toBe(2023); + expect(r?.getMonth()).toBe(Month.FEBRUARY); + expect(r?.getDate()).toBe(28); + }); + }); + describe("for leap years", () => { + it("selects last day of month", () => { + const r = getDateToFocusOn( + new Date(2024, Month.MARCH, 30), + "PageUp" + ); + expect(r?.getFullYear()).toBe(2024); + expect(r?.getMonth()).toBe(Month.FEBRUARY); + expect(r?.getDate()).toBe(29); + }); + }); + }); + }); + describe("PageDown", () => { + describe("when day does not exist in previous month", () => { + it("selects last day of month", () => { + const r = getDateToFocusOn( + new Date(2023, Month.MARCH, 31), + "PageDown" + ); + expect(r?.getFullYear()).toBe(2023); + expect(r?.getMonth()).toBe(Month.APRIL); + expect(r?.getDate()).toBe(30); + }); + }); + }); + }); +}); diff --git a/packages/calendar/src/index.ts b/packages/calendar/src/index.ts index efe603501..3ee61a7ca 100644 --- a/packages/calendar/src/index.ts +++ b/packages/calendar/src/index.ts @@ -28,7 +28,11 @@ export * from "./features/month-picker/MonthPicker"; export * from "./features/year-picker/YearPicker"; export * from "./features/preset-picker/PresetPicker"; export * from "./components/input-types/date-range-dual-text-input/DateRangeDualTextInput"; +export * from "./components/input-types/travel-date-range-input/TravelDateRangeInput"; +export * from "./components/calendar-types/travel-date-range-calendar/TravelDateRangeCalendar"; +export * from "./features/travel-calendar/types"; export * from "./features/localize-date-format/LocalizedDateFormatter"; export * from "./features/localize-date-format/LocalizedDateParser"; +export * from "./features/localize-date-format/LocalizedDateReformatter"; export * from "./features/localize-date-format/DateFormatProvider"; export * from "./features/localize-date-format/LocaleMapper"; diff --git a/packages/calendar/src/types/CalendarTypes.tsx b/packages/calendar/src/types/CalendarTypes.tsx index eac4c1b53..629962c0f 100644 --- a/packages/calendar/src/types/CalendarTypes.tsx +++ b/packages/calendar/src/types/CalendarTypes.tsx @@ -5,7 +5,6 @@ import { MonthData, WeekData, } from "../util/calendar/CalendarDataFactory"; -import { MonthPickerValue } from "../features/month-picker/MonthPicker"; import { Locale } from "date-fns"; export interface CalendarDayProps extends ExtraDayContentProps { @@ -53,8 +52,8 @@ export interface Renderers { } export interface RenderMonthPickerArgs { - value: MonthPickerValue; - onValueChange: (value: MonthPickerValue) => void; + value: Date; + onValueChange: (value: Date) => void; locale: Locale | undefined; firstMonth: Date; numMonths: number; diff --git a/packages/calendar/src/util/calendar/CalendarDataFactory.ts b/packages/calendar/src/util/calendar/CalendarDataFactory.ts index b9235894d..3198d0d68 100644 --- a/packages/calendar/src/util/calendar/CalendarDataFactory.ts +++ b/packages/calendar/src/util/calendar/CalendarDataFactory.ts @@ -45,6 +45,7 @@ export enum WeekDay { export interface DayData { name: string; + fullName: string; date: Date; // YYYY-MM dateString: string; // YYYY-MM weekNumber: number; @@ -94,6 +95,9 @@ export const getMonthInYear = ( month: number, locale: Locale ): MonthData => { + if (isNaN(year) || isNaN(month)) { + throw new Error("getMonthInYear() received NaN."); + } const yearToUse = year + Math.floor(month / 12); const monthToUse = month % 12; const firstDayOfMonth = new Date(yearToUse, monthToUse, 1); @@ -151,6 +155,7 @@ export const createDay = (date: Date, locale: Locale): DayData => { return { date, name: format(date, "EEE", locale ? { locale } : undefined), + fullName: format(date, "EEEE", locale ? { locale } : undefined), dateString: format(addHours(date, 12), DateFormats.fullDate), weekNumber: getWeek(date, { locale }), year: getYear(date), diff --git a/packages/forms/src/components/ui/labelled-text-input/LabelledTextInput.module.css b/packages/forms/src/components/ui/labelled-text-input/LabelledTextInput.module.css index 0f38b8137..fc52dfa3a 100644 --- a/packages/forms/src/components/ui/labelled-text-input/LabelledTextInput.module.css +++ b/packages/forms/src/components/ui/labelled-text-input/LabelledTextInput.module.css @@ -29,6 +29,17 @@ border-top-right-radius: 0; } + &&.onlyLeft { + border-bottom-right-radius: 0; + border-top-right-radius: 0; + border-right-color: transparent; + } + + &&.onlyRight { + border-top-left-radius: 0; + border-bottom-left-radius: 0; + } + &:focus-within { outline: var(--swui-focus-outline); outline-offset: -1px; diff --git a/packages/forms/src/components/ui/labelled-text-input/LabelledTextInput.stories.tsx b/packages/forms/src/components/ui/labelled-text-input/LabelledTextInput.stories.tsx index ffe8256ce..f268c590a 100644 --- a/packages/forms/src/components/ui/labelled-text-input/LabelledTextInput.stories.tsx +++ b/packages/forms/src/components/ui/labelled-text-input/LabelledTextInput.stories.tsx @@ -1,6 +1,6 @@ import * as React from "react"; import { LabelledTextInput } from "./LabelledTextInput"; -import { Column, Heading } from "@stenajs-webui/core"; +import { Column, Heading, Row } from "@stenajs-webui/core"; export default { title: "forms/LabelledTextInput", @@ -30,19 +30,26 @@ export const Demo = () => ( variant={"error"} id={"testlarge"} /> - Combined - - - - + Combined vertical + + + + + + + Combined horizontal + + + + ); diff --git a/packages/forms/src/components/ui/labelled-text-input/LabelledTextInput.tsx b/packages/forms/src/components/ui/labelled-text-input/LabelledTextInput.tsx index 9b8375fcc..832b5dab5 100644 --- a/packages/forms/src/components/ui/labelled-text-input/LabelledTextInput.tsx +++ b/packages/forms/src/components/ui/labelled-text-input/LabelledTextInput.tsx @@ -12,7 +12,9 @@ export type LabelledTextInputSize = "medium" | "large"; export type LabelledTextInputBorderVariant = | "normalBorder" | "onlyTop" - | "onlyBottom"; + | "onlyBottom" + | "onlyLeft" + | "onlyRight"; export interface LabelledTextInputProps extends Omit, @@ -43,6 +45,7 @@ export const LabelledTextInput = React.forwardRef< onValueChange, borderRadiusVariant = "normalBorder", variant = "normal", + width, ...inputProps }, ref @@ -68,6 +71,7 @@ export const LabelledTextInput = React.forwardRef< styles[size], disabled && styles.disabled )} + style={width ? { width } : undefined} >