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..72c6fe175527 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 @@ -7,6 +7,7 @@ import { setWidth } from '@js/core/utils/size'; import { setStyle } from '@js/core/utils/style'; 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'; const PIVOTGRID_EXPAND_CLASS = 'dx-expand'; @@ -139,6 +140,49 @@ abstract class AreaItem { return ''; } + _getColumnIndexOffset() { + return this.component._dataController?.getColumnIndexOffset() || 0; + } + + _getRowIndexOffset() { + return this.component._dataController?.getRowIndexOffset() || 0; + } + + _createColumnIndexMap(data) { + const columnIndexMap = new Map(); + + foreachColumnInfo(data, (cell, visibleIndex, rowIndex, colIndex) => { + columnIndexMap.set(`${rowIndex}-${colIndex}`, visibleIndex); + }); + + return columnIndexMap; + } + + _setCellAriaIndexAttributes(td, options) { + const { + areaName, + rowIndex, + columnIndex, + columnIndexMap, + columnIndexOffset, + rowIndexOffset, + } = options; + + if (areaName === 'column' || areaName === 'data') { + const visibleColumnIndex = areaName === 'column' + ? columnIndexMap.get(`${rowIndex}-${columnIndex}`) + : columnIndex; + + if (visibleColumnIndex !== undefined) { + td.setAttribute('aria-colindex', String(visibleColumnIndex + columnIndexOffset + 1)); + } + } + + if (areaName === 'row' || areaName === 'data') { + td.setAttribute('aria-rowindex', String(rowIndex + rowIndexOffset + 1)); + } + } + _renderTableContent(tableElement, data) { const rowsCount = data.length; const rtlEnabled = this.option('rtlEnabled'); @@ -149,6 +193,12 @@ abstract class AreaItem { tableElement.css('width', ''); const tbody = this._getMainElementMarkup(); + const areaName = this._getAreaName(); + const columnIndexOffset = this._getColumnIndexOffset(); + const rowIndexOffset = this._getRowIndexOffset(); + const columnIndexMap = areaName === 'column' + ? this._createColumnIndexMap(data) + : null; for (let i = 0; i < rowsCount; i += 1) { const row = data[i]; @@ -163,6 +213,7 @@ abstract class AreaItem { this._getRowClassNames(i, cell, rowClassNames); let cellText = ''; + const span = domAdapter.createElement('span'); if (cell) { cell.rowspan && td.setAttribute('rowspan', cell.rowspan || 1); @@ -194,9 +245,9 @@ abstract class AreaItem { if (isDefined(cell.expanded)) { const div = domAdapter.createElement('div'); div.classList.add('dx-expand-icon-container'); - const span = domAdapter.createElement('span'); - span.classList.add(PIVOTGRID_EXPAND_CLASS); - div.appendChild(span); + const expandSpan = domAdapter.createElement('span'); + expandSpan.classList.add(PIVOTGRID_EXPAND_CLASS); + div.appendChild(expandSpan); td.appendChild(div); const ariaLabel = String(cell.text ?? cell.value ?? ''); div.setAttribute('role', 'button'); @@ -206,23 +257,30 @@ abstract class AreaItem { } cellText = this._getCellText(cell, encodeHtml); - } - - const span = domAdapter.createElement('span'); - if (isDefined(cell.wordWrapEnabled)) { - span.style.whiteSpace = cell.wordWrapEnabled ? 'normal' : 'nowrap'; + if (isDefined(cell.wordWrapEnabled)) { + span.style.whiteSpace = cell.wordWrapEnabled ? 'normal' : 'nowrap'; + } } span.innerHTML = cellText; td.appendChild(span); - if (cell.sorted) { - const span = domAdapter.createElement('span'); - span.classList.add('dx-icon-sorted'); - td.appendChild(span); + if (cell?.sorted) { + const sortedSpan = domAdapter.createElement('span'); + sortedSpan.classList.add('dx-icon-sorted'); + td.appendChild(sortedSpan); } + this._setCellAriaIndexAttributes(td, { + areaName, + rowIndex: i, + columnIndex: j, + columnIndexMap, + columnIndexOffset, + rowIndexOffset, + }); + tr.appendChild(td); } diff --git a/packages/devextreme/js/__internal/grids/pivot_grid/data_controller/m_data_controller.ts b/packages/devextreme/js/__internal/grids/pivot_grid/data_controller/m_data_controller.ts index 2975ab9a0892..6171c3fd7f3c 100644 --- a/packages/devextreme/js/__internal/grids/pivot_grid/data_controller/m_data_controller.ts +++ b/packages/devextreme/js/__internal/grids/pivot_grid/data_controller/m_data_controller.ts @@ -1336,6 +1336,18 @@ class DataController { return this._columnPageIndex || 0; } + getColumnIndexOffset() { + return this._columnsScrollController + ? this._columnsScrollController.beginPageIndex() * this._columnsScrollController.pageSize() + : 0; + } + + getRowIndexOffset() { + return this._rowsScrollController + ? this._rowsScrollController.beginPageIndex() * this._rowsScrollController.pageSize() + : 0; + } + getCellsInfo(getAllData) { const rowsInfo = this.getRowsInfo(getAllData); const columnsInfo = this.getColumnsInfo(getAllData); 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..ea077b64ccb9 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 @@ -295,4 +295,228 @@ QUnit.module('PivotGrid accessibility markup', { assert.strictEqual(container.getAttribute('tabindex'), null); }); }); + + QUnit.test('Column header and data cells have aria-colindex along shared column axis', function(assert) { + if(!windowUtils.hasWindow()) { + assert.expect(0); + return; + } + + const pivotGrid = createPivotGrid({ + width: 600, height: 400, + dataSource: createExpandableDataSource() + }); + this.clock.tick(10); + + const $columnArea = pivotGrid.$element().find('.dx-pivotgrid-horizontal-headers'); + const $dataArea = pivotGrid.$element().find('.dx-pivotgrid-area-data'); + + $columnArea.find('td').each((_, cell) => { + assert.ok(parseInt(cell.getAttribute('aria-colindex'), 10) >= 1, 'column header cell has 1-based aria-colindex'); + assert.strictEqual(cell.getAttribute('aria-rowindex'), null, 'column header cell has no aria-rowindex'); + }); + + $dataArea.find('td').each((_, cell) => { + assert.ok(parseInt(cell.getAttribute('aria-colindex'), 10) >= 1, 'data cell has 1-based aria-colindex'); + }); + + const $firstDataRowCells = $dataArea.find('tr').first().find('td'); + + $firstDataRowCells.each((_, dataCell) => { + const colIndex = dataCell.getAttribute('aria-colindex'); + const $headerCells = $columnArea.find(`td[aria-colindex="${colIndex}"]`); + + assert.ok($headerCells.length > 0, `column header exists for aria-colindex ${colIndex}`); + }); + }); + + QUnit.test('Row header and data cells have aria-rowindex along shared row axis', function(assert) { + if(!windowUtils.hasWindow()) { + assert.expect(0); + return; + } + + const pivotGrid = createPivotGrid({ + width: 600, height: 400, + dataSource: createExpandableDataSource() + }); + this.clock.tick(10); + + const $rowArea = pivotGrid.$element().find('.dx-pivotgrid-vertical-headers'); + const $dataArea = pivotGrid.$element().find('.dx-pivotgrid-area-data'); + + $rowArea.find('td').each((_, cell) => { + assert.ok(parseInt(cell.getAttribute('aria-rowindex'), 10) >= 1, 'row header cell has 1-based aria-rowindex'); + assert.strictEqual(cell.getAttribute('aria-colindex'), null, 'row header cell has no aria-colindex'); + }); + + $dataArea.find('tr').each((rowIndex, row) => { + const expectedRowIndex = String(rowIndex + 1); + + $(row).find('td').each((_, cell) => { + assert.strictEqual(cell.getAttribute('aria-rowindex'), expectedRowIndex, 'data cell aria-rowindex'); + }); + + const $rowHeaderCells = $rowArea.find('tr').eq(rowIndex).find('td'); + + $rowHeaderCells.each((_, cell) => { + assert.strictEqual(cell.getAttribute('aria-rowindex'), expectedRowIndex, 'row header aria-rowindex matches data row'); + }); + }); + }); + + QUnit.test('Multi-level column header colspan is reflected in aria-colindex', function(assert) { + if(!windowUtils.hasWindow()) { + assert.expect(0); + return; + } + + const pivotGrid = createPivotGrid({ + width: 600, height: 400, + dataSource: createExpandableDataSource() + }); + this.clock.tick(10); + + const $columnArea = pivotGrid.$element().find('.dx-pivotgrid-horizontal-headers'); + const $topRowCells = $columnArea.find('tr').first().find('td'); + + assert.ok($topRowCells.length > 0, 'top header row has cells'); + + const $leafRow = $columnArea.find('tr').last(); + + $topRowCells.each((_, cell) => { + const colspan = cell.colSpan || 1; + const colIndex = parseInt(cell.getAttribute('aria-colindex'), 10); + + assert.ok(colIndex >= 1, 'aria-colindex is 1-based'); + + if(colspan > 1) { + const leafColIndices = $leafRow.find('td').toArray() + .map(c => parseInt(c.getAttribute('aria-colindex'), 10)) + .filter(ci => ci >= colIndex && ci < colIndex + colspan); + + assert.strictEqual(leafColIndices.length, colspan, + `parent with colspan=${colspan} at colindex=${colIndex} has exactly ${colspan} leaf columns`); + assert.strictEqual(leafColIndices[0], colIndex, + 'first leaf aria-colindex matches parent'); + assert.strictEqual(leafColIndices[leafColIndices.length - 1], colIndex + colspan - 1, + 'last leaf aria-colindex equals parent colindex + colspan - 1'); + } + }); + }); + + QUnit.test('aria-colindex and aria-rowindex account for virtual scrolling offsets', function(assert) { + if(!windowUtils.hasWindow()) { + assert.expect(0); + return; + } + + const store = []; + for(let i = 0; i < 50; i++) { + store.push({ row: `row${i}`, column: `col${i}`, value: i + 1 }); + } + + const pivotGrid = createPivotGrid({ + width: 120, + height: 120, + fieldChooser: { enabled: false }, + scrolling: { + mode: 'virtual', + timeout: 0, + virtualColumnWidth: 20, + virtualRowHeight: 20 + }, + dataSource: { + fields: [ + { dataField: 'row', area: 'row' }, + { dataField: 'column', area: 'column' }, + { dataField: 'value', area: 'data' } + ], + store + } + }); + this.clock.tick(10); + + const dataController = pivotGrid._dataController; + const columnPageSize = dataController._columnsScrollController.pageSize(); + const rowPageSize = dataController._rowsScrollController.pageSize(); + + const assertAriaIndices = (columnOffset, rowOffset, messagePrefix) => { + const $dataRows = pivotGrid.$element().find('.dx-pivotgrid-area-data tr'); + const $firstDataCell = $dataRows.first().find('td').first(); + const $lastDataCell = $dataRows.last().find('td').last(); + const $firstColumnHeaderCell = pivotGrid.$element().find('.dx-pivotgrid-horizontal-headers td[aria-colindex]').first(); + const $lastColumnHeaderCell = pivotGrid.$element().find('.dx-pivotgrid-horizontal-headers tr').last().find('td[aria-colindex]').last(); + const $firstRowHeaderCell = pivotGrid.$element().find('.dx-pivotgrid-vertical-headers tr').first().find('td').first(); + const $lastRowHeaderCell = pivotGrid.$element().find('.dx-pivotgrid-vertical-headers tr').last().find('td').last(); + + assert.strictEqual( + dataController.getColumnIndexOffset(), + columnOffset, + `${messagePrefix}: getColumnIndexOffset` + ); + assert.strictEqual( + dataController.getRowIndexOffset(), + rowOffset, + `${messagePrefix}: getRowIndexOffset` + ); + assert.strictEqual( + $firstDataCell.attr('aria-colindex'), + String(columnOffset + 1), + `${messagePrefix}: first data cell aria-colindex` + ); + assert.strictEqual( + $firstColumnHeaderCell.attr('aria-colindex'), + String(columnOffset + 1), + `${messagePrefix}: first column header aria-colindex` + ); + assert.ok( + parseInt($lastColumnHeaderCell.attr('aria-colindex'), 10) > columnOffset + 1, + `${messagePrefix}: last column header aria-colindex is greater than first` + ); + assert.strictEqual( + $firstDataCell.attr('aria-rowindex'), + String(rowOffset + 1), + `${messagePrefix}: first data cell aria-rowindex` + ); + assert.strictEqual( + $firstRowHeaderCell.attr('aria-rowindex'), + String(rowOffset + 1), + `${messagePrefix}: first row header aria-rowindex` + ); + assert.ok( + parseInt($lastDataCell.attr('aria-rowindex'), 10) > rowOffset + 1, + `${messagePrefix}: last data cell aria-rowindex is greater than first` + ); + assert.ok( + parseInt($lastRowHeaderCell.attr('aria-rowindex'), 10) > rowOffset + 1, + `${messagePrefix}: last row header aria-rowindex is greater than first` + ); + }; + + assertAriaIndices(0, 0, 'initial'); + + const columnBeginPageIndex = 2; + const rowBeginPageIndex = 1; + + const stubs = [ + sinon.stub(dataController._columnsScrollController, 'beginPageIndex').returns(columnBeginPageIndex), + sinon.stub(dataController._rowsScrollController, 'beginPageIndex').returns(rowBeginPageIndex), + sinon.stub(dataController._columnsScrollController, 'endPageIndex').returns(columnBeginPageIndex), + sinon.stub(dataController._rowsScrollController, 'endPageIndex').returns(rowBeginPageIndex), + ]; + + try { + dataController.changed.fire(); + this.clock.tick(10); + + assertAriaIndices( + columnBeginPageIndex * columnPageSize, + rowBeginPageIndex * rowPageSize, + 'after virtual page change' + ); + } finally { + stubs.forEach(s => s.restore()); + } + }); });