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.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 ?",