├── book.json ├── src ├── index.js ├── constants │ ├── lifecycle.js │ ├── status.js │ ├── index.js │ ├── actions.js │ └── events.js ├── components │ ├── Spotlight.js │ ├── Tooltip │ │ ├── CloseBtn.js │ │ ├── Container.js │ │ └── index.js │ ├── Portal.js │ ├── Beacon.js │ ├── Overlay.js │ ├── Step.js │ └── index.js ├── config │ ├── defaults.js │ └── types.js ├── modules │ ├── scope.js │ ├── step.js │ ├── helpers.js │ ├── dom.js │ └── store.js └── styles.js ├── .gitignore ├── test ├── __setup__ │ ├── styleMock.js │ ├── setupTests.js │ ├── fileMock.js │ ├── shim.js │ └── index.js ├── .eslintrc ├── index.spec.js ├── modules │ ├── helpers.spec.js │ └── store.spec.js └── __fixtures__ │ ├── steps.js │ └── Tour.js ├── docs ├── SUMMARY.md ├── accessibility.md ├── constants.md ├── migration.md ├── README.md ├── styling.md ├── callback.md ├── step.md └── props.md ├── .editorconfig ├── issue_template.md ├── .flowconfig ├── rollup.config.js ├── .babelrc ├── .codeclimate.yml ├── LICENSE ├── jest.config.js ├── CONTRIBUTING.md ├── .travis.yml ├── tools └── index.js ├── README.md ├── package.json └── .eslintrc /book.json: -------------------------------------------------------------------------------- 1 | { 2 | "root": "./docs" 3 | } -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import Joyride from './components'; 2 | 3 | export default Joyride; 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .tmp 2 | _book/ 3 | coverage 4 | dist 5 | es 6 | lib 7 | node_modules 8 | reports 9 | -------------------------------------------------------------------------------- /test/__setup__/styleMock.js: -------------------------------------------------------------------------------- 1 | // Return an object to emulate css modules (if you are using them) 2 | module.exports = {}; 3 | -------------------------------------------------------------------------------- /test/__setup__/setupTests.js: -------------------------------------------------------------------------------- 1 | require('jest-enzyme/lib/index.js'); 2 | require('jest-extended'); 3 | require('jest-chain'); 4 | -------------------------------------------------------------------------------- /test/__setup__/fileMock.js: -------------------------------------------------------------------------------- 1 | // Return an empty string or other mock path to emulate the url that 2 | // webpack provides via the file-loader 3 | module.exports = ''; 4 | -------------------------------------------------------------------------------- /src/constants/lifecycle.js: -------------------------------------------------------------------------------- 1 | export default { 2 | INIT: 'init', 3 | READY: 'ready', 4 | BEACON: 'beacon', 5 | TOOLTIP: 'tooltip', 6 | COMPLETE: 'complete', 7 | ERROR: 'error', 8 | }; 9 | -------------------------------------------------------------------------------- /test/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "rules": { 3 | "prefer-destructuring": "off", 4 | "react/destructuring-assignment": "off", 5 | "react/jsx-filename-extension": "off", 6 | "react/prop-types": "off" 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /test/__setup__/shim.js: -------------------------------------------------------------------------------- 1 | global.requestAnimationFrame = (callback) => { 2 | setTimeout(callback, 0); 3 | }; 4 | 5 | global.matchMedia = () => ({ 6 | matches: false, 7 | addListener: () => {}, 8 | removeListener: () => {}, 9 | }); 10 | -------------------------------------------------------------------------------- /src/constants/status.js: -------------------------------------------------------------------------------- 1 | export default { 2 | IDLE: 'idle', 3 | READY: 'ready', 4 | WAITING: 'waiting', 5 | RUNNING: 'running', 6 | PAUSED: 'paused', 7 | SKIPPED: 'skipped', 8 | FINISHED: 'finished', 9 | ERROR: 'error', 10 | }; 11 | -------------------------------------------------------------------------------- /docs/SUMMARY.md: -------------------------------------------------------------------------------- 1 | # Summary 2 | 3 | * [Overview](README.md) 4 | * [Props](props.md) 5 | * [Step](step.md) 6 | * [Styling](styling.md) 7 | * [Callback](callback.md) 8 | * [Accessibility](accessibility.md) 9 | * [Constants](constants.md) 10 | * [Migration](migration.md) 11 | 12 | -------------------------------------------------------------------------------- /src/constants/index.js: -------------------------------------------------------------------------------- 1 | import ACTIONS from './actions'; 2 | import EVENTS from './events'; 3 | import LIFECYCLE from './lifecycle'; 4 | import STATUS from './status'; 5 | 6 | export { ACTIONS, EVENTS, LIFECYCLE, STATUS }; 7 | export default { ACTIONS, EVENTS, LIFECYCLE, STATUS }; 8 | -------------------------------------------------------------------------------- /src/constants/actions.js: -------------------------------------------------------------------------------- 1 | export default { 2 | INIT: 'init', 3 | START: 'start', 4 | STOP: 'stop', 5 | RESET: 'reset', 6 | RESTART: 'restart', 7 | PREV: 'prev', 8 | NEXT: 'next', 9 | GO: 'go', 10 | INDEX: 'index', 11 | CLOSE: 'close', 12 | SKIP: 'skip', 13 | UPDATE: 'update', 14 | }; 15 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig is awesome: http://EditorConfig.org 2 | 3 | # top-most EditorConfig file 4 | root = true 5 | 6 | # Unix-style newlines with a newline ending every file 7 | [*] 8 | end_of_line = lf 9 | insert_final_newline = true 10 | indent_style = space 11 | indent_size = 2 12 | 13 | [*.md] 14 | trim_trailing_whitespace = false 15 | -------------------------------------------------------------------------------- /src/constants/events.js: -------------------------------------------------------------------------------- 1 | export default { 2 | TOUR_START: 'tour:start', 3 | STEP_BEFORE: 'step:before', 4 | BEACON: 'beacon', 5 | TOOLTIP: 'tooltip', 6 | TOOLTIP_CLOSE: 'close', 7 | STEP_AFTER: 'step:after', 8 | TOUR_END: 'tour:end', 9 | TOUR_STATUS: 'tour:status', 10 | TARGET_NOT_FOUND: 'error:target_not_found', 11 | ERROR: 'error', 12 | }; 13 | -------------------------------------------------------------------------------- /src/components/Spotlight.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | 4 | const JoyrideSpotlight = ({ styles }) => ( 5 |
10 | ); 11 | 12 | JoyrideSpotlight.propTypes = { 13 | styles: PropTypes.object.isRequired, 14 | }; 15 | 16 | export default JoyrideSpotlight; 17 | -------------------------------------------------------------------------------- /issue_template.md: -------------------------------------------------------------------------------- 1 | >Expected behavior 2 | 3 | 4 | 5 | >Actual behavior 6 | 7 | 8 | 9 | >Steps to reproduce the problem 10 | 11 | 12 | 13 | >React version 14 | 15 | 16 | 17 | >React-Joyride version 18 | 19 | 20 | 21 | >Browser name and version 22 | 23 | 24 | 25 | >Error stack (if available) 26 | 27 | 28 | 29 | *If you want to get this issue fixed quickly, make sure to send a public URL or codesandbox example.* 30 | -------------------------------------------------------------------------------- /.flowconfig: -------------------------------------------------------------------------------- 1 | [ignore] 2 | .*/node_modules/config-chain/test/broken.json 3 | .*/node_modules/npmconf/test/.* 4 | .*/node_modules/react-side-effect/.* 5 | 6 | [include] 7 | 8 | [libs] 9 | ./defs 10 | 11 | [options] 12 | module.name_mapper='^scripts\/?\(.*\)?$' -> '/src/scripts/\1' 13 | module.name_mapper='^styles\/\(.*\)$' -> '/src/styles/\1' 14 | 15 | munge_underscores=true 16 | 17 | suppress_type=$FlowIssue 18 | suppress_type=$FlowFixMe 19 | suppress_type=$FixMe 20 | -------------------------------------------------------------------------------- /src/config/defaults.js: -------------------------------------------------------------------------------- 1 | export default { 2 | floaterProps: { 3 | options: { 4 | preventOverflow: { 5 | boundariesElement: 'scrollParent', 6 | }, 7 | }, 8 | wrapperOptions: { 9 | offset: -18, 10 | position: true, 11 | }, 12 | }, 13 | locale: { 14 | back: 'Back', 15 | close: 'Close', 16 | last: 'Last', 17 | next: 'Next', 18 | open: 'Open', 19 | skip: 'Skip', 20 | }, 21 | step: { 22 | event: 'click', 23 | placement: 'bottom', 24 | offset: 10, 25 | }, 26 | }; 27 | -------------------------------------------------------------------------------- /docs/accessibility.md: -------------------------------------------------------------------------------- 1 | # Accessibility 2 | 3 | react-joyride aims to be fully accessible, using the [WAI-ARIA](https://www.w3.org/WAI/intro/aria) guidelines to support users of assistive technologies. 4 | 5 | ## Keyboard navigation 6 | 7 | When the tooltip is opened, the TAB key will be hijacked to only focus on form elements \(input\|select\|textarea\|button\|object\) within its contents. Elements outside the tooltip won't receive focus. 8 | 9 | When the tooltip is closed the focus returns to the default. 10 | 11 | ## ARIA attributes 12 | 13 | Soon 14 | 15 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import babel from 'rollup-plugin-babel'; 2 | import commonjs from 'rollup-plugin-commonjs'; 3 | import packageJSON from './package.json'; 4 | 5 | export default { 6 | input: 'src/index.js', 7 | output: { 8 | file: 'es/index.js', 9 | format: 'es', 10 | exports: 'named', 11 | }, 12 | external: [ 13 | ...Object.keys(packageJSON.peerDependencies), 14 | ...Object.keys(packageJSON.dependencies), 15 | ], 16 | plugins: [ 17 | babel({ 18 | exclude: 'node_modules/**', 19 | runtimeHelpers: true, 20 | }), 21 | commonjs(), 22 | ], 23 | }; 24 | -------------------------------------------------------------------------------- /test/__setup__/index.js: -------------------------------------------------------------------------------- 1 | import Enzyme from 'enzyme'; 2 | import Adapter from 'enzyme-adapter-react-16'; 3 | 4 | Enzyme.configure({ adapter: new Adapter() }); 5 | 6 | Object.defineProperty(Element.prototype, 'clientHeight', { 7 | writable: true, 8 | value: '', 9 | }); 10 | 11 | Object.defineProperty(Element.prototype, 'clientWidth', { 12 | writable: true, 13 | value: '', 14 | }); 15 | 16 | const react = document.createElement('div'); 17 | react.id = 'react'; 18 | react.style.height = '100vh'; 19 | document.body.appendChild(react); 20 | 21 | window.matchMedia = () => ({ 22 | matches: false, 23 | addListener: () => { 24 | }, 25 | removeListener: () => { 26 | }, 27 | }); 28 | -------------------------------------------------------------------------------- /test/index.spec.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { mount } from 'enzyme'; 3 | 4 | import Tour from './__fixtures__/Tour'; 5 | 6 | const props = { 7 | content: 'Hello! This is my content!', 8 | }; 9 | 10 | function setup(ownProps = props) { 11 | return mount( 12 | , 13 | { attachTo: document.getElementById('react') } 14 | ); 15 | } 16 | 17 | describe('ReactJoyride', () => { 18 | let joyride; 19 | 20 | describe('basic usage', () => { 21 | beforeAll(() => { 22 | joyride = setup(); 23 | }); 24 | 25 | it('should render properly', () => { 26 | expect(joyride.find('Joyride')).toExist(); 27 | }); 28 | }); 29 | }); 30 | -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | [ 4 | "env", { 5 | "useBuiltIns": true, 6 | "modules": false, 7 | "targets": { 8 | "browsers": ["last 2 versions", "safari >= 7"] 9 | } 10 | } 11 | ], 12 | "react", 13 | "stage-1" 14 | ], 15 | "plugins": [], 16 | "env": { 17 | "production": { 18 | "plugins": [ 19 | "array-includes", 20 | "external-helpers", 21 | "transform-runtime", 22 | "transform-flow-strip-types", 23 | "transform-node-env-inline" 24 | ] 25 | }, 26 | "test": { 27 | "plugins": [ 28 | "transform-es2015-modules-commonjs" 29 | ], 30 | "sourceMaps": "both" 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /.codeclimate.yml: -------------------------------------------------------------------------------- 1 | --- 2 | engines: 3 | eslint: 4 | enabled: true 5 | channel: "eslint-4" 6 | config: 7 | extensions: 8 | - .js 9 | - .jsx 10 | checks: 11 | import/no-duplicates: 12 | enabled: false 13 | no-template-curly-in-string: 14 | enabled: false 15 | import/no-unresolved: 16 | enabled: false 17 | import/no-extraneous-dependencies: 18 | enabled: false 19 | import/no-named-as-default-member: 20 | enabled: false 21 | stylelint: 22 | enabled: true 23 | ratings: 24 | paths: 25 | - "**.js" 26 | - "**.jsx" 27 | - "**.scss" 28 | exclude_paths: 29 | - coverage/**/* 30 | - cypress/**/* 31 | - es/**/* 32 | - lib/**/* 33 | - node_modules/**/* 34 | - test/**/* 35 | -------------------------------------------------------------------------------- /docs/constants.md: -------------------------------------------------------------------------------- 1 | # Constants 2 | 3 | Joyride uses a few constants to keep its state and lifecycle. 4 | You can use these in your component for the callback events. 5 | 6 | ```javascript 7 | import { ACTIONS, EVENTS, LIFECYCLE, STATUS } from 'react-joyride/es/constants'; 8 | ``` 9 | 10 | [Actions](https://github.com/gilbarbara/react-joyride/tree/3e08384415a831b20ce21c8423b6c271ad419fbf/src/constants/actions.js) - The action that updated the state. 11 | 12 | [Events](https://github.com/gilbarbara/react-joyride/tree/3e08384415a831b20ce21c8423b6c271ad419fbf/src/constants/events.js) - The type of the event. 13 | 14 | [Lifecycle](https://github.com/gilbarbara/react-joyride/tree/3e08384415a831b20ce21c8423b6c271ad419fbf/src/constants/lifecycle.js) - The step lifecycle. 15 | 16 | [Status](https://github.com/gilbarbara/react-joyride/tree/3e08384415a831b20ce21c8423b6c271ad419fbf/src/constants/status.js) - The tour's status. 17 | 18 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2015, Gil Barbara 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | -------------------------------------------------------------------------------- /test/modules/helpers.spec.js: -------------------------------------------------------------------------------- 1 | import { 2 | hexToRGB, 3 | log, 4 | } from '../../src/modules/helpers'; 5 | 6 | const mockLog = jest.fn(); 7 | const mockWarn = jest.fn(); 8 | const mockError = jest.fn(); 9 | 10 | console.log = mockLog; //eslint-disable-line no-console 11 | console.warn = mockWarn; //eslint-disable-line no-console 12 | console.error = mockError; //eslint-disable-line no-console 13 | 14 | describe('utils', () => { 15 | it('should be able to call `hexToRGB`', () => { 16 | expect(hexToRGB('#ff0044')).toEqual([255, 0, 68]); 17 | expect(hexToRGB('#0f4')).toEqual([0, 255, 68]); 18 | }); 19 | 20 | xit('should be able to call `log`', () => { 21 | log({ 22 | title: 'hello', 23 | debug: true, 24 | }); 25 | 26 | expect(mockLog.mock.calls[0][0]).toBe('%cjoyride'); 27 | expect(mockLog.mock.calls[0][1]).toBe('color: #760bc5; font-weight: bold; font-size: 12px;'); 28 | 29 | log({ 30 | title: 'noou', 31 | msg: ['bye', 'bye'], 32 | warn: true, 33 | debug: true, 34 | }); 35 | 36 | expect(mockWarn.mock.calls[0][0]).toBe('bye'); 37 | }); 38 | }); 39 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | transform: { 3 | '^.+\\.jsx?$': 'babel-jest', 4 | }, 5 | moduleFileExtensions: [ 6 | 'js', 7 | 'jsx', 8 | 'json', 9 | ], 10 | moduleDirectories: [ 11 | 'node_modules', 12 | 'src', 13 | './', 14 | ], 15 | moduleNameMapper: { 16 | '^.+\\.(css|scss)$': '/test/__setup__/styleMock.js', 17 | '^.+\\.(jpe?g|png|gif|ttf|eot|svg|md)$': '/test/__setup__/fileMock.js', 18 | }, 19 | setupFiles: [ 20 | '/test/__setup__/shim.js', 21 | '/test/__setup__/index.js', 22 | ], 23 | setupTestFrameworkScriptFile: '/test/__setup__/setupTests.js', 24 | testEnvironment: 'jest-environment-jsdom-global', 25 | testEnvironmentOptions: { 26 | resources: 'usable', 27 | }, 28 | testRegex: '/test/.*?\\.(test|spec)\\.js$', 29 | testURL: 'http://localhost:3000', 30 | watchPlugins: [ 31 | 'jest-watch-typeahead/filename', 32 | 'jest-watch-typeahead/testname', 33 | ], 34 | collectCoverage: false, 35 | collectCoverageFrom: [ 36 | 'src/**/*.{js,jsx}', 37 | ], 38 | coverageThreshold: { 39 | global: { 40 | branches: 15, 41 | functions: 15, 42 | lines: 15, 43 | statements: 15, 44 | }, 45 | }, 46 | verbose: true, 47 | }; 48 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to react-joyride 2 | 3 | :+1::tada: First off, thanks for taking the time to contribute! :tada::+1: 4 | 5 | **Reporting Bugs** 6 | Before creating bug reports, please check this [list](https://github.com/gilbarbara/react-joyride/issues) as you might find out that you don't need to create one. When you are creating a bug report, please include as many details as possible. 7 | 8 | **Implementation** 9 | Make sure you browse all the examples in the [demo](https://codesandbox.io/s/2zpjporp4p) and check the source code before opening an issue. We don't usually help with those directly but maybe some of the users might have some advice. 10 | 11 | **Pull Requests** 12 | Before submitting a new pull request, open a new issue to discuss it. It may already been implemented but not published or we might have found the same situation before and decide against it. 13 | 14 | In any case: 15 | - Format files using these rules [EditorConfig](https://github.com/gilbarbara/react-joyride/blob/master/.editorconfig) 16 | - Follow the [Javascript](https://github.com/gilbarbara/react-joyride/blob/master/.eslintrc) (ESLint) and [CSS](https://github.com/gilbarbara/react-joyride/blob/master/.scss-lint.yml) (scss-lint) styleguides. 17 | - Document new code with jsdoc. 18 | 19 | Thank you! 20 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - '8' 4 | cache: 5 | directories: 6 | - "node_modules" 7 | before_script: 8 | - curl -L https://codeclimate.com/downloads/test-reporter/test-reporter-latest-linux-amd64 > ./cc-test-reporter 9 | - chmod +x ./cc-test-reporter 10 | - ./cc-test-reporter before-build 11 | script: 12 | - npm test 13 | after_script: 14 | - ./cc-test-reporter after-build --exit-code $TRAVIS_TEST_RESULT 15 | deploy: 16 | provider: npm 17 | email: gilbarbara@gmail.com 18 | api_key: 19 | secure: udTSoiJXNkpRBrbwNtzflguIc/QvRbW/xdajwJqYUxitA409jBO+VhsxwlDJBl0HHHKW7yOwTUXvKAxr92y/6FByVM9yLuduQLOvG9J++l12Lc7fehKXbuNBZZDwjUF3MwftF9mCj4gJTUPrajZjXxUx78hI7dTCjJuJkpXHfZSr00Xu8In59rzr9OhDGBITlMSynE0yEIVBEfboxf9q5uSuJtp6qaK5ohbZqQnz2KIVoI2TUZ9KXpU8765mAd5xxvF4kgpmLYFN9TSd49oipZYPZs4UjlfVrPmKq2nmzepew9HuaXEvg0DLpcI9YjzBkR4AGlRIal2t4gNmy9m2XbaeyTS5q5Aoa+UchR9GS+LLsBIfl+1bI9yqijURZp68xMdQRC2iUrPcJeTKY9WpXEYFjxPmJXqfQl2BAiOlaxCzvNnD2i8th6PpeI+2aU79pWRSwUSQqV4PLjDN+M86cEpVRxZePLte6RIVqtRBes9HY8sQph0MAdzLUTbmMehnB1XzSRbuR77687NrbW8epRsxqRSK2Uqman8ZWSJan9apwP4niVciDJbfVL9M1J7pUZX8HKvfC3l5NJWO69xzQFouSVhtkZZWU4bxGtgS5gM+H/AHKv9/qktoA/K8hGPEFR1S7OoWSP310wetGHMHuXniKQh7glDh/IkpFp8QF14= 20 | on: 21 | tags: true 22 | branch: master 23 | repo: gilbarbara/react-joyride 24 | addons: 25 | code_climate: true 26 | -------------------------------------------------------------------------------- /docs/migration.md: -------------------------------------------------------------------------------- 1 | # Migration 2 | 3 | Version 2 changed radically and you will need to update your component. 4 | 5 | ## Props 6 | 7 | ### Renamed \(breaking\) 8 | 9 | **allowClicksThruHole** ▶︎ **spotlightClicks** 10 | 11 | **disableOverlay** `false` ▶︎ **disableOverlayClicks** `false` 12 | 13 | **keyboardNavigation** `true` ▶︎ **disableCloseOnEsc** `false` 14 | the space, return and tab keys are now controlled with tabIndex 15 | 16 | **scrollToSteps** `true` ▶︎ disableScrolling `false` 17 | 18 | **showBackButton** `true` ▶︎ **hideBackButton** `false` 19 | 20 | **showOverlay** `true` ▶︎ **disableOverlay** `false` 21 | 22 | **showStepsProgress** `false` ▶︎ **showProgress** `false` 23 | 24 | **tooltipOffset** `30` ▶︎ Use the step **floaterProps.offset** 25 | 26 | **type** `'single'` ▶︎ **continuous** `false` 27 | 28 | ### Removed \(breaking\) 29 | 30 | **autoStart** \(use the `disableBeacon` prop on the first step\) 31 | 32 | **offsetParent** 33 | 34 | **resizeDebounce** 35 | 36 | **resizeDebounceDelay** 37 | 38 | ## Step 39 | 40 | There are a few changes to the step syntax too: 41 | 42 | **selector** ▶︎ **target 43 | **Now it supports HTMLElement and string. 44 | 45 | **position** ▶︎ **placement 46 | **The default is** `bottom` **now 47 | 48 | **text** ▶︎ **content** 49 | 50 | **type** ▶︎ **event** 51 | 52 | **allowClicksThruHole** ▶︎ **spotlightClicks** 53 | 54 | **style** ▶︎ **styles** 55 | The properties have changed. Be sure to update to the new [syntax](styling.md). 56 | 57 | -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | # React Joyride 2 | 3 | A React component to create tours for your app! 4 | 5 | You can use it to showcase your app to new users or explain functionality of new features. 6 | It uses [react-floater](https://github.com/gilbarbara/react-floater) \(with [popper.js](https://github.com/FezVrasta/popper.js) for positioning and styling\). 7 | And you can use your own components if you want. 8 | 9 | ### View the demo [here](https://2zpjporp4p.codesandbox.io/) 10 | 11 | ## Setup 12 | 13 | ```bash 14 | npm i react-joyride 15 | ``` 16 | 17 | ## Getting Started 18 | 19 | ```javascript 20 | import Joyride from 'react-joyride'; 21 | 22 | export class App extends React.Component { 23 | state = { 24 | run: false, 25 | steps: [ 26 | { 27 | target: '.my-first-step', 28 | content: 'This if my awesome feature!', 29 | placement: 'bottom', 30 | }, 31 | { 32 | target: '.my-other-step', 33 | content: 'This if my awesome feature!', 34 | placement: 'bottom', 35 | }, 36 | ... 37 | ] 38 | }; 39 | 40 | componentDidMount() { 41 | this.setState({ run: true }); 42 | } 43 | 44 | callback = (tour) => { 45 | const { action, index, type } = data; 46 | }; 47 | 48 | render () { 49 | const { steps, run } = this.state; 50 | 51 | return ( 52 |
53 | 60 | ... 61 |
62 | ); 63 | } 64 | } 65 | ``` 66 | 67 | ## Documentation 68 | 69 | [Props](props.md) 70 | 71 | [Step](step.md) 72 | 73 | [Styling](styling.md) 74 | 75 | [Callback](callback.md) 76 | 77 | [Constants](constants.md) 78 | 79 | [Migration from 1.x](migration.md) 80 | 81 | -------------------------------------------------------------------------------- /src/components/Tooltip/CloseBtn.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | 4 | const CloseBtn = ({ styles, ...props }) => { 5 | const { color, height, width, ...style } = styles; 6 | 7 | return ( 8 | 29 | ); 30 | }; 31 | 32 | CloseBtn.propTypes = { 33 | styles: PropTypes.object.isRequired, 34 | }; 35 | 36 | export default CloseBtn; 37 | -------------------------------------------------------------------------------- /src/components/Portal.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import PropTypes from 'prop-types'; 4 | import { canUseDOM, isReact16 } from '../modules/helpers'; 5 | 6 | export default class JoyridePortal extends React.Component { 7 | constructor(props) { 8 | super(props); 9 | 10 | if (!canUseDOM) return; 11 | 12 | this.node = document.createElement('div'); 13 | if (props.id) { 14 | this.node.id = props.id; 15 | } 16 | 17 | document.body.appendChild(this.node); 18 | } 19 | 20 | static propTypes = { 21 | children: PropTypes.element, 22 | id: PropTypes.oneOfType([ 23 | PropTypes.string, 24 | PropTypes.number, 25 | ]), 26 | }; 27 | 28 | componentDidMount() { 29 | if (!canUseDOM) return; 30 | 31 | if (!isReact16) { 32 | this.renderReact15(); 33 | } 34 | } 35 | 36 | componentDidUpdate() { 37 | if (!canUseDOM) return; 38 | 39 | if (!isReact16) { 40 | this.renderReact15(); 41 | } 42 | } 43 | 44 | componentWillUnmount() { 45 | if (!canUseDOM || !this.node) return; 46 | 47 | if (!isReact16) { 48 | ReactDOM.unmountComponentAtNode(this.node); 49 | } 50 | 51 | document.body.removeChild(this.node); 52 | } 53 | 54 | renderReact15() { 55 | if (!canUseDOM) return null; 56 | 57 | const { children } = this.props; 58 | 59 | ReactDOM.unstable_renderSubtreeIntoContainer( 60 | this, 61 | children, 62 | this.node, 63 | ); 64 | 65 | return null; 66 | } 67 | 68 | renderReact16() { 69 | if (!canUseDOM || !isReact16) return null; 70 | 71 | const { children } = this.props; 72 | 73 | return ReactDOM.createPortal( 74 | children, 75 | this.node, 76 | ); 77 | } 78 | 79 | render() { 80 | if (!isReact16) { 81 | return null; 82 | } 83 | 84 | return this.renderReact16(); 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /docs/styling.md: -------------------------------------------------------------------------------- 1 | # Styling 2 | 3 | Version 2 uses inline styles instead of the previous CSS/SCSS files but you can also use you own components for the beacon and tooltip and style them anyway you want. 4 | 5 | To update the default theme, just pass a `styles` prop to the Joyride component or directly in a [step](step.md). 6 | You can control the overall theme with the special `options` object. 7 | 8 | ```text 9 | const defaultOptions = { 10 | arrowColor: '#fff', 11 | backgroundColor: '#fff', 12 | beaconSize: 36, 13 | overlayColor: 'rgba(0, 0, 0, 0.5)', 14 | primaryColor: '#f04', 15 | spotlightShadow: '0 0 15px rgba(0, 0, 0, 0.5)', 16 | textColor: '#333', 17 | width: undefined, 18 | zIndex: 100, 19 | }; 20 | ``` 21 | 22 | ## Example 23 | 24 | ```javascript 25 | import Joyride from 'react-joyride'; 26 | import { ACTIONS, EVENTS } from 'react-joyride/es/constants'; 27 | 28 | export class App extends React.Component { 29 | state = { 30 | run: false, 31 | steps: [], 32 | }; 33 | 34 | render () { 35 | const { run, stepIndex, steps } = this.state; 36 | 37 | return ( 38 |
39 | 54 | ... 55 |
56 | ); 57 | } 58 | } 59 | ``` 60 | 61 | You can also customize any element independently. Check [styles.js](https://github.com/gilbarbara/react-joyride/tree/3e08384415a831b20ce21c8423b6c271ad419fbf/src/styles.js) for more information. 62 | 63 | If you want to customize the arrow, check [react-floater](https://github.com/gilbarbara/react-floater) documentation. 64 | 65 | -------------------------------------------------------------------------------- /tools/index.js: -------------------------------------------------------------------------------- 1 | /*eslint-disable no-var, vars-on-top, no-console */ 2 | const { exec } = require('child_process'); 3 | const chalk = require('chalk'); 4 | 5 | const args = process.argv.slice(2); 6 | 7 | if (!args[0]) { 8 | console.log(`Valid arguments: 9 | • docs (rebuild documentation) 10 | • update (if package.json has changed run \`npm update\`) 11 | • commits (has new remote commits)`); 12 | } 13 | 14 | if (args[0] === 'update') { 15 | exec('git diff-tree -r --name-only --no-commit-id ORIG_HEAD HEAD', (err, stdout) => { 16 | if (err) { 17 | throw new Error(err); 18 | } 19 | 20 | if (stdout.match('package.json')) { 21 | exec('npm update').stdout.pipe(process.stdout); 22 | } 23 | }); 24 | } 25 | 26 | if (args[0] === 'commits') { 27 | exec('git remote -v update', errRemote => { 28 | if (errRemote) { 29 | throw new Error(errRemote); 30 | } 31 | 32 | const local = new Promise((resolve, reject) => { 33 | exec('git rev-parse @', (err, stdout) => { 34 | if (err) { 35 | return reject(err); 36 | } 37 | 38 | return resolve(stdout); 39 | }); 40 | }); 41 | 42 | const remote = new Promise((resolve, reject) => { 43 | exec('git rev-parse @{u}', (err, stdout) => { 44 | if (err) { 45 | return reject(err); 46 | } 47 | 48 | return resolve(stdout); 49 | }); 50 | }); 51 | 52 | const base = new Promise((resolve, reject) => { 53 | exec('git merge-base @ @{u}', (err, stdout) => { 54 | if (err) { 55 | return reject(err); 56 | } 57 | return resolve(stdout); 58 | }); 59 | }); 60 | 61 | Promise.all([local, remote, base]) 62 | .then(values => { 63 | const [$local, $remote, $base] = values; 64 | 65 | if ($local === $remote) { 66 | console.log(chalk.green('✔ Repo is up-to-date!')); 67 | } 68 | else if ($local === $base) { 69 | console.error(chalk.red('⊘ Error: You need to pull, there are new commits.')); 70 | process.exit(1); 71 | } 72 | }) 73 | .catch(err => { 74 | console.log(chalk.red('⊘ Error: Commits failed'), err); 75 | }); 76 | }); 77 | } 78 | -------------------------------------------------------------------------------- /docs/callback.md: -------------------------------------------------------------------------------- 1 | # Callback 2 | 3 | You can get Joyride's state change using the `callback` prop. 4 | 5 | It will receive a plain object with the state changes. 6 | 7 | Example data: 8 | 9 | ```text 10 | { 11 | action: 'start', 12 | controlled: true, 13 | index: 0, 14 | lifecycle: 'init', 15 | size: 4, 16 | status: 'running', 17 | step: { the.current.step }, 18 | type: 'tour:start', 19 | } 20 | ``` 21 | 22 | ```text 23 | { 24 | action: 'update', 25 | controlled: true, 26 | index: 0, 27 | lifecycle: 'beacon', 28 | size: 4, 29 | status: 'running', 30 | step: { the.current.step }, 31 | type: 'beacon', 32 | } 33 | ``` 34 | 35 | ```text 36 | { 37 | action: 'next', 38 | controlled: true, 39 | index: 0, 40 | lifecycle: 'complete', 41 | size: 4, 42 | status: 'running', 43 | step: { the.current.step }, 44 | type: 'step:after', 45 | } 46 | ``` 47 | 48 | ```javascript 49 | import Joyride from 'react-joyride'; 50 | import { ACTIONS, EVENTS } from 'react-joyride/es/constants'; 51 | 52 | export class App extends React.Component { 53 | state = { 54 | run: false, 55 | steps: [], 56 | stepIndex: 0, // a controlled tour 57 | }; 58 | 59 | callback = (tour) => { 60 | const { action, index, type } = tour; 61 | 62 | if (type === EVENTS.TOUR_END) { 63 | // Update user preferences with completed tour flag 64 | } else if (type === EVENTS.STEP_AFTER && index === 1) { 65 | // pause the tour, load a new route and start it again once is done. 66 | this.setState({ run: false }); 67 | } 68 | else if ([EVENTS.STEP_AFTER, EVENTS.CLOSE, EVENTS.TARGET_NOT_FOUND].includes(type)) { 69 | // Sunce this is a controlled tour you'll need to update the state to advance the tour 70 | this.setState({ stepIndex: index + (action === ACTIONS.PREV ? -1 : 1) }); 71 | } 72 | }; 73 | 74 | render () { 75 | const { run, stepIndex, steps } = this.state; 76 | 77 | return ( 78 |
79 | 86 | ... 87 |
88 | ); 89 | } 90 | } 91 | ``` 92 | 93 | You can read more about the constants [here](constants.md) 94 | 95 | -------------------------------------------------------------------------------- /test/__fixtures__/steps.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export default [ 4 | { 5 | title: 'Title only steps — As they say: Make the font bigger!', 6 | textAlign: 'center', 7 | selector: '.projects .list', 8 | position: 'top', 9 | }, 10 | { 11 | title: 'Our Mission', 12 | text: 'Can be advanced by clicking an element through the overlay hole.', 13 | selector: '.mission button', 14 | position: 'bottom', 15 | allowClicksThruHole: true, 16 | style: { 17 | beacon: { 18 | offsetY: 20, 19 | }, 20 | button: { 21 | display: 'none', 22 | }, 23 | }, 24 | }, 25 | { 26 | title: 'Unmounted target', 27 | text: 'This step tests what happens when a target is missing', 28 | selector: '.not-mounted', 29 | }, 30 | { 31 | text: ( 32 |
33 |

We are the people

34 | 42 | 43 | 47 | 48 | 49 |
50 | ), 51 | selector: '.about h2 span', 52 | position: 'left', 53 | style: { 54 | beacon: { 55 | inner: '#27e200', 56 | offsetX: 20, 57 | outer: '#27e200', 58 | }, 59 | arrow: { 60 | display: 'none', 61 | }, 62 | }, 63 | }, 64 | { 65 | text: 'Text only steps — Because sometimes you don\'t really need a proper heading', 66 | selector: '.demo__footer a', 67 | position: 'top', 68 | isFixed: true, 69 | style: { 70 | beacon: '#000', 71 | }, 72 | }, 73 | ]; 74 | -------------------------------------------------------------------------------- /docs/step.md: -------------------------------------------------------------------------------- 1 | # Step 2 | 3 | The step is an plain object that only requires two properties to be valid: `target` and `content` \(or `tooltipComponent`\). 4 | 5 | ```text 6 | { 7 | target: '.my-selector', 8 | content: 'This is my super awesome feature!' 9 | } 10 | ``` 11 | 12 | It will inherit some properties from the Joyride's own [props](props.md) that can be overridden per step: 13 | 14 | * beaconComponent 15 | * disableCloseOnEsc 16 | * disableOverlay 17 | * disableOverlayClose 18 | * disableScrolling 19 | * floaterProps (check the [getMergedStep](../src/modules/step.js) function for more information) 20 | * hideBackButton 21 | * locale 22 | * showProgress 23 | * showSkipButton 24 | * spotlightClicks 25 | * spotlightPadding 26 | * styles 27 | * tooltipComponent 28 | 29 | ## And you can use these 30 | 31 | **content** {React.Node\|string} 32 | The tooltip's body. 33 | 34 | **disableBeacon** {boolean} ▶︎ `false` 35 | Don't show the Beacon before the tooltip. 36 | 37 | **event** {string} ▶︎ `click` 38 | The event to trigger the beacon. It can be _**click**_ or _**hover**_ 39 | 40 | **isFixed** {boolean} ▶︎ `false` 41 | Force the step to be fixed. 42 | 43 | **offset** {React.Node\|string} ▶︎ `10` 44 | The distance from the target to the tooltip. 45 | 46 | **placement** {string} ▶︎ `bottom` 47 | The placement of the beacon and tooltip. It will re-position itself if there's no space available. 48 | It can be: 49 | 50 | * top \(top-start, top-end\) 51 | * bottom \(bottom-start, bottom-end\) 52 | * left \(left-start, left-end\) 53 | * right \(right-start, right-end 54 | * auto 55 | * center 56 | 57 | Check [react-floater](https://github.com/gilbarbara/react-floater) for more information. 58 | 59 | **placementBeacon** {string} ▶︎ placement 60 | The placement of the beacon. It will use the placement if nothing is passed and it can be: `top, bottom, left, right`. 61 | 62 | **styles** {Object} 63 | Override the [styling](styling.md) of the step's Tooltip 64 | 65 | **target** {Element\|string} - **required** 66 | The target for the step. It can be a [CSS selector](https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_Selectors) or an HtmlElement directly \(but using refs created in the same render would required an additional render afterwards\). 67 | 68 | **title** {React.Node\|string} 69 | The tooltip's title. 70 | 71 | -------------------------------------------------------------------------------- /docs/props.md: -------------------------------------------------------------------------------- 1 | # Props 2 | 3 | **beaconComponent** {React.Node} 4 | A React component or function to be used instead the default Beacon. 5 | 6 | **callback** {function} 7 | It will be called when Joyride's state changes. it returns a single parameter with the state. 8 | 9 | **continuous** {boolean} ▶︎ `false` 10 | The tour is played sequentially with the **Next** button. 11 | 12 | **debug** {boolean} ▶︎ `false` 13 | Log Joyride's actions to the console. 14 | 15 | **disableCloseOnEsc** {boolean} ▶︎ `false` 16 | Disable closing the tooltip on ESC. 17 | 18 | **disableOverlay** {boolean} ▶︎ `false` 19 | Don't show the overlay. 20 | 21 | **disableOverlayClose** {boolean} ▶︎ `false` 22 | Don't close the tooltip when clicking the overlay. 23 | 24 | **disableScrolling** {boolean} ▶︎ `false` 25 | Disable auto scrolling between steps. 26 | 27 | **floaterProps** {Object} 28 | Options to be passed to [react-floater](https://github.com/gilbarbara/react-floater). 29 | 30 | **hideBackButton** {boolean} ▶︎ `false` 31 | Hide the "back" button. 32 | 33 | **locale** {Object} ▶︎ `{ back: 'Back', close: 'Close', last: 'Last', next: 'Next', skip: 'Skip' }` 34 | The strings used in the tooltip. 35 | 36 | **run** {boolean} ▶︎ `true` 37 | Run/stop the tour. 38 | 39 | **scrollOffset** {number} ▶︎ `20` 40 | The scroll distance from the element scrollTop value. 41 | 42 | **scrollToFirstStep** {boolean} ▶︎ `false` 43 | Scroll the page for the first step. 44 | 45 | **showProgress** {boolean} ▶︎ `false` 46 | Display the tour progress in the next button \_e.g. 2/5 \_in `continuous` tours. 47 | 48 | **showSkipButton** {boolean} ▶︎ `false` 49 | Display a button to skip the tour. 50 | 51 | **spotlightClicks** {boolean} ▶︎ `false` 52 | Allow mouse and touch events thru the spotlight. You can click links in your app. 53 | 54 | **spotlightPadding** {boolean} ▶︎ `10` 55 | The padding of the spotlight. 56 | 57 | **stepIndex** {number} 58 | Setting a number here will turn Joyride into `controlled` mode. 59 | You will receive the state events in the `callback` and you'll have to update this prop by yourself. 60 | 61 | **steps** {Array<StepProps>} - **required** 62 | The tour's steps. 63 | 64 | **styles** {Object} 65 | Override the [styling](styling.md) of the Tooltip globally 66 | 67 | **tooltipComponent** {React.Node} 68 | A React component or function to be used instead the default Tooltip excluding the arrow. 69 | 70 | 71 | -------------------------------------------------------------------------------- /src/modules/scope.js: -------------------------------------------------------------------------------- 1 | const validTabNodes = /input|select|textarea|button|object/; 2 | const TAB_KEY = 9; 3 | let modalElement = null; 4 | 5 | function isHidden(element) { 6 | const noSize = element.offsetWidth <= 0 && element.offsetHeight <= 0; 7 | 8 | if (noSize && !element.innerHTML) return true; 9 | 10 | const style = window.getComputedStyle(element); 11 | return noSize ? style.getPropertyValue('overflow') !== 'visible' : style.getPropertyValue('display') === 'none'; 12 | } 13 | 14 | function isVisible(element) { 15 | let parentElement = element; 16 | while (parentElement) { 17 | if (parentElement === document.body) break; 18 | if (isHidden(parentElement)) return false; 19 | parentElement = parentElement.parentNode; 20 | } 21 | return true; 22 | } 23 | 24 | function canHaveFocus(element, isTabIndexNotNaN) { 25 | const nodeName = element.nodeName.toLowerCase(); 26 | const res = (validTabNodes.test(nodeName) && !element.disabled) 27 | || (nodeName === 'a' ? element.href || isTabIndexNotNaN : isTabIndexNotNaN); 28 | return res && isVisible(element); 29 | } 30 | 31 | function canBeTabbed(element) { 32 | let tabIndex = element.getAttribute('tabindex'); 33 | if (tabIndex === null) tabIndex = undefined; 34 | const isTabIndexNaN = isNaN(tabIndex); 35 | return (isTabIndexNaN || tabIndex >= 0) && canHaveFocus(element, !isTabIndexNaN); 36 | } 37 | 38 | function findValidTabElements(element) { 39 | return [].slice.call(element.querySelectorAll('*'), 0).filter(canBeTabbed); 40 | } 41 | 42 | function interceptTab(node, event) { 43 | const elements = findValidTabElements(node); 44 | const { shiftKey } = event; 45 | 46 | if (!elements.length) { 47 | event.preventDefault(); 48 | return; 49 | } 50 | 51 | let x = elements.indexOf(document.activeElement); 52 | 53 | if (x === -1 || (!shiftKey && x + 1 === elements.length)) { 54 | x = 0; 55 | } 56 | else { 57 | x += shiftKey ? -1 : 1; 58 | } 59 | 60 | event.preventDefault(); 61 | 62 | elements[x].focus(); 63 | } 64 | 65 | function handleKeyDown(e) { 66 | if (!modalElement) { 67 | return; 68 | } 69 | 70 | if (e.keyCode === TAB_KEY) { 71 | interceptTab(modalElement, e); 72 | } 73 | } 74 | 75 | export function setScope(element) { 76 | modalElement = element; 77 | 78 | window.addEventListener('keydown', handleKeyDown, false); 79 | } 80 | 81 | export function removeScope() { 82 | modalElement = null; 83 | 84 | window.removeEventListener('keydown', handleKeyDown); 85 | } 86 | -------------------------------------------------------------------------------- /src/config/types.js: -------------------------------------------------------------------------------- 1 | import type { Node } from 'react'; 2 | 3 | export type StateHelpers = { 4 | close: Function, 5 | go: Function, 6 | index: Function, 7 | info: Function, 8 | next: Function, 9 | prev: Function, 10 | reset: Function, 11 | restart: Function, 12 | skip: Function, 13 | start: Function, 14 | stop: Function, 15 | }; 16 | 17 | export type StateInstance = { 18 | ...StateHelpers, 19 | update: Function, 20 | }; 21 | 22 | export type StateObject = { 23 | action: string, 24 | controlled: boolean, 25 | index: number, 26 | lifecycle: string, 27 | size: number, 28 | status: string, 29 | }; 30 | 31 | type placement = 'top' | 'top-start' | 'top-end' | 32 | 'bottom' | 'bottom-start' | 'bottom-end' | 33 | 'left' | 'left-start' | 'left-end' | 34 | 'right' | 'right-start' | 'right-end' | 35 | 'auto' | 'center'; 36 | 37 | type placementBeacon = 'top' | 'bottom' | 'left' | 'right'; 38 | 39 | export type StepProps = { 40 | beaconComponent: ?Node, 41 | content: Node | string, 42 | disableBeacon: boolean, 43 | disableCloseOnEsc: boolean, 44 | disableOverlay: boolean, 45 | disableOverlayClose: boolean, 46 | disableScrolling: boolean, 47 | event: string, 48 | floaterProps: ?Object, 49 | hideBackButton: ?boolean, 50 | isFixed: ?boolean, 51 | offset: number, 52 | placement: placement, 53 | placementBeacon: placementBeacon, 54 | showProgress: ?boolean, 55 | showSkipButton: ?boolean, 56 | spotlightPadding: number, 57 | spotlightClicks: boolean, 58 | styles: ?Object, 59 | target: string | HTMLElement, 60 | title: ?Node, 61 | tooltipComponent: ?Node, 62 | } 63 | 64 | export type JoyrideProps = { 65 | beaconComponent: ?Node, 66 | callback: ?Function, 67 | continuous: boolean, 68 | debug: boolean, 69 | disableCloseOnEsc: boolean, 70 | disableOverlay: boolean, 71 | disableOverlayClose: boolean, 72 | disableScrolling: boolean, 73 | floaterProps: ?Object, 74 | hideBackButton: boolean, 75 | locale: ?Object, 76 | run: boolean, 77 | scrollOffset: number, 78 | scrollToFirstStep: boolean, 79 | showProgress: boolean, 80 | showSkipButton: boolean, 81 | spotlightPadding: boolean, 82 | spotlightClicks: boolean, 83 | stepIndex: ?number, 84 | steps: Array, 85 | styles: ?Object, 86 | tooltipComponent: ?Node, 87 | } 88 | 89 | export type CallBackProps = { 90 | action: string, 91 | controlled: boolean, 92 | index: number, 93 | lifecycle: string, 94 | size: number, 95 | status: string, 96 | step: StepProps, 97 | type: string, 98 | } 99 | -------------------------------------------------------------------------------- /src/components/Beacon.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | 4 | export default class JoyrideBeacon extends React.Component { 5 | constructor(props) { 6 | super(props); 7 | 8 | if (!props.beaconComponent) { 9 | const head = document.head || document.getElementsByTagName('head')[0]; 10 | const style = document.createElement('style'); 11 | const css = ` 12 | @keyframes joyride-beacon-inner { 13 | 20% { 14 | opacity: 0.9; 15 | } 16 | 17 | 90% { 18 | opacity: 0.7; 19 | } 20 | } 21 | 22 | @keyframes joyride-beacon-outer { 23 | 0% { 24 | transform: scale(1); 25 | } 26 | 27 | 45% { 28 | opacity: 0.7; 29 | transform: scale(0.75); 30 | } 31 | 32 | 100% { 33 | opacity: 0.9; 34 | transform: scale(1); 35 | } 36 | } 37 | `; 38 | 39 | style.type = 'text/css'; 40 | style.id = 'joyride-beacon-animation'; 41 | style.appendChild(document.createTextNode(css)); 42 | 43 | head.appendChild(style); 44 | } 45 | } 46 | 47 | static propTypes = { 48 | beaconComponent: PropTypes.oneOfType([ 49 | PropTypes.func, 50 | PropTypes.element, 51 | ]), 52 | locale: PropTypes.object.isRequired, 53 | onClickOrHover: PropTypes.func.isRequired, 54 | styles: PropTypes.object.isRequired, 55 | }; 56 | 57 | componentWillUnmount() { 58 | const style = document.getElementById('joyride-beacon-animation'); 59 | 60 | if (style) { 61 | style.parentNode.removeChild(style); 62 | } 63 | } 64 | 65 | render() { 66 | const { beaconComponent, locale, onClickOrHover, styles } = this.props; 67 | const props = { 68 | 'aria-label': locale.open, 69 | onClick: onClickOrHover, 70 | onMouseEnter: onClickOrHover, 71 | title: locale.open, 72 | }; 73 | let component; 74 | 75 | if (beaconComponent) { 76 | if (React.isValidElement(beaconComponent)) { 77 | component = React.cloneElement(beaconComponent, props); 78 | } 79 | else { 80 | component = beaconComponent(props); 81 | } 82 | } 83 | else { 84 | component = ( 85 | 95 | ); 96 | } 97 | 98 | return component; 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # React Joyride 2 | 3 | [![](https://badge.fury.io/js/react-joyride.svg)](https://www.npmjs.com/package/react-joyride) [![](https://travis-ci.org/gilbarbara/react-joyride.svg)](https://travis-ci.org/gilbarbara/react-joyride) [![](https://api.codeclimate.com/v1/badges/43ecb5536910133429bd/maintainability)](https://codeclimate.com/github/gilbarbara/react-joyride/maintainability) [![](https://api.codeclimate.com/v1/badges/43ecb5536910133429bd/test_coverage)](https://codeclimate.com/github/gilbarbara/react-joyride/test_coverage) 4 | 5 | [![Joyride example image](http://gilbarbara.github.io/react-joyride/media/example.png)](http://gilbarbara.github.io/react-joyride/) 6 | 7 | Create a tour for your app! 8 | Use it to showcase your app for new users! Or explain functionality of complex features! 9 | 10 | #### View the demo [here](https://2zpjporp4p.codesandbox.io/) 11 | 12 | You can edit the demo [here](https://codesandbox.io/s/2zpjporp4p) 13 | 14 | > If you are looking for the documentation for the old 1.x version, go [here](https://github.com/gilbarbara/react-joyride/tree/v1.11.4) 15 | 16 | ## Setup 17 | 18 | ```bash 19 | npm i react-joyride@next 20 | ``` 21 | 22 | ## Getting Started 23 | 24 | Just set a `steps` array to the Joyride component and you're good to go! 25 | 26 | You can use your own component for the tooltip body or beacon, if you want. 27 | 28 | ```js 29 | import Joyride from 'react-joyride'; 30 | 31 | export class App extends React.Component { 32 | state = { 33 | run: false, 34 | steps: [ 35 | { 36 | target: '.my-first-step', 37 | content: 'This if my awesome feature!', 38 | placement: 'bottom', 39 | }, 40 | { 41 | target: '.my-other-step', 42 | content: 'This if my awesome feature!', 43 | placement: 'bottom', 44 | }, 45 | ... 46 | ] 47 | }; 48 | 49 | componentDidMount() { 50 | this.setState({ run: true }); 51 | } 52 | 53 | callback = (data) => { 54 | const { action, index, type } = data; 55 | }; 56 | 57 | render () { 58 | const { steps, run } = this.state; 59 | 60 | return ( 61 |
62 | 68 | ... 69 |
70 | ); 71 | } 72 | } 73 | ``` 74 | 75 | ## Documentation 76 | 77 | [Props](docs/props.md) 78 | 79 | [Step](docs/step.md) 80 | 81 | [Styling](docs/styling.md) 82 | 83 | [Callback](docs/callback.md) 84 | 85 | [Constants](docs/constants.md) 86 | 87 | [Migration from 1.x](docs/migration.md) 88 | 89 | This library uses [react-floater](https://github.com/gilbarbara/react-floater) and [popper.js](https://github.com/FezVrasta/popper.js) for positioning and styling. 90 | 91 | -------------------------------------------------------------------------------- /src/components/Tooltip/Container.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | 4 | import CloseBtn from './CloseBtn'; 5 | 6 | const JoyrideTooltipContainer = ({ 7 | continuous, 8 | backProps, 9 | closeProps, 10 | primaryProps, 11 | skipProps, 12 | index, 13 | isLastStep, 14 | setTooltipRef, 15 | size, 16 | step, 17 | }) => { 18 | const { content, hideBackButton, locale, showProgress, showSkipButton, title, styles } = step; 19 | const { back, close, last, next, skip } = locale; 20 | const output = { 21 | primary: close, 22 | }; 23 | 24 | if (continuous) { 25 | if (isLastStep) { 26 | output.primary = last; 27 | } 28 | else { 29 | output.primary = next; 30 | } 31 | 32 | if (showProgress) { 33 | output.primary += ` (${index + 1}/${size})`; 34 | } 35 | } 36 | 37 | if (showSkipButton && !isLastStep) { 38 | output.skip = ( 39 | 46 | ); 47 | } 48 | 49 | if (!hideBackButton && index > 0) { 50 | output.back = ( 51 | 58 | ); 59 | } 60 | 61 | output.close = (); 62 | 63 | return ( 64 |
69 |
70 | {output.close} 71 | {title && (

{title}

)} 72 | {!!content && ( 73 |
74 | {content} 75 |
76 | )} 77 |
78 |
79 | {output.skip} 80 | {output.back} 81 | 88 |
89 |
90 | ); 91 | }; 92 | 93 | JoyrideTooltipContainer.propTypes = { 94 | backProps: PropTypes.object.isRequired, 95 | closeProps: PropTypes.object.isRequired, 96 | continuous: PropTypes.bool.isRequired, 97 | index: PropTypes.number.isRequired, 98 | isLastStep: PropTypes.bool.isRequired, 99 | primaryProps: PropTypes.object.isRequired, 100 | setTooltipRef: PropTypes.func.isRequired, 101 | size: PropTypes.number.isRequired, 102 | skipProps: PropTypes.object.isRequired, 103 | step: PropTypes.object.isRequired, 104 | }; 105 | 106 | export default JoyrideTooltipContainer; 107 | -------------------------------------------------------------------------------- /src/components/Tooltip/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | 4 | import Container from './Container'; 5 | 6 | export default class JoyrideTooltip extends React.Component { 7 | static propTypes = { 8 | continuous: PropTypes.bool.isRequired, 9 | helpers: PropTypes.object.isRequired, 10 | index: PropTypes.number.isRequired, 11 | isLastStep: PropTypes.bool.isRequired, 12 | setTooltipRef: PropTypes.func.isRequired, 13 | size: PropTypes.number.isRequired, 14 | step: PropTypes.object.isRequired, 15 | }; 16 | 17 | handleClickBack = (e) => { 18 | e.preventDefault(); 19 | const { helpers } = this.props; 20 | 21 | helpers.prev(); 22 | }; 23 | 24 | handleClickClose = (e) => { 25 | e.preventDefault(); 26 | const { helpers } = this.props; 27 | 28 | helpers.close(); 29 | }; 30 | 31 | handleClickPrimary = (e) => { 32 | e.preventDefault(); 33 | const { continuous, helpers } = this.props; 34 | 35 | if (!continuous) { 36 | helpers.close(); 37 | return; 38 | } 39 | 40 | helpers.next(); 41 | }; 42 | 43 | handleClickSkip = (e) => { 44 | e.preventDefault(); 45 | const { helpers } = this.props; 46 | 47 | helpers.skip(); 48 | }; 49 | 50 | render() { 51 | const { continuous, index, isLastStep, setTooltipRef, size, step } = this.props; 52 | const { content, locale, title, tooltipComponent } = step; 53 | const { back, close, last, next, skip } = locale; 54 | let primaryText = continuous ? next : close; 55 | 56 | if (isLastStep) { 57 | primaryText = last; 58 | } 59 | 60 | let component; 61 | const buttonProps = { 62 | backProps: { 'aria-label': back, onClick: this.handleClickBack, role: 'button', title: back }, 63 | closeProps: { 'aria-label': close, onClick: this.handleClickClose, role: 'button', title: close }, 64 | primaryProps: { 'aria-label': primaryText, onClick: this.handleClickPrimary, role: 'button', title: primaryText }, 65 | skipProps: { 'aria-label': skip, onClick: this.handleClickSkip, role: 'button', title: skip }, 66 | }; 67 | 68 | if (tooltipComponent) { 69 | const renderProps = { 70 | ...buttonProps, 71 | content, 72 | continuous, 73 | index, 74 | isLastStep, 75 | locale, 76 | setTooltipRef, 77 | size, 78 | title, 79 | }; 80 | 81 | if (React.isValidElement(tooltipComponent)) { 82 | component = React.cloneElement(tooltipComponent, renderProps); 83 | } 84 | else { 85 | component = tooltipComponent(renderProps); 86 | } 87 | } 88 | else { 89 | component = ( 90 | 99 | ); 100 | } 101 | 102 | return component; 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /src/modules/step.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | import deepmerge from 'deepmerge'; 3 | import is from 'is-lite'; 4 | 5 | import { getElement, hasCustomScrollParent } from './dom'; 6 | import { log } from './helpers'; 7 | import getStyles from '../styles'; 8 | 9 | import DEFAULTS from '../config/defaults'; 10 | 11 | import type { StepProps, JoyrideProps } from '../config/types'; 12 | 13 | /** 14 | * Validate if a step is valid 15 | * 16 | * @param {Object} step - A step object 17 | * @param {boolean} debug 18 | * 19 | * @returns {boolean} - True if the step is valid, false otherwise 20 | */ 21 | export function validateStep(step: StepProps, debug: boolean = false): boolean { 22 | if (!is.plainObject(step)) { 23 | log({ 24 | title: 'validateStep', 25 | data: 'step must be an object', 26 | warn: true, 27 | debug, 28 | }); 29 | return false; 30 | } 31 | 32 | if (!step.target) { 33 | log({ 34 | title: 'validateStep', 35 | data: 'target is missing from the step', 36 | warn: true, 37 | debug, 38 | }); 39 | return false; 40 | } 41 | 42 | return true; 43 | } 44 | 45 | /** 46 | * Validate if steps is valid 47 | * 48 | * @param {Array} steps - A steps array 49 | * @param {boolean} debug 50 | * 51 | * @returns {boolean} - True if the steps are valid, false otherwise 52 | */ 53 | export function validateSteps(steps: Array, debug: boolean = false): boolean { 54 | if (!is.array(steps)) { 55 | log({ 56 | title: 'validateSteps', 57 | data: 'steps must be an array', 58 | warn: true, 59 | debug, 60 | }); 61 | 62 | return false; 63 | } 64 | 65 | return steps.every(d => validateStep(d, debug)); 66 | } 67 | 68 | function getTourProps(props: JoyrideProps): JoyrideProps { 69 | const sharedTourProps = [ 70 | 'beaconComponent', 71 | 'disableCloseOnEsc', 72 | 'disableOverlay', 73 | 'disableOverlayClose', 74 | 'disableScrolling', 75 | 'floaterProps', 76 | 'hideBackButton', 77 | 'locale', 78 | 'showProgress', 79 | 'showSkipButton', 80 | 'spotlightClicks', 81 | 'spotlightPadding', 82 | 'styles', 83 | 'tooltipComponent', 84 | ]; 85 | 86 | return Object.keys(props) 87 | .filter(d => sharedTourProps.includes(d)) 88 | .reduce((acc, i) => { 89 | acc[i] = props[i]; //eslint-disable-line react/destructuring-assignment 90 | 91 | return acc; 92 | }, {}); 93 | } 94 | 95 | export function getMergedStep(step: StepProps, props: JoyrideProps): StepProps { 96 | if (!step) return undefined; 97 | 98 | const mergedStep = deepmerge.all([getTourProps(props), DEFAULTS.step, step]); 99 | const mergedStyles = getStyles(deepmerge(props.styles || {}, step.styles || {})); 100 | const scrollParent = hasCustomScrollParent(getElement(step.target)); 101 | const floaterProps = deepmerge.all([props.floaterProps || {}, DEFAULTS.floaterProps, mergedStep.floaterProps || {}]); 102 | 103 | // Set react-floater props 104 | floaterProps.offset = mergedStep.offset; 105 | floaterProps.styles = deepmerge(floaterProps.styles || {}, mergedStyles.floaterStyles || {}); 106 | 107 | delete mergedStyles.floaterStyles; 108 | 109 | if (mergedStep.floaterProps && mergedStep.floaterProps.offset) { 110 | floaterProps.offset = mergedStep.floaterProps.offset; 111 | } 112 | 113 | if (!mergedStep.disableScrolling) { 114 | floaterProps.offset += props.spotlightPadding || step.spotlightPadding || 0; 115 | } 116 | 117 | if (step.placementBeacon) { 118 | floaterProps.wrapperOptions.placement = step.placementBeacon; 119 | } 120 | 121 | if (scrollParent) { 122 | floaterProps.options.preventOverflow.boundariesElement = 'window'; 123 | } 124 | 125 | return { 126 | ...mergedStep, 127 | locale: deepmerge.all([DEFAULTS.locale, props.locale || {}, mergedStep.locale || {}]), 128 | floaterProps, 129 | styles: mergedStyles, 130 | }; 131 | } 132 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@tesglobal/react-joyride", 3 | "version": "2.0.5", 4 | "description": "Create walkthroughs and guided tours for your apps", 5 | "author": "Gil Barbara ", 6 | "repository": { 7 | "type": "git", 8 | "url": "git+https://github.com/gilbarbara/react-joyride.git" 9 | }, 10 | "bugs": { 11 | "url": "https://github.com/gilbarbara/react-joyride/issues" 12 | }, 13 | "homepage": "https://gilbarbara.github.com/react-joyride/", 14 | "keywords": [ 15 | "react", 16 | "react-component", 17 | "tooltips", 18 | "joyride", 19 | "walkthroughs", 20 | "tour" 21 | ], 22 | "license": "MIT", 23 | "main": "lib/index.js", 24 | "module": "es/index.js", 25 | "files": [ 26 | "es", 27 | "lib", 28 | "src" 29 | ], 30 | "peerDependencies": { 31 | "react": "^0.14.0 || ^15.0.0 || ^16.0.0", 32 | "react-dom": "^0.14.0 || ^15.0.0 || ^16.0.0", 33 | "prop-types": "^15.0.0" 34 | }, 35 | "dependencies": { 36 | "deep-diff": "^1.0.1", 37 | "deepmerge": "^2.1.1", 38 | "exenv": "^1.2.2", 39 | "is-lite": "^0.2.0", 40 | "nested-property": "^0.0.7", 41 | "react-floater": "^0.5.5", 42 | "react-proptype-conditional-require": "^1.0.4", 43 | "scroll": "^2.0.3", 44 | "scroll-doc": "^0.2.1", 45 | "scrollparent": "^2.0.1", 46 | "tree-changes": "^0.3.2" 47 | }, 48 | "devDependencies": { 49 | "babel-core": "^6.26.3", 50 | "babel-eslint": "^8.2.6", 51 | "babel-jest": "^23.4.0", 52 | "babel-plugin-array-includes": "^2.0.3", 53 | "babel-plugin-external-helpers": "^6.22.0", 54 | "babel-plugin-transform-flow-strip-types": "^6.22.0", 55 | "babel-plugin-transform-node-env-inline": "^0.4.3", 56 | "babel-plugin-transform-react-remove-prop-types": "^0.4.13", 57 | "babel-plugin-transform-runtime": "^6.23.0", 58 | "babel-preset-env": "^1.7.0", 59 | "babel-preset-react": "^6.24.1", 60 | "babel-preset-stage-1": "^6.24.1", 61 | "chalk": "^2.4.1", 62 | "cross-env": "^5.2.0", 63 | "date-fns": "^1.29.0", 64 | "enzyme": "^3.3.0", 65 | "enzyme-adapter-react-16": "^1.1.1", 66 | "eslint": "^5.1.0", 67 | "eslint-config-airbnb": "^17.0.0", 68 | "eslint-plugin-babel": "^5.1.0", 69 | "eslint-plugin-flowtype": "^2.50.0", 70 | "eslint-plugin-import": "^2.13.0", 71 | "eslint-plugin-jsx-a11y": "^6.1.1", 72 | "eslint-plugin-react": "^7.10.0", 73 | "flow-bin": "^0.76.0", 74 | "husky": "^0.14.3", 75 | "jest": "^23.4.1", 76 | "jest-chain": "^1.0.3", 77 | "jest-environment-jsdom-global": "^1.1.0", 78 | "jest-enzyme": "^6.0.2", 79 | "jest-extended": "^0.7.2", 80 | "jest-watch-typeahead": "^0.2.0", 81 | "prop-types": "^15.6.2", 82 | "react": "^16.4.1", 83 | "react-dom": "^16.4.1", 84 | "rimraf": "^2.6.2", 85 | "rollup": "^0.62.0", 86 | "rollup-plugin-babel": "^3.0.7", 87 | "rollup-plugin-commonjs": "^9.1.3", 88 | "rollup-plugin-node-resolve": "^3.3.0" 89 | }, 90 | "scripts": { 91 | "build": "npm run clean && npm run build:cjs && npm run build:es", 92 | "build:cjs": "cross-env NODE_ENV=production npm run build:cjs:src && npm run build:cjs:constants", 93 | "build:cjs:src": "rollup -c -f cjs -o lib/index.js", 94 | "build:cjs:constants": "rollup -i src/constants/index.js -c -f cjs -o lib/constants.js", 95 | "build:es": "cross-env NODE_ENV=production npm run build:es:src && npm run build:es:constants", 96 | "build:es:src": "cross-env NODE_ENV=production rollup -c", 97 | "build:es:constants": "cross-env NODE_ENV=production rollup -i src/constants/index.js -c -o es/constants.js", 98 | "watch": "cross-env NODE_ENV=production rollup -cw", 99 | "clean": "rimraf es && rimraf lib", 100 | "lint": "eslint --ext .js --ext .jsx src test", 101 | "test": "jest --coverage", 102 | "test:watch": "jest --watch", 103 | "precommit": "node tools commits && npm run lint && npm test", 104 | "postmerge": "node tools update && npm update", 105 | "prepublishOnly": "npm run build" 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /test/__fixtures__/Tour.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import Joyride from '../../src/index'; 4 | 5 | import steps from './steps'; 6 | 7 | export default class Tour extends React.Component { 8 | state = { 9 | autoStart: false, 10 | run: false, 11 | steps, 12 | stepIndex: 0, 13 | }; 14 | 15 | static propTypes = { 16 | joyride: PropTypes.shape({ 17 | callback: PropTypes.func, 18 | }), 19 | }; 20 | 21 | static defaultProps = { 22 | joyride: {}, 23 | }; 24 | 25 | handleClickStart = (e) => { 26 | e.preventDefault(); 27 | 28 | this.setState({ 29 | run: true, 30 | stepIndex: 0, 31 | }); 32 | }; 33 | 34 | handleClickNextButton = () => { 35 | if (this.state.stepIndex === 1) { 36 | this.joyride.next(); 37 | } 38 | }; 39 | 40 | handleJoyrideCallback = (result) => { 41 | const { joyride } = this.props; 42 | 43 | if (result.type === 'step:before') { 44 | // Keep internal state in sync with joyride 45 | this.setState({ stepIndex: result.index }); 46 | } 47 | 48 | if (result.type === 'finished' && this.state.run) { 49 | // Need to set our running state to false, so we can restart if we click start again. 50 | this.setState({ run: false }); 51 | } 52 | 53 | if (result.type === 'error:target_not_found') { 54 | this.setState({ 55 | stepIndex: result.action === 'back' ? result.index - 1 : result.index + 1, 56 | autoStart: result.action !== 'close' && result.action !== 'esc', 57 | }); 58 | } 59 | 60 | if (typeof joyride.callback === 'function') { 61 | joyride.callback(result); 62 | } 63 | else { 64 | console.log(result); //eslint-disable-line no-console 65 | } 66 | }; 67 | 68 | render() { 69 | const props = { 70 | ...this.state, 71 | ...this.props.joyride, 72 | }; 73 | 74 | return ( 75 |
76 | (this.joyride = c)} 83 | callback={this.handleJoyrideCallback} 84 | /> 85 |
86 |
87 |
88 |
89 |

90 | Create walkthroughs and guided tours for your ReactJS apps. 91 | 92 |

93 | Let's Go! 94 |
95 |
96 |
97 |
98 |
99 |

Projects

100 |
101 |
102 | ASBESTOS 103 |
104 |
105 | GROW 106 |
107 |
108 | ∂Vo∑ 109 |
110 |
111 |
112 |
113 | 114 |
115 |
116 |

Mission

117 | 124 |
125 |
126 |
127 |
128 |

About

129 |
130 |
131 |
132 | 138 |
139 | ); 140 | } 141 | } 142 | -------------------------------------------------------------------------------- /src/styles.js: -------------------------------------------------------------------------------- 1 | import deepmerge from 'deepmerge'; 2 | import { hexToRGB } from './modules/helpers'; 3 | 4 | const defaultOptions = { 5 | arrowColor: '#fff', 6 | backgroundColor: '#fff', 7 | beaconSize: 36, 8 | overlayColor: 'rgba(0, 0, 0, 0.5)', 9 | primaryColor: '#f04', 10 | spotlightShadow: '0 0 15px rgba(0, 0, 0, 0.5)', 11 | textColor: '#333', 12 | zIndex: 100, 13 | }; 14 | 15 | const buttonReset = { 16 | backgroundColor: 'transparent', 17 | border: 0, 18 | borderRadius: 0, 19 | color: '#555', 20 | cursor: 'pointer', 21 | lineHeight: 1, 22 | padding: 8, 23 | WebkitAppearance: 'none', 24 | }; 25 | 26 | export default function getStyles(stepStyles) { 27 | const options = deepmerge(defaultOptions, stepStyles.options || {}); 28 | let width = 290; 29 | 30 | if (window.innerWidth > 480) { 31 | width = 380; 32 | } 33 | else if (window.innerWidth > 768) { 34 | width = 490; 35 | } 36 | 37 | if (options.width) { 38 | if (window.innerWidth < options.width) { 39 | width = window.innerWidth - 30; 40 | } 41 | else { 42 | width = options.width; //eslint-disable-line prefer-destructuring 43 | } 44 | } 45 | 46 | const defaultStyles = { 47 | beacon: { 48 | ...buttonReset, 49 | display: 'inline-block', 50 | height: options.beaconSize, 51 | position: 'relative', 52 | width: options.beaconSize, 53 | zIndex: options.zIndex, 54 | }, 55 | beaconInner: { 56 | animation: 'joyride-beacon-inner 1.2s infinite ease-in-out', 57 | backgroundColor: options.primaryColor, 58 | borderRadius: '50%', 59 | display: 'block', 60 | height: '50%', 61 | left: '50%', 62 | opacity: 0.7, 63 | position: 'absolute', 64 | top: '50%', 65 | transform: 'translate(-50%, -50%)', 66 | width: '50%', 67 | }, 68 | beaconOuter: { 69 | animation: 'joyride-beacon-outer 1.2s infinite ease-in-out', 70 | backgroundColor: `rgba(${hexToRGB(options.primaryColor).join(',')}, 0.2)`, 71 | border: `2px solid ${options.primaryColor}`, 72 | borderRadius: '50%', 73 | boxSizing: 'border-box', 74 | display: 'block', 75 | height: '100%', 76 | left: 0, 77 | opacity: 0.9, 78 | position: 'absolute', 79 | top: 0, 80 | transformOrigin: 'center', 81 | width: '100%', 82 | }, 83 | tooltip: { 84 | backgroundColor: options.backgroundColor, 85 | borderRadius: 5, 86 | boxSizing: 'border-box', 87 | color: options.textColor, 88 | fontSize: 16, 89 | maxWidth: '100%', 90 | padding: 15, 91 | position: 'relative', 92 | width, 93 | }, 94 | tooltipContainer: { 95 | lineHeight: 1.4, 96 | textAlign: 'center', 97 | }, 98 | tooltipTitle: { 99 | fontSize: 18, 100 | margin: '0 0 10px 0', 101 | }, 102 | tooltipContent: { 103 | padding: '20px 10px', 104 | }, 105 | tooltipFooter: { 106 | alignItems: 'center', 107 | display: 'flex', 108 | justifyContent: 'space-between', 109 | marginTop: 15, 110 | }, 111 | buttonNext: { 112 | ...buttonReset, 113 | backgroundColor: options.primaryColor, 114 | borderRadius: 4, 115 | color: '#fff', 116 | }, 117 | buttonBack: { 118 | ...buttonReset, 119 | color: options.primaryColor, 120 | marginLeft: 'auto', 121 | marginRight: 5, 122 | }, 123 | buttonClose: { 124 | ...buttonReset, 125 | color: options.textColor, 126 | height: 14, 127 | padding: 15, 128 | position: 'absolute', 129 | right: 0, 130 | top: 0, 131 | width: 14, 132 | }, 133 | buttonSkip: { 134 | ...buttonReset, 135 | color: options.textColor, 136 | fontSize: 14, 137 | }, 138 | overlay: { 139 | bottom: 0, 140 | left: 0, 141 | overflow: 'hidden', 142 | position: 'absolute', 143 | right: 0, 144 | top: 0, 145 | zIndex: options.zIndex, 146 | }, 147 | overlayLegacy: { 148 | bottom: 0, 149 | left: 0, 150 | overflow: 'hidden', 151 | position: 'absolute', 152 | right: 0, 153 | top: 0, 154 | zIndex: options.zIndex, 155 | }, 156 | spotlight: { 157 | position: 'absolute', 158 | outline: `9999px ${options.overlayColor} solid`, 159 | }, 160 | spotlightLegacy: { 161 | position: 'absolute', 162 | outline: `9999px ${options.overlayColor} solid`, 163 | }, 164 | floaterStyles: { 165 | arrow: { 166 | color: options.arrowColor, 167 | width: '100%', 168 | }, 169 | floater: { 170 | zIndex: options.zIndex, 171 | }, 172 | }, 173 | options, 174 | }; 175 | 176 | return deepmerge(defaultStyles, stepStyles || {}); 177 | } 178 | -------------------------------------------------------------------------------- /src/modules/helpers.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | import ReactDOM from 'react-dom'; 3 | import ExecutionEnvironment from 'exenv'; 4 | import is from 'is-lite'; 5 | 6 | export const { canUseDOM } = ExecutionEnvironment; 7 | export const isReact16 = ReactDOM.createPortal !== undefined; 8 | 9 | export function isMobile(): boolean { 10 | return ('ontouchstart' in window) && /Mobi/.test(navigator.userAgent); 11 | } 12 | 13 | /** 14 | * Convert hex to RGB 15 | * 16 | * @param {string} hex 17 | * @returns {Array} 18 | */ 19 | export function hexToRGB(hex: string): ?Array { 20 | // Expand shorthand form (e.g. "03F") to full form (e.g. "0033FF") 21 | const shorthandRegex = /^#?([a-f\d])([a-f\d])([a-f\d])$/i; 22 | const properHex = hex.replace(shorthandRegex, (m, r, g, b) => (r + r + g + g + b + b)); 23 | 24 | const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(properHex); 25 | return result ? [ 26 | parseInt(result[1], 16), 27 | parseInt(result[2], 16), 28 | parseInt(result[3], 16), 29 | ] : null; 30 | } 31 | 32 | /** 33 | * Get the current browser 34 | * 35 | * @returns {String} 36 | */ 37 | export function getBrowser(): string { 38 | /* istanbul ignore if */ 39 | if (typeof window === 'undefined') { 40 | return 'node'; 41 | } 42 | 43 | if (document.documentMode) { 44 | return 'ie'; 45 | } 46 | 47 | if (/Edge/.test(navigator.userAgent)) { 48 | return 'edge'; 49 | } 50 | 51 | // Opera 8.0+ 52 | if (Boolean(window.opera) || navigator.userAgent.indexOf(' OPR/') >= 0) { 53 | return 'opera'; 54 | } 55 | 56 | // Firefox 1.0+ 57 | if (typeof window.InstallTrigger !== 'undefined') { 58 | return 'firefox'; 59 | } 60 | 61 | // Chrome 1+ 62 | if (window.chrome) { 63 | return 'chrome'; 64 | } 65 | 66 | // Safari (and Chrome iOS, Firefox iOS) 67 | if (/(Version\/([0-9._]+).*Safari|CriOS|FxiOS| Mobile\/)/.test(navigator.userAgent)) { 68 | return 'safari'; 69 | } 70 | 71 | return navigator.userAgent; 72 | } 73 | 74 | /** 75 | * Detect legacy browsers 76 | * 77 | * @returns {boolean} 78 | */ 79 | export function isLegacy(): boolean { 80 | return !['chrome', 'safari', 'firefox', 'opera'].includes(getBrowser()); 81 | } 82 | 83 | /** 84 | * Log method calls if debug is enabled 85 | * 86 | * @private 87 | * @param {Object} arg 88 | * @param {string} arg.title - The title the logger was called from 89 | * @param {Object|Array} [arg.data] - The data to be logged 90 | * @param {boolean} [arg.warn] - If true, the message will be a warning 91 | * @param {boolean} [arg.debug] - Nothing will be logged unless debug is true 92 | */ 93 | export function log({ title, data, warn = false, debug = false }: Object) { 94 | /* eslint-disable no-console */ 95 | const logFn = warn ? console.warn || console.error : console.log; 96 | 97 | if (debug && title && data) { 98 | console.groupCollapsed(`%creact-joyride: ${title}`, 'color: #ff0044; font-weight: bold; font-size: 12px;'); 99 | 100 | if (Array.isArray(data)) { 101 | data.forEach(d => { 102 | if (is.plainObject(d) && d.key) { 103 | logFn.apply(console, [d.key, d.value]); 104 | } 105 | else { 106 | logFn.apply(console, [d]); 107 | } 108 | }); 109 | } 110 | else { 111 | logFn.apply(console, [data]); 112 | } 113 | 114 | console.groupEnd(); 115 | } 116 | /* eslint-enable */ 117 | } 118 | 119 | export function hasKey(value: Object, key: string): boolean { 120 | return Object.prototype.hasOwnProperty.call(value, key); 121 | } 122 | 123 | export function hasValidKeys(value: Object, keys: string | Array): boolean { 124 | if (!is.plainObject(value) || !is.array(keys)) { 125 | return false; 126 | } 127 | let validKeys = keys; 128 | 129 | if (is.string(keys)) { 130 | validKeys = [keys]; 131 | } 132 | 133 | return Object.keys(value).every(d => validKeys.includes(d)); 134 | } 135 | 136 | export function isEqual(a: any, b: any): boolean { 137 | let p; 138 | let t; 139 | 140 | for (p in a) { 141 | if (Object.prototype.hasOwnProperty.call(a, p)) { 142 | if (typeof b[p] === 'undefined') { 143 | return false; 144 | } 145 | 146 | if (b[p] && !a[p]) { 147 | return false; 148 | } 149 | 150 | t = typeof a[p]; 151 | 152 | if (t === 'object' && !isEqual(a[p], b[p])) { 153 | return false; 154 | } 155 | 156 | if (t === 'function' && (typeof b[p] === 'undefined' || a[p].toString() !== b[p].toString())) { 157 | return false; 158 | } 159 | 160 | if (a[p] !== b[p]) { 161 | return false; 162 | } 163 | } 164 | } 165 | 166 | for (p in b) { 167 | if (typeof a[p] === 'undefined') { 168 | return false; 169 | } 170 | } 171 | 172 | return true; 173 | } 174 | -------------------------------------------------------------------------------- /src/modules/dom.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | import scroll from 'scroll'; 3 | import scrollDoc from 'scroll-doc'; 4 | import getScrollParent from 'scrollparent'; 5 | 6 | export { getScrollParent }; 7 | 8 | /** 9 | * Find the bounding client rect 10 | * 11 | * @private 12 | * @param {HTMLElement} element - The target element 13 | * @returns {Object} 14 | */ 15 | export function getClientRect(element: HTMLElement): Object { 16 | if (!element) { 17 | return {}; 18 | } 19 | 20 | return element.getBoundingClientRect(); 21 | } 22 | 23 | /** 24 | * Helper function to get the browser-normalized "document height" 25 | * @returns {Number} 26 | */ 27 | export function getDocumentHeight(): number { 28 | const { body, documentElement: html } = document; 29 | 30 | if (!body || !html) { 31 | return 0; 32 | } 33 | 34 | return Math.max(body.scrollHeight, body.offsetHeight, html.clientHeight, html.scrollHeight, html.offsetHeight); 35 | } 36 | 37 | /** 38 | * Find the bounding client rect relative to the parent 39 | * 40 | * @private 41 | * @param {HTMLElement} element - The target element 42 | * @param {HTMLElement} [parent] - The parent element to calculate offsets from 43 | * @returns {Object} 44 | */ 45 | export function getRelativeClientRect(element: HTMLElement, parent: HTMLElement): Object { 46 | const elementRect = getClientRect(element); 47 | 48 | if (!parent || parent.style.position) { 49 | return elementRect; 50 | } 51 | 52 | const parentRect = getClientRect(parent); 53 | const offsetTop = (elementRect.top - parentRect.top) + parent.scrollTop; 54 | const offsetLeft = (elementRect.left - parentRect.left) + parent.scrollLeft; 55 | 56 | return { 57 | top: offsetTop, 58 | left: offsetLeft, 59 | right: parentRect.right > 0 ? parentRect.right - elementRect.right : elementRect.right, 60 | bottom: parentRect.bottom > 0 ? parentRect.bottom - elementRect.bottom : elementRect.bottom, 61 | x: offsetLeft, 62 | y: offsetTop, 63 | width: elementRect.width, 64 | height: elementRect.height, 65 | }; 66 | } 67 | 68 | export function getStyleComputedProperty(el: HTMLElement): Object { 69 | if (!el || el.nodeType !== 1) { 70 | return {}; 71 | } 72 | 73 | return getComputedStyle(el); 74 | } 75 | 76 | export function hasCustomScrollParent(element: ?HTMLElement): boolean { 77 | if (!element) { 78 | return false; 79 | } 80 | return getScrollParent(element) !== scrollDoc(); 81 | } 82 | 83 | export function hasCustomOffsetParent(element: HTMLElement): boolean { 84 | return element.offsetParent !== document.body; 85 | } 86 | 87 | export function isFixed(el: ?HTMLElement | Node): boolean { 88 | if (!el || !(el instanceof HTMLElement)) { 89 | return false; 90 | } 91 | 92 | const { nodeName } = el; 93 | 94 | if (nodeName === 'BODY' || nodeName === 'HTML') { 95 | return false; 96 | } 97 | 98 | if (getStyleComputedProperty(el).position === 'fixed') { 99 | return true; 100 | } 101 | 102 | return isFixed(el.parentNode); 103 | } 104 | 105 | /** 106 | * Get the scrollTop position 107 | * 108 | * @param {HTMLElement} element 109 | * @param {number} offset 110 | * 111 | * @returns {number} 112 | */ 113 | export function getScrollTo(element: HTMLElement, offset: number): number { 114 | if (!element) { 115 | return 0; 116 | } 117 | 118 | const parent = getScrollParent(element); 119 | let top = element.offsetTop; 120 | 121 | if (hasCustomScrollParent(element) && !hasCustomOffsetParent(element)) { 122 | top -= parent.offsetTop; 123 | } 124 | 125 | return Math.floor(top - offset); 126 | } 127 | 128 | /** 129 | * Find and return the target DOM element based on a step's 'target'. 130 | * 131 | * @private 132 | * @param {string|HTMLElement} element 133 | * 134 | * @returns {HTMLElement|undefined} 135 | */ 136 | export function getElement(element: string | HTMLElement): ?HTMLElement { 137 | if (typeof element !== 'string') { 138 | return element; 139 | } 140 | 141 | return element ? document.querySelector(element) : null; 142 | } 143 | 144 | /** 145 | * Find and return the target DOM element based on a step's 'target'. 146 | * 147 | * @private 148 | * @param {string|HTMLElement} element 149 | * @param {number} offset 150 | * 151 | * @returns {HTMLElement|undefined} 152 | */ 153 | export function getElementPosition(element: HTMLElement, offset: number): number { 154 | const elementRect = getClientRect(element); 155 | const scrollParent = getScrollParent(element); 156 | const hasScrollParent = hasCustomScrollParent(element); 157 | 158 | const top = elementRect.top + (!hasScrollParent && !isFixed(element) ? scrollParent.scrollTop : 0); 159 | 160 | return Math.floor(top - offset); 161 | } 162 | 163 | export function scrollTo(value: number, element: HTMLElement = scrollDoc()): Promise<*> { 164 | return new Promise((resolve, reject) => { 165 | const { scrollTop } = element; 166 | 167 | const limit = value > scrollTop ? value - scrollTop : scrollTop - value; 168 | 169 | scroll.top(element, value, { duration: limit < 100 ? 50 : 300 }, (error) => { 170 | if (error && error.message !== 'Element already at target scroll position') { 171 | return reject(error); 172 | } 173 | 174 | return resolve(); 175 | }); 176 | }); 177 | } 178 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "airbnb", 3 | "parser": "babel-eslint", 4 | "env": { 5 | "browser": true, 6 | "jest": true 7 | }, 8 | "plugins": [ 9 | "babel", 10 | "flowtype" 11 | ], 12 | "settings": { 13 | "flowtype": { 14 | "onlyFilesWithFlowAnnotation": true 15 | } 16 | }, 17 | "rules": { 18 | "arrow-body-style": ["warn", "as-needed"], 19 | "arrow-parens": "off", 20 | "block-spacing": "warn", 21 | "brace-style": [1, "stroustrup", { "allowSingleLine": true }], 22 | "camelcase": "off", 23 | "class-methods-use-this": "off", 24 | "comma-dangle": [ 25 | "warn", { 26 | "arrays": "always-multiline", 27 | "imports": "always-multiline", 28 | "objects": "always-multiline", 29 | "functions": "only-multiline" 30 | } 31 | ], 32 | "dot-notation": "warn", 33 | "function-paren-newline": "off", 34 | "generator-star-spacing": "off", 35 | "global-require": "off", 36 | "indent": ["warn", 2, { "SwitchCase": 1 }], 37 | "max-len": "off", 38 | "newline-per-chained-call": ["warn", { "ignoreChainWithDepth": 5 }], 39 | "no-case-declarations": "warn", 40 | "no-confusing-arrow": ["warn", { "allowParens": true }], 41 | "no-mixed-spaces-and-tabs": ["warn", "smart-tabs"], 42 | "no-multi-spaces": [ 43 | "warn", { 44 | "exceptions": { 45 | "VariableDeclarator": true, 46 | "Property": false 47 | } 48 | } 49 | ], 50 | "no-nested-ternary": "warn", 51 | "no-param-reassign": ["warn", { "props": false }], 52 | "no-plusplus": "off", 53 | "no-restricted-globals": ["error", "fdescribe", "fit"], 54 | "no-restricted-syntax": [ 55 | "error", 56 | "DebuggerStatement", 57 | "LabeledStatement", 58 | "WithStatement" 59 | ], 60 | "no-return-assign": ["error", "except-parens"], 61 | "no-template-curly-in-string": "warn", 62 | "no-trailing-spaces": "warn", 63 | "no-underscore-dangle": "off", 64 | "no-unused-vars": "warn", 65 | "object-curly-newline": "off", 66 | "object-shorthand": ["warn", "always"], 67 | "one-var": "warn", 68 | "padded-blocks": "warn", 69 | "prefer-const": "warn", 70 | "prefer-destructuring": "warn", 71 | "prefer-template": "warn", 72 | "prefer-promise-reject-errors": "off", 73 | "quotes": ["warn", "single", "avoid-escape"], 74 | "require-jsdoc": [ 75 | 0, { 76 | "require": { 77 | "FunctionDeclaration": true, 78 | "MethodDefinition": false, 79 | "ClassDeclaration": false 80 | } 81 | } 82 | ], 83 | "space-before-function-paren": [ 84 | "warn", { 85 | "anonymous": "never", 86 | "named": "never", 87 | "asyncArrow": "always" 88 | } 89 | ], 90 | "space-in-parens": "warn", 91 | "spaced-comment": [ 92 | "warn", 93 | "always", { 94 | "exceptions": [ 95 | "-+" 96 | ], 97 | "markers": [ 98 | "eslint-disable", 99 | "eslint-disable-line", 100 | "eslint-disable-next-line", 101 | "eslint-enable" 102 | ] 103 | } 104 | ], 105 | "valid-jsdoc": [ 106 | "warn", { 107 | "prefer": { 108 | "return": "returns" 109 | }, 110 | "requireReturn": false, 111 | "requireParamDescription": false, 112 | "requireReturnDescription": false 113 | } 114 | ], 115 | "flowtype/require-parameter-type": ["warn", { "excludeArrowFunctions": true }], 116 | "flowtype/require-return-type": ["warn", "always", { "excludeArrowFunctions": true }], 117 | "flowtype/space-after-type-colon": ["error", "always"], 118 | "import/export": "off", 119 | "import/no-dynamic-require": "off", 120 | "import/no-extraneous-dependencies": ["error", { "devDependencies": true }], 121 | "import/no-named-as-default": "off", 122 | "import/no-unresolved": "warn", 123 | "import/no-webpack-loader-syntax": "off", 124 | "import/prefer-default-export": "off", 125 | "jsx-a11y/click-events-have-key-events": "off", 126 | "jsx-a11y/label-has-for": "off", 127 | "jsx-a11y/no-static-element-interactions": "off", 128 | "jsx-quotes": "warn", 129 | "react/forbid-prop-types": "off", 130 | "react/jsx-closing-bracket-location": ["warn", "line-aligned"], 131 | "react/jsx-filename-extension": "off", 132 | "react/jsx-first-prop-new-line": ["warn", "multiline"], 133 | "react/jsx-indent": ["warn", 2], 134 | "react/jsx-indent-props": ["warn", 2], 135 | "react/jsx-key": "warn", 136 | "react/jsx-max-props-per-line": ["warn", { "maximum": 4 }], 137 | "react/jsx-no-target-blank": "off", 138 | "react/no-array-index-key": "off", 139 | "react/no-danger": "off", 140 | "react/no-did-mount-set-state": "warn", 141 | "react/no-did-update-set-state": "warn", 142 | "react/jsx-boolean-value": "off", 143 | "react/jsx-no-duplicate-props": "warn", 144 | "react/jsx-one-expression-per-line": "off", 145 | "react/jsx-pascal-case": "warn", 146 | "react/no-direct-mutation-state": "warn", 147 | "react/no-unused-prop-types": "warn", 148 | "react/no-unused-state": "warn", 149 | "react/no-unescaped-entities": "off", 150 | "react/prefer-stateless-function": "off", 151 | "react/prop-types": "error", 152 | "react/require-default-props": "off", 153 | "react/sort-prop-types": "warn", 154 | "react/sort-comp": [ 155 | "warn", 156 | { 157 | "order": [ 158 | "constructor", 159 | "lifecycle", 160 | "everything-else", 161 | "render" 162 | ], 163 | "groups": { 164 | "lifecycle": [ 165 | "state", 166 | "statics", 167 | "contextTypes", 168 | "childContextTypes", 169 | "getChildContext", 170 | "propTypes", 171 | "defaultProps", 172 | "shouldComponentUpdate", 173 | "componentWillMount", 174 | "componentDidMount", 175 | "componentWillReceiveProps", 176 | "componentWillUpdate", 177 | "componentDidUpdate", 178 | "componentWillUnmount" 179 | ] 180 | } 181 | } 182 | ] 183 | } 184 | } 185 | -------------------------------------------------------------------------------- /src/components/Overlay.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import treeChanges from 'tree-changes'; 4 | 5 | import { 6 | getClientRect, 7 | getDocumentHeight, 8 | getElement, 9 | getElementPosition, 10 | getScrollParent, 11 | hasCustomScrollParent, 12 | isFixed, 13 | } from '../modules/dom'; 14 | import { getBrowser, isLegacy } from '../modules/helpers'; 15 | 16 | import LIFECYCLE from '../constants/lifecycle'; 17 | 18 | import Spotlight from './Spotlight'; 19 | 20 | export default class Overlay extends React.Component { 21 | constructor(props) { 22 | super(props); 23 | 24 | this.state = { 25 | mouseOverSpotlight: false, 26 | isScrolling: false, 27 | }; 28 | } 29 | 30 | static propTypes = { 31 | disableOverlay: PropTypes.bool.isRequired, 32 | disableScrolling: PropTypes.bool.isRequired, 33 | lifecycle: PropTypes.string.isRequired, 34 | onClickOverlay: PropTypes.func.isRequired, 35 | placement: PropTypes.string.isRequired, 36 | spotlightClicks: PropTypes.bool.isRequired, 37 | spotlightPadding: PropTypes.number, 38 | styles: PropTypes.object.isRequired, 39 | target: PropTypes.oneOfType([ 40 | PropTypes.object, 41 | PropTypes.string, 42 | ]).isRequired, 43 | }; 44 | 45 | componentDidMount() { 46 | const { disableScrolling, target } = this.props; 47 | 48 | if (!disableScrolling) { 49 | const element = getElement(target); 50 | this.scrollParent = hasCustomScrollParent(element) ? getScrollParent(element) : document; 51 | } 52 | 53 | window.addEventListener('resize', this.handleResize); 54 | } 55 | 56 | componentWillReceiveProps(nextProps) { 57 | const { disableScrolling, lifecycle, spotlightClicks } = nextProps; 58 | const { changed, changedTo } = treeChanges(this.props, nextProps); 59 | 60 | if (!disableScrolling) { 61 | if (changedTo('lifecycle', LIFECYCLE.TOOLTIP)) { 62 | this.scrollParent.addEventListener('scroll', this.handleScroll, { passive: true }); 63 | 64 | setTimeout(() => { 65 | const { isScrolling } = this.state; 66 | 67 | if (!isScrolling) { 68 | this.scrollParent.removeEventListener('scroll', this.handleScroll); 69 | } 70 | }, 100); 71 | } 72 | } 73 | 74 | if (changed('spotlightClicks') || changed('disableOverlay') || changed('lifecycle')) { 75 | if (spotlightClicks && lifecycle === LIFECYCLE.TOOLTIP) { 76 | window.addEventListener('mousemove', this.handleMouseMove, false); 77 | } 78 | else if (lifecycle !== LIFECYCLE.TOOLTIP) { 79 | window.removeEventListener('mousemove', this.handleMouseMove); 80 | } 81 | } 82 | } 83 | 84 | componentWillUnmount() { 85 | const { disableScrolling } = this.props; 86 | 87 | window.removeEventListener('mousemove', this.handleMouseMove); 88 | window.removeEventListener('resize', this.handleResize); 89 | 90 | if (!disableScrolling) { 91 | clearTimeout(this.scrollTimeout); 92 | this.scrollParent.removeEventListener('scroll', this.handleScroll); 93 | } 94 | } 95 | 96 | handleMouseMove = (e) => { 97 | const { mouseOverSpotlight } = this.state; 98 | const { height, left, position, top, width } = this.stylesSpotlight; 99 | 100 | const offsetY = position === 'fixed' ? e.clientY : e.pageY; 101 | const offsetX = position === 'fixed' ? e.clientX : e.pageX; 102 | const inSpotlightHeight = (offsetY >= top && offsetY <= top + height); 103 | const inSpotlightWidth = (offsetX >= left && offsetX <= left + width); 104 | const inSpotlight = inSpotlightWidth && inSpotlightHeight; 105 | 106 | if (inSpotlight !== mouseOverSpotlight) { 107 | this.setState({ mouseOverSpotlight: inSpotlight }); 108 | } 109 | }; 110 | 111 | handleScroll = () => { 112 | const { isScrolling } = this.state; 113 | 114 | if (!isScrolling) { 115 | this.setState({ isScrolling: true }); 116 | } 117 | 118 | clearTimeout(this.scrollTimeout); 119 | 120 | this.scrollTimeout = setTimeout(() => { 121 | clearTimeout(this.scrollTimeout); 122 | this.setState({ isScrolling: false }); 123 | this.scrollParent.removeEventListener('scroll', this.handleScroll); 124 | }, 50); 125 | }; 126 | 127 | handleResize = () => { 128 | clearTimeout(this.resizeTimeout); 129 | 130 | this.resizeTimeout = setTimeout(() => { 131 | clearTimeout(this.resizeTimeout); 132 | this.forceUpdate(); 133 | }, 100); 134 | }; 135 | 136 | get stylesSpotlight() { 137 | const { spotlightClicks, spotlightPadding, styles, target } = this.props; 138 | const element = getElement(target); 139 | const elementRect = getClientRect(element); 140 | const isFixedTarget = isFixed(element); 141 | const top = getElementPosition(element, spotlightPadding); 142 | 143 | return { 144 | ...(isLegacy() ? styles.spotlightLegacy : styles.spotlight), 145 | height: Math.round(elementRect.height + (spotlightPadding * 2)), 146 | left: Math.round(elementRect.left - spotlightPadding), 147 | pointerEvents: spotlightClicks ? 'none' : 'auto', 148 | position: isFixedTarget ? 'fixed' : 'absolute', 149 | top, 150 | width: Math.round(elementRect.width + (spotlightPadding * 2)), 151 | }; 152 | } 153 | 154 | render() { 155 | const { mouseOverSpotlight } = this.state; 156 | const { 157 | disableOverlay, 158 | lifecycle, 159 | onClickOverlay, 160 | placement, 161 | styles, 162 | } = this.props; 163 | 164 | if (disableOverlay || lifecycle !== LIFECYCLE.TOOLTIP) { 165 | return null; 166 | } 167 | 168 | const stylesOverlay = { 169 | cursor: disableOverlay ? 'default' : 'pointer', 170 | height: getDocumentHeight(), 171 | pointerEvents: mouseOverSpotlight ? 'none' : 'auto', 172 | ...(isLegacy() ? styles.overlayLegacy : styles.overlay), 173 | }; 174 | 175 | let spotlight = placement !== 'center' && ( 176 | 177 | ); 178 | 179 | // Hack for Safari bug with mix-blend-mode with z-index 180 | if (getBrowser() === 'safari') { 181 | const { mixBlendMode, zIndex, ...safarOverlay } = stylesOverlay; 182 | 183 | spotlight = ( 184 |
185 | {spotlight} 186 |
187 | ); 188 | delete stylesOverlay.backgroundColor; 189 | } 190 | 191 | return ( 192 |
197 | {spotlight} 198 |
199 | ); 200 | } 201 | } 202 | -------------------------------------------------------------------------------- /src/modules/store.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | import is from 'is-lite'; 3 | import STATUS from '../constants/status'; 4 | import ACTIONS from '../constants/actions'; 5 | import LIFECYCLE from '../constants/lifecycle'; 6 | 7 | import { hasValidKeys } from './helpers'; 8 | 9 | import type { StateHelpers, StateInstance, StateObject } from '../config/types'; 10 | 11 | const defaultState: StateObject = { 12 | action: '', 13 | controlled: false, 14 | index: 0, 15 | lifecycle: LIFECYCLE.INIT, 16 | size: 0, 17 | status: STATUS.IDLE, 18 | }; 19 | 20 | const validKeys = ['action', 'index', 'lifecycle', 'status']; 21 | 22 | export default function createStore(props: StateObject): StateInstance { 23 | const store: Map = new Map(); 24 | const data: Map = new Map(); 25 | 26 | class Store { 27 | listener: Function; 28 | 29 | constructor({ continuous = false, stepIndex, steps = [] }: Object = {}) { 30 | this.setState({ 31 | action: ACTIONS.INIT, 32 | controlled: is.number(stepIndex), 33 | continuous, 34 | index: is.number(stepIndex) ? stepIndex : 0, 35 | lifecycle: LIFECYCLE.INIT, 36 | status: steps.length ? STATUS.READY : STATUS.IDLE, 37 | }, true); 38 | 39 | this.setSteps(steps); 40 | } 41 | 42 | addListener(listener: Function) { 43 | this.listener = listener; 44 | } 45 | 46 | setState(nextState: Object, initial: boolean = false) { 47 | const state = this.getState(); 48 | 49 | const { action, index, lifecycle, status } = { 50 | ...state, 51 | ...nextState, 52 | }; 53 | 54 | store.set('action', action); 55 | store.set('index', index); 56 | store.set('lifecycle', lifecycle); 57 | store.set('status', status); 58 | 59 | if (initial) { 60 | store.set('controlled', nextState.controlled); 61 | store.set('continuous', nextState.continuous); 62 | } 63 | 64 | /* istanbul ignore else */ 65 | if (this.listener && this.hasUpdatedState(state)) { 66 | // console.log('▶ ▶ ▶ NEW STATE', this.getState()); 67 | this.listener(this.getState()); 68 | } 69 | } 70 | 71 | getState(): Object { 72 | if (!store.size) { 73 | return { ...defaultState }; 74 | } 75 | 76 | const index = parseInt(store.get('index'), 10); 77 | const steps = this.getSteps(); 78 | const size = steps.length; 79 | 80 | return { 81 | action: store.get('action'), 82 | controlled: store.get('controlled'), 83 | index, 84 | lifecycle: store.get('lifecycle'), 85 | size, 86 | status: store.get('status'), 87 | }; 88 | } 89 | 90 | getNextState(state: StateObject, force: ?boolean = false): Object { 91 | const { action, controlled, index, size, status } = this.getState(); 92 | const newIndex = is.number(state.index) ? state.index : index; 93 | const nextIndex = controlled && !force ? index : Math.min(Math.max(newIndex, 0), size); 94 | 95 | return { 96 | action: state.action || action, 97 | index: nextIndex, 98 | lifecycle: state.lifecycle || LIFECYCLE.INIT, 99 | status: nextIndex === size ? STATUS.FINISHED : (state.status || status), 100 | }; 101 | } 102 | 103 | hasUpdatedState(oldState: StateObject): boolean { 104 | const before = JSON.stringify(oldState); 105 | const after = JSON.stringify(this.getState()); 106 | 107 | return before !== after; 108 | } 109 | 110 | setSteps = (steps: Array) => { 111 | const { size, status } = this.getState(); 112 | data.set('steps', steps); 113 | 114 | if (status === STATUS.WAITING && !size && steps.length) { 115 | this.setState({ status: STATUS.RUNNING }); 116 | } 117 | }; 118 | 119 | getSteps(): Array { 120 | const steps = data.get('steps'); 121 | 122 | return Array.isArray(steps) ? steps : []; 123 | } 124 | 125 | getHelpers(): StateHelpers { 126 | return { 127 | start: this.start, 128 | stop: this.stop, 129 | restart: this.restart, 130 | reset: this.reset, 131 | prev: this.prev, 132 | next: this.next, 133 | go: this.go, 134 | index: this.index, 135 | close: this.close, 136 | skip: this.skip, 137 | info: this.info, 138 | }; 139 | } 140 | 141 | update = (state: StateObject) => { 142 | if (!hasValidKeys(state, validKeys)) { 143 | throw new Error('state is not valid'); 144 | } 145 | 146 | this.setState({ 147 | ...this.getNextState({ 148 | ...this.getState(), 149 | ...state, 150 | action: state.action || ACTIONS.UPDATE, 151 | }, true), 152 | }); 153 | }; 154 | 155 | steps = (nextSteps) => { 156 | if (!is.array(nextSteps)) return; 157 | 158 | this.setSteps(nextSteps); 159 | }; 160 | 161 | start = (nextIndex: number) => { 162 | const { index, size } = this.getState(); 163 | 164 | this.setState({ 165 | ...this.getNextState({ 166 | action: ACTIONS.START, 167 | index: is.number(nextIndex) ? nextIndex : index, 168 | }, true), 169 | status: size ? STATUS.RUNNING : STATUS.WAITING, 170 | }); 171 | }; 172 | 173 | stop = (advance = false) => { 174 | const { index, status } = this.getState(); 175 | 176 | if ([STATUS.FINISHED, STATUS.SKIPPED].includes(status)) return; 177 | 178 | this.setState({ 179 | ...this.getNextState({ action: ACTIONS.STOP, index: index + (advance ? 1 : 0) }), 180 | status: STATUS.PAUSED, 181 | }); 182 | }; 183 | 184 | restart = () => { 185 | const { controlled } = this.getState(); 186 | if (controlled) return; 187 | 188 | this.setState({ 189 | ...this.getNextState({ action: ACTIONS.RESTART, index: 0 }), 190 | status: STATUS.RUNNING, 191 | }); 192 | }; 193 | 194 | reset = () => { 195 | const { controlled } = this.getState(); 196 | if (controlled) return; 197 | 198 | this.setState({ 199 | ...this.getNextState({ action: ACTIONS.RESET, index: 0 }), 200 | status: STATUS.READY, 201 | }); 202 | }; 203 | 204 | prev = () => { 205 | const { index, status } = this.getState(); 206 | if (status !== STATUS.RUNNING) return; 207 | 208 | this.setState({ 209 | ...this.getNextState({ action: ACTIONS.PREV, index: index - 1 }), 210 | }); 211 | }; 212 | 213 | next = () => { 214 | const { index, status } = this.getState(); 215 | if (status !== STATUS.RUNNING) return; 216 | 217 | this.setState(this.getNextState({ action: ACTIONS.NEXT, index: index + 1 })); 218 | }; 219 | 220 | go = (number) => { 221 | const { index, status } = this.getState(); 222 | if (status !== STATUS.RUNNING) return; 223 | 224 | this.setState({ 225 | ...this.getNextState({ action: ACTIONS.GO, index: index + number }), 226 | }); 227 | }; 228 | 229 | index = (nextIndex) => { 230 | const { status } = this.getState(); 231 | if (status !== STATUS.RUNNING) return; 232 | 233 | const step = this.getSteps()[nextIndex]; 234 | 235 | this.setState({ 236 | ...this.getNextState({ action: ACTIONS.INDEX, index: nextIndex }), 237 | status: step ? status : STATUS.FINISHED, 238 | }); 239 | }; 240 | 241 | close = () => { 242 | const { index, status } = this.getState(); 243 | if (status !== STATUS.RUNNING) return; 244 | 245 | this.setState({ 246 | ...this.getNextState({ action: ACTIONS.CLOSE, index: index + 1 }), 247 | }); 248 | }; 249 | 250 | skip = () => { 251 | const { status } = this.getState(); 252 | if (status !== STATUS.RUNNING) return; 253 | 254 | this.setState({ 255 | action: ACTIONS.SKIP, 256 | lifecycle: LIFECYCLE.INIT, 257 | status: STATUS.SKIPPED, 258 | }); 259 | }; 260 | 261 | info = (): Object => this.getState() 262 | } 263 | 264 | return new Store(props); 265 | } 266 | -------------------------------------------------------------------------------- /src/components/Step.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import isRequiredIf from 'react-proptype-conditional-require'; 4 | import Floater from 'react-floater'; 5 | import treeChanges from 'tree-changes'; 6 | import is from 'is-lite'; 7 | 8 | import ACTIONS from '../constants/actions'; 9 | import LIFECYCLE from '../constants/lifecycle'; 10 | 11 | import { getElement, isFixed } from '../modules/dom'; 12 | import { log } from '../modules/helpers'; 13 | import { setScope, removeScope } from '../modules/scope'; 14 | import { validateStep } from '../modules/step'; 15 | 16 | import Beacon from './Beacon'; 17 | import Overlay from './Overlay'; 18 | import Tooltip from './Tooltip/index'; 19 | import JoyridePortal from './Portal'; 20 | import EVENTS from '../constants/events'; 21 | import STATUS from '../constants/status'; 22 | 23 | export default class JoyrideStep extends React.Component { 24 | static propTypes = { 25 | action: PropTypes.string.isRequired, 26 | callback: PropTypes.func.isRequired, 27 | continuous: PropTypes.bool.isRequired, 28 | controlled: PropTypes.bool.isRequired, 29 | debug: PropTypes.bool.isRequired, 30 | getPopper: PropTypes.func.isRequired, 31 | helpers: PropTypes.object.isRequired, 32 | index: PropTypes.number.isRequired, 33 | lifecycle: PropTypes.string.isRequired, 34 | size: PropTypes.number.isRequired, 35 | status: PropTypes.string.isRequired, 36 | step: PropTypes.shape({ 37 | beaconComponent: PropTypes.oneOfType([ 38 | PropTypes.func, 39 | PropTypes.element, 40 | ]), 41 | content: isRequiredIf(PropTypes.node, props => !props.tooltipComponent && !props.title), 42 | disableBeacon: PropTypes.bool, 43 | disableOverlay: PropTypes.bool, 44 | disableOverlayClose: PropTypes.bool, 45 | event: PropTypes.string, 46 | floaterProps: PropTypes.shape({ 47 | offset: PropTypes.number, 48 | }), 49 | hideBackButton: PropTypes.bool, 50 | isFixed: PropTypes.bool, 51 | locale: PropTypes.object, 52 | offset: PropTypes.number.isRequired, 53 | placement: PropTypes.oneOf([ 54 | 'top', 'top-start', 'top-end', 55 | 'bottom', 'bottom-start', 'bottom-end', 56 | 'left', 'left-start', 'left-end', 57 | 'right', 'right-start', 'right-end', 58 | 'auto', 'center', 59 | ]), 60 | spotlightClicks: PropTypes.bool, 61 | spotlightPadding: PropTypes.number, 62 | styles: PropTypes.object, 63 | target: PropTypes.oneOfType([ 64 | PropTypes.object, 65 | PropTypes.string, 66 | ]).isRequired, 67 | title: PropTypes.node, 68 | tooltipComponent: isRequiredIf(PropTypes.oneOfType([ 69 | PropTypes.func, 70 | PropTypes.element, 71 | ]), props => !props.content && !props.title), 72 | }).isRequired, 73 | update: PropTypes.func.isRequired, 74 | }; 75 | 76 | componentDidMount() { 77 | const { debug, lifecycle } = this.props; 78 | 79 | log({ 80 | title: `step:${lifecycle}`, 81 | data: [ 82 | { key: 'props', value: this.props }, 83 | ], 84 | debug, 85 | }); 86 | } 87 | 88 | componentWillReceiveProps(nextProps) { 89 | const { action, continuous, debug, index, lifecycle, step, update } = this.props; 90 | const { changed, changedFrom } = treeChanges(this.props, nextProps); 91 | const skipBeacon = continuous && action !== ACTIONS.CLOSE && (index > 0 || action === ACTIONS.PREV); 92 | 93 | if (changedFrom('lifecycle', LIFECYCLE.INIT, LIFECYCLE.READY)) { 94 | update({ lifecycle: step.disableBeacon || skipBeacon ? LIFECYCLE.TOOLTIP : LIFECYCLE.BEACON }); 95 | } 96 | 97 | if (changed('index')) { 98 | log({ 99 | title: `step:${lifecycle}`, 100 | data: [ 101 | { key: 'props', value: this.props }, 102 | ], 103 | debug, 104 | }); 105 | } 106 | } 107 | 108 | componentDidUpdate(prevProps) { 109 | const { action, callback, controlled, index, lifecycle, size, status, step, update } = this.props; 110 | const { changed, changedTo, changedFrom } = treeChanges(prevProps, this.props); 111 | const state = { action, controlled, index, lifecycle, size, status }; 112 | 113 | const isAfterAction = [ 114 | ACTIONS.NEXT, 115 | ACTIONS.PREV, 116 | ACTIONS.SKIP, 117 | ACTIONS.CLOSE, 118 | ].includes(action) && changed('action'); 119 | 120 | const hasChangedIndex = changed('index') && changedFrom('lifecycle', LIFECYCLE.TOOLTIP, LIFECYCLE.INIT); 121 | 122 | if (!changed('status') && (hasChangedIndex || (controlled && isAfterAction))) { 123 | callback({ 124 | ...state, 125 | index: prevProps.index, 126 | lifecycle: LIFECYCLE.COMPLETE, 127 | step: prevProps.step, 128 | type: EVENTS.STEP_AFTER, 129 | }); 130 | } 131 | 132 | // There's a step to use, but there's no target in the DOM 133 | if (step) { 134 | const hasRenderedTarget = !!getElement(step.target); 135 | 136 | if (hasRenderedTarget) { 137 | if (changedFrom('status', STATUS.READY, STATUS.RUNNING) || changed('index')) { 138 | callback({ 139 | ...state, 140 | step, 141 | type: EVENTS.STEP_BEFORE, 142 | }); 143 | } 144 | } 145 | 146 | if (!hasRenderedTarget) { 147 | console.warn('Target not mounted', step); //eslint-disable-line no-console 148 | callback({ 149 | ...state, 150 | type: EVENTS.TARGET_NOT_FOUND, 151 | step, 152 | }); 153 | 154 | if (!controlled) { 155 | update({ index: index + ([ACTIONS.PREV].includes(action) ? -1 : 1) }); 156 | } 157 | } 158 | } 159 | 160 | /* istanbul ignore else */ 161 | if (changedTo('lifecycle', LIFECYCLE.BEACON)) { 162 | callback({ 163 | ...state, 164 | step, 165 | type: EVENTS.BEACON, 166 | }); 167 | } 168 | 169 | if (changedTo('lifecycle', LIFECYCLE.TOOLTIP)) { 170 | callback({ 171 | ...state, 172 | step, 173 | type: EVENTS.TOOLTIP, 174 | }); 175 | 176 | setScope(this.tooltip); 177 | } 178 | 179 | if (changedFrom('lifecycle', LIFECYCLE.TOOLTIP, LIFECYCLE.INIT)) { 180 | removeScope(); 181 | } 182 | 183 | if (changedTo('lifecycle', LIFECYCLE.INIT)) { 184 | delete this.beaconPopper; 185 | delete this.tooltipPopper; 186 | } 187 | } 188 | 189 | /** 190 | * Beacon click/hover event listener 191 | * 192 | * @param {Event} e 193 | */ 194 | handleClickHoverBeacon = (e) => { 195 | const { step, update } = this.props; 196 | 197 | if (e.type === 'mouseenter' && step.event !== 'hover') { 198 | return; 199 | } 200 | 201 | update({ lifecycle: LIFECYCLE.TOOLTIP }); 202 | }; 203 | 204 | handleClickOverlay = () => { 205 | const { helpers, step } = this.props; 206 | 207 | if (!step.disableOverlayClose) { 208 | helpers.close(); 209 | } 210 | }; 211 | 212 | setTooltipRef = (c) => { 213 | this.tooltip = c; 214 | }; 215 | 216 | setPopper = (popper, type) => { 217 | const { action, getPopper, update } = this.props; 218 | 219 | if (type === 'wrapper') { 220 | this.beaconPopper = popper; 221 | } 222 | else { 223 | this.tooltipPopper = popper; 224 | } 225 | 226 | getPopper(popper, type); 227 | 228 | if (this.beaconPopper && this.tooltipPopper) { 229 | update({ 230 | action: action === ACTIONS.CLOSE ? ACTIONS.CLOSE : action, 231 | lifecycle: LIFECYCLE.READY, 232 | }); 233 | } 234 | }; 235 | 236 | get open() { 237 | const { step, lifecycle } = this.props; 238 | 239 | return !!(step.disableBeacon || lifecycle === LIFECYCLE.TOOLTIP); 240 | } 241 | 242 | render() { 243 | const { 244 | continuous, 245 | controlled, 246 | debug, 247 | helpers, 248 | index, 249 | lifecycle, 250 | size, 251 | step, 252 | } = this.props; 253 | const target = getElement(step.target); 254 | 255 | if (!validateStep(step) || !is.domElement(target)) { 256 | return null; 257 | } 258 | 259 | return ( 260 |
261 | 262 | 267 | 268 | 280 | )} 281 | debug={debug} 282 | getPopper={this.setPopper} 283 | id={`react-joyride:${index}`} 284 | isPositioned={step.isFixed || isFixed(target)} 285 | open={this.open} 286 | placement={step.placement} 287 | target={step.target} 288 | {...step.floaterProps} 289 | > 290 | 296 | 297 |
298 | ); 299 | } 300 | } 301 | -------------------------------------------------------------------------------- /src/components/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import treeChanges from 'tree-changes'; 4 | import is from 'is-lite'; 5 | 6 | import Store from '../modules/store'; 7 | import { 8 | getElement, 9 | getScrollTo, 10 | getScrollParent, 11 | hasCustomScrollParent, 12 | isFixed, 13 | scrollTo, 14 | } from '../modules/dom'; 15 | import { canUseDOM, isEqual, log } from '../modules/helpers'; 16 | import { getMergedStep, validateSteps } from '../modules/step'; 17 | 18 | import ACTIONS from '../constants/actions'; 19 | import EVENTS from '../constants/events'; 20 | import LIFECYCLE from '../constants/lifecycle'; 21 | import STATUS from '../constants/status'; 22 | 23 | import Step from './Step'; 24 | 25 | class Joyride extends React.Component { 26 | constructor(props) { 27 | super(props); 28 | 29 | this.store = new Store({ 30 | ...props, 31 | controlled: props.run && is.number(props.stepIndex), 32 | }); 33 | this.state = this.store.getState(); 34 | this.helpers = this.store.getHelpers(); 35 | } 36 | 37 | static propTypes = { 38 | beaconComponent: PropTypes.oneOfType([ 39 | PropTypes.func, 40 | PropTypes.element, 41 | ]), 42 | callback: PropTypes.func, 43 | continuous: PropTypes.bool, 44 | debug: PropTypes.bool, 45 | disableCloseOnEsc: PropTypes.bool, 46 | disableOverlay: PropTypes.bool, 47 | disableOverlayClose: PropTypes.bool, 48 | disableScrolling: PropTypes.bool, 49 | floaterProps: PropTypes.shape({ 50 | offset: PropTypes.number, 51 | }), 52 | hideBackButton: PropTypes.bool, 53 | locale: PropTypes.object, 54 | run: PropTypes.bool, 55 | scrollOffset: PropTypes.number, 56 | scrollToFirstStep: PropTypes.bool, 57 | showProgress: PropTypes.bool, 58 | showSkipButton: PropTypes.bool, 59 | spotlightClicks: PropTypes.bool, 60 | spotlightPadding: PropTypes.number, 61 | stepIndex: PropTypes.number, 62 | steps: PropTypes.array, 63 | styles: PropTypes.object, 64 | tooltipComponent: PropTypes.oneOfType([ 65 | PropTypes.func, 66 | PropTypes.element, 67 | ]), 68 | }; 69 | 70 | static defaultProps = { 71 | continuous: false, 72 | debug: false, 73 | disableCloseOnEsc: false, 74 | disableOverlay: false, 75 | disableOverlayClose: false, 76 | disableScrolling: false, 77 | hideBackButton: false, 78 | run: true, 79 | scrollOffset: 20, 80 | scrollToFirstStep: false, 81 | showSkipButton: false, 82 | showProgress: false, 83 | spotlightClicks: false, 84 | spotlightPadding: 10, 85 | steps: [], 86 | }; 87 | 88 | componentDidMount() { 89 | if (!canUseDOM) return; 90 | 91 | const { 92 | debug, 93 | disableCloseOnEsc, 94 | run, 95 | steps, 96 | } = this.props; 97 | const { start } = this.store; 98 | 99 | log({ 100 | title: 'init', 101 | data: [ 102 | { key: 'props', value: this.props }, 103 | { key: 'state', value: this.state }, 104 | ], 105 | debug, 106 | }); 107 | 108 | // Sync the store to this component state. 109 | this.store.addListener(this.syncState); 110 | 111 | if (validateSteps(steps, debug) && run) { 112 | start(); 113 | } 114 | 115 | /* istanbul ignore else */ 116 | if (!disableCloseOnEsc) { 117 | document.body.addEventListener('keydown', this.handleKeyboard, { passive: true }); 118 | } 119 | } 120 | 121 | componentWillReceiveProps(nextProps) { 122 | if (!canUseDOM) return; 123 | const { action, status } = this.state; 124 | const { steps, stepIndex } = this.props; 125 | const { debug, run, steps: nextSteps, stepIndex: nextStepIndex } = nextProps; 126 | const { setSteps, start, stop, update } = this.store; 127 | const diffProps = !isEqual(this.props, nextProps); 128 | const { changed } = treeChanges(this.props, nextProps); 129 | 130 | if (diffProps) { 131 | log({ 132 | title: 'props', 133 | data: [ 134 | { key: 'nextProps', value: nextProps }, 135 | { key: 'props', value: this.props }, 136 | ], 137 | debug, 138 | }); 139 | 140 | const stepsChanged = !isEqual(nextSteps, steps); 141 | const stepIndexChanged = is.number(nextStepIndex) && changed('stepIndex'); 142 | 143 | /* istanbul ignore else */ 144 | if (changed('run')) { 145 | if (run) { 146 | start(nextStepIndex); 147 | } 148 | else { 149 | stop(); 150 | } 151 | } 152 | 153 | if (stepsChanged) { 154 | if (validateSteps(nextSteps, debug)) { 155 | setSteps(nextSteps); 156 | } 157 | else { 158 | console.warn('Steps are not valid', nextSteps); //eslint-disable-line no-console 159 | } 160 | } 161 | 162 | /* istanbul ignore else */ 163 | if (stepIndexChanged) { 164 | let nextAction = stepIndex < nextStepIndex ? ACTIONS.NEXT : ACTIONS.PREV; 165 | 166 | if (action === ACTIONS.STOP) { 167 | nextAction = ACTIONS.START; 168 | } 169 | 170 | if (![STATUS.FINISHED, STATUS.SKIPPED].includes(status)) { 171 | update({ 172 | action: action === ACTIONS.CLOSE ? ACTIONS.CLOSE : nextAction, 173 | index: nextStepIndex, 174 | lifecycle: LIFECYCLE.INIT, 175 | }); 176 | } 177 | } 178 | } 179 | } 180 | 181 | componentDidUpdate(prevProps, prevState) { 182 | if (!canUseDOM) return; 183 | 184 | const { index, lifecycle, status } = this.state; 185 | const { debug, steps } = this.props; 186 | const { changed, changedFrom, changedTo } = treeChanges(prevState, this.state); 187 | const diffState = !isEqual(prevState, this.state); 188 | let step = getMergedStep(steps[index], this.props); 189 | 190 | if (diffState) { 191 | log({ 192 | title: 'state', 193 | data: [ 194 | { key: 'state', value: this.state }, 195 | { key: 'changed', value: diffState }, 196 | { key: 'step', value: step }, 197 | ], 198 | debug, 199 | }); 200 | 201 | let currentIndex = index; 202 | 203 | if (changed('status')) { 204 | let type = EVENTS.TOUR_STATUS; 205 | 206 | if (changedTo('status', STATUS.FINISHED) || changedTo('status', STATUS.SKIPPED)) { 207 | type = EVENTS.TOUR_END; 208 | // Return the last step when the tour is finished 209 | step = getMergedStep(steps[prevState.index], this.props); 210 | currentIndex = prevState.index; 211 | } 212 | else if (changedFrom('status', STATUS.READY, STATUS.RUNNING)) { 213 | type = EVENTS.TOUR_START; 214 | } 215 | 216 | this.callback({ 217 | ...this.state, 218 | index: currentIndex, 219 | step, 220 | type, 221 | }); 222 | } 223 | 224 | if (step) { 225 | this.scrollToStep(prevState); 226 | 227 | if (step.placement === 'center' && status === STATUS.RUNNING && lifecycle === LIFECYCLE.INIT) { 228 | this.store.update({ lifecycle: LIFECYCLE.READY }); 229 | } 230 | } 231 | 232 | if (changedTo('lifecycle', LIFECYCLE.INIT)) { 233 | delete this.beaconPopper; 234 | delete this.tooltipPopper; 235 | } 236 | } 237 | } 238 | 239 | componentWillUnmount() { 240 | const { disableCloseOnEsc } = this.props; 241 | 242 | /* istanbul ignore else */ 243 | if (!disableCloseOnEsc) { 244 | document.body.removeEventListener('keydown', this.handleKeyboard); 245 | } 246 | } 247 | 248 | scrollToStep(prevState) { 249 | const { index, lifecycle, status } = this.state; 250 | const { debug, disableScrolling, scrollToFirstStep, scrollOffset, steps } = this.props; 251 | const step = getMergedStep(steps[index], this.props); 252 | 253 | if (step) { 254 | const target = getElement(step.target); 255 | 256 | const shouldScroll = step 257 | && !disableScrolling 258 | && (!step.isFixed || !isFixed(target)) // fixed steps don't need to scroll 259 | && (prevState.lifecycle !== lifecycle && [LIFECYCLE.BEACON, LIFECYCLE.TOOLTIP].includes(lifecycle)) 260 | && (scrollToFirstStep || prevState.index !== index); 261 | 262 | if (status === STATUS.RUNNING && shouldScroll) { 263 | const hasCustomScroll = hasCustomScrollParent(target); 264 | const scrollParent = getScrollParent(target); 265 | let scrollY = Math.floor(getScrollTo(target, scrollOffset)); 266 | 267 | log({ 268 | title: 'scrollToStep', 269 | data: [ 270 | { key: 'index', value: index }, 271 | { key: 'lifecycle', value: lifecycle }, 272 | { key: 'status', value: status }, 273 | ], 274 | debug, 275 | }); 276 | 277 | if (lifecycle === LIFECYCLE.BEACON && this.beaconPopper) { 278 | const { placement, popper } = this.beaconPopper; 279 | 280 | if (!['bottom'].includes(placement) && !hasCustomScroll) { 281 | scrollY = Math.floor(popper.top - scrollOffset); 282 | } 283 | } 284 | else if (lifecycle === LIFECYCLE.TOOLTIP && this.tooltipPopper) { 285 | const { flipped, placement, popper } = this.tooltipPopper; 286 | 287 | if (['top', 'right'].includes(placement) && !flipped && !hasCustomScroll) { 288 | scrollY = Math.floor(popper.top - scrollOffset); 289 | } 290 | else { 291 | scrollY -= step.spotlightPadding; 292 | } 293 | } 294 | 295 | if (status === STATUS.RUNNING && shouldScroll && scrollY >= 0) { 296 | scrollTo(scrollY, scrollParent); 297 | } 298 | } 299 | } 300 | } 301 | 302 | /** 303 | * Trigger the callback. 304 | * 305 | * @private 306 | * @param {Object} data 307 | */ 308 | callback = (data) => { 309 | const { callback } = this.props; 310 | 311 | /* istanbul ignore else */ 312 | if (is.function(callback)) { 313 | callback(data); 314 | } 315 | }; 316 | 317 | /** 318 | * Keydown event listener 319 | * 320 | * @private 321 | * @param {Event} e - Keyboard event 322 | */ 323 | handleKeyboard = (e) => { 324 | const { index, lifecycle } = this.state; 325 | const { steps } = this.props; 326 | const step = steps[index]; 327 | const intKey = window.Event ? e.which : e.keyCode; 328 | 329 | if (lifecycle === LIFECYCLE.TOOLTIP) { 330 | if (intKey === 27 && (step && !step.disableCloseOnEsc)) { 331 | this.store.close(); 332 | } 333 | } 334 | }; 335 | 336 | /** 337 | * Sync the store with the component's state 338 | * 339 | * @param {Object} state 340 | */ 341 | syncState = (state) => { 342 | this.setState(state); 343 | }; 344 | 345 | getPopper = (popper, type) => { 346 | if (type === 'wrapper') { 347 | this.beaconPopper = popper; 348 | } 349 | else { 350 | this.tooltipPopper = popper; 351 | } 352 | }; 353 | 354 | render() { 355 | if (!canUseDOM) return null; 356 | 357 | const { index, status } = this.state; 358 | const { continuous, debug, disableScrolling, steps } = this.props; 359 | const step = getMergedStep(steps[index], this.props); 360 | let output; 361 | 362 | if (status === STATUS.RUNNING && step) { 363 | output = ( 364 | 375 | ); 376 | } 377 | 378 | return ( 379 |
380 | {output} 381 |
382 | ); 383 | } 384 | } 385 | 386 | export default Joyride; 387 | -------------------------------------------------------------------------------- /test/modules/store.spec.js: -------------------------------------------------------------------------------- 1 | import createStore from '../../src/modules/store'; 2 | 3 | import ACTIONS from '../../src/constants/actions'; 4 | import LIFECYCLE from '../../src/constants/lifecycle'; 5 | import STATUS from '../../src/constants/status'; 6 | 7 | import stepsData from '../__fixtures__/steps'; 8 | 9 | const mockSyncStore = jest.fn(); 10 | 11 | describe('store', () => { 12 | beforeEach(() => { 13 | mockSyncStore.mockClear(); 14 | }); 15 | 16 | describe('without initial values', () => { 17 | const store = createStore(); 18 | 19 | const { 20 | go, 21 | index, 22 | info, 23 | next, 24 | prev, 25 | reset, 26 | restart, 27 | start, 28 | steps, 29 | stop, 30 | update, 31 | } = store; 32 | 33 | it('should have initiated a new store', () => { 34 | expect(store.constructor.name).toBe('Store'); 35 | 36 | expect(info()).toEqual({ 37 | action: ACTIONS.INIT, 38 | controlled: false, 39 | index: 0, 40 | lifecycle: LIFECYCLE.INIT, 41 | size: 0, 42 | status: STATUS.IDLE, 43 | }); 44 | }); 45 | 46 | it('shouldn\'t be able to start without steps', () => { 47 | start(); 48 | 49 | expect(info()).toEqual({ 50 | action: ACTIONS.START, 51 | controlled: false, 52 | index: 0, 53 | lifecycle: LIFECYCLE.INIT, 54 | size: 0, 55 | status: STATUS.WAITING, 56 | }); 57 | }); 58 | 59 | it('should ignore all back/forward methods', () => { 60 | const initialStore = info(); 61 | 62 | next(); 63 | expect(info()).toEqual(initialStore); 64 | 65 | prev(); 66 | expect(info()).toEqual(initialStore); 67 | 68 | go(2); 69 | expect(info()).toEqual(initialStore); 70 | }); 71 | 72 | it('should be able to add steps', () => { 73 | steps(stepsData); 74 | 75 | expect(info()).toEqual({ 76 | action: ACTIONS.START, 77 | controlled: false, 78 | index: 0, 79 | lifecycle: LIFECYCLE.INIT, 80 | size: stepsData.length, 81 | status: STATUS.RUNNING, 82 | }); 83 | }); 84 | 85 | it('should be able to call prev but no changes [1st step]', () => { 86 | prev(); 87 | expect(info()).toEqual({ 88 | action: ACTIONS.PREV, 89 | controlled: false, 90 | index: 0, 91 | lifecycle: LIFECYCLE.INIT, 92 | size: stepsData.length, 93 | status: STATUS.RUNNING, 94 | }); 95 | }); 96 | 97 | it(`should be able to update lifecycle to ${LIFECYCLE.BEACON}`, () => { 98 | update({ lifecycle: LIFECYCLE.BEACON }); 99 | 100 | expect(info()).toEqual({ 101 | action: ACTIONS.UPDATE, 102 | controlled: false, 103 | index: 0, 104 | lifecycle: LIFECYCLE.BEACON, 105 | size: stepsData.length, 106 | status: STATUS.RUNNING, 107 | }); 108 | }); 109 | 110 | it(`should be able to update lifecycle to ${LIFECYCLE.TOOLTIP}`, () => { 111 | update({ lifecycle: LIFECYCLE.TOOLTIP }); 112 | 113 | expect(info()).toEqual({ 114 | action: ACTIONS.UPDATE, 115 | controlled: false, 116 | index: 0, 117 | lifecycle: LIFECYCLE.TOOLTIP, 118 | size: stepsData.length, 119 | status: STATUS.RUNNING, 120 | }); 121 | }); 122 | 123 | it('should be able to call next [2nd step]', () => { 124 | next(); 125 | expect(info()).toEqual({ 126 | action: ACTIONS.NEXT, 127 | controlled: false, 128 | index: 1, 129 | lifecycle: LIFECYCLE.INIT, 130 | size: stepsData.length, 131 | status: STATUS.RUNNING, 132 | }); 133 | }); 134 | 135 | it('should be able to call prev [1st step]', () => { 136 | prev(); 137 | expect(info()).toEqual({ 138 | action: ACTIONS.PREV, 139 | controlled: false, 140 | index: 0, 141 | lifecycle: LIFECYCLE.INIT, 142 | size: stepsData.length, 143 | status: STATUS.RUNNING, 144 | }); 145 | }); 146 | 147 | it('should be able to call stop', () => { 148 | stop(); 149 | expect(info()).toEqual({ 150 | action: ACTIONS.STOP, 151 | controlled: false, 152 | index: 0, 153 | lifecycle: LIFECYCLE.INIT, 154 | size: stepsData.length, 155 | status: STATUS.PAUSED, 156 | }); 157 | }); 158 | 159 | it('should be able to call start [1st step]', () => { 160 | start(); 161 | expect(info()).toEqual({ 162 | action: ACTIONS.START, 163 | controlled: false, 164 | index: 0, 165 | lifecycle: LIFECYCLE.INIT, 166 | size: stepsData.length, 167 | status: STATUS.RUNNING, 168 | }); 169 | }); 170 | 171 | it('should be able to call stop again but with `advance`', () => { 172 | stop(true); 173 | expect(info()).toEqual({ 174 | action: ACTIONS.STOP, 175 | controlled: false, 176 | index: 1, 177 | lifecycle: LIFECYCLE.INIT, 178 | size: stepsData.length, 179 | status: STATUS.PAUSED, 180 | }); 181 | }); 182 | 183 | 184 | it('should be able to call start [2nd step]', () => { 185 | start(); 186 | expect(info()).toEqual({ 187 | action: ACTIONS.START, 188 | controlled: false, 189 | index: 1, 190 | lifecycle: LIFECYCLE.INIT, 191 | size: stepsData.length, 192 | status: STATUS.RUNNING, 193 | }); 194 | }); 195 | 196 | it('should be able to call next [3rd step]', () => { 197 | next(); 198 | expect(info()).toEqual({ 199 | action: ACTIONS.NEXT, 200 | controlled: false, 201 | index: 2, 202 | lifecycle: LIFECYCLE.INIT, 203 | size: stepsData.length, 204 | status: STATUS.RUNNING, 205 | }); 206 | }); 207 | 208 | it('should be able to call next [4th step]', () => { 209 | next(); 210 | expect(info()).toEqual({ 211 | action: ACTIONS.NEXT, 212 | controlled: false, 213 | index: 3, 214 | lifecycle: LIFECYCLE.INIT, 215 | size: stepsData.length, 216 | status: STATUS.RUNNING, 217 | }); 218 | }); 219 | 220 | it('should be able to call next [5th step]', () => { 221 | next(); 222 | expect(info()).toEqual({ 223 | action: ACTIONS.NEXT, 224 | controlled: false, 225 | index: 4, 226 | lifecycle: LIFECYCLE.INIT, 227 | size: stepsData.length, 228 | status: STATUS.RUNNING, 229 | }); 230 | }); 231 | 232 | it(`should be able to update lifecycle to ${LIFECYCLE.BEACON}`, () => { 233 | update({ lifecycle: LIFECYCLE.BEACON }); 234 | 235 | expect(info()).toEqual({ 236 | action: ACTIONS.UPDATE, 237 | controlled: false, 238 | index: 4, 239 | lifecycle: LIFECYCLE.BEACON, 240 | size: stepsData.length, 241 | status: STATUS.RUNNING, 242 | }); 243 | }); 244 | 245 | it('should be able to call next again but the tour has finished', () => { 246 | next(); 247 | expect(info()).toEqual({ 248 | action: ACTIONS.NEXT, 249 | controlled: false, 250 | index: 5, 251 | lifecycle: LIFECYCLE.INIT, 252 | size: stepsData.length, 253 | status: STATUS.FINISHED, 254 | }); 255 | }); 256 | 257 | it('should be able to call next again but there\'s no change to the store', () => { 258 | next(); 259 | expect(info()).toEqual({ 260 | action: ACTIONS.NEXT, 261 | controlled: false, 262 | index: 5, 263 | lifecycle: LIFECYCLE.INIT, 264 | size: stepsData.length, 265 | status: STATUS.FINISHED, 266 | }); 267 | }); 268 | 269 | it('should be able to call restart', () => { 270 | restart(); 271 | 272 | expect(info()).toEqual({ 273 | action: ACTIONS.RESTART, 274 | controlled: false, 275 | index: 0, 276 | lifecycle: LIFECYCLE.INIT, 277 | size: stepsData.length, 278 | status: STATUS.RUNNING, 279 | }); 280 | }); 281 | 282 | it('should be able to call reset', () => { 283 | reset(); 284 | 285 | expect(info()).toEqual({ 286 | action: ACTIONS.RESET, 287 | controlled: false, 288 | index: 0, 289 | lifecycle: LIFECYCLE.INIT, 290 | size: stepsData.length, 291 | status: STATUS.READY, 292 | }); 293 | }); 294 | 295 | it('should be able to call start with custom index and lifecycle', () => { 296 | start(2); 297 | 298 | expect(info()).toEqual({ 299 | action: ACTIONS.START, 300 | controlled: false, 301 | index: 2, 302 | lifecycle: LIFECYCLE.INIT, 303 | size: stepsData.length, 304 | status: STATUS.RUNNING, 305 | }); 306 | }); 307 | 308 | it('should be able to call index [1st step]', () => { 309 | index(0); 310 | 311 | expect(info()).toEqual({ 312 | action: ACTIONS.INDEX, 313 | controlled: false, 314 | index: 0, 315 | lifecycle: LIFECYCLE.INIT, 316 | size: stepsData.length, 317 | status: STATUS.RUNNING, 318 | }); 319 | }); 320 | 321 | it('should be able to call go [2nd step]', () => { 322 | go(2); 323 | 324 | expect(info()).toEqual({ 325 | action: ACTIONS.GO, 326 | controlled: false, 327 | index: 2, 328 | lifecycle: LIFECYCLE.INIT, 329 | size: stepsData.length, 330 | status: STATUS.RUNNING, 331 | }); 332 | }); 333 | 334 | it('should be able to call go [3rd step]', () => { 335 | go(-1); 336 | 337 | expect(info()).toEqual({ 338 | action: ACTIONS.GO, 339 | controlled: false, 340 | index: 1, 341 | lifecycle: LIFECYCLE.INIT, 342 | size: stepsData.length, 343 | status: STATUS.RUNNING, 344 | }); 345 | }); 346 | 347 | it('should be able to call go with a big negative number [1st step]', () => { 348 | go(-10); 349 | 350 | expect(info()).toEqual({ 351 | action: ACTIONS.GO, 352 | controlled: false, 353 | index: 0, 354 | lifecycle: LIFECYCLE.INIT, 355 | size: stepsData.length, 356 | status: STATUS.RUNNING, 357 | }); 358 | }); 359 | 360 | it('should be able to call go with a big number and finish the tour', () => { 361 | go(10); 362 | 363 | expect(info()).toEqual({ 364 | action: ACTIONS.GO, 365 | controlled: false, 366 | index: 5, 367 | lifecycle: LIFECYCLE.INIT, 368 | size: stepsData.length, 369 | status: STATUS.FINISHED, 370 | }); 371 | }); 372 | }); 373 | 374 | describe('with initial steps', () => { 375 | const store = createStore({ steps: stepsData }); 376 | 377 | const { 378 | info, 379 | update, 380 | } = store; 381 | 382 | it('should have initiated a new store', () => { 383 | expect(store.constructor.name).toBe('Store'); 384 | 385 | expect(info()).toEqual({ 386 | action: ACTIONS.INIT, 387 | controlled: false, 388 | index: 0, 389 | lifecycle: LIFECYCLE.INIT, 390 | size: stepsData.length, 391 | status: STATUS.READY, 392 | }); 393 | }); 394 | 395 | it('should handle listeners', () => { 396 | store.addListener(mockSyncStore); 397 | 398 | update({ status: STATUS.READY }); 399 | update({ status: STATUS.READY }); 400 | expect(mockSyncStore).toHaveBeenCalledTimes(1); 401 | 402 | update({ status: STATUS.IDLE }); 403 | update({ status: STATUS.READY }); 404 | 405 | expect(mockSyncStore).toHaveBeenCalledTimes(3); 406 | }); 407 | }); 408 | 409 | describe('with controlled prop', () => { 410 | const store = createStore({ controlled: true }); 411 | 412 | const { 413 | update, 414 | } = store; 415 | 416 | it('should throw an error if try to update the `controlled` prop', () => { 417 | expect(() => update({ controlled: false })).toThrow(); 418 | }); 419 | }); 420 | }); 421 | --------------------------------------------------------------------------------