diff --git a/src/components/table/README.md b/src/components/table/README.md index 74aef89bd59..46f71fafcc9 100644 --- a/src/components/table/README.md +++ b/src/components/table/README.md @@ -1188,6 +1188,16 @@ as read-only.** ``` +When table is selectable, it will have class `b-table-selectable`, and one of the following three +classes (depending on which mode is in use), on the `` element: + +- `b-table-select-single` +- `b-table-select-multi` +- `b-table-select-range` + +When at least one row is selected the class `b-table-selecting` will be active on the `
` +element. + **Notes:** - _Paging, filtering, or sorting will clear the selection. The `row-selected` event will be emitted diff --git a/src/components/table/_table.scss b/src/components/table/_table.scss index 14f20c57192..aa6e24c516d 100644 --- a/src/components/table/_table.scss +++ b/src/components/table/_table.scss @@ -159,7 +159,15 @@ } /* b-table: selectable rows */ -table.b-table.b-table-selectable > tbody > tr { - cursor: pointer; - // user-select: none; +.b-table.table.b-table-selectable { + & > tbody > tr { + cursor: pointer; + } + + &.b-table-selecting { + // Disabled text-selection when in mode range when at least one row selected + &.b-table-select-range > tbody > tr { + user-select: none; + } + } } diff --git a/src/components/table/helpers/mixin-selectable.js b/src/components/table/helpers/mixin-selectable.js index b350dc9e893..2eaba14ee03 100644 --- a/src/components/table/helpers/mixin-selectable.js +++ b/src/components/table/helpers/mixin-selectable.js @@ -1,5 +1,5 @@ import looseEqual from '../../../utils/loose-equal' -import { isArray } from '../../../utils/array' +import { isArray, arrayIncludes } from '../../../utils/array' import sanitizeRow from './sanitize-row' export default { @@ -23,6 +23,29 @@ export default { selectedLastRow: -1 } }, + computed: { + selectableTableClasses() { + const selectable = this.selectable + const isSelecting = selectable && this.selectedRows && this.selectedRows.some(Boolean) + return { + 'b-table-selectable': selectable, + [`b-table-select-${this.selectMode}`]: selectable, + 'b-table-selecting': isSelecting + } + }, + selectableTableAttrs() { + return { + 'aria-multiselectable': this.selectableIsMultiSelect + } + }, + selectableIsMultiSelect() { + if (this.selectable) { + return arrayIncludes(['range', 'multi'], this.selectMode) ? 'true' : 'false' + } else { + return null + } + } + }, watch: { computedItems(newVal, oldVal) { // Reset for selectable @@ -72,17 +95,18 @@ export default { isRowSelected(idx) { return Boolean(this.selectedRows[idx]) }, - rowSelectedClasses(idx) { - if (this.selectable) { - const rowSelected = this.isRowSelected(idx) - const base = this.dark ? 'bg' : 'table' - const variant = this.selectedVariant - return { - 'b-row-selected': rowSelected, - [`${base}-${variant}`]: rowSelected && variant - } - } else { - return {} + selectableRowClasses(idx) { + const rowSelected = this.isRowSelected(idx) + const base = this.dark ? 'bg' : 'table' + const variant = this.selectedVariant + return { + 'b-table-row-selected': this.selectable && rowSelected, + [`${base}-${variant}`]: this.selectable && rowSelected && variant + } + }, + selectableRowAttrs(idx) { + return { + 'aria-selected': !this.selectable ? null : this.isRowSelected(idx) ? 'true' : 'false' } }, clearSelected() { @@ -125,7 +149,6 @@ export default { idx <= Math.max(this.selectedLastRow, index); idx++ ) { - // this.$set(this.selectedRows, idx, true) selectedRows[idx] = true } selected = true @@ -138,7 +161,6 @@ export default { this.selectedLastRow = selected ? index : -1 } } - // this.$set(this.selectedRows, index, selected) selectedRows[index] = selected this.selectedRows = selectedRows } diff --git a/src/components/table/helpers/mixin-tbody-row.js b/src/components/table/helpers/mixin-tbody-row.js index a53a4e4519e..17b61256ffc 100644 --- a/src/components/table/helpers/mixin-tbody-row.js +++ b/src/components/table/helpers/mixin-tbody-row.js @@ -195,7 +195,6 @@ export default { const hasRowClickHandler = this.$listeners['row-clicked'] || this.selectable const $detailsSlot = $scoped['row-details'] const rowShowDetails = Boolean(item._showDetails && $detailsSlot) - const rowSelected = this.isRowSelected(rowIndex) /* from selctable mixin */ // We can return more than one TR if rowDetails enabled const $rows = [] @@ -244,7 +243,7 @@ export default { key: `__b-table-row-${rowKey}__`, class: [ this.rowClasses(item), - this.rowSelectedClasses(rowIndex), + this.selectableRowClasses(rowIndex), { 'b-table-has-details': rowShowDetails } @@ -256,8 +255,8 @@ export default { 'aria-describedby': detailsId, 'aria-owns': detailsId, 'aria-rowindex': ariaRowIndex, - 'aria-selected': this.selectable ? (rowSelected ? 'true' : 'false') : null, - role: 'row' + role: 'row', + ...this.selectableRowAttrs(rowIndex) }, on: { // TODO: only instatiate handlers if we have registered listeners (except row-clicked) diff --git a/src/components/table/table-selectable.spec.js b/src/components/table/table-selectable.spec.js index 5ebf113454e..b495d528900 100644 --- a/src/components/table/table-selectable.spec.js +++ b/src/components/table/table-selectable.spec.js @@ -28,6 +28,12 @@ describe('table row select', () => { }) expect(wrapper).toBeDefined() await wrapper.vm.$nextTick() + expect(wrapper.attributes('aria-multiselectable')).not.toBeDefined() + expect(wrapper.classes()).not.toContain('b-table-selectable') + expect(wrapper.classes()).not.toContain('b-table-selecting') + expect(wrapper.classes()).not.toContain('b-table-select-single') + expect(wrapper.classes()).not.toContain('b-table-select-multi') + expect(wrapper.classes()).not.toContain('b-table-select-range') const $rows = wrapper.findAll('tbody > tr') expect($rows.length).toBe(4) // Doesn't have aria-selected attribute on all TRs @@ -50,6 +56,12 @@ describe('table row select', () => { }) expect(wrapper).toBeDefined() await wrapper.vm.$nextTick() + expect(wrapper.attributes('aria-multiselectable')).not.toBeDefined() + expect(wrapper.classes()).not.toContain('b-table-selectable') + expect(wrapper.classes()).not.toContain('b-table-selecting') + expect(wrapper.classes()).not.toContain('b-table-select-single') + expect(wrapper.classes()).not.toContain('b-table-select-multi') + expect(wrapper.classes()).not.toContain('b-table-select-range') const $rows = wrapper.findAll('tbody > tr') expect($rows.length).toBe(4) // Doesn't have aria-selected attribute on all TRs @@ -73,6 +85,12 @@ describe('table row select', () => { expect(wrapper).toBeDefined() await wrapper.vm.$nextTick() + expect(wrapper.attributes('aria-multiselectable')).toBe('false') + expect(wrapper.classes()).toContain('b-table-selectable') + expect(wrapper.classes()).toContain('b-table-select-single') + expect(wrapper.classes()).not.toContain('b-table-selecting') + expect(wrapper.classes()).not.toContain('b-table-select-multi') + expect(wrapper.classes()).not.toContain('b-table-select-range') expect(wrapper.emitted('row-selected')).not.toBeDefined() $rows = wrapper.findAll('tbody > tr') expect($rows.length).toBe(4) @@ -94,8 +112,13 @@ describe('table row select', () => { expect($rows.at(1).is('[aria-selected="false"]')).toBe(true) expect($rows.at(2).is('[aria-selected="false"]')).toBe(true) expect($rows.at(3).is('[aria-selected="false"]')).toBe(true) + expect(wrapper.classes()).toContain('b-table-selectable') + expect(wrapper.classes()).toContain('b-table-select-single') + expect(wrapper.classes()).toContain('b-table-selecting') + expect(wrapper.classes()).not.toContain('b-table-select-multi') + expect(wrapper.classes()).not.toContain('b-table-select-range') - // Click third row + // Click third row to select it wrapper .findAll('tbody > tr') .at(2) @@ -109,8 +132,13 @@ describe('table row select', () => { expect($rows.at(1).is('[aria-selected="false"]')).toBe(true) expect($rows.at(2).is('[aria-selected="true"]')).toBe(true) expect($rows.at(3).is('[aria-selected="false"]')).toBe(true) + expect(wrapper.classes()).toContain('b-table-selectable') + expect(wrapper.classes()).toContain('b-table-select-single') + expect(wrapper.classes()).toContain('b-table-selecting') + expect(wrapper.classes()).not.toContain('b-table-select-multi') + expect(wrapper.classes()).not.toContain('b-table-select-range') - // Click third row again + // Click third row again to clear selection wrapper .findAll('tbody > tr') .at(2) @@ -124,6 +152,11 @@ describe('table row select', () => { expect($rows.at(1).is('[aria-selected="false"]')).toBe(true) expect($rows.at(2).is('[aria-selected="false"]')).toBe(true) expect($rows.at(3).is('[aria-selected="false"]')).toBe(true) + expect(wrapper.classes()).toContain('b-table-selectable') + expect(wrapper.classes()).toContain('b-table-select-single') + expect(wrapper.classes()).not.toContain('b-table-selecting') + expect(wrapper.classes()).not.toContain('b-table-select-multi') + expect(wrapper.classes()).not.toContain('b-table-select-range') wrapper.destroy() }) @@ -140,6 +173,12 @@ describe('table row select', () => { let $rows expect(wrapper).toBeDefined() await wrapper.vm.$nextTick() + expect(wrapper.attributes('aria-multiselectable')).toBe('true') + expect(wrapper.classes()).toContain('b-table-selectable') + expect(wrapper.classes()).toContain('b-table-select-multi') + expect(wrapper.classes()).not.toContain('b-table-select-single') + expect(wrapper.classes()).not.toContain('b-table-select-range') + expect(wrapper.classes()).not.toContain('b-table-selecting') expect(wrapper.emitted('row-selected')).not.toBeDefined() // Click first row @@ -157,6 +196,11 @@ describe('table row select', () => { expect($rows.at(1).is('[aria-selected="false"]')).toBe(true) expect($rows.at(2).is('[aria-selected="false"]')).toBe(true) expect($rows.at(3).is('[aria-selected="false"]')).toBe(true) + expect(wrapper.classes()).toContain('b-table-selectable') + expect(wrapper.classes()).toContain('b-table-selecting') + expect(wrapper.classes()).toContain('b-table-select-multi') + expect(wrapper.classes()).not.toContain('b-table-select-single') + expect(wrapper.classes()).not.toContain('b-table-select-range') // Click third row wrapper @@ -172,6 +216,11 @@ describe('table row select', () => { expect($rows.at(1).is('[aria-selected="false"]')).toBe(true) expect($rows.at(2).is('[aria-selected="true"]')).toBe(true) expect($rows.at(3).is('[aria-selected="false"]')).toBe(true) + expect(wrapper.classes()).toContain('b-table-selectable') + expect(wrapper.classes()).toContain('b-table-selecting') + expect(wrapper.classes()).toContain('b-table-select-multi') + expect(wrapper.classes()).not.toContain('b-table-select-single') + expect(wrapper.classes()).not.toContain('b-table-select-range') // Click third row again wrapper @@ -187,6 +236,11 @@ describe('table row select', () => { expect($rows.at(1).is('[aria-selected="false"]')).toBe(true) expect($rows.at(2).is('[aria-selected="false"]')).toBe(true) expect($rows.at(3).is('[aria-selected="false"]')).toBe(true) + expect(wrapper.classes()).toContain('b-table-selectable') + expect(wrapper.classes()).toContain('b-table-selecting') + expect(wrapper.classes()).toContain('b-table-select-multi') + expect(wrapper.classes()).not.toContain('b-table-select-single') + expect(wrapper.classes()).not.toContain('b-table-select-range') // Click first row again wrapper @@ -202,6 +256,11 @@ describe('table row select', () => { expect($rows.at(1).is('[aria-selected="false"]')).toBe(true) expect($rows.at(2).is('[aria-selected="false"]')).toBe(true) expect($rows.at(3).is('[aria-selected="false"]')).toBe(true) + expect(wrapper.classes()).toContain('b-table-selectable') + expect(wrapper.classes()).toContain('b-table-select-multi') + expect(wrapper.classes()).not.toContain('b-table-selecting') + expect(wrapper.classes()).not.toContain('b-table-select-single') + expect(wrapper.classes()).not.toContain('b-table-select-range') wrapper.destroy() }) @@ -218,6 +277,12 @@ describe('table row select', () => { let $rows expect(wrapper).toBeDefined() await wrapper.vm.$nextTick() + expect(wrapper.attributes('aria-multiselectable')).toBe('true') + expect(wrapper.classes()).toContain('b-table-selectable') + expect(wrapper.classes()).toContain('b-table-select-range') + expect(wrapper.classes()).not.toContain('b-table-selecting') + expect(wrapper.classes()).not.toContain('b-table-select-single') + expect(wrapper.classes()).not.toContain('b-table-select-multi') expect(wrapper.emitted('row-selected')).not.toBeDefined() $rows = wrapper.findAll('tbody > tr') expect($rows.is('[tabindex="0"]')).toBe(true) @@ -238,6 +303,11 @@ describe('table row select', () => { expect($rows.at(1).is('[aria-selected="false"]')).toBe(true) expect($rows.at(2).is('[aria-selected="false"]')).toBe(true) expect($rows.at(3).is('[aria-selected="false"]')).toBe(true) + expect(wrapper.classes()).toContain('b-table-selectable') + expect(wrapper.classes()).toContain('b-table-select-range') + expect(wrapper.classes()).toContain('b-table-selecting') + expect(wrapper.classes()).not.toContain('b-table-select-single') + expect(wrapper.classes()).not.toContain('b-table-select-multi') // Shift-Click third row wrapper @@ -257,6 +327,11 @@ describe('table row select', () => { expect($rows.at(1).is('[aria-selected="true"]')).toBe(true) expect($rows.at(2).is('[aria-selected="true"]')).toBe(true) expect($rows.at(3).is('[aria-selected="false"]')).toBe(true) + expect(wrapper.classes()).toContain('b-table-selectable') + expect(wrapper.classes()).toContain('b-table-select-range') + expect(wrapper.classes()).toContain('b-table-selecting') + expect(wrapper.classes()).not.toContain('b-table-select-single') + expect(wrapper.classes()).not.toContain('b-table-select-multi') // Click third row again wrapper @@ -272,6 +347,11 @@ describe('table row select', () => { expect($rows.at(1).is('[aria-selected="false"]')).toBe(true) expect($rows.at(2).is('[aria-selected="true"]')).toBe(true) expect($rows.at(3).is('[aria-selected="false"]')).toBe(true) + expect(wrapper.classes()).toContain('b-table-selectable') + expect(wrapper.classes()).toContain('b-table-select-range') + expect(wrapper.classes()).toContain('b-table-selecting') + expect(wrapper.classes()).not.toContain('b-table-select-single') + expect(wrapper.classes()).not.toContain('b-table-select-multi') // Click fourth row wrapper @@ -287,6 +367,11 @@ describe('table row select', () => { expect($rows.at(1).is('[aria-selected="false"]')).toBe(true) expect($rows.at(2).is('[aria-selected="false"]')).toBe(true) expect($rows.at(3).is('[aria-selected="true"]')).toBe(true) + expect(wrapper.classes()).toContain('b-table-selectable') + expect(wrapper.classes()).toContain('b-table-select-range') + expect(wrapper.classes()).toContain('b-table-selecting') + expect(wrapper.classes()).not.toContain('b-table-select-single') + expect(wrapper.classes()).not.toContain('b-table-select-multi') // Click fourth row again wrapper @@ -302,6 +387,11 @@ describe('table row select', () => { expect($rows.at(1).is('[aria-selected="false"]')).toBe(true) expect($rows.at(2).is('[aria-selected="false"]')).toBe(true) expect($rows.at(3).is('[aria-selected="true"]')).toBe(true) + expect(wrapper.classes()).toContain('b-table-selectable') + expect(wrapper.classes()).toContain('b-table-select-range') + expect(wrapper.classes()).toContain('b-table-selecting') + expect(wrapper.classes()).not.toContain('b-table-select-single') + expect(wrapper.classes()).not.toContain('b-table-select-multi') // Ctrl-Click second row wrapper @@ -317,6 +407,11 @@ describe('table row select', () => { expect($rows.at(1).is('[aria-selected="true"]')).toBe(true) expect($rows.at(2).is('[aria-selected="false"]')).toBe(true) expect($rows.at(3).is('[aria-selected="true"]')).toBe(true) + expect(wrapper.classes()).toContain('b-table-selectable') + expect(wrapper.classes()).toContain('b-table-select-range') + expect(wrapper.classes()).toContain('b-table-selecting') + expect(wrapper.classes()).not.toContain('b-table-select-single') + expect(wrapper.classes()).not.toContain('b-table-select-multi') // Ctrl-Click second row wrapper @@ -332,6 +427,11 @@ describe('table row select', () => { expect($rows.at(1).is('[aria-selected="false"]')).toBe(true) expect($rows.at(2).is('[aria-selected="false"]')).toBe(true) expect($rows.at(3).is('[aria-selected="true"]')).toBe(true) + expect(wrapper.classes()).toContain('b-table-selectable') + expect(wrapper.classes()).toContain('b-table-select-range') + expect(wrapper.classes()).toContain('b-table-selecting') + expect(wrapper.classes()).not.toContain('b-table-select-single') + expect(wrapper.classes()).not.toContain('b-table-select-multi') // Ctrl-Click fourth row wrapper @@ -347,6 +447,11 @@ describe('table row select', () => { expect($rows.at(1).is('[aria-selected="false"]')).toBe(true) expect($rows.at(2).is('[aria-selected="false"]')).toBe(true) expect($rows.at(3).is('[aria-selected="false"]')).toBe(true) + expect(wrapper.classes()).toContain('b-table-selectable') + expect(wrapper.classes()).toContain('b-table-select-range') + expect(wrapper.classes()).not.toContain('b-table-selecting') + expect(wrapper.classes()).not.toContain('b-table-select-single') + expect(wrapper.classes()).not.toContain('b-table-select-multi') wrapper.destroy() }) @@ -572,6 +677,8 @@ describe('table row select', () => { expect($rows.at(1).is('[aria-selected="false"]')).toBe(true) expect($rows.at(2).is('[aria-selected="false"]')).toBe(true) expect($rows.at(3).is('[aria-selected="false"]')).toBe(true) + expect(wrapper.classes()).toContain('b-table-selectable') + expect(wrapper.classes()).not.toContain('b-table-selecting-range') // Disabled selectable wrapper.setProps({ @@ -584,6 +691,8 @@ describe('table row select', () => { // Should remove tabindex and aria-selected attributes expect($rows.is('[tabindex]')).toBe(false) expect($rows.is('[aria-selected]')).toBe(false) + expect(wrapper.classes()).not.toContain('b-table-selectable') + expect(wrapper.classes()).not.toContain('b-table-selecting-range') wrapper.destroy() }) diff --git a/src/components/table/table.js b/src/components/table/table.js index fb484250fd7..1678a43955e 100644 --- a/src/components/table/table.js +++ b/src/components/table/table.js @@ -186,19 +186,43 @@ export default { : '' }, tableClasses() { + return [ + { + 'table-striped': this.striped, + 'table-hover': this.hover, + 'table-dark': this.dark, + 'table-bordered': this.bordered, + 'table-borderless': this.borderless, + 'table-sm': this.small, + border: this.outlined, + // The following are b-table custom styles + 'b-table-fixed': this.fixed, + 'b-table-stacked': this.stacked === true || this.stacked === '', + [`b-table-stacked-${this.stacked}`]: this.stacked !== true && this.stacked + }, + // Selectable classes + this.selectableTableClasses + ] + }, + tableAttrs() { + // Preserve user supplied aria-describedby, if provided in $attrs + const adb = + [(this.$attrs || {})['aria-describedby'], this.captionId].filter(Boolean).join(' ') || null + const items = this.computedItems + const fields = this.computedFields return { - 'table-striped': this.striped, - 'table-hover': this.hover, - 'table-dark': this.dark, - 'table-bordered': this.bordered, - 'table-borderless': this.borderless, - 'table-sm': this.small, - border: this.outlined, - // The following are b-table custom styles - 'b-table-fixed': this.fixed, - 'b-table-stacked': this.stacked === true || this.stacked === '', - [`b-table-stacked-${this.stacked}`]: this.stacked !== true && this.stacked, - 'b-table-selectable': this.selectable + // We set aria-rowcount before merging in $attrs, in case user has supplied their own + 'aria-rowcount': + this.filteredItems.length > items.length ? String(this.filteredItems.length) : null, + // Merge in user supplied $attrs if any + ...this.$attrs, + // Now we can override any $attrs here + id: this.safeId(), + role: this.isStacked ? 'table' : null, + 'aria-busy': this.computedBusy ? 'true' : 'false', + 'aria-colcount': String(fields.length), + 'aria-describedby': adb, + ...this.selectableTableAttrs } }, // Items related computed props @@ -477,9 +501,6 @@ export default { } }, render(h) { - const fields = this.computedFields - const items = this.computedItems - // Build the caption (from caption mixin) const $caption = this.renderCaption() @@ -502,31 +523,7 @@ export default { key: 'b-table', staticClass: 'table b-table', class: this.tableClasses, - attrs: { - // We set aria-rowcount before merging in $attrs, in case user has supplied their own - 'aria-rowcount': - this.filteredItems.length > items.length ? String(this.filteredItems.length) : null, - // Merge in user supplied $attrs if any - ...this.$attrs, - // Now we can override any $attrs here - id: this.safeId(), - role: this.isStacked ? 'table' : null, - 'aria-multiselectable': this.selectable - ? this.selectMode === 'single' - ? 'false' - : 'true' - : null, - 'aria-busy': this.computedBusy ? 'true' : 'false', - 'aria-colcount': String(fields.length), - 'aria-describedby': - [ - // Preserve user supplied aria-describedby, if provided in $attrs - (this.$attrs || {})['aria-describedby'], - this.captionId - ] - .filter(a => a) - .join(' ') || null - } + attrs: this.tableAttrs }, [$caption, $colgroup, $thead, $tfoot, $tbody] )