@praxisjs/composables 1.2.0 → 1.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/dist/browser.d.ts +41 -18
  3. package/dist/browser.d.ts.map +1 -1
  4. package/dist/browser.js +114 -112
  5. package/dist/browser.js.map +1 -1
  6. package/dist/dom.d.ts +24 -16
  7. package/dist/dom.d.ts.map +1 -1
  8. package/dist/dom.js +75 -78
  9. package/dist/dom.js.map +1 -1
  10. package/dist/list.d.ts +4 -1
  11. package/dist/list.d.ts.map +1 -1
  12. package/dist/list.js +29 -11
  13. package/dist/list.js.map +1 -1
  14. package/dist/upload.d.ts.map +1 -1
  15. package/dist/upload.js +32 -12
  16. package/dist/upload.js.map +1 -1
  17. package/dist/utilities.d.ts +13 -1
  18. package/dist/utilities.d.ts.map +1 -1
  19. package/dist/utilities.js +88 -26
  20. package/dist/utilities.js.map +1 -1
  21. package/package.json +14 -5
  22. package/src/browser.ts +129 -113
  23. package/src/dom.ts +77 -79
  24. package/src/list.ts +29 -10
  25. package/src/upload.ts +30 -11
  26. package/src/utilities.ts +101 -24
  27. package/dist/__tests__/browser.test.d.ts +0 -2
  28. package/dist/__tests__/browser.test.d.ts.map +0 -1
  29. package/dist/__tests__/browser.test.js +0 -333
  30. package/dist/__tests__/browser.test.js.map +0 -1
  31. package/dist/__tests__/dom.test.d.ts +0 -2
  32. package/dist/__tests__/dom.test.d.ts.map +0 -1
  33. package/dist/__tests__/dom.test.js +0 -416
  34. package/dist/__tests__/dom.test.js.map +0 -1
  35. package/dist/__tests__/list.test.d.ts +0 -2
  36. package/dist/__tests__/list.test.d.ts.map +0 -1
  37. package/dist/__tests__/list.test.js +0 -156
  38. package/dist/__tests__/list.test.js.map +0 -1
  39. package/dist/__tests__/upload.test.d.ts +0 -2
  40. package/dist/__tests__/upload.test.d.ts.map +0 -1
  41. package/dist/__tests__/upload.test.js +0 -531
  42. package/dist/__tests__/upload.test.js.map +0 -1
  43. package/dist/__tests__/utilities.test.d.ts +0 -2
  44. package/dist/__tests__/utilities.test.d.ts.map +0 -1
  45. package/dist/__tests__/utilities.test.js +0 -251
  46. package/dist/__tests__/utilities.test.js.map +0 -1
  47. package/src/__tests__/browser.test.ts +0 -395
  48. package/src/__tests__/dom.test.ts +0 -515
  49. package/src/__tests__/list.test.ts +0 -210
  50. package/src/__tests__/upload.test.ts +0 -639
  51. package/src/__tests__/utilities.test.ts +0 -327
  52. package/tsconfig.json +0 -8
package/src/dom.ts CHANGED
@@ -1,28 +1,36 @@
1
1
  import { Composable } from "@praxisjs/core";
2
- import { signal } from "@praxisjs/core/internal";
2
+ import { endBatch, signal, startBatch } from "@praxisjs/core/internal";
3
+
4
+ const hasWindow = typeof window !== "undefined";
5
+
6
+ // Listeners and observers start in onMount and stop in onUnmount: setup() runs when the component
7
+ // is constructed, which also happens for components that never mount (and on the server).
3
8
 
4
9
  export class WindowSize extends Composable {
5
10
  declare width: number;
6
11
  declare height: number;
7
12
 
8
- private _handler!: () => void;
9
- private _view?: Record<string, unknown>;
13
+ private readonly _width = signal(hasWindow ? window.innerWidth : 0);
14
+ private readonly _height = signal(hasWindow ? window.innerHeight : 0);
15
+
16
+ private readonly _onResize = () => {
17
+ startBatch();
18
+ this._width.set(window.innerWidth);
19
+ this._height.set(window.innerHeight);
20
+ endBatch();
21
+ };
10
22
 
11
23
  setup() {
12
- if (this._view) return this._view;
13
- const width = signal(window.innerWidth);
14
- const height = signal(window.innerHeight);
15
- this._handler = () => {
16
- width.set(window.innerWidth);
17
- height.set(window.innerHeight);
18
- };
19
- window.addEventListener("resize", this._handler);
20
- this._view = { width, height };
21
- return this._view;
24
+ return { width: this._width, height: this._height };
25
+ }
26
+
27
+ onMount() {
28
+ this._onResize();
29
+ window.addEventListener("resize", this._onResize);
22
30
  }
23
31
 
24
32
  onUnmount() {
25
- window.removeEventListener("resize", this._handler);
33
+ window.removeEventListener("resize", this._onResize);
26
34
  }
27
35
  }
28
36
 
@@ -32,10 +40,23 @@ export class ScrollPosition extends Composable {
32
40
 
33
41
  private readonly _ref?: { current: HTMLElement | null };
34
42
  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;
43
+ private readonly _x = signal(0);
44
+ private readonly _y = signal(0);
45
+ private _target: HTMLElement | Window | null = null;
46
+
47
+ private readonly _onScroll = () => {
48
+ const t = this._target;
49
+ if (t === null) return;
50
+ startBatch();
51
+ if (t === window) {
52
+ this._x.set(window.scrollX);
53
+ this._y.set(window.scrollY);
54
+ } else {
55
+ this._x.set((t as HTMLElement).scrollLeft);
56
+ this._y.set((t as HTMLElement).scrollTop);
57
+ }
58
+ endBatch();
59
+ };
39
60
 
40
61
  constructor(
41
62
  target: HTMLElement | Window | { current: HTMLElement | null } = window,
@@ -49,47 +70,26 @@ export class ScrollPosition extends Composable {
49
70
  }
50
71
 
51
72
  private _resolveTarget(): HTMLElement | Window {
52
- if (this._ref) {
53
- if (this._ref.current !== null) {
54
- return this._ref.current;
55
- }
56
-
57
- return window;
58
- }
59
-
60
- if (this._staticTarget !== undefined) {
61
- return this._staticTarget;
62
- }
63
-
64
- const fallback: HTMLElement | Window = window;
65
- return fallback;
73
+ if (this._ref) return this._ref.current ?? window;
74
+ return this._staticTarget ?? window;
66
75
  }
67
76
 
68
77
  setup() {
69
- this._x = signal(0);
70
- this._y = signal(0);
71
78
  return { x: this._x, y: this._y };
72
79
  }
73
80
 
74
81
  onMount() {
75
- if (this._mounted) return;
76
- this._mounted = true;
82
+ if (this._target !== null) return;
83
+ // Kept so onUnmount detaches from the same target even if the ref changed meanwhile.
77
84
  const t = this._resolveTarget();
78
- this._handler = () => {
79
- this._x.set(
80
- t === window ? window.scrollX : (t as HTMLElement).scrollLeft,
81
- );
82
- this._y.set(t === window ? window.scrollY : (t as HTMLElement).scrollTop);
83
- };
84
- t.addEventListener("scroll", this._handler);
85
- // set initial values
86
- this._x.set(t === window ? window.scrollX : (t as HTMLElement).scrollLeft);
87
- this._y.set(t === window ? window.scrollY : (t as HTMLElement).scrollTop);
85
+ this._target = t;
86
+ t.addEventListener("scroll", this._onScroll);
87
+ this._onScroll();
88
88
  }
89
89
 
90
90
  onUnmount() {
91
- this._mounted = false;
92
- this._resolveTarget().removeEventListener("scroll", this._handler);
91
+ this._target?.removeEventListener("scroll", this._onScroll);
92
+ this._target = null;
93
93
  }
94
94
  }
95
95
 
@@ -98,32 +98,31 @@ export class ElementSize extends Composable {
98
98
  declare height: number;
99
99
 
100
100
  private _observer?: ResizeObserver;
101
- private _width!: ReturnType<typeof signal<number>>;
102
- private _height!: ReturnType<typeof signal<number>>;
103
- private _view?: Record<string, unknown>;
101
+ private readonly _width = signal(0);
102
+ private readonly _height = signal(0);
104
103
 
105
104
  constructor(private readonly ref: { current: HTMLElement | null }) {
106
105
  super();
107
106
  }
108
107
 
109
108
  setup() {
110
- if (this._view) return this._view;
111
- this._width = signal(0);
112
- this._height = signal(0);
113
- this._observer = new ResizeObserver(([entry]) => {
114
- this._width.set(entry.contentRect.width);
115
- this._height.set(entry.contentRect.height);
116
- });
117
- this._view = { width: this._width, height: this._height };
118
- return this._view;
109
+ return { width: this._width, height: this._height };
119
110
  }
120
111
 
121
112
  onMount() {
122
113
  const el = this.ref.current;
123
114
  if (!el) return;
124
- this._observer?.observe(el);
115
+ this._observer ??= new ResizeObserver(([entry]) => {
116
+ startBatch();
117
+ this._width.set(entry.contentRect.width);
118
+ this._height.set(entry.contentRect.height);
119
+ endBatch();
120
+ });
121
+ this._observer.observe(el);
122
+ startBatch();
125
123
  this._width.set(el.offsetWidth);
126
124
  this._height.set(el.offsetHeight);
125
+ endBatch();
127
126
  }
128
127
 
129
128
  onUnmount() {
@@ -135,8 +134,7 @@ export class Intersection extends Composable {
135
134
  declare visible: boolean;
136
135
 
137
136
  private _observer?: IntersectionObserver;
138
- private _visible!: ReturnType<typeof signal<boolean>>;
139
- private _view?: Record<string, unknown>;
137
+ private readonly _visible = signal(false);
140
138
 
141
139
  constructor(
142
140
  private readonly ref: { current: HTMLElement | null },
@@ -146,18 +144,16 @@ export class Intersection extends Composable {
146
144
  }
147
145
 
148
146
  setup() {
149
- if (this._view) return this._view;
150
- this._visible = signal(false);
151
- this._observer = new IntersectionObserver(([entry]) => {
152
- this._visible.set(entry.isIntersecting);
153
- }, this.options);
154
- this._view = { visible: this._visible };
155
- return this._view;
147
+ return { visible: this._visible };
156
148
  }
157
149
 
158
150
  onMount() {
159
151
  const el = this.ref.current;
160
- if (el) this._observer?.observe(el);
152
+ if (!el) return;
153
+ this._observer ??= new IntersectionObserver(([entry]) => {
154
+ this._visible.set(entry.isIntersecting);
155
+ }, this.options);
156
+ this._observer.observe(el);
161
157
  }
162
158
 
163
159
  onUnmount() {
@@ -168,38 +164,40 @@ export class Intersection extends Composable {
168
164
  export class Focus extends Composable {
169
165
  declare focused: boolean;
170
166
 
171
- private _focused!: ReturnType<typeof signal<boolean>>;
167
+ private readonly _focused = signal(false);
168
+ private _el: HTMLElement | null = null;
169
+
172
170
  private readonly _onFocus = () => {
173
171
  this._focused.set(true);
174
172
  };
175
173
  private readonly _onBlur = () => {
176
174
  this._focused.set(false);
177
175
  };
178
- private _mounted = false;
179
176
 
180
177
  constructor(private readonly ref: { current: HTMLElement | null }) {
181
178
  super();
182
179
  }
183
180
 
184
181
  setup() {
185
- this._focused = signal(false);
186
182
  return { focused: this._focused };
187
183
  }
188
184
 
189
185
  onMount() {
190
- if (this._mounted) return;
191
- this._mounted = true;
186
+ if (this._el !== null) return;
192
187
  const el = this.ref.current;
193
188
  if (!el) return;
189
+ // Kept so onUnmount detaches from the same element even if the ref changed meanwhile.
190
+ this._el = el;
194
191
  el.addEventListener("focus", this._onFocus);
195
192
  el.addEventListener("blur", this._onBlur);
193
+ this._focused.set(el.ownerDocument.activeElement === el);
196
194
  }
197
195
 
198
196
  onUnmount() {
199
- this._mounted = false;
200
- const el = this.ref.current;
201
- if (!el) return;
197
+ const el = this._el;
198
+ if (el === null) return;
202
199
  el.removeEventListener("focus", this._onFocus);
203
200
  el.removeEventListener("blur", this._onBlur);
201
+ this._el = null;
204
202
  }
205
203
  }
package/src/list.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { Composable } from "@praxisjs/core";
2
- import { computed, signal } from "@praxisjs/core/internal";
2
+ import { computed, endBatch, signal, startBatch } from "@praxisjs/core/internal";
3
3
 
4
4
  export interface VirtualItem<T> {
5
5
  item: T;
@@ -14,7 +14,17 @@ export class VirtualList<T = unknown> extends Composable {
14
14
 
15
15
  private readonly _scrollTop = signal(0);
16
16
  private readonly _viewHeight = signal(600);
17
- private _scrollCleanup?: () => void;
17
+ private _el: HTMLElement | null = null;
18
+ private _observer?: ResizeObserver;
19
+
20
+ private readonly _onScroll = () => {
21
+ if (this._el) this._scrollTop.set(this._el.scrollTop);
22
+ };
23
+
24
+ private readonly _onResize = () => {
25
+ const h = this._el?.clientHeight ?? 0;
26
+ if (h > 0) this._viewHeight.set(h);
27
+ };
18
28
 
19
29
  constructor(
20
30
  private readonly containerRef: { current: HTMLElement | null },
@@ -43,8 +53,9 @@ export class VirtualList<T = unknown> extends Composable {
43
53
 
44
54
  const visibleItems = computed<Array<VirtualItem<T>>>(() => {
45
55
  const arr = items();
56
+ const end = endIdx();
46
57
  const result: Array<VirtualItem<T>> = [];
47
- for (let i = startIdx(); i <= endIdx(); i++) {
58
+ for (let i = startIdx(); i <= end; i++) {
48
59
  result.push({ item: arr[i], index: i });
49
60
  }
50
61
  return result;
@@ -61,17 +72,25 @@ export class VirtualList<T = unknown> extends Composable {
61
72
 
62
73
  onMount() {
63
74
  const el = this.containerRef.current;
64
- if (!el) return;
75
+ if (!el || this._el) return;
76
+ this._el = el;
65
77
 
66
- const h = el.clientHeight;
67
- if (h > 0) this._viewHeight.set(h);
78
+ startBatch();
79
+ this._onResize();
80
+ this._scrollTop.set(el.scrollTop);
81
+ endBatch();
68
82
 
69
- const handler = () => { this._scrollTop.set(el.scrollTop); };
70
- el.addEventListener("scroll", handler);
71
- this._scrollCleanup = () => { el.removeEventListener("scroll", handler); };
83
+ el.addEventListener("scroll", this._onScroll, { passive: true });
84
+ // The visible window follows the container's height when it is resized, not only at mount.
85
+ if (typeof ResizeObserver !== "undefined") {
86
+ this._observer ??= new ResizeObserver(this._onResize);
87
+ this._observer.observe(el);
88
+ }
72
89
  }
73
90
 
74
91
  onUnmount() {
75
- this._scrollCleanup?.();
92
+ this._el?.removeEventListener("scroll", this._onScroll);
93
+ this._observer?.disconnect();
94
+ this._el = null;
76
95
  }
77
96
  }
package/src/upload.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { Composable } from "@praxisjs/core";
2
- import { signal, computed } from "@praxisjs/core/internal";
2
+ import { computed, peek, signal } from "@praxisjs/core/internal";
3
3
  import type { Signal, Computed } from "@praxisjs/shared";
4
4
 
5
5
  export interface FileValidationOptions {
@@ -143,7 +143,8 @@ function createSelection(options: FileSelectionOptions) {
143
143
  const add = (incoming: FileList | File[] | null | undefined): void => {
144
144
  const list = toFileArray(incoming);
145
145
  if (list.length === 0) return;
146
- const current = multiple ? files() : [];
146
+ // Untracked: add() may be called from an effect, which must not subscribe to the list.
147
+ const current = multiple ? peek(files) : [];
147
148
  const { accepted, rejected } = validateFiles(list, current.length, validation);
148
149
  errors.set(rejected);
149
150
  if (accepted.length === 0) return;
@@ -280,10 +281,19 @@ export class FileUpload extends Composable {
280
281
  const errors = signal<FileError[]>([]);
281
282
  let nextId = 0;
282
283
 
284
+ // Every internal read is untracked: these run from callers' effects (add, retry, start) and
285
+ // must not subscribe them to the list, or each progress update would re-run them.
286
+ const current = () => peek(items);
287
+
283
288
  const patch = (id: string, changes: Partial<UploadItem>) => {
284
- items.update((list) => list.map((i) => (i.id === id ? { ...i, ...changes } : i)));
289
+ const list = current();
290
+ const index = list.findIndex((i) => i.id === id);
291
+ if (index === -1) return;
292
+ const next = list.slice();
293
+ next[index] = { ...list[index], ...changes };
294
+ items.set(next);
285
295
  };
286
- const find = (id: string) => items().find((i) => i.id === id);
296
+ const find = (id: string) => current().find((i) => i.id === id);
287
297
 
288
298
  const finish = (id: string, changes: Partial<UploadItem>) => {
289
299
  this._requests.delete(id);
@@ -305,8 +315,14 @@ export class FileUpload extends Composable {
305
315
  const headers = typeof opts.headers === "function" ? opts.headers() : opts.headers;
306
316
  for (const [key, value] of Object.entries(headers ?? {})) xhr.setRequestHeader(key, value);
307
317
 
318
+ // Progress events arrive far more often than the rounded percentage changes.
319
+ let progress = 0;
308
320
  xhr.upload.onprogress = (e) => {
309
- if (e.lengthComputable) patch(item.id, { progress: Math.round((e.loaded / e.total) * 100) });
321
+ if (!e.lengthComputable) return;
322
+ const next = Math.round((e.loaded / e.total) * 100);
323
+ if (next === progress) return;
324
+ progress = next;
325
+ patch(item.id, { progress });
310
326
  };
311
327
  xhr.onload = () => {
312
328
  if (xhr.status >= 200 && xhr.status < 300) {
@@ -333,8 +349,9 @@ export class FileUpload extends Composable {
333
349
 
334
350
  const pump = () => {
335
351
  if (this._destroyed) return;
336
- let running = items().filter((i) => i.status === "uploading").length;
337
- for (const item of items()) {
352
+ // Every running upload has its request in the map.
353
+ let running = this._requests.size;
354
+ for (const item of current()) {
338
355
  if (running >= concurrency) break;
339
356
  if (item.status === "pending") {
340
357
  running++;
@@ -356,11 +373,11 @@ export class FileUpload extends Composable {
356
373
  const add = (incoming: FileList | File[] | null | undefined) => {
357
374
  const list = toFileArray(incoming);
358
375
  if (list.length === 0) return;
359
- const { accepted, rejected } = validateFiles(list, items().length, opts);
376
+ const { accepted, rejected } = validateFiles(list, current().length, opts);
360
377
  errors.set(rejected);
361
378
  if (accepted.length === 0) return;
362
- items.update((current) => [
363
- ...current,
379
+ items.update((list) => [
380
+ ...list,
364
381
  ...accepted.map((file): UploadItem => ({
365
382
  id: `upload-${String(++nextId)}`,
366
383
  file,
@@ -394,9 +411,11 @@ export class FileUpload extends Composable {
394
411
  items.update((list) => list.filter((i) => i.id !== id));
395
412
  },
396
413
  clear: () => {
397
- abortAll();
414
+ // Emptied first: aborting fires onabort, which pumps and would otherwise start the
415
+ // pending uploads that are about to be discarded.
398
416
  items.set([]);
399
417
  errors.set([]);
418
+ abortAll();
400
419
  },
401
420
  };
402
421
  }
package/src/utilities.ts CHANGED
@@ -1,5 +1,13 @@
1
1
  import { Composable } from "@praxisjs/core";
2
- import { signal, computed } from "@praxisjs/core/internal";
2
+ import {
3
+ computed,
4
+ createEffect,
5
+ disposeEffect,
6
+ endBatch,
7
+ signal,
8
+ startBatch,
9
+ type EffectNode,
10
+ } from "@praxisjs/core/internal";
3
11
  import type { Signal, Computed } from "@praxisjs/shared";
4
12
 
5
13
  export interface PaginationOptions {
@@ -27,8 +35,10 @@ export class Clipboard extends Composable {
27
35
  const copy = async (text: string): Promise<void> => {
28
36
  try {
29
37
  await navigator.clipboard.writeText(text);
38
+ startBatch();
30
39
  content.set(text);
31
40
  copied.set(true);
41
+ endBatch();
32
42
  clearTimeout(this._resetTimer);
33
43
  this._resetTimer = setTimeout(() => { copied.set(false); }, resetDelay);
34
44
  } catch {
@@ -50,34 +60,42 @@ export class Geolocation extends Composable {
50
60
  declare error: GeolocationPositionError | null;
51
61
  declare loading: boolean;
52
62
 
53
- private _active = true;
63
+ private readonly _lat = signal<number | null>(null);
64
+ private readonly _lng = signal<number | null>(null);
65
+ private readonly _error = signal<GeolocationPositionError | null>(null);
66
+ private readonly _loading = signal(true);
67
+ private _active = false;
54
68
 
55
69
  constructor(private readonly options?: PositionOptions) {
56
70
  super();
57
71
  }
58
72
 
59
73
  setup() {
60
- const lat = signal<number | null>(null);
61
- const lng = signal<number | null>(null);
62
- const error = signal<GeolocationPositionError | null>(null);
63
- const loading = signal(true);
74
+ return { lat: this._lat, lng: this._lng, error: this._error, loading: this._loading };
75
+ }
64
76
 
77
+ // Requested on mount rather than in setup(), which also runs for components that never mount.
78
+ onMount() {
79
+ if (this._active) return;
80
+ this._active = true;
65
81
  navigator.geolocation.getCurrentPosition(
66
82
  (pos) => {
67
83
  if (!this._active) return;
68
- lat.set(pos.coords.latitude);
69
- lng.set(pos.coords.longitude);
70
- loading.set(false);
84
+ startBatch();
85
+ this._lat.set(pos.coords.latitude);
86
+ this._lng.set(pos.coords.longitude);
87
+ this._loading.set(false);
88
+ endBatch();
71
89
  },
72
90
  (err) => {
73
91
  if (!this._active) return;
74
- error.set(err);
75
- loading.set(false);
92
+ startBatch();
93
+ this._error.set(err);
94
+ this._loading.set(false);
95
+ endBatch();
76
96
  },
77
97
  this.options,
78
98
  );
79
-
80
- return { lat, lng, error, loading };
81
99
  }
82
100
 
83
101
  onUnmount() {
@@ -85,10 +103,58 @@ export class Geolocation extends Composable {
85
103
  }
86
104
  }
87
105
 
106
+ const SECOND = 1_000;
107
+ const MINUTE = 60_000;
108
+ const HOUR = 3_600_000;
109
+ const DAY = 86_400_000;
110
+ const MONTH = 2_592_000_000;
111
+ // setTimeout fires immediately for delays above 2^31 - 1 ms.
112
+ const MAX_DELAY = 2_147_483_647;
113
+
114
+ interface TimeUnit {
115
+ /** The unit applies while |diff| is below this. */
116
+ below: number;
117
+ size: number;
118
+ name: Intl.RelativeTimeFormatUnit;
119
+ }
120
+
121
+ const UNITS: TimeUnit[] = [
122
+ { below: MINUTE, size: SECOND, name: "second" },
123
+ { below: HOUR, size: MINUTE, name: "minute" },
124
+ { below: DAY, size: HOUR, name: "hour" },
125
+ { below: MONTH, size: DAY, name: "day" },
126
+ { below: Infinity, size: MONTH, name: "month" },
127
+ ];
128
+
129
+ function unitIndex(diff: number): number {
130
+ const abs = Math.abs(diff);
131
+ let i = 0;
132
+ while (abs >= UNITS[i].below) i++;
133
+ return i;
134
+ }
135
+
136
+ /**
137
+ * Milliseconds until the text for `diff` (target − now, shrinking as time passes) changes: the
138
+ * next rounding step within its unit, or the moment it crosses into another unit.
139
+ */
140
+ export function msUntilChange(diff: number): number {
141
+ const i = unitIndex(diff);
142
+ const { size, below } = UNITS[i];
143
+ // Math.round(x) changes once x falls below its value minus one half.
144
+ let wait = diff - (Math.round(diff / size) - 0.5) * size + 1;
145
+ if (diff > 0) {
146
+ if (i > 0) wait = Math.min(wait, diff - UNITS[i - 1].below + 1);
147
+ } else if (below !== Infinity) {
148
+ wait = Math.min(wait, diff + below);
149
+ }
150
+ return Math.min(Math.max(Math.ceil(wait), 1), MAX_DELAY);
151
+ }
152
+
88
153
  export class TimeAgo extends Composable {
89
154
  declare value: string;
90
155
 
91
- private _interval?: ReturnType<typeof setInterval>;
156
+ private readonly _tick = signal(0);
157
+ private _effect?: EffectNode;
92
158
 
93
159
  constructor(
94
160
  private readonly source:
@@ -101,28 +167,39 @@ export class TimeAgo extends Composable {
101
167
  }
102
168
 
103
169
  setup() {
104
- const tick = signal(Date.now());
105
- this._interval = setInterval(() => { tick.set(Date.now()); }, 60_000);
106
-
107
170
  const rtf = new Intl.RelativeTimeFormat(this.locale, { numeric: "auto" });
108
171
  const read = this.source;
172
+ const tick = this._tick;
109
173
 
110
174
  const value = computed(() => {
111
175
  void tick();
112
176
  const diff = new Date(read() as Date).getTime() - Date.now();
113
- const abs = Math.abs(diff);
114
- if (abs < 60_000) return rtf.format(Math.round(diff / 1000), "second");
115
- if (abs < 3_600_000) return rtf.format(Math.round(diff / 60_000), "minute");
116
- if (abs < 86_400_000) return rtf.format(Math.round(diff / 3_600_000), "hour");
117
- if (abs < 2_592_000_000) return rtf.format(Math.round(diff / 86_400_000), "day");
118
- return rtf.format(Math.round(diff / 2_592_000_000), "month");
177
+ const { size, name } = UNITS[unitIndex(diff)];
178
+ return rtf.format(Math.round(diff / size), name);
119
179
  });
120
180
 
121
181
  return { value };
122
182
  }
123
183
 
184
+ // One timer for the exact moment the text changes, instead of polling: re-armed whenever it
185
+ // fires or the source changes.
186
+ onMount() {
187
+ if (this._effect) return;
188
+ const read = this.source;
189
+ const tick = this._tick;
190
+ this._effect = createEffect(() => {
191
+ tick();
192
+ const diff = new Date(read() as Date).getTime() - Date.now();
193
+ // An invalid date has no next change; a NaN delay would fire at once, over and over.
194
+ if (Number.isNaN(diff)) return;
195
+ const timer = setTimeout(() => { tick.update((n) => n + 1); }, msUntilChange(diff));
196
+ return () => { clearTimeout(timer); };
197
+ });
198
+ }
199
+
124
200
  onUnmount() {
125
- clearInterval(this._interval);
201
+ if (this._effect) disposeEffect(this._effect);
202
+ this._effect = undefined;
126
203
  }
127
204
  }
128
205
 
@@ -1,2 +0,0 @@
1
- export {};
2
- //# sourceMappingURL=browser.test.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"browser.test.d.ts","sourceRoot":"","sources":["../../src/__tests__/browser.test.ts"],"names":[],"mappings":""}