diff --git a/.changeset/lemon-candles-deny.md b/.changeset/lemon-candles-deny.md
new file mode 100644
index 00000000000..afb5158ad1c
--- /dev/null
+++ b/.changeset/lemon-candles-deny.md
@@ -0,0 +1,5 @@
+---
+"@primer/react": patch
+---
+
+ActionList: Enable focusZone for roles listbox and menu
diff --git a/packages/react/src/ActionList/ActionList.test.tsx b/packages/react/src/ActionList/ActionList.test.tsx
index 06a35990b2c..8524b35dd91 100644
--- a/packages/react/src/ActionList/ActionList.test.tsx
+++ b/packages/react/src/ActionList/ActionList.test.tsx
@@ -205,23 +205,22 @@ describe('ActionList', () => {
it('should focus the button around the leading visual when tabbing to an inactive item', async () => {
const component = HTMLRender()
const inactiveOptionButton = await waitFor(() => component.getByRole('button', {name: projects[3].inactiveText}))
- const inactiveIndex = projects.findIndex(project => project.inactiveText === projects[3].inactiveText)
-
- for (let i = 0; i < inactiveIndex; i++) {
- await userEvent.tab()
- }
+ await userEvent.tab() // get focus on first element
+ await userEvent.keyboard('{ArrowDown}')
+ await userEvent.keyboard('{ArrowDown}')
expect(inactiveOptionButton).toHaveFocus()
})
it('should behave as inactive if both inactiveText and loading props are passed', async () => {
const component = HTMLRender()
const inactiveOptionButton = await waitFor(() => component.getByRole('button', {name: projects[5].inactiveText}))
- const inactiveIndex = projects.findIndex(project => project.inactiveText === projects[5].inactiveText)
- for (let i = 0; i < inactiveIndex; i++) {
- await userEvent.tab()
- }
+ await userEvent.tab() // get focus on first element
+ await userEvent.keyboard('{ArrowDown}')
+ await userEvent.keyboard('{ArrowDown}')
+ await userEvent.keyboard('{ArrowDown}')
+ await userEvent.keyboard('{ArrowDown}')
expect(inactiveOptionButton).toHaveFocus()
})
@@ -590,4 +589,36 @@ describe('ActionList', () => {
expect(mockOnSelect).toHaveBeenCalledTimes(1)
})
+
+ it('should be navigatable with arrow keys for certain roles', async () => {
+ HTMLRender(
+
+ Option 1
+ Option 2
+
+ Option 3
+
+ Option 4
+
+ Option 5
+
+ ,
+ )
+
+ await userEvent.tab() // tab into the story, this should focus on the first button
+ expect(document.activeElement).toHaveTextContent('Option 1')
+
+ await userEvent.keyboard('{ArrowDown}')
+ expect(document.activeElement).toHaveTextContent('Option 2')
+
+ await userEvent.keyboard('{ArrowDown}')
+ expect(document.activeElement).not.toHaveTextContent('Option 3') // option 3 is disabled
+ expect(document.activeElement).toHaveTextContent('Option 4')
+
+ await userEvent.keyboard('{ArrowDown}')
+ expect(document.activeElement).toHaveAccessibleName('Unavailable due to an outage')
+
+ await userEvent.keyboard('{ArrowUp}')
+ expect(document.activeElement).toHaveTextContent('Option 4')
+ })
})
diff --git a/packages/react/src/ActionList/List.tsx b/packages/react/src/ActionList/List.tsx
index c6dc26d415a..7786f392682 100644
--- a/packages/react/src/ActionList/List.tsx
+++ b/packages/react/src/ActionList/List.tsx
@@ -33,19 +33,25 @@ export const List = React.forwardRef(
/** if list is inside a Menu, it will get a role from the Menu */
const {
- listRole,
+ listRole: listRoleFromContainer,
listLabelledBy,
selectionVariant: containerSelectionVariant, // TODO: Remove after DropdownMenu2 deprecation
- enableFocusZone,
+ enableFocusZone: enableFocusZoneFromContainer,
} = React.useContext(ActionListContainerContext)
const ariaLabelledBy = slots.heading ? slots.heading.props.id ?? headingId : listLabelledBy
-
+ const listRole = role || listRoleFromContainer
const listRef = useProvidedRefOrCreate(forwardedRef as React.RefObject)
+
+ let enableFocusZone = false
+ if (enableFocusZoneFromContainer !== undefined) enableFocusZone = enableFocusZoneFromContainer
+ else if (listRole) enableFocusZone = ['menu', 'menubar', 'listbox'].includes(listRole)
+
useFocusZone({
disabled: !enableFocusZone,
containerRef: listRef,
bindKeys: FocusKeys.ArrowVertical | FocusKeys.HomeAndEnd | FocusKeys.PageUpDown,
+ focusOutBehavior: listRole === 'menu' ? 'wrap' : undefined,
})
return (
@@ -54,14 +60,14 @@ export const List = React.forwardRef(
variant,
selectionVariant: selectionVariant || containerSelectionVariant,
showDividers,
- role: role || listRole,
+ role: listRole,
headingId,
}}
>
{slots.heading}
> = ({
listLabelledBy: ariaLabelledby || anchorAriaLabelledby || anchorId,
selectionAttribute: 'aria-checked', // Should this be here?
afterSelect: () => onClose?.('item-select'),
+ enableFocusZone: false, // AnchoredOverlay takes care of focus zone
}}
>
{children}
diff --git a/packages/react/src/__tests__/ActionMenu.test.tsx b/packages/react/src/__tests__/ActionMenu.test.tsx
index 91533a83b3a..c3054754512 100644
--- a/packages/react/src/__tests__/ActionMenu.test.tsx
+++ b/packages/react/src/__tests__/ActionMenu.test.tsx
@@ -334,6 +334,29 @@ describe('ActionMenu', () => {
})
})
+ it('should wrap focus when ArrowDown is pressed on the last element', async () => {
+ const component = HTMLRender()
+ const button = component.getByRole('button')
+
+ const user = userEvent.setup()
+ await user.click(button)
+
+ expect(component.queryByRole('menu')).toBeInTheDocument()
+ const menuItems = component.getAllByRole('menuitem')
+
+ await user.keyboard('{ArrowDown}')
+ expect(menuItems[0]).toEqual(document.activeElement)
+
+ await user.keyboard('{ArrowDown}')
+ await user.keyboard('{ArrowDown}')
+ await user.keyboard('{ArrowDown}')
+ await user.keyboard('{ArrowDown}')
+ expect(menuItems[menuItems.length - 1]).toEqual(document.activeElement) // last elememt
+
+ await user.keyboard('{ArrowDown}')
+ expect(menuItems[0]).toEqual(document.activeElement) // wrap to first
+ })
+
it('should have no axe violations', async () => {
const {container} = HTMLRender()
const results = await axe.run(container)