diff --git a/docs/callback.md b/docs/callback.md index 70b35bbf..0ed796d6 100644 --- a/docs/callback.md +++ b/docs/callback.md @@ -11,6 +11,7 @@ It will receive an object with the current state. controlled: true, index: 0, lifecycle: 'init', + origin: null, size: 4, status: 'running', step: { the.current.step }, @@ -24,6 +25,7 @@ It will receive an object with the current state. controlled: true, index: 0, lifecycle: 'beacon', + origin: null, size: 4, status: 'running', step: { the.current.step }, @@ -37,6 +39,7 @@ It will receive an object with the current state. controlled: true, index: 0, lifecycle: 'complete', + origin: null, size: 4, status: 'running', step: { the.current.step }, @@ -47,7 +50,7 @@ It will receive an object with the current state. ## Usage ```jsx -import Joyride, { ACTIONS, EVENTS, STATUS } from 'react-joyride'; +import Joyride, { ACTIONS, EVENTS, ORIGIN, STATUS } from 'react-joyride'; export class App extends React.Component { state = { @@ -62,7 +65,11 @@ export class App extends React.Component { }; handleJoyrideCallback = data => { - const { action, index, status, type } = data; + const { action, index, origin, status, type } = data; + + if (action === ACTIONS.CLOSE && origin === ORIGIN.KEYBOARD) { + // do something + } if ([EVENTS.STEP_AFTER, EVENTS.TARGET_NOT_FOUND].includes(type)) { // Update state to advance the tour diff --git a/docs/constants.md b/docs/constants.md index a42abac5..3d368207 100644 --- a/docs/constants.md +++ b/docs/constants.md @@ -4,7 +4,7 @@ Joyride uses a few constants to keep its state and lifecycle. You should use them in your component for the callback events. ```javascript -import Joyride, { ACTIONS, EVENTS, LIFECYCLE, STATUS } from 'react-joyride'; +import Joyride, { ACTIONS, EVENTS, LIFECYCLE, ORIGIN, STATUS } from 'react-joyride'; ``` ACTIONS - The action that updated the state. @@ -13,6 +13,8 @@ EVENTS - The type of the event. LIFECYCLE - The step lifecycle. +ORIGIN - The origin of the `CLOSE` action. + STATUS - The tour's status. Consult the [source code](https://github.com/gilbarbara/react-joyride/blob/main/src/literals/index.ts) for more information. diff --git a/e2e/controlled.spec.tsx b/e2e/controlled.spec.tsx index a82dc8e3..dda71a9e 100644 --- a/e2e/controlled.spec.tsx +++ b/e2e/controlled.spec.tsx @@ -10,6 +10,7 @@ import Controlled from '../test/__fixtures__/Controlled'; function formatCallbackResponse(input: Partial) { return { controlled: true, + origin: null, size: 6, status: STATUS.RUNNING, ...input, diff --git a/e2e/scroll.spec.tsx b/e2e/scroll.spec.tsx index bf37890c..b93217e5 100644 --- a/e2e/scroll.spec.tsx +++ b/e2e/scroll.spec.tsx @@ -11,6 +11,7 @@ import Scroll from '../test/__fixtures__/Scroll'; function formatCallbackResponse(input: Partial) { return { controlled: false, + origin: null, size: 5, status: STATUS.RUNNING, ...input, diff --git a/e2e/standard.spec.tsx b/e2e/standard.spec.tsx index d3d4dcb9..d605229d 100644 --- a/e2e/standard.spec.tsx +++ b/e2e/standard.spec.tsx @@ -10,6 +10,7 @@ import Standard from '../test/__fixtures__/Standard'; function formatCallbackResponse(input: Partial) { return { controlled: false, + origin: null, size: 6, status: STATUS.RUNNING, ...input, diff --git a/src/components/Step.tsx b/src/components/Step.tsx index e73f0a7e..6eec3c0f 100644 --- a/src/components/Step.tsx +++ b/src/components/Step.tsx @@ -38,15 +38,15 @@ export default class JoyrideStep extends React.Component { continuous, controlled, debug, + helpers, index, lifecycle, - size, status, step, store, } = this.props; const { changed, changedFrom } = treeChanges(previousProps, this.props); - const state = { action, controlled, index, lifecycle, size, status }; + const state = helpers.info(); const skipBeacon = continuous && action !== ACTIONS.CLOSE && (index > 0 || action === ACTIONS.PREV); @@ -175,7 +175,7 @@ export default class JoyrideStep extends React.Component { const { helpers, step } = this.props; if (!step.disableOverlayClose) { - helpers.close(); + helpers.close('overlay'); } }; diff --git a/src/components/Tooltip/index.tsx b/src/components/Tooltip/index.tsx index aa0a95f0..e8aaa079 100644 --- a/src/components/Tooltip/index.tsx +++ b/src/components/Tooltip/index.tsx @@ -18,7 +18,7 @@ export default class JoyrideTooltip extends React.Component { event.preventDefault(); const { helpers } = this.props; - helpers.close(); + helpers.close('button_close'); }; handleClickPrimary = (event: React.MouseEvent) => { @@ -26,7 +26,7 @@ export default class JoyrideTooltip extends React.Component { const { continuous, helpers } = this.props; if (!continuous) { - helpers.close(); + helpers.close('button_primary'); return; } diff --git a/src/components/index.tsx b/src/components/index.tsx index 5092313f..f6e2531a 100644 --- a/src/components/index.tsx +++ b/src/components/index.tsx @@ -236,7 +236,7 @@ class Joyride extends React.Component { if (lifecycle === LIFECYCLE.TOOLTIP) { if (event.code === 'Escape' && step && !step.disableCloseOnEsc) { - this.store.close(); + this.store.close('keyboard'); } } }; diff --git a/src/literals/index.ts b/src/literals/index.ts index ea944eae..5d13cfaa 100644 --- a/src/literals/index.ts +++ b/src/literals/index.ts @@ -32,6 +32,13 @@ export const LIFECYCLE = { ERROR: 'error', } as const; +export const ORIGIN = { + BUTTON_CLOSE: 'button_close', + BUTTON_PRIMARY: 'button_primary', + KEYBOARD: 'keyboard', + OVERLAY: 'overlay', +} as const; + export const STATUS = { IDLE: 'idle', READY: 'ready', diff --git a/src/modules/store.ts b/src/modules/store.ts index 17de18d4..cf7175d6 100644 --- a/src/modules/store.ts +++ b/src/modules/store.ts @@ -1,9 +1,10 @@ import { Props as FloaterProps } from 'react-floater'; +import { objectKeys, omit } from '@gilbarbara/helpers'; import is from 'is-lite'; import { ACTIONS, LIFECYCLE, STATUS } from '~/literals'; -import { AnyObject, State, Status, Step, StoreHelpers, StoreOptions } from '~/types'; +import { Origin, State, Status, Step, StoreHelpers, StoreOptions } from '~/types'; import { hasValidKeys } from './helpers'; @@ -16,10 +17,11 @@ const defaultState: State = { controlled: false, index: 0, lifecycle: LIFECYCLE.INIT, + origin: null, size: 0, status: STATUS.IDLE, }; -const validKeys = ['action', 'index', 'lifecycle', 'status']; +const validKeys = objectKeys(omit(defaultState, 'controlled', 'size')); class Store { private beaconPopper: PopperData | null; @@ -38,6 +40,7 @@ class Store { continuous, index: is.number(stepIndex) ? stepIndex : 0, lifecycle: LIFECYCLE.INIT, + origin: null, status: steps.length ? STATUS.READY : STATUS.IDLE, }, true, @@ -59,6 +62,7 @@ class Store { controlled: this.store.get('controlled') || false, index: parseInt(this.store.get('index'), 10), lifecycle: this.store.get('lifecycle') || '', + origin: this.store.get('origin') || null, size: this.store.get('size') || 0, status: (this.store.get('status') as Status) || '', }; @@ -74,6 +78,7 @@ class Store { controlled, index: nextIndex, lifecycle: state.lifecycle ?? LIFECYCLE.INIT, + origin: state.origin ?? null, size: state.size ?? size, status: nextIndex === size ? STATUS.FINISHED : state.status ?? status, }; @@ -95,7 +100,14 @@ class Store { private setState(nextState: Partial, initial: boolean = false) { const state = this.getState(); - const { action, index, lifecycle, size, status } = { + const { + action, + index, + lifecycle, + origin = null, + size, + status, + } = { ...state, ...nextState, }; @@ -103,6 +115,7 @@ class Store { this.store.set('action', action); this.store.set('index', index); this.store.set('lifecycle', lifecycle); + this.store.set('origin', origin); this.store.set('size', size); this.store.set('status', status); @@ -121,7 +134,7 @@ class Store { this.listener = listener; }; - public setSteps = (steps: Array) => { + public setSteps = (steps: Array) => { const { size, status } = this.getState(); const state = { size: steps.length, @@ -171,7 +184,7 @@ class Store { this.tooltipPopper = null; }; - public close = () => { + public close = (origin: Origin | null = null) => { const { index, status } = this.getState(); if (status !== STATUS.RUNNING) { @@ -179,7 +192,7 @@ class Store { } this.setState({ - ...this.getNextState({ action: ACTIONS.CLOSE, index: index + 1 }), + ...this.getNextState({ action: ACTIONS.CLOSE, index: index + 1, origin }), }); }; @@ -198,7 +211,7 @@ class Store { }); }; - public info = (): AnyObject => this.getState(); + public info = (): State => this.getState(); public next = () => { const { index, status } = this.getState(); @@ -300,6 +313,7 @@ class Store { ...this.getState(), ...state, action: state.action ?? ACTIONS.UPDATE, + origin: state.origin ?? null, }, true, ), diff --git a/src/types/common.ts b/src/types/common.ts index 689c6f43..3cb771d0 100644 --- a/src/types/common.ts +++ b/src/types/common.ts @@ -1,11 +1,12 @@ import { CSSProperties, ReactNode } from 'react'; import { ValueOf } from 'type-fest'; -import { ACTIONS, EVENTS, LIFECYCLE, STATUS } from '~/literals'; +import { ACTIONS, EVENTS, LIFECYCLE, ORIGIN, STATUS } from '~/literals'; export type Actions = ValueOf; export type Events = ValueOf; export type Lifecycle = ValueOf; +export type Origin = ValueOf; export type Status = ValueOf; export type AnyObject = Record; diff --git a/src/types/components.ts b/src/types/components.ts index 874f4bd7..d365ecb8 100644 --- a/src/types/components.ts +++ b/src/types/components.ts @@ -4,7 +4,7 @@ import { PartialDeep, SetRequired, Simplify, ValueOf } from 'type-fest'; import type { StoreInstance } from '~/modules/store'; -import { Actions, Events, Lifecycle, Locale, Placement, Status, Styles } from './common'; +import { Actions, Events, Lifecycle, Locale, Origin, Placement, Status, Styles } from './common'; export type BaseProps = { beaconComponent?: ElementType; @@ -51,6 +51,7 @@ export type CallBackProps = { controlled: boolean; index: number; lifecycle: Lifecycle; + origin: Origin | null; size: number; status: Status; step: Step; @@ -85,6 +86,7 @@ export type State = { controlled: boolean; index: number; lifecycle: Lifecycle; + origin: Origin | null; size: number; status: Status; }; @@ -145,9 +147,9 @@ export type StepProps = Simplify< >; export type StoreHelpers = { - close: () => void; + close: (origin?: Origin | null) => void; go: (nextIndex: number) => void; - info: (state: State) => void; + info: () => State; next: () => void; open: () => void; prev: () => void; diff --git a/test/__fixtures__/test-helpers.ts b/test/__fixtures__/test-helpers.ts index 2a3ab141..422501ea 100644 --- a/test/__fixtures__/test-helpers.ts +++ b/test/__fixtures__/test-helpers.ts @@ -8,6 +8,7 @@ export function callbackResponseFactory(initial?: Partial) { return (input: Partial) => { return { controlled, + origin: null, size, status, step: expect.any(Object), diff --git a/test/modules/__snapshots__/store.spec.ts.snap b/test/modules/__snapshots__/store.spec.ts.snap index c306029c..b9068396 100644 --- a/test/modules/__snapshots__/store.spec.ts.snap +++ b/test/modules/__snapshots__/store.spec.ts.snap @@ -6,6 +6,7 @@ exports[`store with initial steps should have initiated a new store 1`] = ` "controlled": false, "index": 0, "lifecycle": "init", + "origin": null, "size": 6, "status": "ready", } @@ -17,6 +18,7 @@ exports[`store without initial values should be able to add steps 1`] = ` "controlled": false, "index": 0, "lifecycle": "init", + "origin": null, "size": 6, "status": "running", } @@ -28,6 +30,31 @@ exports[`store without initial values should handle "close" 1`] = ` "controlled": false, "index": 2, "lifecycle": "init", + "origin": null, + "size": 6, + "status": "running", +} +`; + +exports[`store without initial values should handle "close" with origin "keyboard 1`] = ` +{ + "action": "close", + "controlled": false, + "index": 4, + "lifecycle": "init", + "origin": "keyboard", + "size": 6, + "status": "running", +} +`; + +exports[`store without initial values should handle "close" with origin "overlay" 1`] = ` +{ + "action": "close", + "controlled": false, + "index": 3, + "lifecycle": "init", + "origin": "overlay", "size": 6, "status": "running", } @@ -39,6 +66,7 @@ exports[`store without initial values should handle "go" [2nd step] 1`] = ` "controlled": false, "index": 2, "lifecycle": "init", + "origin": null, "size": 6, "status": "running", } @@ -50,6 +78,7 @@ exports[`store without initial values should handle "go" [3rd step] 1`] = ` "controlled": false, "index": 1, "lifecycle": "init", + "origin": null, "size": 6, "status": "running", } @@ -61,6 +90,7 @@ exports[`store without initial values should handle "go" with a number higher th "controlled": false, "index": 6, "lifecycle": "init", + "origin": null, "size": 6, "status": "finished", } @@ -72,6 +102,7 @@ exports[`store without initial values should handle "next" [2nd step] 1`] = ` "controlled": false, "index": 1, "lifecycle": "init", + "origin": null, "size": 6, "status": "running", } @@ -83,6 +114,7 @@ exports[`store without initial values should handle "next" [3rd step] 1`] = ` "controlled": false, "index": 2, "lifecycle": "init", + "origin": null, "size": 6, "status": "running", } @@ -94,6 +126,7 @@ exports[`store without initial values should handle "next" [4th step] 1`] = ` "controlled": false, "index": 3, "lifecycle": "init", + "origin": null, "size": 6, "status": "running", } @@ -105,6 +138,7 @@ exports[`store without initial values should handle "next" [5th step] 1`] = ` "controlled": false, "index": 4, "lifecycle": "init", + "origin": null, "size": 6, "status": "running", } @@ -116,6 +150,7 @@ exports[`store without initial values should handle "next" again but the tour ha "controlled": false, "index": 5, "lifecycle": "init", + "origin": null, "size": 6, "status": "running", } @@ -127,6 +162,7 @@ exports[`store without initial values should handle "next" again but there's no "controlled": false, "index": 6, "lifecycle": "init", + "origin": null, "size": 6, "status": "finished", } @@ -136,8 +172,9 @@ exports[`store without initial values should handle "open" 1`] = ` { "action": "update", "controlled": false, - "index": 2, + "index": 4, "lifecycle": "tooltip", + "origin": null, "size": 6, "status": "running", } @@ -149,6 +186,7 @@ exports[`store without initial values should handle "prev" [1st step] 1`] = ` "controlled": false, "index": 0, "lifecycle": "init", + "origin": null, "size": 6, "status": "running", } @@ -160,6 +198,7 @@ exports[`store without initial values should handle "prev" but no changes [1st s "controlled": false, "index": 0, "lifecycle": "init", + "origin": null, "size": 6, "status": "running", } @@ -171,6 +210,7 @@ exports[`store without initial values should handle "reset" 1`] = ` "controlled": false, "index": 0, "lifecycle": "init", + "origin": null, "size": 6, "status": "ready", } @@ -182,6 +222,7 @@ exports[`store without initial values should handle "reset" to restart 1`] = ` "controlled": false, "index": 0, "lifecycle": "init", + "origin": null, "size": 6, "status": "running", } @@ -193,6 +234,7 @@ exports[`store without initial values should handle "start" with custom index an "controlled": false, "index": 2, "lifecycle": "init", + "origin": null, "size": 6, "status": "running", } @@ -204,6 +246,7 @@ exports[`store without initial values should handle "update" the lifecycle to be "controlled": false, "index": 0, "lifecycle": "beacon", + "origin": null, "size": 6, "status": "running", } @@ -215,6 +258,7 @@ exports[`store without initial values should handle "update" the lifecycle to be "controlled": false, "index": 4, "lifecycle": "beacon", + "origin": null, "size": 6, "status": "running", } @@ -226,6 +270,7 @@ exports[`store without initial values should handle "update" the lifecycle to to "controlled": false, "index": 0, "lifecycle": "tooltip", + "origin": null, "size": 6, "status": "running", } @@ -237,6 +282,7 @@ exports[`store without initial values should handle \`start\` [1st step] 1`] = ` "controlled": false, "index": 0, "lifecycle": "init", + "origin": null, "size": 6, "status": "running", } @@ -248,6 +294,7 @@ exports[`store without initial values should handle \`start\` [2nd step] 1`] = ` "controlled": false, "index": 1, "lifecycle": "init", + "origin": null, "size": 6, "status": "running", } @@ -259,6 +306,7 @@ exports[`store without initial values should handle \`stop\` 1`] = ` "controlled": false, "index": 0, "lifecycle": "init", + "origin": null, "size": 6, "status": "paused", } @@ -270,6 +318,7 @@ exports[`store without initial values should handle \`stop\` again but with \`ad "controlled": false, "index": 1, "lifecycle": "init", + "origin": null, "size": 6, "status": "paused", } @@ -281,12 +330,13 @@ exports[`store without initial values should have initiated a new store 1`] = ` "controlled": false, "index": 0, "lifecycle": "init", + "origin": null, "size": 0, "status": "idle", } `; -exports[`store without initial values should throw an error with \`update\` with invalid keys 1`] = `"State is not valid. Valid keys: action, index, lifecycle, status"`; +exports[`store without initial values should throw an error with \`update\` with invalid keys 1`] = `"State is not valid. Valid keys: action, index, lifecycle, origin, status"`; exports[`store without initial values shouldn't be able to start without steps 1`] = ` { @@ -294,6 +344,7 @@ exports[`store without initial values shouldn't be able to start without steps 1 "controlled": false, "index": 0, "lifecycle": "init", + "origin": null, "size": 0, "status": "waiting", } diff --git a/test/modules/store.spec.ts b/test/modules/store.spec.ts index 0a93d798..ec765463 100644 --- a/test/modules/store.spec.ts +++ b/test/modules/store.spec.ts @@ -182,6 +182,18 @@ describe('store', () => { expect(info()).toMatchSnapshot(); }); + it('should handle "close" with origin "overlay"', () => { + close('overlay'); + + expect(info()).toMatchSnapshot(); + }); + + it('should handle "close" with origin "keyboard', () => { + close('keyboard'); + + expect(info()).toMatchSnapshot(); + }); + it('should handle "open"', () => { open(); diff --git a/test/tours/standard.spec.tsx b/test/tours/standard.spec.tsx index 15a8c7ec..0cb02655 100644 --- a/test/tours/standard.spec.tsx +++ b/test/tours/standard.spec.tsx @@ -143,6 +143,7 @@ describe('Joyride > Standard', () => { action: ACTIONS.CLOSE, index: 1, lifecycle: LIFECYCLE.COMPLETE, + origin: 'keyboard', type: EVENTS.STEP_AFTER, }), ); @@ -237,6 +238,7 @@ describe('Joyride > Standard', () => { action: ACTIONS.CLOSE, index: 1, lifecycle: LIFECYCLE.COMPLETE, + origin: 'overlay', type: EVENTS.STEP_AFTER, }), );