Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

refactor(VSlideGroup): replace css transform with native scroll #17286

Merged
merged 17 commits into from
Apr 23, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading