Skip to content

Commit

Permalink
refactor(VSlideGroup): replace css transform with native scroll (#17286)
Browse files Browse the repository at this point in the history
Co-authored-by: Kael <kaelwd@gmail.com>
Co-authored-by: John Leider <john@vuetifyjs.com>
  • Loading branch information
3 people authored Apr 23, 2024
1 parent 74cc2bf commit 93bd7b5
Show file tree
Hide file tree
Showing 6 changed files with 260 additions and 157 deletions.
15 changes: 14 additions & 1 deletion packages/vuetify/src/components/VSlideGroup/VSlideGroup.sass
Original file line number Diff line number Diff line change
Expand Up @@ -38,11 +38,24 @@
contain: content
display: flex
flex: 1 1 auto
overflow: hidden
overflow-x: auto
overflow-y: hidden

scrollbar-width: none
scrollbar-color: rgba(0, 0, 0, 0)

&::-webkit-scrollbar
display: none

// Modifiers
.v-slide-group--vertical
max-height: inherit

&,
.v-slide-group__container,
.v-slide-group__content
flex-direction: column

.v-slide-group__container
overflow-x: hidden
overflow-y: auto
217 changes: 121 additions & 96 deletions packages/vuetify/src/components/VSlideGroup/VSlideGroup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { VIcon } from '@/components/VIcon'
// Composables
import { makeComponentProps } from '@/composables/component'
import { makeDisplayProps, useDisplay } from '@/composables/display'
import { useGoTo } from '@/composables/goto'
import { makeGroupProps, useGroup } from '@/composables/group'
import { IconValue } from '@/composables/icons'
import { useRtl } from '@/composables/locale'
Expand All @@ -16,11 +17,19 @@ import { makeTagProps } from '@/composables/tag'

// Utilities
import { computed, shallowRef, watch } from 'vue'
import { bias, calculateCenteredOffset, calculateUpdatedOffset } from './helpers'
import { clamp, focusableChildren, genericComponent, IN_BROWSER, propsFactory, useRender } from '@/util'
import {
calculateCenteredTarget,
calculateUpdatedTarget,
getClientSize,
getOffsetSize,
getScrollPosition,
getScrollSize,
} from './helpers'
import { focusableChildren, genericComponent, IN_BROWSER, propsFactory, useRender } from '@/util'

// Types
import type { InjectionKey, PropType } from 'vue'
import type { GoToOptions } from '@/composables/goto'
import type { GroupProvide } from '@/composables/group'
import type { GenericProps } from '@/util'

Expand Down Expand Up @@ -104,6 +113,15 @@ export const VSlideGroup = genericComponent<new <T>(
const { resizeRef: containerRef, contentRect: containerRect } = useResizeObserver()
const { resizeRef: contentRef, contentRect } = useResizeObserver()

const goTo = useGoTo()
const goToOptions = computed<Partial<GoToOptions>>(() => {
return {
container: containerRef.value,
duration: 200,
easing: 'easeOutQuart',
}
})

const firstSelectedIndex = computed(() => {
if (!group.selected.value.length) return -1

Expand Down Expand Up @@ -134,71 +152,67 @@ export const VSlideGroup = genericComponent<new <T>(
// TODO: Is this too naive? Should we store element references in group composable?
const selectedElement = contentRef.value.children[lastSelectedIndex.value] as HTMLElement

if (firstSelectedIndex.value === 0 || !isOverflowing.value) {
scrollOffset.value = 0
} else if (props.centerActive) {
scrollOffset.value = calculateCenteredOffset({
selectedElement,
containerSize: containerSize.value,
contentSize: contentSize.value,
isRtl: isRtl.value,
isHorizontal: isHorizontal.value,
})
} else if (isOverflowing.value) {
scrollOffset.value = calculateUpdatedOffset({
selectedElement,
containerSize: containerSize.value,
contentSize: contentSize.value,
isRtl: isRtl.value,
currentScrollOffset: scrollOffset.value,
isHorizontal: isHorizontal.value,
})
}
scrollToChildren(selectedElement, props.centerActive)
}
})
})
}

const disableTransition = shallowRef(false)
const isFocused = shallowRef(false)

let startTouch = 0
let startOffset = 0
function scrollToChildren (children: HTMLElement, center?: boolean) {
let target = 0

if (center) {
target = calculateCenteredTarget({
containerElement: containerRef.value!,
isHorizontal: isHorizontal.value,
selectedElement: children,
})
} else {
target = calculateUpdatedTarget({
containerElement: containerRef.value!,
isHorizontal: isHorizontal.value,
isRtl: isRtl.value,
selectedElement: children,
})
}

function onTouchstart (e: TouchEvent) {
const sizeProperty = isHorizontal.value ? 'clientX' : 'clientY'
const sign = isRtl.value && isHorizontal.value ? -1 : 1
startOffset = sign * scrollOffset.value
startTouch = e.touches[0][sizeProperty]
disableTransition.value = true
scrollToPosition(target)
}

function onTouchmove (e: TouchEvent) {
if (!isOverflowing.value) return
function scrollToPosition (newPosition: number) {
if (!IN_BROWSER || !containerRef.value) return

const sizeProperty = isHorizontal.value ? 'clientX' : 'clientY'
const sign = isRtl.value && isHorizontal.value ? -1 : 1
scrollOffset.value = sign * (startOffset + startTouch - e.touches[0][sizeProperty])
}
const offsetSize = getOffsetSize(isHorizontal.value, containerRef.value)
const scrollPosition = getScrollPosition(isHorizontal.value, isRtl.value, containerRef.value)
const scrollSize = getScrollSize(isHorizontal.value, containerRef.value)

function onTouchend (e: TouchEvent) {
const maxScrollOffset = contentSize.value - containerSize.value
if (
scrollSize <= offsetSize ||
// Prevent scrolling by only a couple of pixels, which doesn't look smooth
Math.abs(newPosition - scrollPosition) < 16
) return

if (scrollOffset.value < 0 || !isOverflowing.value) {
scrollOffset.value = 0
} else if (scrollOffset.value >= maxScrollOffset) {
scrollOffset.value = maxScrollOffset
if (isHorizontal.value && isRtl.value && containerRef.value) {
const { scrollWidth, offsetWidth: containerWidth } = containerRef.value!

newPosition = (scrollWidth - containerWidth) - newPosition
}

disableTransition.value = false
if (isHorizontal.value) {
goTo.horizontal(newPosition, goToOptions.value)
} else {
goTo(newPosition, goToOptions.value)
}
}

function onScroll () {
if (!containerRef.value) return
function onScroll (e: UIEvent) {
const { scrollTop, scrollLeft } = e.target as HTMLElement

containerRef.value[isHorizontal.value ? 'scrollLeft' : 'scrollTop'] = 0
scrollOffset.value = isHorizontal.value ? scrollLeft : scrollTop
}

const isFocused = shallowRef(false)
function onFocusin (e: FocusEvent) {
isFocused.value = true

Expand All @@ -209,14 +223,7 @@ export const VSlideGroup = genericComponent<new <T>(
for (const el of e.composedPath()) {
for (const item of contentRef.value.children) {
if (item === el) {
scrollOffset.value = calculateUpdatedOffset({
selectedElement: item as HTMLElement,
containerSize: containerSize.value,
contentSize: contentSize.value,
isRtl: isRtl.value,
currentScrollOffset: scrollOffset.value,
isHorizontal: isHorizontal.value,
})
scrollToChildren(item as HTMLElement)
return
}
}
Expand All @@ -227,82 +234,94 @@ export const VSlideGroup = genericComponent<new <T>(
isFocused.value = false
}

// Affix clicks produce onFocus that we have to ignore to avoid extra scrollToChildren
let ignoreFocusEvent = false
function onFocus (e: FocusEvent) {
if (
!ignoreFocusEvent &&
!isFocused.value &&
!(e.relatedTarget && contentRef.value?.contains(e.relatedTarget as Node))
) focus()

ignoreFocusEvent = false
}

function onFocusAffixes () {
ignoreFocusEvent = true
}

function onKeydown (e: KeyboardEvent) {
if (!contentRef.value) return

function toFocus (location: Parameters<typeof focus>[0]) {
e.preventDefault()
focus(location)
}

if (isHorizontal.value) {
if (e.key === 'ArrowRight') {
focus(isRtl.value ? 'prev' : 'next')
toFocus(isRtl.value ? 'prev' : 'next')
} else if (e.key === 'ArrowLeft') {
focus(isRtl.value ? 'next' : 'prev')
toFocus(isRtl.value ? 'next' : 'prev')
}
} else {
if (e.key === 'ArrowDown') {
focus('next')
toFocus('next')
} else if (e.key === 'ArrowUp') {
focus('prev')
toFocus('prev')
}
}

if (e.key === 'Home') {
focus('first')
toFocus('first')
} else if (e.key === 'End') {
focus('last')
toFocus('last')
}
}

function focus (location?: 'next' | 'prev' | 'first' | 'last') {
if (!contentRef.value) return

let el: HTMLElement | undefined

if (!location) {
const focusable = focusableChildren(contentRef.value)
focusable[0]?.focus()
el = focusable[0]
} else if (location === 'next') {
const el = contentRef.value.querySelector(':focus')?.nextElementSibling as HTMLElement | undefined
if (el) el.focus()
else focus('first')
el = contentRef.value.querySelector(':focus')?.nextElementSibling as HTMLElement | undefined

if (!el) return focus('first')
} else if (location === 'prev') {
const el = contentRef.value.querySelector(':focus')?.previousElementSibling as HTMLElement | undefined
if (el) el.focus()
else focus('last')
el = contentRef.value.querySelector(':focus')?.previousElementSibling as HTMLElement | undefined

if (!el) return focus('last')
} else if (location === 'first') {
(contentRef.value.firstElementChild as HTMLElement)?.focus()
el = (contentRef.value.firstElementChild as HTMLElement)
} else if (location === 'last') {
(contentRef.value.lastElementChild as HTMLElement)?.focus()
el = (contentRef.value.lastElementChild as HTMLElement)
}

if (el) {
el.focus({ preventScroll: true })
}
}

function scrollTo (location: 'prev' | 'next') {
const newAbsoluteOffset = scrollOffset.value + (location === 'prev' ? -1 : 1) * containerSize.value
const direction = isHorizontal.value && isRtl.value ? -1 : 1

scrollOffset.value = clamp(newAbsoluteOffset, 0, contentSize.value - containerSize.value)
}
const offsetStep = (location === 'prev' ? -direction : direction) * containerSize.value

const contentStyles = computed(() => {
// This adds friction when scrolling the 'wrong' way when at max offset
let scrollAmount = scrollOffset.value > contentSize.value - containerSize.value
? -(contentSize.value - containerSize.value) + bias(contentSize.value - containerSize.value - scrollOffset.value)
: -scrollOffset.value
let newPosition = scrollOffset.value + offsetStep

// This adds friction when scrolling the 'wrong' way when at min offset
if (scrollOffset.value <= 0) {
scrollAmount = bias(-scrollOffset.value)
}
// TODO: improve it
if (isHorizontal.value && isRtl.value && containerRef.value) {
const { scrollWidth, offsetWidth: containerWidth } = containerRef.value!

const sign = isRtl.value && isHorizontal.value ? -1 : 1
return {
transform: `translate${isHorizontal.value ? 'X' : 'Y'}(${sign * scrollAmount}px)`,
transition: disableTransition.value ? 'none' : '',
willChange: disableTransition.value ? 'transform' : '',
newPosition += scrollWidth - containerWidth
}
})

scrollToPosition(newPosition)
}

const slotProps = computed(() => ({
next: group.next,
Expand Down Expand Up @@ -340,12 +359,20 @@ export const VSlideGroup = genericComponent<new <T>(
})

const hasPrev = computed(() => {
return Math.abs(scrollOffset.value) > 0
// 1 pixel in reserve, may be lost after rounding
return Math.abs(scrollOffset.value) > 1
})

const hasNext = computed(() => {
// Check one scroll ahead to know the width of right-most item
return contentSize.value > Math.abs(scrollOffset.value) + containerSize.value
if (!containerRef.value) return false

const scrollSize = getScrollSize(isHorizontal.value, containerRef.value)
const clientSize = getClientSize(isHorizontal.value, containerRef.value)

const scrollSizeMax = scrollSize - clientSize

// 1 pixel in reserve, may be lost after rounding
return scrollSizeMax - Math.abs(scrollOffset.value) > 1
})

useRender(() => (
Expand All @@ -371,6 +398,7 @@ export const VSlideGroup = genericComponent<new <T>(
'v-slide-group__prev',
{ 'v-slide-group__prev--disabled': !hasPrev.value },
]}
onMousedown={ onFocusAffixes }
onClick={ () => hasPrev.value && scrollTo('prev') }
>
{ slots.prev?.(slotProps.value) ?? (
Expand All @@ -390,10 +418,6 @@ export const VSlideGroup = genericComponent<new <T>(
<div
ref={ contentRef }
class="v-slide-group__content"
style={ contentStyles.value }
onTouchstartPassive={ onTouchstart }
onTouchmovePassive={ onTouchmove }
onTouchendPassive={ onTouchend }
onFocusin={ onFocusin }
onFocusout={ onFocusout }
onKeydown={ onKeydown }
Expand All @@ -409,6 +433,7 @@ export const VSlideGroup = genericComponent<new <T>(
'v-slide-group__next',
{ 'v-slide-group__next--disabled': !hasNext.value },
]}
onMousedown={ onFocusAffixes }
onClick={ () => hasNext.value && scrollTo('next') }
>
{ slots.next?.(slotProps.value) ?? (
Expand Down
Loading

0 comments on commit 93bd7b5

Please sign in to comment.