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/e2e/testcafe-devextreme/tests/common/pivotGrid/kbn/expandIcon.ts b/e2e/testcafe-devextreme/tests/common/pivotGrid/kbn/expandIcon.ts
index 7f249c6b94d8..a11ed71dc2f7 100644
--- a/e2e/testcafe-devextreme/tests/common/pivotGrid/kbn/expandIcon.ts
+++ b/e2e/testcafe-devextreme/tests/common/pivotGrid/kbn/expandIcon.ts
@@ -20,13 +20,13 @@ test('Expandable cell should have a visible focus outline when focused by keyboa
for (let i = 0; i < 10; i += 1) {
await t.pressKey('tab');
- if (await Selector(':focus').hasAttribute('aria-expanded')) {
+ if (await Selector(':focus').find('[aria-expanded]').exists) {
break;
}
}
await t
- .expect(Selector(':focus').hasAttribute('aria-expanded'))
+ .expect(Selector(':focus').find('[aria-expanded]').exists)
.ok('an expandable cell is focused');
await testScreenshot(t, takeScreenshot, 'pivotgrid_kbn_expandable_cell_focused.png', { element: pivotGrid.element });
diff --git a/e2e/testcafe-devextreme/tests/common/pivotGrid/kbn/rowHeaders.ts b/e2e/testcafe-devextreme/tests/common/pivotGrid/kbn/rowHeaders.ts
new file mode 100644
index 000000000000..052e88e20e2a
--- /dev/null
+++ b/e2e/testcafe-devextreme/tests/common/pivotGrid/kbn/rowHeaders.ts
@@ -0,0 +1,205 @@
+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_rowHeaders`
+ .page(url(__dirname, '../../../container.html'));
+
+const PIVOT_GRID_SELECTOR = '#container';
+const ROW_HEADERS_CELL_SELECTOR = 'tbody.dx-pivotgrid-vertical-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: 'row',
+ expanded: true,
+ }, {
+ dataField: 'city',
+ area: 'row',
+ }, {
+ dataField: 'date',
+ dataType: 'date',
+ area: 'column',
+ }, {
+ dataField: 'amount',
+ area: 'data',
+ summaryType: 'sum',
+ dataType: 'number',
+ }],
+ store: sales,
+ },
+});
+
+test('Row header cells should form a single tab stop', async (t) => {
+ const pivotGrid = new PivotGrid(PIVOT_GRID_SELECTOR);
+ const rowsArea = pivotGrid.getRowsArea();
+
+ await t
+ .expect(Selector(`${ROW_HEADERS_CELL_SELECTOR}[tabindex="0"]`).count)
+ .eql(1, 'only one row header cell is in the tab order');
+
+ await blurActiveElement();
+
+ // The first Tab focuses the column headers area, the second one - the row headers area.
+ await t
+ .pressKey('tab tab')
+ .expect(rowsArea.getCellByPosition(0, 0).focused)
+ .ok('first row header cell is focused by Tab');
+
+ await t
+ .expect(Selector(`${ROW_HEADERS_CELL_SELECTOR} .dx-expand-icon-container[tabindex="0"]`).count)
+ .eql(0, 'expand icons of row header cells are not in the tab order');
+}).before(async () => createWidget('dxPivotGrid', createConfig()));
+
+test('ArrowUp and ArrowDown should move focus between rows', async (t) => {
+ const pivotGrid = new PivotGrid(PIVOT_GRID_SELECTOR);
+ const rowsArea = pivotGrid.getRowsArea();
+
+ await t
+ .click(rowsArea.getCellByPosition(0, 1))
+ .expect(rowsArea.getCellByPosition(0, 1).focused)
+ .ok('first city cell is focused after click');
+
+ await t
+ .pressKey('down')
+ .expect(rowsArea.getCellByPosition(1, 0).focused)
+ .ok('city cell in the next row is focused after ArrowDown');
+
+ await t
+ .pressKey('up')
+ .expect(rowsArea.getCellByPosition(0, 1).focused)
+ .ok('city cell in the previous row is focused after ArrowUp');
+}).before(async () => createWidget('dxPivotGrid', createConfig()));
+
+test('ArrowLeft and ArrowRight should move focus between row header levels', async (t) => {
+ const pivotGrid = new PivotGrid(PIVOT_GRID_SELECTOR);
+ const rowsArea = pivotGrid.getRowsArea();
+
+ await t
+ .click(rowsArea.getCellByPosition(0, 1));
+
+ await t
+ .pressKey('left')
+ .expect(rowsArea.getCellByPosition(0, 0).focused)
+ .ok('parent level cell is focused after ArrowLeft');
+
+ await t
+ .pressKey('right')
+ .expect(rowsArea.getCellByPosition(0, 1).focused)
+ .ok('child level cell is focused after ArrowRight');
+}).before(async () => createWidget('dxPivotGrid', createConfig()));
+
+test('Roving tabindex should follow the focused cell', async (t) => {
+ const pivotGrid = new PivotGrid(PIVOT_GRID_SELECTOR);
+ const rowsArea = pivotGrid.getRowsArea();
+
+ await t
+ .click(rowsArea.getCellByPosition(1, 0))
+ .expect(rowsArea.getCellByPosition(1, 0).getAttribute('tabindex'))
+ .eql('0', 'focused cell is in the tab order');
+
+ await t
+ .expect(Selector(`${ROW_HEADERS_CELL_SELECTOR}[tabindex="0"]`).count)
+ .eql(1, 'the focused cell is the only tab stop');
+
+ await t
+ .expect(rowsArea.getCellByPosition(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 rowsArea = pivotGrid.getRowsArea();
+
+ await blurActiveElement();
+
+ await t
+ .pressKey('tab tab')
+ .expect(rowsArea.getCellByPosition(0, 0).focused)
+ .ok('expandable cell is focused');
+
+ const firstCellText = (await rowsArea.getCellByPosition(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 getRowsScrollTop = ClientFunction(
+ // eslint-disable-next-line no-underscore-dangle
+ () => (getInstance() as any)._rowsArea._getScrollable().scrollTop(),
+ { dependencies: { getInstance } },
+ );
+
+ await blurActiveElement();
+
+ await t.pressKey('tab tab');
+
+ for (let i = 0; i < 8; i += 1) {
+ await t.pressKey('down');
+ }
+
+ const focusedCell = Selector(ROW_HEADERS_CELL_SELECTOR)
+ .filter((node) => node === document.activeElement);
+
+ await t
+ .expect(focusedCell.count)
+ .eql(1, 'a row header cell is focused after arrow navigation')
+ .expect(focusedCell.visible)
+ .ok('the focused cell is visible')
+ .expect(getRowsScrollTop())
+ .gt(0, 'the row headers area is scrolled to the focused cell');
+}).before(async () => createWidget('dxPivotGrid', {
+ ...createConfig(),
+ width: 400,
+ height: 250,
+ scrolling: {
+ mode: 'virtual',
+ },
+ dataSource: {
+ fields: [{
+ dataField: 'region',
+ area: 'row',
+ expanded: true,
+ }, {
+ dataField: 'country',
+ area: 'row',
+ expanded: true,
+ }, {
+ dataField: 'city',
+ area: 'row',
+ }, {
+ dataField: 'date',
+ dataType: 'date',
+ area: 'column',
+ }, {
+ 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..9438cab26234 100644
--- a/packages/devextreme-scss/scss/widgets/base/pivotGrid/_index.scss
+++ b/packages/devextreme-scss/scss/widgets/base/pivotGrid/_index.scss
@@ -133,6 +133,13 @@ $pivotgrid-expand-icon-text-offset: 0;
outline-offset: -2px;
}
+ .dx-pivotgrid-horizontal-headers td:focus-visible,
+ .dx-pivotgrid-vertical-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 0cd020da7661..e2f8636cc6be 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
@@ -135,6 +135,10 @@ abstract class AreaItem {
return domAdapter.createElement('tbody');
}
+ _isCellNavigationEnabled() {
+ return false;
+ }
+
_getCloseMainElementMarkup() {
return '';
}
@@ -143,6 +147,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);
@@ -168,6 +173,10 @@ abstract class AreaItem {
cell.rowspan && td.setAttribute('rowspan', cell.rowspan || 1);
cell.colspan && td.setAttribute('colspan', cell.colspan || 1);
+ if (isCellNavigationEnabled && !cell.isWhiteSpace) {
+ td.setAttribute('tabindex', '-1');
+ }
+
const styleOptions = {
cellElement: undefined,
cell,
@@ -202,7 +211,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);
@@ -552,7 +561,9 @@ abstract class AreaItem {
that._fakeTable = that.tableElement()
.clone()
.addClass('dx-pivot-grid-fake-table')
- .appendTo(that._virtualContent);
+ .attr('aria-hidden', 'true');
+ that._fakeTable.find('[tabindex]').removeAttr('tabindex');
+ that._fakeTable.appendTo(that._virtualContent);
}
}
@@ -644,6 +655,13 @@ abstract class AreaItem {
}
}
+ scrollToElement(element) {
+ const scrollable = this._getScrollable();
+ if (scrollable) {
+ scrollable.scrollToElement(element);
+ }
+ }
+
updateScrollable() {
const scrollable = this._getScrollable();
if (scrollable) {
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 7eacc0939003..4b9f7226c15e 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())
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/keyboard_navigation/roving_tab_index.test.ts b/packages/devextreme/js/__internal/grids/pivot_grid/keyboard_navigation/roving_tab_index.test.ts
new file mode 100644
index 000000000000..bb13b565ce8f
--- /dev/null
+++ b/packages/devextreme/js/__internal/grids/pivot_grid/keyboard_navigation/roving_tab_index.test.ts
@@ -0,0 +1,234 @@
+import {
+ afterEach,
+ beforeEach,
+ describe,
+ expect,
+ it,
+ jest,
+} from '@jest/globals';
+
+import type { RovingTabIndexComponent } from './roving_tab_index';
+import { RovingTabIndex } from './roving_tab_index';
+
+const createComponent = (
+ container: HTMLElement,
+ tabindex: number | undefined = undefined,
+): RovingTabIndexComponent => ({
+ option: (optionName?: string) => {
+ if (optionName === undefined) {
+ return { tabindex };
+ }
+
+ return optionName === 'tabindex' ? tabindex : undefined;
+ },
+ element: () => container,
+});
+
+describe('RovingTabIndex', () => {
+ let container: HTMLElement = document.createElement('div');
+ let items: HTMLElement[] = [];
+
+ const createItems = (count: number): HTMLElement[] => {
+ container.innerHTML = '';
+
+ return Array.from({ length: count }, () => {
+ const item = document.createElement('div');
+ container.appendChild(item);
+
+ return item;
+ });
+ };
+
+ const createHelper = (
+ options: Partial<{
+ tabindex: number;
+ scrollToItem: (item: HTMLElement) => void;
+ }> = {},
+ ): RovingTabIndex => new RovingTabIndex({
+ component: createComponent(container, options.tabindex),
+ getItems: () => items,
+ scrollToItem: options.scrollToItem,
+ });
+
+ beforeEach(() => {
+ container = document.createElement('div');
+ document.body.appendChild(container);
+ items = createItems(3);
+ });
+
+ afterEach(() => {
+ container.remove();
+ });
+
+ describe('updateTabIndexes', () => {
+ it('should make the first item the single tab stop by default', () => {
+ const helper = createHelper();
+
+ helper.updateTabIndexes();
+
+ expect(items.map((item) => item.getAttribute('tabindex'))).toEqual(['0', '-1', '-1']);
+ });
+
+ it('should use the component tabindex option for the focused item', () => {
+ const helper = createHelper({ tabindex: 5 });
+
+ helper.updateTabIndexes();
+
+ expect(items.map((item) => item.getAttribute('tabindex'))).toEqual(['5', '-1', '-1']);
+ });
+
+ it('should keep the focused item as the tab stop', () => {
+ const helper = createHelper();
+ helper.focusItem(2);
+
+ helper.updateTabIndexes();
+
+ expect(items.map((item) => item.getAttribute('tabindex'))).toEqual(['-1', '-1', '0']);
+ });
+
+ it('should clamp the focused index when items shrink', () => {
+ const helper = createHelper();
+ helper.focusItem(2);
+
+ items = createItems(2);
+ helper.updateTabIndexes();
+
+ expect(items.map((item) => item.getAttribute('tabindex'))).toEqual(['-1', '0']);
+ expect(helper.getFocusedItemIndex()).toBe(1);
+ });
+
+ it('should do nothing when there are no items', () => {
+ const helper = createHelper();
+ items = [];
+
+ expect(() => helper.updateTabIndexes()).not.toThrow();
+ expect(helper.getFocusedItemIndex()).toBe(-1);
+ expect(helper.getFocusedItem()).toBeUndefined();
+ });
+ });
+
+ describe('focusItem', () => {
+ it('should focus the item by index and move the tab stop', () => {
+ const helper = createHelper();
+
+ helper.focusItem(1);
+
+ expect(document.activeElement).toBe(items[1]);
+ expect(items.map((item) => item.getAttribute('tabindex'))).toEqual(['-1', '0', '-1']);
+ expect(helper.getFocusedItem()).toBe(items[1]);
+ });
+
+ it('should focus the item by element', () => {
+ const helper = createHelper();
+
+ helper.focusItem(items[2]);
+
+ expect(document.activeElement).toBe(items[2]);
+ expect(helper.getFocusedItemIndex()).toBe(2);
+ });
+
+ it('should scroll the item into view', () => {
+ const scrollToItem = jest.fn();
+ const helper = createHelper({ scrollToItem });
+
+ helper.focusItem(1);
+
+ expect(scrollToItem).toHaveBeenCalledTimes(1);
+ expect(scrollToItem).toHaveBeenCalledWith(items[1]);
+ });
+
+ it('should ignore an out of range index', () => {
+ const helper = createHelper();
+ helper.focusItem(1);
+
+ helper.focusItem(10);
+
+ expect(helper.getFocusedItemIndex()).toBe(1);
+ });
+
+ it('should ignore a foreign element', () => {
+ const helper = createHelper();
+ helper.focusItem(1);
+
+ helper.focusItem(document.createElement('div'));
+
+ expect(helper.getFocusedItemIndex()).toBe(1);
+ });
+ });
+
+ describe('handleFocusIn', () => {
+ it('should move the tab stop to the item focused from outside', () => {
+ const helper = createHelper();
+ helper.updateTabIndexes();
+
+ helper.handleFocusIn(items[2]);
+
+ expect(items.map((item) => item.getAttribute('tabindex'))).toEqual(['-1', '-1', '0']);
+ expect(helper.getFocusedItemIndex()).toBe(2);
+ });
+
+ it('should ignore a foreign element', () => {
+ const helper = createHelper();
+ helper.updateTabIndexes();
+
+ helper.handleFocusIn(document.createElement('div'));
+
+ expect(helper.getFocusedItemIndex()).toBe(0);
+ });
+ });
+
+ describe('reset', () => {
+ it('should move the tab stop back to the first item', () => {
+ const helper = createHelper();
+ helper.focusItem(2);
+
+ helper.reset();
+ helper.updateTabIndexes();
+
+ expect(items.map((item) => item.getAttribute('tabindex'))).toEqual(['0', '-1', '-1']);
+ });
+ });
+
+ describe('containsActiveElement', () => {
+ it('should detect focus on an item and on its content', () => {
+ const helper = createHelper();
+ const content = document.createElement('button');
+ items[1].appendChild(content);
+
+ expect(helper.containsActiveElement()).toBe(false);
+
+ helper.focusItem(1);
+ expect(helper.containsActiveElement()).toBe(true);
+
+ content.focus();
+ expect(helper.containsActiveElement()).toBe(true);
+ });
+ });
+
+ describe('refocusFocusedItem', () => {
+ it('should focus the roving item after items are re-created', () => {
+ const helper = createHelper();
+ helper.focusItem(1);
+
+ items = createItems(3);
+ helper.updateTabIndexes();
+ helper.refocusFocusedItem();
+
+ expect(document.activeElement).toBe(items[1]);
+ });
+ });
+
+ describe('saveFocus and restoreFocus', () => {
+ it('should restore focus to the same position after a re-render', () => {
+ const helper = createHelper();
+ helper.focusItem(1);
+
+ helper.saveFocus();
+ items = createItems(3);
+ helper.updateTabIndexes();
+ helper.restoreFocus();
+
+ expect(document.activeElement).toBe(items[1]);
+ });
+ });
+});
diff --git a/packages/devextreme/js/__internal/grids/pivot_grid/keyboard_navigation/roving_tab_index.ts b/packages/devextreme/js/__internal/grids/pivot_grid/keyboard_navigation/roving_tab_index.ts
new file mode 100644
index 000000000000..260fe7a8e474
--- /dev/null
+++ b/packages/devextreme/js/__internal/grids/pivot_grid/keyboard_navigation/roving_tab_index.ts
@@ -0,0 +1,117 @@
+import eventsEngine from '@js/common/core/events/core/events_engine';
+import domAdapter from '@js/core/dom_adapter';
+import $ from '@js/core/renderer';
+import { restoreFocus, saveFocusedElementInfo, setTabIndex } from '@js/ui/shared/accessibility';
+
+const NOT_FOCUSABLE_TAB_INDEX = '-1';
+
+export interface RovingTabIndexComponent {
+ option: (optionName?: string) => unknown;
+ element: () => Element | undefined;
+}
+
+export interface RovingTabIndexOptions {
+ component: RovingTabIndexComponent;
+ getItems: () => HTMLElement[];
+ scrollToItem?: (item: HTMLElement) => void;
+}
+
+export class RovingTabIndex {
+ private focusedItemIndex = 0;
+
+ constructor(private readonly options: RovingTabIndexOptions) {}
+
+ getItems(): HTMLElement[] {
+ return this.options.getItems() ?? [];
+ }
+
+ getFocusedItemIndex(): number {
+ return this.getNormalizedItemIndex(this.getItems());
+ }
+
+ getFocusedItem(): HTMLElement | undefined {
+ const items = this.getItems();
+
+ return items[this.getNormalizedItemIndex(items)];
+ }
+
+ updateTabIndexes(): void {
+ const items = this.getItems();
+
+ if (!items.length) {
+ return;
+ }
+
+ this.focusedItemIndex = this.getNormalizedItemIndex(items);
+
+ items.forEach((item, index) => {
+ if (index === this.focusedItemIndex) {
+ setTabIndex(this.options.component, $(item));
+ } else {
+ item.setAttribute('tabindex', NOT_FOCUSABLE_TAB_INDEX);
+ }
+ });
+ }
+
+ focusItem(itemOrIndex: HTMLElement | number): void {
+ const items = this.getItems();
+ const index = typeof itemOrIndex === 'number' ? itemOrIndex : items.indexOf(itemOrIndex);
+
+ if (index < 0 || index >= items.length) {
+ return;
+ }
+
+ this.focusedItemIndex = index;
+ this.updateTabIndexes();
+
+ const item = items[index];
+
+ this.options.scrollToItem?.(item);
+ // @ts-expect-error ts-error
+ eventsEngine.trigger($(item), 'focus');
+ }
+
+ containsActiveElement(): boolean {
+ const activeElement = domAdapter.getActiveElement();
+
+ return this.getItems()
+ .some((item) => item === activeElement || item.contains(activeElement));
+ }
+
+ refocusFocusedItem(): void {
+ this.getFocusedItem()?.focus({ preventScroll: true });
+ }
+
+ handleFocusIn(item: HTMLElement): void {
+ const index = this.getItems().indexOf(item);
+
+ if (index >= 0 && index !== this.focusedItemIndex) {
+ this.focusedItemIndex = index;
+ this.updateTabIndexes();
+ }
+ }
+
+ saveFocus(): void {
+ const item = this.getFocusedItem();
+
+ if (item) {
+ saveFocusedElementInfo(item, this.options.component);
+ }
+ }
+
+ restoreFocus(): void {
+ restoreFocus(this.options.component);
+ }
+
+ reset(): void {
+ this.focusedItemIndex = 0;
+ }
+
+ private getNormalizedItemIndex(items: HTMLElement[]): number {
+ if (!items.length) {
+ return -1;
+ }
+
+ return Math.min(Math.max(this.focusedItemIndex, 0), items.length - 1);
+ }
+}
diff --git a/packages/devextreme/js/__internal/grids/pivot_grid/keyboard_navigation/table_cell_navigation.test.ts b/packages/devextreme/js/__internal/grids/pivot_grid/keyboard_navigation/table_cell_navigation.test.ts
new file mode 100644
index 000000000000..e92a3ea23097
--- /dev/null
+++ b/packages/devextreme/js/__internal/grids/pivot_grid/keyboard_navigation/table_cell_navigation.test.ts
@@ -0,0 +1,126 @@
+import { describe, expect, it } from '@jest/globals';
+
+import {
+ buildCellMatrix,
+ getAdjacentCell,
+ getCellCoordinates,
+} from './table_cell_navigation';
+
+// Multi-level column headers layout:
+// | A (colspan=2) | B (rowspan=2) |
+// | A1 | A2 | |
+const createColumnHeadersSection = (): HTMLTableSectionElement => {
+ const table = document.createElement('table');
+ table.innerHTML = `
+
+ | |
+ | |
+
+ `;
+
+ return table.tHead as HTMLTableSectionElement;
+};
+
+// Nested row headers layout:
+// | P (rowspan=2) | P1 |
+// | | P2 |
+// | Q (colspan=2) |
+const createRowHeadersSection = (): HTMLTableSectionElement => {
+ const table = document.createElement('table');
+ table.innerHTML = `
+
+ | |
+ |
+ |
+
+ `;
+
+ return table.tBodies[0];
+};
+
+const getCell = (
+ section: HTMLTableSectionElement,
+ id: string,
+): HTMLTableCellElement => section.querySelector(`#${id}`) as HTMLTableCellElement;
+
+describe('buildCellMatrix', () => {
+ it('should expand colspan and rowspan into matrix positions', () => {
+ const section = createColumnHeadersSection();
+
+ const matrix = buildCellMatrix(section);
+
+ expect(matrix[0]).toEqual([getCell(section, 'a'), getCell(section, 'a'), getCell(section, 'b')]);
+ expect(matrix[1]).toEqual([getCell(section, 'a1'), getCell(section, 'a2'), getCell(section, 'b')]);
+ });
+});
+
+describe('getCellCoordinates', () => {
+ it('should return the top-left position of a spanned cell', () => {
+ const section = createRowHeadersSection();
+ const matrix = buildCellMatrix(section);
+
+ expect(getCellCoordinates(matrix, getCell(section, 'p'))).toEqual({ rowIndex: 0, columnIndex: 0 });
+ expect(getCellCoordinates(matrix, getCell(section, 'p2'))).toEqual({ rowIndex: 1, columnIndex: 1 });
+ expect(getCellCoordinates(matrix, getCell(section, 'q'))).toEqual({ rowIndex: 2, columnIndex: 0 });
+ });
+
+ it('should return undefined for a foreign cell', () => {
+ const section = createRowHeadersSection();
+ const matrix = buildCellMatrix(section);
+
+ expect(getCellCoordinates(matrix, document.createElement('td'))).toBeUndefined();
+ });
+});
+
+describe('getAdjacentCell', () => {
+ describe('multi-level column headers', () => {
+ it('should move between cells of one level', () => {
+ const section = createColumnHeadersSection();
+
+ expect(getAdjacentCell(section, getCell(section, 'a1'), 'right')).toBe(getCell(section, 'a2'));
+ expect(getAdjacentCell(section, getCell(section, 'a2'), 'left')).toBe(getCell(section, 'a1'));
+ expect(getAdjacentCell(section, getCell(section, 'a'), 'right')).toBe(getCell(section, 'b'));
+ });
+
+ it('should move between levels', () => {
+ const section = createColumnHeadersSection();
+
+ expect(getAdjacentCell(section, getCell(section, 'a'), 'down')).toBe(getCell(section, 'a1'));
+ expect(getAdjacentCell(section, getCell(section, 'a2'), 'up')).toBe(getCell(section, 'a'));
+ });
+
+ it('should not move outside the section or inside an own span', () => {
+ const section = createColumnHeadersSection();
+
+ expect(getAdjacentCell(section, getCell(section, 'a'), 'up')).toBeNull();
+ expect(getAdjacentCell(section, getCell(section, 'a'), 'left')).toBeNull();
+ expect(getAdjacentCell(section, getCell(section, 'b'), 'right')).toBeNull();
+ expect(getAdjacentCell(section, getCell(section, 'b'), 'down')).toBeNull();
+ expect(getAdjacentCell(section, getCell(section, 'a1'), 'down')).toBeNull();
+ });
+ });
+
+ describe('nested row headers', () => {
+ it('should move between rows', () => {
+ const section = createRowHeadersSection();
+
+ expect(getAdjacentCell(section, getCell(section, 'p1'), 'down')).toBe(getCell(section, 'p2'));
+ expect(getAdjacentCell(section, getCell(section, 'p2'), 'down')).toBe(getCell(section, 'q'));
+ expect(getAdjacentCell(section, getCell(section, 'q'), 'up')).toBe(getCell(section, 'p'));
+ expect(getAdjacentCell(section, getCell(section, 'p'), 'down')).toBe(getCell(section, 'q'));
+ });
+
+ it('should move between nesting levels', () => {
+ const section = createRowHeadersSection();
+
+ expect(getAdjacentCell(section, getCell(section, 'p'), 'right')).toBe(getCell(section, 'p1'));
+ expect(getAdjacentCell(section, getCell(section, 'p2'), 'left')).toBe(getCell(section, 'p'));
+ });
+
+ it('should return null for a foreign cell', () => {
+ const section = createRowHeadersSection();
+
+ expect(getAdjacentCell(section, document.createElement('td'), 'down')).toBeNull();
+ });
+ });
+});
diff --git a/packages/devextreme/js/__internal/grids/pivot_grid/keyboard_navigation/table_cell_navigation.ts b/packages/devextreme/js/__internal/grids/pivot_grid/keyboard_navigation/table_cell_navigation.ts
new file mode 100644
index 000000000000..7226ec09c7cd
--- /dev/null
+++ b/packages/devextreme/js/__internal/grids/pivot_grid/keyboard_navigation/table_cell_navigation.ts
@@ -0,0 +1,80 @@
+export type CellNavigationDirection = 'up' | 'down' | 'left' | 'right';
+
+export interface CellCoordinates {
+ rowIndex: number;
+ columnIndex: number;
+}
+
+export function buildCellMatrix(section: HTMLTableSectionElement): HTMLTableCellElement[][] {
+ const matrix: HTMLTableCellElement[][] = [];
+
+ Array.from(section.rows).forEach((row, rowIndex) => {
+ matrix[rowIndex] ??= [];
+
+ Array.from(row.cells).forEach((cell) => {
+ let columnIndex = 0;
+
+ while (matrix[rowIndex][columnIndex]) {
+ columnIndex += 1;
+ }
+
+ for (let rowOffset = 0; rowOffset < cell.rowSpan; rowOffset += 1) {
+ for (let columnOffset = 0; columnOffset < cell.colSpan; columnOffset += 1) {
+ matrix[rowIndex + rowOffset] ??= [];
+ matrix[rowIndex + rowOffset][columnIndex + columnOffset] = cell;
+ }
+ }
+ });
+ });
+
+ return matrix;
+}
+
+export function getCellCoordinates(
+ matrix: HTMLTableCellElement[][],
+ cell: HTMLTableCellElement,
+): CellCoordinates | undefined {
+ for (let rowIndex = 0; rowIndex < matrix.length; rowIndex += 1) {
+ const columnIndex = matrix[rowIndex]?.indexOf(cell) ?? -1;
+
+ if (columnIndex > -1) {
+ return { rowIndex, columnIndex };
+ }
+ }
+
+ return undefined;
+}
+
+export function getAdjacentCell(
+ section: HTMLTableSectionElement,
+ cell: HTMLTableCellElement,
+ direction: CellNavigationDirection,
+): HTMLTableCellElement | null {
+ const matrix = buildCellMatrix(section);
+ const coordinates = getCellCoordinates(matrix, cell);
+
+ if (!coordinates) {
+ return null;
+ }
+
+ const { rowIndex, columnIndex } = coordinates;
+
+ const getTarget = (): HTMLTableCellElement | undefined => {
+ switch (direction) {
+ case 'left':
+ return matrix[rowIndex][columnIndex - 1];
+ case 'right':
+ return matrix[rowIndex][columnIndex + cell.colSpan];
+ case 'up':
+ return matrix[rowIndex - 1]?.[columnIndex];
+ case 'down':
+ return matrix[rowIndex + cell.rowSpan]?.[columnIndex];
+ default:
+ return undefined;
+ }
+ };
+
+ const target = getTarget();
+
+ return target && target !== cell ? target : null;
+}
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 e72d30859e20..4cb0e41a0736 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, VERTICAL_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,15 @@ const TEST_HEIGHT = 66666;
const FIELD_CALCULATED_OPTIONS = ['allowSorting', 'allowSortingBySummary', 'allowFiltering', 'allowExpandAll'];
+const WHITE_SPACE_COLUMN_CLASS = 'dx-white-space-column';
+
+const ARROW_KEY_DIRECTIONS: Record = {
+ ArrowUp: 'up',
+ ArrowDown: 'down',
+ ArrowLeft: 'left',
+ ArrowRight: 'right',
+};
+
function getArraySum(array) {
let sum = 0;
@@ -111,6 +124,10 @@ function clickedOnFieldsArea($targetElement) {
class PivotGrid extends Widget {
_dataController: any;
+ _columnsAreaNavigation: RovingTabIndex | undefined;
+
+ _rowsAreaNavigation: RovingTabIndex | undefined;
+
_scrollLeft: any;
_scrollTop: any;
@@ -891,7 +908,107 @@ 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;
+ }
+
+ _getRowsAreaNavigation() {
+ this._rowsAreaNavigation = this._rowsAreaNavigation ?? new RovingTabIndex({
+ component: this,
+ getItems: () => this._getRowHeaderCells(),
+ scrollToItem: (item) => this._rowsArea.scrollToElement(item),
+ });
+
+ return this._rowsAreaNavigation;
+ }
+
+ _getHeaderAreaCells(cellSelector: string): HTMLElement[] {
+ const element = this.$element().get(0);
+
+ if (!element) {
+ return [];
+ }
+
+ const cells: HTMLElement[] = Array.from(element.querySelectorAll(cellSelector));
+
+ return cells.filter((cell) => !cell.closest(`.${FAKE_TABLE_CLASS}`));
+ }
+
+ _getColumnHeaderCells(): HTMLElement[] {
+ return this._getHeaderAreaCells(`thead.${HORIZONTAL_HEADERS_AREA_CLASS} td`);
+ }
+
+ _getRowHeaderCells(): HTMLElement[] {
+ return this._getHeaderAreaCells(`tbody.${VERTICAL_HEADERS_AREA_CLASS} td:not(.${WHITE_SPACE_COLUMN_CLASS})`);
+ }
+
+ _getCellAreaNavigation(cell): RovingTabIndex | undefined {
+ if (cell.closest?.(`.${FAKE_TABLE_CLASS}`)) {
+ return undefined;
+ }
+ if (cell.closest?.(`thead.${HORIZONTAL_HEADERS_AREA_CLASS}`)) {
+ return this._getColumnsAreaNavigation();
+ }
+ if (cell.closest?.(`tbody.${VERTICAL_HEADERS_AREA_CLASS}`)) {
+ return this._getRowsAreaNavigation();
+ }
+
+ 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 normalizedDirection = this._normalizeCellNavigationDirection(direction);
+ let target = getAdjacentCell(section, cell, normalizedDirection);
+
+ while (target?.classList.contains(WHITE_SPACE_COLUMN_CLASS)) {
+ target = getAdjacentCell(section, target, normalizedDirection);
+ }
+
+ 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 +1238,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;
}
@@ -1138,16 +1256,48 @@ class PivotGrid extends Widget {
const that = this;
const rowsArea = that._rowsArea || new HeadersArea.VerticalHeadersArea(that);
that._rowsArea = rowsArea;
+
+ const navigation = that._getRowsAreaNavigation();
+ const needRestoreFocus = navigation.containsActiveElement();
+
rowsArea.render(rowsAreaElement, that._dataController.getRowsInfo());
+ navigation.updateTabIndexes();
+
+ if (needRestoreFocus) {
+ navigation.refocusFocusedItem();
+ that._scheduleNavigationFocusRestore(navigation);
+ }
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/devextreme/testing/tests/DevExpress.ui.widgets.pivotGrid/pivotGrid.markup.tests.js b/packages/devextreme/testing/tests/DevExpress.ui.widgets.pivotGrid/pivotGrid.markup.tests.js
index aed722833eb3..b5b52578f73e 100644
--- a/packages/devextreme/testing/tests/DevExpress.ui.widgets.pivotGrid/pivotGrid.markup.tests.js
+++ b/packages/devextreme/testing/tests/DevExpress.ui.widgets.pivotGrid/pivotGrid.markup.tests.js
@@ -137,9 +137,9 @@ QUnit.module('PivotGrid markup tests', () => {
const $collapsedControl = $collapsedTd.find('.dx-expand-icon-container');
assert.strictEqual($collapsedControl.attr('role'), 'button', 'control has role="button"');
- assert.strictEqual($collapsedControl.attr('tabindex'), '0', 'control is focusable');
+ assert.strictEqual($collapsedControl.attr('tabindex'), '-1', 'control is focusable programmatically only');
assert.strictEqual($collapsedTd.attr('role'), undefined, 'td keeps native cell role');
- assert.strictEqual($collapsedTd.attr('tabindex'), undefined, 'td is not in the tab order');
+ assert.strictEqual($collapsedTd.attr('tabindex'), '0', 'td is the roving tab stop of the row headers area');
assert.strictEqual($collapsedTd.attr('aria-expanded'), undefined, 'td has no aria-expanded');
} finally {
clock.restore();
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);
}
diff --git a/packages/testcafe-models/pivotGrid/rowsArea.ts b/packages/testcafe-models/pivotGrid/rowsArea.ts
index 8128dfad3d39..2027342461b1 100644
--- a/packages/testcafe-models/pivotGrid/rowsArea.ts
+++ b/packages/testcafe-models/pivotGrid/rowsArea.ts
@@ -12,4 +12,8 @@ export default class RowsArea {
getCell(idx = 0): Selector {
return this.element.find('td').nth(idx);
}
+
+ getCellByPosition(rowIdx = 0, cellIdx = 0): Selector {
+ return this.element.find('tr').nth(rowIdx).find('td').nth(cellIdx);
+ }
}