feat(components): [el-virtualized-grid] fulfillment (#3612)

* feat(components): [el-virtualized-grid] fulfillment

- Add scrollabr and scroll handler to v-grid

* Address comments and code perfection

* Update render function

* fix linter

* address comments
This commit is contained in:
jeremywu 2021-09-26 19:22:56 +08:00 committed by GitHub
parent 9a5a1d5ea6
commit 50a1b6f892
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 307 additions and 153 deletions

View File

@ -1,4 +1,4 @@
import { nextTick } from 'vue'
import { nextTick, unref } from 'vue'
import makeMount from '@element-plus/test-utils/make-mount'
import makeScroll from '@element-plus/test-utils/make-scroll'
import setupMock from '../setup-mock'
@ -96,12 +96,12 @@ describe('<fixed-size-grid />', () => {
expect(wrapper.findAll(ITEM_SELECTOR)).toHaveLength(36)
const gridRef = wrapper.vm.$refs.gridRef as GridRef
makeScroll(gridRef.windowRef, 'scrollTop', 100)
makeScroll(unref(gridRef.windowRef), 'scrollTop', 100)
await nextTick()
// 8 x 5 grid
expect(wrapper.findAll(ITEM_SELECTOR)).toHaveLength(40)
makeScroll(gridRef.windowRef, 'scrollLeft', 100)
makeScroll(unref(gridRef.windowRef), 'scrollLeft', 100)
await nextTick()
expect(wrapper.findAll(ITEM_SELECTOR)).toHaveLength(64)
})
@ -112,11 +112,11 @@ describe('<fixed-size-grid />', () => {
expect(wrapper.findAll(ITEM_SELECTOR)).toHaveLength(36)
const gridRef = wrapper.vm.$refs.gridRef as GridRef
makeScroll(gridRef.windowRef, 'scrollTop', 0)
makeScroll(unref(gridRef.windowRef), 'scrollTop', 0)
await nextTick()
expect(wrapper.findAll(ITEM_SELECTOR)).toHaveLength(36)
makeScroll(gridRef.windowRef, 'scrollLeft', 0)
makeScroll(unref(gridRef.windowRef), 'scrollLeft', 0)
await nextTick()
expect(wrapper.findAll(ITEM_SELECTOR)).toHaveLength(36)
})

View File

@ -1,4 +1,4 @@
import { nextTick } from 'vue'
import { nextTick, unref } from 'vue'
import makeMount from '@element-plus/test-utils/make-mount'
import makeScroll from '@element-plus/test-utils/make-scroll'
import setupMock from '../setup-mock'
@ -71,8 +71,8 @@ describe('<fixed-size-grid />', () => {
expect(wrapper.findAll(ITEM_SELECTOR)).toHaveLength(24)
const gridRef = wrapper.vm.$refs.gridRef as GridRef
expect(gridRef.innerRef.style.height).toBe('2500px')
expect(gridRef.innerRef.style.width).toBe('5000px')
expect(unref(gridRef.innerRef).style.height).toBe('2500px')
expect(unref(gridRef.innerRef).style.width).toBe('5000px')
})
it('should render zero row zero column', async () => {
@ -96,11 +96,11 @@ describe('<fixed-size-grid />', () => {
const gridRef = wrapper.vm.$refs.gridRef as GridRef
makeScroll(gridRef.windowRef, 'scrollTop', 100)
makeScroll(unref(gridRef.windowRef), 'scrollTop', 100)
await nextTick()
expect(wrapper.findAll(ITEM_SELECTOR)).toHaveLength(21)
makeScroll(gridRef.windowRef, 'scrollLeft', 100)
makeScroll(unref(gridRef.windowRef), 'scrollLeft', 100)
await nextTick()
// 5 (backward cache 1 + visible 2 + forward cache 2)
// * 7 (backward cache 1 + visible 4 + forward cache 2)
@ -114,11 +114,11 @@ describe('<fixed-size-grid />', () => {
expect(wrapper.findAll(ITEM_SELECTOR)).toHaveLength(24)
const gridRef = wrapper.vm.$refs.gridRef as GridRef
makeScroll(gridRef.windowRef, 'scrollTop', 0)
makeScroll(unref(gridRef.windowRef), 'scrollTop', 0)
await nextTick()
expect(wrapper.findAll(ITEM_SELECTOR)).toHaveLength(24)
makeScroll(gridRef.windowRef, 'scrollLeft', 0)
makeScroll(unref(gridRef.windowRef), 'scrollLeft', 0)
await nextTick()
expect(wrapper.findAll(ITEM_SELECTOR)).toHaveLength(24)
})

View File

@ -6,7 +6,7 @@ import { ScrollbarDirKey } from '../src/defaults'
describe('virtual scrollbar', () => {
async function testInlineStyle(layout = 'vertical') {
const wrapper = mount({
template: `<scrollbar visible layout="${layout}"></scrollbar>`,
template: `<scrollbar visible layout="${layout}" :total="100" :ratio="25" :client-size="100" :scroll-from="20"></scrollbar>`,
components: {
Scrollbar,
},

View File

@ -16,7 +16,9 @@ import { isNumber, isString } from '@element-plus/utils/util'
import isServer from '@element-plus/utils/isServer'
import getScrollBarWidth from '@element-plus/utils/scrollbar-width'
import { useCache } from '../hooks/useCache'
import Scrollbar from '../components/scrollbar'
import { useGridWheel } from '../hooks/use-grid-wheel'
import { useCache } from '../hooks/use-cache'
import { virtualizedGridProps } from '../props'
import { getScrollDir, getRTLOffsetType, isRTL } from '../utils'
import {
@ -31,8 +33,9 @@ import {
RTL_OFFSET_POS_ASC,
} from '../defaults'
import type { CSSProperties, Slot, VNode, VNodeChild } from 'vue'
import type { GridConstructorProps, Alignment } from '../types'
import type { CSSProperties, VNode, VNodeChild } from 'vue'
import type { StyleValue } from '@element-plus/utils/types'
import type { GridConstructorProps, Alignment, ScrollbarExpose } from '../types'
import type { VirtualizedGridProps } from '../props'
const createGrid = ({
@ -56,7 +59,7 @@ const createGrid = ({
name: name ?? 'ElVirtualList',
props: virtualizedGridProps,
emits: [ITEM_RENDER_EVT, SCROLL_EVT],
setup(props, { emit, expose }) {
setup(props, { emit, expose, slots }) {
validateProps(props)
const instance = getCurrentInstance()!
const cache = ref(initCache(props, instance))
@ -65,6 +68,8 @@ const createGrid = ({
// or user defined component type, depends on the type passed
// by user
const windowRef = ref<HTMLElement>()
const hScrollbar = ref<ScrollbarExpose>()
const vScrollbar = ref<ScrollbarExpose>()
// innerRef is the actual container element which contains all the elements
const innerRef = ref(null)
const states = ref({
@ -79,6 +84,8 @@ const createGrid = ({
const getItemStyleCache = useCache()
// computed
const parsedHeight = computed(() => parseInt(`${props.height}`, 10))
const parsedWidth = computed(() => parseInt(`${props.width}`, 10))
const columnsToRender = computed(() => {
const { totalColumn, totalRow, columnCache } = props
const { isScrolling, xAxisScrollDir, scrollLeft } = unref(states)
@ -158,10 +165,10 @@ const createGrid = ({
getEstimatedTotalWidth(props, unref(cache))
)
const windowStyle = computed(() => [
const windowStyle = computed<StyleValue>(() => [
{
position: 'relative',
overflow: 'auto',
overflow: 'hidden',
WebkitOverflowScrolling: 'touch',
willChange: 'transform',
},
@ -170,7 +177,7 @@ const createGrid = ({
height: isNumber(props.height) ? `${props.height}px` : props.height,
width: isNumber(props.width) ? `${props.width}px` : props.width,
},
props.style,
props.style ?? {},
])
const innerStyle = computed(() => {
@ -279,7 +286,57 @@ const createGrid = ({
emitEvents()
}
const scrollTo = ({ scrollLeft, scrollTop }) => {
const onVerticalScroll = (distance: number, totalSteps: number) => {
const height = unref(parsedHeight)
const offset =
((estimatedTotalHeight.value - height) / totalSteps) * distance
scrollTo({
scrollTop: Math.min(estimatedTotalHeight.value - height, offset),
})
}
const onHorizontalScroll = (distance: number, totalSteps: number) => {
const width = unref(parsedWidth)
const offset =
((estimatedTotalWidth.value - width) / totalSteps) * distance
scrollTo({
scrollLeft: Math.min(estimatedTotalWidth.value - width, offset),
})
}
const { onWheel } = useGridWheel(
{
atXStartEdge: computed(() => states.value.scrollLeft <= 0),
atXEndEdge: computed(
() => states.value.scrollLeft >= estimatedTotalWidth.value
),
atYStartEdge: computed(() => states.value.scrollTop <= 0),
atYEndEdge: computed(
() => states.value.scrollTop >= estimatedTotalHeight.value
),
},
(x: number, y: number) => {
hScrollbar.value?.onMouseUp?.()
hScrollbar.value?.onMouseUp?.()
const width = unref(parsedWidth)
const height = unref(parsedHeight)
scrollTo({
scrollLeft: Math.min(
states.value.scrollLeft + x,
estimatedTotalWidth.value - width
),
scrollTop: Math.min(
states.value.scrollTop + y,
estimatedTotalHeight.value - height
),
})
}
)
const scrollTo = ({
scrollLeft = states.value.scrollLeft,
scrollTop = states.value.scrollTop,
}) => {
scrollLeft = Math.max(scrollLeft, 0)
scrollTop = Math.max(scrollTop, 0)
const _states = unref(states)
@ -434,20 +491,6 @@ const createGrid = ({
}
})
const api = {
windowStyle,
windowRef,
columnsToRender,
innerRef,
innerStyle,
states,
rowsToRender,
getItemStyle,
onScroll,
scrollTo,
scrollToItem,
}
expose({
windowRef,
innerRef,
@ -457,77 +500,121 @@ const createGrid = ({
states,
})
return api
},
// rendering part
render(ctx: any) {
const {
$slots,
className,
containerElement,
columnsToRender,
data,
getItemStyle,
innerElement,
innerStyle,
rowsToRender,
onScroll,
states,
useIsScrolling,
windowStyle,
totalColumn,
totalRow,
} = ctx
const renderScrollbars = () => {
const { totalColumn, totalRow } = props
const [columnStart, columnEnd] = columnsToRender
const [rowStart, rowEnd] = rowsToRender
const width = unref(parsedWidth)
const height = unref(parsedHeight)
const estimatedWidth = unref(estimatedTotalWidth)
const estimatedHeight = unref(estimatedTotalHeight)
const { scrollLeft, scrollTop } = unref(states)
const horizontalScrollbar = h(Scrollbar, {
ref: hScrollbar,
clientSize: width,
layout: 'horizontal',
onScroll: onHorizontalScroll,
ratio: (width * 100) / estimatedWidth,
scrollFrom: scrollLeft / (estimatedWidth - width),
total: totalRow,
visible: true,
})
const Container = resolveDynamicComponent(containerElement)
const Inner = resolveDynamicComponent(innerElement)
const verticalScrollbar = h(Scrollbar, {
ref: vScrollbar,
clientSize: height,
layout: 'vertical',
onScroll: onVerticalScroll,
ratio: (height * 100) / estimatedHeight,
scrollFrom: scrollTop / (estimatedHeight - height),
total: totalColumn,
visible: true,
})
const children: VNodeChild[] = []
if (totalRow > 0 && totalColumn > 0) {
for (let row = rowStart; row <= rowEnd; row++) {
for (let column = columnStart; column <= columnEnd; column++) {
children.push(
($slots.default as Slot)?.({
columnIndex: column,
data,
key: column,
isScrolling: useIsScrolling ? states.isScrolling : undefined,
style: getItemStyle(row, column),
rowIndex: row,
})
)
}
return {
horizontalScrollbar,
verticalScrollbar,
}
}
const InnerNode = [
h(
Inner as VNode,
{
style: innerStyle,
ref: 'innerRef',
},
!isString(Inner)
? {
default: () => children,
}
: children
),
]
const renderItems = () => {
const [columnStart, columnEnd] = unref(columnsToRender)
const [rowStart, rowEnd] = unref(rowsToRender)
const { data, totalColumn, totalRow, useIsScrolling } = props
const children: VNodeChild[] = []
if (totalRow > 0 && totalColumn > 0) {
for (let row = rowStart; row <= rowEnd; row++) {
for (let column = columnStart; column <= columnEnd; column++) {
children.push(
slots.default?.({
columnIndex: column,
data,
key: column,
isScrolling: useIsScrolling
? unref(states).isScrolling
: undefined,
style: getItemStyle(row, column),
rowIndex: row,
})
)
}
}
}
return children
}
return h(
Container as VNode,
{
class: className,
style: windowStyle,
onScroll,
ref: 'windowRef',
},
!isString(Container) ? { default: () => InnerNode } : InnerNode
)
const renderInner = () => {
const Inner = resolveDynamicComponent(props.innerElement) as VNode
const children = renderItems()
return [
h(
Inner,
{
style: unref(innerStyle),
ref: innerRef,
},
!isString(Inner)
? {
default: () => children,
}
: children
),
]
}
const renderWindow = () => {
const Container = resolveDynamicComponent(
props.containerElement
) as VNode
const { horizontalScrollbar, verticalScrollbar } = renderScrollbars()
const Inner = renderInner()
return h(
'div',
{
key: 0,
class: 'el-vg__wrapper',
},
[
h(
Container,
{
class: props.className,
style: unref(windowStyle),
onScroll,
onWheel,
ref: windowRef,
},
!isString(Container) ? { default: () => Inner } : Inner
),
horizontalScrollbar,
verticalScrollbar,
]
)
}
return renderWindow
},
})
}

View File

@ -15,8 +15,8 @@ import { hasOwn } from '@vue/shared'
import { isNumber, isString } from '@element-plus/utils/util'
import isServer from '@element-plus/utils/isServer'
import { useCache } from '../hooks/useCache'
import useWheel from '../hooks/useWheel'
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'
@ -64,7 +64,7 @@ const createList = ({
// by user
const windowRef = ref<HTMLElement>()
const innerRef = ref<HTMLElement>()
const scrollbarRef = ref(null)
const scrollbarRef = ref()
const states = ref({
isScrolling: false,

View File

@ -1,6 +1,6 @@
import { isFunction } from '@vue/shared'
import { throwError } from '@element-plus/utils/error'
import createGrid from '../builders/buildGrid'
import createGrid from '../builders/build-grid'
import {
AUTO_ALIGNMENT,
@ -10,15 +10,14 @@ import {
SMART_ALIGNMENT,
START_ALIGNMENT,
} from '../defaults'
import type { DefaultGridProps } from '../defaults'
import type { VirtualizedGridProps } from '../props'
import type { ExtractPropTypes } from 'vue'
import type { Alignment, GridCache, ListItem, ItemSize } from '../types'
const { max, min, floor } = Math
const SCOPE = 'ElDynamicSizeGrid'
type Props = ExtractPropTypes<typeof DefaultGridProps>
type Props = VirtualizedGridProps
type CacheItemType = 'column' | 'row'
// generates props access key via type

View File

@ -1,6 +1,6 @@
import { throwError } from '@element-plus/utils/error'
import createList from '../builders/buildList'
import createList from '../builders/build-list'
import { isHorizontal } from '../utils'
import {
@ -228,10 +228,10 @@ const DynamicSizeList = createList({
cache.clearCacheAfterIndex = (index: number, forceUpdate = true) => {
cache.lastVisitedIndex = Math.min(cache.lastVisitedIndex, index - 1)
instance.exposed.getItemStyleCache(-1)
instance.exposed?.getItemStyleCache(-1)
if (forceUpdate) {
instance.proxy.$forceUpdate()
instance.proxy?.$forceUpdate()
}
}

View File

@ -1,6 +1,6 @@
import { isNumber } from '@element-plus/utils/util'
import { throwError } from '@element-plus/utils/error'
import createGrid from '../builders/buildGrid'
import createGrid from '../builders/build-grid'
import {
AUTO_ALIGNMENT,
@ -198,8 +198,11 @@ const FixedSizeGrid = createGrid({
)
)
},
initCache: () => undefined,
/**
* Fixed size grid does not need this cache
* Using any to bypass it, TODO: Using type inference to fix this.
*/
initCache: () => undefined as any,
clearCache: true,

View File

@ -1,6 +1,6 @@
import { isString } from '@element-plus/utils/util'
import { throwError } from '@element-plus/utils/error'
import buildList from '../builders/buildList'
import buildList from '../builders/build-list'
import { isHorizontal } from '../utils'
import {
SMART_ALIGNMENT,
@ -9,11 +9,10 @@ import {
END_ALIGNMENT,
CENTERED_ALIGNMENT,
} from '../defaults'
import type { DefaultListProps } from '../defaults'
import type { ExtractPropTypes } from 'vue'
import type { VirtualizedListProps } from '../props'
type IProps = ExtractPropTypes<typeof DefaultListProps>
type Props = VirtualizedListProps
const FixedSizeList = buildList({
name: 'ElFixedSizeList',
@ -92,7 +91,7 @@ const FixedSizeList = buildList({
Math.max(0, Math.min(total - 1, Math.floor(offset / (itemSize as number)))),
getStopIndexForStartIndex: (
{ height, total, itemSize, layout, width }: IProps,
{ height, total, itemSize, layout, width }: Props,
startIndex: number,
scrollOffset: number
) => {
@ -112,8 +111,12 @@ const FixedSizeList = buildList({
)
},
/**
* Fixed size list does not need this cache
* Using any to bypass it, TODO: Using type inference to fix this.
*/
initCache() {
return undefined
return undefined as any
},
clearCache: true,

View File

@ -0,0 +1,61 @@
import { rAF, cAF } from '@element-plus/utils/raf'
import { isFF } from '../utils'
import type { ComputedRef } from 'vue'
interface GridWheelState {
atXStartEdge: ComputedRef<boolean>
atXEndEdge: ComputedRef<boolean>
atYStartEdge: ComputedRef<boolean>
atYEndEdge: ComputedRef<boolean>
}
type GridWheelHandler = (x: number, y: number) => void
export const useGridWheel = (
{ atXEndEdge, atXStartEdge, atYEndEdge, atYStartEdge }: GridWheelState,
onWheelDelta: GridWheelHandler
) => {
let frameHandle: number | null = null
let xOffset = 0
let yOffset = 0
const hasReachedEdge = (x: number, y: number) => {
const xEdgeReached =
(x < 0 && atXStartEdge.value) || (x > 0 && atXEndEdge.value)
const yEdgeReached =
(y < 0 && atYStartEdge.value) || (y > 0 && atYEndEdge.value)
return xEdgeReached && yEdgeReached
}
const onWheel = (e: WheelEvent) => {
cAF(frameHandle!)
const x = e.deltaX
const y = e.deltaY
if (
hasReachedEdge(xOffset, yOffset) &&
hasReachedEdge(xOffset + x, yOffset + y)
)
return
xOffset += x
yOffset += y
if (!isFF) {
e.preventDefault()
}
frameHandle = rAF(() => {
onWheelDelta(xOffset, yOffset)
xOffset = 0
yOffset = 0
})
}
return {
hasReachedEdge,
onWheel,
}
}

View File

@ -10,19 +10,19 @@ const LayoutKeys = {
[VERTICAL]: 'deltaY',
}
interface IWheelState {
interface ListWheelState {
atStartEdge: ComputedRef<boolean> // exclusive to reachEnd
atEndEdge: ComputedRef<boolean>
layout: ComputedRef<LayoutDirection>
}
type IWheelHandler = (offset: number) => void
type ListWheelHandler = (offset: number) => void
const useWheel = (
{ atEndEdge, atStartEdge, layout }: IWheelState,
onWheelDelta: IWheelHandler
{ atEndEdge, atStartEdge, layout }: ListWheelState,
onWheelDelta: ListWheelHandler
) => {
let frameHandle: number | null = null
let frameHandle: number
let offset = 0
// let scrollLock = false

View File

@ -1,4 +1,4 @@
import { isNumber } from '@element-plus/utils/util'
import { buildProp } from '@element-plus/utils/props'
import { LTR, RTL, VERTICAL } from './defaults'
import type { ExtractPropTypes, PropType } from 'vue'
@ -32,10 +32,10 @@ const initScrollOffset = {
default: 0,
}
const total = {
type: Number as PropType<number>,
const total = buildProp({
type: Number,
required: true,
}
})
const layout = {
type: String as PropType<LayoutDirection>,
@ -63,11 +63,10 @@ export const virtualizedProps = {
*/
direction,
height: {
type: [String, Number] as PropType<string | number>,
height: buildProp({
type: [String, Number],
required: true,
validator: isNumber,
},
}),
innerElement: {
type: [String, Object],
@ -83,11 +82,10 @@ export const virtualizedProps = {
default: false,
},
width: {
type: [Number, String] as PropType<string | number>,
width: buildProp({
type: [Number, String],
required: true,
validator: isNumber,
},
}),
perfMode: {
type: Boolean,
default: true,

View File

@ -1,8 +1,4 @@
import type {
CSSProperties,
ComponentInternalInstance,
ExtractPropTypes,
} from 'vue'
import type { CSSProperties, ComponentInternalInstance, Ref } from 'vue'
export type Instance = ComponentInternalInstance
@ -46,7 +42,7 @@ export type GridCache = {
export type ScrollDir = 'forwards' | 'backwards'
export type ListItemSizer<T, P extends InitListCacheFunc<T>> = (
props: ExtractPropTypes<T>,
props: T,
index: number,
cache: ReturnType<P>
) => number
@ -54,10 +50,10 @@ export type ListItemSizer<T, P extends InitListCacheFunc<T>> = (
export type GetEstimatedTotalSize<
T,
P extends InitCacheFunc<T, GridCache | ListCache>
> = (props: ExtractPropTypes<T>, cache: ReturnType<P>) => number
> = (props: T, cache: ReturnType<P>) => number
export type GetOffset<T, P extends InitListCacheFunc<T>> = (
props: ExtractPropTypes<T>,
props: T,
idx: number,
alignment: Alignment,
offset: number,
@ -67,24 +63,21 @@ export type GetOffset<T, P extends InitListCacheFunc<T>> = (
export type GetStartIndexForOffset<
T,
P extends InitCacheFunc<T, GridCache | ListCache>
> = (props: ExtractPropTypes<T>, offset: number, cache: ReturnType<P>) => number
> = (props: T, offset: number, cache: ReturnType<P>) => number
export type GetStopIndexForStartIndex<
T,
P extends InitCacheFunc<T, GridCache | ListCache>
> = (
props: ExtractPropTypes<T>,
props: T,
startIndex: number,
scrollOffset: number,
cache: ReturnType<P>
) => number
export type PropValidator<T> = (props: ExtractPropTypes<T>) => void
export type PropValidator<T> = (props: T) => void
export type InitCacheFunc<T, P> = (
props: ExtractPropTypes<T>,
cache: Instance
) => P
export type InitCacheFunc<T, P> = (props: T, cache: Instance) => P
export type InitListCacheFunc<T> = InitCacheFunc<T, ListCache>
export type InitGridCacheFunc<T> = InitCacheFunc<T, GridCache>
@ -110,8 +103,8 @@ export type ExposesStates = {
}
export type SharedExposes = {
windowRef: HTMLElement
innerRef: HTMLElement
windowRef: Ref<HTMLElement>
innerRef: Ref<HTMLElement>
getItemStyleCache: (_: any, __: any, ___: any) => CSSProperties
}
@ -139,8 +132,12 @@ export type GridExposes = {
) => void
} & SharedExposes
export type ScrollbarExpose = {
onMouseUp: () => void
}
export type GetGridOffset<T, P extends InitGridCacheFunc<T>> = (
props: ExtractPropTypes<T>,
props: T,
index: number,
alignment: Alignment,
offset: number,
@ -149,7 +146,7 @@ export type GetGridOffset<T, P extends InitGridCacheFunc<T>> = (
) => number
export type GetPosition<T, P extends InitGridCacheFunc<T>> = (
props: ExtractPropTypes<T>,
props: T,
index: number,
cache: ReturnType<P>
) => [number, number]

View File

@ -6,3 +6,9 @@
position: relative;
}
}
@include b(vg) {
@include e(wrapper) {
position: relative;
}
}