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,222 @@
|
|
|
1
|
+
<!-- Generated from docs/src/content/docs/recipes by scripts/copy-guides.mjs; online: https://sygnal.js.org/recipes/ -->
|
|
2
|
+
# Translations (i18n)
|
|
3
|
+
|
|
4
|
+
[i18next](https://www.i18next.com/) looks up translated strings, fills in values and picks plural forms. In a Sygnal app the chosen locale is ordinary state: the root keeps it, [`.context`](https://sygnal.js.org/guide/context/) hands every component a `t()` for that locale, and [`persist()`](../persistence.md) remembers it for the next visit. Switching the language is an action like any other, and every component re-renders in the new language.
|
|
5
|
+
|
|
6
|
+
## Install
|
|
7
|
+
|
|
8
|
+
```sh
|
|
9
|
+
npm install i18next
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
## The i18next instance
|
|
13
|
+
|
|
14
|
+
```js live-file=./i18n.js
|
|
15
|
+
// i18n.js
|
|
16
|
+
import i18next from 'i18next'
|
|
17
|
+
|
|
18
|
+
export const LOCALES = { en: 'English', fr: 'Français' }
|
|
19
|
+
|
|
20
|
+
export const i18n = i18next.createInstance()
|
|
21
|
+
i18n.init({
|
|
22
|
+
initAsync: false, // the resources are here: ready before the first render
|
|
23
|
+
interpolation: { escapeValue: false }, // Sygnal renders strings as text, never as HTML
|
|
24
|
+
fallbackLng: 'en',
|
|
25
|
+
supportedLngs: Object.keys(LOCALES),
|
|
26
|
+
resources: {
|
|
27
|
+
en: {
|
|
28
|
+
translation: {
|
|
29
|
+
language: 'Language',
|
|
30
|
+
greeting: 'Hello, {{name}}!',
|
|
31
|
+
cart: {
|
|
32
|
+
title: 'Your cart',
|
|
33
|
+
add: 'Add a coffee',
|
|
34
|
+
items_one: '{{count}} item',
|
|
35
|
+
items_other: '{{count}} items',
|
|
36
|
+
total: 'Total: {{total, currency(EUR)}}',
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
},
|
|
40
|
+
fr: {
|
|
41
|
+
translation: {
|
|
42
|
+
language: 'Langue',
|
|
43
|
+
greeting: 'Bonjour, {{name}} !',
|
|
44
|
+
cart: {
|
|
45
|
+
title: 'Votre panier',
|
|
46
|
+
add: 'Ajouter un café',
|
|
47
|
+
items_one: '{{count}} article',
|
|
48
|
+
items_other: '{{count}} articles',
|
|
49
|
+
total: 'Total : {{total, currency(EUR)}}',
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
},
|
|
53
|
+
},
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
// One t() per locale, made once: the context value changes only when the locale does
|
|
57
|
+
const fixed = {}
|
|
58
|
+
export const translator = (locale) => (fixed[locale] ||= i18n.getFixedT(locale))
|
|
59
|
+
|
|
60
|
+
// The browser's language; 'en' on the server (Node 21+ has a navigator too, with the server's)
|
|
61
|
+
export function detectLocale() {
|
|
62
|
+
const lang = typeof window === 'undefined' ? 'en' : (navigator.language || '').slice(0, 2)
|
|
63
|
+
return lang in LOCALES ? lang : 'en'
|
|
64
|
+
}
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
- `initAsync: false` with the translations inline makes `t()` work as soon as the module is loaded, so the first render is already translated.
|
|
68
|
+
- `escapeValue: false`: i18next escapes the values it fills in for HTML by default (`&` becomes `&`). Sygnal puts strings into the page as text, which is already safe, so escaping would show the entities.
|
|
69
|
+
- `getFixedT(locale)` returns a `t` bound to one locale. The app never calls `i18n.changeLanguage()`: the locale lives in Sygnal's state, not in the i18next instance, so several apps (or tests) can show different languages at once.
|
|
70
|
+
- `translator` keeps one `t` per locale, so the function only changes when the locale does.
|
|
71
|
+
|
|
72
|
+
## The app
|
|
73
|
+
|
|
74
|
+
```jsx live
|
|
75
|
+
// App.jsx
|
|
76
|
+
import { persist } from 'sygnal'
|
|
77
|
+
import { LOCALES, translator, detectLocale } from './i18n.js'
|
|
78
|
+
import { Cart } from './Cart.jsx'
|
|
79
|
+
|
|
80
|
+
export function App({ state }) {
|
|
81
|
+
const t = translator(state.locale)
|
|
82
|
+
return (
|
|
83
|
+
<main lang={state.locale}>
|
|
84
|
+
<label>
|
|
85
|
+
{t('language')}
|
|
86
|
+
<select className="locale" value={state.locale}>
|
|
87
|
+
{Object.entries(LOCALES).map(([code, name]) => <option value={code}>{name}</option>)}
|
|
88
|
+
</select>
|
|
89
|
+
</label>
|
|
90
|
+
<h1>{t('greeting', { name: state.name })}</h1>
|
|
91
|
+
<Cart state="cart" />
|
|
92
|
+
</main>
|
|
93
|
+
)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
App.initialState = { locale: detectLocale(), name: 'Ada', cart: { items: 0 } }
|
|
97
|
+
|
|
98
|
+
App.context = { t: (state) => translator(state.locale) }
|
|
99
|
+
|
|
100
|
+
App.intent = ({ DOM }) => ({
|
|
101
|
+
LOCALE: DOM.change('.locale').value(),
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
App.model = {
|
|
105
|
+
LOCALE: (state, locale) => ({ ...state, locale }),
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
App.persist = persist({ key: 'locale', pick: ['locale'] })
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
```jsx live-file=./Cart.jsx
|
|
112
|
+
// Cart.jsx
|
|
113
|
+
const PRICE = 4.5
|
|
114
|
+
|
|
115
|
+
export function Cart({ state, context }) {
|
|
116
|
+
const { t } = context
|
|
117
|
+
return (
|
|
118
|
+
<section>
|
|
119
|
+
<h2>{t('cart.title')}</h2>
|
|
120
|
+
<p className="count">{t('cart.items', { count: state.items })}</p>
|
|
121
|
+
<p className="total">{t('cart.total', { total: state.items * PRICE })}</p>
|
|
122
|
+
<button className="add">{t('cart.add')}</button>
|
|
123
|
+
</section>
|
|
124
|
+
)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
Cart.intent = ({ DOM }) => ({
|
|
128
|
+
ADD: DOM.click('.add'),
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
Cart.model = {
|
|
132
|
+
ADD: (state) => ({ ...state, items: state.items + 1 }),
|
|
133
|
+
}
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
- **The locale** is a key of the root's state. On the first visit it comes from the browser (`detectLocale()`); after that, `persist()` restores the saved one before the first render, so there is no flash of the wrong language.
|
|
137
|
+
- **`t` for every component**: `App.context.t` is computed from the root's state, and every component below reads it as `context.t`, however deep. When the locale changes, the context changes, and the components that use it render again. The root itself calls `translator(state.locale)`, since the locale is in its own state.
|
|
138
|
+
- **Reducers** get the context too, on their fourth argument: `(state, data, next, { context }) => ... context.t('saved') ...`, for a message the model builds.
|
|
139
|
+
|
|
140
|
+
## Plurals, values and formats
|
|
141
|
+
|
|
142
|
+
- **Plurals**: pass `count`, and i18next picks the key with the language's plural suffix (`items_one`, `items_other`; some languages have `_zero`, `_two`, `_few`, `_many`). The rules are the language's own, from `Intl.PluralRules`: French uses the singular for 0 ("0 article"), English the plural ("0 items").
|
|
143
|
+
- **Values**: `{{name}}` in a string is filled from the options: `t('greeting', { name: state.name })`.
|
|
144
|
+
- **Formats**: `{{total, currency(EUR)}}` formats with `Intl.NumberFormat` for the locale: "€4.50" in English, "4,50 €" in French. `number`, `datetime`, `relativetime` and `list` work the same way.
|
|
145
|
+
|
|
146
|
+
## Accessibility
|
|
147
|
+
|
|
148
|
+
- Put the locale in a `lang` attribute (here on `<main>`), so screen readers pronounce the text in the right language. For the whole page, set `document.documentElement.lang` from an `EFFECT` in the `LOCALE` entry and in `BOOTSTRAP`.
|
|
149
|
+
- Translated labels are fine. sygnal-check's accessibility rules ([SYG702](https://sygnal.js.org/reference/errors/#syg702), [SYG705](https://sygnal.js.org/reference/errors/#syg705)) check that a field has a label and a button has a name; a `{t('...')}` expression counts as one. They can't check what the translation says, so keep the keys for labels and names present in every language (a missing key shows the key itself).
|
|
150
|
+
- Translate `aria-label`s, `alt` text and `title`s too: `aria-label={t('cart.remove')}`.
|
|
151
|
+
|
|
152
|
+
## Testing
|
|
153
|
+
|
|
154
|
+
`renderComponent` gives a persisting root a fake storage; fill it to start in a saved locale, and read what was saved with `t.storage()`:
|
|
155
|
+
|
|
156
|
+
```jsx
|
|
157
|
+
// App.test.jsx
|
|
158
|
+
import { test, expect } from 'vitest'
|
|
159
|
+
import { renderComponent } from 'sygnal'
|
|
160
|
+
import { App } from './App.jsx'
|
|
161
|
+
|
|
162
|
+
const text = (t, selector) => t.query(selector).textContent.replace(/\s/g, ' ')
|
|
163
|
+
|
|
164
|
+
test('restores the saved locale, pluralises, switches language and saves it', async () => {
|
|
165
|
+
const t = renderComponent(App, { storage: { locale: { version: 1, state: { locale: 'fr' } } } })
|
|
166
|
+
await t.ready()
|
|
167
|
+
expect(text(t, 'h1')).toBe('Bonjour, Ada !')
|
|
168
|
+
expect(text(t, '.count')).toBe('0 article')
|
|
169
|
+
|
|
170
|
+
t.simulateEvent('.add', 'click')
|
|
171
|
+
t.simulateEvent('.add', 'click')
|
|
172
|
+
await t.next((state) => state.cart.items === 2)
|
|
173
|
+
expect(text(t, '.count')).toBe('2 articles')
|
|
174
|
+
expect(text(t, '.total')).toBe('Total : 9,00 €')
|
|
175
|
+
|
|
176
|
+
t.simulateEvent('.locale', 'change', { value: 'en' })
|
|
177
|
+
await t.next((state) => state.locale === 'en')
|
|
178
|
+
expect(text(t, 'h2')).toBe('Your cart')
|
|
179
|
+
expect(text(t, '.count')).toBe('2 items')
|
|
180
|
+
expect(text(t, '.total')).toBe('Total: €9.00')
|
|
181
|
+
expect(t.query('main').getAttribute('lang')).toBe('en')
|
|
182
|
+
|
|
183
|
+
await t.settle()
|
|
184
|
+
expect(t.storage('locale')).toEqual({ version: 1, state: { locale: 'en' } })
|
|
185
|
+
t.dispose()
|
|
186
|
+
})
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
`Intl` puts non-breaking spaces in French numbers and punctuation ("9,00 €", "Ada !"); the `text` helper turns them into plain spaces before comparing.
|
|
190
|
+
|
|
191
|
+
A component further down reads `t` from the context, which comes from its ancestors. To test it alone, pass its state and the context its ancestors would give it ([`renderComponent`'s `context`](https://sygnal.js.org/integration/testing/#context)):
|
|
192
|
+
|
|
193
|
+
```jsx
|
|
194
|
+
// Cart.test.jsx
|
|
195
|
+
import { test, expect } from 'vitest'
|
|
196
|
+
import { renderComponent } from 'sygnal'
|
|
197
|
+
import { Cart } from './Cart.jsx'
|
|
198
|
+
import { translator } from './i18n.js'
|
|
199
|
+
|
|
200
|
+
test('the cart in French', async () => {
|
|
201
|
+
// the state and the context the app would give it
|
|
202
|
+
const t = renderComponent(Cart, { initialState: { items: 1 }, context: { t: translator('fr') } })
|
|
203
|
+
await t.ready()
|
|
204
|
+
expect(t.query('.count').textContent).toBe('1 article')
|
|
205
|
+
t.simulateEvent('.add', 'click')
|
|
206
|
+
await t.next((state) => state.items === 2)
|
|
207
|
+
expect(t.query('.count').textContent).toBe('2 articles')
|
|
208
|
+
t.dispose()
|
|
209
|
+
})
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
## Size
|
|
213
|
+
|
|
214
|
+
Measured with Vite, minified and gzipped, Sygnal not included: **15.5 KB** for i18next and this recipe (the translations are part of it). Larger apps keep each language in its own JSON file and load only the chosen one (below).
|
|
215
|
+
|
|
216
|
+
## Pitfalls
|
|
217
|
+
|
|
218
|
+
- **Don't call `i18n.changeLanguage()`.** It changes a global inside i18next that Sygnal doesn't see, so nothing re-renders. Keep the locale in state and use `getFixedT`.
|
|
219
|
+
- **Keep `t` stable per locale.** `getFixedT` returns a new function on each call. Called directly in `.context`, it gives the context a new value whenever the root's state changes, not only when the locale does; `translator` hands out the same function until the locale changes.
|
|
220
|
+
- **Load translations before switching.** With translations in separate files, fetch the new language first (a `makeFetchDriver` request on the `LOCALE` action), add it with `i18n.addResourceBundle(locale, 'translation', data)` in an `EFFECT` of the reply's action (a reducer only returns state), and only then put the new locale in state (the same action's `STATE` entry: the `EFFECT` runs synchronously and the view renders after the whole action, so the strings are there). A locale whose strings aren't loaded shows the keys.
|
|
221
|
+
- **Server rendering.** Take the locale from the request (the URL or `Accept-Language`) and pass it in the server's state. A locale saved only in `localStorage` reaches a server-rendered page after hydration, as a `RESTORE` action: the page shows the server's language first.
|
|
222
|
+
- **Missing keys show the key.** `t('cart.titel')` renders "cart.titel". Set `saveMissing` with a `missingKeyHandler` in development to log them.
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
<!-- Generated from docs/src/content/docs/recipes by scripts/copy-guides.mjs; online: https://sygnal.js.org/recipes/ -->
|
|
2
|
+
# Icons (Lucide)
|
|
3
|
+
|
|
4
|
+
[Lucide](https://lucide.dev/) has about 1,600 icons. Its framework-neutral package, `lucide`, exports each icon as plain data: a list of SVG elements and their attributes. A small helper turns that data into Sygnal SVG vnodes, so an icon is part of the view like any other markup: it is patched, server-rendered and tested in the mock DOM.
|
|
5
|
+
|
|
6
|
+
## Install
|
|
7
|
+
|
|
8
|
+
```sh
|
|
9
|
+
npm install lucide
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
## The helper
|
|
13
|
+
|
|
14
|
+
```jsx live-file=./icon.jsx
|
|
15
|
+
// icon.jsx
|
|
16
|
+
// A Lucide icon (an array of [tag, attributes, children?]) as Sygnal SVG vnodes
|
|
17
|
+
const parts = (node) => node.map(([Tag, attrs, children]) => <Tag {...attrs}>{children && parts(children)}</Tag>)
|
|
18
|
+
|
|
19
|
+
export function icon(node, { label, size = 24 } = {}) {
|
|
20
|
+
return (
|
|
21
|
+
<svg className="icon" width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
|
22
|
+
stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
|
|
23
|
+
role={label ? 'img' : undefined} aria-label={label} aria-hidden={label ? undefined : 'true'}>
|
|
24
|
+
{parts(node)}
|
|
25
|
+
</svg>
|
|
26
|
+
)
|
|
27
|
+
}
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
`icon` is a plain function that returns vnodes, not a component: call it in the view (`{icon(Plus)}`), don't render it as a tag. A component per icon would cost an instance, a state and a stream for each one, for markup that never changes.
|
|
31
|
+
|
|
32
|
+
The `<svg>` gets Lucide's default attributes (a 24 × 24 view box, a 2-pixel round stroke in `currentColor`, so the icon takes the text colour). Inside an `<svg>`, Sygnal's JSX sets every prop as an attribute in the SVG namespace, so the data's `d`, `cx` or `points` work as written.
|
|
33
|
+
|
|
34
|
+
## Using it
|
|
35
|
+
|
|
36
|
+
```jsx live
|
|
37
|
+
// Toolbar.jsx
|
|
38
|
+
import { Plus, Trash2, CloudCheck, CloudOff } from 'lucide'
|
|
39
|
+
import { icon } from './icon.jsx'
|
|
40
|
+
|
|
41
|
+
export function Toolbar({ state }) {
|
|
42
|
+
return (
|
|
43
|
+
<div role="toolbar" aria-label="Items">
|
|
44
|
+
<button className="add">{icon(Plus)} Add</button>
|
|
45
|
+
<button className="remove" aria-label="Remove the last item">{icon(Trash2)}</button>
|
|
46
|
+
<span className="status">
|
|
47
|
+
{state.online ? icon(CloudCheck, { label: 'Saved' }) : icon(CloudOff, { label: 'Offline' })}
|
|
48
|
+
</span>
|
|
49
|
+
<span className="count">{state.count} items</span>
|
|
50
|
+
</div>
|
|
51
|
+
)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
Toolbar.initialState = { count: 0, online: true }
|
|
55
|
+
|
|
56
|
+
Toolbar.intent = ({ DOM }) => ({
|
|
57
|
+
ADD: DOM.click('.add'),
|
|
58
|
+
REMOVE: DOM.click('.remove'),
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
Toolbar.model = {
|
|
62
|
+
ADD: (state) => ({ ...state, count: state.count + 1 }),
|
|
63
|
+
REMOVE: (state) => ({ ...state, count: Math.max(0, state.count - 1) }),
|
|
64
|
+
}
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Three cases, three ways to make the icon accessible:
|
|
68
|
+
|
|
69
|
+
| Icon | Example | How |
|
|
70
|
+
|---|---|---|
|
|
71
|
+
| Next to text that says the same | **Add** | Decorative: no label, so the helper sets `aria-hidden="true"`. |
|
|
72
|
+
| The only content of a button or link | **Remove** | The button carries the name (`aria-label`); the icon stays hidden. |
|
|
73
|
+
| Meaning on its own, no text | **Saved / Offline** | Pass `label`: the icon gets `role="img"` and an `aria-label`. |
|
|
74
|
+
|
|
75
|
+
sygnal-check reports a button with no accessible name ([SYG705](https://sygnal.js.org/reference/errors/#syg705)) when the `<svg>` is written inside it, and when its only content is an `icon(...)` call with an icon imported from `lucide` (or another icon package) and no `label`: `<button>{icon(Trash2)}</button>` is reported, `<button aria-label="Remove">{icon(Trash2)}</button>` is not. Only `label` names the icon, as in the helper above (`title` or `aria-label` in the options are ignored by the helper, so they don't count). It knows only a function named `icon` (or `renderIcon`); a helper with another name, or an icon passed through a variable, isn't checked, so assert the button's name in a test as below. If your own `icon` helper renders screen-reader text itself, put the name on the button instead, or silence the line with `// sygnal-ignore SYG705`.
|
|
76
|
+
|
|
77
|
+
```css live
|
|
78
|
+
.icon { vertical-align: -0.125em; width: 1.25em; height: 1.25em; }
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
## Testing
|
|
82
|
+
|
|
83
|
+
```jsx
|
|
84
|
+
// Toolbar.test.jsx
|
|
85
|
+
import { test, expect } from 'vitest'
|
|
86
|
+
import { renderComponent } from 'sygnal'
|
|
87
|
+
import { Toolbar } from './Toolbar.jsx'
|
|
88
|
+
|
|
89
|
+
test('decorative icons are hidden, a meaningful icon has a name', async () => {
|
|
90
|
+
const t = renderComponent(Toolbar)
|
|
91
|
+
await t.ready()
|
|
92
|
+
expect(t.query('.add svg').getAttribute('aria-hidden')).toBe('true')
|
|
93
|
+
expect(t.query('.remove svg').getAttribute('aria-hidden')).toBe('true')
|
|
94
|
+
expect(t.query('.remove').getAttribute('aria-label')).toBe('Remove the last item')
|
|
95
|
+
expect(t.query('.status svg').getAttribute('role')).toBe('img')
|
|
96
|
+
expect(t.query('.status svg').getAttribute('aria-label')).toBe('Saved')
|
|
97
|
+
expect(t.queryAll('.add svg path').length).toBe(2)
|
|
98
|
+
|
|
99
|
+
t.simulateEvent('.add', 'click')
|
|
100
|
+
await t.next((state) => state.count === 1)
|
|
101
|
+
expect(t.query('.count').textContent).toBe('1 items')
|
|
102
|
+
t.dispose()
|
|
103
|
+
})
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
## Size
|
|
107
|
+
|
|
108
|
+
Each icon is a few hundred bytes. Measured with Vite, minified and gzipped, Sygnal not included: the four icons and the helper above add **0.9 KB**. Import icons by name (`import { Plus } from 'lucide'`) so the bundler drops the rest.
|
|
109
|
+
|
|
110
|
+
## Pitfalls
|
|
111
|
+
|
|
112
|
+
- **Don't look icons up by name at runtime.** `import { icons } from 'lucide'` and `icons[name]` keep every icon in the bundle: 94 KB instead of 0.9. Map the names you need yourself: `const STATUS = { saved: CloudCheck, offline: CloudOff }`.
|
|
113
|
+
- **Don't use `createIcons()`.** It replaces `<i data-lucide="...">` elements in the page with SVG after the fact; Sygnal owns those elements and puts its own back on the next render. `createElement(Plus)` from `lucide` returns a DOM element, which is useful inside a [widget](../widgets.md)'s `mount`, not in a view.
|
|
114
|
+
- **Icon-only buttons need a name.** The icon is hidden from screen readers, so the button must say what it does.
|
|
115
|
+
- **Not `lucide-react`.** The React package returns React elements; use `lucide`, whose icons are plain data.
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
<!-- Generated from docs/src/content/docs/recipes by scripts/copy-guides.mjs; online: https://sygnal.js.org/recipes/ -->
|
|
2
|
+
# Recipes
|
|
3
|
+
|
|
4
|
+
Most JavaScript UI libraries don't need a Sygnal version. A library that builds its UI inside an element you give it becomes a JSX tag with [`defineWidget`](../widgets.md); a library that only computes (a table model, a translation) is called from the view or the context, as a pure function of state. Each recipe shows the code, how to test it, what it adds to the bundle, and the mistakes to avoid.
|
|
5
|
+
|
|
6
|
+
| Recipe | Library | How |
|
|
7
|
+
|---|---|---|
|
|
8
|
+
| [Charts](./charts.md) | Chart.js, ECharts | Widget |
|
|
9
|
+
| [Rich text](./rich-text.md) | Tiptap | Widget, toolbar as element commands |
|
|
10
|
+
| [Code editor](./code-editor.md) | CodeMirror 6 | Widget, `focus` command |
|
|
11
|
+
| [Carousel](./carousel.md) | Embla Carousel | Widget, previous/next as element commands |
|
|
12
|
+
| [Data table](./data-table.md) | TanStack Table v9 | A pure function of state in the view |
|
|
13
|
+
| [Data grid](./data-grid.md) | AG Grid Community | Widget, immutable row data |
|
|
14
|
+
| [Icons](./icons.md) | Lucide | A helper that returns Sygnal SVG vnodes |
|
|
15
|
+
| [Translations (i18n)](./i18n.md) | i18next | `t()` in `.context`, the locale saved with `persist()` |
|
|
16
|
+
|
|
17
|
+
Elsewhere in the docs:
|
|
18
|
+
|
|
19
|
+
- **Positioning** (tooltips, popovers, menus) with Floating UI: [Floating UI for older browsers](../ui/overview.md#floating-ui-for-older-browsers). Current browsers position `sygnal/ui` parts with CSS anchor positioning and need no library.
|
|
20
|
+
- **List animations** with AutoAnimate and Motion: [View Transitions](https://sygnal.js.org/guide/view-transitions/#recipe-autoanimate).
|
|
21
|
+
- **Date pickers** (flatpickr): the [Widgets](../widgets.md) guide's example.
|
|
22
|
+
- **Web components** (Web Awesome, Shoelace): no wrapper needed, see [Web components](../web-components.md).
|
|
23
|
+
- **Forms with validation** (zod, valibot): the [`form` behavior](../forms.md).
|
|
24
|
+
|
|
25
|
+
## Choosing a pattern
|
|
26
|
+
|
|
27
|
+
- **The library owns some DOM** (a canvas, an editor, a slider): `defineWidget`. Sygnal renders the host element and never touches what the library builds inside it. Props flow in through `update`, events flow out through `dispatch` and the intent, and the model calls the library's methods through the widget's `commands`.
|
|
28
|
+
- **The library only computes** (rows to show, a translated string, an icon's shape): call it in the view or in `.context`, and render the result with JSX. Sygnal keeps rendering the DOM, so the output is ordinary markup: it is server-rendered, tested in the mock DOM, and checked by sygnal-check.
|
|
29
|
+
- **The library is a framework component** (React, Vue): see [Adapters](../adapters.md), and look for a framework-agnostic core first (most headless libraries have one).
|
|
30
|
+
|
|
31
|
+
## Size numbers
|
|
32
|
+
|
|
33
|
+
Each recipe gives what it adds to an app, measured with Vite (minified, gzipped), Sygnal itself not included. `defineWidget` adds about 1.1 KB the first time an app uses it.
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
<!-- Generated from docs/src/content/docs/recipes by scripts/copy-guides.mjs; online: https://sygnal.js.org/recipes/ -->
|
|
2
|
+
# Rich Text (Tiptap)
|
|
3
|
+
|
|
4
|
+
[Tiptap](https://tiptap.dev/) is a rich-text editor built on ProseMirror. Its core doesn't depend on a framework, so [`defineWidget`](../widgets.md) turns it into a JSX tag: the document is HTML in your state, each edit comes back as an event, and toolbar buttons run the editor's commands through [element commands](../element-commands.md).
|
|
5
|
+
|
|
6
|
+
## Install
|
|
7
|
+
|
|
8
|
+
```sh
|
|
9
|
+
npm install @tiptap/core @tiptap/pm @tiptap/starter-kit
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
## The widget
|
|
13
|
+
|
|
14
|
+
```js live-file=./RichText.js
|
|
15
|
+
// RichText.js
|
|
16
|
+
import { defineWidget } from 'sygnal'
|
|
17
|
+
import { Editor } from '@tiptap/core'
|
|
18
|
+
import StarterKit from '@tiptap/starter-kit'
|
|
19
|
+
|
|
20
|
+
const editorProps = (props) => ({ attributes: { role: 'textbox', 'aria-multiline': 'true', 'aria-label': props.label } })
|
|
21
|
+
|
|
22
|
+
// the state gets the HTML as Tiptap writes it ('Hi' → '<p>Hi</p>'), so update() doesn't see a
|
|
23
|
+
// difference and reset the content (and the selection) on the next prop change
|
|
24
|
+
const dispatches = new WeakMap()
|
|
25
|
+
const normalise = (editor, html) => {
|
|
26
|
+
if (editor.getHTML() !== html) dispatches.get(editor)('edit', editor.getHTML())
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export const RichText = defineWidget({
|
|
30
|
+
name: 'RichText',
|
|
31
|
+
mount: (el, props, dispatch) => {
|
|
32
|
+
const editor = new Editor({
|
|
33
|
+
element: el,
|
|
34
|
+
extensions: [StarterKit],
|
|
35
|
+
content: props.html,
|
|
36
|
+
editorProps: editorProps(props),
|
|
37
|
+
onUpdate: ({ editor }) => dispatch('edit', editor.getHTML()),
|
|
38
|
+
})
|
|
39
|
+
dispatches.set(editor, dispatch)
|
|
40
|
+
normalise(editor, props.html)
|
|
41
|
+
return editor
|
|
42
|
+
},
|
|
43
|
+
update: (editor, props) => {
|
|
44
|
+
if (props.html !== editor.getHTML()) {
|
|
45
|
+
editor.commands.setContent(props.html, { emitUpdate: false })
|
|
46
|
+
normalise(editor, props.html)
|
|
47
|
+
}
|
|
48
|
+
// a new label (another language): Tiptap's options are read at mount, so set them again
|
|
49
|
+
if (props.label !== editor.options.editorProps.attributes['aria-label']) editor.setOptions({ editorProps: editorProps(props) })
|
|
50
|
+
},
|
|
51
|
+
unmount: (editor) => editor.destroy(),
|
|
52
|
+
events: ['edit'],
|
|
53
|
+
commands: {
|
|
54
|
+
bold: (editor) => editor.chain().focus().toggleBold().run(),
|
|
55
|
+
italic: (editor) => editor.chain().focus().toggleItalic().run(),
|
|
56
|
+
},
|
|
57
|
+
})
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
- `mount` creates the editor inside the host `<div>`. Tiptap adds its own editable element there; `editorProps.attributes` labels it.
|
|
61
|
+
- `onUpdate` runs after each change the user makes and sends the new HTML as the `edit` event.
|
|
62
|
+
- `update` gets the HTML from state. When the user typed it, it is what the editor already holds and nothing happens. When the state changed it some other way (a reset, a loaded draft), `setContent` replaces the document, with `emitUpdate: false` so the change isn't sent back as an edit.
|
|
63
|
+
- The `bold` and `italic` commands run Tiptap commands on the instance. `focus()` comes first because clicking a toolbar button moves the focus to the button.
|
|
64
|
+
|
|
65
|
+
## Using it
|
|
66
|
+
|
|
67
|
+
```jsx live
|
|
68
|
+
// Notes.jsx
|
|
69
|
+
import { RichText } from './RichText.js'
|
|
70
|
+
|
|
71
|
+
export function Notes({ state }) {
|
|
72
|
+
return (
|
|
73
|
+
<section>
|
|
74
|
+
<div role="toolbar" aria-label="Formatting">
|
|
75
|
+
<button type="button" className="make-bold">Bold</button>
|
|
76
|
+
<button type="button" className="make-italic">Italic</button>
|
|
77
|
+
<button type="button" className="clear">Clear</button>
|
|
78
|
+
</div>
|
|
79
|
+
<RichText className="body" label="Notes" html={state.html} />
|
|
80
|
+
<p className="saved">{state.html.length} characters saved</p>
|
|
81
|
+
</section>
|
|
82
|
+
)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
Notes.initialState = { html: '<p>Hello</p>' }
|
|
86
|
+
|
|
87
|
+
Notes.intent = ({ DOM }) => ({
|
|
88
|
+
EDIT: DOM.select('.body').events('edit').detail(),
|
|
89
|
+
BOLD: DOM.click('.make-bold'),
|
|
90
|
+
ITALIC: DOM.click('.make-italic'),
|
|
91
|
+
CLEAR: DOM.click('.clear'),
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
Notes.model = {
|
|
95
|
+
EDIT: (state, html) => ({ ...state, html }),
|
|
96
|
+
BOLD: { ELEMENT: { bold: '.body' } },
|
|
97
|
+
ITALIC: { ELEMENT: { italic: '.body' } },
|
|
98
|
+
CLEAR: (state) => ({ ...state, html: '<p></p>' }),
|
|
99
|
+
}
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
```css live
|
|
103
|
+
.body .tiptap { border: 1px solid #8888; border-radius: 4px; padding: 0 0.75rem; min-height: 6rem; }
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
Tiptap brings no styles of its own. Its editable element has the class `tiptap`: give it a border and a height, so it looks like a place to type.
|
|
107
|
+
|
|
108
|
+
The label is a prop of its own (`label`), not `aria-label`: an `aria-label` prop would go on the host `<div>`, and the element a screen reader lands on is Tiptap's editable element inside it. Tiptap reads its options when it starts, so `update` sets them again when the label changes (a translated label after a language switch).
|
|
109
|
+
|
|
110
|
+
Tiptap writes HTML its own way (`Hello` becomes `<p>Hello</p>`). `mount` sends the editor's HTML back as an `edit` when it differs from the `html` prop, so the state holds what the editor holds; otherwise the next prop change (a new label) would see a difference and reset the content.
|
|
111
|
+
|
|
112
|
+
To save the notes across reloads, add [`persist()`](../persistence.md) to the root and pick `html`.
|
|
113
|
+
|
|
114
|
+
## Testing
|
|
115
|
+
|
|
116
|
+
In the mock DOM, `t.widget('.body')` sends the editor's events, and `t.commands()` lists the element commands the toolbar sent:
|
|
117
|
+
|
|
118
|
+
```jsx
|
|
119
|
+
// Notes.test.jsx
|
|
120
|
+
import { test, expect } from 'vitest'
|
|
121
|
+
import { renderComponent } from 'sygnal'
|
|
122
|
+
import { Notes } from './Notes.jsx'
|
|
123
|
+
|
|
124
|
+
test('edits reach the state, and the toolbar sends commands to the editor', async () => {
|
|
125
|
+
const t = renderComponent(Notes)
|
|
126
|
+
await t.ready()
|
|
127
|
+
t.widget('.body').dispatch('edit', '<p>Hello <strong>you</strong></p>')
|
|
128
|
+
await t.next((state) => state.html === '<p>Hello <strong>you</strong></p>')
|
|
129
|
+
|
|
130
|
+
t.simulateEvent('.make-bold', 'click')
|
|
131
|
+
t.simulateEvent('.make-italic', 'click')
|
|
132
|
+
await t.settle()
|
|
133
|
+
expect(t.commands()).toEqual([{ bold: '.body' }, { italic: '.body' }])
|
|
134
|
+
|
|
135
|
+
t.simulateEvent('.clear', 'click')
|
|
136
|
+
await t.next((state) => state.html === '<p></p>')
|
|
137
|
+
expect(t.widget('.body').props.html).toBe('<p></p>')
|
|
138
|
+
t.dispose()
|
|
139
|
+
})
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
Tiptap also starts in jsdom: with `renderComponent(Notes, { dom: 'real' })`, `t.widget('.body').instance` is the editor, and `t.dispose()` destroys it (`editor.isDestroyed`). Typing and selecting text need a real browser (Playwright); there, select a word, click **Bold**, and the state's HTML gets a `<strong>`.
|
|
143
|
+
|
|
144
|
+
## Size
|
|
145
|
+
|
|
146
|
+
Measured with Vite, minified and gzipped, Sygnal not included: Tiptap with `StarterKit` (paragraphs, headings, lists, code, quotes, marks, history) adds **128 KB**. Pass only the extensions you need instead of `StarterKit` to make it smaller. `defineWidget` adds 1.1 KB for the first widget in an app.
|
|
147
|
+
|
|
148
|
+
## Pitfalls
|
|
149
|
+
|
|
150
|
+
- **Compare before `setContent`.** Without the `props.html !== editor.getHTML()` check, every keystroke would set the content again and move the cursor to the end.
|
|
151
|
+
- **Tiptap normalises HTML.** `getHTML()` returns the editor's own serialisation (`<p>Hello</p>`, an empty document is `<p></p>`), which may differ from the HTML you started with. Store what `getHTML()` returns; then the comparison in `update` holds. The widget does this for HTML that comes from state: after `mount` and after a `setContent` in `update`, it sends an `edit` with the normalised HTML when it differs, so a later prop change (a new label) doesn't set the content again and lose the selection.
|
|
152
|
+
- **Keep `emitUpdate: false`.** Without it, a reset from state comes back as an `edit` event: harmless here (the same HTML), but a second action for every external change.
|
|
153
|
+
- **Toolbar state.** To show which marks are active (`aria-pressed` on the Bold button), add an `onSelectionUpdate` / `onTransaction` callback that dispatches `editor.isActive('bold')` as an event of its own, and keep it in state.
|
|
154
|
+
- **Untrusted HTML.** Tiptap only keeps what its extensions know, but HTML you save and show elsewhere (`innerHTML`) still needs sanitising there.
|