sygnal 7.0.0 → 7.2.0-preview.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 (343) hide show
  1. package/CHANGELOG.md +632 -0
  2. package/dist/agent-ui.cjs.js +592 -0
  3. package/dist/agent-ui.cjs.js.map +1 -0
  4. package/dist/agent-ui.esm.js +582 -0
  5. package/dist/agent-ui.esm.js.map +1 -0
  6. package/dist/api.cjs.js +63 -0
  7. package/dist/api.cjs.js.map +1 -0
  8. package/dist/api.esm.js +2 -0
  9. package/dist/api.esm.js.map +1 -0
  10. package/dist/astro/index.cjs.js +968 -4
  11. package/dist/astro/index.cjs.js.map +1 -1
  12. package/dist/astro/index.mjs +968 -4
  13. package/dist/astro/index.mjs.map +1 -1
  14. package/dist/astro/server.cjs.js +73 -45
  15. package/dist/astro/server.cjs.js.map +1 -1
  16. package/dist/astro/server.mjs +73 -45
  17. package/dist/astro/server.mjs.map +1 -1
  18. package/dist/canvas.cjs.js +2919 -0
  19. package/dist/canvas.cjs.js.map +1 -0
  20. package/dist/canvas.esm.js +2866 -0
  21. package/dist/canvas.esm.js.map +1 -0
  22. package/dist/devtools.cjs.js +38 -6
  23. package/dist/devtools.cjs.js.map +1 -1
  24. package/dist/devtools.esm.js +38 -6
  25. package/dist/devtools.esm.js.map +1 -1
  26. package/dist/diagnostics.cjs.js +224 -20
  27. package/dist/diagnostics.cjs.js.map +1 -1
  28. package/dist/diagnostics.esm.js +224 -20
  29. package/dist/diagnostics.esm.js.map +1 -1
  30. package/dist/guide/accessibility.md +1 -1
  31. package/dist/guide/ai-compose.md +150 -0
  32. package/dist/guide/behaviors.md +2 -1
  33. package/dist/guide/collaborative-state.md +265 -0
  34. package/dist/guide/element-commands.md +36 -2
  35. package/dist/guide/forms-reference.md +5 -5
  36. package/dist/guide/forms.md +3 -2
  37. package/dist/guide/http.md +28 -1
  38. package/dist/guide/islands.md +240 -0
  39. package/dist/guide/recipes/code-editor.md +1 -1
  40. package/dist/guide/renderers/agent-ui.md +138 -0
  41. package/dist/guide/renderers/custom-targets.md +126 -0
  42. package/dist/guide/renderers/overview.md +120 -0
  43. package/dist/guide/renderers/react-native.md +128 -0
  44. package/dist/guide/renderers/terminal.md +306 -0
  45. package/dist/guide/renderers/three.md +332 -0
  46. package/dist/guide/renderers/write-once.md +137 -0
  47. package/dist/guide/resources.md +2 -0
  48. package/dist/guide/sandboxed-components.md +223 -0
  49. package/dist/guide/timers.md +32 -5
  50. package/dist/guide/typed-apis.md +442 -0
  51. package/dist/guide/ui/accordion.md +11 -0
  52. package/dist/guide/ui/combobox.md +112 -8
  53. package/dist/guide/ui/dialog.md +96 -20
  54. package/dist/guide/ui/menu.md +57 -5
  55. package/dist/guide/ui/overview.md +8 -4
  56. package/dist/guide/ui/popover.md +69 -9
  57. package/dist/guide/ui/select.md +54 -4
  58. package/dist/guide/ui/tabs.md +1 -1
  59. package/dist/guide/ui/tooltip.md +7 -5
  60. package/dist/guide/virtual-collections.md +3 -0
  61. package/dist/guide/widgets.md +90 -1
  62. package/dist/host.cjs.js +2181 -0
  63. package/dist/host.cjs.js.map +1 -0
  64. package/dist/host.esm.js +2146 -0
  65. package/dist/host.esm.js.map +1 -0
  66. package/dist/index.cjs.js +3948 -586
  67. package/dist/index.cjs.js.map +1 -1
  68. package/dist/index.d.ts +584 -44
  69. package/dist/index.esm.js +3918 -586
  70. package/dist/index.esm.js.map +1 -1
  71. package/dist/primitives.cjs.js +143 -0
  72. package/dist/primitives.cjs.js.map +1 -0
  73. package/dist/primitives.esm.js +133 -0
  74. package/dist/primitives.esm.js.map +1 -0
  75. package/dist/react-native.cjs.js +248 -0
  76. package/dist/react-native.cjs.js.map +1 -0
  77. package/dist/react-native.esm.js +244 -0
  78. package/dist/react-native.esm.js.map +1 -0
  79. package/dist/replay.cjs.js +39 -0
  80. package/dist/replay.cjs.js.map +1 -0
  81. package/dist/replay.esm.js +2 -0
  82. package/dist/replay.esm.js.map +1 -0
  83. package/dist/sandbox-build.cjs.js +140 -0
  84. package/dist/sandbox-build.cjs.js.map +1 -0
  85. package/dist/sandbox-build.esm.js +137 -0
  86. package/dist/sandbox-build.esm.js.map +1 -0
  87. package/dist/sandbox-kernel.esm.js +212 -0
  88. package/dist/sandbox-kernel.esm.js.map +1 -0
  89. package/dist/sandbox-worker.cjs.js +857 -0
  90. package/dist/sandbox-worker.cjs.js.map +1 -0
  91. package/dist/sandbox-worker.esm.js +854 -0
  92. package/dist/sandbox-worker.esm.js.map +1 -0
  93. package/dist/sandbox.cjs.js +1425 -0
  94. package/dist/sandbox.cjs.js.map +1 -0
  95. package/dist/sandbox.esm.js +1406 -0
  96. package/dist/sandbox.esm.js.map +1 -0
  97. package/dist/sygnal.min.js +1 -1
  98. package/dist/sygnal.min.js.map +1 -1
  99. package/dist/terminal.cjs.js +1314 -0
  100. package/dist/terminal.cjs.js.map +1 -0
  101. package/dist/terminal.esm.js +1304 -0
  102. package/dist/terminal.esm.js.map +1 -0
  103. package/dist/three.cjs.js +1111 -0
  104. package/dist/three.cjs.js.map +1 -0
  105. package/dist/three.esm.js +1079 -0
  106. package/dist/three.esm.js.map +1 -0
  107. package/dist/ui-combobox.cjs.js +256 -18
  108. package/dist/ui-combobox.cjs.js.map +1 -1
  109. package/dist/ui-combobox.esm.js +256 -18
  110. package/dist/ui-combobox.esm.js.map +1 -1
  111. package/dist/ui-menu.cjs.js +150 -16
  112. package/dist/ui-menu.cjs.js.map +1 -1
  113. package/dist/ui-menu.esm.js +151 -17
  114. package/dist/ui-menu.esm.js.map +1 -1
  115. package/dist/ui-select.cjs.js +95 -9
  116. package/dist/ui-select.cjs.js.map +1 -1
  117. package/dist/ui-select.esm.js +95 -9
  118. package/dist/ui-select.esm.js.map +1 -1
  119. package/dist/ui.cjs.js +679 -173
  120. package/dist/ui.cjs.js.map +1 -1
  121. package/dist/ui.esm.js +679 -175
  122. package/dist/ui.esm.js.map +1 -1
  123. package/dist/vike/config/package.json +1 -1
  124. package/dist/vite/plugin.cjs.js +968 -4
  125. package/dist/vite/plugin.cjs.js.map +1 -1
  126. package/dist/vite/plugin.mjs +968 -4
  127. package/dist/vite/plugin.mjs.map +1 -1
  128. package/dist/worker-page.cjs.js +669 -0
  129. package/dist/worker-page.cjs.js.map +1 -0
  130. package/dist/worker-page.esm.js +665 -0
  131. package/dist/worker-page.esm.js.map +1 -0
  132. package/dist/worker.cjs.js +377 -0
  133. package/dist/worker.cjs.js.map +1 -0
  134. package/dist/worker.esm.js +374 -0
  135. package/dist/worker.esm.js.map +1 -0
  136. package/dist/yjs-testing.cjs.js +98 -0
  137. package/dist/yjs-testing.cjs.js.map +1 -0
  138. package/dist/yjs-testing.esm.js +77 -0
  139. package/dist/yjs-testing.esm.js.map +1 -0
  140. package/dist/yjs.cjs.js +923 -0
  141. package/dist/yjs.cjs.js.map +1 -0
  142. package/dist/yjs.esm.js +900 -0
  143. package/dist/yjs.esm.js.map +1 -0
  144. package/dist/zag.cjs.js +11 -1
  145. package/dist/zag.cjs.js.map +1 -1
  146. package/dist/zag.esm.js +11 -1
  147. package/dist/zag.esm.js.map +1 -1
  148. package/llms/ai.md +65 -0
  149. package/llms/collaboration.md +5 -0
  150. package/llms/graphics.md +10 -0
  151. package/llms/renderers.md +7 -0
  152. package/llms/replay.md +5 -0
  153. package/llms/sandbox.md +5 -0
  154. package/llms/typed-apis.md +5 -0
  155. package/llms/ui.md +10 -0
  156. package/llms-full.txt +444 -0
  157. package/llms.txt +37 -35
  158. package/package.json +139 -3
  159. package/src/agent-ui/mcp.ts +152 -0
  160. package/src/agent-ui/snapshot.ts +212 -0
  161. package/src/agent-ui.d.ts +106 -0
  162. package/src/agent-ui.ts +249 -0
  163. package/src/ai.d.ts +95 -2
  164. package/src/api.d.ts +303 -0
  165. package/src/api.ts +9 -0
  166. package/src/canvas/Canvas.ts +585 -0
  167. package/src/canvas/backend.ts +627 -0
  168. package/src/canvas/geometry.ts +187 -0
  169. package/src/canvas/gesture.ts +23 -0
  170. package/src/canvas/image.ts +197 -0
  171. package/src/canvas/kit.ts +130 -0
  172. package/src/canvas/paint.ts +260 -0
  173. package/src/canvas/pointer.ts +25 -0
  174. package/src/canvas/snap.ts +68 -0
  175. package/src/canvas/svg.ts +99 -0
  176. package/src/canvas.d.ts +373 -0
  177. package/src/canvas.ts +46 -0
  178. package/src/core/actions.ts +14 -3
  179. package/src/core/cell.ts +12 -4
  180. package/src/core/debug.ts +8 -3
  181. package/src/core/hooks.ts +16 -2
  182. package/src/core/hosts/collection.ts +1 -1
  183. package/src/core/hosts/switchable.ts +26 -8
  184. package/src/core/instance.ts +98 -45
  185. package/src/core/markers/portal.ts +4 -0
  186. package/src/core/registry.ts +4 -0
  187. package/src/core/runtime.ts +34 -25
  188. package/src/core/statics.ts +5 -5
  189. package/src/cycle/dom/EventDelegator.ts +37 -33
  190. package/src/cycle/dom/MainDOMSource.ts +16 -14
  191. package/src/cycle/dom/enrichEventStream.ts +13 -9
  192. package/src/cycle/dom/fromEvent.ts +6 -10
  193. package/src/cycle/dom/index.ts +11 -7
  194. package/src/cycle/dom/isolate.ts +13 -6
  195. package/src/cycle/dom/makeDOMDriver.ts +6 -2
  196. package/src/cycle/dom/utils.ts +9 -2
  197. package/src/cycle/run/adapt.ts +4 -12
  198. package/src/devtools.d.ts +9 -0
  199. package/src/devtools.ts +2 -1
  200. package/src/extra/ai/compose/catalog.ts +2 -1
  201. package/src/extra/ai/compose/decisions.ts +1 -2
  202. package/src/extra/ai/compose/generated.ts +234 -0
  203. package/src/extra/ai/compose/index.ts +6 -2
  204. package/src/extra/ai/compose/layout.ts +3 -1
  205. package/src/extra/ai/compose/llm.ts +7 -3
  206. package/src/extra/ai/compose/types.ts +2 -2
  207. package/src/extra/ai/transports/shared.ts +3 -1
  208. package/src/extra/api/contract.ts +485 -0
  209. package/src/extra/api/index.ts +26 -0
  210. package/src/extra/behaviors.ts +3 -3
  211. package/src/extra/controlled.ts +44 -0
  212. package/src/extra/copyAsTest.ts +22 -0
  213. package/src/extra/devtools.ts +5 -3
  214. package/src/extra/devtoolsActions.ts +8 -0
  215. package/src/extra/devtoolsNext.ts +1 -1
  216. package/src/extra/diagnostics/checks/actionLog.ts +8 -3
  217. package/src/extra/diagnostics/checks/behaviors.ts +25 -0
  218. package/src/extra/diagnostics/checks/elementCommands.ts +17 -5
  219. package/src/extra/diagnostics/checks/index.ts +1 -1
  220. package/src/extra/diagnostics/checks/next.ts +78 -4
  221. package/src/extra/diagnostics/checks/props.ts +6 -1
  222. package/src/extra/diagnostics/checks/replies.ts +11 -7
  223. package/src/extra/diagnostics/checks/timers.ts +8 -2
  224. package/src/extra/diagnostics/checks/virtual.ts +11 -0
  225. package/src/extra/diagnostics/codes.ts +51 -1
  226. package/src/extra/elementCommands.ts +20 -4
  227. package/src/extra/fetchDriver.ts +4 -1
  228. package/src/extra/fetchEventSource.ts +6 -2
  229. package/src/extra/field.ts +29 -0
  230. package/src/extra/form.ts +42 -16
  231. package/src/extra/frame.ts +54 -0
  232. package/src/extra/keymap.ts +41 -0
  233. package/src/extra/pager.ts +2 -0
  234. package/src/extra/persist.ts +2 -1
  235. package/src/extra/pointer.ts +262 -0
  236. package/src/extra/processForm.ts +34 -1
  237. package/src/extra/replay/address.ts +49 -0
  238. package/src/extra/replay/entropy.ts +99 -0
  239. package/src/extra/replay/format.ts +263 -0
  240. package/src/extra/replay/index.ts +8 -0
  241. package/src/extra/replay/inert.ts +22 -0
  242. package/src/extra/replay/recorder.ts +247 -0
  243. package/src/extra/replay/replayer.ts +234 -0
  244. package/src/extra/replay/report.ts +126 -0
  245. package/src/extra/replay/schema.ts +52 -0
  246. package/src/extra/replay/version.ts +3 -0
  247. package/src/extra/router.ts +7 -3
  248. package/src/extra/selection.ts +2 -0
  249. package/src/extra/socketDriver.ts +51 -6
  250. package/src/extra/ssr.ts +128 -42
  251. package/src/extra/testing.ts +295 -39
  252. package/src/extra/timers.ts +96 -7
  253. package/src/extra/undo.ts +2 -0
  254. package/src/extra/virtual.ts +25 -2
  255. package/src/extra/widget.ts +106 -17
  256. package/src/host/actions.ts +383 -0
  257. package/src/host/delegation.ts +101 -0
  258. package/src/host/dom.ts +553 -0
  259. package/src/host/driver.ts +185 -0
  260. package/src/host/selector.ts +119 -0
  261. package/src/host/style.ts +51 -0
  262. package/src/host.d.ts +298 -0
  263. package/src/host.ts +46 -0
  264. package/src/index.d.ts +302 -38
  265. package/src/index.ts +15 -3
  266. package/src/pragma/blocks.ts +211 -0
  267. package/src/pragma/index.ts +44 -10
  268. package/src/primitives.d.ts +54 -0
  269. package/src/primitives.ts +136 -0
  270. package/src/react-native.d.ts +57 -0
  271. package/src/react-native.ts +285 -0
  272. package/src/react-peers.d.ts +21 -3
  273. package/src/replay.d.ts +185 -0
  274. package/src/replay.ts +6 -0
  275. package/src/sandbox/build.ts +119 -0
  276. package/src/sandbox/create.ts +187 -0
  277. package/src/sandbox/engine.ts +267 -0
  278. package/src/sandbox/generated.ts +195 -0
  279. package/src/sandbox/kernel.ts +180 -0
  280. package/src/sandbox/mirror.ts +269 -0
  281. package/src/sandbox/protocol.ts +128 -0
  282. package/src/sandbox/quickjs.ts +74 -0
  283. package/src/sandbox/ssr.ts +41 -0
  284. package/src/sandbox/widget.ts +60 -0
  285. package/src/sandbox/worker-entry.ts +59 -0
  286. package/src/sandbox/worker.ts +137 -0
  287. package/src/sandbox-build.d.ts +12 -0
  288. package/src/sandbox-build.ts +6 -0
  289. package/src/sandbox.d.ts +299 -0
  290. package/src/sandbox.ts +25 -0
  291. package/src/shared.ts +12 -0
  292. package/src/terminal/paint.ts +712 -0
  293. package/src/terminal.d.ts +167 -0
  294. package/src/terminal.ts +530 -0
  295. package/src/three/assets.ts +170 -0
  296. package/src/three/host.ts +36 -0
  297. package/src/three/scene.ts +217 -0
  298. package/src/three/target.ts +252 -0
  299. package/src/three.d.ts +221 -0
  300. package/src/three.ts +30 -0
  301. package/src/ui/accordion.ts +21 -7
  302. package/src/ui/dialog.ts +192 -90
  303. package/src/ui/disclosure.ts +2 -0
  304. package/src/ui/dom.ts +30 -0
  305. package/src/ui/layers.ts +132 -0
  306. package/src/ui/popover.ts +120 -21
  307. package/src/ui/shared.ts +98 -12
  308. package/src/ui/stack.ts +18 -0
  309. package/src/ui/tabs.ts +2 -0
  310. package/src/ui/toaster.ts +16 -15
  311. package/src/ui/tooltip.ts +24 -8
  312. package/src/ui/tooltips.ts +26 -14
  313. package/src/ui/zag/combobox.ts +180 -13
  314. package/src/ui/zag/menu.ts +97 -17
  315. package/src/ui/zag/select.ts +15 -4
  316. package/src/ui/zag/shared.ts +60 -8
  317. package/src/ui-combobox.d.ts +16 -1
  318. package/src/ui-menu.d.ts +22 -7
  319. package/src/ui-zag-types.d.ts +13 -5
  320. package/src/ui.d.ts +62 -7
  321. package/src/ui.ts +2 -2
  322. package/src/vite/api.ts +186 -0
  323. package/src/vite/compile.ts +280 -0
  324. package/src/vite/plugin.d.ts +34 -0
  325. package/src/vite/plugin.ts +78 -4
  326. package/src/vite/sandbox.ts +16 -0
  327. package/src/worker/page.ts +396 -0
  328. package/src/worker/protocol.ts +48 -0
  329. package/src/worker/target.ts +282 -0
  330. package/src/worker-page.d.ts +42 -0
  331. package/src/worker-page.ts +12 -0
  332. package/src/worker.d.ts +65 -0
  333. package/src/worker.ts +16 -0
  334. package/src/yjs/driver.ts +253 -0
  335. package/src/yjs/mapping.ts +389 -0
  336. package/src/yjs/shared.ts +138 -0
  337. package/src/yjs/testing.ts +91 -0
  338. package/src/yjs-testing.d.ts +34 -0
  339. package/src/yjs-testing.ts +7 -0
  340. package/src/yjs.d.ts +109 -0
  341. package/src/yjs.ts +18 -0
  342. package/src/zag.d.ts +2 -0
  343. package/src/zag.ts +8 -1
@@ -1,20 +1,22 @@
1
1
  <!-- Generated from docs/src/content/docs/ui by scripts/copy-guides.mjs; online: https://sygnal.js.org/ui/ -->
2
2
  # Dialog
3
3
 
4
- `dialog()` is a [behavior](../behaviors.md) for a native `<dialog>`. It opens the dialog with `showModal()`, so the browser does the hard parts: it traps the focus inside the dialog, makes the rest of the page inert, closes it on Escape and returns the focus when it closes. The behavior keeps `open` and `returnValue` in state, whichever way the dialog closed.
4
+ `dialog()` is a [behavior](../behaviors.md) for a native `<dialog>`. It opens the dialog with `showModal()`, so the browser does the hard parts: it traps the focus inside the dialog, makes the rest of the page inert, closes it on Escape and returns the focus when it closes. The behavior keeps `open` and `returnValue` in state, and `open` drives the dialog: a reducer that writes it opens or closes it.
5
5
 
6
6
  ```jsx live
7
- import { dialog } from 'sygnal/ui'
7
+ import { dialog, dialogAttrs } from 'sygnal/ui'
8
8
 
9
9
  function Profile({ state, uid }) {
10
+ const a = dialogAttrs(state.profile, uid)
10
11
  return (
11
12
  <div>
12
- <button className="edit-profile">Edit profile</button>
13
- <dialog className="profile" aria-labelledby={uid('title')}>
14
- <h2 id={uid('title')}>Edit profile</h2>
13
+ <button className="edit-profile" {...a.trigger}>Edit profile</button>
14
+ <dialog className="profile" {...a.dialog}>
15
+ <h2 {...a.title}>Edit profile</h2>
16
+ <p {...a.description}>Your name as others see it.</p>
15
17
  <label>Name <input className="name" value={state.name} /></label>
16
18
  <button className="save">Save</button>
17
- <button className="cancel">Cancel</button>
19
+ <button className="cancel" {...a.close}>Cancel</button>
18
20
  </dialog>
19
21
  <p>{state.profile.returnValue === 'saved' ? 'Profile saved' : ''}</p>
20
22
  </div>
@@ -33,7 +35,7 @@ Profile.model = {
33
35
  }
34
36
  ```
35
37
 
36
- A click on `.edit-profile` opens the dialog and a click on `.cancel` closes it. Save closes it from the model with a return value, through the built-in [`ELEMENT` sink](../element-commands.md).
38
+ A click on `.edit-profile` opens the dialog and a click on `.cancel` closes it. Save closes it from the model with a return value, through the built-in [`ELEMENT` sink](../element-commands.md). `dialogAttrs` links the elements for assistive technology ([The parts' attributes](#the-parts-attributes)).
37
39
 
38
40
  ## Options
39
41
 
@@ -43,25 +45,70 @@ A click on `.edit-profile` opens the dialog and a click on `.cancel` closes it.
43
45
  | `trigger` | | Its clicks open the dialog |
44
46
  | `close` | | Its clicks close the dialog, with the return value `''` |
45
47
  | `modal` | `true` | `false` opens it with `show()`: not modal, the page stays usable |
46
- | `cancelable` | `true` | `false` keeps Escape from closing it: the `cancel` event is prevented, and opening it sets `closedby="none"` (Chromium closes a dialog on a second Escape otherwise; the attribute goes again when it closes). `CANCEL` still runs on each Escape. In a browser without `closedby`, a second Escape can still close it; the `CLOSED` action tells you |
48
+ | `cancelable` | `true` | `false`: Escape doesn't close it. `CANCEL` still runs on each Escape, and the state stays open |
49
+ | `open` | | Open at the start. Given at all, even `open: false`, it makes the dialog controlled: the browser never dismisses it, the behavior does (see [Controlled dismissal](#controlled-dismissal)) |
50
+ | `id` | the key in `uses` | The prefix of the ids `dialogAttrs` makes |
47
51
  | `returnFocus` | `true` | When the dialog closes and the focus was lost, focus the element that opened it (the clicked trigger, when several match `trigger`), else the trigger. A selector names another element; `false` leaves the focus to the browser |
48
52
 
49
53
  The browser returns the focus to the element that had it before the dialog opened. Safari doesn't focus a button when it is clicked, so after a mouse click the focus would go back to the page itself; `returnFocus` focuses the trigger instead. It does nothing when the browser already moved the focus somewhere. It works for a dialog the host renders only while it is open, too.
50
54
 
51
55
  ## State and actions
52
56
 
53
- `state.profile` is `{ open, returnValue }`.
57
+ `state.profile` is `{ id, open, returnValue }` (`id`: the prefix of `dialogAttrs`' ids); `open` drives the dialog (see [Controlled open](#controlled-open)).
54
58
 
55
59
  | Action | Data | |
56
60
  |---|---|---|
57
- | `profile.OPEN` | | Opens the dialog (no change when it is open) |
58
- | `profile.CLOSE` | the return value | Closes it with that return value |
59
- | `profile.CLOSED` | the return value | The dialog closed, whichever way: sets `open: false` and `returnValue` |
60
- | `profile.TOGGLED` | `true` / `false` | The dialog's `toggle` event: a dialog opened without the model (for example with `commandfor`) sets `open: true` |
61
- | `profile.CANCEL` | the event | Escape was pressed (the dialog closes next, unless `cancelable: false`). The `cancel` event; with `cancelable: false`, the Escape `keydown` in the dialog (`closedby="none"` stops the `cancel` event) |
62
- | `profile.SYNC` | `false` | The dialog left the page while open (a page change, a conditional render), or OPEN's `showModal()` threw (a disconnected dialog, a popover open on it): sets `open: false`, as no close event comes (your `close` listeners don't run for a dialog that never opened) |
61
+ | `profile.OPEN` | | Sets `open: true` and opens the dialog: the trigger, a `commandfor` button with `command="show-modal"` |
62
+ | `profile.CLOSE` | the return value | Sets `open: false` with that return value and closes it: the `close` button, a `method="dialog"` form's submit (the submit button's `value`), a `commandfor` button with `command="close"` |
63
+ | `profile.CANCEL` | the event | Escape, a light dismiss (`closedby="any"`; controlled: a press or a focus outside), or `command="request-close"`: sets `open: false` with the return value `''`, unless `cancelable: false` |
64
+ | `profile.CLOSED` | the return value | The dialog's `close` event: sets `open: false` and `returnValue` when it closed some other way |
65
+ | `profile.TOGGLED` | `true` / `false` | The dialog's `toggle` event: a dialog opened without the model (`showModal()` called elsewhere) sets `open: true` |
66
+ | `profile.SYNC` | `false`, or `true` when `showModal()` threw | The dialog left the page while open (a page change, a conditional render), or OPEN's `showModal()` threw (a disconnected dialog, a popover open on it): sets `open: false`, as no close event comes (your `close` listeners don't run for a dialog that never opened) |
63
67
 
64
- `OPEN` and `CLOSE` check the dialog itself before they act: `showModal()` only on a closed dialog, `close()` only on an open one. A dialog removed while open opens again when it is back.
68
+ | `profile.APPLY` | `true` / `false` | `open` changed outside these actions (your reducer wrote it): the dialog is opened or closed to match |
69
+
70
+ The commands check the dialog itself before they act: `showModal()` only on a closed dialog, `close()` only on an open one.
71
+
72
+ ## Controlled open
73
+
74
+ `state.profile.open` is the source of truth, as Radix's controlled `open` is. Write it in any reducer and the dialog opens or closes to match:
75
+
76
+ ```jsx
77
+ Profile.model = {
78
+ EDIT_FROM_LINK: (state) => ({ ...state, profile: { ...state.profile, open: true } }),
79
+ }
80
+ ```
81
+
82
+ Everything that would close the dialog arrives as an action first: Escape and a light dismiss as `CANCEL`, the close button, a `method="dialog"` form and `commandfor` as `CLOSE`. The behavior prevents the browser's own close and sets `open: false`; a host entry for the same action runs after it and decides. To keep the dialog open, return the state from before the action, as Radix's `onOpenChange(false)` does when you ignore it:
83
+
84
+ ```jsx
85
+ Profile.model = {
86
+ // unsaved changes: Escape and the form don't close it
87
+ 'profile.CANCEL': (state, event, next, { previousState }) => (state.dirty ? previousState : state),
88
+ 'profile.CLOSE': (state, returnValue, next, { previousState }) => (state.dirty && returnValue !== 'discard' ? previousState : state),
89
+ }
90
+ ```
91
+
92
+ The dialog never closes in between, so the focus stays where it was. For Escape the behavior gives a modal dialog `closedby="none"` while it is open (unless you set `closedby` yourself) and reads the key, so a second Escape can't close it behind the state's back. A close the browser makes on its own (your own code calling `close()`) arrives as `CLOSED`; a host entry that keeps `open: true` there opens the dialog again.
93
+
94
+ ### Controlled dismissal
95
+
96
+ Pass the `open` option, even `open: false`, and the browser never dismisses the dialog: it keeps `closedby="none"` while it is open, also when you set `closedby`, and the behavior dismisses it itself, as Radix's `DismissableLayer` does. Your `closedby` says on what, and each one arrives as `CANCEL` with its event as the data:
97
+
98
+ | `closedby` | Dismissed by |
99
+ |---|---|
100
+ | (none) | Escape for a modal dialog, nothing for a non-modal one |
101
+ | `"closerequest"` | Escape |
102
+ | `"any"` | Escape, a press outside the dialog (on its backdrop), the focus moving out of it |
103
+ | `"none"` | nothing |
104
+
105
+ A refused `CANCEL` leaves the dialog open with no close in between, whatever dismissed it. Without the `open` option a light dismiss (`closedby="any"`) is the browser's `cancel` event, which the behavior prevents; a browser may refuse to let a cancel be prevented without a user gesture since the last one (the CloseWatcher rule), and then the dialog closes and opens again.
106
+
107
+ Dialogs and [controlled popovers](./popover.md#controlled-dismissal) stack as layers. Escape dismisses the top layer only, so a controlled popover open in a dialog closes first and the next Escape closes the dialog. A press on the dialog outside the popover dismisses the popover only, and a press outside both dismisses both, each through its own action. A layer opened in another one counts as inside it, also when it renders through a `<Portal>`.
108
+
109
+ One interaction is one `CANCEL`: a press on the backdrop that also moves the focus reports the `pointerdown`. An open [Menu](./menu.md), [Select](./select.md) or [Combobox](./combobox.md) in the dialog is a layer above it: Escape and a press outside close it first, and a press in its list isn't outside the dialog.
110
+
111
+ The dialog works inside a [`<Portal>`](https://sygnal.js.org/advanced/portals/) too: the behavior finds its element, its events and its commands in the portal's content. The top layer already draws a modal dialog above the page, so a portal is rarely needed.
65
112
 
66
113
  React to a close in your own model with an entry for the namespaced action. It runs after the behavior's, on the full state:
67
114
 
@@ -71,14 +118,43 @@ Profile.model = {
71
118
  }
72
119
  ```
73
120
 
74
- Open the dialog from another place with an intent action of the same name, `'profile.OPEN': DOM.click('.avatar')`, or from a model entry that sends `{ showModal: '.profile' }` to `ELEMENT`.
121
+ Open the dialog from another button by listing it in the option, `trigger: '.edit-profile, .avatar'`, from a model entry with `next('profile.OPEN')`, or by writing `open: true`. An intent action of the same name, `'profile.OPEN': DOM.click('.avatar')`, replaces the `trigger` option's clicks instead of adding to them ([SYG118](https://sygnal.js.org/reference/errors/#syg118)). (A `{ showModal: '.profile' }` command to `ELEMENT` opens it too, but skips what `OPEN` adds: the `returnFocus` opener, the `closedby` that routes Escape to `CANCEL`, and clearing the last close's return value.)
122
+
123
+ ## The parts' attributes
124
+
125
+ `dialogAttrs(state.profile, uid)` gives each element the attributes Radix's Dialog parts put on theirs, computed from the slice. Spread them on your own markup:
126
+
127
+ | Part | Spread on | Attributes |
128
+ |---|---|---|
129
+ | `trigger` | the button that opens it | `type="button"`, `aria-haspopup="dialog"`, `aria-expanded`, `aria-controls` (the dialog's id), `data-state` |
130
+ | `dialog` | the `<dialog>` | `id`, `aria-labelledby` (the title), `aria-describedby` (the description), `data-state` |
131
+ | `title` | its heading | `id` |
132
+ | `description` | a paragraph that describes it | `id` |
133
+ | `close` | a close button | `type="button"` |
134
+
135
+ `data-state` is `open` or `closed`. The ids come from `uid()`, so each instance of the component gets its own, and the `id` option names their prefix. A dialog without a description writes `aria-describedby={undefined}` after the spread, as Radix asks for one without `Description`.
136
+
137
+ There's no `role`: a `<dialog>` has `role="dialog"` already, and a modal one is `aria-modal`. There's no overlay part either: the backdrop is `::backdrop`.
138
+
139
+ ### Alert dialogs
140
+
141
+ For a confirmation that needs an answer (Radix's `AlertDialog`), write `role="alertdialog"` on the `<dialog>`, after the spread, and give it a description: a screen reader reads it when the dialog opens. A modal dialog already ignores presses outside it; to keep Escape from closing it as well, pass `cancelable: false`. Open it with the focus on the safe answer by giving Cancel `autofocus`:
142
+
143
+ ```jsx
144
+ <dialog className="confirm" {...a.dialog} role="alertdialog">
145
+ <h2 {...a.title}>Delete the file?</h2>
146
+ <p {...a.description}>This can't be undone.</p>
147
+ <button className="cancel" {...a.close} autofocus>Cancel</button>
148
+ <button className="delete">Delete</button>
149
+ </dialog>
150
+ ```
75
151
 
76
152
  ## Accessibility
77
153
 
78
- - Name the dialog: `aria-labelledby` pointing at its heading, as above, or `aria-label`.
154
+ - Name the dialog: spread `dialogAttrs`' `dialog` and `title` (`aria-labelledby` pointing at its heading), or write `aria-label`.
79
155
  - Put a close button inside. Escape closes a modal dialog, but not everyone has a keyboard.
80
156
  - The dialog element has `role="dialog"`; a modal one is also `aria-modal` to assistive technology.
81
- - A form inside the dialog with `method="dialog"` closes it on submit, with the submit button's `value` as the return value; `CLOSED` sees it.
157
+ - A form inside the dialog with `method="dialog"` closes it on submit, with the submit button's `value` as the return value (the `CLOSE` action's data). Enter in a field submits with the form's first submit button, so put the confirming button first and make Cancel `type="button"` (the `close` option), or Enter cancels. `OPEN` clears the element's old return value, so an Escape after an earlier `'ok'` reads as `''` in every browser.
82
158
 
83
159
  ## Styling
84
160
 
@@ -121,4 +197,4 @@ it('opens the dialog and keeps the return value', async () => {
121
197
  })
122
198
  ```
123
199
 
124
- With `dom: 'real'`, the commands run on the jsdom elements; focus trapping and the backdrop need a real browser.
200
+ With `dom: 'real'`, the commands run on the jsdom elements; focus trapping and the backdrop need a real browser. A reducer that writes `open` is applied by `APPLY`, which compares the state with the element: test it with `dom: 'real'` (renderComponent fakes jsdom's missing dialog methods) or in a browser; on the mock DOM, check `t.state`.
@@ -50,15 +50,15 @@ Without them, `sygnal/vite` stops with [SYG666](https://sygnal.js.org/reference/
50
50
 
51
51
  | Prop | | |
52
52
  |---|---|---|
53
- | `label` | (required) | The trigger's text: its accessible name |
53
+ | `label` | | The trigger's content: its text, and its accessible name. For an icon trigger, pass the icon as `label` and name it with `aria-label` |
54
54
  | `aria-label`, `aria-labelledby`, `aria-describedby` | | Name or describe the trigger when there is no visible `label` (or in addition to it); they go on the trigger, not the host |
55
- | `items` | (required) | Strings, or `{ value, label, disabled }` objects; `{ separator: true }` draws a separator |
55
+ | `items` | (required) | Strings, or `{ value, label, disabled }` objects; `{ separator: true }` draws a separator; checkbox and radio items, links and submenus: see [Item kinds](#item-kinds) |
56
56
  | `open` | | Controlled open state; follow the `open-change` event |
57
57
  | `defaultOpen` | `false` | |
58
58
  | `closeOnSelect` | `true` | `false` keeps the menu open after a pick |
59
59
  | `loopFocus` | `false` | The arrow keys wrap from the last item to the first |
60
60
  | `typeahead` | `true` | Typing a letter moves to the next item starting with it |
61
- | `positioning` | | Zag's positioning options: `{ placement: 'bottom-end', gutter: 4 }` |
61
+ | `positioning` | `{ strategy: 'fixed' }` | Zag's positioning options: `{ placement: 'bottom-end', gutter: 4 }` ([Positioning](#positioning)) |
62
62
  | `dir` | `'ltr'` | |
63
63
 
64
64
  `className`, `id`, `style` and `aria-*` / `data-*` props go on the host `<div>`.
@@ -68,10 +68,59 @@ Without them, `sygnal/vite` stops with [SYG666](https://sygnal.js.org/reference/
68
68
  | Event | Detail |
69
69
  |---|---|
70
70
  | `select` | The value of the item picked |
71
+ | `checked-change` | A checkbox or radio item was picked: `{ value, checked, type }` |
71
72
  | `open-change` | `true` when it opens, `false` when it closes |
72
73
 
73
74
  Open or close it from the model with an [element command](../element-commands.md) on its class: `OPEN_ACTIONS: { ELEMENT: { open: '.card-actions' } }`, or `{ close: '.card-actions' }`.
74
75
 
76
+ ## Item kinds
77
+
78
+ Besides actions and separators, an item can be a checkbox, a radio, a link or a submenu, as in Radix's DropdownMenu:
79
+
80
+ ```jsx live live-height=280
81
+ import { Menu } from 'sygnal/ui/menu'
82
+
83
+ function Toolbar({ state }) {
84
+ const items = [
85
+ { type: 'checkbox', value: 'grid', label: 'Show grid', checked: state.grid },
86
+ { separator: true },
87
+ { type: 'radio', value: 'small', label: 'Small', checked: state.size === 'small' },
88
+ { type: 'radio', value: 'large', label: 'Large', checked: state.size === 'large' },
89
+ { separator: true },
90
+ { value: 'share', label: 'Share', items: [{ value: 'mail', label: 'Mail' }, { value: 'link', label: 'Copy link' }] },
91
+ { href: '#item-kinds', value: 'help', label: 'Help' },
92
+ ]
93
+ return (
94
+ <div className="toolbar">
95
+ <Menu className="view" label="View" items={items} />
96
+ <p>{`Grid ${state.grid ? 'on' : 'off'}, ${state.size}${state.last ? `; last: ${state.last}` : ''}`}</p>
97
+ </div>
98
+ )
99
+ }
100
+
101
+ Toolbar.initialState = { grid: true, size: 'small', last: '' }
102
+ Toolbar.intent = ({ DOM }) => ({
103
+ CHECK: DOM.select('.view').events('checked-change').detail(),
104
+ PICK: DOM.select('.view').events('select').detail(),
105
+ })
106
+ Toolbar.model = {
107
+ CHECK: (state, { value, checked, type }) => (type === 'checkbox' ? { ...state, [value]: checked } : { ...state, size: value }),
108
+ PICK: (state, last) => ({ ...state, last }),
109
+ }
110
+ ```
111
+
112
+ ```css live
113
+ .view [data-part='content'] { background: Canvas; border: 1px solid #ddd; border-radius: 6px; padding: 4px; min-width: 10rem; }
114
+ .view [data-part='item'], .view [data-part='trigger-item'] { display: flex; gap: 6px; padding: 4px 8px; border-radius: 4px; cursor: default; }
115
+ .view [data-part='item'][data-highlighted], .view [data-part='trigger-item'][data-highlighted] { background: rgb(99 102 241 / 0.25); }
116
+ .view [data-part='item-indicator'] { width: 1em; }
117
+ .view [data-part='submenu-indicator'] { margin-left: auto; }
118
+ ```
119
+
120
+ - **Checkbox and radio items**: `{ type: 'checkbox' | 'radio', value, label, checked }`, with role `menuitemcheckbox` / `menuitemradio` and a `✓` indicator (`[data-part="item-indicator"]`, `hidden` while unchecked). A pick reports `checked-change` (`{ value, checked, type }`; a radio's `checked` is always `true`) and `select`. The menu shows the `checked` the items say, so keep it in state.
121
+ - **Links**: `{ href, value, label, target, rel }` renders an `<a href>` menu item. Click or Enter follows the link (and reports `select`).
122
+ - **Submenus**: `{ value, label, items }`. The item (`[data-part="trigger-item"]`, with a `›` indicator) opens its own menu beside it on ArrowRight, Enter, Space or hover; ArrowLeft or Escape closes it and returns to the item. A pick inside it is the Menu's `select` and closes every level. Submenus nest. Each one is its own Zag machine, linked to its parent's.
123
+
75
124
  ## Keyboard
76
125
 
77
126
  | Key | |
@@ -81,13 +130,16 @@ Open or close it from the model with an [element command](../element-commands.md
81
130
  | Home / End | The first / last item |
82
131
  | A letter | The next item that starts with it |
83
132
  | Enter, Space | Pick the highlighted item and close |
133
+ | ArrowRight / ArrowLeft | On a submenu's item: open it / inside a submenu: close it (reversed with `dir="rtl"`) |
84
134
  | Escape, Tab | Close; Escape returns the focus to the trigger |
85
135
 
86
136
  A click outside closes it too.
87
137
 
138
+ Inside a [controlled popover](./popover.md#controlled-dismissal) or [dialog](./dialog.md#controlled-dismissal), the open list is the top layer: Escape and a press outside close it first, a press in it isn't outside the popover or dialog, and the next Escape closes that one.
139
+
88
140
  ## Styling
89
141
 
90
- The parts carry Zag's data attributes: `[data-scope="menu"]` with `[data-part="trigger"]`, `"positioner"`, `"content"`, `"item"` and `"separator"`; the content and trigger have `data-state="open" | "closed"`, an item `data-highlighted` and `data-disabled`. The content is `hidden` while closed, and the positioner places it with `position` and CSS variables (`--x`, `--y`).
142
+ The parts carry Zag's data attributes: `[data-scope="menu"]` with `[data-part="trigger"]`, `"positioner"`, `"content"`, `"item"`, `"separator"`, and for the item kinds `"item-indicator"`, `"item-text"`, `"trigger-item"` and `"submenu-indicator"`; the content and trigger have `data-state="open" | "closed"`, an item `data-highlighted` and `data-disabled`, a checkbox or radio item `data-type` and `data-state="checked" | "unchecked"`. The content is `hidden` while closed, and the positioner places it with `position` and CSS variables (`--x`, `--y`).
91
143
 
92
144
  ```css live
93
145
  .card-actions [data-part='content'] { background: Canvas; border: 1px solid #ddd; border-radius: 6px; padding: 4px; }
@@ -98,7 +150,7 @@ The parts carry Zag's data attributes: `[data-scope="menu"]` with `[data-part="t
98
150
 
99
151
  ## Positioning
100
152
 
101
- The menu renders inside the widget's host, next to the trigger, 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 menu 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 trigger 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' }}`.
153
+ The list renders inside the widget's host, next to its trigger, and Zag places it with `position: fixed` by default (as Radix's Popper does; the `--x` / `--y` variables on the positioner). So a container that clips its content (`overflow: hidden` or `auto`: a card, a scrolling panel, a table cell, a `<dialog>`) doesn't cut it off, and it still follows the trigger when the page scrolls. The caveat: an ancestor with a `transform`, `filter`, `perspective` or `contain` becomes the containing block of a fixed element, so inside one (an animated drawer, a scaled canvas) the list is placed and clipped relative to that ancestor. There, or to keep the list in the document flow, pass `positioning={{ strategy: 'absolute' }}`. Other options go in the same object and merge over the default: `positioning={{ placement: 'bottom-end', gutter: 4 }}`.
102
154
 
103
155
  ## Testing
104
156
 
@@ -9,8 +9,8 @@ import { dialog, tabs, tabsAttrs, Toaster } from 'sygnal/ui'
9
9
 
10
10
  | Part | What it is | Built on |
11
11
  |---|---|---|
12
- | [Dialog](./dialog.md) | A behavior for `uses` | `<dialog>`, `showModal()` |
13
- | [Popover](./popover.md) | A behavior for `uses` | The Popover API (`popover`, `popovertarget`) |
12
+ | [Dialog](./dialog.md) | A behavior for `uses` (+ `dialogAttrs`) | `<dialog>`, `showModal()` |
13
+ | [Popover](./popover.md) | A behavior for `uses` (+ `popoverAttrs` for a `trigger`) | The Popover API (`popover`, `popovertarget`) |
14
14
  | [Tooltip](./tooltip.md) | A behavior for `uses` | A manual popover placed by CSS anchor positioning, with show and hide delays |
15
15
  | [Tabs](./tabs.md) | A behavior + `tabsAttrs` | Your buttons and panels; roving focus |
16
16
  | [Accordion](./accordion.md) | A behavior + `accordionAttrs` | Your buttons and panels |
@@ -45,7 +45,7 @@ function Settings({ state, uid }) {
45
45
  Settings.uses = { settings: dialog({ dialog: '.settings', trigger: '.open-settings', close: '.close-settings' }) }
46
46
  ```
47
47
 
48
- `state.settings` is `{ open, returnValue }`, and the actions are `settings.OPEN`, `settings.CLOSE` and so on. Your own intent and model can trigger them and react to them, as with any behavior: an intent action `'settings.OPEN': DOM.click('.edit')` adds a trigger, and a model entry `'settings.CLOSED': (state) => …` runs after the behavior's.
48
+ `state.settings` is `{ id, open, returnValue }`, and the actions are `settings.OPEN`, `settings.CLOSE` and so on. Every slice is the source of truth, as Radix's controlled props are: a reducer that writes `open: true` opens the dialog, and one that keeps `open: true` when the user presses Escape keeps it open ([Controlled open](./dialog.md#controlled-open)). Give a dialog or popover its `open` option, even `open: false`, and the browser never dismisses it: the behavior turns Escape, a press outside and the focus moving outside into the close request, top layer first, so a refused one never hides it for a frame ([Controlled dismissal](./popover.md#controlled-dismissal)). The parts also work inside a [`<Portal>`](https://sygnal.js.org/advanced/portals/). Your own intent and model can trigger them and react to them, as with any behavior: a model entry `'settings.CLOSED': (state) => …` runs after the behavior's, and `next('settings.OPEN')` dispatches one from your model. An intent action of the same name, `'settings.OPEN': DOM.click('.edit')`, *replaces* the behavior's trigger for that action, so the `.open-settings` clicks stop opening it; to add a second opener, list both in the option: `trigger: '.open-settings, .edit'`.
49
49
 
50
50
  Tabs, Accordion and Disclosure have no markup of their own either. A helper gives you the attributes to spread on your elements, computed from the slice: roles, ARIA states, ids that link the buttons and panels, and the roving `tabindex`. It takes the view's `uid`, so each instance of the component gets its own ids:
51
51
 
@@ -54,6 +54,8 @@ const a = tabsAttrs(state.tabs, uid)
54
54
  // <div {...a.list} aria-label="Settings"> <button className="tab" {...a.tab('general')}>General</button> …
55
55
  ```
56
56
 
57
+ Dialog and Popover have helpers too, `dialogAttrs` and `popoverAttrs`, for their trigger's `aria-expanded` and `aria-controls` and, for a dialog, its name and description.
58
+
57
59
  The Toaster renders its own markup, so it is a component: render `<Toaster />` once and send `event('TOAST', { text })` from any component.
58
60
 
59
61
  ## Styling
@@ -63,7 +65,7 @@ Style the parts with your own classes, the ones you pass in the options. The par
63
65
  | Attribute | On | Values |
64
66
  |---|---|---|
65
67
  | `data-state` | tabs and tab panels | `active`, `inactive` |
66
- | `data-state` | accordion and disclosure buttons and panels | `open`, `closed` |
68
+ | `data-state` | accordion and disclosure buttons and panels; dialog and popover triggers and elements (from the helpers) | `open`, `closed` |
67
69
  | `data-value` | tabs, accordion buttons | the value you passed |
68
70
  | `data-kind` | each toast | `info`, `success`, `warning`, `error` |
69
71
  | `data-paused` | the toast region | present while paused |
@@ -74,6 +76,8 @@ The browser also exposes the open state: `dialog[open]`, `:modal`, `:popover-ope
74
76
 
75
77
  The parts target current evergreen browsers: Chromium (Chrome, Edge), Firefox and Safari. They rely on `<dialog>`, the Popover API (including `popovertarget` and `toggle` events), the `:modal` pseudo-class and, for the Tooltip, CSS anchor positioning (`anchor-name`, `position-anchor`, `position-area`). Sygnal's browser suite runs every part in Chromium, Firefox and WebKit.
76
78
 
79
+ The parts read the document of the element they act on, not the global one, so they also work in a shadow root (a [custom element](../web-components.md)), in an iframe, and on [`sygnal/host`](../renderers/custom-targets.md) trees such as the [terminal](../renderers/terminal.md#sygnalui-in-a-terminal).
80
+
77
81
  The package has no JavaScript positioning fallback. To support a browser without CSS anchor positioning, position the tooltip with [Floating UI](#floating-ui-for-older-browsers) instead.
78
82
 
79
83
  ### Floating UI for older browsers
@@ -32,32 +32,92 @@ Filters.model = { ONLY_OPEN: (state, onlyOpen) => ({ ...state, onlyOpen }) }
32
32
  | Option | | |
33
33
  |---|---|---|
34
34
  | `popover` | (required) | The popover element: a selector or a [control](https://sygnal.js.org/guide/controls/) |
35
+ | `trigger` | | A button whose clicks open and close the popover through the model (`TOGGLE`), as Radix's `Popover.Trigger`; see [A trigger button](#a-trigger-button) |
35
36
  | `close` | | A button inside: its clicks close the popover |
37
+ | `id` | the key in `uses` | The prefix of the ids `popoverAttrs` makes |
38
+ | `open` | | Open at the start. Given at all, even `open: false`, it makes the popover controlled (see [Controlled dismissal](#controlled-dismissal)) |
36
39
 
37
- There is no `trigger` option: open it with a `popovertarget` button. A button that toggled an `auto` popover through the model would open it again right after closing it, because the press outside the popover light-dismisses it first; the browser exempts `popovertarget` buttons from that.
40
+ ## A trigger button
41
+
42
+ A `popovertarget` button needs no option. To open and close the popover through the model instead, as Radix's `Popover.Trigger` does, name the button in `trigger` and spread `popoverAttrs` on both elements:
43
+
44
+ ```jsx live
45
+ import { popover, popoverAttrs } from 'sygnal/ui'
46
+
47
+ function Share({ state, uid }) {
48
+ const a = popoverAttrs(state.share, uid)
49
+ return (
50
+ <div>
51
+ <button className="share-button" {...a.trigger}>Share</button>
52
+ <div className="share" {...a.popover} popover="auto" aria-label="Share">
53
+ <button>Copy link</button>
54
+ <button>Email</button>
55
+ </div>
56
+ <p>{state.share.open ? 'Sharing…' : ''}</p>
57
+ </div>
58
+ )
59
+ }
60
+
61
+ Share.uses = { share: popover({ popover: '.share', trigger: '.share-button' }) }
62
+ ```
63
+
64
+ - A click on the trigger dispatches `share.TOGGLE` with the value to go to, `true` or `false`. The behavior reads it from the state at the press, because a press on the trigger light-dismisses an `auto` popover before the click comes: a plain flip would open it again.
65
+ - `popoverAttrs(state.share, uid)` gives the trigger `aria-haspopup="dialog"`, `aria-expanded`, `aria-controls` and `data-state`, and the popover its `id` and `data-state`.
66
+ - A press on the trigger isn't outside a [controlled](#controlled-dismissal) popover.
38
67
 
39
68
  ## State and actions
40
69
 
41
- `state.filters` is `{ open }`. It follows the popover's `toggle` event, whatever opened or closed it.
70
+ `state.filters` is `{ id, open }` (`id`: the prefix of `popoverAttrs`' ids), and `open` is the source of truth: a reducer that writes it shows or hides the popover, and the popover's `toggle` event writes it back, whatever opened or closed it.
42
71
 
43
72
  | Action | Data | |
44
73
  |---|---|---|
45
- | `filters.OPEN` | | Shows the popover (`showPopover`) |
46
- | `filters.CLOSE` | | Hides it (`hidePopover`) |
47
- | `filters.TOGGLE` | | Toggles it (`togglePopover`) |
48
- | `filters.TOGGLED` | `true` / `false` | The `toggle` event: sets `open` |
74
+ | `filters.OPEN` | | Sets `open: true` and shows the popover (`showPopover`) |
75
+ | `filters.CLOSE` | | Sets `open: false` and hides it (`hidePopover`) |
76
+ | `filters.TOGGLE` | none, or `true` / `false` | Flips `open` (with data: sets it) and shows or hides it: the `trigger` |
77
+ | `filters.TOGGLED` | `true` / `false` | The `toggle` event (a `popovertarget` button, a light dismiss, Escape): sets `open` |
78
+ | `filters.DISMISS` | the event | Controlled only: Escape, a press outside, the focus moving outside (the `keydown`, `pointerdown` or `focusin` event): sets `open: false` and hides it |
49
79
  | `filters.SYNC` | `false` | The popover left the page while open (a page change, a conditional render): sets `open: false`, as no `toggle` event comes |
50
80
 
51
- `OPEN` and `CLOSE` check the popover itself (`:popover-open`), not `state.filters.open`, which follows the `toggle` event a moment later: `OPEN` then `CLOSE` in one go leaves it closed, and `showPopover()` is never called on an open popover.
81
+ | `filters.APPLY` | `true` / `false` | `open` changed outside these actions (your reducer wrote it): the popover is shown or hidden to match |
82
+
83
+ The commands check the popover itself (`:popover-open`): `OPEN` then `CLOSE` in one go leaves it closed, and `showPopover()` is never called on an open popover.
84
+
85
+ The popover works inside a [`<Portal>`](https://sygnal.js.org/advanced/portals/) too.
86
+
87
+ ## Controlled dismissal
88
+
89
+ Without the `open` option the browser light-dismisses an `auto` popover, and the `toggle` event tells the state afterwards. A host entry for `filters.TOGGLED` that keeps `open: true` shows it again, but the browser has already hidden it, so it can disappear for a frame.
90
+
91
+ To decide before anything is hidden, pass the `open` option, even `open: false`. The popover is then controlled, as Radix's `open` / `onOpenChange` is:
92
+
93
+ - It is a `manual` popover (the behavior sets `popover="manual"` before it shows it), so the browser never dismisses it.
94
+ - The behavior dismisses it itself, as Radix's `DismissableLayer` does: Escape, a press outside and the focus moving outside arrive as `filters.DISMISS`, with the event as the data. One interaction is one `DISMISS`: a press outside that also moves the focus outside reports the `pointerdown`. A press on its `trigger` or its own `popovertarget` button isn't outside.
95
+ - Its `popovertarget` buttons and `commandfor` commands go through the model too: the behavior prevents the browser's own toggle and dispatches `TOGGLE`, `OPEN` or `CLOSE` (from `popovertargetaction`).
96
+
97
+ A host entry that keeps `open: true` keeps it shown, with no frame where it is hidden:
98
+
99
+ ```jsx
100
+ Filters.uses = { filters: popover({ popover: '.filters', close: '.filters-done', open: false }) }
101
+ Filters.model = {
102
+ // pinned: Escape and a click elsewhere leave it open
103
+ 'filters.DISMISS': (state, event, next, { previousState }) => (state.pinned ? previousState : state),
104
+ }
105
+ ```
106
+
107
+ Controlled popovers and [dialogs](./dialog.md#controlled-dismissal) stack as layers. Escape dismisses the top layer only (not while an `auto` popover inside it is open, which the browser closes first), and an Escape that an element inside handled (`preventDefault()`) dismisses nothing. A press on a parent layer outside a child dismisses the child only, and a press outside both dismisses both. A layer opened in another one counts as inside it, also when it renders through a `<Portal>`. Under a modal dialog, presses can't reach the page, so the layers under it aren't dismissed by them.
108
+
109
+ The [Menu](./menu.md), [Select](./select.md) and [Combobox](./combobox.md) parts are layers too. One open inside a controlled popover or dialog is the top layer: Escape and a press outside it close it first, and a press in it (in its list as well) isn't outside the popover. The next Escape closes the popover, and a press outside both closes both.
110
+
111
+ A modal dialog that isn't rendered inside the popover moves the focus out of it when it opens, so it dismisses the popover, as in Radix. Keep the popover open by rendering the dialog inside it, or with a `DISMISS` entry that refuses while the dialog is open.
52
112
 
53
113
  Open it from somewhere else, for example a keyboard shortcut, with an intent action of the same name: `'filters.OPEN': DOM.keydown('document').filter((e) => e.key === 'f' && e.altKey)`.
54
114
 
55
115
  ## `auto` or `manual`
56
116
 
57
117
  - `popover="auto"` (or just `popover`): light dismiss, and opening one closes the other `auto` popovers that aren't its ancestors. Menus, pickers and filter panels.
58
- - `popover="manual"`: only your code and its `popovertarget` button close it. Use it for something that stays open, such as a [tooltip](./tooltip.md) or [toasts](./toaster.md).
118
+ - `popover="manual"`: only your code and its `popovertarget` button close it. Use it for something that stays open, such as a [tooltip](./tooltip.md) or [toasts](./toaster.md). A [controlled](#controlled-dismissal) popover is a manual one with the dismissal done by the behavior.
59
119
 
60
- A popover inside a modal [dialog](./dialog.md) works: it opens above the dialog, the first Escape closes the popover and the second the dialog.
120
+ A popover inside a modal [dialog](./dialog.md) works: it opens above the dialog, the first Escape closes the popover and the second the dialog (controlled or not).
61
121
 
62
122
  ## Accessibility
63
123
 
@@ -42,7 +42,7 @@ Install Zag's packages first: `npm install @zag-js/vanilla@~1.45.0 @zag-js/selec
42
42
  |---|---|---|
43
43
  | `label` | | The visible label, linked to the trigger (its accessible name). Give every select one |
44
44
  | `aria-label`, `aria-labelledby`, `aria-describedby` | | Name or describe the trigger when there is no visible `label` (or in addition to it); they go on the trigger, not the host |
45
- | `items` | (required) | Strings, or `{ value, label, disabled }` objects. Values are strings |
45
+ | `items` | (required) | Strings, or `{ value, label, disabled }` objects, or [groups](#groups) of them. Values are strings: a number value comes back as a string in `value-change` |
46
46
  | `value` | | Controlled: the value (an array with `multiple`); `null` for none |
47
47
  | `defaultValue` | | The start value when `value` is left out |
48
48
  | `placeholder` | | Shown while nothing is selected |
@@ -52,9 +52,57 @@ Install Zag's packages first: `npm install @zag-js/vanilla@~1.45.0 @zag-js/selec
52
52
  | `deselectable` | `false` | A click on the selected option clears it |
53
53
  | `closeOnSelect` | `true` | With `multiple`: `false` |
54
54
  | `loopFocus` | `false` | |
55
- | `positioning` | | Zag's positioning options: `{ placement: 'bottom-start', sameWidth: true }` |
55
+ | `positioning` | `{ strategy: 'fixed' }` | Zag's positioning options: `{ placement: 'bottom-start', sameWidth: true }` ([Positioning](#positioning)) |
56
56
  | `open` | | Controlled open state; follow `open-change` |
57
57
 
58
+ ## Groups
59
+
60
+ An item can be a group of items, `{ group, items }`. Each group renders as a `role="group"` named by its label (`[data-part="item-group"]`, `[data-part="item-group-label"]`); the arrow keys and typeahead go through the groups in order:
61
+
62
+ ```jsx live live-height=280
63
+ import { Select } from 'sygnal/ui/select'
64
+
65
+ const FOOD = [
66
+ { group: 'Fruit', items: ['Apple', 'Banana', 'Cherry'] },
67
+ { group: 'Vegetables', items: [{ value: 'kale', label: 'Kale' }, { value: 'leek', label: 'Leek' }] },
68
+ ]
69
+
70
+ function Lunch({ state }) {
71
+ return (
72
+ <div className="lunch">
73
+ <Select className="food" label="Food" items={FOOD} value={state.food} placeholder="Pick one" />
74
+ <p>{state.food ? `Picked: ${state.food}` : ''}</p>
75
+ </div>
76
+ )
77
+ }
78
+
79
+ Lunch.initialState = { food: null }
80
+ Lunch.intent = ({ DOM }) => ({
81
+ FOOD: DOM.select('.food').events('value-change').detail(),
82
+ })
83
+ Lunch.model = {
84
+ FOOD: (state, food) => ({ ...state, food }),
85
+ }
86
+ ```
87
+
88
+ ```css live
89
+ .food [data-part='content'] { background: Canvas; border: 1px solid #ddd; padding: 4px; }
90
+ .food [data-part='item-group-label'] { font-size: 0.75rem; font-weight: 600; opacity: 0.7; padding: 4px 8px 0; }
91
+ .food [data-part='item'] { padding: 4px 8px; }
92
+ .food [data-part='item'][data-highlighted] { background: rgb(99 102 241 / 0.25); }
93
+ ```
94
+
95
+ ## Forms
96
+
97
+ A Select is a form field. With `name`, the [`form` behavior](../forms.md) reads its value and marks it touched when the focus leaves it, as it does for a native `<select>` ([form fields](../widgets.md#form-fields)). A hidden native `<select>` carries the value for `FormData` and a plain form submission, and [`processForm`](../forms-reference.md#processform) entries get it:
98
+
99
+ <!-- docs-check: skip -->
100
+ ```jsx
101
+ <Select className="size" label="Size" name="size" items={SIZES} value={state.form.values.size} />
102
+ ```
103
+
104
+ A form reset puts back the first value (`defaultValue`, or the controlled `value` the select started with); a controlled select reports it with `value-change`.
105
+
58
106
  ## Events and commands
59
107
 
60
108
  | Event | Detail |
@@ -74,9 +122,11 @@ Commands for `ELEMENT`: `open`, `close`, `clear`, and `focus` (the trigger): `CL
74
122
  | Enter | Select the highlighted option and close; the focus returns to the trigger |
75
123
  | Escape, Tab | Close |
76
124
 
125
+ Inside a [controlled popover](./popover.md#controlled-dismissal) or [dialog](./dialog.md#controlled-dismissal), the open list is the top layer: Escape and a press outside close it first, a press in it isn't outside the popover or dialog, and the next Escape closes that one.
126
+
77
127
  ## Styling
78
128
 
79
- Zag's data attributes: `[data-scope="select"]` with `[data-part="root"]`, `"label"`, `"control"`, `"trigger"`, `"value-text"`, `"indicator"`, `"positioner"`, `"content"`, `"item"`, `"item-text"` and `"item-indicator"`. The trigger and content have `data-state`, options `data-highlighted`, `data-state="checked"` and `data-disabled`, and the value text `data-placeholder-shown` while empty. The item indicator (`✓`) is `hidden` on unselected options.
129
+ Zag's data attributes: `[data-scope="select"]` with `[data-part="root"]`, `"label"`, `"control"`, `"trigger"`, `"value-text"`, `"indicator"`, `"positioner"`, `"content"`, `"item"`, `"item-text"`, `"item-indicator"`, and for [groups](#groups) `"item-group"` and `"item-group-label"`. The trigger and content have `data-state`, options `data-highlighted`, `data-state="checked"` and `data-disabled`, and the value text `data-placeholder-shown` while empty. The item indicator (`✓`) is `hidden` on unselected options.
80
130
 
81
131
  ```css live
82
132
  .size [data-part='trigger'] { min-width: 10rem; display: flex; justify-content: space-between; }
@@ -88,7 +138,7 @@ Zag's data attributes: `[data-scope="select"]` with `[data-part="root"]`, `"labe
88
138
 
89
139
  ## Positioning
90
140
 
91
- The listbox renders inside the widget's host, next to the trigger, 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 trigger 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' }}`.
141
+ The list renders inside the widget's host, next to its trigger, and Zag places it with `position: fixed` by default (as Radix's Popper does; the `--x` / `--y` variables on the positioner). So a container that clips its content (`overflow: hidden` or `auto`: a card, a scrolling panel, a table cell, a `<dialog>`) doesn't cut it off, and it still follows the trigger when the page scrolls. The caveat: an ancestor with a `transform`, `filter`, `perspective` or `contain` becomes the containing block of a fixed element, so inside one (an animated drawer, a scaled canvas) the list is placed and clipped relative to that ancestor. There, or to keep the list in the document flow, pass `positioning={{ strategy: 'absolute' }}`. Other options go in the same object and merge over the default: `positioning={{ placement: 'bottom-end', gutter: 4 }}`.
92
142
 
93
143
  ## Testing
94
144
 
@@ -57,7 +57,7 @@ Disabled tabs (`disabled`) are skipped.
57
57
 
58
58
  ## State, actions and attributes
59
59
 
60
- `state.tabs` is `{ id, selected, orientation }`. Actions: `tabs.SELECT` (a value; a click on a tab sends its own) and `tabs.MOVE` (a value: focus that tab, and select it unless manual). Select a tab from the model with `next('tabs.SELECT', 'billing')`, or from your intent with `'tabs.SELECT': DOM.click('.go-billing').mapTo('billing')`.
60
+ `state.tabs` is `{ id, selected, orientation }`. Actions: `tabs.SELECT` (a value; a click on a tab sends its own) and `tabs.MOVE` (a value: focus that tab, and select it unless manual). Select a tab from the model with `next('tabs.SELECT', 'billing')`, or write the slice in your own reducer: `GO_BILLING: (state) => ({ ...state, tabs: { ...state.tabs, selected: 'billing' } })`. (An intent action named `'tabs.SELECT'` would replace the behavior's own trigger: clicks on the tabs would stop selecting them.)
61
61
 
62
62
  `tabsAttrs(state.tabs, uid)` returns:
63
63
 
@@ -41,13 +41,15 @@ The delays need the timer driver in `run()`. Tests don't: [fake timers](../timer
41
41
 
42
42
  ## Behavior
43
43
 
44
- - Hovering or focusing the trigger shows the tip after `showDelay`; leaving before that never shows it.
44
+ - Hovering the trigger or giving it the keyboard focus shows the tip after `showDelay`; leaving before that never shows it.
45
+ - Only a focus the browser draws a focus ring for (`:focus-visible`) shows it. A mouse click focuses a button in Chromium and Firefox, so a tip that showed it would stay after the pointer left; a clicked trigger's tip goes with the pointer instead. When a dialog closes and the focus returns to its trigger, the browser decides: after a keyboard close the tip usually shows, after a mouse close it doesn't.
45
46
  - Leaving hides it after `hideDelay`. Moving the pointer from the trigger onto the tip keeps it open, so the tip can be read and selected ([WCAG 1.4.13](https://www.w3.org/WAI/WCAG22/Understanding/content-on-hover-or-focus)).
46
47
  - The pointer and the focus count apart: it hides once both have left, so the mouse moving away doesn't hide a tip the keyboard focus still shows.
47
- - Touch is left out: a tap focuses the trigger, which shows the tip as focus does.
48
+ - Touch is left out: touch pointer events are ignored, and the focus a tap gives isn't `:focus-visible`, so a tap shows no tip.
49
+ - A press on the trigger (pointerdown) hides it at once, and the pointer shows it again only after leaving and coming back, as Radix does: clicking a button doesn't leave its tip over what the click opened.
48
50
  - Escape hides it at once, without moving the focus. (The behavior listens for it on the document only while the tip is shown or about to be.)
49
51
 
50
- `state.saveTip` is `{ open, pending, hover, focus }`: `pending` is `'show'` or `'hide'` while a delay runs; `hover` and `focus` say where the pointer and the focus are. The actions are `saveTip.ENTER` and `saveTip.LEAVE` (data: `'hover'` or `'focus'`), `saveTip.SHOW`, `saveTip.HIDE` (the timers), `saveTip.ESCAPE` and `saveTip.TOGGLED`. The timers are declared under the names `saveTip.show` and `saveTip.hide`, next to the component's own [`timers`](../timers.md).
52
+ `state.saveTip` is `{ open, pending, hover, focus, pressed }`: `pending` is `'show'` or `'hide'` while a delay runs; `hover` and `focus` say where the pointer and the focus are. The actions are `saveTip.ENTER` and `saveTip.LEAVE` (data: `'hover'` or `'focus'`), `saveTip.SHOW`, `saveTip.HIDE` (the timers), `saveTip.PRESS` (a pointerdown on the trigger), `saveTip.ESCAPE` and `saveTip.TOGGLED`. The trigger and the tip can be inside a [`<Portal>`](https://sygnal.js.org/advanced/portals/). The timers are declared under the names `saveTip.show` and `saveTip.hide`, next to the component's own [`timers`](../timers.md).
51
53
 
52
54
  ## Many tooltips: `tooltips()`
53
55
 
@@ -71,7 +73,7 @@ function Glossary({ uid }) {
71
73
  Glossary.uses = { tips: tooltips({ trigger: '.term', tip: '.tip' }) }
72
74
  ```
73
75
 
74
- The options are those of `tooltip()`, with `trigger` and `tip` matching every trigger and every tip. Moving from one term to another while a tip is shown switches at once, without a second delay. `state.tips` is `{ id, open, pending, hover, focus }`: `id` is the tip of the trigger last entered and `open` the id of the tip shown. The actions carry the tip's id: `tips.ENTER` and `tips.LEAVE` (`{ via: 'hover' | 'focus', id }`), `tips.TOGGLED` (`{ id, open }`), plus `tips.SHOW`, `tips.HIDE` and `tips.ESCAPE`.
76
+ The options are those of `tooltip()`, with `trigger` and `tip` matching every trigger and every tip. Moving from one term to another while a tip is shown switches at once, without a second delay. `state.tips` is `{ id, open, pending, hover, focus, pressed }`: `id` is the tip of the trigger last entered and `open` the id of the tip shown. The actions carry the tip's id: `tips.ENTER` and `tips.LEAVE` (`{ via: 'hover' | 'focus', id }`), `tips.TOGGLED` (`{ id, open }`), plus `tips.SHOW`, `tips.HIDE`, `tips.PRESS` (`{ id }`) and `tips.ESCAPE`.
75
77
 
76
78
  ## Positioning
77
79
 
@@ -100,7 +102,7 @@ Safari 26 places the tip wrongly when the trigger is inside a `position: fixed`
100
102
 
101
103
  - `aria-describedby` on the trigger points at the tip, so screen readers read the tip as the trigger's description. The trigger still needs its own name (its text or `aria-label`).
102
104
  - Keep the tip short and plain text; it can't take the focus. For anything interactive, use a [popover](./popover.md).
103
- - Don't rely on a tooltip for touch users: there is no hover, and a tap focuses the trigger only briefly.
105
+ - Don't rely on a tooltip for touch users: there is no hover, and a tap shows no tip.
104
106
 
105
107
  ## Testing
106
108
 
@@ -116,6 +116,8 @@ Rows can have any height, and each can differ. Until a row has been rendered, it
116
116
 
117
117
  An inline function like this one is a new function at every render; that's fine, it is taken as the same estimate and the measured heights are kept. A numeric `estimateSize` that changes, or a switch between a number and a function, measures the rows again.
118
118
 
119
+ The estimate matters most when it is too low. Rows that haven't been rendered count at `estimateSize`, so with rows taller than the estimate the list's total height is too short until the last rows have been measured: a fast scroll to the end shows blank space where they belong, and the scrollbar jumps as the total grows. Use the rows' real height, and when unsure, err high. In development, [SYG436](https://sygnal.js.org/reference/errors/#syg436) reports a numeric estimate more than 1.3× away from the median of the first 10 measured rows, with that median.
120
+
119
121
  A row must render one element, which is what is measured ([SYG432](https://sygnal.js.org/reference/errors/#syg432) for a fragment).
120
122
 
121
123
  ## Accessibility
@@ -181,3 +183,4 @@ It doesn't do (yet): horizontal lists, grids, sticky group headers, or scrolling
181
183
  | [SYG432](https://sygnal.js.org/reference/errors/#syg432) | An item renders a fragment or text, not one element |
182
184
  | [SYG433](https://sygnal.js.org/reference/errors/#syg433) | `scrollToIndex` / `scrollToId` for a row not in the list |
183
185
  | [SYG434](https://sygnal.js.org/reference/errors/#syg434) | `estimateSize` or `overscan` isn't a valid number |
186
+ | [SYG436](https://sygnal.js.org/reference/errors/#syg436) | A numeric `estimateSize` is far from the measured row heights (blank space at the end of a fast scroll) |