diff --git a/.changeset/common-lizards-try.md b/.changeset/common-lizards-try.md new file mode 100644 index 000000000..ea845477c --- /dev/null +++ b/.changeset/common-lizards-try.md @@ -0,0 +1,5 @@ +--- +"@gouvfr-lasuite/cunningham-react": minor +--- + +Upgrading modal accessibility diff --git a/packages/react/src/components/Forms/DatePicker/DatePickerAux.tsx b/packages/react/src/components/Forms/DatePicker/DatePickerAux.tsx index ccb64dfb7..56f23a44b 100644 --- a/packages/react/src/components/Forms/DatePicker/DatePickerAux.tsx +++ b/packages/react/src/components/Forms/DatePicker/DatePickerAux.tsx @@ -281,6 +281,9 @@ const DatePickerAux = ({ parentRef={pickerRef} onClickOutside={pickerState.close} borderless + aria-label={t( + "components.forms.date_picker.calendar_popover_aria_label", + )} > {calendar} diff --git a/packages/react/src/components/Modal/ModalDefaultLayout.tsx b/packages/react/src/components/Modal/ModalDefaultLayout.tsx index 248c6a2ba..6a2c31254 100644 --- a/packages/react/src/components/Modal/ModalDefaultLayout.tsx +++ b/packages/react/src/components/Modal/ModalDefaultLayout.tsx @@ -1,6 +1,7 @@ import React from "react"; import classNames from "classnames"; import { Button } from ":/components/Button"; +import { useCunningham } from ":/components/Provider"; import { CloseIcon } from "./CloseIcon"; import { ModalFooter } from "./ModalFooter"; import { ModalDefaultVariantProps } from "./index"; @@ -8,8 +9,15 @@ import { ModalDefaultVariantProps } from "./index"; export const ModalDefaultLayout = ({ titleVariant = "default", showCloseButton, + titleId, + subtitleId, ...props -}: ModalDefaultVariantProps & { showCloseButton: boolean }) => { +}: ModalDefaultVariantProps & { + showCloseButton: boolean; + titleId: string; + subtitleId: string; +}) => { + const { t } = useCunningham(); const isCompact = titleVariant === "compact"; return ( @@ -22,12 +30,17 @@ export const ModalDefaultLayout = ({ {isCompact ? (
{(props.titleIcon || props.title) && ( -
+

{props.titleIcon && ( -
{props.titleIcon}
+ )} {props.title} -

+ )} {showCloseButton && (
)} {(props.titleIcon || props.title) && ( -
+

{props.titleIcon && ( -
{props.titleIcon}
+ )} {props.title} -

+ )} {props.subtitle && ( -
{props.subtitle}
+

+ {props.subtitle} +

)} )} diff --git a/packages/react/src/components/Modal/ModalTabLayout.tsx b/packages/react/src/components/Modal/ModalTabLayout.tsx index e04db7e45..0950eb529 100644 --- a/packages/react/src/components/Modal/ModalTabLayout.tsx +++ b/packages/react/src/components/Modal/ModalTabLayout.tsx @@ -1,6 +1,7 @@ import React, { useCallback, useEffect, useRef, useState } from "react"; import classNames from "classnames"; import { Button } from ":/components/Button"; +import { useCunningham } from ":/components/Provider"; import { CloseIcon } from "./CloseIcon"; import { ChevronRightIcon } from "./ChevronRightIcon"; import { ChevronLeftIcon } from "./ChevronLeftIcon"; @@ -9,8 +10,10 @@ import { ModalTabVariantProps } from "./index"; export const ModalTabLayout = ({ showCloseButton, + titleId, ...props -}: ModalTabVariantProps & { showCloseButton: boolean }) => { +}: ModalTabVariantProps & { showCloseButton: boolean; titleId: string }) => { + const { t } = useCunningham(); const [internalActiveTab, setInternalActiveTab] = useState( props.defaultActiveTab ?? props.tabs[0]?.id, ); @@ -44,7 +47,7 @@ export const ModalTabLayout = ({ const activeTabId = props.activeTab ?? internalActiveTab; const currentTab = - props.tabs.find((t) => t.id === activeTabId) ?? props.tabs[0]; + props.tabs.find((tab) => tab.id === activeTabId) ?? props.tabs[0]; const handleTabChange = (tabId: string) => { if (props.activeTab === undefined) { @@ -67,9 +70,9 @@ export const ModalTabLayout = ({
{props.sidebarTitle && ( -
+

{props.sidebarTitle} -

+ )} {showCloseButton && (
@@ -78,7 +81,7 @@ export const ModalTabLayout = ({ variant="tertiary" color="neutral" size="small" - aria-label="close" + aria-label={t("components.modals.close_button_aria_label")} onClick={props.onClose} />
@@ -122,13 +125,13 @@ export const ModalTabLayout = ({ type="button" className="c__modal__tab-content__back" onClick={handleBackToSidebar} - aria-label="Back to tabs" + aria-label={t("components.modals.back_to_tabs_aria_label")} >
{currentTab?.title && ( -
{currentTab.title}
+

{currentTab.title}

)}
{showCloseButton && ( @@ -138,7 +141,7 @@ export const ModalTabLayout = ({ variant="tertiary" color="neutral" size="small" - aria-label="close" + aria-label={t("components.modals.close_button_aria_label")} onClick={props.onClose} />
diff --git a/packages/react/src/components/Modal/index.scss b/packages/react/src/components/Modal/index.scss index 4f84d0ae9..74be4014d 100644 --- a/packages/react/src/components/Modal/index.scss +++ b/packages/react/src/components/Modal/index.scss @@ -77,6 +77,7 @@ display: flex; align-items: center; gap: var(--c--globals--spacings--xs); + margin-top: 0; margin-bottom: var(--c--globals--spacings--base); color: var(--c--contextuals--content--semantic--neutral--primary); font-size: var(--font-size); @@ -104,6 +105,7 @@ font-style: normal; font-weight: 400; line-height: var(--line-height-sm, 18px); + margin: 0; } &__title-icon { @@ -295,6 +297,7 @@ color: var(--c--contextuals--content--semantic--neutral--primary); padding: 0 var(--c--globals--spacings--xs); line-height: 24px; + margin: 0; } &__nav { diff --git a/packages/react/src/components/Modal/index.spec.tsx b/packages/react/src/components/Modal/index.spec.tsx index 003bc44d0..bd2e6bdda 100644 --- a/packages/react/src/components/Modal/index.spec.tsx +++ b/packages/react/src/components/Modal/index.spec.tsx @@ -69,6 +69,63 @@ describe("", () => { expect(screen.getByText("Modal Content")).toBeInTheDocument(); expect(app).toHaveAttribute("aria-hidden", "true"); }); + it("has aria-labelledby pointing to the title heading", async () => { + const Wrapper = () => { + const modal = useModal({ isOpenDefault: true }); + return ( + + +
Modal Content
+
+
+ ); + }; + render(); + + const dialog = await screen.findByRole("dialog"); + const heading = screen.getByRole("heading", { name: "My Dialog Title" }); + expect(heading.tagName).toBe("H2"); + expect(dialog).toHaveAttribute("aria-labelledby", heading.id); + }); + it("has aria-describedby pointing to the subtitle", async () => { + const Wrapper = () => { + const modal = useModal({ isOpenDefault: true }); + return ( + + +
Content
+
+
+ ); + }; + render(); + + const dialog = await screen.findByRole("dialog"); + const subtitle = screen.getByText("Description text"); + expect(dialog).toHaveAttribute("aria-describedby", subtitle.id); + }); + it("falls back to aria-label when there is no visible title", async () => { + const Wrapper = () => { + const modal = useModal({ isOpenDefault: true }); + return ( + + +
Content
+
+
+ ); + }; + render(); + + const dialog = await screen.findByRole("dialog"); + expect(dialog).toHaveAttribute("aria-label", "Custom label"); + expect(dialog).not.toHaveAttribute("aria-labelledby"); + }); it("use modalParentSelector to change the modal portal", async () => { const Wrapper = () => { const modal = useModal(); @@ -128,7 +185,7 @@ describe("", () => { expect(screen.getByText("Modal Content")).toBeInTheDocument(); const closeButton = screen.getByRole("button", { - name: "close", + name: "Close", }); await user.click(closeButton); expect(screen.queryByText("Modal Content")).not.toBeInTheDocument(); @@ -155,7 +212,7 @@ describe("", () => { expect(screen.getByText("Modal Content")).toBeInTheDocument(); const closeButton = screen.queryByRole("button", { - name: "close", + name: "Close", }); expect(closeButton).not.toBeInTheDocument(); }); @@ -284,7 +341,7 @@ describe("", () => { expect(screen.getByText("Modal Content")).toBeInTheDocument(); const closeButton = screen.queryByRole("button", { - name: "close", + name: "Close", }); expect(closeButton).not.toBeInTheDocument(); }); @@ -357,7 +414,7 @@ describe("", () => { // Close the modal. const closeButton = screen.getByRole("button", { - name: "close", + name: "Close", }); await user.click(closeButton); @@ -436,7 +493,7 @@ describe("", () => { // Close the modal. const closeButton = screen.getByRole("button", { - name: "close", + name: "Close", }); await user.click(closeButton); @@ -522,7 +579,7 @@ describe("", () => { // Close the modal. const closeButton = screen.getByRole("button", { - name: "close", + name: "Close", }); await user.click(closeButton); @@ -556,7 +613,7 @@ describe("", () => { expect(document.body.classList.contains(NOSCROLL_CLASS)).toBeTruthy(); const closeButton = screen.getByRole("button", { - name: "close", + name: "Close", }); await user.click(closeButton); expect(document.body.classList.contains(NOSCROLL_CLASS)).toBeFalsy(); @@ -602,7 +659,7 @@ describe("", () => { expect(document.body.classList.contains(NOSCROLL_CLASS)).toBeTruthy(); const closeButtons = screen.getAllByRole("button", { - name: "close", + name: "Close", }); expect(closeButtons).toHaveLength(3); @@ -811,6 +868,12 @@ describe("", () => { // Other tabs are not selected const secondTab = screen.getByRole("tab", { name: "Privacy" }); expect(secondTab).toHaveAttribute("aria-selected", "false"); + + // Dialog is labelled by the sidebar title heading + const dialog = screen.getByRole("dialog"); + const sidebarHeading = screen.getByRole("heading", { name: "Settings" }); + expect(sidebarHeading.tagName).toBe("H2"); + expect(dialog).toHaveAttribute("aria-labelledby", sidebarHeading.id); }); it("closes the tab modal when clicking the close button", async () => { diff --git a/packages/react/src/components/Modal/index.tsx b/packages/react/src/components/Modal/index.tsx index 2ac62d41b..fd9f42f02 100644 --- a/packages/react/src/components/Modal/index.tsx +++ b/packages/react/src/components/Modal/index.tsx @@ -1,4 +1,4 @@ -import React, { PropsWithChildren, ReactNode, useEffect } from "react"; +import React, { PropsWithChildren, ReactNode, useEffect, useId } from "react"; import classNames from "classnames"; import ReactModal from "react-modal"; import { NOSCROLL_CLASS, useModals } from ":/components/Modal/ModalProvider"; @@ -121,6 +121,9 @@ export const Modal = (props: ModalProps) => { export const ModalInner = (props: ModalProps) => { const { modalParentSelector } = useModals(); + const id = useId(); + const titleId = `${id}-modal-title`; + const subtitleId = `${id}-modal-subtitle`; const showCloseButton = !props.hideCloseButton && !props.preventClose; const closeOnEsc = props.closeOnEsc ?? true; const variant = props.variant ?? "default"; @@ -129,11 +132,23 @@ export const ModalInner = (props: ModalProps) => { return null; } - const contentLabel = - props["aria-label"] || - (variant === "tab" - ? (props as ModalTabVariantProps).sidebarTitle?.toString() - : (props as ModalDefaultVariantProps).title?.toString()); + const hasVisibleTitle = + variant === "tab" + ? !!(props as ModalTabVariantProps).sidebarTitle + : !!(props as ModalDefaultVariantProps).title; + + const hasSubtitle = + variant === "default" && !!(props as ModalDefaultVariantProps).subtitle; + + const ariaProps: Record = {}; + if (hasVisibleTitle) { + ariaProps.labelledby = titleId; + } + if (hasSubtitle) { + ariaProps.describedby = subtitleId; + } + + const contentLabel = !hasVisibleTitle ? props["aria-label"] : undefined; const constraintStyle: React.CSSProperties = {}; if (props.constraints?.minHeight !== undefined) @@ -161,16 +176,20 @@ export const ModalInner = (props: ModalProps) => { shouldCloseOnEsc={closeOnEsc} bodyOpenClassName={classNames("c__modals--opened", NOSCROLL_CLASS)} contentLabel={contentLabel} + aria={ariaProps} > {variant === "tab" ? ( ) : ( )} diff --git a/packages/react/src/components/Popover/index.tsx b/packages/react/src/components/Popover/index.tsx index 718a8e700..3a3eef3b1 100644 --- a/packages/react/src/components/Popover/index.tsx +++ b/packages/react/src/components/Popover/index.tsx @@ -12,6 +12,7 @@ export type PopoverProps = PropsWithChildren & { parentRef: RefObject; onClickOutside: () => void; borderless?: boolean; + "aria-label"?: string; }; export const Popover = ({ @@ -19,6 +20,7 @@ export const Popover = ({ children, onClickOutside, borderless = false, + "aria-label": ariaLabel, }: PopoverProps) => { const popoverRef = useRef(null); useHandleClickOutside(parentRef, onClickOutside); @@ -68,6 +70,7 @@ export const Popover = ({ top: topPosition, }} role="dialog" + aria-label={ariaLabel} > {children}
diff --git a/packages/react/src/locales/en-US.json b/packages/react/src/locales/en-US.json index 45bebc3a3..3cc75720b 100644 --- a/packages/react/src/locales/en-US.json +++ b/packages/react/src/locales/en-US.json @@ -52,7 +52,8 @@ "previous_month_button_aria_label": "Previous month", "previous_year_button_aria_label": "Previous year", "year_select_button_aria_label": "Select a year", - "month_select_button_aria_label": "Select a month" + "month_select_button_aria_label": "Select a month", + "calendar_popover_aria_label": "Choose a date" } }, "calendar": { @@ -61,6 +62,8 @@ "ok": "OK" }, "modals": { + "close_button_aria_label": "Close", + "back_to_tabs_aria_label": "Back to tabs", "helpers": { "delete_confirmation": { "title": "Are you sure?", diff --git a/packages/react/src/locales/fr-FR.json b/packages/react/src/locales/fr-FR.json index bc90e5c81..2e351107c 100644 --- a/packages/react/src/locales/fr-FR.json +++ b/packages/react/src/locales/fr-FR.json @@ -52,7 +52,8 @@ "previous_month_button_aria_label": "Mois précédent", "previous_year_button_aria_label": "Année précédente", "year_select_button_aria_label": "Sélectionner une année", - "month_select_button_aria_label": "Sélectionner un mois" + "month_select_button_aria_label": "Sélectionner un mois", + "calendar_popover_aria_label": "Choisir une date" } }, "calendar": { @@ -61,6 +62,8 @@ "ok": "OK" }, "modals": { + "close_button_aria_label": "Fermer", + "back_to_tabs_aria_label": "Retour aux onglets", "helpers": { "delete_confirmation": { "title": "Êtes-vous sûr ?",