@praxisjs/composables 1.2.1 → 1.2.3
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 -114
- 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 -84
- 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 -115
- package/src/dom.ts +77 -85
- 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 -350
- 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 -463
- 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 -414
- package/src/__tests__/dom.test.ts +0 -567
- 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,30 +1,36 @@
|
|
|
1
1
|
import { Composable } from "@praxisjs/core";
|
|
2
|
-
import {
|
|
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
|
-
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.
|
|
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
|
|
38
|
-
private
|
|
39
|
-
private
|
|
40
|
-
|
|
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
|
-
|
|
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.
|
|
78
|
-
|
|
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.
|
|
81
|
-
|
|
82
|
-
|
|
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.
|
|
96
|
-
this.
|
|
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
|
|
106
|
-
private _height
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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)
|
|
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
|
|
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.
|
|
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
|
-
|
|
206
|
-
|
|
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
|
|
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":""}
|