From b5eb150bcc5760f548db60610f70c5ccd6a7db18 Mon Sep 17 00:00:00 2001 From: edleeks87 Date: Thu, 19 Oct 2023 14:41:32 +0100 Subject: [PATCH 1/3] fix(date): add aria labels to picker navigation buttons and fix other accessibility issues Adds aria-label support to `next` and `previous` month navigation buttons in `Date` picker component. Removes invalid role and aria-live attributes from caption element to fix some axe warnings. fix #5804 --- cypress/components/date/date.cy.tsx | 20 ++ .../date-range/date-range.stories.mdx | 14 + .../date-range/date-range.stories.tsx | 8 +- .../date-picker/date-picker.component.tsx | 241 ++++++++++-------- .../date-picker/date-picker.spec.tsx | 8 +- .../__internal__/navbar/navbar.component.tsx | 32 ++- .../date/__internal__/navbar/navbar.spec.tsx | 24 +- src/components/date/date.spec.tsx | 20 +- src/components/date/date.stories.mdx | 14 + src/components/date/date.stories.tsx | 16 +- src/locales/en-gb.ts | 4 + src/locales/locale.ts | 4 + src/locales/pl-pl.ts | 4 + 13 files changed, 274 insertions(+), 135 deletions(-) diff --git a/cypress/components/date/date.cy.tsx b/cypress/components/date/date.cy.tsx index 02ffe3615d..04defa7217 100644 --- a/cypress/components/date/date.cy.tsx +++ b/cypress/components/date/date.cy.tsx @@ -411,6 +411,10 @@ context("Test for DateInput component", () => { locale: () => localeValue, date: { dateFnsLocale: () => dateFnsLocaleValue, + ariaLabels: { + previousMonthButton: () => "Previous month", + nextMonthButton: () => "Next month", + }, }, } ); @@ -478,6 +482,10 @@ context("Test for DateInput component", () => { locale: () => localeValue, date: { dateFnsLocale: () => dateFnsLocaleValue, + ariaLabels: { + previousMonthButton: () => "Previous month", + nextMonthButton: () => "Next month", + }, }, } ); @@ -544,6 +552,10 @@ context("Test for DateInput component", () => { locale: () => localeValue, date: { dateFnsLocale: () => dateFnsLocaleValue, + ariaLabels: { + previousMonthButton: () => "Previous month", + nextMonthButton: () => "Next month", + }, }, } ); @@ -864,4 +876,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/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", + }, + }, }} > ( - ( - { - 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] - ); - const weekdaysLong = useMemo( - () => Array.from({ length: 7 }).map((_, i) => localize?.day(i)), - [localize] +export const DatePicker = ({ + inputElement, + minDate, + maxDate, + selectedDays, + disablePortal, + onDayClick, + pickerMouseDown, + pickerProps, + open, +}: 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 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 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); + }, [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); + } + }; + + const formatDay = (date: Date) => + `${weekdaysShort[date.getDay()]} ${date.getDate()} ${ + monthsShort[date.getMonth()] + } ${date.getFullYear()}`; + + 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"); } - }; - const formatDay = (date: Date) => - `${weekdaysShort[date.getDay()]} ${date.getDate()} ${ - monthsShort[date.getMonth()] - } ${date.getFullYear()}`; + // 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']" + ); - if (!open) { - return null; + /* istanbul ignore else */ + if (selectedDay && firstDay !== selectedDay) { + selectedDay?.setAttribute("tabindex", "0"); + firstDay?.setAttribute("tabindex", "-1"); + } } + }, [open]); - const localeUtils = { formatDay } as LocaleUtils; - - return ( - - - { - const { className, weekday } = weekdayElementProps; - - return ( - - {weekdaysShort[weekday]} - - ); - }} - navbarElement={} - fixedWeeks - initialMonth={selectedDays || undefined} - disabledDays={getDisabledDays(minDate, maxDate)} - locale={l.locale()} - localeUtils={localeUtils} - {...pickerProps} - /> - - - ); + if (!open) { + return null; } -); + + const localeUtils = { formatDay } as LocaleUtils; + + return ( + + + { + const { className, weekday } = weekdayElementProps; + + return ( + + {weekdaysShort[weekday]} + + ); + }} + navbarElement={} + fixedWeeks + initialMonth={selectedDays || undefined} + disabledDays={getDisabledDays(minDate, maxDate)} + locale={l.locale()} + localeUtils={localeUtils} + {...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..91f758fa14 100644 --- a/src/components/date/__internal__/date-picker/date-picker.spec.tsx +++ b/src/components/date/__internal__/date-picker/date-picker.spec.tsx @@ -225,7 +225,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__/navbar/navbar.component.tsx b/src/components/date/__internal__/navbar/navbar.component.tsx index 9cd5cd1ec9..4a4222f5a2 100644 --- a/src/components/date/__internal__/navbar/navbar.component.tsx +++ b/src/components/date/__internal__/navbar/navbar.component.tsx @@ -2,6 +2,7 @@ import React from "react"; import StyledButton from "./button.style"; import StyledNavbar from "./navbar.style"; import Icon from "../../../icon"; +import useLocale from "../../../../hooks/__internal__/useLocale"; export interface NavbarProps { onPreviousClick?: () => void; @@ -13,15 +14,26 @@ export const Navbar = ({ onPreviousClick, onNextClick, className, -}: NavbarProps) => ( - - onPreviousClick?.()}> - - - onNextClick?.()}> - - - -); +}: NavbarProps) => { + const locale = useLocale(); + const { previousMonthButton, nextMonthButton } = locale.date.ariaLabels; + + return ( + + onPreviousClick?.()} + > + + + onNextClick?.()} + > + + + + ); +}; 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..6c8b2147f2 100644 --- a/src/components/date/__internal__/navbar/navbar.spec.tsx +++ b/src/components/date/__internal__/navbar/navbar.spec.tsx @@ -1,12 +1,12 @@ 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"; describe("Navbar", () => { - let wrapper: ShallowWrapper; + let wrapper: ShallowWrapper | ReactWrapper; let onPreviousClick: jest.Mock; let onNextClick: jest.Mock; @@ -30,14 +30,30 @@ 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"); + }); }); describe("Navbar Button", () => { diff --git a/src/components/date/date.spec.tsx b/src/components/date/date.spec.tsx index b0cff6d43d..d187b72086 100644 --- a/src/components/date/date.spec.tsx +++ b/src/components/date/date.spec.tsx @@ -43,45 +43,49 @@ import { } from "../../locales/date-fns-locales"; import Logger from "../../__internal__/utils/logger"; +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: "/", }, }; 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: { From 11d185306a6cf30e67e1efcf31c7985366594b78 Mon Sep 17 00:00:00 2001 From: edleeks87 Date: Fri, 20 Oct 2023 16:03:41 +0100 Subject: [PATCH 2/3] feat(date): add support for keyboard navigation in picker Adds support for allowing users to keyboard navigate into the picker from the input element and around the focusable elements within it. The behaviour is as follows: - Tabbing from the input will focus the `previous` month navigation button. - Tabbing again will focus the `next` month navigation button. - Pressing enter or space on either of these buttons will change the currently displayed month. - Pressing tab again will focus the day element corresponding to either the selected or current (today's) date. - Pressing enter or space on a day element will select it, update the input value and close the picker. - Pressing tab will close the picker and focus the next focusable element in the DOM fix #6324, fix #3969 --- cypress/components/date/date.cy.tsx | 242 +++++++++++++++++- cypress/support/helper.ts | 7 +- .../date-picker/date-picker.component.tsx | 119 +++++++-- .../date-picker/date-picker.spec.tsx | 13 +- .../__internal__/navbar/navbar.component.tsx | 15 ++ .../date/__internal__/navbar/navbar.spec.tsx | 111 ++++++++ src/components/date/date-test.stories.tsx | 5 +- src/components/date/date.component.tsx | 17 +- src/components/date/date.spec.tsx | 94 ++++++- 9 files changed, 566 insertions(+), 57 deletions(-) diff --git a/cypress/components/date/date.cy.tsx b/cypress/components/date/date.cy.tsx index 04defa7217..02ff25c75b 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([ - ["chevron_right", "next", keysToTrigger[0]], - ["chevron_left", "previous", keysToTrigger[1]], + ["24", "1"], + ["25", "2"], + ["26", "3"], + ["27", "4"], + ["28", "5"], + ["29", "6"], + ["30", "7"], ])( - "should trigger %s arrow in DayPicker to verify %s month is shown using %s keyboard key", - (arrow, month, key) => { + "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"); + } + ); - dateInput().clear().type(DATE_INPUT); - dateInputParent().click(); + it("should close the picker when escape is pressed and input focused", () => { + CypressMountWithProviders(); + cy.get("body").tab(); + dayPickerWrapper().should("exist"); - dayPickerWrapper().focus(); - getDataElementByValue(arrow).trigger("keydown", keyCode(key)); + 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([ + ["enter", "next", "chevron_right"], + ["space", "next", "chevron_right"], + ["enter", "previous", "chevron_left"], + ["space", "previous", "chevron_left"], + ])( + "should change the displayed month when %s is pressed and %s button is focused", + (key, month, arrow) => { + CypressMountWithProviders(); + + const keyToType = key === "space" ? " " : key; + dateInputParent().click(); + 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"); } } ); 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/__internal__/date-picker/date-picker.component.tsx b/src/components/date/__internal__/date-picker/date-picker.component.tsx index 94a1b44518..0370ac8de3 100644 --- a/src/components/date/__internal__/date-picker/date-picker.component.tsx +++ b/src/components/date/__internal__/date-picker/date-picker.component.tsx @@ -13,6 +13,8 @@ import useLocale from "../../../../hooks/__internal__/useLocale"; import Navbar from "../navbar"; import Weekday from "../weekday"; import StyledDayPicker from "./day-picker.style"; +import Events from "../../../../__internal__/utils/helpers/events"; +import { defaultFocusableSelectors } from "../../../../__internal__/focus-trap/focus-trap-utils"; type CustomRefObject = { 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 = [ @@ -70,6 +76,8 @@ export const DatePicker = ({ pickerMouseDown, pickerProps, open, + setOpen, + pickerTabGuardId, }: DatePickerProps) => { const l = useLocale(); const { localize, options } = l.date.dateFnsLocale(); @@ -106,29 +114,6 @@ export const DatePicker = ({ .substring(0, isGivenLocale("de") ? 2 : 3) ); }, [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); - } - }; - - const formatDay = (date: Date) => - `${weekdaysShort[date.getDay()]} ${date.getDate()} ${ - monthsShort[date.getMonth()] - } ${date.getFullYear()}`; - const ref = useRef(null); useEffect(() => { @@ -157,12 +142,87 @@ export const DatePicker = ({ } }, [open]); + 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); + } + }; + + const handleOnKeyDown = (ev: React.KeyboardEvent) => { + if (Events.isEscKey(ev)) { + inputElement.current?.querySelector("input")?.focus(); + setOpen(false); + } + + if ( + ref.current?.querySelector(".DayPicker-NavBar button") === + document.activeElement && + Events.isTabKey(ev) && + Events.isShiftKey(ev) + ) { + ev.preventDefault(); + setOpen(false); + inputElement.current?.querySelector("input")?.focus(); + } + }; + + 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 ( - + +
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 91f758fa14..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(() => { diff --git a/src/components/date/__internal__/navbar/navbar.component.tsx b/src/components/date/__internal__/navbar/navbar.component.tsx index 4a4222f5a2..1732c337f8 100644 --- a/src/components/date/__internal__/navbar/navbar.component.tsx +++ b/src/components/date/__internal__/navbar/navbar.component.tsx @@ -2,6 +2,7 @@ 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 { @@ -18,17 +19,31 @@ export const Navbar = ({ 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} > diff --git a/src/components/date/__internal__/navbar/navbar.spec.tsx b/src/components/date/__internal__/navbar/navbar.spec.tsx index 6c8b2147f2..833b644d3e 100644 --- a/src/components/date/__internal__/navbar/navbar.spec.tsx +++ b/src/components/date/__internal__/navbar/navbar.spec.tsx @@ -5,6 +5,9 @@ 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 | ReactWrapper; let onPreviousClick: jest.Mock; @@ -54,6 +57,114 @@ describe("Navbar", () => { 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 d187b72086..6e76402793 100644 --- a/src/components/date/date.spec.tsx +++ b/src/components/date/date.spec.tsx @@ -42,6 +42,7 @@ 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", @@ -155,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(() => { @@ -519,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); }); }); From 47fd717e92ef2d701b9d98acbf89f547ec02c8f1 Mon Sep 17 00:00:00 2001 From: edleeks87 Date: Fri, 20 Oct 2023 17:14:50 +0100 Subject: [PATCH 3/3] chore(date): increase spacing between input and picker when new focus styles applied --- cypress/components/date/date.cy.tsx | 2 ++ .../__snapshots__/date-picker.spec.tsx.snap | 1 + .../date/__internal__/date-picker/day-picker.style.ts | 10 ++++++++-- 3 files changed, 11 insertions(+), 2 deletions(-) diff --git a/cypress/components/date/date.cy.tsx b/cypress/components/date/date.cy.tsx index 02ff25c75b..9c28bc31dc 100644 --- a/cypress/components/date/date.cy.tsx +++ b/cypress/components/date/date.cy.tsx @@ -892,6 +892,8 @@ context("Test for DateInput component", () => { dateInput().focus(); + dayPickerParent().should("have.css", "margin-top", "4px"); + dateInputParent() .should( "have.css", diff --git a/src/components/date/__internal__/date-picker/__snapshots__/date-picker.spec.tsx.snap b/src/components/date/__internal__/date-picker/__snapshots__/date-picker.spec.tsx.snap index 1540d6b7c5..cb7c678324 100644 --- a/src/components/date/__internal__/date-picker/__snapshots__/date-picker.spec.tsx.snap +++ b/src/components/date/__internal__/date-picker/__snapshots__/date-picker.spec.tsx.snap @@ -6,6 +6,7 @@ exports[`StyledDayPicker renders presentational div and context provider for its height: 346px; width: 352px; z-index: 6000; + margin-top: var(--spacing050); } .c0 .DayPicker { 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;