From ac6e46c7d3126ff192ebb8af24c1806c7a108640 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Iv=C3=A1n=20G=C3=B3mez=20Pinta?=
<44321109+GomezIvann@users.noreply.github.com>
Date: Tue, 3 Dec 2024 13:04:20 +0100
Subject: [PATCH 1/4] Updates on the FocusLock & Dialog
---
.../components/dialog/code/DialogCodePage.tsx | 2 +-
.../dialog/code/examples/backgroundClick.ts | 2 +-
.../screens/theme-generator/ImportDialog.tsx | 2 +-
.../src/dialog/Dialog.accessibility.test.tsx | 2 +-
packages/lib/src/dialog/Dialog.stories.tsx | 10 +-
packages/lib/src/dialog/Dialog.test.tsx | 15 ++-
packages/lib/src/dialog/Dialog.tsx | 124 +++++++++---------
packages/lib/src/dialog/types.ts | 2 +-
packages/lib/src/utils/FocusLock.tsx | 11 +-
9 files changed, 90 insertions(+), 80 deletions(-)
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..7488e992b1 100644
--- a/packages/lib/src/dialog/Dialog.stories.tsx
+++ b/packages/lib/src/dialog/Dialog.stories.tsx
@@ -11,6 +11,7 @@ import DxcInset from "../inset/Inset";
import DxcParagraph from "../paragraph/Paragraph";
import DxcTextInput from "../text-input/TextInput";
import DxcDialog from "./Dialog";
+import DxcTooltip from "../tooltip/Tooltip";
export default {
title: "Dialog",
@@ -249,14 +250,15 @@ export const DialogWithInputs = () => (
-
+
+
+
@@ -317,7 +319,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..9b101a5317 100644
--- a/packages/lib/src/dialog/Dialog.test.tsx
+++ b/packages/lib/src/dialog/Dialog.test.tsx
@@ -12,6 +12,7 @@ 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";
(global as any).ResizeObserver = class ResizeObserver {
observe() {}
@@ -35,13 +36,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
);
@@ -215,13 +216,17 @@ describe("Dialog component: Focus lock tests", () => {
-
+
+
+
-
+
+
+
);
const textarea = getAllByRole("textbox")[2];
@@ -264,7 +269,7 @@ describe("Dialog component: Focus lock tests", () => {
const { getAllByRole } = render(
<>
-
+
Policy agreement
Sample text.
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..d0f1128c4d 100644
--- a/packages/lib/src/utils/FocusLock.tsx
+++ b/packages/lib/src/utils/FocusLock.tsx
@@ -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();
@@ -101,13 +102,15 @@ const FocusLock = ({ children }: { children: React.ReactNode }): JSX.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();
- }, [focusFirst]);
+ }
+ }, [focusableElements, focusFirst]);
return (
<>
From 682199ea07693b4c1e50cceadb89467eccabf310 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Iv=C3=A1n=20G=C3=B3mez=20Pinta?=
<44321109+GomezIvann@users.noreply.github.com>
Date: Tue, 3 Dec 2024 17:39:34 +0100
Subject: [PATCH 2/4] FocusLock updates & fixes
---
packages/lib/src/dialog/Dialog.stories.tsx | 38 +++++++--------
packages/lib/src/dialog/Dialog.test.tsx | 56 +++++++++++++++++++---
packages/lib/src/utils/FocusLock.tsx | 21 +++++++-
3 files changed, 85 insertions(+), 30 deletions(-)
diff --git a/packages/lib/src/dialog/Dialog.stories.tsx b/packages/lib/src/dialog/Dialog.stories.tsx
index 7488e992b1..9eba66dde8 100644
--- a/packages/lib/src/dialog/Dialog.stories.tsx
+++ b/packages/lib/src/dialog/Dialog.stories.tsx
@@ -11,6 +11,8 @@ import DxcInset from "../inset/Inset";
import DxcParagraph from "../paragraph/Paragraph";
import DxcTextInput from "../text-input/TextInput";
import DxcDialog from "./Dialog";
+import DxcDateInput from "../date-input/DateInput";
+import DxcSelect from "../select/Select";
import DxcTooltip from "../tooltip/Tooltip";
export default {
@@ -245,28 +247,20 @@ export const DialogWithInputs = () => (
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
);
diff --git a/packages/lib/src/dialog/Dialog.test.tsx b/packages/lib/src/dialog/Dialog.test.tsx
index 9b101a5317..bc8587643d 100644
--- a/packages/lib/src/dialog/Dialog.test.tsx
+++ b/packages/lib/src/dialog/Dialog.test.tsx
@@ -13,7 +13,12 @@ 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() {}
@@ -216,17 +221,13 @@ describe("Dialog component: Focus lock tests", () => {
-
-
-
+
-
-
-
+
);
const textarea = getAllByRole("textbox")[2];
@@ -285,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/utils/FocusLock.tsx b/packages/lib/src/utils/FocusLock.tsx
index d0f1128c4d..56afc7acc4 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 (
@@ -98,7 +98,7 @@ 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) => {
@@ -112,6 +112,23 @@ const FocusLock = ({ children }: { children: React.ReactNode }): JSX.Element =>
}
}, [focusableElements, focusFirst]);
+ useEffect(() => {
+ const focusGuardHandler = (event: FocusEvent) => {
+ if (
+ !childrenContainerRef.current?.contains(event.relatedTarget as Node) &&
+ !childrenContainerRef.current?.nextElementSibling?.contains(event.relatedTarget as Node) &&
+ !childrenContainerRef.current?.previousElementSibling?.contains(event.relatedTarget as Node) &&
+ !radixPortalContains(event.relatedTarget as Node)
+ )
+ focusFirst();
+ };
+
+ document.addEventListener("focusout", focusGuardHandler);
+ return () => {
+ document.removeEventListener("focusout", focusGuardHandler);
+ };
+ }, [focusFirst]);
+
return (
<>
From 6f0a178be695a8df1430838bf2b0dd3918f29011 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Iv=C3=A1n=20G=C3=B3mez=20Pinta?=
<44321109+GomezIvann@users.noreply.github.com>
Date: Tue, 3 Dec 2024 17:42:22 +0100
Subject: [PATCH 3/4] Restored Dialog stories
---
packages/lib/src/dialog/Dialog.stories.tsx | 37 ++++++++++++----------
1 file changed, 20 insertions(+), 17 deletions(-)
diff --git a/packages/lib/src/dialog/Dialog.stories.tsx b/packages/lib/src/dialog/Dialog.stories.tsx
index 9eba66dde8..182a345ed6 100644
--- a/packages/lib/src/dialog/Dialog.stories.tsx
+++ b/packages/lib/src/dialog/Dialog.stories.tsx
@@ -11,9 +11,6 @@ import DxcInset from "../inset/Inset";
import DxcParagraph from "../paragraph/Paragraph";
import DxcTextInput from "../text-input/TextInput";
import DxcDialog from "./Dialog";
-import DxcDateInput from "../date-input/DateInput";
-import DxcSelect from "../select/Select";
-import DxcTooltip from "../tooltip/Tooltip";
export default {
title: "Dialog",
@@ -247,20 +244,26 @@ export const DialogWithInputs = () => (
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
);
From dbce55629459f7c40ccf1832fe506cbcc02cecbd Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Iv=C3=A1n=20G=C3=B3mez=20Pinta?=
<44321109+GomezIvann@users.noreply.github.com>
Date: Wed, 4 Dec 2024 13:57:41 +0100
Subject: [PATCH 4/4] Focus lock code updates
---
packages/lib/src/utils/FocusLock.tsx | 19 ++++++++++++++-----
1 file changed, 14 insertions(+), 5 deletions(-)
diff --git a/packages/lib/src/utils/FocusLock.tsx b/packages/lib/src/utils/FocusLock.tsx
index 56afc7acc4..1026641a18 100644
--- a/packages/lib/src/utils/FocusLock.tsx
+++ b/packages/lib/src/utils/FocusLock.tsx
@@ -98,7 +98,10 @@ const FocusLock = ({ children }: { children: React.ReactNode }): JSX.Element =>
}, [focusableElements]);
const focusLast = () => {
- focusableElements?.slice().reverse()?.some((element) => attemptFocus(element));
+ focusableElements
+ ?.slice()
+ .reverse()
+ ?.some((element) => attemptFocus(element));
};
const focusLock = (event: React.KeyboardEvent) => {
@@ -114,11 +117,17 @@ const FocusLock = ({ children }: { children: React.ReactNode }): JSX.Element =>
useEffect(() => {
const focusGuardHandler = (event: FocusEvent) => {
+ const target = event.relatedTarget as Node | null;
+ const container = childrenContainerRef.current;
+
if (
- !childrenContainerRef.current?.contains(event.relatedTarget as Node) &&
- !childrenContainerRef.current?.nextElementSibling?.contains(event.relatedTarget as Node) &&
- !childrenContainerRef.current?.previousElementSibling?.contains(event.relatedTarget as Node) &&
- !radixPortalContains(event.relatedTarget as Node)
+ target &&
+ !(
+ container?.contains(target) ||
+ container?.nextElementSibling?.contains(target) ||
+ container?.previousElementSibling?.contains(target) ||
+ radixPortalContains(target)
+ )
)
focusFirst();
};