UNPKG

bootstrap-vue

Version:

BootstrapVue, with over 40 plugins and more than 75 custom components, provides one of the most comprehensive implementations of Bootstrap v4 components and grid system for Vue.js. With extensive and automated WAI-ARIA accessibility markup.

398 lines (382 loc) 14 kB
import KeyCodes from '../../../utils/key-codes' import get from '../../../utils/get' import toString from '../../../utils/to-string' import { arrayIncludes } from '../../../utils/array' import { isFunction, isNull, isString, isUndefined } from '../../../utils/inspect' import filterEvent from './filter-event' import textSelectionActive from './text-selection-active' export default { props: { tbodyTrClass: { type: [String, Array, Function], default: null } }, methods: { // Methods for computing classes, attributes and styles for table cells tdClasses(field, item) { let cellVariant = '' if (item._cellVariants && item._cellVariants[field.key]) { cellVariant = `${this.dark ? 'bg' : 'table'}-${item._cellVariants[field.key]}` } return [ field.variant && !cellVariant ? `${this.dark ? 'bg' : 'table'}-${field.variant}` : '', cellVariant, field.class ? field.class : '', this.getTdValues(item, field.key, field.tdClass, '') ] }, tdAttrs(field, item, colIndex) { const attrs = { role: 'cell', 'aria-colindex': String(colIndex + 1) } if (field.isRowHeader) { attrs.scope = 'row' attrs.role = 'rowheader' } if (this.isStacked) { // Generate the "header cell" label content in stacked mode attrs['data-label'] = field.label } return { ...attrs, ...this.getTdValues(item, field.key, field.tdAttr, {}) } }, rowClasses(item) { return [ item._rowVariant ? `${this.dark ? 'bg' : 'table'}-${item._rowVariant}` : '', isFunction(this.tbodyTrClass) ? this.tbodyTrClass(item, 'row') : this.tbodyTrClass ] }, getTdValues(item, key, tdValue, defValue) { const parent = this.$parent if (tdValue) { const value = get(item, key, '') if (isFunction(tdValue)) { return tdValue(value, key, item) } else if (isString(tdValue) && isFunction(parent[tdValue])) { return parent[tdValue](value, key, item) } return tdValue } return defValue }, // Method to get the value for a field getFormattedValue(item, field) { const key = field.key const formatter = field.formatter const parent = this.$parent let value = get(item, key, null) if (formatter) { if (isFunction(formatter)) { value = formatter(value, key, item) } else if (isString(formatter) && isFunction(parent[formatter])) { value = parent[formatter](value, key, item) } } return isUndefined(value) || isNull(value) ? '' : value }, tbodyRowKeydown(evt, item, rowIndex) { const keyCode = evt.keyCode const target = evt.target const trs = this.$refs.itemRows if (this.stopIfBusy && 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 && this.stopIfBusy(e)) { // If table is busy (via provider) then don't propagate return } else if (filterEvent(e)) { // clicked on a non-disabled control so ignore return } else if (textSelectionActive(this.$el)) { // User is selecting text, so ignore /* istanbul ignore next: JSDOM doesn't support getSelection() */ return } this.$emit('row-clicked', item, index, e) }, middleMouseRowClicked(e, item, index) { if (this.stopIfBusy && this.stopIfBusy(e)) { // If table is busy (via provider) then don't propagate return } this.$emit('row-middle-clicked', item, index, e) }, rowDblClicked(e, item, index) { if (this.stopIfBusy && this.stopIfBusy(e)) { // If table is busy (via provider) then don't propagate return } else if (filterEvent(e)) { // clicked on a non-disabled control so ignore /* istanbul ignore next: event filtering already tested via click handler */ return } this.$emit('row-dblclicked', item, index, e) }, rowHovered(e, item, index) { if (this.stopIfBusy && this.stopIfBusy(e)) { // If table is busy (via provider) then don't propagate return } this.$emit('row-hovered', item, index, e) }, rowUnhovered(e, item, index) { if (this.stopIfBusy && this.stopIfBusy(e)) { // If table is busy (via provider) then don't propagate return } this.$emit('row-unhovered', item, index, e) }, rowContextmenu(e, item, index) { if (this.stopIfBusy && this.stopIfBusy(e)) { // If table is busy (via provider) then don't propagate return } this.$emit('row-contextmenu', item, index, e) }, // Render helpers renderTbodyRowCell(field, colIndex, item, rowIndex) { const h = this.$createElement // Renders a TD or TH for a row's field const $scoped = this.$scopedSlots const detailsSlot = $scoped['row-details'] const formatted = this.getFormattedValue(item, field) const data = { // For the Vue key, we concatenate the column index and // field key (as field keys can be duplicated) key: `row-${rowIndex}-cell-${colIndex}-${field.key}`, class: this.tdClasses(field, item), attrs: this.tdAttrs(field, item, colIndex) } const toggleDetailsFn = () => { if (detailsSlot) { this.$set(item, '_showDetails', !item._showDetails) } } const slotScope = { item: item, index: rowIndex, field: field, unformatted: get(item, field.key, ''), value: formatted, toggleDetails: toggleDetailsFn, detailsShowing: Boolean(item._showDetails) } if (this.selectedRows) { // Add in rowSelected scope property if selectable rows supported slotScope.rowSelected = Boolean(this.selectedRows[rowIndex]) } let $childNodes = $scoped[field.key] ? $scoped[field.key](slotScope) : toString(formatted) if (this.isStacked) { // We wrap in a DIV to ensure rendered as a single cell when visually stacked! $childNodes = [h('div', {}, [$childNodes])] } // Render either a td or th cell return h(field.isRowHeader ? 'th' : 'td', data, [$childNodes]) }, renderTbodyRow(item, rowIndex) { // Renders an item's row (or rows if details supported) const h = this.$createElement const $scoped = this.$scopedSlots const fields = this.computedFields const tableStriped = this.striped const hasRowClickHandler = this.$listeners['row-clicked'] || this.selectable const $detailsSlot = $scoped['row-details'] const rowShowDetails = Boolean(item._showDetails && $detailsSlot) // We can return more than one TR if rowDetails enabled const $rows = [] // 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) } } // For each item data field in row const $tds = fields.map((field, colIndex) => { return this.renderTbodyRowCell(field, colIndex, item, rowIndex) }) // 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) } // Create a unique :key to help ensure that sub components are re-rendered rather than // re-used, which can cause issues. If a primary key is not provided we use the rendered // rows index within the tbody. // See: https://github.com/bootstrap-vue/bootstrap-vue/issues/2410 const primaryKey = this.primaryKey const rowKey = primaryKey && !isUndefined(item[primaryKey]) && !isNull(item[primaryKey]) ? toString(item[primaryKey]) : String(rowIndex) // If primary key is provided, use it to generate a unique ID on each tbody > tr // In the format of '{tableId}__row_{primaryKeyValue}' const rowId = primaryKey && !isUndefined(item[primaryKey]) && !isNull(item[primaryKey]) ? 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) } } // Selctable classes and attributes const selectableClasses = this.selectableRowClasses ? this.selectableRowClasses(rowIndex) : {} const selectableAttrs = this.selectableRowAttrs ? this.selectableRowAttrs(rowIndex) : {} // Add the item row $rows.push( h( 'tr', { key: `__b-table-row-${rowKey}__`, ref: 'itemRows', refInFor: true, class: [ this.rowClasses(item), selectableClasses, { 'b-table-has-details': rowShowDetails } ], attrs: { id: rowId, tabindex: hasRowClickHandler ? '0' : null, 'data-pk': rowId ? String(item[primaryKey]) : null, 'aria-describedby': detailsId, 'aria-owns': detailsId, 'aria-rowindex': ariaRowIndex, role: 'row', ...selectableAttrs }, on: { ...handlers, // TODO: Instantiate 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) } }, contextmenu: evt => { this.rowContextmenu(evt, item, rowIndex) }, // Note: these events are not accessibility friendly! dblclick: evt => { this.rowDblClicked(evt, item, rowIndex) }, mouseenter: evt => { this.rowHovered(evt, item, rowIndex) }, mouseleave: evt => { this.rowUnhovered(evt, item, rowIndex) } } }, $tds ) ) // Row Details slot if (rowShowDetails) { const tdAttrs = { colspan: String(fields.length), role: 'cell' } const trAttrs = { id: detailsId, role: 'row' } // Render the details slot const $details = h('td', { attrs: tdAttrs }, [ $detailsSlot({ item: item, index: rowIndex, fields: fields, toggleDetails: toggleDetailsFn }) ]) // Add a hidden row to keep table row striping consistent when details showing if (tableStriped) { $rows.push( h('tr', { key: `__b-table-details-${rowIndex}-stripe__`, staticClass: 'd-none', attrs: { 'aria-hidden': 'true', role: 'presentation' } }) ) } // Add the actual details row $rows.push( h( 'tr', { key: `__b-table-details-${rowIndex}__`, staticClass: 'b-table-details', class: [ isFunction(this.tbodyTrClass) ? 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)) if (tableStriped) { // add extra placeholder if table is striped $rows.push(h(false)) } } // Return the row(s) return $rows } } }