<script>
export default {
  name: 'CollapseTransition',

  props: {
    name: {
      type: String,
      required: false,
      default: 'collapse',
    },
    dimension: {
      type: String,
      required: false,
      default: 'height',
      validator: (value) => {
        return ['height', 'width'].includes(value)
      },
    },
    duration: {
      type: Number,
      required: false,
      default: 300,
    },
    easing: {
      type: String,
      required: false,
      default: 'ease-in-out',
    },
  },

  emits: ['before-appear', 'appear', 'after-appear', 'appear-cancelled', 'before-enter', 'enter', 'after-enter', 'enter-cancelled', 'before-leave', 'leave', 'after-leave', 'leave-cancelled'],

  data() {
    return {
      cachedStyles: null,
    }
  },

  computed: {
    transition() {
      const transitions = []

      Object.keys(this.cachedStyles).forEach((key) => {
        transitions.push(
          `${this.convertToCssProperty(key)} ${this.duration}ms ${this.easing}`,
        )
      })

      return transitions.join(', ')
    },
  },

  watch: {
    dimension() {
      this.clearCachedDimensions()
    },
  },

  methods: {
    beforeAppear(el) {
      // Emit the event to the parent
      this.$emit('before-appear', el)
    },

    appear(el) {
      // Emit the event to the parent
      this.$emit('appear', el)
    },

    afterAppear(el) {
      // Emit the event to the parent
      this.$emit('after-appear', el)
    },

    appearCancelled(el) {
      // Emit the event to the parent
      this.$emit('appear-cancelled', el)
    },

    beforeEnter(el) {
      // Emit the event to the parent
      this.$emit('before-enter', el)
    },

    enter(el, done) {
      // Because width and height may be 'auto',
      // first detect and cache the dimensions
      this.detectAndCacheDimensions(el)

      // The order of applying styles is important:
      // - 1. Set styles for state before transition
      // - 2. Force repaint
      // - 3. Add transition style
      // - 4. Set styles for state after transition
      // If the order is not right and you open any 2nd level submenu
      // for the first time, the transition will not work.
      this.setClosedDimensions(el)
      this.hideOverflow(el)
      this.forceRepaint(el)
      this.setTransition(el)
      this.setOpenedDimensions(el)

      // Emit the event to the parent
      this.$emit('enter', el, done)

      // Call done() when the transition ends
      // to trigger the @after-enter event.
      setTimeout(done, this.duration)
    },

    afterEnter(el) {
      // Clean up inline styles
      this.unsetOverflow(el)
      this.unsetTransition(el)
      this.unsetDimensions(el)
      this.clearCachedDimensions()

      // Emit the event to the parent
      this.$emit('after-enter', el)
    },

    enterCancelled(el) {
      // Emit the event to the parent
      this.$emit('enter-cancelled', el)
    },

    beforeLeave(el) {
      // Emit the event to the parent
      this.$emit('before-leave', el)
    },

    leave(el, done) {
      // For some reason, @leave triggered when starting
      // from open state on page load. So for safety,
      // check if the dimensions have been cached.
      this.detectAndCacheDimensions(el)

      // The order of applying styles is less important
      // than in the enter phase, as long as we repaint
      // before setting the closed dimensions.
      // But it is probably best to use the same
      // order as the enter phase.
      this.setOpenedDimensions(el)
      this.hideOverflow(el)
      this.forceRepaint(el)
      this.setTransition(el)
      this.setClosedDimensions(el)

      // Emit the event to the parent
      this.$emit('leave', el, done)

      // Call done() when the transition ends
      // to trigger the @after-leave event.
      // This will also cause v-show
      // to reapply 'display: none'.
      setTimeout(done, this.duration)
    },

    afterLeave(el) {
      // Clean up inline styles
      this.unsetOverflow(el)
      this.unsetTransition(el)
      this.unsetDimensions(el)
      this.clearCachedDimensions()

      // Emit the event to the parent
      this.$emit('after-leave', el)
    },

    leaveCancelled(el) {
      // Emit the event to the parent
      this.$emit('leave-cancelled', el)
    },

    detectAndCacheDimensions(el) {
      // Cache actual dimensions
      // only once to void invalid values when
      // triggering during a transition
      if (this.cachedStyles)
        return

      const visibility = el.style.visibility
      const display = el.style.display

      // Trick to get the width and
      // height of a hidden element
      el.style.visibility = 'hidden'
      el.style.display = ''

      this.cachedStyles = this.detectRelevantDimensions(el)

      // Restore any original styling
      el.style.visibility = visibility
      el.style.display = display
    },

    clearCachedDimensions() {
      this.cachedStyles = null
    },

    detectRelevantDimensions(el) {
      // These properties will be transitioned
      if (this.dimension === 'height') {
        return {
          height: `${el.offsetHeight}px`,
          paddingTop:
            el.style.paddingTop || this.getCssValue(el, 'padding-top'),
          paddingBottom:
            el.style.paddingBottom || this.getCssValue(el, 'padding-bottom'),
        }
      }

      if (this.dimension === 'width') {
        return {
          width: `${el.offsetWidth}px`,
          paddingLeft:
            el.style.paddingLeft || this.getCssValue(el, 'padding-left'),
          paddingRight:
            el.style.paddingRight || this.getCssValue(el, 'padding-right'),
        }
      }

      return {}
    },

    setTransition(el) {
      el.style.transition = this.transition
    },

    unsetTransition(el) {
      el.style.transition = ''
    },

    hideOverflow(el) {
      el.style.overflow = 'hidden'
    },

    unsetOverflow(el) {
      el.style.overflow = ''
    },

    setClosedDimensions(el) {
      Object.keys(this.cachedStyles).forEach((key) => {
        el.style[key] = '0'
      })
    },

    setOpenedDimensions(el) {
      Object.keys(this.cachedStyles).forEach((key) => {
        el.style[key] = this.cachedStyles[key]
      })
    },

    unsetDimensions(el) {
      Object.keys(this.cachedStyles).forEach((key) => {
        el.style[key] = ''
      })
    },

    forceRepaint(el) {
      // Force repaint to make sure the animation is triggered correctly.
      // Thanks: https://markus.oberlehner.net/blog/transition-to-height-auto-with-vue/
      // eslint-disable-next-line no-unused-expressions
      getComputedStyle(el)[this.dimension]
    },

    getCssValue(el, style) {
      return getComputedStyle(el, null).getPropertyValue(style)
    },

    convertToCssProperty(style) {
      // Example: convert 'paddingTop' to 'padding-top'
      // Thanks: https://gist.github.com/tan-yuki/3450323
      const upperChars = style.match(/([A-Z])/g)

      if (!upperChars)
        return style

      for (let i = 0, n = upperChars.length; i < n; i++) {
        style = style.replace(
          new RegExp(upperChars[i]),
          `-${upperChars[i].toLowerCase()}`,
        )
      }

      if (style.slice(0, 1) === '-')
        style = style.slice(1)

      return style
    },
  },
}
</script>

<template>
  <transition
    :name="name"
    @before-appear="beforeAppear"
    @appear="appear"
    @after-appear="afterAppear"
    @appear-cancelled="appearCancelled"
    @before-enter="beforeEnter"
    @enter="enter"
    @after-enter="afterEnter"
    @enter-cancelled="enterCancelled"
    @before-leave="beforeLeave"
    @leave="leave"
    @after-leave="afterLeave"
    @leave-cancelled="leaveCancelled"
  >
    <slot />
  </transition>
</template>
