@marianmeres/stuic 3.35.1 → 3.37.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/actions/index.d.ts +1 -0
- package/dist/actions/index.js +1 -0
- package/dist/actions/onboarding/OnboardingShell.svelte +71 -0
- package/dist/actions/onboarding/OnboardingShell.svelte.d.ts +19 -0
- package/dist/actions/onboarding/index.css +93 -0
- package/dist/actions/onboarding/onboarding.svelte.d.ts +148 -0
- package/dist/actions/onboarding/onboarding.svelte.js +249 -0
- package/dist/index.css +1 -0
- package/package.json +1 -1
package/dist/actions/index.d.ts
CHANGED
package/dist/actions/index.js
CHANGED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
<script lang="ts" module>
|
|
2
|
+
import type { Snippet } from "svelte";
|
|
3
|
+
import type { TourStepDef, TourLabels, TourShellContext } from "./onboarding.svelte.js";
|
|
4
|
+
|
|
5
|
+
export interface Props {
|
|
6
|
+
step: TourStepDef;
|
|
7
|
+
/** 0-based index of this step */
|
|
8
|
+
index: number;
|
|
9
|
+
total: number;
|
|
10
|
+
isFirst: boolean;
|
|
11
|
+
isLast: boolean;
|
|
12
|
+
labels: Required<TourLabels>;
|
|
13
|
+
/** Optional custom shell snippet — replaces the entire default layout */
|
|
14
|
+
shell?: Snippet<[TourShellContext]>;
|
|
15
|
+
next: () => void;
|
|
16
|
+
prev: () => void;
|
|
17
|
+
skip: () => void;
|
|
18
|
+
}
|
|
19
|
+
</script>
|
|
20
|
+
|
|
21
|
+
<script lang="ts">
|
|
22
|
+
import Thc from "../../components/Thc/Thc.svelte";
|
|
23
|
+
|
|
24
|
+
let { step, index, total, isFirst, isLast, labels, shell, next, prev, skip }: Props = $props();
|
|
25
|
+
|
|
26
|
+
const context: TourShellContext = $derived({
|
|
27
|
+
step,
|
|
28
|
+
index,
|
|
29
|
+
total,
|
|
30
|
+
isFirst,
|
|
31
|
+
isLast,
|
|
32
|
+
next,
|
|
33
|
+
prev,
|
|
34
|
+
skip,
|
|
35
|
+
});
|
|
36
|
+
</script>
|
|
37
|
+
|
|
38
|
+
{#if shell}
|
|
39
|
+
{@render shell(context)}
|
|
40
|
+
{:else}
|
|
41
|
+
<div class="stuic-onboarding-shell">
|
|
42
|
+
{#if step.title}
|
|
43
|
+
<div class="stuic-onboarding-title">{step.title}</div>
|
|
44
|
+
{/if}
|
|
45
|
+
{#if step.content}
|
|
46
|
+
<div class="stuic-onboarding-content">
|
|
47
|
+
<Thc thc={step.content} />
|
|
48
|
+
</div>
|
|
49
|
+
{/if}
|
|
50
|
+
<div class="stuic-onboarding-footer">
|
|
51
|
+
<span class="stuic-onboarding-steps">{index + 1} / {total}</span>
|
|
52
|
+
<div class="stuic-onboarding-actions">
|
|
53
|
+
{#if !isLast}
|
|
54
|
+
<button class="stuic-onboarding-btn-skip" onclick={skip}>
|
|
55
|
+
{step.skipLabel ?? labels.skip}
|
|
56
|
+
</button>
|
|
57
|
+
{/if}
|
|
58
|
+
{#if !isFirst}
|
|
59
|
+
<button class="stuic-onboarding-btn-prev" onclick={prev}>
|
|
60
|
+
{step.prevLabel ?? labels.prev}
|
|
61
|
+
</button>
|
|
62
|
+
{/if}
|
|
63
|
+
<button class="stuic-onboarding-btn-next" onclick={next}>
|
|
64
|
+
{isLast
|
|
65
|
+
? (step.finishLabel ?? labels.finish)
|
|
66
|
+
: (step.nextLabel ?? labels.next)}
|
|
67
|
+
</button>
|
|
68
|
+
</div>
|
|
69
|
+
</div>
|
|
70
|
+
</div>
|
|
71
|
+
{/if}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { Snippet } from "svelte";
|
|
2
|
+
import type { TourStepDef, TourLabels, TourShellContext } from "./onboarding.svelte.js";
|
|
3
|
+
export interface Props {
|
|
4
|
+
step: TourStepDef;
|
|
5
|
+
/** 0-based index of this step */
|
|
6
|
+
index: number;
|
|
7
|
+
total: number;
|
|
8
|
+
isFirst: boolean;
|
|
9
|
+
isLast: boolean;
|
|
10
|
+
labels: Required<TourLabels>;
|
|
11
|
+
/** Optional custom shell snippet — replaces the entire default layout */
|
|
12
|
+
shell?: Snippet<[TourShellContext]>;
|
|
13
|
+
next: () => void;
|
|
14
|
+
prev: () => void;
|
|
15
|
+
skip: () => void;
|
|
16
|
+
}
|
|
17
|
+
declare const OnboardingShell: import("svelte").Component<Props, {}, "">;
|
|
18
|
+
type OnboardingShell = ReturnType<typeof OnboardingShell>;
|
|
19
|
+
export default OnboardingShell;
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/* Onboarding shell tokens */
|
|
2
|
+
:root {
|
|
3
|
+
--stuic-onboarding-shell-padding: calc(var(--spacing) * 3);
|
|
4
|
+
--stuic-onboarding-shell-gap: calc(var(--spacing) * 2);
|
|
5
|
+
--stuic-onboarding-shell-min-width: 200px;
|
|
6
|
+
--stuic-onboarding-shell-max-width: 320px;
|
|
7
|
+
--stuic-onboarding-title-size: var(--text-base);
|
|
8
|
+
--stuic-onboarding-content-size: var(--text-sm);
|
|
9
|
+
--stuic-onboarding-footer-gap: calc(var(--spacing) * 1.5);
|
|
10
|
+
--stuic-onboarding-steps-size: var(--text-xs);
|
|
11
|
+
--stuic-onboarding-btn-padding-x: calc(var(--spacing) * 2.5);
|
|
12
|
+
--stuic-onboarding-btn-padding-y: calc(var(--spacing) * 1);
|
|
13
|
+
--stuic-onboarding-btn-radius: calc(var(--spacing) * 1);
|
|
14
|
+
--stuic-onboarding-btn-font-size: var(--text-xs);
|
|
15
|
+
--stuic-onboarding-btn-primary-bg: var(--stuic-color-primary, #737373);
|
|
16
|
+
--stuic-onboarding-btn-primary-fg: var(--stuic-color-primary-foreground, #fff);
|
|
17
|
+
--stuic-onboarding-btn-secondary-bg: oklch(from currentColor l c h / 0.08);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
.stuic-onboarding-shell {
|
|
21
|
+
display: flex;
|
|
22
|
+
flex-direction: column;
|
|
23
|
+
gap: var(--stuic-onboarding-shell-gap);
|
|
24
|
+
padding: var(--stuic-onboarding-shell-padding);
|
|
25
|
+
min-width: var(--stuic-onboarding-shell-min-width);
|
|
26
|
+
max-width: var(--stuic-onboarding-shell-max-width);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
.stuic-onboarding-title {
|
|
30
|
+
font-weight: 600;
|
|
31
|
+
font-size: var(--stuic-onboarding-title-size);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
.stuic-onboarding-content {
|
|
35
|
+
font-size: var(--stuic-onboarding-content-size);
|
|
36
|
+
opacity: 0.85;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
.stuic-onboarding-footer {
|
|
40
|
+
display: flex;
|
|
41
|
+
align-items: center;
|
|
42
|
+
justify-content: space-between;
|
|
43
|
+
gap: calc(var(--spacing) * 2);
|
|
44
|
+
padding-top: calc(var(--spacing) * 2);
|
|
45
|
+
margin-top: calc(var(--spacing) * 1);
|
|
46
|
+
border-top: 1px solid var(--stuic-spotlight-annotation-border, rgba(0, 0, 0, 0.1));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
.stuic-onboarding-steps {
|
|
50
|
+
font-size: var(--stuic-onboarding-steps-size);
|
|
51
|
+
opacity: 0.5;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
.stuic-onboarding-actions {
|
|
55
|
+
display: flex;
|
|
56
|
+
align-items: center;
|
|
57
|
+
gap: var(--stuic-onboarding-footer-gap);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/* Shared button base */
|
|
61
|
+
.stuic-onboarding-btn-skip,
|
|
62
|
+
.stuic-onboarding-btn-prev,
|
|
63
|
+
.stuic-onboarding-btn-next {
|
|
64
|
+
font-size: var(--stuic-onboarding-btn-font-size);
|
|
65
|
+
padding: var(--stuic-onboarding-btn-padding-y) var(--stuic-onboarding-btn-padding-x);
|
|
66
|
+
border-radius: var(--stuic-onboarding-btn-radius);
|
|
67
|
+
line-height: 1.5;
|
|
68
|
+
transition:
|
|
69
|
+
opacity 0.15s,
|
|
70
|
+
filter 0.15s;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
.stuic-onboarding-btn-skip {
|
|
74
|
+
opacity: 0.45;
|
|
75
|
+
&:hover {
|
|
76
|
+
opacity: 0.85;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
.stuic-onboarding-btn-prev {
|
|
81
|
+
background: var(--stuic-onboarding-btn-secondary-bg);
|
|
82
|
+
&:hover {
|
|
83
|
+
filter: brightness(0.9);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
.stuic-onboarding-btn-next {
|
|
88
|
+
background: var(--stuic-onboarding-btn-primary-bg);
|
|
89
|
+
color: var(--stuic-onboarding-btn-primary-fg);
|
|
90
|
+
&:hover {
|
|
91
|
+
filter: brightness(0.9);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import type { Snippet } from "svelte";
|
|
2
|
+
import type { SpotlightPosition } from "../spotlight/spotlight.svelte.js";
|
|
3
|
+
import type { THC } from "../../components/Thc/Thc.svelte";
|
|
4
|
+
/**
|
|
5
|
+
* Definition of a single step in an onboarding tour.
|
|
6
|
+
*/
|
|
7
|
+
export interface TourStepDef {
|
|
8
|
+
/** Unique identifier — must match the `id` passed to `use:tourStep` */
|
|
9
|
+
id: string;
|
|
10
|
+
/** Short title shown in the default shell header */
|
|
11
|
+
title?: string;
|
|
12
|
+
/** Rich step description (any THC value: string, html, component, snippet) */
|
|
13
|
+
content?: THC;
|
|
14
|
+
/** Spotlight annotation position relative to the target element */
|
|
15
|
+
position?: SpotlightPosition;
|
|
16
|
+
/** Spotlight cutout padding in px */
|
|
17
|
+
padding?: number;
|
|
18
|
+
/** Spotlight cutout border radius in px */
|
|
19
|
+
borderRadius?: number;
|
|
20
|
+
/** Override "Next" button label for this step */
|
|
21
|
+
nextLabel?: string;
|
|
22
|
+
/** Override "Back" button label for this step */
|
|
23
|
+
prevLabel?: string;
|
|
24
|
+
/** Override "Skip" button label for this step */
|
|
25
|
+
skipLabel?: string;
|
|
26
|
+
/** Override "Finish" button label for the last step */
|
|
27
|
+
finishLabel?: string;
|
|
28
|
+
/** Called when tour enters this step */
|
|
29
|
+
onEnter?: () => void;
|
|
30
|
+
/** Called when tour leaves this step */
|
|
31
|
+
onLeave?: () => void;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Default label overrides for the navigation shell buttons.
|
|
35
|
+
*/
|
|
36
|
+
export interface TourLabels {
|
|
37
|
+
next?: string;
|
|
38
|
+
prev?: string;
|
|
39
|
+
skip?: string;
|
|
40
|
+
finish?: string;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Context passed to a custom `shell` snippet.
|
|
44
|
+
*/
|
|
45
|
+
export interface TourShellContext {
|
|
46
|
+
step: TourStepDef;
|
|
47
|
+
/** 0-based index of the current step */
|
|
48
|
+
index: number;
|
|
49
|
+
total: number;
|
|
50
|
+
isFirst: boolean;
|
|
51
|
+
isLast: boolean;
|
|
52
|
+
next: () => void;
|
|
53
|
+
prev: () => void;
|
|
54
|
+
skip: () => void | Promise<void>;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Options for `createTour`.
|
|
58
|
+
*/
|
|
59
|
+
export interface TourOptions {
|
|
60
|
+
steps: TourStepDef[];
|
|
61
|
+
/** How long to wait (ms) for a step's element to appear before skipping it. Default: 500 */
|
|
62
|
+
waitForElement?: number;
|
|
63
|
+
/** Default button labels (per-step overrides take precedence) */
|
|
64
|
+
labels?: TourLabels;
|
|
65
|
+
/** Replace the entire default shell with a custom Svelte snippet */
|
|
66
|
+
shell?: Snippet<[TourShellContext]>;
|
|
67
|
+
/** Press Escape to skip the tour. Default: true */
|
|
68
|
+
closeOnEscape?: boolean;
|
|
69
|
+
/** Called when tour starts */
|
|
70
|
+
onStart?: () => void;
|
|
71
|
+
/** Called when tour reaches the end naturally (after last step) */
|
|
72
|
+
onEnd?: () => void;
|
|
73
|
+
/** Called when tour is skipped by the user */
|
|
74
|
+
onSkip?: () => void;
|
|
75
|
+
/**
|
|
76
|
+
* If provided, called before skipping. Return (or resolve) `false` to cancel the skip.
|
|
77
|
+
* Works for both the Skip button and Escape key.
|
|
78
|
+
* @example
|
|
79
|
+
* ```ts
|
|
80
|
+
* // with createConfirm helper
|
|
81
|
+
* confirmSkip: () => confirm("Are you sure you want to skip the tutorial?")
|
|
82
|
+
* ```
|
|
83
|
+
*/
|
|
84
|
+
confirmSkip?: () => boolean | Promise<boolean>;
|
|
85
|
+
/** Called on every step change */
|
|
86
|
+
onStepChange?: (step: TourStepDef, index: number) => void;
|
|
87
|
+
/**
|
|
88
|
+
* If set, the tour result ('completed' or 'skipped') is persisted under this key.
|
|
89
|
+
* On subsequent `start()` calls, the tour will silently skip if the key exists.
|
|
90
|
+
* Use `tour.reset()` to clear the persisted state and allow the tour to run again.
|
|
91
|
+
*/
|
|
92
|
+
storageKey?: string;
|
|
93
|
+
/** Storage backend for persistence. Default: 'local' */
|
|
94
|
+
storage?: "local" | "session";
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Creates a multi-step onboarding tour on top of the spotlight primitive.
|
|
98
|
+
*
|
|
99
|
+
* Define all steps centrally here, then attach `use:tourStep={[tour, stepId]}`
|
|
100
|
+
* to each target element in your markup.
|
|
101
|
+
*
|
|
102
|
+
* @example
|
|
103
|
+
* ```svelte
|
|
104
|
+
* <script>
|
|
105
|
+
* import { createTour, tourStep } from '../..';
|
|
106
|
+
*
|
|
107
|
+
* const tour = createTour({
|
|
108
|
+
* steps: [
|
|
109
|
+
* { id: 'header', title: 'Welcome', content: 'This is the top of the page.' },
|
|
110
|
+
* { id: 'save-btn', title: 'Save your work', content: 'Click here to save.' },
|
|
111
|
+
* ],
|
|
112
|
+
* onEnd: () => console.log('Tour complete!'),
|
|
113
|
+
* });
|
|
114
|
+
* </script>
|
|
115
|
+
*
|
|
116
|
+
* <header use:tourStep={[tour, 'header']}>...</header>
|
|
117
|
+
* <button use:tourStep={[tour, 'save-btn']}>Save</button>
|
|
118
|
+
* <button onclick={tour.start}>Start Tour</button>
|
|
119
|
+
* ```
|
|
120
|
+
*/
|
|
121
|
+
export declare function createTour(options: TourOptions): {
|
|
122
|
+
readonly active: boolean;
|
|
123
|
+
readonly currentStep: TourStepDef;
|
|
124
|
+
readonly currentIndex: number;
|
|
125
|
+
readonly seen: boolean;
|
|
126
|
+
start: () => void;
|
|
127
|
+
next: () => void;
|
|
128
|
+
prev: () => void;
|
|
129
|
+
skip: () => Promise<void>;
|
|
130
|
+
reset: () => void;
|
|
131
|
+
_register: (id: string, el: HTMLElement) => void;
|
|
132
|
+
_unregister: (id: string) => void;
|
|
133
|
+
_isCurrentStep: (id: string) => boolean;
|
|
134
|
+
_getShellContent: (id: string) => THC;
|
|
135
|
+
};
|
|
136
|
+
export type TourInstance = ReturnType<typeof createTour>;
|
|
137
|
+
/**
|
|
138
|
+
* Svelte action that registers a DOM element as the target for a specific tour step.
|
|
139
|
+
*
|
|
140
|
+
* @param el - The target element
|
|
141
|
+
* @param args - Tuple of `[TourInstance, stepId]`
|
|
142
|
+
*
|
|
143
|
+
* @example
|
|
144
|
+
* ```svelte
|
|
145
|
+
* <button use:tourStep={[tour, 'save-btn']}>Save</button>
|
|
146
|
+
* ```
|
|
147
|
+
*/
|
|
148
|
+
export declare function tourStep(el: HTMLElement, args: [TourInstance, string]): void;
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
import { spotlight } from "../spotlight/spotlight.svelte.js";
|
|
2
|
+
import OnboardingShell from "./OnboardingShell.svelte";
|
|
3
|
+
import { StorageAbstraction } from "../../utils/storage-abstraction.js";
|
|
4
|
+
/**
|
|
5
|
+
* Creates a multi-step onboarding tour on top of the spotlight primitive.
|
|
6
|
+
*
|
|
7
|
+
* Define all steps centrally here, then attach `use:tourStep={[tour, stepId]}`
|
|
8
|
+
* to each target element in your markup.
|
|
9
|
+
*
|
|
10
|
+
* @example
|
|
11
|
+
* ```svelte
|
|
12
|
+
* <script>
|
|
13
|
+
* import { createTour, tourStep } from '../..';
|
|
14
|
+
*
|
|
15
|
+
* const tour = createTour({
|
|
16
|
+
* steps: [
|
|
17
|
+
* { id: 'header', title: 'Welcome', content: 'This is the top of the page.' },
|
|
18
|
+
* { id: 'save-btn', title: 'Save your work', content: 'Click here to save.' },
|
|
19
|
+
* ],
|
|
20
|
+
* onEnd: () => console.log('Tour complete!'),
|
|
21
|
+
* });
|
|
22
|
+
* </script>
|
|
23
|
+
*
|
|
24
|
+
* <header use:tourStep={[tour, 'header']}>...</header>
|
|
25
|
+
* <button use:tourStep={[tour, 'save-btn']}>Save</button>
|
|
26
|
+
* <button onclick={tour.start}>Start Tour</button>
|
|
27
|
+
* ```
|
|
28
|
+
*/
|
|
29
|
+
export function createTour(options) {
|
|
30
|
+
let currentIndex = $state(-1);
|
|
31
|
+
const active = $derived(currentIndex >= 0);
|
|
32
|
+
const currentStep = $derived(options.steps[currentIndex] ?? null);
|
|
33
|
+
// Optional persistence store
|
|
34
|
+
const store = options.storageKey
|
|
35
|
+
? new StorageAbstraction(options.storage ?? "local")
|
|
36
|
+
: null;
|
|
37
|
+
// Element registry: stepId -> HTMLElement
|
|
38
|
+
const registry = new Map();
|
|
39
|
+
// Wait-for-element mechanism (one pending wait at a time)
|
|
40
|
+
let pendingStepId = null;
|
|
41
|
+
let pendingResolve = null;
|
|
42
|
+
// Guard against concurrent navigation calls
|
|
43
|
+
let advancing = false;
|
|
44
|
+
const resolvedLabels = {
|
|
45
|
+
next: "Next",
|
|
46
|
+
prev: "Back",
|
|
47
|
+
skip: "Skip",
|
|
48
|
+
finish: "Finish",
|
|
49
|
+
...options.labels,
|
|
50
|
+
};
|
|
51
|
+
// Escape key listener — active only while tour is running
|
|
52
|
+
$effect(() => {
|
|
53
|
+
if (active && options.closeOnEscape !== false) {
|
|
54
|
+
const handler = async (e) => {
|
|
55
|
+
if (e.key === "Escape") {
|
|
56
|
+
e.preventDefault();
|
|
57
|
+
e.stopPropagation();
|
|
58
|
+
e.stopImmediatePropagation();
|
|
59
|
+
await skip();
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
document.addEventListener("keydown", handler);
|
|
63
|
+
return () => document.removeEventListener("keydown", handler);
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
// -- Internal API (used by tourStep action) -----------------------------------------
|
|
67
|
+
function _register(id, el) {
|
|
68
|
+
registry.set(id, el);
|
|
69
|
+
// If we were waiting for this element, resolve immediately
|
|
70
|
+
if (pendingStepId === id && pendingResolve) {
|
|
71
|
+
pendingResolve(true);
|
|
72
|
+
pendingStepId = null;
|
|
73
|
+
pendingResolve = null;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
function _unregister(id) {
|
|
77
|
+
registry.delete(id);
|
|
78
|
+
// If the active step's element unmounts mid-tour, end gracefully
|
|
79
|
+
if (active && currentStep?.id === id) {
|
|
80
|
+
console.warn(`[createTour] Active step "${id}" element unmounted — ending tour`);
|
|
81
|
+
_end();
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
function _isCurrentStep(id) {
|
|
85
|
+
return active && currentStep?.id === id;
|
|
86
|
+
}
|
|
87
|
+
function _getShellContent(id) {
|
|
88
|
+
const step = options.steps.find((s) => s.id === id);
|
|
89
|
+
const index = options.steps.indexOf(step);
|
|
90
|
+
const total = options.steps.length;
|
|
91
|
+
return {
|
|
92
|
+
component: OnboardingShell,
|
|
93
|
+
props: {
|
|
94
|
+
step,
|
|
95
|
+
index,
|
|
96
|
+
total,
|
|
97
|
+
isFirst: index === 0,
|
|
98
|
+
isLast: index === total - 1,
|
|
99
|
+
labels: resolvedLabels,
|
|
100
|
+
shell: options.shell,
|
|
101
|
+
next,
|
|
102
|
+
prev,
|
|
103
|
+
skip,
|
|
104
|
+
},
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
// -- Navigation ---------------------------------------------------------------------
|
|
108
|
+
function waitForElement(id) {
|
|
109
|
+
// Cancel any previous pending wait
|
|
110
|
+
if (pendingResolve) {
|
|
111
|
+
pendingResolve(false);
|
|
112
|
+
pendingResolve = null;
|
|
113
|
+
pendingStepId = null;
|
|
114
|
+
}
|
|
115
|
+
return new Promise((resolve) => {
|
|
116
|
+
pendingStepId = id;
|
|
117
|
+
pendingResolve = resolve;
|
|
118
|
+
setTimeout(() => {
|
|
119
|
+
if (pendingStepId === id) {
|
|
120
|
+
console.warn(`[createTour] Step "${id}" element not found after ${options.waitForElement ?? 500}ms — skipping`);
|
|
121
|
+
pendingStepId = null;
|
|
122
|
+
pendingResolve = null;
|
|
123
|
+
resolve(false);
|
|
124
|
+
}
|
|
125
|
+
}, options.waitForElement ?? 500);
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
async function advanceTo(targetIndex) {
|
|
129
|
+
if (advancing)
|
|
130
|
+
return;
|
|
131
|
+
advancing = true;
|
|
132
|
+
try {
|
|
133
|
+
// Call onLeave for the step we're leaving
|
|
134
|
+
currentStep?.onLeave?.();
|
|
135
|
+
const direction = targetIndex >= currentIndex ? 1 : -1;
|
|
136
|
+
let index = targetIndex;
|
|
137
|
+
// Find the nearest available step in the direction of travel
|
|
138
|
+
while (index >= 0 && index < options.steps.length) {
|
|
139
|
+
const step = options.steps[index];
|
|
140
|
+
if (!registry.has(step.id)) {
|
|
141
|
+
const found = await waitForElement(step.id);
|
|
142
|
+
if (!found) {
|
|
143
|
+
index += direction;
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
// Step element is available — navigate here
|
|
148
|
+
currentIndex = index;
|
|
149
|
+
step.onEnter?.();
|
|
150
|
+
options.onStepChange?.(step, index);
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
// Exhausted all steps in the direction of travel
|
|
154
|
+
_end();
|
|
155
|
+
}
|
|
156
|
+
finally {
|
|
157
|
+
advancing = false;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
function _end() {
|
|
161
|
+
currentIndex = -1;
|
|
162
|
+
store?.set(options.storageKey, "completed");
|
|
163
|
+
options.onEnd?.();
|
|
164
|
+
}
|
|
165
|
+
// -- Public API ---------------------------------------------------------------------
|
|
166
|
+
function start() {
|
|
167
|
+
if (store && store.has(options.storageKey))
|
|
168
|
+
return;
|
|
169
|
+
options.onStart?.();
|
|
170
|
+
advanceTo(0);
|
|
171
|
+
}
|
|
172
|
+
function next() {
|
|
173
|
+
advanceTo(currentIndex + 1);
|
|
174
|
+
}
|
|
175
|
+
function prev() {
|
|
176
|
+
if (currentIndex > 0)
|
|
177
|
+
advanceTo(currentIndex - 1);
|
|
178
|
+
}
|
|
179
|
+
async function skip() {
|
|
180
|
+
if (options.confirmSkip && !(await options.confirmSkip()))
|
|
181
|
+
return;
|
|
182
|
+
currentIndex = -1;
|
|
183
|
+
store?.set(options.storageKey, "skipped");
|
|
184
|
+
options.onSkip?.();
|
|
185
|
+
}
|
|
186
|
+
function reset() {
|
|
187
|
+
store?.remove(options.storageKey);
|
|
188
|
+
}
|
|
189
|
+
return {
|
|
190
|
+
get active() {
|
|
191
|
+
return active;
|
|
192
|
+
},
|
|
193
|
+
get currentStep() {
|
|
194
|
+
return currentStep;
|
|
195
|
+
},
|
|
196
|
+
get currentIndex() {
|
|
197
|
+
return currentIndex;
|
|
198
|
+
},
|
|
199
|
+
get seen() {
|
|
200
|
+
return store ? store.has(options.storageKey) : false;
|
|
201
|
+
},
|
|
202
|
+
start,
|
|
203
|
+
next,
|
|
204
|
+
prev,
|
|
205
|
+
skip,
|
|
206
|
+
reset,
|
|
207
|
+
// Internal — used by tourStep action
|
|
208
|
+
_register,
|
|
209
|
+
_unregister,
|
|
210
|
+
_isCurrentStep,
|
|
211
|
+
_getShellContent,
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
215
|
+
* Svelte action that registers a DOM element as the target for a specific tour step.
|
|
216
|
+
*
|
|
217
|
+
* @param el - The target element
|
|
218
|
+
* @param args - Tuple of `[TourInstance, stepId]`
|
|
219
|
+
*
|
|
220
|
+
* @example
|
|
221
|
+
* ```svelte
|
|
222
|
+
* <button use:tourStep={[tour, 'save-btn']}>Save</button>
|
|
223
|
+
* ```
|
|
224
|
+
*/
|
|
225
|
+
export function tourStep(el, args) {
|
|
226
|
+
const [tour, id] = args;
|
|
227
|
+
tour._register(id, el);
|
|
228
|
+
spotlight(el, () => {
|
|
229
|
+
const isActive = tour._isCurrentStep(id);
|
|
230
|
+
if (!isActive) {
|
|
231
|
+
return { open: false };
|
|
232
|
+
}
|
|
233
|
+
return {
|
|
234
|
+
open: true,
|
|
235
|
+
content: tour._getShellContent(id),
|
|
236
|
+
position: tour.currentStep?.position ?? "bottom",
|
|
237
|
+
padding: tour.currentStep?.padding,
|
|
238
|
+
borderRadius: tour.currentStep?.borderRadius,
|
|
239
|
+
closeOnEscape: false,
|
|
240
|
+
closeOnBackdropClick: false,
|
|
241
|
+
scrollIntoView: true,
|
|
242
|
+
};
|
|
243
|
+
});
|
|
244
|
+
$effect(() => {
|
|
245
|
+
return () => {
|
|
246
|
+
tour._unregister(id);
|
|
247
|
+
};
|
|
248
|
+
});
|
|
249
|
+
}
|
package/dist/index.css
CHANGED