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.
@@ -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
+ }
@@ -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,5 @@
1
+ import type { EventCallback } from "./EventCallback.ts";
2
+
3
+ export type EventCallbackMap<Map extends Record<string, unknown>> = Partial<{
4
+ [K in keyof Map]: Set<EventCallback<Map[K]>>;
5
+ }>;
@@ -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
+ };
@@ -0,0 +1,6 @@
1
+ import type { LocationValue } from "./LocationValue.ts";
2
+ import type { URLConfig } from "./URLConfig.ts";
3
+
4
+ export type LocationPattern = URLConfig["strict"] extends true
5
+ ? LocationValue | LocationValue[]
6
+ : LocationValue | RegExp | (LocationValue | RegExp)[];