snapse 0.0.0-stage → 0.9.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 +1169 -2
- package/dist/DOM/domFunctions.d.ts +12 -0
- package/dist/DOM/elements/DOMElements.d.ts +141 -0
- package/dist/DOM/elements/SVGElements.d.ts +65 -0
- package/dist/DOM/elements/elementAttributeTypes.d.ts +100 -0
- package/dist/DOM/nodeControllers/ComponentController.d.ts +10 -0
- package/dist/DOM/nodeControllers/ElementController.d.ts +14 -0
- package/dist/DOM/nodeControllers/NodeController.d.ts +81 -0
- package/dist/DOM/nodeControllers/RootController.d.ts +12 -0
- package/dist/DOM/nodeControllers/SVGElementController.d.ts +4 -0
- package/dist/DOM/nodeControllers/StateController.d.ts +43 -0
- package/dist/DOM/server/serialize.d.ts +2 -0
- package/dist/DOM/server/serverNodes.d.ts +41 -0
- package/dist/DOM/types.d.ts +84 -0
- package/dist/DOM/util/RenderError.d.ts +10 -0
- package/dist/DOM/util/childShape.d.ts +7 -0
- package/dist/DOM/util/deferredNodes.d.ts +7 -0
- package/dist/DOM/util/domNode.d.ts +2 -0
- package/dist/DOM/util/moveBeforeSupport.d.ts +1 -0
- package/dist/DOM/util/nodeCount.d.ts +2 -0
- package/dist/DOM/util/util.d.ts +2 -0
- package/dist/DOM.d.ts +4 -0
- package/dist/SVG.d.ts +3 -0
- package/dist/common/computeMutation.d.ts +16 -0
- package/dist/common/dev.d.ts +1 -0
- package/dist/common/shorthands.d.ts +1 -0
- package/dist/common/undoUtils.d.ts +1 -0
- package/dist/common/util.d.ts +4 -0
- package/dist/index.d.ts +7 -0
- package/dist/lint/index.d.ts +7 -0
- package/dist/lint/index.js +8 -0
- package/dist/lint/noBooleanStateArgument.d.ts +2 -0
- package/dist/lint/noBooleanStateArgument.js +43 -0
- package/dist/lint/noStateTruthiness.d.ts +2 -0
- package/dist/lint/noStateTruthiness.js +92 -0
- package/dist/lint/stateTypes.d.ts +4 -0
- package/dist/lint/stateTypes.js +14 -0
- package/dist/server.d.ts +2 -0
- package/dist/snapse.DOM.cjs +1 -0
- package/dist/snapse.DOM.development.cjs +115 -0
- package/dist/snapse.DOM.development.js +1 -0
- package/dist/snapse.SVG.cjs +1 -0
- package/dist/snapse.SVG.development.cjs +66 -0
- package/dist/snapse.SVG.development.js +1 -0
- package/dist/snapse.SVG.js +1 -0
- package/dist/snapse.cjs +1 -0
- package/dist/snapse.development.cjs +13 -0
- package/dist/snapse.development.js +1 -0
- package/dist/snapse.js +75 -0
- package/dist/snapse.server.cjs +1 -0
- package/dist/snapse.server.development.cjs +3 -0
- package/dist/snapse.server.development.js +1 -0
- package/dist/snapseCore-Bc8ZDYHa.cjs +1 -0
- package/dist/snapseCore-DpadpXgD.js +3002 -0
- package/dist/snapseCore-Xfnvf03Z.cjs +4135 -0
- package/dist/state/common/refCountAndType.d.ts +8 -0
- package/dist/state/common/stateProperty.d.ts +1 -0
- package/dist/state/compute.d.ts +2 -0
- package/dist/state/state.d.ts +39 -0
- package/dist/state/stateInternals/LightState.d.ts +20 -0
- package/dist/state/stateInternals/internalFunctions.d.ts +13 -0
- package/dist/state/stateInternals/notify.d.ts +7 -0
- package/dist/state/stateInternals/poison.d.ts +4 -0
- package/dist/state/stateInternals/stateUpdate.d.ts +9 -0
- package/dist/state/subscribeChanges.d.ts +2 -0
- package/dist/state/transforms/CallbackTransform.d.ts +13 -0
- package/dist/state/transforms/ChainFunctionTransform.d.ts +8 -0
- package/dist/state/transforms/ChangeAggregateTransform.d.ts +20 -0
- package/dist/state/transforms/ChangeCallbackTransform.d.ts +5 -0
- package/dist/state/transforms/ComputeTransform.d.ts +4 -0
- package/dist/state/transforms/DOMRootTransform.d.ts +8 -0
- package/dist/state/transforms/FunctionTransform.d.ts +5 -0
- package/dist/state/transforms/IsTransform.d.ts +25 -0
- package/dist/state/transforms/LightStateFunctionTransform.d.ts +3 -0
- package/dist/state/transforms/MapArrayTransform.d.ts +10 -0
- package/dist/state/transforms/MapValueTransform.d.ts +3 -0
- package/dist/state/transforms/OptTransform.d.ts +10 -0
- package/dist/state/transforms/PromiseTransform.d.ts +6 -0
- package/dist/state/transforms/SubscribeTransform.d.ts +13 -0
- package/dist/state/transforms/Transform.d.ts +35 -0
- package/dist/state/transforms/transforms.d.ts +30 -0
- package/dist/state/types/stateTypes.d.ts +175 -0
- package/dist/state/types/types.d.ts +35 -0
- package/dist/undo/undo.d.ts +39 -0
- package/package.json +141 -4
package/README.md
CHANGED
|
@@ -1,3 +1,1170 @@
|
|
|
1
|
-
|
|
1
|
+
<!-- Generated from docs/README.template.md by scripts/buildReadme.mjs. Do not edit README.md directly;
|
|
2
|
+
edit the template or the example sources and run `npm run build:readme`. -->
|
|
3
|
+
# Snapse
|
|
2
4
|
|
|
3
|
-
|
|
5
|
+
Snapse is a JavaScript/TypeScript library for creating interactive web pages and applications. It provides state management with a tightly integrated store, and high level components based on DOM elements.
|
|
6
|
+
|
|
7
|
+
Snapse ties chain-reactive states into components to create atomic reactivity, while offering a straightforward development experience that scales from the simplest web page to the most advanced application.
|
|
8
|
+
|
|
9
|
+
Snapse is a very small library. But its goal is to do everything that large web frameworks do - just more simply by leveraging standard JavaScript functionality.
|
|
10
|
+
|
|
11
|
+
## Compatibility
|
|
12
|
+
|
|
13
|
+
Snapse follows the specifications for ECMAScript 2021 strict mode. It does not modify any global objects or functions. Snapse adheres to the W3C defined behaviour for DOM elements, but extends it - most obviously by supporting the insertion of stateful content. It does not insert 'magic content' into the DOM, and you are free to directly modify the DOM if you so choose - with the obvious limitation that anything Snapse is delegated to update may remove elements and content as the corresponding state changes.
|
|
14
|
+
|
|
15
|
+
Snapse is not a framework - it is a library designed to co-exist with any other tools or libraries that support JavaScript and web standards.
|
|
16
|
+
|
|
17
|
+
### Import and require
|
|
18
|
+
|
|
19
|
+
Both ESM and CommonJS imports are supported, as well as loading snapse.js directly in a web page. In DEV mode, Snapse provides source maps and throws helpful error messages.
|
|
20
|
+
|
|
21
|
+
### TypeScript
|
|
22
|
+
|
|
23
|
+
Snapse preserves TypeScript types, and all functions including DOM element factory functions are fully typed, requiring a minimum of explicit typing.
|
|
24
|
+
|
|
25
|
+
## Usage
|
|
26
|
+
|
|
27
|
+
Create a Vite project, then add Snapse:
|
|
28
|
+
|
|
29
|
+
```sh
|
|
30
|
+
npm create vite@latest my-app -- --template vanilla-ts
|
|
31
|
+
cd my-app
|
|
32
|
+
npm install snapse
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Replace the contents of `src/main.ts`:
|
|
36
|
+
|
|
37
|
+
```typescript
|
|
38
|
+
import { SnapseRoot, state } from 'snapse'
|
|
39
|
+
import { button } from 'snapse/DOM'
|
|
40
|
+
|
|
41
|
+
const s = state({ count: 0 })
|
|
42
|
+
|
|
43
|
+
SnapseRoot(button({ click: () => s.count++ }, 'Clicked ', s.count, ' times'))
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Start the dev server with `npm run dev`.
|
|
47
|
+
|
|
48
|
+
### Without a build step
|
|
49
|
+
|
|
50
|
+
Snapse can also be loaded directly in a web page, as a single JavaScript module that includes all elements. In this module, the SVG elements whose names clash with HTML elements are named `svg_a`, `svg_script`, `svg_style` and `svg_title`:
|
|
51
|
+
|
|
52
|
+
```html
|
|
53
|
+
<!doctype html>
|
|
54
|
+
<script type="module">
|
|
55
|
+
import { SnapseRoot, button, state } from 'https://cdn.jsdelivr.net/npm/snapse/dist/snapse.js'
|
|
56
|
+
|
|
57
|
+
const s = state({ count: 0 })
|
|
58
|
+
|
|
59
|
+
SnapseRoot(button({ click: () => s.count++ }, 'Clicked ', s.count, ' times'))
|
|
60
|
+
</script>
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
### Lint rule
|
|
64
|
+
|
|
65
|
+
A State is an object, so `if (s.done)` or `s.done && …` is always true, whatever the value. TypeScript cannot see this, but the `snapse/lint` ESLint rule can. It needs [typescript-eslint](https://typescript-eslint.io) with type information:
|
|
66
|
+
|
|
67
|
+
```sh
|
|
68
|
+
npm install --save-dev eslint typescript-eslint
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
```js
|
|
72
|
+
// eslint.config.js
|
|
73
|
+
import snapse from 'snapse/lint'
|
|
74
|
+
|
|
75
|
+
export default tseslint.config(
|
|
76
|
+
{
|
|
77
|
+
...
|
|
78
|
+
plugins: { snapse },
|
|
79
|
+
rules: {
|
|
80
|
+
'snapse/no-state-truthiness': 'error',
|
|
81
|
+
'snapse/no-boolean-state-argument': 'warn',
|
|
82
|
+
},
|
|
83
|
+
},
|
|
84
|
+
)
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
To test the value of a boolean or number State, use `+s.done`.
|
|
88
|
+
|
|
89
|
+
The `no-boolean-state-argument` rule warns when a boolean State is passed where a plain `boolean` is expected, since the receiving code will most likely test it: pass `!!+s.done` instead.
|
|
90
|
+
|
|
91
|
+
## State basics
|
|
92
|
+
|
|
93
|
+
The basis of Snapse is State objects. These tie together with Components and DOM elements.
|
|
94
|
+
|
|
95
|
+
### state: define a primary State
|
|
96
|
+
|
|
97
|
+
```typescript
|
|
98
|
+
function state<T>(value: T, name?: string | symbol): State<T>
|
|
99
|
+
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
The value can be any JS data type. One or more literal objects that group state is the suggested way to set up state for a Component, but individual State values can be freely created, extracted, recombined and passed around to functions and components.
|
|
103
|
+
|
|
104
|
+
Name is optional and can be a string or a Symbol. If specified, and the State is defined inside a Component, descendants of the Component can access it.
|
|
105
|
+
|
|
106
|
+
Plain object properties and array items of a State also automatically become State. In the example below, `s.toBuy[0]` is a State representing the string 'milk'.
|
|
107
|
+
|
|
108
|
+
```typescript
|
|
109
|
+
const s = state({
|
|
110
|
+
listName: 'Groceries',
|
|
111
|
+
toBuy: ['milk', 'tinfoil'],
|
|
112
|
+
alreadyBought: ['jam', 'elephant'],
|
|
113
|
+
locked: false,
|
|
114
|
+
sortFunction: (a: string, b: string) => a.localeCompare(b),
|
|
115
|
+
}, 'shoppingListState')
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
### What should be in a State?
|
|
119
|
+
|
|
120
|
+
Snapse States are primarily intended for states that, when updated, will trigger changes in the DOM. While you can put anything you like in a State, and indeed it can be useful to do so, there are many cases where stateful variables are better kept outside of Snapse State.
|
|
121
|
+
|
|
122
|
+
For instance, suppose that your app creates entities with a unique id. You need a counter to keep track of the first available id - but updating that counter probably should not lead to visible changes in the DOM. In this case, putting the id in a State most likely serves no purpose and will only complicate your code.
|
|
123
|
+
|
|
124
|
+
### Function States: create chained (computed) States
|
|
125
|
+
|
|
126
|
+
Function States create chained (computed) States
|
|
127
|
+
|
|
128
|
+
Turning a function into a State and calling that function will in turn produce a new, _chained_ State. The chained State will be updated to reflect changes to any State used in its calculation, or to the function itself.
|
|
129
|
+
|
|
130
|
+
However, Snapse does not update chained States until the result is needed somewhere. This happens when the State, or any other State that depends on it, is inserted in the DOM, or when `subscribe` is called on the State or any State depending on it. This means that it is safe to create all the chained States you ever need in your application, and let Snapse keep track of what needs to be calculated.
|
|
131
|
+
|
|
132
|
+
```typescript
|
|
133
|
+
const s = state({
|
|
134
|
+
width: 4,
|
|
135
|
+
area: (height: number): number => s.width * height,
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
// Calling the function state produces a chained state that tracks width.
|
|
139
|
+
// It is not recalculated until it is rendered or subscribed to.
|
|
140
|
+
const area = s.area(7)
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
### Promise States: update if the promise resolves
|
|
144
|
+
|
|
145
|
+
Creating a State with a promise, such as an async function, will result in a State that has the value `undefined` until the promise resolves. If the promise is rejected, the State will always remain undefined.
|
|
146
|
+
|
|
147
|
+
```typescript
|
|
148
|
+
const s = state({ user: fetchUser() })
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
### Custom objects
|
|
152
|
+
|
|
153
|
+
Custom objects are opaque to State. Internal changes to a custom object, even for publicly gettable and settable properties, will not cause reactive updates. Reactive updates will only be triggered if the custom object itself is replaced.
|
|
154
|
+
|
|
155
|
+
```typescript
|
|
156
|
+
// The root reference can't be reassigned in place, so a state to be replaced wholesale
|
|
157
|
+
// is held as a property of a root state instead of being a root state itself.
|
|
158
|
+
const s = state({ v: { selection: new Set<string>(['a']) } })
|
|
159
|
+
const hasB = compute(() => s.v.selection.has('b'))
|
|
160
|
+
|
|
161
|
+
s.v.selection.add('b') // Does not update hasB
|
|
162
|
+
s.v = { selection: new Set(['b', 'c']) } as unknown as typeof s.v // Updates hasB
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
## State functions
|
|
166
|
+
|
|
167
|
+
Snapse has a number of functions to handle States. Most code will not use these functions, with the exception of `compute`.
|
|
168
|
+
|
|
169
|
+
### compute: define computed (chained) States
|
|
170
|
+
|
|
171
|
+
```typescript
|
|
172
|
+
function compute<T>(fn: () => T): State<T>
|
|
173
|
+
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
The compute method is a convenient way to create a chained State by an anonymous function.
|
|
177
|
+
|
|
178
|
+
```typescript
|
|
179
|
+
s.allItems = compute(() => [...s.toBuy, ...s.alreadyBought])
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
### findState: find a named State in an ancestor component
|
|
183
|
+
|
|
184
|
+
```typescript
|
|
185
|
+
function findState<T>(name: string | symbol, defaultValue?: State<T>): State<T>
|
|
186
|
+
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
Use `findState` inside a component to retrieve a named State from the closest ancestor component that defines it. Note that by using a Symbol, you can limit access to components that know the value of that Symbol. If no State is found, `defaultValue` is returned if given. Otherwise, an Error is thrown. The application is responsible for ensuring that States retrieved using `findState` have the expected format.
|
|
190
|
+
|
|
191
|
+
```typescript
|
|
192
|
+
const shoppingListState = findState<ShoppingListType>('shoppingListState')
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
### peek: get the raw value of a State
|
|
196
|
+
|
|
197
|
+
```typescript
|
|
198
|
+
function peek<T>(value: T | State<T>): T
|
|
199
|
+
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
⚠️ **Note on Boolean Logic:** Because Snapse States are JavaScript objects under the hood, they are always truthy. `!s.flag` will always evaluate to `false`.
|
|
203
|
+
|
|
204
|
+
In most cases, you can use State objects transparently, because JavaScript automatically coerces them into primitives during string manipulation, math operations, or native method calls.
|
|
205
|
+
|
|
206
|
+
There are a few exceptions. If using the strict equality comparison (`===`) on a State, the object State itself will be compared for referential equality. This is always false, unless comparing the State to another reference to itself. It is recommended to cast the State explicitly to its type, eg `String(s.str) === 'approved'`. However, note that `Boolean(s.flag)` is always truthy - here, it is recommended to use `(!!+s.flag)`.
|
|
207
|
+
|
|
208
|
+
Standard array functions that take a boolean returning callback function (`every`, `filter`, `find`, `findIndex`, `findLast`, `findLastIndex` and `some`) automatically coerce a State value to boolean.
|
|
209
|
+
|
|
210
|
+
Using loose equality comparison (`==`) may or may not work depending on what types are being compared, and is discouraged.
|
|
211
|
+
|
|
212
|
+
The logical not operator: `!s.flag` is always `false`. Use `!+s.flag` instead.
|
|
213
|
+
|
|
214
|
+
Using `typeof` on a State will always return `function`. Use `typeof peek(s.value)`.
|
|
215
|
+
|
|
216
|
+
```typescript
|
|
217
|
+
if (typeof peek(s.sortFunction) === 'function') doSomething()
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
*Avoid using `peek` unless necessary, and then only use it locally. Passing around or performing calculations with raw values from your States will probably result in more complex code - let Snapse handle your stateful logic. Many applications never need to use `peek` at all.*
|
|
221
|
+
|
|
222
|
+
Most imperative patterns have a direct reactive form:
|
|
223
|
+
|
|
224
|
+
* `peek(s.n) + 1` → `s.n + 1` (arithmetic, string and relational `<` / `>` comparisons coerce the State)
|
|
225
|
+
* `compute(() => peek(a) + peek(b))` → `compute(() => a + b)` (reference the States directly)
|
|
226
|
+
* `compute(() if (peek(a.flag)) return b)` → `a.flag.opt(b)`
|
|
227
|
+
* `if (peek(s.flag))` → `if (+s.flag)` (`+` coerces to integer, which is safe for booleans)
|
|
228
|
+
* `if (!peek(s.flag))` → `if (!+s.flag)`
|
|
229
|
+
|
|
230
|
+
### isState: check if something is a State
|
|
231
|
+
|
|
232
|
+
```typescript
|
|
233
|
+
function isState(value: any): boolean
|
|
234
|
+
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
You rarely need this in application code. Snapse usually handles State and raw values transparently, and you can use `peek()` when you need to guarantee a raw value.
|
|
238
|
+
|
|
239
|
+
```typescript
|
|
240
|
+
if (isState(propA)) console.log('Received propA as a State')
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
### flush: synchronously apply State changes
|
|
244
|
+
|
|
245
|
+
```typescript
|
|
246
|
+
function flush(state?: State<any>): void
|
|
247
|
+
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
Snapse processes State and DOM updates asynchronously. Use `flush()` to apply pending changes immediately. This is primarily intended for synchronous unit testing; avoid using it in application code.
|
|
251
|
+
|
|
252
|
+
```typescript
|
|
253
|
+
s.toBuy.push('snakes')
|
|
254
|
+
flush()
|
|
255
|
+
expect(String(s.allItems)).toBe('milk,tinfoil,snakes,jam,elephant')
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
One exception is when performing a view transition: in this case, call `flush()` at the end of your `startViewTransition` callback to update the DOM synchronously.
|
|
259
|
+
|
|
260
|
+
```typescript
|
|
261
|
+
startViewTransition(() => {
|
|
262
|
+
s.locked.set(true)
|
|
263
|
+
flush()
|
|
264
|
+
})
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
### subscribe: get notifications for State changes
|
|
268
|
+
|
|
269
|
+
```typescript
|
|
270
|
+
type SubscribeOptions = { deep?: boolean }
|
|
271
|
+
|
|
272
|
+
function subscribe<T>(
|
|
273
|
+
state: State<T>,
|
|
274
|
+
callback?: (value: State<T>) => void,
|
|
275
|
+
options?: SubscribeOptions,
|
|
276
|
+
): () => void
|
|
277
|
+
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
Avoid setting other States in the callback, as that can result in unexpected update chains or even infinite loops. Most applications should not need to use `subscribe`.
|
|
281
|
+
|
|
282
|
+
Passing the `deep` option will make the callback fire also for updates to descendants of the State, through any plain objects and/or array children.
|
|
283
|
+
|
|
284
|
+
Calling the resulting unsubscribe function ends the subscription. If `subscribe` is called in a Component, it will be automatically unsubscribed when the Component is removed from the DOM.
|
|
285
|
+
|
|
286
|
+
```typescript
|
|
287
|
+
subscribe(s.allItems, () => {
|
|
288
|
+
localStorage.setItem('shoppingListItems', JSON.stringify(s.allItems))
|
|
289
|
+
})
|
|
290
|
+
```
|
|
291
|
+
|
|
292
|
+
## Working with State
|
|
293
|
+
|
|
294
|
+
State variables can, with few exceptions (see `peek`), be used just like the values they contain.
|
|
295
|
+
|
|
296
|
+
### Assignment
|
|
297
|
+
|
|
298
|
+
Primary States can be reassigned to any value:
|
|
299
|
+
|
|
300
|
+
`s.myState = null`
|
|
301
|
+
|
|
302
|
+
Alternately, a setter function can be used for primitive State values and arrays (but not for object States).
|
|
303
|
+
|
|
304
|
+
`s.myState.set(null)`
|
|
305
|
+
This must also be used to modify a standalone or destructured State object, to avoid overwriting your reference:
|
|
306
|
+
`someState.set(null)`
|
|
307
|
+
|
|
308
|
+
### Object literal assignment
|
|
309
|
+
|
|
310
|
+
Assigning a non-state value to an object literal will create a new state.
|
|
311
|
+
|
|
312
|
+
```typescript
|
|
313
|
+
const s = state({
|
|
314
|
+
dogs: 17,
|
|
315
|
+
})
|
|
316
|
+
s.cats = 19 // s.cats is now also a State
|
|
317
|
+
```
|
|
318
|
+
|
|
319
|
+
Assigning an object literal key to an existing state creates a reference.
|
|
320
|
+
|
|
321
|
+
```typescript
|
|
322
|
+
const s = state({
|
|
323
|
+
productIds: [37, 42, 55],
|
|
324
|
+
})
|
|
325
|
+
s.selectedProduct = s.productIds[2] // Create a reference to a state
|
|
326
|
+
```
|
|
327
|
+
|
|
328
|
+
To replace a reference to another state, the existing reference must first be explicitly deleted.
|
|
329
|
+
|
|
330
|
+
```typescript
|
|
331
|
+
delete s.selectedProduct
|
|
332
|
+
s.selectedProduct = s.productIds[1]
|
|
333
|
+
```
|
|
334
|
+
|
|
335
|
+
Assigning an object literal State to another object literal State will result in an upsert operation: existing State values will be updated (not replaced), and any new values will become new States.
|
|
336
|
+
|
|
337
|
+
```typescript
|
|
338
|
+
const s = state({
|
|
339
|
+
p: {
|
|
340
|
+
priceBeforeTax: 5,
|
|
341
|
+
taxRate: 0.2,
|
|
342
|
+
},
|
|
343
|
+
})
|
|
344
|
+
s.p.priceAfterTax = compute(() => s.p.priceBeforeTax * (1 + s.p.taxRate))
|
|
345
|
+
// Assigning an object literal can only be done on a property of another object literal.
|
|
346
|
+
// It upserts: the existing values are updated in place (not replaced), so priceAfterTax
|
|
347
|
+
// stays connected and recomputes. New values are added.
|
|
348
|
+
s.p = { priceBeforeTax: 8, taxRate: 0.25, campaign: true } as State<{
|
|
349
|
+
priceBeforeTax: number
|
|
350
|
+
taxRate: number
|
|
351
|
+
campaign: boolean
|
|
352
|
+
}>
|
|
353
|
+
```
|
|
354
|
+
|
|
355
|
+
### Calling chain functions
|
|
356
|
+
|
|
357
|
+
We have seen how chained or derived States can be created using the `compute` function. Calling *any* chain function on a State will return a State - including for custom objects in a State. Any other States used in supplied callbacks will also be connected to the resulting State.
|
|
358
|
+
|
|
359
|
+
```typescript
|
|
360
|
+
const s = state({
|
|
361
|
+
fruits: ['banana', 'mango', 'blueberry', 'melon'],
|
|
362
|
+
filterString: 'a',
|
|
363
|
+
customFilter: (value: string) => value.includes('go'),
|
|
364
|
+
})
|
|
365
|
+
s.filteredFruits = s.fruits.filter(fruit => fruit.indexOf(s.filterString) >= 0)
|
|
366
|
+
s.customFilteredFruits = s.fruits.filter(s.customFilter)
|
|
367
|
+
```
|
|
368
|
+
|
|
369
|
+
In the example, `s.filteredFruits` will be updated whenever the `s.fruits` array is updated, whenever any of its items are updated, and also whenever `s.filterString` is updated. The value of `s.customFilteredFruits` will also be updated whenever the `s.customFilter` *function* is updated.
|
|
370
|
+
|
|
371
|
+
The only exception to this rule is standard Array functions that modify the array, such as `.push`, `.sort`, `.splice`, etc, since such a State would serve no purpose.
|
|
372
|
+
|
|
373
|
+
### Serialising and deserialising
|
|
374
|
+
|
|
375
|
+
As long as a State only contains valid JSON values, a State can be serialized:
|
|
376
|
+
|
|
377
|
+
`JSON.stringify(s)`
|
|
378
|
+
|
|
379
|
+
Conversely, State can be initiated from stored JSON:
|
|
380
|
+
|
|
381
|
+
`state(JSON.parse(jsonString))`
|
|
382
|
+
|
|
383
|
+
### Awaiting State changes
|
|
384
|
+
|
|
385
|
+
Snapse States are thenables, which means it is possible to use `await` to halt code execution until the State changes value.
|
|
386
|
+
|
|
387
|
+
```typescript
|
|
388
|
+
s.confirmDialogOpen.set(true)
|
|
389
|
+
await s.confirmDialogResponse
|
|
390
|
+
if (String(s.confirmDialogResponse) !== 'confirmed') return
|
|
391
|
+
```
|
|
392
|
+
|
|
393
|
+
### State error handling
|
|
394
|
+
|
|
395
|
+
If the calculation of a chained state throws an error, it will result in a State that, when accessed, immediately re-throws that error. When the State is used in the DOM, it can be convenient to handle using the `.catch` function. However, if you want to detect State errors in other code, use a try ... catch.
|
|
396
|
+
|
|
397
|
+
```typescript
|
|
398
|
+
const total = compute(() => {
|
|
399
|
+
if (+s.locked) throw new Error('order total unavailable while locked')
|
|
400
|
+
return s.price * s.quantity
|
|
401
|
+
})
|
|
402
|
+
|
|
403
|
+
let message = ''
|
|
404
|
+
try {
|
|
405
|
+
String(total)
|
|
406
|
+
} catch (e) {
|
|
407
|
+
message = (e as Error).message
|
|
408
|
+
}
|
|
409
|
+
```
|
|
410
|
+
|
|
411
|
+
If a State is calculated using a function that makes it self-referential, directly or through a series of other computed States, its computation throws a `CircularDependencyError`.
|
|
412
|
+
|
|
413
|
+
```typescript
|
|
414
|
+
const s = state<number[]>([])
|
|
415
|
+
const next = compute(() => s[0]! + 1)
|
|
416
|
+
s.push(next)
|
|
417
|
+
|
|
418
|
+
const display = div(next).catch('circular dependency')
|
|
419
|
+
```
|
|
420
|
+
|
|
421
|
+
## State chain functions
|
|
422
|
+
|
|
423
|
+
Snapse defines a number of chain functions that can be more convenient to use than `compute` or a stateful function.
|
|
424
|
+
|
|
425
|
+
### .is: test for strict equality
|
|
426
|
+
|
|
427
|
+
```typescript
|
|
428
|
+
is(value: any): State<boolean>
|
|
429
|
+
|
|
430
|
+
```
|
|
431
|
+
|
|
432
|
+
The `.is` chain function checks a State for strict equality against another value, which may also be a State.
|
|
433
|
+
|
|
434
|
+
```typescript
|
|
435
|
+
const isSelected = s.selectedRowId.is(currentRow.id)
|
|
436
|
+
```
|
|
437
|
+
|
|
438
|
+
### .not: create an inverse State
|
|
439
|
+
|
|
440
|
+
```typescript
|
|
441
|
+
not(value: any): State<boolean>
|
|
442
|
+
|
|
443
|
+
```
|
|
444
|
+
|
|
445
|
+
Note that the result will be a State, not a boolean: see `peek` for how to compare boolean States. The .not chain function is useful when you need to pass an inverted State, for instance to a `disabled` DOM element attribute.
|
|
446
|
+
|
|
447
|
+
```typescript
|
|
448
|
+
input({ disabled: s.locked.not() })
|
|
449
|
+
```
|
|
450
|
+
|
|
451
|
+
### .opt: select State values
|
|
452
|
+
|
|
453
|
+
```typescript
|
|
454
|
+
opt(options: any | any[] | Record<string, any> | State<any>, defaultValue?: any | State<any>): State<any>
|
|
455
|
+
|
|
456
|
+
```
|
|
457
|
+
|
|
458
|
+
Often, it is necessary to choose between two or more values, for instance when rendering a Component. The custom `.opt` chain function can simplify this. A ternary or `switch` statement inside a `compute` function can often do the same job, so it is a matter of convenience.
|
|
459
|
+
|
|
460
|
+
* If `options` is a primitive, it will be returned if the calling State is truthy. Otherwise, `defaultValue` will be returned (or `null` if not given).
|
|
461
|
+
* If `options` is an Array and the calling State evaluates to a number, or if `options` is an object literal, the value mapped to the State's current value (`options[stateValue]`) will be returned. If the key does not exist, `defaultValue` or `null` will be returned.
|
|
462
|
+
|
|
463
|
+
```typescript
|
|
464
|
+
s.locked.opt(LockIcon())
|
|
465
|
+
s.selectedIndex.opt(s.data, 'Nothing selected')
|
|
466
|
+
```
|
|
467
|
+
|
|
468
|
+
This works the same way for a State that holds a DOM element or component, not just plain values:
|
|
469
|
+
|
|
470
|
+
```typescript
|
|
471
|
+
const selected = state<DOMChild>(null)
|
|
472
|
+
const checkmark = selected.opt(CheckIcon())
|
|
473
|
+
```
|
|
474
|
+
|
|
475
|
+
### .or: fallback for falsy values and empty arrays
|
|
476
|
+
|
|
477
|
+
```typescript
|
|
478
|
+
or(fallback: any): State<any>
|
|
479
|
+
|
|
480
|
+
```
|
|
481
|
+
|
|
482
|
+
The `.or` chain function creates a chained State which will be the original State if it is truthy or a non-empty array, otherwise a fallback value.
|
|
483
|
+
|
|
484
|
+
```typescript
|
|
485
|
+
return s.listItems.or('List is empty')
|
|
486
|
+
```
|
|
487
|
+
|
|
488
|
+
A State that may or may not hold a rendered element can use `.or` the same way, to fall back while it doesn't:
|
|
489
|
+
|
|
490
|
+
```typescript
|
|
491
|
+
const selected = state<DOMChild>(null)
|
|
492
|
+
const display = selected.or(div('Nothing selected'))
|
|
493
|
+
```
|
|
494
|
+
|
|
495
|
+
## DOM elements
|
|
496
|
+
|
|
497
|
+
In Snapse, the DOM tree is described using functions and values that represent elements and components. Elements and components are freely nested and mixed as children of one another.
|
|
498
|
+
|
|
499
|
+
```typescript
|
|
500
|
+
const MainComponent = component(() => div({ class: 'main' }, 'Main content'))
|
|
501
|
+
type FooterProps = { size: string, children: ChildProp[] }
|
|
502
|
+
const Footer = component(({ size, children }: FooterProps) =>
|
|
503
|
+
div({ class: `footer-${size}` }, children))
|
|
504
|
+
|
|
505
|
+
const page = div({ class: 'page' },
|
|
506
|
+
MainComponent,
|
|
507
|
+
Footer({ size: 'large' },
|
|
508
|
+
'Snapse example',
|
|
509
|
+
),
|
|
510
|
+
)
|
|
511
|
+
```
|
|
512
|
+
|
|
513
|
+
### element functions: create DOM elements
|
|
514
|
+
|
|
515
|
+
```typescript
|
|
516
|
+
type StyleValue = Record<string, string | State<string>> | State<any>
|
|
517
|
+
type ClassObject = Record<string, boolean | State<boolean>>
|
|
518
|
+
type ClassValue = string | ClassObject | ClassValue[] | State<any>
|
|
519
|
+
|
|
520
|
+
type EventListenerOptions = boolean | AddEventListenerOptions
|
|
521
|
+
type EventListenerValue = Function | [Function, EventListenerOptions] | State<any>
|
|
522
|
+
|
|
523
|
+
type AttributesAndEventListeners = {
|
|
524
|
+
style?: StyleValue
|
|
525
|
+
class?: ClassValue
|
|
526
|
+
[key: string]: string | StyleValue | ClassValue | EventListenerValue | any
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
type ChildProp = Component | DOMElement | string | number | null | ChildProp[]
|
|
530
|
+
| State<Component | DOMElement | string | number | null>
|
|
531
|
+
|
|
532
|
+
function element(attributesAndEventListeners?: AttributesAndEventListeners, ...children: ChildProp[]): DOMElement
|
|
533
|
+
|
|
534
|
+
```
|
|
535
|
+
|
|
536
|
+
Standard elements such as `div` and `span` are exported from `snapse/DOM`. Tag attributes are optionally specified in a plain object as the first argument to the function. The remaining arguments are treated as children.
|
|
537
|
+
|
|
538
|
+
Any attribute, child or argument can also be a State. In that case, Snapse will reactively update the DOM whenever the value of the State changes.
|
|
539
|
+
|
|
540
|
+
```typescript
|
|
541
|
+
div({ id: 'greeting', class: 'banner' }, 'Hello, ', s.name, '!')
|
|
542
|
+
```
|
|
543
|
+
|
|
544
|
+
### SVG elements
|
|
545
|
+
|
|
546
|
+
SVG element functions (`svg`, `circle`, `path`, …) are imported from `snapse/SVG`; their event handlers are typed with `SVGHandler`. Attributes and reactivity work exactly as for DOM elements. Some names (`a`, `script`, `style` and `title`) clash with DOM elements - use an import alias if you need both in the same file. When loading `snapse.js` directly in a web page, these SVG elements are named `svg_a`, `svg_script`, `svg_style` and `svg_title`.
|
|
547
|
+
|
|
548
|
+
```typescript
|
|
549
|
+
// SVG element functions come from snapse/SVG; they work like DOM elements.
|
|
550
|
+
svg({ viewBox: '0 0 100 100', width: 100 },
|
|
551
|
+
circle({ cx: 50, cy: 50, r: 40, fill: 'tomato' }))
|
|
552
|
+
```
|
|
553
|
+
|
|
554
|
+
### Element attributes
|
|
555
|
+
|
|
556
|
+
Snapse passes on any attributes to the DOM element, including custom attributes for custom elements. A boolean attribute will be interpreted as set/unset.
|
|
557
|
+
|
|
558
|
+
```typescript
|
|
559
|
+
// A boolean attribute is rendered when truthy and omitted when falsy; both plain
|
|
560
|
+
// and reactive values are accepted, as is any custom attribute.
|
|
561
|
+
input({ type: 'text', required: true, disabled: s.busy, 'data-role': 'title' })
|
|
562
|
+
```
|
|
563
|
+
|
|
564
|
+
### Attribute interpolation
|
|
565
|
+
|
|
566
|
+
To join a State with a string, pass an array as the attribute value. This works for whole attributes and for individual `style` values; any State among the elements keeps the result reactive.
|
|
567
|
+
|
|
568
|
+
```typescript
|
|
569
|
+
// Arrays are joined into one value; a State among them keeps it reactive.
|
|
570
|
+
const box = div({ id: 'sidebar', style: { width: [s.width, 'px'] } })
|
|
571
|
+
```
|
|
572
|
+
|
|
573
|
+
### Events
|
|
574
|
+
|
|
575
|
+
```typescript
|
|
576
|
+
type EventHandlerValue =
|
|
577
|
+
| ((event: Event) => void)
|
|
578
|
+
| [(event: Event) => void, EventListenerOptions]
|
|
579
|
+
| State<any>
|
|
580
|
+
|
|
581
|
+
```
|
|
582
|
+
|
|
583
|
+
Snapse registers an attribute `xyz` as a listener for the `xyz` event when its value is a function, or a `[function, options]` tuple for `addEventListener` options such as `{ once: true }` or `{ capture: true }`.
|
|
584
|
+
|
|
585
|
+
```typescript
|
|
586
|
+
const s = state({ clicks: 0 })
|
|
587
|
+
// const countClick = () => s.clicks++
|
|
588
|
+
const counter = button({ click: () => s.clicks++ }, 'Clicked ', s.clicks, ' times')
|
|
589
|
+
```
|
|
590
|
+
|
|
591
|
+
#### Binding States to output
|
|
592
|
+
|
|
593
|
+
Passing a State as the handler is a convenient alternative to `ev => s.text.set(ev.target.value)`. Combined with a matching `value`, `checked` or `selected` attribute it gives output binding with no handler code - for text inputs, checkboxes, `select`, and any control whose event target carries the value.
|
|
594
|
+
|
|
595
|
+
```typescript
|
|
596
|
+
const s = state({ name: '', agreed: false, size: 'M' })
|
|
597
|
+
// Passing s.name (a State) is equivalent to `ev => s.name.set(ev.target.value)`.
|
|
598
|
+
div(
|
|
599
|
+
input({ value: s.name, input: s.name }),
|
|
600
|
+
input({ type: 'checkbox', checked: s.agreed, change: s.agreed }),
|
|
601
|
+
select({ value: s.size, change: s.size },
|
|
602
|
+
option({ value: 'S' }, 'Small'),
|
|
603
|
+
option({ value: 'M' }, 'Medium')),
|
|
604
|
+
)
|
|
605
|
+
```
|
|
606
|
+
|
|
607
|
+
### The class attribute
|
|
608
|
+
|
|
609
|
+
For the `class` attribute, Snapse supports nesting classes using arrays, as well as setting classes conditionally using object notation (`ClassObject`):
|
|
610
|
+
|
|
611
|
+
```typescript
|
|
612
|
+
div(
|
|
613
|
+
{
|
|
614
|
+
class: [
|
|
615
|
+
'standard-div',
|
|
616
|
+
s.customClass,
|
|
617
|
+
{
|
|
618
|
+
'special-div': s.isSpecial,
|
|
619
|
+
},
|
|
620
|
+
],
|
|
621
|
+
},
|
|
622
|
+
content,
|
|
623
|
+
)
|
|
624
|
+
```
|
|
625
|
+
|
|
626
|
+
### Element children
|
|
627
|
+
|
|
628
|
+
Children can be other elements, Snapse components, strings, numbers or `null` (which renders nothing). Arrays are automatically expanded. If the argument is a function, the function will be called and the result will be inserted. This allows creating lightweight 'anonymous components'.
|
|
629
|
+
|
|
630
|
+
```typescript
|
|
631
|
+
const s = state({
|
|
632
|
+
background: 'red',
|
|
633
|
+
buttonText: 'Ok',
|
|
634
|
+
})
|
|
635
|
+
div({ style: { background: s.background } },
|
|
636
|
+
button({ click: () => alert('Clicked') }, s.buttonText))
|
|
637
|
+
```
|
|
638
|
+
|
|
639
|
+
A child can also be a State, used to imperatively switch the display.
|
|
640
|
+
|
|
641
|
+
```typescript
|
|
642
|
+
const view = state<DOMChild>(null)
|
|
643
|
+
const showWelcome = () => view.set(div('Welcome!'))
|
|
644
|
+
const showProfile = () => view.set(div({ class: 'profile' }, 'Your profile'))
|
|
645
|
+
const app = div({ id: 'app' }, view)
|
|
646
|
+
```
|
|
647
|
+
|
|
648
|
+
### domElement: create a custom DOM element factory
|
|
649
|
+
|
|
650
|
+
```typescript
|
|
651
|
+
function domElement(tagName: string): (...args: any[]) => DOMElement
|
|
652
|
+
|
|
653
|
+
```
|
|
654
|
+
|
|
655
|
+
To use custom elements with Snapse, use `domElement` to create the corresponding element function.
|
|
656
|
+
|
|
657
|
+
```typescript
|
|
658
|
+
const specialElement = domElement('special-element')
|
|
659
|
+
|
|
660
|
+
specialElement({ excel: true, prevaricate: 'maybe' },
|
|
661
|
+
div('Excellent child'))
|
|
662
|
+
```
|
|
663
|
+
|
|
664
|
+
## Components
|
|
665
|
+
|
|
666
|
+
Snapse has two types of components: feature-rich proper Snapse components, and plain JS functions that are suitable for code refactoring without extra boilerplate.
|
|
667
|
+
|
|
668
|
+
### component: define Snapse components
|
|
669
|
+
|
|
670
|
+
```typescript
|
|
671
|
+
function component<P>(functionBody: (properties: P & { children?: ChildProp[] }) => ChildProp): Component
|
|
672
|
+
|
|
673
|
+
```
|
|
674
|
+
|
|
675
|
+
Once defined, components can be used just like DOM elements. In addition to any custom properties the caller submits, the component function body will receive the special `children` parameter containing any children nested when invoking the component. The function body will be called only once, when your component is created.
|
|
676
|
+
|
|
677
|
+
```typescript
|
|
678
|
+
const SearchableTable = component(({ rowData }: { rowData: State<string[][]> }) => {
|
|
679
|
+
const query = state('')
|
|
680
|
+
const filterFunction = (row: State<string[]>) => row.some(cell => cell.includes(query))
|
|
681
|
+
|
|
682
|
+
return div(
|
|
683
|
+
table(
|
|
684
|
+
tbody(
|
|
685
|
+
rowData.filter(filterFunction)
|
|
686
|
+
.map((row: string[]) => tr(
|
|
687
|
+
row.map((cellData: string) => td(cellData)),
|
|
688
|
+
)),
|
|
689
|
+
),
|
|
690
|
+
tfoot(
|
|
691
|
+
tr(td(input({ change: query }))),
|
|
692
|
+
),
|
|
693
|
+
),
|
|
694
|
+
)
|
|
695
|
+
})
|
|
696
|
+
```
|
|
697
|
+
|
|
698
|
+
### async components: defer display
|
|
699
|
+
|
|
700
|
+
When the function body is an async function, the component will return `null` and display nothing until the function resolves. In an async component, calls to `state`, `findState` and `subscribe` must be placed before the first use of `await`, or an error will be thrown.
|
|
701
|
+
|
|
702
|
+
```typescript
|
|
703
|
+
const UserCard = component(async () => {
|
|
704
|
+
const user = await loadUser()
|
|
705
|
+
return div({ class: 'card' }, user.name)
|
|
706
|
+
})
|
|
707
|
+
```
|
|
708
|
+
|
|
709
|
+
### .or: display a fallback
|
|
710
|
+
|
|
711
|
+
```typescript
|
|
712
|
+
or(fallback: ChildProp): Component
|
|
713
|
+
|
|
714
|
+
```
|
|
715
|
+
|
|
716
|
+
If a component can return `null`, typically as a State, or if it is an async component, it is possible to display a fallback.
|
|
717
|
+
|
|
718
|
+
```typescript
|
|
719
|
+
const page = div(UserCard().or('Loading...'))
|
|
720
|
+
```
|
|
721
|
+
|
|
722
|
+
The same fallback applies if you hold the component in a State.
|
|
723
|
+
|
|
724
|
+
```typescript
|
|
725
|
+
const view = state(UserCard()).or('Loading...')
|
|
726
|
+
const page = div(view)
|
|
727
|
+
```
|
|
728
|
+
|
|
729
|
+
### Function components: minimal boilerplate
|
|
730
|
+
|
|
731
|
+
In Snapse, any function is also a lightweight component. If the function needs no argument, it can just be added as a child.
|
|
732
|
+
|
|
733
|
+
```typescript
|
|
734
|
+
const simpleComponent = () => div('example text')
|
|
735
|
+
const page = div(simpleComponent)
|
|
736
|
+
```
|
|
737
|
+
|
|
738
|
+
This is useful for refactoring your DOM code to avoid excessive depth, with an absolute minimum of boilerplate.
|
|
739
|
+
|
|
740
|
+
If a function component needs arguments, it can be invoked using an anonymous function.
|
|
741
|
+
|
|
742
|
+
```typescript
|
|
743
|
+
const argumentComponent = (n: number) => div('the answer is ', n)
|
|
744
|
+
const page = div(() => argumentComponent(42))
|
|
745
|
+
```
|
|
746
|
+
|
|
747
|
+
Note that if you simply call the function directly, it may also work - but this means your 'component' will be evaluated, and all its children will be created, at the time you define the parent component. While this is perfectly valid, the resulting behaviour can be counter-intuitive.
|
|
748
|
+
|
|
749
|
+
### .catch: handle errors
|
|
750
|
+
|
|
751
|
+
```typescript
|
|
752
|
+
type CatchFallbackFunction = (renderError: RenderError) => ChildProp
|
|
753
|
+
type CatchFallback = ChildProp | CatchFallbackFunction | State<CatchFallbackFunction>
|
|
754
|
+
|
|
755
|
+
catch(fallback: CatchFallback): this
|
|
756
|
+
|
|
757
|
+
```
|
|
758
|
+
|
|
759
|
+
Every element, component and `SnapseRoot` has a `.catch()` chain function. It catches errors thrown while building or updating its subtree - both during initial construction and during later reactive recomputation - and replaces the affected content with `fallback` instead of leaving it in a broken state. The fallback can be a plain child value, a Component, or a function (or `State` of one) that receives the `RenderError` and returns the replacement content.
|
|
760
|
+
|
|
761
|
+
```typescript
|
|
762
|
+
const Weather = component(() => {
|
|
763
|
+
throw new Error('forecast service unavailable')
|
|
764
|
+
})
|
|
765
|
+
|
|
766
|
+
const page = div(Weather().catch('Unable to load weather'))
|
|
767
|
+
```
|
|
768
|
+
|
|
769
|
+
If an error occurs beneath more than one `.catch()`, the nearest one wins. Outer `.catch()` calls are still notified of the error (a function fallback is invoked for any side effects), but their result is only mounted if the nearer fallback itself rendered nothing.
|
|
770
|
+
|
|
771
|
+
### RenderError: fine-grained error handling
|
|
772
|
+
|
|
773
|
+
```typescript
|
|
774
|
+
class RenderError extends Error {
|
|
775
|
+
readonly cause: unknown
|
|
776
|
+
readonly fallbackRendered: boolean
|
|
777
|
+
readonly propagationStopped: boolean
|
|
778
|
+
|
|
779
|
+
reset(): void
|
|
780
|
+
stopPropagation(): void
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
```
|
|
784
|
+
|
|
785
|
+
The `RenderError` passed to a `.catch()` fallback carries the original error as `cause`. Calling `reset()` re-attempts the original render in place, replacing the fallback once it succeeds - this is the basis for "retry" buttons. A fallback that is itself a Component receives the `RenderError` as its `renderError` prop, rather than as a call argument.
|
|
786
|
+
|
|
787
|
+
```typescript
|
|
788
|
+
let widgetBroken = true
|
|
789
|
+
const FlakyWidget = component(() => {
|
|
790
|
+
if (widgetBroken) throw new Error('widget crashed')
|
|
791
|
+
return div('widget ok')
|
|
792
|
+
})
|
|
793
|
+
|
|
794
|
+
const Fallback = component(({ renderError }) => div(
|
|
795
|
+
'Failed: ',
|
|
796
|
+
(renderError!.cause as Error).message,
|
|
797
|
+
' ',
|
|
798
|
+
button({ click: () => renderError!.reset() }, 'Retry'),
|
|
799
|
+
))
|
|
800
|
+
|
|
801
|
+
const page = div(FlakyWidget().catch(Fallback()))
|
|
802
|
+
```
|
|
803
|
+
|
|
804
|
+
Calling `stopPropagation()` prevents any further, outer `.catch()` from being notified of the error at all.
|
|
805
|
+
|
|
806
|
+
```typescript
|
|
807
|
+
let outerNotified = false
|
|
808
|
+
const Throwing = component(() => {
|
|
809
|
+
throw new Error('boom')
|
|
810
|
+
})
|
|
811
|
+
|
|
812
|
+
const Inner = component(() => Throwing().catch(e => {
|
|
813
|
+
e.stopPropagation() // Prevents the outer .catch() below from also being notified.
|
|
814
|
+
return 'inner handled'
|
|
815
|
+
}))
|
|
816
|
+
|
|
817
|
+
const page = div(Inner().catch(() => {
|
|
818
|
+
outerNotified = true
|
|
819
|
+
return 'outer handled'
|
|
820
|
+
}))
|
|
821
|
+
```
|
|
822
|
+
|
|
823
|
+
## SnapseRoot: bootstrap the Snapse application or add a 'portal'
|
|
824
|
+
|
|
825
|
+
```typescript
|
|
826
|
+
type RenderMode = 'hydrate' | 'csr'
|
|
827
|
+
type RootOptions = { root?: Node; mode?: RenderMode }
|
|
828
|
+
|
|
829
|
+
function SnapseRoot(options?: RootOptions, child?: ChildProp): void
|
|
830
|
+
function SnapseRoot(child: ChildProp): void
|
|
831
|
+
|
|
832
|
+
```
|
|
833
|
+
|
|
834
|
+
If no root is specified, Snapse will install at the end of `document.body`.
|
|
835
|
+
|
|
836
|
+
The available modes are:
|
|
837
|
+
|
|
838
|
+
* `hydrate`: attach event listeners and reactive State to existing DOM elements, then switch to `csr` mode
|
|
839
|
+
* `csr` (default): create DOM elements, install event listeners and reactive State
|
|
840
|
+
|
|
841
|
+
```typescript
|
|
842
|
+
SnapseRoot(MyComponent)
|
|
843
|
+
```
|
|
844
|
+
|
|
845
|
+
### Rendering a portal
|
|
846
|
+
|
|
847
|
+
If `SnapseRoot` is passed as a child of an element or component, its children will be rendered in the specified root, or at the end of `document.body`, creating a 'portal'. This subtree will be removed if the `SnapseRoot` component is removed.
|
|
848
|
+
|
|
849
|
+
```typescript
|
|
850
|
+
const Dialog = component(() => SnapseRoot(
|
|
851
|
+
{ root: document.getElementById('modal-root') },
|
|
852
|
+
div({ class: 'dialog' }, 'Saved!'),
|
|
853
|
+
))
|
|
854
|
+
|
|
855
|
+
const Page = component(() => div('Page content', s.dialogOpen.opt(Dialog)))
|
|
856
|
+
```
|
|
857
|
+
|
|
858
|
+
## Custom attributes & events
|
|
859
|
+
|
|
860
|
+
Snapse offers some custom attributes and event handlers.
|
|
861
|
+
|
|
862
|
+
### nodeCreated: notify on element creation
|
|
863
|
+
|
|
864
|
+
```typescript
|
|
865
|
+
nodeCreated?: (element: DOMElement) => void
|
|
866
|
+
|
|
867
|
+
```
|
|
868
|
+
|
|
869
|
+
The `nodeCreated` callback will fire as soon as the element has been created, before it is installed in the DOM.
|
|
870
|
+
|
|
871
|
+
```typescript
|
|
872
|
+
div({ nodeCreated: (element: { target?: HTMLElement }) => console.log(element) })
|
|
873
|
+
```
|
|
874
|
+
|
|
875
|
+
### nodeInserted: notify on addition to DOM
|
|
876
|
+
|
|
877
|
+
```typescript
|
|
878
|
+
nodeInserted?: (element: DOMElement) => void
|
|
879
|
+
|
|
880
|
+
```
|
|
881
|
+
|
|
882
|
+
The `nodeInserted` callback will fire as soon as the element has been installed in the DOM.
|
|
883
|
+
|
|
884
|
+
```typescript
|
|
885
|
+
div({ nodeInserted: (element: { target?: HTMLElement }) => console.log(element) })
|
|
886
|
+
```
|
|
887
|
+
|
|
888
|
+
### nodeRemoved: notify after removal from DOM
|
|
889
|
+
|
|
890
|
+
```typescript
|
|
891
|
+
nodeRemoved?: () => void
|
|
892
|
+
|
|
893
|
+
```
|
|
894
|
+
|
|
895
|
+
The handler is called just after the element has been removed. It receives no arguments.
|
|
896
|
+
|
|
897
|
+
```typescript
|
|
898
|
+
div({ nodeRemoved: () => console.log('Element was removed') })
|
|
899
|
+
```
|
|
900
|
+
|
|
901
|
+
### innerHTML: insert raw HTML content
|
|
902
|
+
|
|
903
|
+
```typescript
|
|
904
|
+
innerHTML?: string
|
|
905
|
+
|
|
906
|
+
```
|
|
907
|
+
|
|
908
|
+
Inserting HTML creates a security risk, so it is important to use this feature only with trusted data, such as content received from a CMS system. A `DOMElement` can either have an `innerHTML` attribute or children, not both.
|
|
909
|
+
|
|
910
|
+
```typescript
|
|
911
|
+
div({ innerHTML: 'Some text with <em>markup</em>.' })
|
|
912
|
+
```
|
|
913
|
+
|
|
914
|
+
### `null`: The do-nothing value
|
|
915
|
+
|
|
916
|
+
Snapse ignores ``null`` values as children, DOM Node properties, or event handlers. In order to deactivate something, create a State and set it to ``null`` to deactivate.
|
|
917
|
+
|
|
918
|
+
```typescript
|
|
919
|
+
SnapseRoot(component(() => {
|
|
920
|
+
const s = state({
|
|
921
|
+
disabled: false,
|
|
922
|
+
onClick: (() => {
|
|
923
|
+
console.log('clicked, disabling')
|
|
924
|
+
s.disabled.set(true)
|
|
925
|
+
s.onClick.set(null)
|
|
926
|
+
}) as (() => void) | null,
|
|
927
|
+
})
|
|
928
|
+
const onMouseOver = s.disabled.opt(() => console.log('mouse over'))
|
|
929
|
+
|
|
930
|
+
return button({ disabled: s.disabled, click: s.onClick, mouseover: onMouseOver })
|
|
931
|
+
})())
|
|
932
|
+
```
|
|
933
|
+
|
|
934
|
+
## Moving content
|
|
935
|
+
|
|
936
|
+
Snapse will move DOM elements using moveBefore when supported by the browser, preserving their internal state. Snapse will also move existing Snapse components when they are added using an array that sits directly in a DOM element.
|
|
937
|
+
|
|
938
|
+
```typescript
|
|
939
|
+
const Greeting = component(() => div({ class: 'greeting' }, 'Hello from a component'))
|
|
940
|
+
|
|
941
|
+
const left = state<DOMChild[]>([input({ placeholder: 'Type here' }), Greeting()])
|
|
942
|
+
const right = state<DOMChild[]>([])
|
|
943
|
+
|
|
944
|
+
const moveRight = () => right.push(...left.splice(0))
|
|
945
|
+
|
|
946
|
+
const page = div(
|
|
947
|
+
div({ class: 'left' }, left),
|
|
948
|
+
div({ class: 'right' }, right),
|
|
949
|
+
button({ click: moveRight }, 'Move'),
|
|
950
|
+
)
|
|
951
|
+
```
|
|
952
|
+
|
|
953
|
+
## Server-side rendering
|
|
954
|
+
|
|
955
|
+
```typescript
|
|
956
|
+
import { renderToString } from 'snapse/server'
|
|
957
|
+
|
|
958
|
+
function renderToString(...children: ChildProp[]): string
|
|
959
|
+
|
|
960
|
+
```
|
|
961
|
+
|
|
962
|
+
`renderToString` renders a tree to an HTML string. It runs anywhere JavaScript does: Node, Deno, Bun, or an edge worker.
|
|
963
|
+
|
|
964
|
+
```typescript
|
|
965
|
+
import { renderToString } from 'snapse/server'
|
|
966
|
+
import { component, state } from 'snapse'
|
|
967
|
+
import { button, div, span } from 'snapse/DOM'
|
|
968
|
+
```
|
|
969
|
+
|
|
970
|
+
```typescript
|
|
971
|
+
const Counter = component(() => {
|
|
972
|
+
const s = state({ count: 41 })
|
|
973
|
+
return div(span(s.count), button({ click: () => s.count++ }, 'Count'))
|
|
974
|
+
})
|
|
975
|
+
|
|
976
|
+
const html = renderToString(Counter())
|
|
977
|
+
```
|
|
978
|
+
|
|
979
|
+
When a page containing the resulting string has been loaded in a browser, it can be hydrated using the *same* tree to `SnapseRoot` in `hydrate` mode to attach behavior to the elements:
|
|
980
|
+
|
|
981
|
+
```typescript
|
|
982
|
+
SnapseRoot({ mode: 'hydrate' }, Counter())
|
|
983
|
+
```
|
|
984
|
+
|
|
985
|
+
Snapse needs to mark the boundaries for dynamic content which is not bounded by an element. Therefore, you need to make sure that any minification preserves the Snapse boundary.
|
|
986
|
+
```
|
|
987
|
+
<!--𝒮-->
|
|
988
|
+
```
|
|
989
|
+
|
|
990
|
+
Note that the `SnapseRoot` component does not render anything when calling the `renderToString` function. Normally, it is used for elements that are hidden by default, and will be inserted with client side rendering. If you do want to render into multiple roots, you need to call `renderToString` for each tree separately in your server code, and initialize each case separately with `SnapseRoot` in the browser.
|
|
991
|
+
|
|
992
|
+
## Undo functionality
|
|
993
|
+
|
|
994
|
+
Snapse offers a powerful mechanism to support undo, redo and point-in-time restore features in your application with minimum effort.
|
|
995
|
+
|
|
996
|
+
### TrackHistory
|
|
997
|
+
|
|
998
|
+
```typescript
|
|
999
|
+
type TrackHistoryOptions = { limit?: number; addHistoryVersion?: () => number }
|
|
1000
|
+
type HistoryEntry = { batch: ChangeRecord[]; t: number }
|
|
1001
|
+
|
|
1002
|
+
class TrackHistory<S extends State> {
|
|
1003
|
+
constructor(root: S, options?: TrackHistoryOptions)
|
|
1004
|
+
|
|
1005
|
+
undo(): boolean
|
|
1006
|
+
redo(): boolean
|
|
1007
|
+
restoreAt(timestamp: number): void
|
|
1008
|
+
|
|
1009
|
+
startBatch(): void
|
|
1010
|
+
commitBatch(): void
|
|
1011
|
+
|
|
1012
|
+
getHistory(): HistoryEntry[]
|
|
1013
|
+
setHistory(history: HistoryEntry[]): void
|
|
1014
|
+
unsubscribe(): void
|
|
1015
|
+
|
|
1016
|
+
undoEmpty: State<boolean>
|
|
1017
|
+
redoEmpty: State<boolean>
|
|
1018
|
+
historyVersions: State<number[]>
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
```
|
|
1022
|
+
|
|
1023
|
+
Construct it with the State you want tracked as `root`; this must be an object or array State. Every change to `root` or any of its descendants is recorded from then on. All changes applied between two flushes become a single history entry.
|
|
1024
|
+
|
|
1025
|
+
Options:
|
|
1026
|
+
|
|
1027
|
+
* `limit`: the maximum number of entries kept on the undo stack. Older entries are discarded.
|
|
1028
|
+
* `addHistoryVersion`: called once per entry to produce its timestamp, default is `Date.now()`. Return a monotonically increasing number.
|
|
1029
|
+
|
|
1030
|
+
Members:
|
|
1031
|
+
|
|
1032
|
+
* `undo()` / `redo()`: step one entry back or forward. Each returns `false` if there was nothing to do. The tracked State is updated synchronously.
|
|
1033
|
+
* `restoreAt(timestamp)`: move the tracked State to the point in time given by `timestamp`, undoing or redoing as many entries as needed in either direction.
|
|
1034
|
+
* `startBatch()` / `commitBatch()`: collapse every change made between these calls, across any number of flushes, into a single undo entry. Committing with no changes recorded adds nothing.
|
|
1035
|
+
* `getHistory()` / `setHistory(history)`: read the undo stack, or replace it, for persistence between sessions. `setHistory` also clears the redo stack.
|
|
1036
|
+
* `unsubscribe()`: stop recording. Call this when the TrackHistory is no longer needed and was not created inside a Component.
|
|
1037
|
+
* `undoEmpty` / `redoEmpty`: boolean States that track whether each stack is empty - wire them to the `disabled` attribute of undo/redo buttons.
|
|
1038
|
+
* `historyVersions`: a State holding the array of entry timestamps, newest last - useful for rendering a history timeline for `restoreAt`.
|
|
1039
|
+
|
|
1040
|
+
```typescript
|
|
1041
|
+
const Counter = component(() => {
|
|
1042
|
+
const s = state({ count: 0 })
|
|
1043
|
+
const history = new TrackHistory(s)
|
|
1044
|
+
|
|
1045
|
+
return div(
|
|
1046
|
+
output(s.count),
|
|
1047
|
+
button({ click: () => s.count.set(s.count + 1) }, 'Increment'),
|
|
1048
|
+
button({ click: history.undo, disabled: history.undoEmpty }, 'Undo'),
|
|
1049
|
+
button({ click: history.redo, disabled: history.redoEmpty }, 'Redo'),
|
|
1050
|
+
)
|
|
1051
|
+
})
|
|
1052
|
+
```
|
|
1053
|
+
|
|
1054
|
+
### Limitations of TrackHistory
|
|
1055
|
+
|
|
1056
|
+
While TrackHistory can be sufficient for many use cases, there are limitations you need to consider.
|
|
1057
|
+
|
|
1058
|
+
All the undoable application state needs to be in the tracked State, possibly including data that should not be directly displayed in the DOM. If your application data is very large, this may not be efficient.
|
|
1059
|
+
|
|
1060
|
+
TrackHistory does not support storing duplicated State. For example, the following is not supported:
|
|
1061
|
+
|
|
1062
|
+
```typescript
|
|
1063
|
+
// NOT supported: selectedItem now holds the very same State that also lives in
|
|
1064
|
+
// items, so TrackHistory sees one child stored in two places and cannot track it.
|
|
1065
|
+
board.selectedItem.set([board.items[42]!])
|
|
1066
|
+
```
|
|
1067
|
+
|
|
1068
|
+
TrackHistory also does not support circular references among its children.
|
|
1069
|
+
|
|
1070
|
+
Finally, since TrackHistory is a highly generic implementation, it is always possible to design a more efficient implementation for your application, albeit at a much higher cost of development.
|
|
1071
|
+
|
|
1072
|
+
## Example
|
|
1073
|
+
|
|
1074
|
+
```typescript
|
|
1075
|
+
import { SnapseRoot, type State, component, findState, flush, state, subscribe }
|
|
1076
|
+
from 'snapse'
|
|
1077
|
+
import { type ChildProp, type DOMHandler, button, div, h2, input, output, span }
|
|
1078
|
+
from 'snapse/DOM'
|
|
1079
|
+
import cl from './styles.module.css'
|
|
1080
|
+
|
|
1081
|
+
type TodoItem = State<{ id: number, checked: boolean, text: string }>
|
|
1082
|
+
type TodoListState = State<{
|
|
1083
|
+
items: TodoItem[]
|
|
1084
|
+
query: string
|
|
1085
|
+
selected: number
|
|
1086
|
+
}>
|
|
1087
|
+
|
|
1088
|
+
export const TodoList = component(() => {
|
|
1089
|
+
const s = state<TodoListState>({
|
|
1090
|
+
items: JSON.parse(localStorage.getItem('ToDo') ?? '[]'),
|
|
1091
|
+
query: '',
|
|
1092
|
+
selected: 0,
|
|
1093
|
+
}, 'TodoList')
|
|
1094
|
+
let nextId = Number(s.items.reduce((max, item) => Math.max(max, item.id), 0))
|
|
1095
|
+
subscribe(s.items,
|
|
1096
|
+
() => localStorage.setItem('ToDo', JSON.stringify(s.items)),
|
|
1097
|
+
{ deep: true })
|
|
1098
|
+
const addTodo: DOMHandler<'change'> = ev => {
|
|
1099
|
+
s.items.push({ id: ++nextId, checked: false, text: ev.target.value } as TodoItem)
|
|
1100
|
+
ev.target.value = ''
|
|
1101
|
+
}
|
|
1102
|
+
const matchingItems = s.items.filter(item => String(item.text).includes(s.query))
|
|
1103
|
+
const itemComponents = matchingItems.map(todoItem => Todo({ todoItem })
|
|
1104
|
+
.or(div({ class: cl.item }, 'Creating...')))
|
|
1105
|
+
|
|
1106
|
+
return div({ class: cl.app },
|
|
1107
|
+
div({ class: cl.lists },
|
|
1108
|
+
div(
|
|
1109
|
+
h2('To do'),
|
|
1110
|
+
itemComponents.filter((_item, i) => matchingItems[i]!.checked.not())
|
|
1111
|
+
.or(output(s.query.opt(["No match for '", s.query, "'"], 'Nothing to do'))),
|
|
1112
|
+
),
|
|
1113
|
+
div({ class: cl.done },
|
|
1114
|
+
h2('Done'),
|
|
1115
|
+
itemComponents.filter((_item, i) => matchingItems[i]!.checked)
|
|
1116
|
+
.or(output(s.query.opt(["No match for '", s.query, "'"], 'Nothing finished'))),
|
|
1117
|
+
),
|
|
1118
|
+
input({ class: cl.search, type: 'search', placeholder: 'Search', input: s.query }),
|
|
1119
|
+
),
|
|
1120
|
+
div(input({ class: cl.add, placeholder: 'Add todo', change: addTodo })),
|
|
1121
|
+
)
|
|
1122
|
+
})
|
|
1123
|
+
|
|
1124
|
+
const startViewTransition = document.startViewTransition?.bind(document)
|
|
1125
|
+
?? ((f: Function) => f())
|
|
1126
|
+
type TodoItemProps = { todoItem: TodoItem }
|
|
1127
|
+
const Todo = component(async ({ todoItem }: TodoItemProps) => {
|
|
1128
|
+
const s: TodoListState = findState('TodoList')
|
|
1129
|
+
const handleCheck: DOMHandler<'change'> = ev => startViewTransition(() => {
|
|
1130
|
+
todoItem.checked.set(ev.target.checked)
|
|
1131
|
+
flush()
|
|
1132
|
+
})
|
|
1133
|
+
await new Promise(r => setTimeout(r, 300 + Math.random() * 700)) // Make it expensive
|
|
1134
|
+
|
|
1135
|
+
return div({ class: cl.item, style: { viewTransitionName: `item${todoItem.id}` } },
|
|
1136
|
+
input({ type: 'checkbox', checked: todoItem.checked, change: handleCheck }),
|
|
1137
|
+
s.selected.is(todoItem.id).opt(
|
|
1138
|
+
ItemEditor({ todoItem, todoListState: s }),
|
|
1139
|
+
span({ click: () => s.selected.set(todoItem.id) },
|
|
1140
|
+
HighlightedText({ text: todoItem.text, highlight: s.query }),
|
|
1141
|
+
),
|
|
1142
|
+
),
|
|
1143
|
+
)
|
|
1144
|
+
})
|
|
1145
|
+
|
|
1146
|
+
type ItemEditorProps = { todoItem: TodoItem, todoListState: TodoListState }
|
|
1147
|
+
const ItemEditor = component(({ todoItem, todoListState: s }: ItemEditorProps) => {
|
|
1148
|
+
const updateTodoText: DOMHandler<'change'> = ev => {
|
|
1149
|
+
todoItem.text.set(ev.target.value)
|
|
1150
|
+
s.selected.set(0)
|
|
1151
|
+
}
|
|
1152
|
+
const deleteTodo = () => s.items.splice(s.items.indexOf(todoItem), 1)
|
|
1153
|
+
|
|
1154
|
+
return [
|
|
1155
|
+
input({ value: todoItem.text, change: updateTodoText, blur: updateTodoText,
|
|
1156
|
+
nodeInserted: ev => ev.target?.focus() }),
|
|
1157
|
+
button({ class: cl.delete, mousedown: deleteTodo }),
|
|
1158
|
+
]
|
|
1159
|
+
})
|
|
1160
|
+
|
|
1161
|
+
type HighLightedTextProps = State<{ text: string, highlight: string }>
|
|
1162
|
+
const HighlightedText = component(({ text, highlight }: HighLightedTextProps) =>
|
|
1163
|
+
text.split(highlight.or(undefined) as string)
|
|
1164
|
+
.reduce((acc, fragment, i) => i
|
|
1165
|
+
? [...acc, span({ class: cl.highlight }, highlight), fragment]
|
|
1166
|
+
: [fragment], [] as (string | ChildProp)[]),
|
|
1167
|
+
)
|
|
1168
|
+
|
|
1169
|
+
SnapseRoot(TodoList())
|
|
1170
|
+
```
|