├── .gitignore ├── .gitlab-ci.yml ├── .storybook ├── main.js ├── preview.js └── webpack.config.js ├── README.md ├── TODO.md ├── __mocks__ └── fileMock.ts ├── __tests__ ├── minitooltip.test.tsx ├── tour.test.tsx └── tourProvider.test.tsx ├── build.prod.js ├── index.html ├── module.yaml ├── package-lock.json ├── package.json ├── setupTests.ts ├── src ├── @types │ └── globals.d.ts ├── example │ ├── app.less │ └── app.tsx └── lib │ ├── components │ ├── MultiStepFooter.tsx │ ├── footer │ │ ├── Footer.less │ │ └── Footer.tsx │ ├── highlight │ │ ├── Highlight.less │ │ └── Highlight.tsx │ ├── miniTooltip │ │ ├── MiniTooltip.less │ │ └── MiniTooltip.tsx │ ├── points │ │ ├── Points.less │ │ └── Points.tsx │ ├── tooltip │ │ ├── Tooltip.less │ │ ├── Tooltip.tsx │ │ └── TooltipHighlight.tsx │ ├── tourButton │ │ ├── TourButton.less │ │ └── TourButton.tsx │ └── variables.less │ ├── index.ts │ ├── miniTooltipStep │ └── MiniTooltipStep.tsx │ ├── modalStep │ └── ModalStep.tsx │ ├── step │ └── step.tsx │ ├── tooltipStep │ └── TooltipStep.tsx │ └── tour │ ├── Tour.tsx │ ├── TourProvider.tsx │ └── processMove.ts ├── stories └── tourStory.tsx ├── tsconfig.json ├── tsconfig.prod.json ├── tslint.json └── webpack.config.js /.gitignore: -------------------------------------------------------------------------------- 1 | manifest.json 2 | node_modules 3 | dist 4 | **.DS_Store 5 | **.less.d.ts 6 | .vscode/ 7 | 8 | .awcache 9 | .idea 10 | build 11 | 12 | # Crashlytics plugin (for Android Studio and IntelliJ) 13 | crashlytics.properties 14 | crashlytics-build.properties 15 | fabric.properties 16 | -------------------------------------------------------------------------------- /.gitlab-ci.yml: -------------------------------------------------------------------------------- 1 | # This file is a template, and might need editing before it works on your project. 2 | # Full project: https://gitlab.com/pages/plain-html 3 | pages: 4 | stage: deploy 5 | script: 6 | - npm i && npm run build-doc 7 | - mkdir .public 8 | - cp -r * .public 9 | - mv .public public 10 | artifacts: 11 | paths: 12 | - public 13 | only: 14 | - master 15 | -------------------------------------------------------------------------------- /.storybook/main.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | "stories": [ 3 | "../stories/**/*.tsx", 4 | ], 5 | "addons": [ 6 | "@storybook/addon-links", 7 | "@storybook/addon-essentials" 8 | ], 9 | "webpackFinal": async (config, { configType }) => { 10 | config.module.rules.push({ 11 | test: /\.less$/, 12 | use: [ 13 | require.resolve('style-loader'), 14 | { 15 | loader: require.resolve('css-loader'), 16 | options: { 17 | modules: true, 18 | importLoaders: 1, 19 | localIdentName: '[name]__[local]___[hash:base64:5]' 20 | }, 21 | }, 22 | require.resolve('less-loader') 23 | ] 24 | }); 25 | 26 | return config; 27 | }, 28 | } 29 | -------------------------------------------------------------------------------- /.storybook/preview.js: -------------------------------------------------------------------------------- 1 | 2 | export const parameters = { 3 | actions: { argTypesRegex: "^on[A-Z].*" }, 4 | } -------------------------------------------------------------------------------- /.storybook/webpack.config.js: -------------------------------------------------------------------------------- 1 | // you can use this file to add your custom webpack plugins, loaders and anything you like. 2 | // This is just the basic way to add additional webpack configurations. 3 | // For more information refer the docs: https://storybook.js.org/configurations/custom-webpack-config 4 | 5 | // IMPORTANT 6 | // When you add this file, we won't add the default configurations which is similar 7 | // to "React Create App". This only has babel loader to load JavaScript. 8 | const path = require('path'); 9 | 10 | module.exports = { 11 | plugins: [ 12 | // your custom plugins 13 | ], 14 | module: { 15 | rules: [ 16 | { 17 | test: /\.ts(x?)$/, 18 | loader: require.resolve('awesome-typescript-loader'), 19 | }, 20 | { 21 | test: /\.jsx?$/, 22 | loader: 'babel-loader', 23 | include: [ 24 | path.join(__dirname, 'src'), 25 | path.join(__dirname, '.storybook'), 26 | ], 27 | query: { 28 | presets: [ 29 | require.resolve('babel-preset-es2015'), 30 | require.resolve('babel-preset-stage-2'), 31 | require.resolve('babel-preset-react') 32 | ] 33 | }, 34 | }, 35 | { 36 | test: /\.css$/, 37 | loaders: ['style-loader', 'css-loader'], 38 | include: [ 39 | path.join(__dirname, '../src'), 40 | /react-ui/ 41 | ] 42 | }, 43 | { 44 | test: /\.less$/, 45 | loaders: ['style-loader', 'css-loader?sourceMap&localIdentName=[name]__[local]#[md5:hash:hex:4]', 'less-loader'], 46 | include: [ 47 | path.join(__dirname, '../src'), 48 | path.join(__dirname, '../stories/'), 49 | ] 50 | }, 51 | {test: /\.(woff|woff2|eot)$/, loader: "file-loader"}, 52 | {test: /\.(jpe?g|png|gif|svg)$/i, loader: "url-loader?limit=10000"}, 53 | {test: /\.json$/, loader: "json"} 54 | ], 55 | }, 56 | resolve: { 57 | extensions: ['.js', '.jsx', '.ts', '.tsx'] 58 | }, 59 | stats: { 60 | children: false 61 | } 62 | }; 63 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # React UI Tour 2 | 3 | Проект переехал в [gitlab](https://git.skbkontur.ru/ke/keweb.front.components/-/tree/master/packages/reactUiTour) 4 | -------------------------------------------------------------------------------- /TODO.md: -------------------------------------------------------------------------------- 1 | * prettier 2 | * stage lint 3 | * styleguidist 4 | * share tooltip components 5 | * extract arrow buttons to retail-ui 6 | * optional scroll to view 7 | * make interfaces more concise 8 | * detect window resize 9 | * devide doc and lib files 10 | -------------------------------------------------------------------------------- /__mocks__/fileMock.ts: -------------------------------------------------------------------------------- 1 | module.exports = 'test-file-stub'; 2 | -------------------------------------------------------------------------------- /__tests__/minitooltip.test.tsx: -------------------------------------------------------------------------------- 1 | import {mount} from "enzyme"; 2 | import React from "react"; 3 | import {MiniTooltip} from "../src/lib"; 4 | 5 | 6 | describe('miniTooltip', () => { 7 | let wrapper; 8 | let div; 9 | beforeAll(() => { 10 | wrapper = mount( 11 |
div = tag}> 12 | div}>F 13 |
14 | ) 15 | }) 16 | it("renders correctly", () => { 17 | expect(wrapper).toBeDefined(); 18 | } 19 | ) 20 | }); -------------------------------------------------------------------------------- /__tests__/tour.test.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import {mount} from 'enzyme'; 3 | import {TourProvider, Tour, Step} from '../src/lib'; 4 | import {processMove} from '../src/lib/tour/processMove' 5 | 6 | jest.useFakeTimers(); 7 | 8 | const createDelay = (ms) => (fn?) => { 9 | return new Promise(res => { 10 | setTimeout(() => {fn && fn(), res()}, ms) 11 | }) 12 | } 13 | 14 | const withStep = (id: string) => 15 | ({onNext, onPrev, onClose}) => ( 16 |
17 | 18 | 19 | 20 |
21 | ) 22 | 23 | const Step1 = withStep('id1'); 24 | const Step2 = withStep('id2'); 25 | const Step3 = withStep('id3'); 26 | 27 | describe('Tour. basic scenario', () => { 28 | let wrapper; 29 | beforeAll(() => { 30 | wrapper = mount( 31 | true} onTourShown={(id) => {}}> 32 | 33 | 34 | 35 | 36 | 37 | 38 | ) 39 | }) 40 | it('only first step will be rendered on start', () => { 41 | expect(wrapper.find('#id1').length).toBe(1) 42 | expect(wrapper.find('#id2').length).toBe(0) 43 | expect(wrapper.find('#id3').length).toBe(0) 44 | }) 45 | it('after next click only second tour is showing', () => { 46 | wrapper.find('.next').simulate('click') 47 | expect(wrapper.find('#id1').length).toBe(0) 48 | expect(wrapper.find('#id2').length).toBe(1) 49 | expect(wrapper.find('#id3').length).toBe(0) 50 | }) 51 | it('after prev click only first is rendering', () => { 52 | wrapper.find('.prev').simulate('click') 53 | expect(wrapper.find('#id1').length).toBe(1) 54 | expect(wrapper.find('#id2').length).toBe(0) 55 | expect(wrapper.find('#id3').length).toBe(0) 56 | }) 57 | it('after close nothing is showing', () => { 58 | wrapper.find('.close').simulate('click') 59 | expect(wrapper.find('#id1').length).toBe(0) 60 | expect(wrapper.find('#id2').length).toBe(0) 61 | expect(wrapper.find('#id3').length).toBe(0) 62 | }) 63 | }) 64 | 65 | describe('Tour. onBefore, onAfter, onOpen', () => { 66 | let wrapper; 67 | const onShown = jest.fn() 68 | const cbs = new Array(4).fill(0).map(() => jest.fn(() => Promise.resolve())); 69 | const [onBefore1, onAfter1, onBefore2, onAfter2] = cbs; 70 | const onOpen1 = jest.fn(); 71 | const onOpen2 = jest.fn(); 72 | const getCalls = (cbs) => cbs.map(cb => cb.mock.calls.length).join(' '); 73 | beforeAll(() => { 74 | wrapper = mount( 75 | true} onTourShown={(id) => onShown(id)}> 76 | 77 | 78 | 79 | 80 | 81 | ) 82 | }); 83 | 84 | it('only onBefore will be called on start', () => { 85 | expect(getCalls(cbs)).toBe('1 0 0 0'); 86 | }) 87 | it('onOpen in the first step will be called', () => { 88 | expect(onOpen1).toHaveBeenCalledTimes(1); 89 | expect(onOpen2).toHaveBeenCalledTimes(0); 90 | }) 91 | it('after next click onAfter should be called', () => { 92 | wrapper.update() 93 | wrapper.find('.next').simulate('click') 94 | expect(getCalls(cbs)).toBe('1 1 0 0'); 95 | }) 96 | it('onOpen in the second step will be called', () => { 97 | expect(onOpen2).toHaveBeenCalledTimes(1); 98 | }) 99 | it('onBefore should be called right after onAfter', () => { 100 | expect(getCalls(cbs)).toBe('1 1 1 0'); 101 | }) 102 | it('after prev click onAfter should be called', () => { 103 | wrapper.update(); 104 | wrapper.find('.prev').simulate('click') 105 | expect(getCalls(cbs)).toBe('1 1 1 1'); 106 | }) 107 | it('onBefore should be called right after onAfter', () => { 108 | expect(getCalls(cbs)).toBe('2 1 1 1'); 109 | }) 110 | it('onOpen in the first step will be called again', () => { 111 | expect(onOpen1).toHaveBeenCalledTimes(2); 112 | }) 113 | it('after close just onAfter will be called', () => { 114 | wrapper.update(); 115 | wrapper.find('.close').simulate('click') 116 | expect(onShown).not.toHaveBeenCalled(); 117 | expect(getCalls(cbs)).toBe('2 2 1 1'); 118 | }) 119 | it('onShown should be called right after onAfter', () => { 120 | expect(getCalls(cbs)).toBe('2 2 1 1'); 121 | expect(onShown).toHaveBeenCalledWith('someid') 122 | }) 123 | }) 124 | 125 | describe('Tour. fallback step in the middle', () => { 126 | let wrapper; 127 | beforeEach(() => { 128 | wrapper = mount( 129 | true} onTourShown={(id) => {}}> 130 | 131 | 132 | 133 | 134 | 135 | 136 | ) 137 | }) 138 | it('after next click fallback tour is skiping', () => { 139 | wrapper.find('.next').simulate('click') 140 | expect(wrapper.find('#id1').length).toBe(0) 141 | expect(wrapper.find('#id2').length).toBe(0) 142 | expect(wrapper.find('#id3').length).toBe(1) 143 | }) 144 | it('after next click on last step tour is closing', () => { 145 | wrapper.find('.next').simulate('click') 146 | wrapper.find('.next').simulate('click') 147 | expect(wrapper.find('#id1').length).toBe(0) 148 | expect(wrapper.find('#id2').length).toBe(0) 149 | expect(wrapper.find('#id3').length).toBe(0) 150 | }) 151 | // тест не проходит на macOS 152 | xit('after close only fallback step is showing', () => { 153 | wrapper.find('.close').simulate('click') 154 | expect(wrapper.find('#id1').length).toBe(0) 155 | expect(wrapper.find('#id2').length).toBe(1) 156 | expect(wrapper.find('#id3').length).toBe(0) 157 | }) 158 | }) 159 | 160 | describe('Tour. fallback step in the end', () => { 161 | let wrapper; 162 | beforeEach(() => { 163 | wrapper = mount( 164 | true} onTourShown={(id) => {}}> 165 | 166 | 167 | 168 | 169 | 170 | 171 | ) 172 | }) 173 | it('after next click tour is closing', () => { 174 | wrapper.find('.next').simulate('click') 175 | wrapper.find('.next').simulate('click') 176 | expect(wrapper.find('#id1').length).toBe(0) 177 | expect(wrapper.find('#id2').length).toBe(0) 178 | expect(wrapper.find('#id3').length).toBe(0) 179 | }) 180 | it('after close only fallback step is showing', () => { 181 | wrapper.find('.close').simulate('click') 182 | expect(wrapper.find('#id1').length).toBe(0) 183 | expect(wrapper.find('#id2').length).toBe(0) 184 | expect(wrapper.find('#id3').length).toBe(1) 185 | }) 186 | it('after close in last step tour is closing', () => { 187 | wrapper.find('.next').simulate('click') 188 | wrapper.find('.close').simulate('click') 189 | expect(wrapper.find('#id1').length).toBe(0) 190 | expect(wrapper.find('#id2').length).toBe(0) 191 | expect(wrapper.find('#id3').length).toBe(0) 192 | }) 193 | it('after close on fallback step nothing is showing', () => { 194 | wrapper.find('.close').simulate('click') 195 | wrapper.find('.close').simulate('click') 196 | expect(wrapper.find('#id1').length).toBe(0) 197 | expect(wrapper.find('#id2').length).toBe(0) 198 | expect(wrapper.find('#id3').length).toBe(0) 199 | }) 200 | }) 201 | 202 | describe('Tour. Api', () => { 203 | let wrapper; 204 | const subscribe = jest.fn((id, cb) => cb()); 205 | const unsubscribe = jest.fn(); 206 | const onShown = jest.fn(() => Promise.resolve()); 207 | beforeEach(() => { 208 | wrapper = mount( 209 | 210 | 211 | 212 | 213 | 214 | , { 215 | context: { 216 | [TourProvider.contextName]: { 217 | subscribe, 218 | unsubscribe, 219 | onShown, 220 | }, 221 | } 222 | }) 223 | }) 224 | it('subscribe will be called after render', () => { 225 | expect(subscribe.mock.calls.length).toBe(1); 226 | expect(onShown.mock.calls.length).toBe(0); 227 | }) 228 | it('onShown and unsubscribe called after close', () => { 229 | wrapper.find('.close').simulate('click'); 230 | expect(onShown.mock.calls.length).toBe(1); 231 | expect(unsubscribe.mock.calls.length).toBe(0); 232 | return Promise.resolve().then(() => { 233 | expect(unsubscribe.mock.calls.length).toBe(1); 234 | }); 235 | }) 236 | it('onShown should not called without manual closing', () => { 237 | wrapper.unmount(); 238 | expect(unsubscribe.mock.calls.length).toBe(2); 239 | expect(onShown.mock.calls.length).toBe(1); 240 | }) 241 | }) 242 | 243 | export class TourContainer extends React.Component { 244 | tour = null; 245 | 246 | render() { 247 | return ( 248 |
249 | true} onTourShown={() => {}}> 250 | this.tour = tour}> 251 | {this.props.children} 252 | 253 | 254 | 255 |
256 | ) 257 | } 258 | 259 | run = () => { 260 | this.tour.run(); 261 | } 262 | } 263 | 264 | describe('Tour. restart', () => { 265 | let wrapper; 266 | beforeAll(() => { 267 | wrapper = mount( 268 | 269 | 270 | 271 | 272 | ) 273 | }) 274 | it('tour should start again', () => { 275 | expect(wrapper.find('#id1').length).toBe(1) 276 | wrapper.find('.close').simulate('click') 277 | expect(wrapper.find('#id1').length).toBe(0) 278 | wrapper.find('.run').simulate('click') 279 | expect(wrapper.find('#id1').length).toBe(1) }) 280 | }) 281 | 282 | describe('processMove. delays', ()=> { 283 | const delay = createDelay(1000); 284 | const callbacks = new Array(4).fill(null).map( 285 | () => jest.fn() 286 | ); 287 | const [before1, before2, after1, after2] = callbacks; 288 | const move = jest.fn(); 289 | const clear = jest.fn(); 290 | const [onBefore1, onBefore2, onAfter1, onAfter2] = callbacks.map( 291 | cb => () => delay(cb) 292 | ) 293 | const step1 = 294 | const step2 = 295 | processMove(step1, step2, move, clear); 296 | it('nothing should be called except clear', () => { 297 | expect(callbacks.reduce((acc, fn) => acc + fn.mock.calls.length, 0)).toBe(0) 298 | expect(clear).toBeCalled(); 299 | }) 300 | it('after first delay onafter callback should be called', () => { 301 | jest.runOnlyPendingTimers(); 302 | expect(callbacks.reduce((acc, fn) => acc + fn.mock.calls.length, 0)).toBe(1) 303 | expect(after1).toBeCalled(); 304 | }) 305 | it('after second delay onbefore callback should be called', () => { 306 | jest.runOnlyPendingTimers(); 307 | expect(callbacks.reduce((acc, fn) => acc + fn.mock.calls.length, 0)).toBe(2) 308 | expect(before2).toBeCalled(); 309 | }) 310 | it('after all should be called move fn', () => { 311 | expect(move).toBeCalled(); 312 | }) 313 | }) 314 | 315 | describe('processMove. group', () => { 316 | let stepProps1; 317 | let stepProps2; 318 | let move; 319 | let clear; 320 | beforeEach(() => { 321 | const callbacks = new Array(4).fill(null).map( 322 | () => jest.fn(() => Promise.resolve()) 323 | ); 324 | const [onBefore1, onAfter1, onBefore2, onAfter2] = callbacks 325 | move = jest.fn() 326 | clear = jest.fn() 327 | stepProps1 = { 328 | onBefore: onBefore1, 329 | onAfter: onAfter1, 330 | render: Step1 331 | } 332 | stepProps2 = { 333 | onBefore: onBefore2, 334 | onAfter: onAfter2, 335 | render: Step2 336 | } 337 | }) 338 | it('onBefore & onAfter should not be called in same group',() => { 339 | const step1 = 340 | const step2 = 341 | 342 | return processMove(step1, step2, move, clear).then(() => { 343 | expect(move).toBeCalled(); 344 | expect(stepProps1.onAfter).not.toBeCalled() 345 | expect(stepProps2.onBefore).not.toBeCalled() 346 | }) 347 | }) 348 | 349 | it('onBefore & onAfter should be called in different groups',() => { 350 | const step1 = 351 | const step2 = 352 | 353 | return processMove(step1, step2, move, clear).then(() => { 354 | expect(move).toBeCalled(); 355 | expect(stepProps1.onAfter).toBeCalled() 356 | expect(stepProps2.onBefore).toBeCalled() 357 | }) 358 | }) 359 | 360 | it('onBefore & onAfter should be called in different groups',() => { 361 | const step1 = 362 | const step2 = 363 | 364 | return processMove(step1, step2, move, clear).then(() => { 365 | expect(move).toBeCalled(); 366 | expect(stepProps1.onAfter).toBeCalled() 367 | expect(stepProps2.onBefore).toBeCalled() 368 | return processMove(step2, step1, move, clear); 369 | }).then(() => { 370 | expect(move).toHaveBeenCalledTimes(2) 371 | expect(stepProps2.onAfter).toBeCalled() 372 | expect(stepProps1.onBefore).toBeCalled(); 373 | }) 374 | }) 375 | }) 376 | -------------------------------------------------------------------------------- /__tests__/tourProvider.test.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { shallow } from "enzyme"; 3 | import { TourProvider } from "../src/lib"; 4 | 5 | 6 | describe("test tour main logic", () => { 7 | const tourIds = { 8 | first: "id1", 9 | second: "id2", 10 | third: "id3", 11 | }; 12 | let providerWrapper, predicateFunc, onTourShownFunc; 13 | 14 | beforeEach(() => { 15 | predicateFunc = jest.fn((id) => id !== tourIds.second); 16 | onTourShownFunc = jest.fn((id) => id); 17 | 18 | providerWrapper = shallow( 19 | 20 |
21 | 22 | ); 23 | }); 24 | 25 | it("provider's onTourShown was called when tour was closed", () => { 26 | const providerInstance = providerWrapper.instance(); 27 | expect(onTourShownFunc).toHaveBeenCalledTimes(0); 28 | providerInstance.onShown(tourIds.first); 29 | return Promise.resolve().then(() => { 30 | expect(onTourShownFunc).lastCalledWith(tourIds.first); 31 | expect(onTourShownFunc).toHaveBeenCalledTimes(1); 32 | }); 33 | }); 34 | 35 | it("provider's onTourShown wasn't called when tour was unsubsribed", () => { 36 | const providerInstance = providerWrapper.instance(); 37 | providerInstance.unsubscribe(tourIds.first); 38 | expect(onTourShownFunc).toHaveBeenCalledTimes(0); 39 | }); 40 | 41 | it("subscription callback was called", () => { 42 | const providerInstance = providerWrapper.instance(); 43 | const subscribeClb = jest.fn(); 44 | providerInstance.subscribe(tourIds.first, subscribeClb); 45 | expect(predicateFunc).toHaveBeenCalledWith(tourIds.first); 46 | expect(subscribeClb).toHaveBeenCalledTimes(1); 47 | }); 48 | 49 | it("subscription callback wasn't called", () => { 50 | const providerInstance = providerWrapper.instance(); 51 | const subscribeClb = jest.fn(); 52 | providerInstance.subscribe(tourIds.second, subscribeClb); 53 | expect(predicateFunc).toHaveBeenCalledWith(tourIds.second); 54 | expect(subscribeClb).toHaveBeenCalledTimes(0); 55 | }); 56 | 57 | it("callbacks in providers's queue were called in right order", () => { 58 | const providerInstance = providerWrapper.instance(); 59 | const subscribeClbFirst = jest.fn(); 60 | const subscribeClbThird = jest.fn(); 61 | providerInstance.subscribe(tourIds.first, subscribeClbFirst); 62 | providerInstance.subscribe(tourIds.third, subscribeClbThird); 63 | expect(subscribeClbFirst).toHaveBeenCalledTimes(1); 64 | expect(subscribeClbThird).toHaveBeenCalledTimes(0); 65 | providerInstance.unsubscribe(tourIds.first); 66 | expect(subscribeClbThird).toHaveBeenCalledTimes(1); 67 | }); 68 | }); 69 | -------------------------------------------------------------------------------- /build.prod.js: -------------------------------------------------------------------------------- 1 | var fs = require('fs'); 2 | var cpx = require('cpx'); 3 | var path = require('path'); 4 | var exec = require('child_process').exec; 5 | 6 | var source = path.join(__dirname, './src/lib/**/*.less'); 7 | var destination = path.join(__dirname, './build'); 8 | var options = {clean: true}; 9 | 10 | (function deleteFolderRecursive (path) { 11 | if (fs.existsSync(path)) { 12 | fs.readdirSync(path).forEach(function (file, index) { 13 | var curPath = path + '/' + file; 14 | if (fs.lstatSync(curPath).isDirectory()) { 15 | deleteFolderRecursive(curPath); 16 | } else { 17 | fs.unlinkSync(curPath); 18 | } 19 | }) 20 | fs.rmdirSync(path); 21 | } 22 | })(destination); 23 | 24 | cpx.copySync(source, destination, options, function (err) { 25 | if (err) { 26 | console.error(err); 27 | throw err; 28 | } 29 | }); 30 | 31 | var tscProcess = exec('tsc -p tsconfig.prod.json', function (error, stdout, stderr) { 32 | if (error !== null) { 33 | console.error(error); 34 | throw error; 35 | } 36 | }); 37 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | React UI Tour demo page 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /module.yaml: -------------------------------------------------------------------------------- 1 | full-build: 2 | deps: 3 | 4 | build: 5 | tool: npm 6 | target: run cmbuild 7 | configuration: Release 8 | 9 | artifacts: 10 | - build\index.js 11 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-ui-tour", 3 | "version": "1.3.0", 4 | "description": "react tours library", 5 | "repository": "https://github.com/skbkontur/react-ui-tour.git", 6 | "main": "./build/index.js", 7 | "types": "./build/index.d.ts", 8 | "scripts": { 9 | "start": "webpack serve --mode=development", 10 | "build": "node build.prod.js", 11 | "build-doc": "webpack --mode=production", 12 | "publish-doc": "rimraf dist && git clone -b gh-pages git@github.com:skbkontur/react-ui-tour.git dist && npm run build-doc && cd dist && git add --all && git commit --allow-empty -m \"published doc\" && git push && cd ..", 13 | "cmbuild": "npm i && npm run build", 14 | "storybook": "start-storybook -p 6006 --no-dll", 15 | "prepublishOnly": "npm run test && npm run build", 16 | "test": "jest", 17 | "test:watch": "jest --watch", 18 | "test:coverage": "jest --coverage", 19 | "build-storybook": "build-storybook --no-dll" 20 | }, 21 | "peerDependencies": { 22 | "prop-types": "^15.6.2", 23 | "@skbkontur/react-ui": ">=2 <4", 24 | "react": ">=16.9 <17", 25 | "react-dom": ">=16.9 <17" 26 | }, 27 | "devDependencies": { 28 | "@babel/core": "^7.12.3", 29 | "@babel/preset-env": "^7.12.1", 30 | "@extern/tslint": "^1.2.0", 31 | "@skbkontur/react-ui": "^2.17.3", 32 | "@storybook/addon-actions": "^6.0.28", 33 | "@storybook/addon-essentials": "^6.0.28", 34 | "@storybook/addon-links": "^6.0.28", 35 | "@storybook/react": "^6.0.28", 36 | "@types/enzyme": "^3.1.6", 37 | "@types/enzyme-adapter-react-16": "^1.0.6", 38 | "@types/jest": "^21.1.8", 39 | "@types/node": "^14.14.5", 40 | "@types/prop-types": "^15.7.3", 41 | "@types/react": "^16.9.53", 42 | "@types/react-dom": "^0.14.23", 43 | "@webpack-cli/serve": "^1.0.1", 44 | "awesome-typescript-loader": "^5.2.1", 45 | "babel-loader": "^8.1.0", 46 | "babel-preset-es2015": "^6.24.1", 47 | "babel-preset-react": "^6.24.1", 48 | "babel-preset-stage-2": "^6.24.1", 49 | "classnames": "^2.2.6", 50 | "core-js": "^3.6.5", 51 | "cpx": "^1.5.0", 52 | "css-loader": "^0.28.11", 53 | "enzyme": "^3.11.0", 54 | "enzyme-adapter-react-16": "^1.15.5", 55 | "es3ify-webpack-plugin": "^0.1.0", 56 | "extract-text-webpack-plugin": "^4.0.0-beta.0", 57 | "file-loader": "^6.1.1", 58 | "html-webpack-plugin": "^4.5.0", 59 | "identity-obj-proxy": "^3.0.0", 60 | "jest": "^26.6.1", 61 | "jest-cli": "^26.6.1", 62 | "jest-css-modules-transform": "^1.0.3", 63 | "less": "^2.7.2", 64 | "less-loader": "^7.0.2", 65 | "mini-css-extract-plugin": "^1.2.0", 66 | "react": "^16.14.0", 67 | "react-addons-css-transition-group": "^0.14.8", 68 | "react-addons-test-utils": "^15.6.2", 69 | "react-dom": "^16.0.0", 70 | "react-is": "^17.0.1", 71 | "react-scripts": "^4.0.0", 72 | "request": "^2.88.2", 73 | "style-loader": "^2.0.0", 74 | "ts-jest": "^26.4.2", 75 | "ts-loader": "^8.0.7", 76 | "tslib": "^2.0.3", 77 | "tslint": "^6.1.3", 78 | "typescript": "^4.0.3", 79 | "typings-for-css-modules-loader": "^1.7.0", 80 | "webpack": "^4.44.2", 81 | "webpack-cleanup-plugin": "^0.5.1", 82 | "webpack-cli": "^4.1.0", 83 | "webpack-dev-server": "^3.11.0" 84 | }, 85 | "keywords": [], 86 | "author": "Vladimir Tolstikov", 87 | "license": "ISC", 88 | "files": [ 89 | "build/" 90 | ], 91 | "jest": { 92 | "setupFiles": [ 93 | "./setupTests.ts" 94 | ], 95 | "transform": { 96 | ".(ts|tsx)": "ts-jest", 97 | ".+\\.(css|less)$": "/node_modules/jest-css-modules-transform" 98 | }, 99 | "testRegex": "(/__tests__/.*|\\.(test|spec))\\.(ts|tsx|js)$", 100 | "moduleFileExtensions": [ 101 | "ts", 102 | "tsx", 103 | "js", 104 | "jsx" 105 | ], 106 | "moduleNameMapper": { 107 | "\\.(css|less)$": "identity-obj-proxy", 108 | "\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$": "/__mocks__/fileMock.ts" 109 | }, 110 | "testURL": "http://localhost/" 111 | }, 112 | "dependencies": { 113 | "raf": "^3.4.0" 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /setupTests.ts: -------------------------------------------------------------------------------- 1 | import {configure} from 'enzyme'; 2 | import Adapter from 'enzyme-adapter-react-16'; 3 | 4 | configure({ adapter: new Adapter() }); 5 | -------------------------------------------------------------------------------- /src/@types/globals.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.less' { 2 | const content: {[className: string]: string}; 3 | export default content; 4 | } 5 | -------------------------------------------------------------------------------- /src/example/app.less: -------------------------------------------------------------------------------- 1 | html, 2 | body { 3 | height: 100%; 4 | } 5 | body { 6 | margin: 0; 7 | font: 14px 'Segoe UI', Helvetica, Arial, sans-serif; 8 | color: #333; 9 | line-height: 1.42; 10 | overflow: hidden; 11 | } 12 | 13 | .container { 14 | position: absolute; 15 | top: 50px; 16 | bottom: 50px; 17 | left: 50px; 18 | right: 50px; 19 | } 20 | 21 | .demo { 22 | position: absolute; 23 | border-radius: 5px; 24 | border: 1px solid #ddd; 25 | padding: 10px 20px; 26 | font-size: 22px; 27 | line-height: 70px; 28 | height: 70px; 29 | width: 120px; 30 | text-align: center; 31 | } 32 | 33 | .demo1 { 34 | .demo(); 35 | left: 0; 36 | top: 0; 37 | } 38 | 39 | .demo2 { 40 | .demo(); 41 | right: -200px; 42 | top: 0; 43 | bottom: 0; 44 | margin: auto; 45 | transition: 0.5s; 46 | &.shown { 47 | right: 0; 48 | } 49 | } 50 | 51 | .demo3 { 52 | .demo(); 53 | left: -200px; 54 | bottom: 0; 55 | transition: 0.5s; 56 | &.shown { 57 | left: 0; 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/example/app.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import {render} from 'react-dom'; 3 | import classnames from 'classnames'; 4 | import { 5 | Tour, 6 | TourComponent, 7 | TourProvider, 8 | ModalStep, 9 | Tooltip, 10 | TooltipStep, 11 | TooltipHighlight, 12 | MiniTooltipStep, MiniTooltip 13 | } from '../lib'; 14 | import styles from './app.less'; 15 | 16 | 17 | const reactContainer = document.createElement('div'); 18 | document.body.appendChild(reactContainer); 19 | 20 | const CustomStep = ({onNext, onPrev, onClose}) => ( 21 |
22 |

custom markup

23 | 24 | 25 | X 26 |
27 | ); 28 | 29 | const defaultContent = ( 30 |
31 | 32 | Все новые требования будут появляться на вкладке «в работе» 33 | в таблице сверху. Записывайте в таблицу номер документа 34 | и имя, ответственного. Это поможет отслеживать, какое требование 35 | в работе и кто им занимается. 36 | 37 |
38 | ); 39 | 40 | const customHighlight =
; 41 | 42 | const demo1 = () => document.querySelector('[data-tour-id=demo1]'); 43 | const demo2 = () => document.querySelector('[data-tour-id=demo2]'); 44 | const demo3 = () => document.querySelector('[data-tour-id=demo3]'); 45 | 46 | class App extends React.Component<{}, {}> { 47 | state = { 48 | demo2isShown: false, 49 | demo3isShown: false, 50 | tooltipOpened: true, 51 | unconnectedTourOpened: false, 52 | miniTooltipOpened: true, 53 | miniTourOpened: true, 54 | }; 55 | 56 | componentDidMount() { 57 | this.setState({tooltipOpened: true}); 58 | } 59 | 60 | render() { 61 | const demo2Class = classnames(styles.demo2, { 62 | [styles.shown]: this.state.demo2isShown 63 | }); 64 | const demo3Class = classnames(styles.demo3, { 65 | [styles.shown]: this.state.demo3isShown 66 | }); 67 | return ( 68 |
69 | {this.state.tooltipOpened && ( 70 | 71 | this.setState({tooltipOpened: false})} 78 | > 79 | 80 | Тултип 81 | 82 |
Это обычный тултип, не зависящий от провайдера
83 |
84 |
85 |
86 |
87 | )} 88 | {this.state.miniTooltipOpened && ( 89 | this.setState({miniTooltipOpened: false})} 93 | >Это обычный минитултип, не зависящий от провайдера 94 | 95 | )} 96 | 97 | true} 99 | onTourShown={id => console.log(`shown tour ${id}`)} 100 | > 101 |
102 |
103 | Hi, there! 104 |
105 |
106 | Hi, there! 107 |
108 |
109 | Hi, there! 110 |
111 | 112 | 113 | new Promise(res => setTimeout(res, 3000))} 117 | onAfter={() => new Promise(res => res())} 118 | onOpen={() => { 119 | console.log('Это приветственный шаг, onOpen'); 120 | this.setState({tooltipOpened: false}); 121 | }} 122 | /> 123 | 131 | 140 | new Promise(res => { 141 | this.setState({demo2isShown: true}); 142 | setTimeout(res, 500); 143 | }) 144 | } 145 | onAfter={() => 146 | new Promise(res => 147 | this.setState({demo2isShown: false}, res) 148 | ) 149 | } 150 | /> 151 | 159 | new Promise(res => { 160 | this.setState({demo3isShown: true}); 161 | setTimeout(res, 500); 162 | }) 163 | } 164 | onAfter={() => 165 | new Promise(res => 166 | this.setState({demo3isShown: false}, res) 167 | ) 168 | } 169 | offset={30} 170 | /> 171 | 172 | 173 | this.setState({unconnectedTourOpened: true})} 176 | > 177 | 181 | console.log('Это второй шаг, onOpen')} 190 | onBefore={() => 191 | new Promise(res => { 192 | console.log('Это второй шаг, onBefore'); 193 | this.setState({demo2isShown: true}); 194 | setTimeout(res, 500); 195 | }) 196 | } 197 | onAfter={() => 198 | new Promise(res => { 199 | console.log('Это второй шаг, onAfter'); 200 | this.setState({demo2isShown: false}, res); 201 | }) 202 | } 203 | /> 204 | 213 | new Promise(res => { 214 | console.log('Это третий шаг, onBefore'); 215 | this.setState({demo2isShown: true}); 216 | setTimeout(res, 500); 217 | }) 218 | } 219 | onAfter={() => 220 | new Promise(res => { 221 | console.log('Это третий шаг, onAfter'); 222 | this.setState({demo2isShown: false}, res); 223 | }) 224 | } 225 | /> 226 | 234 | new Promise(res => { 235 | this.setState({demo3isShown: true}); 236 | setTimeout(res, 500); 237 | }) 238 | } 239 | onAfter={() => 240 | new Promise(res => { 241 | this.setState({demo3isShown: false}, res); 242 | }) 243 | } 244 | /> 245 | 246 |
247 |
248 | 249 | {this.state.unconnectedTourOpened && ( 250 | this.setState({unconnectedTourOpened: false})}> 251 | 259 | 268 | new Promise(res => { 269 | this.setState({demo2isShown: true}); 270 | setTimeout(res, 500); 271 | }) 272 | } 273 | onAfter={() => 274 | new Promise(res => this.setState({demo2isShown: false}, res)) 275 | } 276 | /> 277 | 278 | )} 279 | 280 | {this.state.miniTourOpened && 281 | this.setState({miniTourOpened: false})}> 282 | 283 | Это минитур, который должен указывать на изменение в интерфейсе 284 | в тот момент, когда новая функция может помочь пользователю 285 | 286 | 287 | } 288 |
289 | ); 290 | } 291 | } 292 | 293 | render(, reactContainer); 294 | -------------------------------------------------------------------------------- /src/lib/components/MultiStepFooter.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { Points } from './points/Points'; 3 | import { TourButton } from './tourButton/TourButton'; 4 | import { Footer } from './footer/Footer'; 5 | 6 | export interface MultiStepFooterProps { 7 | points: number; 8 | activePoint: number; 9 | prevButtonText?: string; 10 | nextButtonText?: string; 11 | onNext?: () => void; 12 | onPrev?: () => void; 13 | } 14 | 15 | export function MultiStepFooter(props: MultiStepFooterProps) { 16 | if (!props.points) { 17 | return null; 18 | } 19 | 20 | const renderNextButton = (innerText?: string, needArrow: boolean = true) => { 21 | return ( 22 | 27 | {props.nextButtonText || innerText || 'Далее'} 28 | 29 | ); 30 | }; 31 | 32 | const renderPrevButton = (innerText?: string, needArrow: boolean = true) => { 33 | return ( 34 | 39 | {props.prevButtonText || innerText || 'Назад'} 40 | 41 | ); 42 | }; 43 | 44 | const points = ( 45 | 46 | ); 47 | 48 | let leftPartContent; 49 | let centerPartContent = points; 50 | let rightPartContent; 51 | if (props.points === 1) { 52 | centerPartContent = null; 53 | leftPartContent = renderNextButton('Приступить', false); 54 | } else if (props.activePoint === 1) { 55 | rightPartContent = renderNextButton(); 56 | } else if (props.activePoint === props.points) { 57 | leftPartContent = renderPrevButton(); 58 | rightPartContent = renderNextButton('Приступить', false); 59 | } else if (props.activePoint > props.points) { 60 | leftPartContent = renderPrevButton(); 61 | centerPartContent = null; 62 | rightPartContent = renderNextButton('Приступить', false); 63 | } else { 64 | leftPartContent = renderPrevButton(); 65 | rightPartContent = renderNextButton(); 66 | } 67 | 68 | return ( 69 |
70 | {leftPartContent} 71 | {centerPartContent} 72 | {rightPartContent} 73 |
74 | ); 75 | } 76 | -------------------------------------------------------------------------------- /src/lib/components/footer/Footer.less: -------------------------------------------------------------------------------- 1 | :local { 2 | .footer { 3 | position: relative; 4 | text-align: center; 5 | min-height: 42px; 6 | vertical-align: middle; 7 | } 8 | 9 | .footerLeftPart { 10 | display: inline-block; 11 | position: absolute; 12 | left: 0; 13 | } 14 | 15 | .footerCenterPart { 16 | display: inline-block; 17 | } 18 | 19 | .footerRightPart { 20 | display: inline-block; 21 | position: absolute; 22 | right: 0; 23 | } 24 | 25 | .footerImage { 26 | margin-top: 20px; 27 | vertical-align: bottom; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/lib/components/footer/Footer.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import styles from './Footer.less'; 3 | 4 | export interface FooterProps { 5 | children?: React.ReactNode; 6 | style?: React.CSSProperties; 7 | } 8 | 9 | export class Footer extends React.Component { 10 | static LeftPart = FooterLeftPart; 11 | static CenterPart = FooterCenterPart; 12 | static RightPart = FooterRightPart; 13 | static Image = FooterImage; 14 | 15 | render () { 16 | return ( 17 |
18 | {this.props.children} 19 |
20 | ); 21 | } 22 | } 23 | 24 | export interface FooterPartProps { 25 | children?: React.ReactNode; 26 | } 27 | 28 | export function FooterLeftPart({ children }: FooterPartProps) { 29 | return
{children}
; 30 | } 31 | 32 | export function FooterCenterPart({ children }: FooterPartProps) { 33 | return
{children}
; 34 | } 35 | 36 | export function FooterRightPart({ children }: FooterPartProps) { 37 | return
{children}
; 38 | } 39 | 40 | export interface FooterImageProps { 41 | url: string; 42 | style?: React.CSSProperties; 43 | } 44 | 45 | export function FooterImage({ url, style }: FooterImageProps) { 46 | return ( 47 |
48 | 49 |
50 | ); 51 | } 52 | -------------------------------------------------------------------------------- /src/lib/components/highlight/Highlight.less: -------------------------------------------------------------------------------- 1 | :local { 2 | .wrapper { 3 | position: absolute; 4 | top: 0; 5 | left: 0; 6 | border-style: solid; 7 | border-color: transparent; 8 | box-sizing: content-box; 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/lib/components/highlight/Highlight.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { ZIndex } from "@skbkontur/react-ui/internal/ZIndex"; 3 | import styles from './Highlight.less'; 4 | 5 | export interface HighlightProps { 6 | pos: ClientRect; 7 | highlight: React.ReactElement; 8 | color?: string; 9 | } 10 | 11 | export function Highlight(props: HighlightProps) { 12 | const { pos, highlight, color } = props; 13 | 14 | const highlightRoot = React.cloneElement(highlight, { 15 | ...highlight.props, 16 | style: { 17 | ...highlight.props.style, 18 | position: 'absolute', 19 | top: 0, 20 | left: 0, 21 | bottom: 0, 22 | right: 0 23 | } 24 | }); 25 | 26 | const width = pos.right - pos.left; 27 | const height = pos.bottom - pos.top; 28 | const borderTopWidth = pos.top + document.documentElement.scrollTop; 29 | const borderLeftWidth = pos.left + document.documentElement.scrollLeft; 30 | const computedStyles: React.CSSProperties = { 31 | borderColor: color, 32 | borderTopWidth, 33 | borderLeftWidth, 34 | borderRightWidth: document.documentElement.offsetWidth - (borderLeftWidth + width), 35 | borderBottomWidth: document.documentElement.offsetHeight - (borderTopWidth + height), 36 | width: width, 37 | height: height 38 | }; 39 | 40 | return ( 41 | 42 | {highlightRoot} 43 | 44 | ); 45 | } 46 | -------------------------------------------------------------------------------- /src/lib/components/miniTooltip/MiniTooltip.less: -------------------------------------------------------------------------------- 1 | :local { 2 | .body { 3 | box-sizing: border-box; 4 | padding: 16px; 5 | max-width: 400px; 6 | min-width: 256px; 7 | font-weight: 600; 8 | } 9 | 10 | .cross { 11 | color: rgba(255, 255, 255, 0.374); 12 | cursor: pointer; 13 | line-height: 0; 14 | padding: 8px; 15 | position: absolute; 16 | right: 0; 17 | top: 0; 18 | width: 8px; 19 | height: 8px; 20 | box-sizing: content-box; 21 | 22 | &:hover { 23 | color: white; 24 | } 25 | } 26 | } -------------------------------------------------------------------------------- /src/lib/components/miniTooltip/MiniTooltip.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { Popup } from "@skbkontur/react-ui/internal/Popup"; 3 | import { RenderLayer } from "@skbkontur/react-ui/internal/RenderLayer"; 4 | import { ThemeProvider, ThemeFactory } from "@skbkontur/react-ui"; 5 | import {containsTargetOrRenderContainer} from "@skbkontur/react-ui/lib/listenFocusOutside"; 6 | import styles from "./MiniTooltip.less" 7 | 8 | export type PopupPosition = 9 | | 'top left' 10 | | 'top center' 11 | | 'top right' 12 | | 'right top' 13 | | 'right middle' 14 | | 'right bottom' 15 | | 'bottom left' 16 | | 'bottom center' 17 | | 'bottom right' 18 | | 'left top' 19 | | 'left middle' 20 | | 'left bottom'; 21 | 22 | const CrossIcon = () => ( 23 | 30 | 34 | 35 | ); 36 | 37 | export interface MiniTooltipProps { 38 | target: () => Element; 39 | triggers?: (() => Element)[]; 40 | positions: PopupPosition[]; 41 | onClose?: () => void; 42 | width?: string, 43 | onTargetClicked?: () => void; 44 | children: JSX.Element | string; 45 | } 46 | 47 | const MiniTooltipTheme = ThemeFactory.create({ 48 | bgDefault: "#0697FF", 49 | popupBorderRadius: "8px", 50 | popupTextColor: "white", 51 | popupDropShadow: 52 | "drop-shadow(rgba(6, 151, 255, 0.2) 2px 8px 16px) drop-shadow(rgba(6, 151, 255, 0.2) 0px -2px 4px)", 53 | tooltipCloseBtnColor: "rgba(255, 255, 255, 0.374)", 54 | tooltipCloseBtnHoverColor: "white", 55 | }); 56 | 57 | export class MiniTooltip extends React.Component { 58 | state = {hasElem: true} 59 | static defaultProps = { 60 | positions: ["bottom middle"], 61 | onClose: () => { 62 | }, 63 | } 64 | 65 | componentWillMount() { 66 | if (!this.props.target()) { 67 | this.setState({hasElem: false}) 68 | } 69 | } 70 | 71 | componentDidMount() { 72 | if (!this.state.hasElem) { 73 | this.props.onTargetClicked && this.props.onTargetClicked(); 74 | } 75 | } 76 | 77 | onCloseButtonClick(e: React.MouseEvent) { 78 | e.stopPropagation(); 79 | this.props.onClose(); 80 | } 81 | 82 | render() { 83 | const anchor = this.props.target(); 84 | if (!anchor) return ; 85 | 86 | let elementsToClick = []; 87 | if(!(this.props.triggers instanceof Promise)) { 88 | // Using this.props.target for back compatibility 89 | elementsToClick.push(anchor); 90 | if (this.props.triggers) { 91 | elementsToClick.concat(this.props.triggers.map(g => g())); 92 | } 93 | } 94 | 95 | function isClickOnTarget(event: Event) { 96 | if (!(event.target instanceof Element)) 97 | return false; 98 | const elementClicked = containsTargetOrRenderContainer(event.target); 99 | return elementsToClick.some(e => elementClicked(e)); 100 | } 101 | 102 | return ( 103 | 104 | isClickOnTarget(e) && this.props.onTargetClicked()} 106 | active 107 | > 108 | 122 |
123 | {this.props.children} 124 |
this.onCloseButtonClick(e)}> 125 | 126 |
127 |
128 |
129 |
130 |
131 | ); 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /src/lib/components/points/Points.less: -------------------------------------------------------------------------------- 1 | @import '../variables'; 2 | 3 | :local { 4 | .tooltipPoint { 5 | display: inline-block; 6 | padding-right: 4px; 7 | width: 8px; 8 | box-sizing: content-box; 9 | } 10 | 11 | .tooltipPointActive { 12 | position: relative; 13 | top: 1px; 14 | width: 10px !important; 15 | box-sizing: content-box; 16 | } 17 | 18 | .tooltipPoint:before { 19 | content: '\25cf'; 20 | font-size: 16px; 21 | color: #d7d7d7; 22 | } 23 | 24 | .tooltipPointActive:before { 25 | color: @point-bg-active; 26 | font-size: 20px; 27 | } 28 | 29 | .tooltipSelector { 30 | display: inline-block; 31 | position: relative; 32 | top: 6px; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/lib/components/points/Points.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import styles from './Points.less'; 3 | 4 | export interface PointsProps { 5 | count: number; 6 | activePointIndex: number; 7 | } 8 | 9 | export function Points(props: PointsProps) { 10 | const points = [] as React.ReactElement[]; 11 | for (let i = 1; i <= props.count; i++) { 12 | if (i === props.activePointIndex) { 13 | points.push( 14 | 18 | ); 19 | } else { 20 | points.push(); 21 | } 22 | } 23 | 24 | return
{points}
; 25 | } 26 | -------------------------------------------------------------------------------- /src/lib/components/tooltip/Tooltip.less: -------------------------------------------------------------------------------- 1 | @import '../variables.less'; 2 | 3 | @container-padding-right: 35px; 4 | 5 | :local { 6 | .tooltip { 7 | box-sizing: border-box; 8 | position: relative; 9 | color: #333; 10 | 11 | .closeBtn { 12 | position: absolute; 13 | top: 26px; 14 | right: @container-padding-right; 15 | cursor: pointer; 16 | font-size: 31px !important; 17 | &:before { 18 | content: '×'; 19 | cursor: pointer; 20 | color: #555; 21 | } 22 | } 23 | } 24 | 25 | .container { 26 | padding: 30px @container-padding-right 45px 40px; 27 | p { 28 | margin-top: 10px; 29 | margin-bottom: 7px; 30 | } 31 | } 32 | 33 | .header { 34 | font-size: 26px; 35 | margin-bottom: 15px; 36 | margin-right: 20px; 37 | } 38 | 39 | .body { 40 | min-height: 18px; 41 | font-size: 18px; 42 | a { 43 | text-decoration: none; 44 | color: @tooltip-color-link; 45 | } 46 | } 47 | 48 | .footer { 49 | margin-top: 20px; 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/lib/components/tooltip/Tooltip.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { Popup, PopupPosition } from "@skbkontur/react-ui/internal/Popup"; 3 | import { RenderLayer } from "@skbkontur/react-ui/internal/RenderLayer"; 4 | import styles from "./Tooltip.less"; 5 | 6 | export interface PinOptions { 7 | hasPin?: boolean; 8 | pinSize?: number; 9 | pinOffset?: number; 10 | } 11 | 12 | export type TooltipPartElement = React.ReactElement | React.ReactText; 13 | 14 | export interface TooltipProps { 15 | targetGetter: () => Element; 16 | positions?: string[]; 17 | offset?: number; 18 | onClose?: () => void; 19 | onSkip?: () => void; 20 | pinOptions?: PinOptions; 21 | width?: number; 22 | } 23 | 24 | export class Tooltip extends React.Component { 25 | static defaultProps = { 26 | positions: ["bottom middle"], 27 | width: 500, 28 | pinOptions: { 29 | hasPin: true, 30 | pinSize: 16, 31 | pinOffset: 32, 32 | }, 33 | onClose: () => {}, 34 | }; 35 | static Container = Container; 36 | static Header = Header; 37 | static Body = Content; 38 | static Footer = Footer; 39 | state = { hasElem: true }; 40 | 41 | componentWillMount() { 42 | if (!this.props.targetGetter()) { 43 | this.setState({ hasElem: false }); 44 | } 45 | } 46 | 47 | render() { 48 | if (!this.state.hasElem) return ; 49 | const positions: PopupPosition[] = this.props.positions as PopupPosition[]; 50 | return ( 51 | {}} active> 52 | 61 |
62 | 63 | {this.props.children} 64 |
65 |
66 |
67 | ); 68 | } 69 | componentDidMount() { 70 | if (!this.state.hasElem) { 71 | this.props.onSkip && this.props.onSkip(); 72 | } 73 | } 74 | } 75 | 76 | export interface TooltipPartProps { 77 | children?: React.ReactNode; 78 | } 79 | 80 | export function Container({ children }: TooltipPartProps) { 81 | return
{children}
; 82 | } 83 | 84 | export function Header({ children }: TooltipPartProps) { 85 | return
{children}
; 86 | } 87 | 88 | export function Content({ children }: TooltipPartProps) { 89 | return
{children}
; 90 | } 91 | 92 | function Footer({ children }: TooltipPartProps) { 93 | return
{children}
; 94 | } 95 | -------------------------------------------------------------------------------- /src/lib/components/tooltip/TooltipHighlight.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | const raf = require("raf"); 3 | 4 | import { Highlight } from "../highlight/Highlight"; 5 | import { RenderContainer } from "@skbkontur/react-ui/internal/RenderContainer"; 6 | import { addListener } from "@skbkontur/react-ui/lib/LayoutEvents"; 7 | 8 | const initialRect = { 9 | top: 0, 10 | left: 0, 11 | right: 0, 12 | bottom: 0, 13 | } as ClientRect; 14 | 15 | export interface TooltipHighlightProps { 16 | targetGetter: () => Element; 17 | highlight: React.ReactElement; 18 | children: React.ReactElement; 19 | } 20 | 21 | export class TooltipHighlight extends React.Component { 22 | state = { pos: initialRect, hasElem: true }; 23 | _layoutEventsToken; 24 | target; 25 | 26 | componentWillMount() { 27 | if (!this.props.targetGetter()) { 28 | this.setState({ hasElem: false }); 29 | } 30 | } 31 | 32 | render() { 33 | return ( 34 |
35 | {this.props.children} 36 | {this.state.hasElem && ( 37 | 38 | 39 | 40 | )} 41 |
42 | ); 43 | } 44 | 45 | componentDidMount() { 46 | if (!this.state.hasElem) return; 47 | this.target = this.props.targetGetter(); 48 | this.reflow(); 49 | 50 | //add throttle 51 | this._layoutEventsToken = addListener(this.reflow); 52 | } 53 | 54 | componentWillReceiveProps() { 55 | if (this.target) { 56 | raf(this.reflow.bind(this)); 57 | } 58 | } 59 | 60 | componentWillUnmount() { 61 | this._layoutEventsToken && this._layoutEventsToken.remove(); 62 | } 63 | 64 | reflow = () => { 65 | const pos = this.target.getBoundingClientRect(); 66 | this.setState({ pos }); 67 | }; 68 | } 69 | -------------------------------------------------------------------------------- /src/lib/components/tourButton/TourButton.less: -------------------------------------------------------------------------------- 1 | @import '../variables'; 2 | 3 | :local { 4 | .tourButton { 5 | border: 0; 6 | color: @btn-default-color-text; 7 | cursor: pointer; 8 | display: inline-block; 9 | font-family: inherit; 10 | margin: 0; 11 | outline: 0; 12 | overflow: visible; 13 | position: relative; 14 | width: 100%; 15 | font-size: 16px; 16 | height: 42px; 17 | line-height: 42px; 18 | padding: 0 20px; 19 | -webkit-border-radius: 2px; 20 | -moz-border-radius: 2px; 21 | border-radius: 2px; 22 | z-index: 0; 23 | } 24 | 25 | .tourButton::-moz-focus-inner { 26 | border: 0; 27 | padding: 0; 28 | } 29 | 30 | .greyTourButton { 31 | background-image: linear-gradient( 32 | -180deg, 33 | @btn-default-bg-start, 34 | @btn-default-bg-end 35 | ); 36 | box-shadow: @btn-default-shadow; 37 | } 38 | 39 | .greyTourButton:hover { 40 | background-image: linear-gradient( 41 | -180deg, 42 | @btn-default-hover-bg-start, 43 | @btn-default-hover-bg-end 44 | ); 45 | box-shadow: @btn-default-hover-shadow; 46 | } 47 | 48 | .greyTourButton:active { 49 | background: @btn-default-active-bg; 50 | box-shadow: @btn-default-active-shadow; 51 | } 52 | 53 | :global(.rt-ie8), 54 | :global(.rt-ie9) { 55 | .greyTourButton, 56 | .greyTourButton:active, 57 | .greyTourButton:hover, 58 | .greyTourButton, 59 | .greyTourButton:active, 60 | .greyTourButton:hover { 61 | background: @btn-default-bg; 62 | box-shadow: none; 63 | outline: @border-color-gray-dark solid 1px; 64 | } 65 | } 66 | .blueTourButton { 67 | background-image: linear-gradient( 68 | -180deg, 69 | @btn-primary-bg-start, 70 | @btn-primary-bg-end 71 | ); 72 | box-shadow: @btn-primary-shadow; 73 | color: @btn-primary-color-text; 74 | } 75 | 76 | .blueTourButton:hover { 77 | background-image: linear-gradient( 78 | @btn-primary-hover-bg-start, 79 | @btn-primary-hover-bg-end 80 | ); 81 | box-shadow: @btn-primary-hover-shadow; 82 | } 83 | 84 | .blueTourButton:active { 85 | background: @btn-primary-active-bg; 86 | box-shadow: @btn-primary-active-shadow; 87 | } 88 | 89 | :global(.rt-ie8), 90 | :global(.rt-ie9) { 91 | .blueTourButton, 92 | .blueTourButton:active, 93 | .blueTourButton:hover, 94 | .blueTourButton, 95 | .blueTourButton:active, 96 | .blueTourButton:hover { 97 | background: @btn-primary-bg; 98 | box-shadow: none; 99 | outline: @btn-primary-bg solid 1px; 100 | } 101 | } 102 | 103 | .tourButtonArrow::after { 104 | content: ''; 105 | position: absolute; 106 | z-index: -1; 107 | transform: scaleX(0.6) rotate(45deg); 108 | height: 29px; 109 | width: 29px; 110 | border: 0; 111 | } 112 | 113 | :global(.rt-ie8) .tourButtonArrow::after, 114 | :global(.rt-ie9) .tourButtonArrow::after { 115 | display: none; 116 | } 117 | 118 | .rightTourButtonArrow { 119 | padding-right: 14px; 120 | } 121 | 122 | .leftTourButtonArrow { 123 | padding-left: 14px; 124 | } 125 | 126 | :global(.rt-ie8) .leftTourButtonArrow, 127 | :global(.rt-ie9) .leftTourButtonArrow { 128 | padding-left: 20px !important; 129 | } 130 | 131 | :global(.rt-ie8) .rightTourButtonArrow, 132 | :global(.rt-ie9) .rightTourButtonArrow { 133 | padding-right: 20px !important; 134 | } 135 | 136 | .leftTourButtonArrow::after { 137 | left: -15px; 138 | top: 6px; 139 | } 140 | 141 | .rightTourButtonArrow::after { 142 | right: -15px; 143 | top: 6px; 144 | } 145 | 146 | .greyTourButton.leftTourButtonArrow::after { 147 | background: linear-gradient( 148 | 135deg, 149 | @btn-default-bg-start, 150 | @btn-default-bg-end 151 | ); 152 | border-bottom: @btn-default-arrow-border; 153 | border-left: @btn-default-arrow-border; 154 | } 155 | 156 | .greyTourButton.leftTourButtonArrow:hover::after { 157 | background: linear-gradient( 158 | 135deg, 159 | @btn-default-hover-bg-start, 160 | @btn-default-hover-bg-end 161 | ); 162 | } 163 | 164 | .greyTourButton.leftTourButtonArrow:active::after { 165 | background: @btn-default-active-bg; 166 | } 167 | 168 | .blueTourButton.rightTourButtonArrow::after { 169 | background: linear-gradient( 170 | 135deg, 171 | @btn-primary-bg-start, 172 | @btn-primary-bg-end 173 | ); 174 | border-top: @btn-primary-arrow-border; 175 | border-right: @btn-primary-arrow-border; 176 | } 177 | 178 | .blueTourButton.rightTourButtonArrow:hover::after { 179 | background: linear-gradient( 180 | 135deg, 181 | @btn-primary-hover-bg-start, 182 | @btn-primary-hover-bg-end 183 | ); 184 | border-top: @btn-primary-arrow-border; 185 | border-right: @btn-primary-arrow-border; 186 | } 187 | 188 | .blueTourButton.rightTourButtonArrow:active::after { 189 | background: @btn-primary-active-bg; 190 | } 191 | } 192 | -------------------------------------------------------------------------------- /src/lib/components/tourButton/TourButton.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import styles from './TourButton.less'; 3 | 4 | export interface TourButtonProps { 5 | color: string; 6 | arrow?: string; 7 | style?: Object; 8 | onClick: (e: React.MouseEvent) => void; 9 | children?: React.ReactText; 10 | } 11 | 12 | export function TourButton(props: TourButtonProps) { 13 | let className = `${styles.tourButton} ${styles[props.color + 'TourButton']}`; 14 | if (props.arrow === 'right' || props.arrow === 'left') { 15 | className = `${className} ${styles.tourButtonArrow} ${ 16 | styles[props.arrow + 'TourButtonArrow'] 17 | }`; 18 | } 19 | 20 | return ( 21 |
22 | 25 |
26 | ); 27 | } 28 | -------------------------------------------------------------------------------- /src/lib/components/variables.less: -------------------------------------------------------------------------------- 1 | // Common 2 | @border-color-gray-dark: #b2b2b2; 3 | @border-color-gray-light: #d9d9d9; 4 | @border-color-blue-light: #5785a6; 5 | 6 | // Tooltip 7 | @tooltip-color-link: rgb(48, 115, 197); 8 | 9 | // Point 10 | @point-bg-active: #4a90e2; 11 | 12 | // Button 13 | // use default 14 | @btn-default-color-text: #404040; 15 | @btn-default-bg: #f9f9f9; 16 | @btn-default-bg-start: #fff; 17 | @btn-default-bg-end: #ebebeb; 18 | @btn-default-shadow: 0 1px 0 0 rgba(0, 0, 0, 0.15), 19 | 0 0 0 1px rgba(0, 0, 0, 0.15); 20 | @btn-default-hover-bg-start: #f2f2f2; 21 | @btn-default-hover-bg-end: #dfdfdf; 22 | @btn-default-hover-shadow: 0 1px 0 0 rgba(0, 0, 0, 0.15), 23 | 0 0 0 1px rgba(0, 0, 0, 0.2); 24 | @btn-default-active-bg: #e1e1e1; 25 | @btn-default-active-shadow: 0 -1px 0 0 rgba(0, 0, 0, 0.1), 26 | 0 0 0 1px rgba(0, 0, 0, 0.2), inset 0 1px 2px 0 rgba(0, 0, 0, 0.1); 27 | @btn-default-arrow-border: 1px solid @border-color-gray-light; 28 | 29 | // use primary 30 | @btn-primary-color-text: #fff; 31 | @btn-primary-bg: #1e8dd4; 32 | @btn-primary-bg-start: #2899ea; 33 | @btn-primary-bg-end: #167ac1; 34 | @btn-primary-shadow: 0 0 0 1px rgba(14, 81, 129, 0.7), 35 | 0 1px 0 0 rgba(7, 37, 80, 0.5); 36 | @btn-primary-shadow-arrow: 1px -1px 0 0 rgba(14, 81, 129, 0.7); 37 | @btn-primary-hover-bg-start: #0087d5; 38 | @btn-primary-hover-bg-end: #167ac1; 39 | @btn-primary-hover-shadow: 0 0 0 1px rgba(5, 60, 99, 0.7), 40 | 0 1px 0 0 rgba(7, 37, 80, 0.3); 41 | @btn-primary-active-bg: #0079c3; 42 | @btn-primary-active-shadow: 0 0 0 1px rgba(10, 63, 99, 0.75), 43 | 0 -1px 0 0 rgba(8, 45, 96, 0.5), inset 0 1px 2px 0 rgba(0, 0, 0, 0.2); 44 | @btn-primary-arrow-border: 1px solid @border-color-blue-light; 45 | -------------------------------------------------------------------------------- /src/lib/index.ts: -------------------------------------------------------------------------------- 1 | export * from './tour/TourProvider'; 2 | export * from './tour/Tour'; 3 | export * from './step/step'; 4 | export * from './tooltipStep/TooltipStep'; 5 | export * from './miniTooltipStep/MiniTooltipStep'; 6 | export * from './components/tooltip/Tooltip'; 7 | export * from './components/miniTooltip/MiniTooltip'; 8 | export * from './components/tooltip/TooltipHighlight'; 9 | export * from './components/footer/Footer'; 10 | export * from './modalStep/ModalStep'; 11 | export * from './components/highlight/Highlight'; 12 | export * from './components/points/Points'; 13 | export * from './components/tourButton/TourButton'; 14 | export * from './components/MultiStepFooter'; 15 | -------------------------------------------------------------------------------- /src/lib/miniTooltipStep/MiniTooltipStep.tsx: -------------------------------------------------------------------------------- 1 | import {MiniTooltip, PopupPosition} from "../components/miniTooltip/MiniTooltip"; 2 | import * as React from "react"; 3 | import {StepInternalProps} from "../tour/Tour"; 4 | 5 | export interface MiniTooltipStepProps extends Partial { 6 | target: () => Element; 7 | triggers?: (() => Element)[]; 8 | positions: PopupPosition[]; 9 | children?: any; 10 | width?: string, 11 | } 12 | 13 | export class MiniTooltipStep extends React.Component { 14 | render() { 15 | const { 16 | onNext, 17 | onClose, 18 | positions, 19 | target, 20 | triggers, 21 | children, 22 | width 23 | } = this.props; 24 | 25 | return 32 | {children} 33 | 34 | } 35 | } -------------------------------------------------------------------------------- /src/lib/modalStep/ModalStep.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { Button, Modal } from '@skbkontur/react-ui'; 3 | import {StepInternalProps, StepProps} from '../tour/Tour'; 4 | 5 | export interface ModalStepOuterProps { 6 | width?: number; 7 | content?: React.ReactElement | string; 8 | header?: React.ReactElement | string; 9 | footer?: (props: StepInternalProps) => React.ReactElement; 10 | render?: (props: StepInternalProps) => React.ReactElement; 11 | } 12 | 13 | export interface ModalStepProps 14 | extends ModalStepOuterProps, 15 | StepProps, 16 | Partial { 17 | } 18 | 19 | export class ModalStep extends React.Component { 20 | render() { 21 | const { 22 | header, 23 | content, 24 | footer, 25 | onNext, 26 | width, 27 | onPrev, 28 | onClose, 29 | render, 30 | stepIndex, 31 | stepsCount 32 | } = this.props as ModalStepProps & StepInternalProps; 33 | return ( 34 | render 35 | ? 36 | {render({onNext, onPrev, onClose, stepIndex, stepsCount})} 37 | 38 | : 39 | {header} 40 | {content} 41 | {footer ? ( 42 | footer({onNext, onPrev, onClose, stepIndex, stepsCount}) 43 | ) : ( 44 | 45 | 48 | 49 | )} 50 | 51 | ); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/lib/step/step.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | import { StepProps, StepInternalProps } from '../tour/Tour'; 4 | 5 | export interface StepOuterProps { 6 | render: (props: StepInternalProps) => React.ReactElement; 7 | } 8 | 9 | export interface IStep 10 | extends StepOuterProps, 11 | StepProps, 12 | Partial {} 13 | 14 | export class Step extends React.Component { 15 | render() { 16 | const { 17 | render, 18 | onNext, 19 | onPrev, 20 | onClose, 21 | stepIndex, 22 | stepsCount 23 | } = this.props; 24 | 25 | if (!render) { 26 | onNext(); 27 | return null; 28 | } 29 | 30 | return render({ onNext, onPrev, onClose, stepIndex, stepsCount }); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/lib/tooltipStep/TooltipStep.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | 3 | import { TooltipHighlight } from "../components/tooltip/TooltipHighlight"; 4 | import { MultiStepFooter } from "../components/MultiStepFooter"; 5 | import { StepProps, StepInternalProps } from "../tour/Tour"; 6 | import { Tooltip, PinOptions } from "../components/tooltip/Tooltip"; 7 | 8 | export interface TooltipStepOuterProps { 9 | target: () => Element; 10 | positions: string[]; 11 | highlightTarget?: () => Element; 12 | highlight?: React.ReactElement; 13 | offset?: number; 14 | width?: number; 15 | content?: React.ReactElement | string; 16 | header?: React.ReactElement | string; 17 | footer?: (props: StepInternalProps) => React.ReactElement; 18 | render?: (props: StepInternalProps) => React.ReactElement; 19 | pinOptions?: PinOptions; 20 | } 21 | 22 | export interface TooltipStepProps 23 | extends TooltipStepOuterProps, 24 | StepProps, 25 | Partial {} 26 | 27 | export class TooltipStep extends React.Component { 28 | render() { 29 | const tooltip = this.renderTooltip(); 30 | const highlightTarget = this.props.highlightTarget || this.props.target; 31 | 32 | return this.props.highlight ? ( 33 | 37 | {tooltip} 38 | 39 | ) : ( 40 | tooltip 41 | ); 42 | } 43 | 44 | renderTooltip = () => { 45 | return this.props.render ? ( 46 | this.invokeRender(this.props.render) 47 | ) : ( 48 | 56 | 57 | {this.props.header} 58 | {this.props.content} 59 | {this.renderTooltipFooter()} 60 | 61 | 62 | ); 63 | }; 64 | 65 | renderTooltipFooter = () => { 66 | const stepIndex = this.props.stepIndex + 1; 67 | 68 | return this.props.footer ? ( 69 | this.invokeRender(this.props.footer) 70 | ) : ( 71 | 77 | ); 78 | }; 79 | 80 | invokeRender = renderMethod => { 81 | const props = { 82 | onNext: this.props.onNext, 83 | onPrev: this.props.onPrev, 84 | onClose: this.props.onClose, 85 | stepsCount: this.props.stepsCount, 86 | stepIndex: this.props.stepIndex + 1 87 | }; 88 | 89 | return renderMethod(props); 90 | }; 91 | } 92 | -------------------------------------------------------------------------------- /src/lib/tour/Tour.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import * as PropTypes from 'prop-types'; 3 | import { TourProvider } from './TourProvider'; 4 | import { processMove } from './processMove'; 5 | 6 | export interface StepProps { 7 | isFallback?: boolean; 8 | onBefore?: () => Promise; 9 | onAfter?: () => Promise; 10 | onOpen?: () => void; 11 | group?: string; 12 | } 13 | 14 | export interface StepInternalProps { 15 | stepIndex: number; 16 | stepsCount: number; 17 | onPrev: () => void; 18 | onNext: () => void; 19 | onClose: () => void; 20 | } 21 | 22 | export interface TourComponentProps { 23 | children: React.ReactNode; 24 | onClose?: () => void; 25 | } 26 | 27 | const SAFETY_EMPTY_INDEX = 10000; 28 | 29 | //todo: avoid extra rerendering 30 | export class TourComponent extends React.Component { 31 | steps = null; 32 | fallbackStepIndex = null; 33 | state = { 34 | stepIndex: 0, 35 | active: false 36 | }; 37 | 38 | constructor(props: TourProps) { 39 | super(props); 40 | //todo: warning for two final steps 41 | this.steps = this.processSteps(props.children); 42 | this.fallbackStepIndex = this.steps.findIndex( 43 | step => step.props.isFallback 44 | ); 45 | } 46 | 47 | componentDidMount() { 48 | this.run(); 49 | } 50 | 51 | componentWillReceiveProps(nextProps: TourProps) { 52 | this.steps = this.processSteps(nextProps.children); 53 | } 54 | 55 | render() { 56 | if (!this.state.active) { 57 | return null; 58 | } 59 | const { stepIndex } = this.state; 60 | const step = this.steps[stepIndex]; 61 | const stepsCount = this.steps.length; 62 | 63 | const currentStepWithProps = 64 | step && 65 | React.cloneElement(step, { 66 | onClose: this.handleClose, 67 | onNext: this.handleNext, 68 | onPrev: this.handlePrev, 69 | stepIndex: this.state.stepIndex, 70 | stepsCount: this.fallbackStepIndex !== -1 ? stepsCount - 1 : stepsCount 71 | }); 72 | 73 | return
{currentStepWithProps}
; 74 | } 75 | 76 | processSteps = (children: React.ReactNode) => { 77 | const steps = React.Children.toArray(children) as React.ReactElement< 78 | StepProps & StepInternalProps 79 | >[]; 80 | return steps.sort((a, b) => (a.props.isFallback ? 1 : 0)); 81 | }; 82 | 83 | run = () => { 84 | const firstStep = this.steps[0]; 85 | const { onBefore, onOpen } = firstStep.props; 86 | 87 | if (onBefore) { 88 | return onBefore().then(() => { 89 | this.showTour(onOpen); 90 | }); 91 | } 92 | 93 | this.showTour(onOpen); 94 | }; 95 | 96 | showTour = (clb: () => void) => { 97 | this.setState({ active: true, stepIndex: 0 }, () => clb && clb()); 98 | }; 99 | 100 | updateIndex = (index: number) => { 101 | this.setState({ stepIndex: index }, () => { 102 | if (this.state.stepIndex === this.steps.length && this.props.onClose) { 103 | this.props.onClose(); 104 | } 105 | }); 106 | }; 107 | 108 | handleNext = () => this.move(this.state.stepIndex, (a, b) => a + b); 109 | handlePrev = () => this.move(this.state.stepIndex, (a, b) => a - b); 110 | 111 | move = (ind, moveFunc) => { 112 | const nextStep = this.steps[moveFunc(ind, 1)]; 113 | if (nextStep && nextStep.props.isFallback) { 114 | this.move(moveFunc(ind, 1), moveFunc); 115 | } else { 116 | this.moveTo(moveFunc(ind, 1), ind); 117 | } 118 | }; 119 | 120 | moveTo = (ind, prevInd) => { 121 | const step = this.steps[ind]; 122 | const prevStep = this.steps[prevInd]; 123 | 124 | processMove( 125 | prevStep, 126 | step, 127 | () => { 128 | this.updateIndex(ind); 129 | step && step.props.onOpen && step.props.onOpen(); 130 | }, 131 | () => this.updateIndex(SAFETY_EMPTY_INDEX) 132 | ); 133 | }; 134 | 135 | handleClose = () => { 136 | const { stepIndex } = this.state; 137 | const hasFallbackStepToGo = 138 | this.fallbackStepIndex >= 0 && 139 | this.fallbackStepIndex !== stepIndex && 140 | stepIndex + 1 < this.fallbackStepIndex; 141 | 142 | if (hasFallbackStepToGo) { 143 | this.moveTo(this.fallbackStepIndex, stepIndex); 144 | } else { 145 | this.moveTo(this.steps.length, stepIndex); 146 | } 147 | }; 148 | } 149 | 150 | export interface TourProps { 151 | id: string; 152 | children: React.ReactNode; 153 | onClose?: () => void; 154 | } 155 | 156 | export interface TourState { 157 | showTour: boolean; 158 | } 159 | 160 | export class Tour extends React.Component { 161 | static contextTypes = { 162 | [TourProvider.contextName]: PropTypes.object.isRequired 163 | }; 164 | 165 | state = { showTour: false }; 166 | 167 | render() { 168 | return this.state.showTour ? ( 169 | 170 | ) : null; 171 | } 172 | 173 | componentDidMount() { 174 | this.context[TourProvider.contextName].subscribe(this.props.id, this.run); 175 | } 176 | 177 | componentWillUnmount() { 178 | this.unsubscribe(); 179 | } 180 | 181 | run = () => this.setState({ showTour: true }); 182 | 183 | unsubscribe = () => 184 | this.context[TourProvider.contextName].unsubscribe(this.props.id); 185 | 186 | closeTour = () => { 187 | this.setState({ showTour: false }, () => { 188 | this.props.onClose && this.props.onClose(); 189 | this.context[TourProvider.contextName] 190 | .onShown(this.props.id) 191 | .then(this.unsubscribe); 192 | }); 193 | }; 194 | } 195 | -------------------------------------------------------------------------------- /src/lib/tour/TourProvider.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import * as PropTypes from 'prop-types'; 3 | 4 | export interface TourProviderProps { 5 | predicate?: (id: string) => boolean; 6 | onTourShown?: (id: string) => void; 7 | } 8 | 9 | export class TourProvider extends React.Component { 10 | static contextName = '__tour__'; 11 | static childContextTypes = { 12 | [TourProvider.contextName]: PropTypes.object.isRequired 13 | }; 14 | 15 | currentId: string | undefined; 16 | queue = [] as string[]; 17 | listeners: { 18 | [id: string]: () => void; 19 | } = {}; 20 | 21 | render() { 22 | return React.Children.only(this.props.children); 23 | } 24 | 25 | getChildContext() { 26 | return { 27 | [TourProvider.contextName]: { 28 | subscribe: this.subscribe, 29 | unsubscribe: this.unsubscribe, 30 | onShown: this.onShown 31 | } 32 | }; 33 | } 34 | 35 | subscribe = (id, callback) => { 36 | this.listeners[id] = callback; 37 | this.pushToQueue(id); 38 | }; 39 | 40 | unsubscribe = id => { 41 | this.removeFromQueue(id); 42 | delete this.listeners[id]; 43 | }; 44 | 45 | onShown = id => Promise.resolve(id).then(this.props.onTourShown); 46 | 47 | isPredicate = id => { 48 | const predicate = this.props.predicate; 49 | return predicate ? predicate(id) : true; 50 | }; 51 | 52 | notify(id) { 53 | this.listeners[id](); 54 | } 55 | 56 | removeFromQueue(id) { 57 | if (id !== this.currentId) return; 58 | this.currentId = this.queue.find(this.isPredicate); 59 | this.queue = this.queue.filter(id => id !== this.currentId); 60 | if (this.currentId) { 61 | this.notify(this.currentId); 62 | } 63 | } 64 | 65 | pushToQueue(id) { 66 | this.queue = this.currentId ? this.queue.concat(id) : this.queue; 67 | if (!this.currentId && this.isPredicate(id)) { 68 | this.currentId = id; 69 | this.notify(id); 70 | } 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /src/lib/tour/processMove.ts: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | import { StepProps } from './Tour'; 4 | 5 | export const processMove = ( 6 | prevStep: React.ReactElement, 7 | step: React.ReactElement, 8 | onMove: () => void, 9 | onClear: () => void 10 | ): Promise => { 11 | const onBefore = step && step.props.onBefore; 12 | const onAfter = prevStep && prevStep.props.onAfter; 13 | 14 | const stepGroup = step && step.props.group; 15 | const prevStepGroup = prevStep && prevStep.props.group; 16 | 17 | if ((!onBefore && !onAfter) || (!!stepGroup && stepGroup === prevStepGroup)) { 18 | onMove(); 19 | return Promise.resolve(); 20 | } 21 | 22 | return processMoveAsync(onMove, onClear, onBefore, onAfter); 23 | }; 24 | 25 | const processMoveAsync = ( 26 | onMove: () => void, 27 | onClear: () => void, 28 | onBefore?: () => Promise, 29 | onAfter?: () => Promise 30 | ): Promise => { 31 | const resolve = () => Promise.resolve(); 32 | 33 | const before = onBefore || resolve; 34 | const after = onAfter || resolve; 35 | 36 | onClear(); 37 | return after() 38 | .then(() => before()) 39 | .then(() => { 40 | onMove(); 41 | }); 42 | }; 43 | -------------------------------------------------------------------------------- /stories/tourStory.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import {storiesOf} from '@storybook/react'; 3 | import {action} from '@storybook/addon-actions'; 4 | import {TooltipStep, ModalStep, MiniTooltipStep} from '../src/lib'; 5 | 6 | storiesOf('Tour', module) 7 | .add('tooltip step', () => ( 8 | document.documentElement} 11 | positions={['bottom left']} 12 | onNext={action('next')} 13 | onPrev={action('prev')} 14 | onClose={action('close')} 15 | stepIndex={1} 16 | stepsCount={3} 17 | /> 18 | )) 19 | .add('minitooltip step', () => ( 20 | document.documentElement} 22 | positions={['bottom left']} 23 | onNext={action('next')} 24 | onPrev={action('prev')} 25 | onClose={action('close')} 26 | stepIndex={1} 27 | stepsCount={3}> 28 | hi there ffffffffffffffffffffffffffffffffffffffffff fffffffffffffffffffffffffffffffffffff 29 | 30 | )) 31 | .add('minitooltip step with width', () => ( 32 | document.documentElement} 34 | positions={['bottom left']} 35 | onNext={action('next')} 36 | onPrev={action('prev')} 37 | onClose={action('close')} 38 | width={"327px"}> 39 | hi there ffffffffffffffffffffffffffffffffffffffffff fffffffffffffffffffffffffffffffffffff 40 | 41 | )) 42 | .add('modal step', () => ( 43 | 44 | )); 45 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "jsx": "react", 5 | "module": "commonjs", 6 | "moduleResolution": "node", 7 | "importHelpers": true, 8 | "lib": [ 9 | "dom", 10 | "es6", 11 | "es2015.promise", 12 | "es2015.core", 13 | "scripthost" 14 | ], 15 | "declaration": true, 16 | "esModuleInterop": true, 17 | "allowSyntheticDefaultImports": true, 18 | "outDir": "./build" 19 | }, 20 | "include": [ 21 | "src", 22 | "__tests__", 23 | "stories" 24 | ] 25 | } 26 | -------------------------------------------------------------------------------- /tsconfig.prod.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "rootDir": "./src/lib" 5 | }, 6 | "include": [ 7 | "./src/lib/**/*", 8 | "./src/@types" 9 | ] 10 | } 11 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "@extern/tslint" 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const path = require('path'); 4 | const webpack = require('webpack'); 5 | const WebpackCleanupPlugin = require('webpack-cleanup-plugin'); 6 | const HtmlWebpackPlugin = require('html-webpack-plugin'); 7 | const MiniCssExtractPlugin = require('mini-css-extract-plugin'); 8 | 9 | module.exports = (env, argv) => { 10 | const production = argv.mode === 'production'; 11 | return { 12 | context: path.join(__dirname, 'src'), 13 | entry: { 14 | app: './example/app', 15 | }, 16 | output: { 17 | path: path.join(__dirname, 'dist'), 18 | publicPath: production ? 'http://tech.skbkontur.ru/react-ui-tour/' : '', 19 | filename: production ? '[name].[hash].js' : '[name].js', 20 | library: ['[name]'], 21 | libraryTarget: 'umd', 22 | }, 23 | module: { 24 | rules: [ 25 | { 26 | test: /\.(tsx|ts)$/, 27 | loader: 'ts-loader' 28 | }, 29 | { 30 | test: /\.(css|less)$/, 31 | use: [ 32 | MiniCssExtractPlugin.loader, 33 | { 34 | loader: require.resolve('css-loader'), 35 | options: { 36 | importLoaders: 1, 37 | localIdentName: '[sha512:hash:base32:4]-[name]-[local]', 38 | modules: 'global', 39 | }, 40 | }, 41 | 'less-loader', 42 | ], 43 | }, 44 | {test: /\.(png|woff|woff2|eot)$/, use: 'file-loader?name=[name].[md5:hash:hex:8].[ext]'}, 45 | ], 46 | }, 47 | resolve: { 48 | extensions: ['.ts', '.tsx', '.js'], 49 | }, 50 | plugins: [ 51 | new webpack.WatchIgnorePlugin([/\.js$/, /\.d\.ts$/ ]), 52 | new MiniCssExtractPlugin({ 53 | filename: '[name].[contenthash].css', 54 | }), 55 | new HtmlWebpackPlugin(), 56 | new WebpackCleanupPlugin({ 57 | exclude: ['.git/**/*', '.*'] 58 | }) 59 | ], 60 | devtool: production ? false : 'inline-source-map', 61 | }; 62 | }; 63 | --------------------------------------------------------------------------------