Skip to content

Commit ca6b4b1

Browse files
authored
ActionList: Enable focusZone for roles listbox and menu (#4795)
* enable focus zone for listbox and menu * Create actionlist-focuszone.md * fix other tests that depend on tab instead of ArrowDown * add failing test * disable focuszone inside ActionMenu * wrap focus zone for menus
1 parent 0112347 commit ca6b4b1

File tree

5 files changed

+80
-14
lines changed

5 files changed

+80
-14
lines changed

.changeset/lemon-candles-deny.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@primer/react": patch
3+
---
4+
5+
ActionList: Enable focusZone for roles listbox and menu

packages/react/src/ActionList/ActionList.test.tsx

Lines changed: 40 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -203,23 +203,22 @@ describe('ActionList', () => {
203203
it('should focus the button around the leading visual when tabbing to an inactive item', async () => {
204204
const component = HTMLRender(<SingleSelectListStory />)
205205
const inactiveOptionButton = await waitFor(() => component.getByRole('button', {name: projects[3].inactiveText}))
206-
const inactiveIndex = projects.findIndex(project => project.inactiveText === projects[3].inactiveText)
207-
208-
for (let i = 0; i < inactiveIndex; i++) {
209-
await userEvent.tab()
210-
}
211206

207+
await userEvent.tab() // get focus on first element
208+
await userEvent.keyboard('{ArrowDown}')
209+
await userEvent.keyboard('{ArrowDown}')
212210
expect(inactiveOptionButton).toHaveFocus()
213211
})
214212

215213
it('should behave as inactive if both inactiveText and loading props are passed', async () => {
216214
const component = HTMLRender(<SingleSelectListStory />)
217215
const inactiveOptionButton = await waitFor(() => component.getByRole('button', {name: projects[5].inactiveText}))
218-
const inactiveIndex = projects.findIndex(project => project.inactiveText === projects[5].inactiveText)
219216

220-
for (let i = 0; i < inactiveIndex; i++) {
221-
await userEvent.tab()
222-
}
217+
await userEvent.tab() // get focus on first element
218+
await userEvent.keyboard('{ArrowDown}')
219+
await userEvent.keyboard('{ArrowDown}')
220+
await userEvent.keyboard('{ArrowDown}')
221+
await userEvent.keyboard('{ArrowDown}')
223222

224223
expect(inactiveOptionButton).toHaveFocus()
225224
})
@@ -584,4 +583,36 @@ describe('ActionList', () => {
584583

585584
expect(mockOnSelect).toHaveBeenCalledTimes(1)
586585
})
586+
587+
it('should be navigatable with arrow keys for certain roles', async () => {
588+
HTMLRender(
589+
<ActionList role="listbox" aria-label="Select a project">
590+
<ActionList.Item role="option">Option 1</ActionList.Item>
591+
<ActionList.Item role="option">Option 2</ActionList.Item>
592+
<ActionList.Item role="option" disabled>
593+
Option 3
594+
</ActionList.Item>
595+
<ActionList.Item role="option">Option 4</ActionList.Item>
596+
<ActionList.Item role="option" inactiveText="Unavailable due to an outage">
597+
Option 5
598+
</ActionList.Item>
599+
</ActionList>,
600+
)
601+
602+
await userEvent.tab() // tab into the story, this should focus on the first button
603+
expect(document.activeElement).toHaveTextContent('Option 1')
604+
605+
await userEvent.keyboard('{ArrowDown}')
606+
expect(document.activeElement).toHaveTextContent('Option 2')
607+
608+
await userEvent.keyboard('{ArrowDown}')
609+
expect(document.activeElement).not.toHaveTextContent('Option 3') // option 3 is disabled
610+
expect(document.activeElement).toHaveTextContent('Option 4')
611+
612+
await userEvent.keyboard('{ArrowDown}')
613+
expect(document.activeElement).toHaveAccessibleName('Unavailable due to an outage')
614+
615+
await userEvent.keyboard('{ArrowUp}')
616+
expect(document.activeElement).toHaveTextContent('Option 4')
617+
})
587618
})

packages/react/src/ActionList/List.tsx

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -33,19 +33,25 @@ export const List = React.forwardRef<HTMLUListElement, ActionListProps>(
3333

3434
/** if list is inside a Menu, it will get a role from the Menu */
3535
const {
36-
listRole,
36+
listRole: listRoleFromContainer,
3737
listLabelledBy,
3838
selectionVariant: containerSelectionVariant, // TODO: Remove after DropdownMenu2 deprecation
39-
enableFocusZone,
39+
enableFocusZone: enableFocusZoneFromContainer,
4040
} = React.useContext(ActionListContainerContext)
4141

4242
const ariaLabelledBy = slots.heading ? slots.heading.props.id ?? headingId : listLabelledBy
43-
43+
const listRole = role || listRoleFromContainer
4444
const listRef = useProvidedRefOrCreate(forwardedRef as React.RefObject<HTMLUListElement>)
45+
46+
let enableFocusZone = false
47+
if (enableFocusZoneFromContainer !== undefined) enableFocusZone = enableFocusZoneFromContainer
48+
else if (listRole) enableFocusZone = ['menu', 'menubar', 'listbox'].includes(listRole)
49+
4550
useFocusZone({
4651
disabled: !enableFocusZone,
4752
containerRef: listRef,
4853
bindKeys: FocusKeys.ArrowVertical | FocusKeys.HomeAndEnd | FocusKeys.PageUpDown,
54+
focusOutBehavior: listRole === 'menu' ? 'wrap' : undefined,
4955
})
5056

5157
return (
@@ -54,14 +60,14 @@ export const List = React.forwardRef<HTMLUListElement, ActionListProps>(
5460
variant,
5561
selectionVariant: selectionVariant || containerSelectionVariant,
5662
showDividers,
57-
role: role || listRole,
63+
role: listRole,
5864
headingId,
5965
}}
6066
>
6167
{slots.heading}
6268
<ListBox
6369
sx={merge(styles, sxProp as SxProp)}
64-
role={role || listRole}
70+
role={listRole}
6571
aria-labelledby={ariaLabelledBy}
6672
{...props}
6773
ref={listRef}

packages/react/src/ActionMenu/ActionMenu.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -248,6 +248,7 @@ const Overlay: React.FC<React.PropsWithChildren<MenuOverlayProps>> = ({
248248
listLabelledBy: ariaLabelledby || anchorAriaLabelledby || anchorId,
249249
selectionAttribute: 'aria-checked', // Should this be here?
250250
afterSelect: () => onClose?.('item-select'),
251+
enableFocusZone: false, // AnchoredOverlay takes care of focus zone
251252
}}
252253
>
253254
{children}

packages/react/src/__tests__/ActionMenu.test.tsx

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -326,6 +326,29 @@ describe('ActionMenu', () => {
326326
})
327327
})
328328

329+
it('should wrap focus when ArrowDown is pressed on the last element', async () => {
330+
const component = HTMLRender(<Example />)
331+
const button = component.getByRole('button')
332+
333+
const user = userEvent.setup()
334+
await user.click(button)
335+
336+
expect(component.queryByRole('menu')).toBeInTheDocument()
337+
const menuItems = component.getAllByRole('menuitem')
338+
339+
await user.keyboard('{ArrowDown}')
340+
expect(menuItems[0]).toEqual(document.activeElement)
341+
342+
await user.keyboard('{ArrowDown}')
343+
await user.keyboard('{ArrowDown}')
344+
await user.keyboard('{ArrowDown}')
345+
await user.keyboard('{ArrowDown}')
346+
expect(menuItems[menuItems.length - 1]).toEqual(document.activeElement) // last elememt
347+
348+
await user.keyboard('{ArrowDown}')
349+
expect(menuItems[0]).toEqual(document.activeElement) // wrap to first
350+
})
351+
329352
it('should have no axe violations', async () => {
330353
const {container} = HTMLRender(<Example />)
331354
const results = await axe.run(container)

0 commit comments

Comments
 (0)