diff --git a/src/components/dialog-full-screen/components.test-pw.tsx b/src/components/dialog-full-screen/components.test-pw.tsx index e2c00e886b..5532a7e694 100644 --- a/src/components/dialog-full-screen/components.test-pw.tsx +++ b/src/components/dialog-full-screen/components.test-pw.tsx @@ -54,6 +54,93 @@ export const DialogFullScreenComponent = ({ ); }; +export const DialogFullScreenOpenComponent = ({ + children = "This is an example", + ...props +}: Partial) => { + const [isDialogFullScreenOpen, setIsDialogFullScreenOpen] = useState(true); + + return ( + <> + + setIsDialogFullScreenOpen(false)} + {...props} + > + {children} + + + ); +}; + +export const DialogFullScreenClosedComponent = ({ + children = "This is an example", + ...props +}: Partial) => { + const [isDialogFullScreenOpen, setIsDialogFullScreenOpen] = useState(false); + + return ( + <> + + setIsDialogFullScreenOpen(false)} + {...props} + > + {children} + + + ); +}; + +export const NestedDialogFullScreensWithCallsToAction = ({ + children = "This is an example", + ...props +}: Partial) => { + const [isFirstDialogOpen, setIsFirstDialogOpen] = useState(false); + const [isNestedDialogOpen, setIsNestedDialogOpen] = useState(false); + return ( + <> + + setIsFirstDialogOpen(false)} + {...props} + > + + setIsNestedDialogOpen(false)} + {...props} + > + + + + + ); +}; + export const NestedDialog = () => { const [mainDialogOpen, setMainDialogOpen] = React.useState(false); const [nestedDialogOpen, setNestedDialogOpen] = React.useState(false); 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 599dbd0b13..db6a91faaa 100644 --- a/src/components/dialog-full-screen/dialog-full-screen.pw.tsx +++ b/src/components/dialog-full-screen/dialog-full-screen.pw.tsx @@ -3,6 +3,9 @@ import { expect, test } from "@playwright/experimental-ct-react17"; import type { Page } from "@playwright/test"; import { DialogFullScreenComponent, + DialogFullScreenOpenComponent, + DialogFullScreenClosedComponent, + NestedDialogFullScreensWithCallsToAction, NestedDialog, MultipleDialogsInDifferentProviders, DialogFullScreenWithHeaderChildren, @@ -303,6 +306,82 @@ test.describe("render DialogFullScreen component and check properties", () => { ); }); + test("when Dialog Full Screen is opened and then closed, the call to action element should be focused", async ({ + mount, + page, + }) => { + await mount(); + + const button = page + .getByRole("button") + .filter({ hasText: "Open Dialog Full Screen" }); + const dialogFullScreen = page.getByRole("dialog"); + await expect(button).not.toBeFocused(); + await expect(dialogFullScreen).not.toBeVisible(); + + await button.click(); + await expect(dialogFullScreen).toBeVisible(); + const closeButton = page.getByLabel("Close"); + await closeButton.click(); + await expect(button).toBeFocused(); + await expect(dialogFullScreen).not.toBeVisible(); + }); + + test("when Dialog Full Screen is open on render, then closed, opened and then closed again, the call to action element should be focused", async ({ + mount, + page, + }) => { + await mount(); + + const dialogFullScreen = page.getByRole("dialog"); + await expect(dialogFullScreen).toBeVisible(); + const closeButton = page.getByLabel("Close"); + await closeButton.click(); + + const button = page + .getByRole("button") + .filter({ hasText: "Open Dialog Full Screen" }); + await expect(button).not.toBeFocused(); + await expect(dialogFullScreen).not.toBeVisible(); + + await button.click(); + await expect(dialogFullScreen).toBeVisible(); + await closeButton.click(); + await expect(button).toBeFocused(); + }); + + test("when nested Dialog's are open/closed their respective call to action elements should be focused correctly", async ({ + mount, + page, + }) => { + await mount(); + + const firstButton = page + .getByRole("button") + .filter({ hasText: "Open First Dialog Full Screen" }); + const firstDialog = page.getByRole("dialog").first(); + await expect(firstButton).not.toBeFocused(); + await expect(firstDialog).not.toBeVisible(); + + await firstButton.click(); + await expect(firstDialog).toBeVisible(); + const secondButton = page + .getByRole("button") + .filter({ hasText: "Open Nested Dialog Full Screen" }); + await expect(secondButton).not.toBeFocused(); + await secondButton.click(); + const secondDialog = page.getByRole("dialog").last(); + await expect(secondDialog).toBeVisible(); + + const secondCloseButton = page.getByLabel("Close").last(); + await secondCloseButton.click(); + await expect(secondButton).toBeFocused(); + + const firstCloseButton = page.getByLabel("Close").first(); + await firstCloseButton.click(); + await expect(firstButton).toBeFocused(); + }); + test("should render component with autofocus disabled", async ({ mount, page, diff --git a/src/components/dialog/components.test-pw.tsx b/src/components/dialog/components.test-pw.tsx index b5f592a384..8a7f697fe3 100644 --- a/src/components/dialog/components.test-pw.tsx +++ b/src/components/dialog/components.test-pw.tsx @@ -211,7 +211,51 @@ export const DialogComponentFocusableSelectors = ( }; export const DefaultStory = () => { - const [isOpen, setIsOpen] = useState(defaultOpenState); + const [isOpen, setIsOpen] = useState(false); + return ( + <> + + setIsOpen(false)} + title="Title" + subtitle="Subtitle" + > +
setIsOpen(false)}>Cancel + } + saveButton={ + + } + > + + This is an example of a dialog with a Form as content + + + + + + + + + + + + + + +
+ + ); +}; + +export const DefaultOpenStory = () => { + const [isOpen, setIsOpen] = useState(true); return ( <> @@ -254,6 +298,35 @@ export const DefaultStory = () => { ); }; +export const NestedDialogsWithCallsToAction = () => { + const [isFirstDialogOpen, setIsFirstDialogOpen] = useState(false); + const [isNestedDialogOpen, setIsNestedDialogOpen] = useState(false); + + return ( + <> + + setIsFirstDialogOpen(false)} + title="First Dialog" + > + + setIsNestedDialogOpen(false)} + title="Nested Dialog" + > + + + + + ); +}; + export const Editable = () => { const [isOpen, setIsOpen] = useState(defaultOpenState); const [isDisabled, setIsDisabled] = useState(true); diff --git a/src/components/dialog/dialog.pw.tsx b/src/components/dialog/dialog.pw.tsx index db9c302e6a..8b5600019f 100644 --- a/src/components/dialog/dialog.pw.tsx +++ b/src/components/dialog/dialog.pw.tsx @@ -8,9 +8,11 @@ import { DialogBackgroundScrollTest, DialogWithOpenToastsBackgroundScrollTest, TopModalOverride, + NestedDialogsWithCallsToAction, DialogWithAutoFocusSelect, DialogComponentFocusableSelectors, DefaultStory, + DefaultOpenStory, Editable, WithHelp, LoadingContent, @@ -249,6 +251,78 @@ test.describe("Testing Dialog component properties", () => { ).toBeFocused(); }); + test("when Dialog is opened and then closed, the call to action element should be focused", async ({ + mount, + page, + }) => { + await mount(); + + const button = page.getByRole("button").filter({ hasText: "Open Dialog" }); + const dialog = page.getByRole("dialog"); + await expect(button).not.toBeFocused(); + await expect(dialog).not.toBeVisible(); + + await button.click(); + await expect(dialog).toBeVisible(); + const closeButton = page.getByLabel("Close"); + await closeButton.click(); + await expect(button).toBeFocused(); + await expect(dialog).not.toBeVisible(); + }); + + test("when Dialog is open on render, then closed, opened and then closed again, the call to action element should be focused", async ({ + mount, + page, + }) => { + await mount(); + + const dialog = page.getByRole("dialog"); + await expect(dialog).toBeVisible(); + const closeButton = page.getByLabel("Close"); + await closeButton.click(); + + const button = page.getByRole("button").filter({ hasText: "Open Dialog" }); + await expect(button).not.toBeFocused(); + await expect(dialog).not.toBeVisible(); + + await button.click(); + await expect(dialog).toBeVisible(); + await closeButton.click(); + await expect(button).toBeFocused(); + }); + + test("when nested Dialog's are open/closed their respective call to action elements should be focused correctly", async ({ + mount, + page, + }) => { + await mount(); + + const firstButton = page + .getByRole("button") + .filter({ hasText: "Open First Dialog" }); + const firstDialog = page.getByRole("dialog").first(); + await expect(firstButton).not.toBeFocused(); + await expect(firstDialog).not.toBeVisible(); + + await firstButton.click(); + await expect(firstDialog).toBeVisible(); + const secondButton = page + .getByRole("button") + .filter({ hasText: "Open Nested Dialog" }); + await expect(secondButton).not.toBeFocused(); + await secondButton.click(); + const secondDialog = page.getByRole("dialog").last(); + await expect(secondDialog).toBeVisible(); + + const secondCloseButton = page.getByLabel("Close").last(); + await secondCloseButton.click(); + await expect(secondButton).toBeFocused(); + + const firstCloseButton = page.getByLabel("Close").first(); + await firstCloseButton.click(); + await expect(firstButton).toBeFocused(); + }); + test("when disableAutoFocus prop is passed, the first focusable element should not be focused", async ({ mount, page, diff --git a/src/components/dialog/dialog.stories.tsx b/src/components/dialog/dialog.stories.tsx index 69b0463950..4f6a6ce768 100644 --- a/src/components/dialog/dialog.stories.tsx +++ b/src/components/dialog/dialog.stories.tsx @@ -53,44 +53,28 @@ export default meta; type Story = StoryObj; export const DefaultStory: Story = () => { - const [isOpen, setIsOpen] = useState(defaultOpenState); + const [isFirstDialogOpen, setIsFirstDialogOpen] = useState(false); + const [isNestedDialogOpen, setIsNestedDialogOpen] = useState(false); return ( <> - + setIsOpen(false)} - title="Title" - subtitle="Subtitle" + open={isFirstDialogOpen} + onCancel={() => setIsFirstDialogOpen(false)} + title="First Dialog" > -
setIsOpen(false)}>Cancel - } - saveButton={ - - } + + setIsNestedDialogOpen(false)} + title="Nested Dialog" > - - This is an example of a dialog with a Form as content - - - - - - - - - - - - - - + +
); diff --git a/src/components/menu/component.test-pw.tsx b/src/components/menu/component.test-pw.tsx index 201de941f4..60ab42fbd8 100644 --- a/src/components/menu/component.test-pw.tsx +++ b/src/components/menu/component.test-pw.tsx @@ -304,6 +304,92 @@ export const MenuComponentFullScreen = ( ); }; +export const MenuComponentFullScreenOpen = ( + props: Partial +) => { + const [menuOpen, setMenuOpen] = useState({ + light: true, + dark: false, + white: false, + black: false, + }); + const fullscreenViewBreakPoint = useMediaQuery("(max-width: 1200px)"); + const responsiveMenuItems = ( + startPosition: "left" | "right", + menu: MenuType + ) => { + if (fullscreenViewBreakPoint) { + return [ + setMenuOpen((state) => ({ ...state, [menu]: true }))} + > + Menu + , + setMenuOpen((state) => ({ ...state, [menu]: false }))} + {...props} + > + Menu Item One + {}} submenu="Menu Item Two"> + Submenu Item One + Submenu Item Two + + Menu Item Three + Menu Item Four + + Submenu Item One + Submenu Item Two + + Menu Item Six + , + ]; + } + return [ + + Menu Item One + , + + Submenu Item One + Submenu Item Two + , + + Menu Item Three + , + + Menu Item Four + , + + Submenu Item One + Submenu Item Two + , + + Menu Item Six + , + ]; + }; + return ( + + {menuTypes.map((menuType) => ( +
+ + {menuType} + + + {React.Children.map( + responsiveMenuItems("left", menuType), + (items) => items + )} + +
+ ))} +
+ ); +}; + export const MenuComponentFullScreenWithLongSubmenuText = ( props: Partial ) => { diff --git a/src/components/menu/menu-full-screen/menu-full-screen.component.tsx b/src/components/menu/menu-full-screen/menu-full-screen.component.tsx index 34d1fbe3d8..c7d9a37030 100644 --- a/src/components/menu/menu-full-screen/menu-full-screen.component.tsx +++ b/src/components/menu/menu-full-screen/menu-full-screen.component.tsx @@ -91,6 +91,7 @@ export const MenuFullscreen = ({ closeModal, modalRef: menuRef, topModalOverride, + focusCallToActionElement: true, }); return ( diff --git a/src/components/menu/menu.pw.tsx b/src/components/menu/menu.pw.tsx index d00b510e8d..42498c27bd 100644 --- a/src/components/menu/menu.pw.tsx +++ b/src/components/menu/menu.pw.tsx @@ -69,6 +69,7 @@ import { MenuComponentFullScreenWithLongSubmenuText, MenuItemWithPopoverContainerChild, SubmenuMaxWidth, + MenuComponentFullScreenOpen, } from "./component.test-pw"; import { NavigationBarWithSubmenuAndChangingHeight } from "../navigation-bar/navigation-bar-test.stories"; import { HooksConfig } from "../../../playwright"; @@ -1154,6 +1155,45 @@ test.describe("Prop tests for Menu component", () => { } ); + test("when a Menu Full Screen is opened and then closed, the call to action element should be focused", async ({ + mount, + page, + }) => { + await mount(); + + await page.setViewportSize({ width: 1200, height: 800 }); + const item = page.getByRole("button").filter({ hasText: "Menu" }).first(); + await item.click(); + const fullscreen = getComponent(page, "menu-fullscreen").first(); + await waitForAnimationEnd(fullscreen); + const closeButton = page.getByLabel("Close"); + await closeButton.click(); + await expect(item).toBeFocused(); + }); + + test("when Menu Full Screen is open on render, then closed, opened and then closed again, the call to action element should be focused", async ({ + mount, + page, + }) => { + await mount(); + + await page.setViewportSize({ width: 1200, height: 800 }); + const fullscreen = getComponent(page, "menu-fullscreen").first(); + await waitForAnimationEnd(fullscreen); + await expect(fullscreen).toBeVisible(); + const closeButton = page.getByLabel("Close").first(); + await closeButton.click(); + + const item = page.getByRole("button").filter({ hasText: "Menu" }).first(); + await expect(item).not.toBeFocused(); + await expect(fullscreen).not.toBeVisible(); + + await item.click(); + await expect(fullscreen).toBeVisible(); + await closeButton.click(); + await expect(item).toBeFocused(); + }); + // TODO: Skipped due to flaky focus behaviour. To review in FE-6428 test.skip(`should verify that inner Menu without link is NOT available with tabbing in Fullscreen Menu`, async ({ mount, diff --git a/src/components/modal/modal.component.tsx b/src/components/modal/modal.component.tsx index 4e0447a113..8cebe843aa 100644 --- a/src/components/modal/modal.component.tsx +++ b/src/components/modal/modal.component.tsx @@ -89,6 +89,7 @@ const Modal = ({ modalRef: ref, setTriggerRefocusFlag, topModalOverride, + focusCallToActionElement: true, }); let background; diff --git a/src/components/sidebar/components.test-pw.tsx b/src/components/sidebar/components.test-pw.tsx index 6d2742f4bc..fba2c7a774 100644 --- a/src/components/sidebar/components.test-pw.tsx +++ b/src/components/sidebar/components.test-pw.tsx @@ -29,6 +29,63 @@ export const Default = (args: Partial) => { ); }; +export const DefaultClosed = (args: Partial) => { + const [isOpen, setIsOpen] = useState(false); + const onCancel = () => { + setIsOpen(false); + }; + return ( + <> + + + + + + + Main content + + + ); +}; + +export const NestedDSidebarsWithCallsToAction = ({ + children = "This is an example", + ...props +}: Partial) => { + const [isFirstSidebarOpen, setIsFirstSidebarOpen] = useState(false); + const [isNestedSidebarOpen, setIsNestedSidebarOpen] = useState(false); + return ( + <> + + setIsFirstSidebarOpen(false)} + {...props} + > + + setIsNestedSidebarOpen(false)} + {...props} + > + + + + + + + + ); +}; + export const SidebarComponent = (props: Partial) => { return ( <> diff --git a/src/components/sidebar/sidebar.pw.tsx b/src/components/sidebar/sidebar.pw.tsx index cacbecff4b..cc58c957cd 100644 --- a/src/components/sidebar/sidebar.pw.tsx +++ b/src/components/sidebar/sidebar.pw.tsx @@ -20,6 +20,9 @@ import { waitForAnimationEnd, } from "../../../playwright/support/helper"; import { + Default, + DefaultClosed, + NestedDSidebarsWithCallsToAction, SidebarBackgroundScrollTestComponent, SidebarBackgroundScrollWithOtherFocusableContainers, SidebarComponent, @@ -270,6 +273,78 @@ test.describe("Prop tests for Sidebar component", () => { await expect(closeIconButtonElement).toBeFocused(); }); + test("when Sidebar is opened and then closed, the call to action element should be focused", async ({ + mount, + page, + }) => { + await mount(); + + const button = page.getByRole("button").filter({ hasText: "Open sidebar" }); + const sidebar = sidebarPreview(page); + await expect(button).not.toBeFocused(); + await expect(sidebar).not.toBeVisible(); + + await button.click(); + await expect(sidebar).toBeVisible(); + const closeButton = page.getByLabel("Close"); + await closeButton.click(); + await expect(button).toBeFocused(); + await expect(sidebar).not.toBeVisible(); + }); + + test("when Sidebar is open on render, then closed, opened and then closed again, the call to action element should be focused", async ({ + mount, + page, + }) => { + await mount(); + + const sidebar = sidebarPreview(page); + await expect(sidebar).toBeVisible(); + const closeButton = page.getByLabel("Close"); + await closeButton.click(); + + const button = page.getByRole("button").filter({ hasText: "Open sidebar" }); + await expect(button).not.toBeFocused(); + await expect(sidebar).not.toBeVisible(); + + await button.click(); + await expect(sidebar).toBeVisible(); + await closeButton.click(); + await expect(button).toBeFocused(); + }); + + test("when nested Sidebar's are open/closed their respective call to action elements should be focused correctly", async ({ + mount, + page, + }) => { + await mount(); + + const firstButton = page + .getByRole("button") + .filter({ hasText: "Open First Sidebar" }); + const firstSidebar = sidebarPreview(page).first(); + await expect(firstButton).not.toBeFocused(); + await expect(firstSidebar).not.toBeVisible(); + + await firstButton.click(); + await expect(firstSidebar).toBeVisible(); + const secondButton = page + .getByRole("button") + .filter({ hasText: "Open Nested Sidebar" }); + await expect(secondButton).not.toBeFocused(); + await secondButton.click(); + const secondSidebar = sidebarPreview(page).last(); + await expect(secondSidebar).toBeVisible(); + + const secondCloseButton = page.getByLabel("Close").last(); + await secondCloseButton.click(); + await expect(secondButton).toBeFocused(); + + const firstCloseButton = page.getByLabel("Close").first(); + await firstCloseButton.click(); + await expect(firstButton).toBeFocused(); + }); + test("should call onCancel callback when a click event is triggered", async ({ mount, page, diff --git a/src/hooks/__internal__/useModalManager/useModalManager.ts b/src/hooks/__internal__/useModalManager/useModalManager.ts index f2be84ce41..544ff26ded 100644 --- a/src/hooks/__internal__/useModalManager/useModalManager.ts +++ b/src/hooks/__internal__/useModalManager/useModalManager.ts @@ -8,6 +8,7 @@ type UseModalManagerArgs = { setTriggerRefocusFlag?: (flag: boolean) => void; triggerRefocusOnClose?: boolean; topModalOverride?: boolean; + focusCallToActionElement?: boolean; }; const useModalManager = ({ @@ -17,9 +18,12 @@ const useModalManager = ({ setTriggerRefocusFlag, triggerRefocusOnClose = true, topModalOverride = false, + focusCallToActionElement = false, }: UseModalManagerArgs) => { const listenerAdded = useRef(false); const modalRegistered = useRef(false); + const lastFocusedElement = useRef(null); + const modalOpenOnRender = useRef(false); const handleClose = useCallback( (ev: KeyboardEvent) => { @@ -67,6 +71,13 @@ const useModalManager = ({ (ref: HTMLElement | null) => { /* istanbul ignore else */ if (!modalRegistered.current) { + if ( + !lastFocusedElement.current && + !modalOpenOnRender.current && + focusCallToActionElement + ) { + lastFocusedElement.current = document.activeElement as HTMLElement; + } ModalManager.addModal(ref, setTriggerRefocusFlag, topModalOverride); modalRegistered.current = true; @@ -80,17 +91,30 @@ const useModalManager = ({ if (modalRegistered.current) { ModalManager.removeModal(ref, triggerRefocusOnClose); + if (focusCallToActionElement) { + setTimeout(() => { + lastFocusedElement.current?.focus(); + }, 0); + } + modalRegistered.current = false; } }, [triggerRefocusOnClose] ); + useEffect(() => { + if (open) { + modalOpenOnRender.current = true; + } + }, []); + useEffect(() => { const ref = modalRef.current; if (open) { registerModal(ref); } else { + modalOpenOnRender.current = false; unregisterModal(ref); } }, [modalRef, open, registerModal, unregisterModal]);