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 50bf93f7de..6334ee2aee 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..f632fbfa2e 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,31 @@ const drawerTest = { title: 'Drawer title', } as const; +const DrawerWithButton = ({ + displayMode = DisplayMode.Embedded, +}: { displayMode?: DisplayMode } = {}) => { + const [isOpen, setIsOpen] = useState(false); + const handleOpen = () => setIsOpen(true); + const buttonRef = React.useRef(null); + return ( + setIsOpen(false)} + displayMode={displayMode} + > + + + + + + + ); +}; + function renderDrawer(props: Partial = {}) { const utils = render( @@ -47,6 +73,247 @@ describe('packages/drawer', () => { const results = await axe(container); expect(results).toHaveNoViolations(); }); + + describe('initialFocus prop', () => { + describe('auto', () => { + 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('string selector', () => { + describe.each([DisplayMode.Embedded, DisplayMode.Overlay])( + 'displayMode: %s', + displayMode => { + test('focus is on the initial focus string selector', async () => { + const TestComponent = () => { + return ( + {}} + displayMode={displayMode} + initialFocus="#secondary-button" + > + + + + + + ); + }; + + const { getByTestId } = render(); + + const secondaryButton = getByTestId('secondary-button'); + expect(secondaryButton).toHaveFocus(); + }); + }, + ); + + test('focus returns to the open button when the drawer is closed', async () => { + const TestComponent = () => { + const [isOpen, setIsOpen] = useState(false); + const handleOpen = () => setIsOpen(true); + return ( + setIsOpen(false)} + displayMode={DisplayMode.Embedded} + initialFocus="#secondary-button" + > + + + + + + + ); + }; + + const { getByTestId } = render(); + const { isOpen, getCloseButtonUtils } = getTestUtils(); + + const openDrawerButton = getByTestId('open-drawer-button'); + openDrawerButton.focus(); + userEvent.keyboard('{enter}'); + + await waitFor(() => { + expect(isOpen()).toBe(true); + }); + + const secondaryButton = getByTestId('secondary-button'); + expect(secondaryButton).toHaveFocus(); + + const closeButton = getCloseButtonUtils().getButton(); + closeButton.focus(); + userEvent.keyboard('{enter}'); + + await waitFor(() => { + expect(isOpen()).toBe(false); + expect(openDrawerButton).toHaveFocus(); + }); + }); + }); + + describe('ref', () => { + describe.each([DisplayMode.Embedded, DisplayMode.Overlay])( + 'displayMode: %s', + displayMode => { + test('focus is on the initial focus ref', async () => { + const TestComponent = () => { + const buttonRef = React.useRef(null); + return ( + {}} + displayMode={displayMode} + initialFocus={buttonRef} + > + + + + + + ); + }; + + const { getByTestId } = render(); + + const secondaryButton = getByTestId('secondary-button'); + expect(secondaryButton).toHaveFocus(); + }); + }, + ); + + test('focus returns to the open button when the drawer is closed', async () => { + const TestComponent = () => { + const [isOpen, setIsOpen] = useState(false); + const handleOpen = () => setIsOpen(true); + const buttonRef = React.useRef(null); + return ( + setIsOpen(false)} + displayMode={DisplayMode.Embedded} + initialFocus={buttonRef} + > + + + + + + + ); + }; + + const { getByTestId } = render(); + const { isOpen, getCloseButtonUtils } = getTestUtils(); + + const openDrawerButton = getByTestId('open-drawer-button'); + openDrawerButton.focus(); + userEvent.keyboard('{enter}'); + + await waitFor(() => { + expect(isOpen()).toBe(true); + }); + + const secondaryButton = getByTestId('secondary-button'); + expect(secondaryButton).toHaveFocus(); + + const closeButton = getCloseButtonUtils().getButton(); + closeButton.focus(); + userEvent.keyboard('{enter}'); + + await waitFor(() => { + expect(isOpen()).toBe(false); + expect(openDrawerButton).toHaveFocus(); + }); + }); + }); + + describe('autoFocus attribute', () => { + test('focus is on the element with the autoFocus attribute', async () => { + const TestComponent = () => { + return ( + {}} + displayMode={DisplayMode.Embedded} + > + + + + + + ); + }; + + const { getByTestId } = render(); + + const secondaryButton = getByTestId('secondary-button'); + expect(secondaryButton).toHaveFocus(); + }); + }); + }); }); describe('displayMode prop', () => { diff --git a/packages/drawer/src/Drawer/Drawer.tsx b/packages/drawer/src/Drawer/Drawer.tsx index b59d32ec00..1f1e41f151 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, @@ -32,7 +33,12 @@ import { titleStyles, } from './Drawer.styles'; import { DisplayMode, DrawerProps } from './Drawer.types'; -import { getResolvedDrawerSizes, useResolvedDrawerProps } from './Drawer.utils'; +import { + getResolvedDrawerSizes, + setEmbeddedDrawerFocus, + setOverlayDrawerFocus, + useResolvedDrawerProps, +} from './Drawer.utils'; /** * A drawer is a panel that slides in from the right side of the screen (not customizable). Because the user can use the Drawer without navigating away from the current page, tasks can be completed more efficiently while not changing page context. @@ -51,6 +57,7 @@ export const Drawer = forwardRef( scrollable = true, size: sizeProp, title, + initialFocus: initialFocusProp, ...rest }, fwdRef, @@ -68,22 +75,26 @@ export const Drawer = forwardRef( setDrawerWidth, drawerWidth, size: sizeContextProp, + initialFocus: initialFocusContextProp, } = useDrawerLayoutContext(); const [shouldAnimate, setShouldAnimate] = useState(false); const ref = useRef(null); const [previousWidth, setPreviousWidth] = useState(0); // Returns the resolved displayMode, open state, and onClose function based on the component and context props. - const { displayMode, open, onClose, size } = useResolvedDrawerProps({ - componentDisplayMode: displayModeProp, - contextDisplayMode: displayModeContextProp, - componentOpen: openProp, - contextOpen: isDrawerOpen, - componentOnClose: onCloseProp, - contextOnClose: onCloseContextProp, - componentSize: sizeProp, - contextSize: sizeContextProp, - }); + const { displayMode, open, onClose, size, initialFocus } = + useResolvedDrawerProps({ + componentDisplayMode: displayModeProp, + contextDisplayMode: displayModeContextProp, + componentOpen: openProp, + contextOpen: isDrawerOpen, + componentOnClose: onCloseProp, + contextOnClose: onCloseContextProp, + componentSize: sizeProp, + contextSize: sizeContextProp, + componentInitialFocus: initialFocusProp, + contextInitialFocus: initialFocusContextProp, + }); // Returns the resolved drawer sizes based on whether a toolbar is present. const { initialSize, resizableMinWidth, resizableMaxWidth } = @@ -110,6 +121,11 @@ export const Drawer = forwardRef( // This will use the default value of 0 if not wrapped in a DrawerStackProvider. If using a Drawer + Toolbar, the DrawerStackProvider will not be necessary. const drawerIndex = getDrawerIndex(id); + /** + * Opens and closes the dialog element. + * + * If the initialFocus is not 'auto', we focus the appropriate element. + */ useIsomorphicLayoutEffect(() => { const drawerElement = ref.current; @@ -117,13 +133,14 @@ export const Drawer = forwardRef( return; } - if (open) { + if (open && !drawerElement.open) { drawerElement.show(); setShouldAnimate(true); + setOverlayDrawerFocus(drawerElement, initialFocus); } else { drawerElement.close(); } - }, [ref, open]); + }, [ref, open, initialFocus]); useEffect(() => { if (open) { @@ -133,27 +150,34 @@ 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. + * Handles focus for embedded drawers. * + * We mimic the native focus behavior of the dialog element and if the initialFocus is not 'auto', we focus the appropriate 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) { + useIsomorphicLayoutEffect(() => { + // If the drawer element is not found, we can't focus anything. + if (ref.current === null) { return; } - if (open) { - const firstFocusable = drawerElement.querySelector( - 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])', + if (isEmbedded) { + setEmbeddedDrawerFocus( + open, + ref.current as HTMLDivElement, + initialFocus, + previouslyFocusedRef, + hasHandledFocusRef, ); - (firstFocusable as HTMLElement)?.focus(); } - }; + }, [open, initialFocus, isEmbedded]); - // 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, @@ -217,6 +241,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} > @@ -245,6 +274,8 @@ export const Drawer = forwardRef( resizerClassName: resizerProps?.className, hasToolbar, })} + data-lgid={lgIds.resizer} + data-testid={lgIds.resizer} /> )}
diff --git a/packages/drawer/src/Drawer/Drawer.types.ts b/packages/drawer/src/Drawer/Drawer.types.ts index d65d4aec6b..bed6b2437f 100644 --- a/packages/drawer/src/Drawer/Drawer.types.ts +++ b/packages/drawer/src/Drawer/Drawer.types.ts @@ -66,4 +66,6 @@ export interface DrawerProps * Title of the Drawer. If the title is a string, it will be rendered as a `

` element. If the title is a React node, it will be rendered as is. */ title: React.ReactNode; + + initialFocus?: 'auto' | string | React.RefObject; } diff --git a/packages/drawer/src/Drawer/Drawer.utils.ts b/packages/drawer/src/Drawer/Drawer.utils.ts index 5c8403dfdb..e2f06aeb6f 100644 --- a/packages/drawer/src/Drawer/Drawer.utils.ts +++ b/packages/drawer/src/Drawer/Drawer.utils.ts @@ -1,3 +1,6 @@ +import { RefObject } from 'react'; + +import { queryFirstFocusableElement } from '../../../lib/src/queryFocusableElements'; import { DRAWER_TOOLBAR_WIDTH, DRAWER_WIDTHS } from '../constants'; import { DRAWER_MAX_WIDTH, @@ -6,7 +9,7 @@ import { DRAWER_MIN_WIDTH_WITH_TOOLBAR, } from '../constants'; -import { DisplayMode, Size } from './Drawer.types'; +import { DisplayMode, DrawerProps, Size } from './Drawer.types'; /** * Returns the width of the drawer based on the size. @@ -33,6 +36,8 @@ export const useResolvedDrawerProps = ({ contextOnClose, componentSize, contextSize, + componentInitialFocus, + contextInitialFocus, }: { componentDisplayMode?: DisplayMode; contextDisplayMode?: DisplayMode; @@ -42,14 +47,17 @@ export const useResolvedDrawerProps = ({ contextOnClose?: React.MouseEventHandler; componentSize?: Size; contextSize?: Size; + componentInitialFocus?: DrawerProps['initialFocus']; + contextInitialFocus?: DrawerProps['initialFocus']; }) => { const displayMode = componentDisplayMode ?? contextDisplayMode ?? DisplayMode.Overlay; const open = componentOpen ?? contextOpen ?? false; const onClose = componentOnClose ?? contextOnClose ?? undefined; const size = componentSize ?? contextSize ?? Size.Default; + const initialFocus = componentInitialFocus ?? contextInitialFocus ?? 'auto'; - return { displayMode, open, onClose, size }; + return { displayMode, open, onClose, size, initialFocus }; }; /** @@ -70,3 +78,128 @@ export const getResolvedDrawerSizes = (size: Size, hasToolbar?: boolean) => { return { initialSize, resizableMinWidth, resizableMaxWidth }; }; + +/** + * Focuses an explicit drawer child element. + * @param drawerElement - The drawer element. + * @param initialFocus - The initial focus element. + */ +const focusExplicitElement = ( + drawerElement: HTMLDialogElement | HTMLDivElement, + initialFocus: 'auto' | string | RefObject, +) => { + let targetElement: HTMLElement | null = null; + + if (typeof initialFocus === 'string') { + targetElement = drawerElement.querySelector(initialFocus); + } else if ('current' in initialFocus) { + targetElement = initialFocus.current; + } + + if (targetElement) { + targetElement.focus(); + } +}; + +/** + * Handles the auto focus behavior for embedded drawers. Mimics the native focus behavior of the dialog element. + * If an auto focus element is found, focus it. + * If no auto focus element is found, focus the first focusable element in the drawer. + * @param drawerElement - The drawer element. + */ +const handleAutoFocus = (drawerElement: HTMLDivElement) => { + const autoFocusElement = drawerElement.querySelector( + '[autofocus]', + ) as HTMLElement; + + if (autoFocusElement) { + // Auto focus element found, focus it + autoFocusElement.focus(); + } else { + // Find and focus the first focusable element in the drawer + const firstFocusableElement = queryFirstFocusableElement(drawerElement); + firstFocusableElement?.focus(); + } +}; + +/** + * Restores the previously focused element when closing an embedded drawer. + * + * If the active focus is not in the drawer, this means the user has navigated away from the drawer and we should not restore focus. + * E.g., the user has clicked on a toolbar item which toggles the drawer closed and the focus should remain on the toolbar item. + * + * @param drawerElement - The drawer element. + * @param previouslyFocusedRef - A ref to store the previously focused element. + */ +const restoreEmbeddedPreviousFocus = ( + drawerElement: HTMLDivElement, + previouslyFocusedRef: React.MutableRefObject, +) => { + if (!drawerElement?.contains(document.activeElement)) { + 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 + } +}; + +/** + * Sets the initial focus for embedded drawers. Mimics the native focus behavior of the dialog element and if the initialFocus is not 'auto', we focus the appropriate element. + * @param open - Whether the drawer is open. + * @param drawerElement - The drawer element. + * @param initialFocus - The initial focus element. + * @param previouslyFocusedRef - A ref to store the previously focused element. + * @param hasHandledFocusRef - A ref to track whether focus has been handled. + */ +export const setEmbeddedDrawerFocus = ( + open: boolean, + drawerElement: HTMLDivElement, + initialFocus: 'auto' | string | RefObject, + previouslyFocusedRef: React.MutableRefObject, + hasHandledFocusRef: React.MutableRefObject, +) => { + if (open && !hasHandledFocusRef.current) { + // Set the hasHandledFocusRef to true so that we know we have handled focus for this session. + hasHandledFocusRef.current = true; + // Store the currently focused element when opening + previouslyFocusedRef.current = document.activeElement as HTMLElement; + + if (initialFocus !== 'auto') { + focusExplicitElement(drawerElement, initialFocus); + return; + } + + // If no auto focus element is found, focus the first focusable element in the drawer + handleAutoFocus(drawerElement); + } else if (!open && hasHandledFocusRef.current) { + // If the drawer is closed and we have handled focus(hasHandledFocusRef.current is true), we need to reset the hasHandledFocusRef to false so that the next time the drawer is opened, we can handle focus again. + hasHandledFocusRef.current = false; + // Restore the previously focused element when closing + restoreEmbeddedPreviousFocus(drawerElement, previouslyFocusedRef); + } +}; + +/** + * Sets the initial focus for overlay drawers if initialFocus is not 'auto'. + * @param dialogElement - The dialog element. + * @param initialFocus - The initial focus element. + */ +export const setOverlayDrawerFocus = ( + dialogElement: HTMLDialogElement, + initialFocus: 'auto' | string | RefObject, +) => { + if (initialFocus !== 'auto') { + focusExplicitElement(dialogElement, initialFocus); + } +}; diff --git a/packages/drawer/src/DrawerLayout/DrawerLayout.tsx b/packages/drawer/src/DrawerLayout/DrawerLayout.tsx index c20379b55e..8798b74475 100644 --- a/packages/drawer/src/DrawerLayout/DrawerLayout.tsx +++ b/packages/drawer/src/DrawerLayout/DrawerLayout.tsx @@ -22,6 +22,7 @@ export const DrawerLayout = forwardRef( onClose, size = Size.Default, drawer, + initialFocus, ...rest }: DrawerLayoutProps, forwardedRef, @@ -36,6 +37,7 @@ export const DrawerLayout = forwardRef( onClose={onClose} hasToolbar={hasToolbar} size={size} + initialFocus={initialFocus} > {toolbarData ? ( ; +type PickedDrawerProps = Pick; export interface BaseDrawerLayoutPropsWithoutDisplayMode extends ComponentPropsWithoutRef<'div'>, diff --git a/packages/drawer/src/DrawerLayout/DrawerLayoutContext/DrawerLayoutContext.tsx b/packages/drawer/src/DrawerLayout/DrawerLayoutContext/DrawerLayoutContext.tsx index 8a5c36976f..7041ca8cc8 100644 --- a/packages/drawer/src/DrawerLayout/DrawerLayoutContext/DrawerLayoutContext.tsx +++ b/packages/drawer/src/DrawerLayout/DrawerLayoutContext/DrawerLayoutContext.tsx @@ -25,6 +25,7 @@ export const DrawerLayoutContext = createContext({ setDrawerWidth: () => {}, setIsDrawerResizing: () => {}, size: Size.Default, + initialFocus: 'auto', }); /** @@ -39,6 +40,7 @@ export const DrawerLayoutProvider = ({ onClose, hasToolbar: hasToolbarProp = false, size, + initialFocus, }: PropsWithChildren) => { const [isDrawerOpen, setIsDrawerOpen] = React.useState(isDrawerOpenProp); const [isDrawerResizing, setIsDrawerResizing] = React.useState(false); @@ -62,6 +64,7 @@ export const DrawerLayoutProvider = ({ drawerWidth, setDrawerWidth, size, + initialFocus, }; return ( diff --git a/packages/drawer/src/DrawerLayout/DrawerLayoutContext/DrawerLayoutContext.types.ts b/packages/drawer/src/DrawerLayout/DrawerLayoutContext/DrawerLayoutContext.types.ts index e9df6c1c94..e6ced2ab9e 100644 --- a/packages/drawer/src/DrawerLayout/DrawerLayoutContext/DrawerLayoutContext.types.ts +++ b/packages/drawer/src/DrawerLayout/DrawerLayoutContext/DrawerLayoutContext.types.ts @@ -30,6 +30,11 @@ export interface DrawerLayoutProviderProps { * The size of the drawer. */ size?: DrawerProps['size']; + + /** + * The initial focus of the drawer. + */ + initialFocus?: DrawerProps['initialFocus']; } export interface DrawerLayoutContextType extends DrawerLayoutProviderProps { diff --git a/packages/drawer/src/DrawerToolbarLayout/DrawerToolbarLayout/DrawerToolbarLayout.interactions.stories.tsx b/packages/drawer/src/DrawerToolbarLayout/DrawerToolbarLayout/DrawerToolbarLayout.interactions.stories.tsx index c2fc25637f..b46149a1d0 100644 --- a/packages/drawer/src/DrawerToolbarLayout/DrawerToolbarLayout/DrawerToolbarLayout.interactions.stories.tsx +++ b/packages/drawer/src/DrawerToolbarLayout/DrawerToolbarLayout/DrawerToolbarLayout.interactions.stories.tsx @@ -278,6 +278,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 & { @@ -332,7 +409,12 @@ const Template: StoryFn = ({ padding: ${spacing[400]}px; `} > - + @@ -482,6 +564,24 @@ export const OverlayClosesDrawerWhenActiveItemIsRemovedFromToolbarData: StoryObj play: playClosesDrawerWhenActiveItemIsRemovedFromToolbarData, }; +export const OverlayToolbarIsFocusedOnClose: StoryObj = + { + render: (args: DrawerToolbarLayoutProps) =>