bootstrap-vue
Version:
With more than 85 components, over 45 available plugins, several directives, and 1000+ icons, BootstrapVue provides one of the most comprehensive implementations of the Bootstrap v4 component and grid system available for Vue.js v2.6, complete with extens
713 lines (691 loc) • 21 kB
JavaScript
import Vue from '../../vue'
import { NAME_TABS, NAME_TAB_BUTTON_HELPER } from '../../constants/components'
import {
CODE_DOWN,
CODE_END,
CODE_HOME,
CODE_LEFT,
CODE_RIGHT,
CODE_SPACE,
CODE_UP
} from '../../constants/key-codes'
import { SLOT_NAME_TITLE } from '../../constants/slot-names'
import identity from '../../utils/identity'
import looseEqual from '../../utils/loose-equal'
import observeDom from '../../utils/observe-dom'
import stableSort from '../../utils/stable-sort'
import { arrayIncludes, concat } from '../../utils/array'
import { BvEvent } from '../../utils/bv-event.class'
import { attemptFocus, requestAF, selectAll } from '../../utils/dom'
import { stopEvent } from '../../utils/events'
import { isEvent } from '../../utils/inspect'
import { mathMax } from '../../utils/math'
import { toInteger } from '../../utils/number'
import { omit } from '../../utils/object'
import idMixin from '../../mixins/id'
import normalizeSlotMixin from '../../mixins/normalize-slot'
import { BLink } from '../link/link'
import { BNav, props as BNavProps } from '../nav/nav'
// --- Constants ---
const navProps = omit(BNavProps, ['tabs', 'isNavBar', 'cardHeader'])
// --- Helper methods ---
// Filter function to filter out disabled tabs
const notDisabled = tab => !tab.disabled
// --- Helper components ---
// @vue/component
const BVTabButton = /*#__PURE__*/ Vue.extend({
name: NAME_TAB_BUTTON_HELPER,
inject: {
bvTabs: {
/* istanbul ignore next */
default() /* istanbul ignore next */ {
return {}
}
}
},
props: {
// Reference to the child <b-tab> instance
tab: { default: null },
tabs: {
type: Array,
/* istanbul ignore next */
default() /* istanbul ignore next */ {
return []
}
},
id: { type: String, default: null },
controls: { type: String, default: null },
tabIndex: { type: Number, default: null },
posInSet: { type: Number, default: null },
setSize: { type: Number, default: null },
noKeyNav: { type: Boolean, default: false }
},
methods: {
focus() {
attemptFocus(this.$refs.link)
},
handleEvt(evt) {
if (this.tab.disabled) {
/* istanbul ignore next */
return
}
const { type, keyCode, shiftKey } = evt
if (type === 'click') {
stopEvent(evt)
this.$emit('click', evt)
} else if (type === 'keydown' && keyCode === CODE_SPACE) {
// For ARIA tabs the SPACE key will also trigger a click/select
// Even with keyboard navigation disabled, SPACE should "click" the button
// See: https://github.com/bootstrap-vue/bootstrap-vue/issues/4323
stopEvent(evt)
this.$emit('click', evt)
} else if (type === 'keydown' && !this.noKeyNav) {
// For keyboard navigation
if ([CODE_UP, CODE_LEFT, CODE_HOME].indexOf(keyCode) !== -1) {
stopEvent(evt)
if (shiftKey || keyCode === CODE_HOME) {
this.$emit('first', evt)
} else {
this.$emit('prev', evt)
}
} else if ([CODE_DOWN, CODE_RIGHT, CODE_END].indexOf(keyCode) !== -1) {
stopEvent(evt)
if (shiftKey || keyCode === CODE_END) {
this.$emit('last', evt)
} else {
this.$emit('next', evt)
}
}
}
}
},
render(h) {
const { id, tabIndex, setSize, posInSet, controls, handleEvt } = this
const {
title,
localActive,
disabled,
titleItemClass,
titleLinkClass,
titleLinkAttributes
} = this.tab
const $link = h(
BLink,
{
ref: 'link',
staticClass: 'nav-link',
class: [
{
active: localActive && !disabled,
disabled
},
titleLinkClass,
// Apply <b-tabs> `activeNavItemClass` styles when the tab is active
localActive ? this.bvTabs.activeNavItemClass : null
],
props: { disabled },
attrs: {
...titleLinkAttributes,
role: 'tab',
id,
// Roving tab index when keynav enabled
tabindex: tabIndex,
'aria-selected': localActive && !disabled ? 'true' : 'false',
'aria-setsize': setSize,
'aria-posinset': posInSet,
'aria-controls': controls
},
on: {
click: handleEvt,
keydown: handleEvt
}
},
[this.tab.normalizeSlot(SLOT_NAME_TITLE) || title]
)
return h(
'li',
{
staticClass: 'nav-item',
class: [titleItemClass],
attrs: { role: 'presentation' }
},
[$link]
)
}
})
// @vue/component
export const BTabs = /*#__PURE__*/ Vue.extend({
name: NAME_TABS,
mixins: [idMixin, normalizeSlotMixin],
provide() {
return {
bvTabs: this
}
},
model: {
prop: 'value',
event: 'input'
},
props: {
...navProps,
tag: {
type: String,
default: 'div'
},
card: {
type: Boolean,
default: false
},
end: {
// Synonym for 'bottom'
type: Boolean,
default: false
},
noFade: {
type: Boolean,
default: false
},
noNavStyle: {
type: Boolean,
default: false
},
noKeyNav: {
type: Boolean,
default: false
},
lazy: {
// This prop is sniffed by the <b-tab> child
type: Boolean,
default: false
},
contentClass: {
type: [String, Array, Object]
// default: null
},
navClass: {
type: [String, Array, Object]
// default: null
},
navWrapperClass: {
type: [String, Array, Object]
// default: null
},
activeNavItemClass: {
// Only applied to the currently active <b-nav-item>
type: [String, Array, Object]
// default: null
},
activeTabClass: {
// Only applied to the currently active <b-tab>
// This prop is sniffed by the <b-tab> child
type: [String, Array, Object]
// default: null
},
value: {
// v-model
type: Number,
default: null
}
},
data() {
return {
// Index of current tab
currentTab: toInteger(this.value, -1),
// Array of direct child <b-tab> instances, in DOM order
tabs: [],
// Array of child instances registered (for triggering reactive updates)
registeredTabs: [],
// Flag to know if we are mounted or not
isMounted: false
}
},
computed: {
fade() {
// This computed prop is sniffed by the tab child
return !this.noFade
},
localNavClass() {
const classes = []
if (this.card && this.vertical) {
classes.push('card-header', 'h-100', 'border-bottom-0', 'rounded-0')
}
return [...classes, this.navClass]
}
},
watch: {
currentTab(newVal) {
let index = -1
// Ensure only one tab is active at most
this.tabs.forEach((tab, idx) => {
if (newVal === idx && !tab.disabled) {
tab.localActive = true
index = idx
} else {
tab.localActive = false
}
})
// Update the v-model
this.$emit('input', index)
},
value(newVal, oldVal) {
if (newVal !== oldVal) {
newVal = toInteger(newVal, -1)
oldVal = toInteger(oldVal, 0)
const tabs = this.tabs
if (tabs[newVal] && !tabs[newVal].disabled) {
this.activateTab(tabs[newVal])
} else {
// Try next or prev tabs
if (newVal < oldVal) {
this.previousTab()
} else {
this.nextTab()
}
}
}
},
registeredTabs() {
// Each b-tab will register/unregister itself.
// We use this to detect when tabs are added/removed
// to trigger the update of the tabs.
this.$nextTick(() => {
requestAF(() => {
this.updateTabs()
})
})
},
tabs(newVal, oldVal) {
// If tabs added, removed, or re-ordered, we emit a `changed` event.
// We use `tab._uid` instead of `tab.safeId()`, as the later is changed
// in a nextTick if no explicit ID is provided, causing duplicate emits.
if (!looseEqual(newVal.map(t => t._uid), oldVal.map(t => t._uid))) {
// In a nextTick to ensure currentTab has been set first.
this.$nextTick(() => {
// We emit shallow copies of the new and old arrays of tabs, to
// prevent users from potentially mutating the internal arrays.
this.$emit('changed', newVal.slice(), oldVal.slice())
})
}
},
isMounted(newVal) {
// Trigger an update after mounted. Needed for tabs inside lazy modals.
if (newVal) {
requestAF(() => {
this.updateTabs()
})
}
// Enable or disable the observer
this.setObserver(newVal)
}
},
created() {
// Create private non-reactive props
this.$_observer = null
this.currentTab = toInteger(this.value, -1)
// For SSR and to make sure only a single tab is shown on mount
// We wrap this in a `$nextTick()` to ensure the child tabs have been created
this.$nextTick(() => {
this.updateTabs()
})
},
mounted() {
// Call `updateTabs()` just in case...
this.updateTabs()
this.$nextTick(() => {
// Flag we are now mounted and to switch to DOM for tab probing.
// As this.$slots.default appears to lie about component instances
// after b-tabs is destroyed and re-instantiated.
// And this.$children does not respect DOM order.
this.isMounted = true
})
},
/* istanbul ignore next */
deactivated() /* istanbul ignore next */ {
this.isMounted = false
},
/* istanbul ignore next */
activated() /* istanbul ignore next */ {
this.currentTab = toInteger(this.value, -1)
this.$nextTick(() => {
this.updateTabs()
this.isMounted = true
})
},
beforeDestroy() {
this.isMounted = false
},
destroyed() {
// Ensure no references to child instances exist
this.tabs = []
},
methods: {
registerTab(tab) {
if (!arrayIncludes(this.registeredTabs, tab)) {
this.registeredTabs.push(tab)
tab.$once('hook:destroyed', () => {
this.unregisterTab(tab)
})
}
},
unregisterTab(tab) {
this.registeredTabs = this.registeredTabs.slice().filter(t => t !== tab)
},
// DOM observer is needed to detect changes in order of tabs
setObserver(on) {
this.$_observer && this.$_observer.disconnect()
this.$_observer = null
if (on) {
const self = this
/* istanbul ignore next: difficult to test mutation observer in JSDOM */
const handler = () => {
// We delay the update to ensure that `tab.safeId()` has
// updated with the final ID value.
self.$nextTick(() => {
requestAF(() => {
self.updateTabs()
})
})
}
// Watch for changes to <b-tab> sub components
this.$_observer = observeDom(this.$refs.tabsContainer, handler, {
childList: true,
subtree: false,
attributes: true,
attributeFilter: ['id']
})
}
},
getTabs() {
// We use `registeredTabs` as the source of truth for child tab components
// We also filter out any `<b-tab>` components that are extended
// `<b-tab>` with a root child `<b-tab>`
// See: https://github.com/bootstrap-vue/bootstrap-vue/issues/3260
const tabs = this.registeredTabs.filter(
tab => tab.$children.filter(t => t._isTab).length === 0
)
// DOM Order of Tabs
let order = []
if (this.isMounted && tabs.length > 0) {
// We rely on the DOM when mounted to get the 'true' order of the `<b-tab>` children
// `querySelectorAll()` always returns elements in document order, regardless of
// order specified in the selector
const selector = tabs.map(tab => `#${tab.safeId()}`).join(', ')
order = selectAll(selector, this.$el)
.map(el => el.id)
.filter(identity)
}
// Stable sort keeps the original order if not found in the `order` array,
// which will be an empty array before mount
return stableSort(tabs, (a, b) => order.indexOf(a.safeId()) - order.indexOf(b.safeId()))
},
// Update list of `<b-tab>` children
updateTabs() {
// Probe tabs
const tabs = this.getTabs()
// Find *last* active non-disabled tab in current tabs
// We trust tab state over `currentTab`, in case tabs were added/removed/re-ordered
let tabIndex = tabs.indexOf(
tabs
.slice()
.reverse()
.find(tab => tab.localActive && !tab.disabled)
)
// Else try setting to `currentTab`
if (tabIndex < 0) {
const currentTab = this.currentTab
if (currentTab >= tabs.length) {
// Handle last tab being removed, so find the last non-disabled tab
tabIndex = tabs.indexOf(
tabs
.slice()
.reverse()
.find(notDisabled)
)
} else if (tabs[currentTab] && !tabs[currentTab].disabled) {
// Current tab is not disabled
tabIndex = currentTab
}
}
// Else find *first* non-disabled tab in current tabs
if (tabIndex < 0) {
tabIndex = tabs.indexOf(tabs.find(notDisabled))
}
// Set the current tab state to active
tabs.forEach(tab => {
// tab.localActive = idx === tabIndex && !tab.disabled
tab.localActive = false
})
if (tabs[tabIndex]) {
tabs[tabIndex].localActive = true
}
// Update the array of tab children
this.tabs = tabs
// Set the currentTab index (can be -1 if no non-disabled tabs)
this.currentTab = tabIndex
},
// Find a button that controls a tab, given the tab reference
// Returns the button vm instance
getButtonForTab(tab) {
return (this.$refs.buttons || []).find(btn => btn.tab === tab)
},
// Force a button to re-render its content, given a <b-tab> instance
// Called by <b-tab> on `update()`
updateButton(tab) {
const button = this.getButtonForTab(tab)
if (button && button.$forceUpdate) {
button.$forceUpdate()
}
},
// Activate a tab given a `<b-tab>` instance
// Also accessed by `<b-tab>`
activateTab(tab) {
let result = false
if (tab) {
const index = this.tabs.indexOf(tab)
if (!tab.disabled && index > -1 && index !== this.currentTab) {
const tabEvt = new BvEvent('activate-tab', {
cancelable: true,
vueTarget: this,
componentId: this.safeId()
})
this.$emit(tabEvt.type, index, this.currentTab, tabEvt)
if (!tabEvt.defaultPrevented) {
result = true
this.currentTab = index
}
}
}
// Couldn't set tab, so ensure v-model is set to `this.currentTab`
/* istanbul ignore next: should rarely happen */
if (!result && this.currentTab !== this.value) {
this.$emit('input', this.currentTab)
}
return result
},
// Deactivate a tab given a <b-tab> instance
// Accessed by <b-tab>
deactivateTab(tab) {
if (tab) {
// Find first non-disabled tab that isn't the one being deactivated
// If no tabs are available, then don't deactivate current tab
return this.activateTab(this.tabs.filter(t => t !== tab).find(notDisabled))
}
/* istanbul ignore next: should never/rarely happen */
return false
},
// Focus a tab button given its <b-tab> instance
focusButton(tab) {
// Wrap in `$nextTick()` to ensure DOM has completed rendering/updating before focusing
this.$nextTick(() => {
attemptFocus(this.getButtonForTab(tab))
})
},
// Emit a click event on a specified <b-tab> component instance
emitTabClick(tab, evt) {
if (isEvent(evt) && tab && tab.$emit && !tab.disabled) {
tab.$emit('click', evt)
}
},
// Click handler
clickTab(tab, evt) {
this.activateTab(tab)
this.emitTabClick(tab, evt)
},
// Move to first non-disabled tab
firstTab(focus) {
const tab = this.tabs.find(notDisabled)
if (this.activateTab(tab) && focus) {
this.focusButton(tab)
this.emitTabClick(tab, focus)
}
},
// Move to previous non-disabled tab
previousTab(focus) {
const currentIndex = mathMax(this.currentTab, 0)
const tab = this.tabs
.slice(0, currentIndex)
.reverse()
.find(notDisabled)
if (this.activateTab(tab) && focus) {
this.focusButton(tab)
this.emitTabClick(tab, focus)
}
},
// Move to next non-disabled tab
nextTab(focus) {
const currentIndex = mathMax(this.currentTab, -1)
const tab = this.tabs.slice(currentIndex + 1).find(notDisabled)
if (this.activateTab(tab) && focus) {
this.focusButton(tab)
this.emitTabClick(tab, focus)
}
},
// Move to last non-disabled tab
lastTab(focus) {
const tab = this.tabs
.slice()
.reverse()
.find(notDisabled)
if (this.activateTab(tab) && focus) {
this.focusButton(tab)
this.emitTabClick(tab, focus)
}
}
},
render(h) {
const { tabs, noKeyNav, firstTab, previousTab, nextTab, lastTab } = this
// Currently active tab
const activeTab = tabs.find(tab => tab.localActive && !tab.disabled)
// Tab button to allow focusing when no active tab found (keynav only)
const fallbackTab = tabs.find(tab => !tab.disabled)
// For each `<b-tab>` found create the tab buttons
const buttons = tabs.map((tab, index) => {
let tabIndex = null
// Ensure at least one tab button is focusable when keynav enabled (if possible)
if (!noKeyNav) {
// Buttons are not in tab index unless active, or a fallback tab
tabIndex = -1
if (activeTab === tab || (!activeTab && fallbackTab === tab)) {
// Place tab button in tab sequence
tabIndex = null
}
}
return h(BVTabButton, {
key: tab._uid || index,
ref: 'buttons',
// Needed to make `this.$refs.buttons` an array
refInFor: true,
props: {
tab,
tabs,
id: tab.controlledBy || (tab.safeId ? tab.safeId(`_BV_tab_button_`) : null),
controls: tab.safeId ? tab.safeId() : null,
tabIndex,
setSize: tabs.length,
posInSet: index + 1,
noKeyNav
},
on: {
click: evt => {
this.clickTab(tab, evt)
},
first: firstTab,
prev: previousTab,
next: nextTab,
last: lastTab
}
})
})
// Nav
let nav = h(
BNav,
{
ref: 'nav',
class: this.localNavClass,
attrs: {
role: 'tablist',
id: this.safeId('_BV_tab_controls_')
},
props: {
fill: this.fill,
justified: this.justified,
align: this.align,
tabs: !this.noNavStyle && !this.pills,
pills: !this.noNavStyle && this.pills,
vertical: this.vertical,
small: this.small,
cardHeader: this.card && !this.vertical
}
},
[this.normalizeSlot('tabs-start') || h(), buttons, this.normalizeSlot('tabs-end') || h()]
)
nav = h(
'div',
{
key: 'bv-tabs-nav',
class: [
{
'card-header': this.card && !this.vertical && !this.end,
'card-footer': this.card && !this.vertical && this.end,
'col-auto': this.vertical
},
this.navWrapperClass
]
},
[nav]
)
let empty = h()
if (!tabs || tabs.length === 0) {
empty = h(
'div',
{ key: 'bv-empty-tab', class: ['tab-pane', 'active', { 'card-body': this.card }] },
this.normalizeSlot('empty')
)
}
// Main content section
const content = h(
'div',
{
ref: 'tabsContainer',
key: 'bv-tabs-container',
staticClass: 'tab-content',
class: [{ col: this.vertical }, this.contentClass],
attrs: { id: this.safeId('_BV_tab_container_') }
},
concat(this.normalizeSlot(), empty)
)
// Render final output
return h(
this.tag,
{
staticClass: 'tabs',
class: {
row: this.vertical,
'no-gutters': this.vertical && this.card
},
attrs: { id: this.safeId() }
},
[this.end ? content : h(), [nav], this.end ? h() : content]
)
}
})