@hub-kit/core 0.3.0 → 0.3.2

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.
@@ -1,4 +1,4 @@
1
- export type { TourContentBlock, TourDefinition, TourMap, TourPlacement, TourStep, } from "./types";
1
+ export type { TourContentBlock, TourDefinition, TourMap, TourOutcome, TourPlacement, TourSeenStore, TourStep, } from "./types";
2
2
  export { TourProvider } from "./tour-provider";
3
3
  export type { TourProviderProps } from "./tour-provider";
4
4
  export type { TourState } from "./tour-context";
@@ -11,4 +11,4 @@ export { TourCard } from "./tour-card";
11
11
  export type { TourCardProps } from "./tour-card";
12
12
  export { englishTourLabels } from "./labels";
13
13
  export type { TourLabels } from "./labels";
14
- export { hasSeenTour, markTourSeen, resetSeenTours } from "./seen-store";
14
+ export { hasSeenTour, localTourSeenStore, markTourSeen, resetSeenTours, } from "./seen-store";
@@ -5,4 +5,4 @@ export { TourButton } from "./tour-button.js";
5
5
  export { TourOverlay } from "./tour-overlay.js";
6
6
  export { TourCard } from "./tour-card.js";
7
7
  export { englishTourLabels } from "./labels.js";
8
- export { hasSeenTour, markTourSeen, resetSeenTours } from "./seen-store.js";
8
+ export { hasSeenTour, localTourSeenStore, markTourSeen, resetSeenTours, } from "./seen-store.js";
@@ -1,3 +1,5 @@
1
- export declare function hasSeenTour(tourId: string): boolean;
2
- export declare function markTourSeen(tourId: string): void;
1
+ import type { TourSeenStore } from "./types";
2
+ export declare function hasSeenTour(tourId: string, version?: number): boolean;
3
+ export declare function markTourSeen(tourId: string, version?: number): void;
4
+ export declare const localTourSeenStore: TourSeenStore;
3
5
  export declare function resetSeenTours(): void;
@@ -1,29 +1,45 @@
1
1
  const STORAGE_KEY = "hub-kit.tour.seen";
2
- function readSeenIds() {
2
+ function readEntries() {
3
3
  try {
4
4
  const raw = localStorage.getItem(STORAGE_KEY);
5
5
  const parsed = raw ? JSON.parse(raw) : null;
6
- return Array.isArray(parsed) ? parsed.filter((id) => typeof id === "string") : [];
6
+ if (!Array.isArray(parsed)) {
7
+ return [];
8
+ }
9
+ return parsed.flatMap((entry) => {
10
+ if (typeof entry === "string") {
11
+ return [{ id: entry, version: 1 }];
12
+ }
13
+ if (entry && typeof entry === "object" && "id" in entry) {
14
+ const record = entry;
15
+ if (typeof record.id === "string") {
16
+ return [{ id: record.id, version: Number(record.version) || 1 }];
17
+ }
18
+ }
19
+ return [];
20
+ });
7
21
  }
8
22
  catch {
9
23
  return [];
10
24
  }
11
25
  }
12
- export function hasSeenTour(tourId) {
13
- return readSeenIds().includes(tourId);
26
+ export function hasSeenTour(tourId, version = 1) {
27
+ return readEntries().some((entry) => entry.id === tourId && entry.version >= version);
14
28
  }
15
- export function markTourSeen(tourId) {
16
- const ids = readSeenIds();
17
- if (ids.includes(tourId)) {
18
- return;
19
- }
29
+ export function markTourSeen(tourId, version = 1) {
30
+ const entries = readEntries().filter((entry) => entry.id !== tourId);
20
31
  try {
21
- localStorage.setItem(STORAGE_KEY, JSON.stringify([...ids, tourId]));
32
+ localStorage.setItem(STORAGE_KEY, JSON.stringify([...entries, { id: tourId, version }]));
22
33
  }
23
34
  catch {
24
35
  return;
25
36
  }
26
37
  }
38
+ export const localTourSeenStore = {
39
+ isReady: true,
40
+ hasSeen: hasSeenTour,
41
+ markSeen: (tourId, version) => markTourSeen(tourId, version),
42
+ };
27
43
  export function resetSeenTours() {
28
44
  try {
29
45
  localStorage.removeItem(STORAGE_KEY);
@@ -80,6 +80,23 @@ export function TourOverlay() {
80
80
  wrapper.style.transition = `transform ${MOVE_DURATION} ease`;
81
81
  }
82
82
  }, [target]);
83
+ useEffect(() => {
84
+ if (!tour?.isOpen) {
85
+ return;
86
+ }
87
+ const body = document.body;
88
+ const previousOverflow = body.style.overflow;
89
+ const previousPadding = body.style.paddingRight;
90
+ const scrollbarWidth = window.innerWidth - document.documentElement.clientWidth;
91
+ body.style.overflow = "hidden";
92
+ if (scrollbarWidth > 0) {
93
+ body.style.paddingRight = `${scrollbarWidth}px`;
94
+ }
95
+ return () => {
96
+ body.style.overflow = previousOverflow;
97
+ body.style.paddingRight = previousPadding;
98
+ };
99
+ }, [tour?.isOpen]);
83
100
  useEffect(() => {
84
101
  if (!tour?.isOpen) {
85
102
  return;
@@ -101,7 +118,7 @@ export function TourOverlay() {
101
118
  if (!tour || !tour.isOpen || !step || !rect) {
102
119
  return null;
103
120
  }
104
- return (_jsxs("div", { className: "fixed inset-0 z-50", role: "presentation", children: [_jsx("div", { className: "absolute inset-0", style: { background: "var(--tour-backdrop, rgb(0 0 0 / 0.5))" } }), _jsx("div", { className: "absolute", style: {
121
+ return (_jsxs("div", { className: "fixed inset-0 z-50", role: "presentation", onClick: tour.skip, children: [_jsx("div", { className: "absolute inset-0", style: { background: "var(--tour-backdrop, rgb(0 0 0 / 0.5))" } }), _jsx("div", { className: "absolute", style: {
105
122
  top: rect.top - SPOTLIGHT_PADDING,
106
123
  left: rect.left - SPOTLIGHT_PADDING,
107
124
  width: rect.width + SPOTLIGHT_PADDING * 2,
@@ -1,9 +1,10 @@
1
1
  import { type ReactNode } from "react";
2
2
  import { type TourLabels } from "./labels";
3
- import type { TourMap } from "./types";
3
+ import type { TourMap, TourSeenStore } from "./types";
4
4
  export interface TourProviderProps {
5
5
  tours: TourMap;
6
6
  labels?: TourLabels;
7
+ seenStore?: TourSeenStore;
7
8
  children: ReactNode;
8
9
  }
9
- export declare function TourProvider({ tours, labels, children }: TourProviderProps): import("react").JSX.Element;
10
+ export declare function TourProvider({ tours, labels, seenStore, children, }: TourProviderProps): import("react").JSX.Element;
@@ -3,10 +3,12 @@ import { useCallback, useEffect, useMemo, useState } from "react";
3
3
  import { useRouterState } from "@tanstack/react-router";
4
4
  import { englishTourLabels } from "./labels.js";
5
5
  import { findTourTarget } from "./find-target.js";
6
- import { hasSeenTour, markTourSeen } from "./seen-store.js";
6
+ import { localTourSeenStore } from "./seen-store.js";
7
7
  import { TourContext } from "./tour-context.js";
8
8
  import { TourOverlay } from "./tour-overlay.js";
9
- export function TourProvider({ tours, labels = englishTourLabels, children }) {
9
+ const STABLE_FRAMES = 10;
10
+ const MAX_WAIT_FRAMES = 600;
11
+ export function TourProvider({ tours, labels = englishTourLabels, seenStore = localTourSeenStore, children, }) {
10
12
  const pathname = useRouterState({ select: (state) => state.location.pathname });
11
13
  const tour = tours[pathname] ?? null;
12
14
  const [openTourId, setOpenTourId] = useState(null);
@@ -20,20 +22,45 @@ export function TourProvider({ tours, labels = englishTourLabels, children }) {
20
22
  close();
21
23
  }, [pathname, close]);
22
24
  const tourId = tour?.id ?? null;
25
+ const tourVersion = tour?.version ?? 1;
23
26
  const autoStartAllowed = tour?.autoStart !== false;
24
27
  const firstTarget = tour?.steps[0]?.target ?? null;
28
+ const storeIsReady = seenStore.isReady;
29
+ const alreadySeen = tourId !== null && storeIsReady && seenStore.hasSeen(tourId, tourVersion);
25
30
  useEffect(() => {
26
- if (!tourId || !firstTarget || !autoStartAllowed || hasSeenTour(tourId)) {
31
+ if (!tourId || !firstTarget || !autoStartAllowed || !storeIsReady || alreadySeen) {
27
32
  return;
28
33
  }
29
- const frame = requestAnimationFrame(() => {
30
- if (findTourTarget(firstTarget)) {
31
- setStepIndex(0);
32
- setOpenTourId(tourId);
34
+ let frame = 0;
35
+ let attempts = 0;
36
+ let lastHeight = -1;
37
+ let stableFrames = 0;
38
+ const waitForSettledPage = () => {
39
+ attempts += 1;
40
+ if (attempts > MAX_WAIT_FRAMES) {
41
+ return;
33
42
  }
34
- });
43
+ const element = findTourTarget(firstTarget);
44
+ if (element) {
45
+ const height = element.getBoundingClientRect().height;
46
+ if (height > 0 && height === lastHeight) {
47
+ stableFrames += 1;
48
+ if (stableFrames >= STABLE_FRAMES) {
49
+ setStepIndex(0);
50
+ setOpenTourId(tourId);
51
+ return;
52
+ }
53
+ }
54
+ else {
55
+ stableFrames = 0;
56
+ }
57
+ lastHeight = height;
58
+ }
59
+ frame = requestAnimationFrame(waitForSettledPage);
60
+ };
61
+ frame = requestAnimationFrame(waitForSettledPage);
35
62
  return () => cancelAnimationFrame(frame);
36
- }, [tourId, firstTarget, autoStartAllowed]);
63
+ }, [tourId, firstTarget, autoStartAllowed, storeIsReady, alreadySeen]);
37
64
  const start = useCallback(() => {
38
65
  if (!tour || tour.steps.length === 0) {
39
66
  return;
@@ -41,22 +68,23 @@ export function TourProvider({ tours, labels = englishTourLabels, children }) {
41
68
  setStepIndex(0);
42
69
  setOpenTourId(tour.id);
43
70
  }, [tour]);
44
- const finish = useCallback(() => {
71
+ const finish = useCallback((status) => {
45
72
  if (tour) {
46
- markTourSeen(tour.id);
73
+ seenStore.markSeen(tour.id, tour.version ?? 1, { status, lastStep: stepIndex });
47
74
  }
48
75
  close();
49
- }, [tour, close]);
76
+ }, [tour, seenStore, stepIndex, close]);
50
77
  const next = useCallback(() => {
51
78
  if (!tour) {
52
79
  return;
53
80
  }
54
81
  if (stepIndex + 1 >= tour.steps.length) {
55
- finish();
82
+ finish("completed");
56
83
  return;
57
84
  }
58
85
  setStepIndex(stepIndex + 1);
59
86
  }, [tour, stepIndex, finish]);
87
+ const skip = useCallback(() => finish("skipped"), [finish]);
60
88
  const back = useCallback(() => {
61
89
  setStepIndex((index) => (index > 0 ? index - 1 : index));
62
90
  }, []);
@@ -73,8 +101,8 @@ export function TourProvider({ tours, labels = englishTourLabels, children }) {
73
101
  start,
74
102
  next,
75
103
  back,
76
- skip: finish,
104
+ skip: skip,
77
105
  };
78
- }, [tour, stepIndex, isOpen, labels, start, next, back, finish]);
106
+ }, [tour, stepIndex, isOpen, labels, start, next, back, skip]);
79
107
  return (_jsxs(TourContext.Provider, { value: value, children: [children, _jsx(TourOverlay, {})] }));
80
108
  }
@@ -40,5 +40,15 @@ export interface TourDefinition {
40
40
  id: string;
41
41
  steps: TourStep[];
42
42
  autoStart?: boolean;
43
+ version?: number;
44
+ }
45
+ export type TourOutcome = "skipped" | "completed";
46
+ export interface TourSeenStore {
47
+ isReady: boolean;
48
+ hasSeen: (tourId: string, version: number) => boolean;
49
+ markSeen: (tourId: string, version: number, outcome: {
50
+ status: TourOutcome;
51
+ lastStep: number;
52
+ }) => void;
43
53
  }
44
54
  export type TourMap = Record<string, TourDefinition>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hub-kit/core",
3
- "version": "0.3.0",
3
+ "version": "0.3.2",
4
4
  "description": "Shared pages, components and theme system for all Hub projects.",
5
5
  "type": "module",
6
6
  "files": [