sygnal 4.6.1 → 5.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/README.md +208 -99
- package/dist/astro/client.cjs.js +141 -6
- package/dist/astro/client.mjs +141 -6
- package/dist/index.cjs.js +185 -12
- package/dist/index.d.ts +89 -13
- package/dist/index.esm.js +184 -13
- package/dist/jsx-dev-runtime.cjs.js +23 -6
- package/dist/jsx-dev-runtime.esm.js +23 -6
- package/dist/jsx-runtime.cjs.js +23 -6
- package/dist/jsx-runtime.esm.js +23 -6
- package/dist/jsx.cjs.js +23 -6
- package/dist/jsx.esm.js +23 -6
- package/dist/sygnal.min.js +1 -1
- package/package.json +4 -2
- package/src/component.ts +142 -7
- package/src/cycle/dom/enrichEventStream.ts +14 -5
- package/src/cycle/dom/index.ts +1 -0
- package/src/extra/command.ts +42 -0
- package/src/index.d.ts +89 -13
- package/src/index.ts +2 -0
- package/src/pragma/index.ts +22 -6
- package/src/slot.ts +11 -0
package/README.md
CHANGED
|
@@ -1,25 +1,25 @@
|
|
|
1
1
|
# Sygnal
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
A reactive component framework with pure functions, zero side effects, and automatic state management.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
[](https://www.npmjs.com/package/sygnal)
|
|
6
|
+
[](https://www.npmjs.com/package/sygnal)
|
|
7
|
+
[](https://github.com/tpresley/sygnal/blob/main/LICENSE)
|
|
8
|
+
[](https://pkg-size.dev/sygnal)
|
|
6
9
|
|
|
7
|
-
|
|
8
|
-
- 100% pure components with absolutely no side effects
|
|
9
|
-
- No need for component state management (it's handled automatically at the application level)
|
|
10
|
-
- Small bundle sizes
|
|
11
|
-
- Fast build times
|
|
12
|
-
- Fast rendering
|
|
13
|
-
- Close to zero boiler plate code
|
|
10
|
+
---
|
|
14
11
|
|
|
15
|
-
##
|
|
12
|
+
## Why Sygnal?
|
|
16
13
|
|
|
17
|
-
- **
|
|
18
|
-
- **
|
|
19
|
-
- **
|
|
14
|
+
- **Pure components** — Views are plain functions. All side effects are handled by drivers, outside your code.
|
|
15
|
+
- **Automatic state management** — Monolithic state tree with no store setup, no providers, no hooks. Trivial undo/redo and time-travel debugging.
|
|
16
|
+
- **Model-View-Intent** — Cleanly separate *what* happens (Model), *when* it happens (Intent), and *how* it looks (View).
|
|
17
|
+
- **Tiny footprint** — Three runtime dependencies: [snabbdom](https://github.com/snabbdom/snabbdom), [xstream](https://github.com/staltz/xstream), and [extend](https://github.com/nicjohnson145/extend).
|
|
20
18
|
|
|
21
19
|
## Quick Start
|
|
22
20
|
|
|
21
|
+
**Scaffold a new project:**
|
|
22
|
+
|
|
23
23
|
```bash
|
|
24
24
|
npx degit tpresley/sygnal-template my-app
|
|
25
25
|
cd my-app
|
|
@@ -27,15 +27,15 @@ npm install
|
|
|
27
27
|
npm run dev
|
|
28
28
|
```
|
|
29
29
|
|
|
30
|
-
Or
|
|
30
|
+
**Or add to an existing project:**
|
|
31
31
|
|
|
32
32
|
```bash
|
|
33
33
|
npm install sygnal
|
|
34
34
|
```
|
|
35
35
|
|
|
36
|
-
##
|
|
36
|
+
## A Sygnal Component
|
|
37
37
|
|
|
38
|
-
A
|
|
38
|
+
A component is a function (the **view**) with static properties that define **when** things happen (`.intent`) and **what** happens (`.model`):
|
|
39
39
|
|
|
40
40
|
```jsx
|
|
41
41
|
function Counter({ state }) {
|
|
@@ -50,20 +50,18 @@ function Counter({ state }) {
|
|
|
50
50
|
|
|
51
51
|
Counter.initialState = { count: 0 }
|
|
52
52
|
|
|
53
|
-
// Intent: WHEN things happen
|
|
54
53
|
Counter.intent = ({ DOM }) => ({
|
|
55
54
|
INCREMENT: DOM.select('.increment').events('click'),
|
|
56
|
-
DECREMENT: DOM.select('.decrement').events('click')
|
|
55
|
+
DECREMENT: DOM.select('.decrement').events('click'),
|
|
57
56
|
})
|
|
58
57
|
|
|
59
|
-
// Model: WHAT happens
|
|
60
58
|
Counter.model = {
|
|
61
|
-
INCREMENT: (state) => ({ count: state.count + 1 }),
|
|
62
|
-
DECREMENT: (state) => ({ count: state.count - 1 })
|
|
59
|
+
INCREMENT: (state) => ({ ...state, count: state.count + 1 }),
|
|
60
|
+
DECREMENT: (state) => ({ ...state, count: state.count - 1 }),
|
|
63
61
|
}
|
|
64
62
|
```
|
|
65
63
|
|
|
66
|
-
Start it
|
|
64
|
+
Start it:
|
|
67
65
|
|
|
68
66
|
```javascript
|
|
69
67
|
import { run } from 'sygnal'
|
|
@@ -72,54 +70,105 @@ import Counter from './Counter.jsx'
|
|
|
72
70
|
run(Counter)
|
|
73
71
|
```
|
|
74
72
|
|
|
75
|
-
|
|
73
|
+
No store setup, no providers, no hooks — just a function and some properties.
|
|
76
74
|
|
|
77
|
-
##
|
|
75
|
+
## Features
|
|
78
76
|
|
|
79
|
-
|
|
77
|
+
### Collections
|
|
80
78
|
|
|
81
|
-
|
|
79
|
+
Render dynamic lists with built-in filtering and sorting:
|
|
82
80
|
|
|
83
|
-
|
|
81
|
+
```jsx
|
|
82
|
+
<Collection of={TodoItem} from="items" filter={item => !item.done} sort="name" />
|
|
83
|
+
```
|
|
84
84
|
|
|
85
|
-
|
|
85
|
+
### Switchable
|
|
86
86
|
|
|
87
|
-
|
|
87
|
+
Swap between components based on state:
|
|
88
88
|
|
|
89
|
-
|
|
89
|
+
```jsx
|
|
90
|
+
<Switchable of={{ home: HomePage, settings: SettingsPage }} current={state.activeTab} />
|
|
91
|
+
```
|
|
90
92
|
|
|
91
|
-
###
|
|
93
|
+
### Context
|
|
92
94
|
|
|
93
|
-
|
|
95
|
+
Top-down data propagation without prop drilling:
|
|
94
96
|
|
|
95
|
-
|
|
97
|
+
```jsx
|
|
98
|
+
App.context = {
|
|
99
|
+
theme: (state) => state.settings.theme,
|
|
100
|
+
currentUser: (state) => state.auth.user,
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function Child({ state, context }) {
|
|
104
|
+
return <div className={context.theme}>{context.currentUser.name}</div>
|
|
105
|
+
}
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
### Parent-Child Communication
|
|
96
109
|
|
|
97
|
-
|
|
110
|
+
Structured message passing between components:
|
|
98
111
|
|
|
99
112
|
```jsx
|
|
100
|
-
|
|
113
|
+
// Child emits
|
|
114
|
+
TaskCard.model = {
|
|
115
|
+
DELETE: { PARENT: (state) => ({ type: 'DELETE', taskId: state.id }) }
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Parent receives (use component reference — minification-safe)
|
|
119
|
+
Lane.intent = ({ CHILD }) => ({
|
|
120
|
+
TASK_DELETED: CHILD.select(TaskCard).filter(e => e.type === 'DELETE'),
|
|
121
|
+
})
|
|
101
122
|
```
|
|
102
123
|
|
|
103
|
-
###
|
|
124
|
+
### Event Bus
|
|
104
125
|
|
|
105
|
-
|
|
126
|
+
Global broadcast for cross-component communication:
|
|
127
|
+
|
|
128
|
+
```jsx
|
|
129
|
+
// Any component can emit
|
|
130
|
+
Publisher.model = {
|
|
131
|
+
NOTIFY: { EVENTS: (state) => ({ type: 'notification', data: state.message }) }
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Any component can subscribe
|
|
135
|
+
Subscriber.intent = ({ EVENTS }) => ({
|
|
136
|
+
HANDLE: EVENTS.select('notification'),
|
|
137
|
+
})
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
### Calculated Fields
|
|
141
|
+
|
|
142
|
+
Derived state with optional dependency tracking:
|
|
106
143
|
|
|
107
144
|
```jsx
|
|
108
|
-
|
|
145
|
+
Invoice.calculated = {
|
|
146
|
+
subtotal: [['items'], (state) => sum(state.items.map(i => i.price))],
|
|
147
|
+
tax: [['subtotal'], (state) => state.subtotal * 0.08],
|
|
148
|
+
total: [['subtotal', 'tax'], (state) => state.subtotal + state.tax],
|
|
149
|
+
}
|
|
109
150
|
```
|
|
110
151
|
|
|
111
152
|
### Form Handling
|
|
112
153
|
|
|
113
|
-
Extract form values without the
|
|
154
|
+
Extract form values without the boilerplate:
|
|
114
155
|
|
|
115
156
|
```jsx
|
|
116
|
-
import { processForm } from 'sygnal'
|
|
117
|
-
|
|
118
157
|
MyForm.intent = ({ DOM }) => ({
|
|
119
|
-
SUBMITTED: processForm(DOM.select('.my-form'), { events: 'submit' })
|
|
158
|
+
SUBMITTED: processForm(DOM.select('.my-form'), { events: 'submit' }),
|
|
120
159
|
})
|
|
121
160
|
```
|
|
122
161
|
|
|
162
|
+
### Drag and Drop
|
|
163
|
+
|
|
164
|
+
HTML5 drag-and-drop with a dedicated driver:
|
|
165
|
+
|
|
166
|
+
```javascript
|
|
167
|
+
import { makeDragDriver } from 'sygnal'
|
|
168
|
+
|
|
169
|
+
run(RootComponent, { DND: makeDragDriver() })
|
|
170
|
+
```
|
|
171
|
+
|
|
123
172
|
### Custom Drivers
|
|
124
173
|
|
|
125
174
|
Wrap any async operation as a driver:
|
|
@@ -137,7 +186,7 @@ run(RootComponent, { API: apiDriver })
|
|
|
137
186
|
|
|
138
187
|
### Error Boundaries
|
|
139
188
|
|
|
140
|
-
Catch and recover from
|
|
189
|
+
Catch and recover from rendering errors:
|
|
141
190
|
|
|
142
191
|
```jsx
|
|
143
192
|
BrokenComponent.onError = (error, { componentName }) => (
|
|
@@ -145,31 +194,39 @@ BrokenComponent.onError = (error, { componentName }) => (
|
|
|
145
194
|
)
|
|
146
195
|
```
|
|
147
196
|
|
|
148
|
-
###
|
|
197
|
+
### Portals
|
|
149
198
|
|
|
150
|
-
|
|
199
|
+
Render children into a different DOM container:
|
|
151
200
|
|
|
152
201
|
```jsx
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
function MyComponent({ state }) {
|
|
157
|
-
return <div ref={myRef}>Measured: {state.width}px</div>
|
|
158
|
-
}
|
|
202
|
+
<Portal target="#modal-root">
|
|
203
|
+
<div className="modal">Modal content</div>
|
|
204
|
+
</Portal>
|
|
159
205
|
```
|
|
160
206
|
|
|
161
|
-
###
|
|
207
|
+
### Slots
|
|
162
208
|
|
|
163
|
-
|
|
209
|
+
Pass named content regions to child components:
|
|
164
210
|
|
|
165
211
|
```jsx
|
|
166
|
-
import {
|
|
212
|
+
import { Slot } from 'sygnal'
|
|
213
|
+
|
|
214
|
+
<Card state="card">
|
|
215
|
+
<Slot name="header"><h2>Title</h2></Slot>
|
|
216
|
+
<Slot name="actions"><button>Save</button></Slot>
|
|
217
|
+
<p>Default content</p>
|
|
218
|
+
</Card>
|
|
167
219
|
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
)}
|
|
220
|
+
// In Card's view:
|
|
221
|
+
function Card({ state, slots }) {
|
|
222
|
+
return (
|
|
223
|
+
<div>
|
|
224
|
+
<header>{...(slots.header || [])}</header>
|
|
225
|
+
<main>{...(slots.default || [])}</main>
|
|
226
|
+
<footer>{...(slots.actions || [])}</footer>
|
|
227
|
+
</div>
|
|
228
|
+
)
|
|
229
|
+
}
|
|
173
230
|
```
|
|
174
231
|
|
|
175
232
|
### Transitions
|
|
@@ -177,49 +234,93 @@ import { Portal } from 'sygnal'
|
|
|
177
234
|
CSS-based enter/leave animations:
|
|
178
235
|
|
|
179
236
|
```jsx
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
<Transition enter="fade-in" leave="fade-out">
|
|
237
|
+
<Transition name="fade" duration={300}>
|
|
183
238
|
{state.visible && <div>Animated content</div>}
|
|
184
239
|
</Transition>
|
|
185
240
|
```
|
|
186
241
|
|
|
187
|
-
### Lazy Loading
|
|
242
|
+
### Lazy Loading & Suspense
|
|
188
243
|
|
|
189
|
-
Code-split components with
|
|
244
|
+
Code-split components with loading boundaries:
|
|
190
245
|
|
|
191
246
|
```jsx
|
|
192
|
-
import { lazy } from 'sygnal'
|
|
193
247
|
const HeavyChart = lazy(() => import('./HeavyChart.jsx'))
|
|
248
|
+
|
|
249
|
+
<Suspense fallback={<div>Loading...</div>}>
|
|
250
|
+
<HeavyChart />
|
|
251
|
+
</Suspense>
|
|
194
252
|
```
|
|
195
253
|
|
|
196
|
-
###
|
|
254
|
+
### Refs
|
|
197
255
|
|
|
198
|
-
|
|
256
|
+
Access DOM elements declaratively:
|
|
199
257
|
|
|
200
258
|
```jsx
|
|
201
|
-
|
|
259
|
+
const inputRef = createRef()
|
|
260
|
+
<input ref={inputRef} />
|
|
261
|
+
// inputRef.current.focus()
|
|
262
|
+
```
|
|
202
263
|
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
264
|
+
### Commands
|
|
265
|
+
|
|
266
|
+
Send imperative commands from parent to child:
|
|
267
|
+
|
|
268
|
+
```jsx
|
|
269
|
+
import { createCommand } from 'sygnal'
|
|
270
|
+
|
|
271
|
+
const playerCmd = createCommand()
|
|
272
|
+
<VideoPlayer commands={playerCmd} />
|
|
273
|
+
|
|
274
|
+
// Parent sends commands with optional data
|
|
275
|
+
playerCmd.send('seek', { time: 30 })
|
|
276
|
+
|
|
277
|
+
// Child receives via commands$ source
|
|
278
|
+
VideoPlayer.intent = ({ commands$ }) => ({
|
|
279
|
+
SEEK: commands$.select('seek'), // emits { time: 30 }
|
|
280
|
+
})
|
|
206
281
|
```
|
|
207
282
|
|
|
208
|
-
|
|
283
|
+
### Effect Handlers
|
|
284
|
+
|
|
285
|
+
Run side effects without state changes — no more `ABORT` workarounds:
|
|
286
|
+
|
|
287
|
+
```jsx
|
|
288
|
+
App.model = {
|
|
289
|
+
SEND_COMMAND: {
|
|
290
|
+
EFFECT: () => playerCmd.send('play'),
|
|
291
|
+
},
|
|
292
|
+
ROUTE: {
|
|
293
|
+
EFFECT: (state, data, next) => {
|
|
294
|
+
if (state.mode === 'a') next('DO_A', data)
|
|
295
|
+
else next('DO_B', data)
|
|
296
|
+
},
|
|
297
|
+
},
|
|
298
|
+
}
|
|
299
|
+
```
|
|
300
|
+
|
|
301
|
+
### Model Shorthand
|
|
302
|
+
|
|
303
|
+
Compact syntax for single-driver model entries:
|
|
304
|
+
|
|
305
|
+
```jsx
|
|
306
|
+
App.model = {
|
|
307
|
+
'SEND_CMD | EFFECT': () => playerCmd.send('play'),
|
|
308
|
+
'NOTIFY | EVENTS': (state) => ({ type: 'alert', data: state.message }),
|
|
309
|
+
'DELETE | PARENT': (state) => ({ type: 'DELETE', id: state.id }),
|
|
310
|
+
}
|
|
311
|
+
```
|
|
209
312
|
|
|
210
313
|
### Disposal Hooks
|
|
211
314
|
|
|
212
|
-
|
|
315
|
+
Cleanup on unmount:
|
|
213
316
|
|
|
214
317
|
```jsx
|
|
215
|
-
MyComponent.intent = ({
|
|
216
|
-
CLEANUP: dispose$,
|
|
318
|
+
MyComponent.intent = ({ dispose$ }) => ({
|
|
319
|
+
CLEANUP: dispose$,
|
|
217
320
|
})
|
|
218
321
|
|
|
219
322
|
MyComponent.model = {
|
|
220
|
-
CLEANUP: {
|
|
221
|
-
WEBSOCKET: () => ({ type: 'close' }),
|
|
222
|
-
},
|
|
323
|
+
CLEANUP: { WEBSOCKET: () => ({ type: 'close' }) },
|
|
223
324
|
}
|
|
224
325
|
```
|
|
225
326
|
|
|
@@ -238,6 +339,8 @@ if (import.meta.hot) {
|
|
|
238
339
|
|
|
239
340
|
### Astro Integration
|
|
240
341
|
|
|
342
|
+
First-class Astro support with server rendering and client hydration:
|
|
343
|
+
|
|
241
344
|
```javascript
|
|
242
345
|
// astro.config.mjs
|
|
243
346
|
import sygnal from 'sygnal/astro'
|
|
@@ -251,24 +354,24 @@ import Counter from '../components/Counter.jsx'
|
|
|
251
354
|
<Counter client:load />
|
|
252
355
|
```
|
|
253
356
|
|
|
254
|
-
### TypeScript
|
|
357
|
+
### TypeScript
|
|
255
358
|
|
|
256
359
|
Full type definitions included:
|
|
257
360
|
|
|
258
361
|
```tsx
|
|
259
362
|
import type { RootComponent } from 'sygnal'
|
|
260
363
|
|
|
261
|
-
type
|
|
262
|
-
type
|
|
364
|
+
type State = { count: number }
|
|
365
|
+
type Actions = { INCREMENT: null }
|
|
263
366
|
|
|
264
|
-
const App: RootComponent<
|
|
367
|
+
const App: RootComponent<State, {}, Actions> = ({ state }) => (
|
|
265
368
|
<div>{state.count}</div>
|
|
266
369
|
)
|
|
267
370
|
```
|
|
268
371
|
|
|
269
|
-
## Bundler Setup
|
|
372
|
+
## Bundler Setup
|
|
270
373
|
|
|
271
|
-
|
|
374
|
+
**Vite** (recommended):
|
|
272
375
|
|
|
273
376
|
```javascript
|
|
274
377
|
// vite.config.js
|
|
@@ -276,11 +379,11 @@ export default defineConfig({
|
|
|
276
379
|
esbuild: {
|
|
277
380
|
jsx: 'automatic',
|
|
278
381
|
jsxImportSource: 'sygnal',
|
|
279
|
-
}
|
|
382
|
+
},
|
|
280
383
|
})
|
|
281
384
|
```
|
|
282
385
|
|
|
283
|
-
For TypeScript projects,
|
|
386
|
+
For TypeScript projects, add to `tsconfig.json`:
|
|
284
387
|
|
|
285
388
|
```json
|
|
286
389
|
{
|
|
@@ -291,28 +394,34 @@ For TypeScript projects, also add to `tsconfig.json`:
|
|
|
291
394
|
}
|
|
292
395
|
```
|
|
293
396
|
|
|
294
|
-
Without JSX, use `h()
|
|
397
|
+
Without JSX, use `h()` directly:
|
|
295
398
|
|
|
296
399
|
```javascript
|
|
297
400
|
import { h } from 'sygnal'
|
|
298
401
|
h('div', [h('h1', 'Hello'), h('button.btn', 'Click')])
|
|
299
402
|
```
|
|
300
403
|
|
|
301
|
-
|
|
404
|
+
## Documentation
|
|
405
|
+
|
|
406
|
+
📖 **[sygnal.js.org](https://sygnal.js.org)** — Full guide, API reference, and examples.
|
|
302
407
|
|
|
303
408
|
## Examples
|
|
304
409
|
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
410
|
+
| Example | Description |
|
|
411
|
+
|---------|-------------|
|
|
412
|
+
| [Getting Started](./examples/getting-started) | Interactive guide with live demos (Astro) |
|
|
413
|
+
| [Kanban Board](./examples/kanban) | Drag-and-drop with Collections and cross-component communication |
|
|
414
|
+
| [Advanced Features](./examples/advanced-feature-tests) | Portals, slots, disposal, suspense, lazy loading |
|
|
415
|
+
| [TypeScript 2048](./examples/ts-example-2048) | Full game in TypeScript |
|
|
416
|
+
| [AI Discussion Panel](./examples/ai-panel-spa) | Complex SPA with custom drivers |
|
|
417
|
+
| [Sygnal ToDoMVC](https://github.com/tpresley/sygnal-todomvc) | [Live Demo](https://tpresley.github.io/sygnal-todomvc/) |
|
|
418
|
+
| [Sygnal 2048](https://github.com/tpresley/sygnal-2048) | [Live Demo](https://tpresley.github.io/sygnal-2048/) |
|
|
419
|
+
| [Sygnal Calculator](https://github.com/tpresley/sygnal-calculator) | [Live Demo](https://tpresley.github.io/sygnal-calculator/) |
|
|
420
|
+
|
|
421
|
+
## Acknowledgments
|
|
422
|
+
|
|
423
|
+
Sygnal's reactive architecture is built on patterns from [Cycle.js](https://cycle.js.org/) by [André Staltz](https://github.com/staltz). The Cycle.js runtime, DOM driver, state management, and isolation modules have been absorbed into the library — snabbdom, xstream, and extend are the only external dependencies.
|
|
315
424
|
|
|
316
425
|
## License
|
|
317
426
|
|
|
318
|
-
MIT
|
|
427
|
+
[MIT](./LICENSE)
|