diff --git a/cypress/components/date/date.cy.tsx b/cypress/components/date/date.cy.tsx index 02ffe3615d..9c28bc31dc 100644 --- a/cypress/components/date/date.cy.tsx +++ b/cypress/components/date/date.cy.tsx @@ -42,7 +42,7 @@ import { } from "../../locators/date-input"; import { getDataElementByValue, fieldHelpPreview } from "../../locators"; -import { keyCode } from "../../support/helper"; +import { KeyIds, keyCode } from "../../support/helper"; import { verifyRequiredAsteriskForLabel, assertCssValueIsApproximately, @@ -75,7 +75,12 @@ const DDMMYYY_DATE_TO_ENTER_SHORT = "1,7,22"; const MMDDYYYY_DATE_TO_ENTER_SHORT = "7,1,22"; const YYYYMMDD_DATE_TO_ENTER_SHORT = "22,7,1"; const DATE_TO_VERIFY = "2022-05-12"; -const keysToTrigger = ["rightarrow", "leftarrow"] as const; +const arrowKeys = [ + "rightarrow", + "leftarrow", + "uparrow", + "downarrow", +] as KeyIds[]; context("Test for DateInput component", () => { describe("check functionality for DateInput component", () => { @@ -222,26 +227,239 @@ context("Test for DateInput component", () => { } ); + it.each(arrowKeys)( + "should not change the displayed month when %s is pressed and next button is focused", + (key) => { + CypressMountWithProviders(); + dateInputParent().click(); + getDataElementByValue("chevron_right").parent().focus(); + getDataElementByValue("chevron_right").trigger("keydown", keyCode(key)); + dayPickerHeading().should("have.text", "May 2022"); + } + ); + + it.each(arrowKeys)( + "should not change the displayed month when %s is pressed and previous button is focused", + (key) => { + CypressMountWithProviders(); + dateInputParent().click(); + getDataElementByValue("chevron_left").parent().focus(); + getDataElementByValue("chevron_left").trigger("keydown", keyCode(key)); + dayPickerHeading().should("have.text", "May 2022"); + } + ); + + it("should allow a user to tab into the picker and through its controls", () => { + CypressMountWithProviders(); + cy.get("body").tab(); + dateInput().should("be.focused"); + cy.focused().tab(); + getDataElementByValue("chevron_left").parent().should("be.focused"); + cy.focused().tab(); + getDataElementByValue("chevron_right").parent().should("be.focused"); + cy.focused().tab(); + cy.get(".DayPicker-Day--selected").should("be.focused"); + }); + + it("should close the picker and focus the next element in the DOM when focus is on a day element and tab pressed", () => { + CypressMountWithProviders( + <> + + + + ); + cy.get("body").tab(); + dateInput().should("be.focused"); + cy.focused().tab(); + getDataElementByValue("chevron_left").parent().should("be.focused"); + cy.focused().tab(); + getDataElementByValue("chevron_right").parent().should("be.focused"); + cy.focused().tab(); + cy.get(".DayPicker-Day--selected").should("be.focused"); + cy.focused().tab(); + dayPickerWrapper().should("not.exist"); + cy.get('[data-element="foo-button"]').should("be.focused"); + }); + + it("should focus today's date if no day selected when tabbing to day elements", () => { + CypressMountWithProviders(); + cy.get("body").tab(); + dateInput().should("be.focused"); + cy.focused().tab(); + getDataElementByValue("chevron_left").parent().should("be.focused"); + cy.focused().tab(); + getDataElementByValue("chevron_right").parent().should("be.focused"); + cy.focused().tab(); + cy.get(".DayPicker-Day--today").should("be.focused"); + cy.focused().tab(); + dayPickerWrapper().should("not.exist"); + }); + + it("should navigate through the day elements using the arrow keys", () => { + CypressMountWithProviders(); + cy.get("body").tab(); + dateInput().should("be.focused"); + cy.focused().tab(); + cy.focused().tab(); + cy.focused().tab(); + cy.focused().trigger("keydown", keyCode("downarrow")); + cy.focused().should("have.text", "8"); + cy.focused().trigger("keydown", keyCode("downarrow")); + cy.focused().should("have.text", "15"); + cy.focused().trigger("keydown", keyCode("leftarrow")); + cy.focused().should("have.text", "14"); + cy.focused().trigger("keydown", keyCode("leftarrow")); + cy.focused().should("have.text", "13"); + cy.focused().trigger("keydown", keyCode("rightarrow")); + cy.focused().should("have.text", "14"); + cy.focused().trigger("keydown", keyCode("rightarrow")); + cy.focused().should("have.text", "15"); + cy.focused().trigger("keydown", keyCode("uparrow")); + cy.focused().should("have.text", "8"); + cy.focused().trigger("keydown", keyCode("uparrow")); + cy.focused().should("have.text", "1"); + }); + + it("should navigate to the previous month when left arrow pressed on first day element of a month", () => { + CypressMountWithProviders(); + cy.get("body").tab(); + dateInput().should("be.focused"); + cy.focused().tab(); + cy.focused().tab(); + cy.focused().tab(); + cy.focused().trigger("keydown", keyCode("leftarrow")); + cy.focused().should("have.text", "30"); + dayPickerHeading().should("have.text", PREVIOUS_MONTH); + }); + + it.each([ + ["24", "1"], + ["25", "2"], + ["26", "3"], + ["27", "4"], + ["28", "5"], + ["29", "6"], + ["30", "7"], + ])( + "should navigate to day %s of previous month when up arrow pressed on day %s of first week of current month", + (result, input) => { + CypressMountWithProviders( + + ); + cy.get("body").tab(); + dateInput().should("be.focused"); + cy.focused().tab(); + cy.focused().tab(); + cy.focused().tab(); + cy.focused().trigger("keydown", keyCode("uparrow")); + cy.focused().should("have.text", result); + dayPickerHeading().should("have.text", PREVIOUS_MONTH); + } + ); + + it.each([ + ["7", "31"], + ["6", "30"], + ["5", "29"], + ["4", "28"], + ["3", "27"], + ["2", "26"], + ["1", "25"], + ])( + "should navigate to day %s of next month when down arrow pressed on day %s of last week of current month", + (result, input) => { + CypressMountWithProviders( + + ); + cy.get("body").tab(); + dateInput().should("be.focused"); + cy.focused().tab(); + cy.focused().tab(); + cy.focused().tab(); + cy.focused().trigger("keydown", keyCode("downarrow")); + cy.focused().should("have.text", result); + dayPickerHeading().should("have.text", NEXT_MONTH); + } + ); + + it.each(["Enter", "Space"] as KeyIds[])( + "should update the selected date when %s pressed on a day element", + (key) => { + CypressMountWithProviders(); + cy.get("body").tab(); + dateInput().should("be.focused"); + cy.focused().tab(); + cy.focused().tab(); + cy.focused().tab(); + cy.focused().trigger("keydown", keyCode("leftarrow")); + cy.focused().should("have.text", "30"); + cy.focused().trigger("keydown", keyCode(key)); + getDataElementByValue("input").should("have.value", "30/04/2022"); + } + ); + + it("should close the picker when escape is pressed and input focused", () => { + CypressMountWithProviders(); + cy.get("body").tab(); + dayPickerWrapper().should("exist"); + + cy.focused().trigger("keydown", keyCode("Esc")); + dayPickerWrapper().should("not.exist"); + }); + + it("should close the picker when escape is pressed and focus is within the picker and refocus the input", () => { + CypressMountWithProviders(); + cy.get("body").tab(); + dayPickerWrapper().should("exist"); + cy.focused().tab(); + cy.focused().trigger("keydown", keyCode("Esc")); + dayPickerWrapper().should("not.exist"); + getDataElementByValue("input").should("be.focused"); + }); + + it("should close the picker when shift + tab is pressed and focus is on the previous month button in the picker and refocus the input", () => { + CypressMountWithProviders(); + cy.get("body").tab(); + dayPickerWrapper().should("exist"); + cy.focused().tab(); + cy.focused().tab({ shift: true }); + dayPickerWrapper().should("not.exist"); + getDataElementByValue("input").should("be.focused"); + }); + + it("should navigate to the next month when right arrow pressed on last day element of a month", () => { + CypressMountWithProviders(); + cy.get("body").tab(); + dateInput().should("be.focused"); + cy.focused().tab(); + cy.focused().tab(); + cy.focused().tab(); + cy.focused().trigger("keydown", keyCode("rightarrow")); + cy.focused().should("have.text", "1"); + dayPickerHeading().should("have.text", NEXT_MONTH); + }); + it.each([ - ["chevron_right", "next", keysToTrigger[0]], - ["chevron_left", "previous", keysToTrigger[1]], + ["enter", "next", "chevron_right"], + ["space", "next", "chevron_right"], + ["enter", "previous", "chevron_left"], + ["space", "previous", "chevron_left"], ])( - "should trigger %s arrow in DayPicker to verify %s month is shown using %s keyboard key", - (arrow, month, key) => { + "should change the displayed month when %s is pressed and %s button is focused", + (key, month, arrow) => { CypressMountWithProviders(); - dateInput().clear().type(DATE_INPUT); + const keyToType = key === "space" ? " " : key; dateInputParent().click(); - - dayPickerWrapper().focus(); - getDataElementByValue(arrow).trigger("keydown", keyCode(key)); + getDataElementByValue(arrow).parent().focus(); + getDataElementByValue(arrow).type(`{${keyToType}}`); if (month === "next") { dayPickerHeading().should("have.text", NEXT_MONTH); } else if (month === "previous") { dayPickerHeading().should("have.text", PREVIOUS_MONTH); - } else { - throw new Error("Only Next or Previous month can be applied"); } } ); @@ -411,6 +629,10 @@ context("Test for DateInput component", () => { locale: () => localeValue, date: { dateFnsLocale: () => dateFnsLocaleValue, + ariaLabels: { + previousMonthButton: () => "Previous month", + nextMonthButton: () => "Next month", + }, }, } ); @@ -478,6 +700,10 @@ context("Test for DateInput component", () => { locale: () => localeValue, date: { dateFnsLocale: () => dateFnsLocaleValue, + ariaLabels: { + previousMonthButton: () => "Previous month", + nextMonthButton: () => "Next month", + }, }, } ); @@ -544,6 +770,10 @@ context("Test for DateInput component", () => { locale: () => localeValue, date: { dateFnsLocale: () => dateFnsLocaleValue, + ariaLabels: { + previousMonthButton: () => "Previous month", + nextMonthButton: () => "Next month", + }, }, } ); @@ -662,6 +892,8 @@ context("Test for DateInput component", () => { dateInput().focus(); + dayPickerParent().should("have.css", "margin-top", "4px"); + dateInputParent() .should( "have.css", @@ -864,4 +1096,12 @@ context("Test for DateInput component", () => { cy.checkAccessibility(); }); + + it("should check accessibility when the picker is open", () => { + CypressMountWithProviders(); + + dateInputParent() + .click() + .then(() => cy.checkAccessibility()); + }); }); diff --git a/cypress/support/helper.ts b/cypress/support/helper.ts index 44a660bcbe..12aaa95472 100644 --- a/cypress/support/helper.ts +++ b/cypress/support/helper.ts @@ -65,7 +65,7 @@ const keys = { uparrow: { key: "ArrowUp", keyCode: 38, which: 38 }, leftarrow: { key: "ArrowLeft", keyCode: 37, which: 37 }, rightarrow: { key: "ArrowRight", keyCode: 39, which: 39 }, - Enter: { key: "Enter", keyCode: 13, which: 13 }, + Enter: { key: "Enter", keyCode: 13, which: 13, bubbles: true }, EnterForce: { key: "Enter", keyCode: 13, which: 13, force: true }, Space: { key: " ", keyCode: 32, which: 32 }, Tab: { key: "Tab", keyCode: 9, which: 9 }, @@ -76,8 +76,11 @@ const keys = { pagedown: { key: "PageDown", keyCode: 34, which: 34 }, pageup: { key: "PageUp", keyCode: 33, which: 33 }, }; + +export type KeyIds = keyof typeof keys; + export function keyCode( - type: keyof typeof keys + type: KeyIds ): { key: string; keyCode: number; diff --git a/src/components/date-range/date-range.stories.mdx b/src/components/date-range/date-range.stories.mdx index fed786508c..ca1029bfcd 100644 --- a/src/components/date-range/date-range.stories.mdx +++ b/src/components/date-range/date-range.stories.mdx @@ -141,5 +141,19 @@ The following keys are available to override the translations for this component type: "func", returnType: "date-fns locale object", }, + { + name: "date.ariaLabels.previousMonthButton", + description: + "Aria label text for the previous month navigation button in the date picker", + type: "func", + returnType: "string", + }, + { + name: "date.ariaLabels.nextMonthButton", + description: + "Aria label text for the next month navigation button in the date picker", + type: "func", + returnType: "string", + } ]} /> diff --git a/src/components/date-range/date-range.stories.tsx b/src/components/date-range/date-range.stories.tsx index 73aeadce11..a8af9bd2bb 100644 --- a/src/components/date-range/date-range.stories.tsx +++ b/src/components/date-range/date-range.stories.tsx @@ -285,7 +285,13 @@ export const LocaleOverrideExampleImplementation: ComponentStory< "fr-FR", - date: { dateFnsLocale: () => fr }, + date: { + dateFnsLocale: () => fr, + ariaLabels: { + previousMonthButton: () => "Mois précédent", + nextMonthButton: () => "Mois prochain", + }, + }, }} > = { current?: T | null; @@ -51,6 +53,10 @@ export interface DatePickerProps { open?: boolean; /** Callback triggered when a Day is clicked */ onDayClick?: (date: Date, ev: React.MouseEvent) => void; + /** Sets the picker open state */ + setOpen: (isOpen: boolean) => void; + /** Id passed to tab guard element */ + pickerTabGuardId?: string; } const popoverMiddleware = [ @@ -60,121 +66,209 @@ const popoverMiddleware = [ }), ]; -export const DatePicker = React.forwardRef( - ( - { - inputElement, - minDate, - maxDate, - selectedDays, - disablePortal, - onDayClick, - pickerMouseDown, - pickerProps, - open, - }: DatePickerProps, - ref - ) => { - const l = useLocale(); - const { localize, options } = l.date.dateFnsLocale(); - const { weekStartsOn } = options || /* istanbul ignore next */ {}; - const monthsLong = useMemo( - () => - Array.from({ length: 12 }).map((_, i) => { - const month = localize?.month(i); - return month[0].toUpperCase() + month.slice(1); - }), - [localize] - ); - const monthsShort = useMemo( - () => - Array.from({ length: 12 }).map((_, i) => - localize?.month(i, { width: "abbreviated" }).substring(0, 3) - ), - [localize] +export const DatePicker = ({ + inputElement, + minDate, + maxDate, + selectedDays, + disablePortal, + onDayClick, + pickerMouseDown, + pickerProps, + open, + setOpen, + pickerTabGuardId, +}: DatePickerProps) => { + const l = useLocale(); + const { localize, options } = l.date.dateFnsLocale(); + const { weekStartsOn } = options || /* istanbul ignore next */ {}; + const monthsLong = useMemo( + () => + Array.from({ length: 12 }).map((_, i) => { + const month = localize?.month(i); + return month[0].toUpperCase() + month.slice(1); + }), + [localize] + ); + const monthsShort = useMemo( + () => + Array.from({ length: 12 }).map((_, i) => + localize?.month(i, { width: "abbreviated" }).substring(0, 3) + ), + [localize] + ); + const weekdaysLong = useMemo( + () => Array.from({ length: 7 }).map((_, i) => localize?.day(i)), + [localize] + ); + const weekdaysShort = useMemo(() => { + const isGivenLocale = (str: string) => l.locale().includes(str); + return Array.from({ length: 7 }).map((_, i) => + localize + ?.day( + i, + ["de", "pl"].some(isGivenLocale) + ? { width: "wide" } + : { width: "abbreviated" } + ) + .substring(0, isGivenLocale("de") ? 2 : 3) ); - const weekdaysLong = useMemo( - () => Array.from({ length: 7 }).map((_, i) => localize?.day(i)), - [localize] - ); - const weekdaysShort = useMemo(() => { - const isGivenLocale = (str: string) => l.locale().includes(str); - return Array.from({ length: 7 }).map((_, i) => - localize - ?.day( - i, - ["de", "pl"].some(isGivenLocale) - ? { width: "wide" } - : { width: "abbreviated" } - ) - .substring(0, isGivenLocale("de") ? 2 : 3) + }, [l, localize]); + const ref = useRef(null); + + useEffect(() => { + if (open) { + // this is a temporary fix for some axe issues that are baked into the library we use for the picker + const captionElement = ref.current?.querySelector(".DayPicker-Caption"); + /* istanbul ignore else */ + if (captionElement) { + captionElement.removeAttribute("role"); + captionElement.removeAttribute("aria-live"); + } + + // focus the selected or today's date first + const selectedDay = + ref.current?.querySelector(".DayPicker-Day--selected") || + ref.current?.querySelector(".DayPicker-Day--today"); + const firstDay = ref.current?.querySelector( + ".DayPicker-Day[tabindex='0']" ); - }, [l, localize]); - - const handleDayClick = ( - date: Date, - modifiers: DayModifiers, - ev: React.MouseEvent - ) => { - if (!modifiers.disabled) { - const { id, name } = inputElement?.current - ?.firstChild as HTMLInputElement; - ev.target = { - ...ev.target, - id, - name, - } as HTMLInputElement; - onDayClick?.(date, ev); + + /* istanbul ignore else */ + if (selectedDay && firstDay !== selectedDay) { + selectedDay?.setAttribute("tabindex", "0"); + firstDay?.setAttribute("tabindex", "-1"); } - }; + } + }, [open]); - const formatDay = (date: Date) => - `${weekdaysShort[date.getDay()]} ${date.getDate()} ${ - monthsShort[date.getMonth()] - } ${date.getFullYear()}`; + const handleDayClick = ( + date: Date, + modifiers: DayModifiers, + ev: React.MouseEvent + ) => { + if (!modifiers.disabled) { + const { id, name } = inputElement?.current + ?.firstChild as HTMLInputElement; + ev.target = { + ...ev.target, + id, + name, + } as HTMLInputElement; + onDayClick?.(date, ev); + } + }; - if (!open) { - return null; + const handleOnKeyDown = (ev: React.KeyboardEvent) => { + if (Events.isEscKey(ev)) { + inputElement.current?.querySelector("input")?.focus(); + setOpen(false); } - const localeUtils = { formatDay } as LocaleUtils; + if ( + ref.current?.querySelector(".DayPicker-NavBar button") === + document.activeElement && + Events.isTabKey(ev) && + Events.isShiftKey(ev) + ) { + ev.preventDefault(); + setOpen(false); + inputElement.current?.querySelector("input")?.focus(); + } + }; - return ( - - - { - const { className, weekday } = weekdayElementProps; - - return ( - - {weekdaysShort[weekday]} - - ); - }} - navbarElement={} - fixedWeeks - initialMonth={selectedDays || undefined} - disabledDays={getDisabledDays(minDate, maxDate)} - locale={l.locale()} - localeUtils={localeUtils} - {...pickerProps} - /> - - - ); + const handleOnDayKeyDown = ( + _day: Date, + _modifiers: DayModifiers, + ev: React.KeyboardEvent + ) => { + // we need to manually handle this as the picker may be in a Portal + /* istanbul ignore else */ + if (Events.isTabKey(ev) && !Events.isShiftKey(ev)) { + ev.preventDefault(); + setOpen(false); + const input = inputElement.current?.querySelector("input"); + + /* istanbul ignore else */ + if (input) { + const elements = Array.from( + document.querySelectorAll(defaultFocusableSelectors) || + /* istanbul ignore next */ [] + ) as HTMLElement[]; + const elementsInPicker = Array.from( + ref.current?.querySelectorAll("button, [tabindex]") || + /* istanbul ignore next */ [] + ) as HTMLElement[]; + const filteredElements = elements.filter( + (el) => Number(el.tabIndex) !== -1 && !elementsInPicker.includes(el) + ); + const nextIndex = filteredElements.indexOf(input as HTMLElement) + 1; + filteredElements[nextIndex]?.focus(); + } + } + }; + + const formatDay = (date: Date) => + `${weekdaysShort[date.getDay()]} ${date.getDate()} ${ + monthsShort[date.getMonth()] + } ${date.getFullYear()}`; + + if (!open) { + return null; } -); + + const localeUtils = { formatDay } as LocaleUtils; + + const handleTabGuardFocus = () => { + ref.current?.querySelector("button")?.focus(); + }; + + return ( + + +
+ { + const { className, weekday } = weekdayElementProps; + + return ( + + {weekdaysShort[weekday]} + + ); + }} + navbarElement={} + fixedWeeks + initialMonth={selectedDays || undefined} + disabledDays={getDisabledDays(minDate, maxDate)} + locale={l.locale()} + localeUtils={localeUtils} + onDayKeyDown={handleOnDayKeyDown} + {...pickerProps} + /> + + + ); +}; DatePicker.displayName = "DatePicker"; diff --git a/src/components/date/__internal__/date-picker/date-picker.spec.tsx b/src/components/date/__internal__/date-picker/date-picker.spec.tsx index 70e2e5357d..d29c4d99dc 100644 --- a/src/components/date/__internal__/date-picker/date-picker.spec.tsx +++ b/src/components/date/__internal__/date-picker/date-picker.spec.tsx @@ -61,16 +61,19 @@ const MockComponent = (props: MockProps) => { function renderI18n({ locale, ...props -}: { locale?: Partial } & Omit) { +}: { locale?: Partial } & Omit< + DatePickerProps, + "inputElement" | "setOpen" +>) { return mount( - + {}} /> ); } -function render(props: Omit) { - return mount(); +function render(props: Omit) { + return mount( {}} />); } describe("DatePicker", () => { @@ -162,7 +165,7 @@ describe("DatePicker", () => { }); }); - describe('when the "onDayClick" prop have been triggered', () => { + describe('when the "onDayClick" prop has been triggered', () => { let onDayClickFn: jest.Mock; beforeEach(() => { @@ -225,7 +228,13 @@ describe("StyledDayPicker", () => { const buildLocale = (l: keyof typeof translations) => ({ locale: () => l, - date: { dateFnsLocale: () => translations[l] }, + date: { + dateFnsLocale: () => translations[l], + ariaLabels: { + previousMonthButton: () => "foo", + nextMonthButton: () => "foo", + }, + }, }); type WeekdaysType = { long?: string[]; short?: string[] }; diff --git a/src/components/date/__internal__/date-picker/day-picker.style.ts b/src/components/date/__internal__/date-picker/day-picker.style.ts index 55364c2b7a..8cdf8d4b86 100644 --- a/src/components/date/__internal__/date-picker/day-picker.style.ts +++ b/src/components/date/__internal__/date-picker/day-picker.style.ts @@ -1,4 +1,4 @@ -import styled from "styled-components"; +import styled, { css } from "styled-components"; import baseTheme from "../../../../style/themes/base"; import addFocusStyling from "../../../../style/utils/add-focus-styling"; @@ -187,7 +187,13 @@ const StyledDayPicker = styled.div` position: absolute; height: 346px; width: 352px; - z-index: ${({ theme }) => theme.zIndex.popover}; + ${({ theme }) => css` + z-index: ${theme.zIndex.popover}; + ${!theme.focusRedesignOptOut && + ` + margin-top: var(--spacing050); + `} + `} .DayPicker { z-index: 1000; diff --git a/src/components/date/__internal__/navbar/navbar.component.tsx b/src/components/date/__internal__/navbar/navbar.component.tsx index 9cd5cd1ec9..1732c337f8 100644 --- a/src/components/date/__internal__/navbar/navbar.component.tsx +++ b/src/components/date/__internal__/navbar/navbar.component.tsx @@ -2,6 +2,8 @@ import React from "react"; import StyledButton from "./button.style"; import StyledNavbar from "./navbar.style"; import Icon from "../../../icon"; +import Events from "../../../../__internal__/utils/helpers/events"; +import useLocale from "../../../../hooks/__internal__/useLocale"; export interface NavbarProps { onPreviousClick?: () => void; @@ -13,15 +15,40 @@ export const Navbar = ({ onPreviousClick, onNextClick, className, -}: NavbarProps) => ( - - onPreviousClick?.()}> - - - onNextClick?.()}> - - - -); +}: NavbarProps) => { + const locale = useLocale(); + const { previousMonthButton, nextMonthButton } = locale.date.ariaLabels; + + const handleKeyDown = (ev: React.KeyboardEvent) => { + if ( + Events.isLeftKey(ev) || + Events.isRightKey(ev) || + Events.isUpKey(ev) || + Events.isDownKey(ev) + ) { + ev.stopPropagation(); + ev.preventDefault(); + } + }; + + return ( + + onPreviousClick?.()} + onKeyDown={handleKeyDown} + > + + + onNextClick?.()} + onKeyDown={handleKeyDown} + > + + + + ); +}; export default Navbar; diff --git a/src/components/date/__internal__/navbar/navbar.spec.tsx b/src/components/date/__internal__/navbar/navbar.spec.tsx index 346d97934a..833b644d3e 100644 --- a/src/components/date/__internal__/navbar/navbar.spec.tsx +++ b/src/components/date/__internal__/navbar/navbar.spec.tsx @@ -1,12 +1,15 @@ import React from "react"; import TestRenderer from "react-test-renderer"; -import { shallow, ShallowWrapper } from "enzyme"; +import { mount, ReactWrapper, shallow, ShallowWrapper } from "enzyme"; import Navbar, { NavbarProps } from "./navbar.component"; import StyledButton from "./button.style"; +const arrowKeys = ["ArrowDown", "ArrowUp", "ArrowLeft", "ArrowRight"]; +const actionKeys = ["Enter", "Space"]; + describe("Navbar", () => { - let wrapper: ShallowWrapper; + let wrapper: ShallowWrapper | ReactWrapper; let onPreviousClick: jest.Mock; let onNextClick: jest.Mock; @@ -30,14 +33,138 @@ describe("Navbar", () => { }); it("returns a next button that calls onNextClick", () => { - const prevButton = wrapper.find(StyledButton).at(1); - prevButton.simulate("click"); + const nextButton = wrapper.find(StyledButton).at(1); + nextButton.simulate("click"); expect(onNextClick.mock.calls.length).toEqual(1); }); it("applies the custom class name", () => { expect(wrapper.find(".custom-class").length).toEqual(1); }); + + it("applies the expected aria-labels to the buttons", () => { + wrapper = mount( + + ); + + const prevButton = wrapper.find(StyledButton).at(0).getDOMNode(); + const nextButton = wrapper.find(StyledButton).at(1).getDOMNode(); + + expect(prevButton.getAttribute("aria-label")).toBe("Previous month"); + expect(nextButton.getAttribute("aria-label")).toBe("Next month"); + }); + + it.each(arrowKeys)( + "does not change the current month when %s key is pressed and previous button is focused", + (key) => { + const stopPropagation = jest.fn(); + const preventDefault = jest.fn(); + + wrapper = mount( + + ); + + const prevButton = wrapper.find(StyledButton).at(0); + (prevButton.getDOMNode() as HTMLElement).focus(); + prevButton.prop("onKeyDown")({ + key, + stopPropagation, + preventDefault, + }); + + expect(stopPropagation).toHaveBeenCalled(); + expect(preventDefault).toHaveBeenCalled(); + } + ); + + it.each(arrowKeys)( + "does not change the current month when %s key is pressed and next button is focused", + (key) => { + const stopPropagation = jest.fn(); + const preventDefault = jest.fn(); + + wrapper = mount( + + ); + + const nextButton = wrapper.find(StyledButton).at(1); + (nextButton.getDOMNode() as HTMLElement).focus(); + nextButton.prop("onKeyDown")({ + key, + stopPropagation, + preventDefault, + }); + + expect(stopPropagation).toHaveBeenCalled(); + expect(preventDefault).toHaveBeenCalled(); + } + ); + + it.each(actionKeys)( + "changes the current month when %s key is pressed and previous button is focused", + (key) => { + const stopPropagation = jest.fn(); + const preventDefault = jest.fn(); + + wrapper = mount( + + ); + + const prevButton = wrapper.find(StyledButton).at(0); + (prevButton.getDOMNode() as HTMLElement).focus(); + prevButton.prop("onKeyDown")({ + key, + stopPropagation, + preventDefault, + }); + + expect(stopPropagation).not.toHaveBeenCalled(); + expect(preventDefault).not.toHaveBeenCalled(); + } + ); + + it.each(actionKeys)( + "changes the current month when %s key is pressed and next button is focused", + (key) => { + const stopPropagation = jest.fn(); + const preventDefault = jest.fn(); + + wrapper = mount( + + ); + + const nextButton = wrapper.find(StyledButton).at(1); + (nextButton.getDOMNode() as HTMLElement).focus(); + nextButton.prop("onKeyDown")({ + key, + stopPropagation, + preventDefault, + }); + + expect(stopPropagation).not.toHaveBeenCalled(); + expect(preventDefault).not.toHaveBeenCalled(); + } + ); }); describe("Navbar Button", () => { diff --git a/src/components/date/date-test.stories.tsx b/src/components/date/date-test.stories.tsx index 5c14dd696f..2dee56f5de 100644 --- a/src/components/date/date-test.stories.tsx +++ b/src/components/date/date-test.stories.tsx @@ -91,9 +91,12 @@ NewValidationStory.args = { export const DateInputCustom = ({ onChange, onBlur, + value, ...props }: Partial & Partial) => { - const [state, setState] = React.useState("01/05/2022"); + const [state, setState] = React.useState( + value?.length !== undefined ? value : "01/05/2022" + ); const handleOnChange = (ev: DateChangeEvent) => { if (onChange) { diff --git a/src/components/date/date.component.tsx b/src/components/date/date.component.tsx index ae7fd4b7e6..206303ec99 100644 --- a/src/components/date/date.component.tsx +++ b/src/components/date/date.component.tsx @@ -29,6 +29,7 @@ import DateRangeContext, { InputName } from "../date-range/date-range.context"; import useClickAwayListener from "../../hooks/__internal__/useClickAwayListener"; import Logger from "../../__internal__/utils/logger"; import useFormSpacing from "../../hooks/__internal__/useFormSpacing"; +import guid from "../../__internal__/utils/helpers/guid"; interface CustomDateEvent { type: string; @@ -153,6 +154,7 @@ export const DateInput = React.forwardRef( : parseDate(format, value) ); const isInitialValue = useRef(true); + const pickerTabGuardId = useRef(guid()); if (!deprecateInputRefWarnTriggered && inputRef) { deprecateInputRefWarnTriggered = true; @@ -306,8 +308,19 @@ export const DateInput = React.forwardRef( onKeyDown(ev); } - if (Events.isTabKey(ev)) { + if (Events.isEscKey(ev)) { setOpen(false); + } + + if (open && Events.isTabKey(ev)) { + if (Events.isShiftKey(ev)) { + setOpen(false); + } else if (!disablePortal) { + ev.preventDefault(); + (document?.querySelector( + `[id="${pickerTabGuardId.current}"]` + ) as HTMLElement)?.focus(); + } alreadyFocused.current = false; } }; @@ -488,6 +501,8 @@ export const DateInput = React.forwardRef( maxDate={maxDate} pickerMouseDown={handlePickerMouseDown} open={open} + setOpen={setOpen} + pickerTabGuardId={pickerTabGuardId.current} /> ); diff --git a/src/components/date/date.spec.tsx b/src/components/date/date.spec.tsx index b0cff6d43d..6e76402793 100644 --- a/src/components/date/date.spec.tsx +++ b/src/components/date/date.spec.tsx @@ -42,46 +42,51 @@ import { enUS as enUSLocale, } from "../../locales/date-fns-locales"; import Logger from "../../__internal__/utils/logger"; +import StyledButton from "./__internal__/navbar/button.style"; +const ariaLabels = { + nextMonthButton: () => "foo", + previousMonthButton: () => "foo", +}; const locales = { "en-GB": { locale: () => "en-GB", - date: { dateFnsLocale: () => enGBLocale }, + date: { ariaLabels, dateFnsLocale: () => enGBLocale }, separator: "/", }, de: { locale: () => "de", - date: { dateFnsLocale: () => deLocale }, + date: { ariaLabels, dateFnsLocale: () => deLocale }, separator: ".", }, es: { locale: () => "es", - date: { dateFnsLocale: () => esLocale }, + date: { ariaLabels, dateFnsLocale: () => esLocale }, separator: "/", }, "en-ZA": { locale: () => "en-ZA", - date: { dateFnsLocale: () => enZALocale }, + date: { ariaLabels, dateFnsLocale: () => enZALocale }, separator: "/", }, "fr-FR": { locale: () => "fr-FR", - date: { dateFnsLocale: () => frLocale }, + date: { ariaLabels, dateFnsLocale: () => frLocale }, separator: "/", }, "fr-CA": { locale: () => "fr-CA", - date: { dateFnsLocale: () => frCALocale }, + date: { ariaLabels, dateFnsLocale: () => frCALocale }, separator: "/", }, "en-US": { locale: () => "en-US", - date: { dateFnsLocale: () => enUSLocale }, + date: { ariaLabels, dateFnsLocale: () => enUSLocale }, separator: "/", }, "en-CA": { locale: () => "en-CA", - date: { dateFnsLocale: () => enCALocale }, + date: { ariaLabels, dateFnsLocale: () => enCALocale }, separator: "/", }, }; @@ -151,8 +156,12 @@ function simulateMouseDownOnPicker(wrapper: ReactWrapper) { }); } -function simulateOnKeyDown(wrapper: ReactWrapper, key: string) { - const keyDownParams = { key }; +function simulateOnKeyDown( + wrapper: ReactWrapper, + key: string, + shiftKey?: boolean +) { + const keyDownParams = { key, shiftKey }; const input = wrapper.find("input"); act(() => { @@ -515,20 +524,85 @@ describe("Date", () => { }); }); - describe('and with the "Tab" key', () => { - it('then the "DatePicker" should be closed', () => { - expect(wrapper.update().find(DayPicker).exists()).toBe(true); - simulateOnKeyDown(wrapper, "Tab"); - expect(wrapper.update().find(DayPicker).exists()).toBe(false); + it('the "DatePicker" should remain open and the previous month navigation button should be focused when the "Tab" key is pressed', () => { + expect(wrapper.update().find(DayPicker).exists()).toBe(true); + simulateOnKeyDown(wrapper, "Tab"); + expect(wrapper.update().find(DayPicker).exists()).toBe(true); + expect(wrapper.find(StyledButton).first()).toBeFocused(); + }); + + it('the "DatePicker" should remain open and the previous month navigation button should be focused when the "Tab" key is pressed and disablePortal set', () => { + wrapper = render({ disablePortal: true }); + simulateFocusOnInput(wrapper); + + expect(wrapper.update().find(DayPicker).exists()).toBe(true); + simulateOnKeyDown(wrapper, "Tab"); + expect(wrapper.update().find(DayPicker).exists()).toBe(true); + expect(wrapper.find(StyledButton).first()).toBeFocused(); + }); + + it('the "DatePicker" should close when the "Escape" key is pressed', () => { + expect(wrapper.update().find(DayPicker).exists()).toBe(true); + act(() => { + simulateOnKeyDown(wrapper, "Escape"); }); + expect(wrapper.update().find(DayPicker).exists()).toBe(false); }); - describe('and with the key other that "Tab"', () => { - it('then the "DatePicker" should not be closed', () => { - expect(wrapper.update().find(DayPicker).exists()).toBe(true); - simulateOnKeyDown(wrapper, "Enter"); - expect(wrapper.update().find(DayPicker).exists()).toBe(true); + it('the "DatePicker" should close when the "Shift" and "Tab" keys are pressed', () => { + expect(wrapper.update().find(DayPicker).exists()).toBe(true); + act(() => { + simulateOnKeyDown(wrapper, "Tab", true); + }); + expect(wrapper.update().find(DayPicker).exists()).toBe(false); + }); + + it('the "DatePicker" should not be closed when a key other than "Tab" or "Escape" is pressed', () => { + expect(wrapper.update().find(DayPicker).exists()).toBe(true); + simulateOnKeyDown(wrapper, "Enter"); + expect(wrapper.update().find(DayPicker).exists()).toBe(true); + }); + }); + + describe('when the "keyDown" event is triggered on the picker', () => { + beforeEach(() => { + wrapper = render(); + simulateFocusOnInput(wrapper); + }); + + it('should close the picker when "Escape" key is pressed and focus is in picker', () => { + expect(wrapper.update().find(DayPicker).exists()).toBe(true); + simulateOnKeyDown(wrapper, "Tab"); + expect(wrapper.find(StyledButton).first()).toBeFocused(); + act(() => { + wrapper.find(StyledDayPicker).simulate("keydown", { key: "Escape" }); + }); + expect(wrapper.update().find(DayPicker).exists()).toBe(false); + }); + + it('should close the picker when "Shift" + "Tab" keys are pressed and focus is on previous month button', () => { + expect(wrapper.update().find(DayPicker).exists()).toBe(true); + simulateOnKeyDown(wrapper, "Tab"); + act(() => { + wrapper + .find(StyledDayPicker) + .simulate("keydown", { key: "Tab", shiftKey: true }); + }); + expect(wrapper.update().find(DayPicker).exists()).toBe(false); + }); + + it("should close the picker when Tab pressed and day element focused", () => { + const picker = wrapper.update().find(DayPicker); + expect(picker.exists()).toBe(true); + act(() => { + picker + ?.props() + ?.onDayKeyDown?.(new Date(), { today: false, outside: false }, { + key: "Tab", + preventDefault: () => {}, + } as React.KeyboardEvent); }); + expect(wrapper.update().find(DayPicker).exists()).toBe(false); }); }); diff --git a/src/components/date/date.stories.mdx b/src/components/date/date.stories.mdx index 58457af0d9..b3f84d556f 100644 --- a/src/components/date/date.stories.mdx +++ b/src/components/date/date.stories.mdx @@ -230,5 +230,19 @@ The following keys are available to override the translations for this component type: "func", returnType: "date-fns locale object", }, + { + name: "date.ariaLabels.previousMonthButton", + description: + "Aria label text for the previous month navigation button in the date picker", + type: "func", + returnType: "string", + }, + { + name: "date.ariaLabels.nextMonthButton", + description: + "Aria label text for the next month navigation button in the date picker", + type: "func", + returnType: "string", + } ]} /> diff --git a/src/components/date/date.stories.tsx b/src/components/date/date.stories.tsx index 849bf7fdf0..402dab35af 100644 --- a/src/components/date/date.stories.tsx +++ b/src/components/date/date.stories.tsx @@ -448,7 +448,13 @@ export const LocaleOverrideExampleImplementation: ComponentStory< "de-DE", - date: { dateFnsLocale: () => de }, + date: { + dateFnsLocale: () => de, + ariaLabels: { + previousMonthButton: () => "Vorheriger Monat", + nextMonthButton: () => "Nächster Monat", + }, + }, }} > "zh-CN", - date: { dateFnsLocale: () => zhCN }, + date: { + dateFnsLocale: () => zhCN, + ariaLabels: { + previousMonthButton: () => "上个月", + nextMonthButton: () => "下个月", + }, + }, }} > enGBDateLocale, + ariaLabels: { + previousMonthButton: () => "Previous month", + nextMonthButton: () => "Next month", + }, }, dialog: { ariaLabels: { diff --git a/src/locales/locale.ts b/src/locales/locale.ts index bd4a8be0fe..32bb0ef6cf 100644 --- a/src/locales/locale.ts +++ b/src/locales/locale.ts @@ -31,6 +31,10 @@ interface Locale { }; date: { dateFnsLocale: () => DateFnsLocale; + ariaLabels: { + previousMonthButton: () => string; + nextMonthButton: () => string; + }; }; dialog: { ariaLabels: { diff --git a/src/locales/pl-pl.ts b/src/locales/pl-pl.ts index ea42aded9f..1fa4f62a9e 100644 --- a/src/locales/pl-pl.ts +++ b/src/locales/pl-pl.ts @@ -101,6 +101,10 @@ const plPL: Locale = { date: { dateFnsLocale: () => plDateLocale, + ariaLabels: { + previousMonthButton: () => "Poprzedni miesiąc", + nextMonthButton: () => "Następny miesiąc", + }, }, dialog: { ariaLabels: {