Skip to content

Commit ddcd66a

Browse files
authored
feat(table): add basic keyboard nav when table has row-clicked handler or is selctable (closes #2869) (#2870)
1 parent da49558 commit ddcd66a

File tree

4 files changed

+144
-184
lines changed

4 files changed

+144
-184
lines changed

src/components/table/README.md

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1828,12 +1828,22 @@ When `<b-table>` is mounted in the document, it will automatically trigger a pro
18281828

18291829
## Table accessibility notes
18301830

1831+
When a column (field) is sortable, the header (and footer) heading cells will also be placed into
1832+
the document tab sequence for accessibility.
1833+
18311834
When the table is in `selectable` mode, or if there is a `row-clicked` event listener registered,
18321835
all data item rows (`<tr>` elements) will be placed into the document tab sequence (via
18331836
`tabindex="0"`) to allow keyboard-only and screen reader users the ability to click the rows.
18341837

1835-
When a column (field) is sortable, the header (and footer) heading cells will also be placed into
1836-
the document tab sequence for accessibility.
1838+
When the table items rows are in the tabl sequence, they will also support basic keyboard navigation
1839+
when focused:
1840+
1841+
- <kbd>DOWN</kbd> will move to the next row
1842+
- <kbd>UP</kbd> will move to the previous row
1843+
- <kbd>END</kbd> or <kbd>DOWN</kbd>+<kbd>SHIFT</kbd> will move to the last row
1844+
- <kbd>HOME</kbd> or <kbd>UP</kbd>+<kbd>SHIFT</kbd> will move to the first row
1845+
- <kbd>ENTER</kbd> or <kbd>SPACE</kbd> to click the row. <kbd>SHIFT</kbd> and <kbd>CTRL</kbd>
1846+
modifiers will also work (depending on the table selectable mode).
18371847

18381848
Note the following row based events/actions are not considered accessible, and should only be used
18391849
if the functionality is non critical or can be provided via other means:

src/components/table/helpers/mixin-tbody-row.js

Lines changed: 62 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import toString from '../../../utils/to-string'
22
import get from '../../../utils/get'
33
import KeyCodes from '../../../utils/key-codes'
4+
import { arrayIncludes } from '../../../utils/array'
45
import filterEvent from './filter-event'
56
import textSelectionActive from './text-selection-active'
67

@@ -74,6 +75,52 @@ export default {
7475
}
7576
return value === null || typeof value === 'undefined' ? '' : value
7677
},
78+
tbodyRowKeydown(evt, item, rowIndex) {
79+
const keyCode = evt.keyCode
80+
const target = evt.target
81+
const trs = this.$refs.itemRows
82+
if (this.stopIfBusy(evt)) {
83+
// If table is busy (via provider) then don't propagate
84+
return
85+
} else if (!(target && target.tagName === 'TR' && target === document.activeElement)) {
86+
// Ignore if not the active tr element
87+
return
88+
} else if (target.tabIndex !== 0) {
89+
// Ignore if not focusable
90+
/* istanbul ignore next */
91+
return
92+
} else if (trs && trs.length === 0) {
93+
/* istanbul ignore next */
94+
return
95+
}
96+
const index = trs.indexOf(target)
97+
if (keyCode === KeyCodes.ENTER || keyCode === KeyCodes.SPACE) {
98+
evt.stopPropagation()
99+
evt.preventDefault()
100+
// We also allow enter/space to trigger a click (when row is focused)
101+
// We translate to a row-clicked event
102+
this.rowClicked(evt, item, rowIndex)
103+
} else if (
104+
arrayIncludes([KeyCodes.UP, KeyCodes.DOWN, KeyCodes.HOME, KeyCodes.END], keyCode)
105+
) {
106+
evt.stopPropagation()
107+
evt.preventDefault()
108+
const shift = evt.shiftKey
109+
if (keyCode === KeyCodes.HOME || (shift && keyCode === KeyCodes.UP)) {
110+
// Focus first row
111+
trs[0].focus()
112+
} else if (keyCode === KeyCodes.END || (shift && keyCode === KeyCodes.DOWN)) {
113+
// Focus last row
114+
trs[trs.length - 1].focus()
115+
} else if (keyCode === KeyCodes.UP && index > 0) {
116+
// Focus previous row
117+
trs[index - 1].focus()
118+
} else if (keyCode === KeyCodes.DOWN && index < trs.length - 1) {
119+
// Focus next row
120+
trs[index + 1].focus()
121+
}
122+
}
123+
},
77124
// Row event handlers
78125
rowClicked(e, item, index) {
79126
if (this.stopIfBusy(e)) {
@@ -87,11 +134,6 @@ export default {
87134
/* istanbul ignore next: JSDOM doesn't support getSelection() */
88135
return
89136
}
90-
if (e.type === 'keydown') {
91-
// If the click was generated by space or enter, stop page scroll
92-
e.stopPropagation()
93-
e.preventDefault()
94-
}
95137
this.$emit('row-clicked', item, index, e)
96138
},
97139
middleMouseRowClicked(e, item, index) {
@@ -235,12 +277,24 @@ export default {
235277
? this.safeId(`_row_${item[primaryKey]}`)
236278
: null
237279

280+
const handlers = {}
281+
if (hasRowClickHandler) {
282+
handlers['click'] = evt => {
283+
this.rowClicked(evt, item, rowIndex)
284+
}
285+
handlers['keydown'] = evt => {
286+
this.tbodyRowKeydown(evt, item, rowIndex)
287+
}
288+
}
289+
238290
// Add the item row
239291
$rows.push(
240292
h(
241293
'tr',
242294
{
243295
key: `__b-table-row-${rowKey}__`,
296+
ref: 'itemRows',
297+
refInFor: true,
244298
class: [
245299
this.rowClasses(item),
246300
this.selectableRowClasses(rowIndex),
@@ -259,28 +313,14 @@ export default {
259313
...this.selectableRowAttrs(rowIndex)
260314
},
261315
on: {
262-
// TODO: only instatiate handlers if we have registered listeners (except row-clicked)
316+
...handlers,
317+
// TODO: instatiate the following handlers only if we have registered
318+
// listeners i.e. this.$listeners['row-middle-clicked'], etc.
263319
auxclick: evt => {
264320
if (evt.which === 2) {
265321
this.middleMouseRowClicked(evt, item, rowIndex)
266322
}
267323
},
268-
click: evt => {
269-
this.rowClicked(evt, item, rowIndex)
270-
},
271-
keydown: evt => {
272-
// We also allow enter/space to trigger a click (when row is focused)
273-
const keyCode = evt.keyCode
274-
if (keyCode === KeyCodes.ENTER || keyCode === KeyCodes.SPACE) {
275-
if (
276-
evt.target &&
277-
evt.target.tagName === 'TR' &&
278-
evt.target === document.activeElement
279-
) {
280-
this.rowClicked(evt, item, rowIndex)
281-
}
282-
}
283-
},
284324
contextmenu: evt => {
285325
this.rowContextmenu(evt, item, rowIndex)
286326
},

src/components/table/table-tbody-row-events.spec.js

Lines changed: 70 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,10 @@ describe('table tbody row events', () => {
1010
propsData: {
1111
fields: testFields,
1212
items: testItems
13+
},
14+
listeners: {
15+
// Row Clicked will only occur if there is a registered listener
16+
'row-clicked': () => {}
1317
}
1418
})
1519
expect(wrapper).toBeDefined()
@@ -32,6 +36,10 @@ describe('table tbody row events', () => {
3236
fields: testFields,
3337
items: testItems,
3438
busy: true
39+
},
40+
listeners: {
41+
// Row Clicked will only occur if there is a registered listener
42+
'row-clicked': () => {}
3543
}
3644
})
3745
expect(wrapper).toBeDefined()
@@ -50,6 +58,10 @@ describe('table tbody row events', () => {
5058
propsData: {
5159
fields: testFields,
5260
items: testItems
61+
},
62+
listeners: {
63+
// Row Clicked will only occur if there is a registered listener
64+
'row-clicked': () => {}
5365
}
5466
})
5567
expect(wrapper).toBeDefined()
@@ -283,7 +295,7 @@ describe('table tbody row events', () => {
283295
expect(wrapper.emitted('row-clicked').length).toBe(1)
284296
expect(wrapper.emitted('row-clicked')[0][0]).toEqual(testItems[1]) /* row item */
285297
expect(wrapper.emitted('row-clicked')[0][1]).toEqual(1) /* row index */
286-
// Note: the KeyboardEvent is forwarded to the click handler
298+
// Note: the KeyboardEvent is passed to the row-clicked handler
287299
expect(wrapper.emitted('row-clicked')[0][2]).toBeInstanceOf(KeyboardEvent) /* event */
288300

289301
wrapper.destroy()
@@ -295,6 +307,10 @@ describe('table tbody row events', () => {
295307
fields: testFields,
296308
items: testItems,
297309
busy: true
310+
},
311+
listeners: {
312+
// Row Clicked will only occur if there is a registered listener
313+
'row-clicked': () => {}
298314
}
299315
})
300316
expect(wrapper).toBeDefined()
@@ -323,6 +339,10 @@ describe('table tbody row events', () => {
323339
c: '<a href="#" id="c">link</a>',
324340
d: '<div class="dropdown-menu"><div id="d" class="dropdown-item">dropdown</div></div>',
325341
e: '<label for="e">label</label><input id="e" />'
342+
},
343+
listeners: {
344+
// Row Clicked will only occur if there is a registered listener
345+
'row-clicked': () => {}
326346
}
327347
})
328348
expect(wrapper).toBeDefined()
@@ -358,4 +378,53 @@ describe('table tbody row events', () => {
358378

359379
wrapper.destroy()
360380
})
381+
382+
it('keyboard events moves focus to apropriate rows', async () => {
383+
const wrapper = mount(Table, {
384+
propsData: {
385+
fields: testFields,
386+
items: testItems
387+
},
388+
listeners: {
389+
// Tabindex will only be set if htere is a row-clicked listener
390+
'row-clicked': () => {}
391+
}
392+
})
393+
expect(wrapper).toBeDefined()
394+
const $rows = wrapper.findAll('tbody > tr')
395+
expect($rows.length).toBe(3)
396+
expect(document.activeElement).not.toBe($rows.at(0).element)
397+
expect(document.activeElement).not.toBe($rows.at(1).element)
398+
expect(document.activeElement).not.toBe($rows.at(2).element)
399+
400+
$rows.at(0).element.focus()
401+
expect(document.activeElement).toBe($rows.at(0).element)
402+
403+
$rows.at(0).trigger('keydown.end')
404+
expect(document.activeElement).toBe($rows.at(2).element)
405+
406+
$rows.at(2).trigger('keydown.home')
407+
expect(document.activeElement).toBe($rows.at(0).element)
408+
409+
$rows.at(0).trigger('keydown.down')
410+
expect(document.activeElement).toBe($rows.at(1).element)
411+
412+
$rows.at(1).trigger('keydown.up')
413+
expect(document.activeElement).toBe($rows.at(0).element)
414+
415+
$rows.at(0).trigger('keydown.down', { shiftKey: true })
416+
expect(document.activeElement).toBe($rows.at(2).element)
417+
418+
$rows.at(2).trigger('keydown.up', { shiftKey: true })
419+
expect(document.activeElement).toBe($rows.at(0).element)
420+
421+
// SHould only move focus if TR was target
422+
$rows
423+
.at(0)
424+
.find('td')
425+
.trigger('keydown.down')
426+
expect(document.activeElement).toBe($rows.at(0).element)
427+
428+
wrapper.destroy()
429+
})
361430
})

0 commit comments

Comments
 (0)