@pdanpdan/virtual-scroll 0.3.0 → 0.5.0

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.
@@ -0,0 +1,174 @@
1
+ import { mount } from '@vue/test-utils';
2
+ import { describe, expect, it, vi } from 'vitest';
3
+
4
+ import VirtualScrollbar from './VirtualScrollbar.vue';
5
+
6
+ describe('virtualScrollbar', () => {
7
+ describe('rendering', () => {
8
+ it('renders correctly for vertical axis', () => {
9
+ const wrapper = mount(VirtualScrollbar, {
10
+ props: {
11
+ axis: 'vertical',
12
+ totalSize: 1000,
13
+ position: 100,
14
+ viewportSize: 200,
15
+ scrollToOffset: vi.fn(),
16
+ },
17
+ });
18
+
19
+ const track = wrapper.find('.virtual-scrollbar-track');
20
+ expect(track.classes()).toContain('virtual-scrollbar-track--vertical');
21
+ expect(track.attributes('role')).toBe('scrollbar');
22
+ expect(track.attributes('aria-orientation')).toBe('vertical');
23
+
24
+ const thumb = wrapper.find('.virtual-scrollbar-thumb');
25
+ expect(thumb.classes()).toContain('virtual-scrollbar-thumb--vertical');
26
+
27
+ // viewport is 20% of total size, so thumb should be 20% (minimum is 10%)
28
+ expect((thumb.element as HTMLElement).style.blockSize).toBe('20%');
29
+ });
30
+
31
+ it('renders correctly for horizontal axis', () => {
32
+ const wrapper = mount(VirtualScrollbar, {
33
+ props: {
34
+ axis: 'horizontal',
35
+ totalSize: 1000,
36
+ position: 200,
37
+ viewportSize: 200,
38
+ scrollToOffset: vi.fn(),
39
+ },
40
+ });
41
+
42
+ const track = wrapper.find('.virtual-scrollbar-track');
43
+ expect(track.classes()).toContain('virtual-scrollbar-track--horizontal');
44
+
45
+ const thumb = wrapper.find('.virtual-scrollbar-thumb');
46
+ expect(thumb.classes()).toContain('virtual-scrollbar-thumb--horizontal');
47
+ expect((thumb.element as HTMLElement).style.inlineSize).toBe('20%');
48
+ });
49
+ });
50
+
51
+ describe('thumb size', () => {
52
+ it('enforces minimum thumb size', () => {
53
+ const wrapper = mount(VirtualScrollbar, {
54
+ props: {
55
+ axis: 'vertical',
56
+ totalSize: 10000,
57
+ position: 0,
58
+ viewportSize: 100,
59
+ scrollToOffset: vi.fn(),
60
+ },
61
+ });
62
+
63
+ const thumb = wrapper.find('.virtual-scrollbar-thumb');
64
+ // viewportPercent is 1%, but minimum is 10%
65
+ expect((thumb.element as HTMLElement).style.blockSize).toBe('10%');
66
+ });
67
+ });
68
+
69
+ describe('interactions', () => {
70
+ it('calls scrolltooffset when track is clicked', async () => {
71
+ const scrollToOffset = vi.fn();
72
+ const wrapper = mount(VirtualScrollbar, {
73
+ props: {
74
+ axis: 'vertical',
75
+ totalSize: 1000,
76
+ position: 0,
77
+ viewportSize: 200,
78
+ scrollToOffset,
79
+ },
80
+ });
81
+
82
+ const track = wrapper.find('.virtual-scrollbar-track');
83
+
84
+ // mock getBoundingClientRect for track
85
+ vi.spyOn(track.element, 'getBoundingClientRect').mockReturnValue({
86
+ top: 0,
87
+ left: 0,
88
+ width: 10,
89
+ height: 100,
90
+ bottom: 100,
91
+ right: 10,
92
+ x: 0,
93
+ y: 0,
94
+ toJSON: () => {},
95
+ } as DOMRect);
96
+
97
+ // click at middle of track (50px)
98
+ // totalSize 1000, viewportSize 200, scrollableRange 800
99
+ // thumbSize is 20% of 100px = 20px
100
+ // targetPercent = (50 - 20/2) / (100 - 20) = 40 / 80 = 0.5
101
+ // targetOffset = 0.5 * 800 = 400
102
+ await track.trigger('mousedown', { clientY: 50 });
103
+
104
+ expect(scrollToOffset).toHaveBeenCalledWith(400);
105
+ });
106
+
107
+ it('emits scrolltooffset when clicked', async () => {
108
+ const wrapper = mount(VirtualScrollbar, {
109
+ props: {
110
+ axis: 'vertical',
111
+ totalSize: 1000,
112
+ position: 0,
113
+ viewportSize: 200,
114
+ scrollToOffset: vi.fn(),
115
+ },
116
+ });
117
+
118
+ const track = wrapper.find('.virtual-scrollbar-track');
119
+ vi.spyOn(track.element, 'getBoundingClientRect').mockReturnValue({
120
+ bottom: 100,
121
+ height: 100,
122
+ left: 0,
123
+ right: 10,
124
+ top: 0,
125
+ width: 10,
126
+ x: 0,
127
+ y: 0,
128
+ toJSON: () => {},
129
+ } as DOMRect);
130
+
131
+ // click at middle
132
+ await track.trigger('mousedown', { clientY: 50 });
133
+ expect(wrapper.emitted('scrollToOffset')?.[ 0 ]).toEqual([ 400 ]);
134
+ });
135
+
136
+ it('applies active class when dragging thumb', async () => {
137
+ const wrapper = mount(VirtualScrollbar, {
138
+ props: {
139
+ axis: 'vertical',
140
+ totalSize: 1000,
141
+ position: 0,
142
+ viewportSize: 200,
143
+ scrollToOffset: vi.fn(),
144
+ },
145
+ });
146
+
147
+ const thumb = wrapper.find('.virtual-scrollbar-thumb');
148
+
149
+ // mock setPointerCapture and releasePointerCapture
150
+ thumb.element.setPointerCapture = vi.fn();
151
+ thumb.element.releasePointerCapture = vi.fn();
152
+
153
+ // check initial state
154
+ expect(thumb.classes()).not.toContain('virtual-scrollbar-thumb--active');
155
+
156
+ // start dragging
157
+ await thumb.element.dispatchEvent(new PointerEvent('pointerdown', {
158
+ clientY: 0,
159
+ pointerId: 1,
160
+ bubbles: true,
161
+ cancelable: true,
162
+ }));
163
+ expect(thumb.classes()).toContain('virtual-scrollbar-thumb--active');
164
+
165
+ // stop dragging
166
+ await thumb.element.dispatchEvent(new PointerEvent('pointerup', {
167
+ pointerId: 1,
168
+ bubbles: true,
169
+ cancelable: true,
170
+ }));
171
+ expect(thumb.classes()).not.toContain('virtual-scrollbar-thumb--active');
172
+ });
173
+ });
174
+ });
@@ -0,0 +1,102 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * A cross-browser consistent virtual scrollbar component.
4
+ * Can be used independently or as part of the VirtualScroll component.
5
+ * Supports both vertical and horizontal axes and RTL layouts.
6
+ */
7
+ import type { VirtualScrollbarProps } from '../types';
8
+
9
+ import { useVirtualScrollbar } from '../composables/useVirtualScrollbar';
10
+
11
+ export interface Props extends VirtualScrollbarProps {}
12
+
13
+ const props = withDefaults(defineProps<Props>(), {
14
+ axis: 'vertical',
15
+ isRtl: false,
16
+ });
17
+
18
+ const emit = defineEmits<{
19
+ (e: 'scrollToOffset', offset: number): void;
20
+ }>();
21
+
22
+ const { trackProps, thumbProps } = useVirtualScrollbar({
23
+ axis: () => props.axis,
24
+ totalSize: () => props.totalSize,
25
+ position: () => props.position,
26
+ viewportSize: () => props.viewportSize,
27
+ containerId: () => props.containerId,
28
+ isRtl: () => props.isRtl,
29
+ scrollToOffset: (offset: number) => {
30
+ props.scrollToOffset?.(offset);
31
+ emit('scrollToOffset', offset);
32
+ },
33
+ });
34
+ </script>
35
+
36
+ <template>
37
+ <div v-bind="trackProps">
38
+ <div v-bind="thumbProps" />
39
+ </div>
40
+ </template>
41
+
42
+ <style>
43
+ @layer components {
44
+ .virtual-scrollbar-track {
45
+ --vsi-scrollbar-bg: var(--vs-scrollbar-bg, rgba(230, 230, 230, 0.9));
46
+ --vsi-scrollbar-thumb-bg: var(--vs-scrollbar-thumb-bg, rgba(0, 0, 0, 0.3));
47
+ --vsi-scrollbar-thumb-hover-bg: var(--vs-scrollbar-thumb-hover-bg, rgba(0, 0, 0, 0.6));
48
+
49
+ --vsi-scrollbar-bg: var(--vs-scrollbar-bg, light-dark(rgba(230, 230, 230, 0.9), rgba(30, 30, 30, 0.9)));
50
+ --vsi-scrollbar-thumb-bg: var(--vs-scrollbar-thumb-bg, light-dark(rgba(0, 0, 0, 0.3), rgba(255, 255, 255, 0.3)));
51
+ --vsi-scrollbar-thumb-hover-bg: var(--vs-scrollbar-thumb-hover-bg, light-dark(rgba(0, 0, 0, 0.6), rgba(255, 255, 255, 0.6)));
52
+
53
+ --vsi-scrollbar-radius: var(--vs-scrollbar-radius, 4px);
54
+ --vsi-scrollbar-size: var(--vs-scrollbar-size, 8px);
55
+
56
+ position: absolute;
57
+ contain: layout;
58
+ background-color: var(--vsi-scrollbar-bg);
59
+ border-radius: var(--vsi-scrollbar-radius);
60
+ z-index: 30;
61
+ transition: opacity 0.2s;
62
+ user-select: none;
63
+ -webkit-user-select: none;
64
+ pointer-events: auto;
65
+
66
+ &.virtual-scrollbar-track--vertical {
67
+ inline-size: var(--vsi-scrollbar-size);
68
+ inset-block-start: 2px;
69
+ inset-inline-end: 2px;
70
+ }
71
+
72
+ &.virtual-scrollbar-track--horizontal {
73
+ block-size: var(--vsi-scrollbar-size);
74
+ inset-inline-start: 2px;
75
+ inset-block-end: 2px;
76
+ }
77
+ }
78
+
79
+ .virtual-scrollbar-thumb {
80
+ position: absolute;
81
+ background-color: var(--vsi-scrollbar-thumb-bg);
82
+ border-radius: var(--vsi-scrollbar-radius);
83
+ touch-action: none;
84
+ pointer-events: auto;
85
+ cursor: pointer;
86
+
87
+ &:hover,
88
+ &:active,
89
+ &.virtual-scrollbar-thumb--active {
90
+ background-color: var(--vsi-scrollbar-thumb-hover-bg);
91
+ }
92
+
93
+ &.virtual-scrollbar-thumb--vertical {
94
+ inline-size: 100%;
95
+ }
96
+
97
+ &.virtual-scrollbar-thumb--horizontal {
98
+ block-size: 100%;
99
+ }
100
+ }
101
+ }
102
+ </style>