bootstrap-vue
Version:
BootstrapVue provides one of the most comprehensive implementations of Bootstrap 4 components and grid system for Vue.js and with extensive and automated WAI-ARIA accessibility markup.
1,003 lines (983 loc) • 28.3 kB
JavaScript
import startCase from 'lodash.startcase'
import get from 'lodash/get'
import looseEqual from '../../utils/loose-equal'
import stableSort from '../../utils/stable-sort'
import KeyCodes from '../../utils/key-codes'
import warn from '../../utils/warn'
import { keys, assign } from '../../utils/object'
import { isArray } from '../../utils/array'
import idMixin from '../../mixins/id'
import listenOnRootMixin from '../../mixins/listen-on-root'
// Import styles
import './table.css'
function toString (v) {
if (!v) {
return ''
}
if (v instanceof Object) {
return keys(v)
.map(k => toString(v[k]))
.join(' ')
}
return String(v)
}
function recToString (obj) {
if (!(obj instanceof Object)) {
return ''
}
return toString(
keys(obj).reduce((o, k) => {
// Ignore fields that start with _
if (!/^_/.test(k)) {
o[k] = obj[k]
}
return o
}, {})
)
}
function defaultSortCompare (a, b, sortBy) {
if (typeof a[sortBy] === 'number' && typeof b[sortBy] === 'number') {
return (a[sortBy] < b[sortBy] && -1) || (a[sortBy] > b[sortBy] && 1) || 0
}
return toString(a[sortBy]).localeCompare(toString(b[sortBy]), undefined, {
numeric: true
})
}
function processField (key, value) {
let field = null
if (typeof value === 'string') {
// Label shortcut
field = { key, label: value }
} else if (typeof value === 'function') {
// Formatter shortcut
field = { key, formatter: value }
} else if (typeof value === 'object') {
field = assign({}, value)
field.key = field.key || key
} else if (value !== false) {
// Fallback to just key
field = { key }
}
return field
}
export default {
mixins: [idMixin, listenOnRootMixin],
render (h) {
const t = this
const $slots = t.$slots
const $scoped = t.$scopedSlots
const fields = t.computedFields
const items = t.computedItems
// Build the caption
let caption = h(false)
if (t.caption || $slots['table-caption']) {
const data = { style: t.captionStyles }
if (!$slots['table-caption']) {
data.domProps = { innerHTML: t.caption }
}
caption = h('caption', data, $slots['table-caption'])
}
// Build the colgroup
const colgroup = $slots['table-colgroup']
? h('colgroup', {}, $slots['table-colgroup'])
: h(false)
// factory function for thead and tfoot cells (th's)
const makeHeadCells = (isFoot = false) => {
return fields.map((field, colIndex) => {
const data = {
key: field.key,
class: t.fieldClasses(field),
style: field.thStyle || {},
attrs: {
tabindex: field.sortable ? '0' : null,
abbr: field.headerAbbr || null,
title: field.headerTitle || null,
'aria-colindex': String(colIndex + 1),
'aria-label': field.sortable
? t.localSortDesc && t.localSortBy === field.key
? t.labelSortAsc
: t.labelSortDesc
: null,
'aria-sort':
field.sortable && t.localSortBy === field.key
? t.localSortDesc ? 'descending' : 'ascending'
: null
},
on: {
click: evt => {
evt.stopPropagation()
evt.preventDefault()
t.headClicked(evt, field)
},
keydown: evt => {
const keyCode = evt.keyCode
if (keyCode === KeyCodes.ENTER || keyCode === KeyCodes.SPACE) {
evt.stopPropagation()
evt.preventDefault()
t.headClicked(evt, field)
}
}
}
}
let slot =
isFoot && $scoped[`FOOT_${field.key}`]
? $scoped[`FOOT_${field.key}`]
: $scoped[`HEAD_${field.key}`]
if (slot) {
slot = [slot({ label: field.label, column: field.key, field: field })]
} else {
data.domProps = { innerHTML: field.label }
}
return h('th', data, slot)
})
}
// Build the thead
let thead = h(false)
if (t.isStacked !== true) {
// If in always stacked mode (t.isStacked === true), then we don't bother rendering the thead
thead = h('thead', { class: t.headClasses }, [
h('tr', { class: t.theadTrClass }, makeHeadCells(false))
])
}
// Build the tfoot
let tfoot = h(false)
if (t.footClone && t.isStacked !== true) {
// If in always stacked mode (t.isStacked === true), then we don't bother rendering the tfoot
tfoot = h('tfoot', { class: t.footClasses }, [
h('tr', { class: t.tfootTrClass }, makeHeadCells(true))
])
}
// Prepare the tbody rows
const rows = []
// Add static Top Row slot (hidden in visibly stacked mode as we can't control the data-label)
// If in always stacked mode, we don't bother rendering the row
if ($scoped['top-row'] && t.isStacked !== true) {
rows.push(
h(
'tr',
{ key: 'top-row', class: ['b-table-top-row', t.tbodyTrClass] },
[$scoped['top-row']({ columns: fields.length, fields: fields })]
)
)
} else {
rows.push(h(false))
}
// Add the item data rows
items.forEach((item, rowIndex) => {
const detailsSlot = $scoped['row-details']
const rowShowDetails = Boolean(item._showDetails && detailsSlot)
const detailsId = rowShowDetails
? t.safeId(`_details_${rowIndex}_`)
: null
const toggleDetailsFn = () => {
if (detailsSlot) {
t.$set(item, '_showDetails', !item._showDetails)
}
}
// For each item data field in row
const tds = fields.map((field, colIndex) => {
const data = {
key: `row-${rowIndex}-cell-${colIndex}`,
class: t.tdClasses(field, item),
attrs: field.tdAttr || {},
domProps: {}
}
data.attrs['aria-colindex'] = String(colIndex + 1)
let childNodes
if ($scoped[field.key]) {
childNodes = [
$scoped[field.key]({
item: item,
index: rowIndex,
unformatted: get(item, field.key),
value: t.getFormattedValue(item, field),
toggleDetails: toggleDetailsFn,
detailsShowing: Boolean(item._showDetails)
})
]
if (t.isStacked) {
// We wrap in a DIV to ensure rendered as a single cell when visually stacked!
childNodes = [h('div', {}, [childNodes])]
}
} else {
const formatted = t.getFormattedValue(item, field)
if (t.isStacked) {
// We innerHTML a DIV to ensure rendered as a single cell when visually stacked!
childNodes = [h('div', formatted)]
} else {
// Non stacked
childNodes = formatted
}
}
if (t.isStacked) {
// Generate the "header cell" label content in stacked mode
data.attrs['data-label'] = field.label
if (field.isRowHeader) {
data.attrs['role'] = 'rowheader'
} else {
data.attrs['role'] = 'cell'
}
}
// 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 (t.currentPage && t.perPage && t.perPage > 0) {
ariaRowIndex = (t.currentPage - 1) * t.perPage + rowIndex + 1
}
// Assemble and add the row
rows.push(
h(
'tr',
{
key: `row-${rowIndex}`,
class: [
t.rowClasses(item),
{ 'b-table-has-details': rowShowDetails }
],
attrs: {
'aria-describedby': detailsId,
'aria-rowindex': ariaRowIndex,
role: t.isStacked ? 'row' : null
},
on: {
click: evt => {
t.rowClicked(evt, item, rowIndex)
},
dblclick: evt => {
t.rowDblClicked(evt, item, rowIndex)
},
mouseenter: evt => {
t.rowHovered(evt, item, rowIndex)
}
}
},
tds
)
)
// Row Details slot
if (rowShowDetails) {
const tdAttrs = { colspan: String(fields.length) }
const trAttrs = { id: detailsId }
if (t.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}`,
class: ['b-table-details', t.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 (t.showEmpty && (!items || items.length === 0)) {
let empty = t.filter ? $slots['emptyfiltered'] : $slots['empty']
if (!empty) {
empty = h('div', {
class: ['text-center', 'my-2'],
domProps: { innerHTML: t.filter ? t.emptyFilteredText : t.emptyText }
})
}
empty = h(
'td',
{
attrs: {
colspan: String(fields.length),
role: t.isStacked ? 'cell' : null
}
},
[h('div', { attrs: { role: 'alert', 'aria-live': 'polite' } }, [empty])]
)
rows.push(
h(
'tr',
{
key: 'empty-row',
class: ['b-table-empty-row', t.tbodyTrClass],
attrs: t.isStacked ? { role: 'row' } : {}
},
[empty]
)
)
} else {
rows.push(h(false))
}
// Static bottom row slot (hidden in visibly stacked mode as we can't control the data-label)
// If in always stacked mode, we don't bother rendering the row
if ($scoped['bottom-row'] && t.isStacked !== true) {
rows.push(
h(
'tr',
{ key: 'bottom-row', class: ['b-table-bottom-row', t.tbodyTrClass] },
[$scoped['bottom-row']({ columns: fields.length, fields: fields })]
)
)
} else {
rows.push(h(false))
}
// Assemble the rows into the tbody
const tbody = h(
'tbody',
{ class: t.bodyClasses, attrs: t.isStacked ? { role: 'rowgroup' } : {} },
rows
)
// Assemble table
const table = h(
'table',
{
class: t.tableClasses,
attrs: {
id: t.safeId(),
role: t.isStacked ? 'table' : null,
'aria-busy': t.computedBusy ? 'true' : 'false',
'aria-colcount': String(fields.length),
'aria-rowcount':
t.$attrs['aria-rowcount'] || (t.perPage && t.perPage > 0)
? '-1'
: null
}
},
[caption, colgroup, thead, tfoot, tbody]
)
// Add responsive wrapper if needed and return table
return t.isResponsive
? h('div', { class: t.responsiveClass }, [table])
: table
},
data () {
return {
localSortBy: this.sortBy || '',
localSortDesc: this.sortDesc || false,
localItems: [],
// Note: filteredItems only used to determine if # of items changed
filteredItems: [],
localBusy: false
}
},
props: {
items: {
type: [Array, Function],
default () {
return []
}
},
fields: {
type: [Object, Array],
default: null
},
sortBy: {
type: String,
default: null
},
sortDesc: {
type: Boolean,
default: false
},
caption: {
type: String,
default: null
},
captionTop: {
type: Boolean,
default: false
},
striped: {
type: Boolean,
default: false
},
bordered: {
type: Boolean,
default: false
},
outlined: {
type: Boolean,
default: false
},
dark: {
type: Boolean,
default () {
if (this && typeof this.inverse === 'boolean') {
// Deprecate inverse
warn(
"b-table: prop 'inverse' has been deprecated. Use 'dark' instead"
)
return this.dark
}
return false
}
},
inverse: {
// Deprecated in v1.0.0 in favor of `dark`
type: Boolean,
default: null
},
hover: {
type: Boolean,
default: false
},
small: {
type: Boolean,
default: false
},
fixed: {
type: Boolean,
default: false
},
footClone: {
type: Boolean,
default: false
},
responsive: {
type: [Boolean, String],
default: false
},
stacked: {
type: [Boolean, String],
default: false
},
headVariant: {
type: String,
default: ''
},
footVariant: {
type: String,
default: ''
},
theadClass: {
type: [String, Array],
default: null
},
theadTrClass: {
type: [String, Array],
default: null
},
tbodyClass: {
type: [String, Array],
default: null
},
tbodyTrClass: {
type: [String, Array],
default: null
},
tfootClass: {
type: [String, Array],
default: null
},
tfootTrClass: {
type: [String, Array],
default: null
},
perPage: {
type: Number,
default: 0
},
currentPage: {
type: Number,
default: 1
},
filter: {
type: [String, RegExp, Function],
default: null
},
sortCompare: {
type: Function,
default: null
},
noLocalSorting: {
type: Boolean,
default: false
},
noProviderPaging: {
type: Boolean,
default: false
},
noProviderSorting: {
type: Boolean,
default: false
},
noProviderFiltering: {
type: Boolean,
default: false
},
busy: {
type: Boolean,
default: false
},
value: {
type: Array,
default: () => []
},
labelSortAsc: {
type: String,
default: 'Click to sort Ascending'
},
labelSortDesc: {
type: String,
default: 'Click to sort Descending'
},
showEmpty: {
type: Boolean,
default: false
},
emptyText: {
type: String,
default: 'There are no records to show'
},
emptyFilteredText: {
type: String,
default: 'There are no records matching your request'
},
apiUrl: {
// Passthrough prop. Passed to the context object. Not used by b-table directly
type: String,
default: ''
}
},
watch: {
items (newVal, oldVal) {
if (oldVal !== newVal) {
this._providerUpdate()
}
},
context (newVal, oldVal) {
if (!looseEqual(newVal, oldVal)) {
this.$emit('context-changed', newVal)
}
},
filteredItems (newVal, oldVal) {
if (this.localFiltering && newVal.length !== oldVal.length) {
// Emit a filtered notification event, as number of filtered items has changed
this.$emit('filtered', newVal)
}
},
sortDesc (newVal, oldVal) {
if (newVal === this.localSortDesc) {
return
}
this.localSortDesc = newVal || false
},
localSortDesc (newVal, oldVal) {
// Emit update to sort-desc.sync
if (newVal !== oldVal) {
this.$emit('update:sortDesc', newVal)
if (!this.noProviderSorting) {
this._providerUpdate()
}
}
},
sortBy (newVal, oldVal) {
if (newVal === this.localSortBy) {
return
}
this.localSortBy = newVal || null
},
localSortBy (newVal, oldVal) {
if (newVal !== oldVal) {
this.$emit('update:sortBy', newVal)
if (!this.noProviderSorting) {
this._providerUpdate()
}
}
},
perPage (newVal, oldVal) {
if (oldVal !== newVal && !this.noProviderPaging) {
this._providerUpdate()
}
},
currentPage (newVal, oldVal) {
if (oldVal !== newVal && !this.noProviderPaging) {
this._providerUpdate()
}
},
filter (newVal, oldVal) {
if (oldVal !== newVal && !this.noProviderFiltering) {
this._providerUpdate()
}
},
localBusy (newVal, oldVal) {
if (newVal !== oldVal) {
this.$emit('update:busy', newVal)
}
}
},
mounted () {
this.localSortBy = this.sortBy
this.localSortDesc = this.sortDesc
if (this.hasProvider) {
this._providerUpdate()
}
this.listenOnRoot('bv::refresh::table', id => {
if (id === this.id || id === this) {
this._providerUpdate()
}
})
},
computed: {
isStacked () {
return this.stacked === '' ? true : this.stacked
},
isResponsive () {
const responsive = this.responsive === '' ? true : this.responsive
return this.isStacked ? false : responsive
},
responsiveClass () {
return this.isResponsive === true
? 'table-responsive'
: this.isResponsive ? `table-responsive-${this.responsive}` : ''
},
tableClasses () {
return [
'table',
'b-table',
this.striped ? 'table-striped' : '',
this.hover ? 'table-hover' : '',
this.dark ? 'table-dark' : '',
this.bordered ? 'table-bordered' : '',
this.small ? 'table-sm' : '',
this.outlined ? 'border' : '',
this.fixed ? 'b-table-fixed' : '',
this.isStacked === true
? 'b-table-stacked'
: this.isStacked ? `b-table-stacked-${this.stacked}` : ''
]
},
headClasses () {
return [
this.headVariant ? 'thead-' + this.headVariant : '',
this.theadClass
]
},
bodyClasses () {
return [this.tbodyClass]
},
footClasses () {
const variant = this.footVariant || this.headVariant || null
return [variant ? 'thead-' + variant : '', this.tfootClass]
},
captionStyles () {
// Move caption to top
return this.captionTop ? { captionSide: 'top' } : {}
},
hasProvider () {
return this.items instanceof Function
},
localFiltering () {
return this.hasProvider ? this.noProviderFiltering : true
},
localSorting () {
return this.hasProvider ? this.noProviderSorting : !this.noLocalSorting
},
localPaging () {
return this.hasProvider ? this.noProviderPaging : true
},
context () {
return {
perPage: this.perPage,
currentPage: this.currentPage,
filter: this.filter,
sortBy: this.localSortBy,
sortDesc: this.localSortDesc,
apiUrl: this.apiUrl
}
},
computedFields () {
// We normalize fields into an array of objects
// [ { key:..., label:..., ...}, {...}, ..., {..}]
let fields = []
if (isArray(this.fields)) {
// Normalize array Form
this.fields.filter(f => f).forEach(f => {
if (typeof f === 'string') {
fields.push({ key: f, label: startCase(f) })
} else if (
typeof f === 'object' &&
f.key &&
typeof f.key === 'string'
) {
// Full object definition. We use assign so that we don't mutate the original
fields.push(assign({}, f))
} else if (typeof f === 'object' && keys(f).length === 1) {
// Shortcut object (i.e. { 'foo_bar': 'This is Foo Bar' }
const key = keys(f)[0]
const field = processField(key, f[key])
if (field) {
fields.push(field)
}
}
})
} else if (
this.fields &&
typeof this.fields === 'object' &&
keys(this.fields).length > 0
) {
// Normalize object Form
keys(this.fields).forEach(key => {
let field = processField(key, this.fields[key])
if (field) {
fields.push(field)
}
})
}
// If no field provided, take a sample from first record (if exits)
if (fields.length === 0 && this.computedItems.length > 0) {
const sample = this.computedItems[0]
const ignoredKeys = [
'_rowVariant',
'_cellVariants',
'_showDetails'
]
keys(sample).forEach(k => {
if (!ignoredKeys.includes(k)) {
fields.push({ key: k, label: startCase(k) })
}
})
}
// Ensure we have a unique array of fields and that they have String labels
const memo = {}
return fields.filter(f => {
if (!memo[f.key]) {
memo[f.key] = true
f.label = typeof f.label === 'string' ? f.label : startCase(f.key)
return true
}
return false
})
},
computedItems () {
// Grab some props/data to ensure reactivity
const perPage = this.perPage
const currentPage = this.currentPage
const filter = this.filter
const sortBy = this.localSortBy
const sortDesc = this.localSortDesc
const sortCompare = this.sortCompare
const localFiltering = this.localFiltering
const localSorting = this.localSorting
const localPaging = this.localPaging
let items = this.hasProvider ? this.localItems : this.items
if (!items) {
this.$nextTick(this._providerUpdate)
return []
}
// Array copy for sorting, filtering, etc.
items = items.slice()
// Apply local filter
if (filter && localFiltering) {
if (filter instanceof Function) {
items = items.filter(filter)
} else {
let regex
if (filter instanceof RegExp) {
regex = filter
} else {
regex = new RegExp('.*' + filter + '.*', 'ig')
}
items = items.filter(item => {
const test = regex.test(recToString(item))
regex.lastIndex = 0
return test
})
}
}
if (localFiltering) {
// Make a local copy of filtered items to trigger filtered event
this.filteredItems = items.slice()
}
// Apply local Sort
if (sortBy && localSorting) {
items = stableSort(items, function sortItemsFn (a, b) {
let ret = null
if (typeof sortCompare === 'function') {
// Call user provided sortCompare routine
ret = sortCompare(a, b, sortBy)
}
if (ret === null || ret === undefined) {
// Fallback to defaultSortCompare if sortCompare not defined or returns null
ret = defaultSortCompare(a, b, sortBy)
}
// Handle sorting direction
return (ret || 0) * (sortDesc ? -1 : 1)
})
}
// Apply local pagination
if (Boolean(perPage) && localPaging) {
// Grab the current page of data (which may be past filtered items)
items = items.slice((currentPage - 1) * perPage, currentPage * perPage)
}
// Update the value model with the filtered/sorted/paginated data set
this.$emit('input', items)
return items
},
computedBusy () {
return this.busy || this.localBusy
}
},
methods: {
keys,
fieldClasses (field) {
return [
field.sortable ? 'sorting' : '',
field.sortable && this.localSortBy === field.key
? 'sorting_' + (this.localSortDesc ? 'desc' : 'asc')
: '',
field.variant ? 'table-' + field.variant : '',
field.class ? field.class : '',
field.thClass ? field.thClass : ''
]
},
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 : '',
field.tdClass ? field.tdClass : ''
]
},
rowClasses (item) {
return [
item._rowVariant
? `${this.dark ? 'bg' : 'table'}-${item._rowVariant}`
: '',
this.tbodyTrClass
]
},
rowClicked (e, item, index) {
if (this.stopIfBusy(e)) {
// If table is busy (via provider) then don't propagate
return
}
this.$emit('row-clicked', item, index, e)
},
rowDblClicked (e, item, index) {
if (this.stopIfBusy(e)) {
// If table is busy (via provider) then don't propagate
return
}
this.$emit('row-dblclicked', item, index, e)
},
rowHovered (e, item, index) {
if (this.stopIfBusy(e)) {
// If table is busy (via provider) then don't propagate
return
}
this.$emit('row-hovered', item, index, e)
},
headClicked (e, field) {
if (this.stopIfBusy(e)) {
// If table is busy (via provider) then don't propagate
return
}
let sortChanged = false
if (field.sortable) {
if (field.key === this.localSortBy) {
// Change sorting direction on current column
this.localSortDesc = !this.localSortDesc
} else {
// Start sorting this column ascending
this.localSortBy = field.key
this.localSortDesc = false
}
sortChanged = true
} else if (this.localSortBy) {
this.localSortBy = null
this.localSortDesc = false
sortChanged = true
}
this.$emit('head-clicked', field.key, field, e)
if (sortChanged) {
// Sorting parameters changed
this.$emit('sort-changed', this.context)
}
},
stopIfBusy (evt) {
if (this.computedBusy) {
// If table is busy (via provider) then don't propagate
evt.preventDefault()
evt.stopPropagation()
return true
}
return false
},
refresh () {
// Expose refresh method
if (this.hasProvider) {
this._providerUpdate()
}
},
_providerSetLocal (items) {
this.localItems = items && items.length > 0 ? items.slice() : []
this.localBusy = false
this.$emit('refreshed')
// Deprecated root emit
this.emitOnRoot('table::refreshed', this.id)
// New root emit
if (this.id) {
this.emitOnRoot('bv::table::refreshed', this.id)
}
},
_providerUpdate () {
// Refresh the provider items
if (this.computedBusy || !this.hasProvider) {
// Don't refresh remote data if we are 'busy' or if no provider
return
}
// Set internal busy state
this.localBusy = true
// Call provider function with context and optional callback
const data = this.items(this.context, this._providerSetLocal)
if (data && data.then && typeof data.then === 'function') {
// Provider returned Promise
data.then(items => {
this._providerSetLocal(items)
})
} else {
// Provider returned Array data
this._providerSetLocal(data)
}
},
getFormattedValue (item, field) {
const key = field.key
const formatter = field.formatter
const parent = this.$parent
let value = get(item, key)
if (formatter) {
if (typeof formatter === 'function') {
value = formatter(value, key, item)
} else if (
typeof formatter === 'string' &&
typeof parent[formatter] === 'function'
) {
value = parent[formatter](value, key, item)
}
}
return value
}
}
}