@julscodes/react-spotlight-tour 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 YOUR_NAME_HERE
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,136 @@
1
+ # react-spotlight-tour
2
+
3
+ A lightweight, dependency-free React component for building step-by-step
4
+ spotlight product tours / onboarding walkthroughs. Highlights a target
5
+ element on the page and shows a card with a title, description, optional
6
+ image/video, and Next / Previous / Finish controls.
7
+
8
+ > **Note:** `react-spotlight-tour` is a placeholder package name. Rename it
9
+ > in `package.json` (and update the GitHub URLs) before publishing, since the
10
+ > name must be unique on the npm registry.
11
+
12
+ ## Install
13
+
14
+ ```bash
15
+ npm install react-spotlight-tour
16
+ ```
17
+
18
+ `react` and `react-dom` (>=16.8, for hooks support) are peer dependencies and
19
+ must already be present in your project.
20
+
21
+ ## Usage
22
+
23
+ ```tsx
24
+ import { Tutorial } from "react-spotlight-tour";
25
+
26
+ function App() {
27
+ return (
28
+ <>
29
+ <button id="new-project-btn">New project</button>
30
+ <div id="sidebar">...</div>
31
+
32
+ <Tutorial
33
+ steps={[
34
+ {
35
+ followId: "new-project-btn",
36
+ title: "Create a project",
37
+ description: "Click here to start a new project.",
38
+ },
39
+ {
40
+ followId: "sidebar",
41
+ title: "Your workspace",
42
+ description: "Everything you build lives here.",
43
+ src: "/onboarding/sidebar.png",
44
+ },
45
+ {
46
+ title: "You're all set!",
47
+ description: "That's it — have fun exploring.",
48
+ },
49
+ ]}
50
+ onClose={() => console.log("tour finished or dismissed")}
51
+ />
52
+ </>
53
+ );
54
+ }
55
+ ```
56
+
57
+ Each step card automatically positions itself next to the element referenced
58
+ by `followId` (falling back to a centered card if omitted or the element
59
+ can't be found), and repositions on scroll/resize.
60
+
61
+ ## API
62
+
63
+ ### `<Tutorial />`
64
+
65
+ | Prop | Type | Default | Description |
66
+ | ------------- | ------------ | ------- | ------------------------------------------------- |
67
+ | `steps` | `Step[]` | — | Required. The ordered list of steps to walk through. |
68
+ | `defaultOpen` | `boolean` | `true` | Whether the tour starts open. |
69
+ | `onClose` | `() => void` | — | Called when the tour is finished or dismissed. |
70
+
71
+ ### `Step`
72
+
73
+ | Field | Type | Description |
74
+ | ------------- | -------- | -------------------------------------------------------------------- |
75
+ | `followId` | `string` | Optional `id` of the element to spotlight. Card centers if omitted. |
76
+ | `title` | `string` | Step title. |
77
+ | `description` | `string` | Step body text. |
78
+ | `src` | `string` | Optional image or video URL. `.mp4` renders as `<video>`, else `<img>`. |
79
+
80
+ ## Styling
81
+
82
+ Base styles are imported automatically when you import from the package. If
83
+ your setup (e.g. certain Next.js configurations) doesn't apply CSS imported
84
+ from `node_modules` automatically, import the stylesheet explicitly once in
85
+ your app's entry point:
86
+
87
+ ```ts
88
+ import "react-spotlight-tour/styles.css";
89
+ ```
90
+
91
+ Look, feel, and colors are controlled by CSS custom properties defined on
92
+ `:root` (e.g. `--tutorial-primary`, `--tutorial-bg`, `--tutorial-radius`) —
93
+ override them in your own CSS to theme the component.
94
+
95
+ ## Development
96
+
97
+ ```bash
98
+ npm install
99
+ npm run build # emits dist/esm, dist/cjs, dist/types
100
+ ```
101
+
102
+ ## Publishing to npm
103
+
104
+ 1. Update `name`, `version`, `author`, `repository`/`homepage`/`bugs` URLs in
105
+ `package.json`, and the copyright line in `LICENSE`.
106
+ 2. Make sure the chosen package name is free:
107
+ ```bash
108
+ npm view react-spotlight-tour
109
+ ```
110
+ (a 404 means it's available; pick a new name or scope it, e.g.
111
+ `@yourusername/react-spotlight-tour`, if it's taken).
112
+ 3. Log in to npm (one-time):
113
+ ```bash
114
+ npm login
115
+ ```
116
+ 4. From the package root, do a dry run to confirm exactly what will be
117
+ published:
118
+ ```bash
119
+ npm publish --dry-run
120
+ ```
121
+ 5. Publish for real (this also runs `npm run build` automatically via the
122
+ `prepublishOnly` script):
123
+ ```bash
124
+ npm publish
125
+ # if using a scoped name and want it public:
126
+ npm publish --access public
127
+ ```
128
+ 6. For future updates, bump the version before publishing again:
129
+ ```bash
130
+ npm version patch # or minor / major
131
+ npm publish
132
+ ```
133
+
134
+ ## License
135
+
136
+ MIT
@@ -0,0 +1,31 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.default = StepCard;
4
+ const jsx_runtime_1 = require("react/jsx-runtime");
5
+ const react_1 = require("react");
6
+ const utils_1 = require("./utils");
7
+ function StepCard({ step, index, total, onNext, onPrevious, onFinish, }) {
8
+ const cardRef = (0, react_1.useRef)(null);
9
+ const [position, setPosition] = (0, react_1.useState)(null);
10
+ const updatePosition = (0, react_1.useCallback)(() => {
11
+ if (!cardRef.current)
12
+ return;
13
+ const pos = (0, utils_1.calculateCardPosition)(step.followId, cardRef.current);
14
+ setPosition(pos);
15
+ }, [step.followId]);
16
+ (0, react_1.useLayoutEffect)(() => {
17
+ updatePosition();
18
+ window.addEventListener("resize", updatePosition);
19
+ window.addEventListener("scroll", updatePosition, true);
20
+ return () => {
21
+ window.removeEventListener("resize", updatePosition);
22
+ window.removeEventListener("scroll", updatePosition, true);
23
+ };
24
+ }, [updatePosition]);
25
+ return ((0, jsx_runtime_1.jsxs)("div", { ref: cardRef, className: "tutorial-card", style: {
26
+ top: position ? position.top : "50%",
27
+ left: position ? position.left : "50%",
28
+ transform: position ? undefined : "translate(-50%, -50%)",
29
+ }, children: [step.src &&
30
+ ((0, utils_1.isVideo)(step.src) ? ((0, jsx_runtime_1.jsx)("video", { className: "tutorial-media", src: step.src, autoPlay: true, muted: true, loop: true, playsInline: true })) : ((0, jsx_runtime_1.jsx)("img", { className: "tutorial-media", src: step.src, alt: step.title }))), (0, jsx_runtime_1.jsxs)("div", { className: "tutorial-content", children: [(0, jsx_runtime_1.jsx)("h2", { className: "tutorial-title", children: step.title }), (0, jsx_runtime_1.jsx)("p", { className: "tutorial-description", children: step.description }), (0, jsx_runtime_1.jsxs)("div", { className: "tutorial-actions", children: [(0, jsx_runtime_1.jsx)("button", { className: "tutorial-button tutorial-button-secondary", disabled: index === 0, onClick: onPrevious, children: "Previous" }), index === total - 1 ? ((0, jsx_runtime_1.jsx)("button", { className: "tutorial-button tutorial-button-primary", onClick: onFinish, children: "Finish" })) : ((0, jsx_runtime_1.jsx)("button", { className: "tutorial-button tutorial-button-primary", onClick: onNext, children: "Next" }))] }), (0, jsx_runtime_1.jsx)("div", { className: "tutorial-progress", children: Array.from({ length: total }).map((_, i) => ((0, jsx_runtime_1.jsx)("span", { className: `tutorial-dot ${i === index ? "active" : ""}` }, i))) })] })] }));
31
+ }
@@ -0,0 +1,69 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.default = Tutorial;
7
+ const jsx_runtime_1 = require("react/jsx-runtime");
8
+ const react_1 = require("react");
9
+ const StepCard_1 = __importDefault(require("./StepCard"));
10
+ const utils_1 = require("./utils");
11
+ function Tutorial({ steps, defaultOpen = true, onClose, }) {
12
+ const [open, setOpen] = (0, react_1.useState)(defaultOpen);
13
+ const [currentStepIndex, setCurrentStepIndex] = (0, react_1.useState)(0);
14
+ const highlightedElement = (0, react_1.useRef)(null);
15
+ const currentStep = steps[currentStepIndex];
16
+ /**
17
+ * Remove highlight from previous element.
18
+ */
19
+ const removeCurrentHighlight = (0, react_1.useCallback)(() => {
20
+ (0, utils_1.clearHighlight)(highlightedElement.current);
21
+ highlightedElement.current = null;
22
+ }, []);
23
+ /**
24
+ * Close tutorial.
25
+ */
26
+ const closeTutorial = (0, react_1.useCallback)(() => {
27
+ removeCurrentHighlight();
28
+ (0, utils_1.disableOverlay)();
29
+ setOpen(false);
30
+ onClose === null || onClose === void 0 ? void 0 : onClose();
31
+ }, [onClose, removeCurrentHighlight]);
32
+ /**
33
+ * Apply spotlight whenever the current step changes.
34
+ */
35
+ (0, react_1.useEffect)(() => {
36
+ if (!open || !currentStep)
37
+ return;
38
+ (0, utils_1.enableOverlay)();
39
+ removeCurrentHighlight();
40
+ highlightedElement.current = (0, utils_1.highlightElement)(currentStep.followId);
41
+ return () => {
42
+ removeCurrentHighlight();
43
+ };
44
+ }, [open, currentStep, removeCurrentHighlight]);
45
+ /**
46
+ * Cleanup on unmount.
47
+ */
48
+ (0, react_1.useEffect)(() => {
49
+ return () => {
50
+ removeCurrentHighlight();
51
+ (0, utils_1.disableOverlay)();
52
+ };
53
+ }, [removeCurrentHighlight]);
54
+ const nextStep = () => {
55
+ if (currentStepIndex === steps.length - 1) {
56
+ closeTutorial();
57
+ return;
58
+ }
59
+ setCurrentStepIndex((current) => current + 1);
60
+ };
61
+ const previousStep = () => {
62
+ setCurrentStepIndex((current) => Math.max(current - 1, 0));
63
+ };
64
+ if (!open)
65
+ return null;
66
+ if (steps.length === 0)
67
+ return null;
68
+ return ((0, jsx_runtime_1.jsx)("div", { className: "tutorial-container", "aria-hidden": false, role: "dialog", "aria-modal": "true", children: (0, jsx_runtime_1.jsx)(StepCard_1.default, { step: currentStep, index: currentStepIndex, total: steps.length, onNext: nextStep, onPrevious: previousStep, onFinish: closeTutorial }) }));
69
+ }
@@ -0,0 +1,10 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.Tutorial = void 0;
7
+ // @ts-ignore: CSS import handled by bundler
8
+ require("./styles.css");
9
+ var Tutorial_1 = require("./Tutorial");
10
+ Object.defineProperty(exports, "Tutorial", { enumerable: true, get: function () { return __importDefault(Tutorial_1).default; } });
@@ -0,0 +1,234 @@
1
+ :root {
2
+ --tutorial-z-overlay: 9998;
3
+ --tutorial-z-highlight: 9999;
4
+ --tutorial-z-card: 10000;
5
+
6
+ --tutorial-overlay: rgba(0, 0, 0, 0.72);
7
+
8
+ --tutorial-bg: #292929;
9
+ --tutorial-text: #ffffff;
10
+ --tutorial-muted: #c6c6c6;
11
+
12
+ --tutorial-primary: #2563eb;
13
+ --tutorial-primary-hover: #1d4ed8;
14
+
15
+ --tutorial-secondary: #4b5563;
16
+ --tutorial-secondary-hover: #374151;
17
+
18
+ --tutorial-radius: 12px;
19
+ --tutorial-shadow: 0 18px 40px rgba(0, 0, 0, 0.35);
20
+
21
+ --tutorial-border: #3d3d3d;
22
+
23
+ --tutorial-width: 360px;
24
+
25
+ --tutorial-transition: 0.2s ease;
26
+ }
27
+
28
+ /* ========================================================= */
29
+ /* Overlay */
30
+ /* ========================================================= */
31
+
32
+ .tutorial-container {
33
+ position: fixed;
34
+ inset: 0;
35
+ z-index: var(--tutorial-z-card);
36
+ pointer-events: none;
37
+ }
38
+
39
+ /* ========================================================= */
40
+ /* Card */
41
+ /* ========================================================= */
42
+
43
+ .tutorial-card {
44
+ position: absolute;
45
+
46
+ width: var(--tutorial-width);
47
+ max-width: calc(100vw - 32px);
48
+
49
+ overflow: hidden;
50
+
51
+ border-radius: var(--tutorial-radius);
52
+
53
+ background: var(--tutorial-bg);
54
+
55
+ color: var(--tutorial-text);
56
+
57
+ border: 1px solid var(--tutorial-border);
58
+
59
+ box-shadow: var(--tutorial-shadow);
60
+
61
+ pointer-events: auto;
62
+
63
+ box-sizing: border-box;
64
+ }
65
+
66
+ /* ========================================================= */
67
+ /* Media */
68
+ /* ========================================================= */
69
+
70
+ .tutorial-media {
71
+ display: block;
72
+
73
+ width: 100%;
74
+ height: 190px;
75
+
76
+ object-fit: cover;
77
+
78
+ background: #000;
79
+ }
80
+
81
+ /* ========================================================= */
82
+ /* Content */
83
+ /* ========================================================= */
84
+
85
+ .tutorial-content {
86
+ padding: 20px;
87
+ }
88
+
89
+ .tutorial-title {
90
+ margin: 0;
91
+
92
+ font-size: 22px;
93
+
94
+ font-weight: 700;
95
+
96
+ line-height: 1.3;
97
+ }
98
+
99
+ .tutorial-description {
100
+ margin-top: 12px;
101
+
102
+ margin-bottom: 0;
103
+
104
+ color: var(--tutorial-muted);
105
+
106
+ line-height: 1.55;
107
+
108
+ font-size: 15px;
109
+ }
110
+
111
+ /* ========================================================= */
112
+ /* Progress */
113
+ /* ========================================================= */
114
+
115
+ .tutorial-progress {
116
+ display: flex;
117
+
118
+ justify-content: center;
119
+
120
+ align-items: center;
121
+
122
+ gap: 8px;
123
+
124
+ margin-top: 24px;
125
+ }
126
+
127
+ .tutorial-dot {
128
+ width: 5px;
129
+
130
+ height: 5px;
131
+
132
+ border-radius: 999px;
133
+
134
+ background: #666;
135
+
136
+ transition:
137
+ background var(--tutorial-transition),
138
+ transform var(--tutorial-transition);
139
+ }
140
+
141
+ .tutorial-dot.active {
142
+ background: white;
143
+
144
+ transform: scale(1.2);
145
+ }
146
+
147
+ /* ========================================================= */
148
+ /* Buttons */
149
+ /* ========================================================= */
150
+
151
+ .tutorial-actions {
152
+ display: flex;
153
+
154
+ justify-content: space-between;
155
+
156
+ align-items: center;
157
+
158
+ margin-top: 28px;
159
+
160
+ gap: 12px;
161
+ }
162
+
163
+ .tutorial-button {
164
+ appearance: none;
165
+
166
+ border: none;
167
+
168
+ outline: none;
169
+
170
+ cursor: pointer;
171
+
172
+ padding: 10px 18px;
173
+
174
+ border-radius: 8px;
175
+
176
+ color: white;
177
+
178
+ font-size: 14px;
179
+
180
+ font-weight: 600;
181
+
182
+ transition:
183
+ background var(--tutorial-transition),
184
+ opacity var(--tutorial-transition),
185
+ transform var(--tutorial-transition);
186
+ }
187
+
188
+ .tutorial-button:hover:not(:disabled) {
189
+ transform: translateY(-1px);
190
+ }
191
+
192
+ .tutorial-button:active:not(:disabled) {
193
+ transform: translateY(0);
194
+ }
195
+
196
+ .tutorial-button:disabled {
197
+ cursor: not-allowed;
198
+
199
+ opacity: 0.45;
200
+ }
201
+
202
+ .tutorial-button-primary {
203
+ background: var(--tutorial-primary);
204
+ }
205
+
206
+ .tutorial-button-primary:hover:not(:disabled) {
207
+ background: var(--tutorial-primary-hover);
208
+ }
209
+
210
+ .tutorial-button-secondary {
211
+ background: var(--tutorial-secondary);
212
+ }
213
+
214
+ .tutorial-button-secondary:hover:not(:disabled) {
215
+ background: var(--tutorial-secondary-hover);
216
+ }
217
+
218
+ /* ========================================================= */
219
+ /* Responsive */
220
+ /* ========================================================= */
221
+
222
+ @media (max-width: 500px) {
223
+ .tutorial-card {
224
+ width: calc(100vw - 24px);
225
+ }
226
+
227
+ .tutorial-actions {
228
+ flex-direction: column;
229
+ }
230
+
231
+ .tutorial-button {
232
+ width: 100%;
233
+ }
234
+ }
@@ -0,0 +1,2 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
@@ -0,0 +1,126 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.HIGHLIGHT_CLASS = exports.STYLE_ID = exports.BODY_CLASS = void 0;
4
+ exports.ensureGlobalStyles = ensureGlobalStyles;
5
+ exports.enableOverlay = enableOverlay;
6
+ exports.disableOverlay = disableOverlay;
7
+ exports.highlightElement = highlightElement;
8
+ exports.clearHighlight = clearHighlight;
9
+ exports.isVideo = isVideo;
10
+ exports.calculateCardPosition = calculateCardPosition;
11
+ exports.BODY_CLASS = "__tutorial_overlay__";
12
+ exports.STYLE_ID = "__tutorial_overlay_style__";
13
+ exports.HIGHLIGHT_CLASS = "__tutorial_highlight__";
14
+ /**
15
+ * Inject the package styles into the document only once.
16
+ */
17
+ function ensureGlobalStyles() {
18
+ if (typeof document === "undefined")
19
+ return;
20
+ if (document.getElementById(exports.STYLE_ID))
21
+ return;
22
+ const style = document.createElement("style");
23
+ style.id = exports.STYLE_ID;
24
+ style.innerHTML = `
25
+ body.${exports.BODY_CLASS}::after{
26
+ content:"";
27
+ position:fixed;
28
+ inset:0;
29
+ background:rgba(0,0,0,.72);
30
+ z-index:9998;
31
+ pointer-events:none;
32
+ }
33
+
34
+ .${exports.HIGHLIGHT_CLASS}{
35
+ position:relative !important;
36
+ z-index:9999 !important;
37
+ border-radius:10px;
38
+ box-shadow:0 0 0 9999px rgba(0,0,0,.72);
39
+ transition:box-shadow .25s ease;
40
+ }
41
+ `;
42
+ document.head.appendChild(style);
43
+ }
44
+ /**
45
+ * Enables the page overlay.
46
+ */
47
+ function enableOverlay() {
48
+ if (typeof document === "undefined")
49
+ return;
50
+ ensureGlobalStyles();
51
+ document.body.classList.add(exports.BODY_CLASS);
52
+ }
53
+ /**
54
+ * Removes the page overlay.
55
+ */
56
+ function disableOverlay() {
57
+ if (typeof document === "undefined")
58
+ return;
59
+ document.body.classList.remove(exports.BODY_CLASS);
60
+ }
61
+ /**
62
+ * Adds the spotlight effect to an element.
63
+ */
64
+ function highlightElement(followId) {
65
+ if (typeof document === "undefined")
66
+ return null;
67
+ if (!followId)
68
+ return null;
69
+ const element = document.getElementById(followId);
70
+ if (!element)
71
+ return null;
72
+ element.classList.add(exports.HIGHLIGHT_CLASS);
73
+ return element;
74
+ }
75
+ /**
76
+ * Removes the spotlight from an element.
77
+ */
78
+ function clearHighlight(element) {
79
+ if (!element)
80
+ return;
81
+ element.classList.remove(exports.HIGHLIGHT_CLASS);
82
+ }
83
+ /**
84
+ * Determines whether a source should render as a video.
85
+ */
86
+ function isVideo(src) {
87
+ if (!src)
88
+ return false;
89
+ return src.endsWith(".mp4") || src.includes(".mp4");
90
+ }
91
+ /**
92
+ * Calculates where the tutorial card should appear.
93
+ */
94
+ function calculateCardPosition(followId, card) {
95
+ if (!followId)
96
+ return null;
97
+ const target = document.getElementById(followId);
98
+ if (!target)
99
+ return null;
100
+ const rect = target.getBoundingClientRect();
101
+ const cardRect = card.getBoundingClientRect();
102
+ const GAP = 18;
103
+ const PADDING = 16;
104
+ let left = rect.right + GAP;
105
+ let top = rect.top;
106
+ // Overflow right
107
+ if (left + cardRect.width > window.innerWidth - PADDING) {
108
+ left = rect.left - cardRect.width - GAP;
109
+ }
110
+ // Overflow left
111
+ if (left < PADDING) {
112
+ left = PADDING;
113
+ }
114
+ // Overflow bottom
115
+ if (top + cardRect.height > window.innerHeight - PADDING) {
116
+ top = window.innerHeight - cardRect.height - PADDING;
117
+ }
118
+ // Overflow top
119
+ if (top < PADDING) {
120
+ top = PADDING;
121
+ }
122
+ return {
123
+ top,
124
+ left,
125
+ };
126
+ }
@@ -0,0 +1,28 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useCallback, useLayoutEffect, useRef, useState } from "react";
3
+ import { calculateCardPosition, isVideo } from "./utils";
4
+ export default function StepCard({ step, index, total, onNext, onPrevious, onFinish, }) {
5
+ const cardRef = useRef(null);
6
+ const [position, setPosition] = useState(null);
7
+ const updatePosition = useCallback(() => {
8
+ if (!cardRef.current)
9
+ return;
10
+ const pos = calculateCardPosition(step.followId, cardRef.current);
11
+ setPosition(pos);
12
+ }, [step.followId]);
13
+ useLayoutEffect(() => {
14
+ updatePosition();
15
+ window.addEventListener("resize", updatePosition);
16
+ window.addEventListener("scroll", updatePosition, true);
17
+ return () => {
18
+ window.removeEventListener("resize", updatePosition);
19
+ window.removeEventListener("scroll", updatePosition, true);
20
+ };
21
+ }, [updatePosition]);
22
+ return (_jsxs("div", { ref: cardRef, className: "tutorial-card", style: {
23
+ top: position ? position.top : "50%",
24
+ left: position ? position.left : "50%",
25
+ transform: position ? undefined : "translate(-50%, -50%)",
26
+ }, children: [step.src &&
27
+ (isVideo(step.src) ? (_jsx("video", { className: "tutorial-media", src: step.src, autoPlay: true, muted: true, loop: true, playsInline: true })) : (_jsx("img", { className: "tutorial-media", src: step.src, alt: step.title }))), _jsxs("div", { className: "tutorial-content", children: [_jsx("h2", { className: "tutorial-title", children: step.title }), _jsx("p", { className: "tutorial-description", children: step.description }), _jsxs("div", { className: "tutorial-actions", children: [_jsx("button", { className: "tutorial-button tutorial-button-secondary", disabled: index === 0, onClick: onPrevious, children: "Previous" }), index === total - 1 ? (_jsx("button", { className: "tutorial-button tutorial-button-primary", onClick: onFinish, children: "Finish" })) : (_jsx("button", { className: "tutorial-button tutorial-button-primary", onClick: onNext, children: "Next" }))] }), _jsx("div", { className: "tutorial-progress", children: Array.from({ length: total }).map((_, i) => (_jsx("span", { className: `tutorial-dot ${i === index ? "active" : ""}` }, i))) })] })] }));
28
+ }
@@ -0,0 +1,63 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { useCallback, useEffect, useRef, useState } from "react";
3
+ import StepCard from "./StepCard";
4
+ import { clearHighlight, disableOverlay, enableOverlay, highlightElement, } from "./utils";
5
+ export default function Tutorial({ steps, defaultOpen = true, onClose, }) {
6
+ const [open, setOpen] = useState(defaultOpen);
7
+ const [currentStepIndex, setCurrentStepIndex] = useState(0);
8
+ const highlightedElement = useRef(null);
9
+ const currentStep = steps[currentStepIndex];
10
+ /**
11
+ * Remove highlight from previous element.
12
+ */
13
+ const removeCurrentHighlight = useCallback(() => {
14
+ clearHighlight(highlightedElement.current);
15
+ highlightedElement.current = null;
16
+ }, []);
17
+ /**
18
+ * Close tutorial.
19
+ */
20
+ const closeTutorial = useCallback(() => {
21
+ removeCurrentHighlight();
22
+ disableOverlay();
23
+ setOpen(false);
24
+ onClose === null || onClose === void 0 ? void 0 : onClose();
25
+ }, [onClose, removeCurrentHighlight]);
26
+ /**
27
+ * Apply spotlight whenever the current step changes.
28
+ */
29
+ useEffect(() => {
30
+ if (!open || !currentStep)
31
+ return;
32
+ enableOverlay();
33
+ removeCurrentHighlight();
34
+ highlightedElement.current = highlightElement(currentStep.followId);
35
+ return () => {
36
+ removeCurrentHighlight();
37
+ };
38
+ }, [open, currentStep, removeCurrentHighlight]);
39
+ /**
40
+ * Cleanup on unmount.
41
+ */
42
+ useEffect(() => {
43
+ return () => {
44
+ removeCurrentHighlight();
45
+ disableOverlay();
46
+ };
47
+ }, [removeCurrentHighlight]);
48
+ const nextStep = () => {
49
+ if (currentStepIndex === steps.length - 1) {
50
+ closeTutorial();
51
+ return;
52
+ }
53
+ setCurrentStepIndex((current) => current + 1);
54
+ };
55
+ const previousStep = () => {
56
+ setCurrentStepIndex((current) => Math.max(current - 1, 0));
57
+ };
58
+ if (!open)
59
+ return null;
60
+ if (steps.length === 0)
61
+ return null;
62
+ return (_jsx("div", { className: "tutorial-container", "aria-hidden": false, role: "dialog", "aria-modal": "true", children: _jsx(StepCard, { step: currentStep, index: currentStepIndex, total: steps.length, onNext: nextStep, onPrevious: previousStep, onFinish: closeTutorial }) }));
63
+ }
@@ -0,0 +1,3 @@
1
+ // @ts-ignore: CSS import handled by bundler
2
+ import "./styles.css";
3
+ export { default as Tutorial } from "./Tutorial";
@@ -0,0 +1,234 @@
1
+ :root {
2
+ --tutorial-z-overlay: 9998;
3
+ --tutorial-z-highlight: 9999;
4
+ --tutorial-z-card: 10000;
5
+
6
+ --tutorial-overlay: rgba(0, 0, 0, 0.72);
7
+
8
+ --tutorial-bg: #292929;
9
+ --tutorial-text: #ffffff;
10
+ --tutorial-muted: #c6c6c6;
11
+
12
+ --tutorial-primary: #2563eb;
13
+ --tutorial-primary-hover: #1d4ed8;
14
+
15
+ --tutorial-secondary: #4b5563;
16
+ --tutorial-secondary-hover: #374151;
17
+
18
+ --tutorial-radius: 12px;
19
+ --tutorial-shadow: 0 18px 40px rgba(0, 0, 0, 0.35);
20
+
21
+ --tutorial-border: #3d3d3d;
22
+
23
+ --tutorial-width: 360px;
24
+
25
+ --tutorial-transition: 0.2s ease;
26
+ }
27
+
28
+ /* ========================================================= */
29
+ /* Overlay */
30
+ /* ========================================================= */
31
+
32
+ .tutorial-container {
33
+ position: fixed;
34
+ inset: 0;
35
+ z-index: var(--tutorial-z-card);
36
+ pointer-events: none;
37
+ }
38
+
39
+ /* ========================================================= */
40
+ /* Card */
41
+ /* ========================================================= */
42
+
43
+ .tutorial-card {
44
+ position: absolute;
45
+
46
+ width: var(--tutorial-width);
47
+ max-width: calc(100vw - 32px);
48
+
49
+ overflow: hidden;
50
+
51
+ border-radius: var(--tutorial-radius);
52
+
53
+ background: var(--tutorial-bg);
54
+
55
+ color: var(--tutorial-text);
56
+
57
+ border: 1px solid var(--tutorial-border);
58
+
59
+ box-shadow: var(--tutorial-shadow);
60
+
61
+ pointer-events: auto;
62
+
63
+ box-sizing: border-box;
64
+ }
65
+
66
+ /* ========================================================= */
67
+ /* Media */
68
+ /* ========================================================= */
69
+
70
+ .tutorial-media {
71
+ display: block;
72
+
73
+ width: 100%;
74
+ height: 190px;
75
+
76
+ object-fit: cover;
77
+
78
+ background: #000;
79
+ }
80
+
81
+ /* ========================================================= */
82
+ /* Content */
83
+ /* ========================================================= */
84
+
85
+ .tutorial-content {
86
+ padding: 20px;
87
+ }
88
+
89
+ .tutorial-title {
90
+ margin: 0;
91
+
92
+ font-size: 22px;
93
+
94
+ font-weight: 700;
95
+
96
+ line-height: 1.3;
97
+ }
98
+
99
+ .tutorial-description {
100
+ margin-top: 12px;
101
+
102
+ margin-bottom: 0;
103
+
104
+ color: var(--tutorial-muted);
105
+
106
+ line-height: 1.55;
107
+
108
+ font-size: 15px;
109
+ }
110
+
111
+ /* ========================================================= */
112
+ /* Progress */
113
+ /* ========================================================= */
114
+
115
+ .tutorial-progress {
116
+ display: flex;
117
+
118
+ justify-content: center;
119
+
120
+ align-items: center;
121
+
122
+ gap: 8px;
123
+
124
+ margin-top: 24px;
125
+ }
126
+
127
+ .tutorial-dot {
128
+ width: 5px;
129
+
130
+ height: 5px;
131
+
132
+ border-radius: 999px;
133
+
134
+ background: #666;
135
+
136
+ transition:
137
+ background var(--tutorial-transition),
138
+ transform var(--tutorial-transition);
139
+ }
140
+
141
+ .tutorial-dot.active {
142
+ background: white;
143
+
144
+ transform: scale(1.2);
145
+ }
146
+
147
+ /* ========================================================= */
148
+ /* Buttons */
149
+ /* ========================================================= */
150
+
151
+ .tutorial-actions {
152
+ display: flex;
153
+
154
+ justify-content: space-between;
155
+
156
+ align-items: center;
157
+
158
+ margin-top: 28px;
159
+
160
+ gap: 12px;
161
+ }
162
+
163
+ .tutorial-button {
164
+ appearance: none;
165
+
166
+ border: none;
167
+
168
+ outline: none;
169
+
170
+ cursor: pointer;
171
+
172
+ padding: 10px 18px;
173
+
174
+ border-radius: 8px;
175
+
176
+ color: white;
177
+
178
+ font-size: 14px;
179
+
180
+ font-weight: 600;
181
+
182
+ transition:
183
+ background var(--tutorial-transition),
184
+ opacity var(--tutorial-transition),
185
+ transform var(--tutorial-transition);
186
+ }
187
+
188
+ .tutorial-button:hover:not(:disabled) {
189
+ transform: translateY(-1px);
190
+ }
191
+
192
+ .tutorial-button:active:not(:disabled) {
193
+ transform: translateY(0);
194
+ }
195
+
196
+ .tutorial-button:disabled {
197
+ cursor: not-allowed;
198
+
199
+ opacity: 0.45;
200
+ }
201
+
202
+ .tutorial-button-primary {
203
+ background: var(--tutorial-primary);
204
+ }
205
+
206
+ .tutorial-button-primary:hover:not(:disabled) {
207
+ background: var(--tutorial-primary-hover);
208
+ }
209
+
210
+ .tutorial-button-secondary {
211
+ background: var(--tutorial-secondary);
212
+ }
213
+
214
+ .tutorial-button-secondary:hover:not(:disabled) {
215
+ background: var(--tutorial-secondary-hover);
216
+ }
217
+
218
+ /* ========================================================= */
219
+ /* Responsive */
220
+ /* ========================================================= */
221
+
222
+ @media (max-width: 500px) {
223
+ .tutorial-card {
224
+ width: calc(100vw - 24px);
225
+ }
226
+
227
+ .tutorial-actions {
228
+ flex-direction: column;
229
+ }
230
+
231
+ .tutorial-button {
232
+ width: 100%;
233
+ }
234
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,116 @@
1
+ export const BODY_CLASS = "__tutorial_overlay__";
2
+ export const STYLE_ID = "__tutorial_overlay_style__";
3
+ export const HIGHLIGHT_CLASS = "__tutorial_highlight__";
4
+ /**
5
+ * Inject the package styles into the document only once.
6
+ */
7
+ export function ensureGlobalStyles() {
8
+ if (typeof document === "undefined")
9
+ return;
10
+ if (document.getElementById(STYLE_ID))
11
+ return;
12
+ const style = document.createElement("style");
13
+ style.id = STYLE_ID;
14
+ style.innerHTML = `
15
+ body.${BODY_CLASS}::after{
16
+ content:"";
17
+ position:fixed;
18
+ inset:0;
19
+ background:rgba(0,0,0,.72);
20
+ z-index:9998;
21
+ pointer-events:none;
22
+ }
23
+
24
+ .${HIGHLIGHT_CLASS}{
25
+ position:relative !important;
26
+ z-index:9999 !important;
27
+ border-radius:10px;
28
+ box-shadow:0 0 0 9999px rgba(0,0,0,.72);
29
+ transition:box-shadow .25s ease;
30
+ }
31
+ `;
32
+ document.head.appendChild(style);
33
+ }
34
+ /**
35
+ * Enables the page overlay.
36
+ */
37
+ export function enableOverlay() {
38
+ if (typeof document === "undefined")
39
+ return;
40
+ ensureGlobalStyles();
41
+ document.body.classList.add(BODY_CLASS);
42
+ }
43
+ /**
44
+ * Removes the page overlay.
45
+ */
46
+ export function disableOverlay() {
47
+ if (typeof document === "undefined")
48
+ return;
49
+ document.body.classList.remove(BODY_CLASS);
50
+ }
51
+ /**
52
+ * Adds the spotlight effect to an element.
53
+ */
54
+ export function highlightElement(followId) {
55
+ if (typeof document === "undefined")
56
+ return null;
57
+ if (!followId)
58
+ return null;
59
+ const element = document.getElementById(followId);
60
+ if (!element)
61
+ return null;
62
+ element.classList.add(HIGHLIGHT_CLASS);
63
+ return element;
64
+ }
65
+ /**
66
+ * Removes the spotlight from an element.
67
+ */
68
+ export function clearHighlight(element) {
69
+ if (!element)
70
+ return;
71
+ element.classList.remove(HIGHLIGHT_CLASS);
72
+ }
73
+ /**
74
+ * Determines whether a source should render as a video.
75
+ */
76
+ export function isVideo(src) {
77
+ if (!src)
78
+ return false;
79
+ return src.endsWith(".mp4") || src.includes(".mp4");
80
+ }
81
+ /**
82
+ * Calculates where the tutorial card should appear.
83
+ */
84
+ export function calculateCardPosition(followId, card) {
85
+ if (!followId)
86
+ return null;
87
+ const target = document.getElementById(followId);
88
+ if (!target)
89
+ return null;
90
+ const rect = target.getBoundingClientRect();
91
+ const cardRect = card.getBoundingClientRect();
92
+ const GAP = 18;
93
+ const PADDING = 16;
94
+ let left = rect.right + GAP;
95
+ let top = rect.top;
96
+ // Overflow right
97
+ if (left + cardRect.width > window.innerWidth - PADDING) {
98
+ left = rect.left - cardRect.width - GAP;
99
+ }
100
+ // Overflow left
101
+ if (left < PADDING) {
102
+ left = PADDING;
103
+ }
104
+ // Overflow bottom
105
+ if (top + cardRect.height > window.innerHeight - PADDING) {
106
+ top = window.innerHeight - cardRect.height - PADDING;
107
+ }
108
+ // Overflow top
109
+ if (top < PADDING) {
110
+ top = PADDING;
111
+ }
112
+ return {
113
+ top,
114
+ left,
115
+ };
116
+ }
@@ -0,0 +1,2 @@
1
+ import { StepCardProps } from "./types";
2
+ export default function StepCard({ step, index, total, onNext, onPrevious, onFinish, }: StepCardProps): import("react").JSX.Element;
@@ -0,0 +1,2 @@
1
+ import { TutorialProps } from "./types";
2
+ export default function Tutorial({ steps, defaultOpen, onClose, }: TutorialProps): import("react").JSX.Element | null;
@@ -0,0 +1,3 @@
1
+ import "./styles.css";
2
+ export { default as Tutorial } from "./Tutorial";
3
+ export type { Step, TutorialProps, StepCardProps, Position } from "./types";
@@ -0,0 +1,48 @@
1
+ export interface Step {
2
+ /**
3
+ * ID of the element that should be highlighted.
4
+ * If omitted, the step card will be centered.
5
+ */
6
+ followId?: string;
7
+ /**
8
+ * Step title.
9
+ */
10
+ title: string;
11
+ /**
12
+ * Step description.
13
+ */
14
+ description: string;
15
+ /**
16
+ * Optional image or video.
17
+ * .mp4 files are rendered as <video>,
18
+ * everything else as <img>.
19
+ */
20
+ src?: string;
21
+ }
22
+ export interface TutorialProps {
23
+ /**
24
+ * Tutorial steps.
25
+ */
26
+ steps: Step[];
27
+ /**
28
+ * Whether the tutorial starts opened.
29
+ * @default true
30
+ */
31
+ defaultOpen?: boolean;
32
+ /**
33
+ * Called when the tutorial finishes.
34
+ */
35
+ onClose?: () => void;
36
+ }
37
+ export interface StepCardProps {
38
+ step: Step;
39
+ index: number;
40
+ total: number;
41
+ onNext(): void;
42
+ onPrevious(): void;
43
+ onFinish(): void;
44
+ }
45
+ export interface Position {
46
+ top: number;
47
+ left: number;
48
+ }
@@ -0,0 +1,32 @@
1
+ import { Position } from "./types";
2
+ export declare const BODY_CLASS = "__tutorial_overlay__";
3
+ export declare const STYLE_ID = "__tutorial_overlay_style__";
4
+ export declare const HIGHLIGHT_CLASS = "__tutorial_highlight__";
5
+ /**
6
+ * Inject the package styles into the document only once.
7
+ */
8
+ export declare function ensureGlobalStyles(): void;
9
+ /**
10
+ * Enables the page overlay.
11
+ */
12
+ export declare function enableOverlay(): void;
13
+ /**
14
+ * Removes the page overlay.
15
+ */
16
+ export declare function disableOverlay(): void;
17
+ /**
18
+ * Adds the spotlight effect to an element.
19
+ */
20
+ export declare function highlightElement(followId?: string): HTMLElement | null;
21
+ /**
22
+ * Removes the spotlight from an element.
23
+ */
24
+ export declare function clearHighlight(element: HTMLElement | null): void;
25
+ /**
26
+ * Determines whether a source should render as a video.
27
+ */
28
+ export declare function isVideo(src?: string): boolean;
29
+ /**
30
+ * Calculates where the tutorial card should appear.
31
+ */
32
+ export declare function calculateCardPosition(followId: string | undefined, card: HTMLDivElement): Position | null;
package/package.json ADDED
@@ -0,0 +1,73 @@
1
+ {
2
+ "name": "@julscodes/react-spotlight-tour",
3
+ "version": "0.1.0",
4
+ "description": "A lightweight, dependency-free React component for building step-by-step spotlight product tours / onboarding walkthroughs.",
5
+ "author": "",
6
+ "license": "MIT",
7
+ "publishConfig": {
8
+ "access": "public"
9
+ },
10
+ "keywords": [
11
+ "react",
12
+ "tour",
13
+ "onboarding",
14
+ "walkthrough",
15
+ "spotlight",
16
+ "tooltip",
17
+ "product-tour",
18
+ "tutorial"
19
+ ],
20
+ "repository": {
21
+ "type": "git",
22
+ "url": "git+https://github.com/julscodes/react-spotlight-tour.git"
23
+ },
24
+ "homepage": "https://github.com/julscodes/react-spotlight-tour#readme",
25
+ "bugs": {
26
+ "url": "https://github.com/julscodes/react-spotlight-tour/issues"
27
+ },
28
+ "sideEffects": [
29
+ "**/*.css"
30
+ ],
31
+ "main": "./dist/cjs/index.js",
32
+ "module": "./dist/esm/index.js",
33
+ "types": "./dist/types/index.d.ts",
34
+ "exports": {
35
+ ".": {
36
+ "types": "./dist/types/index.d.ts",
37
+ "import": "./dist/esm/index.js",
38
+ "require": "./dist/cjs/index.js"
39
+ },
40
+ "./styles.css": {
41
+ "import": "./dist/esm/styles.css",
42
+ "require": "./dist/cjs/styles.css"
43
+ },
44
+ "./package.json": "./package.json"
45
+ },
46
+ "files": [
47
+ "dist"
48
+ ],
49
+ "engines": {
50
+ "node": ">=16"
51
+ },
52
+ "peerDependencies": {
53
+ "react": ">=16.8.0",
54
+ "react-dom": ">=16.8.0"
55
+ },
56
+ "devDependencies": {
57
+ "@types/react": "^18.3.0",
58
+ "@types/react-dom": "^18.3.0",
59
+ "react": "^18.3.0",
60
+ "react-dom": "^18.3.0",
61
+ "rimraf": "^5.0.5",
62
+ "typescript": "^5.5.0"
63
+ },
64
+ "scripts": {
65
+ "clean": "rimraf dist",
66
+ "build:esm": "tsc -p tsconfig.esm.json",
67
+ "build:cjs": "tsc -p tsconfig.cjs.json",
68
+ "build:types": "tsc -p tsconfig.types.json",
69
+ "copy:css": "node scripts/copy-css.js",
70
+ "build": "npm run clean && npm run build:esm && npm run build:cjs && npm run build:types && npm run copy:css",
71
+ "prepublishOnly": "npm run build"
72
+ }
73
+ }