Skip to content
Draft
Show file tree
Hide file tree
Changes from 15 commits
Commits
Show all changes
17 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
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
5 changes: 5 additions & 0 deletions .changeset/beige-lights-kick.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@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.
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:^",
"@leafygreen-ui/tabs": "workspace:^",
"@leafygreen-ui/tokens": "workspace:^",
"@leafygreen-ui/toolbar": "workspace:^",
"@leafygreen-ui/typography": "workspace:^",
Expand Down
61 changes: 59 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,23 @@ const drawerTest = {
title: 'Drawer title',
} as const;

const DrawerWithButton = () => {
const [isOpen, setIsOpen] = useState(false);
const handleOpen = () => setIsOpen(true);
return (
<DrawerLayout
isDrawerOpen={isOpen}
onClose={() => setIsOpen(false)}
displayMode={DisplayMode.Embedded}
>
<button data-testid="open-drawer-button" onClick={handleOpen}>
Open Drawer
</button>
<Drawer title={drawerTest.title}>{drawerTest.content}</Drawer>
</DrawerLayout>
);
};

function renderDrawer(props: Partial<DrawerProps> = {}) {
const utils = render(
<DrawerStackProvider>
Expand Down Expand Up @@ -47,6 +65,45 @@ describe('packages/drawer', () => {
const results = await axe(container);
expect(results).toHaveNoViolations();
});

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('displayMode prop', () => {
Expand Down
70 changes: 55 additions & 15 deletions packages/drawer/src/Drawer/Drawer.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import React, { forwardRef, useEffect, useRef, useState } from 'react';
import { useInView } from 'react-intersection-observer';

import { VisuallyHidden } from '@leafygreen-ui/a11y';
import {
useIdAllocator,
useIsomorphicLayoutEffect,
Expand All @@ -11,6 +12,7 @@ import IconButton from '@leafygreen-ui/icon-button';
import LeafyGreenProvider, {
useDarkMode,
} from '@leafygreen-ui/leafygreen-provider';
import { queryFirstFocusableElement } from '@leafygreen-ui/lib';
import { usePolymorphic } from '@leafygreen-ui/polymorphic';
import { Position, useResizable } from '@leafygreen-ui/resizable';
import { BaseFontSize } from '@leafygreen-ui/tokens';
Expand Down Expand Up @@ -134,27 +136,58 @@ export const Drawer = forwardRef<HTMLDivElement, DrawerProps>(
}
}, [id, open, registerDrawer, unregisterDrawer]);

const previouslyFocusedRef = useRef<HTMLElement | null>(null);
const hasHandledFocusRef = useRef<boolean>(false);

/**
* Focuses the first focusable element in the drawer when the animation ends. We have to manually handle this because we are hiding the drawer with visibility: hidden, which breaks the default focus behavior of dialog element.
* Focuses the first focusable element in the drawer when the drawer is opened.
* Also handles restoring focus when the drawer is closed.
*
* This is only necessary for embedded drawers. Overlay drawers use the native focus behavior of the dialog element.
Comment on lines +143 to +146
Copy link
Collaborator

Choose a reason for hiding this comment

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

queryFirstFocusableElement has a set list of focusable selectors it considers and "the native focus behavior of the dialog element" is to look for children with the autofocus attribute. However, these don't actually cover all cases that consumers may want to focus, such as a code editor.

After receiving feedback about this use case, it led to this change reintroducing the initialFocus prop in Modal. We don't use Modal and Drawer in all the same ways, but seeing this change makes me think they should follow the same focus management pattern. We may be able to use a similar method as focusModalChildElement for the first part of this effect

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I like this pattern. However, I think if we go with this approach, then we might have to override the default dialog behavior since initialFocus does not work by default.

So the pattern would be in a native dialog and the embedded div:

  1. Drawer opens
  2. We check the initialFocus prop
    a) if auto, we find the autofocus attribute or the first focusable item
    b) if provided, we focus that provided element
    c) if null, we don't focus anything

We'll end up losing the native dialog focus management this way, but it does become more customizable.

However, I am curious about the modal focus management. Why do we need a ref option when you can add autofocus to the element?

Copy link
Collaborator

Choose a reason for hiding this comment

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

What is the native dialog focus management you're concerned about losing? In Modal, initialFocus is mostly additive and doesn't conflict

autofocus can only be applied to the typical focusable elements but can't be used for something like a code editor. Although the initial implementation of Modal used a css selector like "#my-code-editor", I added a ref option because it's more type-safe and robust than a string

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

What is the native dialog focus management you're concerned about losing?

Not necessarily losing, but having to re-implement it. I noticed here that if any child has autofocus, we let the browser focus it automatically, but we manually focus the first focusable element. So in this case, aren't we manually overriding the default behavior of finding the first focusable element and re-implementing it?

const autoFocusElement = modalElement.querySelector(
'[autofocus]',
) as HTMLElement;
if (autoFocusElement) {
// Don't need to explicitly call `focus` because the browser handles it
return autoFocusElement;
}
// Otherwise, focus first focusable element
const firstFocusableElement = queryFirstFocusableElement(modalElement);

autofocus can only be applied to the typical focusable elements but can't be used for something like a code editor. Although the initial implementation of Modal used a css selector like "#my-code-editor", I added a ref option because it's more type-safe and robust than a string

Thanks for clarifying.

Copy link
Collaborator

Choose a reason for hiding this comment

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

So in this case, aren't we manually overriding the default behavior of finding the first focusable element and re-implementing it?

focusModalChildElement is the logic we use to determine how a child element should be focused. The consumer has full control using either the initialFocus prop or the autoFocus attribute on a child element. If initialFocus is not specified and a child element does not have autoFocus specified, then we query for the first focusable element. Happy to huddle on this if further clarification is needed!

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Yes, actually would love to huddle on this, I'll Slack you

*/
const handleAnimationEnd = () => {
const drawerElement = ref.current;
useIsomorphicLayoutEffect(() => {
if (isOverlay) return;

// Check if the drawerElement is null or is a div, which means it is not a dialog element.
if (!drawerElement || drawerElement instanceof HTMLDivElement) {
return;
}
if (open && !hasHandledFocusRef.current) {
// Store the currently focused element when opening (only once per open session)
previouslyFocusedRef.current = document.activeElement as HTMLElement;
hasHandledFocusRef.current = true;

if (open) {
const firstFocusable = drawerElement.querySelector(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
);
(firstFocusable as HTMLElement)?.focus();
if (ref.current === null) {
return;
}

// Find and focus the first focusable element in the drawer
const firstFocusableElement = queryFirstFocusableElement(ref.current);
firstFocusableElement?.focus();
} else if (!open && hasHandledFocusRef.current) {
// Check if the current focus is not in the drawer
// This means the user has navigated away from the drawer, like the toolbar, and we should not restore focus.
if (!ref.current?.contains(document.activeElement)) {
hasHandledFocusRef.current = false;
previouslyFocusedRef.current = null;
return;
}

// Restore focus when closing (only if we had handled focus during this session)
Copy link
Collaborator

Choose a reason for hiding this comment

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

This is starting to feel like a re-implementation of the <dialog> element's focus patterns. Is there an explicit ticket requesting this functionality? If so, I'm curious what do you think about using the <dialog> under the hood for displayMode="embedded"? Since we're using a non-modal <dialog> element, we already end up having to apply the overlay styles for displayMode="overlay". The benefit would be that we get focus management for free; trade-off is that we'd be incorrectly using a dialog element

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

There was no ticket requesting this. I just observed that the embedded drawer was not accessible when it was opened. And you're correct, I tried to mimic the native dialog behavior. I like that if we use a non-modal dialog, then we get this behavior for free, but I don't know if using dialog incorrectly is worth it. We're trying to be more intentional, and this seems semantically incorrect. It's not really a dialog, and screen readers would announce it as a dialog as well.

Also, If we end up adding the same focus management as the modal, then we probably won't even be using the free focus management from dialog.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Curious, why do you consider using a dialog incorrect in this case? Is it because a dialog is supposed to float on top of content?

Copy link
Collaborator

Choose a reason for hiding this comment

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

Yes, my understanding is that a dialog is intended for modal or non-modal overlays. What makes the embedded drawer not accessible? I'm curious what determines focus jumping to the embedded drawer as accessible vs not doing that?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

My concern about the embedded drawer is that, when it is opened, the focus does not shift to the drawer. It's hard to find out what the correct accessibility behavior of the embedded drawer should be since this is a div and not a dialog. Since the overlay drawer is sitting on top of the content, it makes sense that the focus should automatically move to the drawer. For the embedded drawer, the drawer opens and shifts the content, but the focus remains on the trigger (if there is a trigger). The consumer would have to do a lot of tabbing to get to the drawer.

The research I've done on this hasn't been that helpful, but I still think I should do some more investigating before moving forward with this change.

Copy link
Collaborator

Choose a reason for hiding this comment

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

I see, this sounds like it may vary depending on product usage. Can we pull design in and maybe discuss usage with product engineers before applying to all cases?

Alternatively, we could put this behind a discriminated union prop and make this opt-in. i.e.

  • if overlay, rely on autoFocus being set (although this also may require initialFocus)
  • if embedded, check prop to determine focus management. Could see this working in a few ways; a couple ideas are:
    • default focus first focusable (breaking change)
    • default no focus (minor change) and allow users to specify "auto" which would focus first focusable or specify an element ref

if (previouslyFocusedRef.current) {
// Check if the previously focused element is still in the DOM
if (document.contains(previouslyFocusedRef.current)) {
previouslyFocusedRef.current.focus();
} else {
// If the previously focused element is no longer in the DOM, focus the body
// This mimics the behavior of the native HTML Dialog element
document.body.focus();
Copy link

Copilot AI Oct 16, 2025

Choose a reason for hiding this comment

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

[nitpick] Falling back to document.body.focus() may fail because body is not guaranteed to be focusable, leaving keyboard users without a clear focus target. Consider ensuring the fallback element is explicitly focusable (e.g., adding tabIndex='-1' to body or selecting another stable focusable ancestor) or skipping the fallback to avoid a failed focus call.

Suggested change
document.body.focus();
const body = document.body;
const hadTabIndex = body.hasAttribute('tabIndex');
if (!hadTabIndex) {
body.setAttribute('tabIndex', '-1');
}
body.focus();
if (!hadTabIndex) {
body.removeAttribute('tabIndex');
}

Copilot uses AI. Check for mistakes.
}
previouslyFocusedRef.current = null; // Clear the ref
}
hasHandledFocusRef.current = false; // Reset for next open session
}
};
}, [isOverlay, open]);

// Enables resizable functionality if the drawer is resizable, embedded and open.
/**
* Enables resizable functionality if the drawer is resizable, embedded and open.
*/
const {
resizableRef,
size: drawerSize,
Expand Down Expand Up @@ -218,6 +251,12 @@ export const Drawer = forwardRef<HTMLDivElement, DrawerProps>(

return (
<LeafyGreenProvider darkMode={darkMode}>
{/* Live region for announcing drawer state changes to screen readers */}
{open && (
<VisuallyHidden aria-live="polite" aria-atomic="true">
{`${title} drawer`}
</VisuallyHidden>
)}
<Component
aria-hidden={!open}
aria-labelledby={titleId}
Expand All @@ -235,7 +274,6 @@ export const Drawer = forwardRef<HTMLDivElement, DrawerProps>(
data-testid={lgIds.root}
id={id}
ref={drawerRef}
onAnimationEnd={handleAnimationEnd}
inert={!open ? 'inert' : undefined}
{...rest}
>
Expand All @@ -246,6 +284,8 @@ export const Drawer = forwardRef<HTMLDivElement, DrawerProps>(
resizerClassName: resizerProps?.className,
hasToolbar,
})}
data-lgid={lgIds.resizer}
data-testid={lgIds.resizer}
/>
)}
<div className={getDrawerShadowStyles({ theme, displayMode })}>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -274,6 +274,92 @@
});
};

// Reusable play function for testing focus management with toolbar buttons
Copy link
Collaborator

Choose a reason for hiding this comment

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

looks like there is a flaky snapshot because it's taking before animations/repositioning completes. can we add snapshot delays to ensure UI is stable before capturing? https://www.chromatic.com/docs/delay/

const playToolbarFocusManagement = async ({
canvasElement,
}: {
canvasElement: HTMLElement;
}) => {
const canvas = within(canvasElement);
const { getToolbarTestUtils, getCloseButtonUtils, isOpen, getDrawer } =

Check warning on line 284 in packages/drawer/src/DrawerToolbarLayout/DrawerToolbarLayout/DrawerToolbarLayout.interactions.stories.tsx

View workflow job for this annotation

GitHub Actions / Check lints

'getCloseButtonUtils' is assigned a value but never used. Allowed unused vars must match /^_/u
getTestUtils();
const { getToolbarIconButtonByLabel } = getToolbarTestUtils();
const codeButton = getToolbarIconButtonByLabel('Code')?.getElement();
// Wait for the component to be fully rendered and find the button by test ID
const openCodeButton = await canvas.findByTestId('open-code-drawer-button');

// Verify initial state
expect(isOpen()).toBe(false);
userEvent.click(openCodeButton);

await waitFor(() => {
expect(isOpen()).toBe(true);
expect(canvas.getByText('Code Title')).toBeVisible();
expect(getDrawer()).toContain(document.activeElement);
});

// Click the close button
userEvent.click(codeButton!);

await waitFor(() => {
expect(isOpen()).toBe(false);
});

// Focus should return to the original toolbar button that opened the drawer
await waitFor(() => {
expect(document.activeElement).toBe(codeButton);
});
};

// Reusable play function for testing focus management with main content button
const playMainContentButtonFocusManagement = async ({
canvasElement,
}: {
canvasElement: HTMLElement;
}) => {
const canvas = within(canvasElement);
const { getCloseButtonUtils, isOpen, getDrawer } = getTestUtils();

// Wait for the component to be fully rendered and find the button by test ID
const openCodeButton = await canvas.findByTestId('open-code-drawer-button');

// Verify initial state
expect(isOpen()).toBe(false);
expect(openCodeButton).toBeInTheDocument();

// Focus and click the "Open Code Drawer" button to open drawer
openCodeButton.focus();

// Verify focus is on the button - wait for focus to be applied
await waitFor(() => {
expect(document.activeElement).toBe(openCodeButton);
});

userEvent.click(openCodeButton);

await waitFor(() => {
expect(isOpen()).toBe(true);
expect(canvas.getByText('Code Title')).toBeVisible();
expect(getDrawer()).toContain(document.activeElement);
});

// Get the close button from the drawer
const closeButton = getCloseButtonUtils().getButton();
expect(closeButton).toBeInTheDocument();

// Click the close button to close the drawer
userEvent.click(closeButton!);

await waitFor(() => {
expect(isOpen()).toBe(false);
});

// Focus should return to the original "Open Code Drawer" button
await waitFor(() => {
expect(document.activeElement).toBe(openCodeButton);
});
};

// For testing purposes. displayMode is read from the context, so we need to
// pass it down to the DrawerToolbarLayoutProps.
type DrawerToolbarLayoutPropsWithDisplayMode = DrawerToolbarLayoutProps & {
Expand Down Expand Up @@ -326,7 +412,12 @@
padding: ${spacing[400]}px;
`}
>
<Button onClick={() => openDrawer('Code')}>Open Code Drawer</Button>
<Button
onClick={() => openDrawer('Code')}
data-testid="open-code-drawer-button"
>
Open Code Drawer
</Button>
<LongContent />
<LongContent />
</main>
Expand Down Expand Up @@ -476,6 +567,24 @@
play: playClosesDrawerWhenActiveItemIsRemovedFromToolbarData,
};

export const OverlayToolbarIsFocusedOnClose: StoryObj<DrawerToolbarLayoutPropsWithDisplayMode> =
{
render: (args: DrawerToolbarLayoutProps) => <Template {...args} />,
args: {
displayMode: DisplayMode.Overlay,
},
play: playToolbarFocusManagement,
};

export const OverlayButtonIsFocusedOnClose: StoryObj<DrawerToolbarLayoutPropsWithDisplayMode> =
{
render: (args: DrawerToolbarLayoutProps) => <Template {...args} />,
args: {
displayMode: DisplayMode.Overlay,
},
play: playMainContentButtonFocusManagement,
};

export const EmbeddedOpensFirstToolbarItem: StoryObj<DrawerToolbarLayoutPropsWithDisplayMode> =
{
render: (args: DrawerToolbarLayoutPropsWithDisplayMode) => (
Expand Down Expand Up @@ -542,6 +651,28 @@
play: playClosesDrawerWhenActiveItemIsRemovedFromToolbarData,
};

export const EmbeddedToolbarIsFocusedOnClose: StoryObj<DrawerToolbarLayoutPropsWithDisplayMode> =
{
render: (args: DrawerToolbarLayoutPropsWithDisplayMode) => (
<Template {...args} />
),
args: {
displayMode: DisplayMode.Embedded,
},
play: playToolbarFocusManagement,
};

export const EmbeddedButtonIsFocusedOnClose: StoryObj<DrawerToolbarLayoutPropsWithDisplayMode> =
{
render: (args: DrawerToolbarLayoutPropsWithDisplayMode) => (
<Template {...args} />
),
args: {
displayMode: DisplayMode.Embedded,
},
play: playMainContentButtonFocusManagement,
};

interface MainContentProps {
dashboardButtonRef: React.RefObject<HTMLButtonElement>;
guideCueOpen: boolean;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -88,8 +88,6 @@ export const DrawerToolbarLayoutContent = forwardRef<
scrollable={scrollable}
data-lgid={`${dataLgId}`}
data-testid={`${dataLgId}`}
aria-live="polite"
aria-atomic="true"
>
{content}
</Drawer>
Expand Down
Loading
Loading