diff --git a/.changeset/wicked-books-occur.md b/.changeset/wicked-books-occur.md new file mode 100644 index 00000000000..5fbb6f05ceb --- /dev/null +++ b/.changeset/wicked-books-occur.md @@ -0,0 +1,7 @@ +--- +"@primer/react": minor +--- + +SelectPanel: Support PageDown and PageUp for keyboard navigation + +SelectPanel: Label `listbox` by the title of the panel diff --git a/packages/react/src/FilteredActionList/FilteredActionListWithModernActionList.tsx b/packages/react/src/FilteredActionList/FilteredActionListWithModernActionList.tsx index 0d99b359275..ae791e63909 100644 --- a/packages/react/src/FilteredActionList/FilteredActionListWithModernActionList.tsx +++ b/packages/react/src/FilteredActionList/FilteredActionListWithModernActionList.tsx @@ -1,5 +1,5 @@ import type {ScrollIntoViewOptions} from '@primer/behaviors' -import {scrollIntoView} from '@primer/behaviors' +import {scrollIntoView, FocusKeys} from '@primer/behaviors' import type {KeyboardEventHandler} from 'react' import React, {useCallback, useEffect, useRef} from 'react' import styled from 'styled-components' @@ -86,6 +86,7 @@ export function FilteredActionList({ useFocusZone( { containerRef: listContainerRef, + bindKeys: FocusKeys.ArrowVertical | FocusKeys.PageUpDown, focusOutBehavior: 'wrap', focusableElementFilter: element => { return !(element instanceof HTMLInputElement) diff --git a/packages/react/src/SelectPanel/SelectPanel.test.tsx b/packages/react/src/SelectPanel/SelectPanel.test.tsx index 9d6cec1f9cb..7d6adfbb3ae 100644 --- a/packages/react/src/SelectPanel/SelectPanel.test.tsx +++ b/packages/react/src/SelectPanel/SelectPanel.test.tsx @@ -24,7 +24,7 @@ const items: SelectPanelProps['items'] = [ }, ] -function BasicSelectPanel() { +function BasicSelectPanel(passthroughProps: Record) { const [selected, setSelected] = React.useState([]) const [filter, setFilter] = React.useState('') const [open, setOpen] = React.useState(false) @@ -51,6 +51,7 @@ function BasicSelectPanel() { onOpenChange={isOpen => { setOpen(isOpen) }} + {...passthroughProps} /> ) @@ -200,6 +201,22 @@ for (const useModernActionList of [false, true]) { expect(onOpenChange).toHaveBeenLastCalledWith(false, 'click-outside') }) + it('should label the list by title unless a aria-label is explicitly passed', async () => { + const user = userEvent.setup() + + renderWithFlag(, useModernActionList) + await user.click(screen.getByText('Select items')) + expect(screen.getByRole('listbox', {name: 'test title'})).toBeInTheDocument() + }) + + it('should label the list by aria-label when explicitly passed', async () => { + const user = userEvent.setup() + + renderWithFlag(, useModernActionList) + await user.click(screen.getByText('Select items')) + expect(screen.getByRole('listbox', {name: 'Custom label'})).toBeInTheDocument() + }) + describe('selection', () => { it('should select an active option when activated', async () => { const user = userEvent.setup() @@ -288,6 +305,35 @@ for (const useModernActionList of [false, true]) { screen.getByRole('option', {name: 'item one'}).id, ) }) + + it('should support navigating through items with PageDown and PageUp', async () => { + if (!useModernActionList) return // this feature is only enabled with feature flag on + + const user = userEvent.setup() + + renderWithFlag(, useModernActionList) + + await user.click(screen.getByText('Select items')) + + // First item by default should be the active element + expect(document.activeElement!).toHaveAttribute( + 'aria-activedescendant', + screen.getByRole('option', {name: 'item one'}).id, + ) + + await user.type(document.activeElement!, '{PageDown}') + + expect(document.activeElement!).toHaveAttribute( + 'aria-activedescendant', + screen.getByRole('option', {name: 'item three'}).id, + ) + + await user.type(document.activeElement!, '{PageUp}') + expect(document.activeElement!).toHaveAttribute( + 'aria-activedescendant', + screen.getByRole('option', {name: 'item one'}).id, + ) + }) }) describe('filtering', () => { diff --git a/packages/react/src/SelectPanel/SelectPanel.tsx b/packages/react/src/SelectPanel/SelectPanel.tsx index 5763081361b..edf35fafee6 100644 --- a/packages/react/src/SelectPanel/SelectPanel.tsx +++ b/packages/react/src/SelectPanel/SelectPanel.tsx @@ -218,6 +218,9 @@ export function SelectPanel({ placeholderText={placeholderText} {...listProps} role="listbox" + // browsers give aria-labelledby precedence over aria-label so we need to make sure + // we don't accidentally override props.aria-label + aria-labelledby={listProps['aria-label'] ? undefined : titleId} aria-multiselectable={isMultiSelectVariant(selected) ? 'true' : 'false'} selectionVariant={isMultiSelectVariant(selected) ? 'multiple' : 'single'} items={itemsToRender} diff --git a/packages/react/src/deprecated/ActionList/List.tsx b/packages/react/src/deprecated/ActionList/List.tsx index d873ea97bb1..4e9a7b4b737 100644 --- a/packages/react/src/deprecated/ActionList/List.tsx +++ b/packages/react/src/deprecated/ActionList/List.tsx @@ -37,6 +37,11 @@ export interface ListPropsBase { */ id?: string + /** + * aria-label to attach to the base DOM node of the list + */ + 'aria-label'?: string + /** * A `List`-level custom `Item` renderer. Every `Item` within this `List` * without a `Group`-level or `Item`-level custom `Item` renderer will be