From b91041190c9531088f4f8180727fb8acd274430d Mon Sep 17 00:00:00 2001 From: Aleksei Semikozov Date: Fri, 10 Jul 2026 11:56:51 -0300 Subject: [PATCH 1/3] PivotGrid - KBN - Implement arrow navigation for row header cells (#34204) --- .../common/pivotGrid/kbn/columnHeaders.ts | 202 +++++++++++++++++ .../tests/common/pivotGrid/kbn/expandIcon.ts | 4 +- .../tests/common/pivotGrid/kbn/rowHeaders.ts | 204 ++++++++++++++++++ .../scss/widgets/base/pivotGrid/_index.scss | 7 + .../grids/pivot_grid/area_item/m_area_item.ts | 24 ++- .../pivot_grid/headers_area/m_headers_area.ts | 10 +- .../pivot_grid/keyboard_navigation/const.ts | 3 + .../roving_tab_index.test.ts | 74 +++++++ .../keyboard_navigation/roving_tab_index.ts | 43 +++- .../__internal/grids/pivot_grid/m_widget.ts | 179 +++++++++++++++ .../pivotGrid.markup.tests.js | 6 +- .../testcafe-models/pivotGrid/columnsArea.ts | 21 ++ packages/testcafe-models/pivotGrid/index.ts | 5 + .../testcafe-models/pivotGrid/rowsArea.ts | 4 + 14 files changed, 770 insertions(+), 16 deletions(-) create mode 100644 e2e/testcafe-devextreme/tests/common/pivotGrid/kbn/columnHeaders.ts create mode 100644 e2e/testcafe-devextreme/tests/common/pivotGrid/kbn/rowHeaders.ts create mode 100644 packages/devextreme/js/__internal/grids/pivot_grid/keyboard_navigation/const.ts create mode 100644 packages/testcafe-models/pivotGrid/columnsArea.ts 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..46ade26d3517 --- /dev/null +++ b/e2e/testcafe-devextreme/tests/common/pivotGrid/kbn/rowHeaders.ts @@ -0,0 +1,204 @@ +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(); + + 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 62ec98756bf3..b615905c071d 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 @@ -10,6 +10,8 @@ import { isDefined } from '@js/core/utils/type'; import { getMemoizeScrollTo } from '@ts/core/utils/scroll'; import { foreachColumnInfo } from '@ts/grids/grid_core/virtual_columns/m_virtual_columns_core'; +import { FAKE_TABLE_CLASS } from '../keyboard_navigation/const'; + const PIVOTGRID_EXPAND_CLASS = 'dx-expand'; const getRealElementWidth = function (element) { @@ -141,6 +143,10 @@ abstract class AreaItem { return tbody; } + _isCellNavigationEnabled() { + return false; + } + _getCloseMainElementMarkup() { return ''; } @@ -202,6 +208,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 +247,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, @@ -274,7 +285,13 @@ 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'); + + // With cell navigation the cell itself is the focus target, so it + // must expose the expanded state to assistive technologies. + if (isCellNavigationEnabled) { + td.setAttribute('aria-expanded', String(cell.expanded)); + } } cellText = this._getCellText(cell, encodeHtml); @@ -632,8 +649,9 @@ abstract class AreaItem { .clone() .removeAttr('id') .attr('aria-hidden', 'true') - .addClass('dx-pivot-grid-fake-table') - .appendTo(that._virtualContent); + .addClass(FAKE_TABLE_CLASS); + 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..776b74053574 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 @@ -6,10 +6,12 @@ import { setHeight, setWidth } from '@js/core/utils/size'; import { isDefined } from '@js/core/utils/type'; import { AreaItem } from '../area_item/m_area_item'; +import { + HORIZONTAL_HEADERS_AREA_CLASS as PIVOTGRID_AREA_COLUMN_CLASS, + VERTICAL_HEADERS_AREA_CLASS as PIVOTGRID_AREA_ROW_CLASS, +} from '../keyboard_navigation/const'; const PIVOTGRID_AREA_CLASS = 'dx-pivotgrid-area'; -const PIVOTGRID_AREA_COLUMN_CLASS = 'dx-pivotgrid-horizontal-headers'; -const PIVOTGRID_AREA_ROW_CLASS = 'dx-pivotgrid-vertical-headers'; const PIVOTGRID_TOTAL_CLASS = 'dx-total'; const PIVOTGRID_GRAND_TOTAL_CLASS = 'dx-grandtotal'; const PIVOTGRID_ROW_TOTAL_CLASS = 'dx-row-total'; @@ -49,6 +51,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 index cb9dd8140fcc..fb64c6f774a6 100644 --- 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 @@ -43,11 +43,13 @@ describe('RovingTabIndex', () => { options: Partial<{ tabindex: number; scrollToItem: (item: HTMLElement) => void; + getItemId: (item: HTMLElement) => string | undefined; }> = {}, ): RovingTabIndex => new RovingTabIndex({ component: createComponent(container, options.tabindex), getItems: () => items, scrollToItem: options.scrollToItem, + getItemId: options.getItemId, }); beforeEach(() => { @@ -217,4 +219,76 @@ describe('RovingTabIndex', () => { expect(document.activeElement).toBe(items[1]); }); }); + + describe('item identity', () => { + const getItemId = (item: HTMLElement): string | undefined => item.dataset.id; + + const createIdentifiedItems = (ids: string[]): HTMLElement[] => { + const created = createItems(ids.length); + created.forEach((item, index) => { + item.dataset.id = ids[index]; + }); + + return created; + }; + + it('should keep the tab stop on the same logical item when items shift', () => { + items = createIdentifiedItems(['a', 'b', 'c']); + const helper = createHelper({ getItemId }); + helper.focusItem(1); + + // Simulates a virtual scrolling re-render that re-slices the pages. + items = createIdentifiedItems(['b', 'c', 'd']); + helper.updateTabIndexes(); + + expect(items.map((item) => item.getAttribute('tabindex'))).toEqual(['0', '-1', '-1']); + expect(helper.getFocusedItem()).toBe(items[0]); + }); + + it('should refocus the same logical item after items shift', () => { + items = createIdentifiedItems(['a', 'b', 'c']); + const helper = createHelper({ getItemId }); + helper.focusItem(1); + + items = createIdentifiedItems(['c', 'a', 'b']); + helper.updateTabIndexes(); + helper.refocusFocusedItem(); + + expect(document.activeElement).toBe(items[2]); + }); + + it('should track identity of an item focused from outside', () => { + items = createIdentifiedItems(['a', 'b', 'c']); + const helper = createHelper({ getItemId }); + helper.handleFocusIn(items[2]); + + items = createIdentifiedItems(['c', 'a', 'b']); + helper.updateTabIndexes(); + + expect(items.map((item) => item.getAttribute('tabindex'))).toEqual(['0', '-1', '-1']); + }); + + it('should fall back to the stored index when the focused item is gone', () => { + items = createIdentifiedItems(['a', 'b', 'c']); + const helper = createHelper({ getItemId }); + helper.focusItem(1); + + items = createIdentifiedItems(['x', 'y', 'z']); + helper.updateTabIndexes(); + + expect(items.map((item) => item.getAttribute('tabindex'))).toEqual(['-1', '0', '-1']); + }); + + it('should forget the identity on reset', () => { + items = createIdentifiedItems(['a', 'b', 'c']); + const helper = createHelper({ getItemId }); + helper.focusItem(2); + + helper.reset(); + items = createIdentifiedItems(['c', 'a', 'b']); + helper.updateTabIndexes(); + + expect(items.map((item) => item.getAttribute('tabindex'))).toEqual(['0', '-1', '-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 index c3bf214343f6..3ba6be01572b 100644 --- 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 @@ -6,6 +6,10 @@ const NOT_FOCUSABLE_TAB_INDEX = '-1'; export interface RovingTabIndexComponentOptions { tabindex?: number; + // Widgets declare tabIndex, while the shared setTabIndex helper reads the + // lowercase tabindex option; the overlap also makes WidgetProperties + // structurally assignable to this weak type. + tabIndex?: number; useLegacyKeyboardNavigation?: boolean; } @@ -21,11 +25,14 @@ export interface RovingTabIndexOptions { component: RovingTabIndexComponent; getItems: () => HTMLElement[]; scrollToItem?: (item: HTMLElement) => void; + getItemId?: (item: HTMLElement) => string | undefined; } export class RovingTabIndex { private focusedItemIndex = 0; + private focusedItemId: string | undefined; + constructor(private readonly options: RovingTabIndexOptions) {} getItems(): HTMLElement[] { @@ -33,13 +40,13 @@ export class RovingTabIndex { } getFocusedItemIndex(): number { - return this.getNormalizedItemIndex(this.getItems()); + return this.getActualItemIndex(this.getItems()); } getFocusedItem(): HTMLElement | undefined { const items = this.getItems(); - return items[this.getNormalizedItemIndex(items)]; + return items[this.getActualItemIndex(items)]; } updateTabIndexes(): void { @@ -49,7 +56,7 @@ export class RovingTabIndex { return; } - this.focusedItemIndex = this.getNormalizedItemIndex(items); + this.focusedItemIndex = this.getActualItemIndex(items); items.forEach((item, index) => { if (index === this.focusedItemIndex) { @@ -68,7 +75,7 @@ export class RovingTabIndex { return; } - this.focusedItemIndex = index; + this.setFocusedItem(index, items); this.updateTabIndexes(); const item = items[index]; @@ -91,16 +98,40 @@ export class RovingTabIndex { } handleFocusIn(item: HTMLElement): void { - const index = this.getItems().indexOf(item); + const items = this.getItems(); + const index = items.indexOf(item); if (index >= 0 && index !== this.focusedItemIndex) { - this.focusedItemIndex = index; + this.setFocusedItem(index, items); this.updateTabIndexes(); } } reset(): void { this.focusedItemIndex = 0; + this.focusedItemId = undefined; + } + + private setFocusedItem(index: number, items: HTMLElement[]): void { + this.focusedItemIndex = index; + this.focusedItemId = this.options.getItemId?.(items[index]); + } + + // Items are re-created on each render, and with virtual scrolling the same + // logical item can reappear at a different position, so the focused item is + // re-found by its id first and only then by the stored index. + private getActualItemIndex(items: HTMLElement[]): number { + const { getItemId } = this.options; + + if (getItemId && this.focusedItemId !== undefined) { + const index = items.findIndex((item) => getItemId(item) === this.focusedItemId); + + if (index >= 0) { + return index; + } + } + + return this.getNormalizedItemIndex(items); } private getNormalizedItemIndex(items: HTMLElement[]): number { 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..adc02d6c0c17 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,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,10 @@ function clickedOnFieldsArea($targetElement) { class PivotGrid extends Widget { _dataController: any; + _columnsAreaNavigation: RovingTabIndex | undefined; + + _rowsAreaNavigation: RovingTabIndex | undefined; + _scrollLeft: any; _scrollTop: any; @@ -891,7 +906,140 @@ class PivotGrid extends Widget { }); } + _getColumnsAreaNavigation() { + this._columnsAreaNavigation = this._columnsAreaNavigation ?? new RovingTabIndex({ + component: this, + getItems: () => this._getColumnHeaderCells(), + scrollToItem: (item) => this._columnsArea.scrollToElement(item), + getItemId: (item) => this._getHeaderCellId(item), + }); + + return this._columnsAreaNavigation; + } + + // aria-colindex is absolute (it includes the virtual scrolling offset), so + // it identifies the same logical cell across re-renders that re-slice the + // rendered header pages. + _getHeaderCellId(cell: HTMLElement): string | undefined { + const columnIndex = cell.getAttribute('aria-colindex'); + const row = cell.parentElement as HTMLTableRowElement | null; + + if (!columnIndex || !row) { + return undefined; + } + + return `${row.sectionRowIndex}:${columnIndex}`; + } + + _getRowsAreaNavigation() { + this._rowsAreaNavigation = this._rowsAreaNavigation ?? new RovingTabIndex({ + component: this, + getItems: () => this._getRowHeaderCells(), + scrollToItem: (item) => this._rowsArea.scrollToElement(item), + getItemId: (item) => this._getRowHeaderCellId(item), + }); + + return this._rowsAreaNavigation; + } + + // aria-rowindex is absolute as well, so it identifies the same logical row + // header cell across virtual scrolling re-renders. + _getRowHeaderCellId(cell: HTMLElement): string | undefined { + const rowIndex = cell.getAttribute('aria-rowindex'); + const { cellIndex } = cell as HTMLTableCellElement; + + if (!rowIndex || cellIndex < 0) { + return undefined; + } + + return `${cellIndex}:${rowIndex}`; + } + + _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}`)); + } + + // Only cells marked as navigable on render take part in the roving + // tabindex: whitespace filler cells are rendered without tabindex. + _getColumnHeaderCells(): HTMLElement[] { + return this._getHeaderAreaCells(`thead.${HORIZONTAL_HEADERS_AREA_CLASS} td[tabindex]`); + } + + _getRowHeaderCells(): HTMLElement[] { + return this._getHeaderAreaCells(`tbody.${VERTICAL_HEADERS_AREA_CLASS} td[tabindex]`); + } + + _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); + + // Non-navigable placeholders (e.g. whitespace filler cells) have no + // tabindex and are skipped over. + while (target && !target.hasAttribute('tabindex')) { + 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 +1269,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 +1287,46 @@ 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; } + _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 1438b0258bfc..a6123d08ad36 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,10 +137,10 @@ 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.ok(['columnheader', 'rowheader'].includes($collapsedTd.attr('role')), 'td has a header cell role'); - assert.strictEqual($collapsedTd.attr('tabindex'), undefined, 'td is not in the tab order'); - assert.strictEqual($collapsedTd.attr('aria-expanded'), undefined, 'td has no aria-expanded'); + assert.strictEqual($collapsedTd.attr('tabindex'), '0', 'td is the roving tab stop of the row headers area'); + assert.strictEqual($collapsedTd.attr('aria-expanded'), 'false', 'td exposes the collapsed state'); } 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); + } } From ec8f2118d65a7b0707760be75dc19775f720c8fd Mon Sep 17 00:00:00 2001 From: Aleksei Semikozov Date: Mon, 13 Jul 2026 08:02:31 -0300 Subject: [PATCH 2/3] PivotGrid - KBN - Implement arrow navigation for area fields (#34259) --- .../tests/common/pivotGrid/kbn/fields.ts | 80 +++++++++-- .../field_chooser/m_field_chooser.ts | 1 + .../field_chooser/m_field_chooser_base.ts | 130 ++++++++++++++++++ 3 files changed, 196 insertions(+), 15 deletions(-) diff --git a/e2e/testcafe-devextreme/tests/common/pivotGrid/kbn/fields.ts b/e2e/testcafe-devextreme/tests/common/pivotGrid/kbn/fields.ts index 86a80a6d87a2..faed3c6abb61 100644 --- a/e2e/testcafe-devextreme/tests/common/pivotGrid/kbn/fields.ts +++ b/e2e/testcafe-devextreme/tests/common/pivotGrid/kbn/fields.ts @@ -1,5 +1,6 @@ import PivotGrid from 'devextreme-testcafe-models/pivotGrid'; import HeaderFilter from 'devextreme-testcafe-models/dataGrid/headers/headerFilter'; +import { Selector } from 'testcafe'; import url from '../../../../helpers/getPageUrl'; import { createWidget } from '../../../../helpers/createWidget'; @@ -107,8 +108,42 @@ const createConfig = () => ({ const testTitlePrefix = isFieldChooser ? 'Field Chooser' : 'PivotGrid'; + const getAreaFieldsContainer = (area: string) => (isFieldChooser + ? Selector(`.dx-pivotgridfieldchooser .dx-area-fields[group="${area}"]`) + : Selector(`.dx-pivotgrid-fields-area[group="${area}"]`)); + ['filter', 'data', 'column', 'row'].forEach((area) => { - test(`${testTitlePrefix}: Fields in ${area} area should be focusable by tab`, async (t) => { + test(`${testTitlePrefix}: Fields in ${area} area should form a single tab stop`, async (t) => { + const pivotGrid = new PivotGrid(PIVOT_GRID_SELECTOR); + + if (isFieldChooser) { + await t.click(pivotGrid.getFieldChooserButton()); + } + + const areaContainer = getAreaFieldsContainer(area); + + await t + .expect(areaContainer.find('.dx-area-field[tabindex="0"]').count) + .eql(1, 'only one field of the area is in the tab order'); + + const firstField = getField(pivotGrid, area, 0); + const secondField = getField(pivotGrid, area, 1); + + await t + .click(secondField) + .expect(secondField.focused) + .ok('second field is focused after click') + .expect(secondField.getAttribute('tabindex')) + .eql('0', 'focused field is the tab stop') + .expect(firstField.getAttribute('tabindex')) + .eql('-1', 'first field is removed from the tab order'); + + await t + .expect(areaContainer.find('.dx-area-field[tabindex="0"]').count) + .eql(1, 'the focused field is the only tab stop'); + }).before(async () => createWidget('dxPivotGrid', createConfig())); + + test(`${testTitlePrefix}: Fields in ${area} area should be navigable by arrows`, async (t) => { const pivotGrid = new PivotGrid(PIVOT_GRID_SELECTOR); if (isFieldChooser) { @@ -124,14 +159,29 @@ const createConfig = () => ({ .ok('first field is focused after click'); await t - .pressKey('tab') + .pressKey('right') + .expect(secondField.focused) + .ok('second field is focused after ArrowRight'); + + await t + .pressKey('left') + .expect(firstField.focused) + .ok('first field is focused after ArrowLeft'); + + await t + .pressKey('down') .expect(secondField.focused) - .ok('second field is focused after Tab'); + .ok('second field is focused after ArrowDown'); + + await t + .pressKey('up') + .expect(firstField.focused) + .ok('first field is focused after ArrowUp'); await t - .pressKey('shift+tab') + .pressKey('up') .expect(firstField.focused) - .ok('first field is focused after Shift+Tab'); + .ok('focus stays on the first field at the area boundary'); }).before(async () => createWidget('dxPivotGrid', createConfig())); }); @@ -148,9 +198,9 @@ const createConfig = () => ({ await t .click(firstField) - .pressKey('tab') + .pressKey('right') .expect(secondField.focused) - .ok('second field is focused after Tab') + .ok('second field is focused after ArrowRight') .expect(secondField.find('.dx-sort-up').exists) .ok('second field has asc sort indicator initially'); @@ -181,9 +231,9 @@ const createConfig = () => ({ await t .click(firstField) - .pressKey('tab') + .pressKey('right') .expect(secondField.focused) - .ok('second field is focused after Tab') + .ok('second field is focused after ArrowRight') .expect(secondField.find('.dx-sort-up').exists) .ok('second field has asc sort indicator initially'); @@ -285,17 +335,17 @@ test('PivotGrid: Should traverse fields in all areas by tab', async (t) => { .ok('first field in filter area is focused after click'); await t - .pressKey('tab tab') + .pressKey('tab') .expect(dataFirstField.focused) .ok('first field in data area is focused'); await t - .pressKey('tab tab') + .pressKey('tab') .expect(columnFirstField.focused) .ok('first field in column area is focused'); await t - .pressKey('tab tab tab tab') + .pressKey('tab') .expect(rowFirstField.focused) .ok('first field in row area is focused'); }).before(async () => createWidget('dxPivotGrid', createConfig())); @@ -317,17 +367,17 @@ test('FieldChooser: Should traverse fields in all areas by tab', async (t) => { .ok('first field in row area is focused after click'); await t - .pressKey('tab tab') + .pressKey('tab') .expect(columnFirstField.focused) .ok('first field in column area is focused'); await t - .pressKey('tab tab') + .pressKey('tab') .expect(filterFirstField.focused) .ok('first field in filter area is focused'); await t - .pressKey('tab tab') + .pressKey('tab') .expect(dataFirstField.focused) .ok('first field in data area is focused'); }).before(async () => createWidget('dxPivotGrid', createConfig())); diff --git a/packages/devextreme/js/__internal/grids/pivot_grid/field_chooser/m_field_chooser.ts b/packages/devextreme/js/__internal/grids/pivot_grid/field_chooser/m_field_chooser.ts index bf0bb3312ec2..b9278b15106f 100644 --- a/packages/devextreme/js/__internal/grids/pivot_grid/field_chooser/m_field_chooser.ts +++ b/packages/devextreme/js/__internal/grids/pivot_grid/field_chooser/m_field_chooser.ts @@ -287,6 +287,7 @@ export class FieldChooser extends FieldChooserBase { this.renderSortable(); this._renderContextMenu(); this.updateDimensions(); + this.updateFieldsTabIndexes(); } _fireContentReadyAction() { diff --git a/packages/devextreme/js/__internal/grids/pivot_grid/field_chooser/m_field_chooser_base.ts b/packages/devextreme/js/__internal/grids/pivot_grid/field_chooser/m_field_chooser_base.ts index 3323acb54ba1..d901fff7e551 100644 --- a/packages/devextreme/js/__internal/grids/pivot_grid/field_chooser/m_field_chooser_base.ts +++ b/packages/devextreme/js/__internal/grids/pivot_grid/field_chooser/m_field_chooser_base.ts @@ -19,6 +19,8 @@ import { import gridCoreUtils from '@ts/grids/grid_core/m_utils'; import sortingMixin from '@ts/grids/grid_core/sorting/m_sorting_mixin'; +import type { RovingTabIndexComponent } from '../keyboard_navigation/roving_tab_index'; +import { RovingTabIndex } from '../keyboard_navigation/roving_tab_index'; import { createPath, foreachTree } from '../m_widget_utils'; import SortableModule from '../sortable/m_sortable'; import { ATTRIBUTES, CLASSES } from './const'; @@ -29,6 +31,21 @@ const { Sortable } = SortableModule; const DIV = '
'; +const FIELD_AREAS = ['filter', 'row', 'column', 'data']; + +const FIELD_NAVIGATION_DELTAS = { + ArrowUp: -1, + ArrowLeft: -1, + ArrowDown: 1, + ArrowRight: 1, +}; + +function isFieldNavigationEvent(e): boolean { + return e.type === 'keydown' + && FIELD_NAVIGATION_DELTAS[e.key] !== undefined + && !e.altKey && !e.ctrlKey && !e.metaKey && !e.shiftKey; +} + class HeaderFilterView extends HeaderFilterViewBase { _getSearchExpr(options, headerFilterOptions) { options.useDefaultSearchExpr = true; @@ -97,6 +114,10 @@ const mixinWidget = sortingMixin( export class FieldChooserBase extends mixinWidget { private _focusedFieldIndex = -1; + // NOTE: no initializer — class field initializers do not run for classes + // built on the legacy DevExtreme class system in the systemjs build. + private _fieldNavigationMap: Record | undefined; + _getDefaultOptions() { return { ...super._getDefaultOptions(), @@ -142,11 +163,21 @@ export class FieldChooserBase extends mixinWidget { _refreshDataSource() { const dataSource = this.option('dataSource'); + // Field indexes are only meaningful within one data source, so the roving + // tab stops fall back to the first field of each area. + this._resetFieldNavigation(); + if (dataSource?.fields && dataSource.load/* instanceof DX.ui.dxPivotGrid.DataSource */) { this._dataSource = dataSource; } } + private _resetFieldNavigation(): void { + Object.values(this._fieldNavigationMap ?? {}).forEach((navigation) => { + navigation.reset(); + }); + } + _optionChanged(args) { switch (args.name) { case 'dataSource': @@ -370,6 +401,11 @@ export class FieldChooserBase extends mixinWidget { return; } + if (isFieldNavigationEvent(e)) { + this._handleFieldNavigation(e, field); + return; + } + if (!isClick) { return; } @@ -389,6 +425,10 @@ export class FieldChooserBase extends mixinWidget { } this._focusedFieldIndex = field.index; + + if (field.area) { + this._getFieldNavigation(field.area).handleFocusIn(e.currentTarget); + } }; const focusOutHandler = (e) => { @@ -426,11 +466,101 @@ export class FieldChooserBase extends mixinWidget { } restoreFieldFocus(): void { + this.updateFieldsTabIndexes(); + if (this._focusedFieldIndex !== -1) { this.focusFieldElement(this._focusedFieldIndex); } } + updateFieldsTabIndexes(): void { + FIELD_AREAS.forEach((area) => { + this._getFieldNavigation(area).updateTabIndexes(); + }); + } + + private _getFieldNavigation(area: string): RovingTabIndex { + // The sorting and column state mixins lose the Widget typing, so the + // component contract has to be restored explicitly. + const component = this as unknown as RovingTabIndexComponent; + + this._fieldNavigationMap = this._fieldNavigationMap ?? {}; + this._fieldNavigationMap[area] = this._fieldNavigationMap[area] ?? new RovingTabIndex({ + component, + getItems: () => this._getAreaFieldElements(area), + scrollToItem: (item) => this._scrollFieldElementToView(item), + getItemId: (item) => this._getFieldElementId(item), + }); + + return this._fieldNavigationMap[area]; + } + + // field.index identifies the same logical field when fields are reordered, + // added or removed, so the tab stop does not drift to another field. + private _getFieldElementId(fieldElement: HTMLElement): string | undefined { + const field: any = $(fieldElement).data('field'); + + return isDefined(field?.index) ? String(field.index) : undefined; + } + + // The field chooser popup can be rendered inside the pivot grid element, so + // the fields of this instance are told apart from the other instance's + // fields by the closest field chooser root. + private _getAreaFieldElements(area: string): HTMLElement[] { + const ownFieldChooserRoot = this.$element().closest(`.${CLASSES.fieldChooser.self}`).get(0) ?? null; + // NOTE: get() without an index is not supported by the native renderer. + const fields: HTMLElement[] = this.$element() + .find(`[group="${area}"] .${CLASSES.area.field}.${CLASSES.area.box}`) + .toArray(); + + return fields.filter((field) => { + const fieldChooserRoot = $(field).closest(`.${CLASSES.fieldChooser.self}`).get(0) ?? null; + + return fieldChooserRoot === ownFieldChooserRoot; + }); + } + + private _scrollFieldElementToView(fieldElement: HTMLElement): void { + const $scrollable = $(fieldElement).closest(`.${CLASSES.scrollable.self}`); + const scrollable = $scrollable.length ? ($scrollable as any).dxScrollable('instance') : undefined; + + scrollable?.scrollToElement(fieldElement); + } + + private _getFieldNavigationDelta(key: string): number { + const isHorizontal = key === 'ArrowLeft' || key === 'ArrowRight'; + + if (isHorizontal && this.option('rtlEnabled')) { + return -FIELD_NAVIGATION_DELTAS[key]; + } + + return FIELD_NAVIGATION_DELTAS[key]; + } + + private _handleFieldNavigation(e, field): void { + if (!field.area) { + return; + } + + const navigation = this._getFieldNavigation(field.area); + const items = navigation.getItems(); + const index = items.indexOf(e.currentTarget); + + // The event can bubble to another instance subscribed to an ancestor + // element; the field then does not belong to this instance's items. + if (index < 0) { + return; + } + + e.preventDefault(); + + const targetIndex = index + this._getFieldNavigationDelta(e.key); + + if (targetIndex >= 0 && targetIndex < items.length) { + navigation.focusItem(targetIndex); + } + } + private _renderHeaderFilterIcon($fieldElement, field, showColumnLines): void { if (!fieldHasHeaderFilter(this._dataSource, field)) { return; From 0bbf1e4d08fc23a1cf0f3ff68e25833074efddae Mon Sep 17 00:00:00 2001 From: Aleksei Semikozov <2846685+aleksei-semikozov@users.noreply.github.com> Date: Tue, 14 Jul 2026 14:34:33 -0300 Subject: [PATCH 3/3] PivotGrid - KBN - Open cell context menu from keyboard (#34272) Co-authored-by: Aleksey Semikozov --- .../tests/common/pivotGrid/kbn/contextMenu.ts | 201 ++++++++++++++++++ .../__internal/grids/pivot_grid/m_widget.ts | 36 ++++ 2 files changed, 237 insertions(+) create mode 100644 e2e/testcafe-devextreme/tests/common/pivotGrid/kbn/contextMenu.ts diff --git a/e2e/testcafe-devextreme/tests/common/pivotGrid/kbn/contextMenu.ts b/e2e/testcafe-devextreme/tests/common/pivotGrid/kbn/contextMenu.ts new file mode 100644 index 000000000000..966926fa4abe --- /dev/null +++ b/e2e/testcafe-devextreme/tests/common/pivotGrid/kbn/contextMenu.ts @@ -0,0 +1,201 @@ +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_contextMenu` + .page(url(__dirname, '../../../container.html')); + +const PIVOT_GRID_SELECTOR = '#container'; +const CONTEXT_MENU_SELECTOR = '.dx-context-menu.dx-overlay-content'; +const MENU_ITEM_SELECTOR = `${CONTEXT_MENU_SELECTOR} .dx-menu-item`; +const ROW_HEADERS_CELL_SELECTOR = 'tbody.dx-pivotgrid-vertical-headers td'; + +const blurActiveElement = ClientFunction(() => { + const activeElement = document.activeElement as HTMLElement | null; + activeElement?.blur(); +}); + +// TestCafe's pressKey does not support function keys (F1-F12), so the Shift+F10 +// shortcut is reproduced by dispatching the keydown event on the focused cell. +const SHIFT_F10_KEYDOWN = { + key: 'F10', code: 'F10', shiftKey: true, bubbles: true, cancelable: true, +}; + +const createConfig = () => ({ + width: 800, + allowExpandAll: true, + allowSortingBySummary: 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('Shift+F10 should open the context menu anchored to the focused row header cell', async (t) => { + const pivotGrid = new PivotGrid(PIVOT_GRID_SELECTOR); + const rowsArea = pivotGrid.getRowsArea(); + const cell = rowsArea.getCellByPosition(0, 0); + + await blurActiveElement(); + + await t + .pressKey('tab tab') + .expect(cell.focused) + .ok('a row header cell is focused') + .dispatchEvent(cell, 'keydown', SHIFT_F10_KEYDOWN); + + const menu = Selector(CONTEXT_MENU_SELECTOR); + + await t + .expect(menu.visible) + .ok('the context menu is opened by Shift+F10') + .expect(menu.focused) + .ok('focus is moved into the menu') + .expect(Selector(MENU_ITEM_SELECTOR).withText('Expand All').exists) + .ok('the menu contains the header cell items'); + + const menuRect = await menu.boundingClientRect; + const cellRect = await cell.boundingClientRect; + + await t + .expect(menuRect.left) + .within((cellRect.left ?? 0) - 1, cellRect.right ?? 0, 'the menu is anchored to the cell horizontally') + .expect(menuRect.top) + .within((cellRect.top ?? 0) - 1, cellRect.bottom ?? 0, 'the menu is anchored to the cell vertically'); +}).before(async () => createWidget('dxPivotGrid', createConfig())); + +test('Shift+F10 should open the context menu for a column header cell', 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('a column header cell is focused') + .dispatchEvent(columnsArea.getCell(0, 0), 'keydown', SHIFT_F10_KEYDOWN); + + await t + .expect(Selector(CONTEXT_MENU_SELECTOR).visible) + .ok('the context menu is opened by Shift+F10') + .expect(Selector(MENU_ITEM_SELECTOR).withText('Sort "Region" by This Column').exists) + .ok('the menu contains the sorting by summary items'); +}).before(async () => createWidget('dxPivotGrid', { + ...createConfig(), + dataSource: { + fields: [{ + caption: 'Region', + dataField: 'region', + area: 'row', + allowSortingBySummary: true, + }, { + dataField: 'city', + area: 'column', + }, { + dataField: 'amount', + area: 'data', + summaryType: 'sum', + dataType: 'number', + }], + store: sales, + }, +})); + +test('Escape should close the menu and return focus to the originating cell', async (t) => { + const pivotGrid = new PivotGrid(PIVOT_GRID_SELECTOR); + const rowsArea = pivotGrid.getRowsArea(); + const cell = rowsArea.getCellByPosition(0, 0); + + await blurActiveElement(); + + await t + .pressKey('tab tab') + .expect(cell.focused) + .ok('a row header cell is focused') + .dispatchEvent(cell, 'keydown', SHIFT_F10_KEYDOWN) + .expect(Selector(CONTEXT_MENU_SELECTOR).focused) + .ok('the context menu is opened and focused'); + + await t + .pressKey('esc') + .expect(Selector(CONTEXT_MENU_SELECTOR).visible) + .notOk('the context menu is closed by Escape') + .expect(cell.focused) + .ok('focus is returned to the originating cell'); +}).before(async () => createWidget('dxPivotGrid', createConfig())); + +test('Menu items should be operable by keyboard and focus should return to the cell', async (t) => { + const pivotGrid = new PivotGrid(PIVOT_GRID_SELECTOR); + const rowsArea = pivotGrid.getRowsArea(); + const initialRowsCount = await Selector(ROW_HEADERS_CELL_SELECTOR).count; + + await blurActiveElement(); + + await t + .pressKey('tab tab') + .expect(rowsArea.getCellByPosition(0, 0).focused) + .ok('a row header cell is focused') + .dispatchEvent(rowsArea.getCellByPosition(0, 0), 'keydown', SHIFT_F10_KEYDOWN) + .expect(Selector(CONTEXT_MENU_SELECTOR).focused) + .ok('the context menu is opened and focused') + .pressKey('down') + .expect(Selector(MENU_ITEM_SELECTOR).withText('Expand All').hasClass('dx-state-focused')) + .ok('the first menu item is focused by ArrowDown'); + + await t + .pressKey('enter') + .expect(Selector(CONTEXT_MENU_SELECTOR).visible) + .notOk('the context menu is closed after the item is executed') + .expect(Selector(ROW_HEADERS_CELL_SELECTOR).count) + .gt(initialRowsCount, 'Expand All is executed'); + + const focusedCell = Selector(ROW_HEADERS_CELL_SELECTOR) + .filter((node) => node === document.activeElement); + + await t + .expect(focusedCell.count) + .eql(1, 'focus is returned to the originating cell'); +}).before(async () => createWidget('dxPivotGrid', { + ...createConfig(), + dataSource: { + fields: [{ + dataField: 'region', + area: 'row', + }, { + 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/js/__internal/grids/pivot_grid/m_widget.ts b/packages/devextreme/js/__internal/grids/pivot_grid/m_widget.ts index adc02d6c0c17..f122d1881117 100644 --- a/packages/devextreme/js/__internal/grids/pivot_grid/m_widget.ts +++ b/packages/devextreme/js/__internal/grids/pivot_grid/m_widget.ts @@ -126,6 +126,8 @@ class PivotGrid extends Widget { _rowsAreaNavigation: RovingTabIndex | undefined; + _contextMenuOwnerNavigation: RovingTabIndex | undefined; + _scrollLeft: any; _scrollTop: any; @@ -712,9 +714,22 @@ class PivotGrid extends Widget { const items = that._getContextMenuItems(args); if (items) { actionArgs.component.option('items', items); + if (event.type === 'keydown') { + // A keyboard event carries no pointer coordinates, so the menu is + // anchored to the focused cell instead. + if (actionArgs.position) { + actionArgs.position.of = $(targetElement); + } + } else { + that._contextMenuOwnerNavigation = undefined; + } actionArgs.cancel = false; } }, + onHidden() { + that._contextMenuOwnerNavigation?.refocusFocusedItem(); + that._contextMenuOwnerNavigation = undefined; + }, onItemClick(params) { params.itemData.onItemClick && params.itemData.onItemClick(params); }, @@ -1034,6 +1049,23 @@ class PivotGrid extends Widget { this._getCellAreaNavigation(e.currentTarget)?.handleFocusIn(e.currentTarget); } + _handleCellContextMenuKeyDown(e) { + const cell = e.currentTarget; + const navigation = this._getCellAreaNavigation(cell); + + if (!navigation || !this._contextMenu) { + return; + } + + // The internal _show is called instead of the public show() because only + // _show accepts the initiating event that onPositioning builds items from. + this._contextMenu._show(e); + + if (this._contextMenu.option('visible')) { + this._contextMenuOwnerNavigation = navigation; + } + } + _handleCellKeyDown(e) { const direction = ARROW_KEY_DIRECTIONS[e.key]; if (direction) { @@ -1043,6 +1075,10 @@ class PivotGrid extends Widget { if (e.repeat) { return; } + if (e.key === 'ContextMenu' || (e.shiftKey && e.key === 'F10')) { + this._handleCellContextMenuKeyDown(e); + return; + } if (e.key !== 'Enter' && e.key !== ' ') { return; }