statepod 0.4.0
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/LICENSE +21 -0
- package/README.md +191 -0
- package/dist/index.cjs +576 -0
- package/dist/index.d.ts +294 -0
- package/dist/index.mjs +564 -0
- package/index.ts +25 -0
- package/package.json +42 -0
- package/src/EventEmitter.ts +72 -0
- package/src/PersistentState.ts +99 -0
- package/src/Route.ts +295 -0
- package/src/State.ts +101 -0
- package/src/URLState.ts +161 -0
- package/src/isState.ts +20 -0
- package/src/types/EventCallback.ts +1 -0
- package/src/types/EventCallbackMap.ts +5 -0
- package/src/types/LinkElement.ts +1 -0
- package/src/types/LocationObject.ts +11 -0
- package/src/types/LocationPattern.ts +6 -0
- package/src/types/LocationValue.ts +6 -0
- package/src/types/MatchHandler.ts +6 -0
- package/src/types/MatchState.ts +22 -0
- package/src/types/NavigationOptions.ts +10 -0
- package/src/types/PersistentStorage.ts +4 -0
- package/src/types/URLComponents.ts +4 -0
- package/src/types/URLConfig.ts +5 -0
- package/src/types/URLData.ts +10 -0
- package/src/types/URLSchema.ts +4 -0
- package/src/utils/compileURL.ts +27 -0
- package/src/utils/getNavigationOptions.ts +15 -0
- package/src/utils/isLocationObject.ts +11 -0
- package/src/utils/isRouteEvent.ts +11 -0
- package/src/utils/matchURL.ts +99 -0
- package/tsconfig.json +17 -0
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { State, type StateOptions, type StatePayloadMap } from "./State.ts";
|
|
2
|
+
import type { PersistentStorage } from "./types/PersistentStorage.ts";
|
|
3
|
+
|
|
4
|
+
function getStorage(session = false) {
|
|
5
|
+
if (typeof window !== "undefined")
|
|
6
|
+
return session ? window.sessionStorage : window.localStorage;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export type StorageEntryOptions<T> = {
|
|
10
|
+
key: string;
|
|
11
|
+
session?: boolean;
|
|
12
|
+
serialize?: (value: T) => string;
|
|
13
|
+
deserialize?: (serializedValue: string) => T;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export function getStorageEntry<T>({
|
|
17
|
+
key,
|
|
18
|
+
session,
|
|
19
|
+
serialize = JSON.stringify,
|
|
20
|
+
deserialize = JSON.parse,
|
|
21
|
+
}: StorageEntryOptions<T>): PersistentStorage<T> {
|
|
22
|
+
let storage = getStorage(session);
|
|
23
|
+
|
|
24
|
+
if (!storage) return { read: () => null, write: () => {} };
|
|
25
|
+
|
|
26
|
+
return {
|
|
27
|
+
read() {
|
|
28
|
+
try {
|
|
29
|
+
let serializedValue = storage.getItem(key);
|
|
30
|
+
if (serializedValue !== null) return deserialize(serializedValue);
|
|
31
|
+
} catch {}
|
|
32
|
+
return null;
|
|
33
|
+
},
|
|
34
|
+
write(value: T) {
|
|
35
|
+
try {
|
|
36
|
+
storage.setItem(key, serialize(value));
|
|
37
|
+
} catch {}
|
|
38
|
+
},
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export type PersistentStatePayloadMap<T> = StatePayloadMap<T> & {
|
|
43
|
+
sync: void;
|
|
44
|
+
synconce: void;
|
|
45
|
+
effect: void;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* A container for data persistent across page reloads.
|
|
50
|
+
*/
|
|
51
|
+
export class PersistentState<
|
|
52
|
+
T,
|
|
53
|
+
P extends PersistentStatePayloadMap<T> = PersistentStatePayloadMap<T>,
|
|
54
|
+
> extends State<T, P> {
|
|
55
|
+
/**
|
|
56
|
+
* @param value - Initial state value.
|
|
57
|
+
* @param options - Either of the following:
|
|
58
|
+
* - A set of browser storage settings: `key` points to the target browser
|
|
59
|
+
* storage key where the state value should be saved; `session` set to `true`
|
|
60
|
+
* signals to use `sessionStorage` instead of `localStorage`, with the latter
|
|
61
|
+
* being the default; the optional `serialize` and `deserialize` define the
|
|
62
|
+
* way the state value is saved to and restored from the browser storage
|
|
63
|
+
* entry (default: `JSON.stringify` and `JSON.parse` respectively).
|
|
64
|
+
* - A storage singleton with a `read` and an optional `write` method
|
|
65
|
+
* (synchronous or asynchronous).
|
|
66
|
+
*/
|
|
67
|
+
constructor(
|
|
68
|
+
value: T,
|
|
69
|
+
options: (StorageEntryOptions<T> | PersistentStorage<T>) & StateOptions,
|
|
70
|
+
) {
|
|
71
|
+
super(value, { autoStart: false });
|
|
72
|
+
|
|
73
|
+
let { read, write } =
|
|
74
|
+
"read" in options ? options : getStorageEntry(options);
|
|
75
|
+
|
|
76
|
+
let update = (value: T | null) => {
|
|
77
|
+
if (value === null) write?.(this.getValue());
|
|
78
|
+
else this.setValue(value);
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
let sync = () => {
|
|
82
|
+
let value = read();
|
|
83
|
+
|
|
84
|
+
if (value instanceof Promise) value.then(update);
|
|
85
|
+
else update(value);
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
if (write) {
|
|
89
|
+
this.on("update", ({ current }) => {
|
|
90
|
+
write(current);
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
this.on("sync", sync);
|
|
95
|
+
this.on("start", sync);
|
|
96
|
+
|
|
97
|
+
if (options?.autoStart !== false) this.start();
|
|
98
|
+
}
|
|
99
|
+
}
|
package/src/Route.ts
ADDED
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
import { QuasiURL } from "quasiurl";
|
|
2
|
+
import type { LinkElement } from "./types/LinkElement.ts";
|
|
3
|
+
import type { LocationPattern } from "./types/LocationPattern.ts";
|
|
4
|
+
import type { LocationValue } from "./types/LocationValue.ts";
|
|
5
|
+
import type { MatchHandler } from "./types/MatchHandler.ts";
|
|
6
|
+
import type { NavigationOptions } from "./types/NavigationOptions.ts";
|
|
7
|
+
import type { URLData } from "./types/URLData.ts";
|
|
8
|
+
import {
|
|
9
|
+
URLState,
|
|
10
|
+
type URLStateOptions,
|
|
11
|
+
type URLStatePayloadMap,
|
|
12
|
+
} from "./URLState.ts";
|
|
13
|
+
import { compileURL } from "./utils/compileURL.ts";
|
|
14
|
+
import { getNavigationOptions } from "./utils/getNavigationOptions.ts";
|
|
15
|
+
import { isRouteEvent } from "./utils/isRouteEvent.ts";
|
|
16
|
+
import { matchURL } from "./utils/matchURL.ts";
|
|
17
|
+
|
|
18
|
+
export type ContainerElement = Document | Element | null | undefined;
|
|
19
|
+
export type ElementCollection = (string | Node)[] | HTMLCollection | NodeList;
|
|
20
|
+
|
|
21
|
+
export type ObservedElement =
|
|
22
|
+
| string
|
|
23
|
+
| Node
|
|
24
|
+
| (string | Node)[]
|
|
25
|
+
| HTMLCollection
|
|
26
|
+
| NodeList;
|
|
27
|
+
|
|
28
|
+
function isElementCollection(x: unknown): x is ElementCollection {
|
|
29
|
+
return (
|
|
30
|
+
Array.isArray(x) || x instanceof NodeList || x instanceof HTMLCollection
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function isLinkElement(x: unknown): x is LinkElement {
|
|
35
|
+
return x instanceof HTMLAnchorElement || x instanceof HTMLAreaElement;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function toggleActive(element: Element | Node, route: Route) {
|
|
39
|
+
if (!isLinkElement(element)) return;
|
|
40
|
+
|
|
41
|
+
if (
|
|
42
|
+
element.hasAttribute("href") &&
|
|
43
|
+
route.match(route.toValue(element.href)).ok
|
|
44
|
+
)
|
|
45
|
+
element.dataset.active = "true";
|
|
46
|
+
else delete element.dataset.active;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export type RoutePayloadMap = URLStatePayloadMap & {
|
|
50
|
+
documentclick: MouseEvent;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
export type RouteOptions = URLStateOptions;
|
|
54
|
+
|
|
55
|
+
export class Route extends URLState<RoutePayloadMap> {
|
|
56
|
+
_observeInited = false;
|
|
57
|
+
constructor(href: LocationValue | null = null, options?: RouteOptions) {
|
|
58
|
+
super(String(href ?? ""), options);
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Enables SPA navigation with HTML links inside the specified container.
|
|
62
|
+
*
|
|
63
|
+
* @example
|
|
64
|
+
* ```js
|
|
65
|
+
* let route = new Route();
|
|
66
|
+
* route.observe(document);
|
|
67
|
+
* ```
|
|
68
|
+
*/
|
|
69
|
+
observe(
|
|
70
|
+
container: ContainerElement | (() => ContainerElement),
|
|
71
|
+
elements: ObservedElement = "a, area",
|
|
72
|
+
) {
|
|
73
|
+
if (typeof window === "undefined") return;
|
|
74
|
+
|
|
75
|
+
if (!this._observeInited) {
|
|
76
|
+
let handleClick = (event: MouseEvent) => {
|
|
77
|
+
this.emit("documentclick", event);
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
this.on("start", () => {
|
|
81
|
+
document.addEventListener("click", handleClick);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
this.on("stop", () => {
|
|
85
|
+
document.removeEventListener("click", handleClick);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
if (this.active) document.addEventListener("click", handleClick);
|
|
89
|
+
|
|
90
|
+
this._observeInited = true;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
let resolveParams = () => {
|
|
94
|
+
let resolvedContainer =
|
|
95
|
+
typeof container === "function" ? container() : container;
|
|
96
|
+
|
|
97
|
+
if (!resolvedContainer) return;
|
|
98
|
+
|
|
99
|
+
let targetElements = isElementCollection(elements)
|
|
100
|
+
? Array.from(elements)
|
|
101
|
+
: [elements];
|
|
102
|
+
|
|
103
|
+
return { resolvedContainer, targetElements };
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
let removeClickHandlers = this.on("documentclick", (event: MouseEvent) => {
|
|
107
|
+
if (event.defaultPrevented || !isRouteEvent(event)) return;
|
|
108
|
+
|
|
109
|
+
let resolvedParams = resolveParams();
|
|
110
|
+
|
|
111
|
+
if (!resolvedParams) return;
|
|
112
|
+
|
|
113
|
+
let { resolvedContainer, targetElements } = resolvedParams;
|
|
114
|
+
let element: HTMLAnchorElement | HTMLAreaElement | null = null;
|
|
115
|
+
|
|
116
|
+
for (let targetElement of targetElements) {
|
|
117
|
+
let target: Node | null = null;
|
|
118
|
+
|
|
119
|
+
if (typeof targetElement === "string")
|
|
120
|
+
target =
|
|
121
|
+
event.target instanceof HTMLElement
|
|
122
|
+
? event.target.closest(targetElement)
|
|
123
|
+
: null;
|
|
124
|
+
else target = targetElement;
|
|
125
|
+
|
|
126
|
+
if (isLinkElement(target) && resolvedContainer.contains(target)) {
|
|
127
|
+
element = target;
|
|
128
|
+
break;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (element) {
|
|
133
|
+
event.preventDefault();
|
|
134
|
+
this.navigate(getNavigationOptions(element));
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
let removeURLChangeHandlers = this.on("ready", () => {
|
|
139
|
+
let resolvedParams = resolveParams();
|
|
140
|
+
|
|
141
|
+
if (!resolvedParams) return;
|
|
142
|
+
|
|
143
|
+
let { resolvedContainer, targetElements } = resolvedParams;
|
|
144
|
+
|
|
145
|
+
for (let targetElement of targetElements) {
|
|
146
|
+
if (typeof targetElement === "string") {
|
|
147
|
+
let targets = resolvedContainer.querySelectorAll(targetElement);
|
|
148
|
+
|
|
149
|
+
for (let element of targets) toggleActive(element, this);
|
|
150
|
+
} else if (resolvedContainer.contains(targetElement))
|
|
151
|
+
toggleActive(targetElement, this);
|
|
152
|
+
}
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
return () => {
|
|
156
|
+
removeClickHandlers();
|
|
157
|
+
removeURLChangeHandlers();
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* Navigates to the URL specified with `options.href`.
|
|
162
|
+
*
|
|
163
|
+
* @example
|
|
164
|
+
* ```js
|
|
165
|
+
* let route = new Route();
|
|
166
|
+
* route.navigate({ href: "/intro", history: "replace", scroll: "off" });
|
|
167
|
+
* ```
|
|
168
|
+
*/
|
|
169
|
+
navigate(options?: NavigationOptions<LocationValue>): void {
|
|
170
|
+
if (!options?.href) return;
|
|
171
|
+
|
|
172
|
+
let { href, referrer, ...params } = options;
|
|
173
|
+
|
|
174
|
+
// Stringify `LocationValue` URLs in `options`
|
|
175
|
+
let transformedOptions = {
|
|
176
|
+
href: String(href),
|
|
177
|
+
referrer: referrer && String(referrer),
|
|
178
|
+
...params,
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
this.setValue(transformedOptions.href, transformedOptions);
|
|
182
|
+
}
|
|
183
|
+
assign(url: LocationValue) {
|
|
184
|
+
this.navigate({ href: url });
|
|
185
|
+
}
|
|
186
|
+
replace(url: LocationValue) {
|
|
187
|
+
this.navigate({ href: url, history: "replace" });
|
|
188
|
+
}
|
|
189
|
+
reload() {
|
|
190
|
+
this.assign(this.getValue());
|
|
191
|
+
}
|
|
192
|
+
go(delta: number) {
|
|
193
|
+
if (typeof window !== "undefined" && window.history)
|
|
194
|
+
window.history.go(delta);
|
|
195
|
+
}
|
|
196
|
+
back() {
|
|
197
|
+
this.go(-1);
|
|
198
|
+
}
|
|
199
|
+
forward() {
|
|
200
|
+
this.go(1);
|
|
201
|
+
}
|
|
202
|
+
get href(): string {
|
|
203
|
+
return this.getValue();
|
|
204
|
+
}
|
|
205
|
+
set href(value: LocationValue) {
|
|
206
|
+
this.assign(value);
|
|
207
|
+
}
|
|
208
|
+
get pathname(): string {
|
|
209
|
+
return new QuasiURL(this.href).pathname;
|
|
210
|
+
}
|
|
211
|
+
set pathname(value: LocationValue) {
|
|
212
|
+
let url = new QuasiURL(this.href);
|
|
213
|
+
url.pathname = String(value);
|
|
214
|
+
this.assign(url.href);
|
|
215
|
+
}
|
|
216
|
+
get search(): string {
|
|
217
|
+
return new QuasiURL(this.href).search;
|
|
218
|
+
}
|
|
219
|
+
set search(value: string | URLSearchParams) {
|
|
220
|
+
let url = new QuasiURL(this.href);
|
|
221
|
+
url.search = value;
|
|
222
|
+
this.assign(url.href);
|
|
223
|
+
}
|
|
224
|
+
get hash() {
|
|
225
|
+
return new QuasiURL(this.href).hash;
|
|
226
|
+
}
|
|
227
|
+
set hash(value: string) {
|
|
228
|
+
let url = new QuasiURL(this.href);
|
|
229
|
+
url.hash = value;
|
|
230
|
+
this.assign(url.href);
|
|
231
|
+
}
|
|
232
|
+
toString() {
|
|
233
|
+
return this.href;
|
|
234
|
+
}
|
|
235
|
+
/**
|
|
236
|
+
* Matches the current location against `urlPattern`.
|
|
237
|
+
*/
|
|
238
|
+
match<P extends LocationPattern>(urlPattern: P) {
|
|
239
|
+
return matchURL<P>(urlPattern, this.href);
|
|
240
|
+
}
|
|
241
|
+
/**
|
|
242
|
+
* Compiles `urlPattern` to a URL string by filling out the parameters
|
|
243
|
+
* based on `data`.
|
|
244
|
+
*/
|
|
245
|
+
compile<T extends LocationValue>(urlPattern: T, data?: URLData<T>) {
|
|
246
|
+
return compileURL<T>(urlPattern, data);
|
|
247
|
+
}
|
|
248
|
+
/**
|
|
249
|
+
* `at(urlPattern)` returns `true` if `urlPattern` matches the current URL,
|
|
250
|
+
* and `false` otherwise.
|
|
251
|
+
*
|
|
252
|
+
* `at(urlPattern, x, y?)` returns either based on `x` if `urlPattern`
|
|
253
|
+
* matches the current URL, or based on `y` otherwise. (It loosely resembles
|
|
254
|
+
* the ternary conditional operator `matchesURLPattern ? x : y`.)
|
|
255
|
+
*
|
|
256
|
+
* If the current location matches `urlPattern`, `at(urlPattern, x, y)`
|
|
257
|
+
* returns:
|
|
258
|
+
* - `x`, if `x` is not a function;
|
|
259
|
+
* - `x({ params })`, if `x` is a function, with `params` extracted from
|
|
260
|
+
* the current URL.
|
|
261
|
+
*
|
|
262
|
+
* If the current location doesn't match `urlPattern`, `at(urlPattern, x, y)`
|
|
263
|
+
* returns:
|
|
264
|
+
* - `y`, if `y` is not a function;
|
|
265
|
+
* - `y({ params })`, if `y` is a function, with `params` extracted from
|
|
266
|
+
* the current URL.
|
|
267
|
+
*/
|
|
268
|
+
at<P extends LocationPattern>(urlPattern: P): boolean;
|
|
269
|
+
|
|
270
|
+
at<P extends LocationPattern, X>(
|
|
271
|
+
urlPattern: P,
|
|
272
|
+
x: X | MatchHandler<P, X>,
|
|
273
|
+
): X | undefined;
|
|
274
|
+
|
|
275
|
+
at<P extends LocationPattern, X, Y>(
|
|
276
|
+
urlPattern: P,
|
|
277
|
+
x: X | MatchHandler<P, X>,
|
|
278
|
+
y: Y | MatchHandler<P, Y>,
|
|
279
|
+
): X | Y;
|
|
280
|
+
|
|
281
|
+
at<P extends LocationPattern, X, Y>(
|
|
282
|
+
urlPattern: P,
|
|
283
|
+
x?: X | MatchHandler<P, X>,
|
|
284
|
+
y?: Y | MatchHandler<P, Y>,
|
|
285
|
+
): X | Y | boolean | undefined {
|
|
286
|
+
let result = this.match<P>(urlPattern);
|
|
287
|
+
|
|
288
|
+
if (x === undefined && y === undefined) return result.ok;
|
|
289
|
+
|
|
290
|
+
if (!result.ok)
|
|
291
|
+
return typeof y === "function" ? (y as MatchHandler<P, Y>)(result) : y;
|
|
292
|
+
|
|
293
|
+
return typeof x === "function" ? (x as MatchHandler<P, X>)(result) : x;
|
|
294
|
+
}
|
|
295
|
+
}
|
package/src/State.ts
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { EventEmitter } from "./EventEmitter.ts";
|
|
2
|
+
import type { EventCallback } from "./types/EventCallback.ts";
|
|
3
|
+
|
|
4
|
+
export type StateUpdate<T> = (value: T) => T;
|
|
5
|
+
|
|
6
|
+
export type StateUpdatePayload<T> = {
|
|
7
|
+
previous: T;
|
|
8
|
+
current: T;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export type StatePayloadMap<T> = Record<string, void> & {
|
|
12
|
+
update: StateUpdatePayload<T>;
|
|
13
|
+
// Similar to "update", but its callback is also immediately invoked when added
|
|
14
|
+
set: StateUpdatePayload<T>;
|
|
15
|
+
start: void;
|
|
16
|
+
stop: void;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export type StateOptions = {
|
|
20
|
+
/**
|
|
21
|
+
* Whether to call `start()` at initialization.
|
|
22
|
+
* @default true
|
|
23
|
+
*/
|
|
24
|
+
autoStart?: boolean;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
function isImmediatelyInvokedEvent(event: unknown): event is "set" {
|
|
28
|
+
return event === "set";
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Data container allowing for subscription to its updates.
|
|
33
|
+
*/
|
|
34
|
+
export class State<
|
|
35
|
+
T,
|
|
36
|
+
P extends StatePayloadMap<T> = StatePayloadMap<T>,
|
|
37
|
+
> extends EventEmitter<P> {
|
|
38
|
+
_value: T;
|
|
39
|
+
_revision = -1;
|
|
40
|
+
_active = false;
|
|
41
|
+
// For immediately invoked event callbacks added during inactivity
|
|
42
|
+
_queue: (() => void)[] = [];
|
|
43
|
+
constructor(value: T, options?: StateOptions) {
|
|
44
|
+
super();
|
|
45
|
+
this._value = value;
|
|
46
|
+
this._init();
|
|
47
|
+
if (options?.autoStart !== false) this.start();
|
|
48
|
+
}
|
|
49
|
+
_init() {}
|
|
50
|
+
_call(callback: () => void) {
|
|
51
|
+
if (this._active) callback();
|
|
52
|
+
else this._queue.push(callback);
|
|
53
|
+
}
|
|
54
|
+
on<E extends string>(event: E, callback: EventCallback<P[E]>) {
|
|
55
|
+
if (isImmediatelyInvokedEvent(event))
|
|
56
|
+
this._call(() => {
|
|
57
|
+
let current = this.getValue();
|
|
58
|
+
|
|
59
|
+
callback({ current, previous: current } as P[typeof event]);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
return super.on(event, callback);
|
|
63
|
+
}
|
|
64
|
+
getValue() {
|
|
65
|
+
return this._value;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Updates the state value.
|
|
69
|
+
*
|
|
70
|
+
* @param update - A new value or an update function `(value) => nextValue`
|
|
71
|
+
* that returns a new state value based on the current state value.
|
|
72
|
+
*/
|
|
73
|
+
setValue(update: T | StateUpdate<T>) {
|
|
74
|
+
if (this._active) this._assignValue(this._resolveValue(update));
|
|
75
|
+
}
|
|
76
|
+
_resolveValue(update: T | StateUpdate<T>) {
|
|
77
|
+
return update instanceof Function ? update(this._value) : update;
|
|
78
|
+
}
|
|
79
|
+
_assignValue(value: T) {
|
|
80
|
+
let previous = this._value;
|
|
81
|
+
let current = value;
|
|
82
|
+
|
|
83
|
+
this._value = current;
|
|
84
|
+
this._revision = Math.random();
|
|
85
|
+
|
|
86
|
+
this.emit("update", { previous, current });
|
|
87
|
+
|
|
88
|
+
// Unlike "update" callbacks, "set" callbacks are also immediately
|
|
89
|
+
// invoked when added
|
|
90
|
+
this.emit("set", { previous, current });
|
|
91
|
+
}
|
|
92
|
+
get revision() {
|
|
93
|
+
return this._revision;
|
|
94
|
+
}
|
|
95
|
+
start() {
|
|
96
|
+
super.start();
|
|
97
|
+
|
|
98
|
+
for (let callback of this._queue) callback();
|
|
99
|
+
this._queue = [];
|
|
100
|
+
}
|
|
101
|
+
}
|
package/src/URLState.ts
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import { QuasiURL } from "quasiurl";
|
|
2
|
+
import {
|
|
3
|
+
State,
|
|
4
|
+
type StateOptions,
|
|
5
|
+
type StatePayloadMap,
|
|
6
|
+
type StateUpdate,
|
|
7
|
+
} from "./State.ts";
|
|
8
|
+
import type { EventCallback } from "./types/EventCallback.ts";
|
|
9
|
+
import type { NavigationOptions } from "./types/NavigationOptions.ts";
|
|
10
|
+
|
|
11
|
+
export type URLStatePayloadMap = StatePayloadMap<string> & {
|
|
12
|
+
navigationstart: NavigationOptions;
|
|
13
|
+
// Similar to the "update" event, but with a `NavigationOptions` payload
|
|
14
|
+
navigation: NavigationOptions;
|
|
15
|
+
navigationcomplete: NavigationOptions;
|
|
16
|
+
ready: void;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
function isImmediatelyInvokedNavigationEvent(
|
|
20
|
+
event: unknown,
|
|
21
|
+
): event is "navigationstart" | "navigationcomplete" {
|
|
22
|
+
return event === "navigationstart" || event === "navigationcomplete";
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function isImmediatelyInvokedEvent(event: unknown): event is "ready" {
|
|
26
|
+
return event === "ready";
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export type URLStateOptions = StateOptions;
|
|
30
|
+
|
|
31
|
+
export class URLState<
|
|
32
|
+
P extends URLStatePayloadMap = URLStatePayloadMap,
|
|
33
|
+
> extends State<string, P> {
|
|
34
|
+
constructor(href: string | null = null, options?: URLStateOptions) {
|
|
35
|
+
super(href ?? "", options);
|
|
36
|
+
}
|
|
37
|
+
_init() {
|
|
38
|
+
super._init();
|
|
39
|
+
|
|
40
|
+
if (typeof window === "undefined") return;
|
|
41
|
+
|
|
42
|
+
let handleURLChange = () => {
|
|
43
|
+
this.setValue(window.location.href, { source: "popstate" });
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
this.on("start", () => {
|
|
47
|
+
window.addEventListener("popstate", handleURLChange);
|
|
48
|
+
this.emit("ready");
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
this.on("stop", () => {
|
|
52
|
+
window.removeEventListener("popstate", handleURLChange);
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
on<E extends string>(
|
|
56
|
+
event: E,
|
|
57
|
+
callback: EventCallback<P[E]>,
|
|
58
|
+
invokeImmediately?: boolean,
|
|
59
|
+
) {
|
|
60
|
+
if (invokeImmediately !== false) {
|
|
61
|
+
if (isImmediatelyInvokedNavigationEvent(event))
|
|
62
|
+
this._call(() => {
|
|
63
|
+
callback({ href: this.getValue() } as P[typeof event]);
|
|
64
|
+
});
|
|
65
|
+
else if (isImmediatelyInvokedEvent(event))
|
|
66
|
+
this._call(() => {
|
|
67
|
+
(callback as EventCallback<void>)();
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return super.on(event, callback);
|
|
72
|
+
}
|
|
73
|
+
getValue() {
|
|
74
|
+
return this.toValue(this._value);
|
|
75
|
+
}
|
|
76
|
+
setValue(update: string | StateUpdate<string>, options?: NavigationOptions) {
|
|
77
|
+
if (!this._active) return;
|
|
78
|
+
|
|
79
|
+
let href = this.toValue(this._resolveValue(update));
|
|
80
|
+
|
|
81
|
+
let extendedOptions: NavigationOptions = {
|
|
82
|
+
...options,
|
|
83
|
+
href,
|
|
84
|
+
referrer: this.getValue(),
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
if (
|
|
88
|
+
this.emit("navigationstart", extendedOptions) &&
|
|
89
|
+
this._transition(extendedOptions) !== false
|
|
90
|
+
) {
|
|
91
|
+
this._assignValue(href);
|
|
92
|
+
this.emit("navigation", extendedOptions);
|
|
93
|
+
|
|
94
|
+
if (this.emit("navigationcomplete", extendedOptions)) {
|
|
95
|
+
this._complete(extendedOptions);
|
|
96
|
+
this.emit("ready");
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
_transition(options?: NavigationOptions): boolean | void | undefined {
|
|
101
|
+
if (
|
|
102
|
+
typeof window === "undefined" ||
|
|
103
|
+
options?.href === undefined ||
|
|
104
|
+
options?.source === "popstate"
|
|
105
|
+
)
|
|
106
|
+
return;
|
|
107
|
+
|
|
108
|
+
let { href, target, spa, history } = options;
|
|
109
|
+
|
|
110
|
+
if (target && target !== "_self") {
|
|
111
|
+
window.open(href, target);
|
|
112
|
+
return false;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
let url = new QuasiURL(href);
|
|
116
|
+
|
|
117
|
+
if (
|
|
118
|
+
spa === "off" ||
|
|
119
|
+
!window.history ||
|
|
120
|
+
(url.origin !== "" && url.origin !== window.location.origin)
|
|
121
|
+
) {
|
|
122
|
+
window.location[history === "replace" ? "replace" : "assign"](href);
|
|
123
|
+
return false;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
window.history[history === "replace" ? "replaceState" : "pushState"](
|
|
127
|
+
{},
|
|
128
|
+
"",
|
|
129
|
+
href,
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
_complete(options?: NavigationOptions): boolean | void | undefined {
|
|
133
|
+
if (typeof window === "undefined" || !options || options.scroll === "off")
|
|
134
|
+
return;
|
|
135
|
+
|
|
136
|
+
let { href, target } = options;
|
|
137
|
+
|
|
138
|
+
if (href === undefined || (target && target !== "_self")) return;
|
|
139
|
+
|
|
140
|
+
let { hash } = new QuasiURL(href);
|
|
141
|
+
|
|
142
|
+
requestAnimationFrame(() => {
|
|
143
|
+
let targetElement =
|
|
144
|
+
hash === ""
|
|
145
|
+
? null
|
|
146
|
+
: document.querySelector(`${hash}, a[name="${hash.slice(1)}"]`);
|
|
147
|
+
|
|
148
|
+
if (targetElement) targetElement.scrollIntoView();
|
|
149
|
+
else window.scrollTo(0, 0);
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
toValue(x: string) {
|
|
153
|
+
if (typeof window === "undefined") return x;
|
|
154
|
+
|
|
155
|
+
let url = new QuasiURL(x || window.location.href);
|
|
156
|
+
|
|
157
|
+
if (url.origin === window.location.origin) url.origin = "";
|
|
158
|
+
|
|
159
|
+
return url.href;
|
|
160
|
+
}
|
|
161
|
+
}
|
package/src/isState.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { State, StatePayloadMap } from "./State.ts";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Serves as an alternative to `instanceof State` which can lead to a false
|
|
5
|
+
* negative when `State` comes from a transitive dependency.
|
|
6
|
+
*/
|
|
7
|
+
export function isState<T, P extends StatePayloadMap<T> = StatePayloadMap<T>>(
|
|
8
|
+
x: unknown,
|
|
9
|
+
): x is State<T, P> {
|
|
10
|
+
return (
|
|
11
|
+
x !== null &&
|
|
12
|
+
typeof x === "object" &&
|
|
13
|
+
"on" in x &&
|
|
14
|
+
typeof x.on === "function" &&
|
|
15
|
+
"emit" in x &&
|
|
16
|
+
typeof x.emit === "function" &&
|
|
17
|
+
"setValue" in x &&
|
|
18
|
+
typeof x.setValue === "function"
|
|
19
|
+
);
|
|
20
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export type EventCallback<T> = (payload: T) => boolean | undefined | void;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export type LinkElement = HTMLAnchorElement | HTMLAreaElement;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { URLComponents } from "./URLComponents.ts";
|
|
2
|
+
import type { URLSchema } from "./URLSchema.ts";
|
|
3
|
+
|
|
4
|
+
// URL builder output
|
|
5
|
+
export type LocationObject = {
|
|
6
|
+
_schema: URLSchema | undefined;
|
|
7
|
+
exec: (x: string) => URLComponents | null;
|
|
8
|
+
// biome-ignore lint/suspicious/noExplicitAny: third-party
|
|
9
|
+
compile: (x: any) => string;
|
|
10
|
+
toString: () => string;
|
|
11
|
+
};
|