diff --git a/src/components/table/README.md b/src/components/table/README.md index 5b9b85aca1d..290c3828ba1 100755 --- a/src/components/table/README.md +++ b/src/components/table/README.md @@ -609,6 +609,73 @@ elements are limited. Refer to [MDN](https://developer.mozilla.org/en-US/docs/We for details and usage of `` +## Table busy state +`` provides a `busy` prop that will flag the table as busy, which you can +set to `true` just before you update your items, and then set it to `false` once you have +your items. When in hte busy state, the tabe will have the attribute `aria-busy="true"`. + +During the busy state, the table will be rendered in a "muted" look (`opacity: 0.6`), using the +following custom CSS: + +```css +/* Busy table styling */ +table.b-table[aria-busy='false'] { + opacity: 1; +} +table.b-table[aria-busy='true'] { + opacity: 0.6; +} +``` + +You can override this styling using your own CSS. + +You may optionally provide a `table-busy` slot to show a custom loading message or spinner +whenever the table's busy state is `true`. The slot will be placed in a `` element with +class `b-table-busy-slot`, which has one single `` with a `colspan` set to the number of fields. + +**Example of `table-busy` slot usage:** +```html + + + + +``` + +Also see the [**Using Items Provider Functions**](#using-items-provider-functions) below for additional +informaton on the `busy` state. + +**Note:** All click related and hover events, and sort-changed events will __not__ be + emitted when the table is in the `busy` state. + + ## Custom Data Rendering Custom rendering for each data field in a row is possible using either [scoped slots](http://vuejs.org/v2/guide/components.html#Scoped-Slots) @@ -1226,22 +1293,23 @@ function myProvider (ctx) { } ``` -`` automatically tracks/controls it's `busy` state, however it provides -a `busy` prop that can be used either to override inner `busy`state, or to monitor -``'s current busy state in your application using the 2-way `.sync` modifier. +### Automated table busy state +`` automatically tracks/controls it's `busy` state when items provider functions are +used, however it also provides a `busy` prop that can be used either to override the inner `busy` +state, or to monitor ``'s current busy state in your application using the 2-way `.sync` modifier. -**Note:** in order to allow `` fully track it's `busy` state, custom items +**Note:** in order to allow `` fully track it's `busy` state, the custom items provider function should handle errors from data sources and return an empty array to ``. -`` provides a `busy` prop that will flag the table as busy, which you can -set to `true` just before your async fetch, and then set it to `false` once you have -your data, and just before you send it to the table for display. Example: - +**Example: usage of busy state** ```html - + + ``` - ```js data () { return { @@ -1250,8 +1318,8 @@ data () { } methods: { myProvider (ctx) { - // Here we don't set isBusy prop, so busy state will be handled by table itself - // this.isBusy = true + // Here we don't set isBusy prop, so busy state will be handled by table itself + // this.isBusy = true let promise = axios.get('/some/url') return promise.then((data) => { @@ -1276,6 +1344,7 @@ __not__ be called/refreshed until the `busy` state has been set to `false`. emitted when in the `busy` state (either set automatically during provider update, or when manually set). + ### Provider Paging, Filtering, and Sorting By default, the items provider function is responsible for **all paging, filtering, and sorting** of the data, before passing it to `b-table` for display. diff --git a/src/components/table/package.json b/src/components/table/package.json index a54af6cef23..c684895dc83 100755 --- a/src/components/table/package.json +++ b/src/components/table/package.json @@ -157,6 +157,10 @@ "name": "table-colgroup", "description": "Slot to place custom colgroup and col elements" }, + { + "name": "table-busy", + "description": "Optional slot to place loading message when table is in the busy state" + }, { "name": "[field]", "description": "Scoped slot for custom data rendering of field data. See docs for scoped data" diff --git a/src/components/table/table-busy.spec.js b/src/components/table/table-busy.spec.js new file mode 100644 index 00000000000..53bf984c1d8 --- /dev/null +++ b/src/components/table/table-busy.spec.js @@ -0,0 +1,117 @@ +import Table from './table' +import { mount } from '@vue/test-utils' + +const testItems = [ + { a: 1, b: 2, c: 3 }, + { a: 5, b: 5, c: 6 }, + { a: 7, b: 8, c: 9 } +] + +describe('b-table busy state', async () => { + it('default should have attribute aria-busy=false', async () => { + const wrapper = mount(Table, { + propsData: { + items: testItems + } + }) + expect(wrapper.attributes('aria-busy')).toBeDefined() + expect(wrapper.attributes('aria-busy')).toEqual('false') + }) + + it('default should have item rows rendered', async () => { + const wrapper = mount(Table, { + propsData: { + items: testItems + } + }) + expect(wrapper.find('tbody').exists()).toBe(true) + expect(wrapper.find('tbody').findAll('tr').exists()).toBe(true) + expect(wrapper.find('tbody').findAll('tr').length).toBe(testItems.length) + }) + + it('should have attribute aria-busy=true when prop busy=true', async () => { + const wrapper = mount(Table, { + propsData: { + busy: true, + items: testItems + } + }) + expect(wrapper.attributes('aria-busy')).toBeDefined() + expect(wrapper.attributes('aria-busy')).toEqual('true') + }) + + it('should have attribute aria-busy=true when data localBusy=true', async () => { + const wrapper = mount(Table, { + propsData: { + items: testItems + } + }) + expect(wrapper.attributes('aria-busy')).toBeDefined() + expect(wrapper.attributes('aria-busy')).toEqual('false') + + wrapper.setData({ + localBusy: true + }) + + expect(wrapper.attributes('aria-busy')).toBeDefined() + expect(wrapper.attributes('aria-busy')).toEqual('true') + }) + + it('should emit update:busy event when data localBusy is toggled', async () => { + const wrapper = mount(Table, { + propsData: { + items: testItems + } + }) + expect(wrapper.emitted('update:busy')).not.toBeDefined() + + wrapper.setData({ + localBusy: true + }) + + expect(wrapper.emitted('update:busy')).toBeDefined() + expect(wrapper.emitted('update:busy')[0][0]).toEqual(true) + }) + + it('should render table-busy slot when prop busy=true and slot provided', async () => { + const wrapper = mount(Table, { + propsData: { + busy: false, + items: testItems + }, + slots: { + // Note slot data needs to be wrapped in an element. + // https://github.com/vue/vue-test-utils/issues:992 + // Will be fixed in v1.0.0-beta.26 + 'table-busy': 'busy slot content' + } + }) + expect(wrapper.attributes('aria-busy')).toBeDefined() + expect(wrapper.attributes('aria-busy')).toEqual('false') + expect(wrapper.find('tbody').exists()).toBe(true) + expect(wrapper.find('tbody').findAll('tr').exists()).toBe(true) + expect(wrapper.find('tbody').findAll('tr').length).toBe(testItems.length) + + wrapper.setProps({ + busy: true + }) + + expect(wrapper.attributes('aria-busy')).toBeDefined() + expect(wrapper.attributes('aria-busy')).toEqual('true') + expect(wrapper.find('tbody').exists()).toBe(true) + expect(wrapper.find('tbody').findAll('tr').exists()).toBe(true) + expect(wrapper.find('tbody').findAll('tr').length).toBe(1) + expect(wrapper.find('tbody').text()).toContain('busy slot content') + expect(wrapper.find('tbody').find('tr').classes()).toContain('b-table-busy-slot') + + wrapper.setProps({ + busy: false + }) + + expect(wrapper.attributes('aria-busy')).toBeDefined() + expect(wrapper.attributes('aria-busy')).toEqual('false') + expect(wrapper.find('tbody').exists()).toBe(true) + expect(wrapper.find('tbody').findAll('tr').exists()).toBe(true) + expect(wrapper.find('tbody').findAll('tr').length).toBe(testItems.length) + }) +}) diff --git a/src/components/table/table.js b/src/components/table/table.js index 0c3acda9933..ef495e7730e 100644 --- a/src/components/table/table.js +++ b/src/components/table/table.js @@ -222,122 +222,146 @@ export default { rows.push(h(false)) } - // Add the item data rows - items.forEach((item, rowIndex) => { - const detailsSlot = $scoped['row-details'] - const rowShowDetails = Boolean(item._showDetails && detailsSlot) - // Details ID needed for aria-describedby when details showing - const detailsId = rowShowDetails - ? this.safeId(`_details_${rowIndex}_`) - : null - const toggleDetailsFn = () => { - if (detailsSlot) { - this.$set(item, '_showDetails', !item._showDetails) - } + // Add the item data rows or the busy slot + if ($slots['table-busy'] && this.computedBusy) { + // Show the busy slot + const trAttrs = { + role: this.isStacked ? 'row' : null } - // For each item data field in row - const tds = fields.map((field, colIndex) => { - const formatted = this.getFormattedValue(item, field) - const data = { - key: `row-${rowIndex}-cell-${colIndex}`, - class: this.tdClasses(field, item), - attrs: this.tdAttrs(field, item, colIndex), - domProps: {} - } - let childNodes - if ($scoped[field.key]) { - childNodes = [ - $scoped[field.key]({ - item: item, - index: rowIndex, - field: field, - unformatted: _get(item, field.key, ''), - value: formatted, - toggleDetails: toggleDetailsFn, - detailsShowing: Boolean(item._showDetails) - }) - ] - if (this.isStacked) { - // We wrap in a DIV to ensure rendered as a single cell when visually stacked! - childNodes = [h('div', {}, [childNodes])] - } - } else { - if (this.isStacked) { - // We wrap in a DIV to ensure rendered as a single cell when visually stacked! - childNodes = [h('div', formatted)] - } else { - // Non stacked - childNodes = formatted - } - } - // Render either a td or th cell - return h(field.isRowHeader ? 'th' : 'td', data, childNodes) - }) - // Calculate the row number in the dataset (indexed from 1) - let ariaRowIndex = null - if (this.currentPage && this.perPage && this.perPage > 0) { - ariaRowIndex = String((this.currentPage - 1) * this.perPage + rowIndex + 1) + const tdAttrs = { + colspan: String(fields.length), + role: this.isStacked ? 'cell' : null } - // Assemble and add the row rows.push( h( 'tr', { - key: `row-${rowIndex}`, - class: [ - this.rowClasses(item), - { 'b-table-has-details': rowShowDetails } - ], - attrs: { - 'aria-describedby': detailsId, - 'aria-owns': detailsId, - 'aria-rowindex': ariaRowIndex, - role: this.isStacked ? 'row' : null - }, - on: { - click: evt => { this.rowClicked(evt, item, rowIndex) }, - contextmenu: evt => { this.rowContextmenu(evt, item, rowIndex) }, - dblclick: evt => { this.rowDblClicked(evt, item, rowIndex) }, - mouseenter: evt => { this.rowHovered(evt, item, rowIndex) }, - mouseleave: evt => { this.rowUnhovered(evt, item, rowIndex) } - } + key: 'table-busy-slot', + staticClass: 'b-table-busy-slot', + class: [typeof this.tbodyTrClass === 'function' ? this.tbodyTrClass(null, 'table-busy') : this.tbodyTrClass], + attrs: trAttrs }, - tds + [h('td', { attrs: tdAttrs }, [$slots['table-busy']])] ) ) - // Row Details slot - if (rowShowDetails) { - const tdAttrs = { colspan: String(fields.length) } - const trAttrs = { id: detailsId } - if (this.isStacked) { - tdAttrs['role'] = 'cell' - trAttrs['role'] = 'row' + } else { + // Show the rows + items.forEach((item, rowIndex) => { + const detailsSlot = $scoped['row-details'] + const rowShowDetails = Boolean(item._showDetails && detailsSlot) + // Details ID needed for aria-describedby when details showing + const detailsId = rowShowDetails + ? this.safeId(`_details_${rowIndex}_`) + : null + const toggleDetailsFn = () => { + if (detailsSlot) { + this.$set(item, '_showDetails', !item._showDetails) + } } - const details = h('td', { attrs: tdAttrs }, [ - detailsSlot({ - item: item, - index: rowIndex, - fields: fields, - toggleDetails: toggleDetailsFn - }) - ]) + // For each item data field in row + const tds = fields.map((field, colIndex) => { + const formatted = this.getFormattedValue(item, field) + const data = { + key: `row-${rowIndex}-cell-${colIndex}`, + class: this.tdClasses(field, item), + attrs: this.tdAttrs(field, item, colIndex), + domProps: {} + } + let childNodes + if ($scoped[field.key]) { + childNodes = [ + $scoped[field.key]({ + item: item, + index: rowIndex, + field: field, + unformatted: _get(item, field.key, ''), + value: formatted, + toggleDetails: toggleDetailsFn, + detailsShowing: Boolean(item._showDetails) + }) + ] + if (this.isStacked) { + // We wrap in a DIV to ensure rendered as a single cell when visually stacked! + childNodes = [h('div', {}, [childNodes])] + } + } else { + if (this.isStacked) { + // We wrap in a DIV to ensure rendered as a single cell when visually stacked! + childNodes = [h('div', formatted)] + } else { + // Non stacked + childNodes = formatted + } + } + // Render either a td or th cell + return h(field.isRowHeader ? 'th' : 'td', data, childNodes) + }) + // Calculate the row number in the dataset (indexed from 1) + let ariaRowIndex = null + if (this.currentPage && this.perPage && this.perPage > 0) { + ariaRowIndex = String((this.currentPage - 1) * this.perPage + rowIndex + 1) + } + // Assemble and add the row rows.push( h( 'tr', { - key: `details-${rowIndex}`, - staticClass: 'b-table-details', - class: [typeof this.tbodyTrClass === 'function' ? this.tbodyTrClass(item, 'row-details') : this.tbodyTrClass], - attrs: trAttrs + key: `row-${rowIndex}`, + class: [ + this.rowClasses(item), + { 'b-table-has-details': rowShowDetails } + ], + attrs: { + 'aria-describedby': detailsId, + 'aria-owns': detailsId, + 'aria-rowindex': ariaRowIndex, + role: this.isStacked ? 'row' : null + }, + on: { + click: evt => { this.rowClicked(evt, item, rowIndex) }, + contextmenu: evt => { this.rowContextmenu(evt, item, rowIndex) }, + dblclick: evt => { this.rowDblClicked(evt, item, rowIndex) }, + mouseenter: evt => { this.rowHovered(evt, item, rowIndex) }, + mouseleave: evt => { this.rowUnhovered(evt, item, rowIndex) } + } }, - [details] + tds ) ) - } else if (detailsSlot) { - // Only add the placeholder if a the table has a row-details slot defined (but not shown) - rows.push(h(false)) - } - }) + // Row Details slot + if (rowShowDetails) { + const tdAttrs = { colspan: String(fields.length) } + const trAttrs = { id: detailsId } + if (this.isStacked) { + tdAttrs['role'] = 'cell' + trAttrs['role'] = 'row' + } + const details = h('td', { attrs: tdAttrs }, [ + detailsSlot({ + item: item, + index: rowIndex, + fields: fields, + toggleDetails: toggleDetailsFn + }) + ]) + rows.push( + h( + 'tr', + { + key: `details-${rowIndex}`, + staticClass: 'b-table-details', + class: [typeof this.tbodyTrClass === 'function' ? this.tbodyTrClass(item, 'row-details') : this.tbodyTrClass], + attrs: trAttrs + }, + [details] + ) + ) + } else if (detailsSlot) { + // Only add the placeholder if a the table has a row-details slot defined (but not shown) + rows.push(h(false)) + } + }) + } // Empty Items / Empty Filtered Row slot if (this.showEmpty && (!items || items.length === 0)) {