@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/dist/index.d.ts
CHANGED
|
@@ -19,6 +19,11 @@ interface Step {
|
|
|
19
19
|
placement?: Placement;
|
|
20
20
|
/** Lets the user interact with the page during the step. */
|
|
21
21
|
interactive?: boolean;
|
|
22
|
+
/**
|
|
23
|
+
* Advances the tour when the user clicks the target. Implies `interactive`: a step that
|
|
24
|
+
* waits for a click has to let the click through.
|
|
25
|
+
*/
|
|
26
|
+
advanceOn?: 'click';
|
|
22
27
|
title?: string;
|
|
23
28
|
titleKey?: string;
|
|
24
29
|
body?: string;
|
|
@@ -62,10 +67,34 @@ interface ResolvedChecklistItem {
|
|
|
62
67
|
tourId?: string;
|
|
63
68
|
href?: string;
|
|
64
69
|
}
|
|
70
|
+
interface Hotspot {
|
|
71
|
+
id: string;
|
|
72
|
+
/** Logical key carried by the data-guide attribute on the element. */
|
|
73
|
+
target: string;
|
|
74
|
+
title?: string;
|
|
75
|
+
titleKey?: string;
|
|
76
|
+
body?: string;
|
|
77
|
+
bodyKey?: string;
|
|
78
|
+
/** Tour started from the hotspot's bubble. */
|
|
79
|
+
tourId?: string;
|
|
80
|
+
placement?: Placement;
|
|
81
|
+
}
|
|
82
|
+
interface ResolvedHotspot {
|
|
83
|
+
id: string;
|
|
84
|
+
target: string;
|
|
85
|
+
title: string;
|
|
86
|
+
body: string;
|
|
87
|
+
seen: boolean;
|
|
88
|
+
tourId?: string;
|
|
89
|
+
placement?: Placement;
|
|
90
|
+
}
|
|
91
|
+
interface HotspotsProgress {
|
|
92
|
+
seen: string[];
|
|
93
|
+
}
|
|
65
94
|
interface GuideStorage {
|
|
66
95
|
/**
|
|
67
96
|
* Reads a previously written value. The key is namespaced by the caller,
|
|
68
|
-
* `tour:<id
|
|
97
|
+
* `tour:<id>`, `checklist:<id>`, or `hotspots:seen`, so one storage serves them all.
|
|
69
98
|
*/
|
|
70
99
|
read<T>(key: string): Promise<T | null>;
|
|
71
100
|
write<T>(key: string, value: T): Promise<void>;
|
|
@@ -101,6 +130,12 @@ type GuideEvent = {
|
|
|
101
130
|
} | {
|
|
102
131
|
type: 'checklist:dismiss';
|
|
103
132
|
checklistId: string;
|
|
133
|
+
} | {
|
|
134
|
+
type: 'hotspot:show';
|
|
135
|
+
hotspotId: string;
|
|
136
|
+
} | {
|
|
137
|
+
type: 'hotspot:open';
|
|
138
|
+
hotspotId: string;
|
|
104
139
|
};
|
|
105
140
|
type Translate = (key: string) => string;
|
|
106
141
|
|
|
@@ -116,6 +151,11 @@ declare function isTourProgress(value: unknown): value is TourProgress;
|
|
|
116
151
|
* never trusted until its shape is checked.
|
|
117
152
|
*/
|
|
118
153
|
declare function isChecklistProgress(value: unknown): value is ChecklistProgress;
|
|
154
|
+
/**
|
|
155
|
+
* Same defensive posture as the two guards above: a stored hotspot value is never trusted
|
|
156
|
+
* until its shape is checked.
|
|
157
|
+
*/
|
|
158
|
+
declare function isHotspotsProgress(value: unknown): value is HotspotsProgress;
|
|
119
159
|
|
|
120
160
|
declare function isLiteralRoute(pattern: string): boolean;
|
|
121
161
|
declare function matchRoute(pattern: string, pathname: string): boolean;
|
|
@@ -173,6 +213,14 @@ interface ActiveStep {
|
|
|
173
213
|
stepCount: number;
|
|
174
214
|
element: HTMLElement | null;
|
|
175
215
|
rect: Rect | null;
|
|
216
|
+
/**
|
|
217
|
+
* Whether the page stays reachable during the step. True when the step declares
|
|
218
|
+
* `interactive`, and also when it declares `advanceOn`, whose click has to reach the
|
|
219
|
+
* element. Renderers read this instead of `step.interactive`, so the rule lives here.
|
|
220
|
+
*/
|
|
221
|
+
interactive: boolean;
|
|
222
|
+
/** True when the step advances on a user action rather than on a button. */
|
|
223
|
+
awaitsAction: boolean;
|
|
176
224
|
title: string;
|
|
177
225
|
body: string;
|
|
178
226
|
isFirst: boolean;
|
|
@@ -229,6 +277,15 @@ interface ChecklistContextValue {
|
|
|
229
277
|
checklists: Checklist[];
|
|
230
278
|
progress: Record<string, ChecklistProgress>;
|
|
231
279
|
translate?: Translate;
|
|
280
|
+
/**
|
|
281
|
+
* Whether each checklist's initial restore from storage has settled, keyed by checklist id:
|
|
282
|
+
* true immediately for a checklist when no `storage` prop was given (there is nothing to
|
|
283
|
+
* wait for), and true once that checklist's own read has resolved or rejected. Settled
|
|
284
|
+
* independently per checklist, so a slow or hung read for one checklist never holds another
|
|
285
|
+
* checklist's rendering hostage, and a renderer can wait for its own entry without a broken
|
|
286
|
+
* backend hiding it forever.
|
|
287
|
+
*/
|
|
288
|
+
restored: Record<string, boolean>;
|
|
232
289
|
activate: (checklistId: string, itemId: string) => void;
|
|
233
290
|
toggle: (checklistId: string, itemId: string) => void;
|
|
234
291
|
complete: (checklistId: string, itemId: string) => void;
|
|
@@ -252,6 +309,14 @@ interface UseChecklistResult {
|
|
|
252
309
|
total: number;
|
|
253
310
|
isComplete: boolean;
|
|
254
311
|
dismissed: boolean;
|
|
312
|
+
/**
|
|
313
|
+
* Whether this checklist's own initial restore from storage has settled. A renderer should
|
|
314
|
+
* wait for this before drawing anything, or a checklist already dismissed or partly
|
|
315
|
+
* completed in storage can flash its empty initial state on screen once before the restore
|
|
316
|
+
* lands. Settled per checklist: a slow or hung read for a different checklist on the same
|
|
317
|
+
* provider never holds this one false.
|
|
318
|
+
*/
|
|
319
|
+
restored: boolean;
|
|
255
320
|
activate: (itemId: string) => void;
|
|
256
321
|
toggle: (itemId: string) => void;
|
|
257
322
|
complete: (itemId: string) => void;
|
|
@@ -260,4 +325,49 @@ interface UseChecklistResult {
|
|
|
260
325
|
}
|
|
261
326
|
declare function useChecklist(checklistId: string): UseChecklistResult;
|
|
262
327
|
|
|
263
|
-
|
|
328
|
+
interface HotspotContextValue {
|
|
329
|
+
hotspots: Hotspot[];
|
|
330
|
+
seen: string[];
|
|
331
|
+
translate?: Translate;
|
|
332
|
+
/**
|
|
333
|
+
* Whether the initial restore from storage has settled: true immediately when no `storage`
|
|
334
|
+
* prop was given (there is nothing to wait for), and true once the read resolves or
|
|
335
|
+
* rejects, so a renderer can wait for it without a broken backend hiding hotspots forever.
|
|
336
|
+
*/
|
|
337
|
+
restored: boolean;
|
|
338
|
+
open: (hotspotId: string) => void;
|
|
339
|
+
startTour: (hotspotId: string) => void;
|
|
340
|
+
reset: () => void;
|
|
341
|
+
notifyShown: (hotspotId: string) => void;
|
|
342
|
+
}
|
|
343
|
+
declare const HotspotContext: react.Context<HotspotContextValue | null>;
|
|
344
|
+
interface HotspotProviderProps {
|
|
345
|
+
hotspots: Hotspot[];
|
|
346
|
+
children: ReactNode;
|
|
347
|
+
storage?: GuideStorage;
|
|
348
|
+
translate?: Translate;
|
|
349
|
+
onEvent?: (event: GuideEvent) => void;
|
|
350
|
+
}
|
|
351
|
+
declare function HotspotProvider({ hotspots, children, storage, translate, onEvent, }: HotspotProviderProps): react.JSX.Element;
|
|
352
|
+
|
|
353
|
+
interface UseHotspotsResult {
|
|
354
|
+
/**
|
|
355
|
+
* Every hotspot, each carrying its own `seen`, rather than the unseen ones alone. A
|
|
356
|
+
* renderer has to keep a marker mounted while its own bubble closes, so it needs the seen
|
|
357
|
+
* one too; filtering is one line at the call site.
|
|
358
|
+
*/
|
|
359
|
+
hotspots: ResolvedHotspot[];
|
|
360
|
+
/**
|
|
361
|
+
* Whether the initial restore from storage has settled. A renderer should wait for this
|
|
362
|
+
* before drawing any marker, or a hotspot already seen in storage can flash on screen once
|
|
363
|
+
* before the restore lands.
|
|
364
|
+
*/
|
|
365
|
+
restored: boolean;
|
|
366
|
+
open: (hotspotId: string) => void;
|
|
367
|
+
startTour: (hotspotId: string) => void;
|
|
368
|
+
reset: () => void;
|
|
369
|
+
notifyShown: (hotspotId: string) => void;
|
|
370
|
+
}
|
|
371
|
+
declare function useHotspots(): UseHotspotsResult;
|
|
372
|
+
|
|
373
|
+
export { type ActiveStep, type Checklist, ChecklistContext, type ChecklistContextValue, type ChecklistItem, type ChecklistProgress, ChecklistProvider, type ChecklistProviderProps, GuideContext, type GuideContextValue, type GuideEvent, GuideProvider, type GuideProviderProps, type GuideStorage, type Hotspot, HotspotContext, type HotspotContextValue, HotspotProvider, type HotspotProviderProps, type HotspotsProgress, type MissingTargetPolicy, type Placement, type Rect, type ResolvedChecklistItem, type ResolvedHotspot, type Step, type Tour, type TourAction, type TourProgress, type TourState, type TourStatus, type Translate, type UseChecklistResult, type UseFocusTrapOptions, type UseHotspotsResult, type UseTargetElementOptions, type UseTourResult, createBrowserStorage, createMemoryStorage, findMissingTargets, initialTourState, isChecklistProgress, isHotspotsProgress, isLiteralRoute, isTourProgress, matchRoute, resolveText, tourReducer, useAnnouncer, useChecklist, useElementRect, useFocusTrap, useGuideStep, useHotspots, usePrefersReducedMotion, useTargetElement, useTour };
|
package/dist/index.mjs
CHANGED
|
@@ -44,6 +44,11 @@ function isChecklistProgress(value) {
|
|
|
44
44
|
const candidate = value;
|
|
45
45
|
return Array.isArray(candidate.completed) && candidate.completed.every((entry) => typeof entry === "string") && typeof candidate.dismissed === "boolean";
|
|
46
46
|
}
|
|
47
|
+
function isHotspotsProgress(value) {
|
|
48
|
+
if (typeof value !== "object" || value === null) return false;
|
|
49
|
+
const candidate = value;
|
|
50
|
+
return Array.isArray(candidate.seen) && candidate.seen.every((entry) => typeof entry === "string");
|
|
51
|
+
}
|
|
47
52
|
|
|
48
53
|
// src/matchRoute.ts
|
|
49
54
|
function segments(value) {
|
|
@@ -283,6 +288,24 @@ function resolveText(value, key, translate) {
|
|
|
283
288
|
// src/GuideProvider.tsx
|
|
284
289
|
import { jsx } from "react/jsx-runtime";
|
|
285
290
|
var GuideContext = createContext(null);
|
|
291
|
+
function focusFallback(element) {
|
|
292
|
+
const needsTabIndex = !element.hasAttribute("tabindex") && element.tabIndex < 0;
|
|
293
|
+
if (!needsTabIndex) {
|
|
294
|
+
element.focus();
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
element.setAttribute("tabindex", "-1");
|
|
298
|
+
element.focus();
|
|
299
|
+
if (document.activeElement !== element) {
|
|
300
|
+
element.removeAttribute("tabindex");
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
const onBlur = () => {
|
|
304
|
+
element.removeAttribute("tabindex");
|
|
305
|
+
element.removeEventListener("blur", onBlur);
|
|
306
|
+
};
|
|
307
|
+
element.addEventListener("blur", onBlur);
|
|
308
|
+
}
|
|
286
309
|
function GuideProvider({
|
|
287
310
|
tours,
|
|
288
311
|
children,
|
|
@@ -307,6 +330,7 @@ function GuideProvider({
|
|
|
307
330
|
const [state, dispatch] = useReducer(tourReducer, initialTourState);
|
|
308
331
|
const announce = useAnnouncer();
|
|
309
332
|
const focusOriginRef = useRef(null);
|
|
333
|
+
const lastElementRef = useRef(null);
|
|
310
334
|
const storageWarnedRef = useRef(false);
|
|
311
335
|
const warnStorageFailure = useCallback2((error) => {
|
|
312
336
|
if (storageWarnedRef.current) return;
|
|
@@ -342,6 +366,14 @@ function GuideProvider({
|
|
|
342
366
|
dispatch({ type: "NEXT", stepCount: tour.steps.length });
|
|
343
367
|
if (isLast) emit({ type: "tour:complete", tourId: tour.id });
|
|
344
368
|
}, [tour, state.stepIndex, emit]);
|
|
369
|
+
const nextRef = useRef(next);
|
|
370
|
+
nextRef.current = next;
|
|
371
|
+
useEffect4(() => {
|
|
372
|
+
if (state.status !== "running" || !element || step?.advanceOn !== "click") return;
|
|
373
|
+
const onClick = () => nextRef.current();
|
|
374
|
+
element.addEventListener("click", onClick);
|
|
375
|
+
return () => element.removeEventListener("click", onClick);
|
|
376
|
+
}, [state.status, element, step?.advanceOn]);
|
|
345
377
|
const previous = useCallback2(() => dispatch({ type: "PREVIOUS" }), []);
|
|
346
378
|
const stop = useCallback2(() => {
|
|
347
379
|
if (tour) emit({ type: "tour:stop", tourId: tour.id, stepIndex: state.stepIndex });
|
|
@@ -440,12 +472,23 @@ function GuideProvider({
|
|
|
440
472
|
warnStorageFailure(error);
|
|
441
473
|
}
|
|
442
474
|
}, [storage, state.tourId, state.status, state.stepIndex, warnStorageFailure]);
|
|
475
|
+
useEffect4(() => {
|
|
476
|
+
if (element) lastElementRef.current = element;
|
|
477
|
+
}, [element]);
|
|
443
478
|
useEffect4(() => {
|
|
444
479
|
if (state.status !== "idle" && state.status !== "completed") return;
|
|
445
480
|
const origin = focusOriginRef.current;
|
|
446
|
-
|
|
481
|
+
const fallback = lastElementRef.current;
|
|
447
482
|
focusOriginRef.current = null;
|
|
448
|
-
|
|
483
|
+
lastElementRef.current = null;
|
|
484
|
+
if (typeof document === "undefined") return;
|
|
485
|
+
if (origin && document.contains(origin)) {
|
|
486
|
+
origin.focus();
|
|
487
|
+
return;
|
|
488
|
+
}
|
|
489
|
+
if (document.activeElement !== document.body) return;
|
|
490
|
+
if (!fallback || !document.contains(fallback)) return;
|
|
491
|
+
focusFallback(fallback);
|
|
449
492
|
}, [state.status]);
|
|
450
493
|
const activeStep = useMemo(() => {
|
|
451
494
|
if (!tour || !step || !isActive) return null;
|
|
@@ -456,6 +499,8 @@ function GuideProvider({
|
|
|
456
499
|
stepCount: tour.steps.length,
|
|
457
500
|
element,
|
|
458
501
|
rect,
|
|
502
|
+
interactive: step.interactive === true || step.advanceOn !== void 0,
|
|
503
|
+
awaitsAction: step.advanceOn !== void 0,
|
|
459
504
|
title: resolveText(step.title, step.titleKey, translate),
|
|
460
505
|
body: resolveText(step.body, step.bodyKey, translate),
|
|
461
506
|
isFirst: state.stepIndex === 0,
|
|
@@ -532,6 +577,11 @@ function ChecklistProvider({
|
|
|
532
577
|
return initial;
|
|
533
578
|
});
|
|
534
579
|
const progressRef = useRef2(progress);
|
|
580
|
+
const [restoredById, setRestoredById] = useState5(() => {
|
|
581
|
+
const initial = {};
|
|
582
|
+
for (const candidate of checklists) initial[candidate.id] = !storage;
|
|
583
|
+
return initial;
|
|
584
|
+
});
|
|
535
585
|
const guide = useContext3(GuideContext);
|
|
536
586
|
const storageWarnedRef = useRef2(false);
|
|
537
587
|
const warnStorageFailure = useCallback3((error) => {
|
|
@@ -563,31 +613,33 @@ function ChecklistProvider({
|
|
|
563
613
|
useEffect5(() => {
|
|
564
614
|
if (!storage) return;
|
|
565
615
|
let cancelled = false;
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
for (const candidate of checklists) {
|
|
616
|
+
for (const candidate of checklists) {
|
|
617
|
+
void (async () => {
|
|
569
618
|
try {
|
|
570
619
|
const stored = await storage.read(`checklist:${candidate.id}`);
|
|
571
|
-
if (isChecklistProgress(stored))
|
|
620
|
+
if (!cancelled && isChecklistProgress(stored)) {
|
|
621
|
+
const live = progressRef.current[candidate.id] ?? emptyProgress;
|
|
622
|
+
const merged = {
|
|
623
|
+
...progressRef.current,
|
|
624
|
+
[candidate.id]: {
|
|
625
|
+
completed: live.completed.concat(
|
|
626
|
+
stored.completed.filter((id) => !live.completed.includes(id))
|
|
627
|
+
),
|
|
628
|
+
dismissed: live.dismissed || stored.dismissed
|
|
629
|
+
}
|
|
630
|
+
};
|
|
631
|
+
progressRef.current = merged;
|
|
632
|
+
setProgress(merged);
|
|
633
|
+
}
|
|
572
634
|
} catch (error) {
|
|
573
635
|
warnStorageFailure(error);
|
|
636
|
+
} finally {
|
|
637
|
+
if (!cancelled) {
|
|
638
|
+
setRestoredById((current) => ({ ...current, [candidate.id]: true }));
|
|
639
|
+
}
|
|
574
640
|
}
|
|
575
|
-
}
|
|
576
|
-
|
|
577
|
-
const merged = { ...progressRef.current };
|
|
578
|
-
for (const [checklistId, stored] of Object.entries(restored)) {
|
|
579
|
-
const live = merged[checklistId] ?? emptyProgress;
|
|
580
|
-
merged[checklistId] = {
|
|
581
|
-
completed: live.completed.concat(
|
|
582
|
-
stored.completed.filter((id) => !live.completed.includes(id))
|
|
583
|
-
),
|
|
584
|
-
dismissed: live.dismissed || stored.dismissed
|
|
585
|
-
};
|
|
586
|
-
}
|
|
587
|
-
progressRef.current = merged;
|
|
588
|
-
setProgress(merged);
|
|
589
|
-
}
|
|
590
|
-
})();
|
|
641
|
+
})();
|
|
642
|
+
}
|
|
591
643
|
return () => {
|
|
592
644
|
cancelled = true;
|
|
593
645
|
};
|
|
@@ -725,8 +777,18 @@ function ChecklistProvider({
|
|
|
725
777
|
[resolveItem, guide, navigate, toggle, warnNoGuide, warnNoNavigate, warnTourStartFailure]
|
|
726
778
|
);
|
|
727
779
|
const value = useMemo3(
|
|
728
|
-
() => ({
|
|
729
|
-
|
|
780
|
+
() => ({
|
|
781
|
+
checklists,
|
|
782
|
+
progress,
|
|
783
|
+
translate,
|
|
784
|
+
restored: restoredById,
|
|
785
|
+
activate,
|
|
786
|
+
toggle,
|
|
787
|
+
complete,
|
|
788
|
+
dismiss,
|
|
789
|
+
reset
|
|
790
|
+
}),
|
|
791
|
+
[checklists, progress, translate, restoredById, activate, toggle, complete, dismiss, reset]
|
|
730
792
|
);
|
|
731
793
|
return /* @__PURE__ */ jsx2(ChecklistContext.Provider, { value, children });
|
|
732
794
|
}
|
|
@@ -743,6 +805,7 @@ function useChecklist(checklistId) {
|
|
|
743
805
|
const completed = progress?.completed ?? [];
|
|
744
806
|
const dismissed = progress?.dismissed ?? false;
|
|
745
807
|
const translate = context.translate;
|
|
808
|
+
const restored = context.restored[checklistId] ?? true;
|
|
746
809
|
const items = useMemo4(
|
|
747
810
|
() => checklist.items.map((item) => ({
|
|
748
811
|
id: item.id,
|
|
@@ -765,6 +828,7 @@ function useChecklist(checklistId) {
|
|
|
765
828
|
total,
|
|
766
829
|
isComplete,
|
|
767
830
|
dismissed,
|
|
831
|
+
restored,
|
|
768
832
|
activate: (itemId) => activate(checklistId, itemId),
|
|
769
833
|
toggle: (itemId) => toggle(checklistId, itemId),
|
|
770
834
|
complete: (itemId) => complete(checklistId, itemId),
|
|
@@ -777,6 +841,7 @@ function useChecklist(checklistId) {
|
|
|
777
841
|
total,
|
|
778
842
|
isComplete,
|
|
779
843
|
dismissed,
|
|
844
|
+
restored,
|
|
780
845
|
activate,
|
|
781
846
|
toggle,
|
|
782
847
|
complete,
|
|
@@ -786,16 +851,189 @@ function useChecklist(checklistId) {
|
|
|
786
851
|
]
|
|
787
852
|
);
|
|
788
853
|
}
|
|
854
|
+
|
|
855
|
+
// src/HotspotProvider.tsx
|
|
856
|
+
import {
|
|
857
|
+
createContext as createContext3,
|
|
858
|
+
useCallback as useCallback4,
|
|
859
|
+
useContext as useContext5,
|
|
860
|
+
useEffect as useEffect6,
|
|
861
|
+
useMemo as useMemo5,
|
|
862
|
+
useRef as useRef3,
|
|
863
|
+
useState as useState6
|
|
864
|
+
} from "react";
|
|
865
|
+
import { jsx as jsx3 } from "react/jsx-runtime";
|
|
866
|
+
var STORAGE_KEY = "hotspots:seen";
|
|
867
|
+
var HotspotContext = createContext3(null);
|
|
868
|
+
function HotspotProvider({
|
|
869
|
+
hotspots,
|
|
870
|
+
children,
|
|
871
|
+
storage,
|
|
872
|
+
translate,
|
|
873
|
+
onEvent
|
|
874
|
+
}) {
|
|
875
|
+
const hotspotsById = useMemo5(() => {
|
|
876
|
+
const map = /* @__PURE__ */ new Map();
|
|
877
|
+
for (const candidate of hotspots) {
|
|
878
|
+
if (map.has(candidate.id)) {
|
|
879
|
+
throw new Error(`[guide] duplicate hotspot id: ${candidate.id}`);
|
|
880
|
+
}
|
|
881
|
+
map.set(candidate.id, candidate);
|
|
882
|
+
}
|
|
883
|
+
return map;
|
|
884
|
+
}, [hotspots]);
|
|
885
|
+
const [seen, setSeen] = useState6([]);
|
|
886
|
+
const [restored, setRestored] = useState6(() => !storage);
|
|
887
|
+
const seenRef = useRef3(seen);
|
|
888
|
+
const guide = useContext5(GuideContext);
|
|
889
|
+
const storageWarnedRef = useRef3(false);
|
|
890
|
+
const warnStorageFailure = useCallback4((error) => {
|
|
891
|
+
if (storageWarnedRef.current) return;
|
|
892
|
+
storageWarnedRef.current = true;
|
|
893
|
+
console.warn("[guide] storage failed; hotspot state will not be persisted", error);
|
|
894
|
+
}, []);
|
|
895
|
+
const noGuideWarnedRef = useRef3(false);
|
|
896
|
+
const warnNoGuide = useCallback4(() => {
|
|
897
|
+
if (noGuideWarnedRef.current) return;
|
|
898
|
+
noGuideWarnedRef.current = true;
|
|
899
|
+
console.warn("[guide] a hotspot needs a GuideProvider to launch a tour");
|
|
900
|
+
}, []);
|
|
901
|
+
const tourStartFailedWarnedRef = useRef3(false);
|
|
902
|
+
const warnTourStartFailure = useCallback4((error) => {
|
|
903
|
+
if (tourStartFailedWarnedRef.current) return;
|
|
904
|
+
tourStartFailedWarnedRef.current = true;
|
|
905
|
+
console.warn("[guide] starting a tour for a hotspot failed", error);
|
|
906
|
+
}, []);
|
|
907
|
+
const onEventRef = useRef3(onEvent);
|
|
908
|
+
onEventRef.current = onEvent;
|
|
909
|
+
const emit = useCallback4((event) => onEventRef.current?.(event), []);
|
|
910
|
+
useEffect6(() => {
|
|
911
|
+
if (!storage) return;
|
|
912
|
+
let cancelled = false;
|
|
913
|
+
void (async () => {
|
|
914
|
+
let stored = null;
|
|
915
|
+
try {
|
|
916
|
+
stored = await storage.read(STORAGE_KEY);
|
|
917
|
+
} catch (error) {
|
|
918
|
+
warnStorageFailure(error);
|
|
919
|
+
if (!cancelled) setRestored(true);
|
|
920
|
+
return;
|
|
921
|
+
}
|
|
922
|
+
if (cancelled) return;
|
|
923
|
+
if (isHotspotsProgress(stored)) {
|
|
924
|
+
const merged = seenRef.current.concat(
|
|
925
|
+
stored.seen.filter((id) => !seenRef.current.includes(id))
|
|
926
|
+
);
|
|
927
|
+
seenRef.current = merged;
|
|
928
|
+
setSeen(merged);
|
|
929
|
+
}
|
|
930
|
+
setRestored(true);
|
|
931
|
+
})();
|
|
932
|
+
return () => {
|
|
933
|
+
cancelled = true;
|
|
934
|
+
};
|
|
935
|
+
}, [storage, warnStorageFailure]);
|
|
936
|
+
const applySeen = useCallback4(
|
|
937
|
+
(next) => {
|
|
938
|
+
seenRef.current = next;
|
|
939
|
+
setSeen(next);
|
|
940
|
+
if (!storage) return;
|
|
941
|
+
try {
|
|
942
|
+
void Promise.resolve(storage.write(STORAGE_KEY, { seen: next })).catch(
|
|
943
|
+
warnStorageFailure
|
|
944
|
+
);
|
|
945
|
+
} catch (error) {
|
|
946
|
+
warnStorageFailure(error);
|
|
947
|
+
}
|
|
948
|
+
},
|
|
949
|
+
[storage, warnStorageFailure]
|
|
950
|
+
);
|
|
951
|
+
const resolve = useCallback4(
|
|
952
|
+
(hotspotId) => {
|
|
953
|
+
const hotspot = hotspotsById.get(hotspotId);
|
|
954
|
+
if (!hotspot) {
|
|
955
|
+
console.warn(`[guide] unknown hotspot "${hotspotId}"`);
|
|
956
|
+
return null;
|
|
957
|
+
}
|
|
958
|
+
return hotspot;
|
|
959
|
+
},
|
|
960
|
+
[hotspotsById]
|
|
961
|
+
);
|
|
962
|
+
const open = useCallback4(
|
|
963
|
+
(hotspotId) => {
|
|
964
|
+
if (!resolve(hotspotId)) return;
|
|
965
|
+
emit({ type: "hotspot:open", hotspotId });
|
|
966
|
+
if (seenRef.current.includes(hotspotId)) return;
|
|
967
|
+
applySeen([...seenRef.current, hotspotId]);
|
|
968
|
+
},
|
|
969
|
+
[resolve, applySeen, emit]
|
|
970
|
+
);
|
|
971
|
+
const startTour = useCallback4(
|
|
972
|
+
(hotspotId) => {
|
|
973
|
+
const hotspot = resolve(hotspotId);
|
|
974
|
+
if (!hotspot?.tourId) return;
|
|
975
|
+
if (!guide) {
|
|
976
|
+
warnNoGuide();
|
|
977
|
+
return;
|
|
978
|
+
}
|
|
979
|
+
void guide.start(hotspot.tourId).catch(warnTourStartFailure);
|
|
980
|
+
},
|
|
981
|
+
[resolve, guide, warnNoGuide, warnTourStartFailure]
|
|
982
|
+
);
|
|
983
|
+
const reset = useCallback4(() => applySeen([]), [applySeen]);
|
|
984
|
+
const shownRef = useRef3(/* @__PURE__ */ new Set());
|
|
985
|
+
const notifyShown = useCallback4(
|
|
986
|
+
(hotspotId) => {
|
|
987
|
+
if (shownRef.current.has(hotspotId)) return;
|
|
988
|
+
shownRef.current.add(hotspotId);
|
|
989
|
+
emit({ type: "hotspot:show", hotspotId });
|
|
990
|
+
},
|
|
991
|
+
[emit]
|
|
992
|
+
);
|
|
993
|
+
const value = useMemo5(
|
|
994
|
+
() => ({ hotspots, seen, translate, restored, open, startTour, reset, notifyShown }),
|
|
995
|
+
[hotspots, seen, translate, restored, open, startTour, reset, notifyShown]
|
|
996
|
+
);
|
|
997
|
+
return /* @__PURE__ */ jsx3(HotspotContext.Provider, { value, children });
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
// src/useHotspots.ts
|
|
1001
|
+
import { useContext as useContext6, useMemo as useMemo6 } from "react";
|
|
1002
|
+
function useHotspots() {
|
|
1003
|
+
const context = useContext6(HotspotContext);
|
|
1004
|
+
if (!context)
|
|
1005
|
+
throw new Error("[guide] useHotspots must be used inside a HotspotProvider");
|
|
1006
|
+
const { seen, translate, restored, open, startTour, reset, notifyShown } = context;
|
|
1007
|
+
const hotspots = useMemo6(
|
|
1008
|
+
() => context.hotspots.map((hotspot) => ({
|
|
1009
|
+
id: hotspot.id,
|
|
1010
|
+
target: hotspot.target,
|
|
1011
|
+
title: resolveText(hotspot.title, hotspot.titleKey, translate),
|
|
1012
|
+
body: resolveText(hotspot.body, hotspot.bodyKey, translate),
|
|
1013
|
+
seen: seen.includes(hotspot.id),
|
|
1014
|
+
tourId: hotspot.tourId,
|
|
1015
|
+
placement: hotspot.placement
|
|
1016
|
+
})),
|
|
1017
|
+
[context.hotspots, seen, translate]
|
|
1018
|
+
);
|
|
1019
|
+
return useMemo6(
|
|
1020
|
+
() => ({ hotspots, restored, open, startTour, reset, notifyShown }),
|
|
1021
|
+
[hotspots, restored, open, startTour, reset, notifyShown]
|
|
1022
|
+
);
|
|
1023
|
+
}
|
|
789
1024
|
export {
|
|
790
1025
|
ChecklistContext,
|
|
791
1026
|
ChecklistProvider,
|
|
792
1027
|
GuideContext,
|
|
793
1028
|
GuideProvider,
|
|
1029
|
+
HotspotContext,
|
|
1030
|
+
HotspotProvider,
|
|
794
1031
|
createBrowserStorage,
|
|
795
1032
|
createMemoryStorage,
|
|
796
1033
|
findMissingTargets,
|
|
797
1034
|
initialTourState,
|
|
798
1035
|
isChecklistProgress,
|
|
1036
|
+
isHotspotsProgress,
|
|
799
1037
|
isLiteralRoute,
|
|
800
1038
|
isTourProgress,
|
|
801
1039
|
matchRoute,
|
|
@@ -806,6 +1044,7 @@ export {
|
|
|
806
1044
|
useElementRect,
|
|
807
1045
|
useFocusTrap,
|
|
808
1046
|
useGuideStep,
|
|
1047
|
+
useHotspots,
|
|
809
1048
|
usePrefersReducedMotion,
|
|
810
1049
|
useTargetElement,
|
|
811
1050
|
useTour
|