@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,120 @@
1
+ import deepmerge from 'deepmerge';
2
+ import is from 'is-lite';
3
+
4
+ import { defaultFloaterProps, defaultLocale, defaultStep } from '~/defaults';
5
+ import getStyles from '~/styles';
6
+ import { Props, Step, StepMerged } from '~/types';
7
+
8
+ import { getElement, hasCustomScrollParent } from './dom';
9
+ import { log, omit, pick } from './helpers';
10
+
11
+ function getTourProps(props: Props) {
12
+ return pick(
13
+ props,
14
+ 'beaconComponent',
15
+ 'disableCloseOnEsc',
16
+ 'disableOverlay',
17
+ 'disableOverlayClose',
18
+ 'disableScrolling',
19
+ 'disableScrollParentFix',
20
+ 'floaterProps',
21
+ 'hideBackButton',
22
+ 'hideCloseButton',
23
+ 'locale',
24
+ 'showProgress',
25
+ 'showSkipButton',
26
+ 'spotlightClicks',
27
+ 'spotlightPadding',
28
+ 'styles',
29
+ 'tooltipComponent',
30
+ );
31
+ }
32
+
33
+ export function getMergedStep(props: Props, currentStep?: Step): StepMerged {
34
+ const step = currentStep ?? {};
35
+ const mergedStep = deepmerge.all([defaultStep, getTourProps(props), step], {
36
+ isMergeableObject: is.plainObject,
37
+ }) as StepMerged;
38
+
39
+ const mergedStyles = getStyles(props, mergedStep);
40
+ const scrollParent = hasCustomScrollParent(
41
+ getElement(mergedStep.target),
42
+ mergedStep.disableScrollParentFix,
43
+ );
44
+ const floaterProps = deepmerge.all([
45
+ defaultFloaterProps,
46
+ props.floaterProps ?? {},
47
+ mergedStep.floaterProps ?? {},
48
+ ]) as any;
49
+
50
+ // Set react-floater props
51
+ floaterProps.offset = mergedStep.offset;
52
+ floaterProps.styles = deepmerge(floaterProps.styles ?? {}, mergedStyles.floaterStyles);
53
+
54
+ floaterProps.offset += props.spotlightPadding ?? mergedStep.spotlightPadding ?? 0;
55
+
56
+ if (mergedStep.placementBeacon && floaterProps.wrapperOptions) {
57
+ floaterProps.wrapperOptions.placement = mergedStep.placementBeacon;
58
+ }
59
+
60
+ if (scrollParent && floaterProps.modifiers?.preventOverflow) {
61
+ floaterProps.modifiers.preventOverflow.options = {
62
+ ...floaterProps.modifiers.preventOverflow.options,
63
+ boundary: 'window',
64
+ };
65
+ }
66
+
67
+ return {
68
+ ...mergedStep,
69
+ locale: deepmerge.all([defaultLocale, props.locale ?? {}, mergedStep.locale || {}]),
70
+ floaterProps,
71
+ styles: omit(mergedStyles, 'floaterStyles'),
72
+ };
73
+ }
74
+
75
+ /**
76
+ * Validate if a step is valid
77
+ */
78
+ export function validateStep(step: Step, debug: boolean = false): boolean {
79
+ if (!is.plainObject(step)) {
80
+ log({
81
+ title: 'validateStep',
82
+ data: 'step must be an object',
83
+ warn: true,
84
+ debug,
85
+ });
86
+
87
+ return false;
88
+ }
89
+
90
+ if (!step.target) {
91
+ log({
92
+ title: 'validateStep',
93
+ data: 'target is missing from the step',
94
+ warn: true,
95
+ debug,
96
+ });
97
+
98
+ return false;
99
+ }
100
+
101
+ return true;
102
+ }
103
+
104
+ /**
105
+ * Validate if steps are valid
106
+ */
107
+ export function validateSteps(steps: Array<Step>, debug: boolean = false): boolean {
108
+ if (!is.array(steps)) {
109
+ log({
110
+ title: 'validateSteps',
111
+ data: 'steps must be an array',
112
+ warn: true,
113
+ debug,
114
+ });
115
+
116
+ return false;
117
+ }
118
+
119
+ return steps.every(d => validateStep(d, debug));
120
+ }
@@ -0,0 +1,325 @@
1
+ import is from 'is-lite';
2
+
3
+ import { ACTIONS, LIFECYCLE, STATUS } from '~/literals';
4
+
5
+ import { Origin, State, Status, Step, StoreHelpers, StoreOptions } from '~/types';
6
+
7
+ import { hasValidKeys, objectKeys, omit } from './helpers';
8
+
9
+ type StateWithContinuous = State & { continuous: boolean };
10
+ type Listener = (state: State) => void;
11
+ type PopperData = any;
12
+
13
+ const defaultState: State = {
14
+ action: 'init',
15
+ controlled: false,
16
+ index: 0,
17
+ lifecycle: LIFECYCLE.INIT,
18
+ origin: null,
19
+ size: 0,
20
+ status: STATUS.IDLE,
21
+ };
22
+ const validKeys = objectKeys(omit(defaultState, 'controlled', 'size'));
23
+
24
+ class Store {
25
+ private beaconPopper: PopperData | null;
26
+ private tooltipPopper: PopperData | null;
27
+ private data: Map<string, any> = new Map();
28
+ private listener: Listener | null;
29
+ private store: Map<string, any> = new Map();
30
+
31
+ constructor(options?: StoreOptions) {
32
+ const { continuous = false, stepIndex, steps = [] } = options ?? {};
33
+
34
+ this.setState(
35
+ {
36
+ action: ACTIONS.INIT,
37
+ controlled: is.number(stepIndex),
38
+ continuous,
39
+ index: is.number(stepIndex) ? stepIndex : 0,
40
+ lifecycle: LIFECYCLE.INIT,
41
+ origin: null,
42
+ status: steps.length ? STATUS.READY : STATUS.IDLE,
43
+ },
44
+ true,
45
+ );
46
+
47
+ this.beaconPopper = null;
48
+ this.tooltipPopper = null;
49
+ this.listener = null;
50
+ this.setSteps(steps);
51
+ }
52
+
53
+ public getState(): State {
54
+ if (!this.store.size) {
55
+ return { ...defaultState };
56
+ }
57
+
58
+ return {
59
+ action: this.store.get('action') || '',
60
+ controlled: this.store.get('controlled') || false,
61
+ index: parseInt(this.store.get('index'), 10),
62
+ lifecycle: this.store.get('lifecycle') || '',
63
+ origin: this.store.get('origin') || null,
64
+ size: this.store.get('size') || 0,
65
+ status: (this.store.get('status') as Status) || '',
66
+ };
67
+ }
68
+
69
+ private getNextState(state: Partial<State>, force: boolean = false): State {
70
+ const { action, controlled, index, size, status } = this.getState();
71
+ const newIndex = is.number(state.index) ? state.index : index;
72
+ const nextIndex = controlled && !force ? index : Math.min(Math.max(newIndex, 0), size);
73
+
74
+ return {
75
+ action: state.action ?? action,
76
+ controlled,
77
+ index: nextIndex,
78
+ lifecycle: state.lifecycle ?? LIFECYCLE.INIT,
79
+ origin: state.origin ?? null,
80
+ size: state.size ?? size,
81
+ status: nextIndex === size ? STATUS.FINISHED : (state.status ?? status),
82
+ };
83
+ }
84
+
85
+ private getSteps(): Array<Step> {
86
+ const steps = this.data.get('steps');
87
+
88
+ return Array.isArray(steps) ? steps : [];
89
+ }
90
+
91
+ private hasUpdatedState(oldState: State): boolean {
92
+ const before = JSON.stringify(oldState);
93
+ const after = JSON.stringify(this.getState());
94
+
95
+ return before !== after;
96
+ }
97
+
98
+ private setState(nextState: Partial<StateWithContinuous>, initial: boolean = false) {
99
+ const state = this.getState();
100
+
101
+ const {
102
+ action,
103
+ index,
104
+ lifecycle,
105
+ origin = null,
106
+ size,
107
+ status,
108
+ } = {
109
+ ...state,
110
+ ...nextState,
111
+ };
112
+
113
+ this.store.set('action', action);
114
+ this.store.set('index', index);
115
+ this.store.set('lifecycle', lifecycle);
116
+ this.store.set('origin', origin);
117
+ this.store.set('size', size);
118
+ this.store.set('status', status);
119
+
120
+ if (initial) {
121
+ this.store.set('controlled', nextState.controlled);
122
+ this.store.set('continuous', nextState.continuous);
123
+ }
124
+
125
+ if (this.listener && this.hasUpdatedState(state)) {
126
+ this.listener(this.getState());
127
+ }
128
+ }
129
+
130
+ public addListener = (listener: Listener) => {
131
+ this.listener = listener;
132
+ };
133
+
134
+ public setSteps = (steps: Array<Step>) => {
135
+ const { size, status } = this.getState();
136
+ const state = {
137
+ size: steps.length,
138
+ status,
139
+ };
140
+
141
+ this.data.set('steps', steps);
142
+
143
+ if (status === STATUS.WAITING && !size && steps.length) {
144
+ state.status = STATUS.RUNNING;
145
+ }
146
+
147
+ this.setState(state);
148
+ };
149
+
150
+ public getHelpers(): StoreHelpers {
151
+ return {
152
+ close: this.close,
153
+ go: this.go,
154
+ info: this.info,
155
+ next: this.next,
156
+ open: this.open,
157
+ prev: this.prev,
158
+ reset: this.reset,
159
+ skip: this.skip,
160
+ };
161
+ }
162
+
163
+ public getPopper = (name: 'beacon' | 'tooltip'): PopperData | null => {
164
+ if (name === 'beacon') {
165
+ return this.beaconPopper;
166
+ }
167
+
168
+ return this.tooltipPopper;
169
+ };
170
+
171
+ public setPopper = (name: 'beacon' | 'tooltip', popper: PopperData) => {
172
+ if (name === 'beacon') {
173
+ this.beaconPopper = popper;
174
+ } else {
175
+ this.tooltipPopper = popper;
176
+ }
177
+ };
178
+
179
+ public cleanupPoppers = () => {
180
+ this.beaconPopper = null;
181
+ this.tooltipPopper = null;
182
+ };
183
+
184
+ public close = (origin: Origin | null = null) => {
185
+ const { index, status } = this.getState();
186
+
187
+ if (status !== STATUS.RUNNING) {
188
+ return;
189
+ }
190
+
191
+ this.setState({
192
+ ...this.getNextState({ action: ACTIONS.CLOSE, index: index + 1, origin }),
193
+ });
194
+ };
195
+
196
+ public go = (nextIndex: number) => {
197
+ const { controlled, status } = this.getState();
198
+
199
+ if (controlled || status !== STATUS.RUNNING) {
200
+ return;
201
+ }
202
+
203
+ const step = this.getSteps()[nextIndex];
204
+
205
+ this.setState({
206
+ ...this.getNextState({ action: ACTIONS.GO, index: nextIndex }),
207
+ status: step ? status : STATUS.FINISHED,
208
+ });
209
+ };
210
+
211
+ public info = (): State => this.getState();
212
+
213
+ public next = () => {
214
+ const { index, status } = this.getState();
215
+
216
+ if (status !== STATUS.RUNNING) {
217
+ return;
218
+ }
219
+
220
+ this.setState(this.getNextState({ action: ACTIONS.NEXT, index: index + 1 }));
221
+ };
222
+
223
+ public open = () => {
224
+ const { status } = this.getState();
225
+
226
+ if (status !== STATUS.RUNNING) {
227
+ return;
228
+ }
229
+
230
+ this.setState({
231
+ ...this.getNextState({ action: ACTIONS.UPDATE, lifecycle: LIFECYCLE.TOOLTIP }),
232
+ });
233
+ };
234
+
235
+ public prev = () => {
236
+ const { index, status } = this.getState();
237
+
238
+ if (status !== STATUS.RUNNING) {
239
+ return;
240
+ }
241
+
242
+ this.setState({
243
+ ...this.getNextState({ action: ACTIONS.PREV, index: index - 1 }),
244
+ });
245
+ };
246
+
247
+ public reset = (restart = false) => {
248
+ const { controlled } = this.getState();
249
+
250
+ if (controlled) {
251
+ return;
252
+ }
253
+
254
+ this.setState({
255
+ ...this.getNextState({ action: ACTIONS.RESET, index: 0 }),
256
+ status: restart ? STATUS.RUNNING : STATUS.READY,
257
+ });
258
+ };
259
+
260
+ public skip = () => {
261
+ const { status } = this.getState();
262
+
263
+ if (status !== STATUS.RUNNING) {
264
+ return;
265
+ }
266
+
267
+ this.setState({
268
+ action: ACTIONS.SKIP,
269
+ lifecycle: LIFECYCLE.INIT,
270
+ status: STATUS.SKIPPED,
271
+ });
272
+ };
273
+
274
+ public start = (nextIndex?: number) => {
275
+ const { index, size } = this.getState();
276
+
277
+ this.setState({
278
+ ...this.getNextState(
279
+ {
280
+ action: ACTIONS.START,
281
+ index: is.number(nextIndex) ? nextIndex : index,
282
+ },
283
+ true,
284
+ ),
285
+ status: size ? STATUS.RUNNING : STATUS.WAITING,
286
+ });
287
+ };
288
+
289
+ public stop = (advance = false) => {
290
+ const { index, status } = this.getState();
291
+
292
+ if (([STATUS.FINISHED, STATUS.SKIPPED] as Array<Status>).includes(status)) {
293
+ return;
294
+ }
295
+
296
+ this.setState({
297
+ ...this.getNextState({ action: ACTIONS.STOP, index: index + (advance ? 1 : 0) }),
298
+ status: STATUS.PAUSED,
299
+ });
300
+ };
301
+
302
+ public update = (state: Partial<State>) => {
303
+ if (!hasValidKeys(state, validKeys)) {
304
+ throw new Error(`State is not valid. Valid keys: ${validKeys.join(', ')}`);
305
+ }
306
+
307
+ this.setState({
308
+ ...this.getNextState(
309
+ {
310
+ ...this.getState(),
311
+ ...state,
312
+ action: state.action ?? ACTIONS.UPDATE,
313
+ origin: state.origin ?? null,
314
+ },
315
+ true,
316
+ ),
317
+ });
318
+ };
319
+ }
320
+
321
+ export type StoreInstance = ReturnType<typeof createStore>;
322
+
323
+ export default function createStore(options?: StoreOptions) {
324
+ return new Store(options);
325
+ }
package/src/styles.ts ADDED
@@ -0,0 +1,191 @@
1
+ import deepmerge from 'deepmerge';
2
+
3
+ import { hexToRGB } from './modules/helpers';
4
+ import { Props, StepMerged, StylesOptions, StylesWithFloaterStyles } from './types';
5
+
6
+ const defaultOptions = {
7
+ arrowColor: '#fff',
8
+ backgroundColor: '#fff',
9
+ beaconSize: 36,
10
+ overlayColor: 'rgba(0, 0, 0, 0.5)',
11
+ primaryColor: '#f04',
12
+ spotlightShadow: '0 0 15px rgba(0, 0, 0, 0.5)',
13
+ textColor: '#333',
14
+ width: 380,
15
+ zIndex: 100,
16
+ } satisfies StylesOptions;
17
+
18
+ const buttonBase = {
19
+ backgroundColor: 'transparent',
20
+ border: 0,
21
+ borderRadius: 0,
22
+ color: '#555',
23
+ cursor: 'pointer',
24
+ fontSize: 16,
25
+ lineHeight: 1,
26
+ padding: 8,
27
+ WebkitAppearance: 'none',
28
+ };
29
+
30
+ const spotlight = {
31
+ borderRadius: 4,
32
+ position: 'absolute',
33
+ };
34
+
35
+ export default function getStyles(props: Props, step: StepMerged) {
36
+ const { floaterProps, styles } = props;
37
+ const mergedFloaterProps = deepmerge(step.floaterProps ?? {}, floaterProps ?? {});
38
+ const mergedStyles = deepmerge(styles ?? {}, step.styles ?? {});
39
+ const options = deepmerge(defaultOptions, mergedStyles.options || {}) satisfies StylesOptions;
40
+ const hideBeacon = step.placement === 'center' || step.disableBeacon;
41
+ let { width } = options;
42
+
43
+ if (window.innerWidth > 480) {
44
+ width = 380;
45
+ }
46
+
47
+ if ('width' in options) {
48
+ width =
49
+ typeof options.width === 'number' && window.innerWidth < options.width
50
+ ? window.innerWidth - 30
51
+ : options.width;
52
+ }
53
+
54
+ const overlay = {
55
+ bottom: 0,
56
+ left: 0,
57
+ overflow: 'hidden',
58
+ position: 'absolute',
59
+ right: 0,
60
+ top: 0,
61
+ zIndex: options.zIndex,
62
+ };
63
+
64
+ const defaultStyles = {
65
+ beacon: {
66
+ ...buttonBase,
67
+ display: hideBeacon ? 'none' : 'inline-block',
68
+ height: options.beaconSize,
69
+ position: 'relative',
70
+ width: options.beaconSize,
71
+ zIndex: options.zIndex,
72
+ },
73
+ beaconInner: {
74
+ animation: 'joyride-beacon-inner 1.2s infinite ease-in-out',
75
+ backgroundColor: options.primaryColor,
76
+ borderRadius: '50%',
77
+ display: 'block',
78
+ height: '50%',
79
+ left: '50%',
80
+ opacity: 0.7,
81
+ position: 'absolute',
82
+ top: '50%',
83
+ transform: 'translate(-50%, -50%)',
84
+ width: '50%',
85
+ },
86
+ beaconOuter: {
87
+ animation: 'joyride-beacon-outer 1.2s infinite ease-in-out',
88
+ backgroundColor: `rgba(${hexToRGB(options.primaryColor).join(',')}, 0.2)`,
89
+ border: `2px solid ${options.primaryColor}`,
90
+ borderRadius: '50%',
91
+ boxSizing: 'border-box',
92
+ display: 'block',
93
+ height: '100%',
94
+ left: 0,
95
+ opacity: 0.9,
96
+ position: 'absolute',
97
+ top: 0,
98
+ transformOrigin: 'center',
99
+ width: '100%',
100
+ },
101
+ tooltip: {
102
+ backgroundColor: options.backgroundColor,
103
+ borderRadius: 5,
104
+ boxSizing: 'border-box',
105
+ color: options.textColor,
106
+ fontSize: 16,
107
+ maxWidth: '100%',
108
+ padding: 15,
109
+ position: 'relative',
110
+ width,
111
+ },
112
+ tooltipContainer: {
113
+ lineHeight: 1.4,
114
+ textAlign: 'center',
115
+ },
116
+ tooltipTitle: {
117
+ fontSize: 18,
118
+ margin: 0,
119
+ },
120
+ tooltipContent: {
121
+ padding: '20px 10px',
122
+ },
123
+ tooltipFooter: {
124
+ alignItems: 'center',
125
+ display: 'flex',
126
+ justifyContent: 'flex-end',
127
+ marginTop: 15,
128
+ },
129
+ tooltipFooterSpacer: {
130
+ flex: 1,
131
+ },
132
+ buttonNext: {
133
+ ...buttonBase,
134
+ backgroundColor: options.primaryColor,
135
+ borderRadius: 4,
136
+ color: '#fff',
137
+ },
138
+ buttonBack: {
139
+ ...buttonBase,
140
+ color: options.primaryColor,
141
+ marginLeft: 'auto',
142
+ marginRight: 5,
143
+ },
144
+ buttonClose: {
145
+ ...buttonBase,
146
+ color: options.textColor,
147
+ height: 14,
148
+ padding: 15,
149
+ position: 'absolute',
150
+ right: 0,
151
+ top: 0,
152
+ width: 14,
153
+ },
154
+ buttonSkip: {
155
+ ...buttonBase,
156
+ color: options.textColor,
157
+ fontSize: 14,
158
+ },
159
+ overlay: {
160
+ ...overlay,
161
+ backgroundColor: options.overlayColor,
162
+ mixBlendMode: 'hard-light',
163
+ },
164
+ overlayLegacy: {
165
+ ...overlay,
166
+ },
167
+ overlayLegacyCenter: {
168
+ ...overlay,
169
+ backgroundColor: options.overlayColor,
170
+ },
171
+ spotlight: {
172
+ ...spotlight,
173
+ backgroundColor: 'gray',
174
+ },
175
+ spotlightLegacy: {
176
+ ...spotlight,
177
+ boxShadow: `0 0 0 9999px ${options.overlayColor}, ${options.spotlightShadow}`,
178
+ },
179
+ floaterStyles: {
180
+ arrow: {
181
+ color: mergedFloaterProps?.styles?.arrow?.color ?? options.arrowColor,
182
+ },
183
+ options: {
184
+ zIndex: options.zIndex + 100,
185
+ },
186
+ },
187
+ options,
188
+ };
189
+
190
+ return deepmerge(defaultStyles, mergedStyles) as StylesWithFloaterStyles;
191
+ }