@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,217 @@
1
+ import { type RefObject, useEffect, useRef } from 'react';
2
+
3
+ import type { EmitEvent } from '~/hooks/useEventEmitter';
4
+ import type { MergedProps } from '~/hooks/useTourEngine';
5
+ import { EVENTS, LIFECYCLE, STATUS } from '~/literals';
6
+ import { treeChanges } from '~/modules/changes';
7
+ import {
8
+ getElement,
9
+ getScrollParent,
10
+ getScrollTargetToCenter,
11
+ getScrollTo,
12
+ hasPosition,
13
+ scrollDocument,
14
+ scrollTo,
15
+ } from '~/modules/dom';
16
+ import { log } from '~/modules/helpers';
17
+ import createStore from '~/modules/store';
18
+ import type { StoreState } from '~/modules/store';
19
+
20
+ import type { Lifecycle, PositionData, StepMerged } from '~/types';
21
+
22
+ interface UseScrollEffectParams {
23
+ emitEvent: EmitEvent;
24
+ previousState: StoreState | undefined;
25
+ props: MergedProps;
26
+ state: StoreState;
27
+ step: StepMerged | null;
28
+ store: RefObject<ReturnType<typeof createStore>>;
29
+ }
30
+
31
+ function adjustForPlacement(
32
+ scrollY: number,
33
+ options: {
34
+ beaconPosition: PositionData | null;
35
+ lifecycle: Lifecycle;
36
+ scrollOffset: number;
37
+ step: StepMerged;
38
+ },
39
+ ): number {
40
+ const { beaconPosition, lifecycle, scrollOffset, step } = options;
41
+
42
+ if (step.scrollTarget || step.spotlightTarget) {
43
+ return Math.max(0, scrollY);
44
+ }
45
+
46
+ let adjustedY = scrollY - step.spotlightPadding.top;
47
+
48
+ if (lifecycle === LIFECYCLE.BEACON_BEFORE && beaconPosition?.placement) {
49
+ const y = getMainAxisOffset(beaconPosition);
50
+
51
+ if (!['bottom'].includes(beaconPosition.placement)) {
52
+ adjustedY += Math.floor(y - scrollOffset);
53
+ }
54
+ } else if (lifecycle === LIFECYCLE.TOOLTIP_BEFORE) {
55
+ const { placement } = step;
56
+
57
+ if (placement === 'top') {
58
+ const floaterElement = document.querySelector('.react-joyride__floater');
59
+ const floaterHeight = floaterElement?.getBoundingClientRect().height ?? 0;
60
+ const arrowSize = step.floatingOptions?.hideArrow ? 0 : step.arrowSize;
61
+ const gap = step.offset + step.spotlightPadding.top + arrowSize;
62
+
63
+ adjustedY -= floaterHeight + gap;
64
+ } else if (placement === 'left' || placement === 'right') {
65
+ const floaterElement = document.querySelector('.react-joyride__floater');
66
+ const floaterHeight = floaterElement?.getBoundingClientRect().height ?? 0;
67
+ const targetEl = getElement(step.target);
68
+ const targetHeight = targetEl?.getBoundingClientRect().height ?? 0;
69
+
70
+ // After base scroll, the target center sits at this distance from viewport top
71
+ const targetCenterY = scrollOffset + step.spotlightPadding.top + targetHeight / 2;
72
+ // The floater is centered on the target, so its top edge would be here
73
+ const floaterTopY = targetCenterY - floaterHeight / 2;
74
+
75
+ if (floaterTopY < scrollOffset) {
76
+ adjustedY -= scrollOffset - floaterTopY;
77
+ }
78
+ }
79
+ }
80
+
81
+ return Math.max(0, adjustedY);
82
+ }
83
+
84
+ function getMainAxisOffset(data: PositionData): number {
85
+ const offsetData = data.middlewareData?.offset as { x: number; y: number } | undefined;
86
+
87
+ if (!offsetData) {
88
+ return 0;
89
+ }
90
+
91
+ return ['left', 'right'].some(p => data.placement.startsWith(p)) ? offsetData.x : offsetData.y;
92
+ }
93
+
94
+ export default function useScrollEffect({
95
+ emitEvent,
96
+ previousState,
97
+ props,
98
+ state,
99
+ step,
100
+ store,
101
+ }: UseScrollEffectParams): void {
102
+ const { index, lifecycle, positioned, scrolling, status } = state;
103
+ const cancelScrollRef = useRef<(() => void) | null>(null);
104
+ const stateRef = useRef(state);
105
+ const previousStateRef = useRef(previousState);
106
+ const propsRef = useRef(props);
107
+ const stepRef = useRef(step);
108
+
109
+ stateRef.current = state;
110
+ previousStateRef.current = previousState;
111
+ propsRef.current = props;
112
+ stepRef.current = step;
113
+
114
+ useEffect(() => {
115
+ return () => {
116
+ cancelScrollRef.current?.();
117
+ };
118
+ }, []);
119
+
120
+ useEffect(() => {
121
+ if (!previousStateRef.current || !stepRef.current) {
122
+ return;
123
+ }
124
+
125
+ const { hasChangedTo } = treeChanges(stateRef.current, previousStateRef.current);
126
+ const currentStep = stepRef.current;
127
+ const { debug } = propsRef.current;
128
+ const { scrollDuration } = currentStep;
129
+
130
+ const isBeforePhase =
131
+ lifecycle === LIFECYCLE.BEACON_BEFORE || lifecycle === LIFECYCLE.TOOLTIP_BEFORE;
132
+
133
+ if (
134
+ status === STATUS.RUNNING &&
135
+ isBeforePhase &&
136
+ scrolling &&
137
+ hasChangedTo('positioned', true)
138
+ ) {
139
+ const target = getElement(
140
+ currentStep.scrollTarget ?? currentStep.spotlightTarget ?? currentStep.target,
141
+ );
142
+ const beaconPosition = store.current.getPositionData('beacon');
143
+ const scrollParent = getScrollParent(target);
144
+ const hasCustomScroll = scrollParent ? !scrollParent.isSameNode(scrollDocument()) : false;
145
+
146
+ cancelScrollRef.current?.();
147
+
148
+ const handleScroll = async () => {
149
+ if (hasCustomScroll && !hasPosition(scrollParent as HTMLElement)) {
150
+ const pageElement = scrollDocument();
151
+ const pageScrollY = getScrollTargetToCenter(scrollParent as Element);
152
+ const pageScrollData = {
153
+ initial: pageElement.scrollTop,
154
+ target: pageScrollY,
155
+ element: pageElement,
156
+ duration: scrollDuration,
157
+ };
158
+
159
+ emitEvent(EVENTS.SCROLL_START, currentStep, { scroll: pageScrollData });
160
+
161
+ const { cancel: cancelPage, promise: pagePromise } = scrollTo(pageScrollY, {
162
+ element: pageElement,
163
+ duration: scrollDuration,
164
+ });
165
+
166
+ cancelScrollRef.current = cancelPage;
167
+ await pagePromise;
168
+
169
+ emitEvent(EVENTS.SCROLL_END, currentStep, { scroll: pageScrollData });
170
+ }
171
+
172
+ const baseScrollY = Math.floor(getScrollTo(target, currentStep.scrollOffset)) || 0;
173
+ const scrollY = hasCustomScroll
174
+ ? baseScrollY
175
+ : adjustForPlacement(baseScrollY, {
176
+ beaconPosition,
177
+ lifecycle,
178
+ scrollOffset: currentStep.scrollOffset,
179
+ step: currentStep,
180
+ });
181
+
182
+ log(
183
+ debug,
184
+ `step:${index}`,
185
+ 'scroll',
186
+ hasCustomScroll ? 'custom' : 'document',
187
+ `${baseScrollY} → ${scrollY}`,
188
+ );
189
+
190
+ const scrollElement = scrollParent as Element;
191
+ const scrollData = {
192
+ initial: scrollElement.scrollTop,
193
+ target: scrollY,
194
+ element: scrollElement,
195
+ duration: scrollDuration,
196
+ };
197
+
198
+ emitEvent(EVENTS.SCROLL_START, currentStep, { scroll: scrollData });
199
+
200
+ const { cancel, promise } = scrollTo(scrollY, {
201
+ element: scrollElement,
202
+ duration: scrollDuration,
203
+ });
204
+
205
+ cancelScrollRef.current = cancel;
206
+ await promise;
207
+
208
+ emitEvent(EVENTS.SCROLL_END, currentStep, { scroll: scrollData });
209
+ store.current.updateState({ scrolling: false });
210
+ };
211
+
212
+ handleScroll().catch(() => {
213
+ store.current.updateState({ scrolling: false });
214
+ });
215
+ }
216
+ }, [emitEvent, index, lifecycle, positioned, scrolling, status, store]);
217
+ }
@@ -0,0 +1,154 @@
1
+ import { useCallback, useEffect, useRef, useState } from 'react';
2
+
3
+ import {
4
+ getClientRect,
5
+ getElement,
6
+ getElementPosition,
7
+ getScrollParent,
8
+ hasPosition,
9
+ } from '~/modules/dom';
10
+
11
+ import type { SpotlightPadding, StepTarget } from '~/types';
12
+
13
+ export interface TargetRect {
14
+ height: number;
15
+ isFixed: boolean;
16
+ left: number;
17
+ top: number;
18
+ width: number;
19
+ }
20
+
21
+ const defaultRect: TargetRect = {
22
+ height: 0,
23
+ isFixed: false,
24
+ left: 0,
25
+ top: 0,
26
+ width: 0,
27
+ };
28
+
29
+ function computeRect(target: StepTarget, spotlightPadding: Required<SpotlightPadding>): TargetRect {
30
+ const element = getElement(target);
31
+
32
+ if (!element) {
33
+ return defaultRect;
34
+ }
35
+
36
+ const elementRect = getClientRect(element);
37
+ const isFixed = hasPosition(element);
38
+ const top = getElementPosition(element, spotlightPadding.top, isFixed);
39
+
40
+ return {
41
+ height: Math.round((elementRect?.height ?? 0) + spotlightPadding.top + spotlightPadding.bottom),
42
+ isFixed,
43
+ left: Math.round((elementRect?.left ?? 0) - spotlightPadding.left),
44
+ top,
45
+ width: Math.round((elementRect?.width ?? 0) + spotlightPadding.left + spotlightPadding.right),
46
+ };
47
+ }
48
+
49
+ export default function useTargetPosition(
50
+ target: StepTarget,
51
+ spotlightPadding: Required<SpotlightPadding>,
52
+ force: boolean,
53
+ ): TargetRect {
54
+ const [rect, setRect] = useState<TargetRect>(() => computeRect(target, spotlightPadding));
55
+ const timeoutRef = useRef<number>(undefined);
56
+ const scrollParentRef = useRef<Element | Document | null>(null);
57
+ const previousForceRef = useRef(force);
58
+ const observerRef = useRef<ResizeObserver | null>(null);
59
+
60
+ const updateRect = useCallback(() => {
61
+ clearTimeout(timeoutRef.current);
62
+
63
+ timeoutRef.current = window.setTimeout(() => {
64
+ setRect(previous => {
65
+ const next = computeRect(target, spotlightPadding);
66
+
67
+ if (
68
+ previous.top === next.top &&
69
+ previous.left === next.left &&
70
+ previous.width === next.width &&
71
+ previous.height === next.height &&
72
+ previous.isFixed === next.isFixed
73
+ ) {
74
+ return previous;
75
+ }
76
+
77
+ return next;
78
+ });
79
+ }, 100);
80
+ }, [target, spotlightPadding]);
81
+
82
+ useEffect(() => {
83
+ let mutationObserver: MutationObserver | null = null;
84
+
85
+ const setup = (element: HTMLElement) => {
86
+ scrollParentRef.current = getScrollParent(element, true);
87
+
88
+ if (scrollParentRef.current) {
89
+ scrollParentRef.current.addEventListener('scroll', updateRect, { passive: true });
90
+ }
91
+
92
+ window.addEventListener('scroll', updateRect, { passive: true });
93
+ window.addEventListener('resize', updateRect);
94
+
95
+ if (typeof ResizeObserver !== 'undefined') {
96
+ observerRef.current = new ResizeObserver(updateRect);
97
+ observerRef.current.observe(element);
98
+ }
99
+
100
+ setRect(computeRect(target, spotlightPadding));
101
+ };
102
+
103
+ const element = getElement(target);
104
+
105
+ if (element) {
106
+ setup(element);
107
+ } else {
108
+ // Target not in DOM yet — watch for it
109
+ mutationObserver = new MutationObserver(() => {
110
+ const el = getElement(target);
111
+
112
+ if (el) {
113
+ mutationObserver?.disconnect();
114
+ mutationObserver = null;
115
+ setup(el);
116
+ }
117
+ });
118
+
119
+ mutationObserver.observe(document.body, { childList: true, subtree: true });
120
+ }
121
+
122
+ return () => {
123
+ mutationObserver?.disconnect();
124
+
125
+ if (scrollParentRef.current) {
126
+ scrollParentRef.current.removeEventListener('scroll', updateRect);
127
+ }
128
+
129
+ window.removeEventListener('scroll', updateRect);
130
+ window.removeEventListener('resize', updateRect);
131
+
132
+ observerRef.current?.disconnect();
133
+ clearTimeout(timeoutRef.current);
134
+ };
135
+ }, [target, spotlightPadding, updateRect]);
136
+
137
+ // Persist to state and track transitions after render
138
+ useEffect(() => {
139
+ if (previousForceRef.current && !force) {
140
+ setRect(computeRect(target, spotlightPadding));
141
+ }
142
+
143
+ previousForceRef.current = force;
144
+ }, [force, target, spotlightPadding]);
145
+
146
+ // Synchronous override: when scrolling just ended, return fresh rect immediately
147
+ let finalRect = rect;
148
+
149
+ if (previousForceRef.current && !force) {
150
+ finalRect = computeRect(target, spotlightPadding);
151
+ }
152
+
153
+ return finalRect;
154
+ }
@@ -0,0 +1,106 @@
1
+ import { type RefObject, useCallback, useMemo, useRef, useState } from 'react';
2
+ import { useMemoDeepCompare, useMount, usePrevious, useUpdateEffect } from '@gilbarbara/hooks';
3
+ import { useSyncExternalStore } from 'use-sync-external-store/shim';
4
+
5
+ import { defaultProps } from '~/defaults';
6
+ import useControls from '~/hooks/useControls';
7
+ import useDebugLogger from '~/hooks/useDebugLogger';
8
+ import useEventEmitter from '~/hooks/useEventEmitter';
9
+ import useLifecycleEffect from '~/hooks/useLifecycleEffect';
10
+ import usePropSync from '~/hooks/usePropSync';
11
+ import useScrollEffect from '~/hooks/useScrollEffect';
12
+ import { STATUS } from '~/literals';
13
+ import { mergeProps } from '~/modules/helpers';
14
+ import { getMergedStep, validateSteps } from '~/modules/step';
15
+ import createStore from '~/modules/store';
16
+ import type { StoreState } from '~/modules/store';
17
+
18
+ import type { Controls, FailureReason, Props, StepFailure, StepMerged } from '~/types';
19
+
20
+ export type AddFailure = (step: StepMerged, reason: FailureReason) => void;
21
+
22
+ export type MergedProps = ReturnType<typeof mergeProps<typeof defaultProps, Props>>;
23
+
24
+ export interface UseTourEngineReturn {
25
+ controls: Controls;
26
+ failures: StepFailure[];
27
+ mergedProps: MergedProps;
28
+ state: StoreState;
29
+ step: StepMerged | null;
30
+ store: RefObject<ReturnType<typeof createStore>>;
31
+ }
32
+
33
+ export default function useTourEngine(props: Props): UseTourEngineReturn {
34
+ const mergedProps = useMemoDeepCompare(() => mergeProps(defaultProps, props), [props]);
35
+ const { debug, initialStepIndex, onEvent, run, stepIndex, steps } = mergedProps;
36
+
37
+ const store = useRef(createStore(mergedProps));
38
+ const state = useSyncExternalStore<StoreState>(
39
+ store.current.subscribe,
40
+ store.current.getSnapshot,
41
+ store.current.getServerSnapshot,
42
+ );
43
+
44
+ const [failures, setFailures] = useState<StepFailure[]>([]);
45
+
46
+ const addFailure: AddFailure = useCallback((failedStep, reason) => {
47
+ setFailures(previous => [...previous, { reason, step: failedStep }]);
48
+ }, []);
49
+
50
+ const clearFailures = useCallback(() => {
51
+ setFailures([]);
52
+ }, []);
53
+
54
+ useDebugLogger(store, debug);
55
+
56
+ const controls = useControls(store, debug, clearFailures);
57
+ const emitEvent = useEventEmitter(onEvent, controls, store);
58
+
59
+ const { index, size, status } = state;
60
+
61
+ const previousState = usePrevious(state);
62
+
63
+ const step = useMemo(() => getMergedStep(mergedProps, steps[index]), [index, mergedProps, steps]);
64
+
65
+ useMount(() => {
66
+ if (run && size && validateSteps(steps, debug)) {
67
+ controls.start(stepIndex ?? initialStepIndex);
68
+ }
69
+ });
70
+
71
+ useUpdateEffect(() => {
72
+ if (run && size && status === STATUS.IDLE) {
73
+ store.current.updateState({ status: STATUS.READY });
74
+ }
75
+ }, [run, size, status]);
76
+
77
+ usePropSync({
78
+ controls,
79
+ emitEvent,
80
+ props: mergedProps,
81
+ state,
82
+ store,
83
+ });
84
+
85
+ useLifecycleEffect({
86
+ addFailure,
87
+ controls,
88
+ emitEvent,
89
+ previousState,
90
+ props: mergedProps,
91
+ state,
92
+ step,
93
+ store,
94
+ });
95
+
96
+ useScrollEffect({
97
+ emitEvent,
98
+ previousState,
99
+ props: mergedProps,
100
+ state,
101
+ step,
102
+ store,
103
+ });
104
+
105
+ return { controls, failures, mergedProps, state, step, store };
106
+ }
package/src/index.tsx ADDED
@@ -0,0 +1,23 @@
1
+ import { useJoyride } from '~/hooks/useJoyride';
2
+ import { canUseDOM } from '~/modules/dom';
3
+
4
+ import type { Props } from '~/types';
5
+
6
+ function JoyrideTour(props: Props) {
7
+ const { Tour } = useJoyride(props);
8
+
9
+ return Tour;
10
+ }
11
+
12
+ export function Joyride(props: Props) {
13
+ if (!canUseDOM()) {
14
+ return null;
15
+ }
16
+
17
+ return <JoyrideTour {...props} />;
18
+ }
19
+
20
+ export { defaultLocale, defaultOptions } from './defaults';
21
+ export * from './literals';
22
+ export * from './types';
23
+ export { useJoyride } from '~/hooks/useJoyride';
@@ -0,0 +1,61 @@
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
+ REPLAY: 'replay',
12
+ UPDATE: 'update',
13
+ COMPLETE: 'complete',
14
+ } as const;
15
+
16
+ export const EVENTS = {
17
+ TOUR_START: 'tour:start',
18
+ STEP_BEFORE_HOOK: 'step:before_hook',
19
+ STEP_BEFORE: 'step:before',
20
+ SCROLL_START: 'scroll:start',
21
+ SCROLL_END: 'scroll:end',
22
+ BEACON: 'beacon',
23
+ TOOLTIP: 'tooltip',
24
+ STEP_AFTER: 'step:after',
25
+ STEP_AFTER_HOOK: 'step:after_hook',
26
+ TOUR_END: 'tour:end',
27
+ TOUR_STATUS: 'tour:status',
28
+ TARGET_NOT_FOUND: 'error:target_not_found',
29
+ ERROR: 'error',
30
+ } as const;
31
+
32
+ export const LIFECYCLE = {
33
+ INIT: 'init',
34
+ READY: 'ready',
35
+ BEACON_BEFORE: 'beacon_before',
36
+ BEACON: 'beacon',
37
+ TOOLTIP_BEFORE: 'tooltip_before',
38
+ TOOLTIP: 'tooltip',
39
+ COMPLETE: 'complete',
40
+ } as const;
41
+
42
+ export const ORIGIN = {
43
+ BUTTON_BACK: 'button_back',
44
+ BUTTON_CLOSE: 'button_close',
45
+ BUTTON_PRIMARY: 'button_primary',
46
+ BUTTON_SKIP: 'button_skip',
47
+ KEYBOARD: 'keyboard',
48
+ OVERLAY: 'overlay',
49
+ } as const;
50
+
51
+ export const STATUS = {
52
+ IDLE: 'idle',
53
+ READY: 'ready',
54
+ WAITING: 'waiting',
55
+ RUNNING: 'running',
56
+ PAUSED: 'paused',
57
+ SKIPPED: 'skipped',
58
+ FINISHED: 'finished',
59
+ } as const;
60
+
61
+ export const PORTAL_ELEMENT_ID = 'react-joyride-portal';
@@ -0,0 +1,20 @@
1
+ import type { StoreState } from '~/modules/store';
2
+
3
+ export function treeChanges<T extends Record<string, any> = StoreState>(state: T, previous: T) {
4
+ return {
5
+ hasChanged<K extends keyof T>(key: K): boolean {
6
+ return state[key] !== previous[key];
7
+ },
8
+ hasChangedTo<K extends keyof T>(key: K, value: T[K] | T[K][]): boolean {
9
+ const current = state[key];
10
+ const previousValue = previous[key];
11
+
12
+ if (Array.isArray(value)) {
13
+ return value.includes(current) && !value.includes(previousValue);
14
+ }
15
+
16
+ return current === value && previousValue !== value;
17
+ },
18
+ previous,
19
+ };
20
+ }