├── .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 | next
18 | prev
19 | close
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 | run
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 | prev
24 | next
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 |
23 | {props.children}
24 |
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 |
46 | Поехали
47 |
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 |
--------------------------------------------------------------------------------