├── .npmignore
├── src
├── types.ts
├── index.ts
├── lib
│ ├── const.ts
│ └── fns.ts
├── classNames.ts
├── testUtils.ts
├── series.ts
├── Kindness.tsx
├── KindnessPanelContent.tsx
├── index.css
├── KindnessPanel.spec.tsx
└── KindnessPanel.tsx
├── resources
└── demo.gif
├── .prettierrc.js
├── mocha.opts
├── tsconfig.compile.json
├── .gitignore
├── .editorconfig
├── .gitattributes
├── .github
└── workflows
│ └── nodejs.yml
├── .eslintrc
├── examples
└── basic
│ ├── index.html
│ ├── style.css
│ └── index.js
├── webpack.config.ts
├── karma.conf.ts
├── package.json
├── README.md
└── tsconfig.json
/.npmignore:
--------------------------------------------------------------------------------
1 | .idea
2 | .travis.yml
3 | coverage
4 | test
5 |
--------------------------------------------------------------------------------
/src/types.ts:
--------------------------------------------------------------------------------
1 | export type SpotShapes = 'rect' | 'circle';
2 |
--------------------------------------------------------------------------------
/resources/demo.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/piglovesyou/react-kindness/HEAD/resources/demo.gif
--------------------------------------------------------------------------------
/.prettierrc.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | printWidth: 80,
3 | singleQuote: true,
4 | trailingComma: 'all',
5 | };
6 |
--------------------------------------------------------------------------------
/src/index.ts:
--------------------------------------------------------------------------------
1 | import KindnessPanel from './KindnessPanel';
2 | import Kindness from './Kindness';
3 |
4 | export { KindnessPanel, Kindness };
5 |
--------------------------------------------------------------------------------
/mocha.opts:
--------------------------------------------------------------------------------
1 | # mocha.opts
2 |
3 | --require @babel/register
4 | --reporter dot
5 | --ui bdd
6 | --recursive ./src/**/*.spec.js
7 | --exit
8 |
--------------------------------------------------------------------------------
/src/lib/const.ts:
--------------------------------------------------------------------------------
1 | export const OVERLAY_TRANSITION_DELAY = 400;
2 | export const SPOT_MARGIN = 8;
3 | export const SPOT_MIN_RADIUS = 56;
4 | export const SCROLL_OFFSET = 100;
5 | export const BLUR_STD_DEVIATION = 4;
6 |
--------------------------------------------------------------------------------
/tsconfig.compile.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "./tsconfig",
3 | "include": [
4 | "src"
5 | ],
6 | "exclude": [
7 | "node_modules",
8 | "src/testUtils.ts",
9 | "src/**/*.spec.tsx",
10 | "src/**/*.spec.ts"
11 | ]
12 | }
13 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Include your project-specific ignores in this file
2 | # Read about how to use .gitignore: https://help.github.com/articles/ignoring-files
3 |
4 | coverage
5 | node_modules
6 | npm-debug.log
7 | /*.iml
8 | /dist
9 | /demo
10 | .DS_Store
11 | yarn-error.log
12 |
--------------------------------------------------------------------------------
/.editorconfig:
--------------------------------------------------------------------------------
1 | # EditorConfig helps developers define and maintain consistent
2 | # coding styles between different editors and IDEs
3 | # http://editorconfig.org
4 |
5 | root = true
6 |
7 | [*]
8 |
9 | # Change these settings to your own preference
10 | indent_style = space
11 | indent_size = 2
12 |
13 | # We recommend you to keep these unchanged
14 | end_of_line = lf
15 | charset = utf-8
16 | trim_trailing_whitespace = true
17 | insert_final_newline = true
18 |
19 | [*.md]
20 | trim_trailing_whitespace = false
21 |
--------------------------------------------------------------------------------
/.gitattributes:
--------------------------------------------------------------------------------
1 | # Automatically normalize line endings for all text-based files
2 | # http://git-scm.com/docs/gitattributes#_end_of_line_conversion
3 | * text=auto
4 |
5 | # For the following file types, normalize line endings to LF on
6 | # checkin and prevent conversion to CRLF when they are checked out
7 | # (this is required in order to prevent newline related issues like,
8 | # for example, after the build script is run)
9 | .* text eol=lf
10 | *.js text eol=lf
11 | *.json text eol=lf
12 | *.md text eol=lf
13 | *.txt text eol=lf
14 | *.yml text eol=lf
15 |
--------------------------------------------------------------------------------
/src/classNames.ts:
--------------------------------------------------------------------------------
1 | export const rootClassName = 'react-kindness';
2 | export const svgClassName = `${rootClassName}__svg`;
3 | export const overlayClassName = `${rootClassName}__overlay`;
4 | export const spotClassName = `${rootClassName}__spot`;
5 | export const panelClassName = `${rootClassName}-panel`;
6 | export const panelArrowClassName = `${rootClassName}-panel__arrow`;
7 | export const panelTitleClassName = `${rootClassName}-panel__title`;
8 | export const panelMessageClassName = `${rootClassName}-panel__message`;
9 |
10 | export function classnames(...args) {
11 | return Object.keys(
12 | args.reduce((o, c) => {
13 | if (!c) return o;
14 | return { ...o, [c]: true };
15 | }, {}),
16 | ).join(' ');
17 | }
18 |
--------------------------------------------------------------------------------
/src/testUtils.ts:
--------------------------------------------------------------------------------
1 | import webpack from 'webpack';
2 | import middleware from 'webpack-dev-middleware';
3 | import express from 'express';
4 | import http from 'http';
5 | import webpackConfig from '../webpack.config';
6 |
7 | export function timeout(ms) {
8 | return new Promise(resolve => setTimeout(resolve, ms));
9 | }
10 |
11 | export function launchApp() {
12 | const compiler = webpack({
13 | ...webpackConfig,
14 | mode: 'none',
15 | watch: false,
16 | });
17 | const app = express();
18 |
19 | app.use(
20 | middleware(compiler, {
21 | publicPath: '',
22 | // webpack-dev-middleware options
23 | }),
24 | );
25 |
26 | return new Promise(resolve => {
27 | const server = http.createServer(app).listen(3000, () => {
28 | resolve(server);
29 | });
30 | });
31 | }
32 |
--------------------------------------------------------------------------------
/.github/workflows/nodejs.yml:
--------------------------------------------------------------------------------
1 | name: Node CI
2 |
3 | on: [push]
4 |
5 | jobs:
6 | build:
7 | strategy:
8 | matrix:
9 | platform: [
10 | ubuntu-latest,
11 | # Need more effort to support other platforms/browsers
12 | # macos-latest,
13 | # windows-latest
14 | ]
15 | node-version:
16 | - 12.x
17 | - 10.x
18 | runs-on: ${{ matrix.platform }}
19 |
20 | steps:
21 | - uses: actions/checkout@v1
22 | - name: Use Node.js ${{ matrix.node-version }}
23 | uses: actions/setup-node@v1
24 | with:
25 | node-version: ${{ matrix.node-version }}
26 |
27 | # Pulling caches itself it expensive. Which one is faster?
28 | - name: Get yarn cache
29 | id: yarn-cache
30 | run: echo "::set-output name=dir::$(yarn cache dir)"
31 | - uses: actions/cache@v1
32 | with:
33 | path: ${{ steps.yarn-cache.outputs.dir }}
34 | key: ${{ runner.os }}-yarn-${{ hashFiles('package.json') }}
35 | restore-keys: |
36 | ${{ runner.os }}-yarn-
37 |
38 | - name: yarn install, lint, and test
39 | env:
40 | CI: true
41 | run: |
42 | yarn install --ignore-scripts
43 | yarn prepublishOnly
44 |
--------------------------------------------------------------------------------
/.eslintrc:
--------------------------------------------------------------------------------
1 | {
2 | "root": true,
3 | "parser": "@typescript-eslint/parser",
4 | "plugins": [
5 | "@typescript-eslint/eslint-plugin", "prettier"
6 | ],
7 | "extends": [
8 | "eslint:recommended",
9 | "plugin:@typescript-eslint/eslint-recommended",
10 | "plugin:@typescript-eslint/recommended",
11 | "plugin:prettier/recommended",
12 | "plugin:react/recommended"
13 | ],
14 | "env": {
15 | "es6": true,
16 | "browser": true,
17 | "node": true,
18 | "mocha": true
19 | },
20 | "settings": {
21 | "react": {
22 | "version": "detect"
23 | }
24 | },
25 | "rules": {
26 | "prettier/prettier": "error",
27 | "import/no-extraneous-dependencies": "off",
28 | "jsx-a11y/href-no-hash": "off",
29 | "react/jsx-filename-extension": "off",
30 | "no-use-before-define": "off",
31 | "jsx-a11y/anchor-is-valid": "off",
32 | "react/prop-types": "off",
33 | "@typescript-eslint/no-non-null-assertion": "off",
34 | "@typescript-eslint/no-empty-function": "off",
35 |
36 | /* TODO: fix */
37 | "import/no-cycle": "off",
38 | "@typescript-eslint/ban-ts-ignore": "off",
39 | "@typescript-eslint/explicit-function-return-type": "off",
40 | "@typescript-eslint/no-explicit-any": "off",
41 | }
42 | }
43 |
--------------------------------------------------------------------------------
/examples/basic/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | react-kindness DEMO
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
--------------------------------------------------------------------------------
/src/series.ts:
--------------------------------------------------------------------------------
1 | import Kindness from './Kindness';
2 |
3 | export class Series extends Map {
4 | getOrderKeyByIndex(index: number) {
5 | const key = Array.from(this.keys()).sort()[index];
6 | if (typeof key !== 'number') return -1;
7 | return key;
8 | }
9 |
10 | hasKindnessByIndex(index: number) {
11 | const key = this.getOrderKeyByIndex(index);
12 | return this.has(key);
13 | }
14 |
15 | getKindnessByIndex(index: number) {
16 | const key = this.getOrderKeyByIndex(index);
17 | const k = this.get(key);
18 | if (!k) return null;
19 | return k;
20 | }
21 |
22 | getKindnessElementByIndex(index: number) {
23 | const k = this.getKindnessByIndex(index);
24 | if (!k) return null;
25 | return k.ref.current;
26 | }
27 |
28 | /**
29 | * Returns order key for later use.
30 | */
31 | append(k: Kindness) {
32 | const lastKey = this.getOrderKeyByIndex(this.size - 1);
33 | if (lastKey < 0) {
34 | this.set(0, k);
35 | return 0;
36 | }
37 | const nextKey = lastKey + 1;
38 | this.set(nextKey, k);
39 | return nextKey;
40 | }
41 | }
42 |
43 | export class SeriesPool extends Map {
44 | getOrCreate(key: string) {
45 | const existing = this.get(key);
46 | if (existing) return existing;
47 | const created = new Series();
48 | this.set(key, created);
49 | return created;
50 | }
51 | }
52 |
53 | export const seriesPool = new SeriesPool();
54 |
--------------------------------------------------------------------------------
/webpack.config.ts:
--------------------------------------------------------------------------------
1 | import webpack from 'webpack';
2 | import path from 'path';
3 | import HtmlWebpackPlugin from 'html-webpack-plugin';
4 |
5 | const htmlWebpackPlugin = new HtmlWebpackPlugin({
6 | template: path.join(__dirname, 'examples/basic/index.html'),
7 | filename: './index.html',
8 | });
9 |
10 | const config: webpack.Configuration = {
11 | entry: path.join(__dirname, 'examples/basic/index.js'),
12 | output: {
13 | path: path.join(__dirname, 'demo'),
14 | filename: 'bundle.js',
15 | },
16 | module: {
17 | rules: [
18 | {
19 | test: /\.(j|t)sx?$/,
20 | exclude: /node_modules/,
21 | use: {
22 | loader: 'babel-loader',
23 | options: {
24 | presets: [
25 | '@babel/preset-env',
26 | '@babel/preset-typescript',
27 | '@babel/react',
28 | ],
29 | plugins: [
30 | '@babel/plugin-transform-runtime',
31 | // "@babel/plugin-proposal-class-properties",
32 | [
33 | '@babel/plugin-proposal-class-properties',
34 | {
35 | loose: false,
36 | },
37 | ],
38 | ],
39 | },
40 | },
41 | },
42 | {
43 | test: /\.css$/,
44 | use: ['style-loader', 'css-loader'],
45 | },
46 | ],
47 | },
48 | plugins: [htmlWebpackPlugin],
49 | resolve: {
50 | extensions: ['.ts', '.tsx', '.js', '.jsx'],
51 | },
52 | devServer: {
53 | port: 3000,
54 | },
55 | devtool: 'cheap-module-eval-source-map',
56 | };
57 |
58 | export default config;
59 |
--------------------------------------------------------------------------------
/src/Kindness.tsx:
--------------------------------------------------------------------------------
1 | import React, { ReactNode } from 'react';
2 | import { seriesPool } from './series';
3 | import { SpotShapes } from './types';
4 |
5 | export type KindnessProps = {
6 | shape?: SpotShapes;
7 | order?: 'auto' | number;
8 | seriesId?: 'default';
9 | title?: string;
10 | message?: string;
11 | children: ReactNode;
12 | };
13 |
14 | export default class Kindness extends React.Component {
15 | static defaultProps = {
16 | order: 'auto',
17 | seriesId: 'default',
18 | };
19 |
20 | constructor(props) {
21 | if (!props.seriesId) throw new Error('never');
22 | super(props);
23 | this.series = seriesPool.getOrCreate(props.seriesId);
24 | this.ref = React.createRef();
25 | this.orderKey = null;
26 | }
27 |
28 | componentDidMount() {
29 | const { order } = this.props;
30 | if (order === 'auto') {
31 | this.orderKey = this.series.append(this);
32 | } else {
33 | this.series.set(order, this);
34 | this.orderKey = order;
35 | }
36 | }
37 |
38 | componentWillUnmount() {
39 | if (typeof this.orderKey !== 'number') throw new Error('never');
40 | this.series.delete(this.orderKey);
41 | }
42 |
43 | series;
44 |
45 | orderKey;
46 |
47 | ref;
48 |
49 | render() {
50 | const { children } = this.props;
51 | const child = React.Children.only(children);
52 | if (
53 | !child ||
54 | typeof child === 'string' ||
55 | typeof child === 'number' ||
56 | typeof child === 'boolean'
57 | )
58 | throw new Error('Specify children.');
59 | return React.cloneElement(child as Exclude, {
60 | ref: this.ref,
61 | });
62 | }
63 | }
64 |
65 | // Kindness.defaultProps = {
66 | // shape: null,
67 | // order: 'auto',
68 | // seriesId: 'default',
69 | // title: null,
70 | // message: null,
71 | // };
72 |
--------------------------------------------------------------------------------
/karma.conf.ts:
--------------------------------------------------------------------------------
1 | // Karma configuration
2 | // Generated on Sun Aug 05 2018 09:06:40 GMT+0900 (Japan Standard Time)
3 |
4 | import webpackConfig from './webpack.config';
5 | import { platform } from 'os';
6 |
7 | const isCI = process.env.CI === 'true';
8 | let browsers: string[];
9 | if (isCI) {
10 | browsers = ['ChromeHeadless'];
11 | } else {
12 | browsers = ['Chrome'];
13 | }
14 | // Need more effort to support other platforms/browsers
15 | //
16 | // else if (platform() === 'darwin') {
17 | // browsers = ['Safari'];
18 | // } else if (platform().startsWith('win')) {
19 | // browsers = ['IE'];
20 | // } else if (isCI) {
21 | // browsers = ['ChromeHeadless'];
22 | // }
23 |
24 | const config = function(config) {
25 | config.set({
26 | // base path that will be used to resolve all patterns (eg. files, exclude)
27 | basePath: '',
28 |
29 | // frameworks to use
30 | // available frameworks: https://npmjs.org/browse/keyword/karma-adapter
31 | frameworks: [
32 | 'mocha',
33 | // 'karma-typescript'
34 | ],
35 |
36 | // list of files / patterns to load in the browser
37 | files: [
38 | // 'src/**/*.spec.js',
39 | // 'src/**/*.spec.ts',
40 | 'src/**/*.spec.tsx',
41 | ],
42 |
43 | // list of files / patterns to exclude
44 | exclude: [],
45 |
46 | // preprocess matching files before serving them to the browser
47 | // available preprocessors: https://npmjs.org/browse/keyword/karma-preprocessor
48 | preprocessors: {
49 | '**/*.ts': ['webpack'],
50 | '**/*.tsx': ['webpack'],
51 | },
52 |
53 | webpack: {
54 | ...webpackConfig,
55 | mode: isCI ? 'production' : 'development',
56 | },
57 |
58 | // test results reporter to use
59 | // possible values: 'dots', 'progress'
60 | // available reporters: https://npmjs.org/browse/keyword/karma-reporter
61 | reporters: [
62 | 'progress',
63 | // 'karma-typescript',
64 | ],
65 |
66 | // web server port
67 | port: 9876,
68 |
69 | // enable / disable colors in the output (reporters and logs)
70 | colors: true,
71 |
72 | // level of logging
73 | // possible values: config.LOG_DISABLE || config.LOG_ERROR || config.LOG_WARN || config.LOG_INFO || config.LOG_DEBUG
74 | logLevel: config.LOG_INFO,
75 |
76 | // enable / disable watching file and executing tests whenever any file changes
77 | autoWatch: !isCI,
78 |
79 | // start these browsers
80 | // available browser launchers: https://npmjs.org/browse/keyword/karma-launcher
81 | browsers,
82 |
83 | // Continuous Integration mode
84 | // if true, Karma captures browsers, runs the tests and exits
85 | singleRun: isCI,
86 |
87 | // Concurrency level
88 | // how many browser should be started simultaneous
89 | concurrency: Infinity,
90 |
91 | karmaTypescriptConfig: {
92 | tsconfig: 'tsconfig.json',
93 | },
94 |
95 | browserNoActivityTimeout: 10 * 1000,
96 | });
97 | };
98 | export default config;
99 |
--------------------------------------------------------------------------------
/src/KindnessPanelContent.tsx:
--------------------------------------------------------------------------------
1 | import React, { ReactNode } from 'react';
2 | import {
3 | classnames,
4 | panelArrowClassName,
5 | panelClassName,
6 | panelMessageClassName,
7 | panelTitleClassName,
8 | } from './classNames';
9 | import { EventEmitter } from 'events';
10 |
11 | type KindnessPanelContentProps = {
12 | title: ReactNode;
13 | message: ReactNode;
14 | totalSize: number;
15 | currentIndex: number;
16 | goPrev: () => void;
17 | goNext: () => void;
18 | skip: () => void;
19 | goIndex: (number) => void;
20 | transitionEmitter: EventEmitter;
21 | };
22 |
23 | export default class KindnessPanelContent extends React.Component<
24 | KindnessPanelContentProps
25 | > {
26 | nextRef: HTMLElement | null = null;
27 |
28 | constructor(props) {
29 | super(props);
30 |
31 | const { transitionEmitter } = props;
32 | transitionEmitter.on('onEntered', this.onEntered);
33 | }
34 |
35 | componentWillUnmount() {
36 | const { transitionEmitter } = this.props;
37 | transitionEmitter.removeListener('onEntered', this.onEntered);
38 | }
39 |
40 | onEntered = () => {
41 | if (!this.nextRef) return;
42 | this.nextRef.focus();
43 | };
44 |
45 | render() {
46 | const {
47 | title,
48 | message,
49 | totalSize,
50 | currentIndex,
51 | goPrev,
52 | goNext,
53 | goIndex,
54 | skip,
55 | } = this.props;
56 | return (
57 |
58 |
59 | {title &&
{title} }
60 | {message &&
{message}
}
61 |
62 |
63 | {Array.from(Array(totalSize)).map((_, i) => (
64 | goIndex(i)}
68 | className={classnames(
69 | `${panelClassName}__indicator__dot`,
70 | i === currentIndex
71 | ? `${panelClassName}__indicator__dot--current`
72 | : null,
73 | )}
74 | >
75 | {''}
76 |
77 | ))}
78 |
79 |
80 |
81 | {goNext && (
82 |
83 | Skip
84 |
85 | )}
86 |
87 | {''}
88 |
93 | Prev
94 |
95 | {goNext ? (
96 | (this.nextRef = e)}
100 | >
101 | Next
102 |
103 | ) : (
104 | (this.nextRef = e)}>
105 | Done
106 |
107 | )}
108 |
109 |
110 | {' '}
111 |
112 |
113 | );
114 | }
115 | }
116 |
--------------------------------------------------------------------------------
/src/lib/fns.ts:
--------------------------------------------------------------------------------
1 | import { getScroll } from 'popper.js/dist/popper-utils';
2 | import animateScrollTo from 'animated-scroll-to';
3 | import { SpotShapes } from '../types';
4 | import { SPOT_MIN_RADIUS, SPOT_MARGIN, SCROLL_OFFSET } from './const';
5 |
6 | export function createCircleSvgStyle(popperOffset) {
7 | const wc = popperOffset.width / 2;
8 | const hc = popperOffset.height / 2;
9 | // const rad = wc + (SPOT_MARGIN * 2);
10 | const cx = popperOffset.left + wc;
11 | const cy = popperOffset.top + hc;
12 | const r =
13 | Math.max((popperOffset.width + popperOffset.height) / 4, SPOT_MIN_RADIUS) +
14 | SPOT_MARGIN;
15 | return {
16 | x: cx - r,
17 | y: cy - r,
18 | rx: r,
19 | ry: r,
20 | width: r * 2,
21 | height: r * 2,
22 | };
23 | }
24 |
25 | export function createRectSvgStyle(popperOffset) {
26 | return {
27 | x: popperOffset.left - SPOT_MARGIN,
28 | y: popperOffset.top - SPOT_MARGIN,
29 | width: popperOffset.width + SPOT_MARGIN * 2,
30 | height: popperOffset.height + SPOT_MARGIN * 2,
31 | };
32 | }
33 |
34 | export function createOverlayStyle() {
35 | const d = window.document.documentElement;
36 | const b = window.document.body;
37 | return {
38 | width: Math.max(d.clientWidth, d.offsetWidth, b.scrollWidth),
39 | height: Math.max(d.clientHeight, d.offsetHeight, b.scrollHeight),
40 | };
41 | }
42 |
43 | export function scrollViewport(spotShape: SpotShapes, spotOffset) {
44 | const scrollTop = getScroll(window.document.documentElement, 'top');
45 | const scrollLeft = getScroll(window.document.documentElement, 'left');
46 |
47 | const viewportHeight = window.document.documentElement.clientHeight;
48 | const viewportWidth = window.document.documentElement.clientWidth;
49 |
50 | let y: number | null = null;
51 | let verticalOffset = 0;
52 | if (spotOffset.top < scrollTop) {
53 | y = spotOffset.top;
54 | verticalOffset = -SCROLL_OFFSET;
55 | } else if (scrollTop + viewportHeight < spotOffset.bottom - viewportHeight) {
56 | y = spotOffset.bottom - viewportHeight;
57 | verticalOffset = SCROLL_OFFSET;
58 | }
59 |
60 | let x: number | null = null;
61 | let horizontalOffset = 0;
62 | if (spotOffset.left < scrollLeft) {
63 | x = spotOffset.left;
64 | horizontalOffset = -SCROLL_OFFSET;
65 | } else if (scrollLeft + viewportWidth < spotOffset.right - viewportWidth) {
66 | x = spotOffset.right - viewportWidth;
67 | horizontalOffset = SCROLL_OFFSET;
68 | }
69 |
70 | animateScrollTo([x, y], { verticalOffset, horizontalOffset });
71 | }
72 |
73 | export function insideViewport(data) {
74 | const { popper } = data.offsets;
75 | const { width, height } = popper;
76 | let { top, right, bottom, left } = popper;
77 | const scrollTop = getScroll(window.document.documentElement, 'top');
78 | const scrollLeft = getScroll(window.document.documentElement, 'left');
79 | const viewportWidth = window.document.documentElement.clientWidth;
80 | const viewportHeight = window.document.documentElement.clientHeight;
81 | const viewportRight = scrollLeft + viewportWidth;
82 | const viewportBottom = scrollTop + viewportHeight;
83 |
84 | if (popper.top < scrollTop) {
85 | top = scrollTop;
86 | bottom = top + height;
87 | } else if (popper.bottom > viewportBottom) {
88 | top = viewportBottom - height;
89 | bottom = viewportBottom;
90 | }
91 | if (popper.left < scrollLeft) {
92 | left = scrollLeft;
93 | right = left + width;
94 | } else if (popper.right > viewportRight) {
95 | left = viewportRight - width;
96 | right = viewportRight;
97 | }
98 | return {
99 | ...data,
100 | offsets: {
101 | ...data.offsets,
102 | popper: {
103 | ...popper,
104 | top,
105 | right,
106 | bottom,
107 | left,
108 | },
109 | },
110 | };
111 | }
112 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "react-kindness",
3 | "version": "0.5.0",
4 | "description": "",
5 | "main": "dist/index.js",
6 | "directories": {
7 | "test": "test"
8 | },
9 | "repository": {
10 | "type": "git",
11 | "url": "git+https://github.com/piglovesyou/react-kindness.git"
12 | },
13 | "keywords": [
14 | "react",
15 | "instruction",
16 | "tutorial",
17 | "introduction",
18 | "spot",
19 | "focus",
20 | "demo"
21 | ],
22 | "files": [
23 | "dist"
24 | ],
25 | "author": "thepiglovesyou@gmail.com",
26 | "license": "MIT",
27 | "bugs": {
28 | "url": "https://github.com/piglovesyou/react-kindness/issues"
29 | },
30 | "homepage": "https://github.com/piglovesyou/react-kindness#readme",
31 | "jest": {
32 | "setupTestFrameworkScriptFile": "./src/setupTests.js",
33 | "testEnvironment": "node",
34 | "moduleLoader": "./src/aliasedModuleLoader.js"
35 | },
36 | "peerDependencies": {
37 | "react": "^16.13.0",
38 | "react-dom": "^16.13.0"
39 | },
40 | "dependencies": {
41 | "@babel/runtime": "^7.8.7",
42 | "animated-scroll-to": "^2.0.5",
43 | "lodash.debounce": "^4.0.8",
44 | "popper.js": "^1.16.1",
45 | "react-transition-group": "^4.3.0"
46 | },
47 | "devDependencies": {
48 | "@babel/core": "^7.8.7",
49 | "@babel/plugin-proposal-class-properties": "^7.8.3",
50 | "@babel/plugin-transform-runtime": "^7.8.3",
51 | "@babel/preset-env": "^7.8.7",
52 | "@babel/preset-react": "^7.8.3",
53 | "@babel/preset-typescript": "^7.8.3",
54 | "@types/babel__core": "^7.1.6",
55 | "@types/chai": "^4.2.11",
56 | "@types/cpx": "^1.5.1",
57 | "@types/enzyme": "^3.10.5",
58 | "@types/enzyme-adapter-react-16": "^1.0.6",
59 | "@types/eslint": "^6.1.8",
60 | "@types/eslint-plugin-prettier": "^2.2.0",
61 | "@types/gh-pages": "^2.0.1",
62 | "@types/html-webpack-plugin": "^3.2.2",
63 | "@types/karma": "^4.4.0",
64 | "@types/karma-chrome-launcher": "^3.1.0",
65 | "@types/karma-ie-launcher": "^1.0.0",
66 | "@types/karma-mocha": "^1.3.0",
67 | "@types/karma-webpack": "^2.0.7",
68 | "@types/lodash.debounce": "^4.0.6",
69 | "@types/mocha": "^7.0.2",
70 | "@types/node": "^13.9.1",
71 | "@types/prettier": "^1.19.0",
72 | "@types/react": "^16.9.23",
73 | "@types/react-dom": "^16.9.5",
74 | "@types/react-transition-group": "^4.2.4",
75 | "@types/rimraf": "^2.0.3",
76 | "@types/webpack": "^4.41.7",
77 | "@types/webpack-dev-middleware": "^3.7.0",
78 | "@types/webpack-dev-server": "^3.10.1",
79 | "@typescript-eslint/eslint-plugin": "^2.23.0",
80 | "@typescript-eslint/parser": "^2.23.0",
81 | "babel-loader": "^8.0.6",
82 | "cpx": "^1.5.0",
83 | "css-loader": "^3.4.2",
84 | "enzyme": "^3.11.0",
85 | "enzyme-adapter-react-16": "^1.15.2",
86 | "eslint": "^6.8.0",
87 | "eslint-config-prettier": "^6.10.0",
88 | "eslint-plugin-prettier": "^3.1.2",
89 | "eslint-plugin-react": "^7.19.0",
90 | "gh-pages": "^2.2.0",
91 | "html-webpack-plugin": "^3.2.0",
92 | "husky": "^4.2.3",
93 | "karma": "^4.4.1",
94 | "karma-chrome-launcher": "^3.1.0",
95 | "karma-ie-launcher": "^1.0.0",
96 | "karma-mocha": "^1.3.0",
97 | "karma-safari-launcher": "^1.0.0",
98 | "karma-typescript": "^5.0.0",
99 | "karma-webpack": "^4.0.2",
100 | "lint-staged": "^10.0.8",
101 | "mocha": "^7.1.0",
102 | "prettier": "^1.19.1",
103 | "react": "^16.13.0",
104 | "react-dom": "^16.13.0",
105 | "react-testing-library": "^8.0.1",
106 | "rimraf": "^3.0.2",
107 | "style-loader": "^1.1.3",
108 | "ts-node": "^8.6.2",
109 | "typescript": "^3.8.3",
110 | "webpack": "^4.42.0",
111 | "webpack-cli": "^3.3.11",
112 | "webpack-dev-middleware": "^3.7.2",
113 | "webpack-dev-server": "^3.10.3"
114 | },
115 | "husky": {
116 | "hooks": {
117 | "pre-commit": "lint-staged"
118 | }
119 | },
120 | "lint-staged": {
121 | "**/*.{ts,tsx}": [
122 | "yarn fix",
123 | "git add --force"
124 | ]
125 | },
126 | "scripts": {
127 | "lint": "eslint --ext .ts,.tsx,.js src examples/basic",
128 | "fix": "eslint --ext .ts,.tsx,.js src examples/basic --fix",
129 | "compile": "rimraf dist && tsc --declaration --project ./tsconfig.compile.json && cpx src/index.css dist",
130 | "start": "webpack-dev-server --mode development",
131 | "test": "CI=true karma start",
132 | "publish-demo": "webpack --mode production && gh-pages -d demo",
133 | "prepublishOnly": "yarn lint && yarn test && yarn compile"
134 | }
135 | }
136 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # react-kindness [](https://github.com/piglovesyou/react-kindness/actions)
2 |
3 | A lightweight, fully-customizable kind screen guide for React
4 |
5 | 
6 |
7 | [👉 Demo](https://piglovesyou.github.io/react-kindness/)
8 |
9 | [👉 Concept](https://dev.to/takamura_so/react-kindness-a-customizable-screen-guide-for-react--3nn0)
10 |
11 | To install
12 |
13 | ```bash
14 | $ npm install --save react-kindness
15 | ```
16 |
17 | Put this somewhere in your component tree,
18 |
19 | ```typescript jsx
20 | import {KindnessPanel, Kindness} from 'react-kindness';
21 | import 'react-kindness/dist/index.css';
22 |
23 | // ...
24 | this.setState({show: false})} />
26 | ```
27 |
28 | then point out some elements that you want your guests to focus on
29 |
30 | ```typescript jsx
31 |
32 |
33 |
34 |
35 |
36 | Submit
37 |
38 | ```
39 |
40 | When the ` ` becomes `enabled={true}`, the screen guide starts.
41 |
42 | ## Props of ` `
43 |
44 | ```typescript jsx
45 | type KindnessPanelProps = {
46 | enabled: boolean,
47 | onExit: () => void,
48 | shape?: 'circle' | 'rect', // 'circle' by default
49 | initialIndex?: number, // 0 by default
50 | children?: (args: KindnessPanelContentArgs) => React.Component,
51 | // by default
52 | // Useful if you're customize panel content
53 | seriesId?: string, // 'default' by default
54 | // Useful if you're having multiple
55 | // series of tutorial
56 | onClickOutside?: (e: MouseEvent) => void | boolean,
57 | // () => {} by default
58 | // If false was returned, react-kindness
59 | // stops event propagation
60 | };
61 | ```
62 |
63 |
64 | ## Props of ` `
65 |
66 | ```typescript jsx
67 | type KindnessProps = {
68 | children: ReactNode, // Required
69 | shape?: 'circle' | 'rect', // By default it's what panel says. Able to override it
70 | title?: ReactNode, // null by default
71 | message?: ReactNode, // null by default
72 | order?: number | 'auto', // 'auto' by default. Affect to steps order
73 | seriesId?: SeriesId, // 'default' by default
74 | }
75 | ```
76 |
77 | ## Customizing a panel content
78 |
79 | By default ` ` uses ` ` internally. By passing a function as a child, you can customize the content.
80 |
81 | ```typescript jsx
82 | type KindnessPanelContentArgs = {
83 | title: ReactNode;
84 | message: ReactNode;
85 | totalSize: number;
86 | currentIndex: number;
87 | goPrev: () => void;
88 | goNext: () => void;
89 | skip: () => void;
90 | goIndex: (number) => void;
91 | transitionEmitter: EventEmitter;
92 | };
93 |
94 |
95 | {
96 | ({totalSize, currentIndex, goPrev, goNext}: KindnessPanelContentArgs) => (
97 |
98 |
{`This is ${currentIndex + 1} of ${totalSize} item.`}
99 | Go previous
100 | Go next
101 |
102 | )
103 | }
104 |
105 | ```
106 |
107 | Properties of the argument is these:
108 |
109 | ## (TODO) Get additional variables from ` `
110 |
111 | When you pass a function to ` ` as a child, you can use additional variables.
112 |
113 | ```typescript jsx
114 |
115 | { (focused) => yeah
}
116 |
117 | ```
118 |
119 | ## Todo
120 |
121 | - [x] When scrolling a spot is something wrong
122 | - [x] How can I put all into a single root dom
123 | - [x] Jump to a target with [animated-scroll-to](https://www.npmjs.com/package/animated-scroll-to)
124 | - [x] Why my popper doesn't flip on viewport boundary
125 | - [x] 0.3.0 Fancy API for customising
126 | - [x] 0.4.0 More tests
127 | - [x] Scroll X
128 | - [x] `onClickOutside` of ` `
129 | - [x] Disabling user interactions `onClickOutside`
130 | - [x] feat: ` ` with smooth spot transition of each
131 | - [x] mod: Scroll to a target with decent margin even with circle spot
132 | - [x] 0.5.0 TypeScript implementation
133 | - [ ] Accept a function as a child to ` `
134 |
135 | ## License
136 |
137 | MIT
138 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | /* Basic Options */
4 | // "incremental": true, /* Enable incremental compilation */
5 | "target": "es5", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', or 'ESNEXT'. */
6 | "module": "commonjs", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */
7 | "lib": ["dom", "esnext"], /* Specify library files to be included in the compilation. */
8 | "allowJs": true, /* Allow javascript files to be compiled. */
9 | // "checkJs": true, /* Report errors in .js files. */
10 | "jsx": "react", /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */
11 | // "declaration": true, /* Generates corresponding '.d.ts' file. */
12 | // "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */
13 | "sourceMap": true, /* Generates corresponding '.map' file. */
14 | // "outFile": "./", /* Concatenate and emit output to single file. */
15 | "outDir": "dist", /* Redirect output structure to the directory. */
16 | // "rootDir": "./", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */
17 | // "composite": true, /* Enable project compilation */
18 | // "tsBuildInfoFile": "./", /* Specify file to store incremental compilation information */
19 | // "removeComments": true, /* Do not emit comments to output. */
20 | // "noEmit": true, /* Do not emit outputs. */
21 | // "importHelpers": true, /* Import emit helpers from 'tslib'. */
22 | // "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */
23 | // "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */
24 |
25 | /* Strict Type-Checking Options */
26 | "strict": true, /* Enable all strict type-checking options. */
27 | "noImplicitAny": false, /* Raise error on expressions and declarations with an implied 'any' type. */
28 | // "strictNullChecks": true, /* Enable strict null checks. */
29 | // "strictFunctionTypes": true, /* Enable strict checking of function types. */
30 | // "strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */
31 | // "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */
32 | // "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */
33 | // "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */
34 |
35 | /* Additional Checks */
36 | // "noUnusedLocals": true, /* Report errors on unused locals. */
37 | // "noUnusedParameters": true, /* Report errors on unused parameters. */
38 | // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */
39 | // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */
40 |
41 | /* Module Resolution Options */
42 | // "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */
43 | // "baseUrl": "./", /* Base directory to resolve non-absolute module names. */
44 | // "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */
45 | // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */
46 | // "typeRoots": [], /* List of folders to include type definitions from. */
47 | // "types": [], /* Type declaration files to be included in compilation. */
48 | // "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */
49 | "esModuleInterop": true, /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */
50 | // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */
51 | // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */
52 |
53 | /* Source Map Options */
54 | // "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */
55 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */
56 | // "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */
57 | // "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */
58 |
59 | /* Experimental Options */
60 | "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */
61 | "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */
62 |
63 | /* Advanced Options */
64 | "forceConsistentCasingInFileNames": true /* Disallow inconsistently-cased references to the same file. */
65 | },
66 | "exclude": [
67 | "examples",
68 | "coverage",
69 | "demo",
70 | "dist"
71 | ]
72 | }
73 |
--------------------------------------------------------------------------------
/src/index.css:
--------------------------------------------------------------------------------
1 | :root {
2 | --transition-delay: 0.4s;
3 | --arrow-height: 5px;
4 | --spot-margin: 8px;
5 | }
6 |
7 | /*******************************************************************************
8 | * Commons
9 | */
10 | .react-kindness__svg,
11 | .react-kindness-panel {
12 | z-index: 1000;
13 | }
14 |
15 | .react-kindness__svg {
16 | position: absolute;
17 | top: 0;
18 | right: 0;
19 | bottom: 0;
20 | left: 0;
21 | }
22 |
23 | .react-kindness__overlay {
24 | width: 100%;
25 | height: 100%;
26 | fill: black;
27 | opacity: .2;
28 | }
29 |
30 | /*******************************************************************************
31 | * Opacity transition of SVG and Panel
32 | */
33 |
34 | .react-kindness__svg {
35 | pointer-events: none;
36 | transition: opacity linear var(--transition-delay);
37 | }
38 |
39 | .react-kindness-panel {
40 | transition: opacity linear calc(var(--transition-delay) / 2);
41 | }
42 |
43 | .react-kindness__svg,
44 | .react-kindness-panel {
45 | opacity: 0;
46 | }
47 |
48 | .react-kindness-exit-done .react-kindness__svg,
49 | .react-kindness-exit-done .react-kindness-panel {
50 | display: none;
51 | }
52 |
53 | .react-kindness-enter .react-kindness__svg,
54 | .react-kindness-enter .react-kindness-panel {
55 | opacity: 0;
56 | }
57 |
58 | .react-kindness-enter-active .react-kindness__svg,
59 | .react-kindness-enter-done .react-kindness__svg,
60 | .react-kindness-enter-active .react-kindness-panel,
61 | .react-kindness-enter-done .react-kindness-panel {
62 | opacity: 1 !important;
63 | }
64 |
65 | .react-kindness-exit .react-kindness__svg,
66 | .react-kindness-exit .react-kindness-panel {
67 | opacity: 0
68 | }
69 |
70 | .react-kindness-exit-active .react-kindness__svg,
71 | .react-kindness-exit-done .react-kindness__svg,
72 | .react-kindness-exit-active .react-kindness-panel,
73 | .react-kindness-exit-done .react-kindness-panel {
74 | opacity: 0;
75 | }
76 |
77 | /*******************************************************************************
78 | * Spot
79 | */
80 |
81 | .react-kindness-enter-active {
82 | transition: none;
83 | }
84 |
85 | .react-kindness-enter-done .react-kindness__spot {
86 | transition:
87 | x var(--transition-delay),
88 | y var(--transition-delay),
89 | rx var(--transition-delay),
90 | ry var(--transition-delay),
91 | width var(--transition-delay),
92 | height var(--transition-delay);
93 | }
94 |
95 | /*******************************************************************************
96 | * Panel
97 | */
98 |
99 | .react-kindness-panel {
100 | box-sizing: border-box;
101 | max-width: 40vw;
102 | box-shadow: 0 2px 3px rgba(0, 0, 0, .4);
103 | }
104 | @media screen and (max-width: 480px) {
105 | .react-kindness-panel {
106 | max-width: calc(100% - 10px);
107 | }
108 | }
109 |
110 | /*******************************************************************************
111 | * Popper.js and arrow
112 | */
113 |
114 | .react-kindness-panel .react-kindness-panel__arrow {
115 | width: 0;
116 | height: 0;
117 | border-style: solid;
118 | position: absolute;
119 | margin: 5px;
120 | }
121 |
122 | .react-kindness-panel[x-placement^="top"] {
123 | margin-bottom: calc(5px + var(--spot-margin));
124 | }
125 |
126 | .react-kindness-panel[x-placement^="top"] .react-kindness-panel__arrow {
127 | border-width: 5px 5px 0 5px;
128 | border-left-color: transparent;
129 | border-right-color: transparent;
130 | border-bottom-color: transparent;
131 | bottom: -5px;
132 | left: calc(50% - 5px);
133 | margin-top: 0;
134 | margin-bottom: 0;
135 | }
136 |
137 | .react-kindness-panel[x-placement^="bottom"] {
138 | margin-top: calc(5px + var(--spot-margin));
139 | }
140 |
141 | .react-kindness-panel[x-placement^="bottom"] .react-kindness-panel__arrow {
142 | border-width: 0 5px 5px 5px;
143 | border-left-color: transparent;
144 | border-right-color: transparent;
145 | border-top-color: transparent;
146 | top: -5px;
147 | left: calc(50% - 5px);
148 | margin-top: 0;
149 | margin-bottom: 0;
150 | }
151 |
152 | .react-kindness-panel[x-placement^="right"] {
153 | margin-left: calc(5px + var(--spot-margin));
154 | }
155 |
156 | .react-kindness-panel[x-placement^="right"] .react-kindness-panel__arrow {
157 | border-width: 5px 5px 5px 0;
158 | border-left-color: transparent;
159 | border-top-color: transparent;
160 | border-bottom-color: transparent;
161 | left: -5px;
162 | top: calc(50% - 5px);
163 | margin-left: 0;
164 | margin-right: 0;
165 | }
166 |
167 | .react-kindness-panel[x-placement^="left"] {
168 | margin-right: calc(5px + var(--spot-margin));
169 | }
170 |
171 | .react-kindness-panel[x-placement^="left"] .react-kindness-panel__arrow {
172 | border-width: 5px 0 5px 5px;
173 | border-top-color: transparent;
174 | border-right-color: transparent;
175 | border-bottom-color: transparent;
176 | right: -5px;
177 | top: calc(50% - 5px);
178 | margin-left: 0;
179 | margin-right: 0;
180 | }
181 |
182 | /*******************************************************************************
183 | *
184 | * Preferences: You may want to overwrite these styles.
185 | *
186 | */
187 |
188 | .react-kindness-panel {
189 | display: flex;
190 | flex-direction: column;
191 | min-width: 200px;
192 | min-height: 150px;
193 | background-color: white;
194 | }
195 | .react-kindness-panel__spacer {
196 | flex: 1;
197 | }
198 | .react-kindness-panel__content {
199 | flex: 1;
200 | display: flex;
201 | flex-direction: column;
202 | padding: 10px;
203 | }
204 | .react-kindness-panel__title {
205 | margin: 0 0 10px;
206 | }
207 | .react-kindness-panel__message {
208 | margin: 0;
209 | }
210 | .react-kindness-panel__bottombar {
211 | padding: 10px;
212 | display: flex;
213 | background-color: ghostwhite;
214 | }
215 | .react-kindness-panel__bottombar button {
216 | padding: 5px;
217 | }
218 | .react-kindness-panel__indicator {
219 | text-align: center;
220 | margin-bottom: -5px;
221 | }
222 | .react-kindness-panel__indicator__dot {
223 | border: none;
224 | padding: 5px 3px;
225 | cursor: pointer;
226 | }
227 | .react-kindness-panel__indicator__dot--current {
228 | cursor: inherit;
229 | }
230 | .react-kindness-panel__indicator__dot:before {
231 | content: '';
232 | background-color: #ccc;
233 | display: block;
234 | width: 5px;
235 | height: 5px;
236 | border-radius: 50%;
237 | }
238 | .react-kindness-panel__indicator__dot--current:before {
239 | background-color: #333;
240 | }
241 | .react-kindness-panel__bottombar button:last-child {
242 | padding: 5px 24px;
243 | }
244 | .react-kindness-panel .react-kindness-panel__arrow {
245 | border-color: white;
246 | }
247 |
--------------------------------------------------------------------------------
/src/KindnessPanel.spec.tsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable react/no-multi-comp, @typescript-eslint/no-empty-function */
2 |
3 | import React from 'react';
4 | import assert, { deepStrictEqual } from 'assert';
5 | import './index.css';
6 | import Enzyme, { mount } from 'enzyme';
7 | import Adapter from 'enzyme-adapter-react-16';
8 | import { Kindness, KindnessPanel } from './index';
9 |
10 | Enzyme.configure({ adapter: new Adapter() });
11 |
12 | function timeout(ms) {
13 | return new Promise(resolve => setTimeout(resolve, ms));
14 | }
15 |
16 | describe(' ', function describe() {
17 | this.timeout(30 * 1000);
18 | let appContainer;
19 | let mountOpts;
20 | let app;
21 |
22 | before(async () => {
23 | appContainer = document.createElement('div');
24 | appContainer.id = 'root';
25 | document.body.appendChild(appContainer);
26 | mountOpts = { attachTo: appContainer };
27 | });
28 |
29 | afterEach(async () => {
30 | app && app.unmount();
31 | });
32 |
33 | after(async () => {
34 | document.body.removeChild(appContainer);
35 | });
36 |
37 | it('shows nothing when no ', async () => {
38 | app = mount(
39 |
40 | {}} />
41 |
,
42 | mountOpts,
43 | );
44 | assert(document.querySelector('[class^="react-kindness"]'));
45 | deepStrictEqual(
46 | getComputedStyle(document.querySelector('.react-kindness__svg')!).opacity,
47 | '0',
48 | );
49 | app.setProps({ enabled: String(true) });
50 | await timeout(500); // Wait for transition
51 | assert(document.querySelector('[class^="react-kindness"]'));
52 | deepStrictEqual(
53 | getComputedStyle(document.querySelector('.react-kindness__svg')!).opacity,
54 | '0',
55 | );
56 | });
57 |
58 | it('shows and hide a panel when exists', async () => {
59 | class App extends React.Component {
60 | constructor(props) {
61 | super(props);
62 | this.state = { showKindness: false };
63 | }
64 |
65 | render() {
66 | const { showKindness } = this.state;
67 | return (
68 |
69 | {
72 | this.setState({ showKindness: false });
73 | }}
74 | />
75 |
76 | yeah blaaa
77 |
78 |
79 | );
80 | }
81 | }
82 | app = mount( , mountOpts);
83 |
84 | app.setState({ showKindness: true });
85 | await timeout(1000); // Wait for transition
86 | deepStrictEqual(
87 | getComputedStyle(document.querySelector('.react-kindness__svg')!).opacity,
88 | '1',
89 | );
90 |
91 | const nextEl = app.find('.react-kindness-panel__bottombar button').last();
92 | nextEl.simulate('click');
93 | await timeout(500); // Wait for transition
94 | deepStrictEqual(
95 | getComputedStyle(document.querySelector('.react-kindness__svg')!).opacity,
96 | '0',
97 | );
98 | });
99 |
100 | it('can initially shows on componentDidMount', async () => {
101 | app = mount(
102 |
103 |
{}} />
104 |
105 |
106 | yeah
107 |
108 |
109 | ,
110 | mountOpts,
111 | );
112 | assert(document.querySelector('[class^="react-kindness"]'));
113 | await timeout(500); // Wait for transition
114 | deepStrictEqual(
115 | getComputedStyle(document.querySelector('.react-kindness__svg')!).opacity,
116 | '1',
117 | );
118 | });
119 |
120 | it('follows order of on click "next"', async () => {
121 | class App extends React.Component {
122 | constructor(props) {
123 | super(props);
124 | this.state = {
125 | showKindness: false,
126 | };
127 | }
128 |
129 | render() {
130 | const { showKindness } = this.state;
131 | return (
132 |
133 | {
136 | this.setState({ showKindness: false });
137 | }}
138 | />
139 |
140 |
141 | zero
142 |
143 |
144 |
145 |
146 | two
147 |
148 |
149 |
150 |
151 | one
152 |
153 |
154 |
155 |
156 | three
157 |
158 |
159 |
160 |
161 | four
162 |
163 |
164 |
165 |
166 | five
167 |
168 |
169 |
170 | );
171 | }
172 | }
173 | app = mount( , mountOpts);
174 |
175 | app.setState({ showKindness: true });
176 | await timeout(500); // Wait for transition
177 | const nextEl = app.find('.react-kindness-panel__bottombar button').last();
178 | deepStrictEqual(app.find('.react-kindness-panel__message').text(), 'zero');
179 |
180 | nextEl.simulate('click');
181 | await timeout(500); // Wait for transition
182 | deepStrictEqual(app.find('.react-kindness-panel__message').text(), 'one');
183 |
184 | nextEl.simulate('click');
185 | await timeout(500); // Wait for transition
186 | deepStrictEqual(app.find('.react-kindness-panel__message').text(), 'two');
187 |
188 | nextEl.simulate('click');
189 | await timeout(500); // Wait for transition
190 | deepStrictEqual(app.find('.react-kindness-panel__message').text(), 'three');
191 |
192 | nextEl.simulate('click');
193 | await timeout(500); // Wait for transition
194 | deepStrictEqual(app.find('.react-kindness-panel__message').text(), 'four');
195 |
196 | nextEl.simulate('click');
197 | await timeout(500); // Wait for transition
198 | deepStrictEqual(app.find('.react-kindness-panel__message').text(), 'five');
199 |
200 | nextEl.simulate('click');
201 | await timeout(500); // Wait for transition
202 | deepStrictEqual(
203 | getComputedStyle(document.querySelector('.react-kindness__svg')!).opacity,
204 | '0',
205 | );
206 | });
207 |
208 | it('scrolls to the target', async () => {
209 | app = mount(
210 |
211 |
{}} />
212 |
224 |
225 |
226 |
227 | yeah
228 |
229 |
230 |
231 | ,
232 | mountOpts,
233 | );
234 | assert(document.querySelector('[class^="react-kindness"]'));
235 | await timeout(500); // Wait for transition
236 | deepStrictEqual(window.scrollY, 0);
237 | const nextEl = app.find('.react-kindness-panel__bottombar button').last();
238 |
239 | nextEl.simulate('click');
240 | await timeout(1000); // Wait for transition
241 | const lastScrollY = window.scrollY;
242 | assert(lastScrollY > 0);
243 |
244 | nextEl.simulate('click');
245 | await timeout(1000); // Wait for transition
246 | assert(window.scrollY < lastScrollY);
247 | });
248 | });
249 |
--------------------------------------------------------------------------------
/examples/basic/style.css:
--------------------------------------------------------------------------------
1 | /* Copied from https://pages-themes.github.io/cayman/assets/css/style.css */
2 | /*! normalize.css v3.0.2 | MIT License | git.io/normalize */
3 | @import url("https://fonts.googleapis.com/css?family=Open+Sans:400,700");html{font-family:sans-serif;-ms-text-size-adjust:100%;-webkit-text-size-adjust:100%}body{margin:0}article,aside,details,figcaption,figure,footer,header,hgroup,main,menu,nav,section,summary{display:block}audio,canvas,progress,video{display:inline-block;vertical-align:baseline}audio:not([controls]){display:none;height:0}[hidden],template{display:none}a{background-color:transparent}a:active,a:hover{outline:0}abbr[title]{border-bottom:1px dotted}b,strong{font-weight:bold}dfn{font-style:italic}h1{font-size:2em;margin:0.67em 0}mark{background:#ff0;color:#000}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sup{top:-0.5em}sub{bottom:-0.25em}img{border:0}svg:not(:root){overflow:hidden}figure{margin:1em 40px}hr{box-sizing:content-box;height:0}pre{overflow:auto}code,kbd,pre,samp{font-family:monospace, monospace;font-size:1em}button,input,optgroup,select,textarea{color:inherit;font:inherit;margin:0}button{overflow:visible}button,select{text-transform:none}button,html input[type="button"],input[type="reset"],input[type="submit"]{-webkit-appearance:button;cursor:pointer}button[disabled],html input[disabled]{cursor:default}button::-moz-focus-inner,input::-moz-focus-inner{border:0;padding:0}input{line-height:normal}input[type="checkbox"],input[type="radio"]{box-sizing:border-box;padding:0}input[type="number"]::-webkit-inner-spin-button,input[type="number"]::-webkit-outer-spin-button{height:auto}input[type="search"]{-webkit-appearance:textfield;box-sizing:content-box}input[type="search"]::-webkit-search-cancel-button,input[type="search"]::-webkit-search-decoration{-webkit-appearance:none}fieldset{border:1px solid #c0c0c0;margin:0 2px;padding:0.35em 0.625em 0.75em}legend{border:0;padding:0}textarea{overflow:auto}optgroup{font-weight:bold}table{border-collapse:collapse;border-spacing:0}td,th{padding:0}.highlight table td{padding:5px}.highlight table pre{margin:0}.highlight .cm{color:#999988;font-style:italic}.highlight .cp{color:#999999;font-weight:bold}.highlight .c1{color:#999988;font-style:italic}.highlight .cs{color:#999999;font-weight:bold;font-style:italic}.highlight .c,.highlight .cd{color:#999988;font-style:italic}.highlight .err{color:#a61717;background-color:#e3d2d2}.highlight .gd{color:#000000;background-color:#ffdddd}.highlight .ge{color:#000000;font-style:italic}.highlight .gr{color:#aa0000}.highlight .gh{color:#999999}.highlight .gi{color:#000000;background-color:#ddffdd}.highlight .go{color:#888888}.highlight .gp{color:#555555}.highlight .gs{font-weight:bold}.highlight .gu{color:#aaaaaa}.highlight .gt{color:#aa0000}.highlight .kc{color:#000000;font-weight:bold}.highlight .kd{color:#000000;font-weight:bold}.highlight .kn{color:#000000;font-weight:bold}.highlight .kp{color:#000000;font-weight:bold}.highlight .kr{color:#000000;font-weight:bold}.highlight .kt{color:#445588;font-weight:bold}.highlight .k,.highlight .kv{color:#000000;font-weight:bold}.highlight .mf{color:#009999}.highlight .mh{color:#009999}.highlight .il{color:#009999}.highlight .mi{color:#009999}.highlight .mo{color:#009999}.highlight .m,.highlight .mb,.highlight .mx{color:#009999}.highlight .sb{color:#d14}.highlight .sc{color:#d14}.highlight .sd{color:#d14}.highlight .s2{color:#d14}.highlight .se{color:#d14}.highlight .sh{color:#d14}.highlight .si{color:#d14}.highlight .sx{color:#d14}.highlight .sr{color:#009926}.highlight .s1{color:#d14}.highlight .ss{color:#990073}.highlight .s{color:#d14}.highlight .na{color:#008080}.highlight .bp{color:#999999}.highlight .nb{color:#0086B3}.highlight .nc{color:#445588;font-weight:bold}.highlight .no{color:#008080}.highlight .nd{color:#3c5d5d;font-weight:bold}.highlight .ni{color:#800080}.highlight .ne{color:#990000;font-weight:bold}.highlight .nf{color:#990000;font-weight:bold}.highlight .nl{color:#990000;font-weight:bold}.highlight .nn{color:#555555}.highlight .nt{color:#000080}.highlight .vc{color:#008080}.highlight .vg{color:#008080}.highlight .vi{color:#008080}.highlight .nv{color:#008080}.highlight .ow{color:#000000;font-weight:bold}.highlight .o{color:#000000;font-weight:bold}.highlight .w{color:#bbbbbb}.highlight{background-color:#f8f8f8}*{box-sizing:border-box}body{padding:0;margin:0;font-family:"Open Sans", "Helvetica Neue", Helvetica, Arial, sans-serif;font-size:16px;line-height:1.5;color:#606c71}#skip-to-content{height:1px;width:1px;position:absolute;overflow:hidden;top:-10px}#skip-to-content:focus{position:fixed;top:10px;left:10px;height:auto;width:auto;background:#e19447;outline:thick solid #e19447}a{color:#1e6bb8;text-decoration:none}a:hover{text-decoration:underline}.btn{display:inline-block;margin-bottom:1rem;color:rgba(255,255,255,0.7);background-color:rgba(255,255,255,0.08);border-color:rgba(255,255,255,0.2);border-style:solid;border-width:1px;border-radius:0.3rem;transition:color 0.2s, background-color 0.2s, border-color 0.2s}.btn:hover{color:rgba(255,255,255,0.8);text-decoration:none;background-color:rgba(255,255,255,0.2);border-color:rgba(255,255,255,0.3)}.btn+.btn{margin-left:1rem}@media screen and (min-width: 64em){.btn{padding:0.75rem 1rem}}@media screen and (min-width: 42em) and (max-width: 64em){.btn{padding:0.6rem 0.9rem;font-size:0.9rem}}@media screen and (max-width: 42em){.btn{display:block;width:100%;padding:0.75rem;font-size:0.9rem}.btn+.btn{margin-top:1rem;margin-left:0}}.page-header{color:#fff;text-align:center;background-color:#159957;background-image:linear-gradient(120deg, #155799, #159957)}@media screen and (min-width: 64em){.page-header{padding:5rem 6rem}}@media screen and (min-width: 42em) and (max-width: 64em){.page-header{padding:3rem 4rem}}@media screen and (max-width: 42em){.page-header{padding:2rem 1rem}}.project-name{margin-top:0;margin-bottom:0.1rem}@media screen and (min-width: 64em){.project-name{font-size:3.25rem}}@media screen and (min-width: 42em) and (max-width: 64em){.project-name{font-size:2.25rem}}@media screen and (max-width: 42em){.project-name{font-size:1.75rem}}.project-tagline{margin-bottom:2rem;font-weight:normal;opacity:0.7}@media screen and (min-width: 64em){.project-tagline{font-size:1.25rem}}@media screen and (min-width: 42em) and (max-width: 64em){.project-tagline{font-size:1.15rem}}@media screen and (max-width: 42em){.project-tagline{font-size:1rem}}.main-content{word-wrap:break-word}.main-content :first-child{margin-top:0}@media screen and (min-width: 64em){.main-content{max-width:64rem;padding:2rem 6rem;margin:0 auto;font-size:1.1rem}}@media screen and (min-width: 42em) and (max-width: 64em){.main-content{padding:2rem 4rem;font-size:1.1rem}}@media screen and (max-width: 42em){.main-content{padding:2rem 1rem;font-size:1rem}}.main-content img{max-width:100%}.main-content h1,.main-content h2,.main-content h3,.main-content h4,.main-content h5,.main-content h6{margin-top:2rem;margin-bottom:1rem;font-weight:normal;color:#159957}.main-content p{margin-bottom:1em}.main-content code{padding:2px 4px;font-family:Consolas, "Liberation Mono", Menlo, Courier, monospace;font-size:0.9rem;color:#567482;background-color:#f3f6fa;border-radius:0.3rem}.main-content pre{padding:0.8rem;margin-top:0;margin-bottom:1rem;font:1rem Consolas, "Liberation Mono", Menlo, Courier, monospace;color:#567482;word-wrap:normal;background-color:#f3f6fa;border:solid 1px #dce6f0;border-radius:0.3rem}.main-content pre>code{padding:0;margin:0;font-size:0.9rem;color:#567482;word-break:normal;white-space:pre;background:transparent;border:0}.main-content .highlight{margin-bottom:1rem}.main-content .highlight pre{margin-bottom:0;word-break:normal}.main-content .highlight pre,.main-content pre{padding:0.8rem;overflow:auto;font-size:0.9rem;line-height:1.45;border-radius:0.3rem;-webkit-overflow-scrolling:touch}.main-content pre code,.main-content pre tt{display:inline;max-width:initial;padding:0;margin:0;overflow:initial;line-height:inherit;word-wrap:normal;background-color:transparent;border:0}.main-content pre code:before,.main-content pre code:after,.main-content pre tt:before,.main-content pre tt:after{content:normal}.main-content ul,.main-content ol{margin-top:0}.main-content blockquote{padding:0 1rem;margin-left:0;color:#819198;border-left:0.3rem solid #dce6f0}.main-content blockquote>:first-child{margin-top:0}.main-content blockquote>:last-child{margin-bottom:0}.main-content table{display:block;width:100%;overflow:auto;word-break:normal;word-break:keep-all;-webkit-overflow-scrolling:touch}.main-content table th{font-weight:bold}.main-content table th,.main-content table td{padding:0.5rem 1rem;border:1px solid #e9ebec}.main-content dl{padding:0}.main-content dl dt{padding:0;margin-top:1rem;font-size:1rem;font-weight:bold}.main-content dl dd{padding:0;margin-bottom:1rem}.main-content hr{height:2px;padding:0;margin:1rem 0;background-color:#eff0f1;border:0}.site-footer{padding-top:2rem;margin-top:2rem;border-top:solid 1px #eff0f1}@media screen and (min-width: 64em){.site-footer{font-size:1rem}}@media screen and (min-width: 42em) and (max-width: 64em){.site-footer{font-size:1rem}}@media screen and (max-width: 42em){.site-footer{font-size:0.9rem}}.site-footer-owner{display:block;font-weight:bold}.site-footer-credits{color:#819198}
4 |
--------------------------------------------------------------------------------
/examples/basic/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { render } from 'react-dom';
3 | import { Kindness, KindnessPanel } from '../../src';
4 | import './style.css';
5 | import '../../src/index.css';
6 |
7 | class App extends React.Component {
8 | constructor(props) {
9 | super(props);
10 | this.state = {
11 | showKindness: true,
12 | };
13 | }
14 |
15 | render() {
16 | const { showKindness } = this.state;
17 | return (
18 |
19 | this.setState({ showKindness: false })}
22 | // onClickOutside={() => false}
23 | />
24 |
25 |
26 |
27 |
32 | react-kindness
33 | {' '}
34 | DEMO
35 |
36 |
37 | A lightweight, fully-customizable kind screen guide for React
38 |
39 |
40 |
41 | this.setState({ showKindness: !showKindness })}
45 | >
46 | {!showKindness ? 'Start kindness' : 'Stop kindness'}
47 |
48 |
49 |
53 |
57 | README
58 |
59 |
60 |
61 |
62 |
63 |
64 | Text can be
65 | bold ,italic , or
66 | strikethrough.
67 |
68 |
69 |
70 | Link to another page .
71 |
72 |
73 | There should be whitespace between paragraphs.
74 |
75 |
76 | There should be whitespace between paragraphs. We recommend
77 | including a README, or a file with information about your project.
78 |
79 |
80 |
85 |
86 |
87 |
88 |
89 | This is a normal paragraph following a header. GitHub is a code
90 | hosting platform for version control and collaboration. It lets you
91 | and others work together on projects from anywhere.
92 |
93 |
94 |
95 |
96 |
97 | This is a blockquote following a header.
98 |
99 |
100 | When something is important enough, you do it even if the odds are
101 | not in your favor.
102 |
103 |
104 |
105 |
106 |
107 |
108 |
109 |
110 | This is an unordered list following a header.
111 | This is an unordered list following a header.
112 | This is an unordered list following a header.
113 |
114 |
115 |
116 |
117 |
118 | This is an ordered list following a header.
119 | This is an ordered list following a header.
120 | This is an ordered list following a header.
121 |
122 |
123 |
124 |
125 |
126 |
127 |
128 | head1
129 | head two
130 | three
131 |
132 |
133 |
134 |
135 | ok
136 | good swedish fish
137 | nice
138 |
139 |
140 | out of stock
141 | good and plenty
142 | nice
143 |
144 |
145 | ok
146 |
147 | good
148 | oreos
149 |
150 | hmm
151 |
152 |
153 | ok
154 |
155 | good
156 | zoute drop
157 |
158 | yumm
159 |
160 |
161 |
162 |
163 |
164 | There’s a horizontal rule below this.
165 |
166 |
167 |
168 |
169 | Here is an unordered list:
170 |
171 |
172 | Item foo
173 | Item bar
174 | Item baz
175 | Item zip
176 |
177 |
178 | And an ordered list:
179 |
180 |
181 | Item one
182 | Item two
183 | Item three
184 | Item four
185 |
186 |
187 | And a nested list:
188 |
189 |
190 |
191 | level 1 item
192 |
193 | level 2 item
194 |
195 | level 2 item
196 |
197 | level 3 item
198 | level 3 item
199 |
200 |
201 |
202 |
203 |
204 | level 1 item
205 |
206 | level 2 item
207 | level 2 item
208 | level 2 item
209 |
210 |
211 |
212 | level 1 item
213 |
214 | level 2 item
215 | level 2 item
216 |
217 |
218 | level 1 item
219 |
220 |
221 | Small image
222 |
223 |
224 |
228 |
232 |
233 |
234 |
235 | Large image
236 |
237 |
238 |
242 |
243 |
244 |
245 | Definition lists can be used with HTML syntax.
246 |
247 |
248 |
249 | Name
250 | Godzilla
251 | Born
252 | 1952
253 | Birthplace
254 | Japan
255 | Color
256 | Green
257 |
258 |
259 |
260 |
261 |
262 | The final element.
263 |
264 |
265 |
266 |
267 |
279 |
280 |
281 | );
282 | }
283 | }
284 |
285 | render( , document.getElementById('root'));
286 |
--------------------------------------------------------------------------------
/src/KindnessPanel.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import ReactDOM from 'react-dom';
3 | import { CSSTransition } from 'react-transition-group';
4 | import Popper from 'popper.js';
5 | import { getReferenceOffsets } from 'popper.js/dist/popper-utils';
6 |
7 | import debounce from 'lodash.debounce';
8 | import { EventEmitter } from 'events';
9 | import { BLUR_STD_DEVIATION, OVERLAY_TRANSITION_DELAY } from './lib/const';
10 |
11 | import { Series, seriesPool } from './series';
12 | import {
13 | svgClassName,
14 | overlayClassName,
15 | panelClassName,
16 | rootClassName,
17 | spotClassName,
18 | } from './classNames';
19 | import KindnessPanelContent from './KindnessPanelContent';
20 | import {
21 | createOverlayStyle,
22 | scrollViewport,
23 | createRectSvgStyle,
24 | createCircleSvgStyle,
25 | insideViewport,
26 | } from './lib/fns';
27 | import { SpotShapes } from './types';
28 |
29 | type KindnessPanelProps = {
30 | enabled: boolean;
31 | onExit: () => void;
32 | initialIndex: number;
33 | shape: SpotShapes;
34 | seriesId: string;
35 | onClickOutside: (e: MouseEvent) => void | boolean;
36 | };
37 |
38 | type KindnessPanelState = {
39 | spotOffset: number | null;
40 | overlayStyle: any;
41 | };
42 |
43 | export default class KindnessPanel extends React.Component<
44 | KindnessPanelProps,
45 | KindnessPanelState
46 | > {
47 | spotIndex = -1;
48 | series: Series;
49 | isViewportEventObserved = false;
50 | panel: HTMLDivElement | null = null; // React.Ref;
51 | spot: SVGRectElement | null = null; // React.Ref;
52 | svg: SVGElement | null = null; // React.Ref;
53 | popper: Popper | null = null;
54 | transitionEmitter: EventEmitter = new EventEmitter();
55 | onWindowResize: () => void;
56 |
57 | static defaultProps = {
58 | initialIndex: 0,
59 | shape: 'circle',
60 | seriesId: 'default',
61 | onClickOutside: () => {},
62 | // eslint-disable-next-line react/display-name
63 | children: panelContentProps => (
64 |
65 | ),
66 | };
67 |
68 | constructor(props) {
69 | super(props);
70 |
71 | this.series = seriesPool.getOrCreate(props.seriesId);
72 | this.state = {
73 | spotOffset: null,
74 | overlayStyle: {},
75 | };
76 |
77 | this.onWindowResize = debounce(this.updateOverlayStyle, 10);
78 | }
79 |
80 | componentDidMount() {
81 | const { enabled, initialIndex } = this.props;
82 | if (enabled) {
83 | // Wait for mount of children
84 | setTimeout(() => {
85 | this.updateSpot(initialIndex);
86 | }, 0);
87 | } else {
88 | this.spotIndex = initialIndex;
89 | }
90 | }
91 |
92 | componentDidUpdate(prevProps) {
93 | const { enabled } = this.props;
94 | const { spotIndex } = this;
95 |
96 | if (!prevProps.enabled && enabled) {
97 | this.updateSpot(spotIndex);
98 | }
99 | }
100 |
101 | componentWillUnmount() {
102 | if (!window.document) return;
103 | this.disposeListeners();
104 | }
105 |
106 | onDocumentClick = e => {
107 | const { enabled, onClickOutside } = this.props;
108 | if (!enabled) return;
109 | if (!this.panel) return;
110 | if (!this.series.hasKindnessByIndex(this.spotIndex)) return;
111 | const kEl = this.series.getKindnessElementByIndex(this.spotIndex);
112 | if (this.panel.contains(e.target) || kEl.contains(e.target)) return;
113 | const rv = onClickOutside(e);
114 |
115 | if (rv === false) {
116 | // Block the user interaction
117 | e.preventDefault();
118 | e.stopPropagation();
119 | e.stopImmediatePropagation();
120 | if (!this.svg) return;
121 | const handler = () => {
122 | setTimeout(() => {
123 | this.svg!.style.pointerEvents = '';
124 | document.removeEventListener('mouseup', handler);
125 | });
126 | };
127 | document.addEventListener('mouseup', handler);
128 | this.svg.style.pointerEvents = 'auto';
129 | }
130 | };
131 |
132 | onOverlayDisapeared = () => {
133 | const { initialIndex } = this.props;
134 | this.spotIndex = initialIndex;
135 | this.disposeListeners();
136 | };
137 |
138 | updateOverlayStyle = () => {
139 | this.setState({
140 | spotOffset: this.createSpotOffset(this.spotIndex),
141 | overlayStyle: createOverlayStyle(),
142 | });
143 | this.forceUpdateOverlaySVG();
144 | };
145 |
146 | skip = () => {
147 | const { onExit } = this.props;
148 | onExit();
149 | };
150 |
151 | goNext = () => {
152 | this.incSpotIndex(true);
153 | };
154 |
155 | goPrev = () => {
156 | this.incSpotIndex(false);
157 | };
158 |
159 | goIndex = index => {
160 | if (!this.series.hasKindnessByIndex(index)) return;
161 | this.updateSpot(index);
162 | };
163 |
164 | updateSpot(newIndex) {
165 | this.spotIndex = newIndex;
166 | this.reattachListeners(newIndex);
167 | const spotOffset = this.createSpotOffset(newIndex);
168 | this.setState({
169 | spotOffset,
170 | overlayStyle: createOverlayStyle(),
171 | });
172 |
173 | if (newIndex >= 0 && this.svg && this.spot && spotOffset) {
174 | const k = this.series.getKindnessByIndex(this.spotIndex);
175 | if (!k) throw new Error('boom');
176 | const { shape: shapeSpecific } = k.props;
177 | const { shape: shapeBase } = this.props;
178 | scrollViewport(shapeSpecific || shapeBase, spotOffset);
179 | }
180 | }
181 |
182 | forceUpdateOverlaySVG() {
183 | // At least Chrome often fails drawing overlay rect after window resize
184 | if (!this.svg) return;
185 | const old = this.svg.getAttribute('width');
186 | this.svg.setAttribute('width', '200%');
187 | setTimeout(() => {
188 | if (!this.svg) return;
189 | this.svg.setAttribute('width', old!);
190 | });
191 | }
192 |
193 | incSpotIndex(increment) {
194 | const { spotIndex } = this;
195 | const newIndex = spotIndex + (increment ? 1 : -1);
196 | this.goIndex(newIndex);
197 | }
198 |
199 | reattachListeners(spotIndex) {
200 | if (!this.panel) throw new Error('');
201 |
202 | this.disposeListeners();
203 |
204 | if (this.series.hasKindnessByIndex(spotIndex)) {
205 | const targetEl = this.series.getKindnessElementByIndex(spotIndex);
206 | if (!targetEl) throw new Error('!??');
207 | this.popper = new Popper(targetEl, this.panel, {
208 | modifiers: {
209 | insideViewport: {
210 | order: 840,
211 | enabled: true,
212 | fn: insideViewport,
213 | },
214 | },
215 | });
216 | }
217 |
218 | if (!this.isViewportEventObserved) {
219 | window.addEventListener('resize', this.onWindowResize);
220 | window.document.addEventListener('mousedown', this.onDocumentClick, true);
221 | this.isViewportEventObserved = true;
222 | }
223 | }
224 |
225 | disposeListeners() {
226 | if (this.popper) {
227 | this.popper.destroy();
228 | this.popper = null;
229 | }
230 | if (this.isViewportEventObserved) {
231 | window.removeEventListener('resize', this.onWindowResize);
232 | window.document.removeEventListener(
233 | 'mousedown',
234 | this.onDocumentClick,
235 | true,
236 | );
237 | this.isViewportEventObserved = false;
238 | }
239 | }
240 |
241 | createSpotOffset(spotIndex) {
242 | if (this.panel && this.spot && this.series.hasKindnessByIndex(spotIndex)) {
243 | const targetEl = this.series.getKindnessElementByIndex(spotIndex);
244 | return getReferenceOffsets(null, this.panel, targetEl);
245 | }
246 | return null;
247 | }
248 |
249 | render() {
250 | if (!window.document) return null;
251 | const { enabled, shape: spotShapeBase, children } = this.props;
252 | const { spotOffset, overlayStyle } = this.state;
253 | const k = this.series.getKindnessByIndex(this.spotIndex);
254 | // @ts-ignore
255 | const { shape: spotShapeSpecific, title, message } = k ? k.props : {};
256 | const { spotIndex } = this;
257 | const spotShape = spotShapeSpecific || spotShapeBase;
258 |
259 | let spotStyle;
260 | if (spotOffset) {
261 | spotStyle =
262 | spotShape === 'rect'
263 | ? createRectSvgStyle(spotOffset)
264 | : createCircleSvgStyle(spotOffset);
265 | }
266 |
267 | const wasMounted = Boolean(this.panel);
268 | const panelContentProps = {
269 | title,
270 | message,
271 | totalSize: this.series.size,
272 | currentIndex: this.spotIndex,
273 | goPrev: this.series.hasKindnessByIndex(spotIndex - 1)
274 | ? this.goPrev
275 | : null,
276 | goNext: this.series.hasKindnessByIndex(spotIndex + 1)
277 | ? this.goNext
278 | : null,
279 | goIndex: this.goIndex,
280 | skip: this.skip,
281 | transitionEmitter: this.transitionEmitter,
282 | };
283 |
284 | // @ts-ignore
285 | const panelContent = children(panelContentProps);
286 |
287 | return ReactDOM.createPortal(
288 | this.transitionEmitter.emit('onEntered')}
293 | onExited={this.onOverlayDisapeared}
294 | >
295 |
296 |
(this.svg = e)}
298 | className={svgClassName}
299 | style={overlayStyle}
300 | width="100%"
301 | height="100%"
302 | >
303 |
304 |
308 |
309 |
310 |
311 |
312 | (this.spot = e)}
315 | fill="black"
316 | filter="url(#blurFilter)"
317 | {...(spotOffset ? spotStyle : null)}
318 | />
319 |
320 |
321 |
322 |
323 |
(this.panel = e)} className={panelClassName}>
324 | {panelContent}
325 |
326 |
327 | ,
328 | window.document.body,
329 | );
330 | }
331 | }
332 |
--------------------------------------------------------------------------------