Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(table): add basic keyboard nav when table has row-clicked handler or is selctable (closes #2869) #2870

Merged
merged 19 commits into from
Mar 19, 2019
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
14 changes: 12 additions & 2 deletions src/components/table/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1828,12 +1828,22 @@ When `<b-table>` is mounted in the document, it will automatically trigger a pro

## Table accessibility notes

When a column (field) is sortable, the header (and footer) heading cells will also be placed into
the document tab sequence for accessibility.

When the table is in `selectable` mode, or if there is a `row-clicked` event listener registered,
all data item rows (`<tr>` elements) will be placed into the document tab sequence (via
`tabindex="0"`) to allow keyboard-only and screen reader users the ability to click the rows.

When a column (field) is sortable, the header (and footer) heading cells will also be placed into
the document tab sequence for accessibility.
When the table items rows are in the tabl sequence, they will also support basic keyboard navigation
when focused:

- <kbd>DOWN</kbd> will move to the next row
- <kbd>UP</kbd> will move to the previous row
- <kbd>END</kbd> or <kbd>DOWN</kbd>+<kbd>SHIFT</kbd> will move to the last row
- <kbd>HOME</kbd> or <kbd>UP</kbd>+<kbd>SHIFT</kbd> will move to the first row
- <kbd>ENTER</kbd> or <kbd>SPACE</kbd> to click the row. <kbd>SHIFT</kbd> and <kbd>CTRL</kbd>
modifiers will also work (depending on the table selectable mode).

Note the following row based events/actions are not considered accessible, and should only be used
if the functionality is non critical or can be provided via other means:
Expand Down
84 changes: 62 additions & 22 deletions src/components/table/helpers/mixin-tbody-row.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import toString from '../../../utils/to-string'
import get from '../../../utils/get'
import KeyCodes from '../../../utils/key-codes'
import { arrayIncludes } from '../../../utils/array'
import filterEvent from './filter-event'
import textSelectionActive from './text-selection-active'

Expand Down Expand Up @@ -74,6 +75,52 @@ export default {
}
return value === null || typeof value === 'undefined' ? '' : value
},
tbodyRowKeydown(evt, item, rowIndex) {
const keyCode = evt.keyCode
const target = evt.target
const trs = this.$refs.itemRows
if (this.stopIfBusy(evt)) {
// If table is busy (via provider) then don't propagate
return
} else if (!(target && target.tagName === 'TR' && target === document.activeElement)) {
// Ignore if not the active tr element
return
} else if (target.tabIndex !== 0) {
// Ignore if not focusable
/* istanbul ignore next */
return
} else if (trs && trs.length === 0) {
/* istanbul ignore next */
return
}
const index = trs.indexOf(target)
if (keyCode === KeyCodes.ENTER || keyCode === KeyCodes.SPACE) {
evt.stopPropagation()
evt.preventDefault()
// We also allow enter/space to trigger a click (when row is focused)
// We translate to a row-clicked event
this.rowClicked(evt, item, rowIndex)
} else if (
arrayIncludes([KeyCodes.UP, KeyCodes.DOWN, KeyCodes.HOME, KeyCodes.END], keyCode)
) {
evt.stopPropagation()
evt.preventDefault()
const shift = evt.shiftKey
if (keyCode === KeyCodes.HOME || (shift && keyCode === KeyCodes.UP)) {
// Focus first row
trs[0].focus()
} else if (keyCode === KeyCodes.END || (shift && keyCode === KeyCodes.DOWN)) {
// Focus last row
trs[trs.length - 1].focus()
} else if (keyCode === KeyCodes.UP && index > 0) {
// Focus previous row
trs[index - 1].focus()
} else if (keyCode === KeyCodes.DOWN && index < trs.length - 1) {
// Focus next row
trs[index + 1].focus()
}
}
},
// Row event handlers
rowClicked(e, item, index) {
if (this.stopIfBusy(e)) {
Expand All @@ -87,11 +134,6 @@ export default {
/* istanbul ignore next: JSDOM doesn't support getSelection() */
return
}
if (e.type === 'keydown') {
// If the click was generated by space or enter, stop page scroll
e.stopPropagation()
e.preventDefault()
}
this.$emit('row-clicked', item, index, e)
},
middleMouseRowClicked(e, item, index) {
Expand Down Expand Up @@ -235,12 +277,24 @@ export default {
? this.safeId(`_row_${item[primaryKey]}`)
: null

const handlers = {}
if (hasRowClickHandler) {
handlers['click'] = evt => {
this.rowClicked(evt, item, rowIndex)
}
handlers['keydown'] = evt => {
this.tbodyRowKeydown(evt, item, rowIndex)
}
}

// Add the item row
$rows.push(
h(
'tr',
{
key: `__b-table-row-${rowKey}__`,
ref: 'itemRows',
refInFor: true,
class: [
this.rowClasses(item),
this.selectableRowClasses(rowIndex),
Expand All @@ -259,28 +313,14 @@ export default {
...this.selectableRowAttrs(rowIndex)
},
on: {
// TODO: only instatiate handlers if we have registered listeners (except row-clicked)
...handlers,
// TODO: instatiate the following handlers only if we have registered
// listeners i.e. this.$listeners['row-middle-clicked'], etc.
auxclick: evt => {
if (evt.which === 2) {
this.middleMouseRowClicked(evt, item, rowIndex)
}
},
click: evt => {
this.rowClicked(evt, item, rowIndex)
},
keydown: evt => {
// We also allow enter/space to trigger a click (when row is focused)
const keyCode = evt.keyCode
if (keyCode === KeyCodes.ENTER || keyCode === KeyCodes.SPACE) {
if (
evt.target &&
evt.target.tagName === 'TR' &&
evt.target === document.activeElement
) {
this.rowClicked(evt, item, rowIndex)
}
}
},
contextmenu: evt => {
this.rowContextmenu(evt, item, rowIndex)
},
Expand Down
71 changes: 70 additions & 1 deletion src/components/table/table-tbody-row-events.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@ describe('table tbody row events', () => {
propsData: {
fields: testFields,
items: testItems
},
listeners: {
// Row Clicked will only occur if there is a registered listener
'row-clicked': () => {}
}
})
expect(wrapper).toBeDefined()
Expand All @@ -32,6 +36,10 @@ describe('table tbody row events', () => {
fields: testFields,
items: testItems,
busy: true
},
listeners: {
// Row Clicked will only occur if there is a registered listener
'row-clicked': () => {}
}
})
expect(wrapper).toBeDefined()
Expand All @@ -50,6 +58,10 @@ describe('table tbody row events', () => {
propsData: {
fields: testFields,
items: testItems
},
listeners: {
// Row Clicked will only occur if there is a registered listener
'row-clicked': () => {}
}
})
expect(wrapper).toBeDefined()
Expand Down Expand Up @@ -283,7 +295,7 @@ describe('table tbody row events', () => {
expect(wrapper.emitted('row-clicked').length).toBe(1)
expect(wrapper.emitted('row-clicked')[0][0]).toEqual(testItems[1]) /* row item */
expect(wrapper.emitted('row-clicked')[0][1]).toEqual(1) /* row index */
// Note: the KeyboardEvent is forwarded to the click handler
// Note: the KeyboardEvent is passed to the row-clicked handler
expect(wrapper.emitted('row-clicked')[0][2]).toBeInstanceOf(KeyboardEvent) /* event */

wrapper.destroy()
Expand All @@ -295,6 +307,10 @@ describe('table tbody row events', () => {
fields: testFields,
items: testItems,
busy: true
},
listeners: {
// Row Clicked will only occur if there is a registered listener
'row-clicked': () => {}
}
})
expect(wrapper).toBeDefined()
Expand Down Expand Up @@ -323,6 +339,10 @@ describe('table tbody row events', () => {
c: '<a href="#" id="c">link</a>',
d: '<div class="dropdown-menu"><div id="d" class="dropdown-item">dropdown</div></div>',
e: '<label for="e">label</label><input id="e" />'
},
listeners: {
// Row Clicked will only occur if there is a registered listener
'row-clicked': () => {}
}
})
expect(wrapper).toBeDefined()
Expand Down Expand Up @@ -358,4 +378,53 @@ describe('table tbody row events', () => {

wrapper.destroy()
})

it('keyboard events moves focus to apropriate rows', async () => {
const wrapper = mount(Table, {
propsData: {
fields: testFields,
items: testItems
},
listeners: {
// Tabindex will only be set if htere is a row-clicked listener
'row-clicked': () => {}
}
})
expect(wrapper).toBeDefined()
const $rows = wrapper.findAll('tbody > tr')
expect($rows.length).toBe(3)
expect(document.activeElement).not.toBe($rows.at(0).element)
expect(document.activeElement).not.toBe($rows.at(1).element)
expect(document.activeElement).not.toBe($rows.at(2).element)

$rows.at(0).element.focus()
expect(document.activeElement).toBe($rows.at(0).element)

$rows.at(0).trigger('keydown.end')
expect(document.activeElement).toBe($rows.at(2).element)

$rows.at(2).trigger('keydown.home')
expect(document.activeElement).toBe($rows.at(0).element)

$rows.at(0).trigger('keydown.down')
expect(document.activeElement).toBe($rows.at(1).element)

$rows.at(1).trigger('keydown.up')
expect(document.activeElement).toBe($rows.at(0).element)

$rows.at(0).trigger('keydown.down', { shiftKey: true })
expect(document.activeElement).toBe($rows.at(2).element)

$rows.at(2).trigger('keydown.up', { shiftKey: true })
expect(document.activeElement).toBe($rows.at(0).element)

// SHould only move focus if TR was target
$rows
.at(0)
.find('td')
.trigger('keydown.down')
expect(document.activeElement).toBe($rows.at(0).element)

wrapper.destroy()
})
})