@apollovisionlabs/guide-core 0.2.0 → 0.3.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/README.md +189 -7
- package/dist/index.cjs +259 -24
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +112 -2
- package/dist/index.d.ts +112 -2
- package/dist/index.mjs +263 -24
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -61,7 +61,7 @@ missing-target policy below from ever firing.
|
|
|
61
61
|
|
|
62
62
|
| Prop | Type | Default | Description |
|
|
63
63
|
| --- | --- | --- | --- |
|
|
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". |
|
|
64
|
+
| `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
65
|
| `zIndex` | `number` | `theme.zIndex.modal` | Stacking level of the spotlight; the popover sits one above it. |
|
|
66
66
|
| `padding` | `number` | `8` | Margin, in pixels, between the highlighted element and the edge of the spotlight hole. |
|
|
67
67
|
| `radius` | `number` | `8` | Corner radius, in pixels, of the spotlight hole. |
|
|
@@ -113,8 +113,10 @@ interface GuideStorage {
|
|
|
113
113
|
```
|
|
114
114
|
|
|
115
115
|
`GuideProvider` reads and writes tour progress under `tour:<id>`. `ChecklistProvider` reads and
|
|
116
|
-
writes checklist progress under `checklist:<id>`.
|
|
117
|
-
|
|
116
|
+
writes checklist progress under `checklist:<id>`. `HotspotProvider` reads and writes which
|
|
117
|
+
hotspots have been opened under the single key `hotspots:seen`. See
|
|
118
|
+
[ADR 0016](docs/adr/0016-one-storage-contract-for-tours-and-checklists.md) for why they share one
|
|
119
|
+
interface.
|
|
118
120
|
|
|
119
121
|
`@apollovisionlabs/guide-core` ships `createMemoryStorage()` for tests and `createBrowserStorage(namespace?)` for
|
|
120
122
|
`localStorage`. Neither talks to a server. An implementation backed by your own API looks like
|
|
@@ -147,7 +149,8 @@ tour advances or completes. `ChecklistProvider` reads once on mount and writes w
|
|
|
147
149
|
ticked, completed or the checklist is dismissed.
|
|
148
150
|
|
|
149
151
|
A value read back from storage is validated before it is trusted (`isTourProgress`,
|
|
150
|
-
`isChecklistProgress`,
|
|
152
|
+
`isChecklistProgress`, `isHotspotsProgress`, all exported from `@apollovisionlabs/guide-core`): a
|
|
153
|
+
value that does not
|
|
151
154
|
match the expected shape, from a hand-edited store or an older version of this library, is treated
|
|
152
155
|
the same as nothing stored, rather than crashing or resuming into a broken state.
|
|
153
156
|
|
|
@@ -170,8 +173,8 @@ and nothing English remains:
|
|
|
170
173
|
|
|
171
174
|
## Events
|
|
172
175
|
|
|
173
|
-
`onEvent` on `GuideProvider` and
|
|
174
|
-
a discriminated union of `GuideEvent`:
|
|
176
|
+
`onEvent` on `GuideProvider`, `ChecklistProvider` and `HotspotProvider` each receive their own
|
|
177
|
+
lifecycle events, as a discriminated union of `GuideEvent`:
|
|
175
178
|
|
|
176
179
|
| Event | Payload | When |
|
|
177
180
|
| --- | --- | --- |
|
|
@@ -183,6 +186,8 @@ a discriminated union of `GuideEvent`:
|
|
|
183
186
|
| `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
187
|
| `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
188
|
| `checklist:dismiss` | `{ checklistId }` | `dismiss()` is called. |
|
|
189
|
+
| `hotspot:show` | `{ hotspotId }` | A hotspot's marker is actually drawn on screen. Emitted once per hotspot per mount. |
|
|
190
|
+
| `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
191
|
|
|
187
192
|
## Accessibility
|
|
188
193
|
|
|
@@ -206,6 +211,44 @@ happens: `'skip'` moves to the next step, `'error'` stops the tour, and `'wait'`
|
|
|
206
211
|
pauses and resumes automatically if the target appears later, for instance after a slow async
|
|
207
212
|
render.
|
|
208
213
|
|
|
214
|
+
## Advancing on an action
|
|
215
|
+
|
|
216
|
+
A step can declare `advanceOn: 'click'` instead of ending on the popover's button:
|
|
217
|
+
|
|
218
|
+
```ts
|
|
219
|
+
{
|
|
220
|
+
target: 'project.share',
|
|
221
|
+
title: 'Share it',
|
|
222
|
+
body: 'Click the button yourself, this step is interactive.',
|
|
223
|
+
advanceOn: 'click',
|
|
224
|
+
}
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
The step advances when the user clicks the target, not the popover. `advanceOn` implies
|
|
228
|
+
`interactive`: a step that waits for a click has to let the click through, so `GuideProvider`
|
|
229
|
+
derives both `interactive` and `awaitsAction` on `ActiveStep` from `advanceOn`, rather than
|
|
230
|
+
requiring both to be set by hand. Read `activeStep.interactive` / `activeStep.awaitsAction`, not
|
|
231
|
+
`step.interactive`, which is left `undefined` on a step that only sets `advanceOn`. See
|
|
232
|
+
[ADR 0017](docs/adr/0017-advancing-on-an-action-implies-an-interactive-step.md).
|
|
233
|
+
|
|
234
|
+
`@apollovisionlabs/guide-mui`'s popover reflects `awaitsAction`: no primary button, and the
|
|
235
|
+
`awaitingAction` label in its place (see the `labels` row above). `ArrowRight` is ignored while a
|
|
236
|
+
step awaits its action, since letting it through would be a way around the very thing the step is
|
|
237
|
+
asking for; `Escape` and `ArrowLeft` still work.
|
|
238
|
+
|
|
239
|
+
The click listener is attached, in the bubble phase, to the element resolved when the step
|
|
240
|
+
opened, without `preventDefault` or `stopPropagation`, so your own click handler on the target
|
|
241
|
+
still runs.
|
|
242
|
+
|
|
243
|
+
If your application replaces that DOM node afterward, for instance by re-rendering a list, the
|
|
244
|
+
listener goes with it and the step stops advancing. Nothing notices: the target was found once,
|
|
245
|
+
so the timeout was already cleared, no `target:missing` is emitted and no `wait`, `skip` or
|
|
246
|
+
`error` policy runs. The tour simply sits on that step. The consequence is specific to
|
|
247
|
+
`advanceOn`, even though the cause is not: an `advanceOn` step offers no primary button and
|
|
248
|
+
ignores `ArrowRight`, so a replaced node leaves the tour with `Escape` as its only exit. If the
|
|
249
|
+
element a step points at can be re-created under it, either give it a stable target that survives
|
|
250
|
+
the re-render or use an ordinary step with a Next button.
|
|
251
|
+
|
|
209
252
|
## Checklist
|
|
210
253
|
|
|
211
254
|
A checklist is a separate feature from the tour: a fixed list of items, each completed by
|
|
@@ -266,11 +309,16 @@ finishing a tour whose item is already ticked, does nothing and emits no event.
|
|
|
266
309
|
|
|
267
310
|
### `useChecklist(checklistId)`
|
|
268
311
|
|
|
269
|
-
Returns `{ items, completedCount, total, isComplete, dismissed, activate, toggle, complete, dismiss, reset }`.
|
|
312
|
+
Returns `{ items, completedCount, total, isComplete, dismissed, restored, activate, toggle, complete, dismiss, reset }`.
|
|
270
313
|
`items` is `ResolvedChecklistItem[]`: `{ id, title, body, completed, tourId?, href? }`, with
|
|
271
314
|
`title` / `body` already resolved through `translate`. `activate(itemId)` runs an item's default
|
|
272
315
|
action (start its tour, navigate to its `href`, or toggle it if it has neither); `toggle` and
|
|
273
316
|
`complete` change completion directly; `dismiss()` and `reset()` act on the whole checklist.
|
|
317
|
+
`restored` is whether this checklist's own initial read from storage has settled: `true`
|
|
318
|
+
immediately with no `storage` prop (there is nothing to wait for), and `true` once this
|
|
319
|
+
checklist's own read has resolved or rejected. It settles independently per checklist, so a
|
|
320
|
+
`ChecklistProvider` holding several checklists never lets a slow or hung read for one hold
|
|
321
|
+
another one's `restored` false; each checklist's read runs concurrently with the others.
|
|
274
322
|
|
|
275
323
|
### `Checklist` and `ChecklistLauncher` (`@apollovisionlabs/guide-mui`)
|
|
276
324
|
|
|
@@ -288,6 +336,140 @@ import { Checklist, ChecklistLauncher } from '@apollovisionlabs/guide-mui'
|
|
|
288
336
|
<ChecklistLauncher checklistId="onboarding" title="Get started" placement="bottom-right" />
|
|
289
337
|
```
|
|
290
338
|
|
|
339
|
+
With a `storage` prop configured on `ChecklistProvider`, both `Checklist` and `ChecklistLauncher`
|
|
340
|
+
wait for their own checklist's restore to settle (`useChecklist(checklistId).restored`) before
|
|
341
|
+
drawing anything, rather than rendering their empty initial state (nothing completed, not
|
|
342
|
+
dismissed) for one paint. The tradeoff: with a slow storage backend, a checklist now appears later
|
|
343
|
+
than it used to, instead of appearing at once and then jumping. A slow or broken read for one
|
|
344
|
+
checklist never holds a different checklist back; each restores on its own.
|
|
345
|
+
|
|
346
|
+
## Hotspots
|
|
347
|
+
|
|
348
|
+
A hotspot marks one element outside any tour: a small marker that opens a short explanation, and
|
|
349
|
+
optionally a button that starts a tour. Unlike a tour step, a hotspot has no route and no order;
|
|
350
|
+
it just sits at its target until opened. `HotspotProvider` nests inside `GuideProvider`, the same
|
|
351
|
+
way `ChecklistProvider` does, so a hotspot naming a `tourId` can start it:
|
|
352
|
+
|
|
353
|
+
```tsx
|
|
354
|
+
import {
|
|
355
|
+
GuideProvider,
|
|
356
|
+
HotspotProvider,
|
|
357
|
+
type Hotspot,
|
|
358
|
+
type Tour,
|
|
359
|
+
} from '@apollovisionlabs/guide-core'
|
|
360
|
+
import { GuideTour, Hotspots } from '@apollovisionlabs/guide-mui'
|
|
361
|
+
|
|
362
|
+
const welcomeTour: Tour = {
|
|
363
|
+
id: 'welcome',
|
|
364
|
+
steps: [
|
|
365
|
+
{ target: 'projects.create', title: 'Create a project', body: 'Start here.' },
|
|
366
|
+
{ target: 'project.share', title: 'Share it', body: 'Send a link to your team.' },
|
|
367
|
+
],
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
const hotspots: Hotspot[] = [
|
|
371
|
+
{
|
|
372
|
+
id: 'create',
|
|
373
|
+
target: 'projects.create',
|
|
374
|
+
title: 'Start a project',
|
|
375
|
+
body: 'Everything else in here hangs off a project.',
|
|
376
|
+
},
|
|
377
|
+
{
|
|
378
|
+
id: 'share',
|
|
379
|
+
target: 'project.share',
|
|
380
|
+
title: 'Share a project',
|
|
381
|
+
body: 'Send a link to anyone on your team.',
|
|
382
|
+
// Named tours must exist on the GuideProvider above, or starting one warns and does nothing.
|
|
383
|
+
tourId: 'welcome',
|
|
384
|
+
},
|
|
385
|
+
]
|
|
386
|
+
|
|
387
|
+
function App() {
|
|
388
|
+
return (
|
|
389
|
+
<GuideProvider tours={[welcomeTour]}>
|
|
390
|
+
<HotspotProvider hotspots={hotspots}>
|
|
391
|
+
<YourApplication />
|
|
392
|
+
<GuideTour />
|
|
393
|
+
<Hotspots />
|
|
394
|
+
</HotspotProvider>
|
|
395
|
+
</GuideProvider>
|
|
396
|
+
)
|
|
397
|
+
}
|
|
398
|
+
```
|
|
399
|
+
|
|
400
|
+
Without a `GuideProvider` above it, starting a hotspot's tour warns once in the console and does
|
|
401
|
+
nothing.
|
|
402
|
+
|
|
403
|
+
### `HotspotProvider` props
|
|
404
|
+
|
|
405
|
+
| Prop | Type | Default | Description |
|
|
406
|
+
| --- | --- | --- | --- |
|
|
407
|
+
| `hotspots` | `Hotspot[]` | none | The hotspots to render. Ids must be unique. |
|
|
408
|
+
| `children` | `ReactNode` | none | Your application. |
|
|
409
|
+
| `storage` | `GuideStorage` | none | Persists which hotspots have been opened, under `hotspots:seen`. See "Persistence". |
|
|
410
|
+
| `translate` | `(key: string) => string` | none | Resolves `titleKey` / `bodyKey` on hotspots. See "Translations". |
|
|
411
|
+
| `onEvent` | `(event: GuideEvent) => void` | none | Called for `hotspot:show` and `hotspot:open`. See "Events". |
|
|
412
|
+
|
|
413
|
+
### `useHotspots()`
|
|
414
|
+
|
|
415
|
+
Returns `{ hotspots, restored, open, startTour, reset, notifyShown }`.
|
|
416
|
+
|
|
417
|
+
- `hotspots` is `ResolvedHotspot[]`: every hotspot, each carrying its own `seen`, with `title` /
|
|
418
|
+
`body` already resolved through `translate`. It lists the seen ones too, rather than only the
|
|
419
|
+
unseen ones, because a renderer that keeps a marker mounted while its own bubble closes needs
|
|
420
|
+
the seen one as well; filtering to unseen-only is one line at the call site.
|
|
421
|
+
- `restored` is whether the initial read from storage has settled: `true` immediately with no
|
|
422
|
+
`storage` prop (there is nothing to wait for), and `true` once the read resolves or rejects.
|
|
423
|
+
Wait for it before drawing any marker, or a hotspot already seen in storage can flash on screen
|
|
424
|
+
once before the restore lands.
|
|
425
|
+
- `open(hotspotId)` marks a hotspot seen and emits `hotspot:open`.
|
|
426
|
+
- `startTour(hotspotId)` starts the tour named by the hotspot's `tourId`, if it has one.
|
|
427
|
+
- `reset()` clears the seen state for every hotspot.
|
|
428
|
+
- `notifyShown(hotspotId)` is for renderers: call it once a marker is actually drawn on screen, so
|
|
429
|
+
`hotspot:show` fires once per hotspot per mount. `@apollovisionlabs/guide-mui`'s `Hotspots`
|
|
430
|
+
already calls it.
|
|
431
|
+
|
|
432
|
+
### `Hotspots` (`@apollovisionlabs/guide-mui`)
|
|
433
|
+
|
|
434
|
+
Renders a marker at each unseen hotspot's target; clicking it opens a bubble with the hotspot's
|
|
435
|
+
title, body, and, when it names a `tourId`, a button that starts that tour.
|
|
436
|
+
|
|
437
|
+
| Prop | Type | Default | Description |
|
|
438
|
+
| --- | --- | --- | --- |
|
|
439
|
+
| `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. |
|
|
440
|
+
| `placement` | `Placement` | `'bottom'` | Where the bubble opens relative to the marker. Overridable per hotspot through `Hotspot.placement`. |
|
|
441
|
+
| `zIndex` | `number` | `theme.zIndex.drawer + 1` | Stacking level of the marker; the bubble sits one above it. |
|
|
442
|
+
|
|
443
|
+
Default labels: `` { marker: (title) => `Show what is new: ${title}`, startTour: 'Show me', close: 'Close' } ``.
|
|
444
|
+
|
|
445
|
+
No marker is drawn while a tour is running or paused. A hotspot is an ambient hint and must not
|
|
446
|
+
compete with a guided flow the user is already in: a marker over the element a step points at
|
|
447
|
+
would take the click meant for that step, and one over a non-interactive step would be drawn
|
|
448
|
+
bright and pulsing yet inert behind the spotlight. The markers come back when the tour ends,
|
|
449
|
+
unchanged: this suppresses them, it does not mark them seen. `Hotspots` reads the tour state
|
|
450
|
+
through context and tolerates its absence, so hotspots work with no `GuideProvider` in the tree.
|
|
451
|
+
|
|
452
|
+
`paused` counts, because a paused tour is waiting for its target rather than finished. Note what
|
|
453
|
+
that implies at the edge: a tour paused on a target that never appears, the default `wait`
|
|
454
|
+
policy, draws nothing itself and now hides every hotspot too, for as long as it stays paused,
|
|
455
|
+
with `Escape` as the only way out and nothing on screen to suggest it. If your steps point at
|
|
456
|
+
targets that may never mount, prefer the `skip` or `error` missing-target policy over `wait`.
|
|
457
|
+
|
|
458
|
+
The default `zIndex` sits below `theme.zIndex.modal`, the level a running tour's spotlight uses,
|
|
459
|
+
so a hotspot whose target lives inside your own modal dialog is covered by it. Raise `zIndex` on
|
|
460
|
+
`Hotspots` to bring the marker above that dialog.
|
|
461
|
+
|
|
462
|
+
A marker is drawn only for a target that has actual size on screen. An element that is in the DOM
|
|
463
|
+
but not rendered, `display: none` for instance, measures an all-zero rectangle; that draws no
|
|
464
|
+
marker and emits no `hotspot:show`, so a hotspot cannot be retired before the user has seen what
|
|
465
|
+
it explains.
|
|
466
|
+
|
|
467
|
+
Clicking a marker whose bubble is already open closes the bubble, and emits no second
|
|
468
|
+
`hotspot:open`.
|
|
469
|
+
|
|
470
|
+
With a `storage` prop configured on `HotspotProvider`, `Hotspots` waits for the initial restore to
|
|
471
|
+
settle (`useHotspots().restored`) before drawing any marker.
|
|
472
|
+
|
|
291
473
|
## Compatibility
|
|
292
474
|
|
|
293
475
|
| | Supported |
|
package/dist/index.cjs
CHANGED
|
@@ -25,11 +25,14 @@ __export(index_exports, {
|
|
|
25
25
|
ChecklistProvider: () => ChecklistProvider,
|
|
26
26
|
GuideContext: () => GuideContext,
|
|
27
27
|
GuideProvider: () => GuideProvider,
|
|
28
|
+
HotspotContext: () => HotspotContext,
|
|
29
|
+
HotspotProvider: () => HotspotProvider,
|
|
28
30
|
createBrowserStorage: () => createBrowserStorage,
|
|
29
31
|
createMemoryStorage: () => createMemoryStorage,
|
|
30
32
|
findMissingTargets: () => findMissingTargets,
|
|
31
33
|
initialTourState: () => initialTourState,
|
|
32
34
|
isChecklistProgress: () => isChecklistProgress,
|
|
35
|
+
isHotspotsProgress: () => isHotspotsProgress,
|
|
33
36
|
isLiteralRoute: () => isLiteralRoute,
|
|
34
37
|
isTourProgress: () => isTourProgress,
|
|
35
38
|
matchRoute: () => matchRoute,
|
|
@@ -40,6 +43,7 @@ __export(index_exports, {
|
|
|
40
43
|
useElementRect: () => useElementRect,
|
|
41
44
|
useFocusTrap: () => useFocusTrap,
|
|
42
45
|
useGuideStep: () => useGuideStep,
|
|
46
|
+
useHotspots: () => useHotspots,
|
|
43
47
|
usePrefersReducedMotion: () => usePrefersReducedMotion,
|
|
44
48
|
useTargetElement: () => useTargetElement,
|
|
45
49
|
useTour: () => useTour
|
|
@@ -90,6 +94,11 @@ function isChecklistProgress(value) {
|
|
|
90
94
|
const candidate = value;
|
|
91
95
|
return Array.isArray(candidate.completed) && candidate.completed.every((entry) => typeof entry === "string") && typeof candidate.dismissed === "boolean";
|
|
92
96
|
}
|
|
97
|
+
function isHotspotsProgress(value) {
|
|
98
|
+
if (typeof value !== "object" || value === null) return false;
|
|
99
|
+
const candidate = value;
|
|
100
|
+
return Array.isArray(candidate.seen) && candidate.seen.every((entry) => typeof entry === "string");
|
|
101
|
+
}
|
|
93
102
|
|
|
94
103
|
// src/matchRoute.ts
|
|
95
104
|
function segments(value) {
|
|
@@ -321,6 +330,24 @@ function resolveText(value, key, translate) {
|
|
|
321
330
|
// src/GuideProvider.tsx
|
|
322
331
|
var import_jsx_runtime = require("react/jsx-runtime");
|
|
323
332
|
var GuideContext = (0, import_react4.createContext)(null);
|
|
333
|
+
function focusFallback(element) {
|
|
334
|
+
const needsTabIndex = !element.hasAttribute("tabindex") && element.tabIndex < 0;
|
|
335
|
+
if (!needsTabIndex) {
|
|
336
|
+
element.focus();
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
339
|
+
element.setAttribute("tabindex", "-1");
|
|
340
|
+
element.focus();
|
|
341
|
+
if (document.activeElement !== element) {
|
|
342
|
+
element.removeAttribute("tabindex");
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
const onBlur = () => {
|
|
346
|
+
element.removeAttribute("tabindex");
|
|
347
|
+
element.removeEventListener("blur", onBlur);
|
|
348
|
+
};
|
|
349
|
+
element.addEventListener("blur", onBlur);
|
|
350
|
+
}
|
|
324
351
|
function GuideProvider({
|
|
325
352
|
tours,
|
|
326
353
|
children,
|
|
@@ -345,6 +372,7 @@ function GuideProvider({
|
|
|
345
372
|
const [state, dispatch] = (0, import_react4.useReducer)(tourReducer, initialTourState);
|
|
346
373
|
const announce = useAnnouncer();
|
|
347
374
|
const focusOriginRef = (0, import_react4.useRef)(null);
|
|
375
|
+
const lastElementRef = (0, import_react4.useRef)(null);
|
|
348
376
|
const storageWarnedRef = (0, import_react4.useRef)(false);
|
|
349
377
|
const warnStorageFailure = (0, import_react4.useCallback)((error) => {
|
|
350
378
|
if (storageWarnedRef.current) return;
|
|
@@ -380,6 +408,14 @@ function GuideProvider({
|
|
|
380
408
|
dispatch({ type: "NEXT", stepCount: tour.steps.length });
|
|
381
409
|
if (isLast) emit({ type: "tour:complete", tourId: tour.id });
|
|
382
410
|
}, [tour, state.stepIndex, emit]);
|
|
411
|
+
const nextRef = (0, import_react4.useRef)(next);
|
|
412
|
+
nextRef.current = next;
|
|
413
|
+
(0, import_react4.useEffect)(() => {
|
|
414
|
+
if (state.status !== "running" || !element || step?.advanceOn !== "click") return;
|
|
415
|
+
const onClick = () => nextRef.current();
|
|
416
|
+
element.addEventListener("click", onClick);
|
|
417
|
+
return () => element.removeEventListener("click", onClick);
|
|
418
|
+
}, [state.status, element, step?.advanceOn]);
|
|
383
419
|
const previous = (0, import_react4.useCallback)(() => dispatch({ type: "PREVIOUS" }), []);
|
|
384
420
|
const stop = (0, import_react4.useCallback)(() => {
|
|
385
421
|
if (tour) emit({ type: "tour:stop", tourId: tour.id, stepIndex: state.stepIndex });
|
|
@@ -478,12 +514,23 @@ function GuideProvider({
|
|
|
478
514
|
warnStorageFailure(error);
|
|
479
515
|
}
|
|
480
516
|
}, [storage, state.tourId, state.status, state.stepIndex, warnStorageFailure]);
|
|
517
|
+
(0, import_react4.useEffect)(() => {
|
|
518
|
+
if (element) lastElementRef.current = element;
|
|
519
|
+
}, [element]);
|
|
481
520
|
(0, import_react4.useEffect)(() => {
|
|
482
521
|
if (state.status !== "idle" && state.status !== "completed") return;
|
|
483
522
|
const origin = focusOriginRef.current;
|
|
484
|
-
|
|
523
|
+
const fallback = lastElementRef.current;
|
|
485
524
|
focusOriginRef.current = null;
|
|
486
|
-
|
|
525
|
+
lastElementRef.current = null;
|
|
526
|
+
if (typeof document === "undefined") return;
|
|
527
|
+
if (origin && document.contains(origin)) {
|
|
528
|
+
origin.focus();
|
|
529
|
+
return;
|
|
530
|
+
}
|
|
531
|
+
if (document.activeElement !== document.body) return;
|
|
532
|
+
if (!fallback || !document.contains(fallback)) return;
|
|
533
|
+
focusFallback(fallback);
|
|
487
534
|
}, [state.status]);
|
|
488
535
|
const activeStep = (0, import_react4.useMemo)(() => {
|
|
489
536
|
if (!tour || !step || !isActive) return null;
|
|
@@ -494,6 +541,8 @@ function GuideProvider({
|
|
|
494
541
|
stepCount: tour.steps.length,
|
|
495
542
|
element,
|
|
496
543
|
rect,
|
|
544
|
+
interactive: step.interactive === true || step.advanceOn !== void 0,
|
|
545
|
+
awaitsAction: step.advanceOn !== void 0,
|
|
497
546
|
title: resolveText(step.title, step.titleKey, translate),
|
|
498
547
|
body: resolveText(step.body, step.bodyKey, translate),
|
|
499
548
|
isFirst: state.stepIndex === 0,
|
|
@@ -562,6 +611,11 @@ function ChecklistProvider({
|
|
|
562
611
|
return initial;
|
|
563
612
|
});
|
|
564
613
|
const progressRef = (0, import_react7.useRef)(progress);
|
|
614
|
+
const [restoredById, setRestoredById] = (0, import_react7.useState)(() => {
|
|
615
|
+
const initial = {};
|
|
616
|
+
for (const candidate of checklists) initial[candidate.id] = !storage;
|
|
617
|
+
return initial;
|
|
618
|
+
});
|
|
565
619
|
const guide = (0, import_react7.useContext)(GuideContext);
|
|
566
620
|
const storageWarnedRef = (0, import_react7.useRef)(false);
|
|
567
621
|
const warnStorageFailure = (0, import_react7.useCallback)((error) => {
|
|
@@ -593,31 +647,33 @@ function ChecklistProvider({
|
|
|
593
647
|
(0, import_react7.useEffect)(() => {
|
|
594
648
|
if (!storage) return;
|
|
595
649
|
let cancelled = false;
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
for (const candidate of checklists) {
|
|
650
|
+
for (const candidate of checklists) {
|
|
651
|
+
void (async () => {
|
|
599
652
|
try {
|
|
600
653
|
const stored = await storage.read(`checklist:${candidate.id}`);
|
|
601
|
-
if (isChecklistProgress(stored))
|
|
654
|
+
if (!cancelled && isChecklistProgress(stored)) {
|
|
655
|
+
const live = progressRef.current[candidate.id] ?? emptyProgress;
|
|
656
|
+
const merged = {
|
|
657
|
+
...progressRef.current,
|
|
658
|
+
[candidate.id]: {
|
|
659
|
+
completed: live.completed.concat(
|
|
660
|
+
stored.completed.filter((id) => !live.completed.includes(id))
|
|
661
|
+
),
|
|
662
|
+
dismissed: live.dismissed || stored.dismissed
|
|
663
|
+
}
|
|
664
|
+
};
|
|
665
|
+
progressRef.current = merged;
|
|
666
|
+
setProgress(merged);
|
|
667
|
+
}
|
|
602
668
|
} catch (error) {
|
|
603
669
|
warnStorageFailure(error);
|
|
670
|
+
} finally {
|
|
671
|
+
if (!cancelled) {
|
|
672
|
+
setRestoredById((current) => ({ ...current, [candidate.id]: true }));
|
|
673
|
+
}
|
|
604
674
|
}
|
|
605
|
-
}
|
|
606
|
-
|
|
607
|
-
const merged = { ...progressRef.current };
|
|
608
|
-
for (const [checklistId, stored] of Object.entries(restored)) {
|
|
609
|
-
const live = merged[checklistId] ?? emptyProgress;
|
|
610
|
-
merged[checklistId] = {
|
|
611
|
-
completed: live.completed.concat(
|
|
612
|
-
stored.completed.filter((id) => !live.completed.includes(id))
|
|
613
|
-
),
|
|
614
|
-
dismissed: live.dismissed || stored.dismissed
|
|
615
|
-
};
|
|
616
|
-
}
|
|
617
|
-
progressRef.current = merged;
|
|
618
|
-
setProgress(merged);
|
|
619
|
-
}
|
|
620
|
-
})();
|
|
675
|
+
})();
|
|
676
|
+
}
|
|
621
677
|
return () => {
|
|
622
678
|
cancelled = true;
|
|
623
679
|
};
|
|
@@ -755,8 +811,18 @@ function ChecklistProvider({
|
|
|
755
811
|
[resolveItem, guide, navigate, toggle, warnNoGuide, warnNoNavigate, warnTourStartFailure]
|
|
756
812
|
);
|
|
757
813
|
const value = (0, import_react7.useMemo)(
|
|
758
|
-
() => ({
|
|
759
|
-
|
|
814
|
+
() => ({
|
|
815
|
+
checklists,
|
|
816
|
+
progress,
|
|
817
|
+
translate,
|
|
818
|
+
restored: restoredById,
|
|
819
|
+
activate,
|
|
820
|
+
toggle,
|
|
821
|
+
complete,
|
|
822
|
+
dismiss,
|
|
823
|
+
reset
|
|
824
|
+
}),
|
|
825
|
+
[checklists, progress, translate, restoredById, activate, toggle, complete, dismiss, reset]
|
|
760
826
|
);
|
|
761
827
|
return /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(ChecklistContext.Provider, { value, children });
|
|
762
828
|
}
|
|
@@ -773,6 +839,7 @@ function useChecklist(checklistId) {
|
|
|
773
839
|
const completed = progress?.completed ?? [];
|
|
774
840
|
const dismissed = progress?.dismissed ?? false;
|
|
775
841
|
const translate = context.translate;
|
|
842
|
+
const restored = context.restored[checklistId] ?? true;
|
|
776
843
|
const items = (0, import_react8.useMemo)(
|
|
777
844
|
() => checklist.items.map((item) => ({
|
|
778
845
|
id: item.id,
|
|
@@ -795,6 +862,7 @@ function useChecklist(checklistId) {
|
|
|
795
862
|
total,
|
|
796
863
|
isComplete,
|
|
797
864
|
dismissed,
|
|
865
|
+
restored,
|
|
798
866
|
activate: (itemId) => activate(checklistId, itemId),
|
|
799
867
|
toggle: (itemId) => toggle(checklistId, itemId),
|
|
800
868
|
complete: (itemId) => complete(checklistId, itemId),
|
|
@@ -807,6 +875,7 @@ function useChecklist(checklistId) {
|
|
|
807
875
|
total,
|
|
808
876
|
isComplete,
|
|
809
877
|
dismissed,
|
|
878
|
+
restored,
|
|
810
879
|
activate,
|
|
811
880
|
toggle,
|
|
812
881
|
complete,
|
|
@@ -816,17 +885,182 @@ function useChecklist(checklistId) {
|
|
|
816
885
|
]
|
|
817
886
|
);
|
|
818
887
|
}
|
|
888
|
+
|
|
889
|
+
// src/HotspotProvider.tsx
|
|
890
|
+
var import_react9 = require("react");
|
|
891
|
+
var import_jsx_runtime3 = require("react/jsx-runtime");
|
|
892
|
+
var STORAGE_KEY = "hotspots:seen";
|
|
893
|
+
var HotspotContext = (0, import_react9.createContext)(null);
|
|
894
|
+
function HotspotProvider({
|
|
895
|
+
hotspots,
|
|
896
|
+
children,
|
|
897
|
+
storage,
|
|
898
|
+
translate,
|
|
899
|
+
onEvent
|
|
900
|
+
}) {
|
|
901
|
+
const hotspotsById = (0, import_react9.useMemo)(() => {
|
|
902
|
+
const map = /* @__PURE__ */ new Map();
|
|
903
|
+
for (const candidate of hotspots) {
|
|
904
|
+
if (map.has(candidate.id)) {
|
|
905
|
+
throw new Error(`[guide] duplicate hotspot id: ${candidate.id}`);
|
|
906
|
+
}
|
|
907
|
+
map.set(candidate.id, candidate);
|
|
908
|
+
}
|
|
909
|
+
return map;
|
|
910
|
+
}, [hotspots]);
|
|
911
|
+
const [seen, setSeen] = (0, import_react9.useState)([]);
|
|
912
|
+
const [restored, setRestored] = (0, import_react9.useState)(() => !storage);
|
|
913
|
+
const seenRef = (0, import_react9.useRef)(seen);
|
|
914
|
+
const guide = (0, import_react9.useContext)(GuideContext);
|
|
915
|
+
const storageWarnedRef = (0, import_react9.useRef)(false);
|
|
916
|
+
const warnStorageFailure = (0, import_react9.useCallback)((error) => {
|
|
917
|
+
if (storageWarnedRef.current) return;
|
|
918
|
+
storageWarnedRef.current = true;
|
|
919
|
+
console.warn("[guide] storage failed; hotspot state will not be persisted", error);
|
|
920
|
+
}, []);
|
|
921
|
+
const noGuideWarnedRef = (0, import_react9.useRef)(false);
|
|
922
|
+
const warnNoGuide = (0, import_react9.useCallback)(() => {
|
|
923
|
+
if (noGuideWarnedRef.current) return;
|
|
924
|
+
noGuideWarnedRef.current = true;
|
|
925
|
+
console.warn("[guide] a hotspot needs a GuideProvider to launch a tour");
|
|
926
|
+
}, []);
|
|
927
|
+
const tourStartFailedWarnedRef = (0, import_react9.useRef)(false);
|
|
928
|
+
const warnTourStartFailure = (0, import_react9.useCallback)((error) => {
|
|
929
|
+
if (tourStartFailedWarnedRef.current) return;
|
|
930
|
+
tourStartFailedWarnedRef.current = true;
|
|
931
|
+
console.warn("[guide] starting a tour for a hotspot failed", error);
|
|
932
|
+
}, []);
|
|
933
|
+
const onEventRef = (0, import_react9.useRef)(onEvent);
|
|
934
|
+
onEventRef.current = onEvent;
|
|
935
|
+
const emit = (0, import_react9.useCallback)((event) => onEventRef.current?.(event), []);
|
|
936
|
+
(0, import_react9.useEffect)(() => {
|
|
937
|
+
if (!storage) return;
|
|
938
|
+
let cancelled = false;
|
|
939
|
+
void (async () => {
|
|
940
|
+
let stored = null;
|
|
941
|
+
try {
|
|
942
|
+
stored = await storage.read(STORAGE_KEY);
|
|
943
|
+
} catch (error) {
|
|
944
|
+
warnStorageFailure(error);
|
|
945
|
+
if (!cancelled) setRestored(true);
|
|
946
|
+
return;
|
|
947
|
+
}
|
|
948
|
+
if (cancelled) return;
|
|
949
|
+
if (isHotspotsProgress(stored)) {
|
|
950
|
+
const merged = seenRef.current.concat(
|
|
951
|
+
stored.seen.filter((id) => !seenRef.current.includes(id))
|
|
952
|
+
);
|
|
953
|
+
seenRef.current = merged;
|
|
954
|
+
setSeen(merged);
|
|
955
|
+
}
|
|
956
|
+
setRestored(true);
|
|
957
|
+
})();
|
|
958
|
+
return () => {
|
|
959
|
+
cancelled = true;
|
|
960
|
+
};
|
|
961
|
+
}, [storage, warnStorageFailure]);
|
|
962
|
+
const applySeen = (0, import_react9.useCallback)(
|
|
963
|
+
(next) => {
|
|
964
|
+
seenRef.current = next;
|
|
965
|
+
setSeen(next);
|
|
966
|
+
if (!storage) return;
|
|
967
|
+
try {
|
|
968
|
+
void Promise.resolve(storage.write(STORAGE_KEY, { seen: next })).catch(
|
|
969
|
+
warnStorageFailure
|
|
970
|
+
);
|
|
971
|
+
} catch (error) {
|
|
972
|
+
warnStorageFailure(error);
|
|
973
|
+
}
|
|
974
|
+
},
|
|
975
|
+
[storage, warnStorageFailure]
|
|
976
|
+
);
|
|
977
|
+
const resolve = (0, import_react9.useCallback)(
|
|
978
|
+
(hotspotId) => {
|
|
979
|
+
const hotspot = hotspotsById.get(hotspotId);
|
|
980
|
+
if (!hotspot) {
|
|
981
|
+
console.warn(`[guide] unknown hotspot "${hotspotId}"`);
|
|
982
|
+
return null;
|
|
983
|
+
}
|
|
984
|
+
return hotspot;
|
|
985
|
+
},
|
|
986
|
+
[hotspotsById]
|
|
987
|
+
);
|
|
988
|
+
const open = (0, import_react9.useCallback)(
|
|
989
|
+
(hotspotId) => {
|
|
990
|
+
if (!resolve(hotspotId)) return;
|
|
991
|
+
emit({ type: "hotspot:open", hotspotId });
|
|
992
|
+
if (seenRef.current.includes(hotspotId)) return;
|
|
993
|
+
applySeen([...seenRef.current, hotspotId]);
|
|
994
|
+
},
|
|
995
|
+
[resolve, applySeen, emit]
|
|
996
|
+
);
|
|
997
|
+
const startTour = (0, import_react9.useCallback)(
|
|
998
|
+
(hotspotId) => {
|
|
999
|
+
const hotspot = resolve(hotspotId);
|
|
1000
|
+
if (!hotspot?.tourId) return;
|
|
1001
|
+
if (!guide) {
|
|
1002
|
+
warnNoGuide();
|
|
1003
|
+
return;
|
|
1004
|
+
}
|
|
1005
|
+
void guide.start(hotspot.tourId).catch(warnTourStartFailure);
|
|
1006
|
+
},
|
|
1007
|
+
[resolve, guide, warnNoGuide, warnTourStartFailure]
|
|
1008
|
+
);
|
|
1009
|
+
const reset = (0, import_react9.useCallback)(() => applySeen([]), [applySeen]);
|
|
1010
|
+
const shownRef = (0, import_react9.useRef)(/* @__PURE__ */ new Set());
|
|
1011
|
+
const notifyShown = (0, import_react9.useCallback)(
|
|
1012
|
+
(hotspotId) => {
|
|
1013
|
+
if (shownRef.current.has(hotspotId)) return;
|
|
1014
|
+
shownRef.current.add(hotspotId);
|
|
1015
|
+
emit({ type: "hotspot:show", hotspotId });
|
|
1016
|
+
},
|
|
1017
|
+
[emit]
|
|
1018
|
+
);
|
|
1019
|
+
const value = (0, import_react9.useMemo)(
|
|
1020
|
+
() => ({ hotspots, seen, translate, restored, open, startTour, reset, notifyShown }),
|
|
1021
|
+
[hotspots, seen, translate, restored, open, startTour, reset, notifyShown]
|
|
1022
|
+
);
|
|
1023
|
+
return /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(HotspotContext.Provider, { value, children });
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
// src/useHotspots.ts
|
|
1027
|
+
var import_react10 = require("react");
|
|
1028
|
+
function useHotspots() {
|
|
1029
|
+
const context = (0, import_react10.useContext)(HotspotContext);
|
|
1030
|
+
if (!context)
|
|
1031
|
+
throw new Error("[guide] useHotspots must be used inside a HotspotProvider");
|
|
1032
|
+
const { seen, translate, restored, open, startTour, reset, notifyShown } = context;
|
|
1033
|
+
const hotspots = (0, import_react10.useMemo)(
|
|
1034
|
+
() => context.hotspots.map((hotspot) => ({
|
|
1035
|
+
id: hotspot.id,
|
|
1036
|
+
target: hotspot.target,
|
|
1037
|
+
title: resolveText(hotspot.title, hotspot.titleKey, translate),
|
|
1038
|
+
body: resolveText(hotspot.body, hotspot.bodyKey, translate),
|
|
1039
|
+
seen: seen.includes(hotspot.id),
|
|
1040
|
+
tourId: hotspot.tourId,
|
|
1041
|
+
placement: hotspot.placement
|
|
1042
|
+
})),
|
|
1043
|
+
[context.hotspots, seen, translate]
|
|
1044
|
+
);
|
|
1045
|
+
return (0, import_react10.useMemo)(
|
|
1046
|
+
() => ({ hotspots, restored, open, startTour, reset, notifyShown }),
|
|
1047
|
+
[hotspots, restored, open, startTour, reset, notifyShown]
|
|
1048
|
+
);
|
|
1049
|
+
}
|
|
819
1050
|
// Annotate the CommonJS export names for ESM import in node:
|
|
820
1051
|
0 && (module.exports = {
|
|
821
1052
|
ChecklistContext,
|
|
822
1053
|
ChecklistProvider,
|
|
823
1054
|
GuideContext,
|
|
824
1055
|
GuideProvider,
|
|
1056
|
+
HotspotContext,
|
|
1057
|
+
HotspotProvider,
|
|
825
1058
|
createBrowserStorage,
|
|
826
1059
|
createMemoryStorage,
|
|
827
1060
|
findMissingTargets,
|
|
828
1061
|
initialTourState,
|
|
829
1062
|
isChecklistProgress,
|
|
1063
|
+
isHotspotsProgress,
|
|
830
1064
|
isLiteralRoute,
|
|
831
1065
|
isTourProgress,
|
|
832
1066
|
matchRoute,
|
|
@@ -837,6 +1071,7 @@ function useChecklist(checklistId) {
|
|
|
837
1071
|
useElementRect,
|
|
838
1072
|
useFocusTrap,
|
|
839
1073
|
useGuideStep,
|
|
1074
|
+
useHotspots,
|
|
840
1075
|
usePrefersReducedMotion,
|
|
841
1076
|
useTargetElement,
|
|
842
1077
|
useTour
|