-
- ,
- "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`] = `
+
+
+
+
+
+
+
+
+
+
+
+ Press the up/down arrow keys to navigate
+
+
+
+
+
+
+
+`;
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`] = `