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)