diff --git a/packages/eui/.loki/reference/chrome_desktop_Tabular_Content_EuiDataGrid_Custom_Header_Content.png b/packages/eui/.loki/reference/chrome_desktop_Tabular_Content_EuiDataGrid_Custom_Header_Content.png new file mode 100644 index 00000000000..e36b846da53 Binary files /dev/null and b/packages/eui/.loki/reference/chrome_desktop_Tabular_Content_EuiDataGrid_Custom_Header_Content.png differ diff --git a/packages/eui/.loki/reference/chrome_desktop_Tabular_Content_EuiDataGrid_Custom_Row_Heights.png b/packages/eui/.loki/reference/chrome_desktop_Tabular_Content_EuiDataGrid_Custom_Row_Heights.png index 1123c5f2d5e..c47421ba06f 100644 Binary files a/packages/eui/.loki/reference/chrome_desktop_Tabular_Content_EuiDataGrid_Custom_Row_Heights.png and b/packages/eui/.loki/reference/chrome_desktop_Tabular_Content_EuiDataGrid_Custom_Row_Heights.png differ diff --git a/packages/eui/.loki/reference/chrome_desktop_Tabular_Content_EuiDataGrid_Height_Line_Count.png b/packages/eui/.loki/reference/chrome_desktop_Tabular_Content_EuiDataGrid_Height_Line_Count.png index f60f11a7af2..17b889f1a9b 100644 Binary files a/packages/eui/.loki/reference/chrome_desktop_Tabular_Content_EuiDataGrid_Height_Line_Count.png and b/packages/eui/.loki/reference/chrome_desktop_Tabular_Content_EuiDataGrid_Height_Line_Count.png differ diff --git a/packages/eui/.loki/reference/chrome_desktop_Tabular_Content_EuiDataGrid_Playground.png b/packages/eui/.loki/reference/chrome_desktop_Tabular_Content_EuiDataGrid_Playground.png index acf4fa3a9d6..f6ec8696489 100644 Binary files a/packages/eui/.loki/reference/chrome_desktop_Tabular_Content_EuiDataGrid_Playground.png and b/packages/eui/.loki/reference/chrome_desktop_Tabular_Content_EuiDataGrid_Playground.png differ diff --git a/packages/eui/.loki/reference/chrome_desktop_Tabular_Content_EuiDataGrid_Row_Height.png b/packages/eui/.loki/reference/chrome_desktop_Tabular_Content_EuiDataGrid_Row_Height.png index 69f0d932a28..9543bc4e2f4 100644 Binary files a/packages/eui/.loki/reference/chrome_desktop_Tabular_Content_EuiDataGrid_Row_Height.png and b/packages/eui/.loki/reference/chrome_desktop_Tabular_Content_EuiDataGrid_Row_Height.png differ diff --git a/packages/eui/.loki/reference/chrome_mobile_Tabular_Content_EuiDataGrid_Custom_Header_Content.png b/packages/eui/.loki/reference/chrome_mobile_Tabular_Content_EuiDataGrid_Custom_Header_Content.png new file mode 100644 index 00000000000..7048ecc417a Binary files /dev/null and b/packages/eui/.loki/reference/chrome_mobile_Tabular_Content_EuiDataGrid_Custom_Header_Content.png differ diff --git a/packages/eui/.loki/reference/chrome_mobile_Tabular_Content_EuiDataGrid_Custom_Row_Heights.png b/packages/eui/.loki/reference/chrome_mobile_Tabular_Content_EuiDataGrid_Custom_Row_Heights.png index 9c8410a7672..766b45e81a8 100644 Binary files a/packages/eui/.loki/reference/chrome_mobile_Tabular_Content_EuiDataGrid_Custom_Row_Heights.png and b/packages/eui/.loki/reference/chrome_mobile_Tabular_Content_EuiDataGrid_Custom_Row_Heights.png differ diff --git a/packages/eui/.loki/reference/chrome_mobile_Tabular_Content_EuiDataGrid_Height_Line_Count.png b/packages/eui/.loki/reference/chrome_mobile_Tabular_Content_EuiDataGrid_Height_Line_Count.png index 951f45dcabf..9cdaa3ee10a 100644 Binary files a/packages/eui/.loki/reference/chrome_mobile_Tabular_Content_EuiDataGrid_Height_Line_Count.png and b/packages/eui/.loki/reference/chrome_mobile_Tabular_Content_EuiDataGrid_Height_Line_Count.png differ diff --git a/packages/eui/.loki/reference/chrome_mobile_Tabular_Content_EuiDataGrid_Playground.png b/packages/eui/.loki/reference/chrome_mobile_Tabular_Content_EuiDataGrid_Playground.png index 6e3134d3757..300e2cdd650 100644 Binary files a/packages/eui/.loki/reference/chrome_mobile_Tabular_Content_EuiDataGrid_Playground.png and b/packages/eui/.loki/reference/chrome_mobile_Tabular_Content_EuiDataGrid_Playground.png differ diff --git a/packages/eui/.loki/reference/chrome_mobile_Tabular_Content_EuiDataGrid_Row_Height.png b/packages/eui/.loki/reference/chrome_mobile_Tabular_Content_EuiDataGrid_Row_Height.png index b31f586b9f4..b4bdb81c1e3 100644 Binary files a/packages/eui/.loki/reference/chrome_mobile_Tabular_Content_EuiDataGrid_Row_Height.png and b/packages/eui/.loki/reference/chrome_mobile_Tabular_Content_EuiDataGrid_Row_Height.png differ diff --git a/packages/eui/changelogs/upcoming/7898.md b/packages/eui/changelogs/upcoming/7898.md new file mode 100644 index 00000000000..74c9a0bef20 --- /dev/null +++ b/packages/eui/changelogs/upcoming/7898.md @@ -0,0 +1,6 @@ +- Updated `EuiDataGrid` to support interactive header cell content + +**Accessibility** + +- Improved the keyboard navigation and screen reader output for `EuiDataGrid` header cells + diff --git a/packages/eui/src/components/datagrid/__snapshots__/data_grid.test.tsx.snap b/packages/eui/src/components/datagrid/__snapshots__/data_grid.test.tsx.snap index e50e7a79f1a..f197ca1bf0b 100644 --- a/packages/eui/src/components/datagrid/__snapshots__/data_grid.test.tsx.snap +++ b/packages/eui/src/components/datagrid/__snapshots__/data_grid.test.tsx.snap @@ -595,6 +595,7 @@ exports[`EuiDataGrid rendering renders additional toolbar controls 1`] = ` role="row" >
-
- - + +
-
- - + +
-
- - + +
-
- - + +
-
- - + +
-
- - + +
-
- - + +
-
- - + +
-
- - + +
-
- - + +
-
- - + +
-
- - + +
{ openCellPopover('A'); cy.get('[data-test-subj="euiDataGridExpansionPopover"]') .should('have.css', 'left', '1px') - .should('have.css', 'top', '73px') + .should('have.css', 'top', '80px') .should('have.css', 'width', '112px'); }); @@ -235,7 +235,7 @@ describe('EuiDataGridCellPopover', () => { openCellPopover('B'); cy.get('[data-test-subj="euiDataGridExpansionPopover"]') .should('have.css', 'left', '109px') - .should('have.css', 'top', '73px') + .should('have.css', 'top', '80px') .should('have.css', 'width', '375px'); }); @@ -246,7 +246,7 @@ describe('EuiDataGridCellPopover', () => { // Matchers used due to subpixel rendering shenanigans cy.get('[data-test-subj="euiDataGridExpansionPopover"]') - .should('have.css', 'top', '73px') + .should('have.css', 'top', '80px') .should('have.css', 'left') .and('match', /^255[.\d]+px$/); cy.get('[data-test-subj="euiDataGridExpansionPopover"]') diff --git a/packages/eui/src/components/datagrid/body/cell/focus_utils.spec.tsx b/packages/eui/src/components/datagrid/body/cell/focus_utils.spec.tsx index 1dcfa6d74d2..9b1fc7010d8 100644 --- a/packages/eui/src/components/datagrid/body/cell/focus_utils.spec.tsx +++ b/packages/eui/src/components/datagrid/body/cell/focus_utils.spec.tsx @@ -33,31 +33,13 @@ describe('Cell focus utils', () => { ); describe('does not render a focus trap', () => { - const props = { - ...baseProps, - columns: [{ id: 'column', isExpandable: true, actions: {} }], - }; - - it('when header cells have actions', () => { - cy.mount(); - - const headerCell = '[data-test-subj="dataGridHeaderCell-column"]'; - const headerCellPopover = - '[data-test-subj="dataGridHeaderCellActionGroup-column"]'; - - // Should toggle the actions popover instead - cy.get(headerCell).click(); - cy.get(headerCellPopover).should('be.visible'); - - // Keyboard behavior - cy.realPress('Escape'); - cy.get(headerCellPopover).should('not.exist'); - cy.realPress('Enter'); - cy.get(headerCellPopover).should('exist'); - }); - it('when body cells are expandable', () => { - cy.mount(); + cy.mount( + + ); const cell = '[data-test-subj="dataGridRowCell"]'; const cellAction = '[data-test-subj="euiDataGridCellExpandButton"]'; @@ -75,10 +57,76 @@ describe('Cell focus utils', () => { cy.realPress('Enter'); cy.get(cellPopover).should('exist'); }); + + it('when header cells have actions but no other interactive content', () => { + cy.mount( + + ); + + const headerCell = '[data-test-subj="dataGridHeaderCell-column"]'; + const headerCellActionsButton = + '[data-test-subj="dataGridHeaderCellActionButton-column"]'; + const headerCellPopover = + '[data-test-subj="dataGridHeaderCellActionGroup-column"]'; + + // click behavior + // Should toggle the actions popover on actions button + cy.get(headerCell).click(); + cy.get(headerCellPopover).should('not.exist'); + cy.get(headerCellActionsButton).click(); + cy.get(headerCellPopover).should('exist'); + + // Keyboard behavior + cy.realPress('Escape'); + cy.get(headerCellPopover).should('not.exist'); + cy.realPress('Enter'); + cy.get(headerCellPopover).should('exist'); + }); }); describe('renders a focus trap', () => { - it('when header cells do not have actions', () => { + it('when header cells have actions and interactive content', () => { + cy.mount( + + ); + + // For some reason the header click doesn't register in Cypress until the body is clicked + cy.get('[data-test-subj="dataGridRowCell"]').realClick(); + cy.wait(50); + + // Enter the trap + cy.get('[data-test-subj="dataGridHeaderCell-column"]').realClick(); + cy.realPress('Enter'); + + // Should cycle through focus trap + cy.focused().should('have.attr', 'data-test-subj', 'interactiveChildA'); + cy.realPress('Tab'); + cy.focused().should('have.attr', 'data-test-subj', 'interactiveChildB'); + cy.realPress('Tab'); + cy.focused().should( + 'have.attr', + 'data-test-subj', + 'dataGridHeaderCellActionButton-column' + ); + + // Exit the trap + cy.realPress('Escape'); + cy.focused().should( + 'have.attr', + 'data-test-subj', + 'dataGridHeaderCell-column' + ); + }); + + it('when header cells do not have actions but interactive content', () => { cy.mount( { cy.repeatRealPress('Tab', 4); cy.focused() - .parent() .should('have.attr', 'data-gridcell-row-index', '-1') .should('have.attr', 'data-gridcell-column-index', '0'); @@ -195,10 +242,9 @@ describe('Cell focus utils', () => { cy.focused().should( 'have.attr', 'data-test-subj', - 'dataGridHeaderCellActionButton-column' + 'dataGridHeaderCell-column' ); - cy.focused() - .parent() + cy.get('[data-test-subj="dataGridHeaderCell-column"]') .should('have.attr', 'data-gridcell-row-index', '-1') .should('have.attr', 'data-gridcell-column-index', '0'); }; @@ -215,6 +261,7 @@ describe('Cell focus utils', () => { cy.repeatRealPress('Tab', 4); assertFocusedHeaderActions(); + cy.realPress('Enter'); assertCanToggleActionsPopover(); cy.realPress(['Shift', 'Tab']); diff --git a/packages/eui/src/components/datagrid/body/cell/focus_utils.test.tsx b/packages/eui/src/components/datagrid/body/cell/focus_utils.test.tsx index 7a2459c557e..0373c6429d5 100644 --- a/packages/eui/src/components/datagrid/body/cell/focus_utils.test.tsx +++ b/packages/eui/src/components/datagrid/body/cell/focus_utils.test.tsx @@ -7,7 +7,7 @@ */ import React from 'react'; -import { fireEvent } from '@testing-library/react'; +import { fireEvent, waitFor } from '@testing-library/react'; import { render } from '../../../../test/rtl'; import { FocusTrappedChildren, HandleInteractiveChildren } from './focus_utils'; @@ -21,7 +21,7 @@ const getCellWithInteractiveChildren = () => { return cell; }; -const renderCellWithInteractiveChilren = () => { +const renderCellWithInteractiveChildren = () => { const { container } = render(
@@ -143,9 +143,15 @@ describe('FocusTrappedChildren', () => { data-focus-lock-disabled="disabled" >
@@ -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. + */} +

+
+ +
@@ -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`] = `
-
- - + +
`; diff --git a/packages/eui/src/components/datagrid/body/header/_data_grid_header_row.scss b/packages/eui/src/components/datagrid/body/header/_data_grid_header_row.scss index a32a8a8889e..627d823fc37 100644 --- a/packages/eui/src/components/datagrid/body/header/_data_grid_header_row.scss +++ b/packages/eui/src/components/datagrid/body/header/_data_grid_header_row.scss @@ -10,15 +10,18 @@ @include euiDataGridHeaderCell { @include euiFontSizeS; - font-weight: $euiFontWeightBold; - padding: $euiDataGridCellPaddingM; - flex: 0 0 auto; position: relative; - align-items: center; display: flex; + flex: 0 0 auto; + align-items: center; + padding: $euiDataGridCellPaddingM; + font-weight: $euiFontWeightBold; // Workaround for focus trap & > [data-focus-lock-disabled] { + display: flex; + align-items: center; + gap: $euiSizeXS; width: 100%; } @@ -28,6 +31,11 @@ @include euiDataGridCellFocus; } + // needed to override global style + &:focus:focus-visible { + outline: none; + } + .euiDataGridHeaderCell__content { flex-grow: 1; // ensures content stretches and allows for manual layout styles to apply } @@ -40,8 +48,18 @@ align-items: center; gap: $euiSizeXS; width: 100%; + border-radius: $euiBorderRadiusSmall; font-weight: $euiFontWeightBold; outline: none; + + &:focus-visible { + outline: none; + } + } + + [data-focus-lock-disabled='false'] .euiDataGridHeaderCell__button { + @include euiFocusRing; + color: $euiFocusRingColor; } .euiDataGridHeaderCell__content { @@ -61,9 +79,9 @@ display: flex; align-items: center; justify-content: center; + width: 0; height: $euiSize; overflow: hidden; - width: 0; opacity: 0; transition: width $euiAnimSpeedFast ease-in, opacity $euiAnimSpeedSlow ease-in; } @@ -71,6 +89,10 @@ &:focus-within, &:hover, .euiPopover-isOpen { + .euiDataGridHeaderCell__button { + padding: $euiSizeXS; + } + .euiDataGridHeaderCell__icon { width: $euiSize; opacity: 1; diff --git a/packages/eui/src/components/datagrid/body/header/data_grid_header_cell.tsx b/packages/eui/src/components/datagrid/body/header/data_grid_header_cell.tsx index 9aeef041114..477490d0bde 100644 --- a/packages/eui/src/components/datagrid/body/header/data_grid_header_cell.tsx +++ b/packages/eui/src/components/datagrid/body/header/data_grid_header_cell.tsx @@ -18,15 +18,16 @@ import React, { useCallback, useMemo, memo, + HTMLAttributes, } from 'react'; import { tabbable, FocusableElement } from 'tabbable'; -import { keys } from '../../../../services'; +import { keys, useEuiMemoizedStyles } from '../../../../services'; import { useGeneratedHtmlId } from '../../../../services/accessibility'; -import { EuiScreenReaderOnly } from '../../../accessibility'; -import { EuiI18n } from '../../../i18n'; +import { EuiI18n, useEuiI18n } from '../../../i18n'; import { EuiIcon } from '../../../icon'; import { EuiListGroup } from '../../../list_group'; import { EuiPopover } from '../../../popover'; +import { _emptyHoverStyles } from '../../../button/button_icon/button_icon.styles'; import { DataGridFocusContext } from '../../utils/focus'; import { EuiDataGridHeaderCellProps, @@ -38,11 +39,12 @@ import { EuiDataGridColumnResizer } from './data_grid_column_resizer'; import { EuiDataGridHeaderCellWrapper } from './data_grid_header_cell_wrapper'; const CellContent: FunctionComponent< - PropsWithChildren & { title: string; arrow?: ReactNode } -> = ({ children, title, arrow }) => { + PropsWithChildren & + HTMLAttributes & { title: string; arrow?: ReactNode } +> = ({ children, title, arrow, ...rest }) => { return ( <> -
+
{children}
{arrow} @@ -66,13 +68,22 @@ export const EuiDataGridHeaderCell: FunctionComponent { const { id, display, displayAsText, displayHeaderCellProps } = column; + const title = displayAsText || id; + const children = display || displayAsText || id; const width = columnWidths[id] || defaultColumnWidth; const columnType = schema[id] ? schema[id].columnType : null; const { setFocusedCell, focusFirstVisibleInteractiveCell } = useContext(DataGridFocusContext); + /* + * Column actions + */ const [isPopoverOpen, setIsPopoverOpen] = useState(false); + const togglePopover = useCallback(() => { + setIsPopoverOpen((isOpen) => !isOpen); + }, []); + const closePopover = useCallback(() => setIsPopoverOpen(false), []); const popoverArrowNavigationProps = usePopoverArrowNavigation(); const columnActions = useMemo(() => { @@ -105,27 +116,40 @@ export const EuiDataGridHeaderCell: FunctionComponent 0; const actionsButtonRef = useRef(null); - const focusActionsButton = useCallback(() => { - actionsButtonRef.current?.focus(); + const clickActionsButton = useCallback(() => { + actionsButtonRef.current?.click(); }, []); const [isActionsButtonFocused, setIsActionsButtonFocused] = useState(false); + const actionsButtonAriaLabel = useEuiI18n( + 'euiDataGridHeaderCell.actionsButtonAriaLabel', + '{title}. Click to view column header actions.', + { title } + ); + const actionsEnterKeyInstructions = useEuiI18n( + 'euiDataGridHeaderCell.actionsEnterKeyInstructions', + "Press the Enter key to view this column's actions" + ); + + /* + * Column sorting + */ const { sortingArrow, ariaSort, sortingScreenReaderText } = useSortingUtils({ sorting, id, showColumnActions, }); + const sortingAriaId = useGeneratedHtmlId({ prefix: 'euiDataGridCellHeader', suffix: 'sorting', }); - const actionsAriaId = useGeneratedHtmlId({ - prefix: 'euiDataGridCellHeader', - suffix: 'actions', - }); + /* + * Rendering + */ const classes = classnames( { [`euiDataGridHeaderCell--${columnType}`]: columnType, @@ -135,8 +159,7 @@ export const EuiDataGridHeaderCell: FunctionComponent - {column.isResizable !== false && width != null ? ( - - ) : null} - - {!showColumnActions ? ( + {(hasFocusTrap) => ( <> + {column.isResizable !== false && width != null ? ( + + ) : null} + {children} + {sortingScreenReaderText && ( - -

{sortingScreenReaderText}

-
+ )} - - ) : ( - <> - } isOpen={isPopoverOpen} - closePopover={() => setIsPopoverOpen(false)} + closePopover={closePopover} {...popoverArrowNavigationProps} > - - - - + )} )} diff --git a/packages/eui/src/components/datagrid/body/header/data_grid_header_cell_wrapper.test.tsx b/packages/eui/src/components/datagrid/body/header/data_grid_header_cell_wrapper.test.tsx index b17b7632b47..78721f40fd3 100644 --- a/packages/eui/src/components/datagrid/body/header/data_grid_header_cell_wrapper.test.tsx +++ b/packages/eui/src/components/datagrid/body/header/data_grid_header_cell_wrapper.test.tsx @@ -7,7 +7,7 @@ */ import React from 'react'; -import { mount } from 'enzyme'; +import { render } from '../../../../test/rtl'; import { DataGridFocusContext } from '../../utils/focus'; import { mockFocusContext } from '../../utils/__mocks__/focus_context'; @@ -19,14 +19,19 @@ describe('EuiDataGridHeaderCellWrapper', () => { id: 'someColumn', index: 0, hasActionsPopover: true, - children: + + ), }; - const mountWithContext = (props = {}, isFocused = true) => { + const renderWithContext = (props = {}, isFocused = true) => { (mockFocusContext.onFocusUpdate as jest.Mock).mockImplementation( (_, callback) => callback(isFocused) // allows us to mock isFocused state ); - return mount( + return render( @@ -38,103 +43,95 @@ describe('EuiDataGridHeaderCellWrapper', () => { }); it('renders', () => { - const component = mountWithContext(); - expect(component).toMatchInlineSnapshot(` - -
- -
- } - renderFocusTrap={false} - updateCellFocusContext={[Function]} - > -
- + Mock column actions + + `); }); it('renders width, className, and arbitrary props', () => { - const component = mountWithContext({ + const { getByTestSubject } = renderWithContext({ width: 30, className: 'euiDataGridHeaderCell--test', 'aria-label': 'test', + children: 'No column actions', }); - expect(component.find('[data-test-subj="dataGridHeaderCell-someColumn"]')) + expect(getByTestSubject('dataGridHeaderCell-someColumn')) .toMatchInlineSnapshot(`
- -
- } - renderFocusTrap={false} - updateCellFocusContext={[Function]} + No column actions + + `); + }); + + it('renders a focus trap if the cell contains interactive children', () => { + const { container } = renderWithContext({ + children: ( + <> + Some Column + + + + ), + }); + expect(container.querySelectorAll('[data-focus-guard]')).toHaveLength(2); + expect(container.querySelector('[data-focus-lock-disabled]')) + .toMatchInlineSnapshot(` +
+ Some Column + + +
`); }); diff --git a/packages/eui/src/components/datagrid/body/header/data_grid_header_cell_wrapper.tsx b/packages/eui/src/components/datagrid/body/header/data_grid_header_cell_wrapper.tsx index b4f7b6abb93..166cd2bdaa7 100644 --- a/packages/eui/src/components/datagrid/body/header/data_grid_header_cell_wrapper.tsx +++ b/packages/eui/src/components/datagrid/body/header/data_grid_header_cell_wrapper.tsx @@ -6,16 +6,18 @@ * Side Public License, v 1. */ -import classnames from 'classnames'; import React, { FunctionComponent, - FocusEventHandler, useContext, useEffect, useState, useCallback, + KeyboardEventHandler, } from 'react'; +import classnames from 'classnames'; +import { FocusableElement } from 'tabbable'; +import { keys } from '../../../../services'; import { EuiDataGridHeaderCellWrapperProps } from '../../data_grid_types'; import { DataGridFocusContext } from '../../utils/focus'; import { HandleInteractiveChildren } from '../cell/focus_utils'; @@ -34,14 +36,24 @@ export const EuiDataGridHeaderCellWrapper: FunctionComponent< className, children, hasActionsPopover, - isActionsButtonFocused, - focusActionsButton, + openActionsPopover, + 'aria-label': ariaLabel, ...rest }) => { const classes = classnames('euiDataGridHeaderCell', className); // Must be a state and not a ref to trigger a HandleInteractiveChildren rerender const [headerEl, setHeaderEl] = useState(null); + const [renderFocusTrap, setRenderFocusTrap] = useState(false); + const [interactiveChildren, setInteractiveChildren] = useState< + FocusableElement[] + >([]); + useEffect(() => { + // We're checking for interactive children outside of the default actions button + setRenderFocusTrap( + interactiveChildren.length > (hasActionsPopover ? 1 : 0) + ); + }, [hasActionsPopover, interactiveChildren]); const { setFocusedCell, onFocusUpdate } = useContext(DataGridFocusContext); const updateCellFocusContext = useCallback(() => { @@ -61,23 +73,27 @@ export const EuiDataGridHeaderCellWrapper: FunctionComponent< }); }, [index, onFocusUpdate, headerEl]); - // For cell headers with actions, auto-focus into the button instead of the cell wrapper div - // The button text is significantly more useful to screen readers (e.g. contains sort order & hints) - const onFocus: FocusEventHandler = useCallback( + // For cell headers with only actions, auto-open the actions popover on enter keypress + const onKeyDown: KeyboardEventHandler = useCallback( (e) => { - if (hasActionsPopover && e.target === headerEl) { - focusActionsButton?.(); + if ( + e.key === keys.ENTER && + hasActionsPopover && + !renderFocusTrap && + e.target === headerEl + ) { + openActionsPopover?.(); } }, - [hasActionsPopover, focusActionsButton, headerEl] + [hasActionsPopover, openActionsPopover, renderFocusTrap, headerEl] ); return (
- {children} + {typeof children === 'function' ? children(renderFocusTrap) : children}
); diff --git a/packages/eui/src/components/datagrid/data_grid.a11y.tsx b/packages/eui/src/components/datagrid/data_grid.a11y.tsx index cc2d7d7e65a..ac735be7fd2 100644 --- a/packages/eui/src/components/datagrid/data_grid.a11y.tsx +++ b/packages/eui/src/components/datagrid/data_grid.a11y.tsx @@ -235,6 +235,7 @@ describe('EuiDataGrid', () => { }); it('has zero violations when the column actions menu is open', () => { + cy.get('.euiDataGridHeaderCell').first().realHover(); cy.get('button.euiDataGridHeaderCell__button').first().realClick(); cy.checkAxe(); }); @@ -253,6 +254,7 @@ describe('EuiDataGrid', () => { }); it('has zero violations on sort and when the columns sorting menu is open', () => { + cy.get('.euiDataGridHeaderCell').last().realHover(); cy.get('button.euiDataGridHeaderCell__button').last().realClick(); cy.get('button.euiListGroupItem__button') .contains('Sort Alma to Debian') diff --git a/packages/eui/src/components/datagrid/data_grid.spec.tsx b/packages/eui/src/components/datagrid/data_grid.spec.tsx index 147f83e4681..2d307c72192 100644 --- a/packages/eui/src/components/datagrid/data_grid.spec.tsx +++ b/packages/eui/src/components/datagrid/data_grid.spec.tsx @@ -167,6 +167,7 @@ describe('EuiDataGrid', () => { const columnValueMap: { [key: string]: ReactNode } = { no_interactive: value, no_interactive_expandable: value, + header_interactive: value, one_interactive: ( @@ -432,14 +433,13 @@ describe('EuiDataGrid', () => { .should('have.attr', 'data-gridcell-row-index', '0'); }); - it('column header cells', () => { + it('column header cells without focus trap', () => { cy.realMount(); cy.repeatRealPress('Tab', 5); cy.realPress('{rightarrow}'); - // Should auto-focus the actions button (over the cell itself) + // Should focus cell itself cy.focused() - .parent() .should('have.attr', 'data-gridcell-column-index', '1') .should('have.attr', 'data-gridcell-row-index', '-1'); @@ -457,6 +457,88 @@ describe('EuiDataGrid', () => { cy.realPress('Tab'); cy.focused().should('have.text', 'Move right'); }); + + it('column header cells with focus trap', () => { + const columns: EuiDataGridColumn[] = [ + { + id: 'no_interactive', + display: '0 interactive', + isExpandable: false, + actions: false, + }, + { + id: 'no_interactive_expandable', + display: ( + + ), + }, + { + id: 'one_interactive', + display: '1 interactive', + isExpandable: false, + }, + { + id: 'one_interactive_expandable', + display: '1 interactive', + }, + { + id: 'two_interactives', + display: '2 interactives', + isExpandable: false, + }, + { + id: 'two_interactives_expandable', + display: '2 interactives', + }, + ]; + + cy.realMount( + + ); + cy.repeatRealPress('Tab', 5); + cy.realPress('{rightarrow}'); + + // Should focus cell itself + cy.focused() + .should('have.attr', 'data-gridcell-column-index', '1') + .should('have.attr', 'data-gridcell-row-index', '-1'); + + // Pressing enter should toggle the actions popover + cy.realPress('Enter'); + cy.focused().should( + 'have.attr', + 'data-test-subj', + 'dataGridHeaderCellInteractiveHeader' + ); + cy.realPress('Tab'); + cy.realPress('Enter'); + cy.get( + '[data-test-subj="dataGridHeaderCellActionGroup-no_interactive_expandable"]' + ).should('be.visible'); + + // The actions popover should be fully tabbable/focus trapped with no regressions + cy.realPress('Tab'); + cy.focused().should('have.text', 'Hide column'); + cy.realPress('Tab'); + cy.focused().should('have.text', 'Move left'); + cy.realPress('Tab'); + cy.focused().should('have.text', 'Move right'); + + // close action menu + cy.realPress('Escape'); + cy.focused().should( + 'have.attr', + 'data-test-subj', + 'dataGridHeaderCellActionButton-no_interactive_expandable' + ); + // exit cell content + cy.realPress('Escape'); + cy.focused() + .should('have.attr', 'data-gridcell-column-index', '1') + .should('have.attr', 'data-gridcell-row-index', '-1'); + }); }); }); diff --git a/packages/eui/src/components/datagrid/data_grid.stories.tsx b/packages/eui/src/components/datagrid/data_grid.stories.tsx index dc909f42f12..0496a9f23e9 100644 --- a/packages/eui/src/components/datagrid/data_grid.stories.tsx +++ b/packages/eui/src/components/datagrid/data_grid.stories.tsx @@ -24,6 +24,7 @@ import type { EuiDataGridProps, } from './data_grid_types'; import { EuiDataGrid } from './data_grid'; +import { EuiToolTip } from '../tool_tip'; faker.seed(42); @@ -461,6 +462,85 @@ export const CustomRowHeights: Story = { render: (args: EuiDataGridProps) => , }; +const CustomHeaderCell = ({ title }: { title: string }) => ( + <> + {title} + + + + +); + +export const CustomHeaderContent: Story = { + parameters: { + controls: { + include: ['columns', 'rowCount'], + }, + }, + args: { + columns: [ + { + id: 'name', + displayAsText: 'Name', + display: , + defaultSortDirection: 'asc' as const, + cellActions: [ + ({ rowIndex, Component }: EuiDataGridColumnCellActionProps) => { + const data = raw_data; + const value = data[rowIndex].name.raw; + return ( + alert(`Hi ${value}`)} + iconType="heart" + aria-label={`Say hi to ${value}!`} + > + Say hi + + ); + }, + ], + }, + { + id: 'email', + display: , + initialWidth: 130, + cellActions: [ + ({ rowIndex, Component }: EuiDataGridColumnCellActionProps) => { + const data = raw_data; + const value = data[rowIndex].email.raw; + return ( + alert(value)} + iconType="email" + aria-label={`Send email to ${value}`} + > + Send email + + ); + }, + ], + }, + ...[...columns].slice(2), + ], + rowCount: 10, + renderCellValue: RenderCellValue, + inMemory: { level: 'sorting' }, + toolbarVisibility: { + showColumnSelector: true, + showDisplaySelector: true, + showSortSelector: true, + showKeyboardShortcuts: true, + showFullScreenSelector: true, + additionalControls: null, + }, + }, + render: (args: EuiDataGridProps) => , +}; + const StatefulDataGrid = (props: EuiDataGridProps) => { const { pagination, sorting, columnVisibility, ...rest } = props; @@ -500,8 +580,9 @@ const StatefulDataGrid = (props: EuiDataGridProps) => { const onSort = useCallback( (sortingColumns: EuiDataGridColumnSortingConfig[]) => { setSortingColumns(sortingColumns); + sorting?.onSort?.(sortingColumns); }, - [setSortingColumns] + [setSortingColumns, sorting] ); useEffect(() => { diff --git a/packages/eui/src/components/datagrid/data_grid_types.ts b/packages/eui/src/components/datagrid/data_grid_types.ts index c7f72782035..3ea823dae2e 100644 --- a/packages/eui/src/components/datagrid/data_grid_types.ts +++ b/packages/eui/src/components/datagrid/data_grid_types.ts @@ -17,7 +17,6 @@ import { MutableRefObject, Ref, Component, - PropsWithChildren, ComponentClass, } from 'react'; import { @@ -170,14 +169,15 @@ export interface EuiDataGridControlHeaderCellProps { controlColumn: EuiDataGridControlColumn; } -export interface EuiDataGridHeaderCellWrapperProps extends PropsWithChildren { +export interface EuiDataGridHeaderCellWrapperProps { + children: ReactNode | ((renderFocusTrap: boolean) => ReactNode); id: string; index: number; width?: number | null; className?: string; + 'aria-label'?: AriaAttributes['aria-label']; hasActionsPopover?: boolean; - isActionsButtonFocused?: boolean; - focusActionsButton?: () => void; + openActionsPopover?: () => void; } export type EuiDataGridFooterRowProps = CommonProps & @@ -702,6 +702,7 @@ export interface EuiDataGridColumn { * This can be used to display a readable column name in column hiding/sorting, where `display` won't be used. * This will also be used as a `title` attribute that will display on mouseover (useful if the display text is being truncated by the column width). * If not passed, `id` will be shown as the column name. + * Passing this together with `display` is useful to ensure an accessible label is added to the column. */ displayAsText?: string; /** diff --git a/packages/eui/src/components/datagrid/utils/ref.spec.tsx b/packages/eui/src/components/datagrid/utils/ref.spec.tsx index 388844b6b1c..1b80099c7f2 100644 --- a/packages/eui/src/components/datagrid/utils/ref.spec.tsx +++ b/packages/eui/src/components/datagrid/utils/ref.spec.tsx @@ -113,6 +113,7 @@ describe('useImperativeGridRef', () => { it('should correctly find the specified rowIndex when sorted', () => { cy.get('[data-test-subj="dataGridHeaderCell-A"]').click(); + cy.get('[data-test-subj="dataGridHeaderCellActionButton-A"]').click(); cy.contains('Sort High-Low').click(); cy.then(() => { ref.current!.setFocusedCell({ rowIndex: 95, colIndex: 0 }); @@ -163,6 +164,7 @@ describe('useImperativeGridRef', () => { it('should correctly find the specified rowIndex when sorted', () => { cy.get('[data-test-subj="dataGridHeaderCell-A"]').click(); + cy.get('[data-test-subj="dataGridHeaderCellActionButton-A"]').click(); cy.contains('Sort High-Low').click(); cy.then(() => { ref.current!.openCellPopover({ rowIndex: 98, colIndex: 1 });