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,173 @@
|
|
|
1
|
+
<!-- Generated from docs/src/content/docs/guide by scripts/copy-guides.mjs; online: https://sygnal.js.org/guide/ -->
|
|
2
|
+
# Inputs, Labels and Focus
|
|
3
|
+
|
|
4
|
+
How Sygnal treats the value of a field, how to give labels and descriptions ids that stay unique, and how to focus a field when it appears. For forms with validation, see [Forms](./forms.md).
|
|
5
|
+
|
|
6
|
+
## Controlled Inputs
|
|
7
|
+
|
|
8
|
+
An `<input>`, `<textarea>` or `<select>` with a `value` prop (or a checkbox/radio with `checked`) is **controlled**: on every render, Sygnal writes the value from your view into the element, like React does. That keeps the field in sync with state (clearing a field after "Add" works even when both happen in the same tick), but it means the field must update state as the user types. Otherwise any re-render resets what they typed.
|
|
9
|
+
|
|
10
|
+
A form-associated custom element (one whose class has `static formAssociated = true`, such as Web Awesome's `<wa-input>` or `<wa-rating>`) with a `value` or `checked` prop is controlled the same way.
|
|
11
|
+
|
|
12
|
+
To refuse what the user entered, return `ABORT` (or the state unchanged): the state stays as it was, and because the action came from an `input` or `change` event, the component still renders, so the field shows the state's value again. A digits-only field:
|
|
13
|
+
|
|
14
|
+
```jsx live
|
|
15
|
+
import { ABORT } from 'sygnal'
|
|
16
|
+
|
|
17
|
+
function Pin({ state }) {
|
|
18
|
+
return <input className="pin" aria-label="PIN" inputMode="numeric" value={state.pin} />
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
Pin.initialState = { pin: '' }
|
|
22
|
+
Pin.intent = ({ DOM }) => ({ PIN: DOM.input('.pin').value() })
|
|
23
|
+
Pin.model = {
|
|
24
|
+
PIN: (state, pin) => /^\d{0,6}$/.test(pin) ? { ...state, pin } : ABORT,
|
|
25
|
+
}
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
This applies only while the `input` or `change` event is being handled (an intent that delays the action, with `debounce` for example, gets no extra render). `ABORT` from any other action (a click, a timer, a reply) still renders nothing.
|
|
29
|
+
|
|
30
|
+
Pick one of two patterns:
|
|
31
|
+
|
|
32
|
+
**Controlled**: bind `value` to state and update state on `input`:
|
|
33
|
+
|
|
34
|
+
```jsx live
|
|
35
|
+
import { ABORT } from 'sygnal'
|
|
36
|
+
|
|
37
|
+
function NewTodo({ state }) {
|
|
38
|
+
return (
|
|
39
|
+
<div>
|
|
40
|
+
<input className="new-todo" aria-label="New todo" value={state.draft} />
|
|
41
|
+
<button className="add">Add</button>
|
|
42
|
+
<ul>{state.items.map((item) => <li>{item}</li>)}</ul>
|
|
43
|
+
</div>
|
|
44
|
+
)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
NewTodo.initialState = { draft: '', items: [] }
|
|
48
|
+
|
|
49
|
+
NewTodo.intent = ({ DOM }) => ({
|
|
50
|
+
DRAFT: DOM.input('.new-todo').value(),
|
|
51
|
+
ADD: DOM.click('.add'),
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
NewTodo.model = {
|
|
55
|
+
DRAFT: (state, draft) => ({ ...state, draft }),
|
|
56
|
+
ADD: (state) => state.draft.trim()
|
|
57
|
+
? { ...state, items: [...state.items, state.draft.trim()], draft: '' }
|
|
58
|
+
: ABORT,
|
|
59
|
+
}
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
**Uncontrolled**: leave out `value`, and read the element's value from the event when you need it (on blur, Enter or submit), or with [`processForm()`](./forms-reference.md#processform):
|
|
63
|
+
|
|
64
|
+
```jsx live
|
|
65
|
+
import { ABORT } from 'sygnal'
|
|
66
|
+
|
|
67
|
+
function Rename({ state }) {
|
|
68
|
+
return <input className="rename" aria-label="Title" placeholder={state.title} />
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
Rename.initialState = { title: 'Untitled' }
|
|
72
|
+
|
|
73
|
+
Rename.intent = ({ DOM }) => ({
|
|
74
|
+
RENAME: DOM.keydown('.rename').filter(e => e.key === 'Enter').map(e => e.target.value),
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
Rename.model = {
|
|
78
|
+
RENAME: (state, title) => title ? { ...state, title } : ABORT,
|
|
79
|
+
}
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
What doesn't work is a bound `value` with no `input`/`change` listener, for example a "save on blur" field that only listens to `blur`: the first re-render while the user types puts the old value back. `sygnal-check` reports that as [SYG111](https://sygnal.js.org/reference/errors/#syg111). Literal values (`value=""`) are controlled too, so they reset the field on every render as well.
|
|
83
|
+
|
|
84
|
+
`value={null}` (or `checked={null}`) clears the field and keeps it controlled; leaving the prop out makes the field uncontrolled, so whatever the user typed stays.
|
|
85
|
+
|
|
86
|
+
## Labels and ids: uid()
|
|
87
|
+
|
|
88
|
+
Every field needs a label ([SYG702](./accessibility.md#syg702-form-field-without-a-label)). Wrapping the field in a `<label>` needs no id. When the label sits elsewhere, or a hint is attached with `aria-describedby`, the elements need ids, and a literal `id="email"` is repeated as soon as the component renders twice. Use the `uid` view prop instead:
|
|
89
|
+
|
|
90
|
+
```jsx
|
|
91
|
+
function Signup({ state, uid }) {
|
|
92
|
+
return (
|
|
93
|
+
<form className="signup">
|
|
94
|
+
<label for={uid('email')}>Email</label>
|
|
95
|
+
<input id={uid('email')} type="email" className="email" value={state.email} aria-describedby={uid('email-help')} />
|
|
96
|
+
<p id={uid('email-help')}>We only use it to sign you in.</p>
|
|
97
|
+
</form>
|
|
98
|
+
)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
Signup.initialState = { email: '' }
|
|
102
|
+
Signup.intent = ({ DOM }) => ({ EMAIL: DOM.input('.email').value() })
|
|
103
|
+
Signup.model = { EMAIL: (state, email) => ({ ...state, email }) }
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
`uid()` returns an id for this component instance, and `uid('email')` one derived from it (for example `u-email` at the root, longer further down the tree). Ids come from the instance's position in the tree and its Collection item key, never from a counter, so they:
|
|
107
|
+
|
|
108
|
+
- differ between two instances of the component, and between Collection items;
|
|
109
|
+
- stay the same across renders, and move with their item when a Collection is reordered;
|
|
110
|
+
- are the same on the server and after hydration ([SSR](./ssr.md#stable-ids-uid)).
|
|
111
|
+
|
|
112
|
+
`uid` is also on the reducers' `props` argument. It is a reserved prop: a parent can't pass its own `uid` to a child ([SYG106](https://sygnal.js.org/reference/errors/#syg106)). `sygnal-check` matches `for={uid('email')}` with `id={uid('email')}` ([SYG708](./accessibility.md#syg708-label-or-aria-reference-to-an-id-that-isnt-rendered)).
|
|
113
|
+
|
|
114
|
+
## Focus Management
|
|
115
|
+
|
|
116
|
+
Sygnal components are pure functions — they never touch real DOM elements. But web apps frequently need to focus an element programmatically, for example when an input appears for inline editing.
|
|
117
|
+
|
|
118
|
+
The `autoFocus` and `autoSelect` JSX props handle this declaratively. No imperative code in your view, no drivers, no hooks.
|
|
119
|
+
|
|
120
|
+
### autoFocus
|
|
121
|
+
|
|
122
|
+
Add `autoFocus={true}` to any element. When that element enters the DOM, it receives focus automatically:
|
|
123
|
+
|
|
124
|
+
```jsx
|
|
125
|
+
function SearchBar({ state }) {
|
|
126
|
+
return (
|
|
127
|
+
<div>
|
|
128
|
+
{state.isOpen &&
|
|
129
|
+
<input autoFocus={true} className="search-input" placeholder="Search..." />
|
|
130
|
+
}
|
|
131
|
+
</div>
|
|
132
|
+
)
|
|
133
|
+
}
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
### autoSelect
|
|
137
|
+
|
|
138
|
+
Add `autoSelect={true}` alongside `autoFocus` to select all text in the element after focusing. This is ideal for edit-in-place patterns where the user typically wants to replace the existing value:
|
|
139
|
+
|
|
140
|
+
```jsx live
|
|
141
|
+
function EditableTitle({ state }) {
|
|
142
|
+
return (
|
|
143
|
+
<div>
|
|
144
|
+
{state.isEditing
|
|
145
|
+
? <input autoFocus={true} autoSelect={true} value={state.draft} className="title-input" aria-label="Title" />
|
|
146
|
+
: <h2 className="title">{state.title}</h2>
|
|
147
|
+
}
|
|
148
|
+
</div>
|
|
149
|
+
)
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
EditableTitle.initialState = { title: 'Double-click to rename', isEditing: false, draft: '' }
|
|
153
|
+
|
|
154
|
+
EditableTitle.intent = ({ DOM }) => ({
|
|
155
|
+
EDIT: DOM.dblclick('.title'),
|
|
156
|
+
DRAFT: DOM.input('.title-input').value(),
|
|
157
|
+
SAVE: DOM.blur('.title-input'),
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
EditableTitle.model = {
|
|
161
|
+
EDIT: (state) => ({ ...state, isEditing: true, draft: state.title }),
|
|
162
|
+
DRAFT: (state, draft) => ({ ...state, draft }),
|
|
163
|
+
SAVE: (state) => ({ ...state, isEditing: false, title: state.draft }),
|
|
164
|
+
}
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
When the user double-clicks to edit, the input appears focused with all text selected — ready to type a replacement.
|
|
168
|
+
|
|
169
|
+
### How It Works
|
|
170
|
+
|
|
171
|
+
These props are intercepted by the JSX pragma before they reach the DOM. Under the hood, a snabbdom `insert` hook calls `.focus()` (and optionally `.select()`) when the element is first inserted. The props are never passed to the actual DOM element.
|
|
172
|
+
|
|
173
|
+
If you also set a manual `hook={{ insert: fn }}` on the same element, both hooks run — yours first, then the focus behavior.
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
<!-- Generated from docs/src/content/docs/guide by scripts/copy-guides.mjs; online: https://sygnal.js.org/guide/ -->
|
|
2
|
+
# MCP Apps
|
|
3
|
+
|
|
4
|
+
An [MCP App](https://modelcontextprotocol.io/seps/1865-mcp-apps-interactive-user-interfaces-for-mcp) is an MCP server tool that comes with an interactive view. When the model calls the tool, a host that supports MCP Apps (Claude, ChatGPT, VS Code, Goose, MCPJam, ...) shows the view in the conversation, in a sandboxed iframe. The view and the host talk JSON-RPC over `postMessage`: the host sends the tool's input and result, and the view can call the server's tools, tell the model what the user did, or post a message.
|
|
5
|
+
|
|
6
|
+
In Sygnal that bridge is a driver. The view is an ordinary component.
|
|
7
|
+
|
|
8
|
+
## The view
|
|
9
|
+
|
|
10
|
+
```jsx
|
|
11
|
+
// App.jsx: the view of the get_forecast tool
|
|
12
|
+
function App({ state }) {
|
|
13
|
+
return (
|
|
14
|
+
<main>
|
|
15
|
+
<h1>{state.city ? `Forecast for ${state.city}` : 'Forecast'}</h1>
|
|
16
|
+
<p role="status">{state.status === 'loading' ? 'Loading…' : ''}</p>
|
|
17
|
+
<ul className="days">
|
|
18
|
+
{state.days.map((day) => (
|
|
19
|
+
<li key={day.date}>
|
|
20
|
+
<button type="button" className="day" data={{ date: day.date }} aria-pressed={day.date === state.selected}>
|
|
21
|
+
{day.date}: {day.sky}, {day.high}° / {day.low}°
|
|
22
|
+
</button>
|
|
23
|
+
</li>
|
|
24
|
+
))}
|
|
25
|
+
</ul>
|
|
26
|
+
<button type="button" className="refresh" disabled={!state.city}>Refresh</button>
|
|
27
|
+
<button type="button" className="ask" disabled={!state.selected}>Ask about this day</button>
|
|
28
|
+
</main>
|
|
29
|
+
)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
App.initialState = { city: '', days: [], selected: null, status: 'waiting' }
|
|
33
|
+
|
|
34
|
+
App.intent = ({ DOM, MCP }) => ({
|
|
35
|
+
INPUT: MCP.select('tool-input'), // the arguments the model called the tool with
|
|
36
|
+
RESULT: MCP.select('tool-result'), // the server tool's result
|
|
37
|
+
REFRESH: DOM.click('.refresh'),
|
|
38
|
+
PICK: DOM.click('.day').data('date'),
|
|
39
|
+
ASK: DOM.click('.ask'),
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
App.model = {
|
|
43
|
+
INPUT: (state, args) => ({ ...state, city: args.city ?? state.city, status: 'loading' }),
|
|
44
|
+
RESULT: (state, result) => ({ ...state, status: 'ready', days: result.structuredContent?.days ?? [], selected: null }),
|
|
45
|
+
// call the server's tool again from the view; the result comes back as RESULT
|
|
46
|
+
REFRESH: {
|
|
47
|
+
STATE: (state) => ({ ...state, status: 'loading' }),
|
|
48
|
+
MCP: (state) => ({ callTool: 'get_forecast', args: { city: state.city }, ok: 'RESULT' }),
|
|
49
|
+
},
|
|
50
|
+
// tell the model what the user picked: it reads it on its next turn
|
|
51
|
+
PICK: {
|
|
52
|
+
STATE: (state, date) => ({ ...state, selected: date }),
|
|
53
|
+
MCP: (state, date) => ({ updateModelContext: { city: state.city, selectedDay: state.days.find((d) => d.date === date) } }),
|
|
54
|
+
},
|
|
55
|
+
// post a message to the conversation, as the user
|
|
56
|
+
ASK: { MCP: (state) => ({ message: `What should I pack for ${state.city} on ${state.selected}?` }) },
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export default App
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
```js
|
|
63
|
+
// main.js
|
|
64
|
+
import { run } from 'sygnal'
|
|
65
|
+
import { makeMcpAppDriver } from 'sygnal/ai'
|
|
66
|
+
import App from './App.jsx'
|
|
67
|
+
|
|
68
|
+
run(App, { MCP: makeMcpAppDriver({ appInfo: { name: 'forecast', version: '1.0.0' } }) })
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
The driver does the `ui/initialize` handshake with the host when the app starts. Requests made before it completes wait for it; with no host (the page opened directly, or server rendering) every request gets its `error` action.
|
|
72
|
+
|
|
73
|
+
## The driver
|
|
74
|
+
|
|
75
|
+
**Sources**, in the intent:
|
|
76
|
+
|
|
77
|
+
| `MCP.select(...)` | Delivers |
|
|
78
|
+
|---|---|
|
|
79
|
+
| `'tool-input'` | The arguments the model called the tool with |
|
|
80
|
+
| `'tool-input-partial'` | The same, while the model is still writing them (to show something early) |
|
|
81
|
+
| `'tool-result'` | The tool's result, an MCP `CallToolResult`: `{ content, structuredContent?, isError? }` |
|
|
82
|
+
| `'tool-cancelled'` | The call was cancelled: `{ reason? }` |
|
|
83
|
+
| `'host-context-changed'` | The host context: `theme`, `displayMode`, `availableDisplayModes`, `locale`, `timeZone`, `containerDimensions`, `styles`, ... First the handshake's, then after every change (merged) |
|
|
84
|
+
| `'teardown'` | The host is about to remove the view; it waits for the actions this causes |
|
|
85
|
+
| `'host'` | The handshake's answer: `{ hostInfo, hostCapabilities, protocolVersion }`. `hostCapabilities` says what the host supports (`downloadFile`, `serverResources`, `logging`, `sampling`, `openLinks`, ...) |
|
|
86
|
+
|
|
87
|
+
The latest tool input, tool result, host context and `host` are replayed to a component that subscribes later, so a component that appears after they arrived still gets them.
|
|
88
|
+
|
|
89
|
+
**Sinks**, in the model. Reply actions (`ok`, `error`) go back to the component that sent the request, as with the [fetch driver](./http.md):
|
|
90
|
+
|
|
91
|
+
| Value | Does |
|
|
92
|
+
|---|---|
|
|
93
|
+
| `{ callTool: 'name', args, ok, error }` | Calls a tool on the MCP server through the host. `ok` gets the `CallToolResult`; `error` gets `{ error, code?, result?, request }` (a JSON-RPC error, or a result with `isError: true`) |
|
|
94
|
+
| `{ updateModelContext: value }` | Tells the model something, for its next turn: a string (one text block), content blocks, or an object (`structuredContent`) |
|
|
95
|
+
| `{ message: 'text' }` | Posts a message to the conversation as the user (a string or content blocks) |
|
|
96
|
+
| `{ openLink: 'https://…' }` | Asks the host to open a link |
|
|
97
|
+
| `{ displayMode: 'fullscreen' }` | Asks for `'inline'`, `'fullscreen'` or `'pip'`; `ok` gets `{ mode }`, the mode the host chose |
|
|
98
|
+
| `{ downloadFile: contents }` | Asks the host to save a file (the iframe can't download itself; the host asks the user). `contents`: `{ uri, mimeType, text }` (or `blob`, base64), or MCP `resource` / `resource_link` blocks, one or an array. `error` runs when the host refused or the user cancelled |
|
|
99
|
+
| `{ readResource: 'uri' }` | Reads a resource of the MCP server through the host; `ok` gets `{ contents }` |
|
|
100
|
+
| `{ listResources: true }` | Lists the server's resources (`{ cursor }` for the next page); `ok` gets `{ resources, nextCursor? }` |
|
|
101
|
+
| `{ createMessage: 'prompt' }` | Asks the host's model for a completion (MCP sampling): a string is one user message, or pass the MCP params (`messages`, `maxTokens`, `systemPrompt`, ...); `maxTokens` defaults to 1024. `ok` gets `{ role, content, model }`. The host may ask the user first or refuse |
|
|
102
|
+
| `{ log: data, level?, logger? }` | A log line for the host's logs (`notifications/message`; not shown to the model). `level`: `'debug'` … `'emergency'`, default `'info'` |
|
|
103
|
+
| `{ requestTeardown: true }` | Asks the host to close the view. If it agrees, it sends `teardown` as usual |
|
|
104
|
+
|
|
105
|
+
`log` and `requestTeardown` are notifications: there is no answer, and `ok` runs once they are sent.
|
|
106
|
+
|
|
107
|
+
`makeMcpAppDriver(options)`:
|
|
108
|
+
|
|
109
|
+
| Option | Default | |
|
|
110
|
+
|---|---|---|
|
|
111
|
+
| `appInfo` | `{ name: document.title, version: '0.0.0' }` | The view's name and version in the handshake |
|
|
112
|
+
| `availableDisplayModes` | | The display modes the view supports |
|
|
113
|
+
| `autoResize` | `true` | Reports the document's size to the host when it changes, so the iframe fits the content |
|
|
114
|
+
| `tools` | | `agentTools`: offer the view's `agent` tools to the host ([below](#the-views-own-tools)) |
|
|
115
|
+
| `confirm` | declined | With `tools`: consequential calls run when this resolves `true` |
|
|
116
|
+
| `protocolVersion` | `'2026-01-26'` | The MCP Apps protocol version offered |
|
|
117
|
+
|
|
118
|
+
**Files, resources and the host's model.** Check `hostCapabilities` from `MCP.select('host')` before offering what a host may not support:
|
|
119
|
+
|
|
120
|
+
```jsx
|
|
121
|
+
function Export({ state }) {
|
|
122
|
+
return (
|
|
123
|
+
<div>
|
|
124
|
+
{state.canDownload ? <button className="csv">Download CSV</button> : null}
|
|
125
|
+
{state.canSample ? <button className="summary">Summarize</button> : null}
|
|
126
|
+
<p className="summary-text">{state.summary}</p>
|
|
127
|
+
</div>
|
|
128
|
+
)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
Export.initialState = { days: [], canDownload: false, canSample: false, summary: '' }
|
|
132
|
+
|
|
133
|
+
Export.intent = ({ DOM, MCP }) => ({
|
|
134
|
+
HOST: MCP.select('host'),
|
|
135
|
+
CSV: DOM.click('.csv'),
|
|
136
|
+
SUMMARIZE: DOM.click('.summary'),
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
Export.model = {
|
|
140
|
+
HOST: (state, { hostCapabilities }) => ({ ...state, canDownload: !!hostCapabilities.downloadFile, canSample: !!hostCapabilities.sampling }),
|
|
141
|
+
CSV: {
|
|
142
|
+
MCP: (state) => ({
|
|
143
|
+
downloadFile: { uri: 'file:///forecast.csv', mimeType: 'text/csv', text: state.days.map((d) => `${d.date},${d.high}`).join('\n') },
|
|
144
|
+
error: 'FAILED',
|
|
145
|
+
}),
|
|
146
|
+
},
|
|
147
|
+
SUMMARIZE: {
|
|
148
|
+
MCP: (state) => ({ createMessage: `Summarize this forecast in one sentence: ${JSON.stringify(state.days)}`, ok: 'SUMMARY', error: 'FAILED' }),
|
|
149
|
+
},
|
|
150
|
+
SUMMARY: (state, result) => ({ ...state, summary: result.content.text }),
|
|
151
|
+
FAILED: (state, { error }) => ({ ...state, summary: error }),
|
|
152
|
+
}
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
**Theme and size.** Follow `host-context-changed` for the host's `theme` (and its `styles.variables`, CSS custom properties the host suggests), so the view matches the conversation around it. Keep the view compact for `inline` mode; offer full screen with `{ displayMode: 'fullscreen' }` when `availableDisplayModes` includes it.
|
|
156
|
+
|
|
157
|
+
## The view's own tools
|
|
158
|
+
|
|
159
|
+
The model sees the server's tools. With `tools: agentTools`, the view also offers its components' [`agent` declarations](./agent.md) to the host as tools of its own, so the model can operate the view while it is shown: select a day, change units, open a detail. Calls go through the same [call rules](./agent.md#the-call-rules) as any agent's.
|
|
160
|
+
|
|
161
|
+
```js
|
|
162
|
+
// main.js
|
|
163
|
+
import { run } from 'sygnal'
|
|
164
|
+
import { makeMcpAppDriver, agentTools } from 'sygnal/ai'
|
|
165
|
+
import App from './App.jsx'
|
|
166
|
+
|
|
167
|
+
run(App, { MCP: makeMcpAppDriver({ tools: agentTools }) })
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
```jsx
|
|
171
|
+
// App.jsx: what the model may do in the view
|
|
172
|
+
import { z } from 'zod'
|
|
173
|
+
|
|
174
|
+
App.model = {
|
|
175
|
+
// ...the entries above, plus:
|
|
176
|
+
SELECT_DAY: (state, date) => ({ ...state, selected: date }),
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
App.agent = {
|
|
180
|
+
name: 'forecast',
|
|
181
|
+
description: 'The forecast card on screen',
|
|
182
|
+
read: (state) => ({ city: state.city, days: state.days, selected: state.selected }),
|
|
183
|
+
actions: {
|
|
184
|
+
SELECT_DAY: { description: 'Highlight one day of the forecast', input: z.enum(['Mon', 'Tue', 'Wed', 'Thu', 'Fri']) },
|
|
185
|
+
},
|
|
186
|
+
}
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
The driver answers the host's `tools/list` and `tools/call` with them and tells the host when the list changes (a `when`, a new Collection key). As everywhere, a Collection item's `label` text is user text: the item parameter lists the ids, and the model reads the labels' contents through a `read` tool ([Agents: Collection items](./agent.md#collection-items)). `agentTools` is passed in rather than switched on, so a view without tools doesn't carry the agent layer.
|
|
190
|
+
|
|
191
|
+
A consequential tool is declined unless you pass `confirm`, a function that asks the user in the view and resolves `true` to run the call. The host's own approval UI covers the server's tools, not the view's.
|
|
192
|
+
|
|
193
|
+
## The starter: `create-sygnal-app --template mcp-app`
|
|
194
|
+
|
|
195
|
+
```bash
|
|
196
|
+
npm create sygnal-app@latest my-tool -- --template mcp-app
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
(`--ts` for TypeScript.) The starter needs Sygnal 6.1 or later (`makeMcpAppDriver` is in `sygnal/ai` since 6.1.0). To try it against an unreleased Sygnal change, point it at a local build: `npm run build` in a Sygnal checkout, then `npm install /path/to/sygnal` in the project (its README has the steps).
|
|
200
|
+
|
|
201
|
+
```text
|
|
202
|
+
src/App.jsx the view (the forecast card above, with theme and full screen)
|
|
203
|
+
src/main.js run(App, { MCP: makeMcpAppDriver() })
|
|
204
|
+
server/server.js the MCP server: the get_forecast tool and the ui:// resource that serves the view
|
|
205
|
+
server/forecast.js made-up weather (replace it with a real API)
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
The host loads the view into a sandboxed iframe with no other files and no network access, so the view is built as **one HTML file** (`vite-plugin-singlefile`) and gets its data only through the tool's result and `callTool`. The server registers the tool with `_meta: { ui: { resourceUri: 'ui://forecast/view.html' } }` and serves that file as a resource of type `text/html;profile=mcp-app`.
|
|
209
|
+
|
|
210
|
+
| Command | |
|
|
211
|
+
|---|---|
|
|
212
|
+
| `npm start` | Build `dist/index.html`, then serve MCP on `http://localhost:3001/mcp` (Streamable HTTP) |
|
|
213
|
+
| `node server/server.js --stdio` | The same server over stdio |
|
|
214
|
+
| `npm test` | The view (with `renderComponent`) and the server, no host needed |
|
|
215
|
+
|
|
216
|
+
## Trying it in a host
|
|
217
|
+
|
|
218
|
+
Connect the server, then ask the model for a forecast ("What's the weather in Lisbon this week?"):
|
|
219
|
+
|
|
220
|
+
- **MCPJam Inspector**, no account needed: `npx @mcpjam/inspector@latest`, add the server as Streamable HTTP at `http://localhost:3001/mcp`, then call `get_forecast` from the Tools tab or the chat.
|
|
221
|
+
- **The ext-apps basic host**, the reference host in [modelcontextprotocol/ext-apps](https://github.com/modelcontextprotocol/ext-apps) (`examples/basic-host`).
|
|
222
|
+
- **Claude Desktop**: add the server over stdio to `claude_desktop_config.json` (`npm run build` first):
|
|
223
|
+
|
|
224
|
+
```json
|
|
225
|
+
{ "mcpServers": { "forecast": { "command": "node", "args": ["/absolute/path/to/my-tool/server/server.js", "--stdio"] } } }
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
On claude.ai, add it as a custom connector; that needs the HTTP server at a public HTTPS URL (a tunnel to port 3001).
|
|
229
|
+
- **VS Code** (Copilot agent mode), in `.vscode/mcp.json`:
|
|
230
|
+
|
|
231
|
+
```json
|
|
232
|
+
{ "servers": { "forecast": { "type": "http", "url": "http://localhost:3001/mcp" } } }
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
Rebuild (`npm run build`) after changing the view; the server reads `dist/index.html` on each request.
|
|
236
|
+
|
|
237
|
+
## Testing
|
|
238
|
+
|
|
239
|
+
`renderComponent` runs the view with no host. The `MCP` source's events are actions you simulate, and the `MCP` sink is a fake: `t.requests('MCP')` lists what the view sent, and `t.respond('MCP', result, 'RESULT')` answers a `callTool`.
|
|
240
|
+
|
|
241
|
+
```js
|
|
242
|
+
import { it, expect, afterEach } from 'vitest'
|
|
243
|
+
import { renderComponent } from 'sygnal'
|
|
244
|
+
import App from './App.jsx'
|
|
245
|
+
|
|
246
|
+
let t
|
|
247
|
+
afterEach(() => t?.dispose())
|
|
248
|
+
|
|
249
|
+
const days = [{ date: 'Mon', sky: 'Sunny', high: 21, low: 14 }, { date: 'Tue', sky: 'Showers', high: 17, low: 12 }]
|
|
250
|
+
|
|
251
|
+
it('shows the result, refreshes, and tells the model what the user picked', async () => {
|
|
252
|
+
t = renderComponent(App)
|
|
253
|
+
t.simulateAction('INPUT', { city: 'Oslo' })
|
|
254
|
+
t.simulateAction('RESULT', { content: [], structuredContent: { city: 'Oslo', days } })
|
|
255
|
+
await t.waitForState((s) => s.status === 'ready')
|
|
256
|
+
|
|
257
|
+
t.simulateEvent('.refresh', 'click')
|
|
258
|
+
expect(t.requests('MCP').at(-1)).toMatchObject({ callTool: 'get_forecast', args: { city: 'Oslo' }, ok: 'RESULT' })
|
|
259
|
+
await t.respond('MCP', { content: [], structuredContent: { city: 'Oslo', days: days.slice(1) } }, 'RESULT')
|
|
260
|
+
expect(t.state.days).toEqual(days.slice(1))
|
|
261
|
+
|
|
262
|
+
t.simulateEvent('.day', 'click', { data: { date: 'Tue' } })
|
|
263
|
+
await t.waitForState((s) => s.selected === 'Tue')
|
|
264
|
+
expect(t.requests('MCP').at(-1)).toEqual({ updateModelContext: { city: 'Oslo', selectedDay: days[1] } })
|
|
265
|
+
})
|
|
266
|
+
```
|
|
267
|
+
|
|
268
|
+
The starter's tests also run the server with the MCP SDK's in-memory client.
|
|
269
|
+
|
|
270
|
+
## Not covered yet
|
|
271
|
+
|
|
272
|
+
The driver wraps the view-side requests and notifications of protocol 2026-01-26, except two listings a host may proxy to the server: `resources/templates/list` and `prompts/list`.
|