diff --git a/.changeset/polite-taxis-sin.md b/.changeset/polite-taxis-sin.md new file mode 100644 index 00000000000..f40c21d3eb5 --- /dev/null +++ b/.changeset/polite-taxis-sin.md @@ -0,0 +1,7 @@ +--- +'@primer/react': minor +--- + +Overlay: Add `role="dialog"` and `aria-modal="true"` to the `Overlay` component, and implement a focus trap. + + diff --git a/packages/react/src/Autocomplete/AutocompleteOverlay.tsx b/packages/react/src/Autocomplete/AutocompleteOverlay.tsx index 95d5e479c80..077c526387c 100644 --- a/packages/react/src/Autocomplete/AutocompleteOverlay.tsx +++ b/packages/react/src/Autocomplete/AutocompleteOverlay.tsx @@ -53,6 +53,7 @@ function AutocompleteOverlay({ return showMenu ? ( void + role?: AriaRole } -const TestComponent = ({initialFocus, callback}: TestComponentSettings) => { +const TestComponent = ({initialFocus, callback, role}: TestComponentSettings) => { const [isOpen, setIsOpen] = useState(false) const buttonRef = useRef(null) const confirmButtonRef = useRef(null) @@ -39,6 +41,7 @@ const TestComponent = ({initialFocus, callback}: TestComponentSettings) => { ignoreClickRefs={[buttonRef]} onEscape={closeOverlay} onClickOutside={closeOverlay} + role={role} width="small" > @@ -281,4 +284,32 @@ describe('Overlay', () => { // if stopPropagation worked, mockHandler would not have been called expect(mockHandler).toHaveBeenCalledTimes(0) }) + + it('should provide `role="dialog"` and `aria-modal="true"`, if role is not provided', async () => { + const user = userEvent.setup() + const {getByText, getByRole} = render() + + await user.click(getByText('open overlay')) + + expect(getByRole('dialog')).toBeInTheDocument() + }) + + it('should not provide `dialog` roles if role is already provided', async () => { + const user = userEvent.setup() + const {getByText, queryByRole} = render() + + await user.click(getByText('open overlay')) + + expect(queryByRole('dialog')).not.toBeInTheDocument() + expect(queryByRole('none')).toBeInTheDocument() + }) + + it('should add `aria-modal` if `role="dialog"` is present', async () => { + const user = userEvent.setup() + const {getByText, queryByRole} = render() + + await user.click(getByText('open overlay')) + + expect(queryByRole('dialog')).toHaveAttribute('aria-modal') + }) }) diff --git a/packages/react/src/Overlay/Overlay.tsx b/packages/react/src/Overlay/Overlay.tsx index 251ba0625ab..396dafa26c3 100644 --- a/packages/react/src/Overlay/Overlay.tsx +++ b/packages/react/src/Overlay/Overlay.tsx @@ -13,6 +13,7 @@ import {useRefObjectAsForwardedRef} from '../hooks/useRefObjectAsForwardedRef' import type {AnchorSide} from '@primer/behaviors' import {useTheme} from '../ThemeProvider' import type {ForwardRefComponent as PolymorphicForwardRefComponent} from '../utils/polymorphic' +import {useFocusTrap} from '../hooks/useFocusTrap' type StyledOverlayProps = { width?: keyof typeof widthMap @@ -109,6 +110,7 @@ type BaseOverlayProps = { portalContainerName?: string preventFocusOnOpen?: boolean role?: AriaRole + focusTrap?: boolean children?: React.ReactNode } @@ -133,12 +135,13 @@ type OwnOverlayProps = Merge * @param bottom Optional. Vertical bottom position of the overlay, relative to its closest positioned ancestor (often its `Portal`). * @param position Optional. Sets how an element is positioned in a document. Defaults to `absolute` positioning. * @param portalContainerName Optional. The name of the portal container to render the Overlay into. + * @param focusTrap Optional. Determines if the `Overlay` recieves a focus trap or not. Defaults to `true`. */ const Overlay = React.forwardRef( ( { onClickOutside, - role = 'none', + role, initialFocusRef, returnFocusRef, ignoreClickRefs, @@ -155,6 +158,7 @@ const Overlay = React.forwardRef( preventFocusOnOpen, position, style: styleFromProps = {}, + focusTrap = true, ...rest }, forwardedRef, @@ -200,12 +204,20 @@ const Overlay = React.forwardRef( // To be backwards compatible with the old Overlay, we need to set the left prop if x-position is not specified const leftPosition: React.CSSProperties = left === undefined && right === undefined ? {left: 0} : {left} + const nonDialog = role && role !== 'dialog' ? true : false + + useFocusTrap({ + containerRef: overlayRef, + disabled: !focusTrap || nonDialog, + }) + return (