@apollovisionlabs/guide-core 0.1.1 → 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 +295 -15
- package/dist/index.cjs +555 -16
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +202 -4
- package/dist/index.d.ts +202 -4
- package/dist/index.mjs +561 -16
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
package/dist/index.mjs
CHANGED
|
@@ -4,36 +4,51 @@
|
|
|
4
4
|
function createMemoryStorage(initial = {}) {
|
|
5
5
|
const store = new Map(Object.entries(initial));
|
|
6
6
|
return {
|
|
7
|
-
async read(
|
|
8
|
-
return store.get(
|
|
7
|
+
async read(key) {
|
|
8
|
+
return store.get(key) ?? null;
|
|
9
9
|
},
|
|
10
|
-
async write(
|
|
11
|
-
store.set(
|
|
10
|
+
async write(key, value) {
|
|
11
|
+
store.set(key, value);
|
|
12
12
|
}
|
|
13
13
|
};
|
|
14
14
|
}
|
|
15
15
|
function createBrowserStorage(namespace = "guide") {
|
|
16
|
-
const key = (
|
|
16
|
+
const key = (storageKey) => `${namespace}:${storageKey}`;
|
|
17
17
|
const available = () => typeof window !== "undefined" && !!window.localStorage;
|
|
18
18
|
return {
|
|
19
|
-
async read(
|
|
19
|
+
async read(storageKey) {
|
|
20
20
|
if (!available()) return null;
|
|
21
21
|
try {
|
|
22
|
-
const raw = window.localStorage.getItem(key(
|
|
22
|
+
const raw = window.localStorage.getItem(key(storageKey));
|
|
23
23
|
return raw ? JSON.parse(raw) : null;
|
|
24
24
|
} catch {
|
|
25
25
|
return null;
|
|
26
26
|
}
|
|
27
27
|
},
|
|
28
|
-
async write(
|
|
28
|
+
async write(storageKey, value) {
|
|
29
29
|
if (!available()) return;
|
|
30
30
|
try {
|
|
31
|
-
window.localStorage.setItem(key(
|
|
31
|
+
window.localStorage.setItem(key(storageKey), JSON.stringify(value));
|
|
32
32
|
} catch {
|
|
33
33
|
}
|
|
34
34
|
}
|
|
35
35
|
};
|
|
36
36
|
}
|
|
37
|
+
function isTourProgress(value) {
|
|
38
|
+
if (typeof value !== "object" || value === null) return false;
|
|
39
|
+
const candidate = value;
|
|
40
|
+
return typeof candidate.stepIndex === "number" && Number.isInteger(candidate.stepIndex) && candidate.stepIndex >= 0 && (candidate.status === "in-progress" || candidate.status === "completed");
|
|
41
|
+
}
|
|
42
|
+
function isChecklistProgress(value) {
|
|
43
|
+
if (typeof value !== "object" || value === null) return false;
|
|
44
|
+
const candidate = value;
|
|
45
|
+
return Array.isArray(candidate.completed) && candidate.completed.every((entry) => typeof entry === "string") && typeof candidate.dismissed === "boolean";
|
|
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
|
+
}
|
|
37
52
|
|
|
38
53
|
// src/matchRoute.ts
|
|
39
54
|
function segments(value) {
|
|
@@ -263,14 +278,34 @@ function findMissingTargets(tour, location, attribute = "data-guide") {
|
|
|
263
278
|
return tour.steps.filter((step) => !step.route || location === void 0 || matchRoute(step.route, location)).map((step) => step.target).filter((target) => !document.querySelector(targetSelector(target, attribute)));
|
|
264
279
|
}
|
|
265
280
|
|
|
266
|
-
// src/
|
|
267
|
-
import { jsx } from "react/jsx-runtime";
|
|
268
|
-
var GuideContext = createContext(null);
|
|
281
|
+
// src/resolveText.ts
|
|
269
282
|
function resolveText(value, key, translate) {
|
|
270
283
|
if (value !== void 0) return value;
|
|
271
284
|
if (key === void 0) return "";
|
|
272
285
|
return translate ? translate(key) : key;
|
|
273
286
|
}
|
|
287
|
+
|
|
288
|
+
// src/GuideProvider.tsx
|
|
289
|
+
import { jsx } from "react/jsx-runtime";
|
|
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
|
+
}
|
|
274
309
|
function GuideProvider({
|
|
275
310
|
tours,
|
|
276
311
|
children,
|
|
@@ -295,6 +330,7 @@ function GuideProvider({
|
|
|
295
330
|
const [state, dispatch] = useReducer(tourReducer, initialTourState);
|
|
296
331
|
const announce = useAnnouncer();
|
|
297
332
|
const focusOriginRef = useRef(null);
|
|
333
|
+
const lastElementRef = useRef(null);
|
|
298
334
|
const storageWarnedRef = useRef(false);
|
|
299
335
|
const warnStorageFailure = useCallback2((error) => {
|
|
300
336
|
if (storageWarnedRef.current) return;
|
|
@@ -330,6 +366,14 @@ function GuideProvider({
|
|
|
330
366
|
dispatch({ type: "NEXT", stepCount: tour.steps.length });
|
|
331
367
|
if (isLast) emit({ type: "tour:complete", tourId: tour.id });
|
|
332
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]);
|
|
333
377
|
const previous = useCallback2(() => dispatch({ type: "PREVIOUS" }), []);
|
|
334
378
|
const stop = useCallback2(() => {
|
|
335
379
|
if (tour) emit({ type: "tour:stop", tourId: tour.id, stepIndex: state.stepIndex });
|
|
@@ -354,7 +398,8 @@ function GuideProvider({
|
|
|
354
398
|
if (options?.from === void 0 && options?.resume !== false && storage) {
|
|
355
399
|
let progress = null;
|
|
356
400
|
try {
|
|
357
|
-
|
|
401
|
+
const stored = await storage.read(`tour:${tourId}`);
|
|
402
|
+
progress = isTourProgress(stored) ? stored : null;
|
|
358
403
|
} catch (error) {
|
|
359
404
|
warnStorageFailure(error);
|
|
360
405
|
}
|
|
@@ -421,18 +466,29 @@ function GuideProvider({
|
|
|
421
466
|
if (!status) return;
|
|
422
467
|
try {
|
|
423
468
|
void Promise.resolve(
|
|
424
|
-
storage.write(state.tourId
|
|
469
|
+
storage.write(`tour:${state.tourId}`, { status, stepIndex: state.stepIndex })
|
|
425
470
|
).catch(warnStorageFailure);
|
|
426
471
|
} catch (error) {
|
|
427
472
|
warnStorageFailure(error);
|
|
428
473
|
}
|
|
429
474
|
}, [storage, state.tourId, state.status, state.stepIndex, warnStorageFailure]);
|
|
475
|
+
useEffect4(() => {
|
|
476
|
+
if (element) lastElementRef.current = element;
|
|
477
|
+
}, [element]);
|
|
430
478
|
useEffect4(() => {
|
|
431
479
|
if (state.status !== "idle" && state.status !== "completed") return;
|
|
432
480
|
const origin = focusOriginRef.current;
|
|
433
|
-
|
|
481
|
+
const fallback = lastElementRef.current;
|
|
434
482
|
focusOriginRef.current = null;
|
|
435
|
-
|
|
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);
|
|
436
492
|
}, [state.status]);
|
|
437
493
|
const activeStep = useMemo(() => {
|
|
438
494
|
if (!tour || !step || !isActive) return null;
|
|
@@ -443,6 +499,8 @@ function GuideProvider({
|
|
|
443
499
|
stepCount: tour.steps.length,
|
|
444
500
|
element,
|
|
445
501
|
rect,
|
|
502
|
+
interactive: step.interactive === true || step.advanceOn !== void 0,
|
|
503
|
+
awaitsAction: step.advanceOn !== void 0,
|
|
446
504
|
title: resolveText(step.title, step.titleKey, translate),
|
|
447
505
|
body: resolveText(step.body, step.bodyKey, translate),
|
|
448
506
|
isFirst: state.stepIndex === 0,
|
|
@@ -486,20 +544,507 @@ function useGuideStep() {
|
|
|
486
544
|
if (!context) throw new Error("[guide] useGuideStep must be used inside a GuideProvider");
|
|
487
545
|
return context.activeStep;
|
|
488
546
|
}
|
|
547
|
+
|
|
548
|
+
// src/ChecklistProvider.tsx
|
|
549
|
+
import {
|
|
550
|
+
createContext as createContext2,
|
|
551
|
+
useCallback as useCallback3,
|
|
552
|
+
useContext as useContext3,
|
|
553
|
+
useEffect as useEffect5,
|
|
554
|
+
useMemo as useMemo3,
|
|
555
|
+
useRef as useRef2,
|
|
556
|
+
useState as useState5
|
|
557
|
+
} from "react";
|
|
558
|
+
import { jsx as jsx2 } from "react/jsx-runtime";
|
|
559
|
+
var ChecklistContext = createContext2(null);
|
|
560
|
+
var emptyProgress = { completed: [], dismissed: false };
|
|
561
|
+
function ChecklistProvider({
|
|
562
|
+
checklists,
|
|
563
|
+
children,
|
|
564
|
+
storage,
|
|
565
|
+
translate,
|
|
566
|
+
navigate,
|
|
567
|
+
onEvent
|
|
568
|
+
}) {
|
|
569
|
+
const checklistsById = useMemo3(() => {
|
|
570
|
+
const map = /* @__PURE__ */ new Map();
|
|
571
|
+
for (const candidate of checklists) map.set(candidate.id, candidate);
|
|
572
|
+
return map;
|
|
573
|
+
}, [checklists]);
|
|
574
|
+
const [progress, setProgress] = useState5(() => {
|
|
575
|
+
const initial = {};
|
|
576
|
+
for (const candidate of checklists) initial[candidate.id] = emptyProgress;
|
|
577
|
+
return initial;
|
|
578
|
+
});
|
|
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
|
+
});
|
|
585
|
+
const guide = useContext3(GuideContext);
|
|
586
|
+
const storageWarnedRef = useRef2(false);
|
|
587
|
+
const warnStorageFailure = useCallback3((error) => {
|
|
588
|
+
if (storageWarnedRef.current) return;
|
|
589
|
+
storageWarnedRef.current = true;
|
|
590
|
+
console.warn("[guide] storage failed; checklist progress will not be persisted", error);
|
|
591
|
+
}, []);
|
|
592
|
+
const noGuideWarnedRef = useRef2(false);
|
|
593
|
+
const warnNoGuide = useCallback3(() => {
|
|
594
|
+
if (noGuideWarnedRef.current) return;
|
|
595
|
+
noGuideWarnedRef.current = true;
|
|
596
|
+
console.warn("[guide] a checklist item needs a GuideProvider to launch a tour");
|
|
597
|
+
}, []);
|
|
598
|
+
const tourStartFailedWarnedRef = useRef2(false);
|
|
599
|
+
const warnTourStartFailure = useCallback3((error) => {
|
|
600
|
+
if (tourStartFailedWarnedRef.current) return;
|
|
601
|
+
tourStartFailedWarnedRef.current = true;
|
|
602
|
+
console.warn("[guide] starting a tour for a checklist item failed", error);
|
|
603
|
+
}, []);
|
|
604
|
+
const noNavigateWarnedRef = useRef2(false);
|
|
605
|
+
const warnNoNavigate = useCallback3(() => {
|
|
606
|
+
if (noNavigateWarnedRef.current) return;
|
|
607
|
+
noNavigateWarnedRef.current = true;
|
|
608
|
+
console.warn("[guide] a checklist item declares an href but no navigate function was provided");
|
|
609
|
+
}, []);
|
|
610
|
+
const onEventRef = useRef2(onEvent);
|
|
611
|
+
onEventRef.current = onEvent;
|
|
612
|
+
const emit = useCallback3((event) => onEventRef.current?.(event), []);
|
|
613
|
+
useEffect5(() => {
|
|
614
|
+
if (!storage) return;
|
|
615
|
+
let cancelled = false;
|
|
616
|
+
for (const candidate of checklists) {
|
|
617
|
+
void (async () => {
|
|
618
|
+
try {
|
|
619
|
+
const stored = await storage.read(`checklist:${candidate.id}`);
|
|
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
|
+
}
|
|
634
|
+
} catch (error) {
|
|
635
|
+
warnStorageFailure(error);
|
|
636
|
+
} finally {
|
|
637
|
+
if (!cancelled) {
|
|
638
|
+
setRestoredById((current) => ({ ...current, [candidate.id]: true }));
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
})();
|
|
642
|
+
}
|
|
643
|
+
return () => {
|
|
644
|
+
cancelled = true;
|
|
645
|
+
};
|
|
646
|
+
}, [storage]);
|
|
647
|
+
const applyProgress = useCallback3(
|
|
648
|
+
(checklistId, next) => {
|
|
649
|
+
const merged = { ...progressRef.current, [checklistId]: next };
|
|
650
|
+
progressRef.current = merged;
|
|
651
|
+
setProgress(merged);
|
|
652
|
+
if (!storage) return;
|
|
653
|
+
try {
|
|
654
|
+
void Promise.resolve(storage.write(`checklist:${checklistId}`, next)).catch(
|
|
655
|
+
warnStorageFailure
|
|
656
|
+
);
|
|
657
|
+
} catch (error) {
|
|
658
|
+
warnStorageFailure(error);
|
|
659
|
+
}
|
|
660
|
+
},
|
|
661
|
+
[storage, warnStorageFailure]
|
|
662
|
+
);
|
|
663
|
+
const resolveChecklist = useCallback3(
|
|
664
|
+
(checklistId) => {
|
|
665
|
+
const checklist = checklistsById.get(checklistId);
|
|
666
|
+
if (!checklist) {
|
|
667
|
+
console.warn(`[guide] unknown checklist "${checklistId}"`);
|
|
668
|
+
return null;
|
|
669
|
+
}
|
|
670
|
+
return checklist;
|
|
671
|
+
},
|
|
672
|
+
[checklistsById]
|
|
673
|
+
);
|
|
674
|
+
const resolveItem = useCallback3(
|
|
675
|
+
(checklistId, itemId) => {
|
|
676
|
+
const checklist = resolveChecklist(checklistId);
|
|
677
|
+
if (!checklist) return null;
|
|
678
|
+
const item = checklist.items.find((candidate) => candidate.id === itemId);
|
|
679
|
+
if (!item) {
|
|
680
|
+
console.warn(`[guide] unknown checklist item "${itemId}"`);
|
|
681
|
+
return null;
|
|
682
|
+
}
|
|
683
|
+
return { checklist, item };
|
|
684
|
+
},
|
|
685
|
+
[resolveChecklist]
|
|
686
|
+
);
|
|
687
|
+
const complete = useCallback3(
|
|
688
|
+
(checklistId, itemId) => {
|
|
689
|
+
const resolved = resolveItem(checklistId, itemId);
|
|
690
|
+
if (!resolved) return;
|
|
691
|
+
const { checklist } = resolved;
|
|
692
|
+
const current = progressRef.current[checklistId] ?? emptyProgress;
|
|
693
|
+
if (current.completed.includes(itemId)) return;
|
|
694
|
+
const wasComplete = checklist.items.length > 0 && checklist.items.every((candidate) => current.completed.includes(candidate.id));
|
|
695
|
+
const nextCompleted = [...current.completed, itemId];
|
|
696
|
+
applyProgress(checklistId, { ...current, completed: nextCompleted });
|
|
697
|
+
emit({ type: "checklist:item-complete", checklistId, itemId });
|
|
698
|
+
const isNowComplete = checklist.items.length > 0 && checklist.items.every((candidate) => nextCompleted.includes(candidate.id));
|
|
699
|
+
if (isNowComplete && !wasComplete) emit({ type: "checklist:complete", checklistId });
|
|
700
|
+
},
|
|
701
|
+
[resolveItem, applyProgress, emit]
|
|
702
|
+
);
|
|
703
|
+
const completeItemsForTour = useCallback3(
|
|
704
|
+
(tourId) => {
|
|
705
|
+
for (const candidate of checklists) {
|
|
706
|
+
for (const item of candidate.items) {
|
|
707
|
+
if (item.tourId === tourId) complete(candidate.id, item.id);
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
},
|
|
711
|
+
[checklists, complete]
|
|
712
|
+
);
|
|
713
|
+
const handledCompletionRef = useRef2(null);
|
|
714
|
+
useEffect5(() => {
|
|
715
|
+
const state = guide?.state;
|
|
716
|
+
if (!state || state.status !== "completed" || !state.tourId) {
|
|
717
|
+
handledCompletionRef.current = null;
|
|
718
|
+
return;
|
|
719
|
+
}
|
|
720
|
+
if (handledCompletionRef.current === state.tourId) return;
|
|
721
|
+
handledCompletionRef.current = state.tourId;
|
|
722
|
+
completeItemsForTour(state.tourId);
|
|
723
|
+
}, [guide?.state, completeItemsForTour]);
|
|
724
|
+
const toggle = useCallback3(
|
|
725
|
+
(checklistId, itemId) => {
|
|
726
|
+
const resolved = resolveItem(checklistId, itemId);
|
|
727
|
+
if (!resolved) return;
|
|
728
|
+
const current = progressRef.current[checklistId] ?? emptyProgress;
|
|
729
|
+
if (current.completed.includes(itemId)) {
|
|
730
|
+
const nextCompleted = current.completed.filter((id) => id !== itemId);
|
|
731
|
+
applyProgress(checklistId, { ...current, completed: nextCompleted });
|
|
732
|
+
return;
|
|
733
|
+
}
|
|
734
|
+
complete(checklistId, itemId);
|
|
735
|
+
},
|
|
736
|
+
[resolveItem, applyProgress, complete]
|
|
737
|
+
);
|
|
738
|
+
const dismiss = useCallback3(
|
|
739
|
+
(checklistId) => {
|
|
740
|
+
if (!resolveChecklist(checklistId)) return;
|
|
741
|
+
const current = progressRef.current[checklistId] ?? emptyProgress;
|
|
742
|
+
applyProgress(checklistId, { ...current, dismissed: true });
|
|
743
|
+
emit({ type: "checklist:dismiss", checklistId });
|
|
744
|
+
},
|
|
745
|
+
[resolveChecklist, applyProgress, emit]
|
|
746
|
+
);
|
|
747
|
+
const reset = useCallback3(
|
|
748
|
+
(checklistId) => {
|
|
749
|
+
if (!resolveChecklist(checklistId)) return;
|
|
750
|
+
applyProgress(checklistId, { completed: [], dismissed: false });
|
|
751
|
+
},
|
|
752
|
+
[resolveChecklist, applyProgress]
|
|
753
|
+
);
|
|
754
|
+
const activate = useCallback3(
|
|
755
|
+
(checklistId, itemId) => {
|
|
756
|
+
const resolved = resolveItem(checklistId, itemId);
|
|
757
|
+
if (!resolved) return;
|
|
758
|
+
const { item } = resolved;
|
|
759
|
+
if (item.tourId) {
|
|
760
|
+
if (!guide) {
|
|
761
|
+
warnNoGuide();
|
|
762
|
+
return;
|
|
763
|
+
}
|
|
764
|
+
void guide.start(item.tourId).catch(warnTourStartFailure);
|
|
765
|
+
return;
|
|
766
|
+
}
|
|
767
|
+
if (item.href) {
|
|
768
|
+
if (!navigate) {
|
|
769
|
+
warnNoNavigate();
|
|
770
|
+
return;
|
|
771
|
+
}
|
|
772
|
+
navigate(item.href);
|
|
773
|
+
return;
|
|
774
|
+
}
|
|
775
|
+
toggle(checklistId, itemId);
|
|
776
|
+
},
|
|
777
|
+
[resolveItem, guide, navigate, toggle, warnNoGuide, warnNoNavigate, warnTourStartFailure]
|
|
778
|
+
);
|
|
779
|
+
const value = useMemo3(
|
|
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]
|
|
792
|
+
);
|
|
793
|
+
return /* @__PURE__ */ jsx2(ChecklistContext.Provider, { value, children });
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
// src/useChecklist.ts
|
|
797
|
+
import { useContext as useContext4, useMemo as useMemo4 } from "react";
|
|
798
|
+
function useChecklist(checklistId) {
|
|
799
|
+
const context = useContext4(ChecklistContext);
|
|
800
|
+
if (!context)
|
|
801
|
+
throw new Error("[guide] useChecklist must be used inside a ChecklistProvider");
|
|
802
|
+
const checklist = context.checklists.find((entry) => entry.id === checklistId);
|
|
803
|
+
if (!checklist) throw new Error(`[guide] unknown checklist "${checklistId}"`);
|
|
804
|
+
const progress = context.progress[checklistId];
|
|
805
|
+
const completed = progress?.completed ?? [];
|
|
806
|
+
const dismissed = progress?.dismissed ?? false;
|
|
807
|
+
const translate = context.translate;
|
|
808
|
+
const restored = context.restored[checklistId] ?? true;
|
|
809
|
+
const items = useMemo4(
|
|
810
|
+
() => checklist.items.map((item) => ({
|
|
811
|
+
id: item.id,
|
|
812
|
+
title: resolveText(item.title, item.titleKey, translate),
|
|
813
|
+
body: resolveText(item.body, item.bodyKey, translate),
|
|
814
|
+
completed: completed.includes(item.id),
|
|
815
|
+
tourId: item.tourId,
|
|
816
|
+
href: item.href
|
|
817
|
+
})),
|
|
818
|
+
[checklist, completed, translate]
|
|
819
|
+
);
|
|
820
|
+
const total = checklist.items.length;
|
|
821
|
+
const completedCount = items.filter((item) => item.completed).length;
|
|
822
|
+
const isComplete = total > 0 && completedCount === total;
|
|
823
|
+
const { activate, toggle, complete, dismiss, reset } = context;
|
|
824
|
+
return useMemo4(
|
|
825
|
+
() => ({
|
|
826
|
+
items,
|
|
827
|
+
completedCount,
|
|
828
|
+
total,
|
|
829
|
+
isComplete,
|
|
830
|
+
dismissed,
|
|
831
|
+
restored,
|
|
832
|
+
activate: (itemId) => activate(checklistId, itemId),
|
|
833
|
+
toggle: (itemId) => toggle(checklistId, itemId),
|
|
834
|
+
complete: (itemId) => complete(checklistId, itemId),
|
|
835
|
+
dismiss: () => dismiss(checklistId),
|
|
836
|
+
reset: () => reset(checklistId)
|
|
837
|
+
}),
|
|
838
|
+
[
|
|
839
|
+
items,
|
|
840
|
+
completedCount,
|
|
841
|
+
total,
|
|
842
|
+
isComplete,
|
|
843
|
+
dismissed,
|
|
844
|
+
restored,
|
|
845
|
+
activate,
|
|
846
|
+
toggle,
|
|
847
|
+
complete,
|
|
848
|
+
dismiss,
|
|
849
|
+
reset,
|
|
850
|
+
checklistId
|
|
851
|
+
]
|
|
852
|
+
);
|
|
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
|
+
}
|
|
489
1024
|
export {
|
|
1025
|
+
ChecklistContext,
|
|
1026
|
+
ChecklistProvider,
|
|
490
1027
|
GuideContext,
|
|
491
1028
|
GuideProvider,
|
|
1029
|
+
HotspotContext,
|
|
1030
|
+
HotspotProvider,
|
|
492
1031
|
createBrowserStorage,
|
|
493
1032
|
createMemoryStorage,
|
|
494
1033
|
findMissingTargets,
|
|
495
1034
|
initialTourState,
|
|
1035
|
+
isChecklistProgress,
|
|
1036
|
+
isHotspotsProgress,
|
|
496
1037
|
isLiteralRoute,
|
|
1038
|
+
isTourProgress,
|
|
497
1039
|
matchRoute,
|
|
1040
|
+
resolveText,
|
|
498
1041
|
tourReducer,
|
|
499
1042
|
useAnnouncer,
|
|
1043
|
+
useChecklist,
|
|
500
1044
|
useElementRect,
|
|
501
1045
|
useFocusTrap,
|
|
502
1046
|
useGuideStep,
|
|
1047
|
+
useHotspots,
|
|
503
1048
|
usePrefersReducedMotion,
|
|
504
1049
|
useTargetElement,
|
|
505
1050
|
useTour
|