diff --git a/apps/website/screens/components/dialog/code/DialogCodePage.tsx b/apps/website/screens/components/dialog/code/DialogCodePage.tsx
index e6dc84a8d5..45f4e3bf50 100644
--- a/apps/website/screens/components/dialog/code/DialogCodePage.tsx
+++ b/apps/website/screens/components/dialog/code/DialogCodePage.tsx
@@ -25,7 +25,7 @@ const sections = [
- | isCloseVisible |
+ closable |
boolean
|
diff --git a/apps/website/screens/components/dialog/code/examples/backgroundClick.ts b/apps/website/screens/components/dialog/code/examples/backgroundClick.ts
index 040cc7d544..c3cc1e3e18 100644
--- a/apps/website/screens/components/dialog/code/examples/backgroundClick.ts
+++ b/apps/website/screens/components/dialog/code/examples/backgroundClick.ts
@@ -11,7 +11,7 @@ const code = `() => {
{isDialogVisible && (
-
+
Please enter your personal information.
diff --git a/apps/website/screens/theme-generator/ImportDialog.tsx b/apps/website/screens/theme-generator/ImportDialog.tsx
index 3b117791f5..46434d6beb 100644
--- a/apps/website/screens/theme-generator/ImportDialog.tsx
+++ b/apps/website/screens/theme-generator/ImportDialog.tsx
@@ -53,7 +53,7 @@ const ImportDialog = ({ customThemeSchema, setCustomTheme, setDialogVisible }: I
};
return (
-
+
{
});
it("Should not have basic accessibility issues for close button not visible", async () => {
// baseElement is needed when using React Portals
- const { baseElement } = render(Dialog text);
+ const { baseElement } = render(Dialog text);
const results = await axe(baseElement);
expect(results).toHaveNoViolations();
});
diff --git a/packages/lib/src/dialog/Dialog.stories.tsx b/packages/lib/src/dialog/Dialog.stories.tsx
index db8b0501a8..182a345ed6 100644
--- a/packages/lib/src/dialog/Dialog.stories.tsx
+++ b/packages/lib/src/dialog/Dialog.stories.tsx
@@ -255,8 +255,7 @@ export const DialogWithInputs = () => (
semantic="error"
title="Error"
message={{
- text:
- "User: arn:aws:xxx::xxxxxxxxxxxx:assumed-role/assure-sandbox-xxxx-xxxxxxxxxxxxxxxxxxxxxxxxxx/sandbox-xxxx-xxxxxxxxxxxxxxxxxx is not authorized to perform: lambda:xxxxxxxxxxxxxx on resource: arn:aws:lambda:us-east-1:xxxxxxxxxxxx:function:sandbox-xxxx-xx-xxxxxxx-xxxxxxx-lambda because no identity-based policy allows the lambda:xxxxxxxxxxxxxx action",
+ text: "User: arn:aws:xxx::xxxxxxxxxxxx:assumed-role/assure-sandbox-xxxx-xxxxxxxxxxxxxxxxxxxxxxxxxx/sandbox-xxxx-xxxxxxxxxxxxxxxxxx is not authorized to perform: lambda:xxxxxxxxxxxxxx on resource: arn:aws:lambda:us-east-1:xxxxxxxxxxxx:function:sandbox-xxxx-xx-xxxxxxx-xxxxxxx-lambda because no identity-based policy allows the lambda:xxxxxxxxxxxxxx action",
}}
/>
@@ -317,7 +316,7 @@ export const DialogWithoutOverlay = () => (
export const DialogCloseVisibleFalse = () => (
-
+
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Morbi egestas luctus porttitor. Donec massa magna,
diff --git a/packages/lib/src/dialog/Dialog.test.tsx b/packages/lib/src/dialog/Dialog.test.tsx
index e0c89eb0a7..bc8587643d 100644
--- a/packages/lib/src/dialog/Dialog.test.tsx
+++ b/packages/lib/src/dialog/Dialog.test.tsx
@@ -12,7 +12,13 @@ import DxcSwitch from "../switch/Switch";
import DxcTextInput from "../text-input/TextInput";
import DxcTextarea from "../textarea/Textarea";
import DxcDialog from "./Dialog";
+import DxcTooltip from "../tooltip/Tooltip";
+import DxcAlert from "../alert/Alert";
+(global as any).globalThis = global;
+(global as any).DOMRect = {
+ fromRect: () => ({ top: 0, left: 0, bottom: 0, right: 0, width: 0, height: 0 }),
+};
(global as any).ResizeObserver = class ResizeObserver {
observe() {}
unobserve() {}
@@ -35,13 +41,13 @@ describe("Dialog component tests", () => {
});
test("Dialog renders without close button", () => {
- const { queryByRole } = render(dialog-text);
+ const { queryByRole } = render(dialog-text);
expect(queryByRole("button")).toBeFalsy();
});
test("Dialog renders with aria-modal false when overlay is not used", () => {
const { getByRole } = render(
-
+
dialog-text
);
@@ -264,7 +270,7 @@ describe("Dialog component: Focus lock tests", () => {
const { getAllByRole } = render(
<>
-
+
Policy agreement
Sample text.
@@ -280,4 +286,47 @@ describe("Dialog component: Focus lock tests", () => {
fireEvent.keyDown(dialog, { key: "Tab", shiftKey: true });
expect(document.activeElement).not.toEqual(inputs[0]);
});
+ test("Focus travels correctly in a complex tab sequence", async () => {
+ const { getAllByRole, queryByRole, getByRole } = render(
+
+
+
+
+
+
+
+
+
+
+ );
+ const select = getAllByRole("combobox")[0];
+ expect(document.activeElement).toEqual(select);
+ fireEvent.keyDown(select, { key: "ArrowDown", code: "ArrowDown", keyCode: 40, charCode: 40 });
+ expect(queryByRole("listbox")).toBeTruthy();
+ userEvent.tab();
+ userEvent.tab();
+ await userEvent.keyboard('{Enter}');
+ expect(getAllByRole("dialog")[1]).toBeTruthy();
+ userEvent.click(getAllByRole("dialog")[0]);
+ userEvent.tab();
+ userEvent.tab();
+ userEvent.tab();
+ userEvent.tab();
+ expect(document.activeElement).toEqual(getByRole("button", { name: "Close alert" }));
+ userEvent.tab();
+ userEvent.tab();
+ expect(document.activeElement).toEqual(getByRole("button", { name: "Save" }));
+ userEvent.tab();
+ userEvent.tab();
+ expect(document.activeElement).toEqual(select);
+ userEvent.tab({ shift: true });
+ userEvent.tab({ shift: true });
+ expect(getByRole("button", { name: "Save" })).toBeTruthy();
+ });
});
diff --git a/packages/lib/src/dialog/Dialog.tsx b/packages/lib/src/dialog/Dialog.tsx
index bc217cb52c..3b9ef756a3 100644
--- a/packages/lib/src/dialog/Dialog.tsx
+++ b/packages/lib/src/dialog/Dialog.tsx
@@ -8,66 +8,6 @@ import useTranslatedLabels from "../useTranslatedLabels";
import FocusLock from "../utils/FocusLock";
import DialogPropsType from "./types";
-const DxcDialog = ({
- isCloseVisible = true,
- onCloseClick,
- children,
- overlay = true,
- onBackgroundClick,
- tabIndex = 0,
-}: DialogPropsType): JSX.Element => {
- const colorsTheme = useTheme();
- const translatedLabels = useTranslatedLabels();
-
- useEffect(() => {
- const handleKeyDown = (event: KeyboardEvent) => {
- if (event.key === "Escape") {
- event.preventDefault();
- onCloseClick?.();
- }
- };
-
- document.addEventListener("keydown", handleKeyDown);
- return () => {
- document.removeEventListener("keydown", handleKeyDown);
- };
- }, [onCloseClick]);
-
- return (
-
-
- {createPortal(
-
- {overlay && (
- {
- onBackgroundClick?.();
- }}
- />
- )}
-
- ,
- document.body
- )}
-
- );
-};
-
const BodyStyle = createGlobalStyle`
body {
overflow: hidden;
@@ -91,14 +31,14 @@ const Overlay = styled.div`
background-color: ${(props) => props.theme.overlayColor};
`;
-const Dialog = styled.div<{ isCloseVisible: DialogPropsType["isCloseVisible"] }>`
+const Dialog = styled.div<{ closable: DialogPropsType["closable"] }>`
position: relative;
box-sizing: border-box;
max-width: 80%;
min-width: 696px;
border-radius: 4px;
background-color: ${(props) => props.theme.backgroundColor};
- ${(props) => props.isCloseVisible && "min-height: 72px;"}
+ ${(props) => props.closable && "min-height: 72px;"}
box-shadow: ${(props) =>
`${props.theme.boxShadowOffsetX} ${props.theme.boxShadowOffsetY} ${props.theme.boxShadowBlur} ${props.theme.boxShadowColor}`};
z-index: 2147483647;
@@ -142,4 +82,64 @@ const CloseIconAction = styled.button`
}
`;
+const DxcDialog = ({
+ closable = true,
+ onCloseClick,
+ children,
+ overlay = true,
+ onBackgroundClick,
+ tabIndex = 0,
+}: DialogPropsType): JSX.Element => {
+ const colorsTheme = useTheme();
+ const translatedLabels = useTranslatedLabels();
+
+ useEffect(() => {
+ const handleKeyDown = (event: KeyboardEvent) => {
+ if (event.key === "Escape") {
+ event.preventDefault();
+ onCloseClick?.();
+ }
+ };
+
+ document.addEventListener("keydown", handleKeyDown);
+ return () => {
+ document.removeEventListener("keydown", handleKeyDown);
+ };
+ }, [onCloseClick]);
+
+ return (
+
+
+ {createPortal(
+
+ {overlay && (
+ {
+ onBackgroundClick?.();
+ }}
+ />
+ )}
+
+ ,
+ document.body
+ )}
+
+ );
+};
+
export default DxcDialog;
diff --git a/packages/lib/src/dialog/types.ts b/packages/lib/src/dialog/types.ts
index 238ecc4dea..8884d413d2 100644
--- a/packages/lib/src/dialog/types.ts
+++ b/packages/lib/src/dialog/types.ts
@@ -2,7 +2,7 @@ type Props = {
/**
* If true, the close button will be visible.
*/
- isCloseVisible?: boolean;
+ closable?: boolean;
/**
* This function will be called when the user clicks the close button.
* The responsibility of hiding the dialog lies with the user.
diff --git a/packages/lib/src/utils/FocusLock.tsx b/packages/lib/src/utils/FocusLock.tsx
index 889510c5be..1026641a18 100644
--- a/packages/lib/src/utils/FocusLock.tsx
+++ b/packages/lib/src/utils/FocusLock.tsx
@@ -45,7 +45,7 @@ const attemptFocus = (element: HTMLElement): boolean => {
* @param element: HTMLElement
* @returns boolean: true if element is contained inside a Radix Portal, false otherwise.
*/
-const radixPortalContains = (activeElement: Element): boolean => {
+const radixPortalContains = (activeElement: Node): boolean => {
const radixPortals = document.querySelectorAll("[data-radix-portal]");
const radixPoppers = document.querySelectorAll("[data-radix-popper-content-wrapper]");
return (
@@ -69,7 +69,7 @@ const useFocusableElements = (ref: React.MutableRefObject): HTML
const observer = new MutationObserver(() => {
setFocusableElements(getFocusableElements(ref.current));
});
- observer.observe(ref.current, { childList: true, subtree: true, attributes: true });
+ observer.observe(ref.current, { childList: true, subtree: true });
return () => {
observer.disconnect();
};
@@ -90,6 +90,7 @@ const useFocusableElements = (ref: React.MutableRefObject): HTML
const FocusLock = ({ children }: { children: React.ReactNode }): JSX.Element => {
const childrenContainerRef = useRef();
const focusableElements = useFocusableElements(childrenContainerRef);
+ const initialFocus = useRef(false);
const focusFirst = useCallback(() => {
if (focusableElements?.length === 0) childrenContainerRef.current?.focus();
@@ -97,16 +98,44 @@ const FocusLock = ({ children }: { children: React.ReactNode }): JSX.Element =>
}, [focusableElements]);
const focusLast = () => {
- focusableElements?.reverse()?.some((element) => attemptFocus(element));
+ focusableElements
+ ?.slice()
+ .reverse()
+ ?.some((element) => attemptFocus(element));
};
const focusLock = (event: React.KeyboardEvent) => {
- if (event.key === "Tab") focusableElements.length === 0 && event.preventDefault();
+ if (event.key === "Tab" && focusableElements.length === 0) event.preventDefault();
};
useEffect(() => {
- if (!childrenContainerRef.current?.contains(document.activeElement) && !radixPortalContains(document.activeElement))
+ if (focusableElements !== undefined && !initialFocus.current) {
+ initialFocus.current = true;
focusFirst();
+ }
+ }, [focusableElements, focusFirst]);
+
+ useEffect(() => {
+ const focusGuardHandler = (event: FocusEvent) => {
+ const target = event.relatedTarget as Node | null;
+ const container = childrenContainerRef.current;
+
+ if (
+ target &&
+ !(
+ container?.contains(target) ||
+ container?.nextElementSibling?.contains(target) ||
+ container?.previousElementSibling?.contains(target) ||
+ radixPortalContains(target)
+ )
+ )
+ focusFirst();
+ };
+
+ document.addEventListener("focusout", focusGuardHandler);
+ return () => {
+ document.removeEventListener("focusout", focusGuardHandler);
+ };
}, [focusFirst]);
return (