@apollovisionlabs/guide-core 0.2.0 → 0.3.1

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/README.md CHANGED
@@ -2,9 +2,11 @@
2
2
 
3
3
  `guide` is a headless React library for building in-app product tours. `@apollovisionlabs/guide-core` owns the
4
4
  state machine, target resolution, routing, persistence and accessibility concerns, and exposes
5
- them as hooks with no rendering opinion. `@apollovisionlabs/guide-mui` consumes those hooks to render a tour with
6
- [MUI](https://mui.com) components (a spotlight overlay and a popover), so you get a complete tour
7
- out of the box, or you can render your own UI on top of `@apollovisionlabs/guide-core` directly.
5
+ them as hooks with no rendering opinion. Two packages render on top of those hooks out of the box:
6
+ `@apollovisionlabs/guide-mui` with [MUI](https://mui.com) components (a spotlight overlay and a
7
+ popover), and `@apollovisionlabs/guide-unstyled` with the same parts as plain DOM elements and an
8
+ optional stylesheet. You can also render your own UI on top of `@apollovisionlabs/guide-core`
9
+ directly.
8
10
 
9
11
  This repository includes a runnable demo. From the repo root, run `pnpm --filter demo dev` and
10
12
  open `http://localhost:5173` to try a three-page tour end to end.
@@ -61,7 +63,7 @@ missing-target policy below from ever firing.
61
63
 
62
64
  | Prop | Type | Default | Description |
63
65
  | --- | --- | --- | --- |
64
- | `labels` | `Partial<{ next, previous, finish, close }>` | `{ next: 'Next', previous: 'Back', finish: 'Finish', close: 'Close' }` | The popover's button labels. Defaults are English; override any subset. See "Translations". |
66
+ | `labels` | `Partial<{ next, previous, finish, close, awaitingAction }>` | `{ next: 'Next', previous: 'Back', finish: 'Finish', close: 'Close', awaitingAction: 'Click the highlighted element to continue.' }` | The popover's button labels. Defaults are English; override any subset. See "Translations". |
65
67
  | `zIndex` | `number` | `theme.zIndex.modal` | Stacking level of the spotlight; the popover sits one above it. |
66
68
  | `padding` | `number` | `8` | Margin, in pixels, between the highlighted element and the edge of the spotlight hole. |
67
69
  | `radius` | `number` | `8` | Corner radius, in pixels, of the spotlight hole. |
@@ -113,8 +115,10 @@ interface GuideStorage {
113
115
  ```
114
116
 
115
117
  `GuideProvider` reads and writes tour progress under `tour:<id>`. `ChecklistProvider` reads and
116
- writes checklist progress under `checklist:<id>`. See [ADR 0016](docs/adr/0016-one-storage-contract-for-tours-and-checklists.md)
117
- for why the two share one interface.
118
+ writes checklist progress under `checklist:<id>`. `HotspotProvider` reads and writes which
119
+ hotspots have been opened under the single key `hotspots:seen`. See
120
+ [ADR 0016](docs/adr/0016-one-storage-contract-for-tours-and-checklists.md) for why they share one
121
+ interface.
118
122
 
119
123
  `@apollovisionlabs/guide-core` ships `createMemoryStorage()` for tests and `createBrowserStorage(namespace?)` for
120
124
  `localStorage`. Neither talks to a server. An implementation backed by your own API looks like
@@ -147,7 +151,8 @@ tour advances or completes. `ChecklistProvider` reads once on mount and writes w
147
151
  ticked, completed or the checklist is dismissed.
148
152
 
149
153
  A value read back from storage is validated before it is trusted (`isTourProgress`,
150
- `isChecklistProgress`, both exported from `@apollovisionlabs/guide-core`): a value that does not
154
+ `isChecklistProgress`, `isHotspotsProgress`, all exported from `@apollovisionlabs/guide-core`): a
155
+ value that does not
151
156
  match the expected shape, from a hand-edited store or an older version of this library, is treated
152
157
  the same as nothing stored, rather than crashing or resuming into a broken state.
153
158
 
@@ -170,8 +175,8 @@ and nothing English remains:
170
175
 
171
176
  ## Events
172
177
 
173
- `onEvent` on `GuideProvider` and on `ChecklistProvider` each receive their own lifecycle events, as
174
- a discriminated union of `GuideEvent`:
178
+ `onEvent` on `GuideProvider`, `ChecklistProvider` and `HotspotProvider` each receive their own
179
+ lifecycle events, as a discriminated union of `GuideEvent`:
175
180
 
176
181
  | Event | Payload | When |
177
182
  | --- | --- | --- |
@@ -183,6 +188,8 @@ a discriminated union of `GuideEvent`:
183
188
  | `checklist:item-complete` | `{ checklistId, itemId }` | An item is completed, by finishing its linked tour or by a manual tick. Not emitted for an item already complete. |
184
189
  | `checklist:complete` | `{ checklistId }` | The last incomplete item in a checklist is completed. Fires on every transition into the complete state, so unticking an item and reticking it emits a second time. Deduplicate downstream if you count completions. |
185
190
  | `checklist:dismiss` | `{ checklistId }` | `dismiss()` is called. |
191
+ | `hotspot:show` | `{ hotspotId }` | A hotspot's marker is actually drawn on screen. Emitted once per hotspot per mount. |
192
+ | `hotspot:open` | `{ hotspotId }` | The hotspot's bubble is opened, which also marks it seen. Not emitted again for a bubble that is already open. |
186
193
 
187
194
  ## Accessibility
188
195
 
@@ -206,6 +213,44 @@ happens: `'skip'` moves to the next step, `'error'` stops the tour, and `'wait'`
206
213
  pauses and resumes automatically if the target appears later, for instance after a slow async
207
214
  render.
208
215
 
216
+ ## Advancing on an action
217
+
218
+ A step can declare `advanceOn: 'click'` instead of ending on the popover's button:
219
+
220
+ ```ts
221
+ {
222
+ target: 'project.share',
223
+ title: 'Share it',
224
+ body: 'Click the button yourself, this step is interactive.',
225
+ advanceOn: 'click',
226
+ }
227
+ ```
228
+
229
+ The step advances when the user clicks the target, not the popover. `advanceOn` implies
230
+ `interactive`: a step that waits for a click has to let the click through, so `GuideProvider`
231
+ derives both `interactive` and `awaitsAction` on `ActiveStep` from `advanceOn`, rather than
232
+ requiring both to be set by hand. Read `activeStep.interactive` / `activeStep.awaitsAction`, not
233
+ `step.interactive`, which is left `undefined` on a step that only sets `advanceOn`. See
234
+ [ADR 0017](docs/adr/0017-advancing-on-an-action-implies-an-interactive-step.md).
235
+
236
+ `@apollovisionlabs/guide-mui`'s popover reflects `awaitsAction`: no primary button, and the
237
+ `awaitingAction` label in its place (see the `labels` row above). `ArrowRight` is ignored while a
238
+ step awaits its action, since letting it through would be a way around the very thing the step is
239
+ asking for; `Escape` and `ArrowLeft` still work.
240
+
241
+ The click listener is attached, in the bubble phase, to the element resolved when the step
242
+ opened, without `preventDefault` or `stopPropagation`, so your own click handler on the target
243
+ still runs.
244
+
245
+ If your application replaces that DOM node afterward, for instance by re-rendering a list, the
246
+ listener goes with it and the step stops advancing. Nothing notices: the target was found once,
247
+ so the timeout was already cleared, no `target:missing` is emitted and no `wait`, `skip` or
248
+ `error` policy runs. The tour simply sits on that step. The consequence is specific to
249
+ `advanceOn`, even though the cause is not: an `advanceOn` step offers no primary button and
250
+ ignores `ArrowRight`, so a replaced node leaves the tour with `Escape` as its only exit. If the
251
+ element a step points at can be re-created under it, either give it a stable target that survives
252
+ the re-render or use an ordinary step with a Next button.
253
+
209
254
  ## Checklist
210
255
 
211
256
  A checklist is a separate feature from the tour: a fixed list of items, each completed by
@@ -266,11 +311,16 @@ finishing a tour whose item is already ticked, does nothing and emits no event.
266
311
 
267
312
  ### `useChecklist(checklistId)`
268
313
 
269
- Returns `{ items, completedCount, total, isComplete, dismissed, activate, toggle, complete, dismiss, reset }`.
314
+ Returns `{ items, completedCount, total, isComplete, dismissed, restored, activate, toggle, complete, dismiss, reset }`.
270
315
  `items` is `ResolvedChecklistItem[]`: `{ id, title, body, completed, tourId?, href? }`, with
271
316
  `title` / `body` already resolved through `translate`. `activate(itemId)` runs an item's default
272
317
  action (start its tour, navigate to its `href`, or toggle it if it has neither); `toggle` and
273
318
  `complete` change completion directly; `dismiss()` and `reset()` act on the whole checklist.
319
+ `restored` is whether this checklist's own initial read from storage has settled: `true`
320
+ immediately with no `storage` prop (there is nothing to wait for), and `true` once this
321
+ checklist's own read has resolved or rejected. It settles independently per checklist, so a
322
+ `ChecklistProvider` holding several checklists never lets a slow or hung read for one hold
323
+ another one's `restored` false; each checklist's read runs concurrently with the others.
274
324
 
275
325
  ### `Checklist` and `ChecklistLauncher` (`@apollovisionlabs/guide-mui`)
276
326
 
@@ -288,12 +338,147 @@ import { Checklist, ChecklistLauncher } from '@apollovisionlabs/guide-mui'
288
338
  <ChecklistLauncher checklistId="onboarding" title="Get started" placement="bottom-right" />
289
339
  ```
290
340
 
341
+ With a `storage` prop configured on `ChecklistProvider`, both `Checklist` and `ChecklistLauncher`
342
+ wait for their own checklist's restore to settle (`useChecklist(checklistId).restored`) before
343
+ drawing anything, rather than rendering their empty initial state (nothing completed, not
344
+ dismissed) for one paint. The tradeoff: with a slow storage backend, a checklist now appears later
345
+ than it used to, instead of appearing at once and then jumping. A slow or broken read for one
346
+ checklist never holds a different checklist back; each restores on its own.
347
+
348
+ ## Hotspots
349
+
350
+ A hotspot marks one element outside any tour: a small marker that opens a short explanation, and
351
+ optionally a button that starts a tour. Unlike a tour step, a hotspot has no route and no order;
352
+ it just sits at its target until opened. `HotspotProvider` nests inside `GuideProvider`, the same
353
+ way `ChecklistProvider` does, so a hotspot naming a `tourId` can start it:
354
+
355
+ ```tsx
356
+ import {
357
+ GuideProvider,
358
+ HotspotProvider,
359
+ type Hotspot,
360
+ type Tour,
361
+ } from '@apollovisionlabs/guide-core'
362
+ import { GuideTour, Hotspots } from '@apollovisionlabs/guide-mui'
363
+
364
+ const welcomeTour: Tour = {
365
+ id: 'welcome',
366
+ steps: [
367
+ { target: 'projects.create', title: 'Create a project', body: 'Start here.' },
368
+ { target: 'project.share', title: 'Share it', body: 'Send a link to your team.' },
369
+ ],
370
+ }
371
+
372
+ const hotspots: Hotspot[] = [
373
+ {
374
+ id: 'create',
375
+ target: 'projects.create',
376
+ title: 'Start a project',
377
+ body: 'Everything else in here hangs off a project.',
378
+ },
379
+ {
380
+ id: 'share',
381
+ target: 'project.share',
382
+ title: 'Share a project',
383
+ body: 'Send a link to anyone on your team.',
384
+ // Named tours must exist on the GuideProvider above, or starting one warns and does nothing.
385
+ tourId: 'welcome',
386
+ },
387
+ ]
388
+
389
+ function App() {
390
+ return (
391
+ <GuideProvider tours={[welcomeTour]}>
392
+ <HotspotProvider hotspots={hotspots}>
393
+ <YourApplication />
394
+ <GuideTour />
395
+ <Hotspots />
396
+ </HotspotProvider>
397
+ </GuideProvider>
398
+ )
399
+ }
400
+ ```
401
+
402
+ Without a `GuideProvider` above it, starting a hotspot's tour warns once in the console and does
403
+ nothing.
404
+
405
+ ### `HotspotProvider` props
406
+
407
+ | Prop | Type | Default | Description |
408
+ | --- | --- | --- | --- |
409
+ | `hotspots` | `Hotspot[]` | none | The hotspots to render. Ids must be unique. |
410
+ | `children` | `ReactNode` | none | Your application. |
411
+ | `storage` | `GuideStorage` | none | Persists which hotspots have been opened, under `hotspots:seen`. See "Persistence". |
412
+ | `translate` | `(key: string) => string` | none | Resolves `titleKey` / `bodyKey` on hotspots. See "Translations". |
413
+ | `onEvent` | `(event: GuideEvent) => void` | none | Called for `hotspot:show` and `hotspot:open`. See "Events". |
414
+
415
+ ### `useHotspots()`
416
+
417
+ Returns `{ hotspots, restored, open, startTour, reset, notifyShown }`.
418
+
419
+ - `hotspots` is `ResolvedHotspot[]`: every hotspot, each carrying its own `seen`, with `title` /
420
+ `body` already resolved through `translate`. It lists the seen ones too, rather than only the
421
+ unseen ones, because a renderer that keeps a marker mounted while its own bubble closes needs
422
+ the seen one as well; filtering to unseen-only is one line at the call site.
423
+ - `restored` is whether the initial read from storage has settled: `true` immediately with no
424
+ `storage` prop (there is nothing to wait for), and `true` once the read resolves or rejects.
425
+ Wait for it before drawing any marker, or a hotspot already seen in storage can flash on screen
426
+ once before the restore lands.
427
+ - `open(hotspotId)` marks a hotspot seen and emits `hotspot:open`.
428
+ - `startTour(hotspotId)` starts the tour named by the hotspot's `tourId`, if it has one.
429
+ - `reset()` clears the seen state for every hotspot.
430
+ - `notifyShown(hotspotId)` is for renderers: call it once a marker is actually drawn on screen, so
431
+ `hotspot:show` fires once per hotspot per mount. `@apollovisionlabs/guide-mui`'s `Hotspots`
432
+ already calls it.
433
+
434
+ ### `Hotspots` (`@apollovisionlabs/guide-mui`)
435
+
436
+ Renders a marker at each unseen hotspot's target; clicking it opens a bubble with the hotspot's
437
+ title, body, and, when it names a `tourId`, a button that starts that tour.
438
+
439
+ | Prop | Type | Default | Description |
440
+ | --- | --- | --- | --- |
441
+ | `labels` | `Partial<{ marker, startTour, close }>` | see below | Wording. `marker` is a function of the hotspot's title, not a fixed string, because word order around a name varies by language. |
442
+ | `placement` | `Placement` | `'bottom'` | Where the bubble opens relative to the marker. Overridable per hotspot through `Hotspot.placement`. |
443
+ | `zIndex` | `number` | `theme.zIndex.drawer + 1` | Stacking level of the marker; the bubble sits one above it. |
444
+
445
+ Default labels: `` { marker: (title) => `Show what is new: ${title}`, startTour: 'Show me', close: 'Close' } ``.
446
+
447
+ No marker is drawn while a tour is running or paused. A hotspot is an ambient hint and must not
448
+ compete with a guided flow the user is already in: a marker over the element a step points at
449
+ would take the click meant for that step, and one over a non-interactive step would be drawn
450
+ bright and pulsing yet inert behind the spotlight. The markers come back when the tour ends,
451
+ unchanged: this suppresses them, it does not mark them seen. `Hotspots` reads the tour state
452
+ through context and tolerates its absence, so hotspots work with no `GuideProvider` in the tree.
453
+
454
+ `paused` counts, because a paused tour is waiting for its target rather than finished. Note what
455
+ that implies at the edge: a tour paused on a target that never appears, the default `wait`
456
+ policy, draws nothing itself and now hides every hotspot too, for as long as it stays paused,
457
+ with `Escape` as the only way out and nothing on screen to suggest it. If your steps point at
458
+ targets that may never mount, prefer the `skip` or `error` missing-target policy over `wait`.
459
+
460
+ The default `zIndex` sits below `theme.zIndex.modal`, the level a running tour's spotlight uses,
461
+ so a hotspot whose target lives inside your own modal dialog is covered by it. Raise `zIndex` on
462
+ `Hotspots` to bring the marker above that dialog.
463
+
464
+ A marker is drawn only for a target that has actual size on screen. An element that is in the DOM
465
+ but not rendered, `display: none` for instance, measures an all-zero rectangle; that draws no
466
+ marker and emits no `hotspot:show`, so a hotspot cannot be retired before the user has seen what
467
+ it explains.
468
+
469
+ Clicking a marker whose bubble is already open closes the bubble, and emits no second
470
+ `hotspot:open`.
471
+
472
+ With a `storage` prop configured on `HotspotProvider`, `Hotspots` waits for the initial restore to
473
+ settle (`useHotspots().restored`) before drawing any marker.
474
+
291
475
  ## Compatibility
292
476
 
293
477
  | | Supported |
294
478
  | --- | --- |
295
479
  | React | 19 |
296
480
  | MUI (`@apollovisionlabs/guide-mui` only) | 7, 9 |
481
+ | `@apollovisionlabs/guide-unstyled` | no UI toolkit; peers on `react` and `react-dom` only |
297
482
  | Rendering | ESM and CommonJS, with `"use client"` for Next's App Router |
298
483
 
299
484
  ## Prior art
@@ -301,8 +486,9 @@ import { Checklist, ChecklistLauncher } from '@apollovisionlabs/guide-mui'
301
486
  The spotlight-and-popover approach is inspired by [driver.js](https://driverjs.com) (MIT), as are
302
487
  [react-joyride](https://github.com/gilbarbara/react-joyride) (MIT) and
303
488
  [reactour](https://github.com/elrumordelaluz/reactour) (MIT). `guide` differs from all three mainly
304
- in splitting the state machine (`@apollovisionlabs/guide-core`) from rendering (`@apollovisionlabs/guide-mui`), so the logic can be
305
- reused with a different design system. Contributors must also read the licence discipline in
489
+ in splitting the state machine (`@apollovisionlabs/guide-core`) from rendering, which ships as two
490
+ packages, `@apollovisionlabs/guide-mui` and `@apollovisionlabs/guide-unstyled`, so the same logic
491
+ renders through a design system or through plain DOM. Contributors must also read the licence discipline in
306
492
  `CONTRIBUTING.md` before looking at any other tour library.
307
493
 
308
494
  ## Documentation