├── .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 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | 142 | 143 | 144 | 145 | 146 | 147 | 148 | 149 | 150 | 151 | 152 | 153 | 154 | 155 | 156 | 157 | 158 | 159 | 160 | 161 | 162 | 163 | 164 | 165 | 166 | 167 | 168 | 169 | 170 | 171 | 172 | 173 | 174 | 175 | 176 | 177 | 178 | 179 | 180 | 181 | 182 | 183 | 184 | 185 | 186 | 187 | 188 | 189 | 190 | 191 | 192 | 193 |
NameTypeDescription
contentnode (text or html)the contents of your hover target
tagNamestringhtml tag used for className
directionstringthe 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".
forceDirectionbooleanTells 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.
classNamestring 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 |
tipContentClassNamestringcss class added to the tooltip
tipContentHoverbooleandefines whether you should be able to hover over the tip contents for links and copying content, 100 | defaults to false. 101 |
backgroundstringbackground color for the tooltip contents and arrow
colorstringtext color for the tooltip contents
paddingstringpadding amount for the tooltip contents (defaults to '10px')
stylesobjectstyle overrides for the target wrapper
eventOnstringfull name of supported react event to show the tooltip, e.g.: 'onClick'
eventOffstringfull name of supported react event to hide the tooltip, e.g.: 'onClick'
eventTogglestringfull name of supported react event to toggle the tooltip, e.g.: 'onClick', default hover toggling is disabled when using this option
useHoverbooleanwhether to use hover to show/hide the tip, defaults to true
useDefaultStylesbooleanuses default colors for the tooltip, so you don't need to write any CSS for it
isOpenbooleanforces open/close state from a prop, overrides hover or click state
hoverDelaynumberthe number of milliseconds to determine hover intent, defaults to 200
mouseOutDelaynumberthe number of milliseconds to determine hover-end intent, defaults to the hoverDelay value
arrowbooleanWhether or not to have an arrow on the tooltip, defaults to true
arrowSizenumberNumber in pixels of the size of the arrow, defaults to 10
arrowContentnode (text or html)custom arrow contents, such as an SVG
distancenumberThe distance from the tooltip to the target, defaults to 10px with an arrow and 3px without an arrow
zIndexnumberThe zIndex of the tooltip, defaults to 1000
onTogglefunctionif passed, this is called when the visibility of the tooltip changes.
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 | 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 | 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 | react logo 202 | 203 | 204 | 210 | react logo 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 | 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 | react logo 22 | 23 | 24 | 31 | react logo 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 | 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 | --------------------------------------------------------------------------------