diff --git a/packages/circuit-ui/components/Dialog/Dialog.spec.tsx b/packages/circuit-ui/components/Dialog/Dialog.spec.tsx index bb41bbf9e5..c9f44ae58d 100644 --- a/packages/circuit-ui/components/Dialog/Dialog.spec.tsx +++ b/packages/circuit-ui/components/Dialog/Dialog.spec.tsx @@ -301,15 +301,16 @@ describe('Dialog', () => { }); it('should focus a given element when provided', async () => { - const ref = createRef(); render( - + {() => (
-
diff --git a/packages/circuit-ui/components/Dialog/Dialog.tsx b/packages/circuit-ui/components/Dialog/Dialog.tsx index 01f4e32bb5..3e181d2b2e 100644 --- a/packages/circuit-ui/components/Dialog/Dialog.tsx +++ b/packages/circuit-ui/components/Dialog/Dialog.tsx @@ -38,7 +38,6 @@ import { useEscapeKey } from '../../hooks/useEscapeKey/index.js'; import { useLatest } from '../../hooks/useLatest/index.js'; import { useI18n } from '../../hooks/useI18n/useI18n.js'; -import { getFirstFocusableElement } from './DialogService.js'; import classes from './Dialog.module.css'; import { translations } from './translations/index.js'; @@ -72,11 +71,6 @@ export interface PublicDialogProps * Defaults to `navigator.language` in supported environments. */ locale?: Locale; - /** - * Enables focusing a particular element in the dialog content and overrides the default behavior. - * @default false. - */ - initialFocusRef?: RefObject; /** * A `ReactNode` or a function that returns the content of the modal dialog. */ @@ -127,7 +121,6 @@ export const Dialog = forwardRef( onCloseEnd, closeButtonLabel, className, - initialFocusRef, preventOutsideClickRefs, preventOutsideClickClose = false, hideCloseButton = false, @@ -144,31 +137,6 @@ export const Dialog = forwardRef( const lastFocusedElementRef = useRef(null); // Focus Management - useEffect(() => { - const dialogElement = dialogRef.current; - let timeoutId: NodeJS.Timeout; - if (open && dialogElement) { - timeoutId = setTimeout(() => { - if (initialFocusRef?.current) { - initialFocusRef?.current?.focus({ preventScroll: true }); - } else { - const firstFocusableElement = getFirstFocusableElement( - dialogElement, - !hideCloseButton, - ); - if (firstFocusableElement) { - firstFocusableElement?.focus({ preventScroll: true }); - } else { - dialogElement.focus(); - } - } - }, animationDurationRef.current); - } - return () => { - clearTimeout(timeoutId); - }; - }, [open, initialFocusRef, hideCloseButton, animationDurationRef.current]); - useEffect(() => { // save the opening element to restore focus after the dialog closes if (open) { @@ -377,15 +345,15 @@ export const Dialog = forwardRef( }} {...rest} > + {open && + (typeof children === 'function' + ? children?.({ onClose: onCloseEnd }) + : children)} {!hideCloseButton && ( {closeButtonLabel} )} - {open && - (typeof children === 'function' - ? children?.({ onClose: onCloseEnd }) - : children)}
); diff --git a/packages/circuit-ui/components/Dialog/DialogService.spec.tsx b/packages/circuit-ui/components/Dialog/DialogService.spec.tsx deleted file mode 100644 index 5a8ded49e7..0000000000 --- a/packages/circuit-ui/components/Dialog/DialogService.spec.tsx +++ /dev/null @@ -1,113 +0,0 @@ -/** - * Copyright 2024, SumUp Ltd. - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { describe, expect, it, afterEach } from 'vitest'; - -import { - getFirstFocusableElement, - getKeyboardFocusableElements, -} from './DialogService.js'; - -describe('DialogService', () => { - afterEach(() => { - document.body.innerHTML = ''; - }); - describe('getKeyboardFocusableElements', () => { - it('should return empty array if element is empty', () => { - const container = document.createElement('div'); - document.body.appendChild(container); - - const result = getKeyboardFocusableElements(document.body); - expect(result).toEqual([]); - }); - - it('should not return an a tag without href', () => { - const a = document.createElement('a'); - document.body.appendChild(a); - const result = getKeyboardFocusableElements(document.body); - expect(result).toEqual([]); - }); - - it('should not return a disabled element', () => { - const input = document.createElement('input'); - input.setAttribute('disabled', 'true'); - document.body.appendChild(input); - const result = getKeyboardFocusableElements(document.body); - expect(result).toEqual([]); - }); - - it('should not return an element with aria-hidden', () => { - const input = document.createElement('input'); - input.setAttribute('aria-hidden', 'true'); - document.body.appendChild(input); - const result = getKeyboardFocusableElements(document.body); - expect(result).toEqual([]); - }); - - it('should return an array of focusable elements', () => { - const container = document.createElement('div'); - container.setAttribute('tabindex', '0'); - const button = document.createElement('button'); - const input = document.createElement('input'); - const a = document.createElement('a'); - a.setAttribute('href', 'showSignature(xyz)'); - const textarea = document.createElement('textarea'); - const select = document.createElement('select'); - const details = document.createElement('details'); - - document.body.append( - container, - button, - input, - a, - textarea, - select, - details, - ); - - const result = getKeyboardFocusableElements(document.body); - expect(result).toEqual( - expect.arrayContaining([ - button, - input, - a, - textarea, - select, - details, - container, - ]), - ); - }); - }); - - describe('getFirstFocusableElement', () => { - it('should return the first focusable element', () => { - const button = document.createElement('button'); - const input = document.createElement('input'); - const a = document.createElement('a'); - document.body.append(button, input, a); - - expect(getFirstFocusableElement(document.body, false)).toEqual(button); - }); - it('should return the second focusable element with skipFirst flag', () => { - const button = document.createElement('button'); - const input = document.createElement('input'); - const a = document.createElement('a'); - document.body.append(button, input, a); - - expect(getFirstFocusableElement(document.body, true)).toEqual(input); - }); - }); -}); diff --git a/packages/circuit-ui/components/Dialog/DialogService.ts b/packages/circuit-ui/components/Dialog/DialogService.ts deleted file mode 100644 index 6a9ee7fb88..0000000000 --- a/packages/circuit-ui/components/Dialog/DialogService.ts +++ /dev/null @@ -1,48 +0,0 @@ -/** - * Copyright 2024, SumUp Ltd. - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -export function getKeyboardFocusableElements( - element: HTMLElement, -): HTMLElement[] { - return [ - ...element.querySelectorAll( - 'a[href], button, input, textarea, select, details,[tabindex]:not([tabindex="-1"])', - ), - ].filter( - (el) => - !el.hasAttribute('disabled') && - !el.hasAttribute('aria-disabled') && - !el.getAttribute('aria-hidden'), - ) as HTMLElement[]; -} - -export function getFirstFocusableElement( - dialog: HTMLElement, - /* - * The dialog element needs to focus the first focusable element in its content. - * That content may contain a close button by default, positioned before all other content. - * This flag makes it possible to skip this button. - */ - skipFirstElement?: boolean, -): HTMLElement | undefined { - const focusableElements = getKeyboardFocusableElements(dialog); - if (!skipFirstElement) { - return focusableElements[0]; - } - // if there is only one focusable element (the close button), focus it - return focusableElements.length === 1 - ? focusableElements[0] - : focusableElements[1]; -} diff --git a/packages/circuit-ui/components/NotificationModal/NotificationModal.tsx b/packages/circuit-ui/components/NotificationModal/NotificationModal.tsx index 837cf3fef7..01b77a3c0a 100644 --- a/packages/circuit-ui/components/NotificationModal/NotificationModal.tsx +++ b/packages/circuit-ui/components/NotificationModal/NotificationModal.tsx @@ -15,7 +15,7 @@ 'use client'; -import { type FC, type ReactNode, type SVGProps, useId, useRef } from 'react'; +import { type FC, type ReactNode, type SVGProps, useId } from 'react'; import type { ClickEvent } from '../../types/events.js'; import { Image, type ImageProps } from '../Image/index.js'; @@ -93,14 +93,12 @@ export const NotificationModal = ({ } const headlineId = useId(); - const initialFocusRef = useRef(null); const dialogProps = { className: clsx(className, classes.base), closeButtonLabel, 'aria-labelledby': headlineId, preventClose, onClose, - initialFocusRef, ...props, }; @@ -134,8 +132,12 @@ export const NotificationModal = ({ }, secondary: actions.secondary && { ...actions.secondary, - // @ts-expect-error ref is a valid prop on a button - ref: initialFocusRef, + // @ts-expect-error React purposefully breaks the `autoFocus` + // property. Using the lowercase DOM attribute name instead + // forces it to be added to the DOM but will produce a console + // warning that can be safely ignored. + // https://github.com/facebook/react/issues/23301 + autofocus: 'true', onClick: wrapOnClick(actions.secondary.onClick), }, }}