sygnal 6.5.0 → 7.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (104) hide show
  1. package/CHANGELOG.md +156 -0
  2. package/README.md +2 -0
  3. package/dist/astro/index.cjs.js +423 -27
  4. package/dist/astro/index.cjs.js.map +1 -1
  5. package/dist/astro/index.mjs +423 -27
  6. package/dist/astro/index.mjs.map +1 -1
  7. package/dist/astro/server.cjs.js.map +1 -1
  8. package/dist/astro/server.mjs.map +1 -1
  9. package/dist/devtools.cjs.js +141 -3
  10. package/dist/devtools.cjs.js.map +1 -1
  11. package/dist/devtools.esm.js +141 -4
  12. package/dist/devtools.esm.js.map +1 -1
  13. package/dist/diagnostics.cjs.js +169 -14
  14. package/dist/diagnostics.cjs.js.map +1 -1
  15. package/dist/diagnostics.esm.js +168 -15
  16. package/dist/diagnostics.esm.js.map +1 -1
  17. package/dist/guide/accessibility.md +2 -0
  18. package/dist/guide/ai-chat.md +6 -6
  19. package/dist/guide/behaviors.md +1 -1
  20. package/dist/guide/element-commands.md +4 -2
  21. package/dist/guide/forms-reference.md +89 -3
  22. package/dist/guide/forms.md +8 -8
  23. package/dist/guide/http.md +77 -7
  24. package/dist/guide/migrating-to-7.md +314 -0
  25. package/dist/guide/recipes/ai-summarize.md +2 -1
  26. package/dist/guide/recipes/i18n.md +1 -1
  27. package/dist/guide/ui/tabs.md +5 -4
  28. package/dist/guide/ui/tooltip.md +24 -0
  29. package/dist/index.cjs.js +781 -320
  30. package/dist/index.cjs.js.map +1 -1
  31. package/dist/index.d.ts +319 -43
  32. package/dist/index.esm.js +781 -320
  33. package/dist/index.esm.js.map +1 -1
  34. package/dist/sygnal.min.js +1 -1
  35. package/dist/sygnal.min.js.map +1 -1
  36. package/dist/ui.cjs.js +96 -11
  37. package/dist/ui.cjs.js.map +1 -1
  38. package/dist/ui.esm.js +96 -12
  39. package/dist/ui.esm.js.map +1 -1
  40. package/dist/vike/config/package.json +1 -1
  41. package/dist/vike/onRenderClient.cjs.js +63 -43
  42. package/dist/vike/onRenderClient.cjs.js.map +1 -1
  43. package/dist/vike/onRenderClient.mjs +63 -43
  44. package/dist/vike/onRenderClient.mjs.map +1 -1
  45. package/dist/vike/onRenderHtml.cjs.js +21 -25
  46. package/dist/vike/onRenderHtml.cjs.js.map +1 -1
  47. package/dist/vike/onRenderHtml.mjs +21 -25
  48. package/dist/vike/onRenderHtml.mjs.map +1 -1
  49. package/dist/vite/plugin.cjs.js +410 -27
  50. package/dist/vite/plugin.cjs.js.map +1 -1
  51. package/dist/vite/plugin.mjs +410 -27
  52. package/dist/vite/plugin.mjs.map +1 -1
  53. package/llms.txt +17 -16
  54. package/package.json +1 -1
  55. package/src/astro/index.d.ts +8 -0
  56. package/src/astro/index.ts +18 -0
  57. package/src/core/actions.ts +28 -21
  58. package/src/core/define.ts +2 -0
  59. package/src/core/hooks.ts +8 -2
  60. package/src/core/hosts/collection.ts +4 -3
  61. package/src/core/instance.ts +60 -16
  62. package/src/core/runtime.ts +5 -1
  63. package/src/core/view.ts +2 -0
  64. package/src/cycle/dom/utils.ts +4 -2
  65. package/src/devtools.d.ts +13 -0
  66. package/src/devtools.ts +3 -0
  67. package/src/extra/ai/chat/behavior.ts +3 -3
  68. package/src/extra/ai/commandBar.ts +3 -3
  69. package/src/extra/ai/compose/behavior.ts +3 -3
  70. package/src/extra/ai/transports/shared.ts +8 -2
  71. package/src/extra/behaviors.ts +21 -0
  72. package/src/extra/devGraph.ts +111 -0
  73. package/src/extra/devMcp.ts +1 -1
  74. package/src/extra/devtools.ts +18 -0
  75. package/src/extra/diagnostics/checks/fetch.ts +21 -1
  76. package/src/extra/diagnostics/checks/forms.ts +4 -3
  77. package/src/extra/diagnostics/checks/index.ts +2 -1
  78. package/src/extra/diagnostics/checks/inspect.ts +68 -6
  79. package/src/extra/diagnostics/checks/next.ts +3 -0
  80. package/src/extra/diagnostics/checks/public.d.ts +133 -12
  81. package/src/extra/diagnostics/checks/timingAudit.ts +47 -0
  82. package/src/extra/diagnostics/codes.ts +19 -0
  83. package/src/extra/fetchDriver.ts +112 -7
  84. package/src/extra/fetchEventSource.ts +113 -0
  85. package/src/extra/form.ts +68 -26
  86. package/src/extra/formHelpers.ts +3 -1
  87. package/src/extra/lens.ts +3 -2
  88. package/src/extra/queryCache.ts +4 -3
  89. package/src/extra/socketDriver.ts +10 -4
  90. package/src/extra/testing.ts +68 -11
  91. package/src/index.d.ts +199 -30
  92. package/src/index.ts +1 -0
  93. package/src/shared.ts +3 -0
  94. package/src/ui/dialog.ts +3 -2
  95. package/src/ui/tooltips.ts +92 -0
  96. package/src/ui.d.ts +35 -0
  97. package/src/ui.ts +1 -0
  98. package/src/vike/onRenderClient.ts +44 -46
  99. package/src/vike/onRenderHtml.ts +5 -26
  100. package/src/vike/shell.ts +33 -0
  101. package/src/vite/graph.ts +255 -0
  102. package/src/vite/mcp.ts +3 -3
  103. package/src/vite/plugin.d.ts +19 -0
  104. package/src/vite/plugin.ts +144 -25
@@ -0,0 +1,314 @@
1
+ <!-- Generated from docs/src/content/docs/guide by scripts/copy-guides.mjs; online: https://sygnal.js.org/guide/ -->
2
+ # Migrating to 7.0
3
+
4
+ Sygnal 7.0 has two breaking changes:
5
+
6
+ 1. **Reducer timing.** The output sinks of an action (EFFECT, EVENTS, PARENT, HTTP, ...) get the
7
+ state after the action's STATE update. In 6.x every reducer of an action got the state from
8
+ before it.
9
+ 2. **Binding lifetimes.** When a child's `state` binding points at a different record, Sygnal
10
+ replaces the child with a new instance. In 6.x the child kept the record it was mounted with
11
+ until its key changed.
12
+
13
+ Nothing else in the 6.x API changes. The [timing audit](#step-by-step) points at the
14
+ reducers to check. There is no switch for the old timing: an app moves to 7.0 as a whole. If you
15
+ are not ready, [stay on 6.x](#not-ready-yet).
16
+
17
+ ## Reducer timing
18
+
19
+ ### What changed
20
+
21
+ In every model entry, STATE runs first, whatever the order of the keys. Every other sink
22
+ (EFFECT, EVENTS, PARENT, ELEMENT, HTTP and any other driver) then gets the committed state,
23
+ calculated fields included, as its first argument and as `props.state`. The fourth argument's
24
+ `previousState` is the state from before the action.
25
+
26
+ | Reducer | `state` in 6.x | `state` in 7.0 | `props.previousState` (7.0) |
27
+ |---|---|---|---|
28
+ | STATE | before the action | before the action | before the action |
29
+ | every other sink | before the action | after STATE | before the action |
30
+
31
+ When STATE returns `ABORT`, returns the same object or throws, the other sinks still run, with
32
+ `state === previousState`. `ABORT` means "no state change", not "cancel the action". Effects
33
+ still run synchronously inside the action, and the DOM patch follows it.
34
+
35
+ The tradeoff: payloads get simpler, because an output reads the value STATE just stored instead
36
+ of computing it a second time. A guard that decides whether the action may start reads
37
+ `previousState` instead of `state`.
38
+
39
+ ### A value computed twice
40
+
41
+ An output that computed the new value itself now applies the update twice. A counter that
42
+ tells its parent about the new count:
43
+
44
+ ```jsx
45
+ // 6.x: PARENT got the old count and added 1 itself
46
+ Counter.model = {
47
+ INC: {
48
+ STATE: (state) => ({ ...state, count: state.count + 1 }),
49
+ PARENT: (state) => ({ count: state.count + 1 }),
50
+ },
51
+ }
52
+ ```
53
+
54
+ On 7.0 that sends 2 after the first click. Read the stored value instead:
55
+
56
+ ```jsx
57
+ // 7.0: STATE has already incremented count
58
+ Counter.model = {
59
+ INC: {
60
+ STATE: (state) => ({ ...state, count: state.count + 1 }),
61
+ PARENT: (state) => ({ count: state.count }),
62
+ },
63
+ }
64
+ ```
65
+
66
+ Look for the same pattern in requests that append a message, increment a request id or build a
67
+ record alongside STATE.
68
+
69
+ ### A value STATE clears
70
+
71
+ The opposite case: STATE clears a field and the output needs what was there. In 6.x the output
72
+ read `state.draft`; on 7.0 that is the empty string. Read it from `previousState`:
73
+
74
+ ```jsx
75
+ Editor.model = {
76
+ SAVE: {
77
+ STATE: (state) => ({ ...state, draft: '' }),
78
+ HTTP: (state, data, next, { previousState }) => ({
79
+ url: '/api/drafts', method: 'POST', json: { text: previousState.draft }, ok: 'SAVED',
80
+ }),
81
+ },
82
+ }
83
+ ```
84
+
85
+ ### Deleting
86
+
87
+ A component that removes its own state (STATE returns `undefined`: a Collection item removing
88
+ itself, or a child bound with `state="draft"` or `byId()` deleting its record) has no new state
89
+ to give its outputs. They get its last state, as both `state` and `previousState`, so the 6.x
90
+ form keeps working unchanged:
91
+
92
+ ```jsx
93
+ import { event } from 'sygnal'
94
+
95
+ TodoItem.model = {
96
+ REMOVE: {
97
+ STATE: () => undefined,
98
+ EVENTS: event('TODO_REMOVED', (state) => state.id),
99
+ },
100
+ }
101
+ ```
102
+
103
+ A parent that deletes a record from its own list does get its new state, without the record.
104
+ An output that needs the deleted record finds it in `previousState`:
105
+
106
+ ```jsx
107
+ import { event } from 'sygnal'
108
+
109
+ TodoList.model = {
110
+ DELETE: {
111
+ STATE: (state, id) => ({ ...state, todos: state.todos.filter(t => t.id !== id) }),
112
+ EVENTS: event('TODO_DELETED', (state, id, next, { previousState }) =>
113
+ previousState.todos.find(t => t.id === id)),
114
+ },
115
+ }
116
+ ```
117
+
118
+ ### Loading and admission guards
119
+
120
+ A guard that decides whether an action may start must read the state from before the action.
121
+ Repeating `state.status !== 'working'` in both STATE and HTTP now blocks the first request:
122
+ STATE has already set `status` to `'working'` when HTTP runs. Keep the output guard, and test
123
+ `previousState`:
124
+
125
+ ```jsx
126
+ import { ABORT } from 'sygnal'
127
+
128
+ Worker.model = {
129
+ START: {
130
+ STATE: (state) => (state.status !== 'working' ? { ...state, status: 'working' } : ABORT),
131
+ HTTP: (state, data, next, { previousState }) =>
132
+ previousState.status !== 'working' ? { url: '/api/work', ok: 'DONE' } : ABORT,
133
+ },
134
+ DONE: (state) => ({ ...state, status: 'idle' }),
135
+ }
136
+ ```
137
+
138
+ The first START sends a request; START actions while it works send none. Don't remove the HTTP
139
+ guard: STATE returning `ABORT` does not cancel the other sinks, so without it every START would
140
+ send a request. The same applies to loading flags, toggles and any output that should run only
141
+ when STATE admitted the action. `sygnal-check` reports [SYG256](https://sygnal.js.org/reference/errors#syg256) when
142
+ it can prove that an output guard contradicts the same action's literal STATE write, as `HTTP`
143
+ testing `state.status` would here. It can't prove every case, so check these guards during the
144
+ migration even without a warning.
145
+
146
+ ### Behaviors
147
+
148
+ A behavior's model handlers follow the same contract: the first argument is the slice after
149
+ STATE, `props.state` the host's state after STATE, and `props.previousState` the host's state
150
+ from before the action. The slice from before the action is `props.previousState[key]`, with
151
+ `key` the sixth argument:
152
+
153
+ ```js
154
+ import { defineBehavior } from 'sygnal'
155
+
156
+ const drafts = defineBehavior({
157
+ initialState: { draft: '' },
158
+ model: {
159
+ SAVE: {
160
+ STATE: (slice) => ({ ...slice, draft: '' }),
161
+ HTTP: (slice, data, next, props, options, key) => ({
162
+ url: '/api/drafts',
163
+ method: 'POST',
164
+ json: { text: props.previousState[key].draft },
165
+ ok: key + '.SAVED',
166
+ }),
167
+ },
168
+ },
169
+ })
170
+ ```
171
+
172
+ Every built-in behavior (form, chat, compose, commandBar, undo, pager, selection, sortable and
173
+ the `sygnal/ui` parts) is on this contract. A host entry next to a behavior's action, such as
174
+ `'chat.DONE'`, follows the same rules.
175
+
176
+ ### Libraries for both 6 and 7
177
+
178
+ A component or behavior published for both versions reads old values as
179
+ `props.previousState ?? state`: 6.x has no `previousState`, and there `state` is the old value.
180
+ Compute new values from the old ones, as 6.x code did; that gives the same result on both:
181
+
182
+ ```jsx
183
+ Counter.model = {
184
+ INC: {
185
+ STATE: (state) => ({ ...state, count: state.count + 1 }),
186
+ PARENT: (state, data, next, props) => {
187
+ const before = props.previousState ?? state
188
+ return { count: before.count + 1 }
189
+ },
190
+ },
191
+ }
192
+ ```
193
+
194
+ ## Step by step
195
+
196
+ 1. **Upgrade** `sygnal` and `sygnal-check` together: `npm install sygnal@7 sygnal-check@latest`.
197
+
198
+ 2. **Turn on the timing audit.** It reports [SYG257](https://sygnal.js.org/reference/errors#syg257) when an output
199
+ sink reads a top-level state field that the same action's STATE changed: the reads whose
200
+ value changed with 7.0. Turn it on in tests with the `renderComponent()` option, and in the
201
+ dev server with the Vite plugin's:
202
+
203
+ ```js
204
+ const t = renderComponent(App, { timingAudit: true })
205
+ ```
206
+
207
+ ```js
208
+ // vite.config.js
209
+ import { defineConfig } from 'vite'
210
+ import sygnal from 'sygnal/vite'
211
+
212
+ export default defineConfig({
213
+ plugins: [sygnal({ diagnostics: { timingAudit: true } })],
214
+ })
215
+ ```
216
+
217
+ To turn it on for a whole test file or setup file, call `configureTimingAudit(true)` from
218
+ `'sygnal/diagnostics'`; `globalThis.__SYGNAL_TIMING_AUDIT__ = true` does the same before the
219
+ app starts. The audit is part of the dev diagnostics and adds nothing to production builds.
220
+
221
+ 3. **Run the test suite and use the app in dev.** Each report names the component, action, sink
222
+ and field, once each:
223
+
224
+ ```text
225
+ [Sygnal SYG257] Counter's 'INC' PARENT read 'count', which the same action's STATE changed:
226
+ since 7.0 it gets the new value (6.x: the old one)
227
+ ```
228
+
229
+ 4. **Decide each report.** If the output wants the new value (a payload built from what STATE
230
+ stored), keep reading `state`, and remove any update the output repeats, as in the counter
231
+ above. If it wants the old value (a cleared field, a deleted record, a guard that admits the
232
+ action), read `props.previousState.<field>`. A nested read reports its top-level field: a read
233
+ of `state.form.values` reports `form`.
234
+
235
+ 5. **Run `sygnal-check`** and fix any [SYG256](https://sygnal.js.org/reference/errors#syg256).
236
+
237
+ 6. **Turn the audit off** when the reports are resolved. It is opt-in because in code written
238
+ for 7.0, reading the new value is the intent, and the reports would be noise.
239
+
240
+ Components and behaviors from other packages count too: check that each supports Sygnal 7, or
241
+ run the audit over the screens that use them.
242
+
243
+ ## Binding lifetimes
244
+
245
+ ### What changed
246
+
247
+ In 6.x a child kept the record it was mounted with until its key changed, so a selection needed
248
+ `key={selected.id}`. In 7.0, when the record a child's `state` binding points at changes, Sygnal
249
+ disposes the old child and mounts a new one. This applies to:
250
+
251
+ - a child's `state="path"` when the path changes, and `state={byId(field, id, idKey)}` when the
252
+ field, id or idKey changes;
253
+ - a Collection's or VirtualCollection's `from`;
254
+ - a Switchable's `state`.
255
+
256
+ ```jsx
257
+ // 6.x needed key={selected.id}; 7.0 follows the selection by itself
258
+ <Workspace state={byId('projects', selected.id)} />
259
+ ```
260
+
261
+ The old child gets its `DISPOSE` action with its last state. Its pending requests, delayed
262
+ `next()` actions and subscriptions are cancelled, and every component inside it is disposed. The
263
+ new child starts from scratch: `INITIALIZE` and `BOOTSTRAP` run again and its local state
264
+ starts from its defaults. Collection items and Switchable pages of the old target are disposed
265
+ too, even when the new target has items with the same ids.
266
+
267
+ **The child's DOM is replaced.** Focus, caret position, scroll position and any text being typed
268
+ inside it are lost. Keep an input the user may be typing in while the selection changes outside
269
+ the replaced child, or keep its value in the parent's state.
270
+
271
+ ### Deleting the bound record
272
+
273
+ When the record is deleted (the `byId()` arguments stay the same but the record is gone), the
274
+ old child is disposed as well, after the deleting action finishes, so that action's outputs
275
+ still run. Its `DISPOSE` gets its last state; a `STATE` change from `DISPOSE` can't bring the
276
+ deleted record back. A record added again later with the same id gets a new child, not the old
277
+ child's pending work. Children inside a hidden Switchable page are disposed the same way; their
278
+ replacements mount when the page is shown again.
279
+
280
+ ### What doesn't replace a child
281
+
282
+ - A new `byId()` call with the same arguments on every render.
283
+ - Editing the record's fields, or reordering the array it's in.
284
+ - Switching a Switchable's active page with the same `state` target.
285
+
286
+ ### Keys
287
+
288
+ You can drop a `key` that only worked around `byId()` selection. A key still resets a child whose
289
+ target hasn't changed: change the key and the child starts over.
290
+
291
+ **A stable key no longer keeps a child across a target change.** In 6.x
292
+ `<Editor key="editor" state={byId('docs', id)} />` kept one editor while `id` changed; in 7.0 the
293
+ editor is replaced. To keep something across the change (an open panel, a scroll position, a
294
+ half-written comment), lift it into the parent's state.
295
+
296
+ ### Custom lenses
297
+
298
+ Sygnal can't tell whether a new `{ get, set }` lens object points at a different record, so a
299
+ child with a custom lens keeps the binding it was mounted with. Give it a key tied to the
300
+ selected record, and render it only while that record exists (or include availability in the
301
+ key):
302
+
303
+ ```jsx
304
+ {doc && <Editor key={doc.id} state={docLens(doc.id)} />}
305
+ ```
306
+
307
+ See [Changing the selected record](https://sygnal.js.org/guide/state/#changing-the-selected-record).
308
+
309
+ ## Not ready yet?
310
+
311
+ Stay on 6.x (`npm install sygnal@^6`) until the test suite passes on 7.0 with the audit's reports
312
+ resolved. There is no per-component switch for the old timing or the old lifetimes. The current
313
+ `sygnal-check` supports both: on 6.x it still reports
314
+ [SYG454](https://sygnal.js.org/reference/errors/#syg454) for a `byId()` selection without a key.
@@ -112,7 +112,7 @@ Article.model = {
112
112
  },
113
113
  SUMMARIZE: {
114
114
  STATE: (state) => (state.status === 'busy' ? ABORT : { ...state, summary: '', status: 'busy' }),
115
- LLM: (state) => (state.status === 'busy' ? ABORT : summarize(state.text)),
115
+ LLM: (state, data, next, { previousState }) => (previousState.status === 'busy' ? ABORT : summarize(state.text)),
116
116
  },
117
117
  SUMMARY: (state, { text }) => ({ ...state, summary: text }),
118
118
  SUMMARIZED: (state, { text }) => ({ ...state, summary: text, status: 'ready' }),
@@ -121,6 +121,7 @@ Article.model = {
121
121
  }
122
122
  ```
123
123
 
124
+ - **Guard against the previous state.** `STATE` marks the article busy before the `LLM` reducer runs. Read `previousState.status` to decide whether a summary was already running; use the updated `state` to build the request.
124
125
  - **The summary streams in** through `SUMMARY`, and `SUMMARIZED` ends the busy state.
125
126
  - **Degrade, don't hide.** The excerpt is computed in the view from the text, so it needs no model. Where a summary isn't essential, an excerpt is an honest fallback.
126
127
  - **Keep prompts short and in `instructions`.** The on-device model is small and has a small context window. Long articles need splitting, or a hosted model.
@@ -217,6 +217,6 @@ Measured with Vite, minified and gzipped, Sygnal not included: **15.5 KB** for i
217
217
 
218
218
  - **Don't call `i18n.changeLanguage()`.** It changes a global inside i18next that Sygnal doesn't see, so nothing re-renders. Keep the locale in state and use `getFixedT`.
219
219
  - **Keep `t` stable per locale.** `getFixedT` returns a new function on each call. Called directly in `.context`, it gives the context a new value whenever the root's state changes, not only when the locale does; `translator` hands out the same function until the locale changes.
220
- - **Load translations before switching.** With translations in separate files, fetch the new language first (a `makeFetchDriver` request on the `LOCALE` action), add it with `i18n.addResourceBundle(locale, 'translation', data)` in an `EFFECT` of the reply's action (a reducer only returns state), and only then put the new locale in state (the same action's `STATE` entry: the `EFFECT` runs synchronously and the view renders after the whole action, so the strings are there). A locale whose strings aren't loaded shows the keys.
220
+ - **Load translations before switching.** With translations in separate files, fetch the new language first (a `makeFetchDriver` request on the `LOCALE` action), add it with `i18n.addResourceBundle(locale, 'translation', data)` in an `EFFECT` of the reply's action (a reducer only returns state), and put the new locale in the reply action's `STATE` entry. In Sygnal 7, `STATE` commits first, then `EFFECT` adds the loaded strings synchronously before the action finishes and the view renders. A locale whose strings aren't loaded shows the keys.
221
221
  - **Server rendering.** Take the locale from the request (the URL or `Accept-Language`) and pass it in the server's state. A locale saved only in `localStorage` reaches a server-rendered page after hydration, as a `RESTORE` action: the page shows the server's language first.
222
222
  - **Missing keys show the key.** `t('cart.titel')` renders "cart.titel". Set `saveMissing` with a `missingKeyHandler` in development to log them.
@@ -120,15 +120,16 @@ Editor.model = {
120
120
  files: state.files.filter((f) => f.id !== id),
121
121
  tabs: { ...state.tabs, selected: state.tabs.selected === id ? (neighbour(state.files, id)?.id ?? null) : state.tabs.selected },
122
122
  }),
123
- ELEMENT: (state, id) => {
124
- const next = neighbour(state.files, id)
125
- return next ? { focus: `.tab[data-value="${next.id}"]` } : ABORT
123
+ ELEMENT: (state, id, next, { previousState }) => {
124
+ const nextFile = neighbour(previousState.files, id)
125
+ return nextFile ? { focus: `.tab[data-value="${nextFile.id}"]` } : ABORT
126
126
  },
127
127
  },
128
128
  }
129
129
  ```
130
130
 
131
- (`ELEMENT` gets the state before the action, as every non-`STATE` entry does.)
131
+ `ELEMENT` gets the state after `STATE` removed the file, so it finds the closed file's neighbor
132
+ in `previousState.files`. Closing the last tab returns `ABORT`: there is no neighbor to focus.
132
133
 
133
134
  ## Styling
134
135
 
@@ -49,6 +49,30 @@ The delays need the timer driver in `run()`. Tests don't: [fake timers](../timer
49
49
 
50
50
  `state.saveTip` is `{ open, pending, hover, focus }`: `pending` is `'show'` or `'hide'` while a delay runs; `hover` and `focus` say where the pointer and the focus are. The actions are `saveTip.ENTER` and `saveTip.LEAVE` (data: `'hover'` or `'focus'`), `saveTip.SHOW`, `saveTip.HIDE` (the timers), `saveTip.ESCAPE` and `saveTip.TOGGLED`. The timers are declared under the names `saveTip.show` and `saveTip.hide`, next to the component's own [`timers`](../timers.md).
51
51
 
52
+ ## Many tooltips: `tooltips()`
53
+
54
+ `tooltip()` keeps a state slice and two timers for each trigger, which is a lot for a paragraph of glossary terms or a toolbar of icon buttons. `tooltips()` serves every trigger of the component with one slice and one pair of timers. Each trigger names its tip with `aria-describedby`, and one tip shows at a time:
55
+
56
+ ```jsx live
57
+ import { tooltips } from 'sygnal/ui'
58
+
59
+ const TERMS = { MVI: 'Model-View-Intent: the pattern every component follows', driver: 'The code that talks to the outside world', sink: 'What a component sends to a driver' }
60
+
61
+ function Glossary({ uid }) {
62
+ const term = (name, text) => (
63
+ <>
64
+ <button className="term" aria-describedby={uid('tip-' + name)} style={{ anchorName: '--' + uid(name) }}>{text}</button>
65
+ <span className="tip" id={uid('tip-' + name)} role="tooltip" popover="manual" style={{ positionAnchor: '--' + uid(name) }}>{TERMS[name]}</span>
66
+ </>
67
+ )
68
+ return <p>Every component is {term('MVI', 'MVI')}: its intent reads events, a {term('driver', 'driver')} performs the effects, and each {term('sink', 'sink')} carries a request.</p>
69
+ }
70
+
71
+ Glossary.uses = { tips: tooltips({ trigger: '.term', tip: '.tip' }) }
72
+ ```
73
+
74
+ The options are those of `tooltip()`, with `trigger` and `tip` matching every trigger and every tip. Moving from one term to another while a tip is shown switches at once, without a second delay. `state.tips` is `{ id, open, pending, hover, focus }`: `id` is the tip of the trigger last entered and `open` the id of the tip shown. The actions carry the tip's id: `tips.ENTER` and `tips.LEAVE` (`{ via: 'hover' | 'focus', id }`), `tips.TOGGLED` (`{ id, open }`), plus `tips.SHOW`, `tips.HIDE` and `tips.ESCAPE`.
75
+
52
76
  ## Positioning
53
77
 
54
78
  The trigger gets an anchor name and the tip refers to it. The anchor name must be unique on the page, so build it from `uid()` in the view, as above. The rest is CSS: