diff --git a/src/components/datagrid/__snapshots__/data_grid.test.tsx.snap b/src/components/datagrid/__snapshots__/data_grid.test.tsx.snap index 7a3d934f8994..ac1427f38f3d 100644 --- a/src/components/datagrid/__snapshots__/data_grid.test.tsx.snap +++ b/src/components/datagrid/__snapshots__/data_grid.test.tsx.snap @@ -1070,6 +1070,7 @@ Array [ class="euiPopover__anchor eui-fullWidth" > +
+
+
+
+
+
+
+
+
+
@@ -28,7 +29,6 @@ exports[`EuiDataGridHeaderCell renders 1`] = ` someColumn
+ `; 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 67321c83227a..155da88dd01e 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 @@ -7,16 +7,15 @@ */ import React from 'react'; -import { mount, shallow } from 'enzyme'; +import { mount, shallow, render } 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, + useSortingUtils, usePopoverArrowNavigation, } from './data_grid_header_cell'; @@ -43,70 +42,136 @@ describe('EuiDataGridHeaderCell', () => { }); describe('sorting', () => { - const sortingContext = { - onSort: jest.fn(), - columns: [], - } as EuiDataGridSorting; - - const mountWithContext = (props = {}, sorting = {}) => { - return mount( - - - - ); + const columnId = 'test'; + const mockSortingArgs = { + sorting: undefined, + id: columnId, + showColumnActions: true, }; + const getRenderedText = (text: React.ReactElement) => + render(

{text}

).text(); + describe('if the current column is being sorted', () => { - it('renders a ascending sort arrow', () => { - const component = mountWithContext( - { column: { id: 'test' } }, - { columns: [{ id: 'test', direction: 'asc' }] } + it('renders an ascending sort arrow', () => { + const { + return: { sortingArrow }, + } = testCustomHook(useSortingUtils, { + ...mockSortingArgs, + sorting: { columns: [{ id: columnId, direction: 'asc' }] }, + }); + + expect(shallow(sortingArrow).prop('data-euiicon-type')).toEqual( + 'sortUp' ); - const arrowIcon = component.find('EuiIcon').first(); - expect(arrowIcon.prop('type')).toEqual('sortUp'); }); it('renders a descending sort arrow', () => { - const component = mountWithContext( - { column: { id: 'test' } }, - { columns: [{ id: 'test', direction: 'desc' }] } + const { + return: { sortingArrow }, + } = testCustomHook(useSortingUtils, { + ...mockSortingArgs, + sorting: { columns: [{ id: columnId, direction: 'desc' }] }, + }); + + expect(shallow(sortingArrow).prop('data-euiicon-type')).toEqual( + 'sortDown' ); - const arrowIcon = component.find('EuiIcon').first(); - expect(arrowIcon.prop('type')).toEqual('sortDown'); + }); + + describe('when only the current column is being sorted', () => { + describe('when the header cell has no actions', () => { + it('renders aria-sort but not sortingScreenReaderText', () => { + const { + return: { ariaSort, sortingScreenReaderText }, + } = testCustomHook(useSortingUtils, { + ...mockSortingArgs, + sorting: { columns: [{ id: columnId, direction: 'asc' }] }, + showColumnActions: false, + }); + + expect(ariaSort).toEqual('ascending'); + expect(getRenderedText(sortingScreenReaderText)).toEqual(''); + }); + }); + + describe('when the header cell has actions', () => { + it('renders aria-sort and sortingScreenReaderText', () => { + const { + return: { ariaSort, sortingScreenReaderText }, + } = testCustomHook(useSortingUtils, { + ...mockSortingArgs, + sorting: { columns: [{ id: columnId, direction: 'desc' }] }, + showColumnActions: true, + }); + + expect(ariaSort).toEqual('descending'); + expect(getRenderedText(sortingScreenReaderText)).toEqual( + 'Sorted descending.' + ); + }); + }); }); }); describe('if the current column is not being sorted', () => { it('does not render an arrow even if other columns are sorted', () => { - const component = mountWithContext( - { column: { id: 'test' } }, - { columns: [{ id: 'other', direction: 'asc' }] } - ); - expect( - component.find('.euiDataGridHeaderCell__sortingArrow') - ).toHaveLength(0); + const { + return: { sortingArrow }, + } = testCustomHook(useSortingUtils, { + ...mockSortingArgs, + sorting: { columns: [{ id: 'other', direction: 'desc' }] }, + }); + + expect(sortingArrow).toBeNull(); + }); + + it('does not render aria-sort or screen reader sorting text', () => { + const { + return: { ariaSort, sortingScreenReaderText }, + } = testCustomHook(useSortingUtils, mockSortingArgs); + + expect(ariaSort).toEqual(undefined); + expect(getRenderedText(sortingScreenReaderText)).toEqual(''); }); }); describe('when multiple columns are being sorted', () => { - it('renders EuiScreenReaderOnly text with a full list of sorted columns', () => { - const component = mountWithContext( - { column: { id: 'A' } }, - { + it('does not render aria-sort, but renders sorting screen reader text text with a full list of sorted columns', () => { + const { + return: { ariaSort, sortingScreenReaderText }, + getUpdatedState, + updateHookArgs, + } = testCustomHook(useSortingUtils, { + id: 'A', + sorting: { columns: [ { id: 'A', direction: 'asc' }, { id: 'B', direction: 'desc' }, ], - } + }, + }); + + expect(ariaSort).toEqual(undefined); + expect(getRenderedText(sortingScreenReaderText)).toMatchInlineSnapshot( + '"Sorted by A, ascending, then sorted by B, descending."' ); - expect(component.find('EuiScreenReaderOnly')).toHaveLength(1); + // Branch coverage + updateHookArgs({ + sorting: { + columns: [ + { id: 'B', direction: 'desc' }, + { id: 'C', direction: 'asc' }, + { id: 'A', direction: 'asc' }, + ], + }, + }); + expect( + getRenderedText(getUpdatedState().sortingScreenReaderText) + ).toMatchInlineSnapshot( + '"Sorted by B, descending, then sorted by C, ascending, then sorted by A, ascending."' + ); }); }); }); 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 b08daf684257..6f8e939543ff 100644 --- a/src/components/datagrid/body/header/data_grid_header_cell.tsx +++ b/src/components/datagrid/body/header/data_grid_header_cell.tsx @@ -10,23 +10,26 @@ import classnames from 'classnames'; import React, { AriaAttributes, FunctionComponent, - HTMLAttributes, useContext, useState, useRef, useCallback, + useMemo, } from 'react'; import { tabbable, FocusableElement } from 'tabbable'; import { keys } from '../../../../services'; import { useGeneratedHtmlId } from '../../../../services/accessibility'; import { EuiScreenReaderOnly } from '../../../accessibility'; -import { useEuiI18n, EuiI18n } from '../../../i18n'; +import { EuiI18n } from '../../../i18n'; import { EuiIcon } from '../../../icon'; import { EuiListGroup } from '../../../list_group'; import { EuiPopover } from '../../../popover'; import { DataGridSortingContext } from '../../utils/sorting'; import { DataGridFocusContext } from '../../utils/focus'; -import { EuiDataGridHeaderCellProps } from '../../data_grid_types'; +import { + EuiDataGridHeaderCellProps, + EuiDataGridSorting, +} from '../../data_grid_types'; import { getColumnActions } from './column_actions'; import { EuiDataGridColumnResizer } from './data_grid_column_resizer'; @@ -53,45 +56,10 @@ export const EuiDataGridHeaderCell: FunctionComponent id)); - if (sortedColumnIds.has(id)) { - if (sorting.columns.length === 1) { - const sortDirection = sorting.columns[0].direction; - - let sortValue: HTMLAttributes['aria-sort'] = 'other'; - if (sortDirection === 'asc') { - sortValue = 'ascending'; - } - if (sortDirection === 'desc') { - sortValue = 'descending'; - } - - ariaProps['aria-sort'] = sortValue; - } else { - sortString = sorting.columns - .map((col) => `Sorted by ${col.id} ${col.direction}`) - .join(' then '); - ariaProps['aria-describedby'] = screenReaderId; - } - } - } const [isPopoverOpen, setIsPopoverOpen] = useState(false); const popoverArrowNavigationProps = usePopoverArrowNavigation(); @@ -110,15 +78,20 @@ export const EuiDataGridHeaderCell: FunctionComponent 0; - const sortedColumn = sorting?.columns.find((col) => col.id === id); - const sortingArrow = sortedColumn ? ( - - ) : null; + + const { sortingArrow, ariaSort, sortingScreenReaderText } = useSortingUtils({ + sorting, + id, + showColumnActions, + }); + const sortingAriaId = useGeneratedHtmlId({ + prefix: 'euiDataGridCellHeader', + suffix: 'sorting', + }); + const actionsAriaId = useGeneratedHtmlId({ + prefix: 'euiDataGridCellHeader', + suffix: 'actions', + }); return ( {column.isResizable !== false && width != null ? ( ) : null} - {sortString && ( - -
{sortString}
-
- )} {!showColumnActions ? ( <> {sortingArrow} @@ -151,53 +119,192 @@ export const EuiDataGridHeaderCell: FunctionComponent {display || displayAsText || id} + {sortingScreenReaderText && ( + +

{sortingScreenReaderText}

+
+ )} ) : ( - { - setFocusedCell([index, -1]); - setIsPopoverOpen((isPopoverOpen) => !isPopoverOpen); - }} - > - {sortingArrow} -
+ { + setFocusedCell([index, -1]); + setIsPopoverOpen((isPopoverOpen) => !isPopoverOpen); + }} + aria-describedby={`${sortingAriaId} ${actionsAriaId}`} > - {display || displayAsText || id} -
- - - } - isOpen={isPopoverOpen} - closePopover={() => setIsPopoverOpen(false)} - {...popoverArrowNavigationProps} - > - -
+ {sortingArrow} +
+ {display || displayAsText || id} +
+ + + } + isOpen={isPopoverOpen} + closePopover={() => setIsPopoverOpen(false)} + {...popoverArrowNavigationProps} + > + + + + + + )}
); }; +/** + * Column sorting utility helpers + */ +export const useSortingUtils = ({ + sorting, + id, + showColumnActions, +}: { + sorting?: EuiDataGridSorting; + id: string; + showColumnActions: boolean; +}) => { + const sortedColumn = useMemo( + () => sorting?.columns.find((col) => col.id === id), + [sorting, id] + ); + const isColumnSorted = !!sortedColumn; + const hasOnlyOneSort = sorting?.columns?.length === 1; + + /** + * Arrow icon + */ + const sortingArrow = isColumnSorted ? ( + + ) : null; + + /** + * aria-sort attribute - should only be used when a single column is being sorted + * @see https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Attributes/aria-sort + * @see https://www.w3.org/WAI/ARIA/apg/example-index/table/sortable-table.html + * @see https://github.com/w3c/aria/issues/283 for potential future multi-column usage + */ + const ariaSort: AriaAttributes['aria-sort'] = + // eslint-disable-next-line no-nested-ternary + isColumnSorted && hasOnlyOneSort + ? sorting.columns[0].direction === 'asc' + ? 'ascending' + : 'descending' + : undefined; + + /** + * Sorting status - screen reader text + */ + const sortingScreenReaderText = useMemo(() => { + if (!isColumnSorted) return null; + if (!showColumnActions && hasOnlyOneSort) return null; // in this scenario, the `aria-sort` attribute will be used by screen readers + return ( + <> + {sorting?.columns?.map(({ id: columnId, direction }, index) => { + if (hasOnlyOneSort) { + if (direction === 'asc') { + return ( + + ); + } else { + return ( + + ); + } + } else if (index === 0) { + if (direction === 'asc') { + return ( + + ); + } else { + return ( + + ); + } + } else { + if (direction === 'asc') { + return ( + + ); + } else { + return ( + + ); + } + } + })} + . + + ); + }, [isColumnSorted, showColumnActions, hasOnlyOneSort, sorting]); + + return { sortingArrow, ariaSort, sortingScreenReaderText }; +}; + /** * Add keyboard arrow navigation to the cell actions popover * to match the UX of the rest of EuiDataGrid diff --git a/src/components/datagrid/data_grid.spec.tsx b/src/components/datagrid/data_grid.spec.tsx index 5197ba4fc03a..c6e3949f9215 100644 --- a/src/components/datagrid/data_grid.spec.tsx +++ b/src/components/datagrid/data_grid.spec.tsx @@ -621,14 +621,17 @@ function getGridData() { const rows = cy.get('[role=row]'); return rows.then((rows) => { const headers: string[] = []; - const data = []; + const data: Array<{ [key: string]: string }> = []; // process header const headerRow = rows[0]; const headerCells = headerRow.querySelectorAll('[role=columnheader]'); for (let i = 0; i < headerCells.length; i++) { const headerCell = headerCells[i]; - headers.push(headerCell.textContent ?? ''); + const headerContent = headerCell.querySelector( + '.euiDataGridHeaderCell__content' + )?.textContent; + headers.push(headerContent ?? ''); } // process data rows diff --git a/src/components/datagrid/data_grid.test.tsx b/src/components/datagrid/data_grid.test.tsx index 15177057ceee..81ded173355b 100644 --- a/src/components/datagrid/data_grid.test.tsx +++ b/src/components/datagrid/data_grid.test.tsx @@ -645,64 +645,6 @@ describe('EuiDataGrid', () => { `); }); - it('renders correct aria attributes on column headers', () => { - const component = mount( - {}, - }} - rowCount={1} - renderCellValue={() => 'value'} - /> - ); - - // no columns are sorted, expect no aria-sort or aria-describedby attributes - expect(component.find('[role="columnheader"][aria-sort]').length).toBe(0); - expect( - component.find('[role="columnheader"][aria-describedby]').length - ).toBe(0); - - // sort on one column - component.setProps({ - sorting: { columns: [{ id: 'A', direction: 'asc' }], onSort: () => {} }, - }); - - // expect A column to have aria-sort, expect no aria-describedby - expect(component.find('[role="columnheader"][aria-sort]').length).toBe(1); - expect( - component.find( - '[role="columnheader"][aria-sort="ascending"][data-test-subj="dataGridHeaderCell-A"]' - ).length - ).toBe(1); - expect( - component.find('[role="columnheader"][aria-describedby]').length - ).toBe(0); - - // sort on both columns - component.setProps({ - sorting: { - columns: [ - { id: 'A', direction: 'asc' }, - { id: 'B', direction: 'desc' }, - ], - onSort: () => {}, - }, - }); - - // expect no aria-sort, both columns have aria-describedby - expect(component.find('[role="columnheader"][aria-sort]').length).toBe(0); - expect( - component.find('[role="columnheader"][aria-describedby]').length - ).toBe(2); - expect( - component.find('[role="columnheader"][aria-describedby="generated-id"]') - .length - ).toBe(2); - }); - it('renders additional toolbar controls', () => { const component = render(