@praxisjs/decorators 0.4.2 → 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.
Files changed (190) hide show
  1. package/CHANGELOG.md +43 -0
  2. package/dist/__tests__/component.test.d.ts +2 -0
  3. package/dist/__tests__/component.test.d.ts.map +1 -0
  4. package/dist/__tests__/component.test.js +146 -0
  5. package/dist/__tests__/component.test.js.map +1 -0
  6. package/dist/__tests__/compose.test.d.ts +2 -0
  7. package/dist/__tests__/compose.test.d.ts.map +1 -0
  8. package/dist/__tests__/compose.test.js +171 -0
  9. package/dist/__tests__/compose.test.js.map +1 -0
  10. package/dist/__tests__/events.test.d.ts +2 -0
  11. package/dist/__tests__/events.test.d.ts.map +1 -0
  12. package/dist/__tests__/events.test.js +216 -0
  13. package/dist/__tests__/events.test.js.map +1 -0
  14. package/dist/__tests__/functions.test.js +283 -31
  15. package/dist/__tests__/functions.test.js.map +1 -1
  16. package/dist/__tests__/lazy.test.d.ts +2 -0
  17. package/dist/__tests__/lazy.test.d.ts.map +1 -0
  18. package/dist/__tests__/lazy.test.js +163 -0
  19. package/dist/__tests__/lazy.test.js.map +1 -0
  20. package/dist/__tests__/memo.test.js +71 -22
  21. package/dist/__tests__/memo.test.js.map +1 -1
  22. package/dist/__tests__/properties.test.js +191 -1
  23. package/dist/__tests__/properties.test.js.map +1 -1
  24. package/dist/__tests__/resource.test.d.ts +2 -0
  25. package/dist/__tests__/resource.test.d.ts.map +1 -0
  26. package/dist/__tests__/resource.test.js +103 -0
  27. package/dist/__tests__/resource.test.js.map +1 -0
  28. package/dist/__tests__/slot.test.d.ts +2 -0
  29. package/dist/__tests__/slot.test.d.ts.map +1 -0
  30. package/dist/__tests__/slot.test.js +147 -0
  31. package/dist/__tests__/slot.test.js.map +1 -0
  32. package/dist/__tests__/virtual.test.d.ts +2 -0
  33. package/dist/__tests__/virtual.test.d.ts.map +1 -0
  34. package/dist/__tests__/virtual.test.js +242 -0
  35. package/dist/__tests__/virtual.test.js.map +1 -0
  36. package/dist/component/component.d.ts +1 -1
  37. package/dist/component/component.d.ts.map +1 -1
  38. package/dist/component/component.js +15 -10
  39. package/dist/component/component.js.map +1 -1
  40. package/dist/component/lazy.d.ts +1 -1
  41. package/dist/component/lazy.d.ts.map +1 -1
  42. package/dist/component/lazy.js +29 -27
  43. package/dist/component/lazy.js.map +1 -1
  44. package/dist/component/virtual.d.ts +1 -1
  45. package/dist/component/virtual.d.ts.map +1 -1
  46. package/dist/component/virtual.js +46 -50
  47. package/dist/component/virtual.js.map +1 -1
  48. package/dist/create-class-decorator.d.ts +16 -0
  49. package/dist/create-class-decorator.d.ts.map +1 -0
  50. package/dist/create-class-decorator.js +35 -0
  51. package/dist/create-class-decorator.js.map +1 -0
  52. package/dist/create-field-decorator.d.ts +14 -0
  53. package/dist/create-field-decorator.d.ts.map +1 -0
  54. package/dist/create-field-decorator.js +37 -0
  55. package/dist/create-field-decorator.js.map +1 -0
  56. package/dist/create-getter-decorator.d.ts +18 -0
  57. package/dist/create-getter-decorator.d.ts.map +1 -0
  58. package/dist/create-getter-decorator.js +15 -0
  59. package/dist/create-getter-decorator.js.map +1 -0
  60. package/dist/create-lifecycle-method-decorator.d.ts +6 -0
  61. package/dist/create-lifecycle-method-decorator.d.ts.map +1 -0
  62. package/dist/create-lifecycle-method-decorator.js +22 -0
  63. package/dist/create-lifecycle-method-decorator.js.map +1 -0
  64. package/dist/create-method-decorator.d.ts +6 -0
  65. package/dist/create-method-decorator.d.ts.map +1 -0
  66. package/dist/create-method-decorator.js +14 -0
  67. package/dist/create-method-decorator.js.map +1 -0
  68. package/dist/events/emit.d.ts +1 -1
  69. package/dist/events/emit.d.ts.map +1 -1
  70. package/dist/events/emit.js +24 -29
  71. package/dist/events/emit.js.map +1 -1
  72. package/dist/events/on-command.d.ts +1 -2
  73. package/dist/events/on-command.d.ts.map +1 -1
  74. package/dist/events/on-command.js +15 -32
  75. package/dist/events/on-command.js.map +1 -1
  76. package/dist/functions/bind.d.ts +1 -2
  77. package/dist/functions/bind.d.ts.map +1 -1
  78. package/dist/functions/bind.js +4 -11
  79. package/dist/functions/bind.js.map +1 -1
  80. package/dist/functions/debounce.d.ts +1 -2
  81. package/dist/functions/debounce.d.ts.map +1 -1
  82. package/dist/functions/debounce.js +12 -20
  83. package/dist/functions/debounce.js.map +1 -1
  84. package/dist/functions/log.d.ts +1 -2
  85. package/dist/functions/log.d.ts.map +1 -1
  86. package/dist/functions/log.js +40 -38
  87. package/dist/functions/log.js.map +1 -1
  88. package/dist/functions/memo.d.ts +1 -2
  89. package/dist/functions/memo.d.ts.map +1 -1
  90. package/dist/functions/memo.js +15 -27
  91. package/dist/functions/memo.js.map +1 -1
  92. package/dist/functions/once.d.ts +1 -2
  93. package/dist/functions/once.d.ts.map +1 -1
  94. package/dist/functions/once.js +14 -12
  95. package/dist/functions/once.js.map +1 -1
  96. package/dist/functions/retry.d.ts +1 -2
  97. package/dist/functions/retry.d.ts.map +1 -1
  98. package/dist/functions/retry.js +23 -20
  99. package/dist/functions/retry.js.map +1 -1
  100. package/dist/functions/throttle.d.ts +1 -2
  101. package/dist/functions/throttle.d.ts.map +1 -1
  102. package/dist/functions/throttle.js +9 -16
  103. package/dist/functions/throttle.js.map +1 -1
  104. package/dist/functions/watch.d.ts +1 -1
  105. package/dist/functions/watch.d.ts.map +1 -1
  106. package/dist/functions/watch.js +25 -31
  107. package/dist/functions/watch.js.map +1 -1
  108. package/dist/functions/when.d.ts +1 -2
  109. package/dist/functions/when.d.ts.map +1 -1
  110. package/dist/functions/when.js +10 -26
  111. package/dist/functions/when.js.map +1 -1
  112. package/dist/index.d.ts +6 -1
  113. package/dist/index.d.ts.map +1 -1
  114. package/dist/index.js +6 -1
  115. package/dist/index.js.map +1 -1
  116. package/dist/properties/compose.d.ts +3 -0
  117. package/dist/properties/compose.d.ts.map +1 -0
  118. package/dist/properties/compose.js +40 -0
  119. package/dist/properties/compose.js.map +1 -0
  120. package/dist/properties/computed.d.ts +1 -2
  121. package/dist/properties/computed.d.ts.map +1 -1
  122. package/dist/properties/computed.js +10 -9
  123. package/dist/properties/computed.js.map +1 -1
  124. package/dist/properties/history.d.ts.map +1 -1
  125. package/dist/properties/history.js +37 -32
  126. package/dist/properties/history.js.map +1 -1
  127. package/dist/properties/index.d.ts +2 -0
  128. package/dist/properties/index.d.ts.map +1 -1
  129. package/dist/properties/index.js +2 -0
  130. package/dist/properties/index.js.map +1 -1
  131. package/dist/properties/persisted.d.ts +1 -2
  132. package/dist/properties/persisted.d.ts.map +1 -1
  133. package/dist/properties/persisted.js +13 -29
  134. package/dist/properties/persisted.js.map +1 -1
  135. package/dist/properties/prop.d.ts +1 -2
  136. package/dist/properties/prop.d.ts.map +1 -1
  137. package/dist/properties/prop.js +21 -25
  138. package/dist/properties/prop.js.map +1 -1
  139. package/dist/properties/resource.d.ts +14 -0
  140. package/dist/properties/resource.d.ts.map +1 -0
  141. package/dist/properties/resource.js +27 -0
  142. package/dist/properties/resource.js.map +1 -0
  143. package/dist/properties/slot.d.ts +1 -2
  144. package/dist/properties/slot.d.ts.map +1 -1
  145. package/dist/properties/slot.js +15 -20
  146. package/dist/properties/slot.js.map +1 -1
  147. package/dist/properties/state.d.ts +1 -2
  148. package/dist/properties/state.d.ts.map +1 -1
  149. package/dist/properties/state.js +13 -18
  150. package/dist/properties/state.js.map +1 -1
  151. package/package.json +2 -2
  152. package/src/__tests__/component.test.ts +166 -0
  153. package/src/__tests__/compose.test.ts +202 -0
  154. package/src/__tests__/events.test.ts +268 -0
  155. package/src/__tests__/functions.test.ts +368 -30
  156. package/src/__tests__/lazy.test.ts +218 -0
  157. package/src/__tests__/memo.test.ts +89 -22
  158. package/src/__tests__/properties.test.ts +249 -1
  159. package/src/__tests__/resource.test.ts +113 -0
  160. package/src/__tests__/slot.test.ts +179 -0
  161. package/src/__tests__/virtual.test.ts +260 -0
  162. package/src/component/component.ts +27 -12
  163. package/src/component/lazy.ts +33 -35
  164. package/src/component/virtual.tsx +62 -75
  165. package/src/create-class-decorator.ts +61 -0
  166. package/src/create-field-decorator.ts +66 -0
  167. package/src/create-getter-decorator.ts +52 -0
  168. package/src/create-lifecycle-method-decorator.ts +38 -0
  169. package/src/create-method-decorator.ts +26 -0
  170. package/src/events/emit.ts +22 -33
  171. package/src/events/on-command.ts +22 -47
  172. package/src/functions/bind.ts +4 -15
  173. package/src/functions/debounce.ts +11 -26
  174. package/src/functions/log.ts +40 -43
  175. package/src/functions/memo.ts +15 -45
  176. package/src/functions/once.ts +13 -16
  177. package/src/functions/retry.ts +21 -22
  178. package/src/functions/throttle.ts +9 -23
  179. package/src/functions/watch.ts +27 -34
  180. package/src/functions/when.ts +11 -36
  181. package/src/index.ts +28 -0
  182. package/src/properties/compose.ts +53 -0
  183. package/src/properties/computed.ts +11 -13
  184. package/src/properties/history.ts +42 -41
  185. package/src/properties/index.ts +2 -0
  186. package/src/properties/persisted.ts +17 -58
  187. package/src/properties/prop.ts +21 -31
  188. package/src/properties/resource.ts +34 -0
  189. package/src/properties/slot.ts +17 -30
  190. package/src/properties/state.ts +14 -24
@@ -0,0 +1,113 @@
1
+ import { describe, it, expect, vi } from "vitest";
2
+
3
+ import { Resource } from "../properties/resource";
4
+
5
+ function makeFieldCtx(name: string) {
6
+ const initializers: Array<(this: unknown) => void> = [];
7
+ return {
8
+ ctx: {
9
+ name,
10
+ kind: "field" as const,
11
+ addInitializer(fn: (this: unknown) => void) { initializers.push(fn); },
12
+ } as ClassFieldDecoratorContext,
13
+ run(instance: unknown) { initializers.forEach((fn) => { fn.call(instance); }); },
14
+ };
15
+ }
16
+
17
+ describe("@Resource", () => {
18
+ it("binds a Resource object to the field", () => {
19
+ const { ctx, run } = makeFieldCtx("users");
20
+ Resource(() => Promise.resolve([]))(undefined, ctx);
21
+ const instance: Record<string, unknown> = {};
22
+ run(instance);
23
+ const r = instance.users as Record<string, unknown>;
24
+ expect(typeof r.data).toBe("function");
25
+ expect(typeof r.pending).toBe("function");
26
+ expect(typeof r.error).toBe("function");
27
+ expect(typeof r.status).toBe("function");
28
+ expect(typeof r.refetch).toBe("function");
29
+ expect(typeof r.cancel).toBe("function");
30
+ expect(typeof r.mutate).toBe("function");
31
+ });
32
+
33
+ it("starts in pending state when immediate=true (default)", () => {
34
+ const { ctx, run } = makeFieldCtx("data");
35
+ Resource(() => new Promise(() => {}))(undefined, ctx);
36
+ const instance: Record<string, unknown> = {};
37
+ run(instance);
38
+ const r = instance.data as { pending: () => boolean; status: () => string };
39
+ expect(r.pending()).toBe(true);
40
+ expect(r.status()).toBe("pending");
41
+ });
42
+
43
+ it("starts idle when immediate=false", () => {
44
+ const { ctx, run } = makeFieldCtx("data");
45
+ Resource(() => Promise.resolve(42), { immediate: false })(undefined, ctx);
46
+ const instance: Record<string, unknown> = {};
47
+ run(instance);
48
+ const r = instance.data as { status: () => string; pending: () => boolean };
49
+ expect(r.status()).toBe("idle");
50
+ expect(r.pending()).toBe(false);
51
+ });
52
+
53
+ it("resolves data asynchronously", async () => {
54
+ const { ctx, run } = makeFieldCtx("items");
55
+ Resource(() => Promise.resolve([1, 2, 3]))(undefined, ctx);
56
+ const instance: Record<string, unknown> = {};
57
+ run(instance);
58
+ const r = instance.items as { data: () => unknown; status: () => string };
59
+ await vi.waitFor(() => r.status() === "success");
60
+ expect(r.data()).toEqual([1, 2, 3]);
61
+ });
62
+
63
+ it("sets error state on rejection", async () => {
64
+ const { ctx, run } = makeFieldCtx("items");
65
+ Resource(() => Promise.reject(new Error("fetch failed")))(undefined, ctx);
66
+ const instance: Record<string, unknown> = {};
67
+ run(instance);
68
+ const r = instance.items as { error: () => Error | null; status: () => string };
69
+ await new Promise((res) => setTimeout(res, 0));
70
+ expect(r.status()).toBe("error");
71
+ expect((r.error() as Error).message).toBe("fetch failed");
72
+ });
73
+
74
+ it("each instance gets its own resource", () => {
75
+ const { ctx, run } = makeFieldCtx("r");
76
+ Resource(() => Promise.resolve(0))(undefined, ctx);
77
+ const a: Record<string, unknown> = {};
78
+ const b: Record<string, unknown> = {};
79
+ run(a);
80
+ run(b);
81
+ expect(a.r).not.toBe(b.r);
82
+ });
83
+
84
+ it("mutate() updates data immediately", async () => {
85
+ const { ctx, run } = makeFieldCtx("r");
86
+ Resource(() => Promise.resolve(1))(undefined, ctx);
87
+ const instance: Record<string, unknown> = {};
88
+ run(instance);
89
+ const r = instance.r as { mutate: (v: number) => void; data: () => number; status: () => string };
90
+ r.mutate(99);
91
+ expect(r.data()).toBe(99);
92
+ expect(r.status()).toBe("success");
93
+ });
94
+
95
+ it("cancel() stops a pending fetch", async () => {
96
+ const { ctx, run } = makeFieldCtx("r");
97
+ Resource(() => new Promise(() => {}))(undefined, ctx);
98
+ const instance: Record<string, unknown> = {};
99
+ run(instance);
100
+ const r = instance.r as { cancel: () => void; status: () => string };
101
+ r.cancel();
102
+ expect(r.status()).toBe("idle");
103
+ });
104
+
105
+ it("uses initialData option", () => {
106
+ const { ctx, run } = makeFieldCtx("r");
107
+ Resource(() => Promise.resolve("new"), { initialData: "default", immediate: false })(undefined, ctx);
108
+ const instance: Record<string, unknown> = {};
109
+ run(instance);
110
+ const r = instance.r as { data: () => string };
111
+ expect(r.data()).toBe("default");
112
+ });
113
+ });
@@ -0,0 +1,179 @@
1
+ // @vitest-environment jsdom
2
+ import { describe, it, expect, vi } from "vitest";
3
+
4
+ import { initSlots, getSlot, Slot } from "../properties/slot";
5
+ import { StatefulComponent } from "@praxisjs/core";
6
+
7
+ // ── initSlots / getSlot ──────────────────────────────────────────────────────
8
+
9
+ describe("initSlots / getSlot", () => {
10
+ it("returns empty default slot when children is null", () => {
11
+ const instance = {};
12
+ initSlots(instance, null);
13
+ expect(getSlot(instance, "default")).toEqual([]);
14
+ });
15
+
16
+ it("returns empty array for unknown slot", () => {
17
+ const instance = {};
18
+ initSlots(instance, null);
19
+ expect(getSlot(instance, "unknown")).toEqual([]);
20
+ });
21
+
22
+ it("puts plain children into the default slot", () => {
23
+ const instance = {};
24
+ const child = document.createElement("span");
25
+ initSlots(instance, child);
26
+ expect(getSlot(instance, "default")).toEqual([child]);
27
+ });
28
+
29
+ it("flattens array children into the default slot", () => {
30
+ const instance = {};
31
+ const a = document.createElement("p");
32
+ const b = document.createElement("div");
33
+ initSlots(instance, [a, b]);
34
+ expect(getSlot(instance, "default")).toEqual([a, b]);
35
+ });
36
+
37
+ it("routes element with slot attribute to named slot", () => {
38
+ const instance = {};
39
+ const el = document.createElement("div");
40
+ el.setAttribute("slot", "header");
41
+ initSlots(instance, el);
42
+ expect(getSlot(instance, "header")).toEqual([el]);
43
+ expect(getSlot(instance, "default")).toEqual([]);
44
+ // slot attribute should be removed
45
+ expect(el.hasAttribute("slot")).toBe(false);
46
+ });
47
+
48
+ it("handles mixed named and default children", () => {
49
+ const instance = {};
50
+ const named = document.createElement("aside");
51
+ named.setAttribute("slot", "sidebar");
52
+ const plain = document.createElement("main");
53
+ initSlots(instance, [named, plain]);
54
+ expect(getSlot(instance, "sidebar")).toEqual([named]);
55
+ expect(getSlot(instance, "default")).toEqual([plain]);
56
+ });
57
+
58
+ it("skips null children in array", () => {
59
+ const instance = {};
60
+ const el = document.createElement("span");
61
+ initSlots(instance, [null, el, undefined]);
62
+ expect(getSlot(instance, "default")).toEqual([el]);
63
+ });
64
+
65
+ it("appends to existing named slot when same slot name appears twice", () => {
66
+ const instance = {};
67
+ const a = document.createElement("div");
68
+ a.setAttribute("slot", "header");
69
+ const b = document.createElement("div");
70
+ b.setAttribute("slot", "header");
71
+ initSlots(instance, [a, b]);
72
+ const headerSlot = getSlot(instance, "header");
73
+ expect(headerSlot).toHaveLength(2);
74
+ expect(headerSlot[0]).toBe(a);
75
+ expect(headerSlot[1]).toBe(b);
76
+ });
77
+ });
78
+
79
+ // ── Slot decorator ───────────────────────────────────────────────────────────
80
+
81
+ describe("Slot decorator", () => {
82
+ it("provides the default slot via field decorator", () => {
83
+ class MyComp extends StatefulComponent {
84
+ render() { return null; }
85
+ }
86
+
87
+ const initializers: Array<(this: unknown) => void> = [];
88
+ const ctx = {
89
+ name: "default",
90
+ kind: "field" as const,
91
+ addInitializer(fn: (this: unknown) => void) { initializers.push(fn); },
92
+ } as ClassFieldDecoratorContext<StatefulComponent>;
93
+
94
+ Slot()(undefined, ctx);
95
+
96
+ const instance = new MyComp() as unknown as Record<string, unknown>;
97
+ initializers.forEach((fn) => { fn.call(instance); });
98
+
99
+ // No children initialized — slot should be empty
100
+ const children = document.createElement("p");
101
+ initSlots(instance as object, children);
102
+
103
+ expect(Array.isArray((instance as Record<string, unknown>).default)).toBe(true);
104
+ });
105
+
106
+ it("uses custom slot name when provided", () => {
107
+ class MyComp extends StatefulComponent {
108
+ render() { return null; }
109
+ }
110
+
111
+ const initializers: Array<(this: unknown) => void> = [];
112
+ const ctx = {
113
+ name: "content",
114
+ kind: "field" as const,
115
+ addInitializer(fn: (this: unknown) => void) { initializers.push(fn); },
116
+ } as ClassFieldDecoratorContext<StatefulComponent>;
117
+
118
+ Slot("header")(undefined, ctx);
119
+
120
+ const instance = new MyComp() as unknown as Record<string, unknown>;
121
+ initializers.forEach((fn) => { fn.call(instance); });
122
+
123
+ const el = document.createElement("nav");
124
+ el.setAttribute("slot", "header");
125
+ initSlots(instance as object, el);
126
+
127
+ const slot = (instance as Record<string, unknown>).content as unknown[];
128
+ expect(slot).toHaveLength(1);
129
+ expect(slot[0]).toBe(el);
130
+ });
131
+
132
+ it("skips warn on direct assignment in production mode", () => {
133
+ class MyComp extends StatefulComponent {
134
+ render() { return null; }
135
+ }
136
+
137
+ const initializers: Array<(this: unknown) => void> = [];
138
+ const ctx = {
139
+ name: "default",
140
+ kind: "field" as const,
141
+ addInitializer(fn: (this: unknown) => void) { initializers.push(fn); },
142
+ } as ClassFieldDecoratorContext<StatefulComponent>;
143
+
144
+ Slot()(undefined, ctx);
145
+
146
+ const instance = new MyComp() as unknown as Record<string, unknown>;
147
+ initializers.forEach((fn) => { fn.call(instance); });
148
+
149
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
150
+ vi.stubEnv("NODE_ENV", "production");
151
+ (instance as Record<string, unknown>).default = "bad";
152
+ expect(warn).not.toHaveBeenCalled();
153
+ vi.unstubAllEnvs();
154
+ warn.mockRestore();
155
+ });
156
+
157
+ it("warns on direct assignment in non-production", () => {
158
+ class MyComp extends StatefulComponent {
159
+ render() { return null; }
160
+ }
161
+
162
+ const initializers: Array<(this: unknown) => void> = [];
163
+ const ctx = {
164
+ name: "default",
165
+ kind: "field" as const,
166
+ addInitializer(fn: (this: unknown) => void) { initializers.push(fn); },
167
+ } as ClassFieldDecoratorContext<StatefulComponent>;
168
+
169
+ Slot()(undefined, ctx);
170
+
171
+ const instance = new MyComp() as unknown as Record<string, unknown>;
172
+ initializers.forEach((fn) => { fn.call(instance); });
173
+
174
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
175
+ (instance as Record<string, unknown>).default = "bad";
176
+ expect(warn).toHaveBeenCalledWith(expect.stringContaining("[Slot]"));
177
+ warn.mockRestore();
178
+ });
179
+ });
@@ -0,0 +1,260 @@
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)", () => {
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
+
168
+ // buffer=0, itemHeight=50, scrollTop=500 → startIdx=10 → offsetTop=500px
169
+ const spacerTop = outer.children[0] as HTMLElement;
170
+ expect(spacerTop.getAttribute("style")).toContain("500px");
171
+
172
+ document.body.removeChild(container);
173
+ });
174
+
175
+ it("renderItem returning an array appends all child nodes", () => {
176
+ class ArrayItemList extends StatefulComponent {
177
+ items = ["item"];
178
+ renderItem(item: unknown) {
179
+ const a = document.createElement("span");
180
+ a.textContent = String(item) + "-1";
181
+ const b = document.createElement("span");
182
+ b.textContent = String(item) + "-2";
183
+ return [a, b];
184
+ }
185
+ render() { return null; }
186
+ }
187
+
188
+ const Wrapped = applyVirtual(ArrayItemList as AnyConstructor, 50, 0);
189
+ const instance = new Wrapped();
190
+ const outer = instance.render() as HTMLElement;
191
+ // outer structure: spacerTop, itemsSlot, spacerBottom
192
+ const itemsSlot = outer.children[1] as HTMLElement;
193
+ const wrapper = itemsSlot.children[0] as HTMLElement;
194
+ expect(wrapper.children.length).toBe(2);
195
+ });
196
+
197
+ it("renderItem returning null produces an empty wrapper div", () => {
198
+ class NullItemList extends StatefulComponent {
199
+ items = ["x"];
200
+ renderItem() { return null; }
201
+ render() { return null; }
202
+ }
203
+
204
+ const Wrapped = applyVirtual(NullItemList as AnyConstructor, 50, 0);
205
+ const instance = new Wrapped();
206
+ expect(() => instance.render()).not.toThrow();
207
+ const outer = instance.render() as HTMLElement;
208
+ const itemsSlot = outer.children[1] as HTMLElement;
209
+ const wrapper = itemsSlot.children[0] as HTMLElement;
210
+ // rendered is null — no child nodes appended
211
+ expect(wrapper.children.length).toBe(0);
212
+ });
213
+
214
+ it("uses empty array when items is undefined (host.items ?? [])", () => {
215
+ class NoItems extends StatefulComponent {
216
+ // items is intentionally not defined
217
+ renderItem() { return document.createElement("div"); }
218
+ render() { return null; }
219
+ }
220
+
221
+ const Wrapped = applyVirtual(NoItems as AnyConstructor, 50, 0);
222
+ const instance = new Wrapped();
223
+ // render() should work fine with 0 items
224
+ const result = instance.render() as HTMLElement;
225
+ expect(result).toBeInstanceOf(HTMLElement);
226
+ const style = result.getAttribute("style") ?? "";
227
+ expect(style).toContain("0px"); // totalH = 0 * 50 = 0
228
+ });
229
+
230
+ it("onUnmount without prior onMount does not throw", () => {
231
+ const Wrapped = applyVirtual(ListComp as AnyConstructor, 50);
232
+ const instance = new Wrapped();
233
+ // _container is undefined — onUnmount should be a no-op
234
+ expect(() => instance.onUnmount?.()).not.toThrow();
235
+ });
236
+
237
+ it("updates scrollTop on scroll event", () => {
238
+ const Wrapped = applyVirtual(ListComp as AnyConstructor, 50);
239
+ const instance = new Wrapped();
240
+ (instance as unknown as { items: unknown[] }).items = Array.from({ length: 20 }, (_, i) => i);
241
+
242
+ const container = document.createElement("div");
243
+ document.body.appendChild(container);
244
+ const anchor = document.createComment("end");
245
+ container.appendChild(anchor);
246
+ (instance as unknown as { _anchor: Comment })._anchor = anchor;
247
+
248
+ instance.onMount?.();
249
+ // Render to set up effects that observe scrollTop
250
+ const outer = instance.render() as HTMLElement;
251
+
252
+ Object.defineProperty(container, "scrollTop", { configurable: true, value: 200 });
253
+ container.dispatchEvent(new Event("scroll"));
254
+
255
+ // buffer=3, itemHeight=50, scrollTop=200 → startIdx=1 → offsetTop=50px
256
+ const spacerTop = outer.children[0] as HTMLElement;
257
+ expect(spacerTop.getAttribute("style")).toContain("50px");
258
+ document.body.removeChild(container);
259
+ });
260
+ });
@@ -1,25 +1,40 @@
1
1
  import { StatelessComponent } from "@praxisjs/core";
2
+ import type { RootComponent } from "@praxisjs/core/internal";
2
3
  import type {
3
4
  ComponentConstructor,
4
5
  ComponentInstance,
5
6
  } from "@praxisjs/shared/internal";
6
7
 
8
+ import {
9
+ ClassBehavior,
10
+ createClassDecorator,
11
+ type ClassEnhancement,
12
+ } from "../create-class-decorator";
13
+
14
+ class ComponentBehavior extends ClassBehavior {
15
+ create(_instance: RootComponent): ClassEnhancement {
16
+ return {};
17
+ }
18
+
19
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
20
+ initialize(Enhanced: new (...args: any[]) => unknown, original: new (...args: any[]) => unknown): void {
21
+ const isStateless = (original.prototype as object) instanceof StatelessComponent;
22
+ Object.defineProperty(Enhanced, "__isComponent", { value: true, configurable: true });
23
+ Object.defineProperty(Enhanced, "__isStateless", { value: isStateless, configurable: true });
24
+ }
25
+ }
26
+
27
+ const behavior = new ComponentBehavior();
28
+
7
29
  export function Component() {
8
30
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
9
31
  return function <T extends new (...args: any[]) => ComponentInstance>(
10
32
  constructor: T,
11
- _context: ClassDecoratorContext,
33
+ context: ClassDecoratorContext,
12
34
  ): T & ComponentConstructor {
13
- const isStateless = constructor.prototype instanceof StatelessComponent;
14
- const Enhanced = class extends constructor {
15
- static __isComponent = true as const;
16
- static __isStateless = isStateless;
17
- } as unknown as T & ComponentConstructor;
18
-
19
- Object.defineProperty(Enhanced, "name", {
20
- value: constructor.name,
21
- });
22
-
23
- return Enhanced;
35
+ return createClassDecorator(behavior)(
36
+ constructor as unknown as T & (new(...args: unknown[]) => RootComponent<Record<string, unknown>>),
37
+ context,
38
+ ) as unknown as T & ComponentConstructor;
24
39
  };
25
40
  }