vuetifyjs / vuetify

🐉 Vue Component Framework
https://vuetifyjs.com
MIT License
39.89k stars 6.97k forks source link

[Feature Request] Slide groups: add vertical option #11006

Open KareemDa opened 4 years ago

KareemDa commented 4 years ago

Problem to solve

to achieve slider like this example

Proposed solution

add prop to change view to vertical

mhgbrown commented 1 year ago

Here's a very messy working verticalized extension of the existing component that might help some people out:

<script>
import { VSlideGroup, VIcon } from 'vuetify/lib'
import { composedPath } from 'vuetify/lib/util/helpers'

// Resources:
// https://github.com/vuejs/vue/issues/2977
// https://littlelines.com/blog/2020/03/13/extending-vue-components
// https://github.com/vuetifyjs/vuetify/blob/master/packages/vuetify/src/components/VSlideGroup/VSlideGroup.ts

function bias (val) {
  const c = 0.501
  const x = Math.abs(val)
  return Math.sign(val) * (x / ((1 / c - 2) * (1 - x) + 1))
}

export function calculateUpdatedOffset (
  selectedElement,
  heights,
  rtl,
  currentScrollOffset
) {
  const clientHeight = selectedElement.clientHeight
  const offsetTop = selectedElement.offsetTop
  // const offsetLeft = rtl
  //   ? (widths.content - selectedElement.offsetLeft - clientHeight)
  //   : selectedElement.offsetLeft

  // if (rtl) {
  //   currentScrollOffset = -currentScrollOffset
  // }

  const totalHeight = heights.wrapper + currentScrollOffset
  const itemOffset = clientHeight + offsetTop
  const additionalOffset = clientHeight * 0.4

  console.log('calculateUpdatedOffset', offsetTop, currentScrollOffset, totalHeight)
  if (offsetTop <= currentScrollOffset) {
    currentScrollOffset = Math.max(offsetTop - additionalOffset, 0)
  } else if (totalHeight <= itemOffset) {
    currentScrollOffset = Math.min(currentScrollOffset - (totalHeight - itemOffset - additionalOffset), heights.content - heights.wrapper)
  }

  // return rtl ? -currentScrollOffset : currentScrollOffset
  return currentScrollOffset
}

export function calculateCenteredOffset (
  selectedElement,
  heights,
  rtl
) {
  const { offsetTop, clientHeight } = selectedElement

  // if (rtl) {
  //   const offsetCentered = heights.content - offsetLeft - clientWidth / 2 - heights.wrapper / 2
  //   return -Math.min(heights.content - heights.wrapper, Math.max(0, offsetCentered))
  // } else {
  //   const offsetCentered = offsetLeft + clientWidth / 2 - heights.wrapper / 2
  //   return Math.min(heights.content - heights.wrapper, Math.max(0, offsetCentered))
  // }

  const offsetCentered = offsetTop + clientHeight / 2 - heights.wrapper / 2

  return Math.min(heights.content - heights.wrapper, Math.max(0, offsetCentered))
}

export default {
  extends: VSlideGroup,
  data () {
    return {
      vertical: true,
      // isOverflowing: false,
      // resizeTimeout: 0,
      // startX: 0,
      startY: 0,
      // isSwipingHorizontal: false,
      isSwipingVertical: false,
      // isSwiping: false,
      // scrollOffset: 0,
      scrollYOffset: 0, // ?
      // widths: {
      //   content: 0,
      //   wrapper: 0,
      // },
      heights: {
        content: 0,
        wrapper: 0
      }
    }
  },
  watch: {
    // internalValue: 'setHeights',
    // When overflow changes, the arrows alter
    // the widths of the content and wrapper
    // and need to be recalculated
    // isOverflowing: 'setHeights',
    scrollOffset (val) {
      // if (this.$vuetify.rtl) val = -val

      const scroll =
        val <= 0
          ? bias(-val)
          : val > this.heights.content - this.heights.wrapper
            ? -(this.heights.content - this.heights.wrapper) + bias(this.heights.content - this.heights.wrapper - val)
            : -val

      // if (this.$vuetify.rtl) scroll = -scroll

      // this.$refs.content.style.transform = `translateX(${scroll}px)`
      this.$refs.content.style.transform = `translateY(${scroll}px)`
    }
  },
  computed: {
    // canTouch () {} ok
    // __cachedNext () {} ok
    // __cachedPrev () {} ok
    classes () {
      return {
        ...VSlideGroup.options.computed.classes.call(this),
        'v-slide-group--vertical': this.vertical
      }
    },
    // hasAffixes () {} ok
    hasNext () {
      if (!this.hasAffixes) return false

      const { content, wrapper } = this.heights

      // Check one scroll ahead to know the width of right-most item
      return content > Math.abs(this.scrollOffset) + wrapper
    }
    // hasPrev () {} ok
  },
  methods: {
    onFocusin (e) {
      if (!this.isOverflowing) return

      // Focused element is likely to be the root of an item, so a
      // breadth-first search will probably find it in the first iteration
      for (const el of composedPath(e)) {
        for (const vm of this.items) {
          if (vm.$el === el) {
            this.scrollOffset = calculateUpdatedOffset(
              vm.$el,
              this.heights,
              this.$vuetify.rtl,
              this.scrollOffset
            )
            return
          }
        }
      }
    },
    // genNext () {} ok
    // genContent () {} ok
    // genData () {} ok
    genIcon (location) {
      let icon = location

      if (location === 'prev') {
        icon = 'mdi-chevron-up'
      } else if (location === 'next') {
        icon = 'mdi-chevron-down'
      }

      const upperLocation = `${location[0].toUpperCase()}${location.slice(1)}`
      const hasAffix = this[`has${upperLocation}`]

      if (
        !this.showArrows &&
        !hasAffix
      ) return null

      return this.$createElement(VIcon, {
        props: {
          disabled: !hasAffix
        }
      }, [icon])
    },
    // genPrev () {} ok
    // genTransition () {} ok
    // genWrapper () {} ok
    calculateNewOffset (direction, heights, rtl, currentScrollOffset) {
      // const sign = rtl ? -1 : 1
      const sign = 1
      const newAbosluteOffset = sign * currentScrollOffset +
        (direction === 'prev' ? -1 : 1) * heights.wrapper

      return sign * Math.max(Math.min(newAbosluteOffset, heights.content - heights.wrapper), 0)
    },
    // onAffixClick () {} ok
    // onResize () {} ok
    onTouchStart (e) {
      const { content } = this.$refs

      this.startY = this.scrollOffset + e.touchstartY

      content.style.setProperty('transition', 'none')
      content.style.setProperty('willChange', 'transform')
    },
    onTouchMove (e) {
      if (!this.canTouch) return

      if (!this.isSwiping) {
        // only calculate disableSwipeHorizontal during the first onTouchMove invoke
        // in order to ensure disableSwipeHorizontal value is consistent between onTouchStart and onTouchEnd
        const diffX = e.touchmoveX - e.touchstartX
        const diffY = e.touchmoveY - e.touchstartY
        // this.isSwipingHorizontal = Math.abs(diffX) > Math.abs(diffY)
        this.isSwipingVertical = Math.abs(diffY) > Math.abs(diffX)
        this.isSwiping = true
      }

      // if (this.isSwipingHorizontal) {
      //   // sliding horizontally
      //   this.scrollOffset = this.startX - e.touchmoveX
      //   // temporarily disable window vertical scrolling
      //   document.documentElement.style.overflowY = 'hidden'
      // }

      if (this.isSwipingVertical) {
        // sliding horizontally
        this.scrollOffset = this.startY - e.touchmoveY
        // temporarily disable window vertical scrolling
        document.documentElement.style.overflowY = 'hidden'
      }
    },
    onTouchEnd () {
      if (!this.canTouch) return

      const { content, wrapper } = this.$refs
      const maxScrollOffset = content.clientHeight - wrapper.clientHeight

      content.style.setProperty('transition', null)
      content.style.setProperty('willChange', null)

      // if (this.$vuetify.rtl) {
      //   /* istanbul ignore else */
      //   if (this.scrollOffset > 0 || !this.isOverflowing) {
      //     this.scrollOffset = 0
      //   } else if (this.scrollOffset <= -maxScrollOffset) {
      //     this.scrollOffset = -maxScrollOffset
      //   }
      // } else {
      if (this.scrollOffset < 0 || !this.isOverflowing) {
        this.scrollOffset = 0
      } else if (this.scrollOffset >= maxScrollOffset) {
        this.scrollOffset = maxScrollOffset
      }
      // }

      this.isSwiping = false
      // rollback whole page scrolling to default
      document.documentElement.style.removeProperty('overflow-y')
    },
    // overflowCheck() {} ok
    scrollIntoView () {
      if (!this.selectedItem && this.items.length) {
        const lastItemPosition = this.items[this.items.length - 1].$el.getBoundingClientRect()
        const wrapperPosition = this.$refs.wrapper.getBoundingClientRect()

        //   if (
        //     (this.$vuetify.rtl && wrapperPosition.right < lastItemPosition.right) ||
        //     (!this.$vuetify.rtl && wrapperPosition.left > lastItemPosition.left)
        //   ) {
        //     this.scrollTo('prev')
        //   }
        // }

        if (
          (!this.$vuetify.rtl && wrapperPosition.top > lastItemPosition.top)
        ) {
          this.scrollTo('prev')
        }
      }

      if (!this.selectedItem) {
        return
      }

      if (
        this.selectedIndex === 0 ||
        (!this.centerActive && !this.isOverflowing)
      ) {
        this.scrollOffset = 0
      } else if (this.centerActive) {
        this.scrollOffset = calculateCenteredOffset(
          this.selectedItem.$el,
          this.heights,
          this.$vuetify.rtl
        )
      } else if (this.isOverflowing) {
        this.scrollOffset = calculateUpdatedOffset(
          this.selectedItem.$el,
          this.heights,
          this.$vuetify.rtl,
          this.scrollOffset
        )
      }
    },
    scrollTo (location) {
      this.scrollOffset = this.calculateNewOffset(location, {
        // Force reflow
        content: this.$refs.content ? this.$refs.content.clientHeight : 0,
        wrapper: this.$refs.wrapper ? this.$refs.wrapper.clientHeight : 0
      }, this.$vuetify.rtl, this.scrollOffset)
    },
    setWidths () {
      this.setHeights()
    },
    setHeights () {
      window.requestAnimationFrame(() => {
        if (this._isDestroyed) return

        const { content, wrapper } = this.$refs

        this.heights = {
          content: content ? content.clientHeight : 0,
          wrapper: wrapper ? wrapper.clientHeight : 0
        }

        // https://github.com/vuetifyjs/vuetify/issues/13212
        // We add +1 to the wrappers width to prevent an issue where the `clientWidth`
        // gets calculated wrongly by the browser if using a different zoom-level.
        this.isOverflowing = this.heights.wrapper + 1 < this.heights.content
        console.log(this.isOverflowing)
        this.scrollIntoView()
      })
    }
  }
}
</script>
<style scoped lang="scss">
.v-slide-group.v-slide-group--vertical {
  max-height: 100%;
  flex-direction: column;

  .v-slide-group__wrapper {
    flex-direction: column;
  }

  .v-slide-group__content {
    display: block;
    white-space: normal;
  }

  .v-slide-group__next, .v-slide-group__prev {
    flex: 0 1 52px;
    justify-content: center;
    min-width: auto;
    min-height: 52px;
  }
}
</style>