UNPKG

@gitlab/ui

Version:
403 lines (395 loc) • 12.4 kB
import { Wormhole, Portal } from 'portal-vue'; import { extend, COMPONENT_UID_KEY } from '../../vue'; import { NAME_TOAST, NAME_TOASTER } from '../../constants/components'; import { EVENT_NAME_CHANGE, EVENT_NAME_SHOW, EVENT_NAME_HIDE, EVENT_NAME_DESTROYED, EVENT_OPTIONS_NO_CAPTURE, EVENT_NAME_SHOWN, EVENT_NAME_HIDDEN } from '../../constants/events'; import { PROP_TYPE_BOOLEAN, PROP_TYPE_NUMBER_STRING, PROP_TYPE_ARRAY_OBJECT_STRING, PROP_TYPE_STRING } from '../../constants/props'; import { SLOT_NAME_TOAST_TITLE, SLOT_NAME_DEFAULT } from '../../constants/slots'; import { BvEvent } from '../../utils/bv-event.class'; import { requestAF } from '../../utils/dom'; import { getRootActionEventName, getRootEventName, eventOnOff } from '../../utils/events'; import { mathMax } from '../../utils/math'; import { makeModelMixin } from '../../utils/model'; import { toInteger } from '../../utils/number'; import { pick, sortKeys } from '../../utils/object'; import { makePropsConfigurable, makeProp, pluckProps } from '../../utils/props'; import { isLink } from '../../utils/router'; import { createNewChildComponent } from '../../utils/create-new-child-component'; import { attrsMixin } from '../../mixins/attrs'; import { props as props$2, idMixin } from '../../mixins/id'; import { listenOnRootMixin } from '../../mixins/listen-on-root'; import { normalizeSlotMixin } from '../../mixins/normalize-slot'; import { scopedStyleMixin } from '../../mixins/scoped-style'; import { props as props$1, BLink } from '../link/link'; import { BVTransition } from '../transition/bv-transition'; import { BToaster } from './toaster'; // --- Constants --- const { mixin: modelMixin, props: modelProps, prop: MODEL_PROP_NAME, event: MODEL_EVENT_NAME } = makeModelMixin('visible', { type: PROP_TYPE_BOOLEAN, defaultValue: false, event: EVENT_NAME_CHANGE }); const MIN_DURATION = 1000; // --- Props --- const linkProps = pick(props$1, ['href', 'to']); const props = makePropsConfigurable(sortKeys({ ...props$2, ...modelProps, ...linkProps, appendToast: makeProp(PROP_TYPE_BOOLEAN, false), autoHideDelay: makeProp(PROP_TYPE_NUMBER_STRING, 5000), bodyClass: makeProp(PROP_TYPE_ARRAY_OBJECT_STRING), headerClass: makeProp(PROP_TYPE_ARRAY_OBJECT_STRING), headerTag: makeProp(PROP_TYPE_STRING, 'header'), // Switches role to 'status' and aria-live to 'polite' isStatus: makeProp(PROP_TYPE_BOOLEAN, false), noAutoHide: makeProp(PROP_TYPE_BOOLEAN, false), noFade: makeProp(PROP_TYPE_BOOLEAN, false), noHoverPause: makeProp(PROP_TYPE_BOOLEAN, false), solid: makeProp(PROP_TYPE_BOOLEAN, false), // Render the toast in place, rather than in a portal-target static: makeProp(PROP_TYPE_BOOLEAN, false), title: makeProp(PROP_TYPE_STRING), toastClass: makeProp(PROP_TYPE_ARRAY_OBJECT_STRING), toaster: makeProp(PROP_TYPE_STRING, 'b-toaster-top-right'), variant: makeProp(PROP_TYPE_STRING) }), NAME_TOAST); // --- Main component --- // @vue/component const BToast = /*#__PURE__*/extend({ name: NAME_TOAST, mixins: [attrsMixin, idMixin, modelMixin, listenOnRootMixin, normalizeSlotMixin, scopedStyleMixin], inheritAttrs: false, props, data() { return { isMounted: false, doRender: false, localShow: false, isTransitioning: false, isHiding: false, order: 0, dismissStarted: 0, resumeDismiss: 0 }; }, computed: { toastClasses() { const { appendToast, variant } = this; return { 'b-toast-solid': this.solid, 'b-toast-append': appendToast, 'b-toast-prepend': !appendToast, [`b-toast-${variant}`]: variant }; }, slotScope() { const { hide } = this; return { hide }; }, computedDuration() { // Minimum supported duration is 1 second return mathMax(toInteger(this.autoHideDelay, 0), MIN_DURATION); }, computedToaster() { return String(this.toaster); }, transitionHandlers() { return { beforeEnter: this.onBeforeEnter, afterEnter: this.onAfterEnter, beforeLeave: this.onBeforeLeave, afterLeave: this.onAfterLeave }; }, computedAttrs() { return { ...this.bvAttrs, id: this.safeId(), tabindex: '0' }; } }, watch: { [MODEL_PROP_NAME](newValue) { this[newValue ? 'show' : 'hide'](); }, localShow(newValue) { if (newValue !== this[MODEL_PROP_NAME]) { this.$emit(MODEL_EVENT_NAME, newValue); } }, /* istanbul ignore next */ toaster() { // If toaster target changed, make sure toaster exists this.$nextTick(this.ensureToaster); }, /* istanbul ignore next */ static(newValue) { // If static changes to true, and the toast is showing, // ensure the toaster target exists if (newValue && this.localShow) { this.ensureToaster(); } } }, created() { // Create private non-reactive props this.$_dismissTimer = null; }, mounted() { this.isMounted = true; this.$nextTick(() => { if (this[MODEL_PROP_NAME]) { requestAF(() => { this.show(); }); } }); // Listen for global $root show events this.listenOnRoot(getRootActionEventName(NAME_TOAST, EVENT_NAME_SHOW), id => { if (id === this.safeId()) { this.show(); } }); // Listen for global $root hide events this.listenOnRoot(getRootActionEventName(NAME_TOAST, EVENT_NAME_HIDE), id => { if (!id || id === this.safeId()) { this.hide(); } }); // Make sure we hide when toaster is destroyed /* istanbul ignore next: difficult to test */ this.listenOnRoot(getRootEventName(NAME_TOASTER, EVENT_NAME_DESTROYED), toaster => { /* istanbul ignore next */ if (toaster === this.computedToaster) { this.hide(); } }); }, beforeDestroy() { this.clearDismissTimer(); }, methods: { show() { if (!this.localShow) { this.ensureToaster(); const showEvent = this.buildEvent(EVENT_NAME_SHOW); this.emitEvent(showEvent); 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 hideEvent = this.buildEvent(EVENT_NAME_HIDE); this.emitEvent(hideEvent); this.setHoverHandler(false); this.dismissStarted = this.resumeDismiss = 0; this.clearDismissTimer(); this.isHiding = true; requestAF(() => { this.localShow = false; }); } }, buildEvent(type) { let options = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {}; return new BvEvent(type, { cancelable: false, target: this.$el || null, relatedTarget: null, ...options, vueTarget: this, componentId: this.safeId() }); }, emitEvent(bvEvent) { const { type } = bvEvent; this.emitOnRoot(getRootEventName(NAME_TOAST, type), bvEvent); this.$emit(type, bvEvent); }, ensureToaster() { if (this.static) { return; } const { computedToaster } = this; if (!Wormhole.hasTarget(computedToaster)) { const div = document.createElement('div'); document.body.appendChild(div); const toaster = createNewChildComponent(this.bvEventRoot, BToaster, { propsData: { name: computedToaster } }); toaster.$mount(div); } }, startDismissTimer() { this.clearDismissTimer(); if (!this.noAutoHide) { this.$_dismissTimer = setTimeout(this.hide, this.resumeDismiss || this.computedDuration); this.dismissStarted = Date.now(); this.resumeDismiss = 0; } }, clearDismissTimer() { clearTimeout(this.$_dismissTimer); this.$_dismissTimer = null; }, setHoverHandler(on) { const el = this.$refs['b-toast']; eventOnOff(on, el, 'mouseenter', this.onPause, EVENT_OPTIONS_NO_CAPTURE); eventOnOff(on, el, 'mouseleave', this.onUnPause, EVENT_OPTIONS_NO_CAPTURE); }, onPause() { // Determine time remaining, and then pause timer if (this.noAutoHide || this.noHoverPause || !this.$_dismissTimer || this.resumeDismiss) { return; } const passed = Date.now() - this.dismissStarted; if (passed > 0) { this.clearDismissTimer(); this.resumeDismiss = mathMax(this.computedDuration - passed, MIN_DURATION); } }, onUnPause() { // 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 hiddenEvent = this.buildEvent(EVENT_NAME_SHOWN); this.emitEvent(hiddenEvent); this.startDismissTimer(); this.setHoverHandler(true); }, onBeforeLeave() { this.isTransitioning = true; }, onAfterLeave() { this.isTransitioning = false; this.order = 0; this.resumeDismiss = this.dismissStarted = 0; const hiddenEvent = this.buildEvent(EVENT_NAME_HIDDEN); this.emitEvent(hiddenEvent); this.doRender = false; }, // Render helper for generating the toast makeToast(h) { const { slotScope } = this; const link = isLink(this); const $headerContent = []; const $title = this.normalizeSlot(SLOT_NAME_TOAST_TITLE, slotScope); if ($title) { $headerContent.push($title); } let $header = h(); if ($headerContent.length > 0) { $header = h(this.headerTag, { staticClass: 'toast-header', class: this.headerClass }, $headerContent); } const $body = h(link ? BLink : 'div', { staticClass: 'toast-body', class: this.bodyClass, props: link ? pluckProps(linkProps, this) : {}, on: link ? { click: this.onLinkClick } : {} }, this.normalizeSlot(SLOT_NAME_DEFAULT, slotScope)); return h('div', { staticClass: 'toast', class: this.toastClass, attrs: this.computedAttrs, key: `toast-${this[COMPONENT_UID_KEY]}`, ref: 'toast' }, [$header, $body]); } }, render(h) { if (!this.doRender || !this.isMounted) { return h(); } const { order, static: isStatic, isHiding, isStatus } = this; const name = `b-toast-${this[COMPONENT_UID_KEY]}`; const $toast = h('div', { staticClass: 'b-toast', class: this.toastClasses, attrs: { // If scoped styles are applied and the toast is not static, // make sure the scoped style data attribute is applied ...(isStatic ? {} : this.scopedStyleAttrs), id: this.safeId('_toast_outer'), role: isHiding ? null : isStatus ? 'status' : 'alert', 'aria-live': isHiding ? null : isStatus ? 'polite' : 'assertive', 'aria-atomic': isHiding ? null : 'true' }, key: name, ref: 'b-toast' }, [h(BVTransition, { props: { noFade: this.noFade }, on: this.transitionHandlers }, [this.localShow ? this.makeToast(h) : h()])]); return h(Portal, { props: { name, to: this.computedToaster, order, slim: true, disabled: isStatic } }, [$toast]); } }); export { BToast, props };