- -
Press the Enter key to interact with this cell's contents.
@@ -154,7 +160,7 @@ describe('FocusTrappedChildren', () => {
describe('on enter', () => {
it('enables the focus trap, all interactive children, and moves focus to the first focusable child', () => {
- const cell = renderCellWithInteractiveChilren();
+ const cell = renderCellWithInteractiveChildren();
const { container } = render();
fireEvent.keyUp(cell, { key: 'Enter' });
@@ -168,7 +174,7 @@ describe('FocusTrappedChildren', () => {
});
it('allows pressing F2 to enter as well', () => {
- const cell = renderCellWithInteractiveChilren();
+ const cell = renderCellWithInteractiveChildren();
render();
fireEvent.keyUp(cell, { key: 'F2' });
@@ -183,23 +189,26 @@ describe('FocusTrappedChildren', () => {
.spyOn(window, 'requestAnimationFrame')
.mockImplementation((cb: Function) => cb());
- it('disables the focus trap, all interactive children and moves focus to the cell wrapper', () => {
- const cell = renderCellWithInteractiveChilren();
+ it('disables the focus trap, all interactive children and moves focus to the cell wrapper', async () => {
+ const cell = renderCellWithInteractiveChildren();
const { container } = render();
+
fireEvent.keyUp(cell, { key: 'Enter' });
fireEvent.keyUp(cell, { key: 'Escape' });
- expect(
- container.querySelector('[data-focus-lock-disabled]')
- ).toHaveAttribute('data-focus-lock-disabled', 'disabled');
+ await waitFor(() => {
+ expect(
+ container.querySelector('[data-focus-lock-disabled]')
+ ).toHaveAttribute('data-focus-lock-disabled', 'disabled');
- expect(cell.querySelector('button')).toHaveAttribute('tabindex', '-1');
- expect(cell).toHaveFocus();
+ expect(cell.querySelector('button')).toHaveAttribute('tabindex', '-1');
+ expect(cell).toHaveFocus();
+ });
});
it('does nothing if the cell is not entered', () => {
- const cell = renderCellWithInteractiveChilren();
+ const cell = renderCellWithInteractiveChildren();
render();
fireEvent.keyUp(cell, { key: 'Escape' });
diff --git a/packages/eui/src/components/datagrid/body/cell/focus_utils.tsx b/packages/eui/src/components/datagrid/body/cell/focus_utils.tsx
index 4d425244f83..32e46ba9c82 100644
--- a/packages/eui/src/components/datagrid/body/cell/focus_utils.tsx
+++ b/packages/eui/src/components/datagrid/body/cell/focus_utils.tsx
@@ -13,9 +13,11 @@ import React, {
useState,
useMemo,
} from 'react';
-import { tabbable } from 'tabbable';
+import { FocusableElement, tabbable } from 'tabbable';
import { keys } from '../../../../services';
+import { useGeneratedHtmlId } from '../../../../services/accessibility';
+import { isDOMNode } from '../../../../utils';
import { EuiFocusTrap } from '../../../focus_trap';
import { EuiScreenReaderOnly } from '../../../accessibility';
import { EuiI18n } from '../../../i18n';
@@ -35,20 +37,27 @@ export const HandleInteractiveChildren: FunctionComponent<
cellEl?: HTMLElement | null;
updateCellFocusContext: Function;
renderFocusTrap?: boolean;
+ onInteractiveChildrenFound?: (
+ interactiveChildren: FocusableElement[]
+ ) => void;
}
-> = ({ cellEl, children, updateCellFocusContext, renderFocusTrap }) => {
+> = ({
+ cellEl,
+ children,
+ updateCellFocusContext,
+ renderFocusTrap,
+ onInteractiveChildrenFound,
+}) => {
const [hasInteractiveChildren, setHasInteractiveChildren] = useState(false);
// On mount, disable all interactive children
useEffect(() => {
if (cellEl) {
- const interactiveChildren = disableInteractives(cellEl);
-
- if (renderFocusTrap) {
- setHasInteractiveChildren(interactiveChildren!.length > 0);
- }
+ const interactives = disableInteractives(cellEl);
+ onInteractiveChildrenFound?.(interactives);
+ setHasInteractiveChildren(interactives.length > 0);
}
- }, [cellEl, renderFocusTrap]);
+ }, [cellEl, onInteractiveChildrenFound]);
// Ensure that any interactive children that are clicked update the latest cell focus context
useEffect(() => {
@@ -80,6 +89,28 @@ export const FocusTrappedChildren: FunctionComponent<
PropsWithChildren & { cellEl: HTMLElement }
> = ({ cellEl, children }) => {
const [isCellEntered, setIsCellEntered] = useState(false);
+ const [isExited, setExited] = useState(false);
+
+ const keyboardHintAriaId = useGeneratedHtmlId({
+ prefix: 'euiDataGridCellHeader',
+ suffix: 'keyboardHint',
+ });
+
+ const exitedHintAriaId = useGeneratedHtmlId({
+ prefix: 'euiDataGridCellHeader',
+ suffix: 'exited',
+ });
+
+ // direct DOM manipulation as workaround to attach required hints
+ useEffect(() => {
+ const currentAriaDescribedbyId = cellEl.getAttribute('aria-describedby');
+
+ cellEl.setAttribute(
+ 'aria-describedby',
+ `${currentAriaDescribedbyId} ${exitedHintAriaId} ${keyboardHintAriaId} `
+ );
+ }, [cellEl, keyboardHintAriaId, exitedHintAriaId]);
+
useEffect(() => {
if (isCellEntered) {
enableAndFocusInteractives(cellEl);
@@ -101,36 +132,75 @@ export const FocusTrappedChildren: FunctionComponent<
event.preventDefault();
setIsCellEntered((isCellEntered) => {
if (isCellEntered === true) {
+ setExited(true);
requestAnimationFrame(() => cellEl.focus()); // move focus to cell
return false;
+ } else if (
+ // when opened content is closed, we don't want Escape to return to the cell
+ // immediately but instead return focus to a trigger as expected
+ isCellEntered === false &&
+ isDOMNode(event.target) &&
+ isDOMNode(event.currentTarget) &&
+ event.currentTarget.contains(event.target)
+ ) {
+ return true;
}
+
return isCellEntered;
});
break;
}
};
+
+ // ensures the SR text is reset when navigating to a different cell
+ const onBlur = () => setExited(false);
+
cellEl.addEventListener('keyup', onKeyUp);
+ cellEl.addEventListener('blur', onBlur);
+
return () => {
cellEl.removeEventListener('keyup', onKeyUp);
+ cellEl.removeEventListener('blur', onBlur);
};
}, [cellEl]);
return (
setIsCellEntered(false)}
clickOutsideDisables={true}
+ onDeactivation={() => setIsCellEntered(false)}
>
{children}
-
- {' - '}
-
+ {/**
+ * Hints use aria-hidden to prevent them from being read as regular content.
+ * They are still read in JAWS and NVDA via the linking with aria-describedby.
+ * VoiceOver does generally not read the column on re-focus after exiting a cell,
+ * which mean the exited hint is not read.
+ * VoiceOver does react to aria-live (without aria-hidden) but that would causes
+ * duplicate output in JAWS/NVDA (reading content & live announcement).
+ * Optimizing for Windows screen readers as they have a larger usages.
+ */}
+
+ {isExited && (
+
+ )}
+
+
+
+
+ {!isCellEntered && (
+
+ )}
@@ -154,7 +224,8 @@ const enableAndFocusInteractives = (cell: HTMLElement) => {
const interactives = cell.querySelectorAll('[data-euigrid-tab-managed]');
interactives.forEach((element, i) => {
element.setAttribute('tabIndex', '0');
- if (i === 0) {
+ // focus the first element only if we're on the cell and not inside of it
+ if (i === 0 && !cell.contains(document.activeElement)) {
(element as HTMLElement).focus();
}
});
diff --git a/packages/eui/src/components/datagrid/body/data_grid_body_custom.spec.tsx b/packages/eui/src/components/datagrid/body/data_grid_body_custom.spec.tsx
index 5fa2ba194e8..bce75af9857 100644
--- a/packages/eui/src/components/datagrid/body/data_grid_body_custom.spec.tsx
+++ b/packages/eui/src/components/datagrid/body/data_grid_body_custom.spec.tsx
@@ -135,6 +135,7 @@ describe('EuiDataGridBodyCustomRender', () => {
'[data-gridcell-row-index="0"][data-gridcell-column-index="1"]'
).contains('B,0');
+ cy.get('[data-test-subj="dataGridHeaderCellActionButton-A"]').realHover();
cy.get('[data-test-subj="dataGridHeaderCellActionButton-A"]').click();
cy.contains('Move right').click();
@@ -160,6 +161,7 @@ describe('EuiDataGridBodyCustomRender', () => {
cy.realMount();
cy.get('[role="gridcell"]').first().contains('A,0');
+ cy.get('[data-test-subj="dataGridHeaderCellActionButton-A"]').realHover();
cy.get('[data-test-subj="dataGridHeaderCellActionButton-A"]').click();
cy.contains('Sort High-Low').click();
diff --git a/packages/eui/src/components/datagrid/body/header/__snapshots__/data_grid_header_cell.test.tsx.snap b/packages/eui/src/components/datagrid/body/header/__snapshots__/data_grid_header_cell.test.tsx.snap
index e0ab6fc8903..dbeb0d922ee 100644
--- a/packages/eui/src/components/datagrid/body/header/__snapshots__/data_grid_header_cell.test.tsx.snap
+++ b/packages/eui/src/components/datagrid/body/header/__snapshots__/data_grid_header_cell.test.tsx.snap
@@ -2,6 +2,7 @@
exports[`EuiDataGridHeaderCell renders 1`] = `
-
-