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 = () => ( - <DxcDialog isCloseVisible={false}> + <DxcDialog closable={false}> <DxcInset space="1.5rem"> <DxcParagraph> 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(<DxcDialog isCloseVisible={false}>dialog-text</DxcDialog>); + const { queryByRole } = render(<DxcDialog closable={false}>dialog-text</DxcDialog>); expect(queryByRole("button")).toBeFalsy(); }); test("Dialog renders with aria-modal false when overlay is not used", () => { const { getByRole } = render( - <DxcDialog isCloseVisible={false} overlay={false}> + <DxcDialog closable={false} overlay={false}> dialog-text </DxcDialog> ); @@ -264,7 +270,7 @@ describe("Dialog component: Focus lock tests", () => { const { getAllByRole } = render( <> <DxcTextInput label="Name" /> - <DxcDialog isCloseVisible={false}> + <DxcDialog closable={false}> <h2>Policy agreement</h2> <p>Sample text.</p> </DxcDialog> @@ -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( + <DxcDialog> + <DxcSelect label="Accept" options={options} /> + <DxcDateInput label="Older age" /> + <DxcTooltip label="Text input tooltip label"> + <DxcTextInput label="Name" /> + </DxcTooltip> + <DxcAlert + 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", + }} + /> + <DxcButton label="Cancel" /> + <DxcButton label="Save" /> + </DxcDialog> + ); + 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 ( - <ThemeProvider theme={colorsTheme.dialog}> - <BodyStyle /> - {createPortal( - <DialogContainer> - {overlay && ( - <Overlay - onClick={() => { - onBackgroundClick?.(); - }} - /> - )} - <Dialog role="dialog" aria-modal={overlay} isCloseVisible={isCloseVisible} aria-label="Dialog"> - <FocusLock> - {children} - {isCloseVisible && ( - <CloseIconAction - onClick={() => { - onCloseClick?.(); - }} - aria-label={translatedLabels.dialog.closeIconAriaLabel} - tabIndex={tabIndex} - > - <DxcIcon icon="close" /> - </CloseIconAction> - )} - </FocusLock> - </Dialog> - </DialogContainer>, - document.body - )} - </ThemeProvider> - ); -}; - 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 ( + <ThemeProvider theme={colorsTheme.dialog}> + <BodyStyle /> + {createPortal( + <DialogContainer> + {overlay && ( + <Overlay + onClick={() => { + onBackgroundClick?.(); + }} + /> + )} + <Dialog role="dialog" aria-modal={overlay} closable={closable} aria-label="Dialog"> + <FocusLock> + {children} + {closable && ( + <CloseIconAction + onClick={() => { + onCloseClick?.(); + }} + aria-label={translatedLabels.dialog.closeIconAriaLabel} + tabIndex={tabIndex} + > + <DxcIcon icon="close" /> + </CloseIconAction> + )} + </FocusLock> + </Dialog> + </DialogContainer>, + document.body + )} + </ThemeProvider> + ); +}; + 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<HTMLDivElement>): 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<HTMLDivElement>): HTML const FocusLock = ({ children }: { children: React.ReactNode }): JSX.Element => { const childrenContainerRef = useRef<HTMLDivElement>(); 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<HTMLDivElement>) => { - 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 (