diff --git a/apps/website/screens/components/date-input/overview/images/date_input-datepicker.png b/apps/website/screens/components/date-input/overview/images/date_input-datepicker.png index d618ee1d5e..0b3ad1515b 100644 Binary files a/apps/website/screens/components/date-input/overview/images/date_input-datepicker.png and b/apps/website/screens/components/date-input/overview/images/date_input-datepicker.png differ diff --git a/packages/lib/src/date-input/DateInput.stories.tsx b/packages/lib/src/date-input/DateInput.stories.tsx index 23a5f373b3..821993c92f 100644 --- a/packages/lib/src/date-input/DateInput.stories.tsx +++ b/packages/lib/src/date-input/DateInput.stories.tsx @@ -8,9 +8,10 @@ import DxcContainer from "../container/Container"; import Calendar from "./Calendar"; import DxcDateInput from "./DateInput"; import DxcDatePicker from "./DatePicker"; -import YearPicker from "./YearPicker"; +import DateUnitPicker from "./DateUnitPicker"; import { fireEvent, screen, userEvent, within } from "storybook/internal/test"; import { HalstackProvider } from "../HalstackContext"; +import { getMonthPickerItems, getYearPickerItems } from "./utils"; export default { title: "Date Input", @@ -40,7 +41,7 @@ const DateInputChromatic = () => ( - <DxcDateInput label="Date input" helperText="Help message" format="dd/mm/yy" placeholder optional /> + <DxcDateInput label="Date input" helperText="Help message" format="dd/yyyy/MM" placeholder optional /> </ExampleContainer> <ExampleContainer> <Title title="Disabled" theme="light" level={4} /> @@ -147,86 +148,160 @@ const DatePickerButtonStates = () => ( </ExampleContainer> <ExampleContainer pseudoState="pseudo-focus"> <Title title="Isolated calendar focused" theme="light" level={4} /> - <DxcDatePicker date={dayjs("06-04-1950", "DD-MM-YYYY")} onDateSelect={() => {}} id="test-calendar1" /> + <DxcDatePicker + date={dayjs("06-04-1950", "DD-MM-YYYY")} + onDateSelect={() => {}} + id="test-calendar1" + format="dd/mm/yy" + /> </ExampleContainer> <ExampleContainer pseudoState="pseudo-hover"> <Title title="Isolated calendar hovered" theme="light" level={4} /> - <DxcDatePicker date={dayjs("06-04-1950", "DD-MM-YYYY")} onDateSelect={() => {}} id="test-calendar2" /> + <DxcDatePicker + date={dayjs("06-04-1950", "DD-MM-YYYY")} + onDateSelect={() => {}} + id="test-calendar2" + format="dd/mm/yy" + /> </ExampleContainer> <ExampleContainer pseudoState="pseudo-active"> <Title title="Isolated calendar actived" theme="light" level={4} /> - <DxcDatePicker date={dayjs("06-04-1950", "DD-MM-YYYY")} onDateSelect={() => {}} id="test-calendar3" /> + <DxcDatePicker + date={dayjs("06-04-1950", "DD-MM-YYYY")} + onDateSelect={() => {}} + id="test-calendar3" + format="dd/mm/yy" + /> </ExampleContainer> <ExampleContainer> <Title title="Isolated calendar with locale" theme="light" level={4} /> <HalstackProvider localeTag="en-US"> - <DxcDatePicker date={dayjs("06-04-1950", "DD-MM-YYYY")} onDateSelect={() => {}} id="test-calendar3" /> + <DxcDatePicker + date={dayjs("06-04-1950", "DD-MM-YYYY")} + onDateSelect={() => {}} + id="test-calendar3" + format="dd/mm/yy" + /> </HalstackProvider> </ExampleContainer> </> ); -const YearPickerButtonStates = () => ( - <> - <ExampleContainer pseudoState="pseudo-focus"> - <Title title="Isolated year picker focused" theme="light" level={4} /> - <YearPicker - selectedDate={dayjs("06-04-1905", "DD-MM-YYYY")} - onYearSelect={() => {}} - today={dayjs("1904-04-03", "YYYY-MM-DD")} - /> - </ExampleContainer> - <ExampleContainer pseudoState="pseudo-hover"> - <Title title="Isolated year picker hovered" theme="light" level={4} /> - <YearPicker - selectedDate={dayjs("06-04-1905", "DD-MM-YYYY")} - onYearSelect={() => {}} - today={dayjs("1904-04-03", "YYYY-MM-DD")} - /> - </ExampleContainer> - <ExampleContainer pseudoState="pseudo-active"> - <Title title="Isolated year picker actived" theme="light" level={4} /> - <YearPicker - selectedDate={dayjs("06-04-1905", "DD-MM-YYYY")} - onYearSelect={() => {}} - today={dayjs("1904-04-03", "YYYY-MM-DD")} - /> - </ExampleContainer> - </> -); +const YearPickerButtonStates = () => { + const yearItems = getYearPickerItems(); + return ( + <> + <ExampleContainer pseudoState="pseudo-focus"> + <Title title="Isolated date unit picker focused" theme="light" level={4} /> + <DateUnitPicker + selectedDate={dayjs("06-04-1905", "DD-MM-YYYY")} + onDateUnitSelect={() => {}} + today={dayjs("1904-04-03", "YYYY-MM-DD")} + items={yearItems} + /> + </ExampleContainer> + <ExampleContainer pseudoState="pseudo-hover"> + <Title title="Isolated date unit picker hovered" theme="light" level={4} /> + <DateUnitPicker + selectedDate={dayjs("06-04-1905", "DD-MM-YYYY")} + onDateUnitSelect={() => {}} + today={dayjs("1904-04-03", "YYYY-MM-DD")} + items={yearItems} + /> + </ExampleContainer> + <ExampleContainer pseudoState="pseudo-active"> + <Title title="Isolated date unit picker actived" theme="light" level={4} /> + <DateUnitPicker + selectedDate={dayjs("06-04-1905", "DD-MM-YYYY")} + onDateUnitSelect={() => {}} + today={dayjs("1904-04-03", "YYYY-MM-DD")} + items={yearItems} + /> + </ExampleContainer> + </> + ); +}; -const DatePickerToday = () => ( - <> - <ExampleContainer> - <Title title="Isolated calendar with today" theme="light" level={4} /> - <Calendar - selectedDate={dayjs("06-04-1904", "DD-MM-YYYY")} - today={dayjs("1904-04-03", "YYYY-MM-DD")} - onInnerDateChange={() => {}} - onDaySelect={() => {}} - innerDate={dayjs("06-04-1904", "DD-MM-YYYY")} - /> - </ExampleContainer> - <ExampleContainer> - <Title title="Isolated year picker with today" theme="light" level={4} /> - <YearPicker - selectedDate={dayjs("06-04-1905", "DD-MM-YYYY")} - onYearSelect={() => {}} - today={dayjs("1904-04-03", "YYYY-MM-DD")} - /> - </ExampleContainer> - </> -); +const DatePickerToday = () => { + const monthLabels = { + calendar: { + months: [ + "January", + "February", + "March", + "April", + "May", + "June", + "July", + "August", + "September", + "October", + "November", + "December", + ], + }, + }; + const monthItems = getMonthPickerItems(monthLabels.calendar.months); + const yearItems = getYearPickerItems(); + + return ( + <> + <ExampleContainer> + <Title title="Isolated calendar with today" theme="light" level={4} /> + <Calendar + selectedDate={dayjs("06-04-1904", "DD-MM-YYYY")} + today={dayjs("1904-04-03", "YYYY-MM-DD")} + onInnerDateChange={() => {}} + onDaySelect={() => {}} + innerDate={dayjs("06-04-1904", "DD-MM-YYYY")} + /> + </ExampleContainer> + <ExampleContainer> + <Title title="Isolated month picker with today" theme="light" level={4} /> + <DateUnitPicker + selectedDate={dayjs("06-04-1905", "DD-MM-YYYY")} + onDateUnitSelect={() => {}} + today={dayjs("1904-04-03", "YYYY-MM-DD")} + items={monthItems} + unit="month" + /> + </ExampleContainer> + <ExampleContainer> + <Title title="Isolated date unit picker with today" theme="light" level={4} /> + <DateUnitPicker + selectedDate={dayjs("06-04-1905", "DD-MM-YYYY")} + onDateUnitSelect={() => {}} + today={dayjs("1904-04-03", "YYYY-MM-DD")} + items={yearItems} + /> + </ExampleContainer> + </> + ); +}; const Tooltip = () => ( <> <Title title="Default tooltip" theme="light" level={2} /> <ExampleContainer> - <DxcDatePicker date={dayjs("06-04-1950", "DD-MM-YYYY")} onDateSelect={() => {}} id="test-calendar-tooltip" /> + <DxcDatePicker + date={dayjs("06-04-1950", "DD-MM-YYYY")} + onDateSelect={() => {}} + id="test-calendar-tooltip" + format="dd/mm/yy" + /> </ExampleContainer> </> ); +const YearMonthPickerZhCn = () => ( + <ExampleContainer> + <Title title="Year/Month picker order zh-CN" theme="light" level={4} /> + <HalstackProvider localeTag="zh-CN"> + <DxcDateInput label="Date input" format="dd-yyyy-mm" defaultValue="06-1905-05" /> + </HalstackProvider> + </ExampleContainer> +); + type Story = StoryObj<typeof DxcDateInput>; export const Chromatic: Story = { @@ -290,3 +365,17 @@ export const DatePickerTooltipAfter: Story = { } }, }; + +export const YearMonthPickerWithLocale: Story = { + render: YearMonthPickerZhCn, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const calendarAction = await canvas.findByRole("combobox"); + await userEvent.click(calendarAction); + const dialog = await screen.findByRole("dialog"); + const yearMonthTrigger = within(dialog).getAllByRole("button")[1]; + if (yearMonthTrigger != null) { + await userEvent.click(yearMonthTrigger); + } + }, +}; diff --git a/packages/lib/src/date-input/DateInput.test.tsx b/packages/lib/src/date-input/DateInput.test.tsx index b954489a2a..9dd3a17feb 100644 --- a/packages/lib/src/date-input/DateInput.test.tsx +++ b/packages/lib/src/date-input/DateInput.test.tsx @@ -1,5 +1,6 @@ import { fireEvent, render } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; +import "@testing-library/jest-dom"; import dayjs from "dayjs"; import DxcDateInput from "./DateInput"; import MockDOMRect from "../../test/mocks/domRectMock"; @@ -201,7 +202,7 @@ describe("DateInput component tests", () => { }); expect(input.value).toBe(d.format("DD-MM-YYYY")); }); - test("Selecting a year from the calendar year picker", () => { + test("Selecting a year and month from the calendar year picker", () => { const { getByText, getByRole } = render(<DxcDateInput format="dd-mm-yyyy" defaultValue="10-08-2021" />); const input = getByRole("textbox") as HTMLInputElement; const calendarAction = getByRole("combobox"); @@ -210,9 +211,11 @@ describe("DateInput component tests", () => { userEvent.click(getByText(d.format("MMMM YYYY"))); expect(getByText("2024")).toBeTruthy(); userEvent.click(getByText("2024")); - userEvent.click(getByText(d.set("year", 2024).format("MMMM YYYY"))); + expect(getByText("February")).toBeTruthy(); + userEvent.click(getByText("February")); + userEvent.click(getByText(d.set("year", 2024).set("month", 1).format("MMMM YYYY"))); fireEvent.keyDown(document, { key: "Escape", code: "Escape", keyCode: 27, charCode: 27 }); - expect(input.value).toBe(d.format("DD-MM-YYYY")); + expect(input.value).toBe(d.set("year", 2024).set("month", 1).format("DD-MM-YYYY")); }); test("Selecting a date from the calendar (using keyboard presses)", () => { const { getByRole, getAllByText, getByText } = render(<DxcDateInput />); @@ -488,6 +491,7 @@ describe("DateInput component tests", () => { expect(getByText("October 1910")).toBeTruthy(); userEvent.click(getByText("October 1910")); userEvent.click(getByText("2010")); + userEvent.click(getByText("October 2010")); const day1 = getAllByText("1")[0]; if (day1 != null) { userEvent.click(day1); @@ -530,4 +534,43 @@ describe("DateInput component tests", () => { userEvent.type(calendarAction, "{enter}"); expect(onSubmit).not.toHaveBeenCalled(); }); + test("Two listboxes are shown", () => { + const { getByRole, container } = render(<DxcDateInput defaultValue="15-03-2020" />); + const calendarAction = getByRole("combobox"); + userEvent.click(calendarAction); + + const monthYearButton = getByRole("button", { name: "March 2020" }); + expect(monthYearButton).toBeTruthy(); + userEvent.click(monthYearButton); + + const listboxes = container.querySelectorAll("[role='listbox']"); + expect(listboxes.length).toBe(2); + }); + test("Calendar header formatting", () => { + const { getByRole, container } = render(<DxcDateInput defaultValue="2020/03/15" format="yyyy/MM/dd" />); + const calendarAction = getByRole("combobox"); + userEvent.click(calendarAction); + + const monthYearButton = getByRole("button", { name: "2020 March" }); + expect(monthYearButton).toBeTruthy(); + userEvent.click(monthYearButton); + + const listboxes = container.querySelectorAll("[role='listbox']"); + expect(listboxes.length).toBe(2); + expect(listboxes[0]).toHaveTextContent("2019"); + expect(listboxes[1]).toHaveTextContent("February"); + }); + // listbox keyboard control + test("Listbox keyboard control", () => { + const { getByRole, getAllByRole } = render(<DxcDateInput defaultValue="15-03-2020" />); + const calendarAction = getByRole("combobox"); + userEvent.click(calendarAction); + expect(getByRole("button", { name: "March 2020" })).toBeTruthy(); + userEvent.click(getByRole("button", { name: "March 2020" })); + const yearListbox = getAllByRole("listbox")[1]; + expect(yearListbox).toBeTruthy(); + userEvent.keyboard("{ArrowDown}"); + userEvent.keyboard("{Enter}"); + expect(getByRole("button", { name: "March 2021" })).toBeTruthy(); + }); }); diff --git a/packages/lib/src/date-input/DateInput.tsx b/packages/lib/src/date-input/DateInput.tsx index 6af1c10ea4..9a031ebece 100644 --- a/packages/lib/src/date-input/DateInput.tsx +++ b/packages/lib/src/date-input/DateInput.tsx @@ -322,7 +322,12 @@ const DxcDateInput = forwardRef<RefType, DateInputPropsType>( onKeyDown={handleDatePickerEscKeydown} ref={popoverContentRef} > - <DatePicker id={calendarId} onDateSelect={handleCalendarOnClick} date={dayjsDate} /> + <DatePicker + id={calendarId} + onDateSelect={handleCalendarOnClick} + date={dayjsDate} + format={formatter} + /> </StyledPopoverContent> </Popover.Portal> )} diff --git a/packages/lib/src/date-input/DatePicker.tsx b/packages/lib/src/date-input/DatePicker.tsx index af68c49ff5..b6eaf29b35 100644 --- a/packages/lib/src/date-input/DatePicker.tsx +++ b/packages/lib/src/date-input/DatePicker.tsx @@ -1,12 +1,13 @@ -import { memo, useContext, useState } from "react"; +import { memo, useContext, useMemo, useState } from "react"; import dayjs, { Dayjs } from "dayjs"; import styled from "@emotion/styled"; import { DatePickerPropsType } from "./types"; import Calendar from "./Calendar"; -import YearPicker from "./YearPicker"; import DxcIcon from "../icon/Icon"; import { Tooltip } from "../tooltip/Tooltip"; import { HalstackLanguageContext } from "../HalstackContext"; +import { YearMonthPicker } from "./YearMonthPicker"; +import { calculateIsYearFirst } from "./utils"; const DatePickerContainer = styled.div` padding: var(--spacing-padding-m) var(--spacing-padding-xs) var(--spacing-padding-xs) var(--spacing-padding-xs); @@ -61,7 +62,7 @@ const HeaderButton = styled.button` } `; -const HeaderYearTrigger = styled(HeaderButton)` +const HeaderTrigger = styled(HeaderButton)` gap: var(--spacing-gap-s); padding: 0px var(--spacing-padding-xs) 0px var(--spacing-padding-m); height: var(--height-m); @@ -71,7 +72,7 @@ const HeaderYearTrigger = styled(HeaderButton)` } `; -const HeaderYearTriggerLabel = styled.span` +const HeaderTriggerLabel = styled.span` display: flex; align-items: center; justify-content: center; @@ -80,25 +81,24 @@ const HeaderYearTriggerLabel = styled.span` const today = dayjs(); -const DatePicker = ({ date, onDateSelect, id }: DatePickerPropsType): JSX.Element => { +const DatePicker = ({ date, onDateSelect, id, format }: DatePickerPropsType): JSX.Element => { const [innerDate, setInnerDate] = useState(date?.isValid() ? date : dayjs()); const [content, setContent] = useState("calendar"); const selectedDate = date?.isValid() ? date : dayjs(null); - const translatedLabels = useContext(HalstackLanguageContext).labels; + const languageContext = useContext(HalstackLanguageContext); + const translatedLabels = languageContext.labels; + const isYearFirst = calculateIsYearFirst(format); const handleDateSelect = (chosenDate: Dayjs) => { setInnerDate(chosenDate); onDateSelect(chosenDate); }; - const handleOnYearSelect = (year: number) => { - setInnerDate(innerDate.set("year", year)); - setContent("calendar"); - }; - - const handleMonthChange = (chosenDate: Dayjs) => { - setInnerDate(chosenDate); - }; + const monthYearLabel = useMemo(() => { + const month = translatedLabels.calendar.months[innerDate.get("month")]; + const year = innerDate.format("YYYY"); + return isYearFirst ? `${year} ${month}` : `${month} ${year}`; + }, [translatedLabels, innerDate, isYearFirst]); return ( <DatePickerContainer id={id}> @@ -106,26 +106,30 @@ const DatePicker = ({ date, onDateSelect, id }: DatePickerPropsType): JSX.Elemen <Tooltip label={translatedLabels.calendar.previousMonthTitle}> <HeaderButton aria-label={translatedLabels.calendar.previousMonthTitle} - onClick={() => handleMonthChange(innerDate.set("month", innerDate.get("month") - 1))} + onClick={() => setInnerDate(innerDate.set("month", innerDate.get("month") - 1))} type="button" > <DxcIcon icon="keyboard_arrow_left" /> </HeaderButton> </Tooltip> - <HeaderYearTrigger + <HeaderTrigger aria-live="polite" - onClick={() => setContent((currentContent) => (currentContent === "yearPicker" ? "calendar" : "yearPicker"))} + onClick={() => { + if (content === "calendar") { + setContent("yearMonthPicker"); + } else { + setContent("calendar"); + } + }} type="button" > - <HeaderYearTriggerLabel> - {translatedLabels.calendar.months[innerDate.get("month")]} {innerDate.format("YYYY")} - </HeaderYearTriggerLabel> - <DxcIcon icon={content === "yearPicker" ? "arrow_drop_up" : "arrow_drop_down"} /> - </HeaderYearTrigger> + <HeaderTriggerLabel>{monthYearLabel}</HeaderTriggerLabel> + <DxcIcon icon={content === "calendar" ? "arrow_drop_down" : "arrow_drop_up"} /> + </HeaderTrigger> <Tooltip label={translatedLabels.calendar.nextMonthTitle}> <HeaderButton aria-label={translatedLabels.calendar.nextMonthTitle} - onClick={() => handleMonthChange(innerDate.set("month", innerDate.get("month") + 1))} + onClick={() => setInnerDate(innerDate.set("month", innerDate.get("month") + 1))} type="button" > <DxcIcon icon="keyboard_arrow_right" /> @@ -141,8 +145,13 @@ const DatePicker = ({ date, onDateSelect, id }: DatePickerPropsType): JSX.Elemen today={today} /> )} - {content === "yearPicker" && ( - <YearPicker selectedDate={selectedDate} onYearSelect={handleOnYearSelect} today={today} /> + {content !== "calendar" && ( + <YearMonthPicker + isYearFirst={isYearFirst} + selectedDate={selectedDate} + today={today} + onYearMonthComplete={handleDateSelect} + /> )} </DatePickerContainer> ); diff --git a/packages/lib/src/date-input/DateUnitPicker.tsx b/packages/lib/src/date-input/DateUnitPicker.tsx new file mode 100644 index 0000000000..f8247d68ec --- /dev/null +++ b/packages/lib/src/date-input/DateUnitPicker.tsx @@ -0,0 +1,131 @@ +import dayjs from "dayjs"; +import { KeyboardEvent, useEffect, useId, useState } from "react"; +import styled from "@emotion/styled"; +import { DateUnitPickerPropsType } from "./types"; +import scrollbarStyles from "../styles/scroll"; + +const PickerContainer = styled.div` + box-sizing: border-box; + display: flex; + flex-direction: column; + gap: var(--spacing-gap-xs); + align-items: center; + overflow-y: scroll; + width: 50%; + max-width: 292px; + height: 312px; + box-shadow: var(--shadow-200); + ${scrollbarStyles} +`; + +const PickerButton = styled.button<{ + selected: boolean; + isCurrent: boolean; +}>` + min-width: fit-content; + display: flex; + align-items: center; + justify-content: center; + width: 80px; + min-height: var(--height-m); + height: var(--height-m); + background-color: transparent; + border: none; + border-radius: var(--border-radius-xl); + cursor: pointer; + font-family: var(--typography-font-family); + font-size: var(--typography-label-m); + color: var(--color-fg-neutral-dark); + font-weight: var(--typography-label-regular); + + ${(props) => + props.selected + ? `font-size: var(--typography-label-xl); + color: var(--color-fg-neutral-bright) !important; + background-color: var(--color-bg-primary-strong) !important;` + : props.isCurrent + ? `border: var(--border-width-s) var(--border-style-default) var(--border-color-primary-lighter); + color: var(--color-fg-primary-strong);` + : ``} + + &:hover, &:focus, &:active { + font-size: var(--typography-label-xl); + } + &:hover { + background-color: var(--color-bg-primary-light); + color: var(--color-fg-neutral-dark); + } + &:focus { + outline: var(--border-width-m) var(--border-style-default) var(--border-color-secondary-medium); + } + &:active { + color: var(--color-fg-neutral-bright); + background-color: var(--color-bg-primary-stronger); + } +`; + +const DateUnitPicker = ({ + onDateUnitSelect, + selectedDate, + today, + items, + unit = "year", +}: DateUnitPickerPropsType): JSX.Element => { + const selectedValue = selectedDate?.isValid() && selectedDate ? selectedDate.get(unit) : undefined; + const currentValue = today?.isValid() && today ? today.get(unit) : dayjs().get(unit); + const ariaLabel = `${unit.charAt(0).toUpperCase() + unit.slice(1)} Picker`; + + const id = useId(); + const [itemToFocus, setItemToFocus] = useState(selectedDate ? selectedDate.get(unit) : dayjs().get(unit)); + + useEffect(() => { + const itemToFocusEl = document.getElementById(`${id}_${unit}_${itemToFocus}`); + itemToFocusEl?.scrollIntoView?.({ block: "center", inline: "center" }); + itemToFocusEl?.focus(); + }, [itemToFocus, id, unit]); + + const handleKeyboardEvent = (event: KeyboardEvent<HTMLButtonElement>) => { + switch (event.key) { + case "ArrowUp": + setItemToFocus((prev) => (prev > 0 ? prev - 1 : prev)); + break; + case "ArrowDown": + setItemToFocus((prev) => { + const lastValue = items[items.length - 1]?.value; + return lastValue !== undefined && prev < lastValue ? prev + 1 : prev; + }); + break; + default: + break; + } + }; + + return ( + <PickerContainer role="listbox" aria-label={ariaLabel}> + {items.map((item) => { + const isSelected = item.value === selectedValue; + return ( + <PickerButton + aria-label={item.label} + key={item.value} + selected={isSelected} + aria-selected={isSelected} + tabIndex={itemToFocus === item.value ? 0 : -1} + isCurrent={item.value === currentValue} + onKeyDown={(event) => handleKeyboardEvent(event)} + id={`${id}_${unit}_${item.value}`} + onClick={() => { + onDateUnitSelect(item.value); + }} + role="option" + type="button" + > + {item.label} + </PickerButton> + ); + })} + </PickerContainer> + ); +}; + +export default DateUnitPicker; diff --git a/packages/lib/src/date-input/YearMonthPicker.tsx b/packages/lib/src/date-input/YearMonthPicker.tsx new file mode 100644 index 0000000000..40e653aa49 --- /dev/null +++ b/packages/lib/src/date-input/YearMonthPicker.tsx @@ -0,0 +1,71 @@ +import { useContext, useState } from "react"; +import styled from "@emotion/styled"; +import { HalstackLanguageContext } from "../HalstackContext"; +import DateUnitPicker from "./DateUnitPicker"; +import { getMonthPickerItems, getYearPickerItems } from "./utils"; +import { YearMonthPickerProps } from "./types"; + +const YearMonthPickersContainer = styled.div` + display: flex; + width: 292px; + justify-content: center; +`; + +export const YearMonthPicker = ({ isYearFirst, selectedDate, today, onYearMonthComplete }: YearMonthPickerProps) => { + const [selectedYear, setSelectedYear] = useState<number | null>(selectedDate?.get("year") || null); + const [selectedMonth, setSelectedMonth] = useState<number | null>(selectedDate?.get("month") || null); + const languageContext = useContext(HalstackLanguageContext); + + const yearItems = getYearPickerItems(); + const monthItems = getMonthPickerItems(languageContext.labels.calendar.months); + + const handleOnYearSelect = (year: number) => { + const newDate = selectedDate.set("year", year); + const finalDate = selectedMonth != null ? newDate.set("month", selectedMonth) : newDate; + setSelectedYear(year); + onYearMonthComplete(finalDate); + }; + + const handleOnMonthSelect = (month: number) => { + const newDate = selectedDate.set("month", month); + const finalDate = selectedYear != null ? newDate.set("year", selectedYear) : newDate; + setSelectedMonth(month); + onYearMonthComplete(finalDate); + }; + + const yearPicker = ( + <DateUnitPicker + selectedDate={selectedDate} + onDateUnitSelect={handleOnYearSelect} + today={today} + items={yearItems} + unit="year" + /> + ); + + const monthPicker = ( + <DateUnitPicker + selectedDate={selectedDate} + onDateUnitSelect={handleOnMonthSelect} + today={today} + items={monthItems} + unit="month" + /> + ); + + return ( + <YearMonthPickersContainer> + {isYearFirst ? ( + <> + {yearPicker} + {monthPicker} + </> + ) : ( + <> + {monthPicker} + {yearPicker} + </> + )} + </YearMonthPickersContainer> + ); +}; diff --git a/packages/lib/src/date-input/YearPicker.tsx b/packages/lib/src/date-input/YearPicker.tsx deleted file mode 100644 index d32ecc103c..0000000000 --- a/packages/lib/src/date-input/YearPicker.tsx +++ /dev/null @@ -1,121 +0,0 @@ -import dayjs from "dayjs"; -import { useEffect, useId, useState, memo, KeyboardEvent } from "react"; -import styled from "@emotion/styled"; -import { YearPickerPropsType } from "./types"; - -const YearPickerContainer = styled.div` - box-sizing: border-box; - display: flex; - flex-direction: column; - gap: var(--spacing-gap-xs); - align-items: center; - overflow-y: scroll; - width: 292px; - height: 312px; - box-shadow: var(--shadow-200); -`; - -const YearPickerButton = styled.button<{ - selected: boolean; - isCurrentYear: boolean; -}>` - display: flex; - align-items: center; - justify-content: center; - width: 80px; - min-height: var(--height-m); - height: var(--height-m); - background-color: transparent; - border: none; - border-radius: var(--border-radius-xl); - cursor: pointer; - font-family: var(--typography-font-family); - font-size: var(--typography-label-m); - color: var(--color-fg-neutral-dark); - font-weight: var(--typography-label-regular); - - ${(props) => - props.selected - ? `font-size: var(--typography-label-xl); - color: var(--color-fg-neutral-bright) !important; - background-color: var(--color-bg-primary-strong) !important;` - : props.isCurrentYear - ? `border: var(--border-width-s) var(--border-style-default) var(--border-color-primary-lighter); - color: var(--color-fg-primary-strong);` - : ``} - - &:hover, &:focus, &:active { - font-size: var(--typography-label-xl); - } - &:hover { - background-color: var(--color-bg-primary-light); - color: var(--color-fg-neutral-dark); - } - &:focus { - outline: var(--border-width-m) var(--border-style-default) var(--border-color-secondary-medium); - } - &:active { - color: var(--color-fg-neutral-bright); - background-color: var(--color-bg-primary-stronger); - } -`; - -const getYearsArray = () => { - const yearList = []; - for (let i = 1899; i <= 2100; i++) { - yearList.push(i); - } - return yearList; -}; - -const yearList = getYearsArray(); - -const YearPicker = ({ onYearSelect, selectedDate, today }: YearPickerPropsType): JSX.Element => { - const id = useId(); - const [yearToFocus, setYearToFocus] = useState(selectedDate ? selectedDate.get("year") : dayjs().get("year")); - - useEffect(() => { - const yearToFocusEl = document.getElementById(`${id}_year_${yearToFocus}`); - yearToFocusEl?.scrollIntoView?.({ block: "nearest", inline: "start" }); - yearToFocusEl?.focus(); - }, [yearToFocus]); - - const handleDayKeyboardEvent = (event: KeyboardEvent<HTMLButtonElement>) => { - switch (event.key) { - case "ArrowUp": - setYearToFocus((prev) => (prev > 1899 ? prev - 1 : prev)); - break; - case "ArrowDown": - setYearToFocus((prev) => (prev < 2100 ? prev + 1 : prev)); - break; - default: - break; - } - }; - - return ( - <YearPickerContainer role="listbox" aria-label="Year Picker"> - {yearList.map((year) => ( - <YearPickerButton - aria-label={year.toString()} - key={year} - selected={selectedDate?.get("year") === year} - aria-selected={selectedDate?.get("year") === year} - tabIndex={yearToFocus === year ? 0 : -1} - isCurrentYear={today.get("year") === year} - onKeyDown={(event) => handleDayKeyboardEvent(event)} - id={`${id}_year_${year}`} - onClick={() => { - onYearSelect(year); - }} - role="option" - type="button" - > - {year} - </YearPickerButton> - ))} - </YearPickerContainer> - ); -}; - -export default memo(YearPicker); diff --git a/packages/lib/src/date-input/types.ts b/packages/lib/src/date-input/types.ts index 5913922443..a9bdf8f9ed 100644 --- a/packages/lib/src/date-input/types.ts +++ b/packages/lib/src/date-input/types.ts @@ -1,6 +1,8 @@ import { Dayjs } from "dayjs"; import { Margin, Space } from "../common/utils"; +export type RefType = HTMLDivElement; + type Props = { /** * Initial value of the input element, only when it is uncontrolled. @@ -114,6 +116,10 @@ export type DatePickerPropsType = { * Id assigned to the date picker. */ id: string; + /** + * The format in which the date value will be displayed. + */ + format: string; }; export type CalendarPropsType = { @@ -139,23 +145,39 @@ export type CalendarPropsType = { today: Dayjs; }; -export type YearPickerPropsType = { +export interface YearMonthPickerProps { + isYearFirst: boolean; + selectedDate: Dayjs; + today: Dayjs; + onYearMonthComplete: (newDate: Dayjs) => void; +} + +export type PickerItem = { + value: number; + label: string; +}; + +export type DateUnitPickerPropsType = { /** * Initial selected date value. If invalid the actual date will be used instead. */ - selectedDate: Dayjs; + selectedDate: Dayjs | null; /** * Function called when a year is selected. */ - onYearSelect: (year: number) => void; + onDateUnitSelect: (year: number) => void; /** * Current date. */ today: Dayjs; + /** + * Array of items (months or years) to display in the picker. + */ + items: PickerItem[]; + /** + * Date unit to display in the picker (month or year). + */ + unit?: "month" | "year"; }; -/** - * Reference to the component. - */ -export type RefType = HTMLDivElement; export default Props; diff --git a/packages/lib/src/date-input/utils.tsx b/packages/lib/src/date-input/utils.tsx index 09d426a90b..21c5c16f25 100644 --- a/packages/lib/src/date-input/utils.tsx +++ b/packages/lib/src/date-input/utils.tsx @@ -1,8 +1,10 @@ import { Dispatch, SetStateAction } from "react"; import dayjs, { Dayjs } from "dayjs"; -import { DateType } from "./types"; +import { DateType, PickerItem } from "./types"; const INVIS_CHARS = /[\u200E\u200F\u061C]/g; +const MIN_YEAR = 1899; +const MAX_YEAR = 2100; export const getValueForPicker = (value: string, format: string) => dayjs(value, format.toUpperCase(), true); @@ -109,3 +111,27 @@ export const divideDaysIntoWeeks = (data: DateType[], weekSize: number) => Array.from({ length: Math.ceil(data.length / weekSize) }, (_, rowIndex) => data.slice(rowIndex * weekSize, (rowIndex + 1) * weekSize) ); + +export const getMonthPickerItems = (months: string[]): PickerItem[] => + months.map((month: string, index: number) => ({ + value: index, + label: month, + })); + +export const getYearPickerItems = (): PickerItem[] => { + const yearList: number[] = []; + for (let i = MIN_YEAR; i <= MAX_YEAR; i++) { + yearList.push(i); + } + return yearList.map((year: number) => ({ + value: year, + label: year.toString(), + })); +}; + +export const calculateIsYearFirst = (localeFormat: string): boolean => { + const lower = localeFormat.toLowerCase(); + const yearIndices = lower.indexOf("y"); + const monthIndices = lower.indexOf("m"); + return yearIndices < monthIndices; +};