Skip to content

Commit cad1616

Browse files
Artur-claude
andcommitted
fix: improve keyboard navigation for grid header and footer slots
- Move focusexit element before footer slot to ensure correct Tab order - Fix Tab navigation from grid cells to footer slot content - Update accessibility tests to properly handle grid Tab behavior - Update keyboard navigation test expectations for new DOM structure The focusexit element now properly manages Tab navigation flow: Header slot → Grid cells → Footer slot → Elements after grid 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <[email protected]>
1 parent 89624e5 commit cad1616

File tree

3 files changed

+148
-33
lines changed

3 files changed

+148
-33
lines changed

packages/grid/src/vaadin-grid.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -312,6 +312,8 @@ class Grid extends GridMixin(ElementMixin(ThemableMixin(PolylitMixin(LumoInjecti
312312
<tfoot id="footer" role="rowgroup"></tfoot>
313313
</table>
314314
315+
<div id="focusexit" tabindex="0"></div>
316+
315317
<div part="footer" id="gridFooter">
316318
<slot name="footer"></slot>
317319
</div>
@@ -320,8 +322,6 @@ class Grid extends GridMixin(ElementMixin(ThemableMixin(PolylitMixin(LumoInjecti
320322
</div>
321323
322324
<slot name="tooltip"></slot>
323-
324-
<div id="focusexit" tabindex="0"></div>
325325
`;
326326
}
327327
}

packages/grid/test/accessibility-slots.test.js

Lines changed: 142 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { expect } from '@vaadin/chai-plugins';
2+
import { sendKeys } from '@vaadin/test-runner-commands';
23
import { fixtureSync, nextFrame } from '@vaadin/testing-helpers';
34
import '../src/vaadin-grid.js';
45
import '../src/vaadin-grid-column.js';
@@ -65,25 +66,96 @@ describe('accessibility - header and footer slots', () => {
6566
});
6667

6768
describe('keyboard navigation', () => {
68-
it('should allow keyboard navigation to header slot content', () => {
69+
it('should allow Tab navigation from header to grid', async () => {
6970
const addButton = grid.querySelector('#addBtn');
71+
const searchInput = grid.querySelector('#searchInput');
72+
73+
// Focus the first button in header
7074
addButton.focus();
7175
expect(document.activeElement).to.equal(addButton);
76+
77+
// Tab to search input
78+
await sendKeys({ press: 'Tab' });
79+
expect(document.activeElement).to.equal(searchInput);
80+
81+
// Tab to grid - should focus a header cell
82+
await sendKeys({ press: 'Tab' });
83+
const activeElement = grid.shadowRoot.activeElement;
84+
expect(activeElement).to.exist;
85+
expect(activeElement.getAttribute('role')).to.equal('columnheader');
86+
});
87+
88+
it('should allow Tab navigation from grid to footer', async () => {
89+
// Focus the search input in header first
90+
const searchInput = grid.querySelector('#searchInput');
91+
searchInput.focus();
92+
expect(document.activeElement).to.equal(searchInput);
93+
94+
// Tab into grid
95+
await sendKeys({ press: 'Tab' });
96+
97+
// Focus should be in the grid (on a cell in shadow DOM)
98+
expect(document.activeElement).to.equal(grid);
99+
expect(grid.shadowRoot.activeElement).to.exist;
100+
expect(grid.shadowRoot.activeElement.getAttribute('role')).to.be.oneOf(['columnheader', 'gridcell']);
101+
102+
// Tab out of grid - Grid's internal Tab handling exits the grid
103+
// Note: The grid may handle Tab internally and exit on the first Tab press
104+
// or may require multiple Tab presses depending on its internal state
105+
let attempts = 0;
106+
const maxAttempts = 5;
107+
108+
while (document.activeElement !== grid.querySelector('#nextBtn') && attempts < maxAttempts) {
109+
await sendKeys({ press: 'Tab' });
110+
attempts += 1;
111+
}
112+
113+
// After exiting grid, focus should be on the footer button
114+
expect(document.activeElement.id).to.equal('nextBtn');
72115
});
73116

74-
it('should allow keyboard navigation to footer slot content', () => {
117+
it('should allow Shift+Tab navigation from footer back to grid', async () => {
75118
const nextButton = grid.querySelector('#nextBtn');
119+
120+
// Focus footer button
76121
nextButton.focus();
77122
expect(document.activeElement).to.equal(nextButton);
123+
124+
// Shift+Tab should go back to grid
125+
await sendKeys({ press: 'Shift+Tab' });
126+
127+
// Should be in the grid now
128+
const activeElement = grid.shadowRoot.activeElement;
129+
expect(activeElement).to.exist;
130+
expect(activeElement.getAttribute('role')).to.be.oneOf(['columnheader', 'gridcell']);
131+
});
132+
133+
it('should allow Shift+Tab navigation from grid to header', async () => {
134+
// Focus grid first
135+
const table = grid.shadowRoot.querySelector('#table');
136+
table.focus();
137+
138+
// Shift+Tab should go to header content
139+
await sendKeys({ press: 'Shift+Tab' });
140+
141+
// Should be in header (search input is last in header)
142+
expect(document.activeElement.id).to.equal('searchInput');
78143
});
79144

80-
it('should maintain grid table keyboard navigation', () => {
145+
it('should maintain grid table keyboard navigation', async () => {
81146
const table = grid.shadowRoot.querySelector('#table');
82147
table.focus();
148+
83149
// When the table is focused, the actual focus goes to a cell
84-
const activeElement = grid.shadowRoot.activeElement;
85-
expect(activeElement).to.exist;
86-
expect(activeElement.getAttribute('role')).to.be.oneOf(['columnheader', 'gridcell']);
150+
const initialActiveElement = grid.shadowRoot.activeElement;
151+
expect(initialActiveElement).to.exist;
152+
expect(initialActiveElement.getAttribute('role')).to.be.oneOf(['columnheader', 'gridcell']);
153+
154+
// Arrow keys should work within grid
155+
await sendKeys({ press: 'ArrowRight' });
156+
const afterArrowRight = grid.shadowRoot.activeElement;
157+
expect(afterArrowRight).to.exist;
158+
expect(afterArrowRight).to.not.equal(initialActiveElement);
87159
});
88160
});
89161

@@ -137,40 +209,84 @@ describe('accessibility - header and footer slots', () => {
137209
});
138210

139211
describe('focus management', () => {
140-
it('should not trap focus in header', () => {
212+
it('should not trap focus in header', async () => {
141213
const addButton = grid.querySelector('#addBtn');
142214
const searchInput = grid.querySelector('#searchInput');
143215

144216
addButton.focus();
145217
expect(document.activeElement).to.equal(addButton);
146218

147-
searchInput.focus();
219+
// Tab within header
220+
await sendKeys({ press: 'Tab' });
148221
expect(document.activeElement).to.equal(searchInput);
222+
223+
// Tab out of header to grid
224+
await sendKeys({ press: 'Tab' });
225+
expect(grid.shadowRoot.activeElement).to.exist;
226+
expect(grid.shadowRoot.activeElement.getAttribute('role')).to.equal('columnheader');
149227
});
150228

151-
it('should not trap focus in footer', () => {
229+
it('should not trap focus in footer', async () => {
152230
const nextButton = grid.querySelector('#nextBtn');
153231
nextButton.focus();
154232
expect(document.activeElement).to.equal(nextButton);
233+
234+
// Shift+Tab should go back to grid
235+
await sendKeys({ press: 'Shift+Tab' });
236+
expect(grid.shadowRoot.activeElement).to.exist;
237+
});
238+
239+
it('should maintain proper Tab focus order through entire grid', async () => {
240+
const addButton = grid.querySelector('#addBtn');
241+
242+
// Start from header
243+
addButton.focus();
244+
expect(document.activeElement).to.equal(addButton);
245+
246+
// Tab through header
247+
await sendKeys({ press: 'Tab' });
248+
expect(document.activeElement.id).to.equal('searchInput');
249+
250+
// Tab into grid
251+
await sendKeys({ press: 'Tab' });
252+
expect(grid.shadowRoot.activeElement).to.exist;
253+
expect(grid.shadowRoot.activeElement.getAttribute('role')).to.equal('columnheader');
254+
255+
// Tab out of grid - Grid handles Tab navigation and exits to footer
256+
let attempts = 0;
257+
const maxAttempts = 5;
258+
259+
while (document.activeElement !== grid.querySelector('#nextBtn') && attempts < maxAttempts) {
260+
await sendKeys({ press: 'Tab' });
261+
attempts += 1;
262+
}
263+
264+
// Should reach footer
265+
expect(document.activeElement.id).to.equal('nextBtn');
155266
});
156267

157-
it('should maintain proper focus order', () => {
158-
// Focus should flow naturally through header -> grid -> footer
159-
const focusableElements = [
160-
grid.querySelector('#addBtn'),
161-
grid.querySelector('#searchInput'),
162-
grid.shadowRoot.querySelector('#table'),
163-
grid.querySelector('#nextBtn'),
164-
];
165-
166-
// Verify all elements are focusable
167-
focusableElements.forEach((el) => {
168-
if (el === grid.shadowRoot.querySelector('#table')) {
169-
expect(el.getAttribute('tabindex')).to.equal('0');
170-
} else {
171-
expect(el.tabIndex).to.be.at.least(0);
172-
}
173-
});
268+
it('should support reverse Tab navigation', async () => {
269+
const nextButton = grid.querySelector('#nextBtn');
270+
271+
// Start from footer
272+
nextButton.focus();
273+
expect(document.activeElement).to.equal(nextButton);
274+
275+
// Shift+Tab back through grid to header
276+
let shiftTabCount = 0;
277+
const maxShiftTabs = 15;
278+
279+
while (document.activeElement.id !== 'searchInput' && shiftTabCount < maxShiftTabs) {
280+
await sendKeys({ press: 'Shift+Tab' });
281+
shiftTabCount += 1;
282+
}
283+
284+
// Should reach header search input
285+
expect(document.activeElement.id).to.equal('searchInput');
286+
287+
// One more Shift+Tab to reach add button
288+
await sendKeys({ press: 'Shift+Tab' });
289+
expect(document.activeElement.id).to.equal('addBtn');
174290
});
175291
});
176292
});

packages/grid/test/keyboard-navigation.test.js

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -383,13 +383,12 @@ describe('keyboard navigation', () => {
383383
expect(tabIndexes).to.eql([0, 0, 0, 0, 0]);
384384
});
385385

386-
it('should have a focus exit as the very last child', () => {
386+
it('should have a focus exit element with tabindex', () => {
387387
expect(grid.$.focusexit).to.be.ok;
388388
expect(grid.$.focusexit.tabIndex).to.equal(0);
389-
const lastChild = Array.from(grid.shadowRoot.children)
390-
.filter((child) => child.localName !== 'style')
391-
.pop();
392-
expect(lastChild).to.equal(grid.$.focusexit);
389+
// Focus exit is positioned between the table and footer slot for proper tab navigation
390+
const focusExitParent = grid.$.focusexit.parentElement;
391+
expect(focusExitParent).to.equal(grid.$.scroller);
393392
});
394393

395394
it('should be possible to tab through the grid', () => {

0 commit comments

Comments
 (0)