@realglebivanov/reactive 1.0.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,63 @@
1
+ import { observable, type Observable, type Updatable } from "../observables";
2
+ import type { Event } from "../nodes";
3
+
4
+ type Source<T> = Observable<Event<T>> & Updatable<Event<T>>;
5
+
6
+ export class ReactiveArray<T> {
7
+ private observables: WeakRef<Source<T>>[] = [];
8
+
9
+ constructor(private items: T[] = []) { }
10
+
11
+ get observable$(): Source<T> {
12
+ const obs$ = observable<Event<T>>({ type: "replace", items: this.items });
13
+ this.observables.push(new WeakRef(obs$));
14
+ return obs$;
15
+ };
16
+
17
+ push(...items: T[]) {
18
+ this.items.push(...items);
19
+ this.emit({ type: "append", items: items })
20
+ }
21
+
22
+ pop(): T | undefined {
23
+ if (this.items.length === 0) return undefined;
24
+
25
+ const index = this.items.length - 1;
26
+ const value = this.items.pop() as T;
27
+
28
+ this.emit({ type: "remove", items: new Map().set(index, value) });
29
+
30
+ return value;
31
+ }
32
+
33
+ remove(indices: number[]) {
34
+ if (indices.length == 0) return;
35
+ const eventItems = new Map();
36
+ for (const idx of indices) {
37
+ if (!(idx in this.items)) continue;
38
+ eventItems.set(idx, this.items[idx]);
39
+ delete this.items[idx];
40
+ }
41
+ this.emit({ type: "remove", items: eventItems });
42
+ }
43
+
44
+ replace(items: T[]) {
45
+ this.items = items;
46
+ this.emit({ type: "replace", items: items });
47
+ }
48
+
49
+ replaceKeys(items: Map<number, T>) {
50
+ for (const [idx, value] of items.entries()) {
51
+ if (!(idx in this.items)) continue;
52
+ this.items[idx] = value;
53
+ }
54
+
55
+ this.emit({ type: "replaceKeys", items: items })
56
+ }
57
+
58
+ private emit(event: Event<T>) {
59
+ const updateFn = (_: Event<T>) => event;
60
+ for (const obs$ of this.observables)
61
+ obs$.deref()?.update(updateFn);
62
+ }
63
+ }
@@ -0,0 +1 @@
1
+ export * from './array';
package/src/router.ts ADDED
@@ -0,0 +1,82 @@
1
+ import { toReactiveNode, type ReactiveNode } from "./nodes";
2
+ import { microtaskRunner } from "./task";
3
+
4
+ type RouteKey<T extends RouteCollection<Node>> = keyof T & string;
5
+ type Route<T extends RouteCollection<Node>> = T[RouteKey<T>];
6
+ type RouteCollection<N extends Node> = Record<string, ReactiveNode<N>>;
7
+ type RouterOpts<T extends RouteCollection<Node>> = {
8
+ notFoundRoute: RouteKey<T>;
9
+ };
10
+
11
+ export const router = <
12
+ T extends RouteCollection<Node>
13
+ >(routes: T, opts: RouterOpts<T>): ReactiveNode<Comment> =>
14
+ new Router<T>(routes, opts).toReactiveNode();
15
+
16
+ class Router<T extends RouteCollection<Node>> {
17
+ private anchor: Comment | undefined;
18
+ private currentRoute: Route<T> | undefined;
19
+ private hashChangeListener = () => this.syncHash();
20
+
21
+ constructor(
22
+ private routes: T,
23
+ private opts: RouterOpts<T>
24
+ ) { }
25
+
26
+ toReactiveNode() {
27
+ const anchor = document.createComment('Router');
28
+
29
+ return toReactiveNode(anchor, [{
30
+ mount: (parentNode: HTMLElement) => {
31
+ if (this.anchor !== undefined)
32
+ return console.warn("Router is already active");
33
+ this.anchor = anchor;
34
+ parentNode.appendChild(anchor);
35
+ },
36
+ activate: () => {
37
+ this.syncHash();
38
+ window.addEventListener("hashchange", this.hashChangeListener);
39
+ },
40
+ deactivate: () => {
41
+ window.removeEventListener("hashchange", this.hashChangeListener);
42
+ this.currentRoute?.deactivate();
43
+ },
44
+ unmount: () => {
45
+ this.currentRoute?.unmount();
46
+ this.currentRoute = undefined;
47
+ anchor.remove();
48
+ }
49
+ }]);
50
+ }
51
+
52
+ private syncHash() {
53
+ const newRoute = this.getNewRoute();
54
+
55
+ if (newRoute === this.currentRoute || newRoute === undefined) return;
56
+
57
+ this.currentRoute?.deactivate();
58
+ this.currentRoute?.unmount();
59
+ this.currentRoute = newRoute;
60
+
61
+ microtaskRunner(() => {
62
+ const parentElement = this.anchor?.parentElement;
63
+ if (parentElement === null || parentElement === undefined) return;
64
+ newRoute.mount(parentElement);
65
+ newRoute.activate();
66
+ });
67
+ }
68
+
69
+ private getNewRoute(): Route<T> | undefined {
70
+ const hashLocation = location.hash.slice(1) || "/";
71
+
72
+ const routeKey: RouteKey<T> = this.isRouteKey(hashLocation)
73
+ ? hashLocation
74
+ : this.opts.notFoundRoute;
75
+
76
+ return this.routes[routeKey];
77
+ }
78
+
79
+ private isRouteKey(key: string): key is RouteKey<T> {
80
+ return key in this.routes;
81
+ }
82
+ }
package/src/tag.ts ADDED
@@ -0,0 +1,62 @@
1
+ import {
2
+ reactiveTextNode,
3
+ toTagReactiveNode,
4
+ type ReactiveNode,
5
+ type TagReactiveNode
6
+ } from './nodes/reactive';
7
+
8
+ export type InputChild<
9
+ T extends Node,
10
+ K extends keyof HTMLElementTagNameMap = keyof HTMLElementTagNameMap
11
+ > = TagReactiveNode<K> | ReactiveNode<T> | string;
12
+
13
+ export const tag = <
14
+ K extends keyof HTMLElementTagNameMap
15
+ >(name: K, ...inputChildren: InputChild<Node>[]): TagReactiveNode<K> => {
16
+ const node = document.createElement(name);
17
+ const children: ReactiveNode<Node>[] = [];
18
+
19
+ for (const child of inputChildren) {
20
+ if (typeof (child) === 'string') {
21
+ children.push(reactiveTextNode(child));
22
+ } else if (child instanceof Node) {
23
+ children.push(child);
24
+ } else {
25
+ throw new Error('Unsupported child type');
26
+ }
27
+ }
28
+
29
+ return toTagReactiveNode<K>(node, [{
30
+ mount: (parentNode: Node) => {
31
+ parentNode.appendChild(node);
32
+ for (const child of children) child.mount(node);
33
+ },
34
+ activate: () => {
35
+ for (const child of children) child.activate();
36
+ },
37
+ deactivate: () => {
38
+ for (const child of children) child.deactivate();
39
+ },
40
+ unmount: () => {
41
+ for (const child of children) child.unmount();
42
+ node.remove();
43
+ }
44
+ }]);
45
+ }
46
+
47
+ export const tags = {
48
+ img: (src: string) => tag('img').att('src', src),
49
+ input: (type: string) => tag('input').att('type', type),
50
+ canvas: <T extends InputChild<Node>[]>(...children: T) => tag('canvas', ...children),
51
+ button: <T extends InputChild<Node>[]>(...children: T) => tag('button', ...children),
52
+ h1: <T extends InputChild<Node>[]>(...children: T) => tag('h1', ...children),
53
+ h2: <T extends InputChild<Node>[]>(...children: T) => tag('h2', ...children),
54
+ h3: <T extends InputChild<Node>[]>(...children: T) => tag('h3', ...children),
55
+ p: <T extends InputChild<Node>[]>(...children: T) => tag('p', ...children),
56
+ a: <T extends InputChild<Node>[]>(...children: T) => tag('a', ...children),
57
+ div: <T extends InputChild<Node>[]>(...children: T) => tag('div', ...children),
58
+ ul: <T extends InputChild<Node>[]>(...children: T) => tag('ul', ...children),
59
+ li: <T extends InputChild<Node>[]>(...children: T) => tag('li', ...children),
60
+ span: <T extends InputChild<Node>[]>(...children: T) => tag('span', ...children),
61
+ select: <T extends InputChild<Node>[]>(...children: T) => tag('select', ...children)
62
+ };
package/src/task.ts ADDED
@@ -0,0 +1,36 @@
1
+ export type Task = () => void;
2
+ export type TaskRunner = (task: Task) => void;
3
+
4
+ export const buildDedupMicrotaskRunner = (): TaskRunner => {
5
+ const tasks = new Set<Task>();
6
+ const enqueue = () => queueMicrotask(() => {
7
+ const run = Array.from(tasks);
8
+ tasks.clear();
9
+ for (const task of run) task();
10
+ });
11
+
12
+ return (task: Task) => {
13
+ if (tasks.has(task)) return;
14
+ tasks.add(task);
15
+ if (tasks.size > 1) return;
16
+ enqueue();
17
+ };
18
+ };
19
+
20
+ export const dedupMicrotaskRunner = buildDedupMicrotaskRunner();
21
+
22
+ export const buildMicrotaskRunner = (): TaskRunner => {
23
+ const tasks: Task[] = [];
24
+ const enqueue = () => queueMicrotask(() => {
25
+ const run = tasks.toReversed();
26
+ tasks.length = 0;
27
+ for (const task of run) task();
28
+ });
29
+
30
+ return (task: Task) => {
31
+ tasks.push(task);
32
+ if (tasks.length == 1) enqueue();
33
+ };
34
+ };
35
+
36
+ export const microtaskRunner = buildMicrotaskRunner();
package/tsconfig.json ADDED
@@ -0,0 +1,42 @@
1
+ {
2
+ // Visit https://aka.ms/tsconfig to read more about this file
3
+ "compilerOptions": {
4
+ // File Layout
5
+ "rootDir": "./src",
6
+ "outDir": "./dist",
7
+ "moduleResolution": "bundler",
8
+
9
+ // Environment Settings
10
+ // See also https://aka.ms/tsconfig/module
11
+ "module": "esnext",
12
+ "target": "es2015",
13
+ "types": [],
14
+ "lib": ["dom", "ES2023"],
15
+
16
+ // Other Outputs
17
+ "sourceMap": true,
18
+ "declaration": true,
19
+ "declarationMap": true,
20
+
21
+ // Stricter Typechecking Options
22
+ "noUncheckedIndexedAccess": true,
23
+ "exactOptionalPropertyTypes": true,
24
+
25
+ // Style Options
26
+ "noImplicitReturns": true,
27
+ "noImplicitOverride": true,
28
+ "noUnusedLocals": true,
29
+ "noUnusedParameters": true,
30
+ "noFallthroughCasesInSwitch": true,
31
+ "noPropertyAccessFromIndexSignature": true,
32
+
33
+ // Recommended Options
34
+ "strict": true,
35
+ "jsx": "react-jsx",
36
+ "verbatimModuleSyntax": true,
37
+ "isolatedModules": true,
38
+ "noUncheckedSideEffectImports": true,
39
+ "moduleDetection": "force",
40
+ "skipLibCheck": true,
41
+ }
42
+ }
package/tsup.config.js ADDED
@@ -0,0 +1,21 @@
1
+ import { defineConfig } from "tsup";
2
+
3
+ export default defineConfig({
4
+ entry: {
5
+ index: "src/index.ts",
6
+ example: "src/example.ts",
7
+ },
8
+ tsconfig: "tsconfig.json",
9
+
10
+ format: ["esm"],
11
+ target: "es2018",
12
+ platform: "browser",
13
+
14
+ dts: true,
15
+ minify: true,
16
+ sourcemap: true,
17
+ clean: true,
18
+
19
+ treeshake: true,
20
+ publicDir: './public'
21
+ });