Skip to content

Commit 4b9f1ab

Browse files
PivotGrid A11y and KBN - The expand icons are not accessible via keyboard (KBN) (#33709)
1 parent 6868f5b commit 4b9f1ab

9 files changed

Lines changed: 375 additions & 2 deletions

File tree

27.2 KB
Loading
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import { createScreenshotsComparer } from 'devextreme-screenshot-comparer';
2+
import PivotGrid from 'devextreme-testcafe-models/pivotGrid';
3+
import { Selector } from 'testcafe';
4+
import { createWidget } from '../../../../helpers/createWidget';
5+
import url from '../../../../helpers/getPageUrl';
6+
import { testScreenshot } from '../../../../helpers/themeUtils';
7+
import { sales } from '../data';
8+
9+
fixture.disablePageReloads`pivotGrid_kbn_expandIcon`
10+
.page(url(__dirname, '../../../container.html'));
11+
12+
const PIVOT_GRID_SELECTOR = '#container';
13+
14+
test('Expandable cell should have a visible focus outline when focused by keyboard', async (t) => {
15+
const { takeScreenshot, compareResults } = createScreenshotsComparer(t);
16+
const pivotGrid = new PivotGrid(PIVOT_GRID_SELECTOR);
17+
18+
// Tab through the grid until an expandable cell is focused by keyboard
19+
// so that the :focus-visible outline is applied.
20+
for (let i = 0; i < 10; i += 1) {
21+
await t.pressKey('tab');
22+
23+
if (await Selector(':focus').hasAttribute('aria-expanded')) {
24+
break;
25+
}
26+
}
27+
28+
await t
29+
.expect(Selector(':focus').hasAttribute('aria-expanded'))
30+
.ok('an expandable cell is focused');
31+
32+
await testScreenshot(t, takeScreenshot, 'pivotgrid_kbn_expandable_cell_focused.png', { element: pivotGrid.element });
33+
34+
await t
35+
.expect(compareResults.isValid())
36+
.ok(compareResults.errorMessages());
37+
}).before(async () => createWidget('dxPivotGrid', {
38+
width: 600,
39+
allowExpandAll: true,
40+
fieldChooser: {
41+
enabled: false,
42+
},
43+
dataSource: {
44+
fields: [{
45+
dataField: 'region',
46+
area: 'row',
47+
expanded: false,
48+
}, {
49+
dataField: 'city',
50+
area: 'row',
51+
}, {
52+
dataField: 'date',
53+
area: 'column',
54+
}, {
55+
dataField: 'amount',
56+
area: 'data',
57+
summaryType: 'sum',
58+
dataType: 'number',
59+
}],
60+
store: sales,
61+
},
62+
}));

packages/devextreme-scss/scss/widgets/base/pivotGrid/_index.scss

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,16 @@ $pivotgrid-expand-icon-text-offset: 0;
127127
}
128128

129129
.dx-pivotgrid {
130+
td:has(> .dx-expand-icon-container:focus-visible) {
131+
outline: 2px solid;
132+
outline-color: $pivotgrid-accent-color;
133+
outline-offset: -2px;
134+
}
135+
136+
.dx-expand-icon-container:focus-visible {
137+
outline: none;
138+
}
139+
130140
.dx-column-header,
131141
.dx-filter-header {
132142
.dx-pivotgrid-toolbar {

packages/devextreme/js/__internal/grids/pivot_grid/area_item/m_area_item.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,11 @@ abstract class AreaItem {
198198
span.classList.add(PIVOTGRID_EXPAND_CLASS);
199199
div.appendChild(span);
200200
td.appendChild(div);
201+
const ariaLabel = String(cell.text ?? cell.value ?? '');
202+
div.setAttribute('role', 'button');
203+
div.setAttribute('aria-label', encodeHtml ? ariaLabel : $('<div>').html(ariaLabel).text());
204+
div.setAttribute('aria-expanded', String(cell.expanded));
205+
div.setAttribute('tabindex', '0');
201206
}
202207

203208
cellText = this._getCellText(cell, encodeHtml);

packages/devextreme/js/__internal/grids/pivot_grid/m_widget.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import type { ButtonStyle, Properties } from '@js/ui/button';
2121
import Button from '@js/ui/button';
2222
import ContextMenu from '@js/ui/context_menu';
2323
import Popup from '@js/ui/popup/ui.popup';
24+
import { restoreFocus, saveFocusedElementInfo } from '@js/ui/shared/accessibility';
2425
import { current, isGeneric } from '@js/ui/themes';
2526
import Widget from '@ts/core/widget/widget';
2627
import gridCoreUtils from '@ts/grids/grid_core/m_utils';
@@ -890,6 +891,35 @@ class PivotGrid extends Widget {
890891
});
891892
}
892893

894+
_handleCellKeyDown(e) {
895+
if (e.repeat) {
896+
return;
897+
}
898+
if (e.key !== 'Enter' && e.key !== ' ') {
899+
return;
900+
}
901+
const args = this._createEventArgs(e.currentTarget, e);
902+
const { cell } = args;
903+
if (!cell || !isDefined(cell.expanded)) {
904+
return;
905+
}
906+
e.preventDefault();
907+
this._trigger('onCellClick', args);
908+
if (args.cancel) {
909+
return;
910+
}
911+
const $control = $(e.currentTarget).find('.dx-expand-icon-container');
912+
saveFocusedElementInfo($control.get(0), this);
913+
const onReady = () => {
914+
this.off('contentReady', onReady);
915+
restoreFocus(this);
916+
};
917+
this.on('contentReady', onReady);
918+
setTimeout(() => {
919+
this._dataController[cell.expanded ? 'collapseHeaderItem' : 'expandHeaderItem'](args.area, cell.path);
920+
});
921+
}
922+
893923
_getNoDataText() {
894924
return this.option('texts.noData');
895925
}
@@ -1089,6 +1119,7 @@ class PivotGrid extends Widget {
10891119
.toggleClass('dx-word-wrap', !!that.option('wordWrapEnabled'));
10901120

10911121
eventsEngine.on($table, addNamespace(clickEventName, 'dxPivotGrid'), 'td', that._handleCellClick.bind(that));
1122+
eventsEngine.on($table, addNamespace('keydown', 'dxPivotGrid'), 'td', that._handleCellKeyDown.bind(that));
10921123

10931124
return $table;
10941125
}

packages/devextreme/js/ui/pivot_grid.d.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ export type PivotGridTotalDisplayMode = 'both' | 'columns' | 'none' | 'rows';
5555
* @type object
5656
* @inherits Cancelable,NativeEventInfo
5757
*/
58-
export type CellClickEvent = Cancelable & NativeEventInfo<dxPivotGrid, MouseEvent | PointerEvent> & {
58+
export type CellClickEvent = Cancelable & NativeEventInfo<dxPivotGrid, KeyboardEvent | MouseEvent | PointerEvent> & {
5959
/** @docid _ui_pivot_grid_CellClickEvent.area */
6060
readonly area?: string;
6161
/** @docid _ui_pivot_grid_CellClickEvent.cellElement */

packages/devextreme/testing/tests/DevExpress.ui.widgets.pivotGrid/pivotGrid.markup.tests.js

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,5 +85,109 @@ QUnit.module('PivotGrid markup tests', () => {
8585
clock.restore();
8686
});
8787

88+
const createExpandableDataSource = () => ({
89+
fields: [
90+
{ dataField: 'region', area: 'row' },
91+
{ dataField: 'city', area: 'row' },
92+
{ dataField: 'year', area: 'column', expanded: true },
93+
{ dataField: 'quarter', area: 'column' },
94+
{ dataField: 'amount', area: 'data', summaryType: 'sum', dataType: 'number' }
95+
],
96+
store: [
97+
{ region: 'N', city: 'B', year: 2020, quarter: 'Q1', amount: 100 },
98+
{ region: 'N', city: 'NY', year: 2020, quarter: 'Q2', amount: 200 },
99+
{ region: 'S', city: 'M', year: 2021, quarter: 'Q1', amount: 300 }
100+
]
101+
});
102+
103+
QUnit.test('Expand control has aria-expanded reflecting expanded state', function(assert) {
104+
if(!windowUtils.hasWindow()) {
105+
assert.ok(true, 'skipped on serverSide');
106+
return;
107+
}
108+
const clock = sinon.useFakeTimers();
109+
try {
110+
const pivotGrid = createPivotGrid({ dataSource: createExpandableDataSource() });
111+
clock.tick(10);
112+
113+
const $expandedControl = pivotGrid.$element().find('.dx-pivotgrid-expanded').first().find('.dx-expand-icon-container');
114+
const $collapsedControl = pivotGrid.$element().find('.dx-pivotgrid-collapsed').first().find('.dx-expand-icon-container');
115+
116+
assert.ok($expandedControl.length > 0, 'expanded control present');
117+
assert.ok($collapsedControl.length > 0, 'collapsed control present');
118+
assert.strictEqual($expandedControl.attr('aria-expanded'), 'true', 'expanded control has aria-expanded="true"');
119+
assert.strictEqual($collapsedControl.attr('aria-expanded'), 'false', 'collapsed control has aria-expanded="false"');
120+
} finally {
121+
clock.restore();
122+
}
123+
});
124+
125+
QUnit.test('Expand control is a focusable button, td keeps native cell semantics', function(assert) {
126+
if(!windowUtils.hasWindow()) {
127+
assert.ok(true, 'skipped on serverSide');
128+
return;
129+
}
130+
const clock = sinon.useFakeTimers();
131+
try {
132+
const pivotGrid = createPivotGrid({ dataSource: createExpandableDataSource() });
133+
clock.tick(10);
134+
135+
const $collapsedTd = pivotGrid.$element().find('.dx-pivotgrid-collapsed').first();
136+
const $collapsedControl = $collapsedTd.find('.dx-expand-icon-container');
137+
138+
assert.strictEqual($collapsedControl.attr('role'), 'button', 'control has role="button"');
139+
assert.strictEqual($collapsedControl.attr('tabindex'), '0', 'control is focusable');
140+
assert.strictEqual($collapsedTd.attr('role'), undefined, 'td keeps native cell role');
141+
assert.strictEqual($collapsedTd.attr('tabindex'), undefined, 'td is not in the tab order');
142+
assert.strictEqual($collapsedTd.attr('aria-expanded'), undefined, 'td has no aria-expanded');
143+
} finally {
144+
clock.restore();
145+
}
146+
});
147+
148+
QUnit.test('Expand control aria-label matches the displayed text, not the raw value', function(assert) {
149+
if(!windowUtils.hasWindow()) {
150+
assert.ok(true, 'skipped on serverSide');
151+
return;
152+
}
153+
const clock = sinon.useFakeTimers();
154+
try {
155+
const dataSource = createExpandableDataSource();
156+
dataSource.fields[0].customizeText = (cellInfo) => `${cellInfo.valueText} region`;
157+
const pivotGrid = createPivotGrid({ dataSource });
158+
clock.tick(10);
159+
160+
const $collapsedTd = pivotGrid.$element().find('.dx-pivotgrid-collapsed').first().closest('td');
161+
const $collapsedControl = $collapsedTd.find('.dx-expand-icon-container');
162+
const displayedText = $collapsedTd.text().trim();
163+
164+
assert.notStrictEqual($collapsedControl.attr('aria-label').indexOf(' region'), -1, 'aria-label uses customized display text');
165+
assert.strictEqual($collapsedControl.attr('aria-label'), displayedText, 'aria-label equals the visible cell text');
166+
} finally {
167+
clock.restore();
168+
}
169+
});
170+
171+
QUnit.test('Non-expandable cell has no expand control', function(assert) {
172+
if(!windowUtils.hasWindow()) {
173+
assert.ok(true, 'skipped on serverSide');
174+
return;
175+
}
176+
const clock = sinon.useFakeTimers();
177+
try {
178+
const pivotGrid = createPivotGrid({ dataSource: createExpandableDataSource() });
179+
clock.tick(10);
180+
181+
const $dataCell = pivotGrid.$element().find('.dx-area-data-cell td').first();
182+
183+
assert.ok($dataCell.length > 0, 'data cell exists');
184+
assert.strictEqual($dataCell.find('.dx-expand-icon-container').length, 0, 'no expand control in a non-expandable cell');
185+
assert.strictEqual($dataCell.attr('role'), undefined, 'data cell has no role');
186+
assert.strictEqual($dataCell.attr('tabindex'), undefined, 'data cell is not focusable');
187+
} finally {
188+
clock.restore();
189+
}
190+
});
191+
88192
});
89193

0 commit comments

Comments
 (0)