@v-c/virtual-list 1.0.0 → 1.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/List.tsx DELETED
@@ -1,642 +0,0 @@
1
- import type { Key } from '@v-c/util/dist/type'
2
-
3
- import type { CSSProperties, PropType, VNode } from 'vue'
4
- import type { InnerProps } from './Filler'
5
- import type { ExtraRenderInfo } from './interface'
6
- import type { ScrollBarDirectionType, ScrollBarRef } from './ScrollBar'
7
- import ResizeObserver from '@v-c/resize-observer'
8
- import { pureAttrs } from '@v-c/util/dist/props-util'
9
- import {
10
- computed,
11
- defineComponent,
12
- ref,
13
- shallowRef,
14
- watch,
15
- } from 'vue'
16
- import Filler from './Filler'
17
- import useDiffItem from './hooks/useDiffItem'
18
- import useFrameWheel from './hooks/useFrameWheel'
19
- import { useGetSize } from './hooks/useGetSize'
20
- import useHeights from './hooks/useHeights'
21
- import useMobileTouchMove from './hooks/useMobileTouchMove'
22
- import useScrollDrag from './hooks/useScrollDrag'
23
- import useScrollTo from './hooks/useScrollTo'
24
- import Item from './Item'
25
- import ScrollBar from './ScrollBar'
26
- import { getSpinSize } from './utils/scrollbarUtil'
27
-
28
- const EMPTY_DATA: any[] = []
29
-
30
- const ScrollStyle: CSSProperties = {
31
- overflowY: 'auto',
32
- overflowAnchor: 'none',
33
- }
34
-
35
- export interface ScrollInfo {
36
- x: number
37
- y: number
38
- }
39
-
40
- export type ScrollTo = (arg?: number | ScrollConfig | null) => void
41
-
42
- export interface ListRef {
43
- nativeElement?: HTMLDivElement
44
- scrollTo: ScrollTo
45
- getScrollInfo: () => ScrollInfo
46
- }
47
-
48
- export interface ScrollPos {
49
- left?: number
50
- top?: number
51
- }
52
-
53
- export interface ScrollTarget {
54
- index?: number
55
- key?: Key
56
- align?: 'top' | 'bottom' | 'auto'
57
- offset?: number
58
- }
59
-
60
- export type ScrollConfig = ScrollTarget | ScrollPos
61
-
62
- export interface ListProps {
63
- prefixCls?: string
64
- data?: any[]
65
- height?: number
66
- itemHeight?: number
67
- fullHeight?: boolean
68
- itemKey: Key | ((item: any) => Key)
69
- component?: string
70
- virtual?: boolean
71
- direction?: ScrollBarDirectionType
72
- /**
73
- * By default `scrollWidth` is same as container.
74
- * When set this, it will show the horizontal scrollbar and
75
- * `scrollWidth` will be used as the real width instead of container width.
76
- * When set, `virtual` will always be enabled.
77
- */
78
- scrollWidth?: number
79
- styles?: {
80
- horizontalScrollBar?: CSSProperties
81
- horizontalScrollBarThumb?: CSSProperties
82
- verticalScrollBar?: CSSProperties
83
- verticalScrollBarThumb?: CSSProperties
84
- }
85
- showScrollBar?: boolean | 'optional'
86
- onScroll?: (e: Event) => void
87
- onVirtualScroll?: (info: ScrollInfo) => void
88
- onVisibleChange?: (visibleList: any[], fullList: any[]) => void
89
- innerProps?: InnerProps
90
- extraRender?: (info: ExtraRenderInfo) => VNode
91
- }
92
-
93
- export default defineComponent({
94
- name: 'VirtualList',
95
- props: {
96
- prefixCls: { type: String, default: 'vc-virtual-list' },
97
- data: { type: Array as PropType<any[]> },
98
- height: Number,
99
- itemHeight: Number,
100
- fullHeight: { type: Boolean, default: true },
101
- itemKey: { type: [String, Number, Function] as PropType<Key | ((item: any) => Key)>, required: true },
102
- component: { type: String, default: 'div' },
103
- direction: { type: String as PropType<ScrollBarDirectionType> },
104
- scrollWidth: Number,
105
- styles: Object,
106
- showScrollBar: { type: [Boolean, String] as PropType<boolean | 'optional'>, default: 'optional' },
107
- virtual: { type: Boolean, default: true },
108
- onScroll: Function as PropType<(e: Event) => void>,
109
- onVirtualScroll: Function as PropType<(info: ScrollInfo) => void>,
110
- onVisibleChange: Function as PropType<(visibleList: any[], fullList: any[]) => void>,
111
- innerProps: Object as PropType<InnerProps>,
112
- extraRender: Function as PropType<(info: ExtraRenderInfo) => VNode>,
113
- },
114
- inheritAttrs: false,
115
- setup(props, { expose, attrs, slots }) {
116
- const itemHeight = computed(() => props.itemHeight)
117
- // =============================== Item Key ===============================
118
- const getKey = (item: any): Key => {
119
- if (typeof props.itemKey === 'function') {
120
- return props.itemKey(item)
121
- }
122
- return item?.[props.itemKey as string]
123
- }
124
-
125
- // ================================ Height ================================
126
- const [setInstanceRef, collectHeight, heights, heightUpdatedMark] = useHeights(
127
- getKey,
128
- undefined,
129
- undefined,
130
- )
131
-
132
- // ================================= MISC =================================
133
- const mergedData = computed(() => props.data || EMPTY_DATA)
134
-
135
- const useVirtual = computed(
136
- () => !!(props.virtual !== false && props.height && props.itemHeight),
137
- )
138
-
139
- const containerHeight = computed(() =>
140
- Object.values(heights.maps).reduce((total: number, curr: number) => total + curr, 0),
141
- )
142
-
143
- const inVirtual = computed(() => {
144
- const data = mergedData.value
145
- return (
146
- useVirtual.value
147
- && data
148
- && (Math.max(props.itemHeight! * data.length, containerHeight.value) > props.height!
149
- || !!props.scrollWidth)
150
- )
151
- })
152
-
153
- const componentRef = ref<HTMLDivElement>()
154
- const fillerInnerRef = ref<HTMLDivElement>()
155
- const containerRef = ref<HTMLDivElement>()
156
- const verticalScrollBarRef = shallowRef<ScrollBarRef>()
157
- const horizontalScrollBarRef = shallowRef<ScrollBarRef>()
158
-
159
- const offsetTop = ref(0)
160
- const offsetLeft = ref(0)
161
- const scrollMoving = ref(false)
162
-
163
- // ScrollBar related
164
- const verticalScrollBarSpinSize = ref(0)
165
- const horizontalScrollBarSpinSize = ref(0)
166
- const contentScrollWidth = ref<number>(props.scrollWidth || 0)
167
-
168
- // ========================== Visible Calculation =========================
169
- const scrollHeight = ref(0)
170
- const start = ref(0)
171
- const end = ref(0)
172
- const fillerOffset = ref<number | undefined>(undefined)
173
-
174
- // ================================ Scroll ================================
175
- function syncScrollTop(newTop: number | ((prev: number) => number)) {
176
- let value: number
177
- if (typeof newTop === 'function') {
178
- value = newTop(offsetTop.value)
179
- }
180
- else {
181
- value = newTop
182
- }
183
-
184
- const maxScrollHeight = scrollHeight!.value! - props.height!
185
- const alignedTop = Math.max(0, Math.min(value, maxScrollHeight || 0))
186
-
187
- if (componentRef.value) {
188
- componentRef.value.scrollTop = alignedTop
189
- }
190
- offsetTop.value = alignedTop
191
- }
192
-
193
- // ================================ Range ================================
194
- useDiffItem(mergedData, getKey)
195
-
196
- watch(
197
- [
198
- inVirtual,
199
- useVirtual,
200
- offsetTop,
201
- mergedData,
202
- heightUpdatedMark,
203
- () => props.height,
204
- ],
205
- () => {
206
- if (!useVirtual.value) {
207
- scrollHeight.value = 0
208
- start.value = 0
209
- end.value = mergedData.value.length - 1
210
- fillerOffset.value = undefined
211
- return
212
- }
213
-
214
- if (!inVirtual.value) {
215
- scrollHeight.value = fillerInnerRef.value?.offsetHeight || 0
216
- start.value = 0
217
- end.value = mergedData.value.length - 1
218
- fillerOffset.value = undefined
219
- return
220
- }
221
-
222
- let itemTop = 0
223
- let startIndex: number | undefined
224
- let startOffset: number | undefined
225
- let endIndex: number | undefined
226
-
227
- const dataLen = mergedData.value.length
228
- const data = mergedData.value
229
-
230
- for (let i = 0; i < dataLen; i += 1) {
231
- const item = data[i]
232
- const key = getKey(item)
233
-
234
- const cacheHeight = heights.get(key)
235
- const currentItemBottom = itemTop + (cacheHeight === undefined ? props.itemHeight! : cacheHeight)
236
-
237
- if (currentItemBottom >= offsetTop.value && startIndex === undefined) {
238
- startIndex = i
239
- startOffset = itemTop
240
- }
241
-
242
- if (currentItemBottom > offsetTop.value + props.height! && endIndex === undefined) {
243
- endIndex = i
244
- }
245
-
246
- itemTop = currentItemBottom
247
- }
248
-
249
- if (startIndex === undefined) {
250
- startIndex = 0
251
- startOffset = 0
252
- endIndex = Math.ceil(props.height! / props.itemHeight!)
253
- }
254
- if (endIndex === undefined) {
255
- endIndex = mergedData.value.length - 1
256
- }
257
-
258
- endIndex = Math.min(endIndex + 1, mergedData.value.length - 1)
259
-
260
- scrollHeight.value = itemTop
261
- start.value = startIndex
262
- end.value = endIndex
263
- fillerOffset.value = startOffset
264
- },
265
- { immediate: true },
266
- )
267
-
268
- // Sync scroll top when height changes
269
- watch(
270
- scrollHeight,
271
- () => {
272
- const changedRecord = heights.getRecord()
273
- if (changedRecord.size === 1) {
274
- const recordKey = Array.from(changedRecord.keys())[0]
275
- const prevCacheHeight = changedRecord.get(recordKey)
276
-
277
- const startItem = mergedData.value[start.value]
278
- if (startItem && prevCacheHeight === undefined) {
279
- const startIndexKey = getKey(startItem)
280
- if (startIndexKey === recordKey) {
281
- const realStartHeight = heights.get(recordKey)
282
- const diffHeight = realStartHeight - props.itemHeight!
283
- syncScrollTop(ori => ori + diffHeight)
284
- }
285
- }
286
- }
287
-
288
- heights.resetRecord()
289
- },
290
- )
291
-
292
- // ================================= Size =================================
293
- const size = ref({ width: 0, height: props.height || 0 })
294
-
295
- const onHolderResize = (sizeInfo: { offsetWidth: number, offsetHeight: number }) => {
296
- size.value = {
297
- width: sizeInfo.offsetWidth,
298
- height: sizeInfo.offsetHeight,
299
- }
300
- contentScrollWidth.value = props.scrollWidth ?? sizeInfo.offsetWidth
301
- }
302
-
303
- // =============================== Scroll ===============================
304
- const isRTL = computed(() => props.direction === 'rtl')
305
-
306
- const getVirtualScrollInfo = () => ({
307
- x: isRTL.value ? -offsetLeft.value : offsetLeft.value,
308
- y: offsetTop.value,
309
- })
310
-
311
- const lastVirtualScrollInfo = ref(getVirtualScrollInfo())
312
-
313
- const triggerScroll = (params?: { x?: number, y?: number }) => {
314
- if (props.onVirtualScroll) {
315
- const nextInfo = { ...getVirtualScrollInfo(), ...params }
316
-
317
- if (
318
- lastVirtualScrollInfo.value.x !== nextInfo.x
319
- || lastVirtualScrollInfo.value.y !== nextInfo.y
320
- ) {
321
- props.onVirtualScroll(nextInfo)
322
- lastVirtualScrollInfo.value = nextInfo
323
- }
324
- }
325
- }
326
-
327
- // ========================== Scroll Position ===========================
328
- const horizontalRange = computed(() =>
329
- Math.max(0, (contentScrollWidth.value || 0) - size.value.width),
330
- )
331
-
332
- const isScrollAtTop = computed(() => offsetTop.value === 0)
333
- const isScrollAtBottom = computed(() => offsetTop.value + props.height! >= scrollHeight.value)
334
- const isScrollAtLeft = computed(() => offsetLeft.value === 0)
335
- const isScrollAtRight = computed(() => offsetLeft.value >= horizontalRange.value)
336
-
337
- const keepInHorizontalRange = (nextOffsetLeft: number) => {
338
- const max = horizontalRange.value
339
- return Math.max(0, Math.min(nextOffsetLeft, max))
340
- }
341
-
342
- // ========================== Wheel & Touch =========================
343
- const delayHideScrollBar = () => {
344
- verticalScrollBarRef.value?.delayHidden()
345
- horizontalScrollBarRef.value?.delayHidden()
346
- }
347
-
348
- const [onWheel] = useFrameWheel(
349
- inVirtual,
350
- isScrollAtTop,
351
- isScrollAtBottom,
352
- isScrollAtLeft,
353
- isScrollAtRight,
354
- horizontalRange.value > 0,
355
- (offsetY, isHorizontal) => {
356
- if (isHorizontal) {
357
- const next = isRTL.value ? offsetLeft.value - offsetY : offsetLeft.value + offsetY
358
- const aligned = keepInHorizontalRange(next)
359
- offsetLeft.value = aligned
360
- triggerScroll({ x: isRTL.value ? -aligned : aligned })
361
- }
362
- else {
363
- syncScrollTop(top => top + offsetY)
364
- }
365
- },
366
- )
367
-
368
- useMobileTouchMove(
369
- inVirtual,
370
- componentRef,
371
- (isHorizontal, offset, _smoothOffset, _e) => {
372
- if (isHorizontal) {
373
- const next = isRTL.value ? offsetLeft.value - offset : offsetLeft.value + offset
374
- const aligned = keepInHorizontalRange(next)
375
- offsetLeft.value = aligned
376
- triggerScroll({ x: isRTL.value ? -aligned : aligned })
377
- return true
378
- }
379
- else {
380
- syncScrollTop(top => top + offset)
381
- return true
382
- }
383
- },
384
- )
385
-
386
- useScrollDrag(
387
- inVirtual,
388
- componentRef,
389
- (offset) => {
390
- syncScrollTop(top => top + offset)
391
- },
392
- )
393
-
394
- // ========================== ScrollBar =========================
395
- const onScrollBar = (newScrollOffset: number, horizontal?: boolean) => {
396
- const newOffset = newScrollOffset
397
- if (horizontal) {
398
- offsetLeft.value = newOffset
399
- triggerScroll({ x: isRTL.value ? -newOffset : newOffset })
400
- }
401
- else {
402
- syncScrollTop(newOffset)
403
- }
404
- }
405
-
406
- const onScrollbarStartMove = () => {
407
- scrollMoving.value = true
408
- }
409
-
410
- const onScrollbarStopMove = () => {
411
- scrollMoving.value = false
412
- }
413
-
414
- // Calculate ScrollBar spin size
415
- watch(
416
- [() => props.height, scrollHeight, inVirtual, () => size.value.height],
417
- () => {
418
- if (inVirtual.value && props.height && scrollHeight.value) {
419
- verticalScrollBarSpinSize.value = getSpinSize(size.value.height, scrollHeight.value)
420
- }
421
- },
422
- { immediate: true },
423
- )
424
-
425
- watch(
426
- [() => size.value.width, () => contentScrollWidth.value],
427
- () => {
428
- if (inVirtual.value && contentScrollWidth.value) {
429
- horizontalScrollBarSpinSize.value = getSpinSize(size.value.width, contentScrollWidth.value)
430
- }
431
- },
432
- { immediate: true },
433
- )
434
-
435
- watch(
436
- () => props.scrollWidth,
437
- (val) => {
438
- contentScrollWidth.value = val ?? size.value.width
439
- offsetLeft.value = keepInHorizontalRange(offsetLeft.value)
440
- },
441
- { immediate: true },
442
- )
443
-
444
- function onFallbackScroll(e: Event) {
445
- const target = e.currentTarget as HTMLDivElement
446
- const newScrollTop = target.scrollTop
447
- if (newScrollTop !== offsetTop.value) {
448
- syncScrollTop(newScrollTop)
449
- }
450
-
451
- props.onScroll?.(e)
452
- triggerScroll()
453
- }
454
-
455
- // ================================= Ref ==================================
456
- const scrollTo = useScrollTo(
457
- componentRef as any,
458
- mergedData,
459
- heights,
460
- itemHeight as any,
461
- getKey,
462
- () => collectHeight(true),
463
- syncScrollTop,
464
- delayHideScrollBar,
465
- )
466
-
467
- expose({
468
- nativeElement: containerRef,
469
- getScrollInfo: getVirtualScrollInfo,
470
- scrollTo: (config: any) => {
471
- function isPosScroll(arg: any): arg is ScrollPos {
472
- return arg && typeof arg === 'object' && ('left' in arg || 'top' in arg)
473
- }
474
- if (isPosScroll(config)) {
475
- if (config.left !== undefined) {
476
- offsetLeft.value = keepInHorizontalRange(config.left)
477
- }
478
- scrollTo(config.top as any)
479
- }
480
- else {
481
- scrollTo(config)
482
- }
483
- },
484
- })
485
-
486
- // ================================ Effect ================================
487
- watch(
488
- [start, end, mergedData],
489
- () => {
490
- if (props.onVisibleChange) {
491
- const renderList = mergedData.value.slice(start.value, end.value + 1)
492
- props.onVisibleChange(renderList, mergedData.value)
493
- }
494
- },
495
- )
496
-
497
- const getSize = useGetSize(mergedData, getKey, heights, itemHeight as any)
498
-
499
- return () => {
500
- // ================================ Render ================================
501
- const renderChildren = () => {
502
- const children: VNode[] = []
503
- const data = mergedData.value
504
- const defaultSlot = slots.default
505
-
506
- if (!defaultSlot) {
507
- return children
508
- }
509
-
510
- for (let i = start.value; i <= end.value; i += 1) {
511
- const item = data[i]
512
- const key = getKey(item)
513
- // Call the slot function with item, index, and props
514
- const nodes = defaultSlot({
515
- item,
516
- index: i,
517
- style: {},
518
- offsetX: offsetLeft.value,
519
- })
520
-
521
- // Wrap each node in Item component
522
- const node = Array.isArray(nodes) ? nodes[0] : nodes
523
- if (node) {
524
- children.push(
525
- <Item key={key} setRef={ele => setInstanceRef(item, ele)}>
526
- {node}
527
- </Item>,
528
- )
529
- }
530
- }
531
-
532
- return children
533
- }
534
- const componentStyle: CSSProperties = {}
535
- if (props.height) {
536
- componentStyle[props.fullHeight ? 'height' : 'maxHeight'] = `${props.height}px`
537
- Object.assign(componentStyle, ScrollStyle)
538
-
539
- if (useVirtual.value) {
540
- componentStyle.overflowY = 'hidden'
541
-
542
- if (horizontalRange.value > 0) {
543
- componentStyle.overflowX = 'hidden'
544
- }
545
-
546
- if (scrollMoving.value) {
547
- componentStyle.pointerEvents = 'none'
548
- }
549
- }
550
- }
551
-
552
- const extraContent = props.extraRender?.({
553
- start: start.value,
554
- end: end.value,
555
- virtual: inVirtual.value,
556
- offsetX: offsetLeft.value,
557
- offsetY: fillerOffset.value || 0,
558
- rtl: isRTL.value,
559
- getSize: getSize.value,
560
- })
561
-
562
- const Component = props.component as any
563
-
564
- return (
565
- <div
566
- ref={containerRef}
567
- {...pureAttrs(attrs)}
568
- style={{ position: 'relative', ...(attrs.style as CSSProperties) }}
569
- dir={isRTL.value ? 'rtl' : undefined}
570
- class={[
571
- props.prefixCls,
572
- { [`${props.prefixCls}-rtl`]: isRTL.value },
573
- attrs.class,
574
- ]}
575
- >
576
- <ResizeObserver onResize={onHolderResize}>
577
- <Component
578
- class={`${props.prefixCls}-holder`}
579
- style={componentStyle}
580
- ref={componentRef}
581
- onScroll={onFallbackScroll}
582
- onWheel={onWheel}
583
- onMouseenter={delayHideScrollBar}
584
- >
585
- <Filler
586
- prefixCls={props.prefixCls}
587
- height={scrollHeight.value}
588
- offsetX={offsetLeft.value}
589
- offsetY={fillerOffset.value}
590
- scrollWidth={contentScrollWidth.value}
591
- onInnerResize={collectHeight}
592
- ref={fillerInnerRef}
593
- innerProps={props.innerProps}
594
- rtl={isRTL.value}
595
- extra={extraContent}
596
- >
597
- {renderChildren()}
598
- </Filler>
599
- </Component>
600
- </ResizeObserver>
601
-
602
- {inVirtual.value && scrollHeight.value > (props.height || 0) && (
603
- <ScrollBar
604
- ref={verticalScrollBarRef}
605
- prefixCls={props.prefixCls}
606
- scrollOffset={offsetTop.value}
607
- scrollRange={scrollHeight.value}
608
- rtl={isRTL.value}
609
- onScroll={onScrollBar}
610
- onStartMove={onScrollbarStartMove}
611
- onStopMove={onScrollbarStopMove}
612
- spinSize={verticalScrollBarSpinSize.value}
613
- containerSize={size.value.height}
614
- showScrollBar={props.showScrollBar}
615
- style={(props.styles as any)?.verticalScrollBar}
616
- thumbStyle={(props.styles as any)?.verticalScrollBarThumb}
617
- />
618
- )}
619
-
620
- {inVirtual.value && contentScrollWidth.value > size.value.width && (
621
- <ScrollBar
622
- ref={horizontalScrollBarRef}
623
- prefixCls={props.prefixCls}
624
- scrollOffset={offsetLeft.value}
625
- scrollRange={contentScrollWidth.value}
626
- rtl={isRTL.value}
627
- onScroll={onScrollBar}
628
- onStartMove={onScrollbarStartMove}
629
- onStopMove={onScrollbarStopMove}
630
- spinSize={horizontalScrollBarSpinSize.value}
631
- containerSize={size.value.width}
632
- horizontal
633
- showScrollBar={props.showScrollBar}
634
- style={(props.styles as any)?.horizontalScrollBar}
635
- thumbStyle={(props.styles as any)?.horizontalScrollBarThumb}
636
- />
637
- )}
638
- </div>
639
- )
640
- }
641
- },
642
- })