@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/ScrollBar.tsx DELETED
@@ -1,287 +0,0 @@
1
- import type { CSSProperties } from 'vue'
2
- import raf from '@v-c/util/dist/raf'
3
- import { computed, defineComponent, onMounted, onUnmounted, ref, shallowRef, watch } from 'vue'
4
-
5
- export type ScrollBarDirectionType = 'ltr' | 'rtl'
6
-
7
- export interface ScrollBarProps {
8
- prefixCls: string
9
- scrollOffset: number
10
- scrollRange: number
11
- rtl: boolean
12
- onScroll: (scrollOffset: number, horizontal?: boolean) => void
13
- onStartMove: () => void
14
- onStopMove: () => void
15
- horizontal?: boolean
16
- style?: CSSProperties
17
- thumbStyle?: CSSProperties
18
- spinSize: number
19
- containerSize: number
20
- showScrollBar?: boolean | 'optional'
21
- }
22
-
23
- export interface ScrollBarRef {
24
- delayHidden: () => void
25
- }
26
-
27
- function getPageXY(
28
- e: MouseEvent | TouchEvent,
29
- horizontal: boolean,
30
- ): number {
31
- const obj = 'touches' in e ? e.touches[0] : e
32
- return obj[horizontal ? 'pageX' : 'pageY'] - window[horizontal ? 'scrollX' : 'scrollY']
33
- }
34
-
35
- export default defineComponent<ScrollBarProps>({
36
- name: 'ScrollBar',
37
- setup(props, { expose }) {
38
- const dragging = ref(false)
39
- const pageXY = ref<number | null>(null)
40
- const startTop = ref<number | null>(null)
41
-
42
- const isLTR = computed(() => !props.rtl)
43
-
44
- // ========================= Refs =========================
45
- const scrollbarRef = shallowRef<HTMLDivElement>()
46
- const thumbRef = shallowRef<HTMLDivElement>()
47
-
48
- // ======================= Visible ========================
49
- const visible = ref(props.showScrollBar === 'optional' ? true : props.showScrollBar)
50
- let visibleTimeout: ReturnType<typeof setTimeout> | null = null
51
-
52
- const delayHidden = () => {
53
- // Don't auto-hide if showScrollBar is explicitly true or false
54
- if (props.showScrollBar === true || props.showScrollBar === false)
55
- return
56
- if (visibleTimeout)
57
- clearTimeout(visibleTimeout)
58
- visible.value = true
59
- visibleTimeout = setTimeout(() => {
60
- visible.value = false
61
- }, 3000)
62
- }
63
-
64
- // ======================== Range =========================
65
- const enableScrollRange = computed(() => props.scrollRange - props.containerSize || 0)
66
- const enableOffsetRange = computed(() => props.containerSize - props.spinSize || 0)
67
-
68
- // ========================= Top ==========================
69
- const top = computed(() => {
70
- if (props.scrollOffset === 0 || enableScrollRange.value === 0) {
71
- return 0
72
- }
73
- const ptg = props.scrollOffset / enableScrollRange.value
74
- return ptg * enableOffsetRange.value
75
- })
76
-
77
- // ======================== Thumb =========================
78
- const stateRef = shallowRef({
79
- top: top.value,
80
- dragging: dragging.value,
81
- pageY: pageXY.value,
82
- startTop: startTop.value,
83
- })
84
-
85
- watch([top, dragging, pageXY, startTop], () => {
86
- stateRef.value = {
87
- top: top.value,
88
- dragging: dragging.value,
89
- pageY: pageXY.value,
90
- startTop: startTop.value,
91
- }
92
- })
93
-
94
- const onContainerMouseDown = (e: MouseEvent) => {
95
- e.stopPropagation()
96
- e.preventDefault()
97
- }
98
-
99
- const onThumbMouseDown = (e: MouseEvent | TouchEvent) => {
100
- dragging.value = true
101
- pageXY.value = getPageXY(e, props.horizontal || false)
102
- startTop.value = stateRef.value.top
103
-
104
- props?.onStartMove?.()
105
- e.stopPropagation()
106
- e.preventDefault()
107
- }
108
-
109
- // ======================== Effect ========================
110
-
111
- // React make event as passive, but we need to preventDefault
112
- // Add event on dom directly instead.
113
- // ref: https://github.com/facebook/react/issues/9809
114
- onMounted(() => {
115
- const onScrollbarTouchStart = (e: TouchEvent) => {
116
- e.preventDefault()
117
- }
118
-
119
- const scrollbarEle = scrollbarRef.value
120
- const thumbEle = thumbRef.value
121
-
122
- if (scrollbarEle && thumbEle) {
123
- scrollbarEle.addEventListener('touchstart', onScrollbarTouchStart, { passive: false })
124
- thumbEle.addEventListener('touchstart', onThumbMouseDown as any, { passive: false })
125
-
126
- onUnmounted(() => {
127
- scrollbarEle.removeEventListener('touchstart', onScrollbarTouchStart)
128
- thumbEle.removeEventListener('touchstart', onThumbMouseDown as any)
129
- })
130
- }
131
- })
132
-
133
- // Effect: Handle dragging
134
- watch(dragging, (isDragging, _O, onCleanup) => {
135
- if (isDragging) {
136
- let moveRafId: number | null = null
137
-
138
- const onMouseMove = (e: MouseEvent | TouchEvent) => {
139
- const {
140
- dragging: stateDragging,
141
- pageY: statePageY,
142
- startTop: stateStartTop,
143
- } = stateRef.value
144
- raf.cancel(moveRafId!)
145
-
146
- const rect = scrollbarRef.value!.getBoundingClientRect()
147
- const scale = props.containerSize / (props.horizontal ? rect.width : rect.height)
148
-
149
- if (stateDragging) {
150
- const offset = (getPageXY(e, props.horizontal || false) - (statePageY || 0)) * scale
151
- let newTop = stateStartTop || 0
152
-
153
- if (!isLTR.value && props.horizontal) {
154
- newTop -= offset
155
- }
156
- else {
157
- newTop += offset
158
- }
159
-
160
- const tmpEnableScrollRange = enableScrollRange.value
161
- const tmpEnableOffsetRange = enableOffsetRange.value
162
-
163
- const ptg: number = tmpEnableOffsetRange ? newTop / tmpEnableOffsetRange : 0
164
-
165
- let newScrollTop = Math.ceil(ptg * tmpEnableScrollRange)
166
- newScrollTop = Math.max(newScrollTop, 0)
167
- newScrollTop = Math.min(newScrollTop, tmpEnableScrollRange)
168
-
169
- moveRafId = raf(() => {
170
- props?.onScroll?.(newScrollTop, props.horizontal)
171
- })
172
- }
173
- }
174
-
175
- const onMouseUp = () => {
176
- dragging.value = false
177
- props.onStopMove()
178
- }
179
-
180
- window.addEventListener('mousemove', onMouseMove, { passive: true } as any)
181
- window.addEventListener('touchmove', onMouseMove, { passive: true } as any)
182
- window.addEventListener('mouseup', onMouseUp, { passive: true } as any)
183
- window.addEventListener('touchend', onMouseUp, { passive: true } as any)
184
-
185
- onCleanup(() => {
186
- window.removeEventListener('mousemove', onMouseMove)
187
- window.removeEventListener('touchmove', onMouseMove)
188
- window.removeEventListener('mouseup', onMouseUp)
189
- window.removeEventListener('touchend', onMouseUp)
190
-
191
- raf.cancel(moveRafId!)
192
- })
193
- }
194
- })
195
-
196
- // Effect: Delay hidden on scroll offset change
197
- watch(() => props.scrollOffset, (_n, _o, onCleanup) => {
198
- delayHidden()
199
- onCleanup(() => {
200
- if (visibleTimeout) {
201
- clearTimeout(visibleTimeout)
202
- }
203
- })
204
- })
205
-
206
- // Imperative handle
207
- expose({
208
- delayHidden,
209
- })
210
-
211
- return () => {
212
- const { prefixCls, horizontal } = props
213
- const scrollbarPrefixCls = `${prefixCls}-scrollbar`
214
-
215
- const containerStyle: CSSProperties = {
216
- position: 'absolute',
217
- visibility: visible.value ? undefined : 'hidden',
218
- }
219
-
220
- const thumbStyle: CSSProperties = {
221
- position: 'absolute',
222
- borderRadius: '99px',
223
- background: 'var(--vc-virtual-list-scrollbar-bg, rgba(0, 0, 0, 0.5))',
224
- cursor: 'pointer',
225
- userSelect: 'none',
226
- }
227
-
228
- if (props.horizontal) {
229
- Object.assign(containerStyle, {
230
- height: '8px',
231
- left: 0,
232
- right: 0,
233
- bottom: 0,
234
- })
235
-
236
- Object.assign(thumbStyle, {
237
- height: '100%',
238
- width: `${props.spinSize}px`,
239
- [isLTR.value ? 'left' : 'right']: `${top.value}px`,
240
- })
241
- }
242
- else {
243
- Object.assign(containerStyle, {
244
- width: '8px',
245
- top: 0,
246
- bottom: 0,
247
- [isLTR.value ? 'right' : 'left']: 0,
248
- })
249
-
250
- Object.assign(thumbStyle, {
251
- width: '100%',
252
- height: `${props.spinSize}px`,
253
- top: `${top.value}px`,
254
- })
255
- }
256
-
257
- return (
258
- <div
259
- ref={scrollbarRef}
260
- class={[
261
- scrollbarPrefixCls,
262
- {
263
- [`${scrollbarPrefixCls}-horizontal`]: horizontal,
264
- [`${scrollbarPrefixCls}-vertical`]: !horizontal,
265
- [`${scrollbarPrefixCls}-visible`]: visible.value,
266
- },
267
- ]}
268
- style={{ ...containerStyle, ...props.style }}
269
- onMousedown={onContainerMouseDown}
270
- onMousemove={delayHidden}
271
- >
272
- <div
273
- ref={thumbRef}
274
- class={[
275
- `${scrollbarPrefixCls}-thumb`,
276
- {
277
- [`${scrollbarPrefixCls}-thumb-moving`]: dragging.value,
278
- },
279
- ]}
280
- style={{ ...thumbStyle, ...props.thumbStyle }}
281
- onMousedown={onThumbMouseDown}
282
- />
283
- </div>
284
- )
285
- }
286
- },
287
- })
@@ -1,59 +0,0 @@
1
- import { mount } from '@vue/test-utils'
2
- import { describe, expect, it } from 'vitest'
3
- import { h } from 'vue'
4
- import VirtualList from '../List'
5
-
6
- describe('virtualList', () => {
7
- it('should render basic list', () => {
8
- const data = Array.from({ length: 100 }, (_, i) => ({ id: i, text: `Item ${i}` }))
9
-
10
- const wrapper = mount(VirtualList, {
11
- props: {
12
- data,
13
- height: 200,
14
- itemHeight: 20,
15
- itemKey: 'id',
16
- },
17
- slots: {
18
- default: ({ item }: any) => h('div', `${item.text}`),
19
- },
20
- })
21
-
22
- expect(wrapper.exists()).toBe(true)
23
- expect(wrapper.find('.vc-virtual-list').exists()).toBe(true)
24
- })
25
-
26
- it('should handle empty data', () => {
27
- const wrapper = mount(VirtualList, {
28
- props: {
29
- data: [],
30
- height: 200,
31
- itemHeight: 20,
32
- itemKey: 'id',
33
- },
34
- slots: {
35
- default: ({ item }: any) => h('div', `${item.text}`),
36
- },
37
- })
38
-
39
- expect(wrapper.exists()).toBe(true)
40
- })
41
-
42
- it('should work with function itemKey', () => {
43
- const data = Array.from({ length: 100 }, (_, i) => ({ id: i, text: `Item ${i}` }))
44
-
45
- const wrapper = mount(VirtualList, {
46
- props: {
47
- data,
48
- height: 200,
49
- itemHeight: 20,
50
- itemKey: (item: any) => item.id,
51
- },
52
- slots: {
53
- default: ({ item }: any) => h('div', `${item.text}`),
54
- },
55
- })
56
-
57
- expect(wrapper.exists()).toBe(true)
58
- })
59
- })
@@ -1,28 +0,0 @@
1
- import type { Ref } from 'vue'
2
- import { ref, watch } from 'vue'
3
-
4
- export default function useDiffItem<T>(data: Ref<T[]>, getKey: (item: T) => any): Ref<T | undefined> {
5
- const prevDataRef = ref<T[]>([])
6
- const diffItem = ref<T>()
7
-
8
- watch(
9
- data,
10
- (newData) => {
11
- const prevData = prevDataRef.value
12
-
13
- if (newData !== prevData) {
14
- // Find added item
15
- const addedItem = newData.find((item) => {
16
- const key = getKey(item)
17
- return !prevData.some(prevItem => getKey(prevItem as any) === key)
18
- })
19
-
20
- diffItem.value = addedItem
21
- prevDataRef.value = newData
22
- }
23
- },
24
- { immediate: true },
25
- )
26
-
27
- return diffItem
28
- }
@@ -1,142 +0,0 @@
1
- import type { Ref } from 'vue'
2
- import { onUnmounted, ref } from 'vue'
3
- import isFF from '../utils/isFirefox'
4
- import useOriginScroll from './useOriginScroll'
5
-
6
- interface FireFoxDOMMouseScrollEvent {
7
- detail: number
8
- preventDefault: VoidFunction
9
- }
10
-
11
- export default function useFrameWheel(
12
- inVirtual: Ref<boolean>,
13
- isScrollAtTop: Ref<boolean>,
14
- isScrollAtBottom: Ref<boolean>,
15
- isScrollAtLeft: Ref<boolean>,
16
- isScrollAtRight: Ref<boolean>,
17
- horizontalScroll: boolean,
18
- /**
19
- * Return `true` when you need to prevent default event
20
- */
21
- onWheelDelta: (offset: number, horizontal: boolean) => void,
22
- ): [(e: WheelEvent) => void, (e: FireFoxDOMMouseScrollEvent) => void] {
23
- const offsetRef = ref(0)
24
- let nextFrame: number | null = null
25
-
26
- // Firefox patch
27
- const wheelValueRef = ref<number | null>(null)
28
- const isMouseScrollRef = ref<boolean>(false)
29
-
30
- // Scroll status sync
31
- const originScroll = useOriginScroll(
32
- isScrollAtTop,
33
- isScrollAtBottom,
34
- isScrollAtLeft,
35
- isScrollAtRight,
36
- )
37
-
38
- function onWheelY(e: WheelEvent, deltaY: number) {
39
- if (nextFrame)
40
- cancelAnimationFrame(nextFrame)
41
-
42
- // Do nothing when scroll at the edge, Skip check when is in scroll
43
- if (originScroll(false, deltaY))
44
- return
45
-
46
- // Skip if nest List has handled this event
47
- const event = e as WheelEvent & {
48
- _virtualHandled?: boolean
49
- }
50
- if (!event._virtualHandled) {
51
- event._virtualHandled = true
52
- }
53
- else {
54
- return
55
- }
56
-
57
- offsetRef.value += deltaY
58
- wheelValueRef.value = deltaY
59
-
60
- // Proxy of scroll events
61
- if (!isFF) {
62
- event.preventDefault()
63
- }
64
-
65
- nextFrame = requestAnimationFrame(() => {
66
- // Patch a multiple for Firefox to fix wheel number too small
67
- const patchMultiple = isMouseScrollRef.value ? 10 : 1
68
- onWheelDelta(offsetRef.value * patchMultiple, false)
69
- offsetRef.value = 0
70
- })
71
- }
72
-
73
- function onWheelX(event: WheelEvent, deltaX: number) {
74
- onWheelDelta(deltaX, true)
75
-
76
- if (!isFF) {
77
- event.preventDefault()
78
- }
79
- }
80
-
81
- // Check for which direction does wheel do. `sx` means `shift + wheel`
82
- const wheelDirectionRef = ref<'x' | 'y' | 'sx' | null>(null)
83
- let wheelDirectionClean: number | null = null
84
-
85
- function onWheel(event: WheelEvent) {
86
- if (!inVirtual.value)
87
- return
88
-
89
- // Wait for 2 frame to clean direction
90
- if (wheelDirectionClean)
91
- cancelAnimationFrame(wheelDirectionClean)
92
- wheelDirectionClean = requestAnimationFrame(() => {
93
- wheelDirectionRef.value = null
94
- })
95
-
96
- const { deltaX, deltaY, shiftKey } = event
97
-
98
- let mergedDeltaX = deltaX
99
- let mergedDeltaY = deltaY
100
-
101
- if (
102
- wheelDirectionRef.value === 'sx'
103
- || (!wheelDirectionRef.value && (shiftKey || false) && deltaY && !deltaX)
104
- ) {
105
- mergedDeltaX = deltaY
106
- mergedDeltaY = 0
107
-
108
- wheelDirectionRef.value = 'sx'
109
- }
110
-
111
- const absX = Math.abs(mergedDeltaX)
112
- const absY = Math.abs(mergedDeltaY)
113
-
114
- if (wheelDirectionRef.value === null) {
115
- wheelDirectionRef.value = horizontalScroll && absX > absY ? 'x' : 'y'
116
- }
117
-
118
- if (wheelDirectionRef.value === 'y') {
119
- onWheelY(event, mergedDeltaY)
120
- }
121
- else {
122
- onWheelX(event, mergedDeltaX)
123
- }
124
- }
125
-
126
- // A patch for firefox
127
- function onFireFoxScroll(event: FireFoxDOMMouseScrollEvent) {
128
- if (!inVirtual.value)
129
- return
130
-
131
- isMouseScrollRef.value = event.detail === wheelValueRef.value
132
- }
133
-
134
- onUnmounted(() => {
135
- if (nextFrame)
136
- cancelAnimationFrame(nextFrame)
137
- if (wheelDirectionClean)
138
- cancelAnimationFrame(wheelDirectionClean)
139
- })
140
-
141
- return [onWheel, onFireFoxScroll]
142
- }
@@ -1,45 +0,0 @@
1
- import type { ComputedRef, Ref } from 'vue'
2
- import type { GetKey } from '../interface'
3
- import type CacheMap from '../utils/CacheMap'
4
- import { computed } from 'vue'
5
-
6
- export function useGetSize<T>(
7
- mergedData: Ref<T[]>,
8
- getKey: GetKey<T>,
9
- heights: CacheMap,
10
- itemHeight: Ref<number>,
11
- ): ComputedRef<(startKey: any, endKey?: any) => { top: number, bottom: number }> {
12
- return computed(() => {
13
- return (startKey: any, endKey?: any) => {
14
- let topIndex = 0
15
- let bottomIndex = mergedData.value.length - 1
16
-
17
- if (startKey !== undefined && startKey !== null) {
18
- topIndex = mergedData.value.findIndex(item => getKey(item) === startKey)
19
- }
20
-
21
- if (endKey !== undefined && endKey !== null) {
22
- bottomIndex = mergedData.value.findIndex(item => getKey(item) === endKey)
23
- }
24
-
25
- let top = 0
26
- for (let i = 0; i < topIndex; i += 1) {
27
- const key = getKey(mergedData.value[i])
28
- const cacheHeight = heights.get(key)
29
- top += cacheHeight === undefined ? itemHeight.value : cacheHeight
30
- }
31
-
32
- let bottom = 0
33
- for (let i = mergedData.value.length - 1; i > bottomIndex; i -= 1) {
34
- const key = getKey(mergedData.value[i])
35
- const cacheHeight = heights.get(key)
36
- bottom += cacheHeight === undefined ? itemHeight.value : cacheHeight
37
- }
38
-
39
- return {
40
- top,
41
- bottom,
42
- }
43
- }
44
- })
45
- }
@@ -1,107 +0,0 @@
1
- import type { Key } from '@v-c/util/dist/type'
2
- import type { Ref } from 'vue'
3
- import type { GetKey } from '../interface'
4
- import { onUnmounted, reactive, ref } from 'vue'
5
- import CacheMap from '../utils/CacheMap'
6
-
7
- function parseNumber(value: string) {
8
- const num = parseFloat(value)
9
- return isNaN(num) ? 0 : num
10
- }
11
-
12
- export default function useHeights<T>(
13
- getKey: GetKey<T>,
14
- onItemAdd?: (item: T) => void,
15
- onItemRemove?: (item: T) => void,
16
- ): [
17
- setInstanceRef: (item: T, instance: HTMLElement | null) => void,
18
- collectHeight: (sync?: boolean) => void,
19
- cacheMap: CacheMap,
20
- updatedMark: Ref<number>,
21
- ] {
22
- const updatedMark = ref(0)
23
- const instanceRef = ref(new Map<Key, HTMLElement>())
24
- const heightsRef = reactive(new CacheMap())
25
-
26
- const promiseIdRef = ref<number>(0)
27
-
28
- function cancelRaf() {
29
- promiseIdRef.value += 1
30
- }
31
-
32
- function collectHeight(sync = false) {
33
- cancelRaf()
34
-
35
- const doCollect = () => {
36
- let changed = false
37
-
38
- instanceRef.value.forEach((element, key) => {
39
- if (element && element.offsetParent) {
40
- const { offsetHeight } = element
41
- const { marginTop, marginBottom } = getComputedStyle(element)
42
-
43
- const marginTopNum = parseNumber(marginTop)
44
- const marginBottomNum = parseNumber(marginBottom)
45
- const totalHeight = offsetHeight + marginTopNum + marginBottomNum
46
-
47
- if (heightsRef.get(key) !== totalHeight) {
48
- heightsRef.set(key, totalHeight)
49
- changed = true
50
- }
51
- }
52
- })
53
-
54
- // Always trigger update mark to tell parent that should re-calculate heights when resized
55
- if (changed) {
56
- updatedMark.value += 1
57
- }
58
- }
59
-
60
- if (sync) {
61
- doCollect()
62
- }
63
- else {
64
- promiseIdRef.value += 1
65
- const id = promiseIdRef.value
66
- Promise.resolve().then(() => {
67
- if (id === promiseIdRef.value) {
68
- doCollect()
69
- }
70
- })
71
- }
72
- }
73
-
74
- function setInstanceRef(item: T, instance: HTMLElement | null) {
75
- const key = getKey(item)
76
- const origin = instanceRef.value.get(key)
77
-
78
- // Only update if the instance actually changed
79
- if (origin === instance) {
80
- return
81
- }
82
-
83
- if (instance) {
84
- instanceRef.value.set(key, instance)
85
- collectHeight()
86
- }
87
- else {
88
- instanceRef.value.delete(key)
89
- }
90
-
91
- // Instance changed
92
- if (!origin !== !instance) {
93
- if (instance) {
94
- onItemAdd?.(item)
95
- }
96
- else {
97
- onItemRemove?.(item)
98
- }
99
- }
100
- }
101
-
102
- onUnmounted(() => {
103
- cancelRaf()
104
- })
105
-
106
- return [setInstanceRef, collectHeight, heightsRef, updatedMark]
107
- }