@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/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
- if (!origin) return;
523
+ const fallback = lastElementRef.current;
485
524
  focusOriginRef.current = null;
486
- if (typeof document !== "undefined" && document.contains(origin)) origin.focus();
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
- void (async () => {
597
- const restored = {};
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)) restored[candidate.id] = 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
- if (!cancelled && Object.keys(restored).length > 0) {
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
- () => ({ checklists, progress, translate, activate, toggle, complete, dismiss, reset }),
759
- [checklists, progress, translate, activate, toggle, complete, dismiss, reset]
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