vuetify
Version:
Vue Material Component Framework
418 lines (369 loc) • 11.5 kB
text/typescript
// Styles
import './VSlideGroup.sass'
// Components
import VIcon from '../VIcon'
import { VFadeTransition } from '../transitions'
// Extensions
import { BaseItemGroup } from '../VItemGroup/VItemGroup'
// Mixins
import Mobile from '../../mixins/mobile'
// Directives
import Resize from '../../directives/resize'
import Touch from '../../directives/touch'
// Utilities
import mixins, { ExtractVue } from '../../util/mixins'
// Types
import Vue, { VNode } from 'vue'
interface TouchEvent {
touchstartX: number
touchmoveX: number
stopPropagation: Function
}
interface Widths {
content: number
wrapper: number
}
interface options extends Vue {
$refs: {
content: HTMLElement
wrapper: HTMLElement
}
}
export const BaseSlideGroup = mixins<options &
/* eslint-disable indent */
ExtractVue<[
typeof BaseItemGroup,
typeof Mobile,
]>
/* eslint-enable indent */
>(
BaseItemGroup,
Mobile,
/* @vue/component */
).extend({
name: 'base-slide-group',
directives: {
Resize,
Touch,
},
props: {
activeClass: {
type: String,
default: 'v-slide-item--active',
},
centerActive: Boolean,
nextIcon: {
type: String,
default: '$next',
},
prevIcon: {
type: String,
default: '$prev',
},
showArrows: {
type: [Boolean, String],
validator: v => (
typeof v === 'boolean' || [
'always',
'desktop',
'mobile',
].includes(v)
),
},
},
data: () => ({
internalItemsLength: 0,
isOverflowing: false,
resizeTimeout: 0,
startX: 0,
scrollOffset: 0,
widths: {
content: 0,
wrapper: 0,
},
}),
computed: {
__cachedNext (): VNode {
return this.genTransition('next')
},
__cachedPrev (): VNode {
return this.genTransition('prev')
},
classes (): object {
return {
...BaseItemGroup.options.computed.classes.call(this),
'v-slide-group': true,
'v-slide-group--has-affixes': this.hasAffixes,
'v-slide-group--is-overflowing': this.isOverflowing,
}
},
hasAffixes (): Boolean {
switch (this.showArrows) {
// Always show arrows on desktop & mobile
case 'always': return true
// Always show arrows on desktop
case 'desktop': return !this.isMobile
// Show arrows on mobile when overflowing.
// This matches the default 2.2 behavior
case true: return this.isOverflowing
// Always show on mobile
case 'mobile': return (
this.isMobile ||
this.isOverflowing
)
// https://material.io/components/tabs#scrollable-tabs
// Always show arrows when
// overflowed on desktop
default: return (
!this.isMobile &&
this.isOverflowing
)
}
},
hasNext (): boolean {
if (!this.hasAffixes) return false
const { content, wrapper } = this.widths
// Check one scroll ahead to know the width of right-most item
return content > Math.abs(this.scrollOffset) + wrapper
},
hasPrev (): boolean {
return this.hasAffixes && this.scrollOffset !== 0
},
},
watch: {
internalValue: 'setWidths',
// When overflow changes, the arrows alter
// the widths of the content and wrapper
// and need to be recalculated
isOverflowing: 'setWidths',
scrollOffset (val) {
this.$refs.content.style.transform = `translateX(${-val}px)`
},
},
beforeUpdate () {
this.internalItemsLength = (this.$children || []).length
},
updated () {
if (this.internalItemsLength === (this.$children || []).length) return
this.setWidths()
},
methods: {
// Always generate next for scrollable hint
genNext (): VNode | null {
const slot = this.$scopedSlots.next
? this.$scopedSlots.next({})
: this.$slots.next || this.__cachedNext
return this.$createElement('div', {
staticClass: 'v-slide-group__next',
class: {
'v-slide-group__next--disabled': !this.hasNext,
},
on: {
click: () => this.onAffixClick('next'),
},
key: 'next',
}, [slot])
},
genContent (): VNode {
return this.$createElement('div', {
staticClass: 'v-slide-group__content',
ref: 'content',
}, this.$slots.default)
},
genData (): object {
return {
class: this.classes,
directives: [{
name: 'resize',
value: this.onResize,
}],
}
},
genIcon (location: 'prev' | 'next'): VNode | null {
let icon = location
if (this.$vuetify.rtl && location === 'prev') {
icon = 'next'
} else if (this.$vuetify.rtl && location === 'next') {
icon = 'prev'
}
const upperLocation = `${location[0].toUpperCase()}${location.slice(1)}`
const hasAffix = (this as any)[`has${upperLocation}`]
if (
!this.showArrows &&
!hasAffix
) return null
return this.$createElement(VIcon, {
props: {
disabled: !hasAffix,
},
}, (this as any)[`${icon}Icon`])
},
// Always generate prev for scrollable hint
genPrev (): VNode | null {
const slot = this.$scopedSlots.prev
? this.$scopedSlots.prev({})
: this.$slots.prev || this.__cachedPrev
return this.$createElement('div', {
staticClass: 'v-slide-group__prev',
class: {
'v-slide-group__prev--disabled': !this.hasPrev,
},
on: {
click: () => this.onAffixClick('prev'),
},
key: 'prev',
}, [slot])
},
genTransition (location: 'prev' | 'next') {
return this.$createElement(VFadeTransition, [this.genIcon(location)])
},
genWrapper (): VNode {
return this.$createElement('div', {
staticClass: 'v-slide-group__wrapper',
directives: [{
name: 'touch',
value: {
start: (e: TouchEvent) => this.overflowCheck(e, this.onTouchStart),
move: (e: TouchEvent) => this.overflowCheck(e, this.onTouchMove),
end: (e: TouchEvent) => this.overflowCheck(e, this.onTouchEnd),
},
}],
ref: 'wrapper',
}, [this.genContent()])
},
calculateNewOffset (direction: 'prev' | 'next', widths: Widths, rtl: boolean, currentScrollOffset: number) {
const sign = rtl ? -1 : 1
const newAbosluteOffset = sign * currentScrollOffset +
(direction === 'prev' ? -1 : 1) * widths.wrapper
return sign * Math.max(Math.min(newAbosluteOffset, widths.content - widths.wrapper), 0)
},
onAffixClick (location: 'prev' | 'next') {
this.$emit(`click:${location}`)
this.scrollTo(location)
},
onResize () {
/* istanbul ignore next */
if (this._isDestroyed) return
this.setWidths()
},
onTouchStart (e: TouchEvent) {
const { content } = this.$refs
this.startX = this.scrollOffset + e.touchstartX as number
content.style.setProperty('transition', 'none')
content.style.setProperty('willChange', 'transform')
},
onTouchMove (e: TouchEvent) {
this.scrollOffset = this.startX - e.touchmoveX
},
onTouchEnd () {
const { content, wrapper } = this.$refs
const maxScrollOffset = content.clientWidth - wrapper.clientWidth
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 {
/* istanbul ignore else */
if (this.scrollOffset < 0 || !this.isOverflowing) {
this.scrollOffset = 0
} else if (this.scrollOffset >= maxScrollOffset) {
this.scrollOffset = maxScrollOffset
}
}
},
overflowCheck (e: TouchEvent, fn: (e: TouchEvent) => void) {
e.stopPropagation()
this.isOverflowing && fn(e)
},
scrollIntoView /* istanbul ignore next */ () {
if (!this.selectedItem) {
return
}
if (
this.selectedIndex === 0 ||
(!this.centerActive && !this.isOverflowing)
) {
this.scrollOffset = 0
} else if (this.centerActive) {
this.scrollOffset = this.calculateCenteredOffset(
this.selectedItem.$el as HTMLElement,
this.widths,
this.$vuetify.rtl
)
} else if (this.isOverflowing) {
this.scrollOffset = this.calculateUpdatedOffset(
this.selectedItem.$el as HTMLElement,
this.widths,
this.$vuetify.rtl,
this.scrollOffset
)
}
},
calculateUpdatedOffset (selectedElement: HTMLElement, widths: Widths, rtl: boolean, currentScrollOffset: number): number {
const clientWidth = selectedElement.clientWidth
const offsetLeft = rtl
? (widths.content - selectedElement.offsetLeft - clientWidth)
: selectedElement.offsetLeft
if (rtl) {
currentScrollOffset = -currentScrollOffset
}
const totalWidth = widths.wrapper + currentScrollOffset
const itemOffset = clientWidth + offsetLeft
const additionalOffset = clientWidth * 0.4
if (offsetLeft <= currentScrollOffset) {
currentScrollOffset = Math.max(offsetLeft - additionalOffset, 0)
} else if (totalWidth <= itemOffset) {
currentScrollOffset = Math.min(currentScrollOffset - (totalWidth - itemOffset - additionalOffset), widths.content - widths.wrapper)
}
return rtl ? -currentScrollOffset : currentScrollOffset
},
calculateCenteredOffset (selectedElement: HTMLElement, widths: Widths, rtl: boolean): number {
const { offsetLeft, clientWidth } = selectedElement
if (rtl) {
const offsetCentered = widths.content - offsetLeft - clientWidth / 2 - widths.wrapper / 2
return -Math.min(widths.content - widths.wrapper, Math.max(0, offsetCentered))
} else {
const offsetCentered = offsetLeft + clientWidth / 2 - widths.wrapper / 2
return Math.min(widths.content - widths.wrapper, Math.max(0, offsetCentered))
}
},
scrollTo /* istanbul ignore next */ (location: 'prev' | 'next') {
this.scrollOffset = this.calculateNewOffset(location, {
// Force reflow
content: this.$refs.content ? this.$refs.content.clientWidth : 0,
wrapper: this.$refs.wrapper ? this.$refs.wrapper.clientWidth : 0,
}, this.$vuetify.rtl, this.scrollOffset)
},
setWidths /* istanbul ignore next */ () {
window.requestAnimationFrame(() => {
const { content, wrapper } = this.$refs
this.widths = {
content: content ? content.clientWidth : 0,
wrapper: wrapper ? wrapper.clientWidth : 0,
}
this.isOverflowing = this.widths.wrapper < this.widths.content
this.scrollIntoView()
})
},
},
render (h): VNode {
return h('div', this.genData(), [
this.genPrev(),
this.genWrapper(),
this.genNext(),
])
},
})
export default BaseSlideGroup.extend({
name: 'v-slide-group',
provide (): object {
return {
slideGroup: this,
}
},
})