@praxisjs/composables 0.1.5 → 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.
@@ -1,282 +1,254 @@
1
1
  // @vitest-environment jsdom
2
2
  import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
3
-
4
3
  import { signal } from "@praxisjs/core/internal";
5
4
 
6
- import { usePagination, useClipboard, useGeolocation, useTimeAgo } from "../utilities";
7
-
8
- describe("usePagination", () => {
9
- it("starts on page 1 by default", () => {
10
- const p = usePagination({ total: 100, pageSize: 10 });
11
- expect(p.page()).toBe(1);
12
- });
13
-
14
- it("respects initial page option", () => {
15
- const p = usePagination({ total: 100, pageSize: 10, initial: 3 });
16
- expect(p.page()).toBe(3);
17
- });
18
-
19
- it("computes total pages correctly", () => {
20
- expect(usePagination({ total: 100, pageSize: 10 }).totalPages()).toBe(10);
21
- expect(usePagination({ total: 101, pageSize: 10 }).totalPages()).toBe(11);
22
- expect(usePagination({ total: 5, pageSize: 10 }).totalPages()).toBe(1);
23
- });
24
-
25
- it("computes offset", () => {
26
- const p = usePagination({ total: 100, pageSize: 10 });
27
- expect(p.offset()).toBe(0);
28
- p.goTo(3);
29
- expect(p.offset()).toBe(20);
30
- });
31
-
32
- it("next() advances the page", () => {
33
- const p = usePagination({ total: 30, pageSize: 10 });
34
- p.next();
35
- expect(p.page()).toBe(2);
36
- });
5
+ import { Clipboard, Geolocation, TimeAgo, Pagination } from "../utilities";
37
6
 
38
- it("next() does nothing on last page", () => {
39
- const p = usePagination({ total: 10, pageSize: 10 });
40
- p.next();
41
- expect(p.page()).toBe(1);
42
- });
43
-
44
- it("prev() goes back", () => {
45
- const p = usePagination({ total: 30, pageSize: 10, initial: 3 });
46
- p.prev();
47
- expect(p.page()).toBe(2);
48
- });
49
-
50
- it("prev() does nothing on first page", () => {
51
- const p = usePagination({ total: 30, pageSize: 10 });
52
- p.prev();
53
- expect(p.page()).toBe(1);
54
- });
55
-
56
- it("goTo() clamps to valid range", () => {
57
- const p = usePagination({ total: 30, pageSize: 10 });
58
- p.goTo(0);
59
- expect(p.page()).toBe(1);
60
- p.goTo(99);
61
- expect(p.page()).toBe(3);
62
- });
63
-
64
- it("first() jumps to page 1", () => {
65
- const p = usePagination({ total: 50, pageSize: 10, initial: 5 });
66
- p.first();
67
- expect(p.page()).toBe(1);
68
- });
7
+ // ── Clipboard ─────────────────────────────────────────────────────────────────
69
8
 
70
- it("last() jumps to last page", () => {
71
- const p = usePagination({ total: 50, pageSize: 10 });
72
- p.last();
73
- expect(p.page()).toBe(5);
74
- });
75
-
76
- it("hasNext is false on last page", () => {
77
- const p = usePagination({ total: 10, pageSize: 10 });
78
- expect(p.hasNext()).toBe(false);
79
- });
80
-
81
- it("hasPrev is false on first page", () => {
82
- const p = usePagination({ total: 30, pageSize: 10 });
83
- expect(p.hasPrev()).toBe(false);
84
- });
85
-
86
- it("pages() returns array of page numbers", () => {
87
- const p = usePagination({ total: 30, pageSize: 10 });
88
- expect(p.pages()).toEqual([1, 2, 3]);
89
- });
90
-
91
- it("pageSize() reflects the configured size", () => {
92
- const p = usePagination({ total: 100, pageSize: 25 });
93
- expect(p.pageSize()).toBe(25);
94
- });
95
-
96
- it("accepts a factory function for options", () => {
97
- const p = usePagination(() => ({ total: 50, pageSize: 5 }));
98
- expect(p.totalPages()).toBe(10);
99
- });
100
- });
101
-
102
- // ── useClipboard ──────────────────────────────────────────────────────────────
103
-
104
- describe("useClipboard", () => {
9
+ describe("Clipboard", () => {
105
10
  beforeEach(() => {
106
- vi.useFakeTimers();
107
11
  Object.defineProperty(navigator, "clipboard", {
108
12
  value: { writeText: vi.fn().mockResolvedValue(undefined) },
109
13
  configurable: true,
110
- writable: true,
111
14
  });
112
15
  });
113
16
 
114
- afterEach(() => {
115
- vi.clearAllTimers();
116
- vi.useRealTimers();
117
- });
118
-
119
- it("copied starts as false", () => {
120
- const { copied } = useClipboard();
17
+ it("starts with copied=false and empty content", () => {
18
+ const cb = new Clipboard();
19
+ const { copied, content } = cb.setup() as { copied: () => boolean; content: () => string };
121
20
  expect(copied()).toBe(false);
122
- });
123
-
124
- it("content starts as empty string", () => {
125
- const { content } = useClipboard();
126
21
  expect(content()).toBe("");
127
22
  });
128
23
 
129
- it("sets copied to true and content to the text after copy()", async () => {
130
- const { copy, copied, content } = useClipboard();
24
+ it("sets copied=true and content after copy", async () => {
25
+ const cb = new Clipboard();
26
+ const { copy, copied, content } = cb.setup() as {
27
+ copy: (t: string) => Promise<void>;
28
+ copied: () => boolean;
29
+ content: () => string;
30
+ };
131
31
  await copy("hello");
132
32
  expect(copied()).toBe(true);
133
33
  expect(content()).toBe("hello");
134
34
  });
135
35
 
136
- it("resets copied to false after resetDelay", async () => {
137
- const { copy, copied } = useClipboard(1000);
36
+ it("resets copied after resetDelay", async () => {
37
+ vi.useFakeTimers();
38
+ const cb = new Clipboard(1000);
39
+ const { copy, copied } = cb.setup() as {
40
+ copy: (t: string) => Promise<void>;
41
+ copied: () => boolean;
42
+ };
138
43
  await copy("test");
139
44
  expect(copied()).toBe(true);
140
45
  vi.advanceTimersByTime(1000);
141
46
  expect(copied()).toBe(false);
47
+ vi.useRealTimers();
142
48
  });
143
49
 
144
- it("warns and does not update state on clipboard failure", async () => {
145
- (navigator.clipboard as unknown as { writeText: ReturnType<typeof vi.fn> }).writeText =
146
- vi.fn().mockRejectedValue(new Error("denied"));
50
+ it("warns on clipboard failure", async () => {
51
+ (navigator.clipboard.writeText as ReturnType<typeof vi.fn>).mockRejectedValue(new Error("denied"));
147
52
  const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
148
- const { copy, copied } = useClipboard();
53
+ const cb = new Clipboard();
54
+ const { copy } = cb.setup() as { copy: (t: string) => Promise<void> };
149
55
  await copy("fail");
150
- expect(copied()).toBe(false);
151
56
  expect(warn).toHaveBeenCalled();
152
57
  warn.mockRestore();
153
58
  });
154
59
  });
155
60
 
156
- // ── useGeolocation ────────────────────────────────────────────────────────────
157
-
158
- describe("useGeolocation", () => {
159
- it("starts with loading=true and lat/lng null", () => {
160
- const successCb: PositionCallback[] = [];
161
- Object.defineProperty(navigator, "geolocation", {
162
- value: {
163
- getCurrentPosition: (success: PositionCallback) => {
164
- successCb.push(success);
165
- },
166
- },
167
- configurable: true,
168
- });
61
+ // ── Geolocation ───────────────────────────────────────────────────────────────
169
62
 
170
- const geo = useGeolocation();
171
- expect(geo.loading()).toBe(true);
172
- expect(geo.lat()).toBeNull();
173
- expect(geo.lng()).toBeNull();
63
+ describe("Geolocation", () => {
64
+ function mockGeolocation(impl: (success: PositionCallback, error?: PositionErrorCallback | null) => void) {
65
+ vi.stubGlobal("navigator", {
66
+ ...navigator,
67
+ geolocation: { getCurrentPosition: impl },
68
+ });
69
+ }
70
+
71
+ afterEach(() => { vi.unstubAllGlobals(); });
72
+
73
+ it("starts in loading state", () => {
74
+ mockGeolocation(() => {});
75
+ const geo = new Geolocation();
76
+ const { loading, lat, lng, error } = geo.setup() as {
77
+ loading: () => boolean;
78
+ lat: () => number | null;
79
+ lng: () => number | null;
80
+ error: () => GeolocationPositionError | null;
81
+ };
82
+ expect(loading()).toBe(true);
83
+ expect(lat()).toBeNull();
84
+ expect(lng()).toBeNull();
85
+ expect(error()).toBeNull();
86
+ });
87
+
88
+ it("sets coordinates on success", () => {
89
+ mockGeolocation((success) => {
90
+ success({ coords: { latitude: 51.5, longitude: -0.1 } } as GeolocationPosition);
91
+ });
92
+ const geo = new Geolocation();
93
+ const { loading, lat, lng } = geo.setup() as {
94
+ loading: () => boolean;
95
+ lat: () => number | null;
96
+ lng: () => number | null;
97
+ };
98
+ expect(loading()).toBe(false);
99
+ expect(lat()).toBe(51.5);
100
+ expect(lng()).toBe(-0.1);
101
+ });
102
+
103
+ it("sets error on failure", () => {
104
+ const mockError = { code: 1, message: "denied" } as GeolocationPositionError;
105
+ mockGeolocation((_s, error) => { error!(mockError); });
106
+ const geo = new Geolocation();
107
+ const { loading, error } = geo.setup() as {
108
+ loading: () => boolean;
109
+ error: () => GeolocationPositionError | null;
110
+ };
111
+ expect(loading()).toBe(false);
112
+ expect(error()).toBe(mockError);
174
113
  });
114
+ });
175
115
 
176
- it("updates lat/lng/loading on success", () => {
177
- let successCb: PositionCallback | null = null;
178
- Object.defineProperty(navigator, "geolocation", {
179
- value: {
180
- getCurrentPosition: (success: PositionCallback) => {
181
- successCb = success;
182
- },
183
- },
184
- configurable: true,
185
- });
116
+ // ── TimeAgo ───────────────────────────────────────────────────────────────────
186
117
 
187
- const geo = useGeolocation();
118
+ describe("TimeAgo", () => {
119
+ beforeEach(() => { vi.useFakeTimers(); });
120
+ afterEach(() => { vi.useRealTimers(); });
188
121
 
189
- successCb!({
190
- coords: { latitude: 10.5, longitude: 20.5 } as GeolocationCoordinates,
191
- } as GeolocationPosition);
122
+ const now = Date.now();
192
123
 
193
- expect(geo.lat()).toBe(10.5);
194
- expect(geo.lng()).toBe(20.5);
195
- expect(geo.loading()).toBe(false);
124
+ it("formats seconds ago", () => {
125
+ const ta = new TimeAgo(() => now - 30_000, "en");
126
+ const { value } = ta.setup() as { value: () => string };
127
+ expect(value()).toContain("30 seconds ago");
196
128
  });
197
129
 
198
- it("sets error and loading=false on failure", () => {
199
- let errorCb: PositionErrorCallback | null = null;
200
- Object.defineProperty(navigator, "geolocation", {
201
- value: {
202
- getCurrentPosition: (_s: PositionCallback, error: PositionErrorCallback) => {
203
- errorCb = error;
204
- },
205
- },
206
- configurable: true,
207
- });
130
+ it("formats minutes ago", () => {
131
+ const ta = new TimeAgo(() => now - 5 * 60_000, "en");
132
+ const { value } = ta.setup() as { value: () => string };
133
+ expect(value()).toContain("5 minutes ago");
134
+ });
135
+
136
+ it("formats hours ago", () => {
137
+ const ta = new TimeAgo(() => now - 2 * 3_600_000, "en");
138
+ const { value } = ta.setup() as { value: () => string };
139
+ expect(value()).toContain("2 hours ago");
140
+ });
208
141
 
209
- const geo = useGeolocation();
210
- const fakeError = { code: 1, message: "denied" } as GeolocationPositionError;
211
- errorCb!(fakeError);
142
+ it("formats days ago", () => {
143
+ const ta = new TimeAgo(() => now - 3 * 86_400_000, "en");
144
+ const { value } = ta.setup() as { value: () => string };
145
+ expect(value()).toContain("3 days ago");
146
+ });
147
+
148
+ it("formats months ago", () => {
149
+ const ta = new TimeAgo(() => now - 60 * 86_400_000, "en");
150
+ const { value } = ta.setup() as { value: () => string };
151
+ expect(value()).toContain("2 months ago");
152
+ });
212
153
 
213
- expect(geo.error()).toBe(fakeError);
214
- expect(geo.loading()).toBe(false);
154
+ it("accepts a signal as source", () => {
155
+ const src = signal(now - 30_000);
156
+ const ta = new TimeAgo(src, "en");
157
+ const { value } = ta.setup() as { value: () => string };
158
+ expect(value()).toContain("30 seconds ago");
159
+ });
160
+
161
+ it("clears interval on unmount", () => {
162
+ const clearInterval = vi.spyOn(globalThis, "clearInterval");
163
+ const ta = new TimeAgo(() => now, "en");
164
+ ta.setup();
165
+ ta.onUnmount();
166
+ expect(clearInterval).toHaveBeenCalled();
215
167
  });
216
168
  });
217
169
 
218
- // ── useTimeAgo ────────────────────────────────────────────────────────────────
170
+ // ── Pagination ────────────────────────────────────────────────────────────────
219
171
 
220
- describe("useTimeAgo", () => {
221
- beforeEach(() => {
222
- vi.useFakeTimers();
172
+ describe("Pagination", () => {
173
+ it("initialises with defaults", () => {
174
+ const p = new Pagination({ total: 100, pageSize: 10 });
175
+ const s = p.setup() as {
176
+ page: () => number; totalPages: () => number; offset: () => number;
177
+ hasNext: () => boolean; hasPrev: () => boolean; pageSize: () => number;
178
+ };
179
+ expect(s.page()).toBe(1);
180
+ expect(s.totalPages()).toBe(10);
181
+ expect(s.offset()).toBe(0);
182
+ expect(s.hasNext()).toBe(true);
183
+ expect(s.hasPrev()).toBe(false);
184
+ expect(s.pageSize()).toBe(10);
223
185
  });
224
186
 
225
- afterEach(() => {
226
- vi.clearAllTimers();
227
- vi.useRealTimers();
187
+ it("respects initial page", () => {
188
+ const p = new Pagination({ total: 100, pageSize: 10, initial: 3 });
189
+ const { page, offset } = p.setup() as { page: () => number; offset: () => number };
190
+ expect(page()).toBe(3);
191
+ expect(offset()).toBe(20);
228
192
  });
229
193
 
230
- it("returns a computed string", () => {
231
- const now = Date.now();
232
- const t = useTimeAgo(() => now);
233
- expect(typeof t()).toBe("string");
194
+ it("next() advances page", () => {
195
+ const p = new Pagination({ total: 30, pageSize: 10 });
196
+ const { page, next } = p.setup() as { page: () => number; next: () => void };
197
+ (next as () => void)();
198
+ expect(page()).toBe(2);
234
199
  });
235
200
 
236
- it("formats seconds ago", () => {
237
- const now = Date.now();
238
- const t = useTimeAgo(() => now - 30_000);
239
- expect(t()).toMatch(/segundo|second/i);
201
+ it("prev() goes back a page", () => {
202
+ const p = new Pagination({ total: 30, pageSize: 10, initial: 3 });
203
+ const { page, prev } = p.setup() as { page: () => number; prev: () => void };
204
+ (prev as () => void)();
205
+ expect(page()).toBe(2);
240
206
  });
241
207
 
242
- it("formats minutes ago", () => {
243
- const now = Date.now();
244
- const t = useTimeAgo(() => now - 5 * 60_000);
245
- expect(t()).toMatch(/minuto|minute/i);
208
+ it("next() does not go past last page", () => {
209
+ const p = new Pagination({ total: 10, pageSize: 10 });
210
+ const { page, next } = p.setup() as { page: () => number; next: () => void };
211
+ (next as () => void)();
212
+ expect(page()).toBe(1);
246
213
  });
247
214
 
248
- it("formats hours ago", () => {
249
- const now = Date.now();
250
- const t = useTimeAgo(() => now - 2 * 3_600_000);
251
- expect(t()).toMatch(/hora|hour/i);
215
+ it("prev() does not go before first page", () => {
216
+ const p = new Pagination({ total: 30, pageSize: 10 });
217
+ const { page, prev } = p.setup() as { page: () => number; prev: () => void };
218
+ (prev as () => void)();
219
+ expect(page()).toBe(1);
252
220
  });
253
221
 
254
- it("formats days ago", () => {
255
- const now = Date.now();
256
- const t = useTimeAgo(() => now - 3 * 86_400_000);
257
- expect(t()).toMatch(/dia|day/i);
222
+ it("goTo() jumps to a page", () => {
223
+ const p = new Pagination({ total: 50, pageSize: 10 });
224
+ const { page, goTo } = p.setup() as { page: () => number; goTo: (n: number) => void };
225
+ (goTo as (n: number) => void)(4);
226
+ expect(page()).toBe(4);
258
227
  });
259
228
 
260
- it("formats months ago", () => {
261
- const now = Date.now();
262
- const t = useTimeAgo(() => now - 60 * 86_400_000);
263
- expect(t()).toMatch(/mes|month/i);
229
+ it("first() jumps to page 1", () => {
230
+ const p = new Pagination({ total: 50, pageSize: 10, initial: 5 });
231
+ const { page, first } = p.setup() as { page: () => number; first: () => void };
232
+ (first as () => void)();
233
+ expect(page()).toBe(1);
264
234
  });
265
235
 
266
- it("accepts a signal source", () => {
267
- const src = signal(Date.now() - 90_000);
268
- const t = useTimeAgo(src);
269
- expect(typeof t()).toBe("string");
236
+ it("last() jumps to last page", () => {
237
+ const p = new Pagination({ total: 50, pageSize: 10 });
238
+ const { page, last } = p.setup() as { page: () => number; last: () => void };
239
+ (last as () => void)();
240
+ expect(page()).toBe(5);
241
+ });
242
+
243
+ it("pages() returns array of page numbers", () => {
244
+ const p = new Pagination({ total: 30, pageSize: 10 });
245
+ const { pages } = p.setup() as { pages: () => number[] };
246
+ expect((pages as () => number[])()).toEqual([1, 2, 3]);
270
247
  });
271
248
 
272
- it("ticks after 60 seconds via setInterval", () => {
273
- const now = Date.now();
274
- const t = useTimeAgo(() => now - 30_000);
275
- const before = t();
276
- vi.advanceTimersByTime(60_000);
277
- // After ticking, the computed re-evaluates (may or may not change the string)
278
- expect(typeof t()).toBe("string");
279
- // The interval ran — just verify no errors
280
- expect(before).toBeTruthy();
249
+ it("clamps totalPages for fractional divisions", () => {
250
+ const p = new Pagination({ total: 25, pageSize: 10 });
251
+ const { totalPages } = p.setup() as { totalPages: () => number };
252
+ expect(totalPages()).toBe(3);
281
253
  });
282
254
  });
package/src/browser.ts CHANGED
@@ -1,63 +1,144 @@
1
+ import { Composable } from "@praxisjs/core";
1
2
  import { signal, computed } from "@praxisjs/core/internal";
2
- import type { Computed } from "@praxisjs/shared";
3
-
4
- export function useMediaQuery(query: string): Computed<boolean> {
5
- const mql = window.matchMedia(query);
6
- const matches = signal(mql.matches);
7
- mql.addEventListener("change", (e: MediaQueryListEvent) => {
8
- matches.set(e.matches);
9
- });
10
- return computed(() => matches());
3
+
4
+ export class MediaQuery extends Composable {
5
+ declare matches: boolean;
6
+
7
+ private _mql?: MediaQueryList;
8
+ private _handler?: (e: MediaQueryListEvent) => void;
9
+
10
+ constructor(private readonly query: string) {
11
+ super();
12
+ }
13
+
14
+ setup() {
15
+ this._mql = window.matchMedia(this.query);
16
+ const matches = signal(this._mql.matches);
17
+ this._handler = (e) => { matches.set(e.matches); };
18
+ this._mql.addEventListener("change", this._handler);
19
+ return { matches };
20
+ }
21
+
22
+ onUnmount() {
23
+ if (this._mql && this._handler) {
24
+ this._mql.removeEventListener("change", this._handler);
25
+ }
26
+ }
11
27
  }
12
28
 
13
- export function useColorScheme() {
14
- const isDark = useMediaQuery("(prefers-color-scheme: dark)");
15
- const isLight = computed(() => !isDark());
16
- return { isDark, isLight };
29
+ export class ColorScheme extends Composable {
30
+ declare isDark: boolean;
31
+ declare isLight: boolean;
32
+
33
+ private _mql?: MediaQueryList;
34
+ private _handler?: (e: MediaQueryListEvent) => void;
35
+
36
+ setup() {
37
+ this._mql = window.matchMedia("(prefers-color-scheme: dark)");
38
+ const isDark = signal(this._mql.matches);
39
+ const isLight = computed(() => !isDark());
40
+ this._handler = (e) => { isDark.set(e.matches); };
41
+ this._mql.addEventListener("change", this._handler);
42
+ return { isDark, isLight };
43
+ }
44
+
45
+ onUnmount() {
46
+ if (this._mql && this._handler) {
47
+ this._mql.removeEventListener("change", this._handler);
48
+ }
49
+ }
17
50
  }
18
51
 
19
- export function useMouse() {
20
- const x = signal(0);
21
- const y = signal(0);
22
- window.addEventListener("mousemove", (e) => {
23
- x.set(e.clientX);
24
- y.set(e.clientY);
25
- });
26
- return { x: computed(() => x()), y: computed(() => y()) };
52
+ export class Mouse extends Composable {
53
+ declare x: number;
54
+ declare y: number;
55
+
56
+ // eslint-disable-next-line @typescript-eslint/no-empty-function
57
+ private _handler = (_e: MouseEvent) => {};
58
+
59
+ setup() {
60
+ const x = signal(0);
61
+ const y = signal(0);
62
+ this._handler = (e) => { x.set(e.clientX); y.set(e.clientY); };
63
+ window.addEventListener("mousemove", this._handler);
64
+ return { x, y };
65
+ }
66
+
67
+ onUnmount() {
68
+ window.removeEventListener("mousemove", this._handler);
69
+ }
27
70
  }
28
71
 
29
- export function useKeyCombo(combo: string): Computed<boolean> {
30
- const parts = combo.toLowerCase().split("+");
31
- const pressed = signal(false);
32
- window.addEventListener("keydown", (e: KeyboardEvent) => {
33
- const ctrl = parts.includes("ctrl") ? e.ctrlKey : true;
34
- const shift = parts.includes("shift") ? e.shiftKey : true;
35
- const alt = parts.includes("alt") ? e.altKey : true;
36
- const key = parts.find(
37
- (p) => !["ctrl", "shift", "alt", "meta"].includes(p),
38
- );
39
- if (ctrl && shift && alt && (!key || e.key.toLowerCase() === key))
40
- pressed.set(true);
41
- });
42
- window.addEventListener("keyup", () => {
43
- pressed.set(false);
44
- });
45
- return computed(() => pressed());
72
+ export class KeyCombo extends Composable {
73
+ declare pressed: boolean;
74
+
75
+ // eslint-disable-next-line @typescript-eslint/no-empty-function
76
+ private _keydownHandler = (_e: KeyboardEvent) => {};
77
+ // eslint-disable-next-line @typescript-eslint/no-empty-function
78
+ private _keyupHandler = () => {};
79
+
80
+ constructor(private readonly combo: string) {
81
+ super();
82
+ }
83
+
84
+ setup() {
85
+ const parts = this.combo.toLowerCase().split("+");
86
+ const pressed = signal(false);
87
+
88
+ this._keydownHandler = (e) => {
89
+ const ctrl = parts.includes("ctrl") ? e.ctrlKey : true;
90
+ const shift = parts.includes("shift") ? e.shiftKey : true;
91
+ 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)) {
94
+ pressed.set(true);
95
+ }
96
+ };
97
+ this._keyupHandler = () => { pressed.set(false); };
98
+
99
+ window.addEventListener("keydown", this._keydownHandler);
100
+ window.addEventListener("keyup", this._keyupHandler);
101
+ return { pressed };
102
+ }
103
+
104
+ onUnmount() {
105
+ window.removeEventListener("keydown", this._keydownHandler);
106
+ window.removeEventListener("keyup", this._keyupHandler);
107
+ }
46
108
  }
47
109
 
48
- export function useIdle(timeout = 60_000): Computed<boolean> {
49
- const idle = signal(false);
50
- let timer: ReturnType<typeof setTimeout>;
51
- const reset = () => {
52
- idle.set(false);
53
- clearTimeout(timer);
54
- timer = setTimeout(() => {
55
- idle.set(true);
56
- }, timeout);
57
- };
58
- ["mousemove", "keydown", "click", "scroll", "touchstart"].forEach((e) => {
59
- window.addEventListener(e, reset, { passive: true });
60
- });
61
- reset();
62
- return computed(() => idle());
110
+ export class Idle extends Composable {
111
+ declare idle: boolean;
112
+
113
+ private _listeners: Array<[string, () => void]> = [];
114
+ private _timer?: ReturnType<typeof setTimeout>;
115
+
116
+ constructor(private readonly timeout = 60_000) {
117
+ super();
118
+ }
119
+
120
+ setup() {
121
+ const idle = signal(false);
122
+ const reset = () => {
123
+ idle.set(false);
124
+ clearTimeout(this._timer);
125
+ this._timer = setTimeout(() => { idle.set(true); }, this.timeout);
126
+ };
127
+
128
+ ["mousemove", "keydown", "click", "scroll", "touchstart"].forEach((event) => {
129
+ window.addEventListener(event, reset, { passive: true });
130
+ this._listeners.push([event, reset]);
131
+ });
132
+
133
+ reset();
134
+ return { idle };
135
+ }
136
+
137
+ onUnmount() {
138
+ clearTimeout(this._timer);
139
+ this._listeners.forEach(([event, handler]) => {
140
+ window.removeEventListener(event, handler);
141
+ });
142
+ this._listeners = [];
143
+ }
63
144
  }