-
Notifications
You must be signed in to change notification settings - Fork 72
feat(drawer): add focus management to embedded drawers #3139
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
Draft
shaneeza
wants to merge
21
commits into
main
Choose a base branch
from
s/drawer-focus
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Draft
Changes from all commits
Commits
Show all changes
21 commits
Select commit
Hold shift + click to select a range
ce621ea
feat(PanelGrid): enhance Drawer accessibility and focus management
shaneeza a76826e
fix(PanelGrid): improve focus management by checking DOM presence bef…
shaneeza 58892b1
Merge branch 'main' of github.com:mongodb/leafygreen-ui into s/drawer…
shaneeza 84dcd65
refactor(PanelGrid): replace useEffect with useIsomorphicLayoutEffect…
shaneeza 3192ddf
Merge branch 'main' of github.com:mongodb/leafygreen-ui into s/drawer…
shaneeza 77eeca5
feat(PanelGrid): enhance focus management by utilizing queryFirstFocu…
shaneeza 0f5fed4
merge conflict
shaneeza 0c27da5
feat(DrawerToolbarContext): add wasToggledClosedWithToolbar state to …
shaneeza 91c57fe
feat(Drawer): enhance focus management for embedded drawers by restor…
shaneeza 6c1a865
feat(Drawer): implement focus management enhancements for drawer inte…
shaneeza a11fa22
refactor(getTestUtils): simplify button filtering logic by removing t…
shaneeza 5899e50
refactor(DrawerToolbarContext): revert wasToggledClosedWithToolbar ad…
shaneeza bd6d33e
docs(Drawer): update changeset
shaneeza 2019a47
Merge branch 'main' of github.com:mongodb/leafygreen-ui into s/drawer…
shaneeza 7ce2bd0
feat(Drawer): add a11y dependency and update focus management in inte…
shaneeza becc27b
docs(Drawer): update changeset to include screen reader announcement
shaneeza df328af
refactor(DrawerToolbarLayout): simplify focus management logic in int…
shaneeza 82c84b9
fix(Drawer): improve focus management by prioritizing autofocus eleme…
shaneeza 377e665
Merge branch 'main' of github.com:mongodb/leafygreen-ui into s/drawer…
shaneeza ce01ff2
Enhance Drawer component with initial focus management
shaneeza 8e7471d
Refactor focus management in Drawer component for improved accessibil…
shaneeza File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,7 @@ | ||
| --- | ||
| '@leafygreen-ui/drawer': minor | ||
| --- | ||
|
|
||
| - Adds focus management to embedded drawers. Embedded drawers will now automatically focus the first focusable element when opened and restore focus to the previously focused element when closed. Overlay drawers use the native focus behavior of the dialog element. | ||
| - Adds visually hidden element to announce drawer state changes to screen readers. | ||
| - Removes CSS visibility check from the `isOpen` test utility since `opacity, `visibility` and `display` properties do not change when the drawer is opened or closed. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|
| @@ -1,8 +1,9 @@ | ||||||||||
| import React from 'react'; | ||||||||||
| import { render } from '@testing-library/react'; | ||||||||||
| import React, { useState } from 'react'; | ||||||||||
| import { render, waitFor } from '@testing-library/react'; | ||||||||||
| import userEvent from '@testing-library/user-event'; | ||||||||||
| import { axe } from 'jest-axe'; | ||||||||||
|
|
||||||||||
| import { DrawerLayout } from '../DrawerLayout'; | ||||||||||
| import { DrawerStackProvider } from '../DrawerStackContext'; | ||||||||||
| import { getTestUtils } from '../testing'; | ||||||||||
|
|
||||||||||
|
|
@@ -13,6 +14,31 @@ const drawerTest = { | |||||||||
| title: 'Drawer title', | ||||||||||
| } as const; | ||||||||||
|
|
||||||||||
| const DrawerWithButton = ({ | ||||||||||
| displayMode = DisplayMode.Embedded, | ||||||||||
| }: { displayMode?: DisplayMode } = {}) => { | ||||||||||
| const [isOpen, setIsOpen] = useState(false); | ||||||||||
| const handleOpen = () => setIsOpen(true); | ||||||||||
| const buttonRef = React.useRef<HTMLButtonElement>(null); | ||||||||||
| return ( | ||||||||||
| <DrawerLayout | ||||||||||
| isDrawerOpen={isOpen} | ||||||||||
| onClose={() => setIsOpen(false)} | ||||||||||
| displayMode={displayMode} | ||||||||||
| > | ||||||||||
| <button data-testid="open-drawer-button" onClick={handleOpen}> | ||||||||||
| Open Drawer | ||||||||||
| </button> | ||||||||||
| <Drawer title={drawerTest.title}> | ||||||||||
| <button data-testid="primary-button">Primary</button> | ||||||||||
| <button data-testid="secondary-button" ref={buttonRef}> | ||||||||||
| Secondary | ||||||||||
| </button> | ||||||||||
| </Drawer> | ||||||||||
| </DrawerLayout> | ||||||||||
| ); | ||||||||||
| }; | ||||||||||
|
|
||||||||||
| function renderDrawer(props: Partial<DrawerProps> = {}) { | ||||||||||
| const utils = render( | ||||||||||
| <DrawerStackProvider> | ||||||||||
|
|
@@ -47,6 +73,247 @@ describe('packages/drawer', () => { | |||||||||
| const results = await axe(container); | ||||||||||
| expect(results).toHaveNoViolations(); | ||||||||||
| }); | ||||||||||
|
|
||||||||||
| describe('initialFocus prop', () => { | ||||||||||
| describe('auto', () => { | ||||||||||
| test('focus is on the first focusable element when the drawer is opened by pressing the enter key on the open button', async () => { | ||||||||||
| const { getByTestId } = render(<DrawerWithButton />); | ||||||||||
| const { isOpen, getCloseButtonUtils } = getTestUtils(); | ||||||||||
|
|
||||||||||
| expect(isOpen()).toBe(false); | ||||||||||
| const openDrawerButton = getByTestId('open-drawer-button'); | ||||||||||
| openDrawerButton.focus(); | ||||||||||
| userEvent.keyboard('{enter}'); | ||||||||||
|
|
||||||||||
| await waitFor(() => { | ||||||||||
| expect(isOpen()).toBe(true); | ||||||||||
| const closeButton = getCloseButtonUtils().getButton(); | ||||||||||
| expect(closeButton).toHaveFocus(); | ||||||||||
| }); | ||||||||||
| }); | ||||||||||
|
|
||||||||||
| test('focus returns to the open button when the drawer is closed', async () => { | ||||||||||
| const { getByTestId } = render(<DrawerWithButton />); | ||||||||||
| const { isOpen, getCloseButtonUtils } = getTestUtils(); | ||||||||||
|
|
||||||||||
| expect(isOpen()).toBe(false); | ||||||||||
| const openDrawerButton = getByTestId('open-drawer-button'); | ||||||||||
| openDrawerButton.focus(); | ||||||||||
| userEvent.keyboard('{enter}'); | ||||||||||
|
|
||||||||||
| await waitFor(() => { | ||||||||||
| expect(isOpen()).toBe(true); | ||||||||||
| const closeButton = getCloseButtonUtils().getButton(); | ||||||||||
| expect(closeButton).toHaveFocus(); | ||||||||||
| }); | ||||||||||
|
|
||||||||||
| userEvent.keyboard('{enter}'); | ||||||||||
|
|
||||||||||
| await waitFor(() => { | ||||||||||
| expect(isOpen()).toBe(false); | ||||||||||
| expect(openDrawerButton).toHaveFocus(); | ||||||||||
| }); | ||||||||||
| }); | ||||||||||
| }); | ||||||||||
|
|
||||||||||
| describe('string selector', () => { | ||||||||||
| describe.each([DisplayMode.Embedded, DisplayMode.Overlay])( | ||||||||||
| 'displayMode: %s', | ||||||||||
| displayMode => { | ||||||||||
| test('focus is on the initial focus string selector', async () => { | ||||||||||
| const TestComponent = () => { | ||||||||||
| return ( | ||||||||||
| <DrawerLayout | ||||||||||
| isDrawerOpen={true} | ||||||||||
| onClose={() => {}} | ||||||||||
| displayMode={displayMode} | ||||||||||
| initialFocus="#secondary-button" | ||||||||||
| > | ||||||||||
| <Drawer title={drawerTest.title}> | ||||||||||
| <button data-testid="primary-button">Primary</button> | ||||||||||
| <button | ||||||||||
| data-testid="secondary-button" | ||||||||||
| id="secondary-button" | ||||||||||
| > | ||||||||||
| Secondary | ||||||||||
| </button> | ||||||||||
| </Drawer> | ||||||||||
| </DrawerLayout> | ||||||||||
| ); | ||||||||||
| }; | ||||||||||
|
|
||||||||||
| const { getByTestId } = render(<TestComponent />); | ||||||||||
|
|
||||||||||
| const secondaryButton = getByTestId('secondary-button'); | ||||||||||
| expect(secondaryButton).toHaveFocus(); | ||||||||||
| }); | ||||||||||
| }, | ||||||||||
| ); | ||||||||||
|
|
||||||||||
| test('focus returns to the open button when the drawer is closed', async () => { | ||||||||||
| const TestComponent = () => { | ||||||||||
| const [isOpen, setIsOpen] = useState(false); | ||||||||||
| const handleOpen = () => setIsOpen(true); | ||||||||||
| return ( | ||||||||||
| <DrawerLayout | ||||||||||
| isDrawerOpen={isOpen} | ||||||||||
| onClose={() => setIsOpen(false)} | ||||||||||
| displayMode={DisplayMode.Embedded} | ||||||||||
| initialFocus="#secondary-button" | ||||||||||
| > | ||||||||||
| <button data-testid="open-drawer-button" onClick={handleOpen}> | ||||||||||
| Open Drawer | ||||||||||
| </button> | ||||||||||
| <Drawer title={drawerTest.title}> | ||||||||||
| <button data-testid="primary-button">Primary</button> | ||||||||||
| <button data-testid="secondary-button" id="secondary-button"> | ||||||||||
| Secondary | ||||||||||
| </button> | ||||||||||
| </Drawer> | ||||||||||
| </DrawerLayout> | ||||||||||
| ); | ||||||||||
| }; | ||||||||||
|
|
||||||||||
| const { getByTestId } = render(<TestComponent />); | ||||||||||
| const { isOpen, getCloseButtonUtils } = getTestUtils(); | ||||||||||
|
|
||||||||||
| const openDrawerButton = getByTestId('open-drawer-button'); | ||||||||||
| openDrawerButton.focus(); | ||||||||||
| userEvent.keyboard('{enter}'); | ||||||||||
|
|
||||||||||
| await waitFor(() => { | ||||||||||
| expect(isOpen()).toBe(true); | ||||||||||
| }); | ||||||||||
|
|
||||||||||
| const secondaryButton = getByTestId('secondary-button'); | ||||||||||
| expect(secondaryButton).toHaveFocus(); | ||||||||||
|
|
||||||||||
| const closeButton = getCloseButtonUtils().getButton(); | ||||||||||
| closeButton.focus(); | ||||||||||
| userEvent.keyboard('{enter}'); | ||||||||||
|
|
||||||||||
| await waitFor(() => { | ||||||||||
| expect(isOpen()).toBe(false); | ||||||||||
| expect(openDrawerButton).toHaveFocus(); | ||||||||||
| }); | ||||||||||
| }); | ||||||||||
| }); | ||||||||||
|
|
||||||||||
| describe('ref', () => { | ||||||||||
| describe.each([DisplayMode.Embedded, DisplayMode.Overlay])( | ||||||||||
| 'displayMode: %s', | ||||||||||
| displayMode => { | ||||||||||
| test('focus is on the initial focus ref', async () => { | ||||||||||
| const TestComponent = () => { | ||||||||||
| const buttonRef = React.useRef<HTMLButtonElement>(null); | ||||||||||
| return ( | ||||||||||
| <DrawerLayout | ||||||||||
| isDrawerOpen={true} | ||||||||||
| onClose={() => {}} | ||||||||||
| displayMode={displayMode} | ||||||||||
| initialFocus={buttonRef} | ||||||||||
| > | ||||||||||
| <Drawer title={drawerTest.title}> | ||||||||||
| <button data-testid="primary-button">Primary</button> | ||||||||||
| <button data-testid="secondary-button" ref={buttonRef}> | ||||||||||
| Secondary | ||||||||||
| </button> | ||||||||||
| </Drawer> | ||||||||||
| </DrawerLayout> | ||||||||||
| ); | ||||||||||
| }; | ||||||||||
|
|
||||||||||
| const { getByTestId } = render(<TestComponent />); | ||||||||||
|
|
||||||||||
| const secondaryButton = getByTestId('secondary-button'); | ||||||||||
| expect(secondaryButton).toHaveFocus(); | ||||||||||
| }); | ||||||||||
| }, | ||||||||||
| ); | ||||||||||
|
|
||||||||||
| test('focus returns to the open button when the drawer is closed', async () => { | ||||||||||
| const TestComponent = () => { | ||||||||||
| const [isOpen, setIsOpen] = useState(false); | ||||||||||
| const handleOpen = () => setIsOpen(true); | ||||||||||
| const buttonRef = React.useRef<HTMLButtonElement>(null); | ||||||||||
| return ( | ||||||||||
| <DrawerLayout | ||||||||||
| isDrawerOpen={isOpen} | ||||||||||
| onClose={() => setIsOpen(false)} | ||||||||||
| displayMode={DisplayMode.Embedded} | ||||||||||
| initialFocus={buttonRef} | ||||||||||
| > | ||||||||||
| <button data-testid="open-drawer-button" onClick={handleOpen}> | ||||||||||
| Open Drawer | ||||||||||
| </button> | ||||||||||
| <Drawer title={drawerTest.title}> | ||||||||||
| <button data-testid="primary-button">Primary</button> | ||||||||||
| <button data-testid="secondary-button" ref={buttonRef}> | ||||||||||
| Secondary | ||||||||||
| </button> | ||||||||||
| </Drawer> | ||||||||||
| </DrawerLayout> | ||||||||||
| ); | ||||||||||
| }; | ||||||||||
|
|
||||||||||
| const { getByTestId } = render(<TestComponent />); | ||||||||||
| const { isOpen, getCloseButtonUtils } = getTestUtils(); | ||||||||||
|
|
||||||||||
| const openDrawerButton = getByTestId('open-drawer-button'); | ||||||||||
| openDrawerButton.focus(); | ||||||||||
| userEvent.keyboard('{enter}'); | ||||||||||
|
|
||||||||||
| await waitFor(() => { | ||||||||||
| expect(isOpen()).toBe(true); | ||||||||||
| }); | ||||||||||
|
|
||||||||||
| const secondaryButton = getByTestId('secondary-button'); | ||||||||||
| expect(secondaryButton).toHaveFocus(); | ||||||||||
|
|
||||||||||
| const closeButton = getCloseButtonUtils().getButton(); | ||||||||||
| closeButton.focus(); | ||||||||||
| userEvent.keyboard('{enter}'); | ||||||||||
|
|
||||||||||
| await waitFor(() => { | ||||||||||
| expect(isOpen()).toBe(false); | ||||||||||
| expect(openDrawerButton).toHaveFocus(); | ||||||||||
| }); | ||||||||||
| }); | ||||||||||
| }); | ||||||||||
|
|
||||||||||
| describe('autoFocus attribute', () => { | ||||||||||
| test('focus is on the element with the autoFocus attribute', async () => { | ||||||||||
| const TestComponent = () => { | ||||||||||
| return ( | ||||||||||
| <DrawerLayout | ||||||||||
| isDrawerOpen={true} | ||||||||||
| onClose={() => {}} | ||||||||||
| displayMode={DisplayMode.Embedded} | ||||||||||
| > | ||||||||||
| <Drawer title={drawerTest.title}> | ||||||||||
| <button data-testid="primary-button">Primary</button> | ||||||||||
| <button | ||||||||||
| data-testid="secondary-button" | ||||||||||
| id="secondary-button" | ||||||||||
| // eslint-disable-next-line jsx-a11y/no-autofocus | ||||||||||
| autoFocus={true} | ||||||||||
| // react does not add the autofocus attribute to the button element, so it needs to be added manually for embedded mode | ||||||||||
|
||||||||||
| // react does not add the autofocus attribute to the button element, so it needs to be added manually for embedded mode | |
| // React does not add the `autofocus` attribute to the button element when using the `autoFocus` prop. | |
| // In embedded mode, the browser requires the `autofocus` attribute to be present on the element for it to receive focus automatically. | |
| // Therefore, we manually add the `autofocus` attribute as a workaround. See comment above for more details. |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The removal of
@leafygreen-ui/tabsdependency should be verified to ensure it's not used elsewhere in the package. If this is a cleanup unrelated to focus management, it should ideally be in a separate PR.