@praxisjs/composables 1.0.6 → 1.1.1

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.
@@ -1,8 +1,6 @@
1
1
  // @vitest-environment jsdom
2
2
  import { describe, it, expect, vi, beforeEach } from "vitest";
3
3
 
4
- import { signal } from "@praxisjs/core/internal";
5
-
6
4
  import { WindowSize, ScrollPosition, ElementSize, Intersection, Focus } from "../dom";
7
5
 
8
6
  // ── WindowSize ────────────────────────────────────────────────────────────────
@@ -55,6 +53,7 @@ describe("ScrollPosition", () => {
55
53
  it("updates on scroll event for window", () => {
56
54
  const sp = new ScrollPosition(window);
57
55
  const { x, y } = sp.setup() as { x: () => number; y: () => number };
56
+ sp.onMount();
58
57
 
59
58
  Object.defineProperty(window, "scrollX", { value: 100, configurable: true });
60
59
  Object.defineProperty(window, "scrollY", { value: 200, configurable: true });
@@ -68,6 +67,7 @@ describe("ScrollPosition", () => {
68
67
  const el = document.createElement("div");
69
68
  const sp = new ScrollPosition(el);
70
69
  const { x, y } = sp.setup() as { x: () => number; y: () => number };
70
+ sp.onMount();
71
71
 
72
72
  Object.defineProperty(el, "scrollLeft", { value: 50, configurable: true });
73
73
  Object.defineProperty(el, "scrollTop", { value: 75, configurable: true });
@@ -82,6 +82,7 @@ describe("ScrollPosition", () => {
82
82
  const remove = vi.spyOn(el, "removeEventListener");
83
83
  const sp = new ScrollPosition(el);
84
84
  sp.setup();
85
+ sp.onMount();
85
86
  sp.onUnmount();
86
87
  expect(remove).toHaveBeenCalledWith("scroll", expect.any(Function));
87
88
  });
@@ -137,6 +138,13 @@ describe("ElementSize", () => {
137
138
  es.onUnmount();
138
139
  expect(disconnect).toHaveBeenCalled();
139
140
  });
141
+
142
+ it("onMount() with null ref is a no-op", () => {
143
+ const ref = { current: null };
144
+ const es = new ElementSize(ref);
145
+ es.setup();
146
+ expect(() => es.onMount()).not.toThrow();
147
+ });
140
148
  });
141
149
 
142
150
  // ── Intersection ──────────────────────────────────────────────────────────────
@@ -187,6 +195,38 @@ describe("Intersection", () => {
187
195
  int.onUnmount();
188
196
  expect(disconnect).toHaveBeenCalled();
189
197
  });
198
+
199
+ it("onMount() calls observe() with the element", () => {
200
+ const observeFn = vi.fn();
201
+ vi.stubGlobal("IntersectionObserver", class {
202
+ constructor(cb: IntersectionObserverCallback) { observerCallback = cb; }
203
+ observe = observeFn;
204
+ disconnect = vi.fn();
205
+ });
206
+
207
+ const el = document.createElement("div");
208
+ const ref = { current: el };
209
+ const int = new Intersection(ref);
210
+ int.setup();
211
+ int.onMount();
212
+
213
+ expect(observeFn).toHaveBeenCalledWith(el);
214
+ });
215
+
216
+ it("onMount() does nothing when ref is null", () => {
217
+ const observeFn = vi.fn();
218
+ vi.stubGlobal("IntersectionObserver", class {
219
+ constructor(cb: IntersectionObserverCallback) { observerCallback = cb; }
220
+ observe = observeFn;
221
+ disconnect = vi.fn();
222
+ });
223
+
224
+ const ref = { current: null };
225
+ const int = new Intersection(ref);
226
+ int.setup();
227
+ expect(() => int.onMount()).not.toThrow();
228
+ expect(observeFn).not.toHaveBeenCalled();
229
+ });
190
230
  });
191
231
 
192
232
  // ── ElementSize (additional) ──────────────────────────────────────────────────
@@ -205,6 +245,7 @@ describe("ElementSize (additional)", () => {
205
245
  const ref = { current: el1 as HTMLElement | null };
206
246
  const es = new ElementSize(ref);
207
247
  es.setup();
248
+ es.onMount();
208
249
 
209
250
  // Disconnect old observer and create new one for el2
210
251
  es.onUnmount();
@@ -214,6 +255,7 @@ describe("ElementSize (additional)", () => {
214
255
  ref.current = el2;
215
256
  const es2 = new ElementSize(ref);
216
257
  es2.setup();
258
+ es2.onMount();
217
259
  expect(observeFn).toHaveBeenCalledWith(el2);
218
260
  });
219
261
  });
@@ -251,15 +293,56 @@ describe("Intersection (additional)", () => {
251
293
  });
252
294
  });
253
295
 
296
+ // ── ScrollPosition (ref-based) ────────────────────────────────────────────────
297
+
298
+ describe("ScrollPosition (ref-based)", () => {
299
+ it("accepts a ref object and reads scrollLeft/scrollTop from it", () => {
300
+ const el = document.createElement("div");
301
+ const ref = { current: el };
302
+ const sp = new ScrollPosition(ref);
303
+ const { x, y } = sp.setup() as { x: () => number; y: () => number };
304
+ sp.onMount();
305
+
306
+ Object.defineProperty(el, "scrollLeft", { value: 30, configurable: true });
307
+ Object.defineProperty(el, "scrollTop", { value: 60, configurable: true });
308
+ el.dispatchEvent(new Event("scroll"));
309
+
310
+ expect(x()).toBe(30);
311
+ expect(y()).toBe(60);
312
+ });
313
+
314
+ it("uses ref.current at mount time — subsequent current changes do not affect the listener target", () => {
315
+ const el = document.createElement("div");
316
+ const ref = { current: el as HTMLElement | null };
317
+ const sp = new ScrollPosition(ref);
318
+ sp.setup();
319
+ sp.onMount();
320
+
321
+ const remove = vi.spyOn(el, "removeEventListener");
322
+ sp.onUnmount();
323
+ expect(remove).toHaveBeenCalledWith("scroll", expect.any(Function));
324
+ });
325
+
326
+ it("falls back to window when ref.current is null at mount time", () => {
327
+ const ref = { current: null as HTMLElement | null };
328
+ const sp = new ScrollPosition(ref);
329
+ sp.setup();
330
+ // Should not throw; _target falls back to window
331
+ expect(() => sp.onMount()).not.toThrow();
332
+ sp.onUnmount();
333
+ });
334
+ });
335
+
254
336
  // ── ScrollPosition (additional) ───────────────────────────────────────────────
255
337
 
256
338
  describe("ScrollPosition (additional)", () => {
257
- it("setup() called twice registers only one scroll listener", () => {
339
+ it("onMount() called twice registers only one scroll listener", () => {
258
340
  const el = document.createElement("div");
259
341
  const add = vi.spyOn(el, "addEventListener");
260
342
  const sp = new ScrollPosition(el);
261
343
  sp.setup();
262
- sp.setup();
344
+ sp.onMount();
345
+ sp.onMount(); // second call must be a no-op
263
346
  const scrollCalls = add.mock.calls.filter((c) => c[0] === "scroll").length;
264
347
  expect(scrollCalls).toBe(1);
265
348
  });
@@ -282,6 +365,7 @@ describe("Focus", () => {
282
365
  const ref = { current: el };
283
366
  const focus = new Focus(ref);
284
367
  const { focused } = focus.setup() as { focused: () => boolean };
368
+ focus.onMount();
285
369
 
286
370
  el.dispatchEvent(new Event("focus"));
287
371
  expect(focused()).toBe(true);
@@ -295,6 +379,7 @@ describe("Focus", () => {
295
379
  const ref = { current: el };
296
380
  const focus = new Focus(ref);
297
381
  const { focused } = focus.setup() as { focused: () => boolean };
382
+ focus.onMount();
298
383
 
299
384
  el.dispatchEvent(new Event("focus"));
300
385
  el.dispatchEvent(new Event("blur"));
@@ -309,40 +394,46 @@ describe("Focus", () => {
309
394
  expect(() => focus.setup()).not.toThrow();
310
395
  });
311
396
 
312
- it("removes focus/blur listeners when reactive ref changes (effect cleanup)", () => {
313
- const el1 = document.createElement("input");
314
- document.body.appendChild(el1);
315
-
316
- // Make ref.current reactive so the effect tracks it and re-runs on change
317
- const refSignal = signal<HTMLElement | null>(el1);
318
- const ref = { get current() { return refSignal(); } };
397
+ it("onMount() with null ref is a no-op", () => {
398
+ const ref = { current: null };
399
+ const focus = new Focus(ref);
400
+ focus.setup();
401
+ expect(() => focus.onMount()).not.toThrow();
402
+ });
319
403
 
404
+ it("onUnmount() with null ref is a no-op", () => {
405
+ const ref = { current: null };
320
406
  const focus = new Focus(ref);
321
407
  focus.setup();
408
+ expect(() => focus.onUnmount()).not.toThrow();
409
+ });
322
410
 
323
- const removeEl1 = vi.spyOn(el1, "removeEventListener");
411
+ it("removes focus/blur listeners on unmount", () => {
412
+ const el = document.createElement("input");
413
+ document.body.appendChild(el);
414
+ const ref = { current: el };
415
+ const focus = new Focus(ref);
416
+ focus.setup();
417
+ focus.onMount();
324
418
 
325
- // Changing the signal causes the effect to re-run, calling cleanup for el1
326
- // This covers the cleanup function at dom.ts:134-135
327
- const el2 = document.createElement("input");
328
- document.body.appendChild(el2);
329
- refSignal.set(el2);
419
+ const remove = vi.spyOn(el, "removeEventListener");
420
+ focus.onUnmount();
330
421
 
331
- expect(removeEl1).toHaveBeenCalledWith("focus", expect.any(Function));
332
- expect(removeEl1).toHaveBeenCalledWith("blur", expect.any(Function));
422
+ expect(remove).toHaveBeenCalledWith("focus", expect.any(Function));
423
+ expect(remove).toHaveBeenCalledWith("blur", expect.any(Function));
333
424
 
334
- document.body.removeChild(el1);
335
- document.body.removeChild(el2);
425
+ document.body.removeChild(el);
336
426
  });
337
427
 
338
- it("setup() called twice registers only one listener pair", () => {
428
+ it("onMount() called twice registers only one listener pair", () => {
339
429
  const el = document.createElement("input");
340
430
  document.body.appendChild(el);
341
431
  const add = vi.spyOn(el, "addEventListener");
342
432
  const ref = { current: el };
343
433
  const focus = new Focus(ref);
344
434
  focus.setup();
345
- focus.setup();
435
+ focus.onMount();
436
+ focus.onMount(); // second call must be a no-op
346
437
  const focusCalls = add.mock.calls.filter((c) => c[0] === "focus").length;
347
438
  expect(focusCalls).toBe(1);
348
439
  document.body.removeChild(el);
@@ -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, effect } from "@praxisjs/core/internal";
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 _target: HTMLElement | Window;
34
- // eslint-disable-next-line @typescript-eslint/no-empty-function
35
- private _handler = () => {};
36
- private _view?: Record<string, unknown>;
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
- this._target = target;
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
- if (this._view) return this._view;
45
- const x = signal(0);
46
- const y = signal(0);
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
- x.set(t === window ? window.scrollX : (t as HTMLElement).scrollLeft);
50
- y.set(t === window ? window.scrollY : (t as HTMLElement).scrollTop);
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
- this._view = { x, y };
54
- return this._view;
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
- const width = signal(0);
76
- const height = signal(0);
94
+ this._width = signal(0);
95
+ this._height = signal(0);
77
96
  this._observer = new ResizeObserver(([entry]) => {
78
- width.set(entry.contentRect.width);
79
- height.set(entry.contentRect.height);
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
- const visible = signal(false);
133
+ this._visible = signal(false);
113
134
  this._observer = new IntersectionObserver(([entry]) => {
114
- visible.set(entry.isIntersecting);
135
+ this._visible.set(entry.isIntersecting);
115
136
  }, this.options);
116
- effect(() => {
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 _view?: Record<string, unknown>;
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
- if (this._view) return this._view;
139
- const focused = signal(false);
140
- effect(() => {
141
- const el = this.ref.current;
142
- if (!el) return;
143
- const onFocus = () => {
144
- focused.set(true);
145
- };
146
- const onBlur = () => {
147
- focused.set(false);
148
- };
149
- el.addEventListener("focus", onFocus);
150
- el.addEventListener("blur", onBlur);
151
- return () => {
152
- el.removeEventListener("focus", onFocus);
153
- el.removeEventListener("blur", onBlur);
154
- };
155
- });
156
- this._view = { focused };
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
@@ -13,3 +13,4 @@ export {
13
13
  Pagination,
14
14
  type PaginationOptions,
15
15
  } from "./utilities";
16
+ export { VirtualList, type VirtualItem } from "./list";