├── .babelrc
├── .eslintrc
├── .gitignore
├── .storybook
├── addons.js
├── config.js
└── webpack.config.js
├── LICENSE
├── README.md
├── example
├── bundle.js
├── bundle.js.map
├── index.css
├── index.html
├── index.jsx
└── reactLogo.svg
├── package.json
├── src
├── Portal.js
├── __tests__
│ ├── MobileTooltip.test.jsx
│ ├── Tooltip.node.test.jsx
│ ├── Tooltip.test.jsx
│ ├── __image_snapshots__
│ │ ├── storyshots-test-jsx-storyshots-tooltip-arrow-size-and-distance-1-snap.png
│ │ ├── storyshots-test-jsx-storyshots-tooltip-basic-usage-1-snap.png
│ │ ├── storyshots-test-jsx-storyshots-tooltip-colors-1-snap.png
│ │ ├── storyshots-test-jsx-storyshots-tooltip-compound-alignment-1-snap.png
│ │ ├── storyshots-test-jsx-storyshots-tooltip-custom-arrow-content-1-snap.png
│ │ ├── storyshots-test-jsx-storyshots-tooltip-custom-events-1-snap.png
│ │ ├── storyshots-test-jsx-storyshots-tooltip-html-content-1-snap.png
│ │ ├── storyshots-test-jsx-storyshots-tooltip-in-a-paragraph-1-snap.png
│ │ ├── storyshots-test-jsx-storyshots-tooltip-nested-targets-1-snap.png
│ │ └── storyshots-test-jsx-storyshots-tooltip-wrap-an-image-1-snap.png
│ └── storyshots.test.jsx
├── functions.js
├── getDirection.js
├── index.d.ts
├── index.jsx
└── position.js
├── stories
├── Wrapper.css
├── Wrapper.jsx
├── arrowContent.css
├── arrowContent.stories.jsx
├── arrowSize.stories.jsx
├── basic.stories.jsx
├── colors.stories.jsx
├── compoundAlignment.stories.jsx
├── events.stories.jsx
├── html.stories.jsx
├── image.stories.jsx
├── nestedTargets.stories.jsx
├── paragraph.stories.jsx
├── shared.js
├── stories.css
└── strictMode.stories.jsx
├── test
├── jest.config.json
├── jest.storyshots.config.json
├── jest.storyshots.setup.js
└── styleMock.js
├── webpack.config.js
├── webpack.dist.config.js
└── yarn.lock
/.babelrc:
--------------------------------------------------------------------------------
1 | {
2 | "presets": [
3 | "@babel/preset-env",
4 | "@babel/preset-react"
5 | ],
6 | "plugins": ["@babel/plugin-proposal-class-properties"],
7 | "env": {
8 | "test": {
9 | "plugins": ["require-context-hook"]
10 | }
11 | }
12 | }
13 |
--------------------------------------------------------------------------------
/.eslintrc:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "eslint-config-airbnb",
3 | "parser": "babel-eslint",
4 | "parserOptions": {
5 | "ecmaVersion": 6,
6 | "sourceType": "module",
7 | "ecmaFeatures": {
8 | "experimentalObjectRestSpread": true
9 | }
10 | },
11 | "plugins": [
12 | "prefer-object-spread"
13 | ],
14 | "rules": {
15 | "indent": [
16 | 1,
17 | 2,
18 | {
19 | "SwitchCase": 1
20 | }
21 | ],
22 | "keyword-spacing": [1],
23 | "linebreak-style": [
24 | 2,
25 | "unix"
26 | ],
27 | "new-cap": [1],
28 | "padded-blocks": [0],
29 | "space-before-blocks": [
30 | 1,
31 | "always"
32 | ],
33 | "space-before-function-paren": [
34 | 2,
35 | {
36 | "anonymous": "always",
37 | "named": "never"
38 | }
39 | ],
40 | "react/jsx-indent": [
41 | 2,
42 | 2
43 | ],
44 | "react/destructuring-assignment": [0],
45 | "max-len": [
46 | 1,
47 | 150
48 | ],
49 | // warns when trying to use Object.assign()
50 | "prefer-object-spread/prefer-object-spread": 2,
51 | // TODO: remove after refactor
52 | "import/no-cycle": [0],
53 | "react/forbid-prop-types": [0],
54 | "consistent-return": [0],
55 | "no-unused-expressions": [0],
56 | "import/no-extraneous-dependencies": [0],
57 | "object-curly-newline": [0]
58 | },
59 | "globals": {
60 | "__CLIENT__": true
61 | },
62 | "env": {
63 | "browser": true,
64 | "node": true,
65 | "jest": true
66 | }
67 | }
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | dist
3 |
--------------------------------------------------------------------------------
/.storybook/addons.js:
--------------------------------------------------------------------------------
1 | import '@storybook/addon-knobs/register';
2 | import '@storybook/addon-viewport/register';
3 |
--------------------------------------------------------------------------------
/.storybook/config.js:
--------------------------------------------------------------------------------
1 | import { configure, addDecorator, addParameters } from '@storybook/react';
2 | import { withKnobs } from '@storybook/addon-knobs';
3 | import { INITIAL_VIEWPORTS } from '@storybook/addon-viewport';
4 |
5 | import '../stories/stories.css';
6 |
7 | // automatically import all files ending in *.stories.js
8 | const req = require.context('../stories', true, /\.stories\.jsx?$/);
9 |
10 | addDecorator(withKnobs);
11 |
12 | const newViewports = {
13 | storyshots: {
14 | name: 'storyshots',
15 | styles: {
16 | width: '800px',
17 | height: '600px',
18 | },
19 | },
20 | responsive: {
21 | name: 'Responsive',
22 | styles: {
23 | width: '100%',
24 | height: '100%',
25 | },
26 | type: 'desktop',
27 | }
28 | };
29 |
30 | addParameters({
31 | viewport: {
32 | defaultViewport: 'storyshots',
33 | viewports: {
34 | ...INITIAL_VIEWPORTS,
35 | ...newViewports,
36 | },
37 | },
38 | });
39 |
40 | function loadStories() {
41 | req.keys().forEach(filename => req(filename));
42 | }
43 |
44 | configure(loadStories, module);
45 |
--------------------------------------------------------------------------------
/.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 |
9 | module.exports = {
10 | plugins: [
11 | // your custom plugins
12 | ],
13 | module: {
14 | rules: [
15 | {
16 | test: /\.css$/,
17 | use: ['style-loader', 'css-loader'],
18 | },
19 | ],
20 | },
21 | };
22 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2019 Benny Sidelinger
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # React tooltip-lite
2 |
3 | A lightweight and responsive tooltip. Feel free to Post an [issue](https://github.com/bsidelinger912/react-tooltip-lite/issues) if you're looking to support more use cases.
4 |
5 | ## Getting started
6 |
7 | #### 1. Install with NPM
8 | ```
9 | $ npm install react-tooltip-lite
10 | ```
11 |
12 |
13 | #### 2. Import into your react Component
14 | ```
15 | import Tooltip from 'react-tooltip-lite';
16 | ```
17 |
18 |
19 | #### 3. Wrap any element with the Tooltip component to make it a target
20 | ```
21 |
22 | edge
23 |
24 | ```
25 |
26 |
27 |
28 | **CodePen demo**: [http://codepen.io/bsidelinger912/pen/WOdPNK](http://codepen.io/bsidelinger912/pen/WOdPNK)
29 |
30 |
31 |
32 | ### styling
33 | By default you need to style react-tooltip-lite with CSS, this allows for psuedo elements and some cool border tricks, as well as using css/sass/less variables and such to keep your colors consistent. (Note: as of version 1.2.0 you can also pass the "useDefaultStyles" prop which will allow you to use react-tooltip-lite without a stylesheet.)
34 |
35 | Since the tooltip's arrow is created using the css border rule (https://css-tricks.com/snippets/css/css-triangle/), you'll want to specify the border-color for the arrow to set it's color.
36 |
37 | #### Here's an example stylesheet:
38 |
39 | ```
40 | .react-tooltip-lite {
41 | background: #333;
42 | color: white;
43 | }
44 |
45 | .react-tooltip-lite-arrow {
46 | border-color: #333;
47 | }
48 | ```
49 | For more examples, see the **CodePen demo**: [http://codepen.io/bsidelinger912/pen/WOdPNK](http://codepen.io/bsidelinger912/pen/WOdPNK).
50 |
51 | ## Props
52 | You can pass in props to define tip direction, styling, etc. Content is the only required prop.
53 |
54 |
55 |
56 |
57 | Name
58 | Type
59 | Description
60 |
61 |
62 |
63 |
64 | content
65 | node (text or html)
66 | the contents of your hover target
67 |
68 |
69 | tagName
70 | string
71 | html tag used for className
72 |
73 |
74 | direction
75 | string
76 | the tip direction, defaults to up. Possible values are "up", "down", "left", "right" with optional modifer for alignment of "start" and "end". e.g. "left-start" will attempt tooltip on left and align it with the start of the target. If alignment modifier is not specified the default behavior is to align "middle".
77 |
78 |
79 | forceDirection
80 | boolean
81 | Tells the tip to allow itself to render out of view if there's not room for the specified direction. If undefined or false, the tip will change direction as needed to render within the confines of the window.
82 |
83 |
84 | className
85 | string
86 |
87 | css class added to the rendered wrapper (and the tooltip if tooltipClassName is undefined)
88 | NOTE: in future versions className will only be applied to the wrapper element and not the tooltip
89 |
90 |
91 |
92 | tipContentClassName
93 | string
94 | css class added to the tooltip
95 |
96 |
97 | tipContentHover
98 | boolean
99 | defines whether you should be able to hover over the tip contents for links and copying content,
100 | defaults to false.
101 |
102 |
103 | background
104 | string
105 | background color for the tooltip contents and arrow
106 |
107 |
108 | color
109 | string
110 | text color for the tooltip contents
111 |
112 |
113 | padding
114 | string
115 | padding amount for the tooltip contents (defaults to '10px')
116 |
117 |
118 | styles
119 | object
120 | style overrides for the target wrapper
121 |
122 |
123 | eventOn
124 | string
125 | full name of supported react event to show the tooltip, e.g.: 'onClick'
126 |
127 |
128 | eventOff
129 | string
130 | full name of supported react event to hide the tooltip, e.g.: 'onClick'
131 |
132 |
133 | eventToggle
134 | string
135 | full name of supported react event to toggle the tooltip, e.g.: 'onClick', default hover toggling is disabled when using this option
136 |
137 |
138 | useHover
139 | boolean
140 | whether to use hover to show/hide the tip, defaults to true
141 |
142 |
143 | useDefaultStyles
144 | boolean
145 | uses default colors for the tooltip, so you don't need to write any CSS for it
146 |
147 |
148 | isOpen
149 | boolean
150 | forces open/close state from a prop, overrides hover or click state
151 |
152 |
153 | hoverDelay
154 | number
155 | the number of milliseconds to determine hover intent, defaults to 200
156 |
157 |
158 | mouseOutDelay
159 | number
160 | the number of milliseconds to determine hover-end intent, defaults to the hoverDelay value
161 |
162 |
163 | arrow
164 | boolean
165 | Whether or not to have an arrow on the tooltip, defaults to true
166 |
167 |
168 | arrowSize
169 | number
170 | Number in pixels of the size of the arrow, defaults to 10
171 |
172 |
173 | arrowContent
174 | node (text or html)
175 | custom arrow contents, such as an SVG
176 |
177 |
178 | distance
179 | number
180 | The distance from the tooltip to the target, defaults to 10px with an arrow and 3px without an arrow
181 |
182 |
183 | zIndex
184 | number
185 | The zIndex of the tooltip, defaults to 1000
186 |
187 |
188 | onToggle
189 | function
190 | if passed, this is called when the visibility of the tooltip changes.
191 |
192 |
193 |
194 |
195 |
196 | ### Here's an example using more of the props:
197 |
198 | ```
199 |
202 | An unordered list to demo some html content
203 |
204 | One
205 | Two
206 | Three
207 | Four
208 | Five
209 |
210 |
211 | )}
212 | direction="right"
213 | tagName="span"
214 | className="target"
215 | >
216 | Target content for big html tip
217 |
218 | ```
219 |
220 |
221 |
222 | To see more usage examples, take look at the /example folder in the [source](https://github.com/bsidelinger912/react-tooltip-lite).
223 |
--------------------------------------------------------------------------------
/example/index.css:
--------------------------------------------------------------------------------
1 | #react-root {
2 | max-width: 1200px;
3 | margin: 0 auto;
4 | padding: 0 10px;
5 | }
6 |
7 | section {
8 | margin-bottom: 50px;
9 | }
10 |
11 | a {
12 | display: inline-block;
13 | }
14 |
15 | .target {
16 | text-decoration: underline;
17 | cursor: pointer;
18 | }
19 |
20 | .target .react-tooltip-lite {
21 | cursor: default;
22 | }
23 |
24 | .flex-spread {
25 | display: flex;
26 | justify-content: space-between;
27 | }
28 |
29 | .tip-heading {
30 | margin: 0 0 10px;
31 | }
32 |
33 | .tip-list {
34 | margin: 0;
35 | padding: 0 0 0 15px;
36 | }
37 |
38 | .tip-list li {
39 | margin: 5px 0;
40 | padding: 0;
41 | }
42 |
43 | /* tooltip styles */
44 | .react-tooltip-lite {
45 | background: #333;
46 | color: white;
47 | }
48 |
49 | .react-tooltip-lite a {
50 | color: #86b0f4;
51 | text-decoration: none;
52 | }
53 |
54 | .react-tooltip-lite a:hover {
55 | color: #4286f4;
56 | }
57 |
58 | .react-tooltip-lite-arrow {
59 | border-color: #333;
60 | }
61 |
62 | /* overrides with a custom class */
63 | .customTip .react-tooltip-lite {
64 | border: 1px solid #888;
65 | background: #ccc;
66 | color: black;
67 | }
68 |
69 | .customTip .react-tooltip-lite-arrow {
70 | border-color: #444;
71 | position: relative;
72 | }
73 |
74 | .customTip .react-tooltip-lite-arrow::before {
75 | content: '';
76 | position: absolute;
77 | width: 0;
78 | height: 0;
79 | z-index: 99;
80 | display: block;
81 | }
82 |
83 | .customTip .react-tooltip-lite-up-arrow::before {
84 | border-top: 10px solid #ccc;
85 | border-left: 10px solid transparent;
86 | border-right: 10px solid transparent;
87 | left: -10px;
88 | top: -11px;
89 | }
90 |
91 | .customTip .react-tooltip-lite-down-arrow::before {
92 | border-bottom: 10px solid #ccc;
93 | border-left: 10px solid transparent;
94 | border-right: 10px solid transparent;
95 | left: -10px;
96 | bottom: -11px;
97 | }
98 |
99 | .customTip .react-tooltip-lite-right-arrow::before {
100 | border-right: 10px solid #ccc;
101 | border-top: 10px solid transparent;
102 | border-bottom: 10px solid transparent;
103 | right: -11px;
104 | top: -10px;
105 | }
106 |
107 | .customTip .react-tooltip-lite-left-arrow::before {
108 | border-left: 10px solid #ccc;
109 | border-top: 10px solid transparent;
110 | border-bottom: 10px solid transparent;
111 | left: -11px;
112 | top: -10px;
113 | }
114 |
115 | .imageWrapper {
116 | margin: 50px 0 0;
117 | position: relative;
118 | }
119 |
120 | .imageWrapper img {
121 | width: 500px;
122 | height: 500px;
123 | }
124 |
125 | .controlled-example {
126 | max-width: 250px;
127 | }
128 |
129 | .controlled-example_header {
130 | display: flex;
131 | justify-content: space-between;
132 | margin-bottom: 15px;
133 | padding-bottom: 5px;
134 | border-bottom: 1px solid #fff;
135 | }
136 |
137 | .controlled-example_close-button {
138 | cursor: pointer;
139 | background: none;
140 | border: none;
141 | color: white;
142 | font-size: 16px;
143 | padding: 0;
144 | }
145 |
146 | .controlled-example_close-button:hover {
147 | color: grey;
148 | }
149 |
150 | .internal-scroll-container {
151 | height: 200px;
152 | overflow: auto;
153 | }
154 |
155 | .internal-scroll-container > div {
156 | padding-top: 100px;
157 | height: 400px;
158 | }
159 |
160 | .arrow-content-tooltip .react-tooltip-lite {
161 | box-sizing: border-box;
162 | border: 1px solid gray;
163 | border-radius: 8px;
164 | box-shadow: 0px 4px 4px rgba(0, 0, 0, 0.2);
165 | }
166 |
167 | .arrow-content-tooltip .react-tooltip-lite-down-arrow svg {
168 | transform: translateY(1px);
169 | }
170 |
171 | .arrow-content-tooltip .react-tooltip-lite-right-arrow svg {
172 | transform: rotate(270deg) translateY(-4px) translateX(-4px);
173 | }
174 | .arrow-content-tooltip .react-tooltip-lite-up-arrow svg {
175 | transform: rotate(180deg) translateY(1px);
176 | }
177 | .arrow-content-tooltip .react-tooltip-lite-left-arrow svg {
178 | transform: rotate(90deg) translateY(5px) translateX(4px);
179 | }
180 |
--------------------------------------------------------------------------------
/example/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | React Tooltip Lite
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
--------------------------------------------------------------------------------
/example/index.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import ReactDOM from 'react-dom';
3 |
4 | import Tooltip from '../src/index';
5 |
6 | class App extends React.Component {
7 | constructor(props) {
8 | super(props);
9 |
10 | this.state = { tipOpen: false };
11 |
12 | this.toggleTip = this.toggleTip.bind(this);
13 | this.bodyClick = this.bodyClick.bind(this);
14 | }
15 |
16 | componentDidMount() {
17 | document.addEventListener('mousedown', this.bodyClick);
18 | }
19 |
20 | componentWillUnmount() {
21 | document.removeEventListener('mousedown', this.bodyClick);
22 | }
23 |
24 | tipContentRef;
25 |
26 | buttonRef;
27 |
28 | toggleTip() {
29 | this.setState(prevState => ({ tipOpen: !prevState.tipOpen }));
30 | }
31 |
32 | bodyClick(e) {
33 | if ((this.tipContentRef && this.tipContentRef.contains(e.target)) || this.buttonRef.contains(e.target)) {
34 | return;
35 | }
36 |
37 | this.setState({ tipOpen: false });
38 | }
39 |
40 | render() {
41 | const { tipOpen } = this.state;
42 | return (
43 |
44 |
React tooltip-lite examples
45 |
46 |
47 | Basic:
48 |
49 |
50 |
51 | Target
52 |
53 |
54 |
55 | Target
56 |
57 |
58 |
59 | t
60 |
61 |
62 | { console.log(`Is tooltip open ? \n Answer : ${isOpen ? 'Yes' : 'No'}`); }} content="alert shown" className="target" tipContentClassName="">
63 | Hover Me
64 |
65 |
66 |
67 |
68 |
69 | In a paragraph
70 |
71 | For
72 |
73 | inline text
74 |
75 | , a right or left tip works nicely. The tip will try to go the desired way and flip if there is not
76 | enough
77 |
78 | space
79 |
80 | . Shrink the window and see how the tip behaves when close to the
81 |
82 | edge
83 |
84 | . You can also force the direction of the tip and it will allow itself
85 | to go off screen
86 | .
87 |
88 |
89 |
90 |
91 | Html Contents
92 |
93 |
94 | You can also have a tooltip with
95 |
98 | An unordered list to demo some html content
99 |
100 | One
101 | Two
102 | Three
103 | Four
104 | Five
105 |
106 |
107 | )}
108 | direction="down"
109 | tagName="span"
110 | className="target"
111 | tipContentClassName=""
112 | >
113 | Html content
114 |
115 | .
116 |
117 |
118 |
119 | By specifying the prop "tipContentHover" as true, you can persist hover state when cursor is over the tip. This allows for links
120 | in your tip, copying contents and other behaviors. Here's an
121 |
124 | You can copy this text, or click this
125 | link
126 |
127 | )}
128 | tagName="span"
129 | direction="right"
130 | className="target"
131 | tipContentClassName=""
132 | tipContentHover
133 | >
134 | example
135 |
136 | .
137 |
138 |
139 |
140 |
141 | Colors
142 |
143 | You can pass
144 |
152 | color options as props
153 |
154 |
155 | or use a
156 |
162 | css stylesheet.
163 |
164 |
165 | With the arrowContent prop you have
166 |
176 |
180 |
181 | )}
182 | >
183 | even more control.
184 |
185 |
186 |
187 |
188 | Wrap anything as a target
189 |
190 | Lorem ipsum dolor sit amet, consectetur adipiscing elit. Ut tincidunt egestas sapien quis lacinia. Praesent ut sem leo.
191 | Curabitur vel dolor eu nulla ultrices efficitur a ut mauris. Nulla non odio non nibh venenatis commodo non vitae magna.
192 | Nunc porttitor, dolor nec sodales commodo, velit elit auctor arcu, sed dapibus nibh lacus sit amet nunc.
193 | Phasellus enim dui, blandit sed faucibus sit amet, feugiat vel urna. Vivamus ut lacus sollicitudin, dignissim risus vel,
194 | iaculis leo. Donec lobortis, turpis nec pulvinar venenatis, orci nunc semper sem, nec ornare nisl nisi ut ligula. Integer
195 | ut tempus elit. Cras luctus, tellus id vestibulum accumsan, purus velit mattis erat, euismod tempor mauris elit eget metus.
196 | Vivamus interdum ex sed egestas tincidunt.
197 |
198 |
199 |
200 |
201 |
202 |
203 |
204 |
210 |
211 |
212 |
213 |
214 |
215 |
216 |
217 | Custom events
218 |
219 |
220 | Close on click
221 |
222 |
223 |
224 |
225 |
234 | Open on click
235 |
236 |
237 |
238 |
239 |
240 | Toggle on click
241 |
242 |
243 |
244 |
245 | Default styles
246 |
247 | pass the
248 | {'"defaultStyles"'}
249 | prop as true to get up and running quick and easy
250 |
251 |
252 |
253 | See default styles
254 |
255 |
256 |
257 |
258 | Controlled by props
259 |
260 | { this.buttonRef = el; }}
263 | onClick={this.toggleTip}
264 | >
265 | {tipOpen ? 'close' : 'open'}
266 |
267 |
268 |
269 | { this.tipContentRef = el; }} className="controlled-example">
272 |
273 | Hello
274 | ×
275 |
276 | This tip is controlled by the button, you can also click outside the tip or on the
277 | {'"x"'}
278 | to close it
279 |
280 | )}
281 | isOpen={tipOpen}
282 | tagName="span"
283 | direction="down"
284 | forceDirection
285 | >
286 | click the button
287 |
288 |
289 |
290 |
291 | Distance and arrow size
292 |
293 |
294 | Larger arrowSize
295 | Smaller arrowSize
296 | Increase distance
297 | Decrease distance
298 |
299 |
300 |
301 |
302 | Compound Alignment
303 |
304 |
305 |
306 | right-start
307 |
308 |
309 |
310 | right-end
311 |
312 |
313 |
314 | left-start
315 |
316 |
317 |
318 | left-end
319 |
320 |
321 |
322 | top-start
323 |
324 |
325 |
326 | top-end
327 |
328 |
329 |
330 | down-start
331 |
332 |
333 |
334 | down-end
335 |
336 |
337 |
338 |
339 |
340 |
341 | right-start with arrow
342 |
343 |
344 |
345 | right-end with arrow
346 |
347 |
348 |
349 | left-start with arrow
350 |
351 |
352 |
353 | left-end with arrow
354 |
355 |
356 |
357 | down-start with arrow
358 |
359 |
360 |
361 | down-end with arrow
362 |
363 |
364 |
365 | up-start with arrow
366 |
367 |
368 |
369 | up-end with arrow
370 |
371 |
372 |
373 |
374 | console.log(`is visible: ${isVisible}`)}>
375 | On toggle example
376 |
377 |
378 |
379 | z-index example
380 |
381 |
382 |
383 | Internal scrollbars
384 |
385 |
386 |
387 | Scroll on mobile tapping here
388 |
389 |
390 |
391 |
392 |
393 | );
394 | }
395 | }
396 |
397 | ReactDOM.render( , document.getElementById('react-root'));
398 |
--------------------------------------------------------------------------------
/example/reactLogo.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
5 |
6 |
7 |
11 |
16 |
21 |
22 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "react-tooltip-lite",
3 | "version": "1.12.0",
4 | "description": "React tooltip, focused on simplicity and performance",
5 | "main": "dist/index.js",
6 | "types": "dist/index.d.ts",
7 | "scripts": {
8 | "bundle": "node node_modules/webpack/bin/webpack.js -p --colors --display-error-details --config webpack.dist.config.js",
9 | "dev": "webpack-dev-server --progress --colors",
10 | "transpile": "babel src --out-dir dist --ignore ./src/**/*.test.jsx && cp src/index.d.ts dist/index.d.ts",
11 | "build": "rm -rf dist && mkdir dist && npm run transpile && npm run bundle",
12 | "prepublish": "npm run build",
13 | "storybook": "start-storybook -p 6006",
14 | "build-storybook": "build-storybook",
15 | "test:jest": "jest --config ./test/jest.config.json",
16 | "test:storyshots": "jest --config ./test/jest.storyshots.config.json",
17 | "test": "yarn test:jest && yarn test:storyshots"
18 | },
19 | "files": [
20 | "dist",
21 | "example"
22 | ],
23 | "repository": {
24 | "type": "git",
25 | "url": "git+https://github.com/bsidelinger912/react-tooltip-lite.git"
26 | },
27 | "keywords": [
28 | "React"
29 | ],
30 | "author": "Ben Sidelinger",
31 | "license": "MIT",
32 | "bugs": {
33 | "url": "https://github.com/bsidelinger912/react-tooltip-lite/issues"
34 | },
35 | "homepage": "https://github.com/bsidelinger912/react-tooltip-lite#readme",
36 | "dependencies": {
37 | "prop-types": "^15.5.8"
38 | },
39 | "peerDependencies": {
40 | "react": "^15.5.4 || ^16.0.0",
41 | "react-dom": "^15.5.4 || ^16.0.0"
42 | },
43 | "devDependencies": {
44 | "@babel/cli": "^7.4.3",
45 | "@babel/core": "^7.4.3",
46 | "@babel/plugin-proposal-class-properties": "^7.4.0",
47 | "@babel/preset-env": "^7.4.3",
48 | "@babel/preset-react": "^7.0.0",
49 | "@storybook/addon-actions": "^5.0.10",
50 | "@storybook/addon-knobs": "^5.0.10",
51 | "@storybook/addon-links": "^5.0.10",
52 | "@storybook/addon-storyshots": "^5.0.10",
53 | "@storybook/addon-storyshots-puppeteer": "^5.0.10",
54 | "@storybook/addon-viewport": "^5.0.11",
55 | "@storybook/addons": "^5.0.10",
56 | "@storybook/react": "^5.0.10",
57 | "babel-eslint": "^10.0.1",
58 | "babel-jest": "^24.7.1",
59 | "babel-loader": "^8.0.5",
60 | "babel-plugin-require-context-hook": "^1.0.0",
61 | "css-loader": "^2.1.1",
62 | "eslint": "^5.16.0",
63 | "eslint-config-airbnb": "^17.1.0",
64 | "eslint-plugin-import": "^2.17.2",
65 | "eslint-plugin-jsx-a11y": "^6.2.1",
66 | "eslint-plugin-prefer-object-spread": "^1.1.0",
67 | "eslint-plugin-react": "^7.12.4",
68 | "jest": "^24.7.1",
69 | "react": "^16.8.6",
70 | "react-dom": "^16.8.6",
71 | "react-hot-loader": "^1.3.1",
72 | "react-test-renderer": "^16.8.6",
73 | "react-testing-library": "^7.0.0",
74 | "style-loader": "^0.23.1",
75 | "webpack": "^4.30.0",
76 | "webpack-cli": "^3.3.1",
77 | "webpack-dev-server": "^3.3.1"
78 | }
79 | }
80 |
--------------------------------------------------------------------------------
/src/Portal.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import PropTypes from 'prop-types';
3 | import ReactDom from 'react-dom';
4 |
5 | const useCreatePortal = typeof ReactDom.createPortal === 'function';
6 | export const isBrowser = typeof window !== 'undefined';
7 |
8 | class Portal extends React.Component {
9 | constructor(props) {
10 | super(props);
11 |
12 | if (isBrowser) {
13 | this.container = document.createElement('div');
14 | document.body.appendChild(this.container);
15 |
16 | this.renderLayer();
17 | }
18 | }
19 |
20 | componentDidUpdate() {
21 | this.renderLayer();
22 | }
23 |
24 | componentWillUnmount() {
25 | if (!useCreatePortal) {
26 | ReactDom.unmountComponentAtNode(this.container);
27 | }
28 |
29 | document.body.removeChild(this.container);
30 | }
31 |
32 | renderLayer() {
33 | if (!useCreatePortal) {
34 | ReactDom.unstable_renderSubtreeIntoContainer(this, this.props.children, this.container);
35 | }
36 | }
37 |
38 | render() {
39 | if (useCreatePortal) {
40 | return ReactDom.createPortal(this.props.children, this.container);
41 | }
42 | return null;
43 | }
44 | }
45 |
46 | Portal.propTypes = {
47 | children: PropTypes.node.isRequired,
48 | };
49 |
50 | export default Portal;
51 |
--------------------------------------------------------------------------------
/src/__tests__/MobileTooltip.test.jsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable comma-dangle */
2 |
3 | import React from 'react';
4 | import { render, fireEvent, cleanup } from 'react-testing-library';
5 |
6 | import Tooltip from '../index';
7 |
8 | jest.useFakeTimers();
9 |
10 | describe('Tooltip', () => {
11 | afterEach(() => {
12 | cleanup();
13 | });
14 |
15 | const targetContent = 'Hello world';
16 | const tipContent = 'Tip content';
17 | const nonTipContent = 'not part of the tooltip';
18 |
19 | function assertTipHidden(getByText) {
20 | expect(getByText(tipContent).style.transform).toEqual('translateX(-10000000px)');
21 | }
22 |
23 | function assertTipVisible(getByText) {
24 | expect(getByText(tipContent).style.transform).toBeFalsy();
25 | }
26 |
27 | it('should close tip on any tab outside the tips content area', () => {
28 | const { getByText } = render(
29 |
30 |
{nonTipContent}
31 |
32 |
33 | {targetContent}
34 |
35 |
36 | );
37 |
38 | const target = getByText(targetContent);
39 |
40 | fireEvent.touchStart(target);
41 | fireEvent.touchEnd(target);
42 | jest.runAllTimers();
43 |
44 | assertTipVisible(getByText);
45 |
46 | const nonTipDiv = getByText(nonTipContent);
47 | fireEvent.touchStart(nonTipDiv);
48 | jest.runAllTimers();
49 |
50 | assertTipHidden(getByText);
51 | });
52 | });
53 |
--------------------------------------------------------------------------------
/src/__tests__/Tooltip.node.test.jsx:
--------------------------------------------------------------------------------
1 | /**
2 | * @jest-environment node
3 | */
4 |
5 | import React from 'react';
6 | import { renderToString } from 'react-dom/server';
7 |
8 | import Tooltip from '../index';
9 |
10 | describe('Node env', () => {
11 | const targetContent = 'Hello world';
12 | const tipContent = 'Tip content';
13 |
14 | it('doesnt blow up for ssr', () => {
15 | const string = renderToString(
16 |
17 | {targetContent}
18 | ,
19 | );
20 |
21 | expect(string).toContain(targetContent);
22 | expect(string).not.toContain(tipContent);
23 | });
24 | });
25 |
--------------------------------------------------------------------------------
/src/__tests__/Tooltip.test.jsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable comma-dangle */
2 |
3 | import React from 'react';
4 | import { render, fireEvent, cleanup } from 'react-testing-library';
5 |
6 | import Tooltip from '../index';
7 |
8 | jest.useFakeTimers();
9 |
10 | describe('Tooltip', () => {
11 | afterEach(() => {
12 | cleanup();
13 | });
14 |
15 | const targetContent = 'Hello world';
16 | const tipContent = 'Tip content';
17 |
18 | function assertTipHidden(getByText) {
19 | expect(getByText(tipContent).style.transform).toEqual('translateX(-10000000px)');
20 | }
21 |
22 | function assertTipVisible(getByText) {
23 | expect(getByText(tipContent).style.transform).toBeFalsy();
24 | }
25 |
26 | it('should render and open with hover', () => {
27 | const { getByText } = render(
28 |
29 | {targetContent}
30 |
31 | );
32 |
33 | const target = getByText(targetContent);
34 |
35 | fireEvent.mouseOver(target);
36 | jest.runAllTimers();
37 |
38 | assertTipVisible(getByText);
39 | });
40 |
41 | it('should handle controlled state', () => {
42 | const { getByText, rerender, queryByText } = render(
43 |
44 | {targetContent}
45 |
46 | );
47 |
48 | const target = getByText(targetContent);
49 |
50 | fireEvent.mouseOver(target);
51 | jest.runAllTimers();
52 |
53 | expect(queryByText(tipContent)).toBeNull();
54 |
55 | rerender(
56 |
57 | {targetContent}
58 |
59 | );
60 |
61 | assertTipVisible(getByText);
62 |
63 | rerender(
64 |
65 | {targetContent}
66 |
67 | );
68 |
69 | jest.runAllTimers();
70 |
71 | assertTipHidden(getByText);
72 | });
73 |
74 | it('should not open the tip when isVisible goes from false to undefined', () => {
75 | const { rerender, getByText } = render(
76 |
77 | {targetContent}
78 |
79 | );
80 |
81 | const target = getByText(targetContent);
82 | fireEvent.mouseOver(target);
83 |
84 | jest.runAllTimers();
85 |
86 | assertTipVisible(getByText);
87 |
88 | rerender(
89 |
90 | {targetContent}
91 |
92 | );
93 |
94 | jest.runAllTimers();
95 |
96 | assertTipHidden(getByText);
97 |
98 | rerender(
99 |
100 | {targetContent}
101 |
102 | );
103 |
104 | jest.runAllTimers();
105 |
106 | assertTipHidden(getByText);
107 | });
108 |
109 | it('should handle null as undefined for isOpen prop', () => {
110 | const { getByText } = render(
111 |
112 | {targetContent}
113 |
114 | );
115 |
116 | const target = getByText(targetContent);
117 |
118 | fireEvent.mouseOver(target);
119 | jest.runAllTimers();
120 |
121 | assertTipVisible(getByText);
122 | });
123 |
124 | it('should handle eventOn prop and use mouseout', () => {
125 | const { getByText } = render(
126 |
127 | {targetContent}
128 |
129 | );
130 |
131 | const target = getByText(targetContent);
132 |
133 | fireEvent.click(target);
134 | jest.runAllTimers();
135 |
136 | assertTipVisible(getByText);
137 |
138 | fireEvent.mouseOut(target);
139 | jest.runAllTimers();
140 |
141 | assertTipHidden(getByText);
142 | });
143 |
144 | it('should handle eventOff prop and use mouseover', () => {
145 | const { getByText } = render(
146 |
147 | {targetContent}
148 |
149 | );
150 |
151 | const target = getByText(targetContent);
152 |
153 | fireEvent.mouseOver(target);
154 | jest.runAllTimers();
155 |
156 | assertTipVisible(getByText);
157 |
158 | fireEvent.click(target);
159 | jest.runAllTimers();
160 |
161 | assertTipHidden(getByText);
162 | });
163 |
164 | it('should handle eventToggle prop', () => {
165 | const { getByText, queryByText } = render(
166 |
167 | {targetContent}
168 |
169 | );
170 |
171 | const target = getByText(targetContent);
172 |
173 | fireEvent.mouseOver(target);
174 | jest.runAllTimers();
175 |
176 | expect(queryByText(tipContent)).toBeNull();
177 |
178 | fireEvent.click(target);
179 | jest.runAllTimers();
180 |
181 | assertTipVisible(getByText);
182 |
183 | fireEvent.click(target);
184 | jest.runAllTimers();
185 |
186 | assertTipHidden(getByText);
187 | });
188 |
189 | it('should use hoverDelay prop', () => {
190 | const hoverDelay = 1000;
191 | const { getByText, queryByText, rerender } = render(
192 |
193 | {targetContent}
194 |
195 | );
196 |
197 | const target = getByText(targetContent);
198 | fireEvent.mouseOver(target);
199 |
200 | expect(queryByText(tipContent)).toBeNull();
201 |
202 | jest.advanceTimersByTime(hoverDelay);
203 | assertTipVisible(getByText);
204 |
205 | // hoverDelay is not used on mouseout for tips without the tipContentHoverProp prop
206 | fireEvent.mouseOut(target);
207 | assertTipHidden(getByText);
208 |
209 | // with the tipContentHoverProp hoverDelay should be used with mouseOut
210 | rerender(
211 |
212 | {targetContent}
213 |
214 | );
215 |
216 | fireEvent.mouseOver(target);
217 | assertTipHidden(getByText);
218 |
219 | jest.advanceTimersByTime(hoverDelay);
220 | assertTipVisible(getByText);
221 |
222 | fireEvent.mouseOut(target);
223 | assertTipVisible(getByText);
224 |
225 | jest.advanceTimersByTime(hoverDelay);
226 | assertTipHidden(getByText);
227 | });
228 |
229 | it('should use mouseOutDelay prop', () => {
230 | const hoverDelay = 500;
231 | const mouseOutDelay = 1000;
232 |
233 | const { getByText, queryByText } = render(
234 |
235 | {targetContent}
236 |
237 | );
238 |
239 | const target = getByText(targetContent);
240 | fireEvent.mouseOver(target);
241 |
242 | expect(queryByText(tipContent)).toBeNull();
243 |
244 | jest.advanceTimersByTime(hoverDelay);
245 | assertTipVisible(getByText);
246 |
247 | fireEvent.mouseOut(target);
248 | assertTipVisible(getByText);
249 |
250 | jest.advanceTimersByTime(mouseOutDelay);
251 | assertTipHidden(getByText);
252 | });
253 |
254 | it('should support onToggle prop', () => {
255 | const spy = jest.fn();
256 | const { getByText } = render(
257 |
258 | {targetContent}
259 |
260 | );
261 |
262 | const target = getByText(targetContent);
263 | fireEvent.mouseOver(target);
264 |
265 | jest.runAllTimers();
266 | expect(spy).toHaveBeenCalledWith(true);
267 |
268 | fireEvent.mouseOut(target);
269 |
270 | jest.runAllTimers();
271 | expect(spy).toHaveBeenCalledWith(false);
272 | });
273 |
274 | it('should not call onToggle when the state is not actually changing', () => {
275 | const spy = jest.fn();
276 | const { container } = render(
277 |
278 | {targetContent}
279 |
280 | );
281 |
282 | fireEvent.touchStart(container);
283 |
284 | expect(spy).not.toHaveBeenCalled();
285 | });
286 |
287 | it('should support zIndex prop', () => {
288 | const { getByText } = render(
289 |
290 | {targetContent}
291 |
292 | );
293 |
294 | const target = getByText(targetContent);
295 | fireEvent.mouseOver(target);
296 |
297 | jest.runAllTimers();
298 |
299 | const tip = getByText(tipContent);
300 | const styles = window.getComputedStyle(tip);
301 | expect(styles['z-index']).toEqual('5000');
302 | });
303 |
304 | it('should support the arrowContent prop', () => {
305 | const { getByText, getByTestId } = render(
306 |
310 |
314 |
315 | )}
316 | >
317 | {targetContent}
318 |
319 | );
320 |
321 | const target = getByText(targetContent);
322 | fireEvent.mouseOver(target);
323 |
324 | jest.runAllTimers();
325 |
326 | getByTestId('my-arrow');
327 | });
328 | });
329 |
--------------------------------------------------------------------------------
/src/__tests__/__image_snapshots__/storyshots-test-jsx-storyshots-tooltip-arrow-size-and-distance-1-snap.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bsidelinger912/react-tooltip-lite/dfd3dfff9ed9f8167178ef74ddd9b271926a32ab/src/__tests__/__image_snapshots__/storyshots-test-jsx-storyshots-tooltip-arrow-size-and-distance-1-snap.png
--------------------------------------------------------------------------------
/src/__tests__/__image_snapshots__/storyshots-test-jsx-storyshots-tooltip-basic-usage-1-snap.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bsidelinger912/react-tooltip-lite/dfd3dfff9ed9f8167178ef74ddd9b271926a32ab/src/__tests__/__image_snapshots__/storyshots-test-jsx-storyshots-tooltip-basic-usage-1-snap.png
--------------------------------------------------------------------------------
/src/__tests__/__image_snapshots__/storyshots-test-jsx-storyshots-tooltip-colors-1-snap.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bsidelinger912/react-tooltip-lite/dfd3dfff9ed9f8167178ef74ddd9b271926a32ab/src/__tests__/__image_snapshots__/storyshots-test-jsx-storyshots-tooltip-colors-1-snap.png
--------------------------------------------------------------------------------
/src/__tests__/__image_snapshots__/storyshots-test-jsx-storyshots-tooltip-compound-alignment-1-snap.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bsidelinger912/react-tooltip-lite/dfd3dfff9ed9f8167178ef74ddd9b271926a32ab/src/__tests__/__image_snapshots__/storyshots-test-jsx-storyshots-tooltip-compound-alignment-1-snap.png
--------------------------------------------------------------------------------
/src/__tests__/__image_snapshots__/storyshots-test-jsx-storyshots-tooltip-custom-arrow-content-1-snap.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bsidelinger912/react-tooltip-lite/dfd3dfff9ed9f8167178ef74ddd9b271926a32ab/src/__tests__/__image_snapshots__/storyshots-test-jsx-storyshots-tooltip-custom-arrow-content-1-snap.png
--------------------------------------------------------------------------------
/src/__tests__/__image_snapshots__/storyshots-test-jsx-storyshots-tooltip-custom-events-1-snap.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bsidelinger912/react-tooltip-lite/dfd3dfff9ed9f8167178ef74ddd9b271926a32ab/src/__tests__/__image_snapshots__/storyshots-test-jsx-storyshots-tooltip-custom-events-1-snap.png
--------------------------------------------------------------------------------
/src/__tests__/__image_snapshots__/storyshots-test-jsx-storyshots-tooltip-html-content-1-snap.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bsidelinger912/react-tooltip-lite/dfd3dfff9ed9f8167178ef74ddd9b271926a32ab/src/__tests__/__image_snapshots__/storyshots-test-jsx-storyshots-tooltip-html-content-1-snap.png
--------------------------------------------------------------------------------
/src/__tests__/__image_snapshots__/storyshots-test-jsx-storyshots-tooltip-in-a-paragraph-1-snap.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bsidelinger912/react-tooltip-lite/dfd3dfff9ed9f8167178ef74ddd9b271926a32ab/src/__tests__/__image_snapshots__/storyshots-test-jsx-storyshots-tooltip-in-a-paragraph-1-snap.png
--------------------------------------------------------------------------------
/src/__tests__/__image_snapshots__/storyshots-test-jsx-storyshots-tooltip-nested-targets-1-snap.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bsidelinger912/react-tooltip-lite/dfd3dfff9ed9f8167178ef74ddd9b271926a32ab/src/__tests__/__image_snapshots__/storyshots-test-jsx-storyshots-tooltip-nested-targets-1-snap.png
--------------------------------------------------------------------------------
/src/__tests__/__image_snapshots__/storyshots-test-jsx-storyshots-tooltip-wrap-an-image-1-snap.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bsidelinger912/react-tooltip-lite/dfd3dfff9ed9f8167178ef74ddd9b271926a32ab/src/__tests__/__image_snapshots__/storyshots-test-jsx-storyshots-tooltip-wrap-an-image-1-snap.png
--------------------------------------------------------------------------------
/src/__tests__/storyshots.test.jsx:
--------------------------------------------------------------------------------
1 | // This describe just scaffolds up the storyshots tests
2 | describe('Fires up storyshots tests', () => {});
3 |
--------------------------------------------------------------------------------
/src/functions.js:
--------------------------------------------------------------------------------
1 | /**
2 | * a handful of shared functions and constants
3 | */
4 |
5 | export const minArrowPadding = 5;
6 | export const bodyPadding = 10;
7 | export const noArrowDistance = 3;
8 |
9 | /**
10 | * cross browser scroll positions
11 | */
12 | export function getScrollTop() {
13 | return window.pageYOffset || document.documentElement.scrollTop || document.body.scrollTop || 0;
14 | }
15 |
16 | export function getScrollLeft() {
17 | return window.pageXOffset || document.documentElement.scrollLeft || document.body.scrollLeft || 0;
18 | }
19 |
20 | export function getArrowSpacing(props) {
21 | const defaultArrowSpacing = props.arrow ? props.arrowSize : noArrowDistance;
22 | return typeof props.distance === 'number' ? props.distance : defaultArrowSpacing;
23 | }
24 |
25 | /**
26 | * get first ancestor that might scroll
27 | */
28 | export function getScrollParent(element) {
29 | const style = getComputedStyle(element);
30 | let scrollParent = window;
31 |
32 | if (style.position !== 'fixed') {
33 | let parent = element.parentElement;
34 |
35 | while (parent) {
36 | const parentStyle = getComputedStyle(parent);
37 |
38 | if (/(auto|scroll)/.test(parentStyle.overflow + parentStyle.overflowY + parentStyle.overflowX)) {
39 | scrollParent = parent;
40 | parent = undefined;
41 | } else {
42 | parent = parent.parentElement;
43 | }
44 | }
45 | }
46 |
47 | return scrollParent;
48 | }
49 |
--------------------------------------------------------------------------------
/src/getDirection.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Checks the intended tip direction and falls back if not enough space
3 | */
4 | import { getScrollLeft, getArrowSpacing, minArrowPadding } from './functions';
5 |
6 | function checkLeftRightWidthSufficient(tip, target, distance, bodyPadding) {
7 | const targetRect = target.getBoundingClientRect();
8 | const deadSpace = Math.min(targetRect.left, document.documentElement.clientWidth - targetRect.right);
9 |
10 | return (tip.offsetWidth + target.offsetWidth + distance + bodyPadding + deadSpace < document.documentElement.clientWidth);
11 | }
12 |
13 | function checkTargetSufficientlyVisible(target, tip, props) {
14 | const targetRect = target.getBoundingClientRect();
15 | const bottomOverhang = targetRect.bottom > window.innerHeight;
16 | const topOverhang = targetRect.top < 0;
17 |
18 | // if the target is taller than the viewport (and we know there's sufficient left/right width before this is called),
19 | // then go with the left/right direction as top/bottom will both be off screen
20 | if (topOverhang && bottomOverhang) {
21 | return true;
22 | }
23 |
24 | // if the target is bigger than the tip, we need to check if enough of the target is visible
25 | if (target.offsetHeight > tip.offsetHeight) {
26 | const halfTargetHeight = target.offsetHeight / 2;
27 | const arrowClearance = props.arrowSize + minArrowPadding;
28 | const bottomOverhangAmount = targetRect.bottom - window.innerHeight;
29 | const topOverhangAmount = -targetRect.top;
30 |
31 | const targetCenterToBottomOfWindow = halfTargetHeight - bottomOverhangAmount;
32 | const targetCenterToTopOfWindow = halfTargetHeight - topOverhangAmount;
33 |
34 | return (targetCenterToBottomOfWindow >= arrowClearance && targetCenterToTopOfWindow >= arrowClearance);
35 | }
36 |
37 | // otherwise just check that the whole target is visible
38 | return (!bottomOverhang && !topOverhang);
39 | }
40 |
41 | function checkForArrowOverhang(props, arrowStyles, bodyPadding) {
42 | const scrollLeft = getScrollLeft();
43 | const hasLeftClearance = arrowStyles.positionStyles.left - scrollLeft > bodyPadding;
44 | const hasRightClearance = arrowStyles.positionStyles.left + (props.arrowSize * 2) < (scrollLeft + document.documentElement.clientWidth) - bodyPadding;
45 |
46 | return (!hasLeftClearance || !hasRightClearance);
47 | }
48 |
49 | export default function getDirection(currentDirection, tip, target, props, bodyPadding, arrowStyles, recursive) {
50 | // can't switch until target is rendered
51 | if (!target) {
52 | return currentDirection;
53 | }
54 |
55 | const targetRect = target.getBoundingClientRect();
56 | const arrowSpacing = getArrowSpacing(props);
57 |
58 | // this is how much space is needed to display the tip above or below the target
59 | const heightOfTipWithArrow = tip.offsetHeight + arrowSpacing + bodyPadding;
60 |
61 | const spaceBelowTarget = window.innerHeight - targetRect.bottom;
62 | const spaceAboveTarget = targetRect.top;
63 |
64 | const hasSpaceBelow = spaceBelowTarget >= heightOfTipWithArrow;
65 | const hasSpaceAbove = spaceAboveTarget >= heightOfTipWithArrow;
66 |
67 | switch (currentDirection) {
68 | case 'right':
69 | // if the window is not wide enough try top (which falls back to down)
70 | if (!checkLeftRightWidthSufficient(tip, target, arrowSpacing, bodyPadding) || !checkTargetSufficientlyVisible(target, tip, props)) {
71 | return getDirection('up', tip, target, arrowSpacing, bodyPadding, arrowStyles, true);
72 | }
73 |
74 | if (document.documentElement.clientWidth - targetRect.right < tip.offsetWidth + arrowSpacing + bodyPadding) {
75 | return 'left';
76 | }
77 |
78 | return 'right';
79 |
80 | case 'left':
81 | // if the window is not wide enough try top (which falls back to down)
82 | if (!checkLeftRightWidthSufficient(tip, target, arrowSpacing, bodyPadding) || !checkTargetSufficientlyVisible(target, tip, props)) {
83 | return getDirection('up', tip, target, arrowSpacing, bodyPadding, arrowStyles, true);
84 | }
85 |
86 | if (targetRect.left < tip.offsetWidth + arrowSpacing + bodyPadding) {
87 | return 'right';
88 | }
89 |
90 | return 'left';
91 |
92 | case 'up':
93 | if (!recursive && arrowStyles && checkForArrowOverhang(props, arrowStyles, bodyPadding)) {
94 | return getDirection('left', tip, target, arrowSpacing, bodyPadding, arrowStyles, true);
95 | }
96 |
97 | if (!hasSpaceAbove) {
98 | if (hasSpaceBelow) {
99 | return 'down';
100 | }
101 |
102 | if (!recursive && checkLeftRightWidthSufficient(tip, target, arrowSpacing, bodyPadding)) {
103 | return getDirection('right', tip, target, arrowSpacing, bodyPadding, arrowStyles, true);
104 | }
105 | }
106 |
107 | return 'up';
108 |
109 | case 'down':
110 | default:
111 | if (!recursive && arrowStyles && checkForArrowOverhang(props, arrowStyles, bodyPadding)) {
112 | return getDirection('right', tip, target, arrowSpacing, bodyPadding, arrowStyles, true);
113 | }
114 |
115 | if (!hasSpaceBelow) {
116 | // if there's no space below, but space above, switch to that direction
117 | if (hasSpaceAbove) {
118 | return 'up';
119 |
120 | // if there's not space above or below, check if there would be space left or right
121 | }
122 |
123 | if (!recursive && checkLeftRightWidthSufficient(tip, target, arrowSpacing, bodyPadding)) {
124 | return getDirection('right', tip, target, arrowSpacing, bodyPadding, arrowStyles, true);
125 | }
126 |
127 | // if there's no space in any direction, default to the original direction
128 | }
129 |
130 | return 'down';
131 | }
132 | }
133 |
--------------------------------------------------------------------------------
/src/index.d.ts:
--------------------------------------------------------------------------------
1 | declare module 'react-tooltip-lite' {
2 |
3 | import * as React from 'react';
4 |
5 | export interface TooltipProps {
6 | arrow?: boolean;
7 | arrowSize?: number;
8 | background?: string;
9 | className?: string;
10 | color?: string;
11 | content: React.ReactNode;
12 | direction?: string;
13 | distance?: number;
14 | eventOff?: string;
15 | eventOn?: string;
16 | eventToggle?: string;
17 | forceDirection?: boolean;
18 | hoverDelay?: number;
19 | isOpen?: boolean;
20 | mouseOutDelay?: number;
21 | padding?: string | number;
22 | styles?: object;
23 | tagName?: string;
24 | tipContentHover?: boolean;
25 | tipContentClassName?: string;
26 | useHover?: boolean;
27 | useDefaultStyles?: boolean;
28 | zIndex?: number;
29 | onToggle?: (showTip: boolean) => void;
30 | arrowContent?: React.ReactNode;
31 | }
32 |
33 | export default class Tooltip extends React.Component {
34 | }
35 |
36 | }
37 |
--------------------------------------------------------------------------------
/src/index.jsx:
--------------------------------------------------------------------------------
1 | /**
2 | * @class Tooltip
3 | * @description A lightweight and responsive tooltip.
4 | */
5 | import React from 'react';
6 | import PropTypes from 'prop-types';
7 |
8 | import Portal, { isBrowser } from './Portal';
9 | import positions from './position';
10 | import { getScrollParent } from './functions';
11 |
12 | // default colors
13 | const defaultColor = '#fff';
14 | const defaultBg = '#333';
15 |
16 | const resizeThrottle = 100;
17 | const resizeThreshold = 5;
18 |
19 | const stopProp = e => e.stopPropagation();
20 |
21 | class Tooltip extends React.Component {
22 | static propTypes = {
23 | arrow: PropTypes.bool,
24 | arrowSize: PropTypes.number,
25 | background: PropTypes.string,
26 | children: PropTypes.node.isRequired,
27 | className: PropTypes.string,
28 | color: PropTypes.string,
29 | content: PropTypes.node.isRequired,
30 | direction: PropTypes.string,
31 | distance: PropTypes.number,
32 | eventOff: PropTypes.string,
33 | eventOn: PropTypes.string,
34 | eventToggle: PropTypes.string,
35 | forceDirection: PropTypes.bool,
36 | hoverDelay: PropTypes.number,
37 | isOpen: PropTypes.bool,
38 | mouseOutDelay: PropTypes.number,
39 | padding: PropTypes.oneOfType([
40 | PropTypes.string,
41 | PropTypes.number,
42 | ]),
43 | styles: PropTypes.object,
44 | tagName: PropTypes.string,
45 | tipContentHover: PropTypes.bool,
46 | tipContentClassName: PropTypes.string,
47 | useDefaultStyles: PropTypes.bool,
48 | useHover: PropTypes.bool,
49 | zIndex: PropTypes.number,
50 | onToggle: PropTypes.func,
51 | arrowContent: PropTypes.node,
52 | }
53 |
54 | static defaultProps = {
55 | arrow: true,
56 | arrowSize: 10,
57 | background: '',
58 | className: '',
59 | color: '',
60 | direction: 'up',
61 | distance: undefined,
62 | eventOff: undefined,
63 | eventOn: undefined,
64 | eventToggle: undefined,
65 | forceDirection: false,
66 | hoverDelay: 200,
67 | isOpen: undefined,
68 | mouseOutDelay: undefined,
69 | padding: '10px',
70 | styles: {},
71 | tagName: 'div',
72 | tipContentHover: false,
73 | tipContentClassName: undefined,
74 | useDefaultStyles: false,
75 | useHover: true,
76 | zIndex: 1000,
77 | onToggle: undefined,
78 | arrowContent: null,
79 | }
80 |
81 | static getDerivedStateFromProps(nextProps) {
82 | return isBrowser && nextProps.isOpen ? { hasBeenShown: true } : null;
83 | }
84 |
85 | debounceTimeout = false;
86 |
87 | hoverTimeout = false;
88 |
89 | constructor() {
90 | super();
91 |
92 | this.state = {
93 | showTip: false,
94 | hasHover: false,
95 | ignoreShow: false,
96 | hasBeenShown: false,
97 | };
98 |
99 | this.showTip = this.showTip.bind(this);
100 | this.hideTip = this.hideTip.bind(this);
101 | this.checkHover = this.checkHover.bind(this);
102 | this.toggleTip = this.toggleTip.bind(this);
103 | this.startHover = this.startHover.bind(this);
104 | this.endHover = this.endHover.bind(this);
105 | this.listenResizeScroll = this.listenResizeScroll.bind(this);
106 | this.handleResizeScroll = this.handleResizeScroll.bind(this);
107 | this.bodyTouchStart = this.bodyTouchStart.bind(this);
108 | this.bodyTouchEnd = this.bodyTouchEnd.bind(this);
109 | this.targetTouchStart = this.targetTouchStart.bind(this);
110 | this.targetTouchEnd = this.targetTouchEnd.bind(this);
111 | }
112 |
113 | componentDidMount() {
114 | // if the isOpen prop is passed on first render we need to immediately trigger a second render,
115 | // because the tip ref is needed to calculate the position
116 | if (this.props.isOpen) {
117 | // eslint-disable-next-line react/no-did-mount-set-state
118 | this.setState({ isOpen: true });
119 | }
120 |
121 | this.scrollParent = getScrollParent(this.target);
122 |
123 | window.addEventListener('resize', this.listenResizeScroll);
124 | this.scrollParent.addEventListener('scroll', this.listenResizeScroll);
125 | window.addEventListener('touchstart', this.bodyTouchStart);
126 | window.addEventListener('touchEnd', this.bodyTouchEnd);
127 | }
128 |
129 | componentDidUpdate(_, prevState) {
130 | // older versions of react won't leverage getDerivedStateFromProps, TODO: remove when < 16.3 support is dropped
131 | if (!this.state.hasBeenShown && this.props.isOpen) {
132 | // eslint-disable-next-line react/no-did-update-set-state
133 | this.setState({ hasBeenShown: true });
134 |
135 | return setTimeout(this.showTip, 0);
136 | }
137 |
138 | // we need to render once to get refs in place, then we can make the calculations on a followup render
139 | // this only has to happen the first time the tip is shown, and allows us to not render every tip on the page with initial render.
140 | if (!prevState.hasBeenShown && this.state.hasBeenShown) {
141 | this.showTip();
142 | }
143 | }
144 |
145 | componentWillUnmount() {
146 | window.removeEventListener('resize', this.listenResizeScroll);
147 | this.scrollParent.removeEventListener('scroll', this.listenResizeScroll);
148 | window.removeEventListener('touchstart', this.bodyTouchStart);
149 | window.removeEventListener('touchEnd', this.bodyTouchEnd);
150 | clearTimeout(this.debounceTimeout);
151 | clearTimeout(this.hoverTimeout);
152 | }
153 |
154 | listenResizeScroll() {
155 | clearTimeout(this.debounceTimeout);
156 |
157 | this.debounceTimeout = setTimeout(this.handleResizeScroll, resizeThrottle);
158 |
159 | if (this.state.targetTouch) {
160 | this.setState({ targetTouch: undefined });
161 | }
162 | }
163 |
164 | handleResizeScroll() {
165 | if (this.state.showTip) {
166 | // if we're showing the tip and the resize was actually a signifigant change, then setState to re-render and calculate position
167 | const clientWidth = Math.round(document.documentElement.clientWidth / resizeThreshold) * resizeThreshold;
168 | this.setState({ clientWidth });
169 | }
170 | }
171 |
172 | targetTouchStart() {
173 | this.setState({ targetTouch: true });
174 | }
175 |
176 | targetTouchEnd() {
177 | if (this.state.targetTouch) {
178 | this.toggleTip();
179 | }
180 | }
181 |
182 | bodyTouchEnd() {
183 | if (this.state.targetTouch) {
184 | this.setState({ targetTouch: undefined });
185 | }
186 | }
187 |
188 | bodyTouchStart(e) {
189 | // if it's a controlled tip we don't want to auto-dismiss, otherwise we just ignore taps inside the tip
190 | if (!(this.target && this.target.contains(e.target)) && !(this.tip && this.tip.contains(e.target)) && !this.props.isOpen) {
191 | this.hideTip();
192 | }
193 | }
194 |
195 | toggleTip() {
196 | this.state.showTip ? this.hideTip() : this.showTip();
197 | }
198 |
199 | showTip() {
200 | if (!this.state.hasBeenShown) {
201 | // this will render once, then fire componentDidUpdate, which will show the tip
202 | return this.setState({ hasBeenShown: true });
203 | }
204 |
205 | if (!this.state.showTip) {
206 | this.setState({ showTip: true }, () => {
207 | if (typeof this.props.onToggle === 'function') {
208 | this.props.onToggle(this.state.showTip);
209 | }
210 | });
211 | }
212 | }
213 |
214 | hideTip() {
215 | this.setState({ hasHover: false });
216 |
217 | if (this.state.showTip) {
218 | this.setState({ showTip: false }, () => {
219 | if (typeof this.props.onToggle === 'function') {
220 | this.props.onToggle(this.state.showTip);
221 | }
222 | });
223 | }
224 | }
225 |
226 | startHover() {
227 | if (!this.state.ignoreShow) {
228 | this.setState({ hasHover: true });
229 |
230 | clearTimeout(this.hoverTimeout);
231 | this.hoverTimeout = setTimeout(this.checkHover, this.props.hoverDelay);
232 | }
233 | }
234 |
235 | endHover() {
236 | this.setState({ hasHover: false });
237 |
238 | clearTimeout(this.hoverTimeout);
239 | this.hoverTimeout = setTimeout(this.checkHover, this.props.mouseOutDelay || this.props.hoverDelay);
240 | }
241 |
242 | checkHover() {
243 | this.state.hasHover ? this.showTip() : this.hideTip();
244 | }
245 |
246 | render() {
247 | const {
248 | arrow,
249 | arrowSize,
250 | background,
251 | className,
252 | children,
253 | color,
254 | content,
255 | direction,
256 | distance,
257 | eventOff,
258 | eventOn,
259 | eventToggle,
260 | forceDirection,
261 | isOpen,
262 | mouseOutDelay,
263 | padding,
264 | styles,
265 | tagName: TagName,
266 | tipContentHover,
267 | tipContentClassName,
268 | useDefaultStyles,
269 | useHover,
270 | arrowContent,
271 | } = this.props;
272 |
273 | const isControlledByProps = typeof isOpen !== 'undefined' && isOpen !== null;
274 | const showTip = isControlledByProps ? isOpen : this.state.showTip;
275 |
276 | const wrapperStyles = {
277 | position: 'relative',
278 | ...styles,
279 | };
280 |
281 | const props = {
282 | style: wrapperStyles,
283 | ref: (target) => { this.target = target; },
284 | className,
285 | };
286 |
287 | const portalProps = {
288 | // keep clicks on the tip from closing click controlled tips
289 | onClick: stopProp,
290 | };
291 |
292 | // event handling
293 | if (eventOff) {
294 | props[eventOff] = this.hideTip;
295 | }
296 |
297 | if (eventOn) {
298 | props[eventOn] = this.showTip;
299 | }
300 |
301 | if (eventToggle) {
302 | props[eventToggle] = this.toggleTip;
303 |
304 | // only use hover if they don't have a toggle event
305 | } else if (useHover && !isControlledByProps) {
306 | props.onMouseEnter = this.startHover;
307 | props.onMouseLeave = (tipContentHover || mouseOutDelay) ? this.endHover : this.hideTip;
308 | props.onTouchStart = this.targetTouchStart;
309 | props.onTouchEnd = this.targetTouchEnd;
310 |
311 | if (tipContentHover) {
312 | portalProps.onMouseEnter = this.startHover;
313 | portalProps.onMouseLeave = this.endHover;
314 | portalProps.onTouchStart = stopProp;
315 | }
316 | }
317 |
318 | // conditional rendering of tip
319 | let tipPortal;
320 |
321 | if (this.state.hasBeenShown) {
322 | const currentPositions = positions(direction, forceDirection, this.tip, this.target, { ...this.state, showTip }, {
323 | background: useDefaultStyles ? defaultBg : background,
324 | arrow,
325 | arrowSize,
326 | distance,
327 | });
328 |
329 | const tipStyles = {
330 | ...currentPositions.tip,
331 | background: useDefaultStyles ? defaultBg : background,
332 | color: useDefaultStyles ? defaultColor : color,
333 | padding,
334 | boxSizing: 'border-box',
335 | zIndex: this.props.zIndex,
336 | position: 'absolute',
337 | display: 'inline-block',
338 | };
339 |
340 | const arrowStyles = {
341 | ...currentPositions.arrow.positionStyles,
342 | ...(arrowContent ? {} : currentPositions.arrow.borderStyles),
343 | position: 'absolute',
344 | width: '0px',
345 | height: '0px',
346 | zIndex: this.props.zIndex + 1,
347 | };
348 |
349 | tipPortal = (
350 |
351 |
352 | { this.tip = tip; }}>
353 | {content}
354 |
355 | {arrowContent}
356 |
357 |
358 | );
359 | }
360 |
361 | return (
362 |
363 | {children}
364 | {tipPortal}
365 |
366 | );
367 | }
368 | }
369 |
370 | export default Tooltip;
371 |
--------------------------------------------------------------------------------
/src/position.js:
--------------------------------------------------------------------------------
1 | /**
2 | * @file positions.js
3 | * @description some functions for position calculation
4 | */
5 |
6 | import getDirection from './getDirection';
7 | import { minArrowPadding, bodyPadding, getArrowSpacing, getScrollTop, getScrollLeft } from './functions';
8 |
9 | /**
10 | * Sets tip max width safely for mobile
11 | */
12 | function getTipMaxWidth() {
13 | return (typeof document !== 'undefined') ? document.documentElement.clientWidth - (bodyPadding * 2) : 1000;
14 | }
15 |
16 | /**
17 | * Parses align mode from direction if specified with hyphen, defaulting to middle if not -
18 | * e.g. 'left-start' is mode 'start' and 'left' would be the default of 'middle'
19 | */
20 | function parseAlignMode(direction) {
21 | const directionArray = direction.split('-');
22 | if (directionArray.length > 1) {
23 | return directionArray[1];
24 | }
25 | return 'middle';
26 | }
27 |
28 | /**
29 | * Gets wrapper's left position for top/bottom tooltips as well as needed width restriction
30 | */
31 | function getUpDownPosition(tip, target, state, direction, alignMode, props) {
32 | let left = -10000000;
33 | let top;
34 |
35 | const transform = state.showTip ? undefined : 'translateX(-10000000px)';
36 |
37 | const arrowSpacing = getArrowSpacing(props);
38 |
39 | if (tip) {
40 |
41 | // get wrapper left position
42 | const scrollLeft = getScrollLeft();
43 | const targetRect = target.getBoundingClientRect();
44 | const targetLeft = targetRect.left + scrollLeft;
45 |
46 | const halfTargetWidth = Math.round(target.offsetWidth / 2);
47 | const tipWidth = Math.min(getTipMaxWidth(), tip.offsetWidth);
48 | const arrowCenter = targetLeft + halfTargetWidth;
49 | const arrowLeft = arrowCenter - props.arrowSize;
50 | const arrowRight = arrowCenter + props.arrowSize;
51 |
52 | if (alignMode === 'start') {
53 | left = props.arrow ? Math.min(arrowLeft, targetLeft) : targetLeft;
54 | } else if (alignMode === 'end') {
55 | const rightWithArrow = Math.max(arrowRight, (targetLeft + target.offsetWidth));
56 | const rightEdge = props.arrow ? rightWithArrow : (targetLeft + target.offsetWidth);
57 | left = Math.max(rightEdge - tipWidth, bodyPadding + scrollLeft);
58 | } else {
59 | const centeredLeft = (targetLeft + halfTargetWidth) - Math.round(tipWidth / 2);
60 | const availableSpaceOnLeft = bodyPadding + scrollLeft;
61 |
62 | left = Math.max(centeredLeft, availableSpaceOnLeft);
63 | }
64 |
65 | // check for right overhang
66 | const rightOfTip = left + tipWidth;
67 | const rightOfScreen = (scrollLeft + document.documentElement.clientWidth) - bodyPadding;
68 | const rightOverhang = rightOfTip - rightOfScreen;
69 | if (rightOverhang > 0) {
70 | left -= rightOverhang;
71 | }
72 |
73 | if (direction === 'up') {
74 | top = (targetRect.top + getScrollTop()) - (tip.offsetHeight + arrowSpacing);
75 | } else {
76 | top = targetRect.bottom + getScrollTop() + arrowSpacing;
77 | }
78 | }
79 |
80 | return {
81 | left,
82 | top,
83 | transform,
84 | };
85 | }
86 |
87 |
88 | /**
89 | * gets top position for left/right arrows
90 | */
91 | function getLeftRightPosition(tip, target, state, direction, alignMode, props) {
92 | let left = -10000000;
93 | let top = 0;
94 |
95 | const transform = state.showTip ? undefined : 'translateX(-10000000px)';
96 |
97 | const arrowSpacing = getArrowSpacing(props);
98 | const arrowPadding = props.arrow ? minArrowPadding : 0;
99 |
100 | if (tip) {
101 | const scrollTop = getScrollTop();
102 | const scrollLeft = getScrollLeft();
103 | const targetRect = target.getBoundingClientRect();
104 | const targetTop = targetRect.top + scrollTop;
105 | const halfTargetHeight = Math.round(target.offsetHeight / 2);
106 | const arrowTop = (targetTop + halfTargetHeight) - props.arrowSize;
107 | const arrowBottom = targetRect.top + scrollTop + halfTargetHeight + props.arrowSize;
108 |
109 | // TODO: handle close to edges better
110 | if (alignMode === 'start') {
111 | top = props.arrow ? Math.min(targetTop, arrowTop) : targetTop;
112 | } else if (alignMode === 'end') {
113 | const topForBottomAlign = (targetRect.bottom + scrollTop) - tip.offsetHeight;
114 | top = props.arrow ? Math.max(topForBottomAlign, arrowBottom - tip.offsetHeight) : topForBottomAlign;
115 | } else {
116 | // default to middle, but don't go below body
117 | const centeredTop = Math.max((targetTop + halfTargetHeight) - Math.round(tip.offsetHeight / 2), bodyPadding + scrollTop);
118 |
119 | // make sure it doesn't go below the arrow
120 | top = Math.min(centeredTop, arrowTop - arrowPadding);
121 | }
122 |
123 | // check for bottom overhang
124 | const bottomOverhang = ((top - scrollTop) + tip.offsetHeight + bodyPadding) - window.innerHeight;
125 | if (bottomOverhang > 0) {
126 | // try to add the body padding below the tip, but don't offset too far from the arrow
127 | top = Math.max(top - bottomOverhang, (arrowBottom + arrowPadding) - tip.offsetHeight);
128 | }
129 |
130 | if (direction === 'right') {
131 | left = targetRect.right + arrowSpacing + scrollLeft;
132 | } else {
133 | left = (targetRect.left - arrowSpacing - tip.offsetWidth) + scrollLeft;
134 | }
135 | }
136 |
137 | return {
138 | left,
139 | top,
140 | transform,
141 | };
142 | }
143 |
144 | /**
145 | * sets the Arrow styles based on direction
146 | */
147 | function getArrowStyles(target, tip, direction, state, props) {
148 | if (!target || !props.arrow) {
149 | return {
150 | positionStyles: {
151 | top: '0',
152 | left: '-10000000px',
153 | },
154 | };
155 | }
156 |
157 | const targetRect = target.getBoundingClientRect();
158 | const halfTargetHeight = Math.round(target.offsetHeight / 2);
159 | const halfTargetWidth = Math.round(target.offsetWidth / 2);
160 | const scrollTop = getScrollTop();
161 | const scrollLeft = getScrollLeft();
162 | const arrowSpacing = getArrowSpacing(props);
163 | const borderStyles = {};
164 | const positionStyles = {};
165 |
166 | switch (direction) {
167 | case 'right':
168 | borderStyles.borderTop = `${props.arrowSize}px solid transparent`;
169 | borderStyles.borderBottom = `${props.arrowSize}px solid transparent`;
170 |
171 | if (props.background) {
172 | borderStyles.borderRight = `${props.arrowSize}px solid ${props.background}`;
173 | } else {
174 | borderStyles.borderRightWidth = `${props.arrowSize}px`;
175 | borderStyles.borderRightStyle = 'solid';
176 | }
177 |
178 | positionStyles.top = (state.showTip && tip) ? (targetRect.top + scrollTop + halfTargetHeight) - props.arrowSize : '-10000000px';
179 | positionStyles.left = (targetRect.right + scrollLeft + arrowSpacing) - props.arrowSize;
180 | break;
181 | case 'left':
182 | borderStyles.borderTop = `${props.arrowSize}px solid transparent`;
183 | borderStyles.borderBottom = `${props.arrowSize}px solid transparent`;
184 |
185 | if (props.background) {
186 | borderStyles.borderLeft = `${props.arrowSize}px solid ${props.background}`;
187 | } else {
188 | borderStyles.borderLeftWidth = `${props.arrowSize}px`;
189 | borderStyles.borderLeftStyle = 'solid';
190 | }
191 |
192 | positionStyles.top = (state.showTip && tip) ? (targetRect.top + scrollTop + halfTargetHeight) - props.arrowSize : '-10000000px';
193 | positionStyles.left = (targetRect.left + scrollLeft) - arrowSpacing - 1;
194 | break;
195 | case 'up':
196 | borderStyles.borderLeft = `${props.arrowSize}px solid transparent`;
197 | borderStyles.borderRight = `${props.arrowSize}px solid transparent`;
198 |
199 | // if color is styled with css, we need everything except border-color, if styled with props, we add entire border rule
200 | if (props.background) {
201 | borderStyles.borderTop = `${props.arrowSize}px solid ${props.background}`;
202 | } else {
203 | borderStyles.borderTopWidth = `${props.arrowSize}px`;
204 | borderStyles.borderTopStyle = 'solid';
205 | }
206 |
207 |
208 | positionStyles.left = (state.showTip && tip) ? (targetRect.left + scrollLeft + halfTargetWidth) - props.arrowSize : '-10000000px';
209 | positionStyles.top = (targetRect.top + scrollTop) - arrowSpacing;
210 | break;
211 | case 'down':
212 | default:
213 | borderStyles.borderLeft = `${props.arrowSize}px solid transparent`;
214 | borderStyles.borderRight = `${props.arrowSize}px solid transparent`;
215 |
216 | if (props.background) {
217 | borderStyles.borderBottom = `10px solid ${props.background}`;
218 | } else {
219 | borderStyles.borderBottomWidth = `${props.arrowSize}px`;
220 | borderStyles.borderBottomStyle = 'solid';
221 | }
222 |
223 | positionStyles.left = (state.showTip && tip) ? (targetRect.left + scrollLeft + halfTargetWidth) - props.arrowSize : '-10000000px';
224 | positionStyles.top = (targetRect.bottom + scrollTop + arrowSpacing) - props.arrowSize;
225 | break;
226 | }
227 | return {
228 | borderStyles,
229 | positionStyles,
230 | };
231 | }
232 |
233 | /**
234 | * Returns the positions style rules
235 | */
236 | export default function positions(direction, forceDirection, tip, target, state, props) {
237 | const alignMode = parseAlignMode(direction);
238 | const trimmedDirection = direction.split('-')[0];
239 |
240 | let realDirection = trimmedDirection;
241 | if (!forceDirection && tip) {
242 | const testArrowStyles = props.arrow && getArrowStyles(target, tip, trimmedDirection, state, props);
243 | realDirection = getDirection(trimmedDirection, tip, target, props, bodyPadding, testArrowStyles);
244 | }
245 |
246 | const maxWidth = getTipMaxWidth();
247 |
248 | // force the tip to display the width we measured everything at when visible
249 | let width;
250 | if (tip) {
251 | // adding the exact width on the first render forces a bogus line break, so add 1px the first time
252 | const spacer = tip.style.width ? 0 : 1;
253 | width = Math.min(tip.offsetWidth, maxWidth) + spacer;
254 | }
255 |
256 | const tipPosition = (realDirection === 'up' || realDirection === 'down')
257 | ? getUpDownPosition(tip, target, state, realDirection, alignMode, props)
258 | : getLeftRightPosition(tip, target, state, realDirection, alignMode, props);
259 |
260 | return {
261 | tip: {
262 | ...tipPosition,
263 | maxWidth,
264 | width,
265 | },
266 | arrow: getArrowStyles(target, tip, realDirection, state, props),
267 | realDirection,
268 | };
269 | }
270 |
--------------------------------------------------------------------------------
/stories/Wrapper.css:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/stories/Wrapper.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import PropTypes from 'prop-types';
3 |
4 | import './Wrapper.css';
5 |
6 | const Wrapper = ({ children, extraStyles, flex }) => {
7 | const styles = {
8 | ...extraStyles,
9 | marginTop: 100,
10 | position: 'relative',
11 | };
12 |
13 | if (flex) {
14 | styles.display = 'flex';
15 | styles.justifyContent = 'space-between';
16 | }
17 |
18 | return (
19 | {children}
20 | );
21 | };
22 |
23 | Wrapper.propTypes = {
24 | children: PropTypes.node.isRequired,
25 | extraStyles: PropTypes.object,
26 | flex: PropTypes.bool,
27 | };
28 |
29 | Wrapper.defaultProps = {
30 | extraStyles: undefined,
31 | flex: false,
32 | };
33 |
34 | export default Wrapper;
35 |
--------------------------------------------------------------------------------
/stories/arrowContent.css:
--------------------------------------------------------------------------------
1 | .border-tooltip .react-tooltip-lite {
2 | box-sizing: border-box;
3 | border: 1px solid gray;
4 | border-radius: 8px;
5 | box-shadow: 0px 4px 4px rgba(0, 0, 0, 0.2);
6 | }
7 |
8 | .border-tooltip .react-tooltip-lite-down-arrow svg {
9 | transform: translateY(1px);
10 | }
11 |
12 | .border-tooltip .react-tooltip-lite-right-arrow svg {
13 | transform: rotate(270deg) translateY(-4px) translateX(-4px);
14 | }
15 | .border-tooltip .react-tooltip-lite-up-arrow svg {
16 | transform: rotate(180deg) translateY(1px);
17 | }
18 | .border-tooltip .react-tooltip-lite-left-arrow svg {
19 | transform: rotate(90deg) translateY(5px) translateX(4px);
20 | }
21 |
--------------------------------------------------------------------------------
/stories/arrowContent.stories.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { storiesOf } from '@storybook/react';
3 | import { select } from '@storybook/addon-knobs';
4 |
5 | import Tooltip from '../src/index';
6 | import { isOpenOptions } from './shared';
7 | import Wrapper from './Wrapper';
8 |
9 | import './arrowContent.css';
10 |
11 | storiesOf('Tooltip', module)
12 | .add('Custom arrow content', () => {
13 | const isOpen = select('isOpen', isOpenOptions, true);
14 | const svgArrow = (
15 |
16 |
20 |
21 | );
22 |
23 |
24 | return (
25 |
26 |
27 |
37 | Hover
38 |
39 |
49 | Centered
50 |
51 |
62 | Target is here
63 |
64 |
75 | Target is here
76 |
77 |
88 | Target is here
89 |
90 |
91 |
92 | );
93 | });
94 |
--------------------------------------------------------------------------------
/stories/arrowSize.stories.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { storiesOf } from '@storybook/react';
3 | import { select } from '@storybook/addon-knobs';
4 |
5 | import Tooltip from '../src/index';
6 | import { isOpenOptions } from './shared';
7 |
8 | import Wrapper from './Wrapper';
9 |
10 | storiesOf('Tooltip', module)
11 | .add('Arrow size and distance', () => {
12 | const isOpen = select('isOpen', isOpenOptions, true);
13 |
14 | return (
15 |
16 |
23 | Larger arrowSize
24 |
25 |
33 | Smaller arrowSize
34 |
35 |
42 | Increase distance
43 |
44 |
52 | Decrease distance
53 |
54 |
55 | );
56 | });
57 |
--------------------------------------------------------------------------------
/stories/basic.stories.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { storiesOf } from '@storybook/react';
3 | import { select } from '@storybook/addon-knobs';
4 |
5 | import Tooltip from '../src/index';
6 | import { isOpenOptions } from './shared';
7 | import Wrapper from './Wrapper';
8 |
9 | storiesOf('Tooltip', module)
10 | .add('Basic usage', () => {
11 | const isOpen = select('isOpen', isOpenOptions, true);
12 |
13 | return (
14 |
15 |
16 |
22 | Hover
23 |
24 |
30 | Centered
31 |
32 |
39 | Target is here
40 |
41 |
42 |
43 | );
44 | });
45 |
--------------------------------------------------------------------------------
/stories/colors.stories.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { storiesOf } from '@storybook/react';
3 | import { select } from '@storybook/addon-knobs';
4 |
5 | import Tooltip from '../src/index';
6 | import { isOpenOptions } from './shared';
7 | import Wrapper from './Wrapper';
8 |
9 | storiesOf('Tooltip', module)
10 | .add('Colors', () => {
11 | const isOpen = select('isOpen', isOpenOptions, true);
12 |
13 | return (
14 |
15 | You can pass
16 |
25 | color options as props
26 |
27 | or use a
28 |
36 | css stylesheet.
37 |
38 |
39 | Default Styles
40 |
41 | pass the
42 | {'"defaultStyles"'}
43 | prop as true to get up and running quick and easy
44 |
45 |
46 |
54 | See default styles
55 |
56 |
57 |
58 | );
59 | });
60 |
--------------------------------------------------------------------------------
/stories/compoundAlignment.stories.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { storiesOf } from '@storybook/react';
3 | import { select } from '@storybook/addon-knobs';
4 |
5 | import Tooltip from '../src/index';
6 | import { isOpenOptions } from './shared';
7 |
8 | import Wrapper from './Wrapper';
9 |
10 | storiesOf('Tooltip', module)
11 | .add('Compound alignment', () => {
12 | const isOpen = select('isOpen', isOpenOptions, true);
13 |
14 | return (
15 | <>
16 |
17 |
18 |
26 | right-start
27 |
28 |
29 |
37 | right-end
38 |
39 |
40 |
41 |
42 |
50 | left-start
51 |
52 |
53 |
61 | left-end
62 |
63 |
64 |
65 |
66 |
67 |
68 |
69 |
77 | top-start
78 |
79 |
80 |
88 | down-start
89 |
90 |
91 |
92 |
100 | top-end
101 |
102 |
103 |
111 | down-end
112 |
113 |
114 |
115 | >
116 | );
117 | });
118 |
--------------------------------------------------------------------------------
/stories/events.stories.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { storiesOf } from '@storybook/react';
3 |
4 | import Tooltip from '../src/index';
5 | import Wrapper from './Wrapper';
6 |
7 | storiesOf('Tooltip', module)
8 | .add('Custom Events', () => (
9 |
10 |
11 |
18 | Close on click
19 |
20 |
21 |
22 |
23 |
32 | Open on click
33 |
34 |
35 |
36 |
37 |
44 | Toggle on click
45 |
46 |
47 |
48 |
49 |
57 | With mouseOutDelay
58 |
59 |
60 |
61 | ));
62 |
--------------------------------------------------------------------------------
/stories/html.stories.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { storiesOf } from '@storybook/react';
3 | import { select } from '@storybook/addon-knobs';
4 |
5 | import Tooltip from '../src/index';
6 | import { isOpenOptions } from './shared';
7 | import Wrapper from './Wrapper';
8 |
9 | storiesOf('Tooltip', module)
10 | .add('Html content', () => {
11 | const isOpen = select('isOpen', isOpenOptions, true);
12 |
13 | return (
14 |
15 |
16 | You can also have a tooltip with
17 |
20 | An unordered list to demo some html content
21 |
22 | One
23 | Two
24 | Three
25 | Four
26 | Five
27 |
28 |
29 | )}
30 | direction="right"
31 | tagName="span"
32 | className="target"
33 | tipContentClassName=""
34 | isOpen={isOpen}
35 | >
36 | Html content
37 |
38 | .
39 |
40 |
41 |
42 | By specifying the prop "tipContentHover" as true, you can persist hover state when cursor is over the tip. This allows for links
43 | in your tip, copying contents and other behaviors. Here's an
44 |
47 | You can copy this text, or click this
48 | link
49 |
50 | )}
51 | tagName="span"
52 | direction="right"
53 | className="target"
54 | tipContentClassName=""
55 | tipContentHover
56 | isOpen={isOpen}
57 | >
58 | example
59 |
60 | .
61 |
62 |
63 | );
64 | });
65 |
--------------------------------------------------------------------------------
/stories/image.stories.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { storiesOf } from '@storybook/react';
3 | import { select } from '@storybook/addon-knobs';
4 |
5 | import Tooltip from '../src/index';
6 | import { isOpenOptions } from './shared';
7 | import Wrapper from './Wrapper';
8 |
9 | storiesOf('Tooltip', module)
10 | .add('Wrap an image', () => {
11 | const isOpen = select('isOpen', isOpenOptions, true);
12 |
13 | return (
14 |
15 |
21 |
22 |
23 |
24 |
31 |
32 |
33 |
34 | );
35 | });
36 |
--------------------------------------------------------------------------------
/stories/nestedTargets.stories.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { storiesOf } from '@storybook/react';
3 | import { select } from '@storybook/addon-knobs';
4 |
5 | import Tooltip from '../src/index';
6 | import { isOpenOptions } from './shared';
7 | import Wrapper from './Wrapper';
8 |
9 | storiesOf('Tooltip', module)
10 | .add('Nested Targets', () => {
11 | const isOpen = select('isOpen', isOpenOptions, true);
12 |
13 | return (
14 |
15 |
20 |
21 | some text
22 | a span
23 |
24 |
25 |
26 | );
27 | });
28 |
--------------------------------------------------------------------------------
/stories/paragraph.stories.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { storiesOf } from '@storybook/react';
3 | import { select } from '@storybook/addon-knobs';
4 |
5 | import Tooltip from '../src/index';
6 | import { isOpenOptions } from './shared';
7 | import Wrapper from './Wrapper';
8 |
9 | storiesOf('Tooltip', module)
10 | .add('In a Paragraph', () => {
11 | const isOpen = select('isOpen', isOpenOptions, true);
12 |
13 | return (
14 |
15 | For
16 |
17 | inline text
18 |
19 | , a right or left tip works nicely. The tip will try to go the desired way and flip if there is not
20 | enough
21 |
22 | space
23 |
24 | .
25 |
26 |
27 | You can also force the direction of the tip and it will allow itself
28 |
29 | to go off screen
30 |
31 | .
32 |
33 |
34 | );
35 | });
36 |
--------------------------------------------------------------------------------
/stories/shared.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable import/prefer-default-export */
2 |
3 | export const isOpenOptions = {
4 | True: true,
5 | False: false,
6 | Uncontrolled: null,
7 | };
8 |
--------------------------------------------------------------------------------
/stories/stories.css:
--------------------------------------------------------------------------------
1 | /* Tooltip styles */
2 | .react-tooltip-lite {
3 | background: #333;
4 | color: white;
5 | }
6 |
7 | .react-tooltip-lite-arrow {
8 | border-color: #333;
9 | }
10 |
11 | /* Demo Styles */
12 | .target {
13 | text-decoration: underline;
14 | }
15 |
16 | .tip-heading {
17 | margin: 0 0 10px;
18 | }
19 |
20 | .tip-list {
21 | margin: 0;
22 | padding: 0 0 0 15px;
23 | }
24 |
25 | .tip-list li {
26 | margin: 5px 0;
27 | padding: 0;
28 | }
29 |
30 | /* overrides with a custom class */
31 | .customTip .react-tooltip-lite {
32 | border: 1px solid #888;
33 | background: #ccc;
34 | color: black;
35 | }
36 |
37 | .customTip .react-tooltip-lite-arrow {
38 | border-color: #444;
39 | position: relative;
40 | }
41 |
42 | .customTip .react-tooltip-lite-arrow::before {
43 | content: '';
44 | position: absolute;
45 | width: 0;
46 | height: 0;
47 | z-index: 99;
48 | display: block;
49 | }
50 |
51 | .customTip .react-tooltip-lite-up-arrow::before {
52 | border-top: 10px solid #ccc;
53 | border-left: 10px solid transparent;
54 | border-right: 10px solid transparent;
55 | left: -10px;
56 | top: -11px;
57 | }
58 |
59 | .customTip .react-tooltip-lite-down-arrow::before {
60 | border-bottom: 10px solid #ccc;
61 | border-left: 10px solid transparent;
62 | border-right: 10px solid transparent;
63 | left: -10px;
64 | bottom: -11px;
65 | }
66 |
67 | .customTip .react-tooltip-lite-right-arrow::before {
68 | border-right: 10px solid #ccc;
69 | border-top: 10px solid transparent;
70 | border-bottom: 10px solid transparent;
71 | right: -11px;
72 | top: -10px;
73 | }
74 |
75 | .customTip .react-tooltip-lite-left-arrow::before {
76 | border-left: 10px solid #ccc;
77 | border-top: 10px solid transparent;
78 | border-bottom: 10px solid transparent;
79 | left: -11px;
80 | top: -10px;
81 | }
82 |
83 | /* demo layouts */
84 | .stacked-examples > div {
85 | margin: 0 50px 100px;
86 | }
87 |
--------------------------------------------------------------------------------
/stories/strictMode.stories.jsx:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bsidelinger912/react-tooltip-lite/dfd3dfff9ed9f8167178ef74ddd9b271926a32ab/stories/strictMode.stories.jsx
--------------------------------------------------------------------------------
/test/jest.config.json:
--------------------------------------------------------------------------------
1 | {
2 | "rootDir": "..",
3 | "testPathIgnorePatterns": ["/node_modules/", "/dist/", "storyshots.test.jsx"]
4 | }
--------------------------------------------------------------------------------
/test/jest.storyshots.config.json:
--------------------------------------------------------------------------------
1 | {
2 | "rootDir": "..",
3 | "setupFilesAfterEnv": ["/test/jest.storyshots.setup.js"],
4 | "moduleNameMapper": {
5 | "\\.(css|scss)$": "/test/styleMock.js"
6 | },
7 | "testMatch": ["**/__tests__/storyshots.test.jsx"]
8 | }
--------------------------------------------------------------------------------
/test/jest.storyshots.setup.js:
--------------------------------------------------------------------------------
1 | import registerRequireContextHook from 'babel-plugin-require-context-hook/register';
2 | import initStoryshots from '@storybook/addon-storyshots';
3 | import { imageSnapshot } from '@storybook/addon-storyshots-puppeteer';
4 |
5 | registerRequireContextHook();
6 |
7 | /* const beforeScreenshot = (page) => {
8 | return new Promise((resolve) => {
9 | page.hover('.target')
10 | .then(() => {
11 | setTimeout(resolve, 500);
12 | })
13 | .catch(() => {
14 | console.log('no elemet with .target found');
15 | resolve();
16 | });
17 | });
18 | }; */
19 | const getMatchOptions = () => ({ // ({ context: { kind, story }, url }) => {
20 | failureThreshold: 0.2,
21 | failureThresholdType: 'percent',
22 | });
23 |
24 | initStoryshots({
25 | suite: 'Storyshots',
26 | test: imageSnapshot({
27 | getMatchOptions,
28 | storybookUrl: 'http://localhost:6006',
29 | }),
30 | });
31 |
--------------------------------------------------------------------------------
/test/styleMock.js:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bsidelinger912/react-tooltip-lite/dfd3dfff9ed9f8167178ef74ddd9b271926a32ab/test/styleMock.js
--------------------------------------------------------------------------------
/webpack.config.js:
--------------------------------------------------------------------------------
1 | // const webpack = require('webpack');
2 | const path = require('path');
3 |
4 | module.exports = {
5 | entry: [
6 | './example/index.jsx',
7 | ],
8 | devtool: 'source-map',
9 | output: {
10 | path: path.join(__dirname, 'example'),
11 | filename: 'bundle.js',
12 | },
13 | resolve: {
14 | extensions: ['.js', '.jsx'],
15 | },
16 | module: {
17 | rules: [
18 | {
19 | test: /\.jsx?$/,
20 | exclude: /node_modules/,
21 | loader: 'babel-loader',
22 | },
23 | ],
24 | },
25 | };
26 |
--------------------------------------------------------------------------------
/webpack.dist.config.js:
--------------------------------------------------------------------------------
1 | // const webpack = require('webpack');
2 | const path = require('path');
3 |
4 | module.exports = {
5 | entry: [
6 | './src/index.jsx',
7 | ],
8 | output: {
9 | path: path.join(__dirname, 'dist'),
10 | filename: 'react-tooltip-lite.min.js',
11 | library: 'ReactToolTipLite',
12 | libraryTarget: 'umd',
13 | },
14 | resolve: {
15 | extensions: ['.js', '.jsx'],
16 | },
17 | externals: {
18 | // Use external version of React
19 | react: 'React',
20 | 'react-dom': 'ReactDOM',
21 | },
22 | module: {
23 | rules: [
24 | {
25 | test: /\.jsx?$/,
26 | exclude: /node_modules/,
27 | loader: 'babel-loader',
28 | },
29 | ],
30 | },
31 | };
32 |
--------------------------------------------------------------------------------