From 791b0e7e1ffda5c2cbb02a5ca51427e882e27ab1 Mon Sep 17 00:00:00 2001 From: Aleksey Semikozov Date: Mon, 25 May 2026 20:23:20 +0000 Subject: [PATCH 1/8] PivotGrid - Add keyboard accessibility to expand icons --- .../expand_icon_accessibility.test.ts | 86 +++++++++++++++++++ .../grids/pivot_grid/area_item/m_area_item.ts | 2 + .../__internal/grids/pivot_grid/m_widget.ts | 11 +++ 3 files changed, 99 insertions(+) create mode 100644 packages/devextreme/js/__internal/grids/pivot_grid/__tests__/expand_icon_accessibility.test.ts diff --git a/packages/devextreme/js/__internal/grids/pivot_grid/__tests__/expand_icon_accessibility.test.ts b/packages/devextreme/js/__internal/grids/pivot_grid/__tests__/expand_icon_accessibility.test.ts new file mode 100644 index 000000000000..935b981e92c7 --- /dev/null +++ b/packages/devextreme/js/__internal/grids/pivot_grid/__tests__/expand_icon_accessibility.test.ts @@ -0,0 +1,86 @@ +import $ from '@js/core/renderer'; + +import { AreaItem } from '../area_item/m_area_item'; + +class TestAreaItem extends AreaItem { + _getAreaName() { + return 'row'; + } +} + +const createMockComponent = () => ({ + option(name: string) { + const options: Record = { + rtlEnabled: false, + encodeHtml: true, + onCellPrepared: null, + }; + return options[name]; + }, + _eventsStrategy: { + hasEvent: () => false, + }, + _defaultActionArgs: () => ({}), +}); + +describe('PivotGrid expand icon accessibility', () => { + let $container: any = null; + + beforeEach(() => { + $container = $('
').appendTo('body'); + }); + + afterEach(() => { + $container.remove(); + }); + + it('should set aria-expanded="true" on td when cell is expanded', () => { + const areaItem = new TestAreaItem(createMockComponent()); + const tableData = [[{ expanded: true, text: 'Category' }]]; + + areaItem.render($container, tableData); + + const $td = $container.find('td'); + expect($td.attr('aria-expanded')).toBe('true'); + }); + + it('should set aria-expanded="false" on td when cell is collapsed', () => { + const areaItem = new TestAreaItem(createMockComponent()); + const tableData = [[{ expanded: false, text: 'Category' }]]; + + areaItem.render($container, tableData); + + const $td = $container.find('td'); + expect($td.attr('aria-expanded')).toBe('false'); + }); + + it('should set tabindex="0" on td when cell has expand icon', () => { + const areaItem = new TestAreaItem(createMockComponent()); + const tableData = [[{ expanded: true, text: 'Category' }]]; + + areaItem.render($container, tableData); + + const $td = $container.find('td'); + expect($td.attr('tabindex')).toBe('0'); + }); + + it('should not set tabindex on td when cell has no expand state', () => { + const areaItem = new TestAreaItem(createMockComponent()); + const tableData = [[{ text: 'Plain cell' }]]; + + areaItem.render($container, tableData); + + const $td = $container.find('td'); + expect($td.attr('tabindex')).toBeUndefined(); + }); + + it('should not set aria-expanded on td when cell has no expand state', () => { + const areaItem = new TestAreaItem(createMockComponent()); + const tableData = [[{ text: 'Plain cell' }]]; + + areaItem.render($container, tableData); + + const $td = $container.find('td'); + expect($td.attr('aria-expanded')).toBeUndefined(); + }); +}); 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 dbc68392969d..340579bfbfd9 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 @@ -198,6 +198,8 @@ abstract class AreaItem { span.classList.add(PIVOTGRID_EXPAND_CLASS); div.appendChild(span); td.appendChild(div); + td.setAttribute('aria-expanded', String(cell.expanded)); + td.setAttribute('tabindex', '0'); } cellText = this._getCellText(cell, encodeHtml); 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 f4ab393f4a55..67b29cfeb96b 100644 --- a/packages/devextreme/js/__internal/grids/pivot_grid/m_widget.ts +++ b/packages/devextreme/js/__internal/grids/pivot_grid/m_widget.ts @@ -883,6 +883,16 @@ class PivotGrid extends Widget { }); } + _handleCellKeyDown(e) { + if (e.key === 'Enter' || e.key === ' ') { + const args = this._createEventArgs(e.currentTarget, e); + if (args.cell && isDefined(args.cell.expanded)) { + e.preventDefault(); + this._handleCellClick({ currentTarget: e.currentTarget, preventDefault: noop }); + } + } + } + _getNoDataText() { return this.option('texts.noData'); } @@ -1074,6 +1084,7 @@ class PivotGrid extends Widget { .toggleClass('dx-word-wrap', !!that.option('wordWrapEnabled')); eventsEngine.on($table, addNamespace(clickEventName, 'dxPivotGrid'), 'td', that._handleCellClick.bind(that)); + eventsEngine.on($table, addNamespace('keydown', 'dxPivotGrid'), 'td', that._handleCellKeyDown.bind(that)); return $table; } From 2b37daccca5a7e931a53472b86b4cd688fd40628 Mon Sep 17 00:00:00 2001 From: Aleksey Semikozov Date: Fri, 29 May 2026 07:13:51 -0300 Subject: [PATCH 2/8] PivotGrid - Migrate keyboard accessibility tests to QUnit --- .../expand_icon_accessibility.test.ts | 86 --------------- .../pivotGrid.markup.tests.js | 102 ++++++++++++++++++ .../pivotGrid.tests.js | 89 +++++++++++++++ 3 files changed, 191 insertions(+), 86 deletions(-) delete mode 100644 packages/devextreme/js/__internal/grids/pivot_grid/__tests__/expand_icon_accessibility.test.ts diff --git a/packages/devextreme/js/__internal/grids/pivot_grid/__tests__/expand_icon_accessibility.test.ts b/packages/devextreme/js/__internal/grids/pivot_grid/__tests__/expand_icon_accessibility.test.ts deleted file mode 100644 index 935b981e92c7..000000000000 --- a/packages/devextreme/js/__internal/grids/pivot_grid/__tests__/expand_icon_accessibility.test.ts +++ /dev/null @@ -1,86 +0,0 @@ -import $ from '@js/core/renderer'; - -import { AreaItem } from '../area_item/m_area_item'; - -class TestAreaItem extends AreaItem { - _getAreaName() { - return 'row'; - } -} - -const createMockComponent = () => ({ - option(name: string) { - const options: Record = { - rtlEnabled: false, - encodeHtml: true, - onCellPrepared: null, - }; - return options[name]; - }, - _eventsStrategy: { - hasEvent: () => false, - }, - _defaultActionArgs: () => ({}), -}); - -describe('PivotGrid expand icon accessibility', () => { - let $container: any = null; - - beforeEach(() => { - $container = $('
').appendTo('body'); - }); - - afterEach(() => { - $container.remove(); - }); - - it('should set aria-expanded="true" on td when cell is expanded', () => { - const areaItem = new TestAreaItem(createMockComponent()); - const tableData = [[{ expanded: true, text: 'Category' }]]; - - areaItem.render($container, tableData); - - const $td = $container.find('td'); - expect($td.attr('aria-expanded')).toBe('true'); - }); - - it('should set aria-expanded="false" on td when cell is collapsed', () => { - const areaItem = new TestAreaItem(createMockComponent()); - const tableData = [[{ expanded: false, text: 'Category' }]]; - - areaItem.render($container, tableData); - - const $td = $container.find('td'); - expect($td.attr('aria-expanded')).toBe('false'); - }); - - it('should set tabindex="0" on td when cell has expand icon', () => { - const areaItem = new TestAreaItem(createMockComponent()); - const tableData = [[{ expanded: true, text: 'Category' }]]; - - areaItem.render($container, tableData); - - const $td = $container.find('td'); - expect($td.attr('tabindex')).toBe('0'); - }); - - it('should not set tabindex on td when cell has no expand state', () => { - const areaItem = new TestAreaItem(createMockComponent()); - const tableData = [[{ text: 'Plain cell' }]]; - - areaItem.render($container, tableData); - - const $td = $container.find('td'); - expect($td.attr('tabindex')).toBeUndefined(); - }); - - it('should not set aria-expanded on td when cell has no expand state', () => { - const areaItem = new TestAreaItem(createMockComponent()); - const tableData = [[{ text: 'Plain cell' }]]; - - areaItem.render($container, tableData); - - const $td = $container.find('td'); - expect($td.attr('aria-expanded')).toBeUndefined(); - }); -}); 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 bd9ec652f5ad..507e32c3da54 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 @@ -85,5 +85,107 @@ QUnit.module('PivotGrid markup tests', () => { clock.restore(); }); + QUnit.test('Expandable td has aria-expanded reflecting expanded state', function(assert) { + const clock = sinon.useFakeTimers(); + const pivotGrid = createPivotGrid({ + dataSource: { + fields: [ + { area: 'row' }, + { area: 'column' }, + { caption: 'Sum', area: 'data' } + ], + rows: [ + { value: 'A', index: 0 } + ], + columns: [{ + value: '2010', index: 1, + children: [ + { value: '1', index: 0 } + ] + }, { + value: '2012', index: 2 + }], + values: [[[1]], [[2]], [[3]]] + } + }); + clock.tick(10); + + const $expandedTd = pivotGrid.$element().find('.dx-pivotgrid-expanded').closest('td'); + const $collapsedTd = pivotGrid.$element().find('.dx-pivotgrid-collapsed').closest('td'); + + assert.strictEqual($expandedTd.attr('aria-expanded'), 'true', 'expanded td has aria-expanded="true"'); + assert.strictEqual($collapsedTd.attr('aria-expanded'), 'false', 'collapsed td has aria-expanded="false"'); + + clock.restore(); + }); + + QUnit.test('Expandable td has tabindex="0"', function(assert) { + const clock = sinon.useFakeTimers(); + const pivotGrid = createPivotGrid({ + dataSource: { + fields: [ + { area: 'row' }, + { area: 'column' }, + { caption: 'Sum', area: 'data' } + ], + rows: [ + { value: 'A', index: 0 } + ], + columns: [{ + value: '2010', index: 1, + children: [ + { value: '1', index: 0 } + ] + }, { + value: '2012', index: 2 + }], + values: [[[1]], [[2]], [[3]]] + } + }); + clock.tick(10); + + const $expandedTd = pivotGrid.$element().find('.dx-pivotgrid-expanded').closest('td'); + const $collapsedTd = pivotGrid.$element().find('.dx-pivotgrid-collapsed').closest('td'); + + assert.strictEqual($expandedTd.attr('tabindex'), '0', 'expanded td is focusable'); + assert.strictEqual($collapsedTd.attr('tabindex'), '0', 'collapsed td is focusable'); + + clock.restore(); + }); + + QUnit.test('Non-expandable td has neither aria-expanded nor tabindex', function(assert) { + const clock = sinon.useFakeTimers(); + const pivotGrid = createPivotGrid({ + dataSource: { + fields: [ + { area: 'row' }, + { area: 'column' }, + { caption: 'Sum', area: 'data' } + ], + rows: [ + { value: 'A', index: 0 } + ], + columns: [{ + value: '2010', index: 1, + children: [ + { value: '1', index: 0 } + ] + }, { + value: '2012', index: 2 + }], + values: [[[1]], [[2]], [[3]]] + } + }); + clock.tick(10); + + const $nonExpandableTd = pivotGrid.$element().find('td:not([aria-expanded])').first(); + + assert.ok($nonExpandableTd.length > 0, 'non-expandable td exists'); + assert.strictEqual($nonExpandableTd.attr('aria-expanded'), undefined, 'no aria-expanded attribute'); + assert.strictEqual($nonExpandableTd.attr('tabindex'), undefined, 'no tabindex attribute'); + + clock.restore(); + }); + }); diff --git a/packages/devextreme/testing/tests/DevExpress.ui.widgets.pivotGrid/pivotGrid.tests.js b/packages/devextreme/testing/tests/DevExpress.ui.widgets.pivotGrid/pivotGrid.tests.js index ab0aebf4b533..e54ffae74e3b 100644 --- a/packages/devextreme/testing/tests/DevExpress.ui.widgets.pivotGrid/pivotGrid.tests.js +++ b/packages/devextreme/testing/tests/DevExpress.ui.widgets.pivotGrid/pivotGrid.tests.js @@ -433,6 +433,95 @@ QUnit.module('dxPivotGrid', { assert.strictEqual(expandValueChangingArgs, undefined); }); + QUnit.test('expand column item by Enter keydown', function(assert) { + let expandValueChangingArgs; + const pivotGrid = createPivotGrid({ + dataSource: this.dataSource, + onExpandValueChanging: function(args) { + expandValueChangingArgs = $.extend({}, args); + } + }); + assert.ok(pivotGrid); + + const $collapsedTd = $('#pivotGrid').find('.dx-pivotgrid-collapsed').closest('td'); + assert.strictEqual($collapsedTd.length, 1); + + $collapsedTd.trigger($.Event('keydown', { key: 'Enter' })); + + this.clock.tick(10); + + assert.deepEqual(expandValueChangingArgs, { + area: 'column', + path: ['2012'], + expanded: true, + needExpandData: true + }); + }); + + QUnit.test('collapse column item by Space keydown', function(assert) { + let expandValueChangingArgs; + const pivotGrid = createPivotGrid({ + dataSource: this.dataSource, + onExpandValueChanging: function(args) { + expandValueChangingArgs = $.extend({}, args); + } + }); + assert.ok(pivotGrid); + + const $expandedTd = $('#pivotGrid').find('.dx-pivotgrid-expanded').closest('td'); + assert.strictEqual($expandedTd.length, 1); + + $expandedTd.trigger($.Event('keydown', { key: ' ' })); + + this.clock.tick(10); + + assert.deepEqual(expandValueChangingArgs, { + area: 'column', + path: ['2010'], + expanded: false + }); + }); + + QUnit.test('keydown with keys other than Enter and Space does not toggle expansion', function(assert) { + let expandValueChangingArgs; + const pivotGrid = createPivotGrid({ + dataSource: this.dataSource, + onExpandValueChanging: function(args) { + expandValueChangingArgs = $.extend({}, args); + } + }); + assert.ok(pivotGrid); + + const $collapsedTd = $('#pivotGrid').find('.dx-pivotgrid-collapsed').closest('td'); + assert.strictEqual($collapsedTd.length, 1); + + $collapsedTd.trigger($.Event('keydown', { key: 'Tab' })); + + this.clock.tick(10); + + assert.strictEqual(expandValueChangingArgs, undefined); + }); + + QUnit.test('keydown on a non-expandable td does not toggle expansion', function(assert) { + let expandValueChangingArgs; + const pivotGrid = createPivotGrid({ + dataSource: this.dataSource, + onExpandValueChanging: function(args) { + expandValueChangingArgs = $.extend({}, args); + } + }); + assert.ok(pivotGrid); + + const $nonExpandableTd = $('#pivotGrid').find('td:not([aria-expanded])').first(); + assert.ok($nonExpandableTd.length > 0); + + $nonExpandableTd.trigger($.Event('keydown', { key: 'Enter' })); + + this.clock.tick(10); + + assert.strictEqual(expandValueChangingArgs, undefined); + }); + QUnit.test('T248253. DataSource changed', function(assert) { let expandValueChangingArgs; const pivotGrid = createPivotGrid($.extend(this.testOptions, { From 6af9b31be70b41e5ba06021197137595a79b5d46 Mon Sep 17 00:00:00 2001 From: Aleksey Semikozov Date: Fri, 29 May 2026 10:04:33 -0300 Subject: [PATCH 3/8] PivotGrid - Fix markup test fixture for keyboard a11y tests --- .../pivotGrid.markup.tests.js | 143 +++++++----------- 1 file changed, 53 insertions(+), 90 deletions(-) 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 507e32c3da54..9952e729a932 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 @@ -85,106 +85,69 @@ QUnit.module('PivotGrid markup tests', () => { clock.restore(); }); + const createExpandableDataSource = () => ({ + fields: [ + { dataField: 'region', area: 'row' }, + { dataField: 'city', area: 'row' }, + { dataField: 'year', area: 'column', expanded: true }, + { dataField: 'quarter', area: 'column' }, + { dataField: 'amount', area: 'data', summaryType: 'sum', dataType: 'number' } + ], + store: [ + { region: 'N', city: 'B', year: 2020, quarter: 'Q1', amount: 100 }, + { region: 'N', city: 'NY', year: 2020, quarter: 'Q2', amount: 200 }, + { region: 'S', city: 'M', year: 2021, quarter: 'Q1', amount: 300 } + ] + }); + QUnit.test('Expandable td has aria-expanded reflecting expanded state', function(assert) { const clock = sinon.useFakeTimers(); - const pivotGrid = createPivotGrid({ - dataSource: { - fields: [ - { area: 'row' }, - { area: 'column' }, - { caption: 'Sum', area: 'data' } - ], - rows: [ - { value: 'A', index: 0 } - ], - columns: [{ - value: '2010', index: 1, - children: [ - { value: '1', index: 0 } - ] - }, { - value: '2012', index: 2 - }], - values: [[[1]], [[2]], [[3]]] - } - }); - clock.tick(10); - - const $expandedTd = pivotGrid.$element().find('.dx-pivotgrid-expanded').closest('td'); - const $collapsedTd = pivotGrid.$element().find('.dx-pivotgrid-collapsed').closest('td'); - - assert.strictEqual($expandedTd.attr('aria-expanded'), 'true', 'expanded td has aria-expanded="true"'); - assert.strictEqual($collapsedTd.attr('aria-expanded'), 'false', 'collapsed td has aria-expanded="false"'); - - clock.restore(); + try { + const pivotGrid = createPivotGrid({ dataSource: createExpandableDataSource() }); + clock.tick(10); + + const $expandedTd = pivotGrid.$element().find('.dx-pivotgrid-expanded').first().closest('td'); + const $collapsedTd = pivotGrid.$element().find('.dx-pivotgrid-collapsed').first().closest('td'); + + assert.ok($expandedTd.length > 0, 'expanded td present'); + assert.ok($collapsedTd.length > 0, 'collapsed td present'); + assert.strictEqual($expandedTd.attr('aria-expanded'), 'true', 'expanded td has aria-expanded="true"'); + assert.strictEqual($collapsedTd.attr('aria-expanded'), 'false', 'collapsed td has aria-expanded="false"'); + } finally { + clock.restore(); + } }); QUnit.test('Expandable td has tabindex="0"', function(assert) { const clock = sinon.useFakeTimers(); - const pivotGrid = createPivotGrid({ - dataSource: { - fields: [ - { area: 'row' }, - { area: 'column' }, - { caption: 'Sum', area: 'data' } - ], - rows: [ - { value: 'A', index: 0 } - ], - columns: [{ - value: '2010', index: 1, - children: [ - { value: '1', index: 0 } - ] - }, { - value: '2012', index: 2 - }], - values: [[[1]], [[2]], [[3]]] - } - }); - clock.tick(10); - - const $expandedTd = pivotGrid.$element().find('.dx-pivotgrid-expanded').closest('td'); - const $collapsedTd = pivotGrid.$element().find('.dx-pivotgrid-collapsed').closest('td'); - - assert.strictEqual($expandedTd.attr('tabindex'), '0', 'expanded td is focusable'); - assert.strictEqual($collapsedTd.attr('tabindex'), '0', 'collapsed td is focusable'); - - clock.restore(); + try { + const pivotGrid = createPivotGrid({ dataSource: createExpandableDataSource() }); + clock.tick(10); + + const $expandedTd = pivotGrid.$element().find('.dx-pivotgrid-expanded').first().closest('td'); + const $collapsedTd = pivotGrid.$element().find('.dx-pivotgrid-collapsed').first().closest('td'); + + assert.strictEqual($expandedTd.attr('tabindex'), '0', 'expanded td is focusable'); + assert.strictEqual($collapsedTd.attr('tabindex'), '0', 'collapsed td is focusable'); + } finally { + clock.restore(); + } }); QUnit.test('Non-expandable td has neither aria-expanded nor tabindex', function(assert) { const clock = sinon.useFakeTimers(); - const pivotGrid = createPivotGrid({ - dataSource: { - fields: [ - { area: 'row' }, - { area: 'column' }, - { caption: 'Sum', area: 'data' } - ], - rows: [ - { value: 'A', index: 0 } - ], - columns: [{ - value: '2010', index: 1, - children: [ - { value: '1', index: 0 } - ] - }, { - value: '2012', index: 2 - }], - values: [[[1]], [[2]], [[3]]] - } - }); - clock.tick(10); - - const $nonExpandableTd = pivotGrid.$element().find('td:not([aria-expanded])').first(); - - assert.ok($nonExpandableTd.length > 0, 'non-expandable td exists'); - assert.strictEqual($nonExpandableTd.attr('aria-expanded'), undefined, 'no aria-expanded attribute'); - assert.strictEqual($nonExpandableTd.attr('tabindex'), undefined, 'no tabindex attribute'); - - clock.restore(); + try { + const pivotGrid = createPivotGrid({ dataSource: createExpandableDataSource() }); + clock.tick(10); + + const $nonExpandableTd = pivotGrid.$element().find('td:not([aria-expanded])').first(); + + assert.ok($nonExpandableTd.length > 0, 'non-expandable td exists'); + assert.strictEqual($nonExpandableTd.attr('aria-expanded'), undefined, 'no aria-expanded attribute'); + assert.strictEqual($nonExpandableTd.attr('tabindex'), undefined, 'no tabindex attribute'); + } finally { + clock.restore(); + } }); }); From 0884bcebf9f2e0054491b47f840afad3a73b5050 Mon Sep 17 00:00:00 2001 From: Aleksey Semikozov Date: Fri, 29 May 2026 10:45:32 -0300 Subject: [PATCH 4/8] PivotGrid - Skip markup a11y tests on serverSide --- .../pivotGrid.markup.tests.js | 12 ++++++++++++ 1 file changed, 12 insertions(+) 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 9952e729a932..da52cb0a83f3 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 @@ -101,6 +101,10 @@ QUnit.module('PivotGrid markup tests', () => { }); QUnit.test('Expandable td has aria-expanded reflecting expanded state', function(assert) { + if(!windowUtils.hasWindow()) { + assert.ok(true, 'skipped on serverSide'); + return; + } const clock = sinon.useFakeTimers(); try { const pivotGrid = createPivotGrid({ dataSource: createExpandableDataSource() }); @@ -119,6 +123,10 @@ QUnit.module('PivotGrid markup tests', () => { }); QUnit.test('Expandable td has tabindex="0"', function(assert) { + if(!windowUtils.hasWindow()) { + assert.ok(true, 'skipped on serverSide'); + return; + } const clock = sinon.useFakeTimers(); try { const pivotGrid = createPivotGrid({ dataSource: createExpandableDataSource() }); @@ -135,6 +143,10 @@ QUnit.module('PivotGrid markup tests', () => { }); QUnit.test('Non-expandable td has neither aria-expanded nor tabindex', function(assert) { + if(!windowUtils.hasWindow()) { + assert.ok(true, 'skipped on serverSide'); + return; + } const clock = sinon.useFakeTimers(); try { const pivotGrid = createPivotGrid({ dataSource: createExpandableDataSource() }); From 0acfa338621c07ffdeead8a92fe8ff48c9501441 Mon Sep 17 00:00:00 2001 From: Aleksey Semikozov Date: Fri, 29 May 2026 12:15:50 -0300 Subject: [PATCH 5/8] PivotGrid - Refine keyboard a11y per review feedback --- .../scss/widgets/base/pivotGrid/_common.scss | 5 ++++ .../__internal/grids/pivot_grid/m_widget.ts | 2 +- .../pivotGrid.tests.js | 25 ++++++++++++++++++- 3 files changed, 30 insertions(+), 2 deletions(-) diff --git a/packages/devextreme-scss/scss/widgets/base/pivotGrid/_common.scss b/packages/devextreme-scss/scss/widgets/base/pivotGrid/_common.scss index 87826aab0aca..0faf4d470dca 100644 --- a/packages/devextreme-scss/scss/widgets/base/pivotGrid/_common.scss +++ b/packages/devextreme-scss/scss/widgets/base/pivotGrid/_common.scss @@ -75,6 +75,11 @@ box-sizing: content-box; } + td[aria-expanded]:focus-visible { + outline: 2px solid currentColor; + outline-offset: -2px; + } + .dx-area-description-cell { position: relative; background-clip: padding-box; // T379462 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 67b29cfeb96b..14106e642a4b 100644 --- a/packages/devextreme/js/__internal/grids/pivot_grid/m_widget.ts +++ b/packages/devextreme/js/__internal/grids/pivot_grid/m_widget.ts @@ -888,7 +888,7 @@ class PivotGrid extends Widget { const args = this._createEventArgs(e.currentTarget, e); if (args.cell && isDefined(args.cell.expanded)) { e.preventDefault(); - this._handleCellClick({ currentTarget: e.currentTarget, preventDefault: noop }); + this._handleCellClick(e); } } } diff --git a/packages/devextreme/testing/tests/DevExpress.ui.widgets.pivotGrid/pivotGrid.tests.js b/packages/devextreme/testing/tests/DevExpress.ui.widgets.pivotGrid/pivotGrid.tests.js index e54ffae74e3b..f1ecd3e3c740 100644 --- a/packages/devextreme/testing/tests/DevExpress.ui.widgets.pivotGrid/pivotGrid.tests.js +++ b/packages/devextreme/testing/tests/DevExpress.ui.widgets.pivotGrid/pivotGrid.tests.js @@ -512,7 +512,7 @@ QUnit.module('dxPivotGrid', { }); assert.ok(pivotGrid); - const $nonExpandableTd = $('#pivotGrid').find('td:not([aria-expanded])').first(); + const $nonExpandableTd = $('#pivotGrid').find('.dx-area-row-cell td').first(); assert.ok($nonExpandableTd.length > 0); $nonExpandableTd.trigger($.Event('keydown', { key: 'Enter' })); @@ -522,6 +522,29 @@ QUnit.module('dxPivotGrid', { assert.strictEqual(expandValueChangingArgs, undefined); }); + QUnit.test('onCellClick cancel prevents keyboard expansion', function(assert) { + let expandValueChangingArgs; + const pivotGrid = createPivotGrid({ + dataSource: this.dataSource, + onExpandValueChanging: function(args) { + expandValueChangingArgs = $.extend({}, args); + }, + onCellClick: function(args) { + args.cancel = true; + } + }); + assert.ok(pivotGrid); + + const $collapsedTd = $('#pivotGrid').find('.dx-pivotgrid-collapsed').closest('td'); + assert.strictEqual($collapsedTd.length, 1); + + $collapsedTd.trigger($.Event('keydown', { key: 'Enter' })); + + this.clock.tick(10); + + assert.strictEqual(expandValueChangingArgs, undefined); + }); + QUnit.test('T248253. DataSource changed', function(assert) { let expandValueChangingArgs; const pivotGrid = createPivotGrid($.extend(this.testOptions, { From ee8ccf91a154c34f441663eea08e17bd7c64f51a Mon Sep 17 00:00:00 2001 From: Aleksey Semikozov Date: Fri, 29 May 2026 15:22:47 -0300 Subject: [PATCH 6/8] PivotGrid - Add role="button" to expandable cells --- .../grids/pivot_grid/area_item/m_area_item.ts | 1 + .../pivotGrid.markup.tests.js | 23 ++++++++++++++++++- 2 files changed, 23 insertions(+), 1 deletion(-) 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 340579bfbfd9..eacf3c9f1fee 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 @@ -198,6 +198,7 @@ abstract class AreaItem { span.classList.add(PIVOTGRID_EXPAND_CLASS); div.appendChild(span); td.appendChild(div); + td.setAttribute('role', 'button'); td.setAttribute('aria-expanded', String(cell.expanded)); td.setAttribute('tabindex', '0'); } 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 da52cb0a83f3..99ea944101cb 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 @@ -142,7 +142,27 @@ QUnit.module('PivotGrid markup tests', () => { } }); - QUnit.test('Non-expandable td has neither aria-expanded nor tabindex', function(assert) { + QUnit.test('Expandable td has role="button"', function(assert) { + if(!windowUtils.hasWindow()) { + assert.ok(true, 'skipped on serverSide'); + return; + } + const clock = sinon.useFakeTimers(); + try { + const pivotGrid = createPivotGrid({ dataSource: createExpandableDataSource() }); + clock.tick(10); + + const $expandedTd = pivotGrid.$element().find('.dx-pivotgrid-expanded').first().closest('td'); + const $collapsedTd = pivotGrid.$element().find('.dx-pivotgrid-collapsed').first().closest('td'); + + assert.strictEqual($expandedTd.attr('role'), 'button', 'expanded td has role="button"'); + assert.strictEqual($collapsedTd.attr('role'), 'button', 'collapsed td has role="button"'); + } finally { + clock.restore(); + } + }); + + QUnit.test('Non-expandable td has no role, aria-expanded, or tabindex', function(assert) { if(!windowUtils.hasWindow()) { assert.ok(true, 'skipped on serverSide'); return; @@ -155,6 +175,7 @@ QUnit.module('PivotGrid markup tests', () => { const $nonExpandableTd = pivotGrid.$element().find('td:not([aria-expanded])').first(); assert.ok($nonExpandableTd.length > 0, 'non-expandable td exists'); + assert.strictEqual($nonExpandableTd.attr('role'), undefined, 'no role attribute'); assert.strictEqual($nonExpandableTd.attr('aria-expanded'), undefined, 'no aria-expanded attribute'); assert.strictEqual($nonExpandableTd.attr('tabindex'), undefined, 'no tabindex attribute'); } finally { From 21d053059829e8283c0a79c1daee0356661c72e0 Mon Sep 17 00:00:00 2001 From: Aleksey Semikozov Date: Fri, 29 May 2026 15:43:03 -0300 Subject: [PATCH 7/8] PivotGrid - Ignore key auto-repeat in keyboard expand handler --- .../__internal/grids/pivot_grid/m_widget.ts | 3 +++ .../pivotGrid.tests.js | 20 +++++++++++++++++++ 2 files changed, 23 insertions(+) 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 14106e642a4b..eb710c031dc9 100644 --- a/packages/devextreme/js/__internal/grids/pivot_grid/m_widget.ts +++ b/packages/devextreme/js/__internal/grids/pivot_grid/m_widget.ts @@ -884,6 +884,9 @@ class PivotGrid extends Widget { } _handleCellKeyDown(e) { + if (e.repeat) { + return; + } if (e.key === 'Enter' || e.key === ' ') { const args = this._createEventArgs(e.currentTarget, e); if (args.cell && isDefined(args.cell.expanded)) { diff --git a/packages/devextreme/testing/tests/DevExpress.ui.widgets.pivotGrid/pivotGrid.tests.js b/packages/devextreme/testing/tests/DevExpress.ui.widgets.pivotGrid/pivotGrid.tests.js index f1ecd3e3c740..35af62075899 100644 --- a/packages/devextreme/testing/tests/DevExpress.ui.widgets.pivotGrid/pivotGrid.tests.js +++ b/packages/devextreme/testing/tests/DevExpress.ui.widgets.pivotGrid/pivotGrid.tests.js @@ -522,6 +522,26 @@ QUnit.module('dxPivotGrid', { assert.strictEqual(expandValueChangingArgs, undefined); }); + QUnit.test('keydown auto-repeat does not trigger expansion', function(assert) { + let expandValueChangingArgs; + const pivotGrid = createPivotGrid({ + dataSource: this.dataSource, + onExpandValueChanging: function(args) { + expandValueChangingArgs = $.extend({}, args); + } + }); + assert.ok(pivotGrid); + + const $collapsedTd = $('#pivotGrid').find('.dx-pivotgrid-collapsed').closest('td'); + assert.strictEqual($collapsedTd.length, 1); + + $collapsedTd.trigger($.Event('keydown', { key: 'Enter', repeat: true })); + + this.clock.tick(10); + + assert.strictEqual(expandValueChangingArgs, undefined); + }); + QUnit.test('onCellClick cancel prevents keyboard expansion', function(assert) { let expandValueChangingArgs; const pivotGrid = createPivotGrid({ From 035f29616a10be6701254d6a7089ff01ad480876 Mon Sep 17 00:00:00 2001 From: Aleksey Semikozov Date: Fri, 29 May 2026 18:39:08 -0300 Subject: [PATCH 8/8] PivotGrid - Restore focus after keyboard expand via shared a11y helpers --- .../grids/pivot_grid/area_item/m_area_item.ts | 1 + .../__internal/grids/pivot_grid/m_widget.ts | 7 +++++ .../pivotGrid.tests.js | 28 +++++++++++++++++++ 3 files changed, 36 insertions(+) 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 eacf3c9f1fee..8160d13be582 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 @@ -199,6 +199,7 @@ abstract class AreaItem { div.appendChild(span); td.appendChild(div); td.setAttribute('role', 'button'); + td.setAttribute('aria-label', String(cell.text ?? cell.value ?? '')); td.setAttribute('aria-expanded', String(cell.expanded)); td.setAttribute('tabindex', '0'); } 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 eb710c031dc9..c2967c7f6ecd 100644 --- a/packages/devextreme/js/__internal/grids/pivot_grid/m_widget.ts +++ b/packages/devextreme/js/__internal/grids/pivot_grid/m_widget.ts @@ -20,6 +20,7 @@ import type { Properties } from '@js/ui/button'; import Button from '@js/ui/button'; import ContextMenu from '@js/ui/context_menu'; import Popup from '@js/ui/popup/ui.popup'; +import { restoreFocus, saveFocusedElementInfo } from '@js/ui/shared/accessibility'; import { current, isFluent } from '@js/ui/themes'; import Widget from '@ts/core/widget/widget'; import gridCoreUtils from '@ts/grids/grid_core/m_utils'; @@ -891,6 +892,12 @@ class PivotGrid extends Widget { const args = this._createEventArgs(e.currentTarget, e); if (args.cell && isDefined(args.cell.expanded)) { e.preventDefault(); + saveFocusedElementInfo(e.currentTarget, this); + const onReady = () => { + this.off('contentReady', onReady); + restoreFocus(this); + }; + this.on('contentReady', onReady); this._handleCellClick(e); } } diff --git a/packages/devextreme/testing/tests/DevExpress.ui.widgets.pivotGrid/pivotGrid.tests.js b/packages/devextreme/testing/tests/DevExpress.ui.widgets.pivotGrid/pivotGrid.tests.js index 35af62075899..ba7cc7d4307a 100644 --- a/packages/devextreme/testing/tests/DevExpress.ui.widgets.pivotGrid/pivotGrid.tests.js +++ b/packages/devextreme/testing/tests/DevExpress.ui.widgets.pivotGrid/pivotGrid.tests.js @@ -542,6 +542,34 @@ QUnit.module('dxPivotGrid', { assert.strictEqual(expandValueChangingArgs, undefined); }); + QUnit.test('keyboard activation preserves focus on the same cell after re-render', function(assert) { + let expandValueChangingArgs; + const pivotGrid = createPivotGrid({ + dataSource: this.dataSource, + onExpandValueChanging: function(args) { + expandValueChangingArgs = $.extend({}, args); + } + }); + assert.ok(pivotGrid); + + const $collapsedTd = $('#pivotGrid').find('.dx-pivotgrid-collapsed').closest('td'); + assert.strictEqual($collapsedTd.length, 1); + const ariaLabelBefore = $collapsedTd.attr('aria-label'); + $collapsedTd.get(0).focus(); + + $collapsedTd.trigger($.Event('keydown', { key: 'Enter' })); + + this.clock.tick(100); + + assert.deepEqual(expandValueChangingArgs, { + area: 'column', + path: ['2012'], + expanded: true, + needExpandData: true + }); + assert.strictEqual($(document.activeElement).attr('aria-label'), ariaLabelBefore, 'focus restored to the same cell after expand'); + }); + QUnit.test('onCellClick cancel prevents keyboard expansion', function(assert) { let expandValueChangingArgs; const pivotGrid = createPivotGrid({