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.
447 lines (438 loc) • 11.6 kB
JavaScript
import Vue from '../../utils/vue'
import { Portal, Wormhole } from 'portal-vue'
import BvEvent from '../../utils/bv-event.class'
import BVTransition from '../../utils/bv-transition'
import { getComponentConfig } from '../../utils/config'
import { requestAF, eventOn, eventOff } from '../../utils/dom'
import idMixin from '../../mixins/id'
import listenOnRootMixin from '../../mixins/listen-on-root'
import normalizeSlotMixin from '../../mixins/normalize-slot'
import { BToaster } from './toaster'
import { BButtonClose } from '../button/button-close'
import { BLink } from '../link/link'
// --- Constants ---
const NAME = 'BToast'
const MIN_DURATION = 1000
const EVENT_OPTIONS = { passive: true, capture: false }
// --- Props ---
export const props = {
id: {
// Even though the ID prop is provided by idMixin, we
// add it here for $bvToast props filtering
type: String,
default: null
},
title: {
type: String,
default: null
},
toaster: {
type: String,
default: () => getComponentConfig(NAME, 'toaster')
},
visible: {
type: Boolean,
default: false
},
variant: {
type: String,
default: () => getComponentConfig(NAME, 'variant')
},
isStatus: {
// Switches role to 'status' and aria-live to 'polite'
type: Boolean,
default: false
},
appendToast: {
type: Boolean,
default: false
},
noAutoHide: {
type: Boolean,
default: false
},
autoHideDelay: {
type: [Number, String],
default: () => getComponentConfig(NAME, 'autoHideDelay')
},
noCloseButton: {
type: Boolean,
default: false
},
noFade: {
type: Boolean,
default: false
},
noHoverPause: {
type: Boolean,
default: false
},
solid: {
type: Boolean,
default: false
},
toastClass: {
type: [String, Object, Array],
default: () => getComponentConfig(NAME, 'toastClass')
},
headerClass: {
type: [String, Object, Array],
default: () => getComponentConfig(NAME, 'headerClass')
},
bodyClass: {
type: [String, Object, Array],
default: () => getComponentConfig(NAME, 'bodyClass')
},
href: {
type: String,
default: null
},
to: {
type: [String, Object],
default: null
},
static: {
// Render the toast in place, rather than in a portal-target
type: Boolean,
default: false
}
}
// @vue/component
export const BToast = /*#__PURE__*/ Vue.extend({
name: NAME,
mixins: [idMixin, listenOnRootMixin, normalizeSlotMixin],
inheritAttrs: false,
model: {
prop: 'visible',
event: 'change'
},
props,
data() {
return {
isMounted: false,
doRender: false,
localShow: false,
isTransitioning: false,
isHiding: false,
order: 0,
timer: null,
dismissStarted: 0,
resumeDismiss: 0
}
},
computed: {
bToastClasses() {
return {
'b-toast-solid': this.solid,
'b-toast-append': this.appendToast,
'b-toast-prepend': !this.appendToast,
[`b-toast-${this.variant}`]: this.variant
}
},
slotScope() {
return {
hide: this.hide
}
},
computedDuration() {
// Minimum supported duration is 1 second
return Math.max(parseInt(this.autoHideDelay, 10) || 0, MIN_DURATION)
},
computedToaster() {
return String(this.toaster)
},
transitionHandlers() {
return {
beforeEnter: this.onBeforeEnter,
afterEnter: this.onAfterEnter,
beforeLeave: this.onBeforeLeave,
afterLeave: this.onAfterLeave
}
}
},
watch: {
visible(newVal) {
newVal ? this.show() : this.hide()
},
localShow(newVal) {
if (newVal !== this.visible) {
this.$emit('change', newVal)
}
},
toaster(newVal) /* istanbul ignore next */ {
// If toaster target changed, make sure toaster exists
this.$nextTick(() => this.ensureToaster)
},
static(newVal) /* istanbul ignore next */ {
// If static changes to true, and the toast is showing,
// ensure the toaster target exists
if (newVal && this.localShow) {
this.ensureToaster()
}
}
},
mounted() {
this.isMounted = true
this.$nextTick(() => {
if (this.visible) {
requestAF(() => {
this.show()
})
}
})
// Listen for global $root show events
this.listenOnRoot('bv::show::toast', id => {
if (id === this.safeId()) {
this.show()
}
})
// Listen for global $root hide events
this.listenOnRoot('bv::hide::toast', id => {
if (!id || id === this.safeId()) {
this.hide()
}
})
// Make sure we hide when toaster is destroyed
/* istanbul ignore next: difficult to test */
this.listenOnRoot('bv::toaster::destroyed', toaster => {
if (toaster === this.computedToaster) {
this.hide()
}
})
},
beforeDestroy() {
this.clearDismissTimer()
},
methods: {
show() {
if (!this.localShow) {
this.ensureToaster()
const showEvt = this.buildEvent('show')
this.emitEvent(showEvt)
this.dismissStarted = this.resumeDismiss = 0
this.order = Date.now() * (this.appendToast ? 1 : -1)
this.isHiding = false
this.doRender = true
this.$nextTick(() => {
// We show the toast after we have rendered the portal and b-toast wrapper
// so that screen readers will properly announce the toast
requestAF(() => {
this.localShow = true
})
})
}
},
hide() {
if (this.localShow) {
const hideEvt = this.buildEvent('hide')
this.emitEvent(hideEvt)
this.setHoverHandler(false)
this.dismissStarted = this.resumeDismiss = 0
this.clearDismissTimer()
this.isHiding = true
requestAF(() => {
this.localShow = false
})
}
},
buildEvent(type, opts = {}) {
return new BvEvent(type, {
cancelable: false,
target: this.$el || null,
relatedTarget: null,
...opts,
vueTarget: this,
componentId: this.safeId()
})
},
emitEvent(bvEvt) {
const type = bvEvt.type
this.$root.$emit(`bv::toast:${type}`, bvEvt)
this.$emit(type, bvEvt)
},
ensureToaster() {
if (this.static) {
return
}
if (!Wormhole.hasTarget(this.computedToaster)) {
const div = document.createElement('div')
document.body.appendChild(div)
const toaster = new BToaster({
parent: this.$root,
propsData: {
name: this.computedToaster
}
})
toaster.$mount(div)
}
},
startDismissTimer() {
this.clearDismissTimer()
if (!this.noAutoHide) {
this.timer = setTimeout(this.hide, this.resumeDismiss || this.computedDuration)
this.dismissStarted = Date.now()
this.resumeDismiss = 0
}
},
clearDismissTimer() {
clearTimeout(this.timer)
this.timer = null
},
setHoverHandler(on) {
const method = on ? eventOn : eventOff
method(this.$refs.btoast, 'mouseenter', this.onPause, EVENT_OPTIONS)
method(this.$refs.btoast, 'mouseleave', this.onUnPause, EVENT_OPTIONS)
},
onPause(evt) {
// Determine time remaining, and then pause timer
if (this.noAutoHide || this.noHoverPause || !this.timer || this.resumeDismiss) {
return
}
const passed = Date.now() - this.dismissStarted
if (passed > 0) {
this.clearDismissTimer()
this.resumeDismiss = Math.max(this.computedDuration - passed, MIN_DURATION)
}
},
onUnPause(evt) {
// Restart timer with max of time remaining or 1 second
if (this.noAutoHide || this.noHoverPause || !this.resumeDismiss) {
this.resumeDismiss = this.dismissStarted = 0
return
}
this.startDismissTimer()
},
onLinkClick() {
// We delay the close to allow time for the
// browser to process the link click
this.$nextTick(() => {
requestAF(() => {
this.hide()
})
})
},
onBeforeEnter() {
this.isTransitioning = true
},
onAfterEnter() {
this.isTransitioning = false
const hiddenEvt = this.buildEvent('shown')
this.emitEvent(hiddenEvt)
this.startDismissTimer()
this.setHoverHandler(true)
},
onBeforeLeave() {
this.isTransitioning = true
},
onAfterLeave() {
this.isTransitioning = false
this.order = 0
this.resumeDismiss = this.dismissStarted = 0
const hiddenEvt = this.buildEvent('hidden')
this.emitEvent(hiddenEvt)
this.doRender = false
},
makeToast(h) {
// Render helper for generating the toast
// Assemble the header content
const $headerContent = []
let $title = this.normalizeSlot('toast-title', this.slotScope)
if ($title) {
$headerContent.push($title)
} else if (this.title) {
$headerContent.push(h('strong', { staticClass: 'mr-2' }, this.title))
}
if (!this.noCloseButton) {
$headerContent.push(
h(BButtonClose, {
staticClass: 'ml-auto mb-1',
on: {
click: evt => {
this.hide()
}
}
})
)
}
// Assemble the header (if needed)
let $header = h(false)
if ($headerContent.length > 0) {
$header = h(
'header',
{ staticClass: 'toast-header', class: this.headerClass },
$headerContent
)
}
// Toast body
const isLink = this.href || this.to
const $body = h(
isLink ? BLink : 'div',
{
staticClass: 'toast-body',
class: this.bodyClass,
props: isLink ? { to: this.to, href: this.href } : {},
on: isLink ? { click: this.onLinkClick } : {}
},
[this.normalizeSlot('default', this.slotScope) || h(false)]
)
// Build the toast
const $toast = h(
'div',
{
key: `toast-${this._uid}`,
ref: 'toast',
staticClass: 'toast',
class: this.toastClass,
attrs: {
...this.$attrs,
tabindex: '0',
id: this.safeId()
}
},
[$header, $body]
)
return $toast
}
},
render(h) {
if (!this.doRender || !this.isMounted) {
return h(false)
}
const name = `b-toast-${this._uid}`
return h(
Portal,
{
props: {
name: name,
to: this.computedToaster,
order: this.order,
slim: true,
disabled: this.static
}
},
[
h(
'div',
{
key: name,
ref: 'btoast',
staticClass: 'b-toast',
class: this.bToastClasses,
attrs: {
id: this.safeId('_toast_outer'),
role: this.isHiding ? null : this.isStatus ? 'status' : 'alert',
'aria-live': this.isHiding ? null : this.isStatus ? 'polite' : 'assertive',
'aria-atomic': this.isHiding ? null : 'true'
}
},
[
h(BVTransition, { props: { noFade: this.noFade }, on: this.transitionHandlers }, [
this.localShow ? this.makeToast(h) : h(false)
])
]
)
]
)
}
})
export default BToast