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,255 @@
1
+ <!-- Generated from docs/src/content/docs/integration by scripts/copy-guides.mjs; online: https://sygnal.js.org/integration/ -->
2
+ # Server-Side Rendering
3
+
4
+ `renderToString()` renders a Sygnal component to an HTML string without a browser DOM. Use it for server-rendered pages, static site generation, or any environment where you need HTML output from your components.
5
+
6
+ ```ts
7
+ import { renderToString } from 'sygnal'
8
+
9
+ const html = renderToString(App, { state: { count: 0 } })
10
+ // → '<div class="counter" data-sygnal-ssr=""><h1>Count: 0</h1><button>+</button></div>'
11
+ ```
12
+
13
+ The root element carries an empty `data-sygnal-ssr` attribute. It marks the markup as Sygnal's server HTML, so the client can tell it from other content in the mount point (a [persisted](./persistence.md#server-rendering-hydrate) app restores its saved state after the first render instead of before it). The first client render removes it.
14
+
15
+ ## Basic Usage
16
+
17
+ Pass a component function and optional state:
18
+
19
+ ```jsx
20
+ function Greeting({ state }) {
21
+ return <div className="greeting">Hello, {state.name}!</div>
22
+ }
23
+
24
+ Greeting.initialState = { name: 'World' }
25
+
26
+ // Uses component's initialState
27
+ renderToString(Greeting)
28
+ // → '<div class="greeting" data-sygnal-ssr="">Hello, World!</div>'
29
+
30
+ // Override state
31
+ renderToString(Greeting, { state: { name: 'Alice' } })
32
+ // → '<div class="greeting" data-sygnal-ssr="">Hello, Alice!</div>'
33
+ ```
34
+
35
+ ## Sub-Components
36
+
37
+ Sub-components are rendered recursively. State lensing works the same as in the browser — pass `state="propName"` to scope child state:
38
+
39
+ ```jsx
40
+ function App({ state }) {
41
+ return (
42
+ <div>
43
+ <Header state="header" />
44
+ <Content state="content" />
45
+ </div>
46
+ )
47
+ }
48
+
49
+ App.initialState = {
50
+ header: { title: 'My App' },
51
+ content: { body: 'Welcome' },
52
+ }
53
+
54
+ renderToString(App)
55
+ // Renders both Header and Content with their scoped state
56
+ ```
57
+
58
+ ## Collections
59
+
60
+ Collections render each item from the state array:
61
+
62
+ ```jsx
63
+ function TodoItem({ state }) {
64
+ return <li>{state.text}</li>
65
+ }
66
+
67
+ function TodoList({ state }) {
68
+ return (
69
+ <ul>
70
+ <Collection of={TodoItem} from="items" />
71
+ </ul>
72
+ )
73
+ }
74
+
75
+ TodoList.initialState = {
76
+ items: [
77
+ { id: 1, text: 'Buy milk' },
78
+ { id: 2, text: 'Write docs' },
79
+ ],
80
+ }
81
+
82
+ renderToString(TodoList)
83
+ // → '<ul data-sygnal-ssr=""><div><li>Buy milk</li><li>Write docs</li></div></ul>'
84
+ ```
85
+
86
+ ## Context
87
+
88
+ Component context is computed from state and propagated to descendants:
89
+
90
+ ```jsx
91
+ function App({ state, context }) {
92
+ return <div className={`theme-${context.theme}`}>{state.label}</div>
93
+ }
94
+
95
+ App.initialState = { label: 'Hello', darkMode: true }
96
+ App.context = { theme: (state) => state.darkMode ? 'dark' : 'light' }
97
+
98
+ renderToString(App)
99
+ // → '<div class="theme-dark" data-sygnal-ssr="">Hello</div>'
100
+ ```
101
+
102
+ ## Error Boundaries
103
+
104
+ Error boundaries work during SSR. Components with `onError` render fallback content; those without render an empty `<div data-sygnal-error>`:
105
+
106
+ ```jsx
107
+ function Fragile({ state }) {
108
+ throw new Error('Oops')
109
+ }
110
+
111
+ Fragile.onError = (err, { componentName }) => (
112
+ <div className="error">Something went wrong in {componentName}</div>
113
+ )
114
+
115
+ renderToString(Fragile)
116
+ // → '<div class="error" data-sygnal-ssr="">Something went wrong in Fragile</div>'
117
+ ```
118
+
119
+ To report these errors, pass the [app-level error hook](./error-boundaries.md#app-level-error-hook): `renderToString(App, { onError })`. It is called with the phase `'view'`, after the boundary picked the fallback.
120
+
121
+ ## Special Components
122
+
123
+ | Component | SSR Behavior |
124
+ |-----------|-------------|
125
+ | **Portal** | Children rendered inline (no target container on server) |
126
+ | **Transition** | Unwrapped to child element (no animation) |
127
+ | **Suspense** | Always renders children (not fallback) |
128
+ | **Slot** | Unwrapped to children |
129
+ | **Collection** | Items rendered from state array |
130
+ | **Switchable** | Active component rendered based on state |
131
+
132
+ ## Client Hydration
133
+
134
+ Embed serialized state in a `<script>` tag for client-side rehydration:
135
+
136
+ ```jsx
137
+ const html = renderToString(App, {
138
+ state: { count: 5 },
139
+ hydrateState: true,
140
+ })
141
+ // Appends: <script>window.__SYGNAL_STATE__={"count":5}</script>
142
+ ```
143
+
144
+ Use a custom variable name:
145
+
146
+ ```jsx
147
+ renderToString(App, {
148
+ state: { count: 5 },
149
+ hydrateState: '__MY_APP_STATE__',
150
+ })
151
+ // Appends: <script>window.__MY_APP_STATE__={"count":5}</script>
152
+ ```
153
+
154
+ :::caution[Multiple apps on one page]
155
+ If you render more than one Sygnal app on the same page, use unique variable names to avoid collisions:
156
+
157
+ ```jsx
158
+ renderToString(Header, { state: headerState, hydrateState: '__HEADER_STATE__' })
159
+ renderToString(Sidebar, { state: sidebarState, hydrateState: '__SIDEBAR_STATE__' })
160
+ ```
161
+
162
+ `hydrateState: true` writes to `window.__SYGNAL_STATE__`, which is fine for single-app pages but will collide if used twice. This does not apply to Astro — each island hydrates independently through Astro's own mechanism.
163
+ :::
164
+
165
+ On the client, read the embedded state to hydrate:
166
+
167
+ ```jsx
168
+ import { run } from 'sygnal'
169
+
170
+ const initialState = window.__SYGNAL_STATE__ || App.initialState
171
+
172
+ run(App, '#app', { initialState })
173
+ ```
174
+
175
+ ### What the first client render keeps
176
+
177
+ `run()` doesn't clear the mount point. Its first render patches the server's markup in place and **adopts** each element that matches what the client renders at the same position (the same tag; keyed children such as component roots and `Collection` items match by key). An adopted element stays the same DOM node, so everything the user did before the app started is kept: the focus, text typed into an uncontrolled field, a checked box, the scroll position. The same applies to [Astro islands](https://sygnal.js.org/integration/astro/) and [Vike pages](https://sygnal.js.org/integration/vike/), which both hydrate this way.
178
+
179
+ | Server markup | First client render |
180
+ |---|---|
181
+ | An element the client renders with the same tag | Adopted: the same node. Its attributes become the client's: the ones the client also renders keep the server's value (nothing is written again, so an `iframe` or `img` doesn't reload), and attributes the client doesn't render are removed |
182
+ | `class` and `id` | Kept when the client's are the same, changed or removed when they aren't |
183
+ | `style` | Written again from the client's `style`: a declaration only the server wrote goes |
184
+ | `data-*` | Kept when the client renders them (as `data-*` props or in `attrs`), removed when it doesn't; `data-sygnal-ssr` (the root's marker) goes |
185
+ | `open` on a `<details>` or `<dialog>` the client renders without `open` | Kept: the user may have opened it before start-up. When the client renders `open` (`open={isOpen}`, also `false`), its value wins. A conditional spread (`{...(isOpen && { open: true })}`) renders no `open` at all when closed, so a server `open` stays: write `open={isOpen}` to control it |
186
+ | A text node | Kept, its text set to the client's. Text next to text in the client's view (`Hello, {name}!`: three text nodes) is one text node in the server's HTML: it is split where the client's first text ends, when it starts with that text, so the elements after it are still adopted |
187
+ | A `<textarea>`'s text (`renderToString` writes its `value` as text) | The text node goes; the field keeps its value (the server's text, or what the user typed) |
188
+ | A component that returns a fragment (`<>…</>`), or a `false` / `null` child (`{cond && <X />}`) | A fragment's elements are adopted one by one, as its parent's own children; a `false` / `null` child takes no element: the elements after them are still adopted. Later renders update, add, remove and move them as on a client-only page |
189
+ | Whitespace and comments where the client renders no text (a page template's indentation) | Removed |
190
+ | An element with another tag, or text where the client renders an element | Replaced in place by the client's element; the elements around it are still adopted |
191
+ | An element whose vnode has an `insert` hook: a `<Transition>`'s child (its enter runs), a `<VirtualCollection>` row (measured when inserted), the `<Toaster>` region, a `lazy()` placeholder, an element with your own `hook={{ insert }}` (also with a `postpatch`, a `ref` or `autoFocus`) | Made again in place, as a fresh render makes it: its `insert` runs |
192
+ | An element with a `create` or `init` hook (a `thunk`) | Made again in place; its hooks run |
193
+ | An element whose hyperscript selector has a class or id (`h('p.card')`; JSX never makes one), and a `<Portal>`'s placeholder | Made again in place (patching never changes a selector's class or id) |
194
+ | An element with a `ref`, `autoFocus`, a widget, a `<VirtualCollection>` container, or your own hook without `insert` | Adopted: `ref` points at the server's element, `autoFocus` focuses it, a widget mounts on it. A hook's `postpatch` runs |
195
+ | A `<Portal>`'s content (the server renders it inline) | Replaced by the Portal's placeholder; the content renders in the target |
196
+ | A custom element that builds its own light DOM (children the client doesn't render), already upgraded before start-up | Adopted, but those children are removed (they aren't the client's) and its `connectedCallback` doesn't run again. Wrap such an element in [`<ClientOnly>`](https://sygnal.js.org/integration/vike/#clientonly) (`sygnal/vike/ClientOnly`) so the client makes it |
197
+ | The mount point's own attributes (`<div id="app" class="shell" data-theme="dark">`) | Kept: they aren't the app's (when the app's root element is the mount point itself, its props are written over them) |
198
+
199
+ Form fields follow the usual rule for [controlled fields](./inputs.md#controlled-inputs): a field with a `value` (or `checked`) prop shows the state, so the client's value replaces what the user typed or picked before start-up (an `input`, a `textarea` and a `select` alike); a field without one keeps it.
200
+
201
+ When the server's markup differs from what the client renders (other state, a mismatched template), the page ends up as a fresh client render would make it. This also holds when the mount point holds something else before start-up (a loading spinner): it is patched into the app. The exceptions are the user's changes listed above: an uncontrolled field's value, an `open` the client doesn't render.
202
+
203
+ ## Stable ids: uid
204
+
205
+ [`uid()`](./inputs.md#labels-and-ids-uid) ids come from each component's position in the tree, not from a counter, so `renderToString` and the client produce the same ids and hydration keeps the server's `for` / `id` pairs. Both start from the root `u`. When a page has more than one app, give each its own root, and the same one on both sides:
206
+
207
+ ```jsx
208
+ // server
209
+ const html = renderToString(Signup, { uid: 'signup' })
210
+
211
+ // client, hydrating that markup
212
+ run(Signup, {}, { mountPoint: '#signup', uid: 'signup' })
213
+ ```
214
+
215
+ The ids then start with `signup-` (`signup-email`). In [Astro](https://sygnal.js.org/integration/astro/#props), pass a `uid` prop to the island; in [Vike](https://sygnal.js.org/integration/vike/), the Page, Layouts and Wrappers get matching ids on both sides without any option.
216
+
217
+ ## Astro Integration
218
+
219
+ The Astro server renderer uses `renderToString` internally. When using the Sygnal Astro integration, SSR happens automatically:
220
+
221
+ ```astro
222
+ ---
223
+ import Counter from '../components/Counter.jsx'
224
+ ---
225
+ <Counter client:load />
226
+ ```
227
+
228
+ ## API
229
+
230
+ ```typescript
231
+ function renderToString(
232
+ component: ComponentFunction,
233
+ options?: RenderToStringOptions
234
+ ): string
235
+ ```
236
+
237
+ ### RenderToStringOptions
238
+
239
+ | Option | Type | Default | Description |
240
+ |--------|------|---------|-------------|
241
+ | `state` | `any` | Component's `.initialState` | State for the root component |
242
+ | `props` | `Record<string, any>` | `{}` | Props to pass to the component |
243
+ | `context` | `Record<string, any>` | `{}` | Parent context to merge with |
244
+ | `hydrateState` | `boolean \| string` | — | Embed state in `<script>` tag |
245
+ | `head` | `any[]` | — | Receives each rendered component's `head` static ([HEAD driver](https://sygnal.js.org/guide/head/)) |
246
+ | `cache` | `QueryCache` | — | A seeded `queryCache()`: `resources` found in it render as `'success'` with their data, others as `'loading'` ([Server rendering](./resources.md#server-rendering)) |
247
+ | `onError` | `(error, info) => void` | — | [App-level error hook](./error-boundaries.md#app-level-error-hook), called with the phase `'view'` |
248
+ | `uid` | `string` | `'u'` | The root of the [`uid()`](#stable-ids-uid) ids; use the same value as `run()`'s `uid` on the client |
249
+
250
+ ## Limitations
251
+
252
+ - **Intent and Model are skipped** — SSR is render-only. Event handlers, streams, and state reducers don't run on the server.
253
+ - **No requests** — drivers don't run, so `resources` render as `'loading'` unless the `cache` option has them; seed it in a loader with `cache.set(request, data)` and send `cache.dehydrate()` to the client ([Server rendering](./resources.md#server-rendering)).
254
+ - **Refs are not populated** — No DOM exists, so `createRef()` objects remain `{ current: null }`.
255
+ - **Lazy components** — `lazy()` wrappers render their loading placeholder. For SSR, import components directly instead.
@@ -0,0 +1,178 @@
1
+ <!-- Generated from docs/src/content/docs/guide by scripts/copy-guides.mjs; online: https://sygnal.js.org/guide/ -->
2
+ # Timers
3
+
4
+ A component declares the timers it needs as a function of its state, and gets each tick as one of its own actions. There is no `setInterval` to start or clear: while `state.running` is true the tick runs, and when it turns false the timer stops.
5
+
6
+ ```jsx
7
+ Stopwatch.timers = (state) => ({
8
+ tick: state.running && { every: 100, action: 'TICK' },
9
+ })
10
+ ```
11
+
12
+ ## Setup
13
+
14
+ Timers are run by `makeTimerDriver()`, which you register when you start the app:
15
+
16
+ ```jsx
17
+ import { run, makeTimerDriver } from 'sygnal'
18
+ import App from './App.jsx'
19
+
20
+ run(App, { TIMER: makeTimerDriver() })
21
+ ```
22
+
23
+ The key is yours to choose (`TIMER` by convention): Sygnal finds the driver by the `timers` static it takes, not by its name. One driver serves every component of the app. A component that declares `timers` in an app without the driver gets [SYG643](https://sygnal.js.org/reference/errors/#syg643) in development, since nothing would ever fire.
24
+
25
+ ## The timers static
26
+
27
+ `timers` takes the state (with its calculated fields) and returns an object of named timers. Each value is a timer spec, or a falsy value for "not now":
28
+
29
+ ```jsx
30
+ Game.timers = (state) => ({
31
+ tick: state.running && { every: 100, action: 'TICK' },
32
+ done: state.armed && { after: 5000, action: 'EXPIRE' },
33
+ frame: state.animating && { frame: 'FRAME' },
34
+ })
35
+ ```
36
+
37
+ | Spec | Runs | Action data |
38
+ |---|---|---|
39
+ | `{ every: ms, action }` | Every `ms` milliseconds (a positive number) | `{ n, t }`: the tick number from 1, and `Date.now()` |
40
+ | `{ after: ms, action }` | Once, `ms` milliseconds (0 or more) after it is declared | `{ t }`: `Date.now()` |
41
+ | `{ frame: 'ACTION' }` | Every animation frame (`requestAnimationFrame`; every 16 ms where there is none) | `{ t, dt }`: `Date.now()`, and the milliseconds since the previous frame (0 on the first) |
42
+
43
+ Any spec can add `background: true` (see [hidden pages](#hidden-pages)). The action is one of the component's own actions: it goes to the instance that declared the timer, and every sink of its model entry runs, as for an intent action.
44
+
45
+ Sygnal compares the result with the previous one, name by name, whenever the state changes:
46
+
47
+ - a new name starts its timer;
48
+ - a name that is gone, or now falsy, stops its timer;
49
+ - a spec that changed (another interval, another action) restarts its timer;
50
+ - a spec that is the same as before keeps running, even though the function returned a new object.
51
+
52
+ So `timers` is a description, not a command: return what should be running now. An `after` timer fires once; it doesn't fire again while the same spec stays declared. Make it falsy (and then declared again) to arm it once more.
53
+
54
+ ### Ticks don't drift
55
+
56
+ An `every` timer schedules each tick from the moment it started, not from the previous tick: tick `n` is due at start + n × `every`. A late tick doesn't push the later ones back, so 1,000 ticks of 100 ms end 100 s after the start. Use the `t` in the data (or `Date.now()` in the intent) for elapsed time rather than counting ticks.
57
+
58
+ When a tick comes very late (a browser throttles timers in a background tab), the missed ticks are not delivered one by one: they are coalesced into the next one, and `n` jumps over them.
59
+
60
+ ## A stopwatch
61
+
62
+ The stopwatch keeps the clock time it started at and adds up the finished runs, so it is exact however late a tick comes. The tick only moves `now`, which the view reads. It stops when the stopwatch pauses, resets or is removed:
63
+
64
+ ```jsx live
65
+ const pad = (n) => String(n).padStart(2, '0')
66
+ const format = (ms) => {
67
+ const tenths = Math.floor(ms / 100)
68
+ return `${pad(Math.floor(tenths / 600))}:${pad(Math.floor(tenths / 10) % 60)}.${tenths % 10}`
69
+ }
70
+
71
+ // the running time at clock time `at`: the finished runs plus the current one
72
+ const elapsed = (state, at) => state.done + (state.status === 'running' ? at - state.since : 0)
73
+
74
+ export function Stopwatch({ state }) {
75
+ const label = { idle: 'Start', running: 'Pause', paused: 'Resume' }[state.status]
76
+ return (
77
+ <section className="stopwatch">
78
+ <p className="time">{format(elapsed(state, state.now))}</p>
79
+ <button className="toggle">{label}</button>
80
+ <button className="lap" disabled={state.status !== 'running'}>Lap</button>
81
+ <button className="reset" disabled={state.status !== 'paused'}>Reset</button>
82
+ <ol className="laps">
83
+ {state.laps.map((lap, i) => <li>{`Lap ${i + 1}: ${format(lap)}`}</li>)}
84
+ </ol>
85
+ </section>
86
+ )
87
+ }
88
+
89
+ const INITIAL = { status: 'idle', done: 0, since: 0, now: 0, lapStart: 0, laps: [] }
90
+
91
+ Stopwatch.initialState = INITIAL
92
+ // a tick every 100 ms while running; stopped when it pauses, resets or unmounts
93
+ Stopwatch.timers = (state) => ({ tick: state.status === 'running' && { every: 100, action: 'TICK' } })
94
+ Stopwatch.intent = ({ DOM }) => ({
95
+ TOGGLE: DOM.click('.toggle').map(() => Date.now()),
96
+ LAP: DOM.click('.lap').map(() => Date.now()),
97
+ RESET: DOM.click('.reset'),
98
+ })
99
+ Stopwatch.model = {
100
+ TOGGLE: (state, at) => state.status === 'running'
101
+ ? { ...state, status: 'paused', done: elapsed(state, at), now: at }
102
+ : { ...state, status: 'running', since: at, now: at },
103
+ TICK: (state, { t }) => ({ ...state, now: t }),
104
+ LAP: (state, at) => {
105
+ const total = elapsed(state, at)
106
+ return { ...state, now: at, laps: [...state.laps, total - state.lapStart], lapStart: total }
107
+ },
108
+ RESET: () => INITIAL,
109
+ }
110
+ ```
111
+
112
+ ## Hidden pages
113
+
114
+ A component on a hidden [Switchable](https://sygnal.js.org/guide/switchable/) page keeps its state, but its timers stop while the page is hidden. When the page is shown again they start from scratch: an `every` timer counts from 1 again, and an `after` timer waits its whole delay. A timer that should go on in the background says so:
115
+
116
+ ```jsx
117
+ Inbox.timers = () => ({
118
+ // keeps checking for new mail while another page is shown
119
+ poll: { every: 30000, action: 'CHECK_MAIL', background: true },
120
+ })
121
+ ```
122
+
123
+ When a component is removed, its timers stop; when the app is disposed, all of them do.
124
+
125
+ ## Server rendering
126
+
127
+ Drivers don't run during `renderToString`, so no timer starts on the server. The view renders the initial state, and the timers start in the browser after hydration.
128
+
129
+ ## Testing
130
+
131
+ `renderComponent` runs the real timer driver for you (no `drivers` option needed), on whatever clock the test uses. With Vitest's fake timers, `vi.advanceTimersByTimeAsync(ms)` moves time forward and the timers fire as they would. `t.timers()` lists the timers running now, each as its spec plus `name`, `action` and `component`:
132
+
133
+ ```jsx
134
+ // @vitest-environment jsdom
135
+ import { it, expect, afterEach, vi } from 'vitest'
136
+ import { renderComponent } from 'sygnal'
137
+ import { Stopwatch } from './Stopwatch.jsx'
138
+
139
+ let t
140
+ afterEach(() => {
141
+ t?.dispose()
142
+ vi.useRealTimers()
143
+ })
144
+
145
+ it('ticks while running and stops when paused', async () => {
146
+ vi.useFakeTimers()
147
+ t = renderComponent(Stopwatch)
148
+ await t.ready()
149
+ expect(t.timers()).toEqual([])
150
+
151
+ t.simulateEvent('.toggle', 'click')
152
+ await t.next(s => s.status === 'running')
153
+ expect(t.timers()).toEqual([{ name: 'tick', every: 100, action: 'TICK', component: 'Stopwatch' }])
154
+
155
+ await vi.advanceTimersByTimeAsync(1000)
156
+ expect(t.state.now - t.state.since).toBe(1000)
157
+
158
+ t.simulateEvent('.toggle', 'click')
159
+ await t.next(s => s.status === 'paused')
160
+ expect(t.timers()).toEqual([])
161
+ })
162
+ ```
163
+
164
+ - The fake is registered as the `TIMER` sink (`timerSink: 'CLOCK'` gives it another name, when a test needs `TIMER` for something else). If the test passes its own `makeTimerDriver()` in `drivers`, the fake stands down, so no timer runs twice; `t.timers()` then lists nothing, and throws when that driver is under the fake's name.
165
+ - `t.actions` lists each tick with its data (`t.actions.filter(a => a.type === 'TICK')`), so a test can check the `{ n, t }` values.
166
+ - `vi.getTimerCount()` is 0 after `t.dispose()`: removing a component stops its timers.
167
+ - A `frame` timer uses `requestAnimationFrame` when the environment has one, and a 16 ms timeout otherwise (as in jsdom), so fake timers drive it too.
168
+
169
+ See [Fake timers](https://sygnal.js.org/integration/testing/#fake-timers) for how the waits (`next()`, `settle()`) behave on a fake clock.
170
+
171
+ ## Diagnostics
172
+
173
+ | Code | When |
174
+ |---|---|
175
+ | [SYG422](https://sygnal.js.org/reference/errors/#syg422) (error) | A spec the driver can't run: a zero or negative `every`, a negative `after`, no `action`, a `frame` that isn't an action name. That timer is not started; the others are |
176
+ | [SYG643](https://sygnal.js.org/reference/errors/#syg643) (warning) | The component declares `timers`, but no `makeTimerDriver()` is registered. `sygnal-check` reports it too when it can read the app's `run()` call |
177
+
178
+ To stop a timer, return a falsy value for it, not an invalid spec.
@@ -0,0 +1,93 @@
1
+ <!-- Generated from docs/src/content/docs/ui by scripts/copy-guides.mjs; online: https://sygnal.js.org/ui/ -->
2
+ # Accordion
3
+
4
+ `accordion()` is a [behavior](../behaviors.md) for a list of headers that each show and hide a panel, following the [WAI-ARIA accordion pattern](https://www.w3.org/WAI/ARIA/apg/patterns/accordion/). You render the header buttons and the panels; `accordionAttrs()` gives them `aria-expanded`, the ids that link each button to its panel, and `hidden` on the closed panels.
5
+
6
+ ```jsx live
7
+ import { accordion, accordionAttrs } from 'sygnal/ui'
8
+
9
+ const FAQ = [
10
+ { id: 'shipping', question: 'How long does shipping take?', answer: 'Two to four working days.' },
11
+ { id: 'returns', question: 'Can I return an item?', answer: 'Within 30 days, unused.' },
12
+ ]
13
+
14
+ function Faq({ state, uid }) {
15
+ const a = accordionAttrs(state.faq, uid)
16
+ return (
17
+ <div className="faq">
18
+ {FAQ.map((item) => (
19
+ <div className="faq-item">
20
+ <h3><button className="faq-question" {...a.trigger(item.id)}>{item.question}</button></h3>
21
+ <div className="faq-answer" {...a.panel(item.id)}><p>{item.answer}</p></div>
22
+ </div>
23
+ ))}
24
+ </div>
25
+ )
26
+ }
27
+
28
+ Faq.uses = { faq: accordion({ trigger: '.faq-question' }) }
29
+ ```
30
+
31
+ Wrap each button in a heading of the level that fits the page, so the headers are in the page outline.
32
+
33
+ ## Options
34
+
35
+ | Option | Default | |
36
+ |---|---|---|
37
+ | `trigger` | (required) | The header buttons: a selector or a [control](https://sygnal.js.org/guide/controls/) |
38
+ | `multiple` | `false` | Several panels open at once; without it, opening one closes the others |
39
+ | `collapsible` | `true` | `false`: the last open panel can't be closed (its button gets `aria-disabled`) |
40
+ | `expanded` | `[]` | The panels open at the start: a value or an array |
41
+ | `loop` | `true` | Down on the last header goes to the first |
42
+ | `id` | the key in `uses` | The prefix of the ids |
43
+
44
+ ## Keyboard
45
+
46
+ | Key | |
47
+ |---|---|
48
+ | Enter / Space | Opens or closes the focused header's panel |
49
+ | Down / Up | The next or previous header |
50
+ | Home / End | The first or last header |
51
+ | Tab | The next focusable element, through the open panels |
52
+
53
+ ## State, actions and attributes
54
+
55
+ `state.faq` is `{ id, expanded, collapsible }`: `expanded` lists the open panels' values. Actions: `faq.TOGGLE`, `faq.EXPAND` and `faq.COLLAPSE` take a value (a click on a header toggles its own), `faq.MOVE` focuses a header. Open a panel from your model with `next('faq.EXPAND', 'returns')`.
56
+
57
+ `accordionAttrs(state.faq, uid)` returns:
58
+
59
+ | | Attributes |
60
+ |---|---|
61
+ | `trigger(value)` | `id`, `aria-expanded`, `aria-controls`, `aria-disabled` (only on the open panel that can't close), `type="button"`, `data-value`, `data-state` (`open` / `closed`) |
62
+ | `panel(value)` | `id`, `role="region"`, `aria-labelledby`, `hidden` unless open, `data-state` |
63
+
64
+ ## Styling
65
+
66
+ ```css live
67
+ .faq-question { width: 100%; text-align: start; }
68
+ .faq-question[data-state='open']::after { content: '−'; }
69
+ .faq-question[data-state='closed']::after { content: '+'; }
70
+ ```
71
+
72
+ ## Testing
73
+
74
+ ```jsx
75
+ import { it, expect } from 'vitest'
76
+ import { renderComponent } from 'sygnal'
77
+ import Faq from './Faq.jsx'
78
+
79
+ it('opens one answer at a time', async () => {
80
+ const t = renderComponent(Faq)
81
+ await t.ready()
82
+ t.simulateEvent('.faq-question', 'click', { data: { value: 'shipping' } })
83
+ await t.next((s) => s.faq.expanded.includes('shipping'))
84
+ t.simulateAction('faq.TOGGLE', 'returns')
85
+ await t.next((s) => s.faq.expanded.includes('returns'))
86
+ expect(t.state.faq.expanded).toEqual(['returns'])
87
+ expect(t.query('.faq-question[data-value="returns"]').getAttribute('aria-expanded')).toBe('true')
88
+ expect(t.actions.map((a) => a.type)).toEqual(['INITIALIZE', 'faq.TOGGLE', 'faq.TOGGLE'])
89
+ t.dispose()
90
+ })
91
+ ```
92
+
93
+ The arrow keys read the order of the headers on the page: test them with `dom: 'real'`.
@@ -0,0 +1,144 @@
1
+ <!-- Generated from docs/src/content/docs/ui by scripts/copy-guides.mjs; online: https://sygnal.js.org/ui/ -->
2
+ # Combobox
3
+
4
+ `Combobox` is a text input with a list of suggestions that narrows as the user types, following the [WAI-ARIA combobox pattern](https://www.w3.org/WAI/ARIA/apg/patterns/combobox/). Keyboard handling and the active option come from [Zag.js](https://zagjs.com)'s combobox machine, rendered with [`fromZag`](../adapters.md#zag-machines-fromzag). It is a [widget](../widgets.md) tag.
5
+
6
+ ```jsx live
7
+ import { Combobox } from 'sygnal/ui/combobox'
8
+
9
+ const CITIES = ['Amsterdam', 'Berlin', 'Lisbon', 'London', 'Paris', 'Prague']
10
+
11
+ function Trip({ state }) {
12
+ return (
13
+ <div className="trip">
14
+ <Combobox className="city" label="City" items={CITIES} value={state.city} placeholder="Type a city" />
15
+ <p>{state.city ? `Going to ${state.city}` : ''}</p>
16
+ </div>
17
+ )
18
+ }
19
+
20
+ Trip.initialState = { city: null }
21
+ Trip.intent = ({ DOM }) => ({
22
+ CITY: DOM.select('.city').events('value-change').detail(),
23
+ })
24
+ Trip.model = {
25
+ CITY: (state, city) => ({ ...state, city }),
26
+ }
27
+ ```
28
+
29
+ Typing `lo` leaves London in the list; Down and Enter pick it, the input shows "London", and `value-change` sends `'London'`.
30
+
31
+ Install Zag's packages first: `npm install @zag-js/vanilla@~1.45.0 @zag-js/combobox@~1.45.0` (see [Menu](./menu.md#install)).
32
+
33
+ ## Filtering
34
+
35
+ By default the list shows the items whose label contains the text, ignoring case. The filter text resets when the list closes, so the next open shows every item.
36
+
37
+ - `filter={(item, text) => item.label.toLowerCase().startsWith(text.toLowerCase())}` replaces the test.
38
+ - `filter={false}` turns it off: the app passes the items to show, from the `input-change` event. Use it for server-side search (with [`makeFetchDriver`](../http.md)). In this demo, a stand-in server searches a few names:
39
+
40
+ ```js live-server
41
+ const names = ['Ada Lovelace', 'Alan Turing', 'Grace Hopper', 'Barbara Liskov', 'Donald Knuth']
42
+ const users = names.map((name, i) => ({ id: String(i + 1), name }))
43
+
44
+ export default {
45
+ 'GET /api/users': ({ query }) => {
46
+ const q = (query.q ?? '').toLowerCase()
47
+ return { json: users.filter((u) => u.name.toLowerCase().includes(q)), delayMs: 400 }
48
+ },
49
+ }
50
+ ```
51
+
52
+ ```jsx live live-height=240
53
+ import { Combobox } from 'sygnal/ui/combobox'
54
+
55
+ function Search({ state }) {
56
+ return <Combobox className="user" label="User" items={state.matches} filter={false} placeholder="Search users" />
57
+ }
58
+
59
+ Search.initialState = { matches: [] }
60
+ Search.intent = ({ DOM }) => ({
61
+ QUERY: DOM.select('.user').events('input-change').detail(),
62
+ })
63
+ Search.model = {
64
+ QUERY: { HTTP: (state, q) => ({ url: '/api/users', query: { q }, ok: 'MATCHES', error: 'FAILED', latest: true }) },
65
+ MATCHES: (state, users) => ({ ...state, matches: users.map((u) => ({ value: u.id, label: u.name })) }),
66
+ FAILED: (state) => ({ ...state, matches: [] }),
67
+ }
68
+ ```
69
+
70
+ ## Props
71
+
72
+ | Prop | | |
73
+ |---|---|---|
74
+ | `label` | | The visible label, linked to the input (its accessible name). Give every combobox one |
75
+ | `aria-label`, `aria-labelledby`, `aria-describedby` | | Name or describe the input when there is no visible `label` (or in addition to it); they go on the input, not the host |
76
+ | `items` | (required) | Strings, or `{ value, label, disabled }` objects |
77
+ | `value` | | Controlled: the value (an array with `multiple`); `null` for none |
78
+ | `defaultValue` | | The start value when `value` is left out |
79
+ | `placeholder` | | The input's placeholder |
80
+ | `filter` | contains | A function `(item, text) => boolean`, or `false` |
81
+ | `allowCustomValue` | `false` | Keep text that matches no item when the input loses focus. With `name` (single), the form submits the selected item's value, or, when the user typed since the last selection, the typed text (text equal to an item's label submits the item's value). A new controlled `value` ends the typing, except an empty one (an app that clears its value on `input-change`) |
82
+ | `openOnClick` | `false` | Open the list when the input is clicked |
83
+ | `inputBehavior` | `'none'` | `'autohighlight'` highlights the first match; `'autocomplete'` also completes the text |
84
+ | `selectionBehavior` | `'replace'` | What the input shows after a pick: `'replace'` (the label), `'clear'`, or `'preserve'` (the typed text). With `multiple`: `'clear'` |
85
+ | `closeOnSelect` | `true` | With `multiple`: `false` |
86
+ | `loopFocus` | `true` | The arrow keys wrap |
87
+ | `name` | | The form field: hidden inputs submit the value (one per value with `multiple`), not the label the input shows. `form` (a form's id) goes on those hidden inputs |
88
+ | `multiple`, `form`, `disabled`, `readOnly`, `required`, `invalid`, `positioning`, `open` | | As in Zag's combobox |
89
+
90
+ ## Events and commands
91
+
92
+ | Event | Detail |
93
+ |---|---|
94
+ | `value-change` | The value (with `multiple`: the array); `null` when cleared |
95
+ | `input-change` | The text the user typed (not the label a pick writes) |
96
+ | `open-change` | `true` / `false` |
97
+
98
+ Commands for `ELEMENT`: `open`, `close`, `clear`, and `focus` (the input).
99
+
100
+ ## Keyboard
101
+
102
+ | Key | |
103
+ |---|---|
104
+ | Typing | Filters the list and opens it |
105
+ | Down / Up | Open the list; move the active option (`aria-activedescendant`, the focus stays in the input) |
106
+ | Enter | Pick the active option |
107
+ | Escape | Close the list |
108
+ | Home / End | Move the caret in the input |
109
+
110
+ ## Styling
111
+
112
+ Zag's data attributes: `[data-scope="combobox"]` with `[data-part="root"]`, `"label"`, `"control"`, `"input"`, `"trigger"`, `"positioner"`, `"content"`, `"item"`, `"item-text"` and `"item-indicator"`; `data-state`, `data-highlighted` and `data-disabled` as for [Select](./select.md#styling).
113
+
114
+ ```css live
115
+ .city { max-width: 16rem; }
116
+ .city [data-part='content'] { background: Canvas; border: 1px solid #ddd; }
117
+ .city [data-part='item'][data-highlighted] { background: rgb(99 102 241 / 0.25); }
118
+ ```
119
+
120
+ ## Positioning
121
+
122
+ The listbox renders inside the widget's host, next to the input, and Zag positions it with `position: absolute` (the `--x` / `--y` variables on the positioner). Inside a container that clips its content (`overflow: hidden` or `auto`: a card, a scrolling panel, a table cell), the open listbox is cut off at the container's edge. Pass `positioning={{ strategy: 'fixed' }}` there: the positioner is then placed relative to the viewport and escapes the clipping (it still follows the input when the page scrolls; an ancestor with a `transform`, `filter` or `contain` still clips it). Other options go in the same object: `positioning={{ placement: 'bottom-end', gutter: 4, strategy: 'fixed' }}`.
123
+
124
+ ## Testing
125
+
126
+ ```jsx
127
+ import { it } from 'vitest'
128
+ import { renderComponent } from 'sygnal'
129
+ import Trip from './Trip.jsx'
130
+
131
+ it('keeps the picked city', async () => {
132
+ const t = renderComponent(Trip)
133
+ await t.ready()
134
+ t.widget('.city').dispatch('value-change', 'Paris')
135
+ await t.next((state) => state.city === 'Paris')
136
+ t.dispose()
137
+ })
138
+ ```
139
+
140
+ Typing and filtering need a DOM: see [Menu](./menu.md#testing).
141
+
142
+ ## Size
143
+
144
+ Gzipped, in a small app: Combobox adds **34 KB** (mostly Zag's combobox machine, its positioning and `@zag-js/vanilla`). With Menu and Select, the three add 47 KB together.