@pablo2410/shared-ui 0.19.1 → 0.21.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/dist/{chunk-5RY3AJKI.js → chunk-57T6QK3M.js} +40 -4
- package/dist/chunk-57T6QK3M.js.map +1 -0
- package/dist/services/index.d.ts +25 -1
- package/dist/services/index.js +4 -2
- package/dist/shell/index.js +1 -1
- package/dist/shell/oplytics-shell.css +1 -1
- package/dist/tour/index.d.ts +198 -0
- package/dist/tour/index.js +443 -0
- package/dist/tour/index.js.map +1 -0
- package/package.json +5 -1
- package/dist/chunk-5RY3AJKI.js.map +0 -1
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import * as React from 'react';
|
|
2
|
+
import { ReactNode } from 'react';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Shared types for the Opi onboarding tour engine.
|
|
6
|
+
*
|
|
7
|
+
* Deliberately minimal for the lightweight ship (2026-09-11): a linear list
|
|
8
|
+
* of steps, one per granted service, shown as an overlay on the Portal
|
|
9
|
+
* Service Hub. No cross-origin resume, no setup-task step kind yet — those
|
|
10
|
+
* belong to the heavier enterprise-setup-concierge track, which is scoped
|
|
11
|
+
* and content-authored separately before it gets an engine.
|
|
12
|
+
*/
|
|
13
|
+
type TourStepKind = "intro" | "service" | "outro";
|
|
14
|
+
/**
|
|
15
|
+
* Where a "service"-kind step should highlight a live element on the page,
|
|
16
|
+
* instead of rendering as a centered modal. Spotlighting is a rendering mode
|
|
17
|
+
* of a service step, not a distinct step kind — intro/outro never spotlight
|
|
18
|
+
* anything, and a service step authored without this still renders as the
|
|
19
|
+
* original centered modal.
|
|
20
|
+
*
|
|
21
|
+
* Deliberately same-origin only: the consumer's own DOM must contain an
|
|
22
|
+
* element carrying `data-tour-target="<selector>"`. A cross-origin tour (a
|
|
23
|
+
* subdomain spotlighting its own features) mints its own selector values —
|
|
24
|
+
* this type doesn't know or care which app is consuming it.
|
|
25
|
+
*/
|
|
26
|
+
interface TourSpotlightTarget {
|
|
27
|
+
/** Matches a `data-tour-target` attribute value in the consumer's DOM. */
|
|
28
|
+
selector: string;
|
|
29
|
+
/** Where to place the explanation panel relative to the highlighted
|
|
30
|
+
* element. "auto" (default) picks based on available viewport space,
|
|
31
|
+
* falling back bottom -> top -> right -> left. */
|
|
32
|
+
placement?: "top" | "bottom" | "left" | "right" | "auto";
|
|
33
|
+
}
|
|
34
|
+
interface TourStep {
|
|
35
|
+
/** Stable id — used for progress persistence and analytics, not display. */
|
|
36
|
+
id: string;
|
|
37
|
+
kind: TourStepKind;
|
|
38
|
+
/** Short label shown in the step dots / progress indicator. */
|
|
39
|
+
label: string;
|
|
40
|
+
/** Scripted fallback narration — always renders first; an AI narration
|
|
41
|
+
* pass (via ai.askOpi) may replace it once it resolves. Never blocks. */
|
|
42
|
+
fallbackText: string;
|
|
43
|
+
/** 2-4 bullets shown under the narration. Empty for intro/outro steps.
|
|
44
|
+
* Superseded by `features` when both are present — kept for back-compat
|
|
45
|
+
* with any step that hasn't been upgraded to the richer shape. */
|
|
46
|
+
highlights?: string[];
|
|
47
|
+
/** Named feature call-outs, richer than a flat bullet — rendered instead
|
|
48
|
+
* of `highlights` when present. Each `name` is also the natural spotlight
|
|
49
|
+
* target label a later in-app tour (inside the service itself) can reuse. */
|
|
50
|
+
features?: {
|
|
51
|
+
name: string;
|
|
52
|
+
detail: string;
|
|
53
|
+
}[];
|
|
54
|
+
/** The one action this step offers, if any. */
|
|
55
|
+
action?: {
|
|
56
|
+
label: string;
|
|
57
|
+
/** Absolute URL (cross-origin service) or in-app path. */
|
|
58
|
+
href: string;
|
|
59
|
+
external?: boolean;
|
|
60
|
+
};
|
|
61
|
+
/** Which catalog service slug this step is about, for narration context / readiness lookups. Absent for intro/outro. */
|
|
62
|
+
serviceSlug?: string;
|
|
63
|
+
/** Highlight a live element on the page instead of a centered modal. See
|
|
64
|
+
* TourSpotlightTarget's own doc comment. Absent = today's centered modal. */
|
|
65
|
+
spotlight?: TourSpotlightTarget;
|
|
66
|
+
}
|
|
67
|
+
type TourStatus = "unseen" | "in-progress" | "done" | "dismissed";
|
|
68
|
+
interface TourProgress {
|
|
69
|
+
status: TourStatus;
|
|
70
|
+
/** id of the step the user was last on. */
|
|
71
|
+
lastStepId: string | null;
|
|
72
|
+
updatedAt: string;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
interface TourShellSkin {
|
|
76
|
+
accent: string;
|
|
77
|
+
overlayBg: string;
|
|
78
|
+
panelBg: string;
|
|
79
|
+
panelBorder: string;
|
|
80
|
+
mutedText: string;
|
|
81
|
+
bodyText: string;
|
|
82
|
+
softText: string;
|
|
83
|
+
}
|
|
84
|
+
interface TourShellProps {
|
|
85
|
+
step: TourStep;
|
|
86
|
+
index: number;
|
|
87
|
+
total: number;
|
|
88
|
+
isFirst: boolean;
|
|
89
|
+
isLast: boolean;
|
|
90
|
+
onNext: () => void;
|
|
91
|
+
onBack: () => void;
|
|
92
|
+
onDismiss: () => void;
|
|
93
|
+
/** Narration to render — pass the live AI reply once resolved; while
|
|
94
|
+
* pending or on failure, pass `step.fallbackText` so the tour never
|
|
95
|
+
* shows a blank state. */
|
|
96
|
+
narration: string;
|
|
97
|
+
/** True while an AI narration pass is in flight — shows a subtle pulse, never blocks Next. */
|
|
98
|
+
narrationPending?: boolean;
|
|
99
|
+
/** Rendered below the narration/highlights — e.g. a docked OpiAskBar. */
|
|
100
|
+
children?: ReactNode;
|
|
101
|
+
skin?: TourShellSkin;
|
|
102
|
+
/** Orb / avatar mark shown in the header — pass OpiOrb from the consumer. */
|
|
103
|
+
orb?: ReactNode;
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Full-viewport dismissible overlay with a centered panel. Esc and the
|
|
107
|
+
* backdrop both dismiss; focus is trapped to the panel while open.
|
|
108
|
+
*/
|
|
109
|
+
declare function TourShell({ step, index, total, isFirst, isLast, onNext, onBack, onDismiss, narration, narrationPending, children, skin, orb, }: TourShellProps): React.JSX.Element;
|
|
110
|
+
|
|
111
|
+
interface UseTourOptions {
|
|
112
|
+
/** Called whenever the current step changes (not on initial mount). */
|
|
113
|
+
onStepChange?: (step: TourStep, index: number) => void;
|
|
114
|
+
/** Called once when the user reaches the last step and advances past it. */
|
|
115
|
+
onComplete?: () => void;
|
|
116
|
+
/** Called when the user dismisses the tour before completion. */
|
|
117
|
+
onDismiss?: () => void;
|
|
118
|
+
/** Step id to resume on, if the tour was already in progress. */
|
|
119
|
+
initialStepId?: string | null;
|
|
120
|
+
}
|
|
121
|
+
interface UseTourResult {
|
|
122
|
+
step: TourStep;
|
|
123
|
+
index: number;
|
|
124
|
+
total: number;
|
|
125
|
+
isFirst: boolean;
|
|
126
|
+
isLast: boolean;
|
|
127
|
+
next: () => void;
|
|
128
|
+
back: () => void;
|
|
129
|
+
goto: (stepId: string) => void;
|
|
130
|
+
dismiss: () => void;
|
|
131
|
+
}
|
|
132
|
+
declare function useTour(steps: TourStep[], opts?: UseTourOptions): UseTourResult;
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Pure geometry behind the spotlight rendering mode — no React, no DOM
|
|
136
|
+
* access, just numbers in and numbers out. Factored out so it's testable
|
|
137
|
+
* without jsdom (this package has none — see tourMachine.ts for the same
|
|
138
|
+
* split/precedent on the step-index state machine).
|
|
139
|
+
*
|
|
140
|
+
* TourShell is the consumer: given a target element's measured rect (from
|
|
141
|
+
* useSpotlight.ts) and the current viewport size, it needs to know (a) the
|
|
142
|
+
* four backdrop-frame rectangles that surround the target without ever
|
|
143
|
+
* covering it, and (b) where to place the explanation panel so it doesn't
|
|
144
|
+
* overflow the viewport, honoring a preferred placement when there's room.
|
|
145
|
+
*/
|
|
146
|
+
interface Rect {
|
|
147
|
+
top: number;
|
|
148
|
+
left: number;
|
|
149
|
+
width: number;
|
|
150
|
+
height: number;
|
|
151
|
+
}
|
|
152
|
+
interface Size {
|
|
153
|
+
width: number;
|
|
154
|
+
height: number;
|
|
155
|
+
}
|
|
156
|
+
type Placement = "top" | "bottom" | "left" | "right" | "auto";
|
|
157
|
+
/** The four backdrop segments that frame a target rect without covering it.
|
|
158
|
+
* Each is a plain rect in viewport (fixed-position) coordinates. A segment
|
|
159
|
+
* with zero width or height (e.g. the target touches a viewport edge) is
|
|
160
|
+
* still returned — the caller can skip rendering a degenerate rect, but
|
|
161
|
+
* the geometry itself doesn't special-case it. */
|
|
162
|
+
interface SpotlightFrame {
|
|
163
|
+
top: Rect;
|
|
164
|
+
bottom: Rect;
|
|
165
|
+
left: Rect;
|
|
166
|
+
right: Rect;
|
|
167
|
+
}
|
|
168
|
+
/** Frames `target` within a `viewport` sized 0,0 to width,height. */
|
|
169
|
+
declare function computeSpotlightFrame(target: Rect, viewport: Size): SpotlightFrame;
|
|
170
|
+
type ConcretePlacement = Exclude<Placement, "auto">;
|
|
171
|
+
interface PanelPosition {
|
|
172
|
+
top: number;
|
|
173
|
+
left: number;
|
|
174
|
+
placement: ConcretePlacement;
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* Where to place a panel of `panelSize` next to `target`, preferring
|
|
178
|
+
* `placement` but falling back through the other three sides (then finally
|
|
179
|
+
* clamping into the viewport) if the preferred side doesn't have room.
|
|
180
|
+
*/
|
|
181
|
+
declare function computePanelPosition(target: Rect, panelSize: Size, viewport: Size, placement?: Placement): PanelPosition;
|
|
182
|
+
|
|
183
|
+
interface UseSpotlightResult {
|
|
184
|
+
/** The target's current viewport-relative rect, or null while unresolved. */
|
|
185
|
+
rect: Rect | null;
|
|
186
|
+
/** False if no element in the DOM currently carries this selector —
|
|
187
|
+
* callers must fall back to the non-spotlight rendering path, never
|
|
188
|
+
* throw or render a spotlight around nothing. */
|
|
189
|
+
found: boolean;
|
|
190
|
+
}
|
|
191
|
+
/**
|
|
192
|
+
* @param selector The `data-tour-target` attribute value to look for
|
|
193
|
+
* (e.g. "service-card-policy-deployment"). Pass `null` to disable
|
|
194
|
+
* resolution entirely (e.g. the current step has no spotlight).
|
|
195
|
+
*/
|
|
196
|
+
declare function useSpotlight(selector: string | null): UseSpotlightResult;
|
|
197
|
+
|
|
198
|
+
export { type PanelPosition, type Placement, type Rect, type Size, type SpotlightFrame, type TourProgress, TourShell, type TourShellProps, type TourShellSkin, type TourSpotlightTarget, type TourStatus, type TourStep, type TourStepKind, type UseSpotlightResult, type UseTourOptions, type UseTourResult, computePanelPosition, computeSpotlightFrame, useSpotlight, useTour };
|
|
@@ -0,0 +1,443 @@
|
|
|
1
|
+
// src/tour/TourShell.tsx
|
|
2
|
+
import { useEffect, useRef } from "react";
|
|
3
|
+
import {
|
|
4
|
+
X,
|
|
5
|
+
ChevronLeft,
|
|
6
|
+
ChevronRight,
|
|
7
|
+
ArrowRight,
|
|
8
|
+
ExternalLink
|
|
9
|
+
} from "lucide-react";
|
|
10
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
11
|
+
var defaultSkin = {
|
|
12
|
+
accent: "#8C34E9",
|
|
13
|
+
overlayBg: "rgba(10,14,26,0.72)",
|
|
14
|
+
panelBg: "#0e1624",
|
|
15
|
+
panelBorder: "1px solid #1e2738",
|
|
16
|
+
mutedText: "#596475",
|
|
17
|
+
bodyText: "#c0c6d0",
|
|
18
|
+
softText: "#8890a0"
|
|
19
|
+
};
|
|
20
|
+
function TourShell({
|
|
21
|
+
step,
|
|
22
|
+
index,
|
|
23
|
+
total,
|
|
24
|
+
isFirst,
|
|
25
|
+
isLast,
|
|
26
|
+
onNext,
|
|
27
|
+
onBack,
|
|
28
|
+
onDismiss,
|
|
29
|
+
narration,
|
|
30
|
+
narrationPending,
|
|
31
|
+
children,
|
|
32
|
+
skin = defaultSkin,
|
|
33
|
+
orb
|
|
34
|
+
}) {
|
|
35
|
+
const panelRef = useRef(null);
|
|
36
|
+
useEffect(() => {
|
|
37
|
+
const onKeyDown = (e) => {
|
|
38
|
+
if (e.key === "Escape") onDismiss();
|
|
39
|
+
};
|
|
40
|
+
document.addEventListener("keydown", onKeyDown);
|
|
41
|
+
return () => document.removeEventListener("keydown", onKeyDown);
|
|
42
|
+
}, [onDismiss]);
|
|
43
|
+
useEffect(() => {
|
|
44
|
+
panelRef.current?.focus();
|
|
45
|
+
}, [step.id]);
|
|
46
|
+
return /* @__PURE__ */ jsx(
|
|
47
|
+
"div",
|
|
48
|
+
{
|
|
49
|
+
className: "fixed inset-0 z-50 flex items-center justify-center p-4",
|
|
50
|
+
style: { background: skin.overlayBg },
|
|
51
|
+
onClick: (e) => {
|
|
52
|
+
if (e.target === e.currentTarget) onDismiss();
|
|
53
|
+
},
|
|
54
|
+
children: /* @__PURE__ */ jsxs(
|
|
55
|
+
"div",
|
|
56
|
+
{
|
|
57
|
+
ref: panelRef,
|
|
58
|
+
tabIndex: -1,
|
|
59
|
+
role: "dialog",
|
|
60
|
+
"aria-modal": "true",
|
|
61
|
+
"aria-label": step.label,
|
|
62
|
+
className: "w-full max-w-md rounded-xl overflow-hidden flex flex-col max-h-[85vh] outline-none",
|
|
63
|
+
style: { background: skin.panelBg, border: skin.panelBorder },
|
|
64
|
+
children: [
|
|
65
|
+
/* @__PURE__ */ jsxs(
|
|
66
|
+
"div",
|
|
67
|
+
{
|
|
68
|
+
className: "flex items-center justify-between gap-3 px-5 py-4 shrink-0",
|
|
69
|
+
style: {
|
|
70
|
+
background: "linear-gradient(135deg, #2a1450 0%, #1a0e33 100%)"
|
|
71
|
+
},
|
|
72
|
+
children: [
|
|
73
|
+
/* @__PURE__ */ jsxs("div", { className: "flex items-center gap-2.5 min-w-0", children: [
|
|
74
|
+
orb,
|
|
75
|
+
/* @__PURE__ */ jsx("span", { className: "text-sm font-bold text-white truncate", children: step.label })
|
|
76
|
+
] }),
|
|
77
|
+
/* @__PURE__ */ jsx(
|
|
78
|
+
"button",
|
|
79
|
+
{
|
|
80
|
+
onClick: onDismiss,
|
|
81
|
+
className: "p-1 rounded hover:bg-white/10 transition-colors shrink-0",
|
|
82
|
+
title: "Skip tour",
|
|
83
|
+
children: /* @__PURE__ */ jsx(X, { size: 16, className: "text-white/70" })
|
|
84
|
+
}
|
|
85
|
+
)
|
|
86
|
+
]
|
|
87
|
+
}
|
|
88
|
+
),
|
|
89
|
+
/* @__PURE__ */ jsx(
|
|
90
|
+
"div",
|
|
91
|
+
{
|
|
92
|
+
className: "flex items-center gap-1.5 px-5 py-3 shrink-0",
|
|
93
|
+
style: { borderBottom: skin.panelBorder },
|
|
94
|
+
children: Array.from({ length: total }, (_, i) => /* @__PURE__ */ jsx(
|
|
95
|
+
"span",
|
|
96
|
+
{
|
|
97
|
+
className: "h-1.5 rounded-full transition-all",
|
|
98
|
+
style: {
|
|
99
|
+
width: i === index ? 20 : 8,
|
|
100
|
+
background: i === index ? skin.accent : skin.mutedText,
|
|
101
|
+
opacity: i === index ? 1 : 0.4
|
|
102
|
+
}
|
|
103
|
+
},
|
|
104
|
+
i
|
|
105
|
+
))
|
|
106
|
+
}
|
|
107
|
+
),
|
|
108
|
+
/* @__PURE__ */ jsxs("div", { className: "overflow-y-auto px-5 py-4 space-y-4", children: [
|
|
109
|
+
/* @__PURE__ */ jsx(
|
|
110
|
+
"p",
|
|
111
|
+
{
|
|
112
|
+
className: "text-[13px] leading-relaxed transition-opacity",
|
|
113
|
+
style: {
|
|
114
|
+
color: skin.bodyText,
|
|
115
|
+
opacity: narrationPending ? 0.7 : 1
|
|
116
|
+
},
|
|
117
|
+
children: narration
|
|
118
|
+
}
|
|
119
|
+
),
|
|
120
|
+
step.highlights && step.highlights.length > 0 && /* @__PURE__ */ jsx("ul", { className: "space-y-1.5", children: step.highlights.map((h, i) => /* @__PURE__ */ jsxs(
|
|
121
|
+
"li",
|
|
122
|
+
{
|
|
123
|
+
className: "text-[12.5px] leading-relaxed pl-3 relative",
|
|
124
|
+
style: { color: skin.softText },
|
|
125
|
+
children: [
|
|
126
|
+
/* @__PURE__ */ jsx(
|
|
127
|
+
"span",
|
|
128
|
+
{
|
|
129
|
+
className: "absolute left-0",
|
|
130
|
+
style: { color: skin.mutedText },
|
|
131
|
+
children: "\u2022"
|
|
132
|
+
}
|
|
133
|
+
),
|
|
134
|
+
h
|
|
135
|
+
]
|
|
136
|
+
},
|
|
137
|
+
i
|
|
138
|
+
)) }),
|
|
139
|
+
step.action && /* @__PURE__ */ jsxs(
|
|
140
|
+
"a",
|
|
141
|
+
{
|
|
142
|
+
href: step.action.href,
|
|
143
|
+
target: step.action.external ? "_blank" : void 0,
|
|
144
|
+
rel: step.action.external ? "noopener noreferrer" : void 0,
|
|
145
|
+
className: "inline-flex items-center gap-1.5 px-3.5 py-2 rounded-lg text-[13px] font-semibold transition-opacity hover:opacity-90",
|
|
146
|
+
style: { background: `${skin.accent}20`, color: skin.accent },
|
|
147
|
+
children: [
|
|
148
|
+
step.action.label,
|
|
149
|
+
step.action.external ? /* @__PURE__ */ jsx(ExternalLink, { size: 13 }) : /* @__PURE__ */ jsx(ArrowRight, { size: 13 })
|
|
150
|
+
]
|
|
151
|
+
}
|
|
152
|
+
),
|
|
153
|
+
children
|
|
154
|
+
] }),
|
|
155
|
+
/* @__PURE__ */ jsxs(
|
|
156
|
+
"div",
|
|
157
|
+
{
|
|
158
|
+
className: "flex items-center justify-between gap-2 px-5 py-3.5 shrink-0",
|
|
159
|
+
style: { borderTop: skin.panelBorder },
|
|
160
|
+
children: [
|
|
161
|
+
/* @__PURE__ */ jsx(
|
|
162
|
+
"button",
|
|
163
|
+
{
|
|
164
|
+
onClick: onDismiss,
|
|
165
|
+
className: "text-[12px] font-medium transition-colors hover:underline",
|
|
166
|
+
style: { color: skin.mutedText },
|
|
167
|
+
children: "Skip tour"
|
|
168
|
+
}
|
|
169
|
+
),
|
|
170
|
+
/* @__PURE__ */ jsxs("div", { className: "flex items-center gap-2", children: [
|
|
171
|
+
!isFirst && /* @__PURE__ */ jsxs(
|
|
172
|
+
"button",
|
|
173
|
+
{
|
|
174
|
+
onClick: onBack,
|
|
175
|
+
className: "flex items-center gap-1 px-3 py-1.5 rounded-md text-[12.5px] font-medium transition-colors",
|
|
176
|
+
style: { color: skin.bodyText, border: skin.panelBorder },
|
|
177
|
+
children: [
|
|
178
|
+
/* @__PURE__ */ jsx(ChevronLeft, { size: 13 }),
|
|
179
|
+
"Back"
|
|
180
|
+
]
|
|
181
|
+
}
|
|
182
|
+
),
|
|
183
|
+
/* @__PURE__ */ jsxs(
|
|
184
|
+
"button",
|
|
185
|
+
{
|
|
186
|
+
onClick: onNext,
|
|
187
|
+
className: "flex items-center gap-1 px-3.5 py-1.5 rounded-md text-[12.5px] font-bold text-white transition-opacity hover:opacity-90",
|
|
188
|
+
style: {
|
|
189
|
+
background: "linear-gradient(135deg, #8C34E9 0%, #5B1FA6 100%)"
|
|
190
|
+
},
|
|
191
|
+
children: [
|
|
192
|
+
isLast ? "Done" : "Next",
|
|
193
|
+
!isLast && /* @__PURE__ */ jsx(ChevronRight, { size: 13 })
|
|
194
|
+
]
|
|
195
|
+
}
|
|
196
|
+
)
|
|
197
|
+
] })
|
|
198
|
+
]
|
|
199
|
+
}
|
|
200
|
+
)
|
|
201
|
+
]
|
|
202
|
+
}
|
|
203
|
+
)
|
|
204
|
+
}
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// src/tour/useTour.ts
|
|
209
|
+
import { useCallback, useState } from "react";
|
|
210
|
+
|
|
211
|
+
// src/tour/tourMachine.ts
|
|
212
|
+
function resolveInitialIndex(steps, initialStepId) {
|
|
213
|
+
if (!initialStepId) return 0;
|
|
214
|
+
const found = steps.findIndex((s) => s.id === initialStepId);
|
|
215
|
+
return found === -1 ? 0 : found;
|
|
216
|
+
}
|
|
217
|
+
function nextIndex(steps, current) {
|
|
218
|
+
if (current >= steps.length - 1) {
|
|
219
|
+
return { index: current, completed: true };
|
|
220
|
+
}
|
|
221
|
+
return { index: current + 1, completed: false };
|
|
222
|
+
}
|
|
223
|
+
function backIndex(current) {
|
|
224
|
+
return Math.max(0, current - 1);
|
|
225
|
+
}
|
|
226
|
+
function gotoIndex(steps, stepId, current) {
|
|
227
|
+
const target = steps.findIndex((s) => s.id === stepId);
|
|
228
|
+
return target === -1 ? current : target;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// src/tour/useTour.ts
|
|
232
|
+
function useTour(steps, opts = {}) {
|
|
233
|
+
if (steps.length === 0) {
|
|
234
|
+
throw new Error("useTour requires at least one step");
|
|
235
|
+
}
|
|
236
|
+
const [index, setIndex] = useState(
|
|
237
|
+
() => resolveInitialIndex(steps, opts.initialStepId)
|
|
238
|
+
);
|
|
239
|
+
const next = useCallback(() => {
|
|
240
|
+
const result = nextIndex(steps, index);
|
|
241
|
+
if (result.completed) {
|
|
242
|
+
opts.onComplete?.();
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
setIndex(result.index);
|
|
246
|
+
opts.onStepChange?.(steps[result.index], result.index);
|
|
247
|
+
}, [index, steps]);
|
|
248
|
+
const back = useCallback(() => {
|
|
249
|
+
const target = backIndex(index);
|
|
250
|
+
if (target === index) return;
|
|
251
|
+
setIndex(target);
|
|
252
|
+
opts.onStepChange?.(steps[target], target);
|
|
253
|
+
}, [index, steps]);
|
|
254
|
+
const goto = useCallback(
|
|
255
|
+
(stepId) => {
|
|
256
|
+
const target = gotoIndex(steps, stepId, index);
|
|
257
|
+
if (target === index) return;
|
|
258
|
+
setIndex(target);
|
|
259
|
+
opts.onStepChange?.(steps[target], target);
|
|
260
|
+
},
|
|
261
|
+
[steps, index]
|
|
262
|
+
);
|
|
263
|
+
const dismiss = useCallback(() => {
|
|
264
|
+
opts.onDismiss?.();
|
|
265
|
+
}, []);
|
|
266
|
+
return {
|
|
267
|
+
step: steps[index],
|
|
268
|
+
index,
|
|
269
|
+
total: steps.length,
|
|
270
|
+
isFirst: index === 0,
|
|
271
|
+
isLast: index === steps.length - 1,
|
|
272
|
+
next,
|
|
273
|
+
back,
|
|
274
|
+
goto,
|
|
275
|
+
dismiss
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// src/tour/useSpotlight.ts
|
|
280
|
+
import { useEffect as useEffect2, useRef as useRef2, useState as useState2 } from "react";
|
|
281
|
+
var RESIZE_DEBOUNCE_MS = 100;
|
|
282
|
+
function useSpotlight(selector) {
|
|
283
|
+
const [rect, setRect] = useState2(null);
|
|
284
|
+
const [found, setFound] = useState2(false);
|
|
285
|
+
const scrolledForSelector = useRef2(null);
|
|
286
|
+
useEffect2(() => {
|
|
287
|
+
if (!selector) {
|
|
288
|
+
setRect(null);
|
|
289
|
+
setFound(false);
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
let resizeTimer;
|
|
293
|
+
let cancelled = false;
|
|
294
|
+
const measure = () => {
|
|
295
|
+
if (cancelled) return;
|
|
296
|
+
const el = document.querySelector(
|
|
297
|
+
`[data-tour-target="${selector}"]`
|
|
298
|
+
);
|
|
299
|
+
if (!el) {
|
|
300
|
+
setRect(null);
|
|
301
|
+
setFound(false);
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
const domRect = el.getBoundingClientRect();
|
|
305
|
+
setRect({
|
|
306
|
+
top: domRect.top,
|
|
307
|
+
left: domRect.left,
|
|
308
|
+
width: domRect.width,
|
|
309
|
+
height: domRect.height
|
|
310
|
+
});
|
|
311
|
+
setFound(true);
|
|
312
|
+
if (scrolledForSelector.current !== selector) {
|
|
313
|
+
scrolledForSelector.current = selector;
|
|
314
|
+
el.scrollIntoView({ block: "center", behavior: "smooth" });
|
|
315
|
+
}
|
|
316
|
+
};
|
|
317
|
+
measure();
|
|
318
|
+
const initialFrame = requestAnimationFrame(measure);
|
|
319
|
+
const onResize = () => {
|
|
320
|
+
if (resizeTimer) clearTimeout(resizeTimer);
|
|
321
|
+
resizeTimer = setTimeout(measure, RESIZE_DEBOUNCE_MS);
|
|
322
|
+
};
|
|
323
|
+
const onScroll = () => measure();
|
|
324
|
+
window.addEventListener("resize", onResize);
|
|
325
|
+
window.addEventListener("scroll", onScroll, { capture: true });
|
|
326
|
+
const observer = new MutationObserver(measure);
|
|
327
|
+
observer.observe(document.body, {
|
|
328
|
+
childList: true,
|
|
329
|
+
subtree: true,
|
|
330
|
+
attributes: true
|
|
331
|
+
});
|
|
332
|
+
return () => {
|
|
333
|
+
cancelled = true;
|
|
334
|
+
cancelAnimationFrame(initialFrame);
|
|
335
|
+
if (resizeTimer) clearTimeout(resizeTimer);
|
|
336
|
+
window.removeEventListener("resize", onResize);
|
|
337
|
+
window.removeEventListener("scroll", onScroll, { capture: true });
|
|
338
|
+
observer?.disconnect();
|
|
339
|
+
};
|
|
340
|
+
}, [selector]);
|
|
341
|
+
return { rect, found };
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// src/tour/spotlightMath.ts
|
|
345
|
+
function computeSpotlightFrame(target, viewport) {
|
|
346
|
+
const targetBottom = target.top + target.height;
|
|
347
|
+
const targetRight = target.left + target.width;
|
|
348
|
+
return {
|
|
349
|
+
// Full-width strip above the target.
|
|
350
|
+
top: {
|
|
351
|
+
top: 0,
|
|
352
|
+
left: 0,
|
|
353
|
+
width: viewport.width,
|
|
354
|
+
height: Math.max(0, target.top)
|
|
355
|
+
},
|
|
356
|
+
// Full-width strip below the target.
|
|
357
|
+
bottom: {
|
|
358
|
+
top: targetBottom,
|
|
359
|
+
left: 0,
|
|
360
|
+
width: viewport.width,
|
|
361
|
+
height: Math.max(0, viewport.height - targetBottom)
|
|
362
|
+
},
|
|
363
|
+
// Strip to the left of the target, spanning only the target's own row
|
|
364
|
+
// (the top/bottom strips already cover the rest of those rows).
|
|
365
|
+
left: {
|
|
366
|
+
top: target.top,
|
|
367
|
+
left: 0,
|
|
368
|
+
width: Math.max(0, target.left),
|
|
369
|
+
height: target.height
|
|
370
|
+
},
|
|
371
|
+
// Strip to the right of the target, same row-only span as `left`.
|
|
372
|
+
right: {
|
|
373
|
+
top: target.top,
|
|
374
|
+
left: targetRight,
|
|
375
|
+
width: Math.max(0, viewport.width - targetRight),
|
|
376
|
+
height: target.height
|
|
377
|
+
}
|
|
378
|
+
};
|
|
379
|
+
}
|
|
380
|
+
var FALLBACK_ORDER = {
|
|
381
|
+
bottom: ["bottom", "top", "right", "left"],
|
|
382
|
+
top: ["top", "bottom", "right", "left"],
|
|
383
|
+
right: ["right", "left", "bottom", "top"],
|
|
384
|
+
left: ["left", "right", "bottom", "top"]
|
|
385
|
+
};
|
|
386
|
+
var AUTO_ORDER = FALLBACK_ORDER.bottom;
|
|
387
|
+
var PANEL_GAP = 12;
|
|
388
|
+
function computePanelPosition(target, panelSize, viewport, placement = "auto") {
|
|
389
|
+
const order = placement === "auto" ? AUTO_ORDER : FALLBACK_ORDER[placement];
|
|
390
|
+
for (const candidate of order) {
|
|
391
|
+
const pos2 = placeAt(candidate, target, panelSize);
|
|
392
|
+
if (fits(pos2, panelSize, viewport)) {
|
|
393
|
+
return { ...pos2, placement: candidate };
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
const fallback = order[0];
|
|
397
|
+
const pos = placeAt(fallback, target, panelSize);
|
|
398
|
+
return {
|
|
399
|
+
top: clamp(pos.top, 0, Math.max(0, viewport.height - panelSize.height)),
|
|
400
|
+
left: clamp(pos.left, 0, Math.max(0, viewport.width - panelSize.width)),
|
|
401
|
+
placement: fallback
|
|
402
|
+
};
|
|
403
|
+
}
|
|
404
|
+
function placeAt(side, target, panelSize) {
|
|
405
|
+
const targetCenterX = target.left + target.width / 2;
|
|
406
|
+
const targetCenterY = target.top + target.height / 2;
|
|
407
|
+
switch (side) {
|
|
408
|
+
case "bottom":
|
|
409
|
+
return {
|
|
410
|
+
top: target.top + target.height + PANEL_GAP,
|
|
411
|
+
left: targetCenterX - panelSize.width / 2
|
|
412
|
+
};
|
|
413
|
+
case "top":
|
|
414
|
+
return {
|
|
415
|
+
top: target.top - panelSize.height - PANEL_GAP,
|
|
416
|
+
left: targetCenterX - panelSize.width / 2
|
|
417
|
+
};
|
|
418
|
+
case "right":
|
|
419
|
+
return {
|
|
420
|
+
top: targetCenterY - panelSize.height / 2,
|
|
421
|
+
left: target.left + target.width + PANEL_GAP
|
|
422
|
+
};
|
|
423
|
+
case "left":
|
|
424
|
+
return {
|
|
425
|
+
top: targetCenterY - panelSize.height / 2,
|
|
426
|
+
left: target.left - panelSize.width - PANEL_GAP
|
|
427
|
+
};
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
function fits(pos, panelSize, viewport) {
|
|
431
|
+
return pos.top >= 0 && pos.left >= 0 && pos.top + panelSize.height <= viewport.height && pos.left + panelSize.width <= viewport.width;
|
|
432
|
+
}
|
|
433
|
+
function clamp(value, min, max) {
|
|
434
|
+
return Math.min(Math.max(value, min), max);
|
|
435
|
+
}
|
|
436
|
+
export {
|
|
437
|
+
TourShell,
|
|
438
|
+
computePanelPosition,
|
|
439
|
+
computeSpotlightFrame,
|
|
440
|
+
useSpotlight,
|
|
441
|
+
useTour
|
|
442
|
+
};
|
|
443
|
+
//# sourceMappingURL=index.js.map
|