diff --git a/src/components/table/README.md b/src/components/table/README.md index 46f71fafcc9..47406c46df2 100644 --- a/src/components/table/README.md +++ b/src/components/table/README.md @@ -1828,12 +1828,22 @@ When `` 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 (`` 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: + +- DOWN will move to the next row +- UP will move to the previous row +- END or DOWN+SHIFT will move to the last row +- HOME or UP+SHIFT will move to the first row +- ENTER or SPACE to click the row. SHIFT and CTRL + 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: diff --git a/src/components/table/helpers/mixin-tbody-row.js b/src/components/table/helpers/mixin-tbody-row.js index 17b61256ffc..08db431058d 100644 --- a/src/components/table/helpers/mixin-tbody-row.js +++ b/src/components/table/helpers/mixin-tbody-row.js @@ -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' @@ -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)) { @@ -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) { @@ -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), @@ -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) }, diff --git a/src/components/table/table-tbody-row-events.spec.js b/src/components/table/table-tbody-row-events.spec.js index 44f8c6c61d3..4ea42025199 100644 --- a/src/components/table/table-tbody-row-events.spec.js +++ b/src/components/table/table-tbody-row-events.spec.js @@ -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() @@ -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() @@ -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() @@ -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() @@ -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() @@ -323,6 +339,10 @@ describe('table tbody row events', () => { c: 'link', d: '', e: '' + }, + listeners: { + // Row Clicked will only occur if there is a registered listener + 'row-clicked': () => {} } }) expect(wrapper).toBeDefined() @@ -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() + }) }) diff --git a/src/components/table/table.spec.js b/src/components/table/table.spec.js index 85c94171e04..ac4cca5f4f7 100644 --- a/src/components/table/table.spec.js +++ b/src/components/table/table.spec.js @@ -390,165 +390,6 @@ describe('table', () => { } }) - it('each data row should emit a row-clicked event when clicked', async () => { - const { - app: { $refs } - } = window - const vm = $refs.table_paginated - - const tbody = [...vm.$el.children].find(el => el && el.tagName === 'TBODY') - expect(tbody).toBeDefined() - if (tbody) { - const trs = [...tbody.children] - expect(trs.length).toBe(vm.perPage) - trs.forEach((tr, idx) => { - const spy = jest.fn() - vm.$on('row-clicked', spy) - tr.click() - vm.$off('row-clicked', spy) - expect(spy).toHaveBeenCalled() - }) - } - }) - - /* - * This test needs a polyfill for getSelection and createRange - * - it('row-clicked event should not happen when textSelection is active', async () => { - const { - app: { $refs } - } = window - const vm = $refs.table_paginated - - const tbody = [...vm.$el.children].find(el => el && el.tagName === 'TBODY') - expect(tbody).toBeDefined() - const trs = [...tbody.children] - expect(trs.length).toBe(vm.perPage) - - // Clear selection if any current - let selection = window.getSelection() - if (selection.rangeCount > 0) { - selection.removeAllRanges() - } - - const spy = jest.fn() - vm.$on('row-clicked', spy) - expect(spy).not.toHaveBeenCalled() - - // Select text in first TR - const range = document.createRange() - range.selectNode(trs[0]) - selection.addRange(range) - - // Click row - trs[0].click() - expect(spy).not.toHaveBeenCalled() - - // Clear selection - if (selection.rangeCount > 0) { - selection.removeAllRanges() - } - - // Click row - trs[0].click() - expect(spy).toHaveBeenCalled() - }) - */ - - it('each data row should emit a row-contextmenu event when right clicked', async () => { - const { - app: { $refs } - } = window - const vm = $refs.table_paginated - - const tbody = [...vm.$el.children].find(el => el && el.tagName === 'TBODY') - expect(tbody).toBeDefined() - if (tbody) { - const trs = [...tbody.children] - expect(trs.length).toBe(vm.perPage) - trs.forEach((tr, idx) => { - const spy = jest.fn() - vm.$on('row-contextmenu', spy) - tr.dispatchEvent(new MouseEvent('contextmenu', { button: 2 })) - vm.$off('row-contextmenu', spy) - expect(spy).toHaveBeenCalled() - }) - } - }) - - it('each data row should emit a row-middle-clicked event when middle clicked', async () => { - const { - app: { $refs } - } = window - const vm = $refs.table_paginated - - const tbody = [...vm.$el.children].find(el => el && el.tagName === 'TBODY') - expect(tbody).toBeDefined() - if (tbody) { - const trs = [...tbody.children] - expect(trs.length).toBe(vm.perPage) - trs.forEach((tr, idx) => { - const spy = jest.fn() - vm.$on('row-middle-clicked', spy) - tr.dispatchEvent(new MouseEvent('auxclick', { button: 1, which: 2 })) - vm.$off('row-middle-clicked', spy) - expect(spy).toHaveBeenCalled() - }) - } - }) - - it('each header th should emit a head-clicked event when clicked', async () => { - const { - app: { $refs } - } = window - const vm = $refs.table_paginated - const fieldKeys = Object.keys(vm.fields) - - const thead = [...vm.$el.children].find(el => el && el.tagName === 'THEAD') - expect(thead).toBeDefined() - if (thead) { - const tr = [...thead.children].find(el => el && el.tagName === 'TR') - expect(tr).toBeDefined() - if (tr) { - const ths = [...tr.children] - expect(ths.length).toBe(fieldKeys.length) - ths.forEach((th, idx) => { - const spy = jest.fn() - vm.$on('head-clicked', spy) - th.click() - vm.$off('head-clicked', spy) - expect(spy).toHaveBeenCalled() - }) - } - } - }) - - it('each footer th should emit a head-clicked event when clicked', async () => { - const { - app: { $refs } - } = window - const vm = $refs.table_paginated - const fieldKeys = Object.keys(vm.fields) - - const tfoot = [...vm.$el.children].find(el => el && el.tagName === 'TFOOT') - expect(tfoot).toBeDefined() - if (tfoot) { - const tr = [...tfoot.children].find(el => el && el.tagName === 'TR') - expect(tr).toBeDefined() - if (tr) { - const ths = [...tr.children] - expect(ths.length).toBe(fieldKeys.length) - ths.forEach((th, idx) => { - const spy = jest.fn() - vm.$on('head-clicked', spy) - th.click() - vm.$off('head-clicked', spy) - expect(spy).toHaveBeenCalled() - }) - } - } - }) - it('sortable header th should emit a sort-changed event with context when clicked and sort changed', async () => { const { app: { $refs }