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) => (
+
+ {day.name}
+ |
+ ))}
+
+ {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}
+ />
+ ))}
+
+
+ ))}
+
+
+ );
+};
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}
>