UNPKG

element-plus

Version:

A Component Library for Vue 3

538 lines (471 loc) 14.8 kB
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