Skip to content

Commit ad45b6d

Browse files
committed
feat: add invisible accessibility layer to Grid for e2e testing
- Add GridAccessibilityLayer component that renders DOM elements overlaid on canvas - Data cells use data-testid="grid-cell-{column}-{row}" - Column headers use data-testid="grid-column-header-{column}-{depth}" - Row headers use data-testid="grid-row-header-{row}" (when rowHeaderWidth > 0) - Add enableAccessibilityLayer prop to Grid and IrisGrid - All elements have pointer-events: none to allow clicks through to canvas - Add e2e tests for accessibility layer functionality
1 parent efd24a7 commit ad45b6d

8 files changed

Lines changed: 622 additions & 0 deletions

File tree

packages/dashboard-core-plugins/src/GridWidgetPlugin.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,7 @@ export function GridWidgetPlugin({
171171
onContextMenu={onContextMenu}
172172
inputFilters={inputFilters}
173173
customFilters={customFilters}
174+
enableAccessibilityLayer
174175
// eslint-disable-next-line react/jsx-props-no-spreading
175176
{...linkerProps}
176177
alwaysFetchColumns={alwaysFetchColumns}

packages/dashboard-core-plugins/src/panels/IrisGridPanel.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1245,6 +1245,7 @@ export class IrisGridPanel extends PureComponent<
12451245
columnAlignmentMap={columnAlignmentMap}
12461246
columnSelectionValidator={this.isColumnSelectionValid}
12471247
conditionalFormats={conditionalFormats}
1248+
enableAccessibilityLayer
12481249
inputFilters={this.getGridInputFilters(model.columns, inputFilters)}
12491250
applyInputFiltersOnInit={panelState == null}
12501251
isFilterBarShown={isFilterBarShown}

packages/grid/src/Grid.tsx

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@ import {
7979
type GridRenderState,
8080
type EditingCellTextSelectionRange,
8181
} from './GridRendererTypes';
82+
import GridAccessibilityLayer from './GridAccessibilityLayer';
8283

8384
type LegacyCanvasRenderingContext2D = CanvasRenderingContext2D & {
8485
webkitBackingStorePixelRatio?: number;
@@ -147,6 +148,9 @@ export type GridProps = typeof Grid.defaultProps & {
147148
stateOverride?: Record<string, unknown>;
148149

149150
theme?: Partial<GridThemeType>;
151+
152+
// Whether to render an invisible accessibility layer for e2e testing and screen readers
153+
enableAccessibilityLayer?: boolean;
150154
};
151155

152156
export type GridState = {
@@ -2298,6 +2302,26 @@ class Grid extends PureComponent<GridProps, GridState> {
22982302
);
22992303
}
23002304

2305+
/**
2306+
* Renders the accessibility layer for e2e testing and screen readers
2307+
* @returns The accessibility layer or null if disabled
2308+
*/
2309+
renderAccessibilityLayer(): ReactNode {
2310+
const { enableAccessibilityLayer, model } = this.props;
2311+
const { metrics } = this;
2312+
2313+
if (!enableAccessibilityLayer) {
2314+
return null;
2315+
}
2316+
2317+
return (
2318+
<GridAccessibilityLayer
2319+
metrics={metrics}
2320+
model={model}
2321+
/>
2322+
);
2323+
}
2324+
23012325
/**
23022326
* Gets the render state
23032327
* @returns The render state
@@ -2382,6 +2406,7 @@ class Grid extends PureComponent<GridProps, GridState> {
23822406
Your browser does not support HTML canvas. Update your browser?
23832407
</canvas>
23842408
{this.renderInputField()}
2409+
{this.renderAccessibilityLayer()}
23852410
{children}
23862411
</div>
23872412
);
Lines changed: 268 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,268 @@
1+
import React from 'react';
2+
import { render, screen } from '@testing-library/react';
3+
import GridAccessibilityLayer, {
4+
type GridAccessibilityLayerProps,
5+
} from './GridAccessibilityLayer';
6+
import type GridMetrics from './GridMetrics';
7+
import MockGridModel from './MockGridModel';
8+
9+
function makeMockMetrics(
10+
overrides: Partial<GridMetrics> = {}
11+
): GridMetrics | null {
12+
const allColumns = [0, 1, 2];
13+
const allRows = [0, 1, 2];
14+
15+
return {
16+
gridX: 30,
17+
gridY: 20,
18+
allColumns,
19+
allRows,
20+
visibleColumns: allColumns,
21+
visibleRows: allRows,
22+
floatingColumns: [],
23+
floatingRows: [],
24+
allColumnXs: new Map([
25+
[0, 0],
26+
[1, 100],
27+
[2, 200],
28+
]),
29+
allRowYs: new Map([
30+
[0, 0],
31+
[1, 20],
32+
[2, 40],
33+
]),
34+
allColumnWidths: new Map([
35+
[0, 100],
36+
[1, 100],
37+
[2, 100],
38+
]),
39+
allRowHeights: new Map([
40+
[0, 20],
41+
[1, 20],
42+
[2, 20],
43+
]),
44+
modelColumns: new Map([
45+
[0, 0],
46+
[1, 1],
47+
[2, 2],
48+
]),
49+
modelRows: new Map([
50+
[0, 0],
51+
[1, 1],
52+
[2, 2],
53+
]),
54+
rowHeaderWidth: 30,
55+
columnHeaderHeight: 20,
56+
rowHeight: 20,
57+
columnWidth: 100,
58+
rowCount: 3,
59+
columnCount: 3,
60+
rowFooterWidth: 0,
61+
floatingTopRowCount: 0,
62+
floatingBottomRowCount: 0,
63+
floatingLeftColumnCount: 0,
64+
floatingRightColumnCount: 0,
65+
firstRow: 0,
66+
firstColumn: 0,
67+
treePaddingX: 0,
68+
treePaddingY: 0,
69+
left: 0,
70+
top: 0,
71+
bottom: 2,
72+
right: 2,
73+
topOffset: 0,
74+
leftOffset: 0,
75+
topVisible: 0,
76+
leftVisible: 0,
77+
bottomVisible: 2,
78+
rightVisible: 2,
79+
bottomViewport: 2,
80+
rightViewport: 2,
81+
width: 500,
82+
height: 500,
83+
maxX: 300,
84+
maxY: 60,
85+
lastLeft: 0,
86+
lastTop: 0,
87+
barHeight: 0,
88+
barTop: 0,
89+
barWidth: 0,
90+
barLeft: 0,
91+
handleHeight: 0,
92+
handleWidth: 0,
93+
hasHorizontalBar: false,
94+
hasVerticalBar: false,
95+
verticalBarWidth: 0,
96+
horizontalBarHeight: 0,
97+
scrollX: 0,
98+
scrollY: 0,
99+
scrollableContentWidth: 300,
100+
scrollableContentHeight: 60,
101+
scrollableViewportWidth: 500,
102+
scrollableViewportHeight: 500,
103+
visibleRowHeights: new Map([
104+
[0, 20],
105+
[1, 20],
106+
[2, 20],
107+
]),
108+
visibleColumnWidths: new Map([
109+
[0, 100],
110+
[1, 100],
111+
[2, 100],
112+
]),
113+
floatingTopHeight: 0,
114+
floatingBottomHeight: 0,
115+
floatingLeftWidth: 0,
116+
floatingRightWidth: 0,
117+
visibleRowYs: new Map([
118+
[0, 0],
119+
[1, 20],
120+
[2, 40],
121+
]),
122+
visibleColumnXs: new Map([
123+
[0, 0],
124+
[1, 100],
125+
[2, 200],
126+
]),
127+
visibleRowTreeBoxes: new Map(),
128+
movedRows: [],
129+
movedColumns: [],
130+
fontWidthsLower: new Map(),
131+
fontWidthsUpper: new Map(),
132+
userColumnWidths: new Map(),
133+
userRowHeights: new Map(),
134+
calculatedRowHeights: new Map(),
135+
calculatedColumnWidths: new Map(),
136+
contentColumnWidths: new Map(),
137+
contentRowHeights: new Map(),
138+
columnHeaderMaxDepth: 1,
139+
...overrides,
140+
};
141+
}
142+
143+
function renderAccessibilityLayer(
144+
propsOverrides: Partial<GridAccessibilityLayerProps> = {}
145+
): ReturnType<typeof render> {
146+
const model = new MockGridModel({ rowCount: 3, columnCount: 3 });
147+
const metrics = makeMockMetrics();
148+
149+
return render(
150+
<GridAccessibilityLayer
151+
metrics={metrics}
152+
model={model}
153+
{...propsOverrides}
154+
/>
155+
);
156+
}
157+
158+
describe('GridAccessibilityLayer', () => {
159+
it('renders nothing when metrics is null', () => {
160+
const { container } = renderAccessibilityLayer({ metrics: null });
161+
expect(container.firstChild).toBeNull();
162+
});
163+
164+
it('renders the accessibility layer container with grid role', () => {
165+
renderAccessibilityLayer();
166+
const layer = screen.getByTestId('grid-accessibility-layer');
167+
expect(layer).toBeInTheDocument();
168+
expect(layer).toHaveAttribute('role', 'grid');
169+
});
170+
171+
it('renders data cells with correct data-testid attributes', () => {
172+
renderAccessibilityLayer();
173+
174+
// Check that cells exist for the 3x3 grid
175+
expect(screen.getByTestId('grid-cell-0-0')).toBeInTheDocument();
176+
expect(screen.getByTestId('grid-cell-1-1')).toBeInTheDocument();
177+
expect(screen.getByTestId('grid-cell-2-2')).toBeInTheDocument();
178+
});
179+
180+
it('renders data cells with gridcell role and aria attributes', () => {
181+
renderAccessibilityLayer();
182+
183+
const cell = screen.getByTestId('grid-cell-0-0');
184+
expect(cell).toHaveAttribute('role', 'gridcell');
185+
expect(cell).toHaveAttribute('aria-colindex', '1');
186+
expect(cell).toHaveAttribute('aria-rowindex', '1');
187+
});
188+
189+
it('renders column headers with correct data-testid attributes', () => {
190+
renderAccessibilityLayer();
191+
192+
expect(screen.getByTestId('grid-column-header-0-0')).toBeInTheDocument();
193+
expect(screen.getByTestId('grid-column-header-1-0')).toBeInTheDocument();
194+
expect(screen.getByTestId('grid-column-header-2-0')).toBeInTheDocument();
195+
});
196+
197+
it('renders column headers with columnheader role', () => {
198+
renderAccessibilityLayer();
199+
200+
const header = screen.getByTestId('grid-column-header-0-0');
201+
expect(header).toHaveAttribute('role', 'columnheader');
202+
expect(header).toHaveAttribute('aria-colindex', '1');
203+
});
204+
205+
it('renders row headers when rowHeaderWidth is greater than 0', () => {
206+
renderAccessibilityLayer();
207+
208+
expect(screen.getByTestId('grid-row-header-0')).toBeInTheDocument();
209+
expect(screen.getByTestId('grid-row-header-1')).toBeInTheDocument();
210+
expect(screen.getByTestId('grid-row-header-2')).toBeInTheDocument();
211+
});
212+
213+
it('does not render row headers when rowHeaderWidth is 0', () => {
214+
const metrics = makeMockMetrics({ rowHeaderWidth: 0 });
215+
renderAccessibilityLayer({ metrics });
216+
217+
expect(screen.queryByTestId('grid-row-header-0')).not.toBeInTheDocument();
218+
});
219+
220+
it('renders row headers with rowheader role', () => {
221+
renderAccessibilityLayer();
222+
223+
const header = screen.getByTestId('grid-row-header-0');
224+
expect(header).toHaveAttribute('role', 'rowheader');
225+
expect(header).toHaveAttribute('aria-rowindex', '1');
226+
});
227+
228+
it('cells contain text from model.textForCell', () => {
229+
renderAccessibilityLayer();
230+
231+
// MockGridModel returns text like "0,0" for cell at column 0, row 0
232+
const cell = screen.getByTestId('grid-cell-0-0');
233+
expect(cell.textContent).toBe('0,0');
234+
235+
const cell11 = screen.getByTestId('grid-cell-1-1');
236+
expect(cell11.textContent).toBe('1,1');
237+
});
238+
239+
it('cells are positioned correctly using metrics', () => {
240+
renderAccessibilityLayer();
241+
242+
const cell = screen.getByTestId('grid-cell-0-0');
243+
// gridX = 30, allColumnXs[0] = 0, so left = 30
244+
// gridY = 20, allRowYs[0] = 0, so top = 20
245+
expect(cell).toHaveStyle({
246+
position: 'absolute',
247+
left: '30px',
248+
top: '20px',
249+
width: '100px',
250+
height: '20px',
251+
});
252+
});
253+
254+
it('cells have pointer-events: none to allow clicks through to canvas', () => {
255+
renderAccessibilityLayer();
256+
257+
const cell = screen.getByTestId('grid-cell-0-0');
258+
expect(cell).toHaveStyle({ pointerEvents: 'none' });
259+
});
260+
261+
it('includes aria-rowcount and aria-colcount on the container', () => {
262+
renderAccessibilityLayer();
263+
264+
const layer = screen.getByTestId('grid-accessibility-layer');
265+
expect(layer).toHaveAttribute('aria-rowcount', '3');
266+
expect(layer).toHaveAttribute('aria-colcount', '3');
267+
});
268+
});

0 commit comments

Comments
 (0)