diff --git a/playwright/components/dialog-full-screen/index.ts b/playwright/components/dialog-full-screen/index.ts new file mode 100644 index 0000000000..6e4eeb06b0 --- /dev/null +++ b/playwright/components/dialog-full-screen/index.ts @@ -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; diff --git a/playwright/components/dialog-full-screen/locators.ts b/playwright/components/dialog-full-screen/locators.ts new file mode 100644 index 0000000000..16f6c25932 --- /dev/null +++ b/playwright/components/dialog-full-screen/locators.ts @@ -0,0 +1,3 @@ +const DIALOG_FULL_SCREEN_CONTENT = '[data-role="dialog-full-screen-content"]'; + +export default DIALOG_FULL_SCREEN_CONTENT; diff --git a/playwright/components/dialog/index.ts b/playwright/components/dialog/index.ts index 5a9703b197..10c5beddf8 100644 --- a/playwright/components/dialog/index.ts +++ b/playwright/components/dialog/index.ts @@ -5,6 +5,7 @@ import { DIALOG_SUBTITLE, OPEN_PREVIEW, DIALOG_ARIALABEL, + DIALOG_CONTENT, } from "./locators"; // component preview locators @@ -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, }; diff --git a/playwright/components/dialog/locators.ts b/playwright/components/dialog/locators.ts index 421ff77b71..1456b31be2 100644 --- a/playwright/components/dialog/locators.ts +++ b/playwright/components/dialog/locators.ts @@ -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"]'; diff --git a/playwright/components/sidebar/index.ts b/playwright/components/sidebar/index.ts index 68abb8b713..2e0a5ce867 100644 --- a/playwright/components/sidebar/index.ts +++ b/playwright/components/sidebar/index.ts @@ -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); diff --git a/playwright/components/sidebar/locators.ts b/playwright/components/sidebar/locators.ts index 814dd6669a..ee46b23931 100644 --- a/playwright/components/sidebar/locators.ts +++ b/playwright/components/sidebar/locators.ts @@ -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"]'; diff --git a/src/__internal__/focus-trap/focus-trap-utils.ts b/src/__internal__/focus-trap/focus-trap-utils.ts index 7e26beea79..6edd547595 100644 --- a/src/__internal__/focus-trap/focus-trap-utils.ts +++ b/src/__internal__/focus-trap/focus-trap-utils.ts @@ -5,6 +5,9 @@ type CustomRefObject = { 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; @@ -243,6 +246,7 @@ const trapFunction = ( export { defaultFocusableSelectors, + defaultScrollableSelectors, getNextElement, setElementFocus, onTabGuardFocus, diff --git a/src/__internal__/focus-trap/focus-trap.component.tsx b/src/__internal__/focus-trap/focus-trap.component.tsx index 751d2e7998..42520505a2 100644 --- a/src/__internal__/focus-trap/focus-trap.component.tsx +++ b/src/__internal__/focus-trap/focus-trap.component.tsx @@ -9,6 +9,7 @@ import React, { import { defaultFocusableSelectors, + defaultScrollableSelectors, setElementFocus, onTabGuardFocus, trapFunction, @@ -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[]; diff --git a/src/components/dialog-full-screen/content.style.ts b/src/components/dialog-full-screen/content.style.ts index f824076f35..45b0ccf91c 100644 --- a/src/components/dialog-full-screen/content.style.ts +++ b/src/components/dialog-full-screen/content.style.ts @@ -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() { @@ -27,7 +29,13 @@ const StyledContent = styled.div` 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()} diff --git a/src/components/dialog-full-screen/dialog-full-screen.component.tsx b/src/components/dialog-full-screen/dialog-full-screen.component.tsx index a4eede2630..c8b73390c3 100644 --- a/src/components/dialog-full-screen/dialog-full-screen.component.tsx +++ b/src/components/dialog-full-screen/dialog-full-screen.component.tsx @@ -190,6 +190,7 @@ export const DialogFullScreen = ({ data-element="content" data-role="dialog-full-screen-content" ref={contentRef} + hasTitle={!!title} disableContentPadding={disableContentPadding} > {children} diff --git a/src/components/dialog-full-screen/dialog-full-screen.pw.tsx b/src/components/dialog-full-screen/dialog-full-screen.pw.tsx index 58ad019c8d..e27437a059 100644 --- a/src/components/dialog-full-screen/dialog-full-screen.pw.tsx +++ b/src/components/dialog-full-screen/dialog-full-screen.pw.tsx @@ -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, @@ -25,6 +26,7 @@ import { tooltipPreview, getComponent, } from "../../../playwright/components/index"; +import dialogFullScreenContent from "../../../playwright/components/dialog-full-screen"; import { continuePressingTAB, continuePressingSHIFTTAB, @@ -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( + + {Array.from({ length: 30 }, (_, i) => ( +

Line {i + 1}

+ ))} +
, + ); + 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 ({ diff --git a/src/components/dialog-full-screen/dialog-full-screen.test.tsx b/src/components/dialog-full-screen/dialog-full-screen.test.tsx index 600d93893d..69313cb00d 100644 --- a/src/components/dialog-full-screen/dialog-full-screen.test.tsx +++ b/src/components/dialog-full-screen/dialog-full-screen.test.tsx @@ -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, @@ -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(test); + + 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( + + test + , + ); + + 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( diff --git a/src/components/dialog/dialog.component.tsx b/src/components/dialog/dialog.component.tsx index 2869f68c5c..dc6ad52882 100644 --- a/src/components/dialog/dialog.component.tsx +++ b/src/components/dialog/dialog.component.tsx @@ -239,6 +239,7 @@ export const Dialog = forwardRef( {closeIcon} diff --git a/src/components/dialog/dialog.pw.tsx b/src/components/dialog/dialog.pw.tsx index 1f056ebf91..78e6affc9e 100644 --- a/src/components/dialog/dialog.pw.tsx +++ b/src/components/dialog/dialog.pw.tsx @@ -1,6 +1,7 @@ import React from "react"; import { expect, test } from "@playwright/experimental-ct-react17"; +import Dialog from "."; import { DialogComponent, DialogWithFirstFocusableElement, @@ -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, @@ -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( + + {Array.from({ length: 30 }, (_, i) => ( +

Line {i + 1}

+ ))} +
, + ); + 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( + + {Array.from({ length: 30 }, (_, i) => ( +

Line {i + 1}

+ ))} +
, + ); + 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, diff --git a/src/components/dialog/dialog.style.ts b/src/components/dialog/dialog.style.ts index 26df0180c3..0c14d72b05 100644 --- a/src/components/dialog/dialog.style.ts +++ b/src/components/dialog/dialog.style.ts @@ -13,6 +13,7 @@ import { StyledForm, StyledFormFooter, } from "../form/form.style"; +import addFocusStyling from "../../style/utils/add-focus-styling"; const dialogSizes = { auto: "fit-content", @@ -120,13 +121,24 @@ const StyledDialogTitle = styled.div` } `; -const StyledDialogContent = styled.div` +interface styledDialogContentProps extends ContentPaddingInterface { + hasTitle?: boolean; +} + +const StyledDialogContent = styled.div` 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} diff --git a/src/components/dialog/dialog.test.tsx b/src/components/dialog/dialog.test.tsx index 9c74f7165b..3487f8ef51 100644 --- a/src/components/dialog/dialog.test.tsx +++ b/src/components/dialog/dialog.test.tsx @@ -9,6 +9,7 @@ import userEvent from "@testing-library/user-event"; import CarbonProvider from "../carbon-provider"; import Dialog, { DialogHandle, DialogProps } from "."; +import Button from "../button"; beforeEach(() => jest.useFakeTimers()); afterEach(() => { @@ -258,6 +259,34 @@ test("first focusable element is not focused when disableAutoFocus prop is passe expect(button).not.toHaveFocus(); }); +test("should add dialog content to the tabbing order when scrollable with no other interactive elements", () => { + render(test); + + const container = screen.getByTestId("dialog-content"); + jest.spyOn(container, "clientHeight", "get").mockImplementation(() => 1000); + jest.spyOn(container, "scrollHeight", "get").mockImplementation(() => 1500); + + jest.runAllTimers(); + + expect(container).toHaveAttribute("tabindex", "0"); +}); + +test("should not add dialog content to the tabbing order when scrollable with other interactive elements", () => { + render( + + test + , + ); + + const container = screen.getByTestId("dialog-content"); + jest.spyOn(container, "clientHeight", "get").mockImplementation(() => 1000); + jest.spyOn(container, "scrollHeight", "get").mockImplementation(() => 1500); + + jest.runAllTimers(); + + expect(container).toHaveAttribute("tabindex", "-1"); +}); + test("height prop controls the dialog's height", () => { render(); expect(screen.getByRole("dialog")).toHaveStyle({ height: "500px" }); diff --git a/src/components/sidebar/sidebar.pw.tsx b/src/components/sidebar/sidebar.pw.tsx index ae3af18184..9f5f5a4baa 100644 --- a/src/components/sidebar/sidebar.pw.tsx +++ b/src/components/sidebar/sidebar.pw.tsx @@ -10,6 +10,7 @@ import { CLOSE_ICON_BUTTON } from "../../../playwright/components/locators"; import { sidebarComponent, sidebarPreview, + sidebarContent, } from "../../../playwright/components/sidebar"; import { CHARACTERS } from "../../../playwright/support/constants"; import { @@ -30,7 +31,7 @@ import { SidebarComponentWithOnCancel, TopModalOverride, } from "./components.test-pw"; -import { SidebarProps } from "./sidebar.component"; +import { Sidebar, SidebarProps } from "./sidebar.component"; import { SIDEBAR_SIZES, SIDEBAR_SIZES_CSS } from "./sidebar.config"; test.describe("Prop tests for Sidebar component", () => { @@ -388,6 +389,32 @@ test.describe("Prop tests for Sidebar component", () => { }); }); +test.describe("when sidebar content is scrollable and has no interactive elements", () => { + test("should have the expected styling when the sidebar content is focused", async ({ + mount, + page, + }) => { + await mount( + + {Array.from({ length: 30 }, (_, i) => ( +

Line {i + 1}

+ ))} +
, + ); + await page.keyboard.press("Tab"); + await page.keyboard.press("Tab"); + await expect(sidebarContent(page)).toBeFocused(); + await expect(sidebarContent(page)).toHaveCSS( + "box-shadow", + "rgb(255, 188, 25) 0px 0px 0px 3px, rgba(0, 0, 0, 0.9) 0px 0px 0px 6px", + ); + await expect(sidebarContent(page)).toHaveCSS( + "outline", + "rgba(0, 0, 0, 0) solid 3px", + ); + }); +}); + test.describe("Accessibility tests for Sidebar component", () => { test("should check accessibility for SidebarComponent example", async ({ mount, diff --git a/src/components/sidebar/sidebar.style.ts b/src/components/sidebar/sidebar.style.ts index 8cbcd25212..78578f516d 100644 --- a/src/components/sidebar/sidebar.style.ts +++ b/src/components/sidebar/sidebar.style.ts @@ -8,6 +8,7 @@ import StyledIconButton from "../icon-button/icon-button.style"; import { SIDEBAR_SIZES_CSS } from "./sidebar.config"; import { StyledForm, StyledFormContent } from "../form/form.style"; +import addFocusStyling from "../../style/utils/add-focus-styling"; type StyledSidebarProps = Pick< SidebarProps, @@ -61,6 +62,11 @@ const StyledSidebarContent = styled.div` overflow-y: auto; flex-grow: 1; + &:focus-visible { + margin: var(--spacing075); + ${addFocusStyling()} + } + padding: var(--spacing300) var(--spacing400) var(--spacing400); ${paddingFn} diff --git a/src/components/sidebar/sidebar.test.tsx b/src/components/sidebar/sidebar.test.tsx index 8c419b717f..2637e3348f 100644 --- a/src/components/sidebar/sidebar.test.tsx +++ b/src/components/sidebar/sidebar.test.tsx @@ -14,6 +14,7 @@ import { import CarbonProvider from "../carbon-provider"; import Sidebar, { SidebarProps } from "."; +import Button from "../button"; beforeEach(() => { jest.useFakeTimers(); @@ -244,6 +245,38 @@ test("focus is not trapped within sidebar when enableBackgroundUI is true", asyn expect(firstButton).not.toHaveFocus(); }); +test("should add sidebar content to the tabbing order when scrollable with no other interactive elements", async () => { + render(test); + + const container = screen.getByTestId("sidebar-content"); + jest.spyOn(container, "clientHeight", "get").mockImplementation(() => 1000); + jest.spyOn(container, "scrollHeight", "get").mockImplementation(() => 1500); + + jest.runAllTimers(); + + await waitFor(() => { + expect(container).toHaveAttribute("tabindex", "0"); + }); +}); + +test("should not add sidebar content to the tabbing order when scrollable with other interactive elements", async () => { + render( + + test + , + ); + + const container = screen.getByTestId("sidebar-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("can refocus sidebar container using a forwarded ref", async () => { const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime }); const MockApp = () => {