@matthesketh/react-guidetour 1.0.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.
@@ -0,0 +1,140 @@
1
+ import * as React from 'react';
2
+
3
+ import { getReactNodeText, replaceLocaleContent } from '~/modules/helpers';
4
+
5
+ import { TooltipProps } from '~/types';
6
+
7
+ import Container from './Container';
8
+
9
+ export default function JoyrideTooltip(props: TooltipProps) {
10
+ const { continuous, helpers, index, isLastStep, setTooltipRef, size, step } = props;
11
+
12
+ const handleClickBack = (event: React.MouseEvent<HTMLElement>) => {
13
+ event.preventDefault();
14
+ helpers.prev();
15
+ };
16
+
17
+ const handleClickClose = (event: React.MouseEvent<HTMLElement>) => {
18
+ event.preventDefault();
19
+ helpers.close('button_close');
20
+ };
21
+
22
+ const handleClickPrimary = (event: React.MouseEvent<HTMLElement>) => {
23
+ event.preventDefault();
24
+
25
+ if (!continuous) {
26
+ helpers.close('button_primary');
27
+
28
+ return;
29
+ }
30
+
31
+ helpers.next();
32
+ };
33
+
34
+ const handleClickSkip = (event: React.MouseEvent<HTMLElement>) => {
35
+ event.preventDefault();
36
+ helpers.skip();
37
+ };
38
+
39
+ const getElementsProps = () => {
40
+ const { back, close, last, next, nextLabelWithProgress, skip } = step.locale;
41
+
42
+ const backText = getReactNodeText(back);
43
+ const closeText = getReactNodeText(close);
44
+ const lastText = getReactNodeText(last);
45
+ const nextText = getReactNodeText(next);
46
+ const skipText = getReactNodeText(skip);
47
+
48
+ let primary = close;
49
+ let primaryText = closeText;
50
+
51
+ if (continuous) {
52
+ primary = next;
53
+ primaryText = nextText;
54
+
55
+ if (step.showProgress && !isLastStep) {
56
+ const labelWithProgress = getReactNodeText(nextLabelWithProgress, {
57
+ step: index + 1,
58
+ steps: size,
59
+ });
60
+
61
+ primary = replaceLocaleContent(nextLabelWithProgress, index + 1, size);
62
+ primaryText = labelWithProgress;
63
+ }
64
+
65
+ if (isLastStep) {
66
+ primary = last;
67
+ primaryText = lastText;
68
+ }
69
+ }
70
+
71
+ return {
72
+ backProps: {
73
+ 'aria-label': backText,
74
+ children: back,
75
+ 'data-action': 'back',
76
+ onClick: handleClickBack,
77
+ role: 'button',
78
+ title: backText,
79
+ },
80
+ closeProps: {
81
+ 'aria-label': closeText,
82
+ children: close,
83
+ 'data-action': 'close',
84
+ onClick: handleClickClose,
85
+ role: 'button',
86
+ title: closeText,
87
+ },
88
+ primaryProps: {
89
+ 'aria-label': primaryText,
90
+ children: primary,
91
+ 'data-action': 'primary',
92
+ onClick: handleClickPrimary,
93
+ role: 'button',
94
+ title: primaryText,
95
+ },
96
+ skipProps: {
97
+ 'aria-label': skipText,
98
+ children: skip,
99
+ 'data-action': 'skip',
100
+ onClick: handleClickSkip,
101
+ role: 'button',
102
+ title: skipText,
103
+ },
104
+ tooltipProps: {
105
+ 'aria-modal': true,
106
+ ref: setTooltipRef,
107
+ role: 'alertdialog',
108
+ },
109
+ };
110
+ };
111
+
112
+ const { beaconComponent, tooltipComponent, ...cleanStep } = step;
113
+
114
+ if (tooltipComponent) {
115
+ const renderProps = {
116
+ ...getElementsProps(),
117
+ continuous,
118
+ index,
119
+ isLastStep,
120
+ size,
121
+ step: cleanStep,
122
+ setTooltipRef,
123
+ };
124
+
125
+ const TooltipComponent = tooltipComponent;
126
+
127
+ return <TooltipComponent {...renderProps} />;
128
+ }
129
+
130
+ return (
131
+ <Container
132
+ {...getElementsProps()}
133
+ continuous={continuous}
134
+ index={index}
135
+ isLastStep={isLastStep}
136
+ size={size}
137
+ step={step}
138
+ />
139
+ );
140
+ }
@@ -0,0 +1,410 @@
1
+ import { ReactNode, useCallback, useEffect, useRef, useState } from 'react';
2
+ import isEqual from '@gilbarbara/deep-equal';
3
+ import is from 'is-lite';
4
+ import treeChanges from 'tree-changes';
5
+
6
+ import {
7
+ canUseDOM,
8
+ getElement,
9
+ getScrollParent,
10
+ getScrollTo,
11
+ hasCustomScrollParent,
12
+ scrollTo,
13
+ } from '~/modules/dom';
14
+ import { log, shouldScroll } from '~/modules/helpers';
15
+ import { getMergedStep, validateSteps } from '~/modules/step';
16
+ import createStore from '~/modules/store';
17
+
18
+ import { ACTIONS, EVENTS, LIFECYCLE, STATUS } from '~/literals';
19
+
20
+ import Overlay from '~/components/Overlay';
21
+ import Portal from '~/components/Portal';
22
+
23
+ import { Actions, CallBackProps, Props, State, Status, StoreHelpers } from '~/types';
24
+
25
+ import Step from './Step';
26
+
27
+ const defaultPropsValues = {
28
+ continuous: false,
29
+ debug: false,
30
+ disableCloseOnEsc: false,
31
+ disableOverlay: false,
32
+ disableOverlayClose: false,
33
+ disableScrolling: false,
34
+ disableScrollParentFix: false,
35
+ hideBackButton: false,
36
+ run: true,
37
+ scrollOffset: 20,
38
+ scrollDuration: 300,
39
+ scrollToFirstStep: false,
40
+ showSkipButton: false,
41
+ showProgress: false,
42
+ spotlightClicks: false,
43
+ spotlightPadding: 10,
44
+ steps: [],
45
+ };
46
+
47
+ function Joyride(inputProps: Props) {
48
+ const props = { ...defaultPropsValues, ...inputProps };
49
+ const {
50
+ callback: callbackProp,
51
+ continuous,
52
+ debug,
53
+ disableCloseOnEsc,
54
+ getHelpers,
55
+ nonce,
56
+ run,
57
+ scrollDuration,
58
+ scrollOffset,
59
+ scrollToFirstStep,
60
+ stepIndex,
61
+ steps,
62
+ } = props;
63
+
64
+ const storeRef = useRef(
65
+ createStore({
66
+ ...props,
67
+ controlled: run && is.number(stepIndex),
68
+ }),
69
+ );
70
+ const helpersRef = useRef<StoreHelpers>(storeRef.current.getHelpers());
71
+ const [state, setState] = useState<State>(storeRef.current.getState());
72
+ const previousStateRef = useRef<State>(state);
73
+ const previousPropsRef = useRef(props);
74
+ const mountedRef = useRef(false);
75
+
76
+ const store = storeRef.current;
77
+ const helpers = helpersRef.current;
78
+
79
+ const triggerCallback = useCallback(
80
+ (data: CallBackProps) => {
81
+ if (is.function(callbackProp)) {
82
+ callbackProp(data);
83
+ }
84
+ },
85
+ [callbackProp],
86
+ );
87
+
88
+ const handleKeyboard = useCallback(
89
+ (event: KeyboardEvent) => {
90
+ const { index: currentIndex, lifecycle } = storeRef.current.getState();
91
+ const currentSteps = props.steps;
92
+ const currentStep = currentSteps[currentIndex];
93
+
94
+ if (lifecycle === LIFECYCLE.TOOLTIP) {
95
+ if (event.code === 'Escape' && currentStep && !currentStep.disableCloseOnEsc) {
96
+ storeRef.current.close('keyboard');
97
+ }
98
+ }
99
+ },
100
+ [props.steps],
101
+ );
102
+
103
+ const handleClickOverlay = useCallback(() => {
104
+ const currentState = storeRef.current.getState();
105
+ const step = getMergedStep(props, steps[currentState.index]);
106
+
107
+ if (!step.disableOverlayClose) {
108
+ helpers.close('overlay');
109
+ }
110
+ }, [props, steps, helpers]);
111
+
112
+ // Set up store listener and keyboard handler
113
+ useEffect(() => {
114
+ store.addListener(setState);
115
+
116
+ log({
117
+ title: 'init',
118
+ data: [
119
+ { key: 'props', value: props },
120
+ { key: 'state', value: state },
121
+ ],
122
+ debug,
123
+ });
124
+
125
+ if (getHelpers) {
126
+ getHelpers(helpers);
127
+ }
128
+ }, []); // eslint-disable-line react-hooks/exhaustive-deps
129
+
130
+ // Mount effect - start tour and add keyboard listener
131
+ useEffect(() => {
132
+ if (!canUseDOM()) {
133
+ return;
134
+ }
135
+
136
+ if (validateSteps(steps, debug) && run) {
137
+ store.start();
138
+ }
139
+
140
+ if (!disableCloseOnEsc) {
141
+ document.body.addEventListener('keydown', handleKeyboard, { passive: true });
142
+ }
143
+
144
+ mountedRef.current = true;
145
+
146
+ return () => {
147
+ if (!disableCloseOnEsc) {
148
+ document.body.removeEventListener('keydown', handleKeyboard);
149
+ }
150
+ };
151
+ }, []); // eslint-disable-line react-hooks/exhaustive-deps
152
+
153
+ // componentDidUpdate for props changes
154
+ useEffect(() => {
155
+ if (!mountedRef.current || !canUseDOM()) {
156
+ return;
157
+ }
158
+
159
+ const previousProps = previousPropsRef.current;
160
+ const { changedProps } = treeChanges(previousProps, props) as any;
161
+ const changedPropsFn = (key: string) => {
162
+ const prev = (previousProps as any)[key];
163
+ const curr = (props as any)[key];
164
+ return prev !== curr;
165
+ };
166
+
167
+ const stepsChanged = !isEqual(previousProps.steps, steps);
168
+
169
+ if (stepsChanged) {
170
+ if (validateSteps(steps, debug)) {
171
+ store.setSteps(steps);
172
+ } else {
173
+ // eslint-disable-next-line no-console
174
+ console.warn('Steps are not valid', steps);
175
+ }
176
+ }
177
+
178
+ if (changedPropsFn('run')) {
179
+ if (run) {
180
+ store.start(stepIndex);
181
+ } else {
182
+ store.stop();
183
+ }
184
+ }
185
+
186
+ const stepIndexChanged = is.number(stepIndex) && changedPropsFn('stepIndex');
187
+
188
+ if (stepIndexChanged) {
189
+ const currentState = store.getState();
190
+ let nextAction: Actions =
191
+ is.number(previousProps.stepIndex) && previousProps.stepIndex < stepIndex!
192
+ ? ACTIONS.NEXT
193
+ : ACTIONS.PREV;
194
+
195
+ if (currentState.action === ACTIONS.STOP) {
196
+ nextAction = ACTIONS.START;
197
+ }
198
+
199
+ if (!([STATUS.FINISHED, STATUS.SKIPPED] as Array<Status>).includes(currentState.status)) {
200
+ store.update({
201
+ action: currentState.action === ACTIONS.CLOSE ? ACTIONS.CLOSE : nextAction,
202
+ index: stepIndex,
203
+ lifecycle: LIFECYCLE.INIT,
204
+ });
205
+ }
206
+ }
207
+
208
+ previousPropsRef.current = props;
209
+ });
210
+
211
+ // componentDidUpdate for state changes
212
+ useEffect(() => {
213
+ if (!mountedRef.current || !canUseDOM()) {
214
+ return;
215
+ }
216
+
217
+ const previousState = previousStateRef.current;
218
+ const { action, controlled, index, lifecycle, status } = state;
219
+ const { changed, changedFrom } = treeChanges(previousState, state);
220
+ const step = getMergedStep(props, steps[index]);
221
+ const target = getElement(step.target);
222
+
223
+ // Update the index if the first step is not found
224
+ if (!controlled && status === STATUS.RUNNING && index === 0 && !target) {
225
+ store.update({ index: index + 1 });
226
+ triggerCallback({
227
+ ...state,
228
+ type: EVENTS.TARGET_NOT_FOUND,
229
+ step,
230
+ });
231
+ }
232
+
233
+ const callbackData = {
234
+ ...state,
235
+ index,
236
+ step,
237
+ };
238
+ const isAfterAction = changed('action', [
239
+ ACTIONS.NEXT,
240
+ ACTIONS.PREV,
241
+ ACTIONS.SKIP,
242
+ ACTIONS.CLOSE,
243
+ ]);
244
+
245
+ if (isAfterAction && changed('status', STATUS.PAUSED)) {
246
+ const previousStep = getMergedStep(props, steps[previousState.index]);
247
+
248
+ triggerCallback({
249
+ ...callbackData,
250
+ index: previousState.index,
251
+ lifecycle: LIFECYCLE.COMPLETE,
252
+ step: previousStep,
253
+ type: EVENTS.STEP_AFTER,
254
+ });
255
+ }
256
+
257
+ if (changed('status', [STATUS.FINISHED, STATUS.SKIPPED])) {
258
+ const previousStep = getMergedStep(props, steps[previousState.index]);
259
+
260
+ if (!controlled) {
261
+ triggerCallback({
262
+ ...callbackData,
263
+ index: previousState.index,
264
+ lifecycle: LIFECYCLE.COMPLETE,
265
+ step: previousStep,
266
+ type: EVENTS.STEP_AFTER,
267
+ });
268
+ }
269
+
270
+ triggerCallback({
271
+ ...callbackData,
272
+ type: EVENTS.TOUR_END,
273
+ // Return the last step when the tour is finished
274
+ step: previousStep,
275
+ index: previousState.index,
276
+ });
277
+ store.reset();
278
+ } else if (changedFrom('status', [STATUS.IDLE, STATUS.READY], STATUS.RUNNING)) {
279
+ triggerCallback({
280
+ ...callbackData,
281
+ type: EVENTS.TOUR_START,
282
+ });
283
+ } else if (changed('status') || changed('action', ACTIONS.RESET)) {
284
+ triggerCallback({
285
+ ...callbackData,
286
+ type: EVENTS.TOUR_STATUS,
287
+ });
288
+ }
289
+
290
+ // Scroll to step
291
+ scrollToStep(previousState);
292
+
293
+ previousStateRef.current = state;
294
+ });
295
+
296
+ function scrollToStep(previousState: State) {
297
+ const { index, lifecycle, status } = state;
298
+ const {
299
+ disableScrollParentFix = false,
300
+ } = props;
301
+ const step = getMergedStep(props, steps[index]);
302
+
303
+ const target = getElement(step.target);
304
+ const shouldScrollToStep = shouldScroll({
305
+ isFirstStep: index === 0,
306
+ lifecycle,
307
+ previousLifecycle: previousState.lifecycle,
308
+ scrollToFirstStep,
309
+ step,
310
+ target,
311
+ });
312
+
313
+ if (status === STATUS.RUNNING && shouldScrollToStep) {
314
+ const hasCustomScroll = hasCustomScrollParent(target, disableScrollParentFix);
315
+ const scrollParent = getScrollParent(target, disableScrollParentFix);
316
+ let scrollY = Math.floor(getScrollTo(target, scrollOffset, disableScrollParentFix)) || 0;
317
+
318
+ log({
319
+ title: 'scrollToStep',
320
+ data: [
321
+ { key: 'index', value: index },
322
+ { key: 'lifecycle', value: lifecycle },
323
+ { key: 'status', value: status },
324
+ ],
325
+ debug,
326
+ });
327
+
328
+ const beaconPopper = store.getPopper('beacon');
329
+ const tooltipPopper = store.getPopper('tooltip');
330
+
331
+ if (lifecycle === LIFECYCLE.BEACON && beaconPopper) {
332
+ const placement = beaconPopper.state?.placement ?? '';
333
+ const popperTop = beaconPopper.state?.rects?.popper?.y ?? 0;
334
+
335
+ if (!['bottom'].includes(placement) && !hasCustomScroll) {
336
+ scrollY = Math.floor(popperTop - scrollOffset);
337
+ }
338
+ } else if (lifecycle === LIFECYCLE.TOOLTIP && tooltipPopper) {
339
+ const placement = tooltipPopper.state?.placement ?? '';
340
+ const popperTop = tooltipPopper.state?.rects?.popper?.y ?? 0;
341
+ const flipped = tooltipPopper.state?.modifiersData?.flip?.overflows != null;
342
+
343
+ if (['top', 'right', 'left'].includes(placement) && !flipped && !hasCustomScroll) {
344
+ scrollY = Math.floor(popperTop - scrollOffset);
345
+ } else {
346
+ scrollY -= step.spotlightPadding;
347
+ }
348
+ }
349
+
350
+ scrollY = scrollY >= 0 ? scrollY : 0;
351
+
352
+ if (status === STATUS.RUNNING) {
353
+ scrollTo(scrollY, { element: scrollParent as Element, duration: scrollDuration }).then(
354
+ () => {
355
+ setTimeout(() => {
356
+ store.getPopper('tooltip')?.update();
357
+ }, 10);
358
+ },
359
+ );
360
+ }
361
+ }
362
+ }
363
+
364
+ if (!canUseDOM()) {
365
+ return null;
366
+ }
367
+
368
+ const { index, lifecycle, status } = state;
369
+ const isRunning = status === STATUS.RUNNING;
370
+ const content: Record<string, ReactNode> = {};
371
+
372
+ if (isRunning && steps[index]) {
373
+ const step = getMergedStep(props, steps[index]);
374
+
375
+ content.step = (
376
+ <Step
377
+ {...state}
378
+ callback={triggerCallback}
379
+ continuous={continuous}
380
+ debug={debug}
381
+ helpers={helpers}
382
+ nonce={nonce}
383
+ shouldScroll={!step.disableScrolling && (index !== 0 || scrollToFirstStep)}
384
+ step={step}
385
+ store={store}
386
+ />
387
+ );
388
+
389
+ content.overlay = (
390
+ <Portal id="react-joyride-portal">
391
+ <Overlay
392
+ {...step}
393
+ continuous={continuous}
394
+ debug={debug}
395
+ lifecycle={lifecycle}
396
+ onClickOverlay={handleClickOverlay}
397
+ />
398
+ </Portal>
399
+ );
400
+ }
401
+
402
+ return (
403
+ <div className="react-joyride">
404
+ {content.step}
405
+ {content.overlay}
406
+ </div>
407
+ );
408
+ }
409
+
410
+ export default Joyride;
@@ -0,0 +1,62 @@
1
+ import { noop } from '~/modules/helpers';
2
+
3
+ import { Locale, Props, Step } from '~/types';
4
+
5
+ export const defaultFloaterProps = {
6
+ wrapperOptions: {
7
+ offset: -18,
8
+ position: true,
9
+ },
10
+ };
11
+
12
+ export const defaultLocale: Locale = {
13
+ back: 'Back',
14
+ close: 'Close',
15
+ last: 'Last',
16
+ next: 'Next',
17
+ nextLabelWithProgress: 'Next (Step {step} of {steps})',
18
+ open: 'Open the dialog',
19
+ skip: 'Skip',
20
+ };
21
+
22
+ export const defaultStep = {
23
+ event: 'click',
24
+ placement: 'bottom',
25
+ offset: 10,
26
+ disableBeacon: false,
27
+ disableCloseOnEsc: false,
28
+ disableOverlay: false,
29
+ disableOverlayClose: false,
30
+ disableScrollParentFix: false,
31
+ disableScrolling: false,
32
+ hideBackButton: false,
33
+ hideCloseButton: false,
34
+ hideFooter: false,
35
+ isFixed: false,
36
+ locale: defaultLocale,
37
+ showProgress: false,
38
+ showSkipButton: false,
39
+ spotlightClicks: false,
40
+ spotlightPadding: 10,
41
+ } satisfies Omit<Step, 'content' | 'target'>;
42
+
43
+ export const defaultProps = {
44
+ continuous: false,
45
+ debug: false,
46
+ disableCloseOnEsc: false,
47
+ disableOverlay: false,
48
+ disableOverlayClose: false,
49
+ disableScrolling: false,
50
+ disableScrollParentFix: false,
51
+ getHelpers: noop(),
52
+ hideBackButton: false,
53
+ run: true,
54
+ scrollOffset: 20,
55
+ scrollDuration: 300,
56
+ scrollToFirstStep: false,
57
+ showSkipButton: false,
58
+ showProgress: false,
59
+ spotlightClicks: false,
60
+ spotlightPadding: 10,
61
+ steps: [],
62
+ } satisfies Props;
@@ -0,0 +1,8 @@
1
+ declare global {
2
+ namespace NodeJS {
3
+ interface ProcessEnv {
4
+ NODE_ENV: 'development' | 'production' | 'test';
5
+ }
6
+ }
7
+ }
8
+ export {};
package/src/index.tsx ADDED
@@ -0,0 +1,5 @@
1
+ export * from './literals';
2
+
3
+ // eslint-disable-next-line no-restricted-exports
4
+ export { default } from './components';
5
+ export * from './types';
@@ -0,0 +1,51 @@
1
+ export const ACTIONS = {
2
+ INIT: 'init',
3
+ START: 'start',
4
+ STOP: 'stop',
5
+ RESET: 'reset',
6
+ PREV: 'prev',
7
+ NEXT: 'next',
8
+ GO: 'go',
9
+ CLOSE: 'close',
10
+ SKIP: 'skip',
11
+ UPDATE: 'update',
12
+ } as const;
13
+
14
+ export const EVENTS = {
15
+ TOUR_START: 'tour:start',
16
+ STEP_BEFORE: 'step:before',
17
+ BEACON: 'beacon',
18
+ TOOLTIP: 'tooltip',
19
+ STEP_AFTER: 'step:after',
20
+ TOUR_END: 'tour:end',
21
+ TOUR_STATUS: 'tour:status',
22
+ TARGET_NOT_FOUND: 'error:target_not_found',
23
+ ERROR: 'error',
24
+ } as const;
25
+
26
+ export const LIFECYCLE = {
27
+ INIT: 'init',
28
+ READY: 'ready',
29
+ BEACON: 'beacon',
30
+ TOOLTIP: 'tooltip',
31
+ COMPLETE: 'complete',
32
+ ERROR: 'error',
33
+ } as const;
34
+
35
+ export const ORIGIN = {
36
+ BUTTON_CLOSE: 'button_close',
37
+ BUTTON_PRIMARY: 'button_primary',
38
+ KEYBOARD: 'keyboard',
39
+ OVERLAY: 'overlay',
40
+ } as const;
41
+
42
+ export const STATUS = {
43
+ IDLE: 'idle',
44
+ READY: 'ready',
45
+ WAITING: 'waiting',
46
+ RUNNING: 'running',
47
+ PAUSED: 'paused',
48
+ SKIPPED: 'skipped',
49
+ FINISHED: 'finished',
50
+ ERROR: 'error',
51
+ } as const;