Skip to content
Draft
Show file tree
Hide file tree
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 Sep 10, 2025
a76826e
fix(PanelGrid): improve focus management by checking DOM presence bef…
shaneeza Sep 18, 2025
58892b1
Merge branch 'main' of github.com:mongodb/leafygreen-ui into s/drawer…
shaneeza Sep 18, 2025
84dcd65
refactor(PanelGrid): replace useEffect with useIsomorphicLayoutEffect…
shaneeza Sep 18, 2025
3192ddf
Merge branch 'main' of github.com:mongodb/leafygreen-ui into s/drawer…
shaneeza Sep 19, 2025
77eeca5
feat(PanelGrid): enhance focus management by utilizing queryFirstFocu…
shaneeza Oct 8, 2025
0f5fed4
merge conflict
shaneeza Oct 8, 2025
0c27da5
feat(DrawerToolbarContext): add wasToggledClosedWithToolbar state to …
shaneeza Oct 8, 2025
91c57fe
feat(Drawer): enhance focus management for embedded drawers by restor…
shaneeza Oct 16, 2025
6c1a865
feat(Drawer): implement focus management enhancements for drawer inte…
shaneeza Oct 16, 2025
a11fa22
refactor(getTestUtils): simplify button filtering logic by removing t…
shaneeza Oct 16, 2025
5899e50
refactor(DrawerToolbarContext): revert wasToggledClosedWithToolbar ad…
shaneeza Oct 16, 2025
bd6d33e
docs(Drawer): update changeset
shaneeza Oct 16, 2025
2019a47
Merge branch 'main' of github.com:mongodb/leafygreen-ui into s/drawer…
shaneeza Oct 16, 2025
7ce2bd0
feat(Drawer): add a11y dependency and update focus management in inte…
shaneeza Oct 16, 2025
becc27b
docs(Drawer): update changeset to include screen reader announcement
shaneeza Oct 16, 2025
df328af
refactor(DrawerToolbarLayout): simplify focus management logic in int…
shaneeza Oct 16, 2025
82c84b9
fix(Drawer): improve focus management by prioritizing autofocus eleme…
shaneeza Oct 20, 2025
377e665
Merge branch 'main' of github.com:mongodb/leafygreen-ui into s/drawer…
shaneeza Nov 30, 2025
ce01ff2
Enhance Drawer component with initial focus management
shaneeza Nov 30, 2025
8e7471d
Refactor focus management in Drawer component for improved accessibil…
shaneeza Dec 1, 2025
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
7 changes: 7 additions & 0 deletions .changeset/beige-lights-kick.md
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.
2 changes: 1 addition & 1 deletion packages/drawer/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
"access": "public"
},
"dependencies": {
"@leafygreen-ui/a11y": "workspace:^",
"@leafygreen-ui/button": "workspace:^",
"@leafygreen-ui/emotion": "workspace:^",
"@leafygreen-ui/hooks": "workspace:^",
Expand All @@ -37,7 +38,6 @@
"@leafygreen-ui/palette": "workspace:^",
"@leafygreen-ui/polymorphic": "workspace:^",
"@leafygreen-ui/resizable": "workspace:^",
Copy link

Copilot AI Nov 30, 2025

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/tabs dependency 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.

Copilot uses AI. Check for mistakes.
"@leafygreen-ui/tabs": "workspace:^",
"@leafygreen-ui/tokens": "workspace:^",
"@leafygreen-ui/toolbar": "workspace:^",
"@leafygreen-ui/typography": "workspace:^",
Expand Down
271 changes: 269 additions & 2 deletions packages/drawer/src/Drawer/Drawer.spec.tsx
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';

Expand All @@ -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>
Expand Down Expand Up @@ -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
Copy link

Copilot AI Nov 30, 2025

Choose a reason for hiding this comment

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

Add a comment explaining why the autofocus attribute must be manually added as a workaround for React not adding it automatically in embedded mode, referencing the existing comment on line 299.

Suggested change
// 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.

Copilot uses AI. Check for mistakes.
{...{ autofocus: '' }}
>
Secondary
</button>
</Drawer>
</DrawerLayout>
);
};

const { getByTestId } = render(<TestComponent />);

const secondaryButton = getByTestId('secondary-button');
expect(secondaryButton).toHaveFocus();
});
});
});
});

describe('displayMode prop', () => {
Expand Down
Loading
Loading