Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/common-lizards-try.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@gouvfr-lasuite/cunningham-react": minor
---

Upgrading modal accessibility
Original file line number Diff line number Diff line change
Expand Up @@ -281,6 +281,9 @@ const DatePickerAux = ({
parentRef={pickerRef}
onClickOutside={pickerState.close}
borderless
aria-label={t(
"components.forms.date_picker.calendar_popover_aria_label",
)}
>
{calendar}
</Popover>
Expand Down
37 changes: 27 additions & 10 deletions packages/react/src/components/Modal/ModalDefaultLayout.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,23 @@
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";

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 (
Expand All @@ -22,20 +30,25 @@ export const ModalDefaultLayout = ({
{isCompact ? (
<div className="c__modal__header c__modal__header--compact">
{(props.titleIcon || props.title) && (
<div className="c__modal__title c__modal__title--compact">
<h2
id={titleId}
className="c__modal__title c__modal__title--compact"
>
{props.titleIcon && (
<div className="c__modal__title-icon">{props.titleIcon}</div>
<div className="c__modal__title-icon" aria-hidden="true">
{props.titleIcon}
</div>
)}
{props.title}
</div>
</h2>
)}
{showCloseButton && (
<Button
icon={<CloseIcon />}
variant="tertiary"
color="neutral"
size="small"
aria-label="close"
aria-label={t("components.modals.close_button_aria_label")}
onClick={props.onClose}
/>
)}
Expand All @@ -49,21 +62,25 @@ export const ModalDefaultLayout = ({
variant="tertiary"
color="neutral"
size="small"
aria-label="close"
aria-label={t("components.modals.close_button_aria_label")}
onClick={props.onClose}
/>
</div>
)}
{(props.titleIcon || props.title) && (
<div className="c__modal__title">
<h2 id={titleId} className="c__modal__title">
{props.titleIcon && (
<div className="c__modal__title-icon">{props.titleIcon}</div>
<div className="c__modal__title-icon" aria-hidden="true">
{props.titleIcon}
</div>
)}
{props.title}
</div>
</h2>
)}
{props.subtitle && (
<div className="c__modal__subtitle">{props.subtitle}</div>
<p id={subtitleId} className="c__modal__subtitle">
{props.subtitle}
</p>
)}
</>
)}
Expand Down
19 changes: 11 additions & 8 deletions packages/react/src/components/Modal/ModalTabLayout.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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,
);
Expand Down Expand Up @@ -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) {
Expand All @@ -67,9 +70,9 @@ export const ModalTabLayout = ({
<div className="c__modal__tab-sidebar">
<div className="c__modal__tab-sidebar__header">
{props.sidebarTitle && (
<div className="c__modal__tab-sidebar__title">
<h2 id={titleId} className="c__modal__tab-sidebar__title">
{props.sidebarTitle}
</div>
</h2>
)}
{showCloseButton && (
<div className="c__modal__tab-sidebar__close">
Expand All @@ -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}
/>
</div>
Expand Down Expand Up @@ -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")}
>
<ChevronLeftIcon />
</button>
<div className="c__modal__tab-content__title-group">
{currentTab?.title && (
<div className="c__modal__title">{currentTab.title}</div>
<h2 className="c__modal__title">{currentTab.title}</h2>
)}
</div>
{showCloseButton && (
Expand All @@ -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}
/>
</div>
Expand Down
3 changes: 3 additions & 0 deletions packages/react/src/components/Modal/index.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -104,6 +105,7 @@
font-style: normal;
font-weight: 400;
line-height: var(--line-height-sm, 18px);
margin: 0;
}

&__title-icon {
Expand Down Expand Up @@ -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 {
Expand Down
79 changes: 71 additions & 8 deletions packages/react/src/components/Modal/index.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,63 @@ describe("<Modal/>", () => {
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 (
<CunninghamProvider>
<Modal size={ModalSize.SMALL} title="My Dialog Title" {...modal}>
<div>Modal Content</div>
</Modal>
</CunninghamProvider>
);
};
render(<Wrapper />);

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 (
<CunninghamProvider>
<Modal
size={ModalSize.SMALL}
title="Title"
subtitle="Description text"
{...modal}
>
<div>Content</div>
</Modal>
</CunninghamProvider>
);
};
render(<Wrapper />);

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 (
<CunninghamProvider>
<Modal size={ModalSize.SMALL} aria-label="Custom label" {...modal}>
<div>Content</div>
</Modal>
</CunninghamProvider>
);
};
render(<Wrapper />);

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();
Expand Down Expand Up @@ -128,7 +185,7 @@ describe("<Modal/>", () => {
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();
Expand All @@ -155,7 +212,7 @@ describe("<Modal/>", () => {
expect(screen.getByText("Modal Content")).toBeInTheDocument();

const closeButton = screen.queryByRole("button", {
name: "close",
name: "Close",
});
expect(closeButton).not.toBeInTheDocument();
});
Expand Down Expand Up @@ -284,7 +341,7 @@ describe("<Modal/>", () => {
expect(screen.getByText("Modal Content")).toBeInTheDocument();

const closeButton = screen.queryByRole("button", {
name: "close",
name: "Close",
});
expect(closeButton).not.toBeInTheDocument();
});
Expand Down Expand Up @@ -357,7 +414,7 @@ describe("<Modal/>", () => {

// Close the modal.
const closeButton = screen.getByRole("button", {
name: "close",
name: "Close",
});
await user.click(closeButton);

Expand Down Expand Up @@ -436,7 +493,7 @@ describe("<Modal/>", () => {

// Close the modal.
const closeButton = screen.getByRole("button", {
name: "close",
name: "Close",
});
await user.click(closeButton);

Expand Down Expand Up @@ -522,7 +579,7 @@ describe("<Modal/>", () => {

// Close the modal.
const closeButton = screen.getByRole("button", {
name: "close",
name: "Close",
});
await user.click(closeButton);

Expand Down Expand Up @@ -556,7 +613,7 @@ describe("<Modal/>", () => {
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();
Expand Down Expand Up @@ -602,7 +659,7 @@ describe("<Modal/>", () => {
expect(document.body.classList.contains(NOSCROLL_CLASS)).toBeTruthy();

const closeButtons = screen.getAllByRole("button", {
name: "close",
name: "Close",
});
expect(closeButtons).toHaveLength(3);

Expand Down Expand Up @@ -811,6 +868,12 @@ describe("<Modal variant='tab' />", () => {
// 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 () => {
Expand Down
Loading
Loading