From 5bd4ef914189b596f2272d24d13bb520a73f1910 Mon Sep 17 00:00:00 2001 From: Aleksey Semikozov Date: Thu, 2 Jul 2026 17:41:49 -0300 Subject: [PATCH 1/4] PivotGrid - KBN - Add shared roving tabindex helper for cell navigation --- .../grids/pivot_grid/area_item/m_area_item.ts | 7 + .../roving_tab_index.test.ts | 205 ++++++++++++++++++ .../keyboard_navigation/roving_tab_index.ts | 105 +++++++++ 3 files changed, 317 insertions(+) create mode 100644 packages/devextreme/js/__internal/grids/pivot_grid/keyboard_navigation/roving_tab_index.test.ts create mode 100644 packages/devextreme/js/__internal/grids/pivot_grid/keyboard_navigation/roving_tab_index.ts 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..8307fe38d4d1 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 @@ -644,6 +644,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/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..fd205cf0aeb7 --- /dev/null +++ b/packages/devextreme/js/__internal/grids/pivot_grid/keyboard_navigation/roving_tab_index.test.ts @@ -0,0 +1,205 @@ +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('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..6d13c8a72260 --- /dev/null +++ b/packages/devextreme/js/__internal/grids/pivot_grid/keyboard_navigation/roving_tab_index.ts @@ -0,0 +1,105 @@ +import eventsEngine from '@js/common/core/events/core/events_engine'; +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'); + } + + 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); + } +} From 2d08abf4155dd392c75a4b5589ded05e94caae92 Mon Sep 17 00:00:00 2001 From: Aleksey Semikozov Date: Thu, 2 Jul 2026 17:48:18 -0300 Subject: [PATCH 2/4] PivotGrid - KBN - Add shared table cell navigation primitives --- .../table_cell_navigation.test.ts | 126 ++++++++++++++++++ .../table_cell_navigation.ts | 80 +++++++++++ 2 files changed, 206 insertions(+) create mode 100644 packages/devextreme/js/__internal/grids/pivot_grid/keyboard_navigation/table_cell_navigation.test.ts create mode 100644 packages/devextreme/js/__internal/grids/pivot_grid/keyboard_navigation/table_cell_navigation.ts 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; +} From ffa05cd8d3560aaf7e5155583f59683da17d10c9 Mon Sep 17 00:00:00 2001 From: Aleksey Semikozov Date: Thu, 2 Jul 2026 18:03:30 -0300 Subject: [PATCH 3/4] PivotGrid - KBN - Implement arrow navigation for column header cells --- .../common/pivotGrid/kbn/columnHeaders.ts | 202 ++++++++++++++++++ .../scss/widgets/base/pivotGrid/_index.scss | 6 + .../grids/pivot_grid/area_item/m_area_item.ts | 15 +- .../pivot_grid/headers_area/m_headers_area.ts | 9 + .../pivot_grid/keyboard_navigation/const.ts | 3 + .../roving_tab_index.test.ts | 29 +++ .../keyboard_navigation/roving_tab_index.ts | 12 ++ .../__internal/grids/pivot_grid/m_widget.ts | 110 ++++++++++ .../testcafe-models/pivotGrid/columnsArea.ts | 21 ++ packages/testcafe-models/pivotGrid/index.ts | 5 + 10 files changed, 410 insertions(+), 2 deletions(-) create mode 100644 e2e/testcafe-devextreme/tests/common/pivotGrid/kbn/columnHeaders.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/packages/devextreme-scss/scss/widgets/base/pivotGrid/_index.scss b/packages/devextreme-scss/scss/widgets/base/pivotGrid/_index.scss index 71b2933a3529..662d63d6cfa3 100644 --- a/packages/devextreme-scss/scss/widgets/base/pivotGrid/_index.scss +++ b/packages/devextreme-scss/scss/widgets/base/pivotGrid/_index.scss @@ -133,6 +133,12 @@ $pivotgrid-expand-icon-text-offset: 0; outline-offset: -2px; } + .dx-pivotgrid-horizontal-headers td:focus-visible { + outline: 2px solid; + outline-color: $pivotgrid-accent-color; + outline-offset: -2px; + } + .dx-expand-icon-container:focus-visible { outline: none; } diff --git a/packages/devextreme/js/__internal/grids/pivot_grid/area_item/m_area_item.ts b/packages/devextreme/js/__internal/grids/pivot_grid/area_item/m_area_item.ts index 8307fe38d4d1..d2ac0709cea6 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) { + 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); } } 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..c65cb694223e 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()) @@ -179,6 +183,11 @@ class VerticalHeadersArea extends HorizontalHeadersArea { return PIVOTGRID_AREA_ROW_CLASS; } + // Cell navigation for the row headers area is not implemented yet + _isCellNavigationEnabled() { + return false; + } + _applyCustomStyles(options) { super._applyCustomStyles(options); diff --git a/packages/devextreme/js/__internal/grids/pivot_grid/keyboard_navigation/const.ts b/packages/devextreme/js/__internal/grids/pivot_grid/keyboard_navigation/const.ts new file mode 100644 index 000000000000..119e58b3d1da --- /dev/null +++ b/packages/devextreme/js/__internal/grids/pivot_grid/keyboard_navigation/const.ts @@ -0,0 +1,3 @@ +export const HORIZONTAL_HEADERS_AREA_CLASS = 'dx-pivotgrid-horizontal-headers'; +export const VERTICAL_HEADERS_AREA_CLASS = 'dx-pivotgrid-vertical-headers'; +export const FAKE_TABLE_CLASS = 'dx-pivot-grid-fake-table'; diff --git a/packages/devextreme/js/__internal/grids/pivot_grid/keyboard_navigation/roving_tab_index.test.ts b/packages/devextreme/js/__internal/grids/pivot_grid/keyboard_navigation/roving_tab_index.test.ts index fd205cf0aeb7..bb13b565ce8f 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 @@ -189,6 +189,35 @@ describe('RovingTabIndex', () => { }); }); + 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(); 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 6d13c8a72260..260fe7a8e474 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 @@ -1,4 +1,5 @@ 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'; @@ -70,6 +71,17 @@ export class RovingTabIndex { 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); 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..69b7499bb5a2 100644 --- a/packages/devextreme/js/__internal/grids/pivot_grid/m_widget.ts +++ b/packages/devextreme/js/__internal/grids/pivot_grid/m_widget.ts @@ -34,6 +34,10 @@ import { FieldChooser } from './field_chooser/m_field_chooser'; import { FieldChooserBase } from './field_chooser/m_field_chooser_base'; import { FieldsArea } from './fields_area/m_fields_area'; import HeadersArea from './headers_area/m_headers_area'; +import { FAKE_TABLE_CLASS, HORIZONTAL_HEADERS_AREA_CLASS } from './keyboard_navigation/const'; +import { RovingTabIndex } from './keyboard_navigation/roving_tab_index'; +import type { CellNavigationDirection } from './keyboard_navigation/table_cell_navigation'; +import { getAdjacentCell } from './keyboard_navigation/table_cell_navigation'; import { findField, mergeArraysByMaxValue, setFieldProperty } from './m_widget_utils'; const window = getWindow(); @@ -60,6 +64,13 @@ const TEST_HEIGHT = 66666; const FIELD_CALCULATED_OPTIONS = ['allowSorting', 'allowSortingBySummary', 'allowFiltering', 'allowExpandAll']; +const ARROW_KEY_DIRECTIONS: Record = { + ArrowUp: 'up', + ArrowDown: 'down', + ArrowLeft: 'left', + ArrowRight: 'right', +}; + function getArraySum(array) { let sum = 0; @@ -111,6 +122,8 @@ function clickedOnFieldsArea($targetElement) { class PivotGrid extends Widget { _dataController: any; + _columnsAreaNavigation: RovingTabIndex | undefined; + _scrollLeft: any; _scrollTop: any; @@ -891,7 +904,81 @@ class PivotGrid extends Widget { }); } + _getColumnsAreaNavigation() { + this._columnsAreaNavigation = this._columnsAreaNavigation ?? new RovingTabIndex({ + component: this, + getItems: () => this._getColumnHeaderCells(), + scrollToItem: (item) => this._columnsArea.scrollToElement(item), + }); + + return this._columnsAreaNavigation; + } + + _getColumnHeaderCells(): HTMLElement[] { + const element = this.$element().get(0); + + if (!element) { + return []; + } + + const cells: HTMLElement[] = Array.from(element.querySelectorAll(`thead.${HORIZONTAL_HEADERS_AREA_CLASS} td`)); + + return cells.filter((cell) => !cell.closest(`.${FAKE_TABLE_CLASS}`)); + } + + _getCellAreaNavigation(cell): RovingTabIndex | undefined { + if (cell.closest?.(`.${FAKE_TABLE_CLASS}`)) { + return undefined; + } + if (cell.closest?.(`thead.${HORIZONTAL_HEADERS_AREA_CLASS}`)) { + return this._getColumnsAreaNavigation(); + } + + return undefined; + } + + _normalizeCellNavigationDirection(direction: CellNavigationDirection): CellNavigationDirection { + if (!this.option('rtlEnabled')) { + return direction; + } + if (direction === 'left') { + return 'right'; + } + if (direction === 'right') { + return 'left'; + } + + return direction; + } + + _handleCellArrowKeyDown(e, direction: CellNavigationDirection) { + const cell = e.currentTarget; + const navigation = this._getCellAreaNavigation(cell); + + if (!navigation) { + return; + } + + e.preventDefault(); + + const section = cell.closest('thead, tbody'); + const target = getAdjacentCell(section, cell, this._normalizeCellNavigationDirection(direction)); + + if (target) { + navigation.focusItem(target); + } + } + + _handleCellFocusIn(e) { + this._getCellAreaNavigation(e.currentTarget)?.handleFocusIn(e.currentTarget); + } + _handleCellKeyDown(e) { + const direction = ARROW_KEY_DIRECTIONS[e.key]; + if (direction) { + this._handleCellArrowKeyDown(e, direction); + return; + } if (e.repeat) { return; } @@ -1121,6 +1208,7 @@ class PivotGrid extends Widget { eventsEngine.on($table, addNamespace(clickEventName, 'dxPivotGrid'), 'td', that._handleCellClick.bind(that)); eventsEngine.on($table, addNamespace('keydown', 'dxPivotGrid'), 'td', that._handleCellKeyDown.bind(that)); + eventsEngine.on($table, addNamespace('focusin', 'dxPivotGrid'), 'td', that._handleCellFocusIn.bind(that)); return $table; } @@ -1143,11 +1231,33 @@ class PivotGrid extends Widget { return rowsArea; } + // Rendering moves header elements between area tables, which drops focus, + // so the focused cell is restored after the content is ready. + _scheduleNavigationFocusRestore(navigation: RovingTabIndex) { + const onReady = () => { + this.off('contentReady', onReady); + if (!navigation.containsActiveElement()) { + navigation.refocusFocusedItem(); + } + }; + this.on('contentReady', onReady); + } + _renderColumnsArea(columnsAreaElement) { const that = this; const columnsArea = that._columnsArea || new HeadersArea.HorizontalHeadersArea(that); that._columnsArea = columnsArea; + + const navigation = that._getColumnsAreaNavigation(); + const needRestoreFocus = navigation.containsActiveElement(); + columnsArea.render(columnsAreaElement, that._dataController.getColumnsInfo()); + navigation.updateTabIndexes(); + + if (needRestoreFocus) { + navigation.refocusFocusedItem(); + that._scheduleNavigationFocusRestore(navigation); + } return columnsArea; } diff --git a/packages/testcafe-models/pivotGrid/columnsArea.ts b/packages/testcafe-models/pivotGrid/columnsArea.ts new file mode 100644 index 000000000000..275dd324e7a5 --- /dev/null +++ b/packages/testcafe-models/pivotGrid/columnsArea.ts @@ -0,0 +1,21 @@ +import type { Selector } from 'testcafe'; + +const CLASSES = { + root: 'dx-pivotgrid-horizontal-headers', +}; + +export default class ColumnsArea { + public readonly element: Selector; + + constructor(selector: Selector, idx?: number) { + this.element = selector.find(`thead.${CLASSES.root}`).nth(idx ?? 0); + } + + getCell(rowIdx = 0, cellIdx = 0): Selector { + return this.element.find('tr').nth(rowIdx).find('td').nth(cellIdx); + } + + getCells(): Selector { + return this.element.find('td'); + } +} diff --git a/packages/testcafe-models/pivotGrid/index.ts b/packages/testcafe-models/pivotGrid/index.ts index 753c91a87b92..4f78a22bcef4 100644 --- a/packages/testcafe-models/pivotGrid/index.ts +++ b/packages/testcafe-models/pivotGrid/index.ts @@ -3,6 +3,7 @@ import type { WidgetName } from '../types'; import Widget from '../internal/widget'; import Popup from '../popup'; import ColumnHeaderArea from './columnHeaderArea'; +import ColumnsArea from './columnsArea'; import DataHeaderArea from './dataHeaderArea'; import FieldChooser from './fieldChooser'; import FilterHeaderArea from './filterHeaderArea'; @@ -78,6 +79,10 @@ export default class PivotGrid extends Widget { return new RowsArea(this.element, idx); } + getColumnsArea(idx?: number): ColumnsArea { + return new ColumnsArea(this.element, idx); + } + getDataHeaderArea(): DataHeaderArea { return new DataHeaderArea(this.element); } From 162ff12609417edd2e1e4f97f4f8f8b91b0aed31 Mon Sep 17 00:00:00 2001 From: Aleksey Semikozov Date: Thu, 2 Jul 2026 18:09:46 -0300 Subject: [PATCH 4/4] PivotGrid - KBN - Implement arrow navigation for row header cells --- .../tests/common/pivotGrid/kbn/expandIcon.ts | 4 +- .../tests/common/pivotGrid/kbn/rowHeaders.ts | 205 ++++++++++++++++++ .../scss/widgets/base/pivotGrid/_index.scss | 3 +- .../grids/pivot_grid/area_item/m_area_item.ts | 2 +- .../pivot_grid/headers_area/m_headers_area.ts | 5 - .../__internal/grids/pivot_grid/m_widget.ts | 48 +++- .../pivotGrid.markup.tests.js | 4 +- .../testcafe-models/pivotGrid/rowsArea.ts | 4 + 8 files changed, 260 insertions(+), 15 deletions(-) create mode 100644 e2e/testcafe-devextreme/tests/common/pivotGrid/kbn/rowHeaders.ts 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 662d63d6cfa3..9438cab26234 100644 --- a/packages/devextreme-scss/scss/widgets/base/pivotGrid/_index.scss +++ b/packages/devextreme-scss/scss/widgets/base/pivotGrid/_index.scss @@ -133,7 +133,8 @@ $pivotgrid-expand-icon-text-offset: 0; outline-offset: -2px; } - .dx-pivotgrid-horizontal-headers td:focus-visible { + .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; 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 d2ac0709cea6..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 @@ -173,7 +173,7 @@ abstract class AreaItem { cell.rowspan && td.setAttribute('rowspan', cell.rowspan || 1); cell.colspan && td.setAttribute('colspan', cell.colspan || 1); - if (isCellNavigationEnabled) { + if (isCellNavigationEnabled && !cell.isWhiteSpace) { td.setAttribute('tabindex', '-1'); } 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 c65cb694223e..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 @@ -183,11 +183,6 @@ class VerticalHeadersArea extends HorizontalHeadersArea { return PIVOTGRID_AREA_ROW_CLASS; } - // Cell navigation for the row headers area is not implemented yet - _isCellNavigationEnabled() { - return false; - } - _applyCustomStyles(options) { super._applyCustomStyles(options); diff --git a/packages/devextreme/js/__internal/grids/pivot_grid/m_widget.ts b/packages/devextreme/js/__internal/grids/pivot_grid/m_widget.ts index 69b7499bb5a2..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,7 +34,7 @@ import { FieldChooser } from './field_chooser/m_field_chooser'; import { FieldChooserBase } from './field_chooser/m_field_chooser_base'; import { FieldsArea } from './fields_area/m_fields_area'; import HeadersArea from './headers_area/m_headers_area'; -import { FAKE_TABLE_CLASS, HORIZONTAL_HEADERS_AREA_CLASS } from './keyboard_navigation/const'; +import { 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'; @@ -64,6 +64,8 @@ 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', @@ -124,6 +126,8 @@ class PivotGrid extends Widget { _columnsAreaNavigation: RovingTabIndex | undefined; + _rowsAreaNavigation: RovingTabIndex | undefined; + _scrollLeft: any; _scrollTop: any; @@ -914,18 +918,36 @@ class PivotGrid extends Widget { return this._columnsAreaNavigation; } - _getColumnHeaderCells(): HTMLElement[] { + _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(`thead.${HORIZONTAL_HEADERS_AREA_CLASS} td`)); + 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; @@ -933,6 +955,9 @@ class PivotGrid extends Widget { if (cell.closest?.(`thead.${HORIZONTAL_HEADERS_AREA_CLASS}`)) { return this._getColumnsAreaNavigation(); } + if (cell.closest?.(`tbody.${VERTICAL_HEADERS_AREA_CLASS}`)) { + return this._getRowsAreaNavigation(); + } return undefined; } @@ -962,7 +987,12 @@ class PivotGrid extends Widget { e.preventDefault(); const section = cell.closest('thead, tbody'); - const target = getAdjacentCell(section, cell, this._normalizeCellNavigationDirection(direction)); + 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); @@ -1226,7 +1256,17 @@ 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; } 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/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); + } }