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,342 @@
1
+ <!-- Generated from docs/src/content/docs/guide by scripts/copy-guides.mjs; online: https://sygnal.js.org/guide/ -->
2
+ # Drag and Drop
3
+
4
+ Sygnal has two tools for drag and drop:
5
+
6
+ - **`sortable`**, a [behavior](./behaviors.md) that reorders a list in the component's state by mouse, pen, touch **and keyboard**, with screen-reader announcements. Use it for reordering: task lists, playlists, kanban columns (moves between lists included).
7
+ - **`makeDragDriver()`**, a driver for native HTML5 drag and drop at the document level. Use it when the browser's drag-and-drop model is the point: dropping files from the desktop, dragging between components that don't share a host, arbitrary drop zones, or native drag images. HTML5 drag and drop has no keyboard or touch support of its own, so give those users another way (a menu, buttons).
8
+
9
+ ## Sortable lists
10
+
11
+ `sortable({ from, item, handle })` reorders `state[from]`. The items are usually a Collection; each item's element carries its id in `data-id`, and the handle is a button inside it:
12
+
13
+ ```jsx live
14
+ import { Collection, sortable } from 'sygnal'
15
+
16
+ function Task({ state, context }) {
17
+ const { dragging, over, after, helpId } = context.sort
18
+ const id = String(state.id)
19
+ const cls = ['task', dragging === id && 'dragging', over === id && dragging !== id && (after ? 'drop-after' : 'drop-before')]
20
+ return (
21
+ <li className={cls.filter(Boolean).join(' ')} data-id={state.id}>
22
+ <button type="button" className="grip" aria-label={`Reorder ${state.title}`}
23
+ aria-describedby={helpId} aria-pressed={String(dragging === id)}>⠿</button>
24
+ <span className="title">{state.title}</span>
25
+ </li>
26
+ )
27
+ }
28
+
29
+ function TaskList({ state }) {
30
+ return (
31
+ <section>
32
+ <p id={state.sort.helpId} hidden>
33
+ Press Space or Enter to pick up a task, the arrow keys to move it, Space or Enter to drop it, and Escape to cancel.
34
+ </p>
35
+ <ul className="tasks"><Collection of={Task} from="tasks" /></ul>
36
+ <p className="announcer" role="status" aria-live="assertive">{state.sort.message}</p>
37
+ </section>
38
+ )
39
+ }
40
+
41
+ TaskList.initialState = {
42
+ tasks: [{ id: 1, title: 'Write spec' }, { id: 2, title: 'Build it' }, { id: 3, title: 'Ship' }],
43
+ }
44
+ TaskList.uses = { sort: sortable({ from: 'tasks', item: '.task', handle: '.grip' }) }
45
+ TaskList.context = { sort: (state) => state.sort }
46
+ TaskList.model = {
47
+ // one completed move: save the order
48
+ 'sort.DROPPED': { HTTP: (state, move) => ({ url: '/api/tasks/order', method: 'PUT', json: { ids: state.tasks.map((t) => t.id), move } }) },
49
+ }
50
+ ```
51
+
52
+ ```css live
53
+ .grip { touch-action: none; user-select: none; cursor: grab; }
54
+ .task.dragging { opacity: 0.5; }
55
+ .task.drop-before { box-shadow: 0 -2px 0 royalblue; }
56
+ .task.drop-after { box-shadow: 0 2px 0 royalblue; }
57
+ ```
58
+
59
+ The behavior listens on the host's root element, so it hears the presses and keys that bubble out of the Collection items (which are isolated components): nothing is wired per item. The items read the drag state through [context](https://sygnal.js.org/guide/context/).
60
+
61
+ The announcements in `state.sort.message` are English defaults ("Dropped Write tests at position 2 of 3.", "Reorder cancelled. Write tests is back at position 1 of 3."). When a spec gives its own wording, set it with the `messages` option: `sortable({ from: 'tasks', messages: { cancel: (label, n, m) => 'Cancelled. ' + label + ' is back at position ' + n + ' of ' + m + '.' } })`; the others keep their defaults.
62
+
63
+ ### Options
64
+
65
+ | Option | Default | |
66
+ |---|---|---|
67
+ | `from` | (required) | The host state key of the list. An array of keys (`['todo', 'done']`) allows moves between lists; see [Several lists](#several-lists) |
68
+ | `item` | `'[data-id]'` | Selector of each item's element |
69
+ | `handle` | the item | Selector of the part that starts a drag and takes keyboard focus. Without one, the whole item does, and presses on buttons, links and fields inside it are left to them |
70
+ | `attr` | `'data-id'` | The item element's attribute that holds its id |
71
+ | `idField` | `'id'` | The id field of the list's entries (ids compare as strings) |
72
+ | `axis` | `'y'` | `'y'`: ArrowUp / ArrowDown move, ArrowLeft / ArrowRight change lists; `'x'`: the other way round |
73
+ | `threshold` | `4` | Pixels a pointer moves before a drag starts (a shorter press is a click) |
74
+ | `label` | title, name, label or id | `(entry) => string`: the item's name in announcements |
75
+ | `messages` | English | `{ lift, move, drop, cancel, stay }`, each `(label, position, count, extra) => string`. `extra`: for `lift`, true on a keyboard lift; for `move`, `drop` and `stay`, the list key when the item changed lists. `stay`: Escape after something else changed the list, so the item stays where it is |
76
+
77
+ ### State and actions
78
+
79
+ `state.sort` (the key you chose in `uses`):
80
+
81
+ | Field | |
82
+ |---|---|
83
+ | `dragging` | The id (a string) of the item being moved, or `null` |
84
+ | `over`, `after` | Pointer drags: the id under the pointer, and whether the item lands after it (`true`) or before it |
85
+ | `list` | Pointer drags: the list the item would land in (several lists) |
86
+ | `mode` | `'pointer'`, `'keyboard'` or `null` |
87
+ | `message` | The announcement to render in a live region: picked up, moved, dropped, cancelled |
88
+ | `helpId` | A [`uid()`](./inputs.md#labels-and-ids-uid) id for the instructions element, unique to this host (two lists on a page get two ids); `null` until the first focus, press or key inside the host |
89
+
90
+ `helpId` is set when the host is first used, not when it starts, so nothing is written into the state of a host that is never touched (a Collection item's state is its parent's data). A handle has its description by the time it is focused. The server renders no `helpId`, and neither does the client's first render, so hydration matches.
91
+
92
+ `sort.DROPPED` fires once per completed move with `{ id, list, index, fromList, fromIndex }`: after a pointer drop, or a keyboard drop away from where the item started. The list in the state is already in its new order. Add a host entry for it to save the order. The other actions (`sort.PRESS`, `MOVE`, `UP`, `CANCEL`, `KEY`, `INIT`, `HELP`, `END`) are the behavior's own.
93
+
94
+ ### Keyboard
95
+
96
+ | Key | |
97
+ |---|---|
98
+ | Space or Enter on a handle | Pick the item up (and announce the instructions) |
99
+ | ArrowUp / ArrowDown (`axis: 'x'`: ArrowLeft / ArrowRight) | Move it one place; the list reorders as you go |
100
+ | ArrowLeft / ArrowRight (`axis: 'x'`: ArrowUp / ArrowDown) | Move it to the previous / next list, at the same position |
101
+ | Home / End | Move it to the start / end |
102
+ | Space or Enter | Drop it |
103
+ | Escape | Put it back where it started (unless something else changed the list meanwhile: see below) |
104
+ | Tab | Drop it where it is; focus moves on |
105
+
106
+ Focus stays on the moved item's handle after each step (an [`ELEMENT` command](./element-commands.md) through [`focusWithin`](./element-commands.md#focusing-inside-children-focuswithin), since the keyed Collection may move the focused element). If focus moves to another element while an item is lifted (a click elsewhere, a screen reader's own navigation) or a pointer is pressed anywhere, the item is dropped where it is, as Tab does; a press on a handle then starts a pointer drag at once. Losing focus to nothing (switching windows) keeps the item lifted. A held Space or Enter (the key's auto-repeat) doesn't drop and lift again.
107
+
108
+ If the host is removed while an item is lifted (a route change, a parent hiding it), the drag is cancelled the same way, and no `sort.DROPPED` fires, so state that outlives the host (a parent's) isn't left half-moved. A pointer drag in progress is dropped too (the list hasn't changed yet).
109
+
110
+ Cancelling (Escape, the host removed or made again) puts the list back only when nothing else has changed it since the last arrow key: the lists are then exactly the arrays the drag made, and the ones it lifted the item from come back as they were. When anything else changed them in the meantime (an entry edited, added or removed, the list replaced by new data, an undo or redo, another action reordering it, another tab's copy), the item stays where it is and the drag just ends: the behavior doesn't guess where the item belongs in a list that changed under it. Escape then announces that the item stays (`messages.stay`).
111
+
112
+ The check is by identity, so a list that is made again on every step never matches, and cancelling it always leaves the item where it is: a host whose state comes through a lens that builds new arrays on each read (`get: s => s.tasks.map(...)` or a filter), or a host's own `sort.KEY` entry that returns a new array (normalising or re-sorting the list after each arrow key). Keep the lists the behavior sorts as stored arrays (a plain key or a lens that passes them through) for Escape to put them back. Undo is unaffected: a dropped drag is still one step.
113
+
114
+ ### Pointer and touch
115
+
116
+ A press on a handle followed by a move past `threshold` starts a drag. While it moves, `over` and `after` say where the item would land, for a drop indicator; the list itself changes on release (a release over nothing, or outside the list, cancels). Escape cancels a pointer drag too. Mouse, pen and touch work the same way (Pointer Events, with document listeners only while a press is active).
117
+
118
+ For touch, give the handle `touch-action: none`, or the browser scrolls the page instead (and cancels the drag). While a pointer is pressed the behavior blocks text selection and the browser's own drag of an image or link inside the item (`dragstart`); `user-select: none` on the handle also keeps a long press from selecting its text.
119
+
120
+ ### Several lists
121
+
122
+ With `from: ['todo', 'done']`, items move between the lists. Mark each list's container with `data-list` and its key, so a pointer drop on an empty list lands in it:
123
+
124
+ ```jsx live
125
+ import { Collection, sortable } from 'sygnal'
126
+
127
+ function Card({ state }) {
128
+ return (
129
+ <li className="card" data-id={state.id}>
130
+ <button type="button" className="grip" aria-label={`Move ${state.title}`}>⠿</button> {state.title}
131
+ </li>
132
+ )
133
+ }
134
+
135
+ function Board({ state }) {
136
+ return (
137
+ <div className="board">
138
+ <ul className="lane" data-list="todo" aria-label="To do"><Collection of={Card} from="todo" /></ul>
139
+ <ul className="lane" data-list="done" aria-label="Done"><Collection of={Card} from="done" /></ul>
140
+ <p role="status" aria-live="assertive">{state.sort.message}</p>
141
+ </div>
142
+ )
143
+ }
144
+
145
+ Board.initialState = { todo: [{ id: 'a', title: 'Draft' }, { id: 'b', title: 'Review' }], done: [] }
146
+ Board.uses = { sort: sortable({ from: ['todo', 'done'], item: '.card', handle: '.grip' }) }
147
+ ```
148
+
149
+ Give an empty list some height, so there is something to drop on:
150
+
151
+ ```css live
152
+ .board { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; }
153
+ .board [role="status"] { grid-column: 1 / -1; }
154
+ .lane { min-height: 3rem; border: 1px dashed currentColor; }
155
+ ```
156
+
157
+ `sort.DROPPED`'s `fromList` and `list` say where the item came from and went. Over an item of the other list, the pointer's half of it decides: the upper half (the left half with `axis: 'x'`) lands before it, the lower half after it, so the end of a list is reached over its last item. Within the item's own list it lands before the hovered item when moving up and after it when moving down.
158
+
159
+ ### Accessibility
160
+
161
+ - Make the handle a `<button type="button">` with a name (``aria-label={`Reorder ${title}`}``), or, without a handle, give the item `tabIndex={0}` and an `aria-label`.
162
+ - Point the handles' `aria-describedby` at an instructions element with `id={state.sort.helpId}` (pass `helpId` to the items through context).
163
+ - Render `state.sort.message` in a live region (`role="status"` / `aria-live`): it is how screen-reader users hear where the item is.
164
+ - `sygnal-check` reports a handle that can't take focus or has no name, and a host without a live region ([SYG724](https://sygnal.js.org/reference/errors/#syg724)).
165
+
166
+ ### Nested sortables
167
+
168
+ A sortable inside a sortable's item (sorted lanes, each with sorted cards) works: a press or key belongs to the innermost sortable whose items contain it, and a host's own root element is never one of its items. A host's items are the item elements below its root that aren't inside another of them, so the ids may repeat between the levels (lane `1` holding card `1`): the item under the pointer, the one a key is on and the handle that gets focus are always the host's own. Give each level its own handle.
169
+
170
+ ### Filtered and sorted lists
171
+
172
+ `sortable` moves entries of the array in the state: the arrow keys swap the item with its neighbour in the array, the positions it announces count the array, and a pointer drop lands next to the entry it is dropped on. Render the list in that order. Under a Collection's `sort`, a move changes nothing you can see (the Collection sorts it back); under a `filter`, a keyboard step can pass entries that are hidden, and the positions count them. For a list that can be reordered, sort the array itself (once, for a starting order) instead of the Collection, and hide the handles while a filter hides entries. In development, [SYG435](https://sygnal.js.org/reference/errors/#syg435) warns when a list's items are shown in another order than the array's, or with entries hidden between them.
173
+
174
+ ### Undo and persist
175
+
176
+ With the [`undo`](./undo.md) behavior on the same host, a drag is one undo step: `uses = { sort: sortable({ from: 'tasks' }), history: undo({ key: 'tasks' }) }` records the order from before the drag when the item is dropped, and a cancelled drag records nothing. The live keyboard moves aren't steps of their own, and the order of the two in `uses` doesn't matter.
177
+
178
+ An action during a drag that changes the list too (an item added) is recorded with the drag so far, so the order from before the drag stays reachable; undo during a drag steps back over the drag so far, and redo during a drag records the order from before it (never a half-moved one). A drag cancelled after something else changed the list leaves the item where it is; if the order then differs from the one before the drag, that is one step, and nothing stays pending. With `track` naming none of `sort`'s actions, drags aren't recorded at all. `coalesceMs` joins quick drops only when `coalesce` names one of `sort`'s actions (`coalesce: ['sort.DROPPED']`).
179
+
180
+ With several lists, record them together, so an undo after a move between lists puts both back:
181
+
182
+ ```jsx
183
+ import { sortable, undo } from 'sygnal'
184
+
185
+ Board.uses = {
186
+ sort: sortable({ from: ['todo', 'done'], item: '.card', handle: '.grip' }),
187
+ history: undo({ key: ['todo', 'done'] }),
188
+ }
189
+ ```
190
+
191
+ Each key is compared on its own, so a drag that moves an item to the other list and back, then drops it, records nothing. An undo restores only the keys the state had when the step was recorded (a key that was missing then is removed, not set to `undefined`); `key: []` throws.
192
+
193
+ [`persist()`](./persistence.md) never saves or restores the root component's `state.sort` (it is UI state). A sortable on a sub-component or a Collection item keeps its slice in the data around it (`state.lanes[0].sort`), so a root `persist()` saves it with that data. Drag state that comes back this way, from another tab, or into a host made again (HMR) doesn't resume a drag: it is reset when the host starts or at the next press or key. Restored or synced drag state leaves the data as it is (only the drag that moved the item, in this page, knows its own lists: another tab's drag never moves anything here); a drag of this page (a host made again, a second host of the same slice) is cancelled by the rules above, so its lists come back when nothing else changed them.
194
+
195
+ ### Testing
196
+
197
+ Keyboard moves are plain key events on the handle:
198
+
199
+ ```jsx
200
+ import { renderComponent } from 'sygnal'
201
+ import TaskList from './TaskList.jsx'
202
+
203
+ it('moves a task down with the keyboard', async () => {
204
+ const t = renderComponent(TaskList)
205
+ await t.ready()
206
+ const key = (id, key) => t.simulateEvent('.grip', 'keydown', { key, within: `.task[data-id="${id}"]` })
207
+ key(2, ' ')
208
+ key(2, 'ArrowDown')
209
+ key(2, 'Enter')
210
+ await t.next((s) => s.sort.dragging === null)
211
+ expect(t.state.tasks.map((task) => task.id)).toEqual([1, 3, 2])
212
+ expect(t.actions.find((a) => a.type === 'sort.DROPPED').data).toMatchObject({ id: '2', index: 2, fromIndex: 1 })
213
+ })
214
+ ```
215
+
216
+ `t.commands('ELEMENT')` lists the focus commands; with `dom: 'real'` they run, and `document.activeElement` is the moved item's handle. Pointer drags are best tested in a real browser: a `pointerdown` on a handle, then `pointermove` / `pointerup` with `clientX` / `clientY` (the behavior finds the item under the pointer with `document.elementFromPoint`).
217
+
218
+ ### Diagnostics
219
+
220
+ With `sygnal/diagnostics` loaded (the Vite plugin loads it in development): [SYG145](https://sygnal.js.org/reference/errors/#syg145) an item element without the id attribute, [SYG146](https://sygnal.js.org/reference/errors/#syg146) an `item` or `handle` selector that matches nothing at the first interaction, [SYG147](https://sygnal.js.org/reference/errors/#syg147) a `from` key that isn't an array in the host's state, [SYG435](https://sygnal.js.org/reference/errors/#syg435) a list shown in another order than its array's (a Collection's `sort` or `filter`). `sygnal-check` knows `sortable`'s options (a typo is [SYG127](https://sygnal.js.org/reference/errors/#syg127)) and reports [SYG724](https://sygnal.js.org/reference/errors/#syg724).
221
+
222
+ ## HTML5 drag and drop: makeDragDriver
223
+
224
+ The drag-and-drop driver handles native HTML5 drag events at the document level, bypassing component isolation, so drag interactions work across deeply nested, isolated components. Reach for it for files dropped from the desktop, drags between components that don't share a host, or native drag images; for reordering a list, `sortable` above also covers touch and keyboard.
225
+
226
+ ### Setup
227
+
228
+ Create the driver with `makeDragDriver()` and pass it to `run()`:
229
+
230
+ ```javascript
231
+ import { run, makeDragDriver } from 'sygnal'
232
+ import RootComponent from './RootComponent.jsx'
233
+
234
+ run(RootComponent, { DND: makeDragDriver() })
235
+ ```
236
+
237
+ ### Registering Drag Categories
238
+
239
+ Register drag categories from your model, typically in `BOOTSTRAP`. Each category describes a set of draggable elements and/or drop zones identified by CSS selectors:
240
+
241
+ ```jsx
242
+ RootComponent.model = {
243
+ BOOTSTRAP: {
244
+ DND: () => ({
245
+ configs: [
246
+ { category: 'task', draggable: '.task-card' },
247
+ { category: 'lane', dropZone: '.lane-drop-zone', accepts: 'task' },
248
+ ],
249
+ }),
250
+ },
251
+ }
252
+ ```
253
+
254
+ #### Registration Properties
255
+
256
+ | Property | Type | Description |
257
+ |----------|------|-------------|
258
+ | `category` | `string` | Required. Name for this group of drag elements |
259
+ | `draggable` | `string` | CSS selector for elements that can be dragged |
260
+ | `dropZone` | `string` | CSS selector for elements that accept drops |
261
+ | `accepts` | `string` | Only accept drops from this dragging category. Omit to accept any |
262
+ | `dragImage` | `string` | CSS selector for a custom drag preview. Resolved via `.closest()` from the draggable element |
263
+
264
+ A single category can have both `draggable` and `dropZone` — for example, sortable lists where items are both dragged and dropped onto:
265
+
266
+ ```javascript
267
+ const laneSort = {
268
+ category: 'lane-sort',
269
+ draggable: '.lane-drag-handle',
270
+ dropZone: '.lane-header',
271
+ accepts: 'lane-sort',
272
+ dragImage: '.lane',
273
+ }
274
+ ```
275
+
276
+ ### Listening to Drag Events
277
+
278
+ Use the `DND` source in intent. It supports the same shorthand pattern as the DOM source:
279
+
280
+ ```jsx
281
+ RootComponent.intent = ({ DND }) => ({
282
+ DRAG_START: DND.dragstart('task'), // = DND.select('task').events('dragstart')
283
+ DROP: DND.drop('lane'), // = DND.select('lane').events('drop')
284
+ DRAG_END: DND.dragend('task'), // = DND.select('task').events('dragend')
285
+ })
286
+ ```
287
+
288
+ #### Event Payloads
289
+
290
+ | Event | Payload | Description |
291
+ |-------|---------|-------------|
292
+ | `dragstart` | `{ element, dataset }` | The dragged element and its `data-*` attributes |
293
+ | `dragend` | `null` | Fires when the drag ends (drop or cancel) |
294
+ | `drop` | `{ dropZone, insertBefore }` | The drop zone element, and the sibling element at the cursor position (for ordering) |
295
+ | `dragover` | `null` | Fires continuously while dragging over a valid drop zone. `preventDefault()` is called automatically |
296
+
297
+ ### Handling Drops
298
+
299
+ The `drop` event provides the drop zone element and an `insertBefore` reference for ordering. Use `dataset` attributes on your elements to identify items:
300
+
301
+ ```jsx
302
+ // In the view, put identifying data on elements
303
+ <div className="task-card" data={{ taskId: state.id }}>
304
+ {state.title}
305
+ </div>
306
+
307
+ // In the model, use the drop payload to move items
308
+ RootComponent.model = {
309
+ DROP: (state, { dropZone, insertBefore }) => {
310
+ if (!state.dragging) return ABORT
311
+ const toLaneId = dropZone.dataset.laneId
312
+ const insertBeforeTaskId = insertBefore?.dataset.taskId ?? null
313
+ // moveTask: your own helper that returns the new lanes array
314
+ const lanes = moveTask(state.lanes, state.dragging.taskId, toLaneId, insertBeforeTaskId)
315
+ return { ...state, lanes, dragging: null }
316
+ },
317
+ }
318
+ ```
319
+
320
+ ### Visual Feedback with Context
321
+
322
+ Use context to communicate drag state down to child components for styling:
323
+
324
+ ```jsx
325
+ RootComponent.context = {
326
+ draggingTaskId: state => state.dragging?.taskId ?? null,
327
+ }
328
+
329
+ // In a child component's view
330
+ function TaskCard({ state, context }) {
331
+ const isDragging = context.draggingTaskId === state.id
332
+ return (
333
+ <div className={'task-card' + (isDragging ? ' dragging' : '')} data={{ taskId: state.id }}>
334
+ {state.title}
335
+ </div>
336
+ )
337
+ }
338
+ ```
339
+
340
+ ### Complete Example
341
+
342
+ See the [Kanban board example](https://github.com/tpresley/sygnal/tree/main/examples/kanban) for a full working implementation with task drag-and-drop between lanes, lane reordering with custom drag images, and visual drag feedback.
@@ -0,0 +1,306 @@
1
+ <!-- Generated from docs/src/content/docs/guide by scripts/copy-guides.mjs; online: https://sygnal.js.org/guide/ -->
2
+ # Element Commands
3
+
4
+ Some things the browser does are not state you render: which element has the focus, where the page is scrolled to, whether a `<dialog>` is open as a modal, whether a popover is showing. You change them by calling a method on the element (`input.focus()`, `dialog.showModal()`). In Sygnal, a model entry asks for that call with an **element command** on the built-in `ELEMENT` sink:
5
+
6
+ ```jsx
7
+ Signup.model = {
8
+ SUBMIT: {
9
+ STATE: (state) => ({ ...state, errors: validate(state) }),
10
+ ELEMENT: (state) => (validate(state).email ? { focus: '.email' } : ABORT),
11
+ },
12
+ OPEN_HELP: { ELEMENT: { showModal: '.help' } },
13
+ }
14
+ ```
15
+
16
+ `ELEMENT` is built in: there is no driver to register, and it works in every component. The target is a CSS selector (`{ focus: '.email' }`), looked up in the view of the component that sent the command (see [Which element](#which-element)). A [control](https://sygnal.js.org/guide/controls/) works as a target too.
17
+
18
+ ## The command object
19
+
20
+ A command is an object whose **first key is the method** and whose value is the target. The other keys are the method's options:
21
+
22
+ ```jsx
23
+ Form.model = {
24
+ EDIT_EMAIL: { ELEMENT: { focus: '.email' } }, // email.focus({})
25
+ QUIET_FOCUS: { ELEMENT: { focus: '.email', preventScroll: true } }, // email.focus({ preventScroll: true })
26
+ SHOW_LAST: { ELEMENT: { scrollIntoView: '.row', block: 'nearest' } }, // row.scrollIntoView({ block: 'nearest' })
27
+ CLOSE_HELP: { ELEMENT: { close: '.help', returnValue: 'done' } }, // dialog.close('done')
28
+ }
29
+ ```
30
+
31
+ The documented methods:
32
+
33
+ | Method | Options | Element |
34
+ |---|---|---|
35
+ | `focus` | `preventScroll`, `focusVisible` | any focusable element |
36
+ | `blur` | | any element |
37
+ | `select` | | `<input>`, `<textarea>`: selects the text |
38
+ | `click` | | any element (a programmatic click, e.g. to open a file input's picker) |
39
+ | `scrollIntoView` | `block`, `inline`, `behavior` | any element |
40
+ | `showModal` | | `<dialog>` |
41
+ | `show` | | `<dialog>` (non-modal) |
42
+ | `close` | `returnValue` (passed as the argument) | `<dialog>` |
43
+ | `showPopover` | | an element with a `popover` attribute |
44
+ | `hidePopover` | | an element with a `popover` attribute |
45
+ | `togglePopover` | `force` | an element with a `popover` attribute |
46
+
47
+ The options are passed to the method as one object, except for `close`, which gets `returnValue` as its argument. A popover is an element rendered with `attrs: { popover: 'auto' }` (or `'manual'`); its `toggle` event reaches intent as `DOM.toggle('.tip')`, with `newState` `'open'` or `'closed'`.
48
+
49
+ The browser's declarative forms need no model: `<button popovertarget="tip">` toggles a popover and `<button commandfor="dlg" command="show-modal">` opens a dialog. JSX writes these as attributes, as it does `popovertargetaction`, `closedby`, `interestfor` and `anchor`, and an `aria-*` boolean as `"true"` / `"false"`.
50
+
51
+ The value of the `ELEMENT` entry is a command, or a reducer that returns one: `(state, data) => command`. Return `ABORT` to send nothing. An **array** sends several commands, in order:
52
+
53
+ ```jsx
54
+ Search.model = {
55
+ // put the cursor in the search box and select what is in it
56
+ EDIT_QUERY: { ELEMENT: [{ focus: '.query' }, { select: '.query' }] },
57
+ }
58
+ ```
59
+
60
+ Sygnal runs any method the element has, so other methods work too (`{ play: '.video' }`, `{ showPicker: '.date' }`, `{ requestSubmit: '.checkout' }`). The eleven above are the ones the types know; for TypeScript, add others to the `ElementCommandRegistry` interface:
61
+
62
+ ```ts
63
+ // sygnal-commands.d.ts
64
+ import 'sygnal'
65
+
66
+ declare module 'sygnal' {
67
+ interface ElementCommandRegistry { play: {}; showPicker: {} }
68
+ }
69
+ ```
70
+
71
+ Methods that change the DOM Sygnal renders (`remove`, `append`, `setAttribute` and the like) are reported as [SYG641](https://sygnal.js.org/reference/errors/#syg641): the next render would undo them or trip over them. Change the state instead.
72
+
73
+ ## Which element
74
+
75
+ The target is looked up in the view of the **component instance that sent the command**, the same scope its intent's `DOM` has. An element inside a child component is isolated from its parent, and each item of a [Collection](https://sygnal.js.org/guide/collections/) reaches only its own elements. To focus or scroll something a child renders, send the command from the child's own model. If more than one element matches, the first one gets the command.
76
+
77
+ ### Focusing inside children: `focusWithin`
78
+
79
+ To focus an element a child renders (a field of a child form, the input of a Collection item the same action adds), use `focusWithin(selector)` as the target. It looks for `selector` anywhere under the sender's root element, children included, and focuses the first match with the command's options:
80
+
81
+ ```jsx live
82
+ import { Collection, focusWithin } from 'sygnal'
83
+
84
+ function Row({ state }) {
85
+ return (
86
+ <li data-id={state.id}>
87
+ <input className="title" aria-label="Title" value={state.title} />
88
+ </li>
89
+ )
90
+ }
91
+ Row.intent = ({ DOM }) => ({ TITLE: DOM.input('.title').value() })
92
+ Row.model = { TITLE: (state, title) => ({ ...state, title }) }
93
+
94
+ export function Checklist({ state }) {
95
+ return (
96
+ <div>
97
+ <button className="add">Add item</button>
98
+ <ul><Collection of={Row} from="rows" /></ul>
99
+ </div>
100
+ )
101
+ }
102
+ Checklist.initialState = { rows: [], next: 1 }
103
+ Checklist.intent = ({ DOM }) => ({ ADD: DOM.click('.add') })
104
+ Checklist.model = {
105
+ ADD: {
106
+ STATE: (state) => ({ ...state, rows: [...state.rows, { id: state.next, title: '' }], next: state.next + 1 }),
107
+ // the new row renders in the same patch, before the command runs
108
+ ELEMENT: (state) => ({ focus: focusWithin(`[data-id="${state.next}"] .title`) }),
109
+ },
110
+ }
111
+ ```
112
+
113
+ The reducer for `ELEMENT` sees the state before the action, so `state.next` is the new row's id. Prefer the child's own model when the child decides; `focusWithin` is for a parent that does (adding a row, submitting a form whose fields are child components). If nothing matches, nothing happens; `renderComponent()` reports [SYG640](https://sygnal.js.org/reference/errors/#syg640) when nothing in the view, children included, matches.
114
+
115
+ ## When it runs
116
+
117
+ A command runs once the render that its action may cause is on the page, so it reaches an element that the same action renders (the error message under a field, a new row, a dialog that a state flag adds):
118
+
119
+ - after the action, the command waits for the next patch of the DOM at which its target exists, and runs right after it;
120
+ - if the action changes nothing that renders, it runs at the first check, 16 ms later;
121
+ - while the target is missing, it keeps checking every 16 ms for about 1 s, then gives up ([SYG640](https://sygnal.js.org/reference/errors/#syg640) in development);
122
+ - the pending commands of a component that is removed are dropped.
123
+
124
+ During server rendering (`renderToString`) nothing runs: there are no elements to call.
125
+
126
+ ## Recipes
127
+
128
+ ### Focus the first invalid field
129
+
130
+ After a failed submit, move the focus to the first field with an error. The `ELEMENT` entry runs after the errors have rendered, so it can focus a field the same action marked invalid:
131
+
132
+ ```jsx live
133
+ import { ABORT } from 'sygnal'
134
+
135
+ const validate = ({ name, email }) => ({
136
+ ...(name.trim() ? {} : { name: 'Enter your name' }),
137
+ ...(email.includes('@') ? {} : { email: 'Enter an email address' }),
138
+ })
139
+
140
+ export function Signup({ state }) {
141
+ return (
142
+ <form>
143
+ <label>Name <input className="name" value={state.name} /></label>
144
+ {state.errors.name && <p className="error">{state.errors.name}</p>}
145
+ <label>Email <input className="email" type="email" value={state.email} /></label>
146
+ {state.errors.email && <p className="error">{state.errors.email}</p>}
147
+ <button className="submit" type="button">Sign up</button>
148
+ </form>
149
+ )
150
+ }
151
+
152
+ Signup.initialState = { name: '', email: '', errors: {} }
153
+
154
+ Signup.intent = ({ DOM }) => ({
155
+ NAME: DOM.input('.name').value(),
156
+ EMAIL: DOM.input('.email').value(),
157
+ SUBMIT: DOM.click('.submit'),
158
+ })
159
+
160
+ Signup.model = {
161
+ NAME: (state, name) => ({ ...state, name }),
162
+ EMAIL: (state, email) => ({ ...state, email }),
163
+ SUBMIT: {
164
+ STATE: (state) => ({ ...state, errors: validate(state) }),
165
+ // runs after the errors have rendered; the first invalid field gets the focus
166
+ ELEMENT: (state) => {
167
+ const errors = validate(state)
168
+ if (errors.name) return { focus: '.name' }
169
+ if (errors.email) return { focus: '.email' }
170
+ return ABORT
171
+ },
172
+ },
173
+ }
174
+ ```
175
+
176
+ ### A native dialog
177
+
178
+ `showModal` opens a `<dialog>` as a modal: the browser traps the focus inside it, dims the page and closes it on Escape. Its `close` event tells the component that it closed, whichever way it did:
179
+
180
+ ```jsx live
181
+ export function Help({ state }) {
182
+ return (
183
+ <div>
184
+ <button className="open-help">Keyboard shortcuts</button>
185
+ <dialog className="help">
186
+ <h2>Keyboard shortcuts</h2>
187
+ <p>Press N for a new card.</p>
188
+ <button className="close-help">Close</button>
189
+ </dialog>
190
+ <p className="status">{state.status}</p>
191
+ </div>
192
+ )
193
+ }
194
+
195
+ Help.initialState = { status: 'Help is closed' }
196
+
197
+ Help.intent = ({ DOM }) => ({
198
+ OPEN_HELP: DOM.click('.open-help'),
199
+ CLOSE_HELP: DOM.click('.close-help'),
200
+ // close doesn't bubble; Sygnal listens on the dialog itself (Escape closes it too)
201
+ HELP_CLOSED: DOM.close('.help'),
202
+ })
203
+
204
+ Help.model = {
205
+ OPEN_HELP: {
206
+ STATE: (state) => ({ ...state, status: 'Help is open' }),
207
+ ELEMENT: { showModal: '.help' },
208
+ },
209
+ CLOSE_HELP: { ELEMENT: { close: '.help', returnValue: 'done' } },
210
+ HELP_CLOSED: (state) => ({ ...state, status: 'Help is closed' }),
211
+ }
212
+ ```
213
+
214
+ The dialog's open state belongs to the browser here: the component keeps only a status it shows. `DOM.close('.help').map(e => e.target.returnValue)` gives the `returnValue` the dialog was closed with.
215
+
216
+ ### Scroll a new row into view
217
+
218
+ A Collection item reaches only its own elements, so a new row scrolls itself into view, from its `BOOTSTRAP` (which runs once, when the item is created):
219
+
220
+ ```jsx live
221
+ import { ABORT, Collection } from 'sygnal'
222
+
223
+ function TaskRow({ state }) {
224
+ return <li className="row">{state.text}</li>
225
+ }
226
+
227
+ TaskRow.model = {
228
+ // only rows added by ADD scroll; the ones there at the start don't
229
+ BOOTSTRAP: { ELEMENT: (state) => (state.added ? { scrollIntoView: '.row', block: 'nearest' } : ABORT) },
230
+ }
231
+
232
+ export function TaskList() {
233
+ return (
234
+ <div className="task-list">
235
+ <button className="add-task">Add a task</button>
236
+ <ul>
237
+ <Collection of={TaskRow} from="tasks" />
238
+ </ul>
239
+ </div>
240
+ )
241
+ }
242
+
243
+ TaskList.initialState = { tasks: [{ id: 1, text: 'Water the plants' }] }
244
+
245
+ TaskList.intent = ({ DOM }) => ({ ADD: DOM.click('.add-task') })
246
+
247
+ TaskList.model = {
248
+ ADD: (state) => {
249
+ const id = state.tasks.length + 1
250
+ return { ...state, tasks: [...state.tasks, { id, text: `Task ${id}`, added: true }] }
251
+ },
252
+ }
253
+ ```
254
+
255
+ The list here scrolls inside a box of its own, so a new row has somewhere to scroll to:
256
+
257
+ ```css live
258
+ .task-list ul { max-height: 6.5em; overflow-y: auto; margin-top: 0.5rem; }
259
+ ```
260
+
261
+ In a [`<VirtualCollection>`](./virtual-collections.md#jumping-to-a-row) most rows aren't rendered, so the list jumps instead: its container takes `{ scrollToIndex: '.rows', index }` and `{ scrollToId: '.rows', id }`, sent by the component that renders it.
262
+
263
+ ## Testing
264
+
265
+ [`renderComponent`](https://sygnal.js.org/integration/testing/) records every command the rendered tree sends. `t.commands('ELEMENT')` lists them in order, one entry per command (arrays flattened), as sent. With the mock DOM (the default) they are only recorded; with `dom: 'real'` they also run, so the test can check the focus or the dialog:
266
+
267
+ ```jsx
268
+ // @vitest-environment jsdom
269
+ import { it, expect, afterEach } from 'vitest'
270
+ import { renderComponent } from 'sygnal'
271
+ import { Signup } from './Signup.jsx'
272
+
273
+ let t
274
+ afterEach(() => t?.dispose())
275
+
276
+ it('asks for the focus on the first invalid field', async () => {
277
+ t = renderComponent(Signup)
278
+ await t.ready()
279
+ t.simulateEvent('.submit', 'click')
280
+ await t.next(s => !!s.errors.name)
281
+ await t.settle()
282
+ expect(t.commands('ELEMENT')).toEqual([{ focus: '.name' }])
283
+ })
284
+
285
+ it('moves the focus there (real DOM)', async () => {
286
+ t = renderComponent(Signup, { dom: 'real' })
287
+ await t.ready()
288
+ t.simulateEvent('.submit', 'click')
289
+ await t.next(s => !!s.errors.name)
290
+ await t.settle()
291
+ expect(document.activeElement).toBe(t.query('.name'))
292
+ })
293
+ ```
294
+
295
+ - The commands are recorded as the model sent them, so the test compares them with the same objects: `{ focus: '.name' }`.
296
+ - jsdom has no `showModal`, `show`, `close`, popover methods or `scrollIntoView`. With `dom: 'real'`, `renderComponent` adds stand-ins: a dialog's `open` flips and `close` fires its `close` event, a popover's `toggle` event fires, and `scrollIntoView` does nothing. Check real scrolling and focus trapping in a browser.
297
+ - The mock DOM reports SYG640 and SYG641 too, so `t.expectNoDiagnostics()` catches a command that missed its target.
298
+
299
+ ## Diagnostics
300
+
301
+ | Code | When |
302
+ |---|---|
303
+ | [SYG640](https://sygnal.js.org/reference/errors/#syg640) (warning) | Nothing in the sending instance's view matched the target within about 1 s, or the command has no target. `sygnal-check` reports a literal command whose class the view never renders |
304
+ | [SYG641](https://sygnal.js.org/reference/errors/#syg641) (error) | The element has no method of that name (a typo such as `fokus`, `showModal` on a `<div>`; for a [widget control](https://sygnal.js.org/guide/controls/#widget-controls-for-library-authors), its spec has no such command either), the value isn't a command object, or the method changes the DOM Sygnal renders. `sygnal-check` reports a misspelled documented method or spec command |
305
+
306
+ Both come from the development checks (`sygnal/diagnostics`, loaded by the Vite plugin in dev) and from `sygnal-check`. In production a command that can't run does nothing.