@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.
- package/.tool-versions +1 -0
- package/LICENSE +20 -0
- package/README.md +158 -0
- package/package.json +46 -0
- package/public/Kasha.png +0 -0
- package/public/KashaHard.gif +0 -0
- package/public/index.html +13 -0
- package/src/example.ts +133 -0
- package/src/index.ts +6 -0
- package/src/lifecycle.ts +44 -0
- package/src/nodes/component.ts +107 -0
- package/src/nodes/cond.ts +79 -0
- package/src/nodes/index.ts +7 -0
- package/src/nodes/iterable/collection.ts +106 -0
- package/src/nodes/iterable/item.ts +35 -0
- package/src/nodes/iterable.ts +84 -0
- package/src/nodes/reactive.ts +97 -0
- package/src/nodes/template.ts +95 -0
- package/src/observables/dedup.observable.ts +65 -0
- package/src/observables/index.ts +18 -0
- package/src/observables/map.observable.ts +80 -0
- package/src/observables/scoped.observable.ts +49 -0
- package/src/observables/value.observable.ts +50 -0
- package/src/reactive/array.ts +63 -0
- package/src/reactive/index.ts +1 -0
- package/src/router.ts +82 -0
- package/src/tag.ts +62 -0
- package/src/task.ts +36 -0
- package/tsconfig.json +42 -0
- package/tsup.config.js +21 -0
package/.tool-versions
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
nodejs 25.4.0
|
package/LICENSE
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
Copyright 2026 Gleb Ivanov <realglebivanov@gmail.com>
|
|
2
|
+
|
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
|
4
|
+
a copy of this software and associated documentation files (the
|
|
5
|
+
"Software"), to deal in the Software without restriction, including
|
|
6
|
+
without limitation the rights to use, copy, modify, merge, publish,
|
|
7
|
+
distribute, sublicense, and/or sell copies of the Software, and to
|
|
8
|
+
permit persons to whom the Software is furnished to do so, subject to
|
|
9
|
+
the following conditions:
|
|
10
|
+
|
|
11
|
+
The above copyright notice and this permission notice shall be
|
|
12
|
+
included in all copies or substantial portions of the Software.
|
|
13
|
+
|
|
14
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
|
15
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
|
16
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
|
17
|
+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
|
18
|
+
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
|
19
|
+
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
|
20
|
+
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
# Reactive
|
|
2
|
+
|
|
3
|
+
## Quick Start
|
|
4
|
+
|
|
5
|
+
```typescript
|
|
6
|
+
import {
|
|
7
|
+
observable,
|
|
8
|
+
cond,
|
|
9
|
+
template,
|
|
10
|
+
iterable,
|
|
11
|
+
component,
|
|
12
|
+
mapObservable,
|
|
13
|
+
router,
|
|
14
|
+
tags,
|
|
15
|
+
dedupObservable
|
|
16
|
+
} from "@realglebivanov/reactive";
|
|
17
|
+
|
|
18
|
+
import { ReactiveArray } from "@realglebivanov/reactive";
|
|
19
|
+
|
|
20
|
+
const LOREM = `
|
|
21
|
+
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.
|
|
22
|
+
Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.
|
|
23
|
+
Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur.
|
|
24
|
+
Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.`;
|
|
25
|
+
|
|
26
|
+
const { a, p, h1, h2, div, span, button, ul, li, img, input } = tags;
|
|
27
|
+
|
|
28
|
+
const shoppingItems = new ReactiveArray([
|
|
29
|
+
{ name: "milk", price$: observable("1.99") },
|
|
30
|
+
{ name: "sour cream", price$: observable("2.99") },
|
|
31
|
+
{ name: "cheese", price$: observable("0.99") }
|
|
32
|
+
]);
|
|
33
|
+
|
|
34
|
+
const counter = () => component({
|
|
35
|
+
cache: true,
|
|
36
|
+
props: { counter: "Counter" },
|
|
37
|
+
observables: () => ({
|
|
38
|
+
count$: observable(0),
|
|
39
|
+
hard$: observable(false),
|
|
40
|
+
veryHard$: observable(true)
|
|
41
|
+
}),
|
|
42
|
+
derivedObservables: ({ count$, hard$ }) => ({
|
|
43
|
+
imageSource$: mapObservable(
|
|
44
|
+
(hard) => hard ? "KashaHard.gif" : "Kasha.png",
|
|
45
|
+
dedupObservable(hard$)),
|
|
46
|
+
hexCounter$: mapObservable((x) => x.toString(2), count$)
|
|
47
|
+
}),
|
|
48
|
+
render: function ({ count$, hard$, veryHard$, imageSource$, hexCounter$ }) {
|
|
49
|
+
const onClick = () => {
|
|
50
|
+
count$.update((count) => count + 1);
|
|
51
|
+
hard$.update((hard) => !hard);
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
return div(
|
|
55
|
+
h2(cond({
|
|
56
|
+
if$: mapObservable(
|
|
57
|
+
(hard, veryHard) => hard && veryHard, hard$, veryHard$),
|
|
58
|
+
then: "Rock hard, baby",
|
|
59
|
+
otherwise: "Wood needed"
|
|
60
|
+
})),
|
|
61
|
+
div(span(template`${this.props.counter}: ${hexCounter$}`)),
|
|
62
|
+
div(img("Kasha.png").att$("src", imageSource$).clk(onClick))
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
const shoppingForm = () => component({
|
|
68
|
+
render: () => div(
|
|
69
|
+
div(span('Name: '), input('text').att('id', 'itemName')),
|
|
70
|
+
div(span('Price: '), input('text').att('id', 'itemPrice')),
|
|
71
|
+
button(span('Add')).clk(() => {
|
|
72
|
+
const itemName = document.getElementById('itemName') as HTMLInputElement;
|
|
73
|
+
const itemPrice = document.getElementById('itemPrice') as HTMLInputElement;
|
|
74
|
+
|
|
75
|
+
if (itemName.value == "" || itemPrice.value == "") return;
|
|
76
|
+
|
|
77
|
+
shoppingItems.push({
|
|
78
|
+
name: itemName.value,
|
|
79
|
+
price$: observable(itemPrice.value)
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
itemName.value = "";
|
|
83
|
+
itemPrice.value = "";
|
|
84
|
+
})
|
|
85
|
+
)
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
const shoppingList = () => component({
|
|
89
|
+
observables: () => ({ shoppingItems$: shoppingItems.observable$ }),
|
|
90
|
+
render: ({ shoppingItems$ }) => div(
|
|
91
|
+
h2("Shopping items"),
|
|
92
|
+
ul(
|
|
93
|
+
iterable({
|
|
94
|
+
it$: shoppingItems$,
|
|
95
|
+
buildFn: (_, item) => li(span(template`${item.name} - ${item.price$}`)),
|
|
96
|
+
keyFn: (_, item) => item.name,
|
|
97
|
+
})
|
|
98
|
+
)
|
|
99
|
+
)
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
const exampleRouter = router({
|
|
103
|
+
"/": div(
|
|
104
|
+
h1("Grecha.js"),
|
|
105
|
+
div(a("Foo").att("href", "#/foo")),
|
|
106
|
+
div(a("Bar").att("href", "#/bar")),
|
|
107
|
+
counter(),
|
|
108
|
+
shoppingList(),
|
|
109
|
+
shoppingForm()
|
|
110
|
+
),
|
|
111
|
+
"/foo": component({
|
|
112
|
+
observables: () => ({ count$: observable(0) }),
|
|
113
|
+
derivedObservables: ({ count$ }) => ({
|
|
114
|
+
paragraphStyle$: mapObservable(
|
|
115
|
+
(count) => `color: ${numberToHexColor(count * 999999)}`, count$)
|
|
116
|
+
}),
|
|
117
|
+
render: ({ count$, paragraphStyle$ }) => div(
|
|
118
|
+
h1("Foo"),
|
|
119
|
+
p(LOREM).att$("style", paragraphStyle$),
|
|
120
|
+
button("Change color").clk(() => count$.update((x) => x + 1)),
|
|
121
|
+
div(a("Home").att("href", "#")),
|
|
122
|
+
)
|
|
123
|
+
}),
|
|
124
|
+
"/bar": div(
|
|
125
|
+
h1("Bar"),
|
|
126
|
+
p(LOREM),
|
|
127
|
+
div(a("Home").att("href", "#"))
|
|
128
|
+
)
|
|
129
|
+
}, { notFoundRoute: "/" });
|
|
130
|
+
|
|
131
|
+
function numberToHexColor(number: number) {
|
|
132
|
+
let hex = (number % 0xffffff).toString(16);
|
|
133
|
+
while (hex.length < 6) hex = "0" + hex;
|
|
134
|
+
return "#" + hex;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
exampleRouter.mount(document.getElementById('entry')!);
|
|
138
|
+
exampleRouter.activate();
|
|
139
|
+
|
|
140
|
+
```
|
|
141
|
+
## Restrictions & Edge Cases
|
|
142
|
+
|
|
143
|
+
### 1. Component Observables
|
|
144
|
+
- Subscriptions to observables declared outside of a component **will not be automatically cleaned up**. Users must manage them manually to avoid memory leaks.
|
|
145
|
+
|
|
146
|
+
### 2. ReactiveArray
|
|
147
|
+
- `ReactiveArray` is **primitive** and **not a full replacement for native arrays**.
|
|
148
|
+
- Only the following events are supported:
|
|
149
|
+
- `{ type: "replace", items: T[] }` — replaces the entire array
|
|
150
|
+
- `{ type: "append", items: T[] }` — appends items at the end
|
|
151
|
+
- `{ type: "remove", items: Map<number, T> }` — removes items at given indices
|
|
152
|
+
- `{ type: "replaceKeys", items: Map<number, T> }` — replaces items at specific indices
|
|
153
|
+
|
|
154
|
+
### 3. Iterable
|
|
155
|
+
- Iterable expects a **reactive source** (`Observable<Event<T>>`) or a normal collection (`Array`/`Map`), which is automatically wrapped in a replace event.
|
|
156
|
+
|
|
157
|
+
### 5. Performance Considerations
|
|
158
|
+
- For very large arrays or frequent updates, consider using **your own ReactiveArray-like implementation** that emit fine-grained events (`append`, `replaceKeys`, `remove`) instead of full replacements.
|
package/package.json
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@realglebivanov/reactive",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "A simple and permissive observable-driven UI toolkit",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/index.js",
|
|
7
|
+
"module": "./dist/index.js",
|
|
8
|
+
"types": "./dist/index.d.ts",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"import": "./dist/index.js",
|
|
12
|
+
"types": "./dist/index.d.ts"
|
|
13
|
+
}
|
|
14
|
+
},
|
|
15
|
+
"keywords": [
|
|
16
|
+
"reactive",
|
|
17
|
+
"observable",
|
|
18
|
+
"ui",
|
|
19
|
+
"toolkit",
|
|
20
|
+
"framework",
|
|
21
|
+
"typescript",
|
|
22
|
+
"no-deps",
|
|
23
|
+
"no-dependencies"
|
|
24
|
+
],
|
|
25
|
+
"sideEffects": false,
|
|
26
|
+
"scripts": {
|
|
27
|
+
"build": "tsup",
|
|
28
|
+
"watch": "tsup --watch",
|
|
29
|
+
"serve": "npx serve dist/",
|
|
30
|
+
"test": "echo \"Error: no test specified\" && exit 1"
|
|
31
|
+
},
|
|
32
|
+
"repository": {
|
|
33
|
+
"type": "git",
|
|
34
|
+
"url": "git+https://github.com/realglebivanov/reactive.git"
|
|
35
|
+
},
|
|
36
|
+
"author": "realglebivanov <realglebivanov@gmail.com>",
|
|
37
|
+
"license": "MIT",
|
|
38
|
+
"bugs": {
|
|
39
|
+
"url": "https://github.com/realglebivanov/reactive/issues"
|
|
40
|
+
},
|
|
41
|
+
"homepage": "https://github.com/realglebivanov/reactive#readme",
|
|
42
|
+
"devDependencies": {
|
|
43
|
+
"tsup": "^8.5.1",
|
|
44
|
+
"typescript": "^5.9.3"
|
|
45
|
+
}
|
|
46
|
+
}
|
package/public/Kasha.png
ADDED
|
Binary file
|
|
Binary file
|
package/src/example.ts
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import {
|
|
2
|
+
observable,
|
|
3
|
+
cond,
|
|
4
|
+
template,
|
|
5
|
+
iterable,
|
|
6
|
+
component,
|
|
7
|
+
mapObservable,
|
|
8
|
+
router,
|
|
9
|
+
tags,
|
|
10
|
+
dedupObservable
|
|
11
|
+
} from "@realglebivanov/reactive";
|
|
12
|
+
|
|
13
|
+
import { ReactiveArray } from "@realglebivanov/reactive";
|
|
14
|
+
|
|
15
|
+
const LOREM = `
|
|
16
|
+
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.
|
|
17
|
+
Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.
|
|
18
|
+
Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur.
|
|
19
|
+
Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.`;
|
|
20
|
+
|
|
21
|
+
const { a, p, h1, h2, div, span, button, ul, li, img, input } = tags;
|
|
22
|
+
|
|
23
|
+
const shoppingItems = new ReactiveArray([
|
|
24
|
+
{ name: "milk", price$: observable("1.99") },
|
|
25
|
+
{ name: "sour cream", price$: observable("2.99") },
|
|
26
|
+
{ name: "cheese", price$: observable("0.99") }
|
|
27
|
+
]);
|
|
28
|
+
|
|
29
|
+
const counter = () => component({
|
|
30
|
+
cache: true,
|
|
31
|
+
props: { counter: "Counter" },
|
|
32
|
+
observables: () => ({
|
|
33
|
+
count$: observable(0),
|
|
34
|
+
hard$: observable(false),
|
|
35
|
+
veryHard$: observable(true)
|
|
36
|
+
}),
|
|
37
|
+
derivedObservables: ({ count$, hard$ }) => ({
|
|
38
|
+
imageSource$: mapObservable(
|
|
39
|
+
(hard) => hard ? "KashaHard.gif" : "Kasha.png",
|
|
40
|
+
dedupObservable(hard$)),
|
|
41
|
+
hexCounter$: mapObservable((x) => x.toString(2), count$)
|
|
42
|
+
}),
|
|
43
|
+
render: function ({ count$, hard$, veryHard$, imageSource$, hexCounter$ }) {
|
|
44
|
+
const onClick = () => {
|
|
45
|
+
count$.update((count) => count + 1);
|
|
46
|
+
hard$.update((hard) => !hard);
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
return div(
|
|
50
|
+
h2(cond({
|
|
51
|
+
if$: mapObservable(
|
|
52
|
+
(hard, veryHard) => hard && veryHard, hard$, veryHard$),
|
|
53
|
+
then: "Rock hard, baby",
|
|
54
|
+
otherwise: "Wood needed"
|
|
55
|
+
})),
|
|
56
|
+
div(span(template`${this.props.counter}: ${hexCounter$}`)),
|
|
57
|
+
div(img("Kasha.png").att$("src", imageSource$).clk(onClick))
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
const shoppingForm = () => component({
|
|
63
|
+
render: () => div(
|
|
64
|
+
div(span('Name: '), input('text').att('id', 'itemName')),
|
|
65
|
+
div(span('Price: '), input('text').att('id', 'itemPrice')),
|
|
66
|
+
button(span('Add')).clk(() => {
|
|
67
|
+
const itemName = document.getElementById('itemName') as HTMLInputElement;
|
|
68
|
+
const itemPrice = document.getElementById('itemPrice') as HTMLInputElement;
|
|
69
|
+
|
|
70
|
+
if (itemName.value == "" || itemPrice.value == "") return;
|
|
71
|
+
|
|
72
|
+
shoppingItems.push({
|
|
73
|
+
name: itemName.value,
|
|
74
|
+
price$: observable(itemPrice.value)
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
itemName.value = "";
|
|
78
|
+
itemPrice.value = "";
|
|
79
|
+
})
|
|
80
|
+
)
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
const shoppingList = () => component({
|
|
84
|
+
observables: () => ({ shoppingItems$: shoppingItems.observable$ }),
|
|
85
|
+
render: ({ shoppingItems$ }) => div(
|
|
86
|
+
h2("Shopping items"),
|
|
87
|
+
ul(
|
|
88
|
+
iterable({
|
|
89
|
+
it$: shoppingItems$,
|
|
90
|
+
buildFn: (_, item) => li(span(template`${item.name} - ${item.price$}`)),
|
|
91
|
+
keyFn: (_, item) => item.name,
|
|
92
|
+
})
|
|
93
|
+
)
|
|
94
|
+
)
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
const exampleRouter = router({
|
|
98
|
+
"/": div(
|
|
99
|
+
h1("Grecha.js"),
|
|
100
|
+
div(a("Foo").att("href", "#/foo")),
|
|
101
|
+
div(a("Bar").att("href", "#/bar")),
|
|
102
|
+
counter(),
|
|
103
|
+
shoppingList(),
|
|
104
|
+
shoppingForm()
|
|
105
|
+
),
|
|
106
|
+
"/foo": component({
|
|
107
|
+
observables: () => ({ count$: observable(0) }),
|
|
108
|
+
derivedObservables: ({ count$ }) => ({
|
|
109
|
+
paragraphStyle$: mapObservable(
|
|
110
|
+
(count) => `color: ${numberToHexColor(count * 999999)}`, count$)
|
|
111
|
+
}),
|
|
112
|
+
render: ({ count$, paragraphStyle$ }) => div(
|
|
113
|
+
h1("Foo"),
|
|
114
|
+
p(LOREM).att$("style", paragraphStyle$),
|
|
115
|
+
button("Change color").clk(() => count$.update((x) => x + 1)),
|
|
116
|
+
div(a("Home").att("href", "#")),
|
|
117
|
+
)
|
|
118
|
+
}),
|
|
119
|
+
"/bar": div(
|
|
120
|
+
h1("Bar"),
|
|
121
|
+
p(LOREM),
|
|
122
|
+
div(a("Home").att("href", "#"))
|
|
123
|
+
)
|
|
124
|
+
}, { notFoundRoute: "/" });
|
|
125
|
+
|
|
126
|
+
function numberToHexColor(number: number) {
|
|
127
|
+
let hex = (number % 0xffffff).toString(16);
|
|
128
|
+
while (hex.length < 6) hex = "0" + hex;
|
|
129
|
+
return "#" + hex;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
exampleRouter.mount(document.getElementById('entry')!);
|
|
133
|
+
exampleRouter.activate();
|
package/src/index.ts
ADDED
package/src/lifecycle.ts
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
export interface Lifecycle {
|
|
2
|
+
mount(parentNode: Node): void,
|
|
3
|
+
activate(): void,
|
|
4
|
+
deactivate(): void,
|
|
5
|
+
unmount(): void
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export enum ReactiveNodeStatus {
|
|
9
|
+
Active,
|
|
10
|
+
Inactive,
|
|
11
|
+
Mounted,
|
|
12
|
+
Unmounted
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export const buildLifecycleHooks = (handlers: Lifecycle[]): Lifecycle => {
|
|
16
|
+
let status = ReactiveNodeStatus.Unmounted;
|
|
17
|
+
|
|
18
|
+
return {
|
|
19
|
+
mount: (parentNode: Node) => {
|
|
20
|
+
if (status !== ReactiveNodeStatus.Unmounted)
|
|
21
|
+
return console.warn(`Mounting in status ${ReactiveNodeStatus[status]}`);
|
|
22
|
+
for (const handler of handlers) handler.mount(parentNode);
|
|
23
|
+
status = ReactiveNodeStatus.Mounted;
|
|
24
|
+
},
|
|
25
|
+
activate: () => {
|
|
26
|
+
if (status !== ReactiveNodeStatus.Mounted && status !== ReactiveNodeStatus.Inactive)
|
|
27
|
+
return console.warn(`Activating in status ${ReactiveNodeStatus[status]}`);
|
|
28
|
+
for (const handler of handlers) handler.activate();
|
|
29
|
+
status = ReactiveNodeStatus.Active;
|
|
30
|
+
},
|
|
31
|
+
deactivate: () => {
|
|
32
|
+
if (status !== ReactiveNodeStatus.Active)
|
|
33
|
+
return console.warn(`Deactivating in status ${ReactiveNodeStatus[status]}`);
|
|
34
|
+
for (const handler of handlers) handler.deactivate()
|
|
35
|
+
status = ReactiveNodeStatus.Inactive;
|
|
36
|
+
},
|
|
37
|
+
unmount() {
|
|
38
|
+
if (status !== ReactiveNodeStatus.Inactive)
|
|
39
|
+
return console.warn(`Unmounting in status ${ReactiveNodeStatus[status]}`);
|
|
40
|
+
for (const handler of handlers) handler.unmount();
|
|
41
|
+
status = ReactiveNodeStatus.Unmounted;
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
};
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import {
|
|
2
|
+
scopedObservable,
|
|
3
|
+
ScopedObservable,
|
|
4
|
+
type Observable
|
|
5
|
+
} from "../observables";
|
|
6
|
+
import { toReactiveNode, type ReactiveNode } from "./reactive";
|
|
7
|
+
|
|
8
|
+
type Observables = Record<string, Observable<any>>;
|
|
9
|
+
type ScopedObservables<T extends Observables> = {
|
|
10
|
+
[K in keyof T]: ScopedObservable<T[K]>
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
type RenderFn<O extends Observables, T extends Node, P> =
|
|
14
|
+
(this: Context<T, P>, observables: ScopedObservables<O>) => ReactiveNode<T>;
|
|
15
|
+
|
|
16
|
+
type UserOpts<O1 extends Observables, O2 extends Observables, T extends Node, P> =
|
|
17
|
+
Partial<Opts<O1, O2, T, P>> & { render: RenderFn<O1 & O2, T, P> };
|
|
18
|
+
|
|
19
|
+
type Opts<O1 extends Observables, O2 extends Observables, T extends Node, P> = {
|
|
20
|
+
render: RenderFn<O1 & O2, T, P>,
|
|
21
|
+
observables: () => O1,
|
|
22
|
+
derivedObservables: (observables: O1) => O2,
|
|
23
|
+
cache: boolean,
|
|
24
|
+
props: P
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const defaultOpts = {
|
|
28
|
+
observables: () => ({}),
|
|
29
|
+
derivedObservables: () => ({}),
|
|
30
|
+
cache: false,
|
|
31
|
+
props: {}
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export const component = <
|
|
35
|
+
O1 extends Observables,
|
|
36
|
+
O2 extends Observables,
|
|
37
|
+
T extends Node,
|
|
38
|
+
P
|
|
39
|
+
>(opts: UserOpts<O1, O2, T, P>): ReactiveNode<Comment> => new Component<O1, O2, T, P>(
|
|
40
|
+
Object.assign({}, defaultOpts, opts)
|
|
41
|
+
).toReactiveNode();
|
|
42
|
+
|
|
43
|
+
class Context<T extends Node, P> {
|
|
44
|
+
node: ReactiveNode<T> | undefined;
|
|
45
|
+
constructor(public parent: Node, public props: P) { }
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
class Component<O1 extends Observables, O2 extends Observables, T extends Node, P> {
|
|
49
|
+
private node: ReactiveNode<T> | undefined;
|
|
50
|
+
private observables: ScopedObservables<O1 & O2> | undefined;
|
|
51
|
+
private context: Context<T, P> | undefined;
|
|
52
|
+
|
|
53
|
+
constructor(private opts: Opts<O1, O2, T, P>) { }
|
|
54
|
+
|
|
55
|
+
toReactiveNode() {
|
|
56
|
+
return toReactiveNode(document.createComment('Component'), [{
|
|
57
|
+
mount: (parentNode: Node) => {
|
|
58
|
+
if (this.node === undefined || !this.opts.cache)
|
|
59
|
+
this.setupNode(parentNode);
|
|
60
|
+
this.node?.mount(parentNode);
|
|
61
|
+
},
|
|
62
|
+
activate: () => this.node?.activate(),
|
|
63
|
+
deactivate: () => {
|
|
64
|
+
this.node?.deactivate();
|
|
65
|
+
if (this.observables === undefined) return;
|
|
66
|
+
for (const key in this.observables)
|
|
67
|
+
this.observables[key as keyof O1 & O2].unsubscribeAll();
|
|
68
|
+
},
|
|
69
|
+
unmount: () => {
|
|
70
|
+
this.node?.unmount();
|
|
71
|
+
if (!this.opts.cache) this.cleanUp();
|
|
72
|
+
}
|
|
73
|
+
}]);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
private setupNode(parentNode: Node) {
|
|
77
|
+
this.observables = this.buildObservables();
|
|
78
|
+
this.context = new Context(parentNode, this.opts.props);
|
|
79
|
+
this.node = this.opts.render.call(this.context, this.observables);
|
|
80
|
+
this.context.node = this.node;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
private cleanUp() {
|
|
84
|
+
if (this.context !== undefined) this.context.node = undefined;
|
|
85
|
+
this.node = undefined;
|
|
86
|
+
this.observables = undefined;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
private buildObservables() {
|
|
90
|
+
const coreObservables = this.opts.observables();
|
|
91
|
+
const derivedObservables = this.opts.derivedObservables(coreObservables);
|
|
92
|
+
const observables =
|
|
93
|
+
Object.assign({}, coreObservables, derivedObservables);
|
|
94
|
+
return this.toScoped<O1 & O2>(observables);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
private toScoped<O extends Observables>(observables: O): ScopedObservables<O> {
|
|
98
|
+
const scopedObservables: Partial<ScopedObservables<O>> = {};
|
|
99
|
+
|
|
100
|
+
for (const key in observables) {
|
|
101
|
+
const k: keyof O = key;
|
|
102
|
+
scopedObservables[k] = scopedObservable(observables[k]);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return scopedObservables as ScopedObservables<O>;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { dedupObservable, type Observable } from "../observables";
|
|
2
|
+
import { reactiveTextNode, toReactiveNode, type ReactiveNode } from "./reactive";
|
|
3
|
+
|
|
4
|
+
type ReactiveNodeBuilder<T extends Node> = (() => ReactiveNode<T>);
|
|
5
|
+
|
|
6
|
+
type Params<A extends Node, B extends Node> = {
|
|
7
|
+
if$: Observable<boolean>,
|
|
8
|
+
then: ReactiveNodeBuilder<A> | string,
|
|
9
|
+
otherwise: ReactiveNodeBuilder<B> | string
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
type CurrentNode<A extends Node, B extends Node> =
|
|
13
|
+
ReactiveNode<A> | ReactiveNode<B> | ReactiveNode<Text>;
|
|
14
|
+
|
|
15
|
+
export const cond = <A extends Node, B extends Node>(
|
|
16
|
+
{ if$, then, otherwise }: Params<A, B>
|
|
17
|
+
): ReactiveNode<Comment> => new Cond<A, B>(
|
|
18
|
+
dedupObservable(if$),
|
|
19
|
+
then,
|
|
20
|
+
otherwise
|
|
21
|
+
).toReactiveNode();
|
|
22
|
+
|
|
23
|
+
class Cond<A extends Node, B extends Node> {
|
|
24
|
+
private id = Symbol('Cond');
|
|
25
|
+
private currentNode: CurrentNode<A, B> | undefined;
|
|
26
|
+
|
|
27
|
+
constructor(
|
|
28
|
+
private if$: Observable<boolean>,
|
|
29
|
+
private then: ReactiveNodeBuilder<A> | string,
|
|
30
|
+
private otherwise: ReactiveNodeBuilder<B> | string
|
|
31
|
+
) { }
|
|
32
|
+
|
|
33
|
+
toReactiveNode() {
|
|
34
|
+
const anchor = document.createComment('Cond');
|
|
35
|
+
const updateFn = (value: boolean) => this.updateNode(anchor, value);
|
|
36
|
+
|
|
37
|
+
return toReactiveNode(anchor, [{
|
|
38
|
+
mount: (parentNode: Node) => parentNode.appendChild(anchor),
|
|
39
|
+
activate: () => this.if$.subscribeInit(this.id, updateFn),
|
|
40
|
+
deactivate: () => {
|
|
41
|
+
this.if$.unsubscribe(this.id);
|
|
42
|
+
this.currentNode?.deactivate();
|
|
43
|
+
},
|
|
44
|
+
unmount: () => {
|
|
45
|
+
this.currentNode?.unmount();
|
|
46
|
+
this.currentNode = undefined;
|
|
47
|
+
anchor.remove();
|
|
48
|
+
}
|
|
49
|
+
}]);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
private updateNode(anchor: Node, value: boolean) {
|
|
53
|
+
const newNode = value ?
|
|
54
|
+
this.buildNode<A>(this.then) :
|
|
55
|
+
this.buildNode<B>(this.otherwise);
|
|
56
|
+
try {
|
|
57
|
+
this.switchNode(anchor, newNode);
|
|
58
|
+
} catch (e) {
|
|
59
|
+
console.error(e);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
private buildNode<T extends Node>(node: string | ReactiveNodeBuilder<T>) {
|
|
64
|
+
if (typeof (node) === 'function')
|
|
65
|
+
return node();
|
|
66
|
+
if (typeof (node) === 'string')
|
|
67
|
+
return reactiveTextNode(node);
|
|
68
|
+
|
|
69
|
+
throw new Error('Then/otherwise should be either string or function');
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
private switchNode(anchor: Node, newNode: CurrentNode<A, B>) {
|
|
73
|
+
this.currentNode?.deactivate();
|
|
74
|
+
this.currentNode?.unmount();
|
|
75
|
+
newNode.mount(anchor.parentNode!);
|
|
76
|
+
newNode.activate();
|
|
77
|
+
this.currentNode = newNode;
|
|
78
|
+
}
|
|
79
|
+
}
|