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: {