mirror of
https://github.com/element-plus/element-plus.git
synced 2024-12-03 02:21:49 +08:00
feat(virtual-list): first implementation of virtual list (#790)
This commit is contained in:
parent
15f792de5f
commit
d16fbb66e5
@ -82,6 +82,7 @@ import ElTooltip from '@element-plus/tooltip'
|
||||
import ElTransfer from '@element-plus/transfer'
|
||||
import ElTree from '@element-plus/tree'
|
||||
import ElUpload from '@element-plus/upload'
|
||||
import ElVirtualList from '@element-plus/virtual-list'
|
||||
import { use } from '@element-plus/locale'
|
||||
import { version as version_ } from './version'
|
||||
import { setConfig } from '@element-plus/utils/config'
|
||||
@ -175,6 +176,7 @@ const components = [
|
||||
ElTransfer,
|
||||
ElTree,
|
||||
ElUpload,
|
||||
ElVirtualList,
|
||||
]
|
||||
|
||||
const plugins = [
|
||||
@ -284,6 +286,7 @@ export {
|
||||
ElTransfer,
|
||||
ElTree,
|
||||
ElUpload,
|
||||
ElVirtualList,
|
||||
version,
|
||||
install,
|
||||
locale,
|
||||
|
@ -3,7 +3,14 @@ import { sleep } from '@element-plus/test-utils'
|
||||
const makeScroll = async (dom: Element, name: 'scrollTop' | 'scrollLeft', offset: number) => {
|
||||
const eventTarget = dom === document.documentElement ? window : dom
|
||||
dom[name] = offset
|
||||
eventTarget.dispatchEvent(new CustomEvent('scroll'))
|
||||
const evt = new CustomEvent('scroll', {
|
||||
detail: {
|
||||
target: {
|
||||
[name]: offset,
|
||||
},
|
||||
},
|
||||
})
|
||||
eventTarget.dispatchEvent(evt)
|
||||
// must use setTimeout instead of nextTick to wait dom change
|
||||
return await sleep()
|
||||
}
|
||||
|
@ -13,7 +13,10 @@ export default tick
|
||||
export const rAF = async () => {
|
||||
return new Promise(res => {
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(res)
|
||||
requestAnimationFrame(async () => {
|
||||
res()
|
||||
await nextTick()
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
@ -78,4 +78,5 @@
|
||||
@import "./avatar.scss";
|
||||
@import "./drawer.scss";
|
||||
@import "./popconfirm.scss";
|
||||
@import "./overlay.scss";
|
||||
@import "./overlay.scss";
|
||||
@import "./virtual-list.scss";
|
27
packages/theme-chalk/src/virtual-list.scss
Normal file
27
packages/theme-chalk/src/virtual-list.scss
Normal file
@ -0,0 +1,27 @@
|
||||
@import "mixins/mixins";
|
||||
@import "common/var";
|
||||
|
||||
@include b(vl) {
|
||||
@include e(viewport) {
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
@include e(content) {
|
||||
overflow: hidden;
|
||||
will-change: transform;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
@include e(item-container) {
|
||||
will-change: transform;
|
||||
display: flex;
|
||||
|
||||
&[data-direction="v"] {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
&[data-direction="h"] {
|
||||
flex-direction: row;
|
||||
}
|
||||
}
|
||||
}
|
156
packages/virtual-list/__tests__/virtual-list.spec.ts
Normal file
156
packages/virtual-list/__tests__/virtual-list.spec.ts
Normal file
@ -0,0 +1,156 @@
|
||||
import { nextTick } from 'vue'
|
||||
import makeMount from '@element-plus/test-utils/make-mount'
|
||||
import makeScroll from '@element-plus/test-utils/make-scroll'
|
||||
import { rAF } from '@element-plus/test-utils/tick'
|
||||
import VirtualList from '../src/index.vue'
|
||||
const containerSelector = '.el-vl__item-container'
|
||||
const viewportSelector = '.el-vl__viewport'
|
||||
|
||||
const AXIOM = 'Rem is the best girl'
|
||||
const mount = makeMount(VirtualList, {
|
||||
props: {
|
||||
windowSize: 300,
|
||||
itemSize: 30,
|
||||
data: Array
|
||||
.from({ length: 100 })
|
||||
.map((_, idx) => ({
|
||||
id: idx,
|
||||
})),
|
||||
},
|
||||
slots: {
|
||||
default: () => AXIOM,
|
||||
},
|
||||
})
|
||||
|
||||
describe('VirtualList.vue', () => {
|
||||
test('render test', async () => {
|
||||
const wrapper = mount()
|
||||
await nextTick()
|
||||
expect(wrapper.text()).toContain(AXIOM)
|
||||
expect(
|
||||
wrapper
|
||||
.find(containerSelector)
|
||||
.attributes('data-direction'),
|
||||
).toBe('v')
|
||||
expect(
|
||||
wrapper
|
||||
.find(containerSelector)
|
||||
.attributes('style'),
|
||||
).toContain(
|
||||
'transform: translateY(0px)',
|
||||
)
|
||||
|
||||
await wrapper.setProps({
|
||||
direction: 'h',
|
||||
})
|
||||
|
||||
expect(
|
||||
wrapper
|
||||
.find(containerSelector)
|
||||
.attributes('data-direction'),
|
||||
).toBe('h')
|
||||
expect(
|
||||
wrapper
|
||||
.find(containerSelector)
|
||||
.attributes('style'),
|
||||
).toContain(
|
||||
'transform: translateX(0px)',
|
||||
)
|
||||
|
||||
})
|
||||
|
||||
test('should render with cache', () => {
|
||||
const wrapper = mount({
|
||||
props: {
|
||||
poolSize: 9,
|
||||
},
|
||||
})
|
||||
|
||||
// rendering item should be 9 + (9 / 3) * 2
|
||||
expect(wrapper.findAll('.el-vl__item')).toHaveLength(15)
|
||||
})
|
||||
|
||||
test('should handle scroll event', async () => {
|
||||
const wrapper = mount()
|
||||
await nextTick()
|
||||
|
||||
await makeScroll(
|
||||
wrapper.find(viewportSelector).element,
|
||||
'scrollTop',
|
||||
90,
|
||||
)
|
||||
await rAF()
|
||||
|
||||
expect(
|
||||
wrapper
|
||||
.find(containerSelector)
|
||||
.attributes('style'),
|
||||
).toContain(
|
||||
'transform: translateY(0px)',
|
||||
)
|
||||
|
||||
await makeScroll(
|
||||
wrapper.find(viewportSelector).element,
|
||||
'scrollTop',
|
||||
300,
|
||||
)
|
||||
|
||||
await rAF()
|
||||
|
||||
// when scroll, the scroll is calculated with formula
|
||||
// ((scrollTop / itemSize) - cache) * itemSize
|
||||
// in this case:
|
||||
// (300 / 30) - cache (20(poolSize) / 3 = 6) * itemSize(30)
|
||||
// 120
|
||||
expect(
|
||||
wrapper
|
||||
.find(containerSelector)
|
||||
.attributes('style'),
|
||||
).toContain(
|
||||
'transform: translateY(120px)',
|
||||
)
|
||||
|
||||
await nextTick()
|
||||
|
||||
await wrapper.setProps({
|
||||
direction: 'h',
|
||||
})
|
||||
|
||||
expect(
|
||||
wrapper
|
||||
.find(containerSelector)
|
||||
.attributes('style'),
|
||||
).toContain(
|
||||
'transform: translateX(120px)',
|
||||
)
|
||||
})
|
||||
|
||||
test('should scroll to selected index', async () => {
|
||||
const wrapper = mount()
|
||||
await nextTick();
|
||||
(wrapper.vm as any).scrollTo(10)
|
||||
|
||||
await rAF()
|
||||
expect((wrapper.vm as any).window[0]).toEqual({
|
||||
id: 4, // 10 - 6(cache size)
|
||||
});
|
||||
|
||||
(wrapper.vm as any).scrollTo(10, 'center')
|
||||
|
||||
await nextTick()
|
||||
await rAF()
|
||||
|
||||
expect((wrapper.vm as any).window[0]).toEqual({
|
||||
id: 0,
|
||||
});
|
||||
|
||||
(wrapper.vm as any).scrollTo(20, 'tail')
|
||||
|
||||
await nextTick()
|
||||
await rAF()
|
||||
expect((wrapper.vm as any).window[0]).toEqual({
|
||||
id: 5,
|
||||
})
|
||||
})
|
||||
|
||||
})
|
8
packages/virtual-list/index.ts
Normal file
8
packages/virtual-list/index.ts
Normal file
@ -0,0 +1,8 @@
|
||||
import { App } from 'vue'
|
||||
import VirtualList from './src/index.vue'
|
||||
|
||||
VirtualList.install = (app: App): void => {
|
||||
app.component(VirtualList.name, VirtualList)
|
||||
}
|
||||
|
||||
export default VirtualList
|
12
packages/virtual-list/package.json
Normal file
12
packages/virtual-list/package.json
Normal file
@ -0,0 +1,12 @@
|
||||
{
|
||||
"name": "@element-plus/virtual-list",
|
||||
"version": "0.0.0",
|
||||
"main": "dist/index.js",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"vue": "^3.0.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vue/test-utils": "^2.0.0-beta.3"
|
||||
}
|
||||
}
|
74
packages/virtual-list/src/index.vue
Normal file
74
packages/virtual-list/src/index.vue
Normal file
@ -0,0 +1,74 @@
|
||||
<template>
|
||||
<div
|
||||
ref="viewportRef"
|
||||
class="el-vl__viewport"
|
||||
:style="viewportStyle"
|
||||
@scroll.passive="onScroll"
|
||||
>
|
||||
<div class="el-vl__content" :style="contentStyle">
|
||||
<div
|
||||
class="el-vl__item-container"
|
||||
:style="itemContainerStyle"
|
||||
:data-direction="direction"
|
||||
>
|
||||
<el-virtual-list-item
|
||||
v-for="(item, idx) in window"
|
||||
:key="idx"
|
||||
class="el-vl__item"
|
||||
:style="itemStyle"
|
||||
>
|
||||
<slot :item="item"></slot>
|
||||
</el-virtual-list-item>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script lang="ts">
|
||||
import { defineComponent } from 'vue'
|
||||
import useVirtualScroll from './useVirtualScroll'
|
||||
import VirtualItem from './virtual-item.vue'
|
||||
|
||||
import type { PropType } from 'vue'
|
||||
import type { Direction } from './useVirtualScroll'
|
||||
|
||||
export default defineComponent({
|
||||
name: 'ElVirtualList',
|
||||
components: {
|
||||
[VirtualItem.name]: VirtualItem,
|
||||
},
|
||||
props: {
|
||||
direction: {
|
||||
type: String as PropType<Direction>,
|
||||
default: 'v',
|
||||
},
|
||||
data: {
|
||||
type: Array as PropType<Array<any>>,
|
||||
required: true,
|
||||
},
|
||||
itemSize: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
windowSize: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
poolSize: {
|
||||
type: Number,
|
||||
default: 20,
|
||||
},
|
||||
},
|
||||
setup(props) {
|
||||
// init here
|
||||
|
||||
const api = useVirtualScroll(props)
|
||||
|
||||
// onMounted(() => {
|
||||
|
||||
// })
|
||||
|
||||
return api
|
||||
},
|
||||
})
|
||||
</script>
|
||||
<style scoped></style>
|
150
packages/virtual-list/src/useVirtualScroll.ts
Normal file
150
packages/virtual-list/src/useVirtualScroll.ts
Normal file
@ -0,0 +1,150 @@
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import isServer from '@element-plus/utils/isServer'
|
||||
import { $ } from '@element-plus/utils/util'
|
||||
import throwError from '@element-plus/utils/error'
|
||||
|
||||
export type Direction = 'h' | 'v'
|
||||
export type Alignment = 'head' | 'center' | 'tail'
|
||||
export interface ElVirtualScrollProps<T> {
|
||||
windowSize: number
|
||||
direction: Direction // h stands for horizontal, v stands for vertical, defaults to vertical
|
||||
data: Array<T>
|
||||
itemSize: number
|
||||
poolSize: number
|
||||
}
|
||||
|
||||
export default function useVirtualScroll<T>(props: ElVirtualScrollProps<T>) {
|
||||
const viewportRef = ref<HTMLElement>()
|
||||
const offset = ref(0)
|
||||
const cache = ref(0)
|
||||
|
||||
// the reason of not using computed here is that, keys are accessed frequently
|
||||
// in order to avoid unnecessary overhead, we decided to use watch the `direction`
|
||||
// because direction won't gets changed very frequently.
|
||||
const isVertical = ref(true)
|
||||
const sizeKey = ref('')
|
||||
const scrollKey = ref('')
|
||||
const translateKey = ref()
|
||||
const styleKey = ref('')
|
||||
|
||||
watch(
|
||||
() => props.direction,
|
||||
dir => {
|
||||
const _isVertical = dir === 'v'
|
||||
isVertical.value = _isVertical
|
||||
sizeKey.value = `client${_isVertical ? 'Height' : 'Width'}`
|
||||
scrollKey.value = `scroll${_isVertical ? 'Top' : 'Left'}`
|
||||
translateKey.value = `${_isVertical ? 'Y' : 'X'}`
|
||||
styleKey.value = `${_isVertical ? 'height' : 'width'}`
|
||||
},
|
||||
{
|
||||
immediate: true,
|
||||
},
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.poolSize,
|
||||
val => {
|
||||
cache.value = Math.floor(val / 3)
|
||||
},
|
||||
{
|
||||
immediate: true,
|
||||
},
|
||||
)
|
||||
|
||||
const renderingItems = computed(() => props.poolSize + 2 * $(cache))
|
||||
|
||||
// offset sets the value of
|
||||
const startNode = computed(() => {
|
||||
return Math.max(0, Math.floor($(offset) / props.itemSize) - $(cache))
|
||||
})
|
||||
|
||||
const viewportStyle = computed(() => {
|
||||
return {
|
||||
[$(styleKey)]: `${props.windowSize}px`,
|
||||
}
|
||||
})
|
||||
|
||||
const contentStyle = computed(() => {
|
||||
// make this dynamic
|
||||
return {
|
||||
[$(styleKey)]: `${props.data.length * props.itemSize}px`,
|
||||
}
|
||||
})
|
||||
|
||||
const itemContainerStyle = computed(() => {
|
||||
const _offset = $(startNode) * props.itemSize
|
||||
return {
|
||||
transform: `translate${$(translateKey)}(${_offset}px)`,
|
||||
}
|
||||
})
|
||||
|
||||
const itemStyle = computed(() => {
|
||||
return {
|
||||
[$(styleKey)]: `${props.itemSize}px`,
|
||||
}
|
||||
})
|
||||
|
||||
let animationHandle = null
|
||||
|
||||
const onScroll = (e: Event) => {
|
||||
if (animationHandle) {
|
||||
cancelAnimationFrame(animationHandle)
|
||||
}
|
||||
animationHandle = requestAnimationFrame(() => {
|
||||
offset.value = (e.target as HTMLElement)[$(scrollKey)]
|
||||
})
|
||||
}
|
||||
|
||||
const window = computed(() => {
|
||||
const startNodeVal = $(startNode)
|
||||
const size = Math.min(props.data.length - startNodeVal, $(renderingItems))
|
||||
return props.data.slice(startNodeVal, startNodeVal + size)
|
||||
})
|
||||
|
||||
const scrollTo = (idx: number, alignment: Alignment = 'head') => {
|
||||
if (isServer) return
|
||||
if (idx < 0 || idx > props.data.length) {
|
||||
throwError('ElVirtualList]', 'Out of list range')
|
||||
}
|
||||
let _offset: number
|
||||
switch (alignment) {
|
||||
case 'head': {
|
||||
_offset = idx * props.itemSize
|
||||
break
|
||||
}
|
||||
case 'center': {
|
||||
_offset =
|
||||
(idx -
|
||||
Math.floor(Math.floor(props.windowSize / props.itemSize) / 2)) *
|
||||
props.itemSize
|
||||
break
|
||||
}
|
||||
case 'tail': {
|
||||
_offset =
|
||||
(idx - Math.floor(props.windowSize / props.itemSize) + 1) * props.itemSize
|
||||
break
|
||||
}
|
||||
default: {
|
||||
throwError('[ElVirtualList]', 'Unsupported alignment')
|
||||
}
|
||||
}
|
||||
requestAnimationFrame(() => {
|
||||
offset.value = _offset
|
||||
viewportRef.value[$(scrollKey)] = _offset
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
viewportRef,
|
||||
contentStyle,
|
||||
itemContainerStyle,
|
||||
itemStyle,
|
||||
viewportStyle,
|
||||
startNode,
|
||||
renderingItems,
|
||||
window,
|
||||
onScroll,
|
||||
scrollTo,
|
||||
}
|
||||
}
|
36
packages/virtual-list/src/virtual-item.vue
Normal file
36
packages/virtual-list/src/virtual-item.vue
Normal file
@ -0,0 +1,36 @@
|
||||
<template>
|
||||
<div ref="itemRef" class="el-vl__item">
|
||||
<slot>
|
||||
</slot>
|
||||
</div>
|
||||
</template>
|
||||
<script lang='ts'>
|
||||
import { defineComponent, onMounted, onUpdated, ref } from 'vue'
|
||||
|
||||
// import type { PropType } from 'vue'
|
||||
// import type { Direction } from './useVirtualScroll'
|
||||
|
||||
export default defineComponent({
|
||||
name: 'ElVirtualListItem',
|
||||
props: {
|
||||
|
||||
},
|
||||
setup() {
|
||||
// init here
|
||||
const itemRef = ref<HTMLElement>()
|
||||
onMounted(() => {
|
||||
// console.log('mounted')
|
||||
})
|
||||
|
||||
onUpdated(() => {
|
||||
// console.log(itemRef.value)
|
||||
})
|
||||
|
||||
return {
|
||||
itemRef,
|
||||
}
|
||||
},
|
||||
})
|
||||
</script>
|
||||
<style scoped>
|
||||
</style>
|
Loading…
Reference in New Issue
Block a user