diff --git a/e2e/testcafe-devextreme/tests/common/pivotGrid/kbn/columnHeaders.ts b/e2e/testcafe-devextreme/tests/common/pivotGrid/kbn/columnHeaders.ts new file mode 100644 index 000000000000..ac5e62addeaf --- /dev/null +++ b/e2e/testcafe-devextreme/tests/common/pivotGrid/kbn/columnHeaders.ts @@ -0,0 +1,202 @@ +import PivotGrid from 'devextreme-testcafe-models/pivotGrid'; +import { ClientFunction, Selector } from 'testcafe'; +import { createWidget } from '../../../../helpers/createWidget'; +import url from '../../../../helpers/getPageUrl'; +import { sales } from '../data'; + +fixture.disablePageReloads`pivotGrid_kbn_columnHeaders` + .page(url(__dirname, '../../../container.html')); + +const PIVOT_GRID_SELECTOR = '#container'; +const COLUMN_HEADERS_CELL_SELECTOR = 'thead.dx-pivotgrid-horizontal-headers td'; + +const blurActiveElement = ClientFunction(() => { + const activeElement = document.activeElement as HTMLElement | null; + activeElement?.blur(); +}); + +const createConfig = () => ({ + width: 800, + allowExpandAll: true, + fieldChooser: { + enabled: false, + }, + dataSource: { + fields: [{ + dataField: 'region', + area: 'column', + expanded: true, + }, { + dataField: 'country', + area: 'column', + }, { + dataField: 'city', + area: 'row', + }, { + dataField: 'amount', + area: 'data', + summaryType: 'sum', + dataType: 'number', + }], + store: sales, + }, +}); + +test('Column header cells should form a single tab stop', async (t) => { + const pivotGrid = new PivotGrid(PIVOT_GRID_SELECTOR); + const columnsArea = pivotGrid.getColumnsArea(); + + await t + .expect(Selector(`${COLUMN_HEADERS_CELL_SELECTOR}[tabindex="0"]`).count) + .eql(1, 'only one column header cell is in the tab order'); + + await blurActiveElement(); + + await t + .pressKey('tab') + .expect(columnsArea.getCell(0, 0).focused) + .ok('first column header cell is focused by Tab'); + + await t + .expect(Selector(`${COLUMN_HEADERS_CELL_SELECTOR} .dx-expand-icon-container[tabindex="0"]`).count) + .eql(0, 'expand icons of column header cells are not in the tab order'); +}).before(async () => createWidget('dxPivotGrid', createConfig())); + +test('ArrowLeft and ArrowRight should move focus between cells in a level', async (t) => { + const pivotGrid = new PivotGrid(PIVOT_GRID_SELECTOR); + const columnsArea = pivotGrid.getColumnsArea(); + + await t + .click(columnsArea.getCell(1, 0)) + .expect(columnsArea.getCell(1, 0).focused) + .ok('first cell of the second level is focused after click'); + + await t + .pressKey('right') + .expect(columnsArea.getCell(1, 1).focused) + .ok('next cell in the level is focused after ArrowRight'); + + await t + .pressKey('left') + .expect(columnsArea.getCell(1, 0).focused) + .ok('previous cell in the level is focused after ArrowLeft'); +}).before(async () => createWidget('dxPivotGrid', createConfig())); + +test('ArrowUp and ArrowDown should move focus between header levels', async (t) => { + const pivotGrid = new PivotGrid(PIVOT_GRID_SELECTOR); + const columnsArea = pivotGrid.getColumnsArea(); + + await t + .click(columnsArea.getCell(1, 0)); + + await t + .pressKey('up') + .expect(columnsArea.getCell(0, 0).focused) + .ok('parent level cell is focused after ArrowUp'); + + await t + .pressKey('down') + .expect(columnsArea.getCell(1, 0).focused) + .ok('child level cell is focused after ArrowDown'); +}).before(async () => createWidget('dxPivotGrid', createConfig())); + +test('Roving tabindex should follow the focused cell', async (t) => { + const pivotGrid = new PivotGrid(PIVOT_GRID_SELECTOR); + const columnsArea = pivotGrid.getColumnsArea(); + + await t + .click(columnsArea.getCell(1, 1)) + .expect(columnsArea.getCell(1, 1).getAttribute('tabindex')) + .eql('0', 'focused cell is in the tab order'); + + await t + .expect(Selector(`${COLUMN_HEADERS_CELL_SELECTOR}[tabindex="0"]`).count) + .eql(1, 'the focused cell is the only tab stop'); + + await t + .expect(columnsArea.getCell(0, 0).getAttribute('tabindex')) + .eql('-1', 'the first cell is removed from the tab order'); +}).before(async () => createWidget('dxPivotGrid', createConfig())); + +test('Focus should be preserved after expand and collapse by Enter', async (t) => { + const pivotGrid = new PivotGrid(PIVOT_GRID_SELECTOR); + const columnsArea = pivotGrid.getColumnsArea(); + + await blurActiveElement(); + + await t + .pressKey('tab') + .expect(columnsArea.getCell(0, 0).focused) + .ok('expandable cell is focused'); + + const firstCellText = (await columnsArea.getCell(0, 0).textContent).trim(); + + await t + .pressKey('enter') + .expect(Selector(':focus').getAttribute('aria-label')) + .eql(firstCellText, 'focus stays on the collapsed item control'); + + await t + .pressKey('enter') + .expect(Selector(':focus').getAttribute('aria-label')) + .eql(firstCellText, 'focus stays on the expanded item control'); +}).before(async () => createWidget('dxPivotGrid', createConfig())); + +test('Focused cell should stay in view with virtual scrolling', async (t) => { + const pivotGrid = new PivotGrid(PIVOT_GRID_SELECTOR); + const { getInstance } = pivotGrid; + const getColumnsScrollLeft = ClientFunction( + // eslint-disable-next-line no-underscore-dangle + () => (getInstance() as any)._columnsArea._getScrollable().scrollLeft(), + { dependencies: { getInstance } }, + ); + + await blurActiveElement(); + + await t.pressKey('tab'); + + for (let i = 0; i < 8; i += 1) { + await t.pressKey('right'); + } + + const focusedCell = Selector(`${COLUMN_HEADERS_CELL_SELECTOR}`).filter((node) => node === document.activeElement); + + await t + .expect(focusedCell.count) + .eql(1, 'a column header cell is focused after arrow navigation') + .expect(focusedCell.visible) + .ok('the focused cell is visible') + .expect(getColumnsScrollLeft()) + .gt(0, 'the column headers area is scrolled to the focused cell'); +}).before(async () => createWidget('dxPivotGrid', { + ...createConfig(), + width: 300, + height: 300, + scrolling: { + mode: 'virtual', + }, + dataSource: { + fields: [{ + dataField: 'region', + area: 'column', + expanded: true, + }, { + dataField: 'country', + area: 'column', + expanded: true, + }, { + dataField: 'city', + area: 'column', + }, { + dataField: 'date', + dataType: 'date', + area: 'row', + }, { + dataField: 'amount', + area: 'data', + summaryType: 'sum', + dataType: 'number', + }], + store: sales, + }, +})); diff --git a/packages/devextreme-scss/scss/widgets/base/pivotGrid/_index.scss b/packages/devextreme-scss/scss/widgets/base/pivotGrid/_index.scss index 71b2933a3529..662d63d6cfa3 100644 --- a/packages/devextreme-scss/scss/widgets/base/pivotGrid/_index.scss +++ b/packages/devextreme-scss/scss/widgets/base/pivotGrid/_index.scss @@ -133,6 +133,12 @@ $pivotgrid-expand-icon-text-offset: 0; outline-offset: -2px; } + .dx-pivotgrid-horizontal-headers td:focus-visible { + outline: 2px solid; + outline-color: $pivotgrid-accent-color; + outline-offset: -2px; + } + .dx-expand-icon-container:focus-visible { outline: none; } diff --git a/packages/devextreme/js/__internal/grids/pivot_grid/area_item/m_area_item.ts b/packages/devextreme/js/__internal/grids/pivot_grid/area_item/m_area_item.ts index 62ec98756bf3..9e20d99937e6 100644 --- a/packages/devextreme/js/__internal/grids/pivot_grid/area_item/m_area_item.ts +++ b/packages/devextreme/js/__internal/grids/pivot_grid/area_item/m_area_item.ts @@ -141,6 +141,10 @@ abstract class AreaItem { return tbody; } + _isCellNavigationEnabled() { + return false; + } + _getCloseMainElementMarkup() { return ''; } @@ -202,6 +206,7 @@ abstract class AreaItem { const rowsCount = data.length; const rtlEnabled = this.option('rtlEnabled'); const encodeHtml = this.option('encodeHtml'); + const isCellNavigationEnabled = this._isCellNavigationEnabled(); tableElement.data('area', this._getAreaName()); tableElement.data('data', data); @@ -240,6 +245,10 @@ abstract class AreaItem { cell.rowspan && td.setAttribute('rowspan', cell.rowspan || 1); cell.colspan && td.setAttribute('colspan', cell.colspan || 1); + if (isCellNavigationEnabled) { + td.setAttribute('tabindex', '-1'); + } + const styleOptions = { cellElement: undefined, cell, @@ -274,7 +283,7 @@ abstract class AreaItem { div.setAttribute('role', 'button'); div.setAttribute('aria-label', encodeHtml ? ariaLabel : $('
').html(ariaLabel).text()); div.setAttribute('aria-expanded', String(cell.expanded)); - div.setAttribute('tabindex', '0'); + div.setAttribute('tabindex', isCellNavigationEnabled ? '-1' : '0'); } cellText = this._getCellText(cell, encodeHtml); @@ -633,7 +642,9 @@ abstract class AreaItem { .removeAttr('id') .attr('aria-hidden', 'true') .addClass('dx-pivot-grid-fake-table') - .appendTo(that._virtualContent); + .attr('aria-hidden', 'true'); + that._fakeTable.find('[tabindex]').removeAttr('tabindex'); + that._fakeTable.appendTo(that._virtualContent); } } diff --git a/packages/devextreme/js/__internal/grids/pivot_grid/headers_area/m_headers_area.ts b/packages/devextreme/js/__internal/grids/pivot_grid/headers_area/m_headers_area.ts index 5e5d129d3062..7ac2e2836010 100644 --- a/packages/devextreme/js/__internal/grids/pivot_grid/headers_area/m_headers_area.ts +++ b/packages/devextreme/js/__internal/grids/pivot_grid/headers_area/m_headers_area.ts @@ -49,6 +49,10 @@ class HorizontalHeadersArea extends AreaItem { return PIVOTGRID_AREA_COLUMN_CLASS; } + _isCellNavigationEnabled() { + return true; + } + _createGroupElement() { return $('
') .addClass(this._getAreaClassName()) @@ -180,6 +184,11 @@ class VerticalHeadersArea extends HorizontalHeadersArea { return PIVOTGRID_AREA_ROW_CLASS; } + // Cell navigation for the row headers area is not implemented yet + _isCellNavigationEnabled() { + return false; + } + _applyCustomStyles(options) { super._applyCustomStyles(options); diff --git a/packages/devextreme/js/__internal/grids/pivot_grid/keyboard_navigation/const.ts b/packages/devextreme/js/__internal/grids/pivot_grid/keyboard_navigation/const.ts new file mode 100644 index 000000000000..119e58b3d1da --- /dev/null +++ b/packages/devextreme/js/__internal/grids/pivot_grid/keyboard_navigation/const.ts @@ -0,0 +1,3 @@ +export const HORIZONTAL_HEADERS_AREA_CLASS = 'dx-pivotgrid-horizontal-headers'; +export const VERTICAL_HEADERS_AREA_CLASS = 'dx-pivotgrid-vertical-headers'; +export const FAKE_TABLE_CLASS = 'dx-pivot-grid-fake-table'; diff --git a/packages/devextreme/js/__internal/grids/pivot_grid/m_widget.ts b/packages/devextreme/js/__internal/grids/pivot_grid/m_widget.ts index 725e2b9b55a6..dc8ef6293e1a 100644 --- a/packages/devextreme/js/__internal/grids/pivot_grid/m_widget.ts +++ b/packages/devextreme/js/__internal/grids/pivot_grid/m_widget.ts @@ -34,6 +34,10 @@ import { FieldChooser } from './field_chooser/m_field_chooser'; import { FieldChooserBase } from './field_chooser/m_field_chooser_base'; import { FieldsArea } from './fields_area/m_fields_area'; import HeadersArea from './headers_area/m_headers_area'; +import { FAKE_TABLE_CLASS, HORIZONTAL_HEADERS_AREA_CLASS } from './keyboard_navigation/const'; +import { RovingTabIndex } from './keyboard_navigation/roving_tab_index'; +import type { CellNavigationDirection } from './keyboard_navigation/table_cell_navigation'; +import { getAdjacentCell } from './keyboard_navigation/table_cell_navigation'; import { findField, mergeArraysByMaxValue, setFieldProperty } from './m_widget_utils'; const window = getWindow(); @@ -60,6 +64,13 @@ const TEST_HEIGHT = 66666; const FIELD_CALCULATED_OPTIONS = ['allowSorting', 'allowSortingBySummary', 'allowFiltering', 'allowExpandAll']; +const ARROW_KEY_DIRECTIONS: Record = { + ArrowUp: 'up', + ArrowDown: 'down', + ArrowLeft: 'left', + ArrowRight: 'right', +}; + function getArraySum(array) { let sum = 0; @@ -111,6 +122,8 @@ function clickedOnFieldsArea($targetElement) { class PivotGrid extends Widget { _dataController: any; + _columnsAreaNavigation: RovingTabIndex | undefined; + _scrollLeft: any; _scrollTop: any; @@ -891,7 +904,81 @@ class PivotGrid extends Widget { }); } + _getColumnsAreaNavigation() { + this._columnsAreaNavigation = this._columnsAreaNavigation ?? new RovingTabIndex({ + component: this, + getItems: () => this._getColumnHeaderCells(), + scrollToItem: (item) => this._columnsArea.scrollToElement(item), + }); + + return this._columnsAreaNavigation; + } + + _getColumnHeaderCells(): HTMLElement[] { + const element = this.$element().get(0); + + if (!element) { + return []; + } + + const cells: HTMLElement[] = Array.from(element.querySelectorAll(`thead.${HORIZONTAL_HEADERS_AREA_CLASS} td`)); + + return cells.filter((cell) => !cell.closest(`.${FAKE_TABLE_CLASS}`)); + } + + _getCellAreaNavigation(cell): RovingTabIndex | undefined { + if (cell.closest?.(`.${FAKE_TABLE_CLASS}`)) { + return undefined; + } + if (cell.closest?.(`thead.${HORIZONTAL_HEADERS_AREA_CLASS}`)) { + return this._getColumnsAreaNavigation(); + } + + return undefined; + } + + _normalizeCellNavigationDirection(direction: CellNavigationDirection): CellNavigationDirection { + if (!this.option('rtlEnabled')) { + return direction; + } + if (direction === 'left') { + return 'right'; + } + if (direction === 'right') { + return 'left'; + } + + return direction; + } + + _handleCellArrowKeyDown(e, direction: CellNavigationDirection) { + const cell = e.currentTarget; + const navigation = this._getCellAreaNavigation(cell); + + if (!navigation) { + return; + } + + e.preventDefault(); + + const section = cell.closest('thead, tbody'); + const target = getAdjacentCell(section, cell, this._normalizeCellNavigationDirection(direction)); + + if (target) { + navigation.focusItem(target); + } + } + + _handleCellFocusIn(e) { + this._getCellAreaNavigation(e.currentTarget)?.handleFocusIn(e.currentTarget); + } + _handleCellKeyDown(e) { + const direction = ARROW_KEY_DIRECTIONS[e.key]; + if (direction) { + this._handleCellArrowKeyDown(e, direction); + return; + } if (e.repeat) { return; } @@ -1121,6 +1208,7 @@ class PivotGrid extends Widget { eventsEngine.on($table, addNamespace(clickEventName, 'dxPivotGrid'), 'td', that._handleCellClick.bind(that)); eventsEngine.on($table, addNamespace('keydown', 'dxPivotGrid'), 'td', that._handleCellKeyDown.bind(that)); + eventsEngine.on($table, addNamespace('focusin', 'dxPivotGrid'), 'td', that._handleCellFocusIn.bind(that)); return $table; } @@ -1143,11 +1231,33 @@ class PivotGrid extends Widget { return rowsArea; } + // Rendering moves header elements between area tables, which drops focus, + // so the focused cell is restored after the content is ready. + _scheduleNavigationFocusRestore(navigation: RovingTabIndex) { + const onReady = () => { + this.off('contentReady', onReady); + if (!navigation.containsActiveElement()) { + navigation.refocusFocusedItem(); + } + }; + this.on('contentReady', onReady); + } + _renderColumnsArea(columnsAreaElement) { const that = this; const columnsArea = that._columnsArea || new HeadersArea.HorizontalHeadersArea(that); that._columnsArea = columnsArea; + + const navigation = that._getColumnsAreaNavigation(); + const needRestoreFocus = navigation.containsActiveElement(); + columnsArea.render(columnsAreaElement, that._dataController.getColumnsInfo()); + navigation.updateTabIndexes(); + + if (needRestoreFocus) { + navigation.refocusFocusedItem(); + that._scheduleNavigationFocusRestore(navigation); + } return columnsArea; } diff --git a/packages/testcafe-models/pivotGrid/columnsArea.ts b/packages/testcafe-models/pivotGrid/columnsArea.ts new file mode 100644 index 000000000000..275dd324e7a5 --- /dev/null +++ b/packages/testcafe-models/pivotGrid/columnsArea.ts @@ -0,0 +1,21 @@ +import type { Selector } from 'testcafe'; + +const CLASSES = { + root: 'dx-pivotgrid-horizontal-headers', +}; + +export default class ColumnsArea { + public readonly element: Selector; + + constructor(selector: Selector, idx?: number) { + this.element = selector.find(`thead.${CLASSES.root}`).nth(idx ?? 0); + } + + getCell(rowIdx = 0, cellIdx = 0): Selector { + return this.element.find('tr').nth(rowIdx).find('td').nth(cellIdx); + } + + getCells(): Selector { + return this.element.find('td'); + } +} diff --git a/packages/testcafe-models/pivotGrid/index.ts b/packages/testcafe-models/pivotGrid/index.ts index 753c91a87b92..4f78a22bcef4 100644 --- a/packages/testcafe-models/pivotGrid/index.ts +++ b/packages/testcafe-models/pivotGrid/index.ts @@ -3,6 +3,7 @@ import type { WidgetName } from '../types'; import Widget from '../internal/widget'; import Popup from '../popup'; import ColumnHeaderArea from './columnHeaderArea'; +import ColumnsArea from './columnsArea'; import DataHeaderArea from './dataHeaderArea'; import FieldChooser from './fieldChooser'; import FilterHeaderArea from './filterHeaderArea'; @@ -78,6 +79,10 @@ export default class PivotGrid extends Widget { return new RowsArea(this.element, idx); } + getColumnsArea(idx?: number): ColumnsArea { + return new ColumnsArea(this.element, idx); + } + getDataHeaderArea(): DataHeaderArea { return new DataHeaderArea(this.element); }