sygnal 5.4.0 → 6.1.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 +716 -0
- package/README.md +24 -9
- package/dist/ai.cjs.js +115 -0
- package/dist/ai.cjs.js.map +1 -0
- package/dist/ai.esm.js +2 -0
- package/dist/ai.esm.js.map +1 -0
- package/dist/astro/client.cjs.js +16 -9
- package/dist/astro/client.cjs.js.map +1 -1
- package/dist/astro/client.mjs +16 -9
- package/dist/astro/client.mjs.map +1 -1
- package/dist/astro/index.cjs.js +958 -92
- package/dist/astro/index.cjs.js.map +1 -1
- package/dist/astro/index.mjs +958 -93
- package/dist/astro/index.mjs.map +1 -1
- package/dist/astro/server.cjs.js +3396 -165
- package/dist/astro/server.cjs.js.map +1 -1
- package/dist/astro/server.mjs +3396 -165
- package/dist/astro/server.mjs.map +1 -1
- package/dist/devtools.cjs.js +1840 -0
- package/dist/devtools.cjs.js.map +1 -0
- package/dist/devtools.esm.js +1827 -0
- package/dist/devtools.esm.js.map +1 -0
- package/dist/diagnostics.cjs.js +3190 -296
- package/dist/diagnostics.cjs.js.map +1 -1
- package/dist/diagnostics.esm.js +3190 -296
- package/dist/diagnostics.esm.js.map +1 -1
- 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 +310 -0
- package/dist/guide/adapters.md +221 -0
- package/dist/guide/agent.md +588 -0
- package/dist/guide/ai-chat.md +693 -0
- package/dist/guide/ai-decisions.md +330 -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 +409 -0
- package/dist/guide/forms.md +251 -0
- package/dist/guide/http.md +301 -0
- package/dist/guide/inputs.md +173 -0
- package/dist/guide/mcp-apps.md +272 -0
- package/dist/guide/persistence.md +245 -0
- package/dist/guide/recipes/ai-form-fill.md +163 -0
- package/dist/guide/recipes/ai-summarize.md +155 -0
- package/dist/guide/recipes/ai-support-inbox.md +190 -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 +43 -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/webmcp.md +226 -0
- package/dist/guide/widgets.md +217 -0
- package/dist/index.cjs.js +19288 -7408
- package/dist/index.cjs.js.map +1 -1
- package/dist/index.d.ts +4466 -552
- package/dist/index.esm.js +19274 -7461
- package/dist/index.esm.js.map +1 -1
- package/dist/jsx-dev-runtime.cjs.js +26 -334
- package/dist/jsx-dev-runtime.cjs.js.map +1 -1
- package/dist/jsx-dev-runtime.esm.js +19 -327
- package/dist/jsx-dev-runtime.esm.js.map +1 -1
- package/dist/jsx-runtime.cjs.js +26 -334
- package/dist/jsx-runtime.cjs.js.map +1 -1
- package/dist/jsx-runtime.esm.js +19 -327
- package/dist/jsx-runtime.esm.js.map +1 -1
- package/dist/jsx.cjs.js +10 -314
- package/dist/jsx.cjs.js.map +1 -1
- package/dist/jsx.esm.js +8 -315
- package/dist/jsx.esm.js.map +1 -1
- 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 +1 -1
- package/dist/sygnal.min.js.map +1 -1
- 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 +4 -14
- package/dist/vike/ClientOnly.cjs.js.map +1 -1
- package/dist/vike/ClientOnly.mjs +4 -14
- package/dist/vike/ClientOnly.mjs.map +1 -1
- package/dist/vike/{+config.js → config/+config.js} +9 -1
- package/dist/vike/config/+config.js.map +1 -0
- package/dist/vike/config/package.json +6 -0
- package/dist/vike/onRenderClient.cjs.js +165 -95
- package/dist/vike/onRenderClient.cjs.js.map +1 -1
- package/dist/vike/onRenderClient.mjs +165 -95
- package/dist/vike/onRenderClient.mjs.map +1 -1
- package/dist/vike/onRenderHtml.cjs.js +59 -24
- package/dist/vike/onRenderHtml.cjs.js.map +1 -1
- package/dist/vike/onRenderHtml.mjs +60 -25
- package/dist/vike/onRenderHtml.mjs.map +1 -1
- package/dist/vite/plugin.cjs.js +919 -89
- package/dist/vite/plugin.cjs.js.map +1 -1
- package/dist/vite/plugin.mjs +919 -90
- package/dist/vite/plugin.mjs.map +1 -1
- 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 +172 -103
- package/package.json +112 -14
- package/src/ai.d.ts +1186 -0
- package/src/ai.ts +23 -0
- package/src/astro/client.ts +27 -16
- package/src/astro/index.d.ts +24 -0
- package/src/astro/index.ts +69 -3
- package/src/astro/server.ts +8 -2
- package/src/collection.ts +6 -93
- 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 +9 -2
- package/src/cycle/dom/PriorityQueue.ts +4 -2
- package/src/cycle/dom/SymbolTree.ts +18 -47
- package/src/cycle/dom/classNameModule.ts +9 -7
- package/src/cycle/dom/controlledInputModule.ts +23 -6
- package/src/cycle/dom/enrichEventStream.ts +25 -47
- package/src/cycle/dom/fragment.ts +7 -0
- package/src/cycle/dom/isolate.ts +30 -27
- package/src/cycle/dom/makeDOMDriver.ts +115 -56
- package/src/cycle/dom/mockDOMSource.ts +21 -1
- package/src/cycle/dom/modules.ts +16 -3
- 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 +19 -3
- 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 +170 -0
- package/src/devtools.ts +37 -0
- package/src/element.d.ts +59 -0
- package/src/element.ts +235 -0
- package/src/extra/ai/agent/index.ts +484 -0
- package/src/extra/ai/answers.ts +115 -0
- package/src/extra/ai/chat/behavior.ts +336 -0
- package/src/extra/ai/chat/driver.ts +271 -0
- package/src/extra/ai/chat/memoryTransport.ts +61 -0
- package/src/extra/ai/chat/output.ts +37 -0
- package/src/extra/ai/commandBar.ts +281 -0
- package/src/extra/ai/decide.ts +104 -0
- package/src/extra/ai/index.ts +42 -0
- package/src/extra/ai/link.ts +90 -0
- package/src/extra/ai/mcpApp.ts +314 -0
- package/src/extra/ai/messages.ts +51 -0
- package/src/extra/ai/schema/index.ts +166 -0
- package/src/extra/ai/schema/jsonSchema.ts +72 -0
- package/src/extra/ai/schema/strict.ts +152 -0
- package/src/extra/ai/transports/agui.ts +177 -0
- package/src/extra/ai/transports/anthropicMessages.ts +175 -0
- package/src/extra/ai/transports/chatCompletions.ts +105 -0
- package/src/extra/ai/transports/chromePrompt.ts +77 -0
- package/src/extra/ai/transports/encodeOpenResponses.ts +87 -0
- package/src/extra/ai/transports/fromAISDK.ts +126 -0
- package/src/extra/ai/transports/openResponses.ts +105 -0
- package/src/extra/ai/transports/shared.ts +125 -0
- package/src/extra/ai/transports/tools.ts +59 -0
- package/src/extra/ai/transports/uiMessageStream.ts +107 -0
- package/src/extra/ai/webmcp.ts +260 -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/devMcp.ts +386 -0
- package/src/extra/devtools.ts +101 -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/chat.ts +71 -0
- package/src/extra/diagnostics/checks/collections.ts +1 -1
- 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 +20 -11
- package/src/extra/diagnostics/checks/elementCommands.ts +175 -0
- package/src/extra/diagnostics/checks/events.ts +17 -2
- package/src/extra/diagnostics/checks/fetch.ts +135 -0
- package/src/extra/diagnostics/checks/forms.ts +171 -0
- package/src/extra/diagnostics/checks/index.ts +103 -4
- package/src/extra/diagnostics/checks/inspect.ts +140 -32
- 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 +10 -5
- package/src/extra/diagnostics/checks/public.d.ts +115 -7
- 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 +1 -1
- package/src/extra/diagnostics/checks/shared.ts +86 -2
- 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 +100 -7
- package/src/extra/diagnostics/checks/statics.ts +48 -0
- package/src/extra/diagnostics/checks/strict.ts +10 -67
- 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 +69 -10
- package/src/extra/diagnostics/codes.ts +335 -23
- package/src/extra/diagnostics/index.ts +34 -9
- package/src/extra/diagnostics/legacy.ts +17 -0
- package/src/extra/driverFactories.ts +83 -19
- package/src/extra/elementCommands.ts +53 -0
- package/src/extra/fetchDriver.ts +519 -0
- package/src/extra/focusWithin.ts +27 -0
- package/src/extra/form.ts +284 -0
- package/src/extra/formHelpers.ts +139 -0
- package/src/extra/formTool.ts +150 -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 +1 -1
- 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 +90 -217
- 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 +2550 -175
- 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/index.d.ts +2754 -127
- package/src/index.ts +45 -4
- 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 +256 -133
- 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 +57 -0
- package/src/slot.ts +1 -1
- package/src/suspense.ts +3 -1
- package/src/switchable.ts +6 -121
- 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 +7 -1
- package/src/vite/globalthis-shim.ts +18 -0
- package/src/vite/globalthis.ts +56 -0
- package/src/vite/mcp.ts +568 -0
- package/src/vite/plugin.d.ts +47 -3
- package/src/vite/plugin.ts +262 -25
- package/src/zag.d.ts +64 -0
- package/src/zag.ts +238 -0
- package/dist/vike/+config.cjs.js +0 -66
- package/dist/vike/+config.cjs.js.map +0 -1
- package/dist/vike/+config.js.map +0 -1
- package/src/component.ts +0 -2301
- 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 -184
- package/src/cycle/state/index.ts +0 -5
- package/src/cycle/state/pickCombine.ts +0 -201
- 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,190 @@
|
|
|
1
|
+
<!-- Generated from docs/src/content/docs/recipes by scripts/copy-guides.mjs; online: https://sygnal.js.org/recipes/ -->
|
|
2
|
+
# Support Inbox
|
|
3
|
+
|
|
4
|
+
A support inbox where every ticket is triaged as it arrives: its topic picks the team, its urgency sorts the list. A [decision model](../ai-decisions.md) answers in a few hundred milliseconds for each ticket. The few it is unsure about go to a [chat model](../ai-chat.md), which answers with structured output: a topic and the reason.
|
|
5
|
+
|
|
6
|
+
The pieces:
|
|
7
|
+
|
|
8
|
+
- **Each ticket is a Collection item with its own resource.** The decision follows the ticket's text, and its result is written into the ticket, so the inbox can sort by it.
|
|
9
|
+
- **The resource's `ok` action checks the confidence**, and only an unsure ticket sends a chat request.
|
|
10
|
+
- **The chat model's answer is typed**: `output` is a schema, and `ok` gets the validated value.
|
|
11
|
+
|
|
12
|
+
## Install
|
|
13
|
+
|
|
14
|
+
Nothing beyond Sygnal and a schema library for the structured output:
|
|
15
|
+
|
|
16
|
+
```sh
|
|
17
|
+
npm install sygnal zod
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
`main.js` registers both drivers: the fetch driver for decisions, the chat driver for the escalations.
|
|
21
|
+
|
|
22
|
+
```js
|
|
23
|
+
import { run, makeFetchDriver } from 'sygnal'
|
|
24
|
+
import { makeChatDriver, uiMessageStream } from 'sygnal/ai'
|
|
25
|
+
import Inbox from './Inbox.jsx'
|
|
26
|
+
|
|
27
|
+
run(Inbox, {
|
|
28
|
+
HTTP: makeFetchDriver(),
|
|
29
|
+
LLM: makeChatDriver({ transport: uiMessageStream('/api/chat') }),
|
|
30
|
+
})
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
`decide()` posts to `/api/decide` and the chat driver to `/api/chat`: your server's routes, which hold the keys ([decisions](../ai-decisions.md#keys-stay-on-the-server), [chat](../ai-chat.md#shipping-it)). In development, point them at a local Ollama instead.
|
|
34
|
+
|
|
35
|
+
## The inbox
|
|
36
|
+
|
|
37
|
+
In this demo, the demo server answers for both models. The third ticket is ambiguous, so its escalation runs as soon as the demo shows.
|
|
38
|
+
|
|
39
|
+
```js live-server
|
|
40
|
+
import { encodeOpenResponses } from 'sygnal/ai'
|
|
41
|
+
|
|
42
|
+
const has = (text, words) => words.some((w) => text.toLowerCase().includes(w))
|
|
43
|
+
|
|
44
|
+
export default {
|
|
45
|
+
'POST /v1/systemone': ({ json }) => {
|
|
46
|
+
const t = String(json.state)
|
|
47
|
+
const topic = has(t, ['charge', 'refund']) ? 'billing' : has(t, ['crash', 'error']) ? 'bug' : 'account'
|
|
48
|
+
const sure = has(t, ['charge', 'crash', 'password']) ? 0.93 : 0.38
|
|
49
|
+
const others = ['billing', 'bug', 'account'].filter((k) => k !== topic)
|
|
50
|
+
return {
|
|
51
|
+
delayMs: 500,
|
|
52
|
+
json: {
|
|
53
|
+
model: json.model,
|
|
54
|
+
answers: {
|
|
55
|
+
topic: { type: 'choice', choice: topic, confidence: sure, probabilities: { [topic]: sure, [others[0]]: (1 - sure) / 2, [others[1]]: (1 - sure) / 2 } },
|
|
56
|
+
urgent: { type: 'noul', noul: has(t, ['twice', 'down', 'today']) ? 0.92 : 0.1 },
|
|
57
|
+
},
|
|
58
|
+
},
|
|
59
|
+
}
|
|
60
|
+
},
|
|
61
|
+
'POST /v1/responses': () => ({
|
|
62
|
+
sse: encodeOpenResponses(['{"topic":"billing",', '"reason":"They ask', ' for an invoice', ' copy, a billing', ' document."}']),
|
|
63
|
+
delayMs: 300,
|
|
64
|
+
chunkMs: 60,
|
|
65
|
+
}),
|
|
66
|
+
}
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
```jsx live live-height=280
|
|
70
|
+
import { z } from 'zod'
|
|
71
|
+
import { ABORT, Collection } from 'sygnal'
|
|
72
|
+
import { decide, choice, noul } from 'sygnal/ai'
|
|
73
|
+
|
|
74
|
+
const TOPICS = { billing: 'Payments, invoices, plans, refunds', bug: 'Something in the product is broken', account: 'Login, access or profile' }
|
|
75
|
+
const TEAMS = { billing: 'Billing team', bug: 'Engineering', account: 'Account support' }
|
|
76
|
+
|
|
77
|
+
const questions = {
|
|
78
|
+
topic: choice('What is this ticket about?', TOPICS),
|
|
79
|
+
urgent: noul('Does the customer need this handled today?', {
|
|
80
|
+
true: 'Blocked, losing money, or a deadline',
|
|
81
|
+
false: 'A question or a minor issue',
|
|
82
|
+
}),
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const SecondOpinion = z.object({
|
|
86
|
+
topic: z.enum(['billing', 'bug', 'account']),
|
|
87
|
+
reason: z.string().describe('One short sentence'),
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
const SURE = 0.6
|
|
91
|
+
|
|
92
|
+
// the topic to act on: the chat model's when it was asked, else the decision's
|
|
93
|
+
const topicOf = (ticket) => ticket.opinion?.topic ?? ticket.triage?.data?.answers.topic.choice
|
|
94
|
+
const urgency = (ticket) => ticket.triage?.data?.answers.urgent.noul ?? 0
|
|
95
|
+
|
|
96
|
+
function TicketRow({ state }) {
|
|
97
|
+
const { status, data } = state.triage
|
|
98
|
+
const topic = topicOf(state)
|
|
99
|
+
return (
|
|
100
|
+
<li className="ticket">
|
|
101
|
+
<p><b>{state.from}</b>: {state.text}</p>
|
|
102
|
+
<p className="labels" aria-live="polite">
|
|
103
|
+
{status === 'loading' && 'Triaging…'}
|
|
104
|
+
{status === 'error' && 'Not triaged: a person will look at it.'}
|
|
105
|
+
{state.asking && 'Unsure, asking the chat model…'}
|
|
106
|
+
{topic && !state.asking && <>→ {TEAMS[topic]}</>}
|
|
107
|
+
{data && data.answers.urgent.noul > 0.5 && <strong> urgent</strong>}
|
|
108
|
+
</p>
|
|
109
|
+
{state.opinion && <p className="reason"><i>{state.opinion.reason}</i></p>}
|
|
110
|
+
</li>
|
|
111
|
+
)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
TicketRow.resources = {
|
|
115
|
+
triage: (state) => decide({ url: '/v1/systemone', model: 'nimble', state: state.text, questions, ok: 'TRIAGED' }),
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
TicketRow.model = {
|
|
119
|
+
TRIAGED: {
|
|
120
|
+
STATE: (state, { answers }) => (answers.topic.confidence < SURE ? { ...state, asking: true } : ABORT),
|
|
121
|
+
LLM: (state, { answers }) => (answers.topic.confidence < SURE
|
|
122
|
+
? {
|
|
123
|
+
instructions: 'Classify the support ticket and give one short reason.',
|
|
124
|
+
messages: [{ role: 'user', content: state.text }],
|
|
125
|
+
output: SecondOpinion,
|
|
126
|
+
ok: 'CLASSIFIED',
|
|
127
|
+
error: 'NOT_CLASSIFIED',
|
|
128
|
+
}
|
|
129
|
+
: ABORT),
|
|
130
|
+
},
|
|
131
|
+
CLASSIFIED: (state, { value }) => ({ ...state, asking: false, opinion: value }),
|
|
132
|
+
NOT_CLASSIFIED: (state) => ({ ...state, asking: false }),
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function Inbox({ state }) {
|
|
136
|
+
const urgent = state.tickets.filter((t) => urgency(t) > 0.5).length
|
|
137
|
+
return (
|
|
138
|
+
<section className="inbox">
|
|
139
|
+
<h3>Inbox ({urgent} urgent)</h3>
|
|
140
|
+
<ul>
|
|
141
|
+
<Collection of={TicketRow} from="tickets" sort={(a, b) => urgency(b) - urgency(a)} />
|
|
142
|
+
</ul>
|
|
143
|
+
</section>
|
|
144
|
+
)
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
Inbox.initialState = {
|
|
148
|
+
tickets: [
|
|
149
|
+
{ id: 1, from: 'Ana', text: 'The app crashes when I export a report' },
|
|
150
|
+
{ id: 2, from: 'Ben', text: 'I was charged twice, please fix it today' },
|
|
151
|
+
{ id: 3, from: 'Cleo', text: 'Where can I get a copy of last month\'s invoice?' },
|
|
152
|
+
],
|
|
153
|
+
}
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
- **The resource lives on the item.** Each `TicketRow` has its own `triage` resource, written into its ticket: `state.tickets[i].triage` in the inbox. That is how `Inbox` counts the urgent ones and sorts by urgency, with no messages between the two. The items keep their instances as they move.
|
|
157
|
+
- **One request per ticket.** A decision model answers each in a few hundred milliseconds, and questions are answered in parallel. For hundreds of tickets, triage on the server when they arrive and store the answers instead.
|
|
158
|
+
- **The escalation is typed.** The chat model's answer must match `SecondOpinion`. An answer that doesn't arrives as `NOT_CLASSIFIED`, and the ticket shows the decision's topic.
|
|
159
|
+
- **Unsure is information.** Show it: a ticket the models disagree about, or both are unsure about, is one for a person.
|
|
160
|
+
|
|
161
|
+
## Testing
|
|
162
|
+
|
|
163
|
+
`answers()` builds a decision reply typed from the questions; the LLM fake answers the escalation. Export `TicketRow` and `questions` from the module to test a row on its own:
|
|
164
|
+
|
|
165
|
+
```js
|
|
166
|
+
import { it, expect } from 'vitest'
|
|
167
|
+
import { renderComponent } from 'sygnal'
|
|
168
|
+
import { answers } from 'sygnal/ai'
|
|
169
|
+
import { TicketRow, questions } from './Inbox.jsx'
|
|
170
|
+
|
|
171
|
+
it('routes a confident ticket and asks the chat model about an unsure one', async () => {
|
|
172
|
+
const sure = renderComponent(TicketRow, { initialState: { id: 1, from: 'Ana', text: 'It crashes' } })
|
|
173
|
+
await sure.respond('HTTP', answers(questions, { topic: 'bug' }), 'triage')
|
|
174
|
+
expect(sure.html()).toContain('Engineering')
|
|
175
|
+
expect(sure.requests('LLM')).toEqual([])
|
|
176
|
+
sure.dispose()
|
|
177
|
+
|
|
178
|
+
const unsure = renderComponent(TicketRow, { initialState: { id: 3, from: 'Cleo', text: 'Invoice copy?' } })
|
|
179
|
+
await unsure.respond('HTTP', answers(questions, { topic: { choice: 'account', confidence: 0.4 } }), 'triage')
|
|
180
|
+
await unsure.respond('LLM', { topic: 'billing', reason: 'An invoice is a billing document.' })
|
|
181
|
+
expect(unsure.html()).toContain('Billing team')
|
|
182
|
+
unsure.dispose()
|
|
183
|
+
})
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
## Pitfalls
|
|
187
|
+
|
|
188
|
+
- **Don't put the decision on the inbox.** One resource on the list would send every ticket again when one changes. A resource per item sends one request per ticket, and only again when that ticket's text changes.
|
|
189
|
+
- **Keep the escalation rare.** Tune `SURE` on real tickets: if most go to the chat model, the questions or their option descriptions need work.
|
|
190
|
+
- **A chat model's topic can be anything** without `output`. With a `z.enum`, it can only be one of yours.
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
<!-- Generated from docs/src/content/docs/recipes by scripts/copy-guides.mjs; online: https://sygnal.js.org/recipes/ -->
|
|
2
|
+
# Carousel (Embla)
|
|
3
|
+
|
|
4
|
+
[Embla Carousel](https://www.embla-carousel.com/) handles the hard parts of a carousel: dragging and swiping, snapping, momentum. It renders no buttons and no styles of its own. [`defineWidget`](../widgets.md) turns it into a JSX tag; the buttons are ordinary Sygnal markup that send the carousel [element commands](../element-commands.md), and the slide Embla settles on comes back as state.
|
|
5
|
+
|
|
6
|
+
## Install
|
|
7
|
+
|
|
8
|
+
```sh
|
|
9
|
+
npm install embla-carousel
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
## The widget
|
|
13
|
+
|
|
14
|
+
```js live-file=./Carousel.js
|
|
15
|
+
// Carousel.js
|
|
16
|
+
import { defineWidget } from 'sygnal'
|
|
17
|
+
import EmblaCarousel from 'embla-carousel'
|
|
18
|
+
|
|
19
|
+
// Embla moves a track inside the viewport (the host): the widget builds the track from the props
|
|
20
|
+
function fill(el, photos) {
|
|
21
|
+
const track = document.createElement('div')
|
|
22
|
+
track.className = 'slides'
|
|
23
|
+
photos.forEach((photo, i) => {
|
|
24
|
+
const slide = document.createElement('div')
|
|
25
|
+
slide.className = 'slide'
|
|
26
|
+
slide.setAttribute('role', 'group')
|
|
27
|
+
slide.setAttribute('aria-roledescription', 'slide')
|
|
28
|
+
slide.setAttribute('aria-label', `${i + 1} of ${photos.length}`)
|
|
29
|
+
slide.append(Object.assign(document.createElement('img'), { src: photo.src, alt: photo.alt }))
|
|
30
|
+
track.append(slide)
|
|
31
|
+
})
|
|
32
|
+
el.replaceChildren(track)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export const Carousel = defineWidget({
|
|
36
|
+
name: 'Carousel',
|
|
37
|
+
mount: (el, props, dispatch) => {
|
|
38
|
+
fill(el, props.photos)
|
|
39
|
+
const embla = EmblaCarousel(el, { loop: false })
|
|
40
|
+
// reInit (new photos) can move the selection without a select event: report both
|
|
41
|
+
const report = () => dispatch('slide', embla.selectedScrollSnap())
|
|
42
|
+
embla.on('select', report).on('reInit', report)
|
|
43
|
+
return embla
|
|
44
|
+
},
|
|
45
|
+
update: (embla, props, el) => {
|
|
46
|
+
fill(el, props.photos)
|
|
47
|
+
embla.reInit()
|
|
48
|
+
},
|
|
49
|
+
unmount: (embla) => embla.destroy(),
|
|
50
|
+
events: ['slide'],
|
|
51
|
+
commands: {
|
|
52
|
+
prev: (embla) => embla.scrollPrev(),
|
|
53
|
+
next: (embla) => embla.scrollNext(),
|
|
54
|
+
goTo: (embla, { index }) => embla.scrollTo(index),
|
|
55
|
+
},
|
|
56
|
+
})
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Embla expects a viewport element with one child, the track, whose children are the slides. The host `<div>` is the viewport, and the widget builds the track itself, because Sygnal never renders inside a widget's host. `update` rebuilds it when the photos change and `reInit()` makes Embla measure the new slides; the instance stays the same.
|
|
60
|
+
|
|
61
|
+
## Using it
|
|
62
|
+
|
|
63
|
+
```jsx live
|
|
64
|
+
// Gallery.jsx
|
|
65
|
+
import { Carousel } from './Carousel.js'
|
|
66
|
+
|
|
67
|
+
export function Gallery({ state }) {
|
|
68
|
+
const last = state.photos.length - 1
|
|
69
|
+
return (
|
|
70
|
+
<section aria-roledescription="carousel" aria-label="Photos">
|
|
71
|
+
<Carousel className="photos" photos={state.photos} />
|
|
72
|
+
<button className="prev" aria-label="Previous photo" aria-disabled={state.index === 0}>‹</button>
|
|
73
|
+
<button className="next" aria-label="Next photo" aria-disabled={state.index === last}>›</button>
|
|
74
|
+
{state.photos.map((photo, i) => (
|
|
75
|
+
<button className="dot" data={{ index: i }} aria-label={`Show photo ${i + 1}`}
|
|
76
|
+
aria-current={i === state.index ? 'true' : undefined} />
|
|
77
|
+
))}
|
|
78
|
+
<p className="where" aria-live="polite">Photo {state.index + 1} of {state.photos.length}</p>
|
|
79
|
+
</section>
|
|
80
|
+
)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
Gallery.initialState = {
|
|
84
|
+
photos: [
|
|
85
|
+
{ src: '/photos/harbour.jpg', alt: 'Boats in the harbour at dawn' },
|
|
86
|
+
{ src: '/photos/market.jpg', alt: 'The fish market' },
|
|
87
|
+
{ src: '/photos/cliffs.jpg', alt: 'Cliffs north of the town' },
|
|
88
|
+
],
|
|
89
|
+
index: 0,
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
Gallery.intent = ({ DOM }) => ({
|
|
93
|
+
SLIDE: DOM.select('.photos').events('slide').detail(),
|
|
94
|
+
PREV: DOM.click('.prev'),
|
|
95
|
+
NEXT: DOM.click('.next'),
|
|
96
|
+
GO_TO: DOM.click('.dot').data('index', Number),
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
Gallery.model = {
|
|
100
|
+
SLIDE: (state, index) => ({ ...state, index }),
|
|
101
|
+
PREV: { ELEMENT: { prev: '.photos' } },
|
|
102
|
+
NEXT: { ELEMENT: { next: '.photos' } },
|
|
103
|
+
GO_TO: { ELEMENT: (state, index) => ({ goTo: '.photos', index }) },
|
|
104
|
+
}
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
```css live
|
|
108
|
+
.photos { overflow: hidden; }
|
|
109
|
+
.photos .slides { display: flex; }
|
|
110
|
+
.photos .slide { flex: 0 0 100%; min-width: 0; }
|
|
111
|
+
.photos img { display: block; width: 100%; height: auto; }
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
The buttons don't change `index` themselves. They ask Embla to scroll, and `index` follows from the `slide` event, the same way it does after a swipe: Embla decides where the carousel stops, and the state always says where it is. The widget also reports the slide after `reInit()`: when the photos shrink below the current one, Embla moves to the last slide without a `select` event.
|
|
115
|
+
|
|
116
|
+
At the ends, Previous and Next are marked `aria-disabled` rather than `disabled`: a `disabled` button loses focus as it is pressed (keyboard users land on the page body), while an `aria-disabled` one keeps it, is announced as unavailable, and a press does nothing (Embla doesn't scroll past the ends). Style it with `[aria-disabled="true"]`.
|
|
117
|
+
|
|
118
|
+
## Testing
|
|
119
|
+
|
|
120
|
+
```jsx
|
|
121
|
+
// Gallery.test.jsx
|
|
122
|
+
import { test, expect } from 'vitest'
|
|
123
|
+
import { renderComponent } from 'sygnal'
|
|
124
|
+
import { Gallery } from './Gallery.jsx'
|
|
125
|
+
|
|
126
|
+
test('the buttons send commands, and the shown slide comes back as state', async () => {
|
|
127
|
+
const t = renderComponent(Gallery)
|
|
128
|
+
await t.ready()
|
|
129
|
+
t.simulateEvent('.next', 'click')
|
|
130
|
+
t.simulateEvent('.dot[data-index="2"]', 'click')
|
|
131
|
+
await t.settle()
|
|
132
|
+
expect(t.commands()).toEqual([{ next: '.photos' }, { goTo: '.photos', index: 2 }])
|
|
133
|
+
|
|
134
|
+
t.widget('.photos').dispatch('slide', 2)
|
|
135
|
+
await t.next((state) => state.index === 2)
|
|
136
|
+
expect(t.query('.where').textContent).toBe('Photo 3 of 3')
|
|
137
|
+
expect(t.query('.next').getAttribute('aria-disabled')).toBe('true')
|
|
138
|
+
expect(t.query('.prev').getAttribute('aria-disabled')).toBe('false')
|
|
139
|
+
t.dispose()
|
|
140
|
+
})
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
Dragging needs layout, so test it in a real browser: with `renderComponent(Gallery, { dom: 'real' })` under Playwright, drag across `.photos` and `t.waitForState((state) => state.index === 1)`; `t.widget('.photos').instance` is the Embla API (`selectedScrollSnap()`, `slideNodes()`).
|
|
144
|
+
|
|
145
|
+
## Size
|
|
146
|
+
|
|
147
|
+
Measured with Vite, minified and gzipped, Sygnal not included: **9 KB** for Embla and this recipe. `defineWidget` adds 1.1 KB for the first widget in an app.
|
|
148
|
+
|
|
149
|
+
## Pitfalls
|
|
150
|
+
|
|
151
|
+
- **Build the slides in the widget.** Children passed to a widget tag are ignored, and markup rendered next to the host isn't inside the viewport. Pass the slides' data as a prop and build them in `mount`/`update`, as `fill` does.
|
|
152
|
+
- **`reInit()` after the slides change.** Embla measures the slides when it starts; without `reInit()` it keeps scrolling over the old ones. `reInit()` emits `reInit`, not `select`, so listen to both to keep `index` right.
|
|
153
|
+
- **Initial state belongs to the page.** `Gallery` keeps its photos in `initialState`, which is fine for the root component or a page. A child component rendered by a parent can't have an `initialState` ([SYG405](https://sygnal.js.org/reference/errors/#syg405)): there, drop it, and pass `photos` and `index` down in the parent's state.
|
|
154
|
+
- **Name commands after what they do.** A command called `scrollTo` would replace the host `<div>`'s own `scrollTo()` method for element commands; `goTo` avoids the confusion.
|
|
155
|
+
- **Accessibility.** Give the carousel a name and each slide its position (`role="group"`, `aria-roledescription="slide"`, `aria-label="2 of 3"`), give the icon buttons labels, and announce the current slide (`aria-live="polite"`). Don't auto-play; if you add it, add a pause button and stop on focus or hover.
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
<!-- Generated from docs/src/content/docs/recipes by scripts/copy-guides.mjs; online: https://sygnal.js.org/recipes/ -->
|
|
2
|
+
# Charts (Chart.js, ECharts)
|
|
3
|
+
|
|
4
|
+
[Chart.js](https://www.chartjs.org/) draws charts on a `<canvas>`. It doesn't know about Sygnal, and doesn't need to: [`defineWidget`](../widgets.md) turns it into a JSX tag. The data lives in your state, the chart is redrawn when it changes, and a click on a bar comes back as an event the intent reads.
|
|
5
|
+
|
|
6
|
+
## Install
|
|
7
|
+
|
|
8
|
+
```sh
|
|
9
|
+
npm install chart.js
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
## The widget
|
|
13
|
+
|
|
14
|
+
```js live-file=./SalesChart.js
|
|
15
|
+
// SalesChart.js
|
|
16
|
+
import { defineWidget } from 'sygnal'
|
|
17
|
+
import { Chart, BarController, BarElement, CategoryScale, LinearScale, Tooltip, Colors } from 'chart.js'
|
|
18
|
+
|
|
19
|
+
Chart.register(BarController, BarElement, CategoryScale, LinearScale, Tooltip, Colors)
|
|
20
|
+
|
|
21
|
+
export const SalesChart = defineWidget({
|
|
22
|
+
name: 'SalesChart',
|
|
23
|
+
tag: 'canvas',
|
|
24
|
+
mount: (el, props, dispatch) => new Chart(el, {
|
|
25
|
+
type: 'bar',
|
|
26
|
+
data: { labels: props.labels, datasets: [{ label: props.series, data: props.values }] },
|
|
27
|
+
options: {
|
|
28
|
+
maintainAspectRatio: false,
|
|
29
|
+
onClick: (event, bars) => { if (bars.length) dispatch('bar-select', bars[0].index) },
|
|
30
|
+
},
|
|
31
|
+
}),
|
|
32
|
+
update: (chart, props) => {
|
|
33
|
+
chart.data.labels = props.labels
|
|
34
|
+
chart.data.datasets[0].label = props.series
|
|
35
|
+
chart.data.datasets[0].data = props.values
|
|
36
|
+
chart.update()
|
|
37
|
+
},
|
|
38
|
+
unmount: (chart) => chart.destroy(),
|
|
39
|
+
events: ['bar-select'],
|
|
40
|
+
})
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
- The `Colors` plugin gives each dataset a colour from Chart.js's palette. Without it, a dataset with no `backgroundColor` is drawn in a 10% black that is hard to see on a dark page.
|
|
44
|
+
- `mount` creates the chart on the `<canvas>` host and returns it: that is the instance `update` and `unmount` get.
|
|
45
|
+
- `update` runs when the props change. It changes the chart's data in place and calls `chart.update()`, so the chart animates to the new values instead of being rebuilt.
|
|
46
|
+
- `unmount` runs when the chart leaves the page and frees the canvas.
|
|
47
|
+
- A click on a bar calls `dispatch('bar-select', index)`, a bubbling DOM event on the canvas with the bar's index as its `detail`.
|
|
48
|
+
|
|
49
|
+
## Using it
|
|
50
|
+
|
|
51
|
+
```jsx live
|
|
52
|
+
// Sales.jsx
|
|
53
|
+
import { SalesChart } from './SalesChart.js'
|
|
54
|
+
|
|
55
|
+
const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr']
|
|
56
|
+
|
|
57
|
+
export function Sales({ state }) {
|
|
58
|
+
const picked = state.selected === null ? 'Select a bar' : `${MONTHS[state.selected]}: ${state.values[state.selected]}`
|
|
59
|
+
return (
|
|
60
|
+
<section>
|
|
61
|
+
<div className="chart-box">
|
|
62
|
+
<SalesChart className="sales" role="img" aria-label="Sales by month"
|
|
63
|
+
labels={MONTHS} series="Sales" values={state.values} />
|
|
64
|
+
</div>
|
|
65
|
+
<p className="picked">{picked}</p>
|
|
66
|
+
<button className="add-sale">Add a sale in April</button>
|
|
67
|
+
</section>
|
|
68
|
+
)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
Sales.initialState = { values: [12, 19, 7, 4], selected: null }
|
|
72
|
+
|
|
73
|
+
Sales.intent = ({ DOM }) => ({
|
|
74
|
+
SELECT: DOM.select('.sales').events('bar-select').detail(),
|
|
75
|
+
ADD_SALE: DOM.click('.add-sale'),
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
Sales.model = {
|
|
79
|
+
SELECT: (state, index) => ({ ...state, selected: index }),
|
|
80
|
+
ADD_SALE: (state) => ({ ...state, values: [...state.values.slice(0, 3), state.values[3] + 1] }),
|
|
81
|
+
}
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
```css live
|
|
85
|
+
.chart-box { position: relative; height: 240px; }
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
Chart.js sizes the canvas to its parent, so the parent needs a size (and `position: relative`). With `maintainAspectRatio: false` the chart fills that box.
|
|
89
|
+
|
|
90
|
+
## Testing
|
|
91
|
+
|
|
92
|
+
`renderComponent`'s mock DOM doesn't draw the chart. `t.widget('.sales')` sends the widget's events and shows the props the view passed it, which is what the component is responsible for:
|
|
93
|
+
|
|
94
|
+
```jsx
|
|
95
|
+
// Sales.test.jsx
|
|
96
|
+
import { test, expect } from 'vitest'
|
|
97
|
+
import { renderComponent } from 'sygnal'
|
|
98
|
+
import { Sales } from './Sales.jsx'
|
|
99
|
+
|
|
100
|
+
test('a clicked bar is selected, and a new sale reaches the chart', async () => {
|
|
101
|
+
const t = renderComponent(Sales)
|
|
102
|
+
await t.ready()
|
|
103
|
+
t.widget('.sales').dispatch('bar-select', 1)
|
|
104
|
+
await t.next((state) => state.selected === 1)
|
|
105
|
+
expect(t.query('.picked').textContent).toBe('Feb: 19')
|
|
106
|
+
|
|
107
|
+
t.simulateEvent('.add-sale', 'click')
|
|
108
|
+
await t.next((state) => state.values[3] === 5)
|
|
109
|
+
expect(t.widget('.sales').props.values).toEqual([12, 19, 7, 5])
|
|
110
|
+
t.dispose()
|
|
111
|
+
})
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
To test the chart itself (that a click on the second bar selects it), run the component in a real browser with `renderComponent(Sales, { dom: 'real' })`: `t.widget('.sales').instance` is the Chart.js object, and `Chart.getChart(canvas)` is `undefined` after `t.dispose()`. jsdom has no canvas, so Chart.js can't draw there.
|
|
115
|
+
|
|
116
|
+
## ECharts
|
|
117
|
+
|
|
118
|
+
[Apache ECharts](https://echarts.apache.org/) works the same way. It draws into a `<div>` (the default host), takes one `option` object for everything, and reports clicks through `chart.on('click')`:
|
|
119
|
+
|
|
120
|
+
```js
|
|
121
|
+
// SalesEChart.js
|
|
122
|
+
import { defineWidget } from 'sygnal'
|
|
123
|
+
import * as echarts from 'echarts/core'
|
|
124
|
+
import { BarChart } from 'echarts/charts'
|
|
125
|
+
import { GridComponent, TooltipComponent } from 'echarts/components'
|
|
126
|
+
import { CanvasRenderer } from 'echarts/renderers'
|
|
127
|
+
|
|
128
|
+
echarts.use([BarChart, GridComponent, TooltipComponent, CanvasRenderer])
|
|
129
|
+
|
|
130
|
+
const option = (props) => ({
|
|
131
|
+
xAxis: { type: 'category', data: props.labels },
|
|
132
|
+
yAxis: { type: 'value' },
|
|
133
|
+
tooltip: {},
|
|
134
|
+
series: [{ type: 'bar', data: props.values }],
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
export const SalesEChart = defineWidget({
|
|
138
|
+
name: 'SalesEChart',
|
|
139
|
+
mount: (el, props, dispatch) => {
|
|
140
|
+
const chart = echarts.init(el)
|
|
141
|
+
chart.setOption(option(props))
|
|
142
|
+
chart.on('click', (params) => dispatch('bar-select', params.dataIndex))
|
|
143
|
+
// ECharts measures the element once: redraw at the new size when the host resizes
|
|
144
|
+
const observer = new ResizeObserver(() => chart.resize())
|
|
145
|
+
observer.observe(el)
|
|
146
|
+
return { chart, observer }
|
|
147
|
+
},
|
|
148
|
+
update: ({ chart }, props) => chart.setOption(option(props)),
|
|
149
|
+
unmount: ({ chart, observer }) => {
|
|
150
|
+
observer.disconnect()
|
|
151
|
+
chart.dispose()
|
|
152
|
+
},
|
|
153
|
+
events: ['bar-select'],
|
|
154
|
+
})
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
`setOption` merges the new option into the chart, so `update` can pass the whole option every time. Give the host a height in CSS (`.sales { height: 240px; }`): ECharts measures the element when it starts, and only again on `chart.resize()`, which the `ResizeObserver` calls when the host changes size (a resized window, a sidebar that opens). The instance is `{ chart, observer }`, so `unmount` can stop both. (Chart.js watches its canvas's container itself; with `maintainAspectRatio: false` it follows the container's height.)
|
|
158
|
+
|
|
159
|
+
## Size
|
|
160
|
+
|
|
161
|
+
Measured with Vite, minified and gzipped, Sygnal not included:
|
|
162
|
+
|
|
163
|
+
| | Adds |
|
|
164
|
+
|---|---|
|
|
165
|
+
| Chart.js bar chart (the registrations above) | 60 KB |
|
|
166
|
+
| ECharts bar chart (the `use` list above) | 180 KB |
|
|
167
|
+
| `defineWidget` (the first widget in an app) | 1.1 KB |
|
|
168
|
+
|
|
169
|
+
Register only what you draw: `import Chart from 'chart.js/auto'` registers every chart type, scale and plugin: 75 KB instead of 60.
|
|
170
|
+
|
|
171
|
+
## Pitfalls
|
|
172
|
+
|
|
173
|
+
- **Register what the chart uses.** Chart.js and ECharts are tree-shaken: a chart type, scale or plugin you don't register isn't there (`"bar" is not a registered controller`).
|
|
174
|
+
- **Pass new arrays.** `update` runs when a prop is a different value. A reducer that changes `state.values` in place passes the same array, and the chart doesn't change. The usual immutable update (`[...values]`, `map`) is enough.
|
|
175
|
+
- **A canvas has no text.** Give the host `role="img"` and an `aria-label`, and show the numbers somewhere a screen reader can reach (a table, a summary line), as `picked` does here.
|
|
176
|
+
- **Clicks on bars are mouse-only.** A keyboard user can't click a bar. Offer the same choice with buttons or a `<select>` that dispatch the same action.
|
|
177
|
+
- **Animations.** `chart.update()` animates and `chart.update('none')` doesn't. To respect `prefers-reduced-motion`, set `options.animation: false` in `mount` when `matchMedia('(prefers-reduced-motion: reduce)').matches`.
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
<!-- Generated from docs/src/content/docs/recipes by scripts/copy-guides.mjs; online: https://sygnal.js.org/recipes/ -->
|
|
2
|
+
# Code Editor (CodeMirror)
|
|
3
|
+
|
|
4
|
+
[CodeMirror 6](https://codemirror.net/) is a code editor made of an `EditorView` and extensions. [`defineWidget`](../widgets.md) turns it into a JSX tag: the code is a string in your state, each change comes back as an event, and a `focus` command focuses the editor from the model.
|
|
5
|
+
|
|
6
|
+
## Install
|
|
7
|
+
|
|
8
|
+
```sh
|
|
9
|
+
npm install codemirror @codemirror/state @codemirror/lang-javascript
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
## The widget
|
|
13
|
+
|
|
14
|
+
```js live-file=./CodeEditor.js
|
|
15
|
+
// CodeEditor.js
|
|
16
|
+
import { defineWidget } from 'sygnal'
|
|
17
|
+
import { EditorView, basicSetup } from 'codemirror'
|
|
18
|
+
import { Compartment } from '@codemirror/state'
|
|
19
|
+
import { javascript } from '@codemirror/lang-javascript'
|
|
20
|
+
|
|
21
|
+
// the label can change (another language): it lives in a compartment that update() reconfigures
|
|
22
|
+
const labelling = new Compartment()
|
|
23
|
+
const label = (props) => EditorView.contentAttributes.of(props.label == null ? {} : { 'aria-label': props.label })
|
|
24
|
+
|
|
25
|
+
export const CodeEditor = defineWidget({
|
|
26
|
+
name: 'CodeEditor',
|
|
27
|
+
mount: (el, props, dispatch) => new EditorView({
|
|
28
|
+
parent: el,
|
|
29
|
+
doc: props.code,
|
|
30
|
+
extensions: [
|
|
31
|
+
basicSetup,
|
|
32
|
+
javascript(),
|
|
33
|
+
labelling.of(label(props)),
|
|
34
|
+
EditorView.updateListener.of((update) => {
|
|
35
|
+
if (update.docChanged) dispatch('edit', update.state.doc.toString())
|
|
36
|
+
}),
|
|
37
|
+
],
|
|
38
|
+
}),
|
|
39
|
+
update: (view, props) => {
|
|
40
|
+
const code = view.state.doc.toString()
|
|
41
|
+
if (props.code !== code) view.dispatch({ changes: { from: 0, to: code.length, insert: props.code } })
|
|
42
|
+
// getAttribute gives null for no label
|
|
43
|
+
if ((props.label ?? null) !== view.contentDOM.getAttribute('aria-label')) view.dispatch({ effects: labelling.reconfigure(label(props)) })
|
|
44
|
+
},
|
|
45
|
+
unmount: (view) => view.destroy(),
|
|
46
|
+
events: ['edit'],
|
|
47
|
+
commands: { focus: (view) => view.focus() },
|
|
48
|
+
})
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
- `mount` creates the view inside the host `<div>` (`parent: el`). `basicSetup` brings line numbers, history, bracket matching, search and autocompletion.
|
|
52
|
+
- The update listener sends the whole document as the `edit` event after each change.
|
|
53
|
+
- `update` gets the code from state. When the user typed it, it equals the editor's document and nothing happens; otherwise one transaction replaces the document.
|
|
54
|
+
- `view.dispatch` is CodeMirror's own method for applying a transaction. It has nothing to do with the widget's `dispatch`, the third parameter of `mount`, which sends events to the intent.
|
|
55
|
+
- The `focus` command focuses the editable area. A command the widget declares wins over the host element's method of the same name, so `{ focus: '.code' }` runs this one, not the `<div>`'s.
|
|
56
|
+
|
|
57
|
+
## Using it
|
|
58
|
+
|
|
59
|
+
```jsx live
|
|
60
|
+
// Snippet.jsx
|
|
61
|
+
import { CodeEditor } from './CodeEditor.js'
|
|
62
|
+
|
|
63
|
+
const START = 'const answer = 42\n'
|
|
64
|
+
|
|
65
|
+
export function Snippet({ state }) {
|
|
66
|
+
return (
|
|
67
|
+
<section>
|
|
68
|
+
<CodeEditor className="code" label="Snippet" code={state.code} />
|
|
69
|
+
<p className="lines">{state.code.split('\n').length} lines</p>
|
|
70
|
+
<button type="button" className="edit-code">Edit</button>
|
|
71
|
+
<button type="button" className="reset">Reset</button>
|
|
72
|
+
</section>
|
|
73
|
+
)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
Snippet.initialState = { code: START }
|
|
77
|
+
|
|
78
|
+
Snippet.intent = ({ DOM }) => ({
|
|
79
|
+
EDIT: DOM.select('.code').events('edit').detail(),
|
|
80
|
+
FOCUS_CODE: DOM.click('.edit-code'),
|
|
81
|
+
RESET: DOM.click('.reset'),
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
Snippet.model = {
|
|
85
|
+
EDIT: (state, code) => ({ ...state, code }),
|
|
86
|
+
FOCUS_CODE: { ELEMENT: { focus: '.code' } },
|
|
87
|
+
RESET: (state) => ({ ...state, code: START }),
|
|
88
|
+
}
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
```css live
|
|
92
|
+
.code .cm-editor { background: white; color: black; border: 1px solid #8888; }
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
CodeMirror's default theme is a light one: dark syntax colours and a light gutter, on whatever background the page has. On a page that can be dark, give the editor a light background, as here, or add a dark theme extension (`@codemirror/theme-one-dark`) and switch it with a compartment.
|
|
96
|
+
|
|
97
|
+
## Testing
|
|
98
|
+
|
|
99
|
+
```jsx
|
|
100
|
+
// Snippet.test.jsx
|
|
101
|
+
import { test, expect } from 'vitest'
|
|
102
|
+
import { renderComponent } from 'sygnal'
|
|
103
|
+
import { Snippet } from './Snippet.jsx'
|
|
104
|
+
|
|
105
|
+
test('edits reach the state, Reset puts the start code back', async () => {
|
|
106
|
+
const t = renderComponent(Snippet)
|
|
107
|
+
await t.ready()
|
|
108
|
+
t.widget('.code').dispatch('edit', 'const answer = 42\nconsole.log(answer)\n')
|
|
109
|
+
await t.next((state) => state.code.includes('console.log'))
|
|
110
|
+
expect(t.query('.lines').textContent).toBe('3 lines')
|
|
111
|
+
|
|
112
|
+
t.simulateEvent('.reset', 'click')
|
|
113
|
+
await t.next((state) => state.code === 'const answer = 42\n')
|
|
114
|
+
expect(t.widget('.code').props.code).toBe('const answer = 42\n')
|
|
115
|
+
|
|
116
|
+
t.simulateEvent('.edit-code', 'click')
|
|
117
|
+
await t.settle()
|
|
118
|
+
expect(t.commands()).toEqual([{ focus: '.code' }])
|
|
119
|
+
t.dispose()
|
|
120
|
+
})
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
CodeMirror measures text positions with layout APIs jsdom doesn't have, so test typing in a real browser: with `renderComponent(Snippet, { dom: 'real' })` under Playwright, click **Edit**, type, and `t.waitForState((state) => ...)` sees the new code; `t.widget('.code').instance` is the `EditorView`.
|
|
124
|
+
|
|
125
|
+
## Size
|
|
126
|
+
|
|
127
|
+
Measured with Vite, minified and gzipped, Sygnal not included: `basicSetup` with the JavaScript language adds **177 KB**. For a smaller editor, replace `basicSetup` with `minimalSetup` (history, default keymap, special characters) and the extensions you want from `@codemirror/view`, `@codemirror/commands` and the language packages. `defineWidget` adds 1.1 KB for the first widget in an app.
|
|
128
|
+
|
|
129
|
+
## Pitfalls
|
|
130
|
+
|
|
131
|
+
- **Keep the comparison in `update`.** Replacing the document on every render would reset the cursor and the undo history while the user types.
|
|
132
|
+
- **A reset is also an edit.** The transaction `update` dispatches changes the document, so the listener sends it back as an `edit` with the same code. That costs one extra action and changes nothing. To skip it, add an annotation to the transaction (`annotations: Transaction.remote.of(true)` from `@codemirror/state`) and ignore updates that carry it.
|
|
133
|
+
- **Change configuration with compartments.** `mount` builds the extensions once. The label is in a `Compartment` that `update` reconfigures when the `label` prop changes (a translated app switching language); do the same to switch the language mode, the theme or `readOnly` from props (`view.dispatch({ effects: compartment.reconfigure(...) })`). `@codemirror/state` is installed with `codemirror`; list it yourself so there is one copy (two copies break compartments).
|
|
134
|
+
- **Label the editable element.** The host `<div>` is not what receives the keyboard. `EditorView.contentAttributes` puts `aria-label` on CodeMirror's `.cm-content`, the element a screen reader announces.
|
|
135
|
+
- **Tab.** CodeMirror doesn't trap Tab by default, so keyboard users can leave the editor. Adding `indentWithTab` changes that; if you do, tell users that Escape then Tab leaves the editor.
|