@numidev/react-joyride 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/LICENSE +9 -0
  2. package/README.md +93 -0
  3. package/dist/index.cjs +2677 -0
  4. package/dist/index.cjs.map +1 -0
  5. package/dist/index.d.cts +753 -0
  6. package/dist/index.d.mts +753 -0
  7. package/dist/index.mjs +2638 -0
  8. package/dist/index.mjs.map +1 -0
  9. package/package.json +158 -0
  10. package/src/components/Arrow.tsx +138 -0
  11. package/src/components/Beacon.tsx +141 -0
  12. package/src/components/Floater.tsx +381 -0
  13. package/src/components/Loader.tsx +80 -0
  14. package/src/components/Overlay.tsx +167 -0
  15. package/src/components/Portal.tsx +17 -0
  16. package/src/components/Step.tsx +72 -0
  17. package/src/components/Tooltip/CloseButton.tsx +29 -0
  18. package/src/components/Tooltip/DefaultTooltip.tsx +82 -0
  19. package/src/components/Tooltip/index.tsx +163 -0
  20. package/src/components/TourRenderer.tsx +157 -0
  21. package/src/defaults.ts +64 -0
  22. package/src/global.d.ts +8 -0
  23. package/src/hooks/useControls.ts +219 -0
  24. package/src/hooks/useDebugLogger.ts +58 -0
  25. package/src/hooks/useEventEmitter.ts +55 -0
  26. package/src/hooks/useFocusTrap.ts +72 -0
  27. package/src/hooks/useJoyride.tsx +32 -0
  28. package/src/hooks/useLifecycleEffect.ts +512 -0
  29. package/src/hooks/usePortalElement.ts +49 -0
  30. package/src/hooks/usePropSync.ts +84 -0
  31. package/src/hooks/useScrollEffect.ts +217 -0
  32. package/src/hooks/useTargetPosition.ts +154 -0
  33. package/src/hooks/useTourEngine.ts +106 -0
  34. package/src/index.tsx +23 -0
  35. package/src/literals/index.ts +61 -0
  36. package/src/modules/changes.ts +20 -0
  37. package/src/modules/dom.ts +359 -0
  38. package/src/modules/helpers.tsx +230 -0
  39. package/src/modules/step.ts +156 -0
  40. package/src/modules/store.ts +215 -0
  41. package/src/modules/svg.ts +40 -0
  42. package/src/styles.ts +183 -0
  43. package/src/types/common.ts +315 -0
  44. package/src/types/components.ts +84 -0
  45. package/src/types/events.ts +89 -0
  46. package/src/types/floating.ts +60 -0
  47. package/src/types/index.ts +8 -0
  48. package/src/types/props.ts +124 -0
  49. package/src/types/state.ts +49 -0
  50. package/src/types/step.ts +108 -0
  51. package/src/types/utilities.ts +26 -0
@@ -0,0 +1,512 @@
1
+ import { type RefObject, useEffect, useRef } from 'react';
2
+ import { usePrevious } from '@gilbarbara/hooks';
3
+
4
+ import type { EmitEvent } from '~/hooks/useEventEmitter';
5
+ import type { AddFailure, MergedProps } from '~/hooks/useTourEngine';
6
+ import { ACTIONS, EVENTS, LIFECYCLE, STATUS } from '~/literals';
7
+ import { treeChanges } from '~/modules/changes';
8
+ import { getElement, isElementVisible, needsScrolling } from '~/modules/dom';
9
+ import { log } from '~/modules/helpers';
10
+ import { getMergedStep, shouldHideBeacon } from '~/modules/step';
11
+ import createStore from '~/modules/store';
12
+ import type { StoreState } from '~/modules/store';
13
+
14
+ import type { Actions, Controls, StepMerged, StepTarget } from '~/types';
15
+
16
+ interface UseLifecycleEffectOptions {
17
+ addFailure: AddFailure;
18
+ controls: Controls;
19
+ emitEvent: EmitEvent;
20
+ previousState: StoreState | undefined;
21
+ props: MergedProps;
22
+ state: StoreState;
23
+ step: StepMerged | null;
24
+ store: RefObject<ReturnType<typeof createStore>>;
25
+ }
26
+
27
+ export default function useLifecycleEffect(options: UseLifecycleEffectOptions): void {
28
+ const { addFailure, controls, emitEvent, previousState, props, state, step, store } = options;
29
+ const { action, index, lifecycle, positioned, scrolling, size, status } = state;
30
+
31
+ const previousStep = usePrevious(step) ?? null;
32
+
33
+ const lastAction = useRef<Actions | null>(null);
34
+ const propsRef = useRef(props);
35
+ const stateRef = useRef(state);
36
+ const previousStateRef = useRef(previousState);
37
+ const stepRef = useRef(step);
38
+ const previousStepRef = useRef(previousStep);
39
+ const controlsRef = useRef(controls);
40
+ const pollingRef = useRef<ReturnType<typeof setInterval> | null>(null);
41
+ const pollingTargetRef = useRef<StepTarget | null>(null);
42
+ const beforeRef = useRef<{ cancel: () => void } | null>(null);
43
+
44
+ propsRef.current = props;
45
+ stateRef.current = state;
46
+ previousStateRef.current = previousState;
47
+ stepRef.current = step;
48
+ previousStepRef.current = previousStep;
49
+ controlsRef.current = controls;
50
+
51
+ const cleanup = () => {
52
+ if (pollingRef.current) {
53
+ clearInterval(pollingRef.current);
54
+ pollingRef.current = null;
55
+ }
56
+
57
+ pollingTargetRef.current = null;
58
+
59
+ if (beforeRef.current) {
60
+ beforeRef.current.cancel();
61
+ beforeRef.current = null;
62
+ }
63
+ };
64
+
65
+ // Effect 1: Action tracking
66
+ useEffect(() => {
67
+ if (!previousStateRef.current) {
68
+ return;
69
+ }
70
+
71
+ const { hasChangedTo } = treeChanges(stateRef.current, previousStateRef.current);
72
+
73
+ const isAfterAction = hasChangedTo('action', [
74
+ ACTIONS.NEXT,
75
+ ACTIONS.PREV,
76
+ ACTIONS.SKIP,
77
+ ACTIONS.CLOSE,
78
+ ACTIONS.REPLAY,
79
+ ]);
80
+
81
+ const isStaleAfterStart =
82
+ action === ACTIONS.START &&
83
+ (lastAction.current === ACTIONS.CLOSE || lastAction.current === ACTIONS.REPLAY);
84
+
85
+ if (isAfterAction || isStaleAfterStart) {
86
+ lastAction.current = action;
87
+ }
88
+ }, [action]);
89
+
90
+ // Effect 2: Target resolution (INIT → READY)
91
+ useEffect(() => {
92
+ if (!previousStateRef.current) {
93
+ return () => {
94
+ cleanup();
95
+ };
96
+ }
97
+
98
+ const { hasChanged } = treeChanges(stateRef.current, previousStateRef.current);
99
+ const currentStep = stepRef.current;
100
+
101
+ if (hasChanged('index')) {
102
+ cleanup();
103
+ }
104
+
105
+ if (status !== STATUS.RUNNING || !currentStep || lifecycle !== LIFECYCLE.INIT) {
106
+ return () => {
107
+ cleanup();
108
+ };
109
+ }
110
+
111
+ // Fire tour:start before any step processing when status just changed to RUNNING
112
+ const { hasChangedTo: hasStatusChangedTo } = treeChanges(
113
+ stateRef.current,
114
+ previousStateRef.current,
115
+ );
116
+
117
+ if (
118
+ hasStatusChangedTo('status', STATUS.RUNNING) &&
119
+ ([STATUS.IDLE, STATUS.READY, STATUS.PAUSED] as string[]).includes(
120
+ previousStateRef.current.status,
121
+ )
122
+ ) {
123
+ emitEvent(EVENTS.TOUR_START, currentStep);
124
+ }
125
+
126
+ store.current.cleanupPositionData();
127
+
128
+ const { debug } = propsRef.current;
129
+
130
+ if (currentStep.before && !beforeRef.current) {
131
+ log(debug, `step:${index}`, 'before()', currentStep);
132
+ beforeRef.current = { cancel: () => {} };
133
+
134
+ store.current.updateState({ waiting: true });
135
+
136
+ emitEvent(EVENTS.STEP_BEFORE_HOOK, currentStep, {
137
+ action: lastAction.current ?? stateRef.current.action,
138
+ });
139
+
140
+ const proceed = () => {
141
+ beforeRef.current = null;
142
+ store.current.updateState({
143
+ action: lastAction.current ?? stateRef.current.action,
144
+ waiting: false,
145
+ lifecycle: LIFECYCLE.READY,
146
+ });
147
+ };
148
+
149
+ const abortController = new AbortController();
150
+ const timeout = currentStep.beforeTimeout;
151
+
152
+ beforeRef.current = { cancel: () => abortController.abort() };
153
+
154
+ const timeoutId = timeout
155
+ ? setTimeout(() => {
156
+ if (!abortController.signal.aborted) {
157
+ log(debug, `step:${index}`, 'before()', 'timed out', `${timeout}ms`);
158
+ abortController.abort();
159
+ addFailure(currentStep, 'before_hook');
160
+ emitEvent(EVENTS.ERROR, currentStep, {
161
+ error: new Error('Step before hook timed out'),
162
+ });
163
+ proceed();
164
+ }
165
+ }, timeout)
166
+ : null;
167
+
168
+ currentStep
169
+ .before({
170
+ ...store.current.getState(),
171
+ action: lastAction.current ?? store.current.getState().action,
172
+ step: currentStep,
173
+ })
174
+ .then(() => {
175
+ if (!abortController.signal.aborted) {
176
+ if (timeoutId) clearTimeout(timeoutId);
177
+ proceed();
178
+ }
179
+ })
180
+ .catch((error: unknown) => {
181
+ if (!abortController.signal.aborted) {
182
+ if (timeoutId) clearTimeout(timeoutId);
183
+ addFailure(currentStep, 'before_hook');
184
+ emitEvent(EVENTS.ERROR, currentStep, {
185
+ error: error instanceof Error ? error : new Error(String(error)),
186
+ });
187
+ proceed();
188
+ }
189
+ });
190
+ } else if (!beforeRef.current) {
191
+ if (pollingRef.current && pollingTargetRef.current !== currentStep.target) {
192
+ cleanup();
193
+ }
194
+
195
+ const element = getElement(currentStep.target);
196
+ const targetAvailable = element && isElementVisible(element);
197
+
198
+ if (targetAvailable) {
199
+ cleanup();
200
+ store.current.updateState({
201
+ action: lastAction.current ?? ACTIONS.UPDATE,
202
+ lifecycle: LIFECYCLE.READY,
203
+ waiting: false,
204
+ });
205
+ } else if (currentStep.targetWaitTimeout === 0) {
206
+ store.current.updateState({
207
+ action: lastAction.current ?? ACTIONS.UPDATE,
208
+ lifecycle: LIFECYCLE.READY,
209
+ waiting: false,
210
+ });
211
+ } else if (!pollingRef.current) {
212
+ const { targetWaitTimeout } = currentStep;
213
+
214
+ const startTime = Date.now();
215
+
216
+ pollingTargetRef.current = currentStep.target;
217
+ log(debug, `step:${index}`, 'polling', 'started', `${targetWaitTimeout}ms`);
218
+
219
+ store.current.updateState({ waiting: true });
220
+
221
+ pollingRef.current = setInterval(() => {
222
+ const el = getElement(currentStep.target);
223
+ const elapsed = Date.now() - startTime;
224
+ const timedOut = elapsed >= targetWaitTimeout;
225
+
226
+ if ((el && isElementVisible(el)) || timedOut) {
227
+ log(
228
+ debug,
229
+ `step:${index}`,
230
+ 'polling',
231
+ el && isElementVisible(el) ? 'found' : 'timed out',
232
+ `${elapsed}ms`,
233
+ );
234
+ cleanup();
235
+ store.current.updateState({
236
+ action: lastAction.current ?? ACTIONS.UPDATE,
237
+ lifecycle: LIFECYCLE.READY,
238
+ waiting: false,
239
+ });
240
+ }
241
+ }, 100);
242
+ }
243
+ }
244
+
245
+ return () => {
246
+ cleanup();
247
+ };
248
+ }, [addFailure, emitEvent, index, lifecycle, status, store]);
249
+
250
+ // Effect 3: Step presentation (READY → *_BEFORE → BEACON/TOOLTIP) + target not found
251
+ // eslint-disable-next-line sonarjs/cognitive-complexity
252
+ useEffect(() => {
253
+ if (!previousStateRef.current) {
254
+ return;
255
+ }
256
+
257
+ const { hasChanged, hasChangedTo, previous } = treeChanges(
258
+ stateRef.current,
259
+ previousStateRef.current,
260
+ );
261
+ const currentStep = stepRef.current;
262
+
263
+ if (!currentStep) {
264
+ return;
265
+ }
266
+
267
+ const element = getElement(currentStep.target);
268
+ const elementExists = !!element;
269
+
270
+ if (elementExists && isElementVisible(element)) {
271
+ if (hasChangedTo('lifecycle', LIFECYCLE.READY) && previous.lifecycle === LIFECYCLE.INIT) {
272
+ emitEvent(EVENTS.STEP_BEFORE, currentStep, {
273
+ action: lastAction.current ?? stateRef.current.action,
274
+ });
275
+ }
276
+
277
+ if (hasChangedTo('lifecycle', LIFECYCLE.READY)) {
278
+ const currentState = stateRef.current;
279
+ const finalLifecycle = shouldHideBeacon(
280
+ currentStep,
281
+ currentState,
282
+ propsRef.current.continuous,
283
+ )
284
+ ? LIFECYCLE.TOOLTIP
285
+ : LIFECYCLE.BEACON;
286
+
287
+ const target = getElement(
288
+ currentStep.scrollTarget ?? currentStep.spotlightTarget ?? currentStep.target,
289
+ );
290
+ const willScroll = needsScrolling({
291
+ isFirstStep: currentState.index === 0,
292
+ scrollToFirstStep: propsRef.current.scrollToFirstStep,
293
+ step: currentStep,
294
+ target,
295
+ targetLifecycle: finalLifecycle,
296
+ });
297
+
298
+ const beforeLifecycle =
299
+ finalLifecycle === LIFECYCLE.TOOLTIP ? LIFECYCLE.TOOLTIP_BEFORE : LIFECYCLE.BEACON_BEFORE;
300
+
301
+ log(propsRef.current.debug, `step:${index}`, 'scroll', willScroll ? 'needed' : 'skipped');
302
+
303
+ store.current.updateState({
304
+ action: ACTIONS.UPDATE,
305
+ lifecycle: beforeLifecycle,
306
+ scrolling: willScroll,
307
+ });
308
+ }
309
+ } else if (
310
+ stateRef.current.status === STATUS.RUNNING &&
311
+ lifecycle !== LIFECYCLE.INIT &&
312
+ lifecycle !== LIFECYCLE.COMPLETE &&
313
+ hasChanged('lifecycle')
314
+ ) {
315
+ log(
316
+ propsRef.current.debug,
317
+ `step:${index}`,
318
+ elementExists ? 'Target not visible' : 'Target not mounted',
319
+ currentStep,
320
+ );
321
+
322
+ addFailure(currentStep, 'target_not_found');
323
+ emitEvent(EVENTS.TARGET_NOT_FOUND, currentStep);
324
+
325
+ const currentState = stateRef.current;
326
+
327
+ if (!currentState.controlled) {
328
+ store.current.updateState({
329
+ action: ACTIONS.UPDATE,
330
+ index: currentState.index + (currentState.action === ACTIONS.PREV ? -1 : 1),
331
+ lifecycle: LIFECYCLE.INIT,
332
+ });
333
+ }
334
+ }
335
+ }, [addFailure, emitEvent, index, lifecycle, store]);
336
+
337
+ // Effect 4: *_BEFORE → BEACON/TOOLTIP transition + lifecycle callbacks
338
+ // eslint-disable-next-line sonarjs/cognitive-complexity
339
+ useEffect(() => {
340
+ if (!previousStateRef.current) {
341
+ return;
342
+ }
343
+
344
+ const { hasChangedTo, previous } = treeChanges(stateRef.current, previousStateRef.current);
345
+ const currentStep = stepRef.current;
346
+ const previousStepValue = previousStepRef.current;
347
+
348
+ // BEACON → TOOLTIP_BEFORE: check if scroll adjustment is needed
349
+ if (
350
+ currentStep &&
351
+ hasChangedTo('lifecycle', LIFECYCLE.TOOLTIP_BEFORE) &&
352
+ previous.lifecycle === LIFECYCLE.BEACON
353
+ ) {
354
+ const target = getElement(
355
+ currentStep.scrollTarget ?? currentStep.spotlightTarget ?? currentStep.target,
356
+ );
357
+ const willScroll = needsScrolling({
358
+ isFirstStep: stateRef.current.index === 0,
359
+ scrollToFirstStep: propsRef.current.scrollToFirstStep,
360
+ step: currentStep,
361
+ target,
362
+ targetLifecycle: LIFECYCLE.TOOLTIP,
363
+ });
364
+
365
+ if (willScroll) {
366
+ store.current.updateState({ scrolling: true, positioned: false });
367
+
368
+ return;
369
+ }
370
+ }
371
+
372
+ // *_BEFORE → BEACON/TOOLTIP when scroll is done
373
+ const isBeforePhase =
374
+ lifecycle === LIFECYCLE.BEACON_BEFORE || lifecycle === LIFECYCLE.TOOLTIP_BEFORE;
375
+
376
+ if (currentStep && isBeforePhase && !scrolling) {
377
+ const finalLifecycle =
378
+ lifecycle === LIFECYCLE.TOOLTIP_BEFORE ? LIFECYCLE.TOOLTIP : LIFECYCLE.BEACON;
379
+
380
+ store.current.updateState({
381
+ action: ACTIONS.UPDATE,
382
+ lifecycle: finalLifecycle,
383
+ });
384
+ }
385
+
386
+ if (currentStep && hasChangedTo('lifecycle', LIFECYCLE.BEACON)) {
387
+ emitEvent(EVENTS.BEACON, currentStep);
388
+ }
389
+
390
+ if (currentStep && hasChangedTo('lifecycle', LIFECYCLE.TOOLTIP)) {
391
+ emitEvent(EVENTS.TOOLTIP, currentStep);
392
+ }
393
+
394
+ const currentState = stateRef.current;
395
+ const isRunningOrPausedWithStep =
396
+ currentState.status === STATUS.RUNNING ||
397
+ (currentState.controlled && currentState.status === STATUS.PAUSED && !!currentStep);
398
+ const shouldFireStepAfter =
399
+ isRunningOrPausedWithStep &&
400
+ previousStepValue &&
401
+ hasChangedTo('lifecycle', LIFECYCLE.COMPLETE) &&
402
+ previous.lifecycle === LIFECYCLE.TOOLTIP;
403
+
404
+ if (shouldFireStepAfter) {
405
+ emitEvent(EVENTS.STEP_AFTER, previousStepValue, {
406
+ action: lastAction.current ?? ACTIONS.UPDATE,
407
+ index: previous.index ?? currentState.index,
408
+ lifecycle: currentState.lifecycle,
409
+ });
410
+
411
+ if (previousStepValue.after) {
412
+ emitEvent(EVENTS.STEP_AFTER_HOOK, previousStepValue, {
413
+ action: lastAction.current ?? ACTIONS.UPDATE,
414
+ index: previous.index ?? currentState.index,
415
+ lifecycle: currentState.lifecycle,
416
+ });
417
+
418
+ try {
419
+ previousStepValue.after({
420
+ ...store.current.getState(),
421
+ action: lastAction.current ?? ACTIONS.UPDATE,
422
+ index: previous.index ?? currentState.index,
423
+ lifecycle: currentState.lifecycle,
424
+ step: previousStepValue,
425
+ });
426
+ } catch {
427
+ // fire-and-forget: don't let user code break the tour
428
+ }
429
+ }
430
+ }
431
+ }, [emitEvent, lifecycle, positioned, scrolling, store]);
432
+
433
+ // Effect 5: Tour flow + tour-level callbacks
434
+ // eslint-disable-next-line sonarjs/cognitive-complexity
435
+ useEffect(() => {
436
+ if (!previousStateRef.current) {
437
+ return;
438
+ }
439
+
440
+ const { hasChangedTo, previous } = treeChanges(stateRef.current, previousStateRef.current);
441
+ const currentStep = stepRef.current;
442
+ const previousStepValue = previousStepRef.current;
443
+
444
+ if (hasChangedTo('action', ACTIONS.REPLAY) && hasChangedTo('lifecycle', LIFECYCLE.COMPLETE)) {
445
+ store.current.updateState({ lifecycle: LIFECYCLE.INIT });
446
+
447
+ return;
448
+ }
449
+
450
+ if (size && !currentStep && lifecycle === LIFECYCLE.INIT) {
451
+ store.current.updateState({
452
+ action: ACTIONS.UPDATE,
453
+ lifecycle: LIFECYCLE.COMPLETE,
454
+ status: STATUS.FINISHED,
455
+ });
456
+ }
457
+
458
+ if (
459
+ !stateRef.current.controlled &&
460
+ status === STATUS.RUNNING &&
461
+ hasChangedTo('lifecycle', LIFECYCLE.COMPLETE) &&
462
+ index < size
463
+ ) {
464
+ store.current.updateState({ action: ACTIONS.UPDATE, lifecycle: LIFECYCLE.INIT });
465
+ }
466
+
467
+ if (hasChangedTo('lifecycle', LIFECYCLE.COMPLETE) && index >= size) {
468
+ store.current.updateState({
469
+ action: ACTIONS.UPDATE,
470
+ lifecycle: LIFECYCLE.COMPLETE,
471
+ status: STATUS.FINISHED,
472
+ });
473
+ }
474
+
475
+ const tourEndStep =
476
+ currentStep ??
477
+ previousStepValue ??
478
+ getMergedStep(propsRef.current, propsRef.current.steps[index - 1]);
479
+
480
+ if (tourEndStep && hasChangedTo('status', [STATUS.FINISHED, STATUS.SKIPPED])) {
481
+ let tourEndIndex: number;
482
+
483
+ if (currentStep) {
484
+ tourEndIndex = index;
485
+ } else if (previousStepValue) {
486
+ tourEndIndex = previous.index ?? index;
487
+ } else {
488
+ tourEndIndex = index - 1;
489
+ }
490
+
491
+ emitEvent(EVENTS.TOUR_END, tourEndStep, { index: tourEndIndex });
492
+
493
+ if (!stateRef.current.controlled) {
494
+ controlsRef.current.reset();
495
+ }
496
+
497
+ lastAction.current = null;
498
+ }
499
+
500
+ // tour:start is emitted in Effect 2 (before step processing) to ensure correct event order
501
+
502
+ if (currentStep && hasChangedTo('action', ACTIONS.STOP)) {
503
+ lastAction.current = null;
504
+ emitEvent(EVENTS.TOUR_STATUS, currentStep);
505
+ }
506
+
507
+ if (currentStep && hasChangedTo('action', ACTIONS.RESET)) {
508
+ emitEvent(EVENTS.TOUR_STATUS, currentStep);
509
+ lastAction.current = null;
510
+ }
511
+ }, [action, emitEvent, index, lifecycle, size, status, store]);
512
+ }
@@ -0,0 +1,49 @@
1
+ import { useEffect, useState } from 'react';
2
+ import is from 'is-lite';
3
+
4
+ import { PORTAL_ELEMENT_ID } from '~/literals';
5
+
6
+ import type { SelectorOrElement } from '~/types';
7
+
8
+ export function usePortalElement(portalElement?: SelectorOrElement) {
9
+ const [element, setElement] = useState<HTMLElement | null>(null);
10
+
11
+ useEffect(() => {
12
+ let createdElement: HTMLElement | null = null;
13
+ let isExternal = false;
14
+
15
+ if (portalElement) {
16
+ if (is.domElement(portalElement)) {
17
+ createdElement = portalElement;
18
+ isExternal = true;
19
+ } else {
20
+ const portal = document.querySelector(portalElement);
21
+
22
+ if (portal) {
23
+ createdElement = portal as HTMLElement;
24
+ }
25
+ }
26
+ } else {
27
+ const portal = document.createElement('div');
28
+
29
+ portal.id = PORTAL_ELEMENT_ID;
30
+
31
+ document.body.appendChild(portal);
32
+ createdElement = portal;
33
+ }
34
+
35
+ setElement(createdElement);
36
+
37
+ return () => {
38
+ if (!createdElement || isExternal) {
39
+ return;
40
+ }
41
+
42
+ if (createdElement.parentNode === document.body) {
43
+ document.body.removeChild(createdElement);
44
+ }
45
+ };
46
+ }, [portalElement]);
47
+
48
+ return element;
49
+ }
@@ -0,0 +1,84 @@
1
+ import { type RefObject, useEffect, useRef } from 'react';
2
+ import isEqual from '@gilbarbara/deep-equal';
3
+ import is from 'is-lite';
4
+
5
+ import type { EmitEvent } from '~/hooks/useEventEmitter';
6
+ import type { MergedProps } from '~/hooks/useTourEngine';
7
+ import { ACTIONS, EVENTS, LIFECYCLE, STATUS } from '~/literals';
8
+ import { treeChanges } from '~/modules/changes';
9
+ import { log } from '~/modules/helpers';
10
+ import { validateSteps } from '~/modules/step';
11
+ import createStore from '~/modules/store';
12
+ import type { StoreState } from '~/modules/store';
13
+
14
+ import type { Actions, Controls, Status, StepMerged } from '~/types';
15
+
16
+ interface UsePropSyncParams {
17
+ controls: Controls;
18
+ emitEvent: EmitEvent;
19
+ props: MergedProps;
20
+ state: StoreState;
21
+ store: RefObject<ReturnType<typeof createStore>>;
22
+ }
23
+
24
+ export default function usePropSync({
25
+ controls,
26
+ emitEvent,
27
+ props,
28
+ state,
29
+ store,
30
+ }: UsePropSyncParams): void {
31
+ const { debug, initialStepIndex, run, stepIndex, steps } = props;
32
+
33
+ const previousPropsRef = useRef<MergedProps | undefined>(undefined);
34
+ const stateRef = useRef(state);
35
+ const controlsRef = useRef(controls);
36
+
37
+ stateRef.current = state;
38
+ controlsRef.current = controls;
39
+
40
+ useEffect(() => {
41
+ const previousProps = previousPropsRef.current;
42
+
43
+ previousPropsRef.current = props;
44
+
45
+ if (!previousProps || props === previousProps) {
46
+ return;
47
+ }
48
+
49
+ const { hasChanged } = treeChanges(props, previousProps);
50
+
51
+ if (!isEqual(previousProps.steps, steps)) {
52
+ if (validateSteps(steps, debug)) {
53
+ store.current.setSteps(steps);
54
+ } else {
55
+ log(debug, 'tour', 'Steps are not valid', steps);
56
+ emitEvent(EVENTS.ERROR, (steps[0] ?? { target: '', content: '' }) as StepMerged, {
57
+ error: new Error('Steps are not valid'),
58
+ });
59
+ }
60
+ }
61
+
62
+ if (hasChanged('run')) {
63
+ if (run) {
64
+ if (store.current.getState().size) {
65
+ controlsRef.current.start(stepIndex ?? initialStepIndex);
66
+ }
67
+ } else {
68
+ controlsRef.current.stop();
69
+ }
70
+ } else if (is.number(stepIndex) && hasChanged('stepIndex')) {
71
+ const nextAction: Actions =
72
+ is.number(previousProps.stepIndex) && previousProps.stepIndex < stepIndex
73
+ ? ACTIONS.NEXT
74
+ : ACTIONS.PREV;
75
+
76
+ if (!([STATUS.FINISHED, STATUS.SKIPPED] as Array<Status>).includes(stateRef.current.status)) {
77
+ store.current.updateState(
78
+ { action: nextAction, index: stepIndex, lifecycle: LIFECYCLE.INIT, positioned: false },
79
+ true,
80
+ );
81
+ }
82
+ }
83
+ }, [debug, emitEvent, initialStepIndex, props, run, stepIndex, steps, store]);
84
+ }