@praxisjs/composables 1.0.0 → 1.0.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.
@@ -189,6 +189,82 @@ describe("Intersection", () => {
189
189
  });
190
190
  });
191
191
 
192
+ // ── ElementSize (additional) ──────────────────────────────────────────────────
193
+
194
+ describe("ElementSize (additional)", () => {
195
+ it("ref.current changes to a new element: old observer is disconnected and new one started", () => {
196
+ const disconnectFn = vi.fn();
197
+ const observeFn = vi.fn();
198
+
199
+ vi.stubGlobal("ResizeObserver", class {
200
+ observe = observeFn;
201
+ disconnect = disconnectFn;
202
+ });
203
+
204
+ const el1 = document.createElement("div");
205
+ const ref = { current: el1 as HTMLElement | null };
206
+ const es = new ElementSize(ref);
207
+ es.setup();
208
+
209
+ // Disconnect old observer and create new one for el2
210
+ es.onUnmount();
211
+ expect(disconnectFn).toHaveBeenCalled();
212
+
213
+ const el2 = document.createElement("div");
214
+ ref.current = el2;
215
+ const es2 = new ElementSize(ref);
216
+ es2.setup();
217
+ expect(observeFn).toHaveBeenCalledWith(el2);
218
+ });
219
+ });
220
+
221
+ // ── Intersection (additional) ─────────────────────────────────────────────────
222
+
223
+ describe("Intersection (additional)", () => {
224
+ let observerCallback: IntersectionObserverCallback;
225
+
226
+ beforeEach(() => {
227
+ vi.stubGlobal("IntersectionObserver", class {
228
+ constructor(cb: IntersectionObserverCallback) { observerCallback = cb; }
229
+ observe = vi.fn();
230
+ disconnect = vi.fn();
231
+ });
232
+ });
233
+
234
+ it("visibility toggles false after being true", () => {
235
+ const el = document.createElement("div");
236
+ const ref = { current: el };
237
+ const int = new Intersection(ref);
238
+ const { visible } = int.setup() as { visible: () => boolean };
239
+
240
+ observerCallback(
241
+ [{ isIntersecting: true } as IntersectionObserverEntry],
242
+ {} as IntersectionObserver,
243
+ );
244
+ expect(visible()).toBe(true);
245
+
246
+ observerCallback(
247
+ [{ isIntersecting: false } as IntersectionObserverEntry],
248
+ {} as IntersectionObserver,
249
+ );
250
+ expect(visible()).toBe(false);
251
+ });
252
+ });
253
+
254
+ // ── ScrollPosition (additional) ───────────────────────────────────────────────
255
+
256
+ describe("ScrollPosition (additional)", () => {
257
+ it("setup() called twice registers only one scroll listener", () => {
258
+ const el = document.createElement("div");
259
+ const add = vi.spyOn(el, "addEventListener");
260
+ const sp = new ScrollPosition(el);
261
+ sp.setup();
262
+ sp.setup();
263
+ const scrollCalls = add.mock.calls.filter((c) => c[0] === "scroll").length;
264
+ expect(scrollCalls).toBe(1);
265
+ });
266
+ });
267
+
192
268
  // ── Focus ─────────────────────────────────────────────────────────────────────
193
269
 
194
270
  describe("Focus", () => {
@@ -258,4 +334,72 @@ describe("Focus", () => {
258
334
  document.body.removeChild(el1);
259
335
  document.body.removeChild(el2);
260
336
  });
337
+
338
+ it("setup() called twice registers only one listener pair", () => {
339
+ const el = document.createElement("input");
340
+ document.body.appendChild(el);
341
+ const add = vi.spyOn(el, "addEventListener");
342
+ const ref = { current: el };
343
+ const focus = new Focus(ref);
344
+ focus.setup();
345
+ focus.setup();
346
+ const focusCalls = add.mock.calls.filter((c) => c[0] === "focus").length;
347
+ expect(focusCalls).toBe(1);
348
+ document.body.removeChild(el);
349
+ });
350
+ });
351
+
352
+ // ── WindowSize (additional) ───────────────────────────────────────────────────
353
+
354
+ describe("WindowSize (additional)", () => {
355
+ it("setup() called twice returns the same view object", () => {
356
+ const ws = new WindowSize();
357
+ const view1 = ws.setup();
358
+ const view2 = ws.setup();
359
+ expect(view1).toBe(view2);
360
+ ws.onUnmount();
361
+ });
362
+
363
+ it("setup() called twice registers only one resize listener", () => {
364
+ const add = vi.spyOn(window, "addEventListener");
365
+ const ws = new WindowSize();
366
+ ws.setup();
367
+ ws.setup();
368
+ const resizeCalls = add.mock.calls.filter((c) => c[0] === "resize").length;
369
+ expect(resizeCalls).toBe(1);
370
+ ws.onUnmount();
371
+ vi.restoreAllMocks();
372
+ });
373
+ });
374
+
375
+ // ── ElementSize (cache) ───────────────────────────────────────────────────────
376
+
377
+ describe("ElementSize (cache)", () => {
378
+ it("setup() called twice returns the same view object", () => {
379
+ const el = document.createElement("div");
380
+ document.body.appendChild(el);
381
+ const ref = { current: el };
382
+ const es = new ElementSize(ref);
383
+ const view1 = es.setup();
384
+ const view2 = es.setup();
385
+ expect(view1).toBe(view2);
386
+ es.onUnmount();
387
+ document.body.removeChild(el);
388
+ });
389
+ });
390
+
391
+ // ── Intersection (cache) ──────────────────────────────────────────────────────
392
+
393
+ describe("Intersection (cache)", () => {
394
+ it("setup() called twice returns the same view object", () => {
395
+ const el = document.createElement("div");
396
+ document.body.appendChild(el);
397
+ const ref = { current: el };
398
+ const inter = new Intersection(ref);
399
+ const view1 = inter.setup();
400
+ const view2 = inter.setup();
401
+ expect(view1).toBe(view2);
402
+ inter.onUnmount();
403
+ document.body.removeChild(el);
404
+ });
261
405
  });
@@ -56,6 +56,17 @@ describe("Clipboard", () => {
56
56
  expect(warn).toHaveBeenCalled();
57
57
  warn.mockRestore();
58
58
  });
59
+
60
+ it("setTimeout is cleared on onUnmount", async () => {
61
+ vi.useFakeTimers();
62
+ const clearTimeoutSpy = vi.spyOn(globalThis, "clearTimeout");
63
+ const cb = new Clipboard(1000);
64
+ const { copy } = cb.setup() as { copy: (t: string) => Promise<void> };
65
+ await copy("hello");
66
+ cb.onUnmount();
67
+ expect(clearTimeoutSpy).toHaveBeenCalled();
68
+ vi.useRealTimers();
69
+ });
59
70
  });
60
71
 
61
72
  // ── Geolocation ───────────────────────────────────────────────────────────────
@@ -111,6 +122,42 @@ describe("Geolocation", () => {
111
122
  expect(loading()).toBe(false);
112
123
  expect(error()).toBe(mockError);
113
124
  });
125
+
126
+ it("success callback after unmount does not update state", () => {
127
+ let capturedSuccess: PositionCallback | undefined;
128
+ mockGeolocation((success) => { capturedSuccess = success; });
129
+
130
+ const geo = new Geolocation();
131
+ const { lat, lng, loading } = geo.setup() as {
132
+ lat: () => number | null;
133
+ lng: () => number | null;
134
+ loading: () => boolean;
135
+ };
136
+
137
+ geo.onUnmount();
138
+ capturedSuccess!({ coords: { latitude: 99, longitude: 99 } } as GeolocationPosition);
139
+
140
+ expect(lat()).toBeNull();
141
+ expect(lng()).toBeNull();
142
+ expect(loading()).toBe(true);
143
+ });
144
+
145
+ it("error callback after unmount does not update state", () => {
146
+ let capturedError: PositionErrorCallback | undefined;
147
+ mockGeolocation((_success, error) => { capturedError = error ?? undefined; });
148
+
149
+ const geo = new Geolocation();
150
+ const { error, loading } = geo.setup() as {
151
+ error: () => GeolocationPositionError | null;
152
+ loading: () => boolean;
153
+ };
154
+
155
+ geo.onUnmount();
156
+ capturedError!({ code: 1, message: "denied" } as GeolocationPositionError);
157
+
158
+ expect(error()).toBeNull();
159
+ expect(loading()).toBe(true);
160
+ });
114
161
  });
115
162
 
116
163
  // ── TimeAgo ───────────────────────────────────────────────────────────────────
@@ -165,6 +212,13 @@ describe("TimeAgo", () => {
165
212
  ta.onUnmount();
166
213
  expect(clearInterval).toHaveBeenCalled();
167
214
  });
215
+
216
+ it("future date produces a positive relative time string (e.g. 'in 30 seconds')", () => {
217
+ // A future timestamp produces a positive diff; Intl.RelativeTimeFormat formats it as "in X"
218
+ const ta = new TimeAgo(() => now + 30_000, "en");
219
+ const { value } = ta.setup() as { value: () => string };
220
+ expect(value()).toContain("in 30 seconds");
221
+ });
168
222
  });
169
223
 
170
224
  // ── Pagination ────────────────────────────────────────────────────────────────
@@ -251,4 +305,12 @@ describe("Pagination", () => {
251
305
  const { totalPages } = p.setup() as { totalPages: () => number };
252
306
  expect(totalPages()).toBe(3);
253
307
  });
308
+
309
+ it("throws when pageSize is 0", () => {
310
+ expect(() => new Pagination({ total: 10, pageSize: 0 })).toThrow("pageSize must be greater than 0");
311
+ });
312
+
313
+ it("throws when pageSize is negative", () => {
314
+ expect(() => new Pagination({ total: 10, pageSize: -1 })).toThrow("pageSize must be greater than 0");
315
+ });
254
316
  });
package/src/browser.ts CHANGED
@@ -6,17 +6,20 @@ export class MediaQuery extends Composable {
6
6
 
7
7
  private _mql?: MediaQueryList;
8
8
  private _handler?: (e: MediaQueryListEvent) => void;
9
+ private _view?: Record<string, unknown>;
9
10
 
10
11
  constructor(private readonly query: string) {
11
12
  super();
12
13
  }
13
14
 
14
15
  setup() {
16
+ if (this._view) return this._view;
15
17
  this._mql = window.matchMedia(this.query);
16
18
  const matches = signal(this._mql.matches);
17
19
  this._handler = (e) => { matches.set(e.matches); };
18
20
  this._mql.addEventListener("change", this._handler);
19
- return { matches };
21
+ this._view = { matches };
22
+ return this._view;
20
23
  }
21
24
 
22
25
  onUnmount() {
@@ -32,14 +35,17 @@ export class ColorScheme extends Composable {
32
35
 
33
36
  private _mql?: MediaQueryList;
34
37
  private _handler?: (e: MediaQueryListEvent) => void;
38
+ private _view?: Record<string, unknown>;
35
39
 
36
40
  setup() {
41
+ if (this._view) return this._view;
37
42
  this._mql = window.matchMedia("(prefers-color-scheme: dark)");
38
43
  const isDark = signal(this._mql.matches);
39
44
  const isLight = computed(() => !isDark());
40
45
  this._handler = (e) => { isDark.set(e.matches); };
41
46
  this._mql.addEventListener("change", this._handler);
42
- return { isDark, isLight };
47
+ this._view = { isDark, isLight };
48
+ return this._view;
43
49
  }
44
50
 
45
51
  onUnmount() {
@@ -55,13 +61,16 @@ export class Mouse extends Composable {
55
61
 
56
62
  // eslint-disable-next-line @typescript-eslint/no-empty-function
57
63
  private _handler = (_e: MouseEvent) => {};
64
+ private _view?: Record<string, unknown>;
58
65
 
59
66
  setup() {
67
+ if (this._view) return this._view;
60
68
  const x = signal(0);
61
69
  const y = signal(0);
62
70
  this._handler = (e) => { x.set(e.clientX); y.set(e.clientY); };
63
71
  window.addEventListener("mousemove", this._handler);
64
- return { x, y };
72
+ this._view = { x, y };
73
+ return this._view;
65
74
  }
66
75
 
67
76
  onUnmount() {
@@ -76,21 +85,35 @@ export class KeyCombo extends Composable {
76
85
  private _keydownHandler = (_e: KeyboardEvent) => {};
77
86
  // eslint-disable-next-line @typescript-eslint/no-empty-function
78
87
  private _keyupHandler = () => {};
88
+ private _view?: Record<string, unknown>;
79
89
 
80
90
  constructor(private readonly combo: string) {
81
91
  super();
82
92
  }
83
93
 
84
94
  setup() {
85
- const parts = this.combo.toLowerCase().split("+");
95
+ if (this._view) return this._view;
96
+ const parts = this.combo.toLowerCase().split("+").map((p) => p.trim());
86
97
  const pressed = signal(false);
87
98
 
99
+ const modifiers = ["ctrl", "shift", "alt", "meta"];
100
+ const key = parts.find((p) => !modifiers.includes(p));
101
+
102
+ // A combo with no non-modifier key is invalid and should never fire
103
+ if (!key) {
104
+ this._keyupHandler = () => { pressed.set(false); };
105
+ window.addEventListener("keydown", this._keydownHandler);
106
+ window.addEventListener("keyup", this._keyupHandler);
107
+ this._view = { pressed };
108
+ return this._view;
109
+ }
110
+
88
111
  this._keydownHandler = (e) => {
89
112
  const ctrl = parts.includes("ctrl") ? e.ctrlKey : true;
90
113
  const shift = parts.includes("shift") ? e.shiftKey : true;
91
114
  const alt = parts.includes("alt") ? e.altKey : true;
92
- const key = parts.find((p) => !["ctrl", "shift", "alt", "meta"].includes(p));
93
- if (ctrl && shift && alt && (!key || e.key.toLowerCase() === key)) {
115
+ const meta = parts.includes("meta") ? e.metaKey : true;
116
+ if (ctrl && shift && alt && meta && e.key.toLowerCase() === key) {
94
117
  pressed.set(true);
95
118
  }
96
119
  };
@@ -98,7 +121,8 @@ export class KeyCombo extends Composable {
98
121
 
99
122
  window.addEventListener("keydown", this._keydownHandler);
100
123
  window.addEventListener("keyup", this._keyupHandler);
101
- return { pressed };
124
+ this._view = { pressed };
125
+ return this._view;
102
126
  }
103
127
 
104
128
  onUnmount() {
@@ -112,12 +136,14 @@ export class Idle extends Composable {
112
136
 
113
137
  private _listeners: Array<[string, () => void]> = [];
114
138
  private _timer?: ReturnType<typeof setTimeout>;
139
+ private _view?: Record<string, unknown>;
115
140
 
116
141
  constructor(private readonly timeout = 60_000) {
117
142
  super();
118
143
  }
119
144
 
120
145
  setup() {
146
+ if (this._view) return this._view;
121
147
  const idle = signal(false);
122
148
  const reset = () => {
123
149
  idle.set(false);
@@ -131,7 +157,8 @@ export class Idle extends Composable {
131
157
  });
132
158
 
133
159
  reset();
134
- return { idle };
160
+ this._view = { idle };
161
+ return this._view;
135
162
  }
136
163
 
137
164
  onUnmount() {
package/src/dom.ts CHANGED
@@ -7,8 +7,10 @@ export class WindowSize extends Composable {
7
7
 
8
8
  // eslint-disable-next-line @typescript-eslint/no-empty-function
9
9
  private _handler = () => {};
10
+ private _view?: Record<string, unknown>;
10
11
 
11
12
  setup() {
13
+ if (this._view) return this._view;
12
14
  const width = signal(window.innerWidth);
13
15
  const height = signal(window.innerHeight);
14
16
  this._handler = () => {
@@ -16,7 +18,8 @@ export class WindowSize extends Composable {
16
18
  height.set(window.innerHeight);
17
19
  };
18
20
  window.addEventListener("resize", this._handler);
19
- return { width, height };
21
+ this._view = { width, height };
22
+ return this._view;
20
23
  }
21
24
 
22
25
  onUnmount() {
@@ -31,6 +34,7 @@ export class ScrollPosition extends Composable {
31
34
  private readonly _target: HTMLElement | Window;
32
35
  // eslint-disable-next-line @typescript-eslint/no-empty-function
33
36
  private _handler = () => {};
37
+ private _view?: Record<string, unknown>;
34
38
 
35
39
  constructor(target: HTMLElement | Window = window) {
36
40
  super();
@@ -38,6 +42,7 @@ export class ScrollPosition extends Composable {
38
42
  }
39
43
 
40
44
  setup() {
45
+ if (this._view) return this._view;
41
46
  const x = signal(0);
42
47
  const y = signal(0);
43
48
  const t = this._target;
@@ -46,7 +51,8 @@ export class ScrollPosition extends Composable {
46
51
  y.set(t === window ? window.scrollY : (t as HTMLElement).scrollTop);
47
52
  };
48
53
  t.addEventListener("scroll", this._handler);
49
- return { x, y };
54
+ this._view = { x, y };
55
+ return this._view;
50
56
  }
51
57
 
52
58
  onUnmount() {
@@ -59,12 +65,14 @@ export class ElementSize extends Composable {
59
65
  declare height: number;
60
66
 
61
67
  private _observer?: ResizeObserver;
68
+ private _view?: Record<string, unknown>;
62
69
 
63
70
  constructor(private readonly ref: { current: HTMLElement | null }) {
64
71
  super();
65
72
  }
66
73
 
67
74
  setup() {
75
+ if (this._view) return this._view;
68
76
  const width = signal(0);
69
77
  const height = signal(0);
70
78
  this._observer = new ResizeObserver(([entry]) => {
@@ -78,7 +86,8 @@ export class ElementSize extends Composable {
78
86
  height.set(this.ref.current.offsetHeight);
79
87
  }
80
88
  });
81
- return { width, height };
89
+ this._view = { width, height };
90
+ return this._view;
82
91
  }
83
92
 
84
93
  onUnmount() {
@@ -90,6 +99,7 @@ export class Intersection extends Composable {
90
99
  declare visible: boolean;
91
100
 
92
101
  private _observer?: IntersectionObserver;
102
+ private _view?: Record<string, unknown>;
93
103
 
94
104
  constructor(
95
105
  private readonly ref: { current: HTMLElement | null },
@@ -99,6 +109,7 @@ export class Intersection extends Composable {
99
109
  }
100
110
 
101
111
  setup() {
112
+ if (this._view) return this._view;
102
113
  const visible = signal(false);
103
114
  this._observer = new IntersectionObserver(([entry]) => {
104
115
  visible.set(entry.isIntersecting);
@@ -106,7 +117,8 @@ export class Intersection extends Composable {
106
117
  effect(() => {
107
118
  if (this.ref.current) this._observer?.observe(this.ref.current);
108
119
  });
109
- return { visible };
120
+ this._view = { visible };
121
+ return this._view;
110
122
  }
111
123
 
112
124
  onUnmount() {
@@ -117,11 +129,14 @@ export class Intersection extends Composable {
117
129
  export class Focus extends Composable {
118
130
  declare focused: boolean;
119
131
 
132
+ private _view?: Record<string, unknown>;
133
+
120
134
  constructor(private readonly ref: { current: HTMLElement | null }) {
121
135
  super();
122
136
  }
123
137
 
124
138
  setup() {
139
+ if (this._view) return this._view;
125
140
  const focused = signal(false);
126
141
  effect(() => {
127
142
  const el = this.ref.current;
@@ -135,6 +150,7 @@ export class Focus extends Composable {
135
150
  el.removeEventListener("blur", onBlur);
136
151
  };
137
152
  });
138
- return { focused };
153
+ this._view = { focused };
154
+ return this._view;
139
155
  }
140
156
  }
package/src/utilities.ts CHANGED
@@ -13,6 +13,8 @@ export class Clipboard extends Composable {
13
13
  declare content: string;
14
14
  declare copy: (text: string) => Promise<void>;
15
15
 
16
+ private _resetTimer?: ReturnType<typeof setTimeout>;
17
+
16
18
  constructor(private readonly resetDelay = 2000) {
17
19
  super();
18
20
  }
@@ -27,7 +29,8 @@ export class Clipboard extends Composable {
27
29
  await navigator.clipboard.writeText(text);
28
30
  content.set(text);
29
31
  copied.set(true);
30
- setTimeout(() => { copied.set(false); }, resetDelay);
32
+ clearTimeout(this._resetTimer);
33
+ this._resetTimer = setTimeout(() => { copied.set(false); }, resetDelay);
31
34
  } catch {
32
35
  console.warn("[Clipboard] Falha ao copiar");
33
36
  }
@@ -35,6 +38,10 @@ export class Clipboard extends Composable {
35
38
 
36
39
  return { copied, content, copy };
37
40
  }
41
+
42
+ onUnmount() {
43
+ clearTimeout(this._resetTimer);
44
+ }
38
45
  }
39
46
 
40
47
  export class Geolocation extends Composable {
@@ -43,6 +50,8 @@ export class Geolocation extends Composable {
43
50
  declare error: GeolocationPositionError | null;
44
51
  declare loading: boolean;
45
52
 
53
+ private _active = true;
54
+
46
55
  constructor(private readonly options?: PositionOptions) {
47
56
  super();
48
57
  }
@@ -55,11 +64,13 @@ export class Geolocation extends Composable {
55
64
 
56
65
  navigator.geolocation.getCurrentPosition(
57
66
  (pos) => {
67
+ if (!this._active) return;
58
68
  lat.set(pos.coords.latitude);
59
69
  lng.set(pos.coords.longitude);
60
70
  loading.set(false);
61
71
  },
62
72
  (err) => {
73
+ if (!this._active) return;
63
74
  error.set(err);
64
75
  loading.set(false);
65
76
  },
@@ -68,6 +79,10 @@ export class Geolocation extends Composable {
68
79
 
69
80
  return { lat, lng, error, loading };
70
81
  }
82
+
83
+ onUnmount() {
84
+ this._active = false;
85
+ }
71
86
  }
72
87
 
73
88
  export class TimeAgo extends Composable {
@@ -127,6 +142,9 @@ export class Pagination extends Composable {
127
142
 
128
143
  constructor(private readonly options: PaginationOptions) {
129
144
  super();
145
+ if (options.pageSize <= 0) {
146
+ throw new Error("pageSize must be greater than 0");
147
+ }
130
148
  }
131
149
 
132
150
  setup() {