|
1 | 1 | import { expect } from '@vaadin/chai-plugins';
|
| 2 | +import { sendKeys } from '@vaadin/test-runner-commands'; |
2 | 3 | import { fixtureSync, nextFrame } from '@vaadin/testing-helpers';
|
3 | 4 | import '../src/vaadin-grid.js';
|
4 | 5 | import '../src/vaadin-grid-column.js';
|
@@ -65,25 +66,96 @@ describe('accessibility - header and footer slots', () => {
|
65 | 66 | });
|
66 | 67 |
|
67 | 68 | describe('keyboard navigation', () => {
|
68 |
| - it('should allow keyboard navigation to header slot content', () => { |
| 69 | + it('should allow Tab navigation from header to grid', async () => { |
69 | 70 | const addButton = grid.querySelector('#addBtn');
|
| 71 | + const searchInput = grid.querySelector('#searchInput'); |
| 72 | + |
| 73 | + // Focus the first button in header |
70 | 74 | addButton.focus();
|
71 | 75 | 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'); |
72 | 115 | });
|
73 | 116 |
|
74 |
| - it('should allow keyboard navigation to footer slot content', () => { |
| 117 | + it('should allow Shift+Tab navigation from footer back to grid', async () => { |
75 | 118 | const nextButton = grid.querySelector('#nextBtn');
|
| 119 | + |
| 120 | + // Focus footer button |
76 | 121 | nextButton.focus();
|
77 | 122 | 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'); |
78 | 143 | });
|
79 | 144 |
|
80 |
| - it('should maintain grid table keyboard navigation', () => { |
| 145 | + it('should maintain grid table keyboard navigation', async () => { |
81 | 146 | const table = grid.shadowRoot.querySelector('#table');
|
82 | 147 | table.focus();
|
| 148 | + |
83 | 149 | // 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); |
87 | 159 | });
|
88 | 160 | });
|
89 | 161 |
|
@@ -137,40 +209,84 @@ describe('accessibility - header and footer slots', () => {
|
137 | 209 | });
|
138 | 210 |
|
139 | 211 | describe('focus management', () => {
|
140 |
| - it('should not trap focus in header', () => { |
| 212 | + it('should not trap focus in header', async () => { |
141 | 213 | const addButton = grid.querySelector('#addBtn');
|
142 | 214 | const searchInput = grid.querySelector('#searchInput');
|
143 | 215 |
|
144 | 216 | addButton.focus();
|
145 | 217 | expect(document.activeElement).to.equal(addButton);
|
146 | 218 |
|
147 |
| - searchInput.focus(); |
| 219 | + // Tab within header |
| 220 | + await sendKeys({ press: 'Tab' }); |
148 | 221 | 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'); |
149 | 227 | });
|
150 | 228 |
|
151 |
| - it('should not trap focus in footer', () => { |
| 229 | + it('should not trap focus in footer', async () => { |
152 | 230 | const nextButton = grid.querySelector('#nextBtn');
|
153 | 231 | nextButton.focus();
|
154 | 232 | 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'); |
155 | 266 | });
|
156 | 267 |
|
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'); |
174 | 290 | });
|
175 | 291 | });
|
176 | 292 | });
|
0 commit comments