@praxisjs/decorators 0.8.1 → 1.0.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.
Files changed (47) hide show
  1. package/CHANGELOG.md +127 -3
  2. package/dist/__tests__/lazy.test.js +74 -10
  3. package/dist/__tests__/lazy.test.js.map +1 -1
  4. package/dist/__tests__/properties.test.js +61 -3
  5. package/dist/__tests__/properties.test.js.map +1 -1
  6. package/dist/component/index.d.ts +0 -1
  7. package/dist/component/index.d.ts.map +1 -1
  8. package/dist/component/index.js +0 -1
  9. package/dist/component/index.js.map +1 -1
  10. package/dist/component/lazy.d.ts +15 -1
  11. package/dist/component/lazy.d.ts.map +1 -1
  12. package/dist/component/lazy.js +15 -9
  13. package/dist/component/lazy.js.map +1 -1
  14. package/dist/create-class-decorator.d.ts.map +1 -1
  15. package/dist/create-class-decorator.js +16 -3
  16. package/dist/create-class-decorator.js.map +1 -1
  17. package/dist/index.d.ts +2 -2
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/index.js +2 -2
  20. package/dist/index.js.map +1 -1
  21. package/dist/properties/compose.d.ts +7 -0
  22. package/dist/properties/compose.d.ts.map +1 -1
  23. package/dist/properties/compose.js +18 -3
  24. package/dist/properties/compose.js.map +1 -1
  25. package/dist/properties/index.d.ts +1 -1
  26. package/dist/properties/index.d.ts.map +1 -1
  27. package/dist/properties/index.js +1 -1
  28. package/dist/properties/index.js.map +1 -1
  29. package/package.json +1 -1
  30. package/src/__tests__/lazy.test.ts +88 -10
  31. package/src/__tests__/properties.test.ts +71 -3
  32. package/src/component/index.ts +0 -1
  33. package/src/component/lazy.ts +29 -7
  34. package/src/create-class-decorator.ts +16 -4
  35. package/src/index.ts +2 -1
  36. package/src/properties/compose.ts +21 -8
  37. package/src/properties/index.ts +1 -1
  38. package/dist/__tests__/virtual.test.d.ts +0 -2
  39. package/dist/__tests__/virtual.test.d.ts.map +0 -1
  40. package/dist/__tests__/virtual.test.js +0 -352
  41. package/dist/__tests__/virtual.test.js.map +0 -1
  42. package/dist/component/virtual.d.ts +0 -3
  43. package/dist/component/virtual.d.ts.map +0 -1
  44. package/dist/component/virtual.js +0 -102
  45. package/dist/component/virtual.js.map +0 -1
  46. package/src/__tests__/virtual.test.ts +0 -371
  47. package/src/component/virtual.tsx +0 -163
@@ -1,371 +0,0 @@
1
- // @vitest-environment jsdom
2
- import { describe, it, expect, vi } from "vitest";
3
-
4
- import { StatefulComponent } from "@praxisjs/core";
5
- import { Virtual } from "../component/virtual";
6
-
7
- type AnyConstructor = new (...args: unknown[]) => StatefulComponent;
8
-
9
- function applyVirtual<T extends AnyConstructor>(cls: T, itemHeight: number, buffer?: number): T {
10
- return buffer !== undefined
11
- ? Virtual(itemHeight, buffer)(cls, {} as ClassDecoratorContext)
12
- : Virtual(itemHeight)(cls, {} as ClassDecoratorContext);
13
- }
14
-
15
- // A base component with items and renderItem for virtual scrolling
16
- class ListComp extends StatefulComponent {
17
- items: unknown[] = [];
18
- renderItem(item: unknown, _index: number) {
19
- const div = document.createElement("div");
20
- (div as HTMLElement).textContent = String(item);
21
- return div;
22
- }
23
- render() { return null; }
24
- }
25
-
26
- describe("Virtual decorator", () => {
27
- it("warns and returns null when renderItem is not defined", () => {
28
- class NoRenderItem extends StatefulComponent {
29
- items = ["a", "b"];
30
- render() { return null; }
31
- }
32
-
33
- const Wrapped = applyVirtual(NoRenderItem as AnyConstructor, 50);
34
- const instance = new Wrapped() as unknown as { render: () => unknown };
35
- const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
36
- const result = instance.render();
37
- expect(result).toBeNull();
38
- expect(warn).toHaveBeenCalledWith(expect.stringContaining("[Virtual]"));
39
- warn.mockRestore();
40
- });
41
-
42
- it("renders a container div with the total height", () => {
43
- class MyList extends StatefulComponent {
44
- items = ["a", "b", "c"];
45
- renderItem(item: unknown) {
46
- const el = document.createElement("div");
47
- el.textContent = String(item);
48
- return el;
49
- }
50
- render() { return null; }
51
- }
52
-
53
- const Wrapped = applyVirtual(MyList as AnyConstructor, 50);
54
- const instance = new Wrapped();
55
-
56
- const result = instance.render() as HTMLElement;
57
- expect(result).toBeInstanceOf(HTMLElement);
58
- expect(result.tagName.toLowerCase()).toBe("div");
59
- // Total height = 3 items * 50px
60
- expect(result.getAttribute("style")).toContain("150px");
61
- });
62
-
63
- it("renders visible items within the viewport", () => {
64
- class MyList extends StatefulComponent {
65
- items = [1, 2, 3, 4, 5];
66
- renderItem(item: unknown) {
67
- const el = document.createElement("div");
68
- el.textContent = String(item);
69
- return el;
70
- }
71
- render() { return null; }
72
- }
73
-
74
- const Wrapped = applyVirtual(MyList as AnyConstructor, 100, 0);
75
- const instance = new Wrapped();
76
-
77
- const outer = instance.render() as HTMLElement;
78
- // Should render some content
79
- expect(outer).not.toBeNull();
80
- expect(outer.children.length).toBeGreaterThan(0);
81
- });
82
-
83
- it("handles empty items list without errors", () => {
84
- class EmptyList extends StatefulComponent {
85
- items: unknown[] = [];
86
- renderItem(_item: unknown) {
87
- return document.createElement("div");
88
- }
89
- render() { return null; }
90
- }
91
-
92
- const Wrapped = applyVirtual(EmptyList as AnyConstructor, 50);
93
- const instance = new Wrapped();
94
- expect(() => instance.render()).not.toThrow();
95
- });
96
-
97
- it("onMount sets container styles when _anchor has a parent", () => {
98
- const Wrapped = applyVirtual(ListComp as AnyConstructor, 50);
99
- const instance = new Wrapped();
100
- (instance as unknown as { items: unknown[] }).items = ["a", "b"];
101
-
102
- const container = document.createElement("div");
103
- document.body.appendChild(container);
104
- const anchor = document.createComment("end");
105
- container.appendChild(anchor);
106
- (instance as unknown as { _anchor: Comment })._anchor = anchor;
107
-
108
- instance.onMount?.();
109
- expect(container.style.overflowY).toBe("auto");
110
- expect(container.style.position).toBe("relative");
111
- document.body.removeChild(container);
112
- });
113
-
114
- it("onMount does nothing when _anchor has no parent", () => {
115
- const Wrapped = applyVirtual(ListComp as AnyConstructor, 50);
116
- const instance = new Wrapped();
117
-
118
- const anchor = document.createComment("end");
119
- (instance as unknown as { _anchor: Comment })._anchor = anchor;
120
-
121
- expect(() => instance.onMount?.()).not.toThrow();
122
- });
123
-
124
- it("onUnmount disconnects scroll listener and cleans up effects", () => {
125
- const Wrapped = applyVirtual(ListComp as AnyConstructor, 50);
126
- const instance = new Wrapped();
127
-
128
- const container = document.createElement("div");
129
- document.body.appendChild(container);
130
- const anchor = document.createComment("end");
131
- container.appendChild(anchor);
132
- (instance as unknown as { _anchor: Comment })._anchor = anchor;
133
-
134
- instance.onMount?.();
135
- // Should not throw
136
- expect(() => instance.onUnmount?.()).not.toThrow();
137
- document.body.removeChild(container);
138
- });
139
-
140
- it("re-renders visible items when scrollTop changes (covers while loop cleanup)", async () => {
141
- class ScrollList extends StatefulComponent {
142
- items = Array.from({ length: 20 }, (_, i) => i);
143
- renderItem(item: unknown) {
144
- const el = document.createElement("div");
145
- el.textContent = String(item);
146
- return el;
147
- }
148
- render() { return null; }
149
- }
150
-
151
- const Wrapped = applyVirtual(ScrollList as AnyConstructor, 50, 0);
152
- const instance = new Wrapped();
153
-
154
- const container = document.createElement("div");
155
- document.body.appendChild(container);
156
- const anchor = document.createComment("end");
157
- container.appendChild(anchor);
158
- (instance as unknown as { _anchor: Comment })._anchor = anchor;
159
-
160
- instance.onMount?.();
161
- // First render - sets up effects with the spacerTop/itemsSlot/spacerBottom elements
162
- const outer = instance.render() as HTMLElement;
163
-
164
- // Change scrollTop to trigger effect re-run (covers the while loop removing old nodes)
165
- Object.defineProperty(container, "scrollTop", { configurable: true, value: 500 });
166
- container.dispatchEvent(new Event("scroll"));
167
- await Promise.resolve();
168
-
169
- // buffer=0, itemHeight=50, scrollTop=500 → startIdx=10 → offsetTop=500px
170
- const spacerTop = outer.children[0] as HTMLElement;
171
- expect(spacerTop.getAttribute("style")).toContain("500px");
172
-
173
- document.body.removeChild(container);
174
- });
175
-
176
- it("renderItem returning an array appends all child nodes", () => {
177
- class ArrayItemList extends StatefulComponent {
178
- items = ["item"];
179
- renderItem(item: unknown) {
180
- const a = document.createElement("span");
181
- a.textContent = String(item) + "-1";
182
- const b = document.createElement("span");
183
- b.textContent = String(item) + "-2";
184
- return [a, b];
185
- }
186
- render() { return null; }
187
- }
188
-
189
- const Wrapped = applyVirtual(ArrayItemList as AnyConstructor, 50, 0);
190
- const instance = new Wrapped();
191
- const outer = instance.render() as HTMLElement;
192
- // outer structure: spacerTop, itemsSlot, spacerBottom
193
- const itemsSlot = outer.children[1] as HTMLElement;
194
- const wrapper = itemsSlot.children[0] as HTMLElement;
195
- expect(wrapper.children.length).toBe(2);
196
- });
197
-
198
- it("renderItem returning null produces an empty wrapper div", () => {
199
- class NullItemList extends StatefulComponent {
200
- items = ["x"];
201
- renderItem() { return null; }
202
- render() { return null; }
203
- }
204
-
205
- const Wrapped = applyVirtual(NullItemList as AnyConstructor, 50, 0);
206
- const instance = new Wrapped();
207
- expect(() => instance.render()).not.toThrow();
208
- const outer = instance.render() as HTMLElement;
209
- const itemsSlot = outer.children[1] as HTMLElement;
210
- const wrapper = itemsSlot.children[0] as HTMLElement;
211
- // rendered is null — no child nodes appended
212
- expect(wrapper.children.length).toBe(0);
213
- });
214
-
215
- it("uses empty array when items is undefined (host.items ?? [])", () => {
216
- class NoItems extends StatefulComponent {
217
- // items is intentionally not defined
218
- renderItem() { return document.createElement("div"); }
219
- render() { return null; }
220
- }
221
-
222
- const Wrapped = applyVirtual(NoItems as AnyConstructor, 50, 0);
223
- const instance = new Wrapped();
224
- // render() should work fine with 0 items
225
- const result = instance.render() as HTMLElement;
226
- expect(result).toBeInstanceOf(HTMLElement);
227
- const style = result.getAttribute("style") ?? "";
228
- expect(style).toContain("0px"); // totalH = 0 * 50 = 0
229
- });
230
-
231
- it("onUnmount without prior onMount does not throw", () => {
232
- const Wrapped = applyVirtual(ListComp as AnyConstructor, 50);
233
- const instance = new Wrapped();
234
- // _container is undefined — onUnmount should be a no-op
235
- expect(() => instance.onUnmount?.()).not.toThrow();
236
- });
237
-
238
- it("updates scrollTop on scroll event", async () => {
239
- const Wrapped = applyVirtual(ListComp as AnyConstructor, 50);
240
- const instance = new Wrapped();
241
- (instance as unknown as { items: unknown[] }).items = Array.from({ length: 20 }, (_, i) => i);
242
-
243
- const container = document.createElement("div");
244
- document.body.appendChild(container);
245
- const anchor = document.createComment("end");
246
- container.appendChild(anchor);
247
- (instance as unknown as { _anchor: Comment })._anchor = anchor;
248
-
249
- instance.onMount?.();
250
- // Render to set up effects that observe scrollTop
251
- const outer = instance.render() as HTMLElement;
252
-
253
- Object.defineProperty(container, "scrollTop", { configurable: true, value: 200 });
254
- container.dispatchEvent(new Event("scroll"));
255
- await Promise.resolve();
256
-
257
- // buffer=3, itemHeight=50, scrollTop=200 → startIdx=1 → offsetTop=50px
258
- const spacerTop = outer.children[0] as HTMLElement;
259
- expect(spacerTop.getAttribute("style")).toContain("50px");
260
- document.body.removeChild(container);
261
- });
262
-
263
- it("itemHeight = 0 throws a descriptive error on onMount", () => {
264
- class ZeroHeightList extends StatefulComponent {
265
- items = ["a", "b"];
266
- renderItem(item: unknown) {
267
- const el = document.createElement("div");
268
- el.textContent = String(item);
269
- return el;
270
- }
271
- render() { return null; }
272
- }
273
-
274
- const Wrapped = applyVirtual(ZeroHeightList as AnyConstructor, 0);
275
- const instance = new Wrapped();
276
-
277
- const container = document.createElement("div");
278
- document.body.appendChild(container);
279
- const anchor = document.createComment("end");
280
- container.appendChild(anchor);
281
- (instance as unknown as { _anchor: Comment })._anchor = anchor;
282
-
283
- expect(() => instance.onMount?.()).toThrow(/itemHeight must be a positive number/);
284
- document.body.removeChild(container);
285
- });
286
-
287
- it("itemHeight = 0 in render() does not divide by zero (no container)", () => {
288
- class ZeroHeightList2 extends StatefulComponent {
289
- items = ["a", "b"];
290
- renderItem(item: unknown) {
291
- return document.createElement("div");
292
- }
293
- render() { return null; }
294
- }
295
- // render() is called before onMount, so no throw yet — but it still computes total height
296
- const Wrapped = applyVirtual(ZeroHeightList2 as AnyConstructor, 0);
297
- const instance = new Wrapped();
298
- // Render without mounting — no onMount guard triggered, but division happens in computed
299
- // The guard is in onMount, so render still runs. Verify it doesn't produce NaN/Infinity
300
- expect(() => instance.render()).not.toThrow();
301
- });
302
-
303
- it("renderItem() that throws — does not crash the entire component", () => {
304
- class ThrowingList extends StatefulComponent {
305
- items = ["x"];
306
- renderItem(): Node {
307
- throw new Error("render item failed");
308
- }
309
- render() { return null; }
310
- }
311
-
312
- const Wrapped = applyVirtual(ThrowingList as AnyConstructor, 50, 0);
313
- const instance = new Wrapped();
314
- // Render triggers the effect which calls renderItem — it should propagate the error
315
- expect(() => instance.render()).toThrow("render item failed");
316
- });
317
-
318
- it("scrollTop > totalHeight renders last visible items without crash", () => {
319
- class ShortList extends StatefulComponent {
320
- items = [1, 2, 3]; // only 3 items × 50px = 150px total
321
- renderItem(item: unknown) {
322
- const el = document.createElement("div");
323
- el.textContent = String(item);
324
- return el;
325
- }
326
- render() { return null; }
327
- }
328
-
329
- const Wrapped = applyVirtual(ShortList as AnyConstructor, 50, 0);
330
- const instance = new Wrapped();
331
-
332
- const container = document.createElement("div");
333
- document.body.appendChild(container);
334
- const anchor = document.createComment("end");
335
- container.appendChild(anchor);
336
- (instance as unknown as { _anchor: Comment })._anchor = anchor;
337
-
338
- instance.onMount?.();
339
- const outer = instance.render() as HTMLElement;
340
-
341
- // Simulate scrollTop way past end of list (e.g., 9999px for a 150px list)
342
- Object.defineProperty(container, "scrollTop", { configurable: true, value: 9999 });
343
- expect(() => container.dispatchEvent(new Event("scroll"))).not.toThrow();
344
-
345
- // The outer container should still be present
346
- expect(outer).toBeInstanceOf(HTMLElement);
347
- document.body.removeChild(container);
348
- });
349
-
350
- it("buffer applied correctly — renders extra items beyond visible area", () => {
351
- class BufferList extends StatefulComponent {
352
- items = Array.from({ length: 20 }, (_, i) => i);
353
- renderItem(item: unknown) {
354
- const el = document.createElement("div");
355
- el.textContent = String(item);
356
- return el;
357
- }
358
- render() { return null; }
359
- }
360
-
361
- // itemHeight=100, buffer=2, viewHeight defaults to 600 → visible=6 items + 2 buffer each side
362
- const Wrapped = applyVirtual(BufferList as AnyConstructor, 100, 2);
363
- const instance = new Wrapped();
364
- const outer = instance.render() as HTMLElement;
365
-
366
- // With scrollTop=0, buffer=2: startIdx=max(0, 0-2)=0, endIdx=min(19, ceil(600/100)+2)=8
367
- // So 9 items should be rendered (indices 0..8)
368
- const itemsSlot = outer.children[1] as HTMLElement;
369
- expect(itemsSlot.children.length).toBe(9);
370
- });
371
- });
@@ -1,163 +0,0 @@
1
- import {
2
- computed,
3
- effect,
4
- type RootComponent,
5
- signal,
6
- } from "@praxisjs/core/internal";
7
-
8
- import {
9
- ClassBehavior,
10
- createClassDecorator,
11
- type ClassEnhancement,
12
- } from "../create-class-decorator";
13
-
14
- interface VirtualHost {
15
- _anchor?: Comment;
16
- items?: unknown[];
17
- renderItem(item: unknown, index: number): Node | Node[] | null;
18
- }
19
-
20
- class VirtualBehavior extends ClassBehavior {
21
- constructor(
22
- private readonly itemHeight: number,
23
- private readonly buffer: number,
24
- ) {
25
- super();
26
- }
27
-
28
- create(instance: RootComponent): ClassEnhancement {
29
- const { itemHeight, buffer } = this;
30
- const host = instance as unknown as VirtualHost;
31
- const scrollTop = signal(0);
32
- const viewHeight = signal(600);
33
- let container: HTMLElement | undefined;
34
- let scrollHandler: (() => void) | undefined;
35
- const cleanups: Array<() => void> = [];
36
-
37
- return {
38
- onMount() {
39
- if (itemHeight <= 0) {
40
- throw new Error(
41
- `[Virtual] itemHeight must be a positive number, got ${String(itemHeight)}`,
42
- );
43
- }
44
- container = host._anchor?.parentElement ?? undefined;
45
- if (!container) return;
46
-
47
- container.style.overflowY = "auto";
48
- container.style.position = "relative";
49
- viewHeight.set(container.clientHeight || 600);
50
-
51
- const currentContainer = container;
52
- scrollHandler = () => {
53
- scrollTop.set(currentContainer.scrollTop);
54
- };
55
- container.addEventListener("scroll", scrollHandler);
56
- },
57
-
58
- onUnmount() {
59
- if (container && scrollHandler) {
60
- container.removeEventListener("scroll", scrollHandler);
61
- }
62
- cleanups.forEach((c) => {
63
- c();
64
- });
65
- cleanups.length = 0;
66
- },
67
-
68
- render(originalRender) {
69
- const items = host.items ?? [];
70
- const total = items.length;
71
- const totalH = total * itemHeight;
72
-
73
- if (typeof host.renderItem !== "function") {
74
- console.warn(
75
- `[Virtual] component must implement renderItem(item, index)`,
76
- );
77
- return originalRender();
78
- }
79
-
80
- const renderItem = host.renderItem.bind(host);
81
-
82
- const startIdx = computed(() =>
83
- Math.max(0, Math.floor(scrollTop() / itemHeight) - buffer),
84
- );
85
- const endIdx = computed(() =>
86
- Math.min(
87
- total - 1,
88
- Math.ceil((scrollTop() + viewHeight()) / itemHeight) + buffer,
89
- ),
90
- );
91
- const visibleItems = computed(() => {
92
- const result: Array<{ item: unknown; index: number }> = [];
93
- for (let i = startIdx(); i <= endIdx(); i++) {
94
- result.push({ item: items[i], index: i });
95
- }
96
- return result;
97
- });
98
- const offsetTop = computed(() => startIdx() * itemHeight);
99
- const offsetBottom = computed(
100
- () => (total - 1 - endIdx()) * itemHeight,
101
- );
102
-
103
- const outer = document.createElement("div");
104
- outer.setAttribute(
105
- "style",
106
- `height:${String(totalH)}px; position:relative;`,
107
- );
108
-
109
- const spacerTop = document.createElement("div");
110
- cleanups.push(
111
- effect(() => {
112
- spacerTop.setAttribute("style", `height:${String(offsetTop())}px;`);
113
- }),
114
- );
115
-
116
- const itemsSlot = document.createElement("div");
117
- cleanups.push(
118
- effect(() => {
119
- while (itemsSlot.firstChild)
120
- itemsSlot.removeChild(itemsSlot.firstChild);
121
- visibleItems().forEach(({ item, index }) => {
122
- const wrapper = document.createElement("div");
123
- wrapper.setAttribute(
124
- "style",
125
- `height:${String(itemHeight)}px; overflow:hidden;`,
126
- );
127
- const rendered = renderItem(item, index);
128
- if (rendered) {
129
- (Array.isArray(rendered)
130
- ? rendered.flat()
131
- : [rendered]
132
- ).forEach((n) => {
133
- wrapper.appendChild(n);
134
- });
135
- }
136
- itemsSlot.appendChild(wrapper);
137
- });
138
- }),
139
- );
140
-
141
- const spacerBottom = document.createElement("div");
142
- cleanups.push(
143
- effect(() => {
144
- spacerBottom.setAttribute(
145
- "style",
146
- `height:${String(offsetBottom())}px;`,
147
- );
148
- }),
149
- );
150
-
151
- outer.appendChild(spacerTop);
152
- outer.appendChild(itemsSlot);
153
- outer.appendChild(spacerBottom);
154
-
155
- return outer;
156
- },
157
- };
158
- }
159
- }
160
-
161
- export function Virtual(itemHeight: number, buffer = 3) {
162
- return createClassDecorator(new VirtualBehavior(itemHeight, buffer));
163
- }