elements-kit 0.0.13 → 0.0.14

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.
package/README.md CHANGED
@@ -250,6 +250,44 @@ The same `cart` store drives custom elements, React trees, and plain scripts —
250
250
  Pre-built signal factories for common browser APIs:
251
251
 
252
252
  ```ts
253
+ import { signal, effect, untracked, onCleanup } from "elements-kit/signals";
254
+ import { createMediaQuery } from "elements-kit/utilities/media-query";
255
+ import { async } from "elements-kit/utilities/async";
256
+ import { retry } from "elements-kit/utilities/retry";
257
+ import { online } from "elements-kit/utilities/network";
258
+ import { windowFocused } from "elements-kit/utilities/window-focus";
259
+ import { storage } from "elements-kit/utilities/storage";
260
+
261
+ const id = signal(1);
262
+ const cache = storage("todos"); // persists across reloads, used as initial value
263
+
264
+ // Query — retries on failure, refetches when back online or tab regains focus
265
+ const fetchTodo = async(() => {
266
+ if (!online()) return untracked(cache); // pause while offline, return stale value
267
+ windowFocused(); // refetch on tab focus
268
+ return retry(() => {
269
+ const controller = new AbortController();
270
+ onCleanup(() => controller.abort()); // abort before each retry
271
+ return fetch(`/api/todos/${id()}`, { signal: controller.signal })
272
+ .then((r) => r.json())
273
+ .then(cache); // update cache on success
274
+ }, 3, (n) => n * 500)(); // 0 ms, 500 ms, 1000 ms backoff
275
+ }).start();
276
+
277
+ effect(() => console.log(fetchTodo.state, fetchTodo.value));
278
+
279
+ // Mutation — run once, no reactive tracking
280
+ const deleteTodo = async((todoId: number) =>
281
+ fetch(`/api/todos/${todoId}`, { method: "DELETE" }).then((r) => r.json()),
282
+ );
283
+
284
+ const result = await deleteTodo.run(42);
285
+ ```
286
+
287
+ `createMediaQuery` wraps `window.matchMedia` into a reactive signal — reads inside effects or computeds re-run automatically when the media query result changes.
288
+
289
+ ```tsx
290
+ import { effect } from "elements-kit/signals";
253
291
  import { createMediaQuery } from "elements-kit/utilities/media-query";
254
292
 
255
293
  const isDark = createMediaQuery("(prefers-color-scheme: dark)");
@@ -0,0 +1,37 @@
1
+ import { n as MaybeReactive } from "../index-DUshSQ_6.mjs";
2
+ import { ComputedPromise } from "./promise.mjs";
3
+
4
+ //#region src/utilities/async.d.ts
5
+ type Fn<TInput, TOutput> = (input: TInput) => Promise<TOutput>;
6
+ declare class Async<TInput = undefined, TOutput = unknown> {
7
+ #private;
8
+ get fn(): Fn<TInput, TOutput>;
9
+ set fn(fn: MaybeReactive<Fn<TInput, TOutput>>);
10
+ get raw(): ComputedPromise<TOutput | undefined>;
11
+ get pending(): boolean;
12
+ get state(): "pending" | "fulfilled" | "rejected";
13
+ get value(): TOutput | undefined;
14
+ get reason(): unknown;
15
+ get result(): unknown;
16
+ then<TResult1 = TOutput, TResult2 = never>(onfulfilled?: ((value: TOutput | undefined) => TResult1 | PromiseLike<TResult1>) | null, onrejected?: ((reason: unknown) => TResult2 | PromiseLike<TResult2>) | null): Promise<TResult1 | TResult2>;
17
+ catch<TResult = never>(onrejected?: ((reason: unknown) => TResult | PromiseLike<TResult>) | null): Promise<TOutput | undefined | TResult>;
18
+ finally(onfinally?: (() => void) | null): Promise<TOutput | undefined>;
19
+ constructor(fn: MaybeReactive<Fn<TInput, TOutput>>);
20
+ /**
21
+ * Runs the async function once with the given input, stopping any currently active
22
+ * and register cleanup effects.
23
+ */
24
+ run(...args: TInput extends undefined ? [] : [input: TInput]): this;
25
+ /**
26
+ * Stops the current async operation and run cleanup effects.
27
+ */
28
+ stop(): this;
29
+ [Symbol.dispose](): void;
30
+ /**
31
+ * Starts a new reactive async operation, stopping any currently active one.
32
+ */
33
+ start(...args: TInput extends undefined ? [] : [input: TInput]): this;
34
+ }
35
+ declare function async<TInput = any, TOutput = undefined>(fn: MaybeReactive<(input: TInput) => Promise<TOutput>>): Async<TInput, TOutput> & ((...args: any[]) => TOutput | undefined);
36
+ //#endregion
37
+ export { Async, Fn, async };
@@ -0,0 +1,119 @@
1
+ import { _ as signal, r as resolve, u as effect, y as untracked } from "../signals-BHmWX6ox.mjs";
2
+ import { promise } from "./promise.mjs";
3
+ //#region src/utilities/async.ts
4
+ var Async = class {
5
+ #fn = signal(async () => Promise.resolve(void 0));
6
+ #cleanup = () => {};
7
+ get fn() {
8
+ return this.#fn();
9
+ }
10
+ set fn(fn) {
11
+ this.#fn(resolve(fn));
12
+ }
13
+ #current = signal(promise(() => {}));
14
+ get raw() {
15
+ return this.#current();
16
+ }
17
+ get pending() {
18
+ return this.raw.state === "pending";
19
+ }
20
+ get state() {
21
+ return this.raw.state;
22
+ }
23
+ get value() {
24
+ return this.raw.value;
25
+ }
26
+ get reason() {
27
+ return this.raw.reason;
28
+ }
29
+ get result() {
30
+ return this.raw.result;
31
+ }
32
+ then(onfulfilled, onrejected) {
33
+ return this.raw.then(onfulfilled, onrejected);
34
+ }
35
+ catch(onrejected) {
36
+ return this.raw.catch(onrejected);
37
+ }
38
+ finally(onfinally) {
39
+ return this.raw.finally(onfinally);
40
+ }
41
+ constructor(fn) {
42
+ this.#fn(resolve(fn));
43
+ }
44
+ #execute(...args) {
45
+ const input = args[0];
46
+ const p = promise(this.fn(input));
47
+ this.#current(p);
48
+ return p;
49
+ }
50
+ /**
51
+ * Runs the async function once with the given input, stopping any currently active
52
+ * and register cleanup effects.
53
+ */
54
+ run(...args) {
55
+ this.stop();
56
+ this.#cleanup = effect(() => {
57
+ untracked(() => this.#execute(...args));
58
+ });
59
+ return this;
60
+ }
61
+ /**
62
+ * Stops the current async operation and run cleanup effects.
63
+ */
64
+ stop() {
65
+ this.#cleanup();
66
+ this.#cleanup = () => {};
67
+ return this;
68
+ }
69
+ [Symbol.dispose]() {
70
+ this.stop();
71
+ }
72
+ /**
73
+ * Starts a new reactive async operation, stopping any currently active one.
74
+ */
75
+ start(...args) {
76
+ this.stop();
77
+ this.#cleanup = effect(() => {
78
+ this.#execute(...args);
79
+ });
80
+ return this;
81
+ }
82
+ };
83
+ const ASYNC_KEYS = new Set([
84
+ "then",
85
+ "catch",
86
+ "finally",
87
+ "state",
88
+ "value",
89
+ "reason",
90
+ "result",
91
+ "pending",
92
+ "start",
93
+ "stop",
94
+ "run",
95
+ "fn",
96
+ "raw",
97
+ Symbol.dispose
98
+ ]);
99
+ function async(fn) {
100
+ const inst = new Async(fn);
101
+ const signal = () => inst.result;
102
+ return new Proxy(signal, {
103
+ apply(_target, _thisArg, args) {
104
+ return inst.result;
105
+ },
106
+ get(_target, prop, receiver) {
107
+ if (ASYNC_KEYS.has(prop)) {
108
+ const val = inst[prop];
109
+ return typeof val === "function" ? val.bind(inst) : val;
110
+ }
111
+ return Reflect.get(signal, prop, receiver);
112
+ },
113
+ getPrototypeOf() {
114
+ return Async.prototype;
115
+ }
116
+ });
117
+ }
118
+ //#endregion
119
+ export { Async, async };
@@ -0,0 +1 @@
1
+ export { };
@@ -0,0 +1,273 @@
1
+ import { _ as signal, g as onCleanup, u as effect } from "../signals-BHmWX6ox.mjs";
2
+ import { n as vi, o as describe, s as it, t as globalExpect } from "../test.BmQO5GaM-ANkhHvbr.mjs";
3
+ import { Async, async } from "./async.mjs";
4
+ import { createInterval } from "./interval.mjs";
5
+ //#region src/utilities/async.test.ts
6
+ function deferred() {
7
+ let resolve;
8
+ let reject;
9
+ return {
10
+ promise: new Promise((res, rej) => {
11
+ resolve = res;
12
+ reject = rej;
13
+ }),
14
+ resolve,
15
+ reject
16
+ };
17
+ }
18
+ describe("Async", () => {
19
+ it("is reactive as a computed signal (effect)", async () => {
20
+ const op = async((x) => Promise.resolve(x * 2));
21
+ const values = [];
22
+ const stop = effect(() => {
23
+ values.push(op());
24
+ });
25
+ op.run(2);
26
+ await op;
27
+ op.run(3);
28
+ await op;
29
+ stop();
30
+ globalExpect(values).toContain(void 0);
31
+ globalExpect(values).toContain(4);
32
+ globalExpect(values).toContain(6);
33
+ globalExpect(values.at(-1)).toBe(6);
34
+ });
35
+ it("is callable as a signal and returns the current result", async () => {
36
+ const op = async(() => Promise.resolve(123));
37
+ globalExpect(op()).toBeUndefined();
38
+ op.start();
39
+ await op;
40
+ globalExpect(op()).toBe(123);
41
+ const err = /* @__PURE__ */ new Error("fail");
42
+ const op2 = async(() => Promise.reject(err));
43
+ op2.start();
44
+ await op2.catch(() => {});
45
+ globalExpect(op2()).toBe(err);
46
+ });
47
+ it("starts in pending state with no value or reason", () => {
48
+ const op = async(() => Promise.resolve(1));
49
+ globalExpect(op.state).toBe("pending");
50
+ globalExpect(op.pending).toBe(true);
51
+ globalExpect(op.value).toBeUndefined();
52
+ globalExpect(op.reason).toBeUndefined();
53
+ globalExpect(op.result).toBeUndefined();
54
+ globalExpect(op.pending).toBe(op.state === "pending");
55
+ });
56
+ it("transitions to fulfilled after start()", async () => {
57
+ const op = async(() => Promise.resolve(42));
58
+ op.start();
59
+ await op;
60
+ globalExpect(op.state).toBe("fulfilled");
61
+ globalExpect(op.pending).toBe(false);
62
+ globalExpect(op.value).toBe(42);
63
+ globalExpect(op.reason).toBeUndefined();
64
+ globalExpect(op.result).toBe(42);
65
+ globalExpect(op.result).toBe(42);
66
+ });
67
+ it("transitions to rejected after start()", async () => {
68
+ const err = /* @__PURE__ */ new Error("fail");
69
+ const op = async(() => Promise.reject(err));
70
+ op.start();
71
+ await op.catch(() => {});
72
+ globalExpect(op.state).toBe("rejected");
73
+ globalExpect(op.pending).toBe(false);
74
+ globalExpect(op.reason).toBe(err);
75
+ globalExpect(op.value).toBeUndefined();
76
+ globalExpect(op.result).toBe(err);
77
+ globalExpect(op.reason).toBe(err);
78
+ });
79
+ it("await op.run(args) resolves to the fulfilled value", async () => {
80
+ const op = async((x) => Promise.resolve(x + 100));
81
+ globalExpect(await op.run(23)).toBe(123);
82
+ globalExpect(op.value).toBe(123);
83
+ globalExpect(op.state).toBe("fulfilled");
84
+ });
85
+ it("run() resolves to the fulfilled value", async () => {
86
+ globalExpect(await async((x) => Promise.resolve(x * 2)).run(5)).toBe(10);
87
+ });
88
+ it("await op resolves to the fulfilled value", async () => {
89
+ const op = async(() => Promise.resolve(7));
90
+ op.start();
91
+ globalExpect(await op).toBe(7);
92
+ });
93
+ it("await op.catch() resolves on rejection", async () => {
94
+ const err = /* @__PURE__ */ new Error("fail");
95
+ const op = async(() => Promise.reject(err));
96
+ op.start();
97
+ globalExpect(await op.catch((e) => e)).toBe(err);
98
+ });
99
+ it("delegates .then()", async () => {
100
+ const op = async(() => Promise.resolve(3));
101
+ op.start();
102
+ globalExpect(await op.then((v) => (v ?? 0) * 2)).toBe(6);
103
+ });
104
+ it("instanceof Async", () => {
105
+ globalExpect(async(() => Promise.resolve()) instanceof Async).toBe(true);
106
+ });
107
+ it("is reactive — effect reruns when state transitions to fulfilled", async () => {
108
+ const op = async(() => Promise.resolve(10));
109
+ const snapshots = [];
110
+ const stop = effect(() => {
111
+ snapshots.push(op.state);
112
+ });
113
+ op.start();
114
+ await op;
115
+ stop();
116
+ globalExpect(snapshots).toContain("pending");
117
+ globalExpect(snapshots.at(-1)).toBe("fulfilled");
118
+ });
119
+ it("is reactive — effect sees value after fulfillment", async () => {
120
+ const op = async(() => Promise.resolve(99));
121
+ const values = [];
122
+ const stop = effect(() => {
123
+ values.push(op.value);
124
+ });
125
+ op.start();
126
+ await op;
127
+ stop();
128
+ globalExpect(values).toEqual([
129
+ void 0,
130
+ void 0,
131
+ 99
132
+ ]);
133
+ });
134
+ it("is reactive — effect reruns on rejection", async () => {
135
+ const err = /* @__PURE__ */ new Error("x");
136
+ const op = async(() => Promise.reject(err));
137
+ const reasons = [];
138
+ const stop = effect(() => {
139
+ reasons.push(op.reason);
140
+ });
141
+ op.start();
142
+ await op.catch(() => {});
143
+ stop();
144
+ globalExpect(reasons).toEqual([
145
+ void 0,
146
+ void 0,
147
+ err
148
+ ]);
149
+ });
150
+ it("start() re-runs when a signal read inside fn changes", async () => {
151
+ const id = signal(1);
152
+ const calls = [];
153
+ const op = async(() => {
154
+ const current = id();
155
+ calls.push(current);
156
+ return Promise.resolve(current);
157
+ });
158
+ op.start();
159
+ await op;
160
+ id(2);
161
+ await op;
162
+ op.stop();
163
+ globalExpect(calls).toEqual([1, 2]);
164
+ });
165
+ it("run() is not reactive — signal change does not re-run", async () => {
166
+ const id = signal(1);
167
+ const calls = [];
168
+ const op = async(() => {
169
+ const current = id();
170
+ calls.push(current);
171
+ return Promise.resolve(current);
172
+ });
173
+ op.run();
174
+ await op;
175
+ id(2);
176
+ await Promise.resolve();
177
+ op.stop();
178
+ globalExpect(calls).toEqual([1]);
179
+ });
180
+ it("start() resets to pending on re-run", async () => {
181
+ const d = deferred();
182
+ const id = signal(1);
183
+ const op = async(() => {
184
+ id();
185
+ return d.promise;
186
+ });
187
+ op.start();
188
+ globalExpect(op.pending).toBe(true);
189
+ id(2);
190
+ globalExpect(op.pending).toBe(true);
191
+ d.resolve(5);
192
+ op.stop();
193
+ });
194
+ it("stop() prevents further reactive reruns", async () => {
195
+ const id = signal(1);
196
+ const calls = [];
197
+ const op = async(() => {
198
+ calls.push(id());
199
+ return Promise.resolve(id());
200
+ });
201
+ op.start();
202
+ await op;
203
+ op.stop();
204
+ id(2);
205
+ await Promise.resolve();
206
+ globalExpect(calls).toEqual([1]);
207
+ });
208
+ it("onCleanup inside run() fires when stop() is called", () => {
209
+ const cleaned = [];
210
+ const op = async(() => {
211
+ onCleanup(() => cleaned.push(1));
212
+ return Promise.resolve();
213
+ });
214
+ op.run();
215
+ globalExpect(cleaned).toEqual([]);
216
+ op.stop();
217
+ globalExpect(cleaned).toEqual([1]);
218
+ });
219
+ it("onCleanup inside start() fires on each re-run", async () => {
220
+ const id = signal(1);
221
+ const cleaned = [];
222
+ const op = async(() => {
223
+ const current = id();
224
+ onCleanup(() => cleaned.push(current));
225
+ return Promise.resolve();
226
+ });
227
+ op.start();
228
+ await op;
229
+ id(2);
230
+ await op;
231
+ op.stop();
232
+ globalExpect(cleaned[0]).toBe(1);
233
+ });
234
+ it("re-runs when createInterval ticks", async () => {
235
+ vi.useFakeTimers();
236
+ const timer = createInterval(1e3);
237
+ const calls = [];
238
+ const op = async(() => {
239
+ timer.timestamp();
240
+ calls.push(calls.length);
241
+ return Promise.resolve(calls.length);
242
+ });
243
+ op.start();
244
+ await op;
245
+ globalExpect(calls.length).toBe(1);
246
+ vi.advanceTimersByTime(1e3);
247
+ await op;
248
+ globalExpect(calls.length).toBe(2);
249
+ op.stop();
250
+ timer.stop();
251
+ vi.useRealTimers();
252
+ });
253
+ it("state and value update atomically — no inconsistent intermediate state", async () => {
254
+ const op = async(() => Promise.resolve(7));
255
+ const snapshots = [];
256
+ const stop = effect(() => {
257
+ snapshots.push({
258
+ state: op.state,
259
+ value: op.value
260
+ });
261
+ });
262
+ op.start();
263
+ await op;
264
+ stop();
265
+ globalExpect(snapshots.find((s) => s.state === "fulfilled" && s.value === void 0)).toBeUndefined();
266
+ globalExpect(snapshots.at(-1)).toEqual({
267
+ state: "fulfilled",
268
+ value: 7
269
+ });
270
+ });
271
+ });
272
+ //#endregion
273
+ export {};
@@ -2,17 +2,18 @@ import { t as Computed } from "../index-DUshSQ_6.mjs";
2
2
 
3
3
  //#region src/utilities/interval.d.ts
4
4
  type IntervalResult = {
5
- isRunning: Computed<boolean>;
5
+ timestamp: Computed<number>;
6
+ pending: Computed<boolean>;
6
7
  start(): void;
7
8
  stop(): void;
8
9
  reset(): void;
9
10
  } & Disposable;
11
+ type Fn = () => void;
12
+ type Delay = number | (() => number);
10
13
  /**
11
14
  * Pausable `setInterval` wrapper. Starts running immediately on creation.
12
- *
13
- * @param callback - Called on each tick.
14
- * @param delay - Interval delay in ms (or a reactive getter).
15
15
  */
16
- declare function createInterval(callback: () => void, delay: number | (() => number)): IntervalResult;
16
+ declare function createInterval(delay: Delay): IntervalResult;
17
+ declare function createInterval(callback: Fn, delay: Delay): IntervalResult;
17
18
  //#endregion
18
19
  export { createInterval };
@@ -1,24 +1,23 @@
1
1
  import { _ as signal, g as onCleanup } from "../signals-BHmWX6ox.mjs";
2
2
  //#region src/utilities/interval.ts
3
- /**
4
- * Pausable `setInterval` wrapper. Starts running immediately on creation.
5
- *
6
- * @param callback - Called on each tick.
7
- * @param delay - Interval delay in ms (or a reactive getter).
8
- */
9
- function createInterval(callback, delay) {
10
- const isRunning = signal(true);
3
+ function createInterval(arg1, arg2) {
4
+ const [callback, delay] = resolveArgs(arg1, arg2);
5
+ const pending = signal(true);
11
6
  let id;
7
+ const timestamp = signal(Date.now());
12
8
  const getDelay = typeof delay === "function" ? delay : () => delay;
13
9
  const start = () => {
14
10
  if (id !== void 0) return;
15
- isRunning(true);
16
- id = setInterval(() => callback(), getDelay());
11
+ pending(true);
12
+ id = setInterval(() => {
13
+ callback?.();
14
+ timestamp(Date.now());
15
+ }, getDelay());
17
16
  };
18
17
  const stop = () => {
19
18
  clearInterval(id);
20
19
  id = void 0;
21
- isRunning(false);
20
+ pending(false);
22
21
  };
23
22
  const reset = () => {
24
23
  stop();
@@ -28,12 +27,17 @@ function createInterval(callback, delay) {
28
27
  const cleanup = () => stop();
29
28
  onCleanup(cleanup);
30
29
  return {
31
- isRunning,
30
+ timestamp,
31
+ pending,
32
32
  start,
33
33
  stop,
34
34
  reset,
35
35
  [Symbol.dispose]: cleanup
36
36
  };
37
37
  }
38
+ function resolveArgs(arg1, arg2) {
39
+ if (arg2 === void 0) return [void 0, arg1];
40
+ return [arg1, arg2];
41
+ }
38
42
  //#endregion
39
43
  export { createInterval };
@@ -26,7 +26,7 @@ describe("createInterval", () => {
26
26
  iv.stop();
27
27
  vi.advanceTimersByTime(500);
28
28
  globalExpect(cb).toHaveBeenCalledTimes(0);
29
- globalExpect(iv.isRunning()).toBe(false);
29
+ globalExpect(iv.pending()).toBe(false);
30
30
  });
31
31
  it("start() resumes after stop", () => {
32
32
  vi.useFakeTimers();
@@ -63,6 +63,35 @@ describe("createInterval", () => {
63
63
  vi.advanceTimersByTime(500);
64
64
  globalExpect(cb).toHaveBeenCalledTimes(0);
65
65
  });
66
+ it("no-callback form updates timestamp on each tick", () => {
67
+ vi.useFakeTimers();
68
+ let iv;
69
+ effectScope(() => {
70
+ iv = createInterval(100);
71
+ });
72
+ const before = iv.timestamp();
73
+ vi.advanceTimersByTime(100);
74
+ globalExpect(iv.timestamp()).toBeGreaterThan(before);
75
+ });
76
+ it("dynamic delay form is called each tick", () => {
77
+ vi.useFakeTimers();
78
+ const cb = vi.fn();
79
+ const getDelay = vi.fn().mockReturnValue(100);
80
+ effectScope(() => {
81
+ createInterval(cb, getDelay);
82
+ });
83
+ vi.advanceTimersByTime(300);
84
+ globalExpect(cb).toHaveBeenCalledTimes(3);
85
+ });
86
+ it("stops when scope is disposed", () => {
87
+ vi.useFakeTimers();
88
+ const cb = vi.fn();
89
+ effectScope(() => {
90
+ createInterval(cb, 100);
91
+ })();
92
+ vi.advanceTimersByTime(500);
93
+ globalExpect(cb).toHaveBeenCalledTimes(0);
94
+ });
66
95
  });
67
96
  //#endregion
68
97
  export {};
@@ -0,0 +1,10 @@
1
+ import { t as Computed } from "../index-DUshSQ_6.mjs";
2
+
3
+ //#region src/utilities/network.d.ts
4
+ /**
5
+ * Singleton `Computed<boolean>` — `true` when `navigator.onLine` is true.
6
+ * Reacts to `online` / `offline` window events.
7
+ */
8
+ declare const online: Computed<boolean>;
9
+ //#endregion
10
+ export { online };
@@ -0,0 +1,17 @@
1
+ import { _ as signal } from "../signals-BHmWX6ox.mjs";
2
+ import { on } from "./event-listener.mjs";
3
+ //#region src/utilities/network.ts
4
+ function createOnline() {
5
+ const value = signal(typeof navigator !== "undefined" ? navigator.onLine : true);
6
+ const update = () => value(navigator.onLine);
7
+ on(window, "online", update);
8
+ on(window, "offline", update);
9
+ return value;
10
+ }
11
+ /**
12
+ * Singleton `Computed<boolean>` — `true` when `navigator.onLine` is true.
13
+ * Reacts to `online` / `offline` window events.
14
+ */
15
+ const online = createOnline();
16
+ //#endregion
17
+ export { online };
@@ -0,0 +1 @@
1
+ export { };
@@ -0,0 +1,29 @@
1
+ import { n as vi, o as describe, r as afterEach, s as it, t as globalExpect } from "../test.BmQO5GaM-ANkhHvbr.mjs";
2
+ import { online } from "./network.mjs";
3
+ //#region src/utilities/network.test.ts
4
+ afterEach(() => {
5
+ vi.restoreAllMocks();
6
+ });
7
+ describe("online", () => {
8
+ it("reflects navigator.onLine", () => {
9
+ globalExpect(online()).toBe(navigator.onLine);
10
+ });
11
+ it("becomes false on offline event", () => {
12
+ Object.defineProperty(navigator, "onLine", {
13
+ configurable: true,
14
+ get: () => false
15
+ });
16
+ window.dispatchEvent(new Event("offline"));
17
+ globalExpect(online()).toBe(false);
18
+ });
19
+ it("becomes true on online event", () => {
20
+ Object.defineProperty(navigator, "onLine", {
21
+ configurable: true,
22
+ get: () => true
23
+ });
24
+ window.dispatchEvent(new Event("online"));
25
+ globalExpect(online()).toBe(true);
26
+ });
27
+ });
28
+ //#endregion
29
+ export {};
@@ -0,0 +1,56 @@
1
+ import { t as Computed } from "../index-DUshSQ_6.mjs";
2
+
3
+ //#region src/utilities/promise.d.ts
4
+ /**
5
+ * A `Promise` subclass that exposes its state as reactive signals.
6
+ *
7
+ * Prefer the {@link promise} factory for most use cases — it returns a
8
+ * `ComputedPromise` that is both awaitable and callable as a signal.
9
+ * Use `ReactivePromise` directly when you need the lower-level class —
10
+ * for example, to wrap a promise and expose `.state`, `.value`, `.reason`,
11
+ * and `.result` without the `Computed` callable interface.
12
+ *
13
+ * @example
14
+ * ```ts
15
+ * const rp = ReactivePromise.from(fetch("/api/data"));
16
+ *
17
+ * effect(() => {
18
+ * if (rp.state === "fulfilled") console.log(rp.value);
19
+ * });
20
+ * ```
21
+ */
22
+ declare class ReactivePromise<T, E = unknown> extends Promise<T> {
23
+ #private;
24
+ get state(): "pending" | "fulfilled" | "rejected";
25
+ get value(): T | undefined;
26
+ get reason(): E | undefined;
27
+ get result(): T | E | undefined;
28
+ constructor(executor: (resolve: (value: T | PromiseLike<T>) => void, reject: (reason?: any) => void) => void);
29
+ static from<T, E = unknown>(p: Promise<T>): ReactivePromise<T, E>;
30
+ }
31
+ type ComputedPromise<T, E = unknown> = ReactivePromise<T, E> & Computed<T | E | undefined>;
32
+ type Executor<T, E = unknown> = (resolve: (value: T | PromiseLike<T>) => void, reject: (reason?: E) => void) => void;
33
+ /**
34
+ * Wraps a promise, executor, or `ReactivePromise` into a `ComputedPromise` —
35
+ * an object that is both awaitable like a regular Promise and reactive like a
36
+ * `Computed` signal.
37
+ *
38
+ * **Awaitable:** `await promise(fetch(...))` resolves to the fulfilled value,
39
+ * or rejects with the rejection reason, just like a native Promise.
40
+ *
41
+ * **Reactive:** calling the returned value as a function (`cp()`) reads the
42
+ * current result inside an `effect` or `computed`, tracking it as a dependency.
43
+ * Equivalent to `.result` — returns `undefined` while pending, the fulfilled
44
+ * value when resolved, or the rejection reason when rejected.
45
+ *
46
+ * Reactive state is also accessible via:
47
+ * - `.state` — `"pending" | "fulfilled" | "rejected"`
48
+ * - `.value` — the resolved value (or `undefined` while pending)
49
+ * - `.reason` — the rejection reason (or `undefined` while pending/fulfilled)
50
+ * - `.result` — `T | E | undefined`; the resolved value, rejection reason, or `undefined` while pending
51
+ */
52
+ declare function promise<T, E = unknown>(p: ReactivePromise<T>): ComputedPromise<T, E>;
53
+ declare function promise<T, E = unknown>(p: Promise<T>): ComputedPromise<T, E>;
54
+ declare function promise<T, E = unknown>(executor: Executor<T, E>): ComputedPromise<T, E>;
55
+ //#endregion
56
+ export { ComputedPromise, ReactivePromise, promise };
@@ -0,0 +1,101 @@
1
+ import { _ as signal, c as batch, l as computed } from "../signals-BHmWX6ox.mjs";
2
+ //#region src/utilities/promise.ts
3
+ /**
4
+ * A `Promise` subclass that exposes its state as reactive signals.
5
+ *
6
+ * Prefer the {@link promise} factory for most use cases — it returns a
7
+ * `ComputedPromise` that is both awaitable and callable as a signal.
8
+ * Use `ReactivePromise` directly when you need the lower-level class —
9
+ * for example, to wrap a promise and expose `.state`, `.value`, `.reason`,
10
+ * and `.result` without the `Computed` callable interface.
11
+ *
12
+ * @example
13
+ * ```ts
14
+ * const rp = ReactivePromise.from(fetch("/api/data"));
15
+ *
16
+ * effect(() => {
17
+ * if (rp.state === "fulfilled") console.log(rp.value);
18
+ * });
19
+ * ```
20
+ */
21
+ var ReactivePromise = class ReactivePromise extends Promise {
22
+ #state = signal("pending");
23
+ #value = signal(void 0);
24
+ #reason = signal(void 0);
25
+ #result = computed(() => {
26
+ const state = this.#state();
27
+ if (state === "fulfilled") return this.#value();
28
+ if (state === "rejected") return this.#reason();
29
+ });
30
+ get state() {
31
+ return this.#state();
32
+ }
33
+ get value() {
34
+ return this.#value();
35
+ }
36
+ get reason() {
37
+ return this.#reason();
38
+ }
39
+ get result() {
40
+ return this.#result();
41
+ }
42
+ constructor(executor) {
43
+ super((res, rej) => {
44
+ executor(async (value) => {
45
+ const resolved = await value;
46
+ batch(() => {
47
+ this.#state("fulfilled");
48
+ this.#value(resolved);
49
+ });
50
+ res(value);
51
+ }, async (_reason) => {
52
+ const reason = await _reason;
53
+ batch(() => {
54
+ this.#state("rejected");
55
+ this.#reason(reason);
56
+ });
57
+ rej(reason);
58
+ });
59
+ });
60
+ }
61
+ static from(p) {
62
+ return new ReactivePromise((resolve, reject) => {
63
+ p.then(resolve).catch(reject);
64
+ });
65
+ }
66
+ };
67
+ const PROMISE_KEYS = new Set([
68
+ "then",
69
+ "catch",
70
+ "finally",
71
+ "state",
72
+ "value",
73
+ "reason",
74
+ "result"
75
+ ]);
76
+ function resolvePromise(from) {
77
+ if (from instanceof ReactivePromise) return from;
78
+ if (from instanceof Promise) return ReactivePromise.from(from);
79
+ return new ReactivePromise(from);
80
+ }
81
+ function promise(from) {
82
+ const p = resolvePromise(from);
83
+ const $value = computed(() => p.result);
84
+ return new Proxy($value, {
85
+ apply() {
86
+ return $value();
87
+ },
88
+ get(target, prop, receiver) {
89
+ if (PROMISE_KEYS.has(prop)) {
90
+ const val = p[prop];
91
+ return typeof val === "function" ? val.bind(p) : val;
92
+ }
93
+ return Reflect.get(target, prop, receiver);
94
+ },
95
+ getPrototypeOf() {
96
+ return ReactivePromise.prototype;
97
+ }
98
+ });
99
+ }
100
+ //#endregion
101
+ export { ReactivePromise, promise };
@@ -0,0 +1 @@
1
+ export { };
@@ -0,0 +1,156 @@
1
+ import { u as effect } from "../signals-BHmWX6ox.mjs";
2
+ import { o as describe, s as it, t as globalExpect } from "../test.BmQO5GaM-ANkhHvbr.mjs";
3
+ import { ReactivePromise, promise } from "./promise.mjs";
4
+ //#region src/utilities/promise.test.ts
5
+ describe("ReactivePromise", () => {
6
+ it("starts in pending state", () => {
7
+ const rp = new ReactivePromise(() => {});
8
+ globalExpect(rp.state).toBe("pending");
9
+ globalExpect(rp.value).toBeUndefined();
10
+ globalExpect(rp.reason).toBeUndefined();
11
+ globalExpect(rp.result).toBeUndefined();
12
+ });
13
+ it("transitions to fulfilled", async () => {
14
+ const rp = new ReactivePromise((resolve) => resolve(42));
15
+ await rp;
16
+ globalExpect(rp.state).toBe("fulfilled");
17
+ globalExpect(rp.value).toBe(42);
18
+ globalExpect(rp.reason).toBeUndefined();
19
+ globalExpect(rp.result).toBe(42);
20
+ });
21
+ it("transitions to rejected", async () => {
22
+ const err = /* @__PURE__ */ new Error("fail");
23
+ const rp = new ReactivePromise((_, reject) => reject(err));
24
+ await rp.catch(() => {});
25
+ globalExpect(rp.state).toBe("rejected");
26
+ globalExpect(rp.reason).toBe(err);
27
+ globalExpect(rp.value).toBeUndefined();
28
+ globalExpect(rp.result).toBe(err);
29
+ });
30
+ it("unwraps a PromiseLike value", async () => {
31
+ const rp = new ReactivePromise((resolve) => resolve(Promise.resolve(99)));
32
+ await rp;
33
+ globalExpect(rp.value).toBe(99);
34
+ });
35
+ it("state and value update atomically — no inconsistent intermediate state", async () => {
36
+ const rp = new ReactivePromise((resolve) => resolve(7));
37
+ const snapshots = [];
38
+ const stop = effect(() => {
39
+ snapshots.push({
40
+ state: rp.state,
41
+ value: rp.value
42
+ });
43
+ });
44
+ await rp;
45
+ stop();
46
+ globalExpect(snapshots.find((s) => s.state === "fulfilled" && s.value === void 0)).toBeUndefined();
47
+ globalExpect(snapshots.at(-1)).toEqual({
48
+ state: "fulfilled",
49
+ value: 7
50
+ });
51
+ });
52
+ it("state and reason update atomically on rejection", async () => {
53
+ const err = /* @__PURE__ */ new Error("oops");
54
+ const rp = new ReactivePromise((_, reject) => reject(err));
55
+ const snapshots = [];
56
+ const stop = effect(() => {
57
+ snapshots.push({
58
+ state: rp.state,
59
+ reason: rp.reason
60
+ });
61
+ });
62
+ await rp.catch(() => {});
63
+ stop();
64
+ globalExpect(snapshots.find((s) => s.state === "rejected" && s.reason === void 0)).toBeUndefined();
65
+ globalExpect(snapshots.at(-1)).toEqual({
66
+ state: "rejected",
67
+ reason: err
68
+ });
69
+ });
70
+ it("ReactivePromise.from() wraps an existing Promise", async () => {
71
+ const rp = ReactivePromise.from(Promise.resolve("hello"));
72
+ await rp;
73
+ globalExpect(rp.state).toBe("fulfilled");
74
+ globalExpect(rp.value).toBe("hello");
75
+ });
76
+ });
77
+ describe("promise()", () => {
78
+ it("is instanceof ReactivePromise", () => {
79
+ globalExpect(promise(() => {}) instanceof ReactivePromise).toBe(true);
80
+ });
81
+ it("returns undefined while pending", () => {
82
+ const cp = promise(() => {});
83
+ globalExpect(cp()).toBeUndefined();
84
+ globalExpect(cp.state).toBe("pending");
85
+ });
86
+ it("returns the resolved value when fulfilled", async () => {
87
+ const cp = promise((resolve) => resolve(42));
88
+ await cp;
89
+ globalExpect(cp()).toBe(42);
90
+ });
91
+ it("returns the rejection reason when rejected", async () => {
92
+ const err = /* @__PURE__ */ new Error("fail");
93
+ const cp = promise((_, reject) => reject(err));
94
+ await cp.catch(() => {});
95
+ globalExpect(cp()).toBe(err);
96
+ });
97
+ it("wraps an existing Promise", async () => {
98
+ const cp = promise(Promise.resolve("hi"));
99
+ await cp;
100
+ globalExpect(cp()).toBe("hi");
101
+ });
102
+ it("passes through a ReactivePromise", async () => {
103
+ const cp = promise(new ReactivePromise((resolve) => resolve(5)));
104
+ await cp;
105
+ globalExpect(cp()).toBe(5);
106
+ });
107
+ it("is awaitable — resolves to the fulfilled value", async () => {
108
+ globalExpect(await promise((resolve) => resolve(100))).toBe(100);
109
+ });
110
+ it("is awaitable — rejects on failure", async () => {
111
+ const err = /* @__PURE__ */ new Error("bad");
112
+ const cp = promise((_, reject) => reject(err));
113
+ let caught;
114
+ await cp.catch((e) => {
115
+ caught = e;
116
+ });
117
+ globalExpect(caught).toBe(err);
118
+ });
119
+ it("delegates .then()", async () => {
120
+ globalExpect(await promise((resolve) => resolve(3)).then((v) => v * 2)).toBe(6);
121
+ });
122
+ it("delegates .catch()", async () => {
123
+ const err = /* @__PURE__ */ new Error("caught");
124
+ globalExpect(await promise((_, reject) => reject(err)).catch((e) => e)).toBe(err);
125
+ });
126
+ it("cp.catch handler runs on rejection", async () => {
127
+ const err = /* @__PURE__ */ new Error("caught");
128
+ const cp = promise((_, reject) => reject(err));
129
+ const caught = [];
130
+ await cp.catch((e) => caught.push(e));
131
+ globalExpect(caught).toEqual([err]);
132
+ });
133
+ it("is reactive — effect reruns on fulfillment", async () => {
134
+ const cp = promise((resolve) => resolve(1));
135
+ const values = [];
136
+ const stop = effect(() => {
137
+ values.push(cp.value);
138
+ });
139
+ await cp;
140
+ stop();
141
+ globalExpect(values).toEqual([void 0, 1]);
142
+ });
143
+ it("is reactive — effect reruns on rejection", async () => {
144
+ const err = /* @__PURE__ */ new Error("x");
145
+ const cp = promise((_, reject) => reject(err));
146
+ const results = [];
147
+ const stop = effect(() => {
148
+ results.push(cp());
149
+ });
150
+ await cp.catch(() => {});
151
+ stop();
152
+ globalExpect(results).toEqual([void 0, err]);
153
+ });
154
+ });
155
+ //#endregion
156
+ export {};
@@ -0,0 +1,10 @@
1
+ //#region src/utilities/retry.d.ts
2
+ type RetryDelay = number | ((attempt: number) => number);
3
+ /**
4
+ * Wraps `fn` to retry up to `attempts` times on failure.
5
+ * Delay (if provided) is inserted between failures only — not after the last.
6
+ * Each attempt runs in an effect scope so `onCleanup` inside `fn` fires before each retry.
7
+ */
8
+ declare function retry<T>(fn: () => Promise<T>, attempts: number, delay?: RetryDelay): () => Promise<T>;
9
+ //#endregion
10
+ export { retry };
@@ -0,0 +1,32 @@
1
+ import { u as effect, y as untracked } from "../signals-BHmWX6ox.mjs";
2
+ //#region src/utilities/retry.ts
3
+ /**
4
+ * Wraps `fn` to retry up to `attempts` times on failure.
5
+ * Delay (if provided) is inserted between failures only — not after the last.
6
+ * Each attempt runs in an effect scope so `onCleanup` inside `fn` fires before each retry.
7
+ */
8
+ function retry(fn, attempts, delay) {
9
+ return async () => {
10
+ let last;
11
+ for (let i = 0; i < attempts; i++) {
12
+ let stop = () => {};
13
+ try {
14
+ const result = await new Promise((res, rej) => {
15
+ stop = effect(() => untracked(() => fn().then(res, rej)));
16
+ });
17
+ stop();
18
+ return result;
19
+ } catch (err) {
20
+ stop();
21
+ last = err;
22
+ if (i < attempts - 1 && delay !== void 0) {
23
+ const ms = typeof delay === "function" ? delay(i) : delay;
24
+ await new Promise((r) => setTimeout(r, ms));
25
+ }
26
+ }
27
+ }
28
+ throw last;
29
+ };
30
+ }
31
+ //#endregion
32
+ export { retry };
@@ -0,0 +1 @@
1
+ export { };
@@ -0,0 +1,83 @@
1
+ import { g as onCleanup } from "../signals-BHmWX6ox.mjs";
2
+ import { n as vi, o as describe, s as it, t as globalExpect } from "../test.BmQO5GaM-ANkhHvbr.mjs";
3
+ import { retry } from "./retry.mjs";
4
+ //#region src/utilities/retry.test.ts
5
+ describe("retry", () => {
6
+ it("resolves immediately on first success", async () => {
7
+ globalExpect(await retry(() => Promise.resolve(42), 3)()).toBe(42);
8
+ });
9
+ it("retries after failure and resolves on later success", async () => {
10
+ let calls = 0;
11
+ globalExpect(await retry(() => {
12
+ calls++;
13
+ if (calls < 3) return Promise.reject(/* @__PURE__ */ new Error("fail"));
14
+ return Promise.resolve(calls);
15
+ }, 3)()).toBe(3);
16
+ globalExpect(calls).toBe(3);
17
+ });
18
+ it("rejects after exhausting all attempts", async () => {
19
+ const err = /* @__PURE__ */ new Error("always fails");
20
+ await globalExpect(retry(() => Promise.reject(err), 3)()).rejects.toBe(err);
21
+ });
22
+ it("rejects with the last error", async () => {
23
+ let i = 0;
24
+ const errors = [
25
+ /* @__PURE__ */ new Error("a"),
26
+ /* @__PURE__ */ new Error("b"),
27
+ /* @__PURE__ */ new Error("c")
28
+ ];
29
+ await globalExpect(retry(() => Promise.reject(errors[i++]), 3)()).rejects.toBe(errors[2]);
30
+ });
31
+ it("delays between failures but not after the last", async () => {
32
+ const delayFn = vi.fn().mockReturnValue(0);
33
+ await retry(() => Promise.reject(/* @__PURE__ */ new Error("x")), 3, delayFn)().catch(() => {});
34
+ globalExpect(delayFn).toHaveBeenCalledTimes(2);
35
+ });
36
+ it("supports dynamic delay via function — passes attempt index", async () => {
37
+ const delayFn = vi.fn().mockReturnValue(0);
38
+ await retry(() => Promise.reject(/* @__PURE__ */ new Error("x")), 3, delayFn)().catch(() => {});
39
+ globalExpect(delayFn).toHaveBeenNthCalledWith(1, 0);
40
+ globalExpect(delayFn).toHaveBeenNthCalledWith(2, 1);
41
+ });
42
+ it("onCleanup inside fn fires before each retry", async () => {
43
+ const cleaned = [];
44
+ let attempt = 0;
45
+ await retry(() => {
46
+ const i = attempt++;
47
+ onCleanup(() => cleaned.push(i));
48
+ if (i < 2) return Promise.reject(/* @__PURE__ */ new Error("fail"));
49
+ return Promise.resolve(i);
50
+ }, 3)();
51
+ globalExpect(cleaned).toContain(0);
52
+ globalExpect(cleaned).toContain(1);
53
+ });
54
+ it("composes with async()", async () => {
55
+ const asyncOp = (await import("./async.mjs")).async;
56
+ let calls = 0;
57
+ const op = asyncOp(() => retry(() => {
58
+ calls++;
59
+ if (calls < 2) return Promise.reject(/* @__PURE__ */ new Error("fail"));
60
+ return Promise.resolve(calls);
61
+ }, 3)());
62
+ op.start();
63
+ await op;
64
+ globalExpect(op.value).toBe(2);
65
+ op.stop();
66
+ });
67
+ it("AbortController cleanup fires between retries when composed with async()", async () => {
68
+ const asyncOp = (await import("./async.mjs")).async;
69
+ const aborted = [];
70
+ let attempt = 0;
71
+ const op = asyncOp(() => {
72
+ const i = attempt++;
73
+ onCleanup(() => aborted.push(i));
74
+ if (i < 2) return Promise.reject(/* @__PURE__ */ new Error("fail"));
75
+ return Promise.resolve(i);
76
+ });
77
+ op.start();
78
+ await op.catch(() => {});
79
+ op.stop();
80
+ });
81
+ });
82
+ //#endregion
83
+ export {};
@@ -2,7 +2,7 @@ import { t as Computed } from "../index-DUshSQ_6.mjs";
2
2
 
3
3
  //#region src/utilities/timeout.d.ts
4
4
  type TimeoutResult = {
5
- isRunning: Computed<boolean>;
5
+ pending: Computed<boolean>;
6
6
  start(): void;
7
7
  stop(): void;
8
8
  reset(): void;
@@ -6,19 +6,19 @@ import { _ as signal, g as onCleanup } from "../signals-BHmWX6ox.mjs";
6
6
  * unless `immediate` is set to `false`.
7
7
  */
8
8
  function createTimeout(callback, delay, immediate = true) {
9
- const isRunning = signal(false);
9
+ const pending = signal(false);
10
10
  let id;
11
11
  const getDelay = typeof delay === "function" ? delay : () => delay;
12
12
  const stop = () => {
13
13
  clearTimeout(id);
14
14
  id = void 0;
15
- isRunning(false);
15
+ pending(false);
16
16
  };
17
17
  const start = () => {
18
- if (isRunning()) return;
19
- isRunning(true);
18
+ if (pending()) return;
19
+ pending(true);
20
20
  id = setTimeout(() => {
21
- isRunning(false);
21
+ pending(false);
22
22
  id = void 0;
23
23
  callback();
24
24
  }, getDelay());
@@ -31,7 +31,7 @@ function createTimeout(callback, delay, immediate = true) {
31
31
  const cleanup = () => stop();
32
32
  onCleanup(cleanup);
33
33
  return {
34
- isRunning,
34
+ pending,
35
35
  start,
36
36
  stop,
37
37
  reset,
@@ -14,7 +14,7 @@ describe("createTimeout", () => {
14
14
  effectScope(() => {
15
15
  t = createTimeout(cb, 500);
16
16
  });
17
- globalExpect(t.isRunning()).toBe(true);
17
+ globalExpect(t.pending()).toBe(true);
18
18
  });
19
19
  it("fires callback after delay", () => {
20
20
  vi.useFakeTimers();
@@ -25,14 +25,14 @@ describe("createTimeout", () => {
25
25
  vi.advanceTimersByTime(600);
26
26
  globalExpect(cb).toHaveBeenCalledOnce();
27
27
  });
28
- it("isRunning becomes false after firing", () => {
28
+ it("pending becomes false after firing", () => {
29
29
  vi.useFakeTimers();
30
30
  let t;
31
31
  effectScope(() => {
32
32
  t = createTimeout(vi.fn(), 200);
33
33
  });
34
34
  vi.advanceTimersByTime(300);
35
- globalExpect(t.isRunning()).toBe(false);
35
+ globalExpect(t.pending()).toBe(false);
36
36
  });
37
37
  it("stop() cancels the timeout", () => {
38
38
  vi.useFakeTimers();
@@ -0,0 +1,10 @@
1
+ import { t as Computed } from "../index-DUshSQ_6.mjs";
2
+
3
+ //#region src/utilities/window-focus.d.ts
4
+ /**
5
+ * Singleton `Computed<boolean>` — `true` while the browser window has focus.
6
+ * Reacts to `focus` / `blur` window events.
7
+ */
8
+ declare const windowFocused: Computed<boolean>;
9
+ //#endregion
10
+ export { windowFocused };
@@ -0,0 +1,16 @@
1
+ import { _ as signal } from "../signals-BHmWX6ox.mjs";
2
+ import { on } from "./event-listener.mjs";
3
+ //#region src/utilities/window-focus.ts
4
+ function createWindowFocused() {
5
+ const value = signal(typeof document !== "undefined" ? document.hasFocus() : true);
6
+ on(window, "focus", () => value(true));
7
+ on(window, "blur", () => value(false));
8
+ return value;
9
+ }
10
+ /**
11
+ * Singleton `Computed<boolean>` — `true` while the browser window has focus.
12
+ * Reacts to `focus` / `blur` window events.
13
+ */
14
+ const windowFocused = createWindowFocused();
15
+ //#endregion
16
+ export { windowFocused };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "elements-kit",
3
3
  "type": "module",
4
- "version": "0.0.13",
4
+ "version": "0.0.14",
5
5
  "description": "A lightweight reactive UI library that transforms native HTMLElements into reactive components with signals. Ideal for framework-agnostic applications and web components.",
6
6
  "keywords": [
7
7
  "webcomponents",