@praxisjs/composables 1.0.6 → 1.1.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.
- package/CHANGELOG.md +67 -9
- package/dist/__tests__/dom.test.js +106 -22
- package/dist/__tests__/dom.test.js.map +1 -1
- package/dist/__tests__/list.test.d.ts +2 -0
- package/dist/__tests__/list.test.d.ts.map +1 -0
- package/dist/__tests__/list.test.js +156 -0
- package/dist/__tests__/list.test.js.map +1 -0
- package/dist/dom.d.ts +28 -6
- package/dist/dom.d.ts.map +1 -1
- package/dist/dom.js +69 -53
- package/dist/dom.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/list.d.ts +30 -0
- package/dist/list.d.ts.map +1 -0
- package/dist/list.js +47 -0
- package/dist/list.js.map +1 -0
- package/package.json +1 -1
- package/src/__tests__/dom.test.ts +114 -23
- package/src/__tests__/list.test.ts +210 -0
- package/src/dom.ts +78 -53
- package/src/index.ts +1 -0
- package/src/list.ts +77 -0
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { describe, it, expect, vi } from "vitest";
|
|
3
|
+
|
|
4
|
+
import { signal } from "@praxisjs/core/internal";
|
|
5
|
+
|
|
6
|
+
// Import from index to cover re-exports
|
|
7
|
+
import { VirtualList, type VirtualItem } from "../index";
|
|
8
|
+
|
|
9
|
+
type AnyItem = { id: number };
|
|
10
|
+
|
|
11
|
+
function makeItems(count: number): AnyItem[] {
|
|
12
|
+
return Array.from({ length: count }, (_, i) => ({ id: i }));
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// ── VirtualList ───────────────────────────────────────────────────────────────
|
|
16
|
+
|
|
17
|
+
describe("VirtualList", () => {
|
|
18
|
+
const ITEM_HEIGHT = 50;
|
|
19
|
+
const VIEW_HEIGHT = 600;
|
|
20
|
+
const BUFFER = 3;
|
|
21
|
+
|
|
22
|
+
it("setup() returns computed signals for all four properties", () => {
|
|
23
|
+
const ref = { current: null };
|
|
24
|
+
const vl = new VirtualList(ref, () => makeItems(10), ITEM_HEIGHT);
|
|
25
|
+
const view = vl.setup() as {
|
|
26
|
+
visibleItems: () => VirtualItem<AnyItem>[];
|
|
27
|
+
totalHeight: () => number;
|
|
28
|
+
offsetTop: () => number;
|
|
29
|
+
offsetBottom: () => number;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
expect(typeof view.visibleItems).toBe("function");
|
|
33
|
+
expect(typeof view.totalHeight).toBe("function");
|
|
34
|
+
expect(typeof view.offsetTop).toBe("function");
|
|
35
|
+
expect(typeof view.offsetBottom).toBe("function");
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("totalHeight = items.length * itemHeight", () => {
|
|
39
|
+
const ref = { current: null };
|
|
40
|
+
const vl = new VirtualList(ref, () => makeItems(100), ITEM_HEIGHT);
|
|
41
|
+
const { totalHeight } = vl.setup() as { totalHeight: () => number };
|
|
42
|
+
expect(totalHeight()).toBe(100 * ITEM_HEIGHT);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("offsetTop = 0 when scrolled to top", () => {
|
|
46
|
+
const ref = { current: null };
|
|
47
|
+
const vl = new VirtualList(ref, () => makeItems(100), ITEM_HEIGHT);
|
|
48
|
+
const { offsetTop } = vl.setup() as { offsetTop: () => number };
|
|
49
|
+
expect(offsetTop()).toBe(0);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("visibleItems starts at index 0 with buffer window", () => {
|
|
53
|
+
const ref = { current: null };
|
|
54
|
+
// default viewHeight=600, buffer=3, scrollTop=0
|
|
55
|
+
// startIdx = max(0, floor(0/50) - 3) = 0
|
|
56
|
+
// endIdx = min(99, ceil((0+600)/50) + 3) = min(99, 12+3) = 15
|
|
57
|
+
const vl = new VirtualList(ref, () => makeItems(100), ITEM_HEIGHT, BUFFER);
|
|
58
|
+
const { visibleItems } = vl.setup() as { visibleItems: () => VirtualItem<AnyItem>[] };
|
|
59
|
+
|
|
60
|
+
const items = visibleItems();
|
|
61
|
+
expect(items[0].index).toBe(0);
|
|
62
|
+
expect(items[items.length - 1].index).toBe(15);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("visibleItems contains the correct item references", () => {
|
|
66
|
+
const source = makeItems(20);
|
|
67
|
+
const ref = { current: null };
|
|
68
|
+
const vl = new VirtualList(ref, () => source, ITEM_HEIGHT, 0);
|
|
69
|
+
const { visibleItems } = vl.setup() as { visibleItems: () => VirtualItem<AnyItem>[] };
|
|
70
|
+
|
|
71
|
+
const items = visibleItems();
|
|
72
|
+
expect(items[0].item).toBe(source[0]);
|
|
73
|
+
expect(items[0].index).toBe(0);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("offsetBottom reflects the hidden rows below the window", () => {
|
|
77
|
+
const ref = { current: null };
|
|
78
|
+
// startIdx=0, endIdx=15, total=100
|
|
79
|
+
// offsetBottom = max(0, 100-1-15) * 50 = 84 * 50 = 4200
|
|
80
|
+
const vl = new VirtualList(ref, () => makeItems(100), ITEM_HEIGHT, BUFFER);
|
|
81
|
+
const { offsetBottom } = vl.setup() as { offsetBottom: () => number };
|
|
82
|
+
expect(offsetBottom()).toBe((100 - 1 - 15) * ITEM_HEIGHT);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("empty list: all offsets are 0 and visibleItems is empty", () => {
|
|
86
|
+
const ref = { current: null };
|
|
87
|
+
const vl = new VirtualList(ref, () => [], ITEM_HEIGHT);
|
|
88
|
+
const { visibleItems, totalHeight, offsetTop, offsetBottom } = vl.setup() as {
|
|
89
|
+
visibleItems: () => VirtualItem<AnyItem>[];
|
|
90
|
+
totalHeight: () => number;
|
|
91
|
+
offsetTop: () => number;
|
|
92
|
+
offsetBottom: () => number;
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
expect(visibleItems()).toHaveLength(0);
|
|
96
|
+
expect(totalHeight()).toBe(0);
|
|
97
|
+
expect(offsetTop()).toBe(0);
|
|
98
|
+
expect(offsetBottom()).toBe(0);
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
// ── VirtualList — onMount ─────────────────────────────────────────────────────
|
|
103
|
+
|
|
104
|
+
describe("VirtualList — onMount", () => {
|
|
105
|
+
const ITEM_HEIGHT = 50;
|
|
106
|
+
|
|
107
|
+
it("onMount() with null ref is a no-op", () => {
|
|
108
|
+
const ref = { current: null };
|
|
109
|
+
const vl = new VirtualList(ref, () => makeItems(10), ITEM_HEIGHT);
|
|
110
|
+
vl.setup();
|
|
111
|
+
expect(() => vl.onMount()).not.toThrow();
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("onMount() sets viewHeight from container's clientHeight when > 0", () => {
|
|
115
|
+
const el = document.createElement("div");
|
|
116
|
+
Object.defineProperty(el, "clientHeight", { value: 400, configurable: true });
|
|
117
|
+
|
|
118
|
+
const ref = { current: el };
|
|
119
|
+
const vl = new VirtualList(ref, () => makeItems(100), ITEM_HEIGHT, 0);
|
|
120
|
+
const { visibleItems } = vl.setup() as { visibleItems: () => VirtualItem<AnyItem>[] };
|
|
121
|
+
vl.onMount();
|
|
122
|
+
|
|
123
|
+
// With viewHeight=400 and no buffer: endIdx = min(99, ceil(400/50)-1) = 7
|
|
124
|
+
// Visible range [0..7] = 8 items
|
|
125
|
+
const items = visibleItems();
|
|
126
|
+
expect(items.length).toBeLessThan(100);
|
|
127
|
+
expect(items.length).toBeGreaterThan(0);
|
|
128
|
+
// Specifically: ceil((0+400)/50) + 0 = 8; min(99, 8) = 8 → indices 0..8 = 9 items
|
|
129
|
+
expect(items[items.length - 1].index).toBe(8);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("onMount() does not override default viewHeight when clientHeight is 0", () => {
|
|
133
|
+
const el = document.createElement("div");
|
|
134
|
+
Object.defineProperty(el, "clientHeight", { value: 0, configurable: true });
|
|
135
|
+
|
|
136
|
+
const ref = { current: el };
|
|
137
|
+
const vl = new VirtualList(ref, () => makeItems(100), ITEM_HEIGHT, 0);
|
|
138
|
+
const { visibleItems } = vl.setup() as { visibleItems: () => VirtualItem<AnyItem>[] };
|
|
139
|
+
vl.onMount();
|
|
140
|
+
|
|
141
|
+
// Default viewHeight=600 preserved; endIdx = min(99, ceil(600/50)) = 12
|
|
142
|
+
const items = visibleItems();
|
|
143
|
+
expect(items[items.length - 1].index).toBe(12);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("onMount() registers a scroll listener that updates the window", () => {
|
|
147
|
+
const el = document.createElement("div");
|
|
148
|
+
Object.defineProperty(el, "clientHeight", { value: 0, configurable: true });
|
|
149
|
+
|
|
150
|
+
const ref = { current: el };
|
|
151
|
+
const vl = new VirtualList(ref, () => makeItems(100), ITEM_HEIGHT, 0);
|
|
152
|
+
const { offsetTop } = vl.setup() as { offsetTop: () => number };
|
|
153
|
+
vl.onMount();
|
|
154
|
+
|
|
155
|
+
expect(offsetTop()).toBe(0);
|
|
156
|
+
|
|
157
|
+
Object.defineProperty(el, "scrollTop", { value: 500, configurable: true });
|
|
158
|
+
el.dispatchEvent(new Event("scroll"));
|
|
159
|
+
|
|
160
|
+
// scrollTop=500, buffer=0: startIdx = floor(500/50) = 10; offsetTop = 10*50 = 500
|
|
161
|
+
expect(offsetTop()).toBe(500);
|
|
162
|
+
});
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
// ── VirtualList — onUnmount ───────────────────────────────────────────────────
|
|
166
|
+
|
|
167
|
+
describe("VirtualList — onUnmount", () => {
|
|
168
|
+
const ITEM_HEIGHT = 50;
|
|
169
|
+
|
|
170
|
+
it("onUnmount() removes the scroll listener", () => {
|
|
171
|
+
const el = document.createElement("div");
|
|
172
|
+
const remove = vi.spyOn(el, "removeEventListener");
|
|
173
|
+
|
|
174
|
+
const ref = { current: el };
|
|
175
|
+
const vl = new VirtualList(ref, () => makeItems(50), ITEM_HEIGHT);
|
|
176
|
+
vl.setup();
|
|
177
|
+
vl.onMount();
|
|
178
|
+
vl.onUnmount();
|
|
179
|
+
|
|
180
|
+
expect(remove).toHaveBeenCalledWith("scroll", expect.any(Function));
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it("onUnmount() without prior onMount() does not throw", () => {
|
|
184
|
+
const ref = { current: null };
|
|
185
|
+
const vl = new VirtualList(ref, () => makeItems(10), ITEM_HEIGHT);
|
|
186
|
+
vl.setup();
|
|
187
|
+
expect(() => vl.onUnmount()).not.toThrow();
|
|
188
|
+
});
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
// ── VirtualList — reactive getItems ──────────────────────────────────────────
|
|
192
|
+
|
|
193
|
+
describe("VirtualList — reactive getItems", () => {
|
|
194
|
+
it("visibleItems updates when the source signal changes", () => {
|
|
195
|
+
const itemsSignal = signal(makeItems(5));
|
|
196
|
+
const ref = { current: null };
|
|
197
|
+
const vl = new VirtualList(ref, () => itemsSignal(), 50);
|
|
198
|
+
const { visibleItems, totalHeight } = vl.setup() as {
|
|
199
|
+
visibleItems: () => VirtualItem<AnyItem>[];
|
|
200
|
+
totalHeight: () => number;
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
expect(totalHeight()).toBe(250);
|
|
204
|
+
expect(visibleItems()).toHaveLength(5);
|
|
205
|
+
|
|
206
|
+
itemsSignal.set(makeItems(10));
|
|
207
|
+
expect(totalHeight()).toBe(500);
|
|
208
|
+
expect(visibleItems()).toHaveLength(10);
|
|
209
|
+
});
|
|
210
|
+
});
|
package/src/dom.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Composable } from "@praxisjs/core";
|
|
2
|
-
import { signal
|
|
2
|
+
import { signal } from "@praxisjs/core/internal";
|
|
3
3
|
|
|
4
4
|
export class WindowSize extends Composable {
|
|
5
5
|
declare width: number;
|
|
@@ -30,31 +30,48 @@ export class ScrollPosition extends Composable {
|
|
|
30
30
|
declare x: number;
|
|
31
31
|
declare y: number;
|
|
32
32
|
|
|
33
|
-
private readonly
|
|
34
|
-
|
|
35
|
-
private _handler
|
|
36
|
-
private
|
|
33
|
+
private readonly _ref?: { current: HTMLElement | null };
|
|
34
|
+
private readonly _staticTarget?: HTMLElement | Window;
|
|
35
|
+
private _handler!: () => void;
|
|
36
|
+
private _x!: ReturnType<typeof signal<number>>;
|
|
37
|
+
private _y!: ReturnType<typeof signal<number>>;
|
|
38
|
+
private _mounted = false;
|
|
37
39
|
|
|
38
|
-
constructor(target: HTMLElement | Window = window) {
|
|
40
|
+
constructor(target: HTMLElement | Window | { current: HTMLElement | null } = window) {
|
|
39
41
|
super();
|
|
40
|
-
|
|
42
|
+
if (typeof target === "object" && "current" in target) {
|
|
43
|
+
this._ref = target as { current: HTMLElement | null };
|
|
44
|
+
} else {
|
|
45
|
+
this._staticTarget = target;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
private get _target(): HTMLElement | Window {
|
|
50
|
+
return this._ref ? (this._ref.current ?? window) : (this._staticTarget ?? window);
|
|
41
51
|
}
|
|
42
52
|
|
|
43
53
|
setup() {
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
54
|
+
this._x = signal(0);
|
|
55
|
+
this._y = signal(0);
|
|
56
|
+
return { x: this._x, y: this._y };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
onMount() {
|
|
60
|
+
if (this._mounted) return;
|
|
61
|
+
this._mounted = true;
|
|
47
62
|
const t = this._target;
|
|
48
63
|
this._handler = () => {
|
|
49
|
-
|
|
50
|
-
|
|
64
|
+
this._x.set(t === window ? window.scrollX : (t as HTMLElement).scrollLeft);
|
|
65
|
+
this._y.set(t === window ? window.scrollY : (t as HTMLElement).scrollTop);
|
|
51
66
|
};
|
|
52
67
|
t.addEventListener("scroll", this._handler);
|
|
53
|
-
|
|
54
|
-
|
|
68
|
+
// set initial values
|
|
69
|
+
this._x.set(t === window ? window.scrollX : (t as HTMLElement).scrollLeft);
|
|
70
|
+
this._y.set(t === window ? window.scrollY : (t as HTMLElement).scrollTop);
|
|
55
71
|
}
|
|
56
72
|
|
|
57
73
|
onUnmount() {
|
|
74
|
+
this._mounted = false;
|
|
58
75
|
this._target.removeEventListener("scroll", this._handler);
|
|
59
76
|
}
|
|
60
77
|
}
|
|
@@ -64,6 +81,8 @@ export class ElementSize extends Composable {
|
|
|
64
81
|
declare height: number;
|
|
65
82
|
|
|
66
83
|
private _observer?: ResizeObserver;
|
|
84
|
+
private _width!: ReturnType<typeof signal<number>>;
|
|
85
|
+
private _height!: ReturnType<typeof signal<number>>;
|
|
67
86
|
private _view?: Record<string, unknown>;
|
|
68
87
|
|
|
69
88
|
constructor(private readonly ref: { current: HTMLElement | null }) {
|
|
@@ -72,23 +91,24 @@ export class ElementSize extends Composable {
|
|
|
72
91
|
|
|
73
92
|
setup() {
|
|
74
93
|
if (this._view) return this._view;
|
|
75
|
-
|
|
76
|
-
|
|
94
|
+
this._width = signal(0);
|
|
95
|
+
this._height = signal(0);
|
|
77
96
|
this._observer = new ResizeObserver(([entry]) => {
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
});
|
|
81
|
-
effect(() => {
|
|
82
|
-
if (this.ref.current) {
|
|
83
|
-
this._observer?.observe(this.ref.current);
|
|
84
|
-
width.set(this.ref.current.offsetWidth);
|
|
85
|
-
height.set(this.ref.current.offsetHeight);
|
|
86
|
-
}
|
|
97
|
+
this._width.set(entry.contentRect.width);
|
|
98
|
+
this._height.set(entry.contentRect.height);
|
|
87
99
|
});
|
|
88
|
-
this._view = { width, height };
|
|
100
|
+
this._view = { width: this._width, height: this._height };
|
|
89
101
|
return this._view;
|
|
90
102
|
}
|
|
91
103
|
|
|
104
|
+
onMount() {
|
|
105
|
+
const el = this.ref.current;
|
|
106
|
+
if (!el) return;
|
|
107
|
+
this._observer?.observe(el);
|
|
108
|
+
this._width.set(el.offsetWidth);
|
|
109
|
+
this._height.set(el.offsetHeight);
|
|
110
|
+
}
|
|
111
|
+
|
|
92
112
|
onUnmount() {
|
|
93
113
|
this._observer?.disconnect();
|
|
94
114
|
}
|
|
@@ -98,6 +118,7 @@ export class Intersection extends Composable {
|
|
|
98
118
|
declare visible: boolean;
|
|
99
119
|
|
|
100
120
|
private _observer?: IntersectionObserver;
|
|
121
|
+
private _visible!: ReturnType<typeof signal<boolean>>;
|
|
101
122
|
private _view?: Record<string, unknown>;
|
|
102
123
|
|
|
103
124
|
constructor(
|
|
@@ -109,17 +130,19 @@ export class Intersection extends Composable {
|
|
|
109
130
|
|
|
110
131
|
setup() {
|
|
111
132
|
if (this._view) return this._view;
|
|
112
|
-
|
|
133
|
+
this._visible = signal(false);
|
|
113
134
|
this._observer = new IntersectionObserver(([entry]) => {
|
|
114
|
-
|
|
135
|
+
this._visible.set(entry.isIntersecting);
|
|
115
136
|
}, this.options);
|
|
116
|
-
|
|
117
|
-
if (this.ref.current) this._observer?.observe(this.ref.current);
|
|
118
|
-
});
|
|
119
|
-
this._view = { visible };
|
|
137
|
+
this._view = { visible: this._visible };
|
|
120
138
|
return this._view;
|
|
121
139
|
}
|
|
122
140
|
|
|
141
|
+
onMount() {
|
|
142
|
+
const el = this.ref.current;
|
|
143
|
+
if (el) this._observer?.observe(el);
|
|
144
|
+
}
|
|
145
|
+
|
|
123
146
|
onUnmount() {
|
|
124
147
|
this._observer?.disconnect();
|
|
125
148
|
}
|
|
@@ -128,32 +151,34 @@ export class Intersection extends Composable {
|
|
|
128
151
|
export class Focus extends Composable {
|
|
129
152
|
declare focused: boolean;
|
|
130
153
|
|
|
131
|
-
private
|
|
154
|
+
private _focused!: ReturnType<typeof signal<boolean>>;
|
|
155
|
+
private readonly _onFocus = () => { this._focused.set(true); };
|
|
156
|
+
private readonly _onBlur = () => { this._focused.set(false); };
|
|
157
|
+
private _mounted = false;
|
|
132
158
|
|
|
133
159
|
constructor(private readonly ref: { current: HTMLElement | null }) {
|
|
134
160
|
super();
|
|
135
161
|
}
|
|
136
162
|
|
|
137
163
|
setup() {
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
this.
|
|
157
|
-
return this._view;
|
|
164
|
+
this._focused = signal(false);
|
|
165
|
+
return { focused: this._focused };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
onMount() {
|
|
169
|
+
if (this._mounted) return;
|
|
170
|
+
this._mounted = true;
|
|
171
|
+
const el = this.ref.current;
|
|
172
|
+
if (!el) return;
|
|
173
|
+
el.addEventListener("focus", this._onFocus);
|
|
174
|
+
el.addEventListener("blur", this._onBlur);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
onUnmount() {
|
|
178
|
+
this._mounted = false;
|
|
179
|
+
const el = this.ref.current;
|
|
180
|
+
if (!el) return;
|
|
181
|
+
el.removeEventListener("focus", this._onFocus);
|
|
182
|
+
el.removeEventListener("blur", this._onBlur);
|
|
158
183
|
}
|
|
159
184
|
}
|
package/src/index.ts
CHANGED
package/src/list.ts
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { Composable } from "@praxisjs/core";
|
|
2
|
+
import { computed, signal } from "@praxisjs/core/internal";
|
|
3
|
+
|
|
4
|
+
export interface VirtualItem<T> {
|
|
5
|
+
item: T;
|
|
6
|
+
index: number;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export class VirtualList<T = unknown> extends Composable {
|
|
10
|
+
declare visibleItems: Array<VirtualItem<T>>;
|
|
11
|
+
declare totalHeight: number;
|
|
12
|
+
declare offsetTop: number;
|
|
13
|
+
declare offsetBottom: number;
|
|
14
|
+
|
|
15
|
+
private readonly _scrollTop = signal(0);
|
|
16
|
+
private readonly _viewHeight = signal(600);
|
|
17
|
+
private _scrollCleanup?: () => void;
|
|
18
|
+
|
|
19
|
+
constructor(
|
|
20
|
+
private readonly containerRef: { current: HTMLElement | null },
|
|
21
|
+
private readonly getItems: () => T[],
|
|
22
|
+
private readonly itemHeight: number,
|
|
23
|
+
private readonly buffer = 3,
|
|
24
|
+
) {
|
|
25
|
+
super();
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
setup() {
|
|
29
|
+
const { _scrollTop, _viewHeight, itemHeight, buffer, getItems } = this;
|
|
30
|
+
|
|
31
|
+
const items = computed(getItems);
|
|
32
|
+
const total = computed(() => items().length);
|
|
33
|
+
|
|
34
|
+
const startIdx = computed(() =>
|
|
35
|
+
Math.max(0, Math.floor(_scrollTop() / itemHeight) - buffer),
|
|
36
|
+
);
|
|
37
|
+
const endIdx = computed(() =>
|
|
38
|
+
Math.min(
|
|
39
|
+
total() - 1,
|
|
40
|
+
Math.ceil((_scrollTop() + _viewHeight()) / itemHeight) + buffer,
|
|
41
|
+
),
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
const visibleItems = computed<Array<VirtualItem<T>>>(() => {
|
|
45
|
+
const arr = items();
|
|
46
|
+
const result: Array<VirtualItem<T>> = [];
|
|
47
|
+
for (let i = startIdx(); i <= endIdx(); i++) {
|
|
48
|
+
result.push({ item: arr[i], index: i });
|
|
49
|
+
}
|
|
50
|
+
return result;
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
const totalHeight = computed(() => total() * itemHeight);
|
|
54
|
+
const offsetTop = computed(() => startIdx() * itemHeight);
|
|
55
|
+
const offsetBottom = computed(() =>
|
|
56
|
+
Math.max(0, total() - 1 - endIdx()) * itemHeight,
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
return { visibleItems, totalHeight, offsetTop, offsetBottom };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
onMount() {
|
|
63
|
+
const el = this.containerRef.current;
|
|
64
|
+
if (!el) return;
|
|
65
|
+
|
|
66
|
+
const h = el.clientHeight;
|
|
67
|
+
if (h > 0) this._viewHeight.set(h);
|
|
68
|
+
|
|
69
|
+
const handler = () => { this._scrollTop.set(el.scrollTop); };
|
|
70
|
+
el.addEventListener("scroll", handler);
|
|
71
|
+
this._scrollCleanup = () => { el.removeEventListener("scroll", handler); };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
onUnmount() {
|
|
75
|
+
this._scrollCleanup?.();
|
|
76
|
+
}
|
|
77
|
+
}
|