@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.
- package/CHANGELOG.md +25 -0
- package/dist/browser.d.ts +41 -18
- package/dist/browser.d.ts.map +1 -1
- package/dist/browser.js +114 -112
- package/dist/browser.js.map +1 -1
- package/dist/dom.d.ts +24 -16
- package/dist/dom.d.ts.map +1 -1
- package/dist/dom.js +75 -78
- package/dist/dom.js.map +1 -1
- package/dist/list.d.ts +4 -1
- package/dist/list.d.ts.map +1 -1
- package/dist/list.js +29 -11
- package/dist/list.js.map +1 -1
- package/dist/upload.d.ts.map +1 -1
- package/dist/upload.js +32 -12
- package/dist/upload.js.map +1 -1
- package/dist/utilities.d.ts +13 -1
- package/dist/utilities.d.ts.map +1 -1
- package/dist/utilities.js +88 -26
- package/dist/utilities.js.map +1 -1
- package/package.json +14 -5
- package/src/browser.ts +129 -113
- package/src/dom.ts +77 -79
- package/src/list.ts +29 -10
- package/src/upload.ts +30 -11
- package/src/utilities.ts +101 -24
- package/dist/__tests__/browser.test.d.ts +0 -2
- package/dist/__tests__/browser.test.d.ts.map +0 -1
- package/dist/__tests__/browser.test.js +0 -333
- package/dist/__tests__/browser.test.js.map +0 -1
- package/dist/__tests__/dom.test.d.ts +0 -2
- package/dist/__tests__/dom.test.d.ts.map +0 -1
- package/dist/__tests__/dom.test.js +0 -416
- package/dist/__tests__/dom.test.js.map +0 -1
- package/dist/__tests__/list.test.d.ts +0 -2
- package/dist/__tests__/list.test.d.ts.map +0 -1
- package/dist/__tests__/list.test.js +0 -156
- package/dist/__tests__/list.test.js.map +0 -1
- package/dist/__tests__/upload.test.d.ts +0 -2
- package/dist/__tests__/upload.test.d.ts.map +0 -1
- package/dist/__tests__/upload.test.js +0 -531
- package/dist/__tests__/upload.test.js.map +0 -1
- package/dist/__tests__/utilities.test.d.ts +0 -2
- package/dist/__tests__/utilities.test.d.ts.map +0 -1
- package/dist/__tests__/utilities.test.js +0 -251
- package/dist/__tests__/utilities.test.js.map +0 -1
- package/src/__tests__/browser.test.ts +0 -395
- package/src/__tests__/dom.test.ts +0 -515
- package/src/__tests__/list.test.ts +0 -210
- package/src/__tests__/upload.test.ts +0 -639
- package/src/__tests__/utilities.test.ts +0 -327
- 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
|
|
9
|
-
private
|
|
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
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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.
|
|
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
|
|
36
|
-
private
|
|
37
|
-
private
|
|
38
|
-
|
|
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
|
-
|
|
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.
|
|
76
|
-
|
|
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.
|
|
79
|
-
|
|
80
|
-
|
|
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.
|
|
92
|
-
this.
|
|
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
|
|
102
|
-
private _height
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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)
|
|
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
|
|
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.
|
|
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
|
-
|
|
200
|
-
|
|
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
|
|
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 <=
|
|
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
|
-
|
|
67
|
-
|
|
78
|
+
startBatch();
|
|
79
|
+
this._onResize();
|
|
80
|
+
this._scrollTop.set(el.scrollTop);
|
|
81
|
+
endBatch();
|
|
68
82
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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.
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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) =>
|
|
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)
|
|
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
|
-
|
|
337
|
-
|
|
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,
|
|
376
|
+
const { accepted, rejected } = validateFiles(list, current().length, opts);
|
|
360
377
|
errors.set(rejected);
|
|
361
378
|
if (accepted.length === 0) return;
|
|
362
|
-
items.update((
|
|
363
|
-
...
|
|
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
|
-
|
|
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 {
|
|
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
|
|
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
|
-
|
|
61
|
-
|
|
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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
-
|
|
75
|
-
|
|
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
|
|
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
|
|
114
|
-
|
|
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
|
-
|
|
201
|
+
if (this._effect) disposeEffect(this._effect);
|
|
202
|
+
this._effect = undefined;
|
|
126
203
|
}
|
|
127
204
|
}
|
|
128
205
|
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"browser.test.d.ts","sourceRoot":"","sources":["../../src/__tests__/browser.test.ts"],"names":[],"mappings":""}
|