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.
- package/CHANGELOG.md +632 -0
- package/dist/agent-ui.cjs.js +592 -0
- package/dist/agent-ui.cjs.js.map +1 -0
- package/dist/agent-ui.esm.js +582 -0
- package/dist/agent-ui.esm.js.map +1 -0
- package/dist/api.cjs.js +63 -0
- package/dist/api.cjs.js.map +1 -0
- package/dist/api.esm.js +2 -0
- package/dist/api.esm.js.map +1 -0
- package/dist/astro/index.cjs.js +968 -4
- package/dist/astro/index.cjs.js.map +1 -1
- package/dist/astro/index.mjs +968 -4
- package/dist/astro/index.mjs.map +1 -1
- package/dist/astro/server.cjs.js +73 -45
- package/dist/astro/server.cjs.js.map +1 -1
- package/dist/astro/server.mjs +73 -45
- package/dist/astro/server.mjs.map +1 -1
- package/dist/canvas.cjs.js +2919 -0
- package/dist/canvas.cjs.js.map +1 -0
- package/dist/canvas.esm.js +2866 -0
- package/dist/canvas.esm.js.map +1 -0
- package/dist/devtools.cjs.js +38 -6
- package/dist/devtools.cjs.js.map +1 -1
- package/dist/devtools.esm.js +38 -6
- package/dist/devtools.esm.js.map +1 -1
- package/dist/diagnostics.cjs.js +224 -20
- package/dist/diagnostics.cjs.js.map +1 -1
- package/dist/diagnostics.esm.js +224 -20
- package/dist/diagnostics.esm.js.map +1 -1
- package/dist/guide/accessibility.md +1 -1
- package/dist/guide/ai-compose.md +150 -0
- package/dist/guide/behaviors.md +2 -1
- package/dist/guide/collaborative-state.md +265 -0
- package/dist/guide/element-commands.md +36 -2
- package/dist/guide/forms-reference.md +5 -5
- package/dist/guide/forms.md +3 -2
- package/dist/guide/http.md +28 -1
- package/dist/guide/islands.md +240 -0
- package/dist/guide/recipes/code-editor.md +1 -1
- package/dist/guide/renderers/agent-ui.md +138 -0
- package/dist/guide/renderers/custom-targets.md +126 -0
- package/dist/guide/renderers/overview.md +120 -0
- package/dist/guide/renderers/react-native.md +128 -0
- package/dist/guide/renderers/terminal.md +306 -0
- package/dist/guide/renderers/three.md +332 -0
- package/dist/guide/renderers/write-once.md +137 -0
- package/dist/guide/resources.md +2 -0
- package/dist/guide/sandboxed-components.md +223 -0
- package/dist/guide/timers.md +32 -5
- package/dist/guide/typed-apis.md +442 -0
- package/dist/guide/ui/accordion.md +11 -0
- package/dist/guide/ui/combobox.md +112 -8
- package/dist/guide/ui/dialog.md +96 -20
- package/dist/guide/ui/menu.md +57 -5
- package/dist/guide/ui/overview.md +8 -4
- package/dist/guide/ui/popover.md +69 -9
- package/dist/guide/ui/select.md +54 -4
- package/dist/guide/ui/tabs.md +1 -1
- package/dist/guide/ui/tooltip.md +7 -5
- package/dist/guide/virtual-collections.md +3 -0
- package/dist/guide/widgets.md +90 -1
- package/dist/host.cjs.js +2181 -0
- package/dist/host.cjs.js.map +1 -0
- package/dist/host.esm.js +2146 -0
- package/dist/host.esm.js.map +1 -0
- package/dist/index.cjs.js +3948 -586
- package/dist/index.cjs.js.map +1 -1
- package/dist/index.d.ts +584 -44
- package/dist/index.esm.js +3918 -586
- package/dist/index.esm.js.map +1 -1
- package/dist/primitives.cjs.js +143 -0
- package/dist/primitives.cjs.js.map +1 -0
- package/dist/primitives.esm.js +133 -0
- package/dist/primitives.esm.js.map +1 -0
- package/dist/react-native.cjs.js +248 -0
- package/dist/react-native.cjs.js.map +1 -0
- package/dist/react-native.esm.js +244 -0
- package/dist/react-native.esm.js.map +1 -0
- package/dist/replay.cjs.js +39 -0
- package/dist/replay.cjs.js.map +1 -0
- package/dist/replay.esm.js +2 -0
- package/dist/replay.esm.js.map +1 -0
- package/dist/sandbox-build.cjs.js +140 -0
- package/dist/sandbox-build.cjs.js.map +1 -0
- package/dist/sandbox-build.esm.js +137 -0
- package/dist/sandbox-build.esm.js.map +1 -0
- package/dist/sandbox-kernel.esm.js +212 -0
- package/dist/sandbox-kernel.esm.js.map +1 -0
- package/dist/sandbox-worker.cjs.js +857 -0
- package/dist/sandbox-worker.cjs.js.map +1 -0
- package/dist/sandbox-worker.esm.js +854 -0
- package/dist/sandbox-worker.esm.js.map +1 -0
- package/dist/sandbox.cjs.js +1425 -0
- package/dist/sandbox.cjs.js.map +1 -0
- package/dist/sandbox.esm.js +1406 -0
- package/dist/sandbox.esm.js.map +1 -0
- package/dist/sygnal.min.js +1 -1
- package/dist/sygnal.min.js.map +1 -1
- package/dist/terminal.cjs.js +1314 -0
- package/dist/terminal.cjs.js.map +1 -0
- package/dist/terminal.esm.js +1304 -0
- package/dist/terminal.esm.js.map +1 -0
- package/dist/three.cjs.js +1111 -0
- package/dist/three.cjs.js.map +1 -0
- package/dist/three.esm.js +1079 -0
- package/dist/three.esm.js.map +1 -0
- package/dist/ui-combobox.cjs.js +256 -18
- package/dist/ui-combobox.cjs.js.map +1 -1
- package/dist/ui-combobox.esm.js +256 -18
- package/dist/ui-combobox.esm.js.map +1 -1
- package/dist/ui-menu.cjs.js +150 -16
- package/dist/ui-menu.cjs.js.map +1 -1
- package/dist/ui-menu.esm.js +151 -17
- package/dist/ui-menu.esm.js.map +1 -1
- package/dist/ui-select.cjs.js +95 -9
- package/dist/ui-select.cjs.js.map +1 -1
- package/dist/ui-select.esm.js +95 -9
- package/dist/ui-select.esm.js.map +1 -1
- package/dist/ui.cjs.js +679 -173
- package/dist/ui.cjs.js.map +1 -1
- package/dist/ui.esm.js +679 -175
- package/dist/ui.esm.js.map +1 -1
- package/dist/vike/config/package.json +1 -1
- package/dist/vite/plugin.cjs.js +968 -4
- package/dist/vite/plugin.cjs.js.map +1 -1
- package/dist/vite/plugin.mjs +968 -4
- package/dist/vite/plugin.mjs.map +1 -1
- package/dist/worker-page.cjs.js +669 -0
- package/dist/worker-page.cjs.js.map +1 -0
- package/dist/worker-page.esm.js +665 -0
- package/dist/worker-page.esm.js.map +1 -0
- package/dist/worker.cjs.js +377 -0
- package/dist/worker.cjs.js.map +1 -0
- package/dist/worker.esm.js +374 -0
- package/dist/worker.esm.js.map +1 -0
- package/dist/yjs-testing.cjs.js +98 -0
- package/dist/yjs-testing.cjs.js.map +1 -0
- package/dist/yjs-testing.esm.js +77 -0
- package/dist/yjs-testing.esm.js.map +1 -0
- package/dist/yjs.cjs.js +923 -0
- package/dist/yjs.cjs.js.map +1 -0
- package/dist/yjs.esm.js +900 -0
- package/dist/yjs.esm.js.map +1 -0
- package/dist/zag.cjs.js +11 -1
- package/dist/zag.cjs.js.map +1 -1
- package/dist/zag.esm.js +11 -1
- package/dist/zag.esm.js.map +1 -1
- package/llms/ai.md +65 -0
- package/llms/collaboration.md +5 -0
- package/llms/graphics.md +10 -0
- package/llms/renderers.md +7 -0
- package/llms/replay.md +5 -0
- package/llms/sandbox.md +5 -0
- package/llms/typed-apis.md +5 -0
- package/llms/ui.md +10 -0
- package/llms-full.txt +444 -0
- package/llms.txt +37 -35
- package/package.json +139 -3
- package/src/agent-ui/mcp.ts +152 -0
- package/src/agent-ui/snapshot.ts +212 -0
- package/src/agent-ui.d.ts +106 -0
- package/src/agent-ui.ts +249 -0
- package/src/ai.d.ts +95 -2
- package/src/api.d.ts +303 -0
- package/src/api.ts +9 -0
- package/src/canvas/Canvas.ts +585 -0
- package/src/canvas/backend.ts +627 -0
- package/src/canvas/geometry.ts +187 -0
- package/src/canvas/gesture.ts +23 -0
- package/src/canvas/image.ts +197 -0
- package/src/canvas/kit.ts +130 -0
- package/src/canvas/paint.ts +260 -0
- package/src/canvas/pointer.ts +25 -0
- package/src/canvas/snap.ts +68 -0
- package/src/canvas/svg.ts +99 -0
- package/src/canvas.d.ts +373 -0
- package/src/canvas.ts +46 -0
- package/src/core/actions.ts +14 -3
- package/src/core/cell.ts +12 -4
- package/src/core/debug.ts +8 -3
- package/src/core/hooks.ts +16 -2
- package/src/core/hosts/collection.ts +1 -1
- package/src/core/hosts/switchable.ts +26 -8
- package/src/core/instance.ts +98 -45
- package/src/core/markers/portal.ts +4 -0
- package/src/core/registry.ts +4 -0
- package/src/core/runtime.ts +34 -25
- package/src/core/statics.ts +5 -5
- package/src/cycle/dom/EventDelegator.ts +37 -33
- package/src/cycle/dom/MainDOMSource.ts +16 -14
- package/src/cycle/dom/enrichEventStream.ts +13 -9
- package/src/cycle/dom/fromEvent.ts +6 -10
- package/src/cycle/dom/index.ts +11 -7
- package/src/cycle/dom/isolate.ts +13 -6
- package/src/cycle/dom/makeDOMDriver.ts +6 -2
- package/src/cycle/dom/utils.ts +9 -2
- package/src/cycle/run/adapt.ts +4 -12
- package/src/devtools.d.ts +9 -0
- package/src/devtools.ts +2 -1
- package/src/extra/ai/compose/catalog.ts +2 -1
- package/src/extra/ai/compose/decisions.ts +1 -2
- package/src/extra/ai/compose/generated.ts +234 -0
- package/src/extra/ai/compose/index.ts +6 -2
- package/src/extra/ai/compose/layout.ts +3 -1
- package/src/extra/ai/compose/llm.ts +7 -3
- package/src/extra/ai/compose/types.ts +2 -2
- package/src/extra/ai/transports/shared.ts +3 -1
- package/src/extra/api/contract.ts +485 -0
- package/src/extra/api/index.ts +26 -0
- package/src/extra/behaviors.ts +3 -3
- package/src/extra/controlled.ts +44 -0
- package/src/extra/copyAsTest.ts +22 -0
- package/src/extra/devtools.ts +5 -3
- package/src/extra/devtoolsActions.ts +8 -0
- package/src/extra/devtoolsNext.ts +1 -1
- package/src/extra/diagnostics/checks/actionLog.ts +8 -3
- package/src/extra/diagnostics/checks/behaviors.ts +25 -0
- package/src/extra/diagnostics/checks/elementCommands.ts +17 -5
- package/src/extra/diagnostics/checks/index.ts +1 -1
- package/src/extra/diagnostics/checks/next.ts +78 -4
- package/src/extra/diagnostics/checks/props.ts +6 -1
- package/src/extra/diagnostics/checks/replies.ts +11 -7
- package/src/extra/diagnostics/checks/timers.ts +8 -2
- package/src/extra/diagnostics/checks/virtual.ts +11 -0
- package/src/extra/diagnostics/codes.ts +51 -1
- package/src/extra/elementCommands.ts +20 -4
- package/src/extra/fetchDriver.ts +4 -1
- package/src/extra/fetchEventSource.ts +6 -2
- package/src/extra/field.ts +29 -0
- package/src/extra/form.ts +42 -16
- package/src/extra/frame.ts +54 -0
- package/src/extra/keymap.ts +41 -0
- package/src/extra/pager.ts +2 -0
- package/src/extra/persist.ts +2 -1
- package/src/extra/pointer.ts +262 -0
- package/src/extra/processForm.ts +34 -1
- package/src/extra/replay/address.ts +49 -0
- package/src/extra/replay/entropy.ts +99 -0
- package/src/extra/replay/format.ts +263 -0
- package/src/extra/replay/index.ts +8 -0
- package/src/extra/replay/inert.ts +22 -0
- package/src/extra/replay/recorder.ts +247 -0
- package/src/extra/replay/replayer.ts +234 -0
- package/src/extra/replay/report.ts +126 -0
- package/src/extra/replay/schema.ts +52 -0
- package/src/extra/replay/version.ts +3 -0
- package/src/extra/router.ts +7 -3
- package/src/extra/selection.ts +2 -0
- package/src/extra/socketDriver.ts +51 -6
- package/src/extra/ssr.ts +128 -42
- package/src/extra/testing.ts +295 -39
- package/src/extra/timers.ts +96 -7
- package/src/extra/undo.ts +2 -0
- package/src/extra/virtual.ts +25 -2
- package/src/extra/widget.ts +106 -17
- package/src/host/actions.ts +383 -0
- package/src/host/delegation.ts +101 -0
- package/src/host/dom.ts +553 -0
- package/src/host/driver.ts +185 -0
- package/src/host/selector.ts +119 -0
- package/src/host/style.ts +51 -0
- package/src/host.d.ts +298 -0
- package/src/host.ts +46 -0
- package/src/index.d.ts +302 -38
- package/src/index.ts +15 -3
- package/src/pragma/blocks.ts +211 -0
- package/src/pragma/index.ts +44 -10
- package/src/primitives.d.ts +54 -0
- package/src/primitives.ts +136 -0
- package/src/react-native.d.ts +57 -0
- package/src/react-native.ts +285 -0
- package/src/react-peers.d.ts +21 -3
- package/src/replay.d.ts +185 -0
- package/src/replay.ts +6 -0
- package/src/sandbox/build.ts +119 -0
- package/src/sandbox/create.ts +187 -0
- package/src/sandbox/engine.ts +267 -0
- package/src/sandbox/generated.ts +195 -0
- package/src/sandbox/kernel.ts +180 -0
- package/src/sandbox/mirror.ts +269 -0
- package/src/sandbox/protocol.ts +128 -0
- package/src/sandbox/quickjs.ts +74 -0
- package/src/sandbox/ssr.ts +41 -0
- package/src/sandbox/widget.ts +60 -0
- package/src/sandbox/worker-entry.ts +59 -0
- package/src/sandbox/worker.ts +137 -0
- package/src/sandbox-build.d.ts +12 -0
- package/src/sandbox-build.ts +6 -0
- package/src/sandbox.d.ts +299 -0
- package/src/sandbox.ts +25 -0
- package/src/shared.ts +12 -0
- package/src/terminal/paint.ts +712 -0
- package/src/terminal.d.ts +167 -0
- package/src/terminal.ts +530 -0
- package/src/three/assets.ts +170 -0
- package/src/three/host.ts +36 -0
- package/src/three/scene.ts +217 -0
- package/src/three/target.ts +252 -0
- package/src/three.d.ts +221 -0
- package/src/three.ts +30 -0
- package/src/ui/accordion.ts +21 -7
- package/src/ui/dialog.ts +192 -90
- package/src/ui/disclosure.ts +2 -0
- package/src/ui/dom.ts +30 -0
- package/src/ui/layers.ts +132 -0
- package/src/ui/popover.ts +120 -21
- package/src/ui/shared.ts +98 -12
- package/src/ui/stack.ts +18 -0
- package/src/ui/tabs.ts +2 -0
- package/src/ui/toaster.ts +16 -15
- package/src/ui/tooltip.ts +24 -8
- package/src/ui/tooltips.ts +26 -14
- package/src/ui/zag/combobox.ts +180 -13
- package/src/ui/zag/menu.ts +97 -17
- package/src/ui/zag/select.ts +15 -4
- package/src/ui/zag/shared.ts +60 -8
- package/src/ui-combobox.d.ts +16 -1
- package/src/ui-menu.d.ts +22 -7
- package/src/ui-zag-types.d.ts +13 -5
- package/src/ui.d.ts +62 -7
- package/src/ui.ts +2 -2
- package/src/vite/api.ts +186 -0
- package/src/vite/compile.ts +280 -0
- package/src/vite/plugin.d.ts +34 -0
- package/src/vite/plugin.ts +78 -4
- package/src/vite/sandbox.ts +16 -0
- package/src/worker/page.ts +396 -0
- package/src/worker/protocol.ts +48 -0
- package/src/worker/target.ts +282 -0
- package/src/worker-page.d.ts +42 -0
- package/src/worker-page.ts +12 -0
- package/src/worker.d.ts +65 -0
- package/src/worker.ts +16 -0
- package/src/yjs/driver.ts +253 -0
- package/src/yjs/mapping.ts +389 -0
- package/src/yjs/shared.ts +138 -0
- package/src/yjs/testing.ts +91 -0
- package/src/yjs-testing.d.ts +34 -0
- package/src/yjs-testing.ts +7 -0
- package/src/yjs.d.ts +109 -0
- package/src/yjs.ts +18 -0
- package/src/zag.d.ts +2 -0
- package/src/zag.ts +8 -1
package/dist/guide/ui/dialog.md
CHANGED
|
@@ -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,
|
|
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"
|
|
14
|
-
<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
|
|
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` | |
|
|
58
|
-
| `profile.CLOSE` | the return value |
|
|
59
|
-
| `profile.
|
|
60
|
-
| `profile.
|
|
61
|
-
| `profile.
|
|
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
|
-
`
|
|
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
|
|
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,
|
|
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
|
|
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`.
|
package/dist/guide/ui/menu.md
CHANGED
|
@@ -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` |
|
|
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 `"
|
|
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
|
|
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:
|
|
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
|
package/dist/guide/ui/popover.md
CHANGED
|
@@ -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
|
-
|
|
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 }
|
|
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` | |
|
|
46
|
-
| `filters.CLOSE` | |
|
|
47
|
-
| `filters.TOGGLE` | |
|
|
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
|
-
`
|
|
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
|
|
package/dist/guide/ui/select.md
CHANGED
|
@@ -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-
|
|
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
|
|
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
|
|
package/dist/guide/ui/tabs.md
CHANGED
|
@@ -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
|
|
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
|
|
package/dist/guide/ui/tooltip.md
CHANGED
|
@@ -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
|
|
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:
|
|
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
|
|
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) |
|