Skip to content

Commit

Permalink
fix(dialog, dialog-full-screen, sidebar): make overflowing content ta…
Browse files Browse the repository at this point in the history
…bbable

When any of the components' content containers overflow and contain no interactive
elements, the container will be added to the tabbing order and can be focused via
keyboard navigation to ensure their contents are accessible

fix #6999
  • Loading branch information
tomdavies73 committed Nov 1, 2024
1 parent cc39891 commit 0164a65
Show file tree
Hide file tree
Showing 19 changed files with 293 additions and 8 deletions.
8 changes: 8 additions & 0 deletions playwright/components/dialog-full-screen/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import type { Page } from "@playwright/test";
import DIALOG_FULL_SCREEN_CONTENT from "./locators";

const dialogFullScreenContent = (page: Page) => {
return page.locator(DIALOG_FULL_SCREEN_CONTENT);
};

export default dialogFullScreenContent;
3 changes: 3 additions & 0 deletions playwright/components/dialog-full-screen/locators.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
const DIALOG_FULL_SCREEN_CONTENT = '[data-role="dialog-full-screen-content"]';

export default DIALOG_FULL_SCREEN_CONTENT;
6 changes: 6 additions & 0 deletions playwright/components/dialog/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
DIALOG_SUBTITLE,
OPEN_PREVIEW,
DIALOG_ARIALABEL,
DIALOG_CONTENT,
} from "./locators";

// component preview locators
Expand All @@ -24,10 +25,15 @@ const dialogAriaLabel = (page: Page) => {
return page.locator(DIALOG_ARIALABEL);
};

const dialogContent = (page: Page) => {
return page.locator(DIALOG_CONTENT);
};

export {
alertDialogPreview,
dialogTitle,
dialogSubtitle,
openPreviewButton,
dialogAriaLabel,
dialogContent,
};
1 change: 1 addition & 0 deletions playwright/components/dialog/locators.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,4 @@ export const DIALOG_TITLE = '[data-element="title"]';
export const DIALOG_SUBTITLE = '[data-element="subtitle"]';
export const OPEN_PREVIEW = '[data-component="button"]';
export const DIALOG_ARIALABEL = "[aria-label]";
export const DIALOG_CONTENT = '[data-role="dialog-content"]';
8 changes: 7 additions & 1 deletion playwright/components/sidebar/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
import { Page } from "@playwright/test";
import { SIDEBAR_PREVIEW, SIDEBAR_COMPONENT } from "./locators";
import {
SIDEBAR_PREVIEW,
SIDEBAR_COMPONENT,
SIDEBAR_CONTENT,
} from "./locators";

// component preview locators
export const sidebarPreview = (page: Page) => page.locator(SIDEBAR_PREVIEW);

export const sidebarComponent = (page: Page) => page.locator(SIDEBAR_COMPONENT);

export const sidebarContent = (page: Page) => page.locator(SIDEBAR_CONTENT);
1 change: 1 addition & 0 deletions playwright/components/sidebar/locators.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
// component preview locators
export const SIDEBAR_PREVIEW = '[data-element="sidebar"]';
export const SIDEBAR_COMPONENT = '[data-component="sidebar"]';
export const SIDEBAR_CONTENT = '[data-role="sidebar-content"]';
4 changes: 4 additions & 0 deletions src/__internal__/focus-trap/focus-trap-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ type CustomRefObject<T> = {
const defaultFocusableSelectors =
'button:not([disabled]), [href], input:not([type="hidden"]):not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]';

const defaultScrollableSelectors =
'div[data-role="sidebar-content"], div[data-role="dialog-content"], div[data-role="dialog-full-screen-content"]';

const INTERVAL = 10;
const MAX_TIME = 100;

Expand Down Expand Up @@ -243,6 +246,7 @@ const trapFunction = (

export {
defaultFocusableSelectors,
defaultScrollableSelectors,
getNextElement,
setElementFocus,
onTabGuardFocus,
Expand Down
27 changes: 23 additions & 4 deletions src/__internal__/focus-trap/focus-trap.component.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import React, {

import {
defaultFocusableSelectors,
defaultScrollableSelectors,
setElementFocus,
onTabGuardFocus,
trapFunction,
Expand Down Expand Up @@ -145,11 +146,29 @@ const FocusTrap = ({
trapWrappers.forEach((ref) => {
// istanbul ignore else
if (ref.current) {
elements.push(
...Array.from(ref.current.querySelectorAll(selector)).filter(
(el) => Number((el as HTMLElement).tabIndex) !== -1,
),
const focusableElements = Array.from(
ref.current.querySelectorAll(selector),
).filter((el) => Number((el as HTMLElement).tabIndex) !== -1);

elements.push(...focusableElements);

const scrollableElements = Array.from(
ref.current.querySelectorAll(defaultScrollableSelectors),
).filter(
(el) =>
el.scrollHeight > el.clientHeight &&
(window.getComputedStyle(el).overflowY === "scroll" ||
window.getComputedStyle(el).overflowY === "auto"),
);

scrollableElements.forEach((el) => {
const focusableElementsInContainer = el.querySelectorAll(
defaultFocusableSelectors,
);
if (focusableElementsInContainer.length === 0) {
el.setAttribute("tabindex", "0");
}
});
}
});
return elements as HTMLElement[];
Expand Down
10 changes: 9 additions & 1 deletion src/components/dialog-full-screen/content.style.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import styled, { css } from "styled-components";
import { StyledForm, StyledFormContent } from "../form/form.style";
import addFocusStyling from "../../style/utils/add-focus-styling";

type StyledContentProps = {
hasHeader: boolean;
disableContentPadding?: boolean;
hasTitle?: boolean;
};

function computePadding() {
Expand All @@ -27,7 +29,13 @@ const StyledContent = styled.div<StyledContentProps>`
overflow-y: auto;
flex: 1;
width: 100%;
&:focus-visible {
margin: var(--spacing075);
${({ hasTitle }) =>
!hasTitle && "border-top-left-radius: var(--borderRadius200)"};
${addFocusStyling()}
}
${({ disableContentPadding }) =>
disableContentPadding ? "padding: 0" : computePadding()}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,7 @@ export const DialogFullScreen = ({
data-element="content"
data-role="dialog-full-screen-content"
ref={contentRef}
hasTitle={!!title}
disableContentPadding={disableContentPadding}
>
{children}
Expand Down
28 changes: 28 additions & 0 deletions src/components/dialog-full-screen/dialog-full-screen.pw.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import React from "react";
import { expect, test } from "@playwright/experimental-ct-react17";
import type { Page } from "@playwright/test";
import DialogFullScreen from ".";
import {
DialogFullScreenComponent,
NestedDialog,
Expand All @@ -25,6 +26,7 @@ import {
tooltipPreview,
getComponent,
} from "../../../playwright/components/index";
import dialogFullScreenContent from "../../../playwright/components/dialog-full-screen";
import {
continuePressingTAB,
continuePressingSHIFTTAB,
Expand Down Expand Up @@ -567,6 +569,32 @@ test.describe("render DialogFullScreen component and check properties", () => {
});
});

test.describe("when dialog full screen content is scrollable and has no interactive elements", () => {
test("should have the expected styling when the dialog full screen content is focused", async ({
mount,
page,
}) => {
await mount(
<DialogFullScreen open>
{Array.from({ length: 30 }, (_, i) => (
<p key={i}>Line {i + 1}</p>
))}
</DialogFullScreen>,
);
await page.keyboard.press("Tab");
await page.keyboard.press("Tab");
await expect(dialogFullScreenContent(page)).toBeFocused();
await expect(dialogFullScreenContent(page)).toHaveCSS(
"box-shadow",
"rgb(255, 188, 25) 0px 0px 0px 3px, rgba(0, 0, 0, 0.9) 0px 0px 0px 6px",
);
await expect(dialogFullScreenContent(page)).toHaveCSS(
"outline",
"rgba(0, 0, 0, 0) solid 3px",
);
});
});

test.describe("Accessibility for DialogFullScreen", () => {
// TODO: Skipped due to flaky focus behaviour. To review in FE-6428
test.skip("should check accessibility for default component", async ({
Expand Down
33 changes: 33 additions & 0 deletions src/components/dialog-full-screen/dialog-full-screen.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import StyledIconButton from "../icon-button/icon-button.style";
import { StyledHeader, StyledHeading } from "../heading/heading.style";
import Form from "../form";
import CarbonProvider from "../carbon-provider";
import Button from "../button";

const ControlledDialog = ({
onCancel,
Expand Down Expand Up @@ -115,6 +116,38 @@ test("when the focusFirstElement prop is passed, the corresponding element shoul
});
});

test("should add dialog content to the tabbing order when scrollable with no other interactive elements", async () => {
render(<DialogFullScreen open>test</DialogFullScreen>);

const container = screen.getByTestId("dialog-full-screen-content");
jest.spyOn(container, "clientHeight", "get").mockImplementation(() => 10000);
jest.spyOn(container, "scrollHeight", "get").mockImplementation(() => 15000);

jest.runAllTimers();

await waitFor(() => {
expect(container).toHaveAttribute("tabindex", "0");
});
});

test("should not add dialog content to the tabbing order when scrollable with other interactive elements", async () => {
render(
<DialogFullScreen open>
<Button>Test</Button>test
</DialogFullScreen>,
);

const container = screen.getByTestId("dialog-full-screen-content");
jest.spyOn(container, "clientHeight", "get").mockImplementation(() => 1000);
jest.spyOn(container, "scrollHeight", "get").mockImplementation(() => 1500);

jest.runAllTimers();

await waitFor(() => {
expect(container).not.toHaveAttribute("tabindex");
});
});

test("all dialog children are rendered", () => {
render(
<DialogFullScreen open>
Expand Down
1 change: 1 addition & 0 deletions src/components/dialog/dialog.component.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,7 @@ export const Dialog = forwardRef<DialogHandle, DialogProps>(
{closeIcon}
<StyledDialogContent
{...contentPadding}
hasTitle={!!title}
data-role="dialog-content"
tabIndex={-1}
>
Expand Down
59 changes: 59 additions & 0 deletions src/components/dialog/dialog.pw.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import React from "react";
import { expect, test } from "@playwright/experimental-ct-react17";

import Dialog from ".";
import {
DialogComponent,
DialogWithFirstFocusableElement,
Expand All @@ -21,6 +22,7 @@ import {
Responsive,
UsingHandle,
} from "./components.test-pw";
import { dialogContent } from "../../../playwright/components/dialog";
import { toastComponent } from "../../../playwright/components/toast";
import {
checkAccessibility,
Expand Down Expand Up @@ -586,6 +588,63 @@ test("Dialog should have rounded corners", async ({ mount, page }) => {
await expect(page.getByRole("dialog")).toHaveCSS("border-radius", "16px");
});

test.describe("when dialog content is scrollable and has no interactive elements", () => {
test("should have the expected styling when the dialog content is focused and a title is passed", async ({
mount,
page,
}) => {
await mount(
<Dialog title="Hello World" open>
{Array.from({ length: 30 }, (_, i) => (
<p key={i}>Line {i + 1}</p>
))}
</Dialog>,
);
await page.keyboard.press("Tab");
await page.keyboard.press("Tab");
await expect(dialogContent(page)).toBeFocused();
await expect(dialogContent(page)).toHaveCSS(
"box-shadow",
"rgb(255, 188, 25) 0px 0px 0px 3px, rgba(0, 0, 0, 0.9) 0px 0px 0px 6px",
);
await expect(dialogContent(page)).toHaveCSS(
"outline",
"rgba(0, 0, 0, 0) solid 3px",
);
});

test("should have the expected styling when the dialog content is focused and no title is passed", async ({
mount,
page,
}) => {
await mount(
<Dialog open>
{Array.from({ length: 30 }, (_, i) => (
<p key={i}>Line {i + 1}</p>
))}
</Dialog>,
);
await page.keyboard.press("Tab");
await page.keyboard.press("Tab");
await expect(dialogContent(page)).toBeFocused();
await expect(dialogContent(page)).toHaveCSS(
"box-shadow",
"rgb(255, 188, 25) 0px 0px 0px 3px, rgba(0, 0, 0, 0.9) 0px 0px 0px 6px",
);
await expect(dialogContent(page)).toHaveCSS(
"outline",
"rgba(0, 0, 0, 0) solid 3px",
);

const borderTopLeftRadius = await getStyle(
dialogContent(page),
"border-top-left-radius",
"focus-visible",
);
await expect(borderTopLeftRadius).toBe("16px");
});
});

// TODO: Skipped due to flaky focus behaviour. To review in FE-6428
test.skip("setting the topModalOverride prop should ensure the Dialog is rendered on top of any others", async ({
mount,
Expand Down
14 changes: 13 additions & 1 deletion src/components/dialog/dialog.style.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
StyledForm,
StyledFormFooter,
} from "../form/form.style";
import addFocusStyling from "../../style/utils/add-focus-styling";

const dialogSizes = {
auto: "fit-content",
Expand Down Expand Up @@ -120,13 +121,24 @@ const StyledDialogTitle = styled.div<StyledDialogTitleProps>`
}
`;

const StyledDialogContent = styled.div<ContentPaddingInterface>`
interface styledDialogContentProps extends ContentPaddingInterface {
hasTitle?: boolean;
}

const StyledDialogContent = styled.div<styledDialogContentProps>`
box-sizing: border-box;
display: block;
overflow-y: auto;
width: 100%;
flex-grow: 1;
&:focus-visible {
border-bottom-left-radius: var(--borderRadius200);
${({ hasTitle }) =>
!hasTitle && "border-top-left-radius: var(--borderRadius200)"};
${addFocusStyling()}
}
padding: 24px 32px 30px;
${paddingFn}
Expand Down
Loading

0 comments on commit 0164a65

Please sign in to comment.