diff --git a/src/components/datagrid/__snapshots__/data_grid.test.tsx.snap b/src/components/datagrid/__snapshots__/data_grid.test.tsx.snap index 5d43120582c..7a3d934f899 100644 --- a/src/components/datagrid/__snapshots__/data_grid.test.tsx.snap +++ b/src/components/datagrid/__snapshots__/data_grid.test.tsx.snap @@ -1065,7 +1065,6 @@ Array [ />
-
- , - "onClick": [Function], - "size": "xs", - }, - Object { - "color": "text", - "iconType": "sortLeft", - "isDisabled": false, - "label": , - "onClick": [Function], - "size": "xs", - }, - Object { - "color": "text", - "iconType": "sortRight", - "isDisabled": true, - "label": , - "onClick": [Function], - "size": "xs", - }, - ] - } + panelProps={ + Object { + "onKeyDown": [Function], + } + } + panelRef={[Function]} + popoverScreenReaderText={ + -
+ } + > + , + "onClick": [Function], + "size": "xs", + }, + Object { + "color": "text", + "iconType": "sortLeft", + "isDisabled": false, + "label": , + "onClick": [Function], + "size": "xs", + }, + Object { + "color": "text", + "iconType": "sortRight", + "isDisabled": true, + "label": , + "onClick": [Function], + "size": "xs", + }, + ] + } + /> `; diff --git a/src/components/datagrid/body/header/data_grid_header_cell.test.tsx b/src/components/datagrid/body/header/data_grid_header_cell.test.tsx index 2e26839e72f..67321c83227 100644 --- a/src/components/datagrid/body/header/data_grid_header_cell.test.tsx +++ b/src/components/datagrid/body/header/data_grid_header_cell.test.tsx @@ -8,13 +8,17 @@ import React from 'react'; import { mount, shallow } from 'enzyme'; +import { testCustomHook } from '../../../../test/internal'; import { EuiDataGridSorting } from '../../data_grid_types'; import { DataGridSortingContext } from '../../utils/sorting'; import { DataGridFocusContext } from '../../utils/focus'; import { mockFocusContext } from '../../utils/__mocks__/focus_context'; -import { EuiDataGridHeaderCell } from './data_grid_header_cell'; +import { + EuiDataGridHeaderCell, + usePopoverArrowNavigation, +} from './data_grid_header_cell'; describe('EuiDataGridHeaderCell', () => { const requiredProps = { @@ -164,5 +168,114 @@ describe('EuiDataGridHeaderCell', () => { expect(component.find('EuiPopover').prop('isOpen')).toEqual(false); }); + + describe('keyboard arrow navigation', () => { + const { + return: { + panelRef, + panelProps: { onKeyDown }, + }, + } = testCustomHook(usePopoverArrowNavigation); + + const mockPanel = document.createElement('div'); + mockPanel.setAttribute('tabindex', '-1'); + mockPanel.innerHTML = ` + + + + `; + panelRef(mockPanel); + + const preventDefault = jest.fn(); + beforeEach(() => jest.clearAllMocks()); + + describe('early returns', () => { + it('does nothing if the up/down arrow keys are not pressed', () => { + onKeyDown({ key: 'Tab', preventDefault }); + expect(preventDefault).not.toHaveBeenCalled(); + }); + + it('does nothing if the popover contains no tabbable elements', () => { + const emptyDiv = document.createElement('div'); + panelRef(emptyDiv); + onKeyDown({ key: 'ArrowDown', preventDefault }); + expect(preventDefault).not.toHaveBeenCalled(); + + panelRef(mockPanel); // Reset for other tests + }); + }); + + describe('when the popover panel is focused (on initial open state)', () => { + beforeEach(() => mockPanel.focus()); + + it('focuses the first action when the arrow down key is pressed', () => { + onKeyDown({ key: 'ArrowDown', preventDefault }); + expect(preventDefault).toHaveBeenCalled(); + expect( + document.activeElement?.getAttribute('data-test-subj') + ).toEqual('first'); + }); + + it('focuses the last action when the arrow up key is pressed', () => { + onKeyDown({ key: 'ArrowUp', preventDefault }); + expect(preventDefault).toHaveBeenCalled(); + expect( + document.activeElement?.getAttribute('data-test-subj') + ).toEqual('last'); + }); + }); + + describe('when already focused on action buttons', () => { + describe('down arrow key', () => { + beforeAll(() => + (mockPanel.firstElementChild as HTMLButtonElement).focus() + ); + + it('moves focus to the the next action', () => { + onKeyDown({ key: 'ArrowDown', preventDefault }); + expect( + document.activeElement?.getAttribute('data-test-subj') + ).toEqual('second'); + + onKeyDown({ key: 'ArrowDown', preventDefault }); + expect( + document.activeElement?.getAttribute('data-test-subj') + ).toEqual('last'); + }); + + it('loops focus back to the first action when pressing down on the last action', () => { + onKeyDown({ key: 'ArrowDown', preventDefault }); + expect( + document.activeElement?.getAttribute('data-test-subj') + ).toEqual('first'); + }); + }); + + describe('up arrow key', () => { + beforeAll(() => + (mockPanel.lastElementChild as HTMLButtonElement).focus() + ); + + it('moves focus to the previous action', () => { + onKeyDown({ key: 'ArrowUp', preventDefault }); + expect( + document.activeElement?.getAttribute('data-test-subj') + ).toEqual('second'); + + onKeyDown({ key: 'ArrowUp', preventDefault }); + expect( + document.activeElement?.getAttribute('data-test-subj') + ).toEqual('first'); + }); + + it('loops focus back to the last action when pressing up on the first action', () => { + onKeyDown({ key: 'ArrowUp', preventDefault }); + expect( + document.activeElement?.getAttribute('data-test-subj') + ).toEqual('last'); + }); + }); + }); + }); }); }); diff --git a/src/components/datagrid/body/header/data_grid_header_cell.tsx b/src/components/datagrid/body/header/data_grid_header_cell.tsx index f2b371d4af0..b08daf68425 100644 --- a/src/components/datagrid/body/header/data_grid_header_cell.tsx +++ b/src/components/datagrid/body/header/data_grid_header_cell.tsx @@ -13,10 +13,14 @@ import React, { HTMLAttributes, useContext, useState, + useRef, + useCallback, } from 'react'; +import { tabbable, FocusableElement } from 'tabbable'; +import { keys } from '../../../../services'; import { useGeneratedHtmlId } from '../../../../services/accessibility'; import { EuiScreenReaderOnly } from '../../../accessibility'; -import { useEuiI18n } from '../../../i18n'; +import { useEuiI18n, EuiI18n } from '../../../i18n'; import { EuiIcon } from '../../../icon'; import { EuiListGroup } from '../../../list_group'; import { EuiPopover } from '../../../popover'; @@ -90,6 +94,7 @@ export const EuiDataGridHeaderCell: FunctionComponent setIsPopoverOpen(false)} + {...popoverArrowNavigationProps} > -
- -
+ )} ); }; + +/** + * Add keyboard arrow navigation to the cell actions popover + * to match the UX of the rest of EuiDataGrid + */ +export const usePopoverArrowNavigation = () => { + const popoverPanelRef = useRef(null); + const actionsRef = useRef(undefined); + const panelRef = useCallback((ref) => { + popoverPanelRef.current = ref; + actionsRef.current = ref ? tabbable(ref) : undefined; + }, []); + + const onKeyDown = useCallback((e: React.KeyboardEvent) => { + if (e.key !== keys.ARROW_DOWN && e.key !== keys.ARROW_UP) return; + if (!actionsRef.current?.length) return; + + e.preventDefault(); + + const initialState = document.activeElement === popoverPanelRef.current; + const currentIndex = !initialState + ? actionsRef.current.findIndex((el) => document.activeElement === el) + : -1; + const lastIndex = actionsRef.current.length - 1; + + let indexToFocus: number; + if (initialState) { + if (e.key === keys.ARROW_DOWN) { + indexToFocus = 0; + } else if (e.key === keys.ARROW_UP) { + indexToFocus = lastIndex; + } + } else { + if (e.key === keys.ARROW_DOWN) { + indexToFocus = currentIndex + 1; + if (indexToFocus > lastIndex) { + indexToFocus = 0; + } + } else if (e.key === keys.ARROW_UP) { + indexToFocus = currentIndex - 1; + if (indexToFocus < 0) { + indexToFocus = lastIndex; + } + } + } + + actionsRef.current[indexToFocus!].focus(); + }, []); + + return { + panelRef, + panelProps: { onKeyDown }, + popoverScreenReaderText: ( + + ), + }; +}; diff --git a/src/components/popover/__snapshots__/popover.test.tsx.snap b/src/components/popover/__snapshots__/popover.test.tsx.snap index 916f744d92d..ea4ba6dfc88 100644 --- a/src/components/popover/__snapshots__/popover.test.tsx.snap +++ b/src/components/popover/__snapshots__/popover.test.tsx.snap @@ -397,7 +397,6 @@ exports[`EuiPopover props offset with arrow 1`] = `
`; + +exports[`EuiPopover props popoverScreenReaderText 1`] = ` +
+
+
+
+
+
+
+ +`; diff --git a/src/components/popover/popover.test.tsx b/src/components/popover/popover.test.tsx index 7dbce260642..f49150f6218 100644 --- a/src/components/popover/popover.test.tsx +++ b/src/components/popover/popover.test.tsx @@ -381,6 +381,23 @@ describe('EuiPopover', () => { expect(component.render()).toMatchSnapshot(); }); + + test('popoverScreenReaderText', () => { + const component = mount( +
+ } + closePopover={() => {}} + isOpen + ownFocus={false} + popoverScreenReaderText="Press the up/down arrow keys to navigate" + /> +
+ ); + + expect(component.render()).toMatchSnapshot(); + }); }); describe('listener cleanup', () => { diff --git a/src/components/popover/popover.tsx b/src/components/popover/popover.tsx index 850e2902c64..9a2f642fb35 100644 --- a/src/components/popover/popover.tsx +++ b/src/components/popover/popover.tsx @@ -149,6 +149,13 @@ export interface EuiPopoverProps { */ panelProps?: Omit; panelRef?: RefCallback; + /** + * Optional screen reader instructions to announce upon popover open, + * in addition to EUI's default popover instructions for Escape on close. + * Useful for popovers that may have additional keyboard capabilities such as + * arrow navigation. + */ + popoverScreenReaderText?: string | ReactNode; popoverRef?: Ref; /** * When `true`, the popover's position is re-calculated when the user @@ -699,6 +706,7 @@ export class EuiPopover extends Component { panelProps, panelRef, panelStyle, + popoverScreenReaderText, popoverRef, hasArrow, arrowChildren, @@ -707,6 +715,7 @@ export class EuiPopover extends Component { initialFocus, attachToAnchor, display, + offset, onTrapDeactivation, buffer, 'aria-label': ariaLabel, @@ -764,15 +773,18 @@ export class EuiPopover extends Component { } let focusTrapScreenReaderText; - if (ownFocus) { + if (ownFocus || popoverScreenReaderText) { ariaDescribedby = this.descriptionId; focusTrapScreenReaderText = (

- + {ownFocus && ( + + )} + {popoverScreenReaderText}

); diff --git a/src/components/tour/__snapshots__/tour_step.test.tsx.snap b/src/components/tour/__snapshots__/tour_step.test.tsx.snap index d2dabb3313d..c4eeda3bcf2 100644 --- a/src/components/tour/__snapshots__/tour_step.test.tsx.snap +++ b/src/components/tour/__snapshots__/tour_step.test.tsx.snap @@ -4,7 +4,6 @@ exports[`EuiTourStep can be closed 1`] = `