From 3d994aaaf7cd4f856ba9786118aeb8a8c65db0f5 Mon Sep 17 00:00:00 2001 From: Giampaolo Bellavite Date: Sat, 30 Nov 2024 18:44:17 -0500 Subject: [PATCH] a11y: improve screen reader and VoiceOver support (#2609) --- examples/FixedWeeks.test.tsx | 2 +- examples/Input.test.tsx | 2 +- examples/ModifiersHidden.test.tsx | 2 +- examples/Range.test.tsx | 2 +- examples/TestCase2511.test.tsx | 4 +- examples/TestCase2585.test.tsx | 4 +- examples/WeeknumberCustom.test.tsx | 6 +- examples/__snapshots__/Range.test.tsx.snap | 198 ++++++++---------- .../StylingCssModules.test.tsx.snap | 99 ++++----- src/DayPicker.test.tsx | 8 +- src/DayPicker.tsx | 25 ++- src/components/Weekdays.tsx | 2 +- src/types/props.ts | 14 ++ test/elements.ts | 10 +- website/docs/guides/accessibility.mdx | 60 ++---- 15 files changed, 199 insertions(+), 239 deletions(-) diff --git a/examples/FixedWeeks.test.tsx b/examples/FixedWeeks.test.tsx index d108fd747b..489dfb8dd2 100644 --- a/examples/FixedWeeks.test.tsx +++ b/examples/FixedWeeks.test.tsx @@ -14,5 +14,5 @@ beforeEach(() => { }); test("should render 7 rows", () => { - expect(screen.getAllByRole("row")).toHaveLength(7); + expect(screen.getAllByRole("row", { hidden: true })).toHaveLength(7); }); diff --git a/examples/Input.test.tsx b/examples/Input.test.tsx index 22bf27827e..99e40077ce 100644 --- a/examples/Input.test.tsx +++ b/examples/Input.test.tsx @@ -13,7 +13,7 @@ function textbox() { } function gridcells() { - return screen.queryAllByRole("cell") as HTMLTableCellElement[]; + return screen.queryAllByRole("gridcell") as HTMLTableCellElement[]; } function selectedCells() { diff --git a/examples/ModifiersHidden.test.tsx b/examples/ModifiersHidden.test.tsx index 993a49840f..a4f7b85cef 100644 --- a/examples/ModifiersHidden.test.tsx +++ b/examples/ModifiersHidden.test.tsx @@ -18,6 +18,6 @@ afterAll(() => jest.useRealTimers()); test.each(days)("the day %s should be hidden", (day) => { render(); expect( - screen.queryByRole("cell", { name: `${day.getDate()}` }) + screen.queryByRole("gridcell", { name: `${day.getDate()}` }) ).not.toBeInTheDocument(); }); diff --git a/examples/Range.test.tsx b/examples/Range.test.tsx index d23846c077..136a123769 100644 --- a/examples/Range.test.tsx +++ b/examples/Range.test.tsx @@ -69,7 +69,7 @@ describe("when a day in the range is clicked", () => { }); function getAllSelected() { - const gridcells = screen.getAllByRole("cell"); + const gridcells = screen.getAllByRole("gridcell"); return Array.from(gridcells).filter( (gridcell) => gridcell.getAttribute("aria-selected") === "true" diff --git a/examples/TestCase2511.test.tsx b/examples/TestCase2511.test.tsx index c28c237a85..45997e9d94 100644 --- a/examples/TestCase2511.test.tsx +++ b/examples/TestCase2511.test.tsx @@ -9,6 +9,8 @@ beforeEach(async () => { }); test("first weekday is Monday", () => { - const weekdaysElements = screen.queryAllByRole("columnheader"); + const weekdaysElements = screen.queryAllByRole("columnheader", { + hidden: true + }); expect(weekdaysElements[0]).toHaveAccessibleName("Monday"); }); diff --git a/examples/TestCase2585.test.tsx b/examples/TestCase2585.test.tsx index 016b695dec..2258f10d25 100644 --- a/examples/TestCase2585.test.tsx +++ b/examples/TestCase2585.test.tsx @@ -7,5 +7,7 @@ import { TestCase2585 } from "./TestCase2585"; render(); test("should render 42*12 days", () => { - expect(screen.getAllByRole("cell")).toHaveLength(42 * 12); + expect(screen.getAllByRole("gridcell", { hidden: true })).toHaveLength( + 42 * 12 + ); }); diff --git a/examples/WeeknumberCustom.test.tsx b/examples/WeeknumberCustom.test.tsx index 626e9292ea..27fa37441f 100644 --- a/examples/WeeknumberCustom.test.tsx +++ b/examples/WeeknumberCustom.test.tsx @@ -9,5 +9,9 @@ beforeEach(() => { }); test("should display the 1st week (even if December)", () => { - expect(screen.getByRole("rowheader", { name: `W1` })).toBeInTheDocument(); + expect( + screen.getByRole("rowheader", { + name: (name, el) => name === "W1" + }) + ).toBeInTheDocument(); }); diff --git a/examples/__snapshots__/Range.test.tsx.snap b/examples/__snapshots__/Range.test.tsx.snap index d4e3744019..ca39143f8b 100644 --- a/examples/__snapshots__/Range.test.tsx.snap +++ b/examples/__snapshots__/Range.test.tsx.snap @@ -68,7 +68,9 @@ exports[`should match the snapshot 1`] = ` class="rdp-month_grid" role="grid" > - + @@ -130,25 +132,17 @@ exports[`should match the snapshot 1`] = ` class="rdp-week" > - - + role="gridcell" + /> - + role="gridcell" + /> - - + role="gridcell" + /> - - + role="gridcell" + /> - - + role="gridcell" + /> @@ -720,7 +707,9 @@ exports[`when a day in the range is clicked when the day is clicked again when a class="rdp-month_grid" role="grid" > - + @@ -782,25 +771,17 @@ exports[`when a day in the range is clicked when the day is clicked again when a class="rdp-week" > - - + role="gridcell" + /> - + role="gridcell" + /> - - + role="gridcell" + /> - - + role="gridcell" + /> - - + role="gridcell" + /> diff --git a/examples/__snapshots__/StylingCssModules.test.tsx.snap b/examples/__snapshots__/StylingCssModules.test.tsx.snap index c1ee323186..21dcb6523b 100644 --- a/examples/__snapshots__/StylingCssModules.test.tsx.snap +++ b/examples/__snapshots__/StylingCssModules.test.tsx.snap @@ -68,7 +68,9 @@ exports[`should match the snapshot 1`] = ` class="rdp-month_grid" role="grid" > - + @@ -130,25 +132,17 @@ exports[`should match the snapshot 1`] = ` class="rdp-week" > - - + role="gridcell" + /> - + role="gridcell" + /> - - + role="gridcell" + /> - - + role="gridcell" + /> - - + role="gridcell" + /> diff --git a/src/DayPicker.test.tsx b/src/DayPicker.test.tsx index 8a29018bd6..917408604c 100644 --- a/src/DayPicker.test.tsx +++ b/src/DayPicker.test.tsx @@ -184,12 +184,16 @@ test("should render the custom components", () => { describe("when interactive", () => { test("render a valid HTML", () => { render(); - expect(document.body).toHTMLValidate(); + expect(document.body).toHTMLValidate({ + rules: { "no-redundant-role": "off" } // Redundant role is allowed for VoiceOver + }); }); }); describe("when not interactive", () => { test("render a valid HTML", () => { render(); - expect(document.body).toHTMLValidate(); + expect(document.body).toHTMLValidate({ + rules: { "no-redundant-role": "off" } // Redundant role is allowed for VoiceOver + }); }); }); diff --git a/src/DayPicker.tsx b/src/DayPicker.tsx index 59aadbae57..4fefa94609 100644 --- a/src/DayPicker.tsx +++ b/src/DayPicker.tsx @@ -279,6 +279,8 @@ export function DayPicker(props: DayPickerProps) { lang={props.lang} nonce={props.nonce} title={props.title} + role={props.role} + aria-label={props["aria-label"]} {...dataAttributes} > {formatWeekNumber(week.weekNumber)} @@ -495,14 +498,15 @@ export function DayPicker(props: DayPickerProps) { props.modifiersClassNames ); - const ariaLabel = !isInteractive - ? labelGridcell( - date, - modifiers, - dateLib.options, - dateLib - ) - : undefined; + const ariaLabel = + !isInteractive && !modifiers.hidden + ? labelGridcell( + date, + modifiers, + dateLib.options, + dateLib + ) + : undefined; return ( - {isInteractive ? ( + {!modifiers.hidden && isInteractive ? ( ) : ( + !modifiers.hidden && formatDay(day.date, dateLib.options, dateLib) )} diff --git a/src/components/Weekdays.tsx b/src/components/Weekdays.tsx index 6636c1b011..b1decad368 100644 --- a/src/components/Weekdays.tsx +++ b/src/components/Weekdays.tsx @@ -8,7 +8,7 @@ import React from "react"; */ export function Weekdays(props: JSX.IntrinsicElements["tr"]) { return ( - + ); diff --git a/src/types/props.ts b/src/types/props.ts index b32243b1a7..95a77a4839 100644 --- a/src/types/props.ts +++ b/src/types/props.ts @@ -355,6 +355,20 @@ export interface PropsBase { * @see https://daypicker.dev/docs/translation#rtl-text-direction */ dir?: HTMLDivElement["dir"]; + /** + * The aria-label attribute to add to the container element. + * + * @since 9.4.1 + * @see https://daypicker.dev/guides/accessibility + */ + ["aria-label"]?: string; + /** + * The role attribute to add to the container element. + * + * @since 9.4.1 + * @see https://daypicker.dev/guides/accessibility + */ + role?: "application" | "dialog" | undefined; /** * A cryptographic nonce ("number used once") which can be used by Content * Security Policy for the inline `style` attributes. diff --git a/test/elements.ts b/test/elements.ts index 8af331e0d3..6085354b6b 100644 --- a/test/elements.ts +++ b/test/elements.ts @@ -33,7 +33,7 @@ export function nextButton() { * @param {string} name - The name of the columnheader. */ export function columnheader(name?: ByRoleOptions["name"]) { - return screen.getByRole("columnheader", name ? { name } : undefined); + return screen.getByRole("columnheader", { name, hidden: true }); } /** @@ -42,7 +42,7 @@ export function columnheader(name?: ByRoleOptions["name"]) { * @param {string} name - The name of the grid. */ export function grid(name?: ByRoleOptions["name"]) { - return screen.getByRole("grid", name ? { name } : undefined); + return screen.getByRole("grid", { name }); } /** Return the parent element of the next button from the screen. */ @@ -83,10 +83,10 @@ export function dateButton(date: Date) { */ export function gridcell(date: Date, interactive?: boolean) { if (interactive) - return screen.getByRole("cell", { + return screen.getByRole("gridcell", { name: date.getDate().toString() }); - return screen.getByRole("cell", { + return screen.getByRole("gridcell", { name: labelGridcell(date) }); } @@ -97,7 +97,7 @@ export function gridcell(date: Date, interactive?: boolean) { * @param {string} name - The name of the rowheader. */ export function rowheader(name?: ByRoleOptions["name"]) { - return screen.getByRole("rowheader", name ? { name } : undefined); + return screen.getByRole("rowheader", { name }); } /** Return the year dropdown element from the screen. */ diff --git a/website/docs/guides/accessibility.mdx b/website/docs/guides/accessibility.mdx index 315be3cedb..de6e3575a9 100644 --- a/website/docs/guides/accessibility.mdx +++ b/website/docs/guides/accessibility.mdx @@ -8,61 +8,29 @@ DayPicker follows the [ARIA Authoring Practices Guide](https://www.w3.org/WAI/AR Depending on your design, you might need to add more accessibility features. For example, when using [Input Fields](../guides/input-fields), there may be some limitations based on your accessibility goals. Keep up with best practices by following the [ARIA Patterns](https://www.w3.org/WAI/ARIA/apg/patterns/). -## Accessibility Tips +:::tip Accessibility Tips - Test your date picker regularly with a screen reader to ensure accessibility. -- Use an `aria-live` region to announce when a date is selected, utilizing the `footer` prop. +- Use an `aria-live` region to announce when a date is selected, like using the `footer` prop. - Customize ARIA labels with the [`labels`](../api/interfaces/PropsBase.md#labels) prop for better user feedback. -- Ensure the date picker is fully navigable with just the keyboard. -- Provide clear focus indicators for keyboard users. - Maintain sufficient color contrast between text and background. - Offer instructions for first-time users or those unfamiliar with the date picker. -## Announcing the Selected Date {#footer} - -Here is an example of an accessible date picker with a live region that announces the selected date using the `footer` prop. - -```tsx title="./AccessibleDatePicker.tsx" -import { format } from "date-fns"; -import { DayPicker } from "react-day-picker"; - -export function AccessibleDatePicker() { - const [meetingDate, setMeetingDate] = React.useState( - undefined - ); - - const footer = meetingDate - ? `Meeting date is set to ${format(meetingDate, "PPPP")}` - : "Please pick a date for the meeting."; - - const labels = { - labelCaption: () => "Select a date for the meeting", - labelDay: (date, modifiers) => { - return modifiers.selected - ? `Selected Meeting Date: ${format(date, "PPP")}` - : ""; - } - }; - - return ( - - ); -} -``` +::: + +## Accessibility Props - - - +| Prop Name | Type | Description | +| ------------ | ----------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `labels` | [`Labels`](../api/type-aliases/Labels.md) | Map of the ARIA labels used within DayPicker for better accessibility. | +| `footer` | `ReactNode` \| `string` | Add a footer to the calendar, which can act as a [live region](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/ARIA_Live_Regions) to announce updates. | +| `role` | `application` \| `dialog` | Set the ARIA role for the container. Use `application` for a more interactive widget or `dialog` for a modal-like date picker. | +| `aria-label` | `string` | The label to use for the container when a `role` is set, providing a descriptive text for screen readers. | +| `autoFocus` | `boolean` | Autofocus the calendar when it opens, improving keyboard navigation. | -## Autofocusing the Calendar {#autofocus} +### Autofocusing the Calendar {#autofocus} -DayPicker manages focus automatically when users interact with the calendar. For better accessibility, you might want to autofocus the calendar when it opens. Use the `autoFocus` prop to achieve this: +DayPicker automatically manages focus when users interact with the calendar. To enhance accessibility, you can autofocus the calendar when it opens by using the `autoFocus` prop: ```tsx