Skip to content
Draft
Show file tree
Hide file tree
Changes from 5 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
6 changes: 6 additions & 0 deletions .changeset/beige-lights-kick.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@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.
- Fixes bug that prevented the toolbar from being focused when the drawer is opened.
28 changes: 7 additions & 21 deletions packages/drawer/src/Drawer/Drawer.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, { forwardRef, useEffect, useRef, useState } from 'react';

Check failure on line 1 in packages/drawer/src/Drawer/Drawer.tsx

View workflow job for this annotation

GitHub Actions / Check lints

Run autofix to sort these imports!
import { useInView } from 'react-intersection-observer';

import {
Expand All @@ -6,6 +6,7 @@
useIsomorphicLayoutEffect,
useMergeRefs,
} from '@leafygreen-ui/hooks';
import { VisuallyHidden } from '@leafygreen-ui/a11y';
import XIcon from '@leafygreen-ui/icon/dist/X';
import IconButton from '@leafygreen-ui/icon-button';
import LeafyGreenProvider, {
Expand Down Expand Up @@ -134,26 +135,6 @@
}
}, [id, open, registerDrawer, unregisterDrawer]);

/**
* 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.
*
*/
const handleAnimationEnd = () => {
const drawerElement = ref.current;

// 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) {
const firstFocusable = drawerElement.querySelector(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
);
(firstFocusable as HTMLElement)?.focus();
}
};

// Enables resizable functionality if the drawer is resizable, embedded and open.
const {
resizableRef,
Expand Down Expand Up @@ -218,6 +199,12 @@

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 +222,6 @@
data-testid={lgIds.root}
id={id}
ref={drawerRef}
onAnimationEnd={handleAnimationEnd}
inert={!open ? 'inert' : undefined}
{...rest}
>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -268,6 +268,98 @@ const playClosesDrawerWhenActiveItemIsRemovedFromToolbarData = async ({
});
};

// 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 } = getTestUtils();
const { getToolbarIconButtonByLabel } = getToolbarTestUtils();
const codeButton = getToolbarIconButtonByLabel('Code')?.getElement();

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

// Focus and click the toolbar button to open drawer
codeButton!.focus();
expect(document.activeElement).toBe(codeButton);

userEvent.click(codeButton!);

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

// For embedded drawers, focus should move to the first focusable element in the drawer
// For overlay drawers, this happens automatically via dialog behavior
const closeButton = getCloseButtonUtils().getButton();
expect(closeButton).toBeInTheDocument();

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

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 } = 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();
});

// 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 @@ -320,7 +412,12 @@ const Template: StoryFn<DrawerToolbarLayoutPropsWithDisplayMode> = ({
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 @@ -470,6 +567,24 @@ export const OverlayClosesDrawerWhenActiveItemIsRemovedFromToolbarData: StoryObj
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 @@ -535,3 +650,25 @@ export const EmbeddedClosesDrawerWhenActiveItemIsRemovedFromToolbarData: StoryOb
},
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,
};
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
42 changes: 40 additions & 2 deletions packages/drawer/src/LayoutComponent/PanelGrid/PanelGrid.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import React, { forwardRef } from 'react';
import React, { forwardRef, useRef } from 'react';

Check failure on line 1 in packages/drawer/src/LayoutComponent/PanelGrid/PanelGrid.tsx

View workflow job for this annotation

GitHub Actions / Check lints

Run autofix to sort these imports!

import { useDarkMode } from '@leafygreen-ui/leafygreen-provider';
import { useIsomorphicLayoutEffect } from '@leafygreen-ui/hooks';

import { useDrawerLayoutContext } from '../../DrawerLayout';

import { getPanelGridStyles } from './PanelGrid.styles';
import { PanelGridProps } from './PanelGrid.types';
import { useForwardedRef } from '@leafygreen-ui/hooks';

/**
* @internal
Expand All @@ -31,9 +33,45 @@
isDrawerOpen,
} = useDrawerLayoutContext();

const layoutRef = useForwardedRef(forwardedRef, null);
const previouslyFocusedRef = useRef<HTMLElement | null>(null);
const hasHandledFocusRef = useRef<boolean>(false);

/**
* Focuses the first focusable element in the drawer when the drawer is opened.
* Also handles restoring focus when the drawer is closed.
*/
useIsomorphicLayoutEffect(() => {
if (isDrawerOpen && !hasHandledFocusRef.current) {
// Store the currently focused element when opening (only once per open session)
previouslyFocusedRef.current = document.activeElement as HTMLElement;
hasHandledFocusRef.current = true;

// Focus the first focusable element in the drawer
const firstFocusable = layoutRef.current?.querySelector(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
) as HTMLElement;

firstFocusable?.focus();
} else if (!isDrawerOpen && hasHandledFocusRef.current) {
// Restore focus when closing (only if we had handled focus during this session)
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
document.body.focus();
}
previouslyFocusedRef.current = null; // Clear the ref
}
hasHandledFocusRef.current = false; // Reset for next open session
}
}, [isDrawerOpen]);

Check warning on line 70 in packages/drawer/src/LayoutComponent/PanelGrid/PanelGrid.tsx

View workflow job for this annotation

GitHub Actions / Check lints

React Hook useIsomorphicLayoutEffect has a missing dependency: 'layoutRef'. Either include it or remove the dependency array

return (
<div
ref={forwardedRef}
ref={layoutRef}
className={getPanelGridStyles({
className,
isDrawerOpen,
Expand Down
Loading