Skip to content
Draft
Show file tree
Hide file tree
Changes from all 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
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
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';

Comment on lines +4 to 11
Copy link
Preview

Copilot AI Sep 19, 2025

Choose a reason for hiding this comment

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

Import statements should be grouped and ordered consistently. The useForwardedRef import on line 10 should be combined with the other hooks import on line 4.

Suggested change
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';
import { useIsomorphicLayoutEffect, useForwardedRef } from '@leafygreen-ui/hooks';
import { useDrawerLayoutContext } from '../../DrawerLayout';
import { getPanelGridStyles } from './PanelGrid.styles';
import { PanelGridProps } from './PanelGrid.types';

Copilot uses AI. Check for mistakes.

/**
* @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;
Comment on lines +51 to +53
Copy link
Preview

Copilot AI Sep 19, 2025

Choose a reason for hiding this comment

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

The CSS selector for focusable elements should be extracted to a constant or utility function to avoid duplication and improve maintainability. This same selector appears to be used elsewhere in the codebase.

Copilot uses AI. Check for mistakes.


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();
}
Comment on lines +60 to +65
Copy link
Preview

Copilot AI Sep 19, 2025

Choose a reason for hiding this comment

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

Focusing document.body may not provide the best user experience for keyboard navigation. Consider focusing a more appropriate fallback element or using a focus management library that can find the next logical focus target.

Copilot uses AI. Check for mistakes.

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