diff --git a/.changeset/curvy-walls-exist.md b/.changeset/curvy-walls-exist.md new file mode 100644 index 00000000000..634d82842c1 --- /dev/null +++ b/.changeset/curvy-walls-exist.md @@ -0,0 +1,4 @@ +--- +--- + +ActionBar: Adds new prop `returnFocusRef` to `ActionBar.Menu` to change where focus lands when the menu is closed diff --git a/packages/react/src/ActionBar/ActionBar.docs.json b/packages/react/src/ActionBar/ActionBar.docs.json index 193a73ba7b3..b38d7affc1a 100644 --- a/packages/react/src/ActionBar/ActionBar.docs.json +++ b/packages/react/src/ActionBar/ActionBar.docs.json @@ -127,6 +127,12 @@ "type": "Component | 'none'", "required": false, "description": "Icon displayed when the menu item is within the overflow menu. If 'none' is provided, no icon will be shown in the overflow menu." + }, + { + "name": "returnFocusRef", + "type": "React.RefObject", + "required": false, + "description": "A ref to an element that should receive focus when the menu is closed." } ] } diff --git a/packages/react/src/ActionBar/ActionBar.test.tsx b/packages/react/src/ActionBar/ActionBar.test.tsx index 05029b2e853..f176d5e7add 100644 --- a/packages/react/src/ActionBar/ActionBar.test.tsx +++ b/packages/react/src/ActionBar/ActionBar.test.tsx @@ -1,9 +1,9 @@ import {describe, expect, it, afterEach, vi} from 'vitest' import {render, screen, act} from '@testing-library/react' import userEvent from '@testing-library/user-event' +import React, {createRef, useState} from 'react' import ActionBar from './' import {BoldIcon, ItalicIcon, CodeIcon} from '@primer/octicons-react' -import {useState} from 'react' import {implementsClassName} from '../utils/testing' import classes from './ActionBar.module.css' @@ -300,3 +300,120 @@ describe('ActionBar gap prop', () => { expect(toolbar).toHaveAttribute('data-gap', 'condensed') }) }) + +describe('ActionBar.Menu returnFocusRef', () => { + it('accepts returnFocusRef prop', () => { + const returnFocusRef = createRef() + render( +
+ + + + +
, + ) + + expect(screen.getByRole('button', {name: 'More options'})).toBeInTheDocument() + }) + + it('returns focus to returnFocusRef when menu is closed', async () => { + const user = userEvent.setup() + const returnFocusRef = createRef() + + render( +
+ + + + +
, + ) + + const menuButton = screen.getByRole('button', {name: 'More options'}) + + // Open the menu + await user.click(menuButton) + + // Verify menu is open + expect(screen.getByRole('menu')).toBeInTheDocument() + + // Close the menu by pressing Escape + await user.keyboard('{Escape}') + + // Verify focus is returned to the returnFocusRef element + const returnFocusTarget = screen.getByTestId('return-focus-target') + expect(document.activeElement).toEqual(returnFocusTarget) + }) + + it('returns focus to returnFocusRef when menu item is selected', async () => { + const user = userEvent.setup() + const returnFocusRef = createRef() + const onClick = vi.fn() + + render( +
+ + + + +
, + ) + + const menuButton = screen.getByRole('button', {name: 'More options'}) + + // Open the menu + await user.click(menuButton) + + // Click a menu item + await user.click(screen.getByRole('menuitem', {name: 'Option 1'})) + + // Verify focus is returned to the returnFocusRef element + const returnFocusTarget = screen.getByTestId('return-focus-target') + expect(document.activeElement).toEqual(returnFocusTarget) + }) + + it('returns focus to anchor button when returnFocusRef is not provided', async () => { + const user = userEvent.setup() + + render( + + + , + ) + + const menuButton = screen.getByRole('button', {name: 'More options'}) + + // Open the menu + await user.click(menuButton) + + // Verify menu is open + expect(screen.getByRole('menu')).toBeInTheDocument() + + // Close the menu by pressing Escape + await user.keyboard('{Escape}') + + // Verify focus returns to the menu button (default behavior) + expect(document.activeElement).toEqual(menuButton) + }) +}) diff --git a/packages/react/src/ActionBar/ActionBar.tsx b/packages/react/src/ActionBar/ActionBar.tsx index 7f2745ef218..4bb9d6e2ec4 100644 --- a/packages/react/src/ActionBar/ActionBar.tsx +++ b/packages/react/src/ActionBar/ActionBar.tsx @@ -35,6 +35,7 @@ type ChildProps = label: string icon: ActionBarIconButtonProps['icon'] | 'none' items: ActionBarMenuProps['items'] + returnFocusRef?: React.RefObject } /** @@ -155,6 +156,10 @@ export type ActionBarMenuProps = { * If 'none' is provided, no icon will be shown in the overflow menu. */ overflowIcon?: ActionBarIconButtonProps['icon'] | 'none' + /** + * Target element to return focus to when the menu is closed. + */ + returnFocusRef?: React.RefObject } & IconButtonProps const MORE_BTN_WIDTH = 32 @@ -428,7 +433,7 @@ export const ActionBar: React.FC> = prop if (menuItem.type === 'menu') { const menuItems = menuItem.items - const {icon: Icon, label} = menuItem + const {icon: Icon, label, returnFocusRef} = menuItem return ( @@ -442,7 +447,7 @@ export const ActionBar: React.FC> = prop {label} - + {menuItems.map((item, index) => renderMenuItem(item, index))} @@ -584,7 +589,10 @@ export const ActionBarGroup = forwardRef(({children}: React.PropsWithChildren, f }) export const ActionBarMenu = forwardRef( - ({'aria-label': ariaLabel, icon, overflowIcon, items, ...props}: ActionBarMenuProps, forwardedRef) => { + ( + {'aria-label': ariaLabel, icon, overflowIcon, items, returnFocusRef, ...props}: ActionBarMenuProps, + forwardedRef, + ) => { const backupRef = useRef(null) const ref = (forwardedRef ?? backupRef) as RefObject const id = useId() @@ -606,6 +614,7 @@ export const ActionBarMenu = forwardRef( width: widthRef.current, label: ariaLabel, icon: overflowIcon ? overflowIcon : icon, + returnFocusRef, items, }) @@ -621,7 +630,7 @@ export const ActionBarMenu = forwardRef( - + {items.map((item, index) => renderMenuItem(item, index))}