Skip to content
Open
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
4 changes: 4 additions & 0 deletions .changeset/curvy-walls-exist.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
---
---

ActionBar: Adds new prop `returnFocusRef` to `ActionBar.Menu` to change where focus lands when the menu is closed
6 changes: 6 additions & 0 deletions packages/react/src/ActionBar/ActionBar.docs.json
Original file line number Diff line number Diff line change
Expand Up @@ -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<HTMLElement>",
"required": false,
"description": "A ref to an element that should receive focus when the menu is closed."
}
]
}
Expand Down
119 changes: 118 additions & 1 deletion packages/react/src/ActionBar/ActionBar.test.tsx
Original file line number Diff line number Diff line change
@@ -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'

Expand Down Expand Up @@ -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<HTMLButtonElement>()
render(
<div>
<button ref={returnFocusRef} type="button">
Return focus target
</button>
<ActionBar aria-label="Toolbar">
<ActionBar.Menu
aria-label="More options"
icon={BoldIcon}
returnFocusRef={returnFocusRef}
items={[{label: 'Option 1', onClick: vi.fn()}]}
/>
</ActionBar>
</div>,
)

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<HTMLButtonElement>()

render(
<div>
<button ref={returnFocusRef} data-testid="return-focus-target" type="button">
Return focus target
</button>
<ActionBar aria-label="Toolbar">
<ActionBar.Menu
aria-label="More options"
icon={BoldIcon}
returnFocusRef={returnFocusRef}
items={[{label: 'Option 1', onClick: vi.fn()}]}
/>
</ActionBar>
</div>,
)

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<HTMLButtonElement>()
const onClick = vi.fn()

render(
<div>
<button ref={returnFocusRef} data-testid="return-focus-target" type="button">
Return focus target
</button>
<ActionBar aria-label="Toolbar">
<ActionBar.Menu
aria-label="More options"
icon={BoldIcon}
returnFocusRef={returnFocusRef}
items={[{label: 'Option 1', onClick}]}
/>
</ActionBar>
</div>,
)

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(
<ActionBar aria-label="Toolbar">
<ActionBar.Menu aria-label="More options" icon={BoldIcon} items={[{label: 'Option 1', onClick: vi.fn()}]} />
</ActionBar>,
)

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)
})
})
17 changes: 13 additions & 4 deletions packages/react/src/ActionBar/ActionBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ type ChildProps =
label: string
icon: ActionBarIconButtonProps['icon'] | 'none'
items: ActionBarMenuProps['items']
returnFocusRef?: React.RefObject<HTMLElement>
}

/**
Expand Down Expand Up @@ -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<HTMLElement>
} & IconButtonProps

const MORE_BTN_WIDTH = 32
Expand Down Expand Up @@ -428,7 +433,7 @@ export const ActionBar: React.FC<React.PropsWithChildren<ActionBarProps>> = prop

if (menuItem.type === 'menu') {
const menuItems = menuItem.items
const {icon: Icon, label} = menuItem
const {icon: Icon, label, returnFocusRef} = menuItem

return (
<ActionMenu key={id}>
Expand All @@ -442,7 +447,7 @@ export const ActionBar: React.FC<React.PropsWithChildren<ActionBarProps>> = prop
{label}
</ActionList.Item>
</ActionMenu.Anchor>
<ActionMenu.Overlay>
<ActionMenu.Overlay {...(returnFocusRef && {returnFocusRef})}>
<ActionList>{menuItems.map((item, index) => renderMenuItem(item, index))}</ActionList>
</ActionMenu.Overlay>
</ActionMenu>
Expand Down Expand Up @@ -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<HTMLButtonElement>(null)
const ref = (forwardedRef ?? backupRef) as RefObject<HTMLButtonElement>
const id = useId()
Expand All @@ -606,6 +614,7 @@ export const ActionBarMenu = forwardRef(
width: widthRef.current,
label: ariaLabel,
icon: overflowIcon ? overflowIcon : icon,
returnFocusRef,
items,
})

Expand All @@ -621,7 +630,7 @@ export const ActionBarMenu = forwardRef(
<ActionMenu.Anchor>
<IconButton variant="invisible" aria-label={ariaLabel} icon={icon} {...props} />
</ActionMenu.Anchor>
<ActionMenu.Overlay>
<ActionMenu.Overlay {...(returnFocusRef && {returnFocusRef})}>
<ActionList>{items.map((item, index) => renderMenuItem(item, index))}</ActionList>
</ActionMenu.Overlay>
</ActionMenu>
Expand Down
Loading