element-plus
Version:
A Component Library for Vue 3
538 lines (471 loc) • 14.8 kB
text/typescript
import {
computed,
defineComponent,
getCurrentInstance,
ref,
nextTick,
onMounted,
onUpdated,
resolveDynamicComponent,
h,
unref,
} from 'vue'
import { hasOwn } from '@vue/shared'
import { isNumber, isString } from '@element-plus/utils/util'
import isServer from '@element-plus/utils/isServer'
import { useCache } from '../hooks/use-cache'
import useWheel from '../hooks/use-wheel'
import Scrollbar from '../components/scrollbar'
import { getScrollDir, isHorizontal, getRTLOffsetType } from '../utils'
import { virtualizedListProps } from '../props'
import {
AUTO_ALIGNMENT,
BACKWARD,
FORWARD,
RTL,
HORIZONTAL,
ITEM_RENDER_EVT,
SCROLL_EVT,
RTL_OFFSET_NAG,
RTL_OFFSET_POS_DESC,
} from '../defaults'
import type { VNode, CSSProperties, Slot, VNodeChild } from 'vue'
import type { ListConstructorProps, Alignment } from '../types'
import type { VirtualizedListProps } from '../props'
const createList = ({
name,
getOffset,
getItemSize,
getItemOffset,
getEstimatedTotalSize,
getStartIndexForOffset,
getStopIndexForStartIndex,
initCache,
clearCache,
validateProps,
}: ListConstructorProps<VirtualizedListProps>) => {
return defineComponent({
name: name ?? 'ElVirtualList',
props: virtualizedListProps,
emits: [ITEM_RENDER_EVT, SCROLL_EVT],
setup(props, { emit, expose }) {
validateProps(props)
const instance = getCurrentInstance()!
const dynamicSizeCache = ref(initCache(props, instance))
const getItemStyleCache = useCache()
// refs
// here windowRef and innerRef can be type of HTMLElement
// or user defined component type, depends on the type passed
// by user
const windowRef = ref<HTMLElement>()
const innerRef = ref<HTMLElement>()
const scrollbarRef = ref()
const states = ref({
isScrolling: false,
scrollDir: 'forward',
scrollOffset: isNumber(props.initScrollOffset)
? props.initScrollOffset
: 0,
updateRequested: false,
isScrollbarDragging: false,
})
// computed
const itemsToRender = computed(() => {
const { total, cache } = props
const { isScrolling, scrollDir, scrollOffset } = unref(states)
if (total === 0) {
return [0, 0, 0, 0]
}
const startIndex = getStartIndexForOffset(
props,
scrollOffset,
unref(dynamicSizeCache)
)
const stopIndex = getStopIndexForStartIndex(
props,
startIndex,
scrollOffset,
unref(dynamicSizeCache)
)
const cacheBackward =
!isScrolling || scrollDir === BACKWARD ? Math.max(1, cache) : 1
const cacheForward =
!isScrolling || scrollDir === FORWARD ? Math.max(1, cache) : 1
return [
Math.max(0, startIndex - cacheBackward),
Math.max(0, Math.min(total! - 1, stopIndex + cacheForward)),
startIndex,
stopIndex,
]
})
const estimatedTotalSize = computed(() =>
getEstimatedTotalSize(props, unref(dynamicSizeCache))
)
const _isHorizontal = computed(() => isHorizontal(props.layout))
const windowStyle = computed(() => [
{
position: 'relative',
overflow: 'hidden',
WebkitOverflowScrolling: 'touch',
willChange: 'transform',
},
{
direction: props.direction,
height: isNumber(props.height) ? `${props.height}px` : props.height,
width: isNumber(props.width) ? `${props.width}px` : props.width,
},
props.style,
])
const innerStyle = computed(() => {
const size = unref(estimatedTotalSize)
const horizontal = unref(_isHorizontal)
return {
height: horizontal ? '100%' : `${size}px`,
pointerEvents: unref(states).isScrolling ? 'none' : undefined,
width: horizontal ? `${size}px` : '100%',
}
})
const clientSize = computed(() =>
_isHorizontal.value ? props.width : props.height
)
// methods
const { onWheel } = useWheel(
{
atStartEdge: computed(() => states.value.scrollOffset <= 0),
atEndEdge: computed(
() => states.value.scrollOffset >= estimatedTotalSize.value
),
layout: computed(() => props.layout),
},
(offset) => {
;(
scrollbarRef.value as any as {
onMouseUp: () => void
}
).onMouseUp?.()
scrollTo(
Math.min(
states.value.scrollOffset + offset,
estimatedTotalSize.value - (clientSize.value as number)
)
)
}
)
const emitEvents = () => {
const { total } = props
if (total! > 0) {
const [cacheStart, cacheEnd, visibleStart, visibleEnd] =
unref(itemsToRender)
emit(ITEM_RENDER_EVT, cacheStart, cacheEnd, visibleStart, visibleEnd)
}
const { scrollDir, scrollOffset, updateRequested } = unref(states)
emit(SCROLL_EVT, scrollDir, scrollOffset, updateRequested)
}
const scrollVertically = (e: Event) => {
const { clientHeight, scrollHeight, scrollTop } =
e.currentTarget as HTMLElement
const _states = unref(states)
if (_states.scrollOffset === scrollTop) {
return
}
const scrollOffset = Math.max(
0,
Math.min(scrollTop, scrollHeight - clientHeight)
)
states.value = {
..._states,
isScrolling: true,
scrollDir: getScrollDir(_states.scrollOffset, scrollOffset),
scrollOffset,
updateRequested: false,
}
nextTick(resetIsScrolling)
}
const scrollHorizontally = (e: Event) => {
const { clientWidth, scrollLeft, scrollWidth } =
e.currentTarget as HTMLElement
const _states = unref(states)
if (_states.scrollOffset === scrollLeft) {
return
}
const { direction } = props
let scrollOffset = scrollLeft
if (direction === RTL) {
// TRICKY According to the spec, scrollLeft should be negative for RTL aligned elements.
// This is not the case for all browsers though (e.g. Chrome reports values as positive, measured relative to the left).
// It's also easier for this component if we convert offsets to the same format as they would be in for ltr.
// So the simplest solution is to determine which browser behavior we're dealing with, and convert based on it.
switch (getRTLOffsetType()) {
case RTL_OFFSET_NAG: {
scrollOffset = -scrollLeft
break
}
case RTL_OFFSET_POS_DESC: {
scrollOffset = scrollWidth - clientWidth - scrollLeft
break
}
}
}
scrollOffset = Math.max(
0,
Math.min(scrollOffset, scrollWidth - clientWidth)
)
states.value = {
..._states,
isScrolling: true,
scrollDir: getScrollDir(_states.scrollOffset, scrollOffset),
scrollOffset,
updateRequested: false,
}
nextTick(resetIsScrolling)
}
const onScroll = (e: Event) => {
unref(_isHorizontal) ? scrollHorizontally(e) : scrollVertically(e)
emitEvents()
}
const onScrollbarScroll = (distanceToGo: number, totalSteps: number) => {
const offset =
((estimatedTotalSize.value - (clientSize.value as number)) /
totalSteps) *
distanceToGo
scrollTo(
Math.min(
estimatedTotalSize.value - (clientSize.value as number),
offset
)
)
}
const scrollTo = (offset: number) => {
offset = Math.max(offset, 0)
if (offset === unref(states).scrollOffset) {
return
}
states.value = {
...unref(states),
scrollOffset: offset,
scrollDir: getScrollDir(unref(states).scrollOffset, offset),
updateRequested: true,
}
nextTick(resetIsScrolling)
}
const scrollToItem = (
idx: number,
alignment: Alignment = AUTO_ALIGNMENT
) => {
const { scrollOffset } = unref(states)
idx = Math.max(0, Math.min(idx, props.total! - 1))
scrollTo(
getOffset(
props,
idx,
alignment,
scrollOffset,
unref(dynamicSizeCache)
)
)
}
const getItemStyle = (idx: number) => {
const { direction, itemSize, layout } = props
const itemStyleCache = getItemStyleCache.value(
clearCache && itemSize,
clearCache && layout,
clearCache && direction
)
let style: CSSProperties
if (hasOwn(itemStyleCache, String(idx))) {
style = itemStyleCache[idx]
} else {
const offset = getItemOffset(props, idx, unref(dynamicSizeCache))
const size = getItemSize(props, idx, unref(dynamicSizeCache))
const horizontal = unref(_isHorizontal)
const isRtl = direction === RTL
const offsetHorizontal = horizontal ? offset : 0
itemStyleCache[idx] = style = {
position: 'absolute',
left: isRtl ? undefined : `${offsetHorizontal}px`,
right: isRtl ? `${offsetHorizontal}px` : undefined,
top: !horizontal ? `${offset}px` : 0,
height: !horizontal ? `${size}px` : '100%',
width: horizontal ? `${size}px` : '100%',
}
}
return style
}
// TODO:
// perf optimization here, reset isScrolling with debounce.
const resetIsScrolling = () => {
// timer = null
states.value.isScrolling = false
nextTick(() => {
getItemStyleCache.value(-1, null, null)
})
}
const resetScrollTop = () => {
const window = windowRef.value
if (window) {
window.scrollTop = 0
}
}
// life cycles
onMounted(() => {
if (isServer) return
const { initScrollOffset } = props
const windowElement = unref(windowRef)
if (isNumber(initScrollOffset) && windowElement) {
if (unref(_isHorizontal)) {
windowElement.scrollLeft = initScrollOffset
} else {
windowElement.scrollTop = initScrollOffset
}
}
emitEvents()
})
onUpdated(() => {
const { direction, layout } = props
const { scrollOffset, updateRequested } = unref(states)
const windowElement = unref(windowRef)
if (updateRequested && windowElement) {
if (layout === HORIZONTAL) {
if (direction === RTL) {
// TRICKY According to the spec, scrollLeft should be negative for RTL aligned elements.
// This is not the case for all browsers though (e.g. Chrome reports values as positive, measured relative to the left).
// So we need to determine which browser behavior we're dealing with, and mimic it.
switch (getRTLOffsetType()) {
case 'negative': {
windowElement.scrollLeft = -scrollOffset
break
}
case 'positive-ascending': {
windowElement.scrollLeft = scrollOffset
break
}
default: {
const { clientWidth, scrollWidth } = windowElement
windowElement.scrollLeft =
scrollWidth - clientWidth - scrollOffset
break
}
}
} else {
windowElement.scrollLeft = scrollOffset
}
} else {
windowElement.scrollTop = scrollOffset
}
}
})
const api = {
clientSize,
estimatedTotalSize,
windowStyle,
windowRef,
innerRef,
innerStyle,
itemsToRender,
scrollbarRef,
states,
getItemStyle,
onScroll,
onScrollbarScroll,
onWheel,
scrollTo,
scrollToItem,
resetScrollTop,
}
expose({
windowRef,
innerRef,
getItemStyleCache,
scrollTo,
scrollToItem,
resetScrollTop,
states,
})
return api
},
render(ctx: any) {
const {
$slots,
className,
clientSize,
containerElement,
data,
getItemStyle,
innerElement,
itemsToRender,
innerStyle,
layout,
total,
onScroll,
onScrollbarScroll,
onWheel,
states,
useIsScrolling,
windowStyle,
} = ctx
const [start, end] = itemsToRender
const Container = resolveDynamicComponent(containerElement)
const Inner = resolveDynamicComponent(innerElement)
const children = [] as VNodeChild[]
if (total > 0) {
for (let i = start; i <= end; i++) {
children.push(
($slots.default as Slot)?.({
data,
key: i,
index: i,
isScrolling: useIsScrolling ? states.isScrolling : undefined,
style: getItemStyle(i),
})
)
}
}
const InnerNode = [
h(
Inner as VNode,
{
style: innerStyle,
ref: 'innerRef',
},
!isString(Inner)
? {
default: () => children,
}
: children
),
]
const scrollbar = h(Scrollbar, {
ref: 'scrollbarRef',
clientSize,
layout,
onScroll: onScrollbarScroll,
ratio: (clientSize * 100) / this.estimatedTotalSize,
scrollFrom:
states.scrollOffset / (this.estimatedTotalSize - clientSize),
total,
visible: true,
})
const listContainer = h(
Container as VNode,
{
class: className,
style: windowStyle,
onScroll,
onWheel,
ref: 'windowRef',
key: 0,
},
!isString(Container) ? { default: () => [InnerNode] } : [InnerNode]
)
return h(
'div',
{
key: 0,
class: 'el-vl__wrapper',
},
[listContainer, scrollbar]
)
},
})
}
export default createList