sygnal 5.4.0 → 6.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.
Files changed (316) hide show
  1. package/CHANGELOG.md +646 -0
  2. package/README.md +24 -9
  3. package/dist/astro/client.cjs.js +16 -9
  4. package/dist/astro/client.cjs.js.map +1 -1
  5. package/dist/astro/client.mjs +16 -9
  6. package/dist/astro/client.mjs.map +1 -1
  7. package/dist/astro/index.cjs.js +362 -91
  8. package/dist/astro/index.cjs.js.map +1 -1
  9. package/dist/astro/index.mjs +362 -92
  10. package/dist/astro/index.mjs.map +1 -1
  11. package/dist/astro/server.cjs.js +3396 -165
  12. package/dist/astro/server.cjs.js.map +1 -1
  13. package/dist/astro/server.mjs +3396 -165
  14. package/dist/astro/server.mjs.map +1 -1
  15. package/dist/devtools.cjs.js +1431 -0
  16. package/dist/devtools.cjs.js.map +1 -0
  17. package/dist/devtools.esm.js +1419 -0
  18. package/dist/devtools.esm.js.map +1 -0
  19. package/dist/diagnostics.cjs.js +3032 -296
  20. package/dist/diagnostics.cjs.js.map +1 -1
  21. package/dist/diagnostics.esm.js +3032 -296
  22. package/dist/diagnostics.esm.js.map +1 -1
  23. package/dist/element.cjs.js +230 -0
  24. package/dist/element.cjs.js.map +1 -0
  25. package/dist/element.esm.js +228 -0
  26. package/dist/element.esm.js.map +1 -0
  27. package/dist/guide/accessibility.md +257 -0
  28. package/dist/guide/adapters.md +221 -0
  29. package/dist/guide/behaviors.md +312 -0
  30. package/dist/guide/browser-sources.md +203 -0
  31. package/dist/guide/drag-and-drop.md +342 -0
  32. package/dist/guide/element-commands.md +306 -0
  33. package/dist/guide/error-boundaries.md +169 -0
  34. package/dist/guide/forms-reference.md +381 -0
  35. package/dist/guide/forms.md +250 -0
  36. package/dist/guide/http.md +299 -0
  37. package/dist/guide/inputs.md +173 -0
  38. package/dist/guide/persistence.md +245 -0
  39. package/dist/guide/recipes/carousel.md +155 -0
  40. package/dist/guide/recipes/charts.md +177 -0
  41. package/dist/guide/recipes/code-editor.md +135 -0
  42. package/dist/guide/recipes/data-grid.md +155 -0
  43. package/dist/guide/recipes/data-table.md +196 -0
  44. package/dist/guide/recipes/i18n.md +222 -0
  45. package/dist/guide/recipes/icons.md +115 -0
  46. package/dist/guide/recipes/overview.md +33 -0
  47. package/dist/guide/recipes/rich-text.md +154 -0
  48. package/dist/guide/resources.md +471 -0
  49. package/dist/guide/ssr.md +255 -0
  50. package/dist/guide/timers.md +178 -0
  51. package/dist/guide/ui/accordion.md +93 -0
  52. package/dist/guide/ui/combobox.md +144 -0
  53. package/dist/guide/ui/dialog.md +124 -0
  54. package/dist/guide/ui/disclosure.md +60 -0
  55. package/dist/guide/ui/menu.md +125 -0
  56. package/dist/guide/ui/overview.md +166 -0
  57. package/dist/guide/ui/popover.md +101 -0
  58. package/dist/guide/ui/select.md +114 -0
  59. package/dist/guide/ui/tabs.md +161 -0
  60. package/dist/guide/ui/toaster.md +152 -0
  61. package/dist/guide/ui/tooltip.md +103 -0
  62. package/dist/guide/undo.md +178 -0
  63. package/dist/guide/virtual-collections.md +183 -0
  64. package/dist/guide/web-components.md +179 -0
  65. package/dist/guide/widgets.md +217 -0
  66. package/dist/index.cjs.js +13178 -5769
  67. package/dist/index.cjs.js.map +1 -1
  68. package/dist/index.d.ts +2908 -276
  69. package/dist/index.esm.js +13149 -5779
  70. package/dist/index.esm.js.map +1 -1
  71. package/dist/jsx-dev-runtime.cjs.js +26 -334
  72. package/dist/jsx-dev-runtime.cjs.js.map +1 -1
  73. package/dist/jsx-dev-runtime.esm.js +19 -327
  74. package/dist/jsx-dev-runtime.esm.js.map +1 -1
  75. package/dist/jsx-runtime.cjs.js +26 -334
  76. package/dist/jsx-runtime.cjs.js.map +1 -1
  77. package/dist/jsx-runtime.esm.js +19 -327
  78. package/dist/jsx-runtime.esm.js.map +1 -1
  79. package/dist/jsx.cjs.js +10 -314
  80. package/dist/jsx.cjs.js.map +1 -1
  81. package/dist/jsx.esm.js +8 -315
  82. package/dist/jsx.esm.js.map +1 -1
  83. package/dist/react.cjs.js +117 -0
  84. package/dist/react.cjs.js.map +1 -0
  85. package/dist/react.esm.js +115 -0
  86. package/dist/react.esm.js.map +1 -0
  87. package/dist/shims/globalthis.cjs +20 -0
  88. package/dist/sygnal.min.js +1 -1
  89. package/dist/sygnal.min.js.map +1 -1
  90. package/dist/ui-combobox.cjs.js +169 -0
  91. package/dist/ui-combobox.cjs.js.map +1 -0
  92. package/dist/ui-combobox.esm.js +148 -0
  93. package/dist/ui-combobox.esm.js.map +1 -0
  94. package/dist/ui-menu.cjs.js +87 -0
  95. package/dist/ui-menu.cjs.js.map +1 -0
  96. package/dist/ui-menu.esm.js +66 -0
  97. package/dist/ui-menu.esm.js.map +1 -0
  98. package/dist/ui-select.cjs.js +104 -0
  99. package/dist/ui-select.cjs.js.map +1 -0
  100. package/dist/ui-select.esm.js +83 -0
  101. package/dist/ui-select.esm.js.map +1 -0
  102. package/dist/ui.cjs.js +738 -0
  103. package/dist/ui.cjs.js.map +1 -0
  104. package/dist/ui.esm.js +727 -0
  105. package/dist/ui.esm.js.map +1 -0
  106. package/dist/vike/ClientOnly.cjs.js +4 -14
  107. package/dist/vike/ClientOnly.cjs.js.map +1 -1
  108. package/dist/vike/ClientOnly.mjs +4 -14
  109. package/dist/vike/ClientOnly.mjs.map +1 -1
  110. package/dist/vike/{+config.js → config/+config.js} +9 -1
  111. package/dist/vike/config/+config.js.map +1 -0
  112. package/dist/vike/config/package.json +6 -0
  113. package/dist/vike/onRenderClient.cjs.js +165 -95
  114. package/dist/vike/onRenderClient.cjs.js.map +1 -1
  115. package/dist/vike/onRenderClient.mjs +165 -95
  116. package/dist/vike/onRenderClient.mjs.map +1 -1
  117. package/dist/vike/onRenderHtml.cjs.js +59 -24
  118. package/dist/vike/onRenderHtml.cjs.js.map +1 -1
  119. package/dist/vike/onRenderHtml.mjs +60 -25
  120. package/dist/vike/onRenderHtml.mjs.map +1 -1
  121. package/dist/vite/plugin.cjs.js +323 -88
  122. package/dist/vite/plugin.cjs.js.map +1 -1
  123. package/dist/vite/plugin.mjs +323 -89
  124. package/dist/vite/plugin.mjs.map +1 -1
  125. package/dist/zag.cjs.js +408 -0
  126. package/dist/zag.cjs.js.map +1 -0
  127. package/dist/zag.esm.js +405 -0
  128. package/dist/zag.esm.js.map +1 -0
  129. package/llms.txt +166 -103
  130. package/package.json +98 -14
  131. package/src/astro/client.ts +27 -16
  132. package/src/astro/index.d.ts +24 -0
  133. package/src/astro/index.ts +69 -3
  134. package/src/astro/server.ts +8 -2
  135. package/src/collection.ts +6 -93
  136. package/src/core/actions.ts +134 -0
  137. package/src/core/cell.ts +202 -0
  138. package/src/core/debug.ts +23 -0
  139. package/src/core/define.ts +242 -0
  140. package/src/core/hooks.ts +314 -0
  141. package/src/core/hosts/collection.ts +325 -0
  142. package/src/core/hosts/switchable.ts +146 -0
  143. package/src/core/instance.ts +592 -0
  144. package/src/core/markers/clientonly.ts +13 -0
  145. package/src/core/markers/lazy.ts +40 -0
  146. package/src/core/markers/portal.ts +96 -0
  147. package/src/core/markers/suspense.ts +41 -0
  148. package/src/core/markers/transition.ts +70 -0
  149. package/src/core/registry.ts +25 -0
  150. package/src/core/runtime.ts +573 -0
  151. package/src/core/statics.ts +104 -0
  152. package/src/core/teardown.ts +60 -0
  153. package/src/core/view.ts +37 -0
  154. package/src/cycle/dom/DocumentDOMSource.ts +13 -8
  155. package/src/cycle/dom/ElementFinder.ts +7 -9
  156. package/src/cycle/dom/EventDelegator.ts +174 -223
  157. package/src/cycle/dom/IsolateModule.ts +65 -35
  158. package/src/cycle/dom/MainDOMSource.ts +9 -2
  159. package/src/cycle/dom/PriorityQueue.ts +4 -2
  160. package/src/cycle/dom/SymbolTree.ts +18 -47
  161. package/src/cycle/dom/classNameModule.ts +9 -7
  162. package/src/cycle/dom/controlledInputModule.ts +23 -6
  163. package/src/cycle/dom/enrichEventStream.ts +25 -47
  164. package/src/cycle/dom/fragment.ts +7 -0
  165. package/src/cycle/dom/isolate.ts +30 -27
  166. package/src/cycle/dom/makeDOMDriver.ts +115 -56
  167. package/src/cycle/dom/mockDOMSource.ts +21 -1
  168. package/src/cycle/dom/modules.ts +16 -3
  169. package/src/cycle/dom/propsModule.ts +65 -0
  170. package/src/cycle/dom/selectModule.ts +22 -17
  171. package/src/cycle/dom/snabbdom.ts +1 -6
  172. package/src/cycle/dom/thunk.ts +3 -0
  173. package/src/cycle/dom/utils.ts +98 -0
  174. package/src/cycle/dom/viewTransition.ts +43 -0
  175. package/src/cycle/state/StateSource.ts +19 -3
  176. package/src/cycle/state/objIsEqual.ts +50 -0
  177. package/src/cycle/state/types.ts +0 -10
  178. package/src/defineComponent.ts +34 -0
  179. package/src/devtools.d.ts +151 -0
  180. package/src/devtools.ts +34 -0
  181. package/src/element.d.ts +59 -0
  182. package/src/element.ts +235 -0
  183. package/src/extra/backoff.ts +9 -0
  184. package/src/extra/behaviors.ts +169 -0
  185. package/src/extra/browserSignals.ts +23 -0
  186. package/src/extra/browserSources.ts +326 -0
  187. package/src/extra/command.ts +3 -3
  188. package/src/extra/controls.ts +47 -0
  189. package/src/extra/copyAsTest.ts +286 -0
  190. package/src/extra/devtools.ts +101 -31
  191. package/src/extra/devtoolsActions.ts +379 -0
  192. package/src/extra/devtoolsHook.ts +9 -0
  193. package/src/extra/devtoolsNext.ts +83 -0
  194. package/src/extra/diagnostics/checks/actionLog.ts +108 -0
  195. package/src/extra/diagnostics/checks/behaviors.ts +49 -0
  196. package/src/extra/diagnostics/checks/browserSources.ts +96 -0
  197. package/src/extra/diagnostics/checks/collections.ts +1 -1
  198. package/src/extra/diagnostics/checks/controls.ts +148 -0
  199. package/src/extra/diagnostics/checks/dataset.ts +57 -0
  200. package/src/extra/diagnostics/checks/dom.ts +20 -11
  201. package/src/extra/diagnostics/checks/elementCommands.ts +175 -0
  202. package/src/extra/diagnostics/checks/events.ts +17 -2
  203. package/src/extra/diagnostics/checks/fetch.ts +135 -0
  204. package/src/extra/diagnostics/checks/forms.ts +163 -0
  205. package/src/extra/diagnostics/checks/index.ts +97 -4
  206. package/src/extra/diagnostics/checks/inspect.ts +140 -32
  207. package/src/extra/diagnostics/checks/next.ts +417 -0
  208. package/src/extra/diagnostics/checks/persist.ts +54 -0
  209. package/src/extra/diagnostics/checks/props.ts +10 -5
  210. package/src/extra/diagnostics/checks/public.d.ts +98 -7
  211. package/src/extra/diagnostics/checks/replies.ts +128 -0
  212. package/src/extra/diagnostics/checks/router.ts +100 -0
  213. package/src/extra/diagnostics/checks/rxjsHints.ts +1 -1
  214. package/src/extra/diagnostics/checks/shared.ts +86 -2
  215. package/src/extra/diagnostics/checks/shorthand.ts +115 -0
  216. package/src/extra/diagnostics/checks/sortable.ts +129 -0
  217. package/src/extra/diagnostics/checks/state.ts +100 -7
  218. package/src/extra/diagnostics/checks/statics.ts +48 -0
  219. package/src/extra/diagnostics/checks/strict.ts +10 -67
  220. package/src/extra/diagnostics/checks/timers.ts +80 -0
  221. package/src/extra/diagnostics/checks/viewTransitions.ts +47 -0
  222. package/src/extra/diagnostics/checks/virtual.ts +73 -0
  223. package/src/extra/diagnostics/checks/widgets.ts +109 -0
  224. package/src/extra/diagnostics/checks/wiring.ts +66 -10
  225. package/src/extra/diagnostics/codes.ts +268 -23
  226. package/src/extra/diagnostics/index.ts +34 -9
  227. package/src/extra/diagnostics/legacy.ts +17 -0
  228. package/src/extra/driverFactories.ts +83 -19
  229. package/src/extra/elementCommands.ts +53 -0
  230. package/src/extra/fetchDriver.ts +519 -0
  231. package/src/extra/focusWithin.ts +27 -0
  232. package/src/extra/form.ts +268 -0
  233. package/src/extra/formHelpers.ts +139 -0
  234. package/src/extra/head.ts +106 -0
  235. package/src/extra/hmr.ts +2 -3
  236. package/src/extra/owned.ts +24 -0
  237. package/src/extra/pager.ts +50 -0
  238. package/src/extra/persist.ts +133 -0
  239. package/src/extra/pwa.ts +1 -1
  240. package/src/extra/queryCache.ts +107 -0
  241. package/src/extra/reducers.ts +1 -1
  242. package/src/extra/reduxDevtools.ts +92 -0
  243. package/src/extra/replies.ts +54 -0
  244. package/src/extra/router.ts +323 -0
  245. package/src/extra/run.ts +90 -217
  246. package/src/extra/selection.ts +76 -0
  247. package/src/extra/socketDriver.ts +265 -0
  248. package/src/extra/sortable.ts +377 -0
  249. package/src/extra/ssr.ts +427 -160
  250. package/src/extra/standardSchema.ts +27 -0
  251. package/src/extra/testing.ts +2433 -175
  252. package/src/extra/timers.ts +95 -0
  253. package/src/extra/undo.ts +261 -0
  254. package/src/extra/viewTransitions.ts +38 -0
  255. package/src/extra/virtual.ts +513 -0
  256. package/src/extra/widget.ts +201 -0
  257. package/src/index.d.ts +2631 -117
  258. package/src/index.ts +25 -4
  259. package/src/jsx-runtime.ts +15 -1
  260. package/src/jsx.ts +2 -1
  261. package/src/lazy.ts +50 -7
  262. package/src/portal.ts +3 -1
  263. package/src/pragma/index.ts +256 -133
  264. package/src/react-peers.d.ts +5 -0
  265. package/src/react.d.ts +43 -0
  266. package/src/react.ts +110 -0
  267. package/src/shared.ts +48 -0
  268. package/src/slot.ts +1 -1
  269. package/src/suspense.ts +3 -1
  270. package/src/switchable.ts +6 -121
  271. package/src/transition.ts +3 -1
  272. package/src/ui/accordion.ts +64 -0
  273. package/src/ui/dialog.ts +129 -0
  274. package/src/ui/disclosure.ts +35 -0
  275. package/src/ui/popover.ts +45 -0
  276. package/src/ui/shared.ts +94 -0
  277. package/src/ui/tabs.ts +82 -0
  278. package/src/ui/toaster.ts +198 -0
  279. package/src/ui/tooltip.ts +70 -0
  280. package/src/ui/zag/combobox.ts +115 -0
  281. package/src/ui/zag/menu.ts +40 -0
  282. package/src/ui/zag/select.ts +54 -0
  283. package/src/ui/zag/shared.ts +43 -0
  284. package/src/ui-combobox.d.ts +20 -0
  285. package/src/ui-combobox.ts +6 -0
  286. package/src/ui-menu.d.ts +30 -0
  287. package/src/ui-menu.ts +6 -0
  288. package/src/ui-select.d.ts +17 -0
  289. package/src/ui-select.ts +6 -0
  290. package/src/ui-zag-types.d.ts +42 -0
  291. package/src/ui.d.ts +208 -0
  292. package/src/ui.ts +15 -0
  293. package/src/vike/+config.ts +9 -1
  294. package/src/vike/ClientOnly.ts +1 -1
  295. package/src/vike/onRenderClient.ts +144 -96
  296. package/src/vike/onRenderHtml.ts +69 -26
  297. package/src/vike/types.ts +7 -1
  298. package/src/vite/globalthis-shim.ts +18 -0
  299. package/src/vite/globalthis.ts +56 -0
  300. package/src/vite/plugin.d.ts +22 -3
  301. package/src/vite/plugin.ts +222 -24
  302. package/src/zag.d.ts +64 -0
  303. package/src/zag.ts +238 -0
  304. package/dist/vike/+config.cjs.js +0 -66
  305. package/dist/vike/+config.cjs.js.map +0 -1
  306. package/dist/vike/+config.js.map +0 -1
  307. package/src/component.ts +0 -2301
  308. package/src/cycle/isolate/index.ts +0 -196
  309. package/src/cycle/run/index.ts +0 -151
  310. package/src/cycle/run/internals.ts +0 -143
  311. package/src/cycle/state/Collection.ts +0 -184
  312. package/src/cycle/state/index.ts +0 -5
  313. package/src/cycle/state/pickCombine.ts +0 -201
  314. package/src/cycle/state/pickMerge.ts +0 -122
  315. package/src/cycle/state/withState.ts +0 -47
  316. package/src/pragma/fn.ts +0 -55
@@ -0,0 +1,178 @@
1
+ <!-- Generated from docs/src/content/docs/advanced by scripts/copy-guides.mjs; online: https://sygnal.js.org/advanced/ -->
2
+ # Undo and Redo
3
+
4
+ Sygnal records undo history for one key of a component's state. Each change to `state[key]` pushes the previous value onto `state.history.past`; undo moves it back, redo forward again. There are two ways to add it:
5
+
6
+ - the **`undo` [behavior](./behaviors.md)**: `uses = { history: undo({ key: 'doc' }) }`, with `canUndo` / `canRedo` in the state and buttons wired through its options;
7
+ - the **`undoable()` model wrapper**: `model = undoable({ … }, { key: 'doc' })`, when you want `UNDO` and `REDO` as plain actions of the component.
8
+
9
+ Both take the same options and keep the same history.
10
+
11
+ ## The undo behavior
12
+
13
+ ```jsx live
14
+ import { undo } from 'sygnal'
15
+
16
+ export function Editor({ state }) {
17
+ return (
18
+ <div>
19
+ <label>Note <textarea className="note" value={state.doc.text} /></label>
20
+ <button className="undo" disabled={!state.history.canUndo}>Undo</button>
21
+ <button className="redo" disabled={!state.history.canRedo}>Redo</button>
22
+ </div>
23
+ )
24
+ }
25
+
26
+ Editor.initialState = { doc: { text: '' } }
27
+ Editor.uses = { history: undo({ key: 'doc', coalesceMs: 500, undo: '.undo', redo: '.redo' }) }
28
+ Editor.intent = ({ DOM }) => ({ TYPE: DOM.input('.note').value() })
29
+ Editor.model = {
30
+ TYPE: (state, text) => ({ ...state, doc: { ...state.doc, text } }),
31
+ }
32
+ ```
33
+
34
+ `state.history` is `{ past, future, canUndo, canRedo }`. A click on `Undo` dispatches `history.UNDO`, which puts the newest snapshot from `past` back into `state.doc` and moves the current value to `future`. Any new change clears `future`.
35
+
36
+ `coalesceMs: 500` joins the changes one action makes less than 500 ms apart into a single step, so typing "hello" quickly is undone at once rather than letter by letter. Without it every change is its own step.
37
+
38
+ ### Grouping only some actions
39
+
40
+ `coalesceMs` alone joins quick repeats of *any* action: two fast clicks on a Larger button would also become one step. To group only typing, list the actions that may join in `coalesce`. Every other action is then always its own step:
41
+
42
+ ```jsx live
43
+ import { undo } from 'sygnal'
44
+
45
+ export function Poster({ state }) {
46
+ return (
47
+ <div>
48
+ <label>Headline <input className="headline" value={state.poster.headline} /></label>
49
+ <button className="larger">Larger</button>
50
+ <button className="undo" disabled={!state.history.canUndo}>Undo</button>
51
+ <button className="redo" disabled={!state.history.canRedo}>Redo</button>
52
+ <h1 style={{ fontSize: `${state.poster.size}px` }}>{state.poster.headline}</h1>
53
+ </div>
54
+ )
55
+ }
56
+
57
+ Poster.initialState = { poster: { headline: '', size: 24 } }
58
+ Poster.uses = { history: undo({ key: 'poster', coalesce: ['HEADLINE'], coalesceMs: 1000, undo: '.undo', redo: '.redo' }) }
59
+ Poster.intent = ({ DOM }) => ({
60
+ HEADLINE: DOM.input('.headline').value(),
61
+ LARGER: DOM.click('.larger'),
62
+ })
63
+ Poster.model = {
64
+ HEADLINE: (state, headline) => ({ ...state, poster: { ...state.poster, headline } }),
65
+ LARGER: (state) => ({ ...state, poster: { ...state.poster, size: state.poster.size + 4 } }),
66
+ }
67
+ ```
68
+
69
+ Typing "Sale" and then clicking Larger twice quickly makes three steps: the typing, and each click. A `HEADLINE` change joins only the previous change when that was a `HEADLINE` change too, less than `coalesceMs` earlier; a click in between starts a new step. With `coalesce`, `coalesceMs` defaults to 500.
70
+
71
+ ### Keyboard shortcuts
72
+
73
+ A host intent action with a behavior action's name replaces the behavior's trigger. To undo with Ctrl+Z (⌘Z) as well as the button, leave out the `undo` / `redo` options and trigger the actions from the host:
74
+
75
+ ```jsx live
76
+ import { undo, xs } from 'sygnal'
77
+
78
+ export function Editor({ state }) {
79
+ return (
80
+ <div>
81
+ <label>Note <textarea className="note" value={state.doc.text} /></label>
82
+ <button className="undo" disabled={!state.history.canUndo}>Undo</button>
83
+ <button className="redo" disabled={!state.history.canRedo}>Redo</button>
84
+ </div>
85
+ )
86
+ }
87
+
88
+ const keys = (DOM, shift) => DOM.select('document').events('keydown')
89
+ .filter(e => (e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'z' && e.shiftKey === shift)
90
+
91
+ Editor.initialState = { doc: { text: '' } }
92
+ Editor.uses = { history: undo({ key: 'doc', coalesceMs: 500 }) }
93
+ Editor.intent = ({ DOM }) => ({
94
+ TYPE: DOM.input('.note').value(),
95
+ 'history.UNDO': xs.merge(DOM.click('.undo'), keys(DOM, false)),
96
+ 'history.REDO': xs.merge(DOM.click('.redo'), keys(DOM, true)),
97
+ })
98
+ Editor.model = {
99
+ TYPE: (state, text) => ({ ...state, doc: { ...state.doc, text } }),
100
+ }
101
+ ```
102
+
103
+ In a text field the browser runs its own undo on Ctrl+Z too. To leave undo to the app, prevent the default for those keys: `events('keydown', { preventDefault: (e) => … })` ([Preventing the default action](https://sygnal.js.org/guide/intent/#preventing-the-default-action)).
104
+
105
+ ## undoable()
106
+
107
+ `undoable(model, options)` wraps a model's STATE reducers and adds `UNDO` and `REDO` entries for your intent to trigger. `state.history` is `{ past, future }` (no calculated fields), and it isn't there until the first change, so read it with a default. In this demo, a stand-in server has the saved note:
108
+
109
+ ```js live-server
110
+ export default {
111
+ 'GET /api/note': () => ({ json: { text: 'Buy milk' } }),
112
+ }
113
+ ```
114
+
115
+ ```jsx live
116
+ import { undoable } from 'sygnal'
117
+
118
+ export function Editor({ state }) {
119
+ const { past, future } = state.history || { past: [], future: [] }
120
+ return (
121
+ <div>
122
+ <label>Note <textarea className="note" value={state.doc.text} /></label>
123
+ <button className="undo" disabled={past.length === 0}>Undo</button>
124
+ <button className="redo" disabled={future.length === 0}>Redo</button>
125
+ </div>
126
+ )
127
+ }
128
+
129
+ Editor.initialState = { doc: { text: '' } }
130
+ Editor.intent = ({ DOM }) => ({ TYPE: DOM.input('.note').value(), UNDO: DOM.click('.undo'), REDO: DOM.click('.redo') })
131
+ Editor.model = undoable({
132
+ BOOTSTRAP: { HTTP: () => ({ url: '/api/note', ok: 'LOADED' }) },
133
+ LOADED: (state, doc) => ({ ...state, doc }),
134
+ TYPE: (state, text) => ({ ...state, doc: { ...state.doc, text } }),
135
+ }, { key: 'doc', coalesceMs: 500, resetOn: ['LOADED'] })
136
+ ```
137
+
138
+ `resetOn: ['LOADED']` clears the history when the note is loaded: undo shouldn't bring back the empty draft from before the load. Actions in `resetOn` are not recorded themselves.
139
+
140
+ ## Options
141
+
142
+ | Option | Default | |
143
+ |---|---|---|
144
+ | `key` | (required) | The state key whose value is recorded; an array of keys (`['todo', 'done']`) records them together, as one `{ todo, done }` value per step |
145
+ | `limit` | `100` | The most steps kept in `past`; the oldest are dropped |
146
+ | `track` | every action with a STATE reducer | Record only these actions' changes. Built-in actions (`INITIALIZE`, `BOOTSTRAP`, …) are recorded only when listed |
147
+ | `coalesceMs` | `0` (off); `500` with `coalesce` | Changes by the same action within this many ms join one step |
148
+ | `coalesce` | every action | Only these actions' changes join a step (typing); every other action is always its own step |
149
+ | `resetOn` | `[]` | Actions that clear the history |
150
+ | `undo`, `redo` | — | (`undo()` only) A selector (or a [control](https://sygnal.js.org/guide/controls/)) whose clicks dispatch `UNDO` / `REDO` |
151
+
152
+ A change is a reducer result whose `state[key]` is a different object than before (with an array of keys: any of them), so reducers that return new objects (as Sygnal reducers do) are recorded. `UNDO` and `REDO` make no change when there is nothing to undo or redo. A model entry of your own for `UNDO` / `REDO` (`'history.UNDO'` with the behavior) runs after the built-in step.
153
+
154
+ A key the state doesn't have is recorded as missing: undoing back to such a step removes the key again (it isn't set to `undefined`). With one `key`, a missing key and one set to `undefined` are the same value; with an array of keys they differ, so a key that appears or goes (even as `undefined`) is a change.
155
+
156
+ Snapshots are the old values themselves, not copies. Keep `key` on the part of the state the user edits (`doc`), not on the whole state, so the history doesn't hold every loading flag and list position too.
157
+
158
+ For the same reason, when you save the state, save `state.doc` and leave `history` out. With [`persist()`](./persistence.md), pick the document:
159
+
160
+ ```jsx
161
+ import { persist } from 'sygnal'
162
+
163
+ Editor.persist = persist({ key: 'note', pick: ['doc'] })
164
+ ```
165
+
166
+ After a reload the note is back and the history starts empty, so the first undo doesn't reach into the previous visit.
167
+
168
+ ## Gestures: one step per drag
169
+
170
+ A behavior on the same host can mark its actions as one gesture with [`undoStep`](./behaviors.md#persisted-state-and-undo-steps). [`sortable`](./drag-and-drop.md#undo-and-persist) does: with `undo({ key: 'tasks' })`, a whole drag, pointer or keyboard, is one undo step, recorded when the item is dropped (`sort.DROPPED`). The live keyboard moves and a cancelled drag (Escape, a drop where it started) add no step. While a drag has moved something, `state.history.base` holds the value from before it; it is gone once the drag is dropped or cancelled. Another recorded change during a drag (an item added while one is lifted) records the value from before the drag, so it stays reachable, and the drag so far joins that change's step. UNDO during a drag goes back to the value from before it (the drag so far is the step undone); REDO during a drag records that value too, in place of the half-moved one. A drag that ends without a drop after something else changed the list (an item added, an undo; see [Drag and Drop](./drag-and-drop.md#keyboard)) leaves the item where it is: when that leaves the value different from the one before the drag, it is recorded as one step, else nothing is; either way nothing stays pending. Only the behavior that started a gesture completes it: with two gesture behaviors on one host, one's completing action doesn't record the other's gesture. This works with `undo()` (the behavior), in either `uses` order; `undoable()` can't see the host's behaviors.
171
+
172
+ A sortable with several lists (`from: ['todo', 'done']`) moves items between them, so record them together: `undo({ key: ['todo', 'done'] })`. With one of them as the key, an undo after a move between lists puts that list back but not the other, and the item is in both.
173
+
174
+ `track` and `coalesce` treat a gesture as one action: naming any of the behavior's actions (`'sort.DROPPED'`, `'sort.KEY'`, ...) records drags, or lets quick drops join one step. A `track` list that names none of them leaves drags unrecorded, like any untracked change: undo steps over the tracked changes only, and a drag in progress isn't undone on its own. `coalesceMs` without `coalesce` doesn't join drops: two quick drags are two steps. A name in `resetOn` (`'sort.DROPPED'`) clears the history when that action runs.
175
+
176
+ ## SYG226
177
+
178
+ A name in `track` or `resetOn` with no model entry is [SYG226](https://sygnal.js.org/reference/errors/#syg226) (a warning): its changes are never recorded, or the history is never cleared. It is usually a typo or a renamed action. `sygnal-check` reports it statically; `undoable()` reports it when diagnostics are on, and `undo()` when the component is first created. For `undo()`, the names are the host component's actions, including other behaviors' namespaced ones (`'pager.NEXT'`); a name under a behavior's key that isn't one of its actions (`'sort.DROPED'`) is reported too.
@@ -0,0 +1,183 @@
1
+ <!-- Generated from docs/src/content/docs/guide by scripts/copy-guides.mjs; online: https://sygnal.js.org/guide/ -->
2
+ # Virtual Collections
3
+
4
+ A `<Collection>` makes a component for every item. With thousands of rows that is slow to create and heavy to keep. `<VirtualCollection>` takes the same props and makes components only for the rows that are in view, plus a few beyond each edge. Scrolling moves that window: rows that leave it are disposed, rows that enter it are made.
5
+
6
+ `<VirtualCollection>` is itself the scroll container: it renders a `div` (`role="list"` by default) that scrolls. Put the class with the bounded height, `role` and `aria-label` on it (`<VirtualCollection className="people" aria-label="People" … />`), and don't wrap it in a scrolling `<ul>` or `<div>` of your own: it would grow with its rows, only a viewport's height of them would render, and [SYG430](https://sygnal.js.org/reference/errors/#syg430) reports it. SYG430 means: fix the height of the VirtualCollection's own class.
7
+
8
+ ```jsx live-file=./Row.jsx
9
+ // Row.jsx
10
+ export function Row({ state }) {
11
+ return (
12
+ <div className="row">
13
+ <span className="name">{state.name}</span>
14
+ <button className="star">{state.starred ? 'Unstar' : 'Star'}</button>
15
+ </div>
16
+ )
17
+ }
18
+
19
+ Row.intent = ({ DOM }) => ({ STAR: DOM.click('.star') })
20
+
21
+ Row.model = {
22
+ STAR: (state) => ({ ...state, starred: !state.starred }),
23
+ }
24
+ ```
25
+
26
+ ```jsx live
27
+ // People.jsx
28
+ import { VirtualCollection } from 'sygnal'
29
+ import { Row } from './Row.jsx'
30
+
31
+ export function People({ state }) {
32
+ return (
33
+ <section>
34
+ <button className="jump">Jump to row 9,000</button>
35
+ <VirtualCollection of={Row} from="people" className="people" estimateSize={32} aria-label="People" />
36
+ </section>
37
+ )
38
+ }
39
+
40
+ People.initialState = {
41
+ people: Array.from({ length: 10000 }, (_, i) => ({ id: i + 1, name: `Person ${i + 1}` })),
42
+ }
43
+
44
+ People.intent = ({ DOM }) => ({
45
+ JUMP: DOM.click('.jump').mapTo(8999),
46
+ })
47
+
48
+ People.model = {
49
+ JUMP: { ELEMENT: (state, index) => ({ scrollToIndex: '.people', index, align: 'start' }) },
50
+ }
51
+ ```
52
+
53
+ ```css live
54
+ .people { height: 480px; }
55
+ ```
56
+
57
+ The element is its own scroll container (`overflow-y: auto`), so **give its class a bounded height**: `height`, `max-height`, or `flex: 1` with `min-height: 0` in a flex column. Without one it grows with its rows and would render all of them; Sygnal then renders only a viewport's height of rows and reports [SYG430](https://sygnal.js.org/reference/errors/#syg430). A `max-height` taller than its rows is fine: the container fits them, and they all render. A percentage `height` or `max-height` bounds it only when its parent has a height itself (`max-height: 100%` of a parent that grows doesn't); Sygnal measures this rather than reading the CSS.
58
+
59
+ That measurement only happens for a container as tall as all its rows and taller than the viewport. It makes the list's spacer much taller for one forced layout and turns scroll anchoring off (`overflow-anchor: none`) on every ancestor meanwhile, so the page doesn't move. It puts each ancestor's inline style back through the CSSOM (`element.style`), not by rewriting the `style` attribute, because a [Content Security Policy](https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CSP) whose `style-src` has no `'unsafe-inline'` blocks attribute writes. The browser then writes those ancestors' `style` attributes again from the declarations it parsed: the text is normalised (`PADDING-TOP:1px` becomes `padding-top: 1px;`), and a declaration it doesn't understand (another engine's vendor prefix) is dropped. Styles you set with JSX `style={{ ... }}` are unaffected. An ancestor without a `style` attribute keeps none.
60
+
61
+ The viewport-high window is in the rows' own pixels under an ancestor's CSS `zoom` (`zoom: 0.5` renders twice the rows); a CSS `transform` such as `scale()` doesn't change it.
62
+
63
+ ## Props
64
+
65
+ `of`, `from`, `filter` and `sort` work as on a [Collection](https://sygnal.js.org/guide/collections/): the same keys (an item's `id`, or its index without one), the same duplicate and missing-`from` handling, and an item writes back to its own array element. Any other prop goes to every item, as with a Collection. These are the container's:
66
+
67
+ | Prop | Default | Description |
68
+ |------|---------|-------------|
69
+ | `className` | | The scroll container's class. Give it a bounded height |
70
+ | `estimateSize` | `32` | A row's height in px until it is measured: a number, or `(item, index) => px` |
71
+ | `overscan` | `5` | Rows rendered beyond each edge of the view |
72
+ | `role` | `'list'` | The container's role. With `list`, rows without a role of their own get `role="listitem"`. `null` sets none |
73
+ | `tabIndex` | `0` | Focusable, so the keyboard scrolls it |
74
+ | `aria-label`, `aria-labelledby`, `aria-describedby`, `id`, `style` | | Set on the container (`style` after the defaults) |
75
+ | `viewTransitionName` | | As on [Collection](https://sygnal.js.org/guide/view-transitions/#collection-items): each row with an `id` gets `view-transition-name: <prefix>-<id>`, so a sort in a `viewTransitions` action animates the rows in view |
76
+
77
+ ## Row state lives in the array
78
+
79
+ A row scrolled out of the window is disposed, like a removed Collection item, and made again when it comes back. Its state is its element of the array, as for every Collection item, so nothing in state is lost: the starred flag above is still there when you scroll back.
80
+
81
+ What lives only in the DOM or in the row's instance is lost:
82
+
83
+ - the text of an input whose value isn't in state (bind `value` to state for anything the user types);
84
+ - work in flight: a row's pending request, timer or `EFFECT` ends with its instance.
85
+
86
+ So keep everything a row needs in its array element, which is the canonical Collection item anyway. Work that must outlive scrolling belongs in the parent.
87
+
88
+ The focus is the exception: the row that holds it stays rendered while it is scrolled out of view (at its own position, outside the window), so the focus and the keyboard user's place are kept. Once the focus leaves the list, the row goes like any other.
89
+
90
+ ## Jumping to a row
91
+
92
+ The target row is usually not rendered, so you can't scroll it into view. The container has two methods for [element commands](./element-commands.md) instead:
93
+
94
+ ```jsx
95
+ Results.model = {
96
+ SHOW_MATCH: { ELEMENT: (state, id) => ({ scrollToId: '.results', id, align: 'center' }) },
97
+ TO_TOP: { ELEMENT: { scrollToIndex: '.results', index: 0 } },
98
+ }
99
+ ```
100
+
101
+ | Command | Options |
102
+ |---|---|
103
+ | `{ scrollToIndex: target, index }` | `index`: a position among the shown rows (after `filter` and `sort`), from 0 |
104
+ | `{ scrollToId: target, id }` | `id`: the `id` of an item the filter keeps |
105
+ | both | `align`: `'auto'` (default: scrolls only when the row isn't in view), `'start'`, `'center'`, `'end'`; `behavior`: `'auto'` or `'smooth'` |
106
+
107
+ The virtualizer scrolls to the row's estimated offset and keeps correcting it while the rows around it are measured, so it lands on the row even when heights vary. An index or id that isn't in the list doesn't scroll ([SYG433](https://sygnal.js.org/reference/errors/#syg433) in development).
108
+
109
+ ## Row heights
110
+
111
+ Rows can have any height, and each can differ. Until a row has been rendered, its height is `estimateSize`; once rendered, it is measured, and a `ResizeObserver` follows later changes (an expanded row, a loaded image). A good estimate keeps the scrollbar steady: use your typical row height, or a function when rows differ predictably:
112
+
113
+ ```jsx
114
+ <VirtualCollection of={Message} from="messages" className="thread" estimateSize={(message) => (message.image ? 240 : 56)} />
115
+ ```
116
+
117
+ An inline function like this one is a new function at every render; that's fine, it is taken as the same estimate and the measured heights are kept. A numeric `estimateSize` that changes, or a switch between a number and a function, measures the rows again.
118
+
119
+ A row must render one element, which is what is measured ([SYG432](https://sygnal.js.org/reference/errors/#syg432) for a fragment).
120
+
121
+ ## Accessibility
122
+
123
+ - The container is a `list` named by `aria-label` (or `aria-labelledby`), and its rows are `listitem`s with `aria-setsize` (the number of shown rows) and `aria-posinset` (the row's position, from 1), so a screen reader can announce "item 4,201 of 10,000" although only a few rows exist.
124
+ - For another pattern, set the container's `role` and give the rows theirs: `role="listbox"` with rows rendering `role="option"` (their `aria-setsize` and `aria-posinset` are set too).
125
+ - The container is focusable (`tabIndex={0}`), so arrow keys, Page Up/Down, Home and End scroll it.
126
+ - A focused row that scrolls out of the window keeps the focus: it stays rendered until the focus leaves the list. For keyboard navigation between rows, keep the active row in state and send `scrollToIndex` with it before focusing.
127
+
128
+ ## Tests and server rendering
129
+
130
+ Without layout (the default mock DOM of [`renderComponent`](https://sygnal.js.org/integration/testing/), jsdom, `renderToString`), the window is the first 10 rows' estimate plus `overscan`: the first 15 rows by default. Test row behaviour on those rows, and check jumps as commands:
131
+
132
+ ```jsx
133
+ import { renderComponent } from 'sygnal'
134
+ import { People } from './People.jsx'
135
+
136
+ it('jumps to row 9,000', async () => {
137
+ const t = renderComponent(People, { initialState: { people: Array.from({ length: 10000 }, (_, i) => ({ id: i + 1, name: 'P' + i })) } })
138
+ await t.ready()
139
+ t.simulateEvent('.jump', 'click')
140
+ await t.settle()
141
+ expect(t.commands('ELEMENT')).toEqual([{ scrollToIndex: '.people', index: 8999, align: 'start' }])
142
+ })
143
+ ```
144
+
145
+ Scrolling, measured heights and real jumps need a browser: Sygnal's own tests for them run in Chromium, Firefox and WebKit. `renderToString` renders the container and the first rows, numbered and named as the client numbers and names them; the client measures and moves the window once the page has layout. The client's first render makes the rows again (as it does every element of the server markup with a class or an id, and what is inside it), so a focus or text typed in a row before the app started is not kept.
146
+
147
+ ## When to use it
148
+
149
+ Measured in Chromium 153 with Sygnal's benchmark harness (a 640 px container of 32 px rows; `benchmarks/RESULTS.md` in the repository). Each cell is the time from the click or scroll to the next painted frame, and in brackets the main thread's CPU time, in ms (medians):
150
+
151
+ | | VirtualCollection | Collection | React + TanStack Virtual | React, every row |
152
+ |---|---:|---:|---:|---:|
153
+ | Create 10,000 rows | 18 (10) | 159 (161) | 17 (7) | 234 (236) |
154
+ | Scroll by a page, 10,000 | 18 (4) | 17 (32) | 18 (3) | 17 (24) |
155
+ | Jump to row 9,000, 10,000 | 19 (4) | 22 (37) | 18 (4) | 17 (27) |
156
+ | Create 100,000 rows | 41 (48) | 1,600 (1,603) | 16 (23) | 11,900 (11,903) |
157
+ | Scroll by a page, 100,000 | 22 (12) | 17 (370) | 18 (8) | 17 (166) |
158
+ | Jump to row 9,000, 100,000 | 22 (13) | 55 (381) | 19 (8) | 33 (177) |
159
+
160
+ A plain Collection costs about 14 ms per 1,000 rows to create, every row stays in the DOM, and every scroll costs the main thread more as the list grows. `VirtualCollection` costs about the same at any length. **Use it from about 1,000 rows**, or earlier when rows are expensive to render. Below a few hundred rows a plain Collection is simpler: find-in-page, printing and scroll anchoring work on every row, and no height is needed.
161
+
162
+ It doesn't do (yet): horizontal lists, grids, sticky group headers, or scrolling with the page instead of its own container.
163
+
164
+ ## Without a bundler
165
+
166
+ `VirtualCollection` uses `@tanstack/virtual-core`, a dependency of Sygnal that bundlers resolve and tree-shake. Loaded as native ES modules straight in the browser (an import map or a CDN, no build step), it needs two things a bundler would otherwise provide:
167
+
168
+ - an import map entry for `@tanstack/virtual-core` (Sygnal imports it by name), next to the ones for `sygnal`, `snabbdom` and `xstream`;
169
+ - `process.env.NODE_ENV`, which the virtualizer reads when it is created. Define it before the app loads:
170
+
171
+ ```html
172
+ <script>globalThis.process ??= { env: { NODE_ENV: 'production' } }</script>
173
+ ```
174
+
175
+ ## Diagnostics
176
+
177
+ | Code | When |
178
+ |---|---|
179
+ | [SYG430](https://sygnal.js.org/reference/errors/#syg430) | The container has no bounded height (0 px, or it grows with its rows) |
180
+ | [SYG431](https://sygnal.js.org/reference/errors/#syg431) | Items without `id` (index keys: measured heights and instances follow positions) |
181
+ | [SYG432](https://sygnal.js.org/reference/errors/#syg432) | An item renders a fragment or text, not one element |
182
+ | [SYG433](https://sygnal.js.org/reference/errors/#syg433) | `scrollToIndex` / `scrollToId` for a row not in the list |
183
+ | [SYG434](https://sygnal.js.org/reference/errors/#syg434) | `estimateSize` or `overscan` isn't a valid number |
@@ -0,0 +1,179 @@
1
+ <!-- Generated from docs/src/content/docs/guide by scripts/copy-guides.mjs; online: https://sygnal.js.org/guide/ -->
2
+ # Web components
3
+
4
+ A web component is an element: once its library has defined the tag, the browser treats `<wa-rating>` like `<input>`. Sygnal renders it, patches its properties and listens to its events the same way, so using a component library such as [Web Awesome](https://webawesome.com) needs no adapter. This page covers [using](#using-web-components) them and [publishing](#publishing-a-component-as-a-custom-element) a Sygnal component as one.
5
+
6
+ ## Using web components
7
+
8
+ Load the library's theme and elements once, at startup (in an app's `main.js`; this demo loads them at its top), then render the tags and select them by class like any element:
9
+
10
+ ```jsx live=Review
11
+ import '@awesome.me/webawesome/dist/styles/themes/default.css'
12
+ import '@awesome.me/webawesome/dist/components/rating/rating.js'
13
+ import '@awesome.me/webawesome/dist/components/input/input.js'
14
+
15
+ export function Review({ state }) {
16
+ return (
17
+ <div>
18
+ <wa-rating className="food" label="Food" value={state.food} />
19
+ <wa-input className="comment" label="Comment" value={state.comment} />
20
+ <p>{state.food} stars</p>
21
+ </div>
22
+ )
23
+ }
24
+
25
+ Review.initialState = { food: 3, comment: '' }
26
+
27
+ Review.intent = ({ DOM }) => ({
28
+ FOOD: DOM.select('.food').events('change').value(Number),
29
+ COMMENT: DOM.select('.comment').events('input').value(),
30
+ })
31
+
32
+ Review.model = {
33
+ FOOD: (state, food) => ({ ...state, food }),
34
+ COMMENT: (state, comment) => ({ ...state, comment }),
35
+ }
36
+ ```
37
+
38
+ Web Awesome's inputs fire the standard `change` and `input` events and keep their value in a `value` property, so `.value()` reads them as it reads an `<input>`. Pointer, touch and keyboard input all arrive this way; the element handles them inside its shadow root.
39
+
40
+ ### Properties and attributes
41
+
42
+ A JSX prop sets the element's **property** of that name: `value={state.food}` sets `rating.value` to the number, `readonly={state.locked}` sets a boolean property. That is what Lit-based libraries expect, and it keeps numbers, booleans, arrays and objects intact.
43
+
44
+ - **camelCase properties.** A library's `with-clear` attribute is its `withClear` property: write `withClear` (`<wa-input withClear />`). A dashed JSX prop (`with-clear`) sets a property literally named `with-clear`, which the element ignores.
45
+ - **Attributes.** For the few things that must be attributes (a `size` the library styles by, an attribute selector in your CSS, the [`name` of some form fields](#forms)), use `attrs`: `<wa-rating attrs={{ size: 'l' }} />`.
46
+ - **Load order.** A tag rendered before its library has defined it keeps the props Sygnal set, and the element takes them over when it upgrades. Defining the elements at startup, before `run()`, is still simplest.
47
+
48
+ ### Events
49
+
50
+ Library events cross the shadow boundary and bubble to the host (they are `composed`), so the intent selects them on the element with any name, and `.detail()` reads their payload:
51
+
52
+ ```jsx
53
+ Review.intent = ({ DOM }) => ({
54
+ HOVER: DOM.select('.food').events('wa-hover').detail((hover) => hover.value),
55
+ ACTION: DOM.select('.actions').events('wa-select').detail((selected) => selected.item.value),
56
+ })
57
+ ```
58
+
59
+ `.detail(fn?)` maps each event to `event.detail` (or `fn(event.detail)`), next to `.value()`, `.checked()` and `.data()`. A custom event name type-checks in TypeScript (`events()` takes any string). The shorthand form `DOM['wa-hover']('.food')` works too, but `DOM.select(…).events(…)` is the one to prefer for custom names.
60
+
61
+ Events reach only the component that renders the element, as for any element: a `<wa-rating>` in each [Collection](https://sygnal.js.org/guide/collections/) item reports to its own item.
62
+
63
+ ### Forms
64
+
65
+ Web Awesome's fields are form-associated: inside a `<form>`, they submit their value under their `name`, and [`processForm`](https://sygnal.js.org/reference/api/#processform) on `submit` sees them as it sees an `<input>`.
66
+
67
+ ```jsx
68
+ export function Signup({ state }) {
69
+ return (
70
+ <form className="signup">
71
+ <wa-input name="email" label="Email" value={state.email} />
72
+ <wa-rating attrs={{ name: 'stars' }} label="Stars" value={state.stars} />
73
+ <button type="submit">Sign up</button>
74
+ </form>
75
+ )
76
+ }
77
+
78
+ Signup.initialState = { email: '', stars: 3 }
79
+
80
+ Signup.intent = ({ DOM }) => ({
81
+ SUBMIT: processForm(DOM.select('.signup'), { events: 'submit' }),
82
+ })
83
+
84
+ Signup.model = {
85
+ SUBMIT: (state, { email, stars }) => ({ ...state, email, stars: Number(stars) }),
86
+ }
87
+ ```
88
+
89
+ - **`name` as an attribute.** The form reads a custom element's `name` attribute. Some elements don't reflect the `name` property to the attribute (`wa-rating` doesn't, `wa-input` does), so pass it with `attrs={{ name: 'stars' }}` when in doubt.
90
+ - **`form` is always an attribute.** `<wa-input form="signup">` links a field outside the `<form>` to `<form id="signup">`, as it does for an `<input>`: a form-associated element's `form` property is read-only. (`list`, by contrast, is a custom element's own property: it is set as one.)
91
+ - **Read live values from the element.** A form-associated element updates its form value asynchronously. On every keystroke, `.value()` on its `input` event is current in every browser, while the form's own data can be one keystroke behind (Firefox). Use `processForm` on `submit`, and `.value()` for live input.
92
+
93
+ ### TypeScript
94
+
95
+ Web Awesome, like most Lit libraries, adds its elements to `HTMLElementTagNameMap` (`'wa-rating': WaRating`). One augmentation types every tag of a library's prefix in JSX, with the element's own property types:
96
+
97
+ ```tsx
98
+ // web-awesome.d.ts
99
+ import type { IntrinsicControlProps } from 'sygnal'
100
+
101
+ type WaTags = {
102
+ [K in keyof HTMLElementTagNameMap as K extends `wa-${string}` ? K : never]: IntrinsicControlProps<K>
103
+ }
104
+
105
+ declare global {
106
+ namespace JSX {
107
+ interface IntrinsicElements extends WaTags {}
108
+ }
109
+ }
110
+ ```
111
+
112
+ Then `<wa-rating value="3" />` is an error (the property is a number), and so are unknown props, methods and read-only properties. Function-valued properties are left out of the JSX props: set them with `props={{ getSymbol }}`. Event payloads are typed where you read them: `.detail<{ value: number }>()`.
113
+
114
+ ### Server rendering
115
+
116
+ `renderToString` writes a custom element's props as attributes, the form its library reads when the element upgrades in the browser: `true` as a bare attribute, `false` and `null` left out. A camelCase HTML property gets its attribute (`tabIndex` → `tabindex`, `readOnly` → `readonly`, `ariaLabel` → `aria-label`); any other camelCase name is written both in kebab-case and in lowercase (`withClear` → `with-clear withclear`), since libraries differ: Web Awesome, Shoelace, Stencil and `defineElement` read `with-clear`, Lit's and FAST's default is `withclear`. The element reads the one it knows. To write one exact attribute, pass it in `attrs`. Function and object props have no attribute form and are left out; they are set on the client, when `run()` renders. Sygnal doesn't render the element's shadow DOM on the server (no declarative shadow DOM): the element renders itself once its library loads.
117
+
118
+ ### As controls
119
+
120
+ Custom-element tags also work as [controls](https://sygnal.js.org/guide/controls/), the alternative form that names elements by identifier: `const { Rating } = controls({ Rating: 'wa-rating' })`, then `<Rating label="Service" value={state.service} />` and `DOM.select(Rating).events('wa-hover').detail()`. With the [TypeScript augmentation](#typescript), a control made from a tag is typed from the element without anything more.
121
+
122
+ ## Publishing a component as a custom element
123
+
124
+ `defineElement` from `sygnal/element` goes the other way: it publishes a Sygnal component as a custom element, for plain HTML pages, other frameworks, or another Sygnal app.
125
+
126
+ ```jsx
127
+ // score-card.js
128
+ import { defineElement } from 'sygnal/element'
129
+
130
+ function ScoreCard({ state }) {
131
+ return (
132
+ <div className="card">
133
+ <strong>{state.title}</strong>
134
+ <wa-rating className="score" label={state.title} value={state.score} />
135
+ </div>
136
+ )
137
+ }
138
+
139
+ ScoreCard.initialState = { title: 'Untitled', score: 0 }
140
+ ScoreCard.intent = ({ DOM }) => ({ SCORE: DOM.select('.score').events('change').value(Number) })
141
+ ScoreCard.model = {
142
+ SCORE: {
143
+ STATE: (state, score) => ({ ...state, score }),
144
+ PARENT: (state, score) => ({ score }),
145
+ },
146
+ }
147
+
148
+ defineElement('score-card', ScoreCard, {
149
+ props: { title: String, score: Number },
150
+ events: { PARENT: 'score-change' },
151
+ })
152
+ ```
153
+
154
+ ```html
155
+ <score-card title="Pasta" score="3"></score-card>
156
+ <script>
157
+ document.querySelector('score-card').addEventListener('score-change', (e) => console.log(e.detail.score))
158
+ </script>
159
+ ```
160
+
161
+ - **`props`** feed the component's state. Each prop is a property (`card.score = 4`) and a kebab-case attribute (`score="4"`, `due-date` for `dueDate`); attributes are parsed to the declared type.
162
+ - **`events`** map a sink to a DOM event: a value the component sends on `PARENT` is dispatched as a bubbling, composed `CustomEvent` named `score-change`, with the value as its `detail`.
163
+ - **`shadow`** (`true`, `'open'` or `'closed'`) renders into a shadow root, and **`styles`** are adopted into it.
164
+
165
+ Each element runs the component as its own app. In another Sygnal app it is a web component like any other: `<score-card className="pasta" title="Pasta" score={state.pasta} />`, and `DOM.select('.pasta').events('score-change').detail((d) => d.score)`.
166
+
167
+ ## defineWidget or defineElement?
168
+
169
+ | | [`defineWidget`](./widgets.md) | `defineElement` (`sygnal/element`) |
170
+ |---|---|---|
171
+ | Direction | A third-party widget **into** a Sygnal view | A Sygnal component **out**, as a custom element |
172
+ | Wraps | A framework-agnostic library (flatpickr, Chart.js, Tiptap) | A Sygnal component (view, intent, model) |
173
+ | Result | A JSX tag rendering a host element, selected by class | A custom element tag, usable from any HTML |
174
+ | State | None: props in, `dispatch` out | The component's own state, fed by props |
175
+ | Events | `dispatch(name, detail)` → `CustomEvent` on the host | A sink value → `CustomEvent` on the element |
176
+ | Methods | `commands`, sent with `ELEMENT` | The element's props as properties |
177
+ | Used by | One app's views | Any page or framework |
178
+
179
+ A web component library you only use (Web Awesome) needs neither: render its tags.