@praxisjs/composables 1.2.1 → 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 +17 -0
  2. package/dist/browser.d.ts +41 -18
  3. package/dist/browser.d.ts.map +1 -1
  4. package/dist/browser.js +114 -114
  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 -84
  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 -115
  23. package/src/dom.ts +77 -85
  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 -350
  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 -463
  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 -414
  48. package/src/__tests__/dom.test.ts +0 -567
  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,30 +1,36 @@
1
1
  import { Composable } from "@praxisjs/core";
2
- import { batch, 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
- batch(() => {
17
- width.set(window.innerWidth);
18
- height.set(window.innerHeight);
19
- });
20
- };
21
- window.addEventListener("resize", this._handler);
22
- this._view = { width, height };
23
- return this._view;
24
+ return { width: this._width, height: this._height };
25
+ }
26
+
27
+ onMount() {
28
+ this._onResize();
29
+ window.addEventListener("resize", this._onResize);
24
30
  }
25
31
 
26
32
  onUnmount() {
27
- window.removeEventListener("resize", this._handler);
33
+ window.removeEventListener("resize", this._onResize);
28
34
  }
29
35
  }
30
36
 
@@ -34,10 +40,23 @@ export class ScrollPosition extends Composable {
34
40
 
35
41
  private readonly _ref?: { current: HTMLElement | null };
36
42
  private readonly _staticTarget?: HTMLElement | Window;
37
- private _handler!: () => void;
38
- private _x!: ReturnType<typeof signal<number>>;
39
- private _y!: ReturnType<typeof signal<number>>;
40
- 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
+ };
41
60
 
42
61
  constructor(
43
62
  target: HTMLElement | Window | { current: HTMLElement | null } = window,
@@ -51,49 +70,26 @@ export class ScrollPosition extends Composable {
51
70
  }
52
71
 
53
72
  private _resolveTarget(): HTMLElement | Window {
54
- if (this._ref) {
55
- if (this._ref.current !== null) {
56
- return this._ref.current;
57
- }
58
-
59
- return window;
60
- }
61
-
62
- if (this._staticTarget !== undefined) {
63
- return this._staticTarget;
64
- }
65
-
66
- const fallback: HTMLElement | Window = window;
67
- return fallback;
73
+ if (this._ref) return this._ref.current ?? window;
74
+ return this._staticTarget ?? window;
68
75
  }
69
76
 
70
77
  setup() {
71
- this._x = signal(0);
72
- this._y = signal(0);
73
78
  return { x: this._x, y: this._y };
74
79
  }
75
80
 
76
81
  onMount() {
77
- if (this._mounted) return;
78
- this._mounted = true;
82
+ if (this._target !== null) return;
83
+ // Kept so onUnmount detaches from the same target even if the ref changed meanwhile.
79
84
  const t = this._resolveTarget();
80
- this._handler = () => {
81
- batch(() => {
82
- this._x.set(
83
- t === window ? window.scrollX : (t as HTMLElement).scrollLeft,
84
- );
85
- this._y.set(t === window ? window.scrollY : (t as HTMLElement).scrollTop);
86
- });
87
- };
88
- t.addEventListener("scroll", this._handler);
89
- // set initial values
90
- this._x.set(t === window ? window.scrollX : (t as HTMLElement).scrollLeft);
91
- this._y.set(t === window ? window.scrollY : (t as HTMLElement).scrollTop);
85
+ this._target = t;
86
+ t.addEventListener("scroll", this._onScroll);
87
+ this._onScroll();
92
88
  }
93
89
 
94
90
  onUnmount() {
95
- this._mounted = false;
96
- this._resolveTarget().removeEventListener("scroll", this._handler);
91
+ this._target?.removeEventListener("scroll", this._onScroll);
92
+ this._target = null;
97
93
  }
98
94
  }
99
95
 
@@ -102,34 +98,31 @@ export class ElementSize extends Composable {
102
98
  declare height: number;
103
99
 
104
100
  private _observer?: ResizeObserver;
105
- private _width!: ReturnType<typeof signal<number>>;
106
- private _height!: ReturnType<typeof signal<number>>;
107
- private _view?: Record<string, unknown>;
101
+ private readonly _width = signal(0);
102
+ private readonly _height = signal(0);
108
103
 
109
104
  constructor(private readonly ref: { current: HTMLElement | null }) {
110
105
  super();
111
106
  }
112
107
 
113
108
  setup() {
114
- if (this._view) return this._view;
115
- this._width = signal(0);
116
- this._height = signal(0);
117
- this._observer = new ResizeObserver(([entry]) => {
118
- batch(() => {
119
- this._width.set(entry.contentRect.width);
120
- this._height.set(entry.contentRect.height);
121
- });
122
- });
123
- this._view = { width: this._width, height: this._height };
124
- return this._view;
109
+ return { width: this._width, height: this._height };
125
110
  }
126
111
 
127
112
  onMount() {
128
113
  const el = this.ref.current;
129
114
  if (!el) return;
130
- 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();
131
123
  this._width.set(el.offsetWidth);
132
124
  this._height.set(el.offsetHeight);
125
+ endBatch();
133
126
  }
134
127
 
135
128
  onUnmount() {
@@ -141,8 +134,7 @@ export class Intersection extends Composable {
141
134
  declare visible: boolean;
142
135
 
143
136
  private _observer?: IntersectionObserver;
144
- private _visible!: ReturnType<typeof signal<boolean>>;
145
- private _view?: Record<string, unknown>;
137
+ private readonly _visible = signal(false);
146
138
 
147
139
  constructor(
148
140
  private readonly ref: { current: HTMLElement | null },
@@ -152,18 +144,16 @@ export class Intersection extends Composable {
152
144
  }
153
145
 
154
146
  setup() {
155
- if (this._view) return this._view;
156
- this._visible = signal(false);
157
- this._observer = new IntersectionObserver(([entry]) => {
158
- this._visible.set(entry.isIntersecting);
159
- }, this.options);
160
- this._view = { visible: this._visible };
161
- return this._view;
147
+ return { visible: this._visible };
162
148
  }
163
149
 
164
150
  onMount() {
165
151
  const el = this.ref.current;
166
- 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);
167
157
  }
168
158
 
169
159
  onUnmount() {
@@ -174,38 +164,40 @@ export class Intersection extends Composable {
174
164
  export class Focus extends Composable {
175
165
  declare focused: boolean;
176
166
 
177
- private _focused!: ReturnType<typeof signal<boolean>>;
167
+ private readonly _focused = signal(false);
168
+ private _el: HTMLElement | null = null;
169
+
178
170
  private readonly _onFocus = () => {
179
171
  this._focused.set(true);
180
172
  };
181
173
  private readonly _onBlur = () => {
182
174
  this._focused.set(false);
183
175
  };
184
- private _mounted = false;
185
176
 
186
177
  constructor(private readonly ref: { current: HTMLElement | null }) {
187
178
  super();
188
179
  }
189
180
 
190
181
  setup() {
191
- this._focused = signal(false);
192
182
  return { focused: this._focused };
193
183
  }
194
184
 
195
185
  onMount() {
196
- if (this._mounted) return;
197
- this._mounted = true;
186
+ if (this._el !== null) return;
198
187
  const el = this.ref.current;
199
188
  if (!el) return;
189
+ // Kept so onUnmount detaches from the same element even if the ref changed meanwhile.
190
+ this._el = el;
200
191
  el.addEventListener("focus", this._onFocus);
201
192
  el.addEventListener("blur", this._onBlur);
193
+ this._focused.set(el.ownerDocument.activeElement === el);
202
194
  }
203
195
 
204
196
  onUnmount() {
205
- this._mounted = false;
206
- const el = this.ref.current;
207
- if (!el) return;
197
+ const el = this._el;
198
+ if (el === null) return;
208
199
  el.removeEventListener("focus", this._onFocus);
209
200
  el.removeEventListener("blur", this._onBlur);
201
+ this._el = null;
210
202
  }
211
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":""}