diff --git a/.changeset/beige-lights-kick.md b/.changeset/beige-lights-kick.md new file mode 100644 index 0000000000..dfa6af8302 --- /dev/null +++ b/.changeset/beige-lights-kick.md @@ -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. \ No newline at end of file diff --git a/packages/drawer/package.json b/packages/drawer/package.json index 8cb0d96617..4d375d3861 100644 --- a/packages/drawer/package.json +++ b/packages/drawer/package.json @@ -28,6 +28,7 @@ "access": "public" }, "dependencies": { + "@leafygreen-ui/a11y": "workspace:^", "@leafygreen-ui/button": "workspace:^", "@leafygreen-ui/emotion": "workspace:^", "@leafygreen-ui/hooks": "workspace:^", @@ -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:^", diff --git a/packages/drawer/src/Drawer/Drawer.spec.tsx b/packages/drawer/src/Drawer/Drawer.spec.tsx index ed19747721..7db7ed1be5 100644 --- a/packages/drawer/src/Drawer/Drawer.spec.tsx +++ b/packages/drawer/src/Drawer/Drawer.spec.tsx @@ -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'; @@ -13,6 +14,23 @@ const drawerTest = { title: 'Drawer title', } as const; +const DrawerWithButton = () => { + const [isOpen, setIsOpen] = useState(false); + const handleOpen = () => setIsOpen(true); + return ( + setIsOpen(false)} + displayMode={DisplayMode.Embedded} + > + + {drawerTest.content} + + ); +}; + function renderDrawer(props: Partial = {}) { const utils = render( @@ -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(); + 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(); + 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', () => { diff --git a/packages/drawer/src/Drawer/Drawer.tsx b/packages/drawer/src/Drawer/Drawer.tsx index f4c55a9fe6..d1e68dce73 100644 --- a/packages/drawer/src/Drawer/Drawer.tsx +++ b/packages/drawer/src/Drawer/Drawer.tsx @@ -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, @@ -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'; @@ -134,27 +136,58 @@ export const Drawer = forwardRef( } }, [id, open, registerDrawer, unregisterDrawer]); + const previouslyFocusedRef = useRef(null); + const hasHandledFocusRef = useRef(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. */ - 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) + 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(); + } + 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, @@ -218,6 +251,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} > @@ -246,6 +284,8 @@ export const Drawer = forwardRef( resizerClassName: resizerProps?.className, hasToolbar, })} + data-lgid={lgIds.resizer} + data-testid={lgIds.resizer} /> )}
diff --git a/packages/drawer/src/DrawerToolbarLayout/DrawerToolbarLayout/DrawerToolbarLayout.interactions.stories.tsx b/packages/drawer/src/DrawerToolbarLayout/DrawerToolbarLayout/DrawerToolbarLayout.interactions.stories.tsx index 3a3f98c42a..57afd29e7f 100644 --- a/packages/drawer/src/DrawerToolbarLayout/DrawerToolbarLayout/DrawerToolbarLayout.interactions.stories.tsx +++ b/packages/drawer/src/DrawerToolbarLayout/DrawerToolbarLayout/DrawerToolbarLayout.interactions.stories.tsx @@ -274,6 +274,83 @@ 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, isOpen, getDrawer } = 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); + }); + + // Toggle the drawer close + userEvent.click(codeButton!); + + await waitFor(() => { + expect(isOpen()).toBe(false); + }); + + // Focus should remain on the toolbar button + 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(); + + 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 & { @@ -326,7 +403,12 @@ const Template: StoryFn = ({ padding: ${spacing[400]}px; `} > - + @@ -476,6 +558,24 @@ export const OverlayClosesDrawerWhenActiveItemIsRemovedFromToolbarData: StoryObj play: playClosesDrawerWhenActiveItemIsRemovedFromToolbarData, }; +export const OverlayToolbarIsFocusedOnClose: StoryObj = + { + render: (args: DrawerToolbarLayoutProps) =>