xote 4.3.0 → 4.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/README.md +5 -5
- package/package.json +6 -2
- package/rescript.json +30 -0
- package/src/Xote.res +23 -0
- package/src/Xote.res.mjs +88 -0
- package/src/Xote__Component.res +495 -0
- package/src/Xote__Component.res.mjs +487 -0
- package/src/Xote__JSX.res +677 -0
- package/src/Xote__JSX.res.mjs +356 -0
- package/src/Xote__Route.res +62 -0
- package/src/Xote__Route.res.mjs +56 -0
- package/src/Xote__Router.res +130 -0
- package/src/Xote__Router.res.mjs +116 -0
package/README.md
CHANGED
|
@@ -11,7 +11,7 @@ Xote is a lightweight UI library for ReScript that combines fine-grained reactiv
|
|
|
11
11
|
- **Signal-based Reactivity**: Powered by [rescript-signals](https://github.com/brnrdog/rescript-signals) for automatic dependency tracking
|
|
12
12
|
- **Fine-grained Updates**: Direct DOM manipulation without virtual DOM diffing
|
|
13
13
|
- **Signal-based Router**: SPA navigation with pattern matching and dynamic parameters
|
|
14
|
-
- **Lightweight**: Minimal runtime footprint
|
|
14
|
+
- **Lightweight**: Minimal runtime footprint
|
|
15
15
|
- **Type-safe**: Full ReScript type safety throughout
|
|
16
16
|
|
|
17
17
|
## Getting Started
|
|
@@ -34,17 +34,17 @@ Then, add it to your ReScript project’s dependencies in `rescript.json`:
|
|
|
34
34
|
}
|
|
35
35
|
```
|
|
36
36
|
|
|
37
|
-
##
|
|
37
|
+
## Why Xote?
|
|
38
38
|
|
|
39
|
-
|
|
39
|
+
Xote uses **rescript-signals** for reactive primitives (Signal, Computed, Effect), and it adds:
|
|
40
40
|
|
|
41
|
-
- **Component System**: A minimal but powerful component model with JSX support
|
|
41
|
+
- **Component System**: A minimal but powerful component model with JSX support for declarative UI
|
|
42
42
|
- **Direct DOM Updates**: Fine-grained reactivity that updates DOM elements directly, no virtual DOM
|
|
43
43
|
- **Signal-based Router**: Client-side routing with pattern matching and reactive location state
|
|
44
44
|
- **Reactive Attributes**: Support for static, signal-based, and computed attributes on elements
|
|
45
45
|
- **Automatic Cleanup**: Effect disposal and memory management built into the component lifecycle
|
|
46
46
|
|
|
47
|
-
Xote focuses on clarity, control, and performance. The goal is to offer precise, fine-grained updates and predictable behavior with minimal abstractions.
|
|
47
|
+
Xote focuses on clarity, control, and performance. The goal is to offer precise, fine-grained updates and predictable behavior with minimal abstractions, while leveraging the robust type system from ReScript.
|
|
48
48
|
|
|
49
49
|
### Quick Example
|
|
50
50
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "xote",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.4.0",
|
|
4
4
|
"repository": {
|
|
5
5
|
"url": "https://github.com/brnrdog/xote"
|
|
6
6
|
},
|
|
@@ -8,7 +8,11 @@
|
|
|
8
8
|
"module": "./dist/xote.mjs",
|
|
9
9
|
"sideEffects": false,
|
|
10
10
|
"files": [
|
|
11
|
-
"dist"
|
|
11
|
+
"dist",
|
|
12
|
+
"src/**/*.res",
|
|
13
|
+
"src/**/*.resi",
|
|
14
|
+
"src/**/*.res.mjs",
|
|
15
|
+
"rescript.json"
|
|
12
16
|
],
|
|
13
17
|
"scripts": {
|
|
14
18
|
"res:build": "rescript",
|
package/rescript.json
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "xote",
|
|
3
|
+
"sources": [
|
|
4
|
+
{
|
|
5
|
+
"dir": "src",
|
|
6
|
+
"subdirs": true,
|
|
7
|
+
"public": ["Xote"]
|
|
8
|
+
},
|
|
9
|
+
{
|
|
10
|
+
"dir": "demos",
|
|
11
|
+
"subdirs": false
|
|
12
|
+
}
|
|
13
|
+
],
|
|
14
|
+
"package-specs": {
|
|
15
|
+
"module": "esmodule",
|
|
16
|
+
"in-source": true
|
|
17
|
+
},
|
|
18
|
+
"suffix": ".res.mjs",
|
|
19
|
+
"dependencies": [
|
|
20
|
+
"@rescript/core",
|
|
21
|
+
"rescript-signals"
|
|
22
|
+
],
|
|
23
|
+
"compiler-flags": [
|
|
24
|
+
"-open RescriptCore"
|
|
25
|
+
],
|
|
26
|
+
"jsx": {
|
|
27
|
+
"version": 4,
|
|
28
|
+
"module": "Xote__JSX"
|
|
29
|
+
}
|
|
30
|
+
}
|
package/src/Xote.res
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
module Signal = {
|
|
2
|
+
include Signals.Signal
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
module Computed = {
|
|
6
|
+
include Signals.Computed
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
module Effect = {
|
|
10
|
+
include Signals.Effect
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
module Component = {
|
|
14
|
+
include Xote__Component
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
module Route = {
|
|
18
|
+
include Xote__Route
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
module Router = {
|
|
22
|
+
include Xote__Router
|
|
23
|
+
}
|
package/src/Xote.res.mjs
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// Generated by ReScript, PLEASE EDIT WITH CARE
|
|
2
|
+
|
|
3
|
+
import * as Signals from "rescript-signals/src/Signals.res.mjs";
|
|
4
|
+
import * as Xote__Route from "./Xote__Route.res.mjs";
|
|
5
|
+
import * as Xote__Router from "./Xote__Router.res.mjs";
|
|
6
|
+
import * as Xote__Component from "./Xote__Component.res.mjs";
|
|
7
|
+
|
|
8
|
+
let Signal_Id = Signals.Signal.Id;
|
|
9
|
+
|
|
10
|
+
let Signal_Scheduler = Signals.Signal.Scheduler;
|
|
11
|
+
|
|
12
|
+
let Signal_make = Signals.Signal.make;
|
|
13
|
+
|
|
14
|
+
let Signal_get = Signals.Signal.get;
|
|
15
|
+
|
|
16
|
+
let Signal_peek = Signals.Signal.peek;
|
|
17
|
+
|
|
18
|
+
let Signal_set = Signals.Signal.set;
|
|
19
|
+
|
|
20
|
+
let Signal_update = Signals.Signal.update;
|
|
21
|
+
|
|
22
|
+
let Signal_batch = Signals.Signal.batch;
|
|
23
|
+
|
|
24
|
+
let Signal_untrack = Signals.Signal.untrack;
|
|
25
|
+
|
|
26
|
+
let Signal = {
|
|
27
|
+
Id: Signal_Id,
|
|
28
|
+
Scheduler: Signal_Scheduler,
|
|
29
|
+
make: Signal_make,
|
|
30
|
+
get: Signal_get,
|
|
31
|
+
peek: Signal_peek,
|
|
32
|
+
set: Signal_set,
|
|
33
|
+
update: Signal_update,
|
|
34
|
+
batch: Signal_batch,
|
|
35
|
+
untrack: Signal_untrack
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
let Computed_Id = Signals.Computed.Id;
|
|
39
|
+
|
|
40
|
+
let Computed_Signal = Signals.Computed.Signal;
|
|
41
|
+
|
|
42
|
+
let Computed_Observer = Signals.Computed.Observer;
|
|
43
|
+
|
|
44
|
+
let Computed_Scheduler = Signals.Computed.Scheduler;
|
|
45
|
+
|
|
46
|
+
let Computed_make = Signals.Computed.make;
|
|
47
|
+
|
|
48
|
+
let Computed_dispose = Signals.Computed.dispose;
|
|
49
|
+
|
|
50
|
+
let Computed = {
|
|
51
|
+
Id: Computed_Id,
|
|
52
|
+
Signal: Computed_Signal,
|
|
53
|
+
Observer: Computed_Observer,
|
|
54
|
+
Scheduler: Computed_Scheduler,
|
|
55
|
+
make: Computed_make,
|
|
56
|
+
dispose: Computed_dispose
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
let Effect_Id = Signals.Effect.Id;
|
|
60
|
+
|
|
61
|
+
let Effect_Observer = Signals.Effect.Observer;
|
|
62
|
+
|
|
63
|
+
let Effect_Scheduler = Signals.Effect.Scheduler;
|
|
64
|
+
|
|
65
|
+
let Effect_run = Signals.Effect.run;
|
|
66
|
+
|
|
67
|
+
let Effect = {
|
|
68
|
+
Id: Effect_Id,
|
|
69
|
+
Observer: Effect_Observer,
|
|
70
|
+
Scheduler: Effect_Scheduler,
|
|
71
|
+
run: Effect_run
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
let Component = Xote__Component;
|
|
75
|
+
|
|
76
|
+
let Route = Xote__Route;
|
|
77
|
+
|
|
78
|
+
let Router = Xote__Router;
|
|
79
|
+
|
|
80
|
+
export {
|
|
81
|
+
Signal,
|
|
82
|
+
Computed,
|
|
83
|
+
Effect,
|
|
84
|
+
Component,
|
|
85
|
+
Route,
|
|
86
|
+
Router,
|
|
87
|
+
}
|
|
88
|
+
/* Signals Not a pure module */
|
|
@@ -0,0 +1,495 @@
|
|
|
1
|
+
open Signals
|
|
2
|
+
|
|
3
|
+
/* ============================================================================
|
|
4
|
+
* DOM Bindings
|
|
5
|
+
* ============================================================================ */
|
|
6
|
+
|
|
7
|
+
module DOM = {
|
|
8
|
+
/* Creation */
|
|
9
|
+
@val @scope("document") external createElement: string => Dom.element = "createElement"
|
|
10
|
+
@val @scope("document")
|
|
11
|
+
external createElementNS: (string, string) => Dom.element = "createElementNS"
|
|
12
|
+
@val @scope("document") external createTextNode: string => Dom.element = "createTextNode"
|
|
13
|
+
@val @scope("document")
|
|
14
|
+
external createDocumentFragment: unit => Dom.element = "createDocumentFragment"
|
|
15
|
+
@val @scope("document") external createComment: string => Dom.element = "createComment"
|
|
16
|
+
@val @scope("document")
|
|
17
|
+
external getElementById: string => Nullable.t<Dom.element> = "getElementById"
|
|
18
|
+
|
|
19
|
+
/* Accessors */
|
|
20
|
+
@get external getNextSibling: Dom.element => Nullable.t<Dom.element> = "nextSibling"
|
|
21
|
+
@get external getParentNode: Dom.element => Nullable.t<Dom.element> = "parentNode"
|
|
22
|
+
|
|
23
|
+
/* Mutations */
|
|
24
|
+
@send
|
|
25
|
+
external addEventListener: (Dom.element, string, Dom.event => unit) => unit = "addEventListener"
|
|
26
|
+
@send external appendChild: (Dom.element, Dom.element) => unit = "appendChild"
|
|
27
|
+
@send external setAttribute: (Dom.element, string, string) => unit = "setAttribute"
|
|
28
|
+
@send external replaceChild: (Dom.element, Dom.element, Dom.element) => unit = "replaceChild"
|
|
29
|
+
@send external insertBefore: (Dom.element, Dom.element, Dom.element) => unit = "insertBefore"
|
|
30
|
+
@set external setTextContent: (Dom.element, string) => unit = "textContent"
|
|
31
|
+
@set external setValue: (Dom.element, string) => unit = "value"
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/* ============================================================================
|
|
35
|
+
* Reactivity / Owner System
|
|
36
|
+
* ============================================================================ */
|
|
37
|
+
|
|
38
|
+
module Reactivity = {
|
|
39
|
+
/* Owner tracks reactive state for a component scope */
|
|
40
|
+
type owner = {
|
|
41
|
+
disposers: array<Effect.disposer>,
|
|
42
|
+
mutable computeds: array<Obj.t>,
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/* Global owner stack */
|
|
46
|
+
let currentOwner: ref<option<owner>> = ref(None)
|
|
47
|
+
|
|
48
|
+
/* Create a new owner */
|
|
49
|
+
let createOwner = (): owner => {
|
|
50
|
+
disposers: [],
|
|
51
|
+
computeds: [],
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/* Run function with owner context */
|
|
55
|
+
let runWithOwner = (owner: owner, fn: unit => 'a): 'a => {
|
|
56
|
+
let previousOwner = currentOwner.contents
|
|
57
|
+
currentOwner := Some(owner)
|
|
58
|
+
let result = fn()
|
|
59
|
+
currentOwner := previousOwner
|
|
60
|
+
result
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/* Add disposer to owner */
|
|
64
|
+
let addDisposer = (owner: owner, disposer: Effect.disposer): unit => {
|
|
65
|
+
owner.disposers->Array.push(disposer)->ignore
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/* Dispose owner and all its reactive state */
|
|
69
|
+
let disposeOwner = (owner: owner): unit => {
|
|
70
|
+
/* Dispose all effects */
|
|
71
|
+
owner.disposers->Array.forEach(disposer => disposer.dispose())
|
|
72
|
+
|
|
73
|
+
/* Dispose all computeds */
|
|
74
|
+
owner.computeds->Array.forEach(computed => {
|
|
75
|
+
let c: Signal.t<Obj.t> = Obj.magic(computed)
|
|
76
|
+
Computed.dispose(c)
|
|
77
|
+
})
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/* Owner storage on DOM elements */
|
|
81
|
+
@warning("-27")
|
|
82
|
+
let setOwner = (element: Dom.element, owner: owner): unit => {
|
|
83
|
+
%raw(`element["__xote_owner__"] = owner`)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
@warning("-27")
|
|
87
|
+
let getOwner = (element: Dom.element): option<owner> => {
|
|
88
|
+
let owner: Nullable.t<owner> = %raw(`element["__xote_owner__"]`)
|
|
89
|
+
owner->Nullable.toOption
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/* ============================================================================
|
|
94
|
+
* Type Definitions
|
|
95
|
+
* ============================================================================ */
|
|
96
|
+
|
|
97
|
+
/* Attribute value source */
|
|
98
|
+
type attrValue =
|
|
99
|
+
| Static(string)
|
|
100
|
+
| SignalValue(Signal.t<string>)
|
|
101
|
+
| Compute(unit => string)
|
|
102
|
+
|
|
103
|
+
/* Virtual node types */
|
|
104
|
+
type rec node =
|
|
105
|
+
| Element({
|
|
106
|
+
tag: string,
|
|
107
|
+
attrs: array<(string, attrValue)>,
|
|
108
|
+
events: array<(string, Dom.event => unit)>,
|
|
109
|
+
children: array<node>,
|
|
110
|
+
})
|
|
111
|
+
| Text(string)
|
|
112
|
+
| SignalText(Signal.t<string>)
|
|
113
|
+
| Fragment(array<node>)
|
|
114
|
+
| SignalFragment(Signal.t<array<node>>)
|
|
115
|
+
| LazyComponent(unit => node)
|
|
116
|
+
| KeyedList({signal: Signal.t<array<Obj.t>>, keyFn: Obj.t => string, renderItem: Obj.t => node})
|
|
117
|
+
|
|
118
|
+
/* ============================================================================
|
|
119
|
+
* Attribute Helpers
|
|
120
|
+
* ============================================================================ */
|
|
121
|
+
|
|
122
|
+
module Attributes = {
|
|
123
|
+
let static = (key: string, value: string): (string, attrValue) => (key, Static(value))
|
|
124
|
+
|
|
125
|
+
let signal = (key: string, signal: Signal.t<string>): (string, attrValue) => (
|
|
126
|
+
key,
|
|
127
|
+
SignalValue(signal),
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
let computed = (key: string, compute: unit => string): (string, attrValue) => (
|
|
131
|
+
key,
|
|
132
|
+
Compute(compute),
|
|
133
|
+
)
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/* Public API for attributes */
|
|
137
|
+
let attr = Attributes.static
|
|
138
|
+
let signalAttr = Attributes.signal
|
|
139
|
+
let computedAttr = Attributes.computed
|
|
140
|
+
|
|
141
|
+
/* ============================================================================
|
|
142
|
+
* Rendering
|
|
143
|
+
* ============================================================================ */
|
|
144
|
+
|
|
145
|
+
module Render = {
|
|
146
|
+
open Reactivity
|
|
147
|
+
|
|
148
|
+
/* Type for tracking keyed list items */
|
|
149
|
+
type keyedItem<'a> = {
|
|
150
|
+
key: string,
|
|
151
|
+
item: 'a,
|
|
152
|
+
element: Dom.element,
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/* Dispose an element and its reactive state */
|
|
156
|
+
let rec disposeElement = (el: Dom.element): unit => {
|
|
157
|
+
/* Dispose the owner if it exists */
|
|
158
|
+
switch getOwner(el) {
|
|
159
|
+
| Some(owner) => disposeOwner(owner)
|
|
160
|
+
| None => ()
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/* Recursively dispose children */
|
|
164
|
+
let childNodes: array<Dom.element> = %raw(`Array.from(el.childNodes || [])`)
|
|
165
|
+
childNodes->Array.forEach(disposeElement)
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/* Render a virtual node to a DOM element */
|
|
169
|
+
let rec render = (node: node): Dom.element => {
|
|
170
|
+
switch node {
|
|
171
|
+
| Text(content) => DOM.createTextNode(content)
|
|
172
|
+
|
|
173
|
+
| SignalText(signal) => {
|
|
174
|
+
let textNode = DOM.createTextNode(Signal.peek(signal))
|
|
175
|
+
let owner = createOwner()
|
|
176
|
+
setOwner(textNode, owner)
|
|
177
|
+
|
|
178
|
+
runWithOwner(owner, () => {
|
|
179
|
+
let disposer = Effect.run(() => {
|
|
180
|
+
DOM.setTextContent(textNode, Signal.get(signal))
|
|
181
|
+
None
|
|
182
|
+
})
|
|
183
|
+
addDisposer(owner, disposer)
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
textNode
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
| Fragment(children) => {
|
|
190
|
+
let fragment = DOM.createDocumentFragment()
|
|
191
|
+
children->Array.forEach(child => {
|
|
192
|
+
let childEl = render(child)
|
|
193
|
+
fragment->DOM.appendChild(childEl)
|
|
194
|
+
})
|
|
195
|
+
fragment
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
| SignalFragment(signal) => {
|
|
199
|
+
let owner = createOwner()
|
|
200
|
+
let container = DOM.createElement("div")
|
|
201
|
+
DOM.setAttribute(container, "style", "display: contents")
|
|
202
|
+
setOwner(container, owner)
|
|
203
|
+
|
|
204
|
+
runWithOwner(owner, () => {
|
|
205
|
+
let disposer = Effect.run(() => {
|
|
206
|
+
let children = Signal.get(signal)
|
|
207
|
+
|
|
208
|
+
/* Dispose existing children */
|
|
209
|
+
let childNodes: array<Dom.element> = %raw(`Array.from(container.childNodes || [])`)
|
|
210
|
+
childNodes->Array.forEach(disposeElement)
|
|
211
|
+
|
|
212
|
+
/* Clear existing children */
|
|
213
|
+
let _ = (%raw(`container.innerHTML = ''`): unit)
|
|
214
|
+
|
|
215
|
+
/* Render and append new children */
|
|
216
|
+
children->Array.forEach(
|
|
217
|
+
child => {
|
|
218
|
+
let childEl = render(child)
|
|
219
|
+
container->DOM.appendChild(childEl)
|
|
220
|
+
},
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
None
|
|
224
|
+
})
|
|
225
|
+
|
|
226
|
+
addDisposer(owner, disposer)
|
|
227
|
+
})
|
|
228
|
+
|
|
229
|
+
container
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
| Element({tag, attrs, events, children}) => {
|
|
233
|
+
let el = DOM.createElement(tag)
|
|
234
|
+
let owner = createOwner()
|
|
235
|
+
setOwner(el, owner)
|
|
236
|
+
|
|
237
|
+
runWithOwner(owner, () => {
|
|
238
|
+
/* Set attributes */
|
|
239
|
+
attrs->Array.forEach(((key, value)) => {
|
|
240
|
+
switch value {
|
|
241
|
+
| Static(v) => DOM.setAttribute(el, key, v)
|
|
242
|
+
| SignalValue(signal) => {
|
|
243
|
+
DOM.setAttribute(el, key, Signal.peek(signal))
|
|
244
|
+
let disposer = Effect.run(
|
|
245
|
+
() => {
|
|
246
|
+
DOM.setAttribute(el, key, Signal.get(signal))
|
|
247
|
+
None
|
|
248
|
+
},
|
|
249
|
+
)
|
|
250
|
+
addDisposer(owner, disposer)
|
|
251
|
+
}
|
|
252
|
+
| Compute(compute) => {
|
|
253
|
+
DOM.setAttribute(el, key, compute())
|
|
254
|
+
let disposer = Effect.run(
|
|
255
|
+
() => {
|
|
256
|
+
DOM.setAttribute(el, key, compute())
|
|
257
|
+
None
|
|
258
|
+
},
|
|
259
|
+
)
|
|
260
|
+
addDisposer(owner, disposer)
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
})
|
|
264
|
+
|
|
265
|
+
/* Attach event listeners */
|
|
266
|
+
events->Array.forEach(((eventName, handler)) => {
|
|
267
|
+
el->DOM.addEventListener(eventName, handler)
|
|
268
|
+
})
|
|
269
|
+
|
|
270
|
+
/* Append children */
|
|
271
|
+
children->Array.forEach(child => {
|
|
272
|
+
let childEl = render(child)
|
|
273
|
+
el->DOM.appendChild(childEl)
|
|
274
|
+
})
|
|
275
|
+
})
|
|
276
|
+
|
|
277
|
+
el
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
| LazyComponent(fn) => {
|
|
281
|
+
let owner = createOwner()
|
|
282
|
+
let childNode = runWithOwner(owner, fn)
|
|
283
|
+
let el = render(childNode)
|
|
284
|
+
setOwner(el, owner)
|
|
285
|
+
el
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
| KeyedList({signal, keyFn, renderItem}) => {
|
|
289
|
+
let owner = createOwner()
|
|
290
|
+
let startAnchor = DOM.createComment(" keyed-list-start ")
|
|
291
|
+
let endAnchor = DOM.createComment(" keyed-list-end ")
|
|
292
|
+
|
|
293
|
+
setOwner(startAnchor, owner)
|
|
294
|
+
|
|
295
|
+
let keyedItems: Dict.t<keyedItem<Obj.t>> = Dict.make()
|
|
296
|
+
|
|
297
|
+
/* Reconciliation logic */
|
|
298
|
+
let reconcile = (): unit => {
|
|
299
|
+
let parentOpt = DOM.getParentNode(endAnchor)->Nullable.toOption
|
|
300
|
+
|
|
301
|
+
switch parentOpt {
|
|
302
|
+
| None => ()
|
|
303
|
+
| Some(parent) => {
|
|
304
|
+
let newItems = Signal.get(signal)
|
|
305
|
+
|
|
306
|
+
let newKeyMap: Dict.t<Obj.t> = Dict.make()
|
|
307
|
+
newItems->Array.forEach(item => {
|
|
308
|
+
newKeyMap->Dict.set(keyFn(item), item)
|
|
309
|
+
})
|
|
310
|
+
|
|
311
|
+
/* Phase 1: Remove */
|
|
312
|
+
let keysToRemove = []
|
|
313
|
+
keyedItems
|
|
314
|
+
->Dict.keysToArray
|
|
315
|
+
->Array.forEach(key => {
|
|
316
|
+
switch newKeyMap->Dict.get(key) {
|
|
317
|
+
| None => keysToRemove->Array.push(key)->ignore
|
|
318
|
+
| Some(_) => ()
|
|
319
|
+
}
|
|
320
|
+
})
|
|
321
|
+
|
|
322
|
+
keysToRemove->Array.forEach(key => {
|
|
323
|
+
switch keyedItems->Dict.get(key) {
|
|
324
|
+
| Some(keyedItem) => {
|
|
325
|
+
disposeElement(keyedItem.element)
|
|
326
|
+
let _ = (%raw(`keyedItem.element.remove()`): unit)
|
|
327
|
+
keyedItems->Dict.delete(key)->ignore
|
|
328
|
+
}
|
|
329
|
+
| None => ()
|
|
330
|
+
}
|
|
331
|
+
})
|
|
332
|
+
|
|
333
|
+
/* Phase 2: Build new order */
|
|
334
|
+
let newOrder: array<keyedItem<Obj.t>> = []
|
|
335
|
+
let elementsToReplace: Dict.t<bool> = Dict.make()
|
|
336
|
+
|
|
337
|
+
newItems->Array.forEach(item => {
|
|
338
|
+
let key = keyFn(item)
|
|
339
|
+
|
|
340
|
+
switch keyedItems->Dict.get(key) {
|
|
341
|
+
| Some(existing) =>
|
|
342
|
+
if existing.item !== item {
|
|
343
|
+
elementsToReplace->Dict.set(key, true)
|
|
344
|
+
let node = renderItem(item)
|
|
345
|
+
let element = render(node)
|
|
346
|
+
let keyedItem = {key, item, element}
|
|
347
|
+
newOrder->Array.push(keyedItem)->ignore
|
|
348
|
+
keyedItems->Dict.set(key, keyedItem)
|
|
349
|
+
} else {
|
|
350
|
+
newOrder->Array.push(existing)->ignore
|
|
351
|
+
}
|
|
352
|
+
| None => {
|
|
353
|
+
let node = renderItem(item)
|
|
354
|
+
let element = render(node)
|
|
355
|
+
let keyedItem = {key, item, element}
|
|
356
|
+
newOrder->Array.push(keyedItem)->ignore
|
|
357
|
+
keyedItems->Dict.set(key, keyedItem)
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
})
|
|
361
|
+
|
|
362
|
+
/* Phase 3: Reconcile DOM */
|
|
363
|
+
let marker = ref(DOM.getNextSibling(startAnchor))
|
|
364
|
+
|
|
365
|
+
newOrder->Array.forEach(keyedItem => {
|
|
366
|
+
let currentElement = marker.contents
|
|
367
|
+
|
|
368
|
+
switch currentElement->Nullable.toOption {
|
|
369
|
+
| Some(elem) if elem === endAnchor =>
|
|
370
|
+
DOM.insertBefore(parent, keyedItem.element, endAnchor)
|
|
371
|
+
| Some(elem) if elem === keyedItem.element => marker := DOM.getNextSibling(elem)
|
|
372
|
+
| Some(elem) => {
|
|
373
|
+
let needsReplacement =
|
|
374
|
+
elementsToReplace->Dict.get(keyedItem.key)->Option.getOr(false)
|
|
375
|
+
|
|
376
|
+
if needsReplacement {
|
|
377
|
+
disposeElement(elem)
|
|
378
|
+
DOM.replaceChild(parent, keyedItem.element, elem)
|
|
379
|
+
marker := DOM.getNextSibling(keyedItem.element)
|
|
380
|
+
} else {
|
|
381
|
+
DOM.insertBefore(parent, keyedItem.element, elem)
|
|
382
|
+
marker := DOM.getNextSibling(keyedItem.element)
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
| None => DOM.insertBefore(parent, keyedItem.element, endAnchor)
|
|
386
|
+
}
|
|
387
|
+
})
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/* Initial render */
|
|
393
|
+
let fragment = DOM.createDocumentFragment()
|
|
394
|
+
fragment->DOM.appendChild(startAnchor)
|
|
395
|
+
|
|
396
|
+
let initialItems = Signal.peek(signal)
|
|
397
|
+
initialItems->Array.forEach(item => {
|
|
398
|
+
let key = keyFn(item)
|
|
399
|
+
let node = renderItem(item)
|
|
400
|
+
let element = render(node)
|
|
401
|
+
let keyedItem = {key, item, element}
|
|
402
|
+
keyedItems->Dict.set(key, keyedItem)
|
|
403
|
+
fragment->DOM.appendChild(element)
|
|
404
|
+
})
|
|
405
|
+
|
|
406
|
+
fragment->DOM.appendChild(endAnchor)
|
|
407
|
+
|
|
408
|
+
runWithOwner(owner, () => {
|
|
409
|
+
let disposer = Effect.run(() => {
|
|
410
|
+
reconcile()
|
|
411
|
+
None
|
|
412
|
+
})
|
|
413
|
+
addDisposer(owner, disposer)
|
|
414
|
+
})
|
|
415
|
+
|
|
416
|
+
fragment
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/* ============================================================================
|
|
423
|
+
* Public API
|
|
424
|
+
* ============================================================================ */
|
|
425
|
+
|
|
426
|
+
/* Text nodes */
|
|
427
|
+
let text = (content: string): node => Text(content)
|
|
428
|
+
|
|
429
|
+
let textSignal = (compute: unit => string): node => {
|
|
430
|
+
let signal = Computed.make(compute)
|
|
431
|
+
SignalText(signal)
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/* Fragments */
|
|
435
|
+
let fragment = (children: array<node>): node => Fragment(children)
|
|
436
|
+
|
|
437
|
+
let signalFragment = (signal: Signal.t<array<node>>): node => SignalFragment(signal)
|
|
438
|
+
|
|
439
|
+
/* Lists */
|
|
440
|
+
let list = (signal: Signal.t<array<'a>>, renderItem: 'a => node): node => {
|
|
441
|
+
let nodesSignal = Computed.make(() => {
|
|
442
|
+
Signal.get(signal)->Array.map(renderItem)
|
|
443
|
+
})
|
|
444
|
+
SignalFragment(nodesSignal)
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
let keyedList = (
|
|
448
|
+
signal: Signal.t<array<'a>>,
|
|
449
|
+
keyFn: 'a => string,
|
|
450
|
+
renderItem: 'a => node,
|
|
451
|
+
): node => {
|
|
452
|
+
KeyedList({
|
|
453
|
+
signal: Obj.magic(signal),
|
|
454
|
+
keyFn: Obj.magic(keyFn),
|
|
455
|
+
renderItem: Obj.magic(renderItem),
|
|
456
|
+
})
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/* Element constructor */
|
|
460
|
+
let element = (
|
|
461
|
+
tag: string,
|
|
462
|
+
~attrs: array<(string, attrValue)>=[]->Array.map(x => x),
|
|
463
|
+
~events: array<(string, Dom.event => unit)>=[]->Array.map(x => x),
|
|
464
|
+
~children: array<node>=[]->Array.map(x => x),
|
|
465
|
+
(),
|
|
466
|
+
): node => Element({tag, attrs, events, children})
|
|
467
|
+
|
|
468
|
+
/* Common elements */
|
|
469
|
+
let div = (~attrs=?, ~events=?, ~children=?, ()) =>
|
|
470
|
+
element("div", ~attrs?, ~events?, ~children?, ())
|
|
471
|
+
let span = (~attrs=?, ~events=?, ~children=?, ()) =>
|
|
472
|
+
element("span", ~attrs?, ~events?, ~children?, ())
|
|
473
|
+
let button = (~attrs=?, ~events=?, ~children=?, ()) =>
|
|
474
|
+
element("button", ~attrs?, ~events?, ~children?, ())
|
|
475
|
+
let input = (~attrs=?, ~events=?, ()) => element("input", ~attrs?, ~events?, ())
|
|
476
|
+
let h1 = (~attrs=?, ~events=?, ~children=?, ()) => element("h1", ~attrs?, ~events?, ~children?, ())
|
|
477
|
+
let h2 = (~attrs=?, ~events=?, ~children=?, ()) => element("h2", ~attrs?, ~events?, ~children?, ())
|
|
478
|
+
let h3 = (~attrs=?, ~events=?, ~children=?, ()) => element("h3", ~attrs?, ~events?, ~children?, ())
|
|
479
|
+
let p = (~attrs=?, ~events=?, ~children=?, ()) => element("p", ~attrs?, ~events?, ~children?, ())
|
|
480
|
+
let ul = (~attrs=?, ~events=?, ~children=?, ()) => element("ul", ~attrs?, ~events?, ~children?, ())
|
|
481
|
+
let li = (~attrs=?, ~events=?, ~children=?, ()) => element("li", ~attrs?, ~events?, ~children?, ())
|
|
482
|
+
let a = (~attrs=?, ~events=?, ~children=?, ()) => element("a", ~attrs?, ~events?, ~children?, ())
|
|
483
|
+
|
|
484
|
+
/* Mounting */
|
|
485
|
+
let mount = (node: node, container: Dom.element): unit => {
|
|
486
|
+
let el = Render.render(node)
|
|
487
|
+
container->DOM.appendChild(el)
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
let mountById = (node: node, containerId: string): unit => {
|
|
491
|
+
switch DOM.getElementById(containerId)->Nullable.toOption {
|
|
492
|
+
| Some(container) => mount(node, container)
|
|
493
|
+
| None => Console.error("Container element not found: " ++ containerId)
|
|
494
|
+
}
|
|
495
|
+
}
|