Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Overlay: Add role="dialog", focus trap to Overlay #4990

Open
wants to merge 8 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/react/src/Autocomplete/AutocompleteOverlay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ function AutocompleteOverlay({

return showMenu ? (
<Overlay
role="none"
returnFocusRef={inputRef}
preventFocusOnOpen={true}
onClickOutside={closeOptionList}
Expand Down
6 changes: 6 additions & 0 deletions packages/react/src/Overlay/Overlay.docs.json
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,12 @@
"defaultValue": "",
"description": "If defined, Overlays will be rendered in the named portal. See also `Portal`."
},
{
"name": "focusTrap",
"type": "string",
"defaultValue": "true",
"description": "Determines if the `Overlay` recieves a focus trap or not"
},
{
"name": "sx",
"type": "SystemStyleObject"
Expand Down
33 changes: 32 additions & 1 deletion packages/react/src/Overlay/Overlay.test.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import React, {useRef, useState} from 'react'
import type {AriaRole} from '../utils/types'
import {Overlay, Box, Text} from '..'
import {ButtonDanger, Button} from '../deprecated'
import {render, waitFor, fireEvent} from '@testing-library/react'
Expand All @@ -12,8 +13,9 @@ import {NestedOverlays, MemexNestedOverlays, MemexIssueOverlay, PositionedOverla
type TestComponentSettings = {
initialFocus?: 'button'
callback?: () => void
role?: AriaRole
}
const TestComponent = ({initialFocus, callback}: TestComponentSettings) => {
const TestComponent = ({initialFocus, callback, role}: TestComponentSettings) => {
const [isOpen, setIsOpen] = useState(false)
const buttonRef = useRef<HTMLButtonElement>(null)
const confirmButtonRef = useRef<HTMLButtonElement>(null)
Expand All @@ -39,6 +41,7 @@ const TestComponent = ({initialFocus, callback}: TestComponentSettings) => {
ignoreClickRefs={[buttonRef]}
onEscape={closeOverlay}
onClickOutside={closeOverlay}
role={role}
width="small"
>
<Box display="flex" flexDirection="column" p={2}>
Expand Down Expand Up @@ -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(<TestComponent />)

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(<TestComponent role="none" />)

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(<TestComponent role="dialog" />)

await user.click(getByText('open overlay'))

expect(queryByRole('dialog')).toHaveAttribute('aria-modal')
})
})
16 changes: 14 additions & 2 deletions packages/react/src/Overlay/Overlay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -109,6 +110,7 @@ type BaseOverlayProps = {
portalContainerName?: string
preventFocusOnOpen?: boolean
role?: AriaRole
focusTrap?: boolean
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added a new prop in case there's an implementation that wants to be a dialog, but doesn't want the focus trap applied. This is a very specific case, and ideally won't be necessary in more than a handful of instances.

children?: React.ReactNode
}

Expand All @@ -133,12 +135,13 @@ type OwnOverlayProps = Merge<StyledOverlayProps, BaseOverlayProps>
* @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<HTMLDivElement, OwnOverlayProps>(
(
{
onClickOutside,
role = 'none',
role,
initialFocusRef,
returnFocusRef,
ignoreClickRefs,
Expand All @@ -155,6 +158,7 @@ const Overlay = React.forwardRef<HTMLDivElement, OwnOverlayProps>(
preventFocusOnOpen,
position,
style: styleFromProps = {},
focusTrap = true,
...rest
},
forwardedRef,
Expand Down Expand Up @@ -200,12 +204,20 @@ const Overlay = React.forwardRef<HTMLDivElement, OwnOverlayProps>(
// 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 (
<Portal containerName={portalContainerName}>
<StyledOverlay
height={height}
width={width}
role={role}
role={role || 'dialog'}
aria-modal={nonDialog ? undefined : 'true'}
{...rest}
ref={overlayRef}
style={
Expand Down
Loading