sygnal 5.3.7 → 6.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +646 -0
- package/README.md +77 -47
- package/dist/astro/client.cjs.js +33 -8114
- package/dist/astro/client.cjs.js.map +1 -0
- package/dist/astro/client.mjs +33 -8114
- package/dist/astro/client.mjs.map +1 -0
- package/dist/astro/index.cjs.js +1039 -9
- package/dist/astro/index.cjs.js.map +1 -0
- package/dist/astro/index.mjs +1038 -9
- package/dist/astro/index.mjs.map +1 -0
- package/dist/astro/server.cjs.js +3400 -166
- package/dist/astro/server.cjs.js.map +1 -0
- package/dist/astro/server.mjs +3400 -166
- package/dist/astro/server.mjs.map +1 -0
- package/dist/devtools.cjs.js +1431 -0
- package/dist/devtools.cjs.js.map +1 -0
- package/dist/devtools.esm.js +1419 -0
- package/dist/devtools.esm.js.map +1 -0
- package/dist/diagnostics.cjs.js +4033 -0
- package/dist/diagnostics.cjs.js.map +1 -0
- package/dist/diagnostics.esm.js +4003 -0
- package/dist/diagnostics.esm.js.map +1 -0
- package/dist/element.cjs.js +230 -0
- package/dist/element.cjs.js.map +1 -0
- package/dist/element.esm.js +228 -0
- package/dist/element.esm.js.map +1 -0
- package/dist/guide/accessibility.md +257 -0
- package/dist/guide/adapters.md +221 -0
- package/dist/guide/behaviors.md +312 -0
- package/dist/guide/browser-sources.md +203 -0
- package/dist/guide/drag-and-drop.md +342 -0
- package/dist/guide/element-commands.md +306 -0
- package/dist/guide/error-boundaries.md +169 -0
- package/dist/guide/forms-reference.md +381 -0
- package/dist/guide/forms.md +250 -0
- package/dist/guide/http.md +299 -0
- package/dist/guide/inputs.md +173 -0
- package/dist/guide/persistence.md +245 -0
- package/dist/guide/recipes/carousel.md +155 -0
- package/dist/guide/recipes/charts.md +177 -0
- package/dist/guide/recipes/code-editor.md +135 -0
- package/dist/guide/recipes/data-grid.md +155 -0
- package/dist/guide/recipes/data-table.md +196 -0
- package/dist/guide/recipes/i18n.md +222 -0
- package/dist/guide/recipes/icons.md +115 -0
- package/dist/guide/recipes/overview.md +33 -0
- package/dist/guide/recipes/rich-text.md +154 -0
- package/dist/guide/resources.md +471 -0
- package/dist/guide/ssr.md +255 -0
- package/dist/guide/timers.md +178 -0
- package/dist/guide/ui/accordion.md +93 -0
- package/dist/guide/ui/combobox.md +144 -0
- package/dist/guide/ui/dialog.md +124 -0
- package/dist/guide/ui/disclosure.md +60 -0
- package/dist/guide/ui/menu.md +125 -0
- package/dist/guide/ui/overview.md +166 -0
- package/dist/guide/ui/popover.md +101 -0
- package/dist/guide/ui/select.md +114 -0
- package/dist/guide/ui/tabs.md +161 -0
- package/dist/guide/ui/toaster.md +152 -0
- package/dist/guide/ui/tooltip.md +103 -0
- package/dist/guide/undo.md +178 -0
- package/dist/guide/virtual-collections.md +183 -0
- package/dist/guide/web-components.md +179 -0
- package/dist/guide/widgets.md +217 -0
- package/dist/index.cjs.js +15283 -6279
- package/dist/index.cjs.js.map +1 -0
- package/dist/index.d.ts +3739 -370
- package/dist/index.esm.js +15230 -6274
- package/dist/index.esm.js.map +1 -0
- package/dist/jsx-dev-runtime.cjs.js +27 -329
- package/dist/jsx-dev-runtime.cjs.js.map +1 -0
- package/dist/jsx-dev-runtime.esm.js +20 -322
- package/dist/jsx-dev-runtime.esm.js.map +1 -0
- package/dist/jsx-runtime.cjs.js +27 -329
- package/dist/jsx-runtime.cjs.js.map +1 -0
- package/dist/jsx-runtime.esm.js +20 -322
- package/dist/jsx-runtime.esm.js.map +1 -0
- package/dist/jsx.cjs.js +11 -309
- package/dist/jsx.cjs.js.map +1 -0
- package/dist/jsx.esm.js +9 -310
- package/dist/jsx.esm.js.map +1 -0
- package/dist/react.cjs.js +117 -0
- package/dist/react.cjs.js.map +1 -0
- package/dist/react.esm.js +115 -0
- package/dist/react.esm.js.map +1 -0
- package/dist/shims/globalthis.cjs +20 -0
- package/dist/sygnal.min.js +2 -1
- package/dist/sygnal.min.js.map +1 -0
- package/dist/ui-combobox.cjs.js +169 -0
- package/dist/ui-combobox.cjs.js.map +1 -0
- package/dist/ui-combobox.esm.js +148 -0
- package/dist/ui-combobox.esm.js.map +1 -0
- package/dist/ui-menu.cjs.js +87 -0
- package/dist/ui-menu.cjs.js.map +1 -0
- package/dist/ui-menu.esm.js +66 -0
- package/dist/ui-menu.esm.js.map +1 -0
- package/dist/ui-select.cjs.js +104 -0
- package/dist/ui-select.cjs.js.map +1 -0
- package/dist/ui-select.esm.js +83 -0
- package/dist/ui-select.esm.js.map +1 -0
- package/dist/ui.cjs.js +738 -0
- package/dist/ui.cjs.js.map +1 -0
- package/dist/ui.esm.js +727 -0
- package/dist/ui.esm.js.map +1 -0
- package/dist/vike/ClientOnly.cjs.js +5 -14
- package/dist/vike/ClientOnly.cjs.js.map +1 -0
- package/dist/vike/ClientOnly.mjs +5 -14
- package/dist/vike/ClientOnly.mjs.map +1 -0
- package/dist/vike/{+config.js → config/+config.js} +10 -1
- package/dist/vike/config/+config.js.map +1 -0
- package/dist/vike/config/package.json +6 -0
- package/dist/vike/onRenderClient.cjs.js +166 -95
- package/dist/vike/onRenderClient.cjs.js.map +1 -0
- package/dist/vike/onRenderClient.mjs +166 -95
- package/dist/vike/onRenderClient.mjs.map +1 -0
- package/dist/vike/onRenderHtml.cjs.js +60 -24
- package/dist/vike/onRenderHtml.cjs.js.map +1 -0
- package/dist/vike/onRenderHtml.mjs +61 -25
- package/dist/vike/onRenderHtml.mjs.map +1 -0
- package/dist/vite/plugin.cjs.js +926 -65
- package/dist/vite/plugin.cjs.js.map +1 -0
- package/dist/vite/plugin.mjs +925 -65
- package/dist/vite/plugin.mjs.map +1 -0
- package/dist/zag.cjs.js +408 -0
- package/dist/zag.cjs.js.map +1 -0
- package/dist/zag.esm.js +405 -0
- package/dist/zag.esm.js.map +1 -0
- package/llms.txt +313 -0
- package/package.json +109 -16
- package/src/astro/client.ts +40 -18
- package/src/astro/index.d.ts +48 -1
- package/src/astro/index.ts +106 -9
- package/src/astro/server.ts +11 -3
- package/src/collection.ts +6 -90
- package/src/core/actions.ts +134 -0
- package/src/core/cell.ts +202 -0
- package/src/core/debug.ts +23 -0
- package/src/core/define.ts +242 -0
- package/src/core/hooks.ts +314 -0
- package/src/core/hosts/collection.ts +325 -0
- package/src/core/hosts/switchable.ts +146 -0
- package/src/core/instance.ts +592 -0
- package/src/core/markers/clientonly.ts +13 -0
- package/src/core/markers/lazy.ts +40 -0
- package/src/core/markers/portal.ts +96 -0
- package/src/core/markers/suspense.ts +41 -0
- package/src/core/markers/transition.ts +70 -0
- package/src/core/registry.ts +25 -0
- package/src/core/runtime.ts +573 -0
- package/src/core/statics.ts +104 -0
- package/src/core/teardown.ts +60 -0
- package/src/core/view.ts +37 -0
- package/src/cycle/dom/DocumentDOMSource.ts +13 -8
- package/src/cycle/dom/ElementFinder.ts +7 -9
- package/src/cycle/dom/EventDelegator.ts +174 -223
- package/src/cycle/dom/IsolateModule.ts +65 -35
- package/src/cycle/dom/MainDOMSource.ts +13 -2
- package/src/cycle/dom/PriorityQueue.ts +4 -2
- package/src/cycle/dom/SymbolTree.ts +18 -47
- package/src/cycle/dom/classNameModule.ts +76 -0
- package/src/cycle/dom/controlledInputModule.ts +50 -0
- package/src/cycle/dom/enrichEventStream.ts +29 -45
- package/src/cycle/dom/fragment.ts +7 -0
- package/src/cycle/dom/isolate.ts +30 -27
- package/src/cycle/dom/makeDOMDriver.ts +115 -57
- package/src/cycle/dom/mockDOMSource.ts +83 -9
- package/src/cycle/dom/modules.ts +21 -4
- package/src/cycle/dom/propsModule.ts +65 -0
- package/src/cycle/dom/selectModule.ts +22 -17
- package/src/cycle/dom/snabbdom.ts +1 -6
- package/src/cycle/dom/thunk.ts +3 -0
- package/src/cycle/dom/utils.ts +98 -0
- package/src/cycle/dom/viewTransition.ts +43 -0
- package/src/cycle/state/StateSource.ts +20 -4
- package/src/cycle/state/objIsEqual.ts +50 -0
- package/src/cycle/state/types.ts +0 -10
- package/src/defineComponent.ts +34 -0
- package/src/devtools.d.ts +151 -0
- package/src/devtools.ts +34 -0
- package/src/element.d.ts +59 -0
- package/src/element.ts +235 -0
- package/src/extra/backoff.ts +9 -0
- package/src/extra/behaviors.ts +169 -0
- package/src/extra/browserSignals.ts +23 -0
- package/src/extra/browserSources.ts +326 -0
- package/src/extra/command.ts +3 -3
- package/src/extra/controls.ts +47 -0
- package/src/extra/copyAsTest.ts +286 -0
- package/src/extra/devtools.ts +109 -31
- package/src/extra/devtoolsActions.ts +379 -0
- package/src/extra/devtoolsHook.ts +9 -0
- package/src/extra/devtoolsNext.ts +83 -0
- package/src/extra/diagnostics/checks/actionLog.ts +108 -0
- package/src/extra/diagnostics/checks/behaviors.ts +49 -0
- package/src/extra/diagnostics/checks/browserSources.ts +96 -0
- package/src/extra/diagnostics/checks/collections.ts +67 -0
- package/src/extra/diagnostics/checks/controls.ts +148 -0
- package/src/extra/diagnostics/checks/dataset.ts +57 -0
- package/src/extra/diagnostics/checks/dom.ts +250 -0
- package/src/extra/diagnostics/checks/elementCommands.ts +175 -0
- package/src/extra/diagnostics/checks/events.ts +83 -0
- package/src/extra/diagnostics/checks/fetch.ts +135 -0
- package/src/extra/diagnostics/checks/forms.ts +163 -0
- package/src/extra/diagnostics/checks/index.ts +202 -0
- package/src/extra/diagnostics/checks/inspect.ts +427 -0
- package/src/extra/diagnostics/checks/next.ts +417 -0
- package/src/extra/diagnostics/checks/persist.ts +54 -0
- package/src/extra/diagnostics/checks/props.ts +56 -0
- package/src/extra/diagnostics/checks/public.d.ts +302 -0
- package/src/extra/diagnostics/checks/replies.ts +128 -0
- package/src/extra/diagnostics/checks/router.ts +100 -0
- package/src/extra/diagnostics/checks/rxjsHints.ts +88 -0
- package/src/extra/diagnostics/checks/shared.ts +203 -0
- package/src/extra/diagnostics/checks/shorthand.ts +115 -0
- package/src/extra/diagnostics/checks/sortable.ts +129 -0
- package/src/extra/diagnostics/checks/state.ts +148 -0
- package/src/extra/diagnostics/checks/statics.ts +48 -0
- package/src/extra/diagnostics/checks/strict.ts +55 -0
- package/src/extra/diagnostics/checks/timers.ts +80 -0
- package/src/extra/diagnostics/checks/viewTransitions.ts +47 -0
- package/src/extra/diagnostics/checks/virtual.ts +73 -0
- package/src/extra/diagnostics/checks/widgets.ts +109 -0
- package/src/extra/diagnostics/checks/wiring.ts +121 -0
- package/src/extra/diagnostics/codes.ts +477 -0
- package/src/extra/diagnostics/index.ts +353 -0
- package/src/extra/diagnostics/legacy.ts +76 -0
- package/src/extra/driverFactories.ts +172 -60
- package/src/extra/elementCommands.ts +53 -0
- package/src/extra/eventDriver.ts +4 -0
- package/src/extra/fetchDriver.ts +519 -0
- package/src/extra/flatten.ts +75 -0
- package/src/extra/focusWithin.ts +27 -0
- package/src/extra/form.ts +268 -0
- package/src/extra/formHelpers.ts +139 -0
- package/src/extra/head.ts +106 -0
- package/src/extra/hmr.ts +2 -3
- package/src/extra/owned.ts +24 -0
- package/src/extra/pager.ts +50 -0
- package/src/extra/persist.ts +133 -0
- package/src/extra/pwa.ts +1 -1
- package/src/extra/queryCache.ts +107 -0
- package/src/extra/reducers.ts +13 -6
- package/src/extra/reduxDevtools.ts +92 -0
- package/src/extra/replies.ts +54 -0
- package/src/extra/router.ts +323 -0
- package/src/extra/run.ts +94 -210
- package/src/extra/selection.ts +76 -0
- package/src/extra/socketDriver.ts +265 -0
- package/src/extra/sortable.ts +377 -0
- package/src/extra/ssr.ts +427 -160
- package/src/extra/standardSchema.ts +27 -0
- package/src/extra/testing.ts +3158 -190
- package/src/extra/timers.ts +95 -0
- package/src/extra/undo.ts +261 -0
- package/src/extra/viewTransitions.ts +38 -0
- package/src/extra/virtual.ts +513 -0
- package/src/extra/widget.ts +201 -0
- package/src/extra/xstreamExtras.ts +269 -0
- package/src/index.d.ts +3012 -87
- package/src/index.ts +32 -10
- package/src/jsx-runtime.ts +15 -1
- package/src/jsx.ts +2 -1
- package/src/lazy.ts +50 -7
- package/src/portal.ts +3 -1
- package/src/pragma/index.ts +262 -134
- package/src/react-peers.d.ts +5 -0
- package/src/react.d.ts +43 -0
- package/src/react.ts +110 -0
- package/src/shared.ts +48 -0
- package/src/slot.ts +1 -1
- package/src/suspense.ts +3 -1
- package/src/switchable.ts +6 -119
- package/src/transition.ts +3 -1
- package/src/ui/accordion.ts +64 -0
- package/src/ui/dialog.ts +129 -0
- package/src/ui/disclosure.ts +35 -0
- package/src/ui/popover.ts +45 -0
- package/src/ui/shared.ts +94 -0
- package/src/ui/tabs.ts +82 -0
- package/src/ui/toaster.ts +198 -0
- package/src/ui/tooltip.ts +70 -0
- package/src/ui/zag/combobox.ts +115 -0
- package/src/ui/zag/menu.ts +40 -0
- package/src/ui/zag/select.ts +54 -0
- package/src/ui/zag/shared.ts +43 -0
- package/src/ui-combobox.d.ts +20 -0
- package/src/ui-combobox.ts +6 -0
- package/src/ui-menu.d.ts +30 -0
- package/src/ui-menu.ts +6 -0
- package/src/ui-select.d.ts +17 -0
- package/src/ui-select.ts +6 -0
- package/src/ui-zag-types.d.ts +42 -0
- package/src/ui.d.ts +208 -0
- package/src/ui.ts +15 -0
- package/src/vike/+config.ts +9 -1
- package/src/vike/ClientOnly.ts +1 -1
- package/src/vike/onRenderClient.ts +144 -96
- package/src/vike/onRenderHtml.ts +69 -26
- package/src/vike/types.ts +27 -4
- package/src/vite/globalthis-shim.ts +18 -0
- package/src/vite/globalthis.ts +56 -0
- package/src/vite/plugin.d.ts +114 -2
- package/src/vite/plugin.ts +920 -56
- package/src/zag.d.ts +64 -0
- package/src/zag.ts +238 -0
- package/dist/astro/astro/client.d.ts +0 -5
- package/dist/astro/astro/index.d.ts +0 -16
- package/dist/astro/astro/server.d.ts +0 -12
- package/dist/astro/client.d.ts +0 -5
- package/dist/astro/client.esm.js +0 -4665
- package/dist/astro/collection.d.ts +0 -12
- package/dist/astro/component.d.ts +0 -22
- package/dist/astro/cycle/dom/BodyDOMSource.d.ts +0 -10
- package/dist/astro/cycle/dom/DOMSource.d.ts +0 -11
- package/dist/astro/cycle/dom/DocumentDOMSource.d.ts +0 -12
- package/dist/astro/cycle/dom/ElementFinder.d.ts +0 -8
- package/dist/astro/cycle/dom/EventDelegator.d.ts +0 -34
- package/dist/astro/cycle/dom/IsolateModule.d.ts +0 -23
- package/dist/astro/cycle/dom/MainDOMSource.d.ts +0 -32
- package/dist/astro/cycle/dom/PriorityQueue.d.ts +0 -7
- package/dist/astro/cycle/dom/ScopeChecker.d.ts +0 -9
- package/dist/astro/cycle/dom/SymbolTree.d.ts +0 -9
- package/dist/astro/cycle/dom/VNodeWrapper.d.ts +0 -8
- package/dist/astro/cycle/dom/enrichEventStream.d.ts +0 -24
- package/dist/astro/cycle/dom/fromEvent.d.ts +0 -6
- package/dist/astro/cycle/dom/index.d.ts +0 -8
- package/dist/astro/cycle/dom/isolate.d.ts +0 -9
- package/dist/astro/cycle/dom/makeDOMDriver.d.ts +0 -11
- package/dist/astro/cycle/dom/mockDOMSource.d.ts +0 -17
- package/dist/astro/cycle/dom/modules.d.ts +0 -5
- package/dist/astro/cycle/dom/snabbdom.d.ts +0 -22
- package/dist/astro/cycle/dom/styleModule.d.ts +0 -13
- package/dist/astro/cycle/dom/thunk.d.ts +0 -11
- package/dist/astro/cycle/dom/utils.d.ts +0 -8
- package/dist/astro/cycle/isolate/index.d.ts +0 -42
- package/dist/astro/cycle/run/adapt.d.ts +0 -6
- package/dist/astro/cycle/run/index.d.ts +0 -36
- package/dist/astro/cycle/run/internals.d.ts +0 -8
- package/dist/astro/cycle/run/types.d.ts +0 -49
- package/dist/astro/cycle/state/Collection.d.ts +0 -29
- package/dist/astro/cycle/state/StateSource.d.ts +0 -20
- package/dist/astro/cycle/state/index.d.ts +0 -5
- package/dist/astro/cycle/state/pickCombine.d.ts +0 -3
- package/dist/astro/cycle/state/pickMerge.d.ts +0 -3
- package/dist/astro/cycle/state/types.d.ts +0 -18
- package/dist/astro/cycle/state/withState.d.ts +0 -17
- package/dist/astro/extra/classes.d.ts +0 -7
- package/dist/astro/extra/devtools.d.ts +0 -78
- package/dist/astro/extra/dragDriver.d.ts +0 -20
- package/dist/astro/extra/driverFactories.d.ts +0 -12
- package/dist/astro/extra/eventDriver.d.ts +0 -9
- package/dist/astro/extra/exactState.d.ts +0 -1
- package/dist/astro/extra/hmr.d.ts +0 -17
- package/dist/astro/extra/logDriver.d.ts +0 -2
- package/dist/astro/extra/processDrag.d.ts +0 -19
- package/dist/astro/extra/processForm.d.ts +0 -15
- package/dist/astro/extra/run.d.ts +0 -13
- package/dist/astro/extra/xstreamCompat.d.ts +0 -5
- package/dist/astro/index.d.ts +0 -19
- package/dist/astro/index.esm.js +0 -27
- package/dist/astro/jsx-dev-runtime.d.ts +0 -1
- package/dist/astro/jsx-runtime.d.ts +0 -4
- package/dist/astro/jsx.d.ts +0 -2
- package/dist/astro/pragma/fn.d.ts +0 -7
- package/dist/astro/pragma/index.d.ts +0 -7
- package/dist/astro/pragma/is.d.ts +0 -10
- package/dist/astro/server.d.ts +0 -12
- package/dist/astro/server.esm.js +0 -25
- package/dist/astro/switchable.d.ts +0 -7
- package/dist/collection.d.ts +0 -12
- package/dist/component.d.ts +0 -22
- package/dist/cycle/dom/BodyDOMSource.d.ts +0 -10
- package/dist/cycle/dom/DOMSource.d.ts +0 -11
- package/dist/cycle/dom/DocumentDOMSource.d.ts +0 -12
- package/dist/cycle/dom/ElementFinder.d.ts +0 -8
- package/dist/cycle/dom/EventDelegator.d.ts +0 -34
- package/dist/cycle/dom/IsolateModule.d.ts +0 -23
- package/dist/cycle/dom/MainDOMSource.d.ts +0 -32
- package/dist/cycle/dom/PriorityQueue.d.ts +0 -7
- package/dist/cycle/dom/ScopeChecker.d.ts +0 -9
- package/dist/cycle/dom/SymbolTree.d.ts +0 -9
- package/dist/cycle/dom/VNodeWrapper.d.ts +0 -8
- package/dist/cycle/dom/enrichEventStream.d.ts +0 -24
- package/dist/cycle/dom/fromEvent.d.ts +0 -6
- package/dist/cycle/dom/index.d.ts +0 -8
- package/dist/cycle/dom/isolate.d.ts +0 -9
- package/dist/cycle/dom/makeDOMDriver.d.ts +0 -11
- package/dist/cycle/dom/mockDOMSource.d.ts +0 -17
- package/dist/cycle/dom/modules.d.ts +0 -5
- package/dist/cycle/dom/snabbdom.d.ts +0 -22
- package/dist/cycle/dom/styleModule.d.ts +0 -13
- package/dist/cycle/dom/thunk.d.ts +0 -11
- package/dist/cycle/dom/utils.d.ts +0 -8
- package/dist/cycle/isolate/index.d.ts +0 -42
- package/dist/cycle/run/adapt.d.ts +0 -6
- package/dist/cycle/run/index.d.ts +0 -36
- package/dist/cycle/run/internals.d.ts +0 -8
- package/dist/cycle/run/types.d.ts +0 -49
- package/dist/cycle/state/Collection.d.ts +0 -29
- package/dist/cycle/state/StateSource.d.ts +0 -20
- package/dist/cycle/state/index.d.ts +0 -5
- package/dist/cycle/state/pickCombine.d.ts +0 -3
- package/dist/cycle/state/pickMerge.d.ts +0 -3
- package/dist/cycle/state/types.d.ts +0 -18
- package/dist/cycle/state/withState.d.ts +0 -17
- package/dist/extra/classes.d.ts +0 -7
- package/dist/extra/devtools.d.ts +0 -78
- package/dist/extra/dragDriver.d.ts +0 -20
- package/dist/extra/driverFactories.d.ts +0 -12
- package/dist/extra/eventDriver.d.ts +0 -9
- package/dist/extra/exactState.d.ts +0 -1
- package/dist/extra/hmr.d.ts +0 -17
- package/dist/extra/logDriver.d.ts +0 -2
- package/dist/extra/processDrag.d.ts +0 -19
- package/dist/extra/processForm.d.ts +0 -15
- package/dist/extra/run.d.ts +0 -13
- package/dist/extra/xstreamCompat.d.ts +0 -5
- package/dist/jsx-dev-runtime.d.ts +0 -1
- package/dist/jsx-dev-runtime.js +0 -224
- package/dist/jsx-runtime.d.ts +0 -4
- package/dist/jsx-runtime.js +0 -224
- package/dist/jsx.d.ts +0 -2
- package/dist/pragma/fn.d.ts +0 -7
- package/dist/pragma/index.d.ts +0 -7
- package/dist/pragma/is.d.ts +0 -10
- package/dist/switchable.d.ts +0 -7
- package/dist/vike/+config.cjs.js +0 -65
- package/src/component.ts +0 -2256
- package/src/cycle/isolate/index.ts +0 -196
- package/src/cycle/run/index.ts +0 -151
- package/src/cycle/run/internals.ts +0 -143
- package/src/cycle/state/Collection.ts +0 -174
- package/src/cycle/state/index.ts +0 -5
- package/src/cycle/state/pickCombine.ts +0 -167
- package/src/cycle/state/pickMerge.ts +0 -122
- package/src/cycle/state/withState.ts +0 -47
- package/src/pragma/fn.ts +0 -55
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
<!-- Generated from docs/src/content/docs/guide by scripts/copy-guides.mjs; online: https://sygnal.js.org/guide/ -->
|
|
2
|
+
# Behaviors
|
|
3
|
+
|
|
4
|
+
A **behavior** is a piece of state, intent and model that several components share: a pager, a selection, an undo history, a disclosure. A component lists the behaviors it uses in its `uses` static, each under a state key. The behavior then runs on that slice of the component's state, and its actions are named after the key (`pager.NEXT`).
|
|
5
|
+
|
|
6
|
+
Sygnal ships four behaviors: [`pager`](#pager), [`selection`](#selection), [`undo`](./undo.md) and [`form`](./forms.md). You write your own with [`defineBehavior`](#writing-a-behavior). The [UI parts](./ui/overview.md) in `sygnal/ui` (dialog, popover, tooltip, tabs, accordion, disclosure) are behaviors too.
|
|
7
|
+
|
|
8
|
+
## Using a behavior
|
|
9
|
+
|
|
10
|
+
`uses` maps a state key to a behavior, called with its options:
|
|
11
|
+
|
|
12
|
+
```jsx
|
|
13
|
+
TaskList.uses = { pager: pager({ pageSize: 10, next: '.newer', prev: '.older' }) }
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
This gives the component:
|
|
17
|
+
|
|
18
|
+
- **state** at `state.pager`: the behavior's own state (`{ page, pageSize, total }`), with its calculated fields (`offset`, `pages`, `hasPrev`, `hasNext`) stored on the slice;
|
|
19
|
+
- **actions** `pager.NEXT`, `pager.PREV`, `pager.GOTO` and `pager.SET_TOTAL`, which change only `state.pager`;
|
|
20
|
+
- **triggers**: the behavior's intent listens to what its options name. Here a click on the `Newer` button dispatches `pager.NEXT`.
|
|
21
|
+
|
|
22
|
+
The options that tell a behavior what to listen to take a CSS selector: `next: '.newer'` listens to clicks on the view's `<button className="newer">`. They take a [control](https://sygnal.js.org/guide/controls/) too.
|
|
23
|
+
|
|
24
|
+
Options that name a key of the behavior's state (`pageSize` here) set its starting value.
|
|
25
|
+
|
|
26
|
+
### Pager
|
|
27
|
+
|
|
28
|
+
A page cursor over a list. The view slices the list with the calculated `offset`, and the buttons disable at the ends. In this demo, a stand-in server returns 23 tasks:
|
|
29
|
+
|
|
30
|
+
```js live-server
|
|
31
|
+
const tasks = Array.from({ length: 23 }, (_, i) => ({ id: i + 1, title: `Task ${i + 1}` }))
|
|
32
|
+
|
|
33
|
+
export default {
|
|
34
|
+
'GET /api/tasks': () => ({ json: tasks }),
|
|
35
|
+
}
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
```jsx live
|
|
39
|
+
import { pager } from 'sygnal'
|
|
40
|
+
|
|
41
|
+
export function TaskList({ state }) {
|
|
42
|
+
const { offset, pageSize, page, pages, hasPrev, hasNext } = state.pager
|
|
43
|
+
return (
|
|
44
|
+
<div>
|
|
45
|
+
<ul>
|
|
46
|
+
{state.tasks.slice(offset, offset + pageSize).map(task => <li>{task.title}</li>)}
|
|
47
|
+
</ul>
|
|
48
|
+
<nav>
|
|
49
|
+
<button className="older" disabled={!hasPrev}>Older</button>
|
|
50
|
+
<span className="page">Page {page + 1} of {pages}</span>
|
|
51
|
+
<button className="newer" disabled={!hasNext}>Newer</button>
|
|
52
|
+
</nav>
|
|
53
|
+
</div>
|
|
54
|
+
)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
TaskList.initialState = { tasks: [] }
|
|
58
|
+
TaskList.uses = { pager: pager({ pageSize: 10, next: '.newer', prev: '.older' }) }
|
|
59
|
+
TaskList.model = {
|
|
60
|
+
BOOTSTRAP: { HTTP: () => ({ url: '/api/tasks', ok: 'LOADED' }) },
|
|
61
|
+
LOADED: (state, tasks) => ({ ...state, tasks, pager: { ...state.pager, total: tasks.length } }),
|
|
62
|
+
}
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
| | |
|
|
66
|
+
|---|---|
|
|
67
|
+
| Options | `pageSize` (20), `page` (0), `total` (`null`: unknown), `next` and `prev` (what to listen to for clicks) |
|
|
68
|
+
| State | `page`, `pageSize`, `total`; calculated `offset`, `pages` (`null` while `total` is unknown), `hasPrev`, `hasNext` |
|
|
69
|
+
| Actions | `NEXT`, `PREV` (no change at the first and last page), `GOTO` (a page number, kept in range), `SET_TOTAL` (the item count) |
|
|
70
|
+
|
|
71
|
+
Without a `total`, `NEXT` has no upper bound. Set it with an option, with `pager.SET_TOTAL`, or, as the `LOADED` reducer above does, by writing `state.pager.total` from a host reducer: the calculated fields follow.
|
|
72
|
+
|
|
73
|
+
### Selection
|
|
74
|
+
|
|
75
|
+
Single or multiple selection over a list, with a select-all toggle:
|
|
76
|
+
|
|
77
|
+
```jsx live
|
|
78
|
+
import { selection, isSelected } from 'sygnal'
|
|
79
|
+
|
|
80
|
+
export function Inbox({ state }) {
|
|
81
|
+
const { count } = state.sel
|
|
82
|
+
return (
|
|
83
|
+
<div>
|
|
84
|
+
<label>
|
|
85
|
+
<input className="pick-all" type="checkbox" checked={count > 0 && count === state.mails.length} /> Select all
|
|
86
|
+
</label>
|
|
87
|
+
<button className="archive" disabled={count === 0}>Archive ({count})</button>
|
|
88
|
+
<ul>
|
|
89
|
+
{state.mails.map(mail => (
|
|
90
|
+
<li>
|
|
91
|
+
<label>
|
|
92
|
+
<input className="pick" type="checkbox" data-id={mail.id} checked={isSelected(state.sel, mail.id)} /> {mail.subject}
|
|
93
|
+
</label>
|
|
94
|
+
</li>
|
|
95
|
+
))}
|
|
96
|
+
</ul>
|
|
97
|
+
</div>
|
|
98
|
+
)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
Inbox.initialState = {
|
|
102
|
+
mails: [{ id: 1, subject: 'Lunch?' }, { id: 2, subject: 'Invoice' }, { id: 3, subject: 'Re: plans' }],
|
|
103
|
+
}
|
|
104
|
+
Inbox.uses = { sel: selection({ multi: true, item: '.pick', all: '.pick-all', from: 'mails' }) }
|
|
105
|
+
Inbox.intent = ({ DOM }) => ({ ARCHIVE: DOM.click('.archive') })
|
|
106
|
+
Inbox.model = {
|
|
107
|
+
ARCHIVE: (state) => ({
|
|
108
|
+
...state,
|
|
109
|
+
mails: state.mails.filter(mail => !isSelected(state.sel, mail.id)),
|
|
110
|
+
sel: { ...state.sel, selected: [] },
|
|
111
|
+
}),
|
|
112
|
+
}
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
| | |
|
|
116
|
+
|---|---|
|
|
117
|
+
| Options | `multi` (false: a click replaces the selection; true: it toggles the item), `item` (a selector for the element on each item), `attr` (the item element's attribute that holds its id, default `data-id`), `all` (a select-all toggle), `clear`, `from` (the host state key of the list, for select-all), `idField` (`'id'`) |
|
|
118
|
+
| State | `selected` (the ids, as strings, in selection order); calculated `count` |
|
|
119
|
+
| Actions | `SELECT` (an id, or a click on an item), `SELECT_ALL` (an array of ids, or every id of `state[from]`), `TOGGLE_ALL` (all selected → none, else all), `CLEAR` |
|
|
120
|
+
|
|
121
|
+
Read the selection with `isSelected(state.sel, id)`. Ids compare as strings, so `isSelected(state.sel, 1)` and `isSelected(state.sel, '1')` agree.
|
|
122
|
+
|
|
123
|
+
### Undo
|
|
124
|
+
|
|
125
|
+
`undo({ key: 'doc' })` records the changes to `state.doc` and adds `history.UNDO` and `history.REDO`. See [Undo and Redo](./undo.md).
|
|
126
|
+
|
|
127
|
+
### Sortable
|
|
128
|
+
|
|
129
|
+
`sortable({ from: 'tasks', item: '.task', handle: '.grip' })` reorders `state.tasks` by mouse, touch and keyboard, announces each step in `state.sort.message` for a live region, and dispatches `sort.DROPPED` once per completed move. See [Drag and Drop](./drag-and-drop.md#sortable-lists).
|
|
130
|
+
|
|
131
|
+
### Form
|
|
132
|
+
|
|
133
|
+
`form(schema, { values, submit })` is a form with validation: `state.form` holds the values, errors and touched fields, the fields inside the form element are matched by `name`, and a valid submit dispatches the `submit` action with the validated values. See [Forms](./forms.md).
|
|
134
|
+
|
|
135
|
+
## Writing a behavior
|
|
136
|
+
|
|
137
|
+
`defineBehavior()` takes the same parts as a component, without a view: `initialState`, `intent`, `model`, `calculated` and `timers`. It returns a factory: call it with the options of one use.
|
|
138
|
+
|
|
139
|
+
```js live-file=./behaviors/disclosure.js
|
|
140
|
+
// behaviors/disclosure.js
|
|
141
|
+
import { ABORT, defineBehavior } from 'sygnal'
|
|
142
|
+
|
|
143
|
+
export const disclosure = defineBehavior({
|
|
144
|
+
initialState: { open: false },
|
|
145
|
+
intent: ({ DOM }, { toggle }) => ({ TOGGLE: DOM.click(toggle) }),
|
|
146
|
+
model: {
|
|
147
|
+
TOGGLE: (slice) => ({ ...slice, open: !slice.open }),
|
|
148
|
+
CLOSE: (slice) => (slice.open ? { ...slice, open: false } : ABORT),
|
|
149
|
+
},
|
|
150
|
+
calculated: {
|
|
151
|
+
label: (slice) => (slice.open ? 'Hide details' : 'Show details'),
|
|
152
|
+
},
|
|
153
|
+
})
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
- `initialState` is the slice a host starts with.
|
|
157
|
+
- `intent` receives the host's sources (its DOM, `STATE`, `EVENTS`, `CHILD` and drivers) and the options. Name the actions without the key: the host sees `TOGGLE` as `more.TOGGLE` when it uses the behavior under `more`.
|
|
158
|
+
- The `model` reducers receive the slice, not the host's state, and return the new slice. `ABORT` (or the slice itself) means no change. `next('CLOSE')` inside the behavior names its own action.
|
|
159
|
+
- `calculated` fields are computed on the slice and stored on it (`state.more.label`).
|
|
160
|
+
- A behavior's entries can use every sink a component can: `EVENTS`, `PARENT`, `EFFECT`, drivers.
|
|
161
|
+
|
|
162
|
+
A host uses it like a first-party behavior:
|
|
163
|
+
|
|
164
|
+
```jsx live
|
|
165
|
+
// Product.jsx
|
|
166
|
+
import { disclosure } from './behaviors/disclosure.js'
|
|
167
|
+
|
|
168
|
+
export function Product({ state }) {
|
|
169
|
+
return (
|
|
170
|
+
<section>
|
|
171
|
+
<h2>{state.name}</h2>
|
|
172
|
+
<button className="toggle" aria-expanded={String(state.more.open)}>{state.more.label}</button>
|
|
173
|
+
{state.more.open && <p>{state.description}</p>}
|
|
174
|
+
</section>
|
|
175
|
+
)
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
Product.initialState = { name: 'Desk lamp', description: 'Warm light, three brightness levels.' }
|
|
179
|
+
Product.uses = { more: disclosure({ toggle: '.toggle' }) }
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
`state.more` is `{ open: false, label: 'Show details' }` to begin with, and a click on the `.toggle` button dispatches `more.TOGGLE`. To start it open, pass the state key as an option: `disclosure({ toggle: '.toggle', open: true })`.
|
|
183
|
+
|
|
184
|
+
Two components can use the same behavior, and so can every item of a [Collection](https://sygnal.js.org/guide/collections/): each instance gets its own slice. In a Collection item (or any child that gets its state from its parent), `state[key]` reads as the behavior's initial state until the first action writes it.
|
|
185
|
+
|
|
186
|
+
### Options, the key, host state and timers
|
|
187
|
+
|
|
188
|
+
Every model handler of a behavior gets the use's options and its key after the usual arguments: `(slice, data, next, props, options, key)`, and the intent gets `(sources, options, key)`. `props.state` is the host's whole state.
|
|
189
|
+
|
|
190
|
+
Two more parts are for behaviors that reach past their slice:
|
|
191
|
+
|
|
192
|
+
- **`HOST`**: a model entry `{ HOST: (state, data, next, props, options, key) => newState }` is a reducer on the host's whole state, for a behavior that edits a host field (reordering `state[options.from]`, say). `ABORT`, or the same state, means no change. An entry can have both: `STATE` runs first (on the slice), then `HOST` gets the whole state with that update applied; an `ABORT` from one keeps the other's change.
|
|
193
|
+
- **`timers: (slice, options, key) => ({ name: spec })`** declares [timers](./timers.md) for the host, as the `timers` static does (the app needs `makeTimerDriver()`). The host sees them as `'<key>.<name>'`; a spec whose `action` names one of the behavior's actions is sent to that action (`'SHOW'` arrives as `'tip.SHOW'`), any other action name goes to the host as written. They run alongside the host's own `timers`.
|
|
194
|
+
|
|
195
|
+
```js live-file=./behaviors/hoverDelay.js
|
|
196
|
+
// behaviors/hoverDelay.js
|
|
197
|
+
import { defineBehavior } from 'sygnal'
|
|
198
|
+
|
|
199
|
+
export const hoverDelay = defineBehavior({
|
|
200
|
+
initialState: { pending: false, open: false },
|
|
201
|
+
intent: ({ DOM }, { target }) => ({ ENTER: DOM.mouseenter(target), LEAVE: DOM.mouseleave(target) }),
|
|
202
|
+
timers: (slice, { delay = 300 }) => ({ show: slice.pending && { after: delay, action: 'SHOW' } }),
|
|
203
|
+
model: {
|
|
204
|
+
ENTER: (slice) => ({ ...slice, pending: true }),
|
|
205
|
+
LEAVE: (slice) => ({ ...slice, pending: false, open: false }),
|
|
206
|
+
SHOW: (slice) => ({ ...slice, pending: false, open: true }),
|
|
207
|
+
},
|
|
208
|
+
})
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
A host uses it with its own delay:
|
|
212
|
+
|
|
213
|
+
```jsx live
|
|
214
|
+
// Card.jsx
|
|
215
|
+
import { hoverDelay } from './behaviors/hoverDelay.js'
|
|
216
|
+
|
|
217
|
+
export function Card({ state }) {
|
|
218
|
+
return (
|
|
219
|
+
<div>
|
|
220
|
+
<button className="help">Help</button>
|
|
221
|
+
{state.tip.open && <p className="tip">Shown half a second after the pointer entered Help.</p>}
|
|
222
|
+
</div>
|
|
223
|
+
)
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
Card.uses = { tip: hoverDelay({ target: '.help', delay: 500 }) }
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
`state.tip.open` turns true half a second after the pointer enters `.help` (`tip.show` is the timer, `tip.SHOW` its action), and false when it leaves. A behavior that sends a driver request names its [reply actions](./http.md) with the key, so they reach its own model: `ok: key + '.LOADED'`.
|
|
230
|
+
|
|
231
|
+
### Persisted state and undo steps
|
|
232
|
+
|
|
233
|
+
Two markers say how a behavior's state relates to the app's data:
|
|
234
|
+
|
|
235
|
+
- **`persist: false`**: the slice is UI state (an open menu, a drag in progress). A root's [`persist()`](./persistence.md) neither saves nor restores that `uses` key, whatever `pick` / `omit` say. This covers the root component's own `uses` only: the slice of a behavior on a sub-component or a Collection item is part of the data around it (`state.list.sort`, `state.lanes[0].sort`) and is saved with it. The behavior has to cope with a restored slice (sortable resets restored drag state when the host starts, and leaves the data as it is).
|
|
236
|
+
- **`undoStep: ['DONE']`**: the actions that complete one undoable step. With the [`undo` behavior](./undo.md) on the same host, the behavior's other actions are steps of a gesture: their changes to the undo key aren't recorded, and `DONE` records the value from before the gesture as one entry. A gesture that ends without `DONE` (cancelled) records nothing, and so does one whose steps bring the value back to where it started: the same value, or an array or plain object with the same entries (compared one level deep, by identity; return the earlier entry objects, not copies, when a step puts them back). The order of the two in `uses` doesn't matter. A recorded change, UNDO or REDO in the middle of a gesture first records the value from before it. Only the behavior that started a gesture completes it: another gesture behavior's `DONE` on the same host doesn't record it. With `track` naming none of the behavior's actions, its gestures aren't recorded.
|
|
237
|
+
|
|
238
|
+
[`sortable`](./drag-and-drop.md) declares both: `persist: false`, and `undoStep: ['DROPPED']`, so one drag is one undo step. It also tells `undo` (internally) when a drag ends without a drop, so a cancelled drag never leaves a step pending.
|
|
239
|
+
|
|
240
|
+
## Extending a behavior's actions
|
|
241
|
+
|
|
242
|
+
The host can trigger a behavior action, and add its own entry for one. Both use the namespaced name:
|
|
243
|
+
|
|
244
|
+
```jsx live
|
|
245
|
+
// Faq.jsx
|
|
246
|
+
import { ABORT } from 'sygnal'
|
|
247
|
+
import { disclosure } from './behaviors/disclosure.js'
|
|
248
|
+
|
|
249
|
+
export function Faq({ state }) {
|
|
250
|
+
return (
|
|
251
|
+
<section>
|
|
252
|
+
<button className="toggle" aria-expanded={String(state.answer.open)}>{state.answer.label}</button>
|
|
253
|
+
{state.answer.open && <p>{state.text}</p>}
|
|
254
|
+
<p className="opened">Opened {state.opened} times</p>
|
|
255
|
+
</section>
|
|
256
|
+
)
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
Faq.initialState = { text: 'Yes, returns are free for 30 days.', opened: 0 }
|
|
260
|
+
Faq.uses = { answer: disclosure({ toggle: '.toggle' }) }
|
|
261
|
+
Faq.intent = ({ DOM }) => ({
|
|
262
|
+
// a behavior action triggered by the host: Escape closes the answer
|
|
263
|
+
'answer.CLOSE': DOM.keydown('document').key().filter(key => key === 'Escape'),
|
|
264
|
+
})
|
|
265
|
+
Faq.model = {
|
|
266
|
+
// runs after the behavior's own TOGGLE, on the full state with its update applied
|
|
267
|
+
'answer.TOGGLE': (state) => (state.answer.open ? { ...state, opened: state.opened + 1 } : ABORT),
|
|
268
|
+
}
|
|
269
|
+
```
|
|
270
|
+
|
|
271
|
+
The merge rules:
|
|
272
|
+
|
|
273
|
+
| Host adds | Effect |
|
|
274
|
+
|---|---|
|
|
275
|
+
| An intent action with a behavior's name (`'answer.CLOSE'`) | Triggers that action. If the behavior has its own trigger for it, the host's replaces it (see [keyboard shortcuts for undo](./undo.md#keyboard-shortcuts)). |
|
|
276
|
+
| A STATE reducer for a behavior action (`'answer.TOGGLE'`) | Runs after the behavior's, on the full state with the behavior's update applied. Its `ABORT` keeps that update. |
|
|
277
|
+
| An `EFFECT` for a behavior action | Runs after the behavior's `EFFECT`. |
|
|
278
|
+
| A value sink (`EVENTS`, `PARENT`, a driver) for a behavior action | Replaces the behavior's value for that sink. |
|
|
279
|
+
| A reducer for another action that changes `state[key]` | The slice's calculated fields are recomputed. |
|
|
280
|
+
|
|
281
|
+
A behavior's [reply actions](./http.md) (`ok: 'LOADED'` on a request it sends) arrive under the name it gives; to reach its own model, it names them with its key (`ok: key + '.LOADED'`, see above).
|
|
282
|
+
|
|
283
|
+
## Testing
|
|
284
|
+
|
|
285
|
+
`simulateAction` takes the namespaced name, and [`t.actions`](https://sygnal.js.org/integration/testing/#action-log-tactions-and-texplain) lists behavior actions under it. A click on a behavior's trigger has the cause `'behavior'`:
|
|
286
|
+
|
|
287
|
+
```jsx
|
|
288
|
+
import { expect } from 'vitest'
|
|
289
|
+
import { renderComponent } from 'sygnal'
|
|
290
|
+
import { Faq } from './Faq.jsx'
|
|
291
|
+
|
|
292
|
+
const t = renderComponent(Faq)
|
|
293
|
+
t.simulateAction('answer.TOGGLE')
|
|
294
|
+
await t.next(s => s.answer.open)
|
|
295
|
+
expect(t.state.opened).toBe(1)
|
|
296
|
+
expect(t.actions.at(-1)).toMatchObject({ type: 'answer.TOGGLE', cause: 'simulateAction', component: 'Faq' })
|
|
297
|
+
t.dispose()
|
|
298
|
+
```
|
|
299
|
+
|
|
300
|
+
## SYG127
|
|
301
|
+
|
|
302
|
+
A `uses` entry that can't work is [SYG127](https://sygnal.js.org/reference/errors/#syg127), an error:
|
|
303
|
+
|
|
304
|
+
- the host's `initialState` already has the key (`initialState = { pager: … }` with `uses = { pager: … }`): set the behavior's values through its options instead;
|
|
305
|
+
- the value isn't a behavior: a plain object, or the factory without the call (`uses = { pager }` instead of `pager()`);
|
|
306
|
+
- an option the behavior never reads, usually a typo (`pager({ nxt: '.newer' })`). Only `sygnal-check` reports this one.
|
|
307
|
+
|
|
308
|
+
`sygnal-check` follows `uses` entries to their `defineBehavior()` call in the same file or a relative import, and knows the first-party behaviors (the `sygnal/ui` parts too: an option typo such as `dialog({ clse: '.close' })` is SYG127), so its other rules ([SYG101](https://sygnal.js.org/reference/errors/#syg101), [SYG102](https://sygnal.js.org/reference/errors/#syg102), …) see the behavior's actions too. A behavior imported from a package is not checked. The dev checks report SYG127 once per component definition.
|
|
309
|
+
|
|
310
|
+
## TypeScript
|
|
311
|
+
|
|
312
|
+
`UsesState<typeof uses>` is the state a `uses` object adds and `UsesActions<typeof uses>` its namespaced actions, for a component's state and action types: `type State = { tasks: Task[] } & UsesState<typeof uses>`.
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
<!-- Generated from docs/src/content/docs/guide by scripts/copy-guides.mjs; online: https://sygnal.js.org/guide/ -->
|
|
2
|
+
# Browser Sources
|
|
3
|
+
|
|
4
|
+
A component declares what it wants to watch in the browser as a function of its state, and gets each change as one of its own actions. There is no observer to create or listener to remove: while the declaration is there the source runs, and when it goes away the source stops.
|
|
5
|
+
|
|
6
|
+
```jsx
|
|
7
|
+
Card.browser = (state) => ({
|
|
8
|
+
seen: !state.seen && { intersection: '.cover', action: 'SEEN' },
|
|
9
|
+
dark: { media: '(prefers-color-scheme: dark)', action: 'DARK' },
|
|
10
|
+
})
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
It is the same shape as [`timers`](./timers.md): a function of the state that returns named specs, compared with the previous result by name.
|
|
14
|
+
|
|
15
|
+
## Setup
|
|
16
|
+
|
|
17
|
+
Browser sources are run by `makeBrowserDriver()`, which you register when you start the app:
|
|
18
|
+
|
|
19
|
+
```jsx
|
|
20
|
+
import { run, makeBrowserDriver } from 'sygnal'
|
|
21
|
+
import App from './App.jsx'
|
|
22
|
+
|
|
23
|
+
run(App, { BROWSER: makeBrowserDriver() })
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
The key is yours to choose (`BROWSER` by convention): Sygnal finds the driver by the `browser` static it takes. One driver serves every component of the app. A component that declares `browser` in an app without the driver gets [SYG643](https://sygnal.js.org/reference/errors/#syg643) in development.
|
|
27
|
+
|
|
28
|
+
`makeBrowserDriver()` has every source. An app that uses only a few can list them, so the others add no bytes:
|
|
29
|
+
|
|
30
|
+
```jsx
|
|
31
|
+
import { run, makeBrowserDriverWith, intersectionSource, mediaSource } from 'sygnal'
|
|
32
|
+
import App from './App.jsx'
|
|
33
|
+
|
|
34
|
+
run(App, { BROWSER: makeBrowserDriverWith(intersectionSource, mediaSource) })
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
A spec whose source the driver wasn't made with is [SYG664](https://sygnal.js.org/reference/errors/#syg664) in development. The sources are `intersectionSource`, `resizeSource`, `mediaSource`, `storageSource`, `visibilitySource`, `onlineSource`, `geolocationSource` and `clipboardSource`.
|
|
38
|
+
|
|
39
|
+
## The browser static
|
|
40
|
+
|
|
41
|
+
`browser` takes the state (with its calculated fields) and returns an object of named specs, or a falsy value for "not now". One key of a spec names its source (a spec has exactly one of them), and `action` names the action each event is delivered as:
|
|
42
|
+
|
|
43
|
+
| Spec | Watches | Action data |
|
|
44
|
+
|---|---|---|
|
|
45
|
+
| `{ intersection: '.cover', action }` | The elements the selector matches in this component's view entering or leaving the viewport (`IntersectionObserver`). `true` instead of a selector: the component's root element. Options: `threshold`, `rootMargin` | `{ visible, ratio, index, dataset }` |
|
|
46
|
+
| `{ resize: '.chart', action }` | The size of those elements' content box (`ResizeObserver`); `true`: the root element | `{ width, height, index, dataset }` |
|
|
47
|
+
| `{ media: '(min-width: 800px)', action }` | A media query | `{ matches, media }` |
|
|
48
|
+
| `{ storage: 'theme', action }` | A `localStorage` key (`area: 'session'`: `sessionStorage`; `json: true`: the value parsed), including writes from other tabs | `{ key, value }` (`null` when absent) |
|
|
49
|
+
| `{ visibility: true, action }` | Whether the page is visible (another tab, a minimized window) | `{ visible }` |
|
|
50
|
+
| `{ online: true, action }` | Whether the browser is online | `{ online }` |
|
|
51
|
+
| `{ geolocation: true, action, error }` | The device's position (`watchPosition`; permission-gated). `true`, or the position options `{ enableHighAccuracy, maximumAge, timeout }` | `{ latitude, longitude, accuracy, altitude, altitudeAccuracy, heading, speed, timestamp }` |
|
|
52
|
+
|
|
53
|
+
`index` and `dataset` say which element the event is about when the selector matches several (its position among them, and a copy of its `data-*` attributes). Sources with a current value (`media`, `storage`, `visibility`, `online`) send it when they start, so the state is right from the first render after it; `intersection` and `resize` send what their observer reports for each element when it starts observing it. Elements that the selector matches later (a list that grows) are observed after the render that adds them, and removed ones are let go.
|
|
54
|
+
|
|
55
|
+
Sygnal compares the result with the previous one, name by name, whenever the state changes:
|
|
56
|
+
|
|
57
|
+
- a new name starts its source;
|
|
58
|
+
- a name that is gone, or now falsy, stops it;
|
|
59
|
+
- a spec that changed (another selector, another query) restarts it;
|
|
60
|
+
- a spec that is the same as before keeps running.
|
|
61
|
+
|
|
62
|
+
So, as with timers, `browser` is a description: return what should be watched now. A "load more when the end of the list is visible" or "fade in once" component turns its spec off with its state:
|
|
63
|
+
|
|
64
|
+
```jsx
|
|
65
|
+
export function Photo({ state }) {
|
|
66
|
+
return <img className={state.seen ? 'photo shown' : 'photo'} src={state.src} alt={state.alt} />
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
Photo.initialState = { src: '/cat.jpg', alt: 'A cat', seen: false }
|
|
70
|
+
// watched until it has been seen once
|
|
71
|
+
Photo.browser = (state) => ({
|
|
72
|
+
seen: !state.seen && { intersection: true, action: 'SEEN', threshold: 0.25 },
|
|
73
|
+
})
|
|
74
|
+
Photo.model = {
|
|
75
|
+
SEEN: (state, { visible }) => visible ? { ...state, seen: true } : state,
|
|
76
|
+
}
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Each [Collection](https://sygnal.js.org/guide/collections/) item declares its own sources, so `intersection: true` in an item component watches that item.
|
|
80
|
+
|
|
81
|
+
## Commands: the clipboard and storage writes
|
|
82
|
+
|
|
83
|
+
Reading or writing the clipboard is something a user asks for, not something to watch, so it is a command: a model entry sends it to the driver's sink, and the answer comes back as a reply action. The method is its `copy`, `paste`, `setItem` or `removeItem` key (in any order with `ok` and `error`); a value the method can't take (a `json` value with a BigInt or a cycle) fails like a refusal, through `error`:
|
|
84
|
+
|
|
85
|
+
| Command | Does | Reply |
|
|
86
|
+
|---|---|---|
|
|
87
|
+
| `{ copy: text, ok, error }` | Writes `text` to the clipboard | `ok`: `{ text }` |
|
|
88
|
+
| `{ paste: true, ok, error }` | Reads the clipboard's text | `ok`: `{ text }` |
|
|
89
|
+
| `{ setItem: key, value, area, json }` | Writes a storage key (`json: true` stores `value` as JSON); this page's `storage` declarations of that key see the change (a write of the value already stored is silent, so a model that writes back what it reads settles; `storage` listeners of your own on `window` hear only other tabs, as in the browser) | `ok`: `{ key }` |
|
|
90
|
+
| `{ removeItem: key, area }` | Removes a storage key | `ok`: `{ key }` |
|
|
91
|
+
|
|
92
|
+
```jsx live
|
|
93
|
+
export function ShareLink({ state }) {
|
|
94
|
+
return (
|
|
95
|
+
<div className="share">
|
|
96
|
+
<input readOnly value={state.url} aria-label="Link" />
|
|
97
|
+
<button className="copy">{state.copied ? 'Copied' : 'Copy link'}</button>
|
|
98
|
+
{state.error ? <p role="alert">{state.error}</p> : null}
|
|
99
|
+
</div>
|
|
100
|
+
)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
ShareLink.initialState = { url: 'https://example.com/p/42', copied: false, error: null }
|
|
104
|
+
ShareLink.intent = ({ DOM }) => ({ COPY: DOM.click('.copy') })
|
|
105
|
+
ShareLink.model = {
|
|
106
|
+
COPY: { BROWSER: (state) => ({ copy: state.url, ok: 'COPIED', error: 'COPY_FAILED' }) },
|
|
107
|
+
COPIED: (state) => ({ ...state, copied: true, error: null }),
|
|
108
|
+
COPY_FAILED: (state, { message }) => ({ ...state, error: message }),
|
|
109
|
+
}
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
The browser only allows the clipboard in a secure context (HTTPS or localhost), and usually only during a user action such as a click: send the command from the action the click causes, as above. Reading needs the user's permission (some browsers ask, Safari shows a paste menu), so always name an `error` action for `paste`.
|
|
113
|
+
|
|
114
|
+
## Permissions and failures
|
|
115
|
+
|
|
116
|
+
`geolocation` and the clipboard are permission-gated: the first use may show a prompt, and the user may say no. A source or command that fails (permission denied, the API is missing, the position timed out, storage is blocked) sends its `error` action with `{ code, message }` (geolocation) or `{ name, message }` (clipboard, storage). Without an `error` action the failure is [SYG665](https://sygnal.js.org/reference/errors/#syg665) in development and nothing else happens, so name one whenever the user can refuse:
|
|
117
|
+
|
|
118
|
+
```jsx
|
|
119
|
+
export function NearMe({ state }) {
|
|
120
|
+
if (state.error) return <p role="alert">Location unavailable: {state.error}</p>
|
|
121
|
+
if (!state.position) return <p>Finding you…</p>
|
|
122
|
+
return <p className="where">{state.position.latitude.toFixed(3)}, {state.position.longitude.toFixed(3)}</p>
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
NearMe.initialState = { position: null, error: null }
|
|
126
|
+
NearMe.browser = (state) => ({
|
|
127
|
+
here: !state.error && { geolocation: { enableHighAccuracy: true }, action: 'POSITION', error: 'NO_POSITION' },
|
|
128
|
+
})
|
|
129
|
+
NearMe.model = {
|
|
130
|
+
POSITION: (state, position) => ({ ...state, position }),
|
|
131
|
+
NO_POSITION: (state, { message }) => ({ ...state, error: message }),
|
|
132
|
+
}
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
Ask for a permission-gated source when the user can see why, for example only after they press "Use my location" (declare it while `state.locating` is true).
|
|
136
|
+
|
|
137
|
+
## Storage and persist()
|
|
138
|
+
|
|
139
|
+
`storage` reads and observes a key that something else owns: another tab, another part of the app, a third-party script. To keep a component's own state across reloads, use [`persist()`](./persistence.md) instead: it restores the state before the first render, saves it as it changes, migrates old versions and syncs tabs.
|
|
140
|
+
|
|
141
|
+
## Hidden pages
|
|
142
|
+
|
|
143
|
+
A component on a hidden [Switchable](https://sygnal.js.org/guide/switchable/) page keeps its state, but its sources stop while the page is hidden, and start again (sending their current value) when it is shown. A source that should keep running says so:
|
|
144
|
+
|
|
145
|
+
```jsx
|
|
146
|
+
Inbox.browser = () => ({
|
|
147
|
+
// keeps following the network while another page is shown
|
|
148
|
+
net: { online: true, action: 'NETWORK', background: true },
|
|
149
|
+
})
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
When a component is removed, its sources stop; when the app is disposed, all of them do. During `renderToString` no driver runs, so nothing is watched on the server.
|
|
153
|
+
|
|
154
|
+
`onlineStatus$` (the [PWA helpers](https://sygnal.js.org/integration/pwa/)) is the same information as a stream for an intent. The `online` source is the declaration form: driven by state, paused on hidden pages, and faked by `renderComponent`.
|
|
155
|
+
|
|
156
|
+
## Testing
|
|
157
|
+
|
|
158
|
+
`renderComponent` provides a fake browser driver (no `drivers` option needed): the real driver, over sources that touch no browser API. `t.browser` drives it, and each call resolves once the actions it caused are reduced and the tree rendered:
|
|
159
|
+
|
|
160
|
+
```jsx
|
|
161
|
+
import { it, expect } from 'vitest'
|
|
162
|
+
import { renderComponent } from 'sygnal'
|
|
163
|
+
import { Photo } from './Photo.jsx'
|
|
164
|
+
|
|
165
|
+
it('shows the photo once it is seen, then stops watching', async () => {
|
|
166
|
+
const t = renderComponent(Photo)
|
|
167
|
+
await t.ready()
|
|
168
|
+
expect(t.browser.active()).toEqual([{ name: 'seen', intersection: true, action: 'SEEN', threshold: 0.25, component: 'Photo' }])
|
|
169
|
+
|
|
170
|
+
await t.browser.intersect(true, true)
|
|
171
|
+
expect(t.state.seen).toBe(true)
|
|
172
|
+
expect(t.browser.active()).toEqual([])
|
|
173
|
+
t.dispose()
|
|
174
|
+
})
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
| Call | Effect |
|
|
178
|
+
|---|---|
|
|
179
|
+
| `t.browser.intersect(target, visible, data?)` | The declarations of that `intersection` target hear `{ visible, ratio: 1 or 0, index: 0, dataset: {} }` merged with `data`; `{ at: n }` in `data` picks the n-th of them (start order, from 0), e.g. one Collection item. Throws when nothing declares it |
|
|
180
|
+
| `t.browser.resize(target, { width, height })` | The same for `resize` |
|
|
181
|
+
| `t.browser.media(query, matches)` | The query matches now (or not) |
|
|
182
|
+
| `t.browser.storage(key, value, area?)` | Another tab writes the key (a non-string is stored as JSON; `null` removes it). With only a key: the stored string |
|
|
183
|
+
| `t.browser.visibility(visible)`, `t.browser.online(online)` | The page is visible / the network is up (or not) |
|
|
184
|
+
| `t.browser.geolocation(coords)` | A position (accuracy 0, the rest `null` unless given); `{ code, message }` instead: a failure |
|
|
185
|
+
| `t.browser.clipboard(text?)` | The clipboard's text (with an argument: sets it) |
|
|
186
|
+
| `t.browser.deny('geolocation', 'clipboard')` | Those permissions are refused from now on (a running geolocation declaration fails with code 1) |
|
|
187
|
+
| `t.browser.active()` | The running declarations: `{ name, ...spec, component }` |
|
|
188
|
+
|
|
189
|
+
As with the real observers, an `intersection` declaration hears `{ visible: false, ratio: 0, index: 0, dataset: {} }` when it starts and a `resize` one `{ width: 0, height: 0, index: 0, dataset: {} }`, so a model that counts reports must ignore a first "not visible". The fake doesn't look for the elements under the mock DOM; with `renderComponent(C, { dom: 'real' })` a selector that matches no element of the component after it renders is [SYG668](https://sygnal.js.org/reference/errors/#syg668).
|
|
190
|
+
|
|
191
|
+
The `browser` option sets the environment at the start: `renderComponent(Theme, { browser: { media: { '(prefers-color-scheme: dark)': true }, storage: { theme: '"dark"' }, online: false, deny: ['geolocation'] } })`. By default no media query matches, storage is empty, the page is visible and online, and nothing is denied. The commands (`copy`, `paste`, `setItem`, `removeItem`) run against the fake too. If the test passes its own browser driver in `drivers`, the fake stands down and `t.browser` throws (`browserSink` renames the fake's sink).
|
|
192
|
+
|
|
193
|
+
## Diagnostics
|
|
194
|
+
|
|
195
|
+
| Code | When |
|
|
196
|
+
|---|---|
|
|
197
|
+
| [SYG663](https://sygnal.js.org/reference/errors/#syg663) (error) | A spec the driver can't start (no known source key, no `action`, an `intersection` / `resize` target that is neither a selector nor `true`), or a command with an unknown method |
|
|
198
|
+
| [SYG664](https://sygnal.js.org/reference/errors/#syg664) (error) | A spec whose source `makeBrowserDriverWith(...)` wasn't given |
|
|
199
|
+
| [SYG665](https://sygnal.js.org/reference/errors/#syg665) (warning) | A source or command failed and names no `error` action |
|
|
200
|
+
| [SYG668](https://sygnal.js.org/reference/errors/#syg668) (warning) | An `intersection` / `resize` entry has nothing to observe: no DOM source reached the driver, or (in `renderComponent` with `dom: 'real'`) its selector matches no element of the component |
|
|
201
|
+
| [SYG643](https://sygnal.js.org/reference/errors/#syg643) (warning) | The component declares `browser`, but no browser driver is registered. `sygnal-check` reports it too when it can read the app's `run()` call |
|
|
202
|
+
|
|
203
|
+
Deferred loading of a component when it scrolls into view is [`lazy(…, { when: 'visible' })`](https://sygnal.js.org/advanced/lazy-loading/#loading-when-visible-or-idle).
|