components/popover/popover.vue

<template>
    <div class="ion-popover" :class="[modeClass,colorClass,cssClass]">
        <vm-backdrop :bdClick="bdClick" :enableBackdropDismiss="enableBackdropDismiss" :hidden="!showBackdrop"
                  :isActive="isActive"></vm-backdrop>
        <transition
                :name="popoverTransitionName"
                @before-enter="beforeEnter"
                @after-enter="afterEnter"
                @before-leave="beforeLeave"
                @after-leave="afterLeave">
            <div class="popover-wrapper" v-show="isActive">
                <div class="popover-arrow" ref="popoverArrow"></div>
                <div class="popover-content" ref="popoverContent">
                    <div class="popover-viewport" ref="popoverViewport"></div>
                </div>
            </div>
        </transition>
    </div>
</template>
<style lang="scss">
    @import "popover";
    @import "popover.ios";
    @import "popover.md";

    // transitioName = 'popover-ios'
    .popover-ios-enter-active, .popove-ios-leave-active {
        transition: opacity ease .1s;
        opacity: 1;
    }

    .popover-ios-enter, .popover-ios-leave-active {
        opacity: 0;
        transition: opacity ease .1s;
    }

    // transitioName = 'popover-md'
    .popover-md {
        .popover-wrapper {
            transition: opacity ease 100ms;
            .popover-content {
                transition: all cubic-bezier(0.36, 0.66, 0.04, 1) 300ms;
                .popover-viewport {
                    transition: opacity ease 300ms;
                }
            }
        }
    }

    .popover-md-enter {
        opacity: 1;
        .popover-content {
            transform: scale(1);
        }
        .popover-viewport {
            opacity: 1;
        }
    }

    .popover-md-enter-active {
        opacity: 0;
        .popover-content {
            transform: scale(0);
        }
        .popover-viewport {
            opacity: 0;
        }
    }

    .popover-md-leave-active {
        opacity: 0;
    }

</style>
<script type="text/javascript">
  /**
   * @component Popover
   * @description
   *
   * ## 弹出层组件 / Popover提示框组件
   *
   * ### 简介
   *
   * 这个组件适用于对组件中某部分进行弹出提示, 比如:
   *
   * - 单词点击弹出翻译
   * - 点击按钮弹出可选择的操作(和Fab有点类似, 但是Popover可自定义程度高, 但是显示内容建议小于Modal组件)
   *
   * ### 传入模板的弹出层组件
   *
   * Popover的实现和Modal组件相似, 都需要传入`*.vue`模板文件, 具体事例参考usage
   *
   *
   * ### 子组件如何获取数据
   *
   * 在组件中使用: `this.$options.$data` 获取传入data. 例如Usage中的示例, 子组件获取data中的contentEle数据这样操作:
   *
   * ```
   * this.contentEle = this.$options.$data.contentEle
   * ```
   *
   *
   * @usage
   * import List from 'vimo/lib/list'
   * import { ListHeader, ItemGroup, Item, ItemSliding, ItemOptions, ItemDivider } from 'vimo/lib/item'
   * import Popover from 'vimo/lib/popover'
   * import TextTool from './textTool.vue'
   * export default{
   *  methods: {
   *    openSetting ($event) {
   *      Popover.present({
   *        ev: $event,                           // 事件
   *        component: TextTool,                  // 传入组件
   *        data: {
   *          contentEle: this.$refs.content.$el  // 传入数据, 内部通过`this.$options.$data`获取这个data
   *        }
   *      })
   *    },
   *    specialText ($event, text) {
   *      Popover.present({
   *        ev: $event,
   *        component: `<p style="padding:0 14px;" text-center>You choose the word of <strong>${text}</strong>.</p>`
   *      })
   *    }
   *  },
   *  components: {Popover, List, ListHeader, ItemGroup, Item, ItemSliding, ItemOptions, ItemDivider}
   * }
   *
   * @props {String} cssClass - 额外的样式
   * @props {mode} [mode='ios'] - 模式
   * @props {Boolean} [showBackdrop=true] - 是否显示backdrop
   * @props {Boolean} [enableBackdropDismiss=true] - 点击backdrop是否能关闭组件
   * @props {Boolean} [dismissOnPageChange=true] - 页面切换是否关闭组件, 默认关闭
   * @props {Function} [onDismiss] - 完全关闭时的回调
   *
   * @props {Object|String|Function|Promise} component - popover内部显示的vue组件, 是一个*.vue文件; 如果是String的话则为html字符串; 支持异步
   * @props {Object} data - 传给popover内部显示的vue组件的数据, 内部组件通过`this.$options.$data`获取
   * @props {Object|MouseEvent} ev - 点击元素的事件, $event, 这个值的传入可以计算popover放置的位置
   *
   * @demo #/popover
   * */
  import Vue from 'vue'
  import { urlChange, parsePxUnit } from '../../util/util'
  import css from '../../util/getCss'
  import ThemeMixins from '../../themes/theme.mixins'
  import VmBackdrop from "../backdrop/backdrop.vue";

  const POPOVER_IOS_BODY_PADDING = 2
  const POPOVER_MD_BODY_PADDING = 12
  const NOOP = () => {}

  export default {
    name: 'vm-popover',
    mixins: [ThemeMixins],
    components: {VmBackdrop},
    props: {
      component: [Object, String, Function, Promise],
      onDismiss: Function,
      data: [Object],
      ev: [Object, MouseEvent], // 点击元素的事件
      cssClass: String,
      showBackdrop: {
        type: Boolean,
        default: true
      },
      enableBackdropDismiss: {
        type: Boolean,
        default: true
      },
      dismissOnPageChange: {
        type: Boolean,
        default: true
      }
    },
    data () {
      return {
        presentCallback: NOOP,  // 开启的promise
        dismissCallback: NOOP,  // 关闭的promise
        unreg: null,            // 页面切换的pagechange监听函数

        isActive: false,        // 内部控制是否开启
        enabled: false          // 内部判断当前组件是否在动画中
      }
    },
    computed: {
      popoverTransitionName () {
        return `popover-${this.mode}`
      },
      popoverEle () {
        return this.$refs.popoverContent
      },
      arrowEle () {
        return this.$refs.popoverArrow
      },
      popoverViewportEle () {
        return this.$refs.popoverViewport
      }
    },
    methods: {
      /**
       * ActionSheet Animate Hooks
       * @private
       * */
      beforeEnter () {
        this.enabled = false // 不允许过渡中途操作
        this.$app && this.$app.setEnabled(false, 300)
      },
      afterEnter () {
        this.presentCallback()
        this.enabled = true
      },
      beforeLeave () {
        this.enabled = false
        this.$app && this.$app.setEnabled(false, 300)
      },
      afterLeave () {
        this.dismissCallback()
        this.onDismiss && this.onDismiss()

        // 删除DOM
        this.$el.remove()
        this.enabled = true
      },

      /**
       * 点击backdrop
       * @private
       * */
      bdClick () {
        if (this.enabled && this.enableBackdropDismiss) {
          this.dismiss()
        }
      },

      /**
       * @function dismiss
       * @description
       * 关闭组件
       * */
      dismiss () {
        if (this.isActive) {
          this.isActive = false // 动起来
          this.unreg && this.unreg()
          if (!this.enabled) {
            this.$nextTick(() => {
              this.dismissCallback()
              this.$el.remove()
              this.enabled = true
            })
          }
          return new Promise((resolve) => { this.dismissCallback = resolve })
        } else {
          return new Promise((resolve) => { resolve() })
        }
      },

      /**
       * @function present
       * @description
       * 开启组件
       * */
      present () {
        this.isActive = true
        return new Promise((resolve) => { this.presentCallback = resolve })
      },

      /**
       * 计算popover的位置/定位
       * @private
       * */
      mdPositionView (nativeEle, ev) {
        let originY = 'top'
        let originX = 'left'

        // Popover content width and height
        let popoverEle = this.popoverEle

        console.assert(popoverEle, 'The component Popover need popoverEle in mdPositionView()')

        let popoverWidth = parsePxUnit(window.getComputedStyle(popoverEle).width)
        let popoverHeight = parsePxUnit(window.getComputedStyle(popoverEle).height)

        // Window body width and height
        let bodyWidth = window['innerWidth']
        let bodyHeight = window['innerHeight']

        // If ev was passed, use that for target element
        let targetDim = this.getTargetDim(ev)
        let targetTop = (targetDim && 'top' in targetDim) ? targetDim.top : (bodyHeight / 2) - (popoverHeight / 2)
        let targetLeft = (targetDim && 'left' in targetDim) ? targetDim.left : (bodyWidth / 2) - (popoverWidth / 2)
        let targetHeight = targetDim && targetDim.height || 0
        let popoverCSS = {
          top: targetTop,
          left: targetLeft
        }

        // If the popover left is less than the padding it is off screen
        // to the left so adjust it, else if the width of the popover
        // exceeds the body width it is off screen to the right so adjust
        if (popoverCSS.left < POPOVER_MD_BODY_PADDING) {
          popoverCSS.left = POPOVER_MD_BODY_PADDING
        } else if (popoverWidth + POPOVER_MD_BODY_PADDING + popoverCSS.left > bodyWidth) {
          popoverCSS.left = bodyWidth - popoverWidth - POPOVER_MD_BODY_PADDING
          originX = 'right'
        }

        // If the popover when popped down stretches past bottom of screen,
        // make it pop up if there's room above
        if (targetTop + targetHeight + popoverHeight > bodyHeight && targetTop - popoverHeight > 0) {
          popoverCSS.top = targetTop - popoverHeight
          nativeEle.className = nativeEle.className + ' popover-bottom'
          originY = 'bottom'
          // If there isn't room for it to pop up above the target cut it off
        } else if (targetTop + targetHeight + popoverHeight > bodyHeight) {
          popoverEle.style.bottom = POPOVER_MD_BODY_PADDING + 'px'
        }

        popoverEle.style.top = popoverCSS.top + 'px'
        popoverEle.style.left = popoverCSS.left + 'px';
        (popoverEle.style)[css.transformOrigin] = originY + ' ' + originX
      },
      iosPositionView (nativeEle, ev) {
        let originY = 'top'
        let originX = 'left'
        // Popover content width and height
        let popoverEle = this.popoverEle
        let popoverWidth = parsePxUnit(window.getComputedStyle(popoverEle).width)
        let popoverHeight = parsePxUnit(window.getComputedStyle(popoverEle).height)

        // Window body width and height
        let bodyWidth = window['innerWidth']
        let bodyHeight = window['innerHeight']

        // If ev was passed, use that for target element
        let targetDim = this.getTargetDim(ev)
        let targetTop = (targetDim && 'top' in targetDim) ? targetDim.top : (bodyHeight / 2) - (popoverHeight / 2)
        let targetLeft = (targetDim && 'left' in targetDim) ? targetDim.left : (bodyWidth / 2)
        let targetWidth = targetDim && targetDim.width || 0
        let targetHeight = targetDim && targetDim.height || 0

        // The arrow that shows above the popover on iOS
        var arrowEle = this.arrowEle
        let arrowWidth = parsePxUnit(window.getComputedStyle(arrowEle).width)
        let arrowHeight = parsePxUnit(window.getComputedStyle(arrowEle).height)

        // If no ev was passed, hide the arrow
        if (!targetDim) {
          arrowEle.style.display = 'none'
        }
        let arrowCSS = {
          top: targetTop + targetHeight,
          left: targetLeft + (targetWidth / 2) - (arrowWidth / 2)
        }

        let popoverCSS = {
          top: targetTop + targetHeight + (arrowHeight - 1),
          left: targetLeft + (targetWidth / 2) - (popoverWidth / 2)
        }

        // If the popover left is less than the padding it is off screen
        // to the left so adjust it, else if the width of the popover
        // exceeds the body width it is off screen to the right so adjust
        if (popoverCSS.left < POPOVER_IOS_BODY_PADDING) {
          popoverCSS.left = POPOVER_IOS_BODY_PADDING
        } else if (popoverWidth + POPOVER_IOS_BODY_PADDING + popoverCSS.left > bodyWidth) {
          popoverCSS.left = bodyWidth - popoverWidth - POPOVER_IOS_BODY_PADDING
          originX = 'right'
        }

        // If the popover when popped down stretches past bottom of screen,
        // make it pop up if there's room above
        if (targetTop + targetHeight + popoverHeight > bodyHeight && targetTop - popoverHeight > 0) {
          arrowCSS.top = targetTop - (arrowHeight + 1)
          popoverCSS.top = targetTop - popoverHeight - (arrowHeight - 1)
          nativeEle.className = nativeEle.className + ' popover-bottom'
          originY = 'bottom'

          // If there isn't room for it to pop up above the target cut it off
        } else if (targetTop + targetHeight + popoverHeight > bodyHeight) {
          popoverEle.style.bottom = POPOVER_IOS_BODY_PADDING + '%'
        }

        arrowEle.style.top = arrowCSS.top + 'px'
        arrowEle.style.left = arrowCSS.left + 'px'

        popoverEle.style.top = popoverCSS.top + 'px'
        popoverEle.style.left = popoverCSS.left + 'px';
        (popoverEle.style)[css.transformOrigin] = originY + ' ' + originX
      },

      /**
       * 根据传入的event事件获取点击元素的尺寸
       * 如果没有事件则使用navbar中的站位元素,默认是在右上角
       * @private
       * */
      getTargetDim (ev) {
        if (ev && ev.target) {
          return ev.target.getBoundingClientRect()
        } else {
          let rightButtonPlaceholderElement = window.document.getElementById('rightButtonPlaceholder')
          if (rightButtonPlaceholderElement) {
            return rightButtonPlaceholderElement.getBoundingClientRect()
          } else {
            return {}
          }
        }
      },

      /**
       * init
       * */
      init (component) {
        if (component) {
          const Component = Vue.extend(component)
          // eslint-disable-next-line no-new
          new Component({
            el: this.popoverViewportEle,
            $data: this.data
          })
        }
      },
      updatePositionView () {
        // 计算位置
        // 渲染传入的组件
        if (this.mode === 'ios') {
          this.iosPositionView(this.$el, this.ev)
        } else {
          this.mdPositionView(this.$el, this.ev)
        }
      }
    },
    created () {
      // mounted before data ready, so no need to judge the `dismissOnPageChange` value
      if (this.dismissOnPageChange) {
        this.unreg = urlChange(() => {
          this.isActive && this.dismiss()
        })
      }
    },
    mounted () {
      let getType = (val) => Object.prototype.toString.call(val).match(/^(\[object )(\w+)\]$/i)[2].toLowerCase()

      if (getType(this.component) === 'object') {
        this.init(this.component)
      } else if (getType(this.component) === 'function') {
        this.component((component) => {
          this.init(component)
        })
      } else if (getType(this.component) === 'promise') {
        this.component.then((component) => {
          this.init(component)
        })
      } else {
        // 如果 this.component 是html模板string的话(支持组件)
        this.component = {
          template: '<div>' + this.component + '</div>'
        }
        this.init(this.component)
      }
    },

    updated() {
      this.isActive && this.updatePositionView();
    },
  }
</script>