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);
}