Skip to content

Commit

Permalink
Merge pull request #7098 from Sage/FE-7005
Browse files Browse the repository at this point in the history
feat: add restoreFocusOnClose prop to modal based components
  • Loading branch information
tomdavies73 authored Dec 12, 2024
2 parents ab6ec24 + 9de948b commit cb7d407
Show file tree
Hide file tree
Showing 23 changed files with 490 additions and 11 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,17 @@ import guid from "../../__internal__/utils/helpers/guid";
import useLocale from "../../hooks/__internal__/useLocale";
import { Dt, Dd } from "../definition-list";
import Logger from "../../__internal__/utils/logger";
import { ModalProps } from "../modal";

let deprecateUncontrolledWarnTriggered = false;
export interface AdvancedColor {
label: string;
value: string;
}

export interface AdvancedColorPickerProps extends MarginProps {
export interface AdvancedColorPickerProps
extends MarginProps,
Pick<ModalProps, "restoreFocusOnClose"> {
/** Prop to specify the aria-describedby property of the component */
"aria-describedby"?: string;
/**
Expand Down Expand Up @@ -71,6 +74,7 @@ export const AdvancedColorPicker = ({
open = false,
role,
selectedColor,
restoreFocusOnClose = true,
...props
}: AdvancedColorPickerProps) => {
const [dialogOpen, setDialogOpen] = useState<boolean>();
Expand Down Expand Up @@ -251,6 +255,7 @@ export const AdvancedColorPicker = ({
bespokeFocusTrap={handleFocus}
focusFirstElement={selectedColorRef}
role={role}
restoreFocusOnClose={restoreFocusOnClose}
>
<StyledAdvancedColorPickerPreview
data-element="color-picker-preview"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,13 @@ Shows how the color picker can be used to select a color from a predefined set.

<Canvas of={AdvancedColorPickerStories.Default} />

### Preventing focus from being restored when AdvancedColorPicker closes

When the `restoreFocusOnClose` prop is `false`, focus will not be restored to the element that was focused before the `AdvancedColorPicker` was opened.
Focus can instead be programmatically applied to another element if appropriate.

<Canvas of={AdvancedColorPickerStories.RestoreFocusOnCloseStory} />

## Props

### AdvancedColorPicker
Expand Down
21 changes: 21 additions & 0 deletions src/components/advanced-color-picker/advanced-color-picker.pw.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,27 @@ test.describe("when focused", () => {
});
});

test("when AdvancedColorPicker is opened and then closed, with the `restoreFocusOnClose` prop passed as `false`, the call to action element should not be focused", async ({
mount,
page,
}) => {
await mount(
<AdvancedColorPickerCustom open={false} restoreFocusOnClose={false} />,
);

const initialCell = advancedColorPickerCell(page);
const dialog = page.getByRole("dialog");
await expect(initialCell).not.toBeFocused();
await expect(dialog).not.toBeVisible();

await initialCell.click();
await expect(dialog).toBeVisible();
const closeButton = page.getByLabel("Close");
await closeButton.click();
await expect(initialCell).not.toBeFocused();
await expect(dialog).not.toBeVisible();
});

test.describe("should render AdvancedColorPicker component and check functionality", () => {
(
[
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import React, { useState } from "react";
import React, { useState, useRef } from "react";
import { Meta, StoryObj } from "@storybook/react";
import { allModes } from "../../../.storybook/modes";
import isChromatic from "../../../.storybook/isChromatic";
import generateStyledSystemProps from "../../../.storybook/utils/styled-system-props";

import Box from "../box";
import AdvancedColorPicker from ".";
import Message from "../message";

const styledSystemProps = generateStyledSystemProps({
margin: true,
Expand Down Expand Up @@ -81,3 +82,61 @@ export const Default: Story = () => {
);
};
Default.storyName = "Default";

export const RestoreFocusOnCloseStory: Story = () => {
const [open, setOpen] = useState(false);
const [showMessage, setShowMessage] = useState(false);
const messageRef = useRef<HTMLDivElement>(null);

const [color, setColor] = useState("orchid");
const onChange = ({ target }: React.ChangeEvent<HTMLInputElement>) => {
setColor(target.value);
};
return (
<>
<AdvancedColorPicker
restoreFocusOnClose={false}
name="advancedPicker"
availableColors={[
{ value: "#FFFFFF", label: "white" },
{ value: "transparent", label: "transparent" },
{ value: "#000000", label: "black" },
{ value: "#A3CAF0", label: "blue" },
{ value: "#FD9BA3", label: "pink" },
{ value: "#B4AEEA", label: "purple" },
{ value: "#ECE6AF", label: "goldenrod" },
{ value: "#EBAEDE", label: "orchid" },
{ value: "#EBC7AE", label: "desert" },
{ value: "#AEECEB", label: "turquoise" },
{ value: "#AEECD6", label: "mint" },
]}
defaultColor="#EBAEDE"
selectedColor={color}
onChange={onChange}
onOpen={() => {
setOpen(!open);
setShowMessage(false);
}}
onClose={() => {
setOpen(false);
setShowMessage(true);
setTimeout(() => messageRef.current?.focus(), 1);
}}
onBlur={() => {}}
open={open}
mb={showMessage ? 5 : 0}
/>
{showMessage && (
<Message
ref={messageRef}
variant="error"
onDismiss={() => setShowMessage(false)}
>
Some custom message
</Message>
)}
</>
);
};
RestoreFocusOnCloseStory.storyName = "With Restore Focus Close";
RestoreFocusOnCloseStory.parameters = { chromatic: { disableSnapshot: true } };
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ export const DialogFullScreen = ({
focusableSelectors,
topModalOverride,
closeButtonDataProps,
restoreFocusOnClose = true,
...rest
}: DialogFullScreenProps) => {
const locale = useLocale();
Expand Down Expand Up @@ -164,6 +165,7 @@ export const DialogFullScreen = ({
onCancel={onCancel}
disableEscKey={disableEscKey}
topModalOverride={topModalOverride}
restoreFocusOnClose={restoreFocusOnClose}
{...componentTags}
>
<FocusTrap
Expand Down
7 changes: 7 additions & 0 deletions src/components/dialog-full-screen/dialog-full-screen.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,13 @@ to ensure behaviour is consistent across all browsers.

<Canvas of={DialogFullScreenStories.Default} />

### Preventing focus from being restored when DialogFullScreen closes

When the `restoreFocusOnClose` prop is `false`, focus will not be restored to the element that was focused before the `DialogFullScreen` was opened.
Focus can instead be programmatically applied to another element if appropriate.

<Canvas of={DialogFullScreenStories.RestoreFocusOnCloseStory} />

### With complex example

If you want to use more than one group of `Tabs` remember to put `selectedTabId` prop in every `Tabs` component
Expand Down
23 changes: 23 additions & 0 deletions src/components/dialog-full-screen/dialog-full-screen.pw.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -379,6 +379,29 @@ test.describe("render DialogFullScreen component and check properties", () => {
await expect(firstButton).toBeFocused();
});

test("when Dialog Full Screen is opened and then closed, with the `restoreFocusOnClose` prop passed as `false`, the call to action element should not be focused", async ({
mount,
page,
}) => {
await mount(
<DialogFullScreenComponent open={false} restoreFocusOnClose={false} />,
);

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).not.toBeFocused();
await expect(dialogFullScreen).not.toBeVisible();
});

test("should render component with autofocus disabled", async ({
mount,
page,
Expand Down
71 changes: 71 additions & 0 deletions src/components/dialog-full-screen/dialog-full-screen.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import Form from "../form";
import Textbox from "../textbox";
import Pill from "../pill";
import Drawer from "../drawer/drawer.component";
import Message from "../message";
import { Tabs, Tab } from "../tabs";
import useMediaQuery from "../../hooks/useMediaQuery";
import Link from "../link";
Expand Down Expand Up @@ -118,6 +119,76 @@ export const Default: Story = () => {
Default.storyName = "Default";
Default.parameters = { chromatic: { disableSnapshot: true } };

export const RestoreFocusOnCloseStory: Story = () => {
const [isOpen, setIsOpen] = useState(false);
const [showMessage, setShowMessage] = useState(false);
const messageRef = useRef<HTMLDivElement>(null);

return (
<>
<Button
onClick={() => {
setIsOpen(true);
setShowMessage(false);
}}
mb={showMessage ? 5 : 0}
>
Open DialogFullScreen
</Button>
{showMessage && (
<Message
ref={messageRef}
variant="error"
onDismiss={() => setShowMessage(false)}
>
Some custom message
</Message>
)}
<DialogFullScreen
open={isOpen}
onCancel={() => {
setIsOpen(false);
setShowMessage(true);
setTimeout(() => messageRef.current?.focus(), 1);
}}
title="Title"
subtitle="Subtitle"
restoreFocusOnClose={false}
>
<Form
stickyFooter
leftSideButtons={
<Button onClick={() => setIsOpen(false)}>Cancel</Button>
}
saveButton={
<Button buttonType="primary" type="submit">
Save
</Button>
}
>
<Typography>
This is an example of a dialog with a Form as content
</Typography>
<Textbox label="First Name" />
<Textbox label="Middle Name" />
<Textbox label="Surname" />
<Textbox label="Birth Place" />
<Textbox label="Favourite Colour" />
<Textbox label="Address" />
<Textbox label="First Name" />
<Textbox label="Middle Name" />
<Textbox label="Surname" />
<Textbox label="Birth Place" />
<Textbox label="Favourite Colour" />
<Textbox label="Address" />
</Form>
</DialogFullScreen>
</>
);
};
RestoreFocusOnCloseStory.storyName = "With Restore Focus On Close";
RestoreFocusOnCloseStory.parameters = { chromatic: { disableSnapshot: true } };

export const WithComplexExample: Story = () => {
const [isOpen, setIsOpen] = useState(defaultOpenState);
const [activeTab1, setActiveTab1] = useState("tab-1");
Expand Down
3 changes: 3 additions & 0 deletions src/components/dialog/components.test-pw.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -212,8 +212,10 @@ export const DialogComponentFocusableSelectors = (

export const DefaultStory = ({
open = defaultOpenState,
restoreFocusOnClose,
}: {
open?: boolean;
restoreFocusOnClose?: boolean;
}) => {
const [isOpen, setIsOpen] = useState(open);
return (
Expand All @@ -224,6 +226,7 @@ export const DefaultStory = ({
onCancel={() => setIsOpen(false)}
title="Title"
subtitle="Subtitle"
restoreFocusOnClose={restoreFocusOnClose}
>
<Form
stickyFooter
Expand Down
2 changes: 2 additions & 0 deletions src/components/dialog/dialog.component.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,7 @@ export const Dialog = forwardRef<DialogHandle, DialogProps>(
focusableContainers,
topModalOverride,
closeButtonDataProps,
restoreFocusOnClose = true,
...rest
},
ref,
Expand Down Expand Up @@ -218,6 +219,7 @@ export const Dialog = forwardRef<DialogHandle, DialogProps>(
disableClose={disableClose}
className={className ? `${className} carbon-dialog` : "carbon-dialog"}
topModalOverride={topModalOverride}
restoreFocusOnClose={restoreFocusOnClose}
>
<FocusTrap
autoFocus={!disableAutoFocus}
Expand Down
7 changes: 7 additions & 0 deletions src/components/dialog/dialog.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,13 @@ to ensure behaviour is consistent across all browsers.

<Canvas of={DialogStories.DefaultStory} />

### Preventing focus from being restored when Dialog closes

When the `restoreFocusOnClose` prop is `false`, focus will not be restored to the element that was focused before the `Dialog` was opened.
Focus can instead be programmatically applied to another element if appropriate.

<Canvas of={DialogStories.RestoreFocusOnCloseStory} />

### With Maximum Size

When the `size` prop is `"maximise"` the height and width of the `Dialog`'s modal extends to the majority of the viewport.
Expand Down
19 changes: 19 additions & 0 deletions src/components/dialog/dialog.pw.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -322,6 +322,25 @@ test.describe("Testing Dialog component properties", () => {
await expect(firstButton).toBeFocused();
});

test("when Dialog is opened and then closed, with the `restoreFocusOnClose` prop passed as `false`, the call to action element should not be focused", async ({
mount,
page,
}) => {
await mount(<DefaultStory restoreFocusOnClose={false} />);

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).not.toBeFocused();
await expect(dialog).not.toBeVisible();
});

test("when disableAutoFocus prop is passed, the first focusable element should not be focused", async ({
mount,
page,
Expand Down
Loading

0 comments on commit cb7d407

Please sign in to comment.