diff --git a/.changeset/beige-lights-kick.md b/.changeset/beige-lights-kick.md new file mode 100644 index 0000000000..573915ea3c --- /dev/null +++ b/.changeset/beige-lights-kick.md @@ -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. diff --git a/packages/drawer/src/Drawer/Drawer.tsx b/packages/drawer/src/Drawer/Drawer.tsx index f4c55a9fe6..02e57f2b17 100644 --- a/packages/drawer/src/Drawer/Drawer.tsx +++ b/packages/drawer/src/Drawer/Drawer.tsx @@ -6,6 +6,7 @@ import { 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, { @@ -134,26 +135,6 @@ export const Drawer = forwardRef( } }, [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, @@ -218,6 +199,12 @@ export const Drawer = forwardRef( return ( + {/* Live region for announcing drawer state changes to screen readers */} + {open && ( + + {`${title} drawer`} + + )} ( data-testid={lgIds.root} id={id} ref={drawerRef} - onAnimationEnd={handleAnimationEnd} inert={!open ? 'inert' : undefined} {...rest} > diff --git a/packages/drawer/src/DrawerToolbarLayout/DrawerToolbarLayout/DrawerToolbarLayout.interactions.stories.tsx b/packages/drawer/src/DrawerToolbarLayout/DrawerToolbarLayout/DrawerToolbarLayout.interactions.stories.tsx index 2e926afa37..35d1d196f0 100644 --- a/packages/drawer/src/DrawerToolbarLayout/DrawerToolbarLayout/DrawerToolbarLayout.interactions.stories.tsx +++ b/packages/drawer/src/DrawerToolbarLayout/DrawerToolbarLayout/DrawerToolbarLayout.interactions.stories.tsx @@ -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 & { @@ -320,7 +412,12 @@ const Template: StoryFn = ({ padding: ${spacing[400]}px; `} > - + @@ -470,6 +567,24 @@ export const OverlayClosesDrawerWhenActiveItemIsRemovedFromToolbarData: StoryObj play: playClosesDrawerWhenActiveItemIsRemovedFromToolbarData, }; +export const OverlayToolbarIsFocusedOnClose: StoryObj = + { + render: (args: DrawerToolbarLayoutProps) =>