bootstrap-vue
Version:
BootstrapVue, with more than 85 custom components, over 45 plugins, several custom directives, and over 300 icons, provides one of the most comprehensive implementations of Bootstrap v4 components and grid system for Vue.js. With extensive and automated W
461 lines (438 loc) • 11.5 kB
JavaScript
import Vue from '../../utils/vue'
import KeyCodes from '../../utils/key-codes'
import BVTransition from '../../utils/bv-transition'
import { contains, getTabables } from '../../utils/dom'
import { getComponentConfig } from '../../utils/config'
import { toString } from '../../utils/string'
import idMixin from '../../mixins/id'
import listenOnRootMixin from '../../mixins/listen-on-root'
import normalizeSlotMixin from '../../mixins/normalize-slot'
import {
EVENT_TOGGLE,
EVENT_STATE,
EVENT_STATE_REQUEST,
EVENT_STATE_SYNC
} from '../../directives/toggle/toggle'
import { BButtonClose } from '../button/button-close'
import { BIconX } from '../../icons/icons'
// --- Constants ---
const NAME = 'BSidebar'
const CLASS_NAME = 'b-sidebar'
// --- Render methods ---
const renderHeaderTitle = (h, ctx) => {
const title = ctx.normalizeSlot('title', ctx.slotScope) || toString(ctx.title) || null
// Render a empty `<span>` when to title was provided
if (!title) {
return h('span')
}
return h('strong', { attrs: { id: ctx.safeId('__title__') } }, [title])
}
const renderHeaderClose = (h, ctx) => {
if (ctx.noHeaderClose) {
return h()
}
const { closeLabel, textVariant, hide } = ctx
return h(
BButtonClose,
{
ref: 'close-button',
props: { ariaLabel: closeLabel, textVariant },
on: { click: hide }
},
[ctx.normalizeSlot('header-close') || h(BIconX)]
)
}
const renderHeader = (h, ctx) => {
if (ctx.noHeader) {
return h()
}
const $title = renderHeaderTitle(h, ctx)
const $close = renderHeaderClose(h, ctx)
return h(
'header',
{
key: 'header',
staticClass: `${CLASS_NAME}-header`,
class: ctx.headerClass
},
ctx.right ? [$close, $title] : [$title, $close]
)
}
const renderBody = (h, ctx) => {
return h(
'div',
{
key: 'body',
staticClass: `${CLASS_NAME}-body`,
class: ctx.bodyClass
},
[ctx.normalizeSlot('default', ctx.slotScope)]
)
}
const renderFooter = (h, ctx) => {
const $footer = ctx.normalizeSlot('footer', ctx.slotScope)
if (!$footer) {
return h()
}
return h(
'footer',
{
key: 'footer',
staticClass: `${CLASS_NAME}-footer`,
class: ctx.footerClass
},
[$footer]
)
}
const renderContent = (h, ctx) => {
// We render the header even if `lazy` is enabled as it
// acts as the accessible label for the sidebar
const $header = renderHeader(h, ctx)
if (ctx.lazy && !ctx.isOpen) {
return $header
}
return [$header, renderBody(h, ctx), renderFooter(h, ctx)]
}
const renderBackdrop = (h, ctx) => {
if (!ctx.backdrop) {
return h()
}
return h('div', {
directives: [{ name: 'show', value: ctx.localShow }],
staticClass: 'b-sidebar-backdrop',
on: { click: ctx.onBackdropClick }
})
}
// --- Main component ---
// @vue/component
export const BSidebar = /*#__PURE__*/ Vue.extend({
name: NAME,
mixins: [idMixin, listenOnRootMixin, normalizeSlotMixin],
inheritAttrs: false,
model: {
prop: 'visible',
event: 'change'
},
props: {
title: {
type: String
// default: null
},
right: {
type: Boolean,
default: false
},
bgVariant: {
type: String,
default: () => getComponentConfig(NAME, 'bgVariant')
},
textVariant: {
type: String,
default: () => getComponentConfig(NAME, 'textVariant')
},
shadow: {
type: [Boolean, String],
default: () => getComponentConfig(NAME, 'shadow')
},
width: {
type: String,
default: () => getComponentConfig(NAME, 'width')
},
zIndex: {
type: [Number, String]
// default: null
},
ariaLabel: {
type: String
// default: null
},
ariaLabelledby: {
type: String
// default: null
},
closeLabel: {
// `aria-label` for close button
// Defaults to 'Close'
type: String
// default: undefined
},
tag: {
type: String,
default: () => getComponentConfig(NAME, 'tag')
},
sidebarClass: {
type: [String, Array, Object]
// default: null
},
headerClass: {
type: [String, Array, Object]
// default: null
},
bodyClass: {
type: [String, Array, Object]
// default: null
},
footerClass: {
type: [String, Array, Object]
// default: null
},
backdrop: {
// If true, shows a basic backdrop
type: Boolean,
default: false
},
noSlide: {
type: Boolean,
default: false
},
noHeader: {
type: Boolean,
default: false
},
noHeaderClose: {
type: Boolean,
default: false
},
noCloseOnEsc: {
type: Boolean,
default: false
},
noCloseOnBackdrop: {
type: Boolean,
default: false
},
noCloseOnRouteChange: {
type: Boolean,
default: false
},
lazy: {
type: Boolean,
default: false
},
visible: {
type: Boolean,
default: false
}
},
data() {
return {
// Internal `v-model` state
localShow: !!this.visible,
// For lazy render triggering
isOpen: !!this.visible
}
},
computed: {
transitionProps() {
return this.noSlide
? /* istanbul ignore next */ { css: true }
: {
css: true,
enterClass: '',
enterActiveClass: 'slide',
enterToClass: 'show',
leaveClass: 'show',
leaveActiveClass: 'slide',
leaveToClass: ''
}
},
slotScope() {
return {
visible: this.localShow,
right: this.right,
hide: this.hide
}
}
},
watch: {
visible(newVal, oldVal) {
if (newVal !== oldVal) {
this.localShow = newVal
}
},
localShow(newVal, oldVal) {
if (newVal !== oldVal) {
this.emitState(newVal)
this.$emit('change', newVal)
}
},
/* istanbul ignore next */
$route(newVal = {}, oldVal = {}) /* istanbul ignore next: pain to mock */ {
if (!this.noCloseOnRouteChange && newVal.fullPath !== oldVal.fullPath) {
this.hide()
}
}
},
created() {
// Define non-reactive properties
this.$_returnFocusEl = null
},
mounted() {
// Add `$root` listeners
this.listenOnRoot(EVENT_TOGGLE, this.handleToggle)
this.listenOnRoot(EVENT_STATE_REQUEST, this.handleSync)
// Send out a gratuitous state event to ensure toggle button is synced
this.$nextTick(() => {
this.emitState(this.localShow)
})
},
/* istanbul ignore next */
activated() /* istanbul ignore next */ {
this.emitSync()
},
beforeDestroy() {
this.localShow = false
this.$_returnFocusEl = null
},
methods: {
hide() {
this.localShow = false
},
emitState(state = this.localShow) {
this.emitOnRoot(EVENT_STATE, this.safeId(), state)
},
emitSync(state = this.localShow) {
this.emitOnRoot(EVENT_STATE_SYNC, this.safeId(), state)
},
handleToggle(id) {
// Note `safeId()` can be null until after mount
if (id && id === this.safeId()) {
this.localShow = !this.localShow
}
},
handleSync(id) {
// Note `safeId()` can be null until after mount
if (id && id === this.safeId()) {
this.$nextTick(() => {
this.emitSync(this.localShow)
})
}
},
onKeydown(evt) {
const { keyCode } = evt
if (!this.noCloseOnEsc && keyCode === KeyCodes.ESC && this.localShow) {
this.hide()
}
},
onBackdropClick() {
if (this.localShow && !this.noCloseOnBackdrop) {
this.hide()
}
},
/* istanbul ignore next */
onTopTrapFocus() /* istanbul ignore next */ {
const tabables = getTabables(this.$refs.content)
try {
tabables.reverse()[0].focus()
} catch {}
},
/* istanbul ignore next */
onBottomTrapFocus() /* istanbul ignore next */ {
const tabables = getTabables(this.$refs.content)
try {
tabables[0].focus()
} catch {}
},
onBeforeEnter() {
this.$_returnFocusEl = null
try {
this.$_returnFocusEl = document.activeElement || null
} catch {}
// Trigger lazy render
this.isOpen = true
},
onAfterEnter(el) {
try {
if (!contains(el, document.activeElement)) {
el.focus()
}
} catch {}
this.$emit('shown')
},
onAfterLeave() {
try {
this.$_returnFocusEl.focus()
} catch {}
this.$_returnFocusEl = null
// Trigger lazy render
this.isOpen = false
this.$emit('hidden')
}
},
render(h) {
const localShow = this.localShow
const shadow = this.shadow === '' ? true : this.shadow
const title = this.normalizeSlot('title', this.slotScope) || toString(this.title) || null
const titleId = title ? this.safeId('__title__') : null
const ariaLabel = this.ariaLabel || null
// `ariaLabel` takes precedence over `ariaLabelledby`
const ariaLabelledby = this.ariaLabelledby || titleId || null
let $sidebar = h(
this.tag,
{
ref: 'content',
directives: [{ name: 'show', value: localShow }],
staticClass: CLASS_NAME,
class: [
{
shadow: shadow === true,
[`shadow-${shadow}`]: shadow && shadow !== true,
[`${CLASS_NAME}-right`]: this.right,
[`bg-${this.bgVariant}`]: !!this.bgVariant,
[`text-${this.textVariant}`]: !!this.textVariant
},
this.sidebarClass
],
attrs: {
...this.$attrs,
id: this.safeId(),
tabindex: '-1',
role: 'dialog',
'aria-modal': this.backdrop ? 'true' : 'false',
'aria-hidden': localShow ? null : 'true',
'aria-label': ariaLabel,
'aria-labelledby': ariaLabelledby
},
style: { width: this.width }
},
[renderContent(h, this)]
)
$sidebar = h(
'transition',
{
props: this.transitionProps,
on: {
beforeEnter: this.onBeforeEnter,
afterEnter: this.onAfterEnter,
afterLeave: this.onAfterLeave
}
},
[$sidebar]
)
const $backdrop = h(BVTransition, { props: { noFade: this.noSlide } }, [
renderBackdrop(h, this)
])
let $tabTrapTop = h()
let $tabTrapBottom = h()
if (this.backdrop && this.localShow) {
$tabTrapTop = h('div', {
attrs: { tabindex: '0' },
on: { focus: this.onTopTrapFocus }
})
$tabTrapBottom = h('div', {
attrs: { tabindex: '0' },
on: { focus: this.onBottomTrapFocus }
})
}
return h(
'div',
{
staticClass: 'b-sidebar-outer',
style: { zIndex: this.zIndex },
attrs: { tabindex: '-1' },
on: { keydown: this.onKeydown }
},
[$tabTrapTop, $sidebar, $tabTrapBottom, $backdrop]
)
}
})