├── .eslintrc.js ├── .github └── FUNDING.yml ├── .gitignore ├── .prettierrc.json ├── .travis.yml ├── LICENSE ├── README.md ├── babel.config.js ├── demo ├── index.css ├── index.html └── index.js ├── headless └── package.json ├── index.d.ts ├── logo.png ├── package.json ├── rollup.config.js ├── src ├── Tippy.js ├── className-plugin.js ├── forwardRef.js ├── headless.js ├── index.js ├── useSingleton.js ├── util-hooks.js └── utils.js ├── test ├── Tippy.test.js ├── __snapshots__ │ ├── Tippy.test.js.snap │ └── useSingleton.test.js.snap ├── setup.js ├── useSingleton.test.js └── utils.test.js ├── tsconfig.json └── yarn.lock /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | browser: true, 4 | node: true, 5 | jest: true, 6 | }, 7 | parser: 'babel-eslint', 8 | parserOptions: { 9 | ecmaVersion: 2018, 10 | sourceType: 'module', 11 | ecmaFeatures: { 12 | jsx: true, 13 | }, 14 | }, 15 | settings: { 16 | react: { 17 | version: 'detect', 18 | }, 19 | }, 20 | plugins: ['react-hooks'], 21 | extends: ['eslint:recommended', 'plugin:react/recommended', 'prettier'], 22 | rules: { 23 | 'no-console': 'off', 24 | 'react/prop-types': 'off', 25 | 'no-unused-vars': ['error', {ignoreRestSiblings: true}], 26 | 'react-hooks/rules-of-hooks': 'error', 27 | 'react/display-name': 'off', 28 | }, 29 | }; 30 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [atomiks] 4 | patreon: # Replace with a single Patreon username 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: # Replace with a single Ko-fi username 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | otechie: # Replace with a single Otechie username 12 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 13 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | coverage 4 | .devserver 5 | dist 6 | umd 7 | esm -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "bracketSpacing": false, 4 | "trailingComma": "all", 5 | "proseWrap": "always" 6 | } 7 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | dist: xenial 2 | language: node_js 3 | node_js: 4 | - lts/* 5 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 atomiks 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 |
2 | Logo 3 |
4 | 5 |
6 |

Tippy.js for React

7 |
8 | 9 | ⚠️⚠️⚠️ 10 | 11 | **If you're new here, we recommend using [Floating UI's React DOM Interactions package](https://floating-ui.com/docs/react-dom-interactions) instead of this library**. It offers a first class React experience rather than being a wrapper around a vanilla library and encourages much better accessibility practices with more flexibility. 12 | 13 | If you want some out-of-the-box styling and animations, and are adding simple tooltips/popovers to your app, Tippy will still work fine. For more advanced/headless solutions, it's best to use Floating UI! 14 | 15 | ⚠️⚠️⚠️ 16 | 17 | --- 18 | 19 | [Tippy.js](https://github.com/atomiks/tippyjs/) is the complete tooltip, 20 | popover, dropdown, and menu solution for the web, powered by Popper. 21 | 22 | Tippy is an abstraction over Popper that provides common logic involved in all 23 | types of elements that pop out on top of the UI, positioned next to a target or 24 | reference element. This is a React wrapper for the core library, providing full 25 | integration including headless rendering abilities. 26 | 27 | ## 🚀 Installation 28 | 29 | ```bash 30 | # npm 31 | npm i @tippyjs/react 32 | 33 | # Yarn 34 | yarn add @tippyjs/react 35 | ``` 36 | 37 | CDN: https://unpkg.com/@tippyjs/react 38 | 39 | ## 🖲 Usage 40 | 41 | There are two ways to use this component: 42 | 43 | - **Default**: With the built-in DOM rendering and optionally the default CSS. 44 | This is complete "out of the box" behavior and requires no setup. If you want 45 | something that just works, this is for you. 46 | - **Headless**: With React's DOM rendering for improved usage with CSS-in-JS and 47 | spring libraries. If you want greater control over your poppers to integrate 48 | fully with design systems, this is for you. 49 | 50 | Both may be used in conjunction. 51 | 52 | ### Default Tippy 53 | 54 | Import the `Tippy` component and (optionally) the core CSS. Wrap the `` 55 | component around the element, supplying the tooltip's content as the `content` 56 | prop. It can take a string or a tree of React elements. 57 | 58 | ```jsx 59 | import React from 'react'; 60 | import Tippy from '@tippyjs/react'; 61 | import 'tippy.js/dist/tippy.css'; // optional 62 | 63 | const StringContent = () => ( 64 | 65 | 66 | 67 | ); 68 | 69 | const JSXContent = () => ( 70 | Tooltip}> 71 | 72 | 73 | ); 74 | ``` 75 | 76 | Default Tippy "just works" out of the box. 77 | 78 | ### Headless Tippy 79 | 80 | Render your own tippy element from scratch: 81 | 82 | ```jsx 83 | import React from 'react'; 84 | import Tippy from '@tippyjs/react/headless'; // different import path! 85 | 86 | const HeadlessTippy = () => ( 87 | ( 89 |
90 | My tippy box 91 |
92 | )} 93 | > 94 | 95 |
96 | ); 97 | ``` 98 | 99 | `attrs` is an object containing `data-placement`, `data-reference-hidden`, and 100 | `data-escaped` attributes. This allows you to conditionally style your tippy. 101 | 102 | #### Headless animation 103 | 104 | - [`framer-motion`](https://codesandbox.io/s/festive-fire-hcr47) 105 | - [`react-spring`](https://codesandbox.io/s/vigilant-northcutt-7w3yr) 106 | 107 | #### Headless arrow 108 | 109 | To make Popper position your custom arrow, set a `data-popper-arrow` attribute 110 | on it: 111 | 112 | ```jsx 113 | ( 115 | 116 | Hello 117 | 118 | 119 | )} 120 | > 121 | 122 | 123 | ``` 124 | 125 | For details on styling the arrow from scratch, 126 | [take a look at the Popper tutorial](https://popper.js.org/docs/v2/tutorial/#arrow). 127 | 128 | **Note: your arrow must be an `HTMLElement` (not an `SVGElement`). To use an SVG 129 | arrow, wrap it in a `
` tag with the `data-popper-arrow` attribute.** 130 | 131 | You may also pass a ref to the element directly without the attribute using a 132 | callback ref: 133 | 134 | ```jsx 135 | function App() { 136 | const [arrow, setArrow] = useState(null); 137 | 138 | return ( 139 | ( 141 | 142 | Content 143 | 144 | 145 | )} 146 | popperOptions={{ 147 | modifiers: [ 148 | { 149 | name: 'arrow', 150 | options: { 151 | element: arrow, // can be a CSS selector too 152 | }, 153 | }, 154 | ], 155 | }} 156 | > 157 | 158 | 159 | ); 160 | } 161 | ``` 162 | 163 | #### Headless root element 164 | 165 | When rendering an element with the `render` prop, you're rendering the inner 166 | element that the root popper (positioned) node wraps. 167 | 168 | For advanced cases you can access the root element via `instance.popper`. 169 | 170 | [Here's `moveTransition` with Framer Motion](https://codesandbox.io/s/tippyjs-react-framer-motion-j94mj). 171 | 172 | ### Component children 173 | 174 | If you want to use a component element as a child of the component, ensure you 175 | forward the ref to the DOM node: 176 | 177 | ```jsx 178 | import React, {forwardRef} from 'react'; 179 | 180 | function ThisWontWork() { 181 | return ; 182 | } 183 | 184 | const ThisWillWork = forwardRef((props, ref) => { 185 | return ; 186 | }); 187 | 188 | function App() { 189 | return ( 190 | 191 | 192 | 193 | ); 194 | } 195 | ``` 196 | 197 | `styled-components` v4+ does this for you automatically, so it should be 198 | seamless when using the `styled` constructor. 199 | 200 | Workaround for old libraries that don't forward the ref is to use a `` 201 | wrapper tag: 202 | 203 | ```jsx 204 | 205 | 206 | Reference 207 | 208 | 209 | ``` 210 | 211 | ## 🧬 Props 212 | 213 | All of the native Tippy.js props can be passed to the component. 214 | 215 | Visit [All Props](https://atomiks.github.io/tippyjs/v6/all-props/) to view the 216 | complete list. 217 | 218 | ```jsx 219 | 220 | 221 | 222 | ``` 223 | 224 | In addition, there are 3 more props added specifically for the React component. 225 | 226 | ### `className?: string` 227 | 228 | ```jsx 229 | 230 | 231 | 232 | ``` 233 | 234 | This allows you to use `styled(Tippy)` or the `css` prop in `styled-components` 235 | or `emotion`. 236 | 237 | > Note: Does not apply if using Headless Tippy. 238 | 239 | ### `disabled?: boolean` 240 | 241 | ```jsx 242 | function App() { 243 | const [disabled, setDisabled] = useState(false); 244 | 245 | return ( 246 | 247 | 248 | 249 | ); 250 | } 251 | ``` 252 | 253 | ### `visible?: boolean` (controlled mode) 254 | 255 | Use React's state to fully control the tippy instead of relying on the native 256 | `trigger` and `hideOnClick` props: 257 | 258 | ```jsx 259 | function App() { 260 | const [visible, setVisible] = useState(true); 261 | const show = () => setVisible(true); 262 | const hide = () => setVisible(false); 263 | 264 | return ( 265 | 266 | 267 | 268 | ); 269 | } 270 | ``` 271 | 272 | ### `reference?: React.RefObject | Element` 273 | 274 | > Available from `v4.1.0` 275 | 276 | If you can't place your reference element as a child inside ``, you can 277 | use this prop instead. It accepts a React `RefObject` (`.current` property) or a 278 | plain `Element`. 279 | 280 | ```jsx 281 | function App() { 282 | const ref = useRef(); 283 | 284 | return ( 285 | <> 286 | 309 | 310 | ); 311 | } 312 | ``` 313 | 314 | [Read more about plugins here](https://atomiks.github.io/tippyjs/v6/plugins/). 315 | 316 | ## 🌈 Multiple tippies on a single element 317 | 318 | You can nest the components like so: 319 | 320 | ```jsx 321 | 322 | 323 | 324 | 325 | 326 | 327 | 328 | 329 | 330 | ``` 331 | 332 | ## Lazy mounting 333 | 334 | By default, Tippy mounts your `content` or `render` elements into a container 335 | element once created, even if the tippy isn't mounted on the DOM. In most cases, 336 | this is fine, but in performance-sensitive scenarios or cases where mounting the 337 | component should fire effects only when the tippy mounted, you can lazify the 338 | component. 339 | 340 | [View the following gists to optimize your `` if needed.](https://gist.github.com/atomiks/520f4b0c7b537202a23a3059d4eec908) 341 | 342 | ## 📚 useSingleton 343 | 344 | A Hook for the 345 | [`createSingleton()`](https://atomiks.github.io/tippyjs/v6/addons/#singleton) 346 | addon to re-use a single tippy element for many different reference element 347 | targets. 348 | 349 | [View on CodeSandbox](https://codesandbox.io/s/unruffled-pasteur-4yy99?file=/src/App.js) 350 | 351 | ```jsx 352 | import Tippy, {useSingleton} from '@tippyjs/react'; 353 | 354 | function App() { 355 | const [source, target] = useSingleton(); 356 | 357 | return ( 358 | <> 359 | {/* This is the tippy that gets used as the singleton */} 360 | 361 | 362 | {/* These become "virtual" */} 363 | 364 | 365 | 366 | 367 | 368 | 369 | 370 | ); 371 | } 372 | ``` 373 | 374 | `useSingleton()` takes an optional props argument: 375 | 376 | ```js 377 | const [source, target] = useSingleton({ 378 | disabled: true, 379 | overrides: ['placement'], 380 | }); 381 | ``` 382 | 383 | ### Headless singleton 384 | 385 | The `render` prop takes the singleton content as a second parameter: 386 | 387 | ```jsx 388 | import Tippy, {useSingleton} from '@tippyjs/react/headless'; 389 | 390 | function App() { 391 | const [source, target] = useSingleton(); 392 | 393 | return ( 394 | <> 395 | ( 398 |
399 | {content} 400 |
401 | )} 402 | delay={500} 403 | /> 404 | 405 | 406 | 407 | 408 | 409 | 410 | 411 | 412 | ); 413 | } 414 | ``` 415 | 416 | ## 📝 License 417 | 418 | MIT 419 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | ['@babel/env', {loose: true, useBuiltIns: 'entry', corejs: 3}], 4 | '@babel/react', 5 | ], 6 | plugins: ['annotate-pure-calls'], 7 | env: { 8 | test: { 9 | presets: [ 10 | ['@babel/env', {targets: {node: 'current'}}], 11 | '@babel/react', 12 | ] 13 | } 14 | } 15 | }; 16 | -------------------------------------------------------------------------------- /demo/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | font-family: sans-serif; 3 | } 4 | 5 | .container { 6 | max-width: 1000px; 7 | margin: 0 auto; 8 | margin-top: 50px; 9 | } 10 | 11 | button { 12 | border: none; 13 | font-size: 18px; 14 | background: #eee; 15 | margin-right: 10px; 16 | padding: 10px; 17 | } 18 | -------------------------------------------------------------------------------- /demo/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 |
8 | 9 | 10 | -------------------------------------------------------------------------------- /demo/index.js: -------------------------------------------------------------------------------- 1 | import React, {useState, useEffect, useRef, forwardRef} from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import styled from 'styled-components'; 4 | import {useSpring, animated} from 'react-spring'; 5 | import {motion, useSpring as useFramerSpring} from 'framer-motion'; 6 | import {followCursor} from 'tippy.js'; 7 | import Tippy, {useSingleton} from '../src'; 8 | import TippyHeadless, { 9 | useSingleton as useSingletonHeadless, 10 | } from '../src/headless'; 11 | 12 | import 'tippy.js/dist/tippy.css'; 13 | import './index.css'; 14 | 15 | const ReactSpringBox = styled(animated.div)` 16 | background: #333; 17 | color: white; 18 | padding: 5px 10px; 19 | border-radius: 4px; 20 | 21 | &[data-placement^='top'] { 22 | transform-origin: bottom; 23 | } 24 | 25 | &[data-placement^='bottom'] { 26 | transform-origin: top; 27 | } 28 | `; 29 | 30 | const ReactFramerBox = styled(motion.div)` 31 | background: #333; 32 | color: white; 33 | padding: 5px 10px; 34 | border-radius: 4px; 35 | 36 | &[data-placement^='top'] { 37 | transform-origin: bottom; 38 | } 39 | 40 | &[data-placement^='bottom'] { 41 | transform-origin: top; 42 | } 43 | `; 44 | 45 | const LazyTippy = forwardRef((props, ref) => { 46 | const [mounted, setMounted] = useState(false); 47 | 48 | const lazyPlugin = { 49 | fn: () => ({ 50 | onMount: () => setMounted(true), 51 | onHidden: () => setMounted(false), 52 | }), 53 | }; 54 | 55 | const computedProps = {...props}; 56 | 57 | computedProps.plugins = [lazyPlugin, ...(props.plugins || [])]; 58 | 59 | if (props.render) { 60 | computedProps.render = (...args) => (mounted ? props.render(...args) : ''); 61 | } else { 62 | computedProps.content = mounted ? props.content : ''; 63 | } 64 | 65 | return ; 66 | }); 67 | 68 | function CountContent() { 69 | const [count, setCount] = useState(0); 70 | 71 | useEffect(() => { 72 | const interval = setInterval(() => { 73 | setCount(c => c + 1); 74 | }, 100); 75 | 76 | return () => clearInterval(interval); 77 | }, []); 78 | 79 | return count; 80 | } 81 | 82 | function LazyTippyExample() { 83 | return ( 84 | } hideOnClick={false}> 85 | } trigger="click" placement="bottom"> 86 | 87 | 88 | 89 | ); 90 | } 91 | 92 | function ContentString() { 93 | const [count, setCount] = useState(0); 94 | 95 | useEffect(() => { 96 | setInterval(() => { 97 | setCount(count => count + 1); 98 | }, 1000); 99 | }, []); 100 | 101 | return ( 102 | 103 | 104 | 105 | ); 106 | } 107 | 108 | function ContentElement() { 109 | const colors = ['red', 'orange', 'yellow', 'green', 'cyan', 'purple', 'pink']; 110 | const [index, setIndex] = useState(0); 111 | 112 | function renderNextColor() { 113 | setIndex(index === colors.length - 1 ? 0 : index + 1); 114 | } 115 | 116 | return ( 117 | 120 | 121 | Hello 122 | 123 | } 124 | interactive={true} 125 | > 126 | 127 | 128 | ); 129 | } 130 | 131 | function DisabledProp() { 132 | const [disabled, setDisabled] = useState(false); 133 | 134 | return ( 135 | 136 | 139 | 140 | ); 141 | } 142 | 143 | function VisibleProp() { 144 | const [visible, setVisible] = useState(false); 145 | 146 | return ( 147 | 148 | 151 | 152 | ); 153 | } 154 | 155 | function Singleton() { 156 | const [source, target] = useSingleton(); 157 | 158 | return ( 159 | <> 160 | 161 | 162 | 163 | 164 | 165 | 166 | 167 | 168 | ); 169 | } 170 | 171 | function SingletonHeadlessDynamicContent() { 172 | const [source, target] = useSingletonHeadless(); 173 | const [count, setCount] = useState(0); 174 | 175 | useEffect(() => { 176 | setInterval(() => { 177 | setCount(c => c + 1); 178 | }, 1000); 179 | }, []); 180 | 181 | return ( 182 | <> 183 | ( 185 | {content} 186 | )} 187 | singleton={source} 188 | /> 189 | 190 | 191 | 192 | 193 | 194 | 195 | 196 | ); 197 | } 198 | 199 | function SingletonHeadless() { 200 | const [source, target] = useSingletonHeadless({overrides: ['placement']}); 201 | const [enabled, setEnabled] = useState(false); 202 | 203 | useEffect(() => { 204 | setInterval(() => { 205 | setEnabled(e => !e); 206 | }, 2000); 207 | }, []); 208 | 209 | return ( 210 | <> 211 | ( 213 | {content} 214 | )} 215 | singleton={source} 216 | /> 217 | 218 | {enabled && ( 219 | 220 | 221 | 222 | )} 223 | 224 | 225 | 226 | 227 | 228 | 229 | 230 | ); 231 | } 232 | 233 | function FollowCursor() { 234 | return ( 235 | 236 | 237 | 238 | ); 239 | } 240 | 241 | function ReactSpring() { 242 | const config = {tension: 300, friction: 15}; 243 | const initialStyles = {opacity: 0, transform: 'scale(0.5)'}; 244 | const [props, setSpring] = useSpring(() => initialStyles); 245 | 246 | function onMount() { 247 | setSpring({ 248 | opacity: 1, 249 | transform: 'scale(1)', 250 | onRest: () => {}, 251 | config, 252 | }); 253 | } 254 | 255 | function onHide({unmount}) { 256 | setSpring({ 257 | ...initialStyles, 258 | onRest: unmount, 259 | config: {...config, clamp: true}, 260 | }); 261 | } 262 | 263 | return ( 264 | ( 266 | 267 | Hello 268 | 269 | )} 270 | animation={true} 271 | onMount={onMount} 272 | onHide={onHide} 273 | > 274 | 275 | 276 | ); 277 | } 278 | 279 | function FramerMotion() { 280 | const springConfig = {damping: 15, stiffness: 300}; 281 | const initialScale = 0.5; 282 | const opacity = useFramerSpring(0, springConfig); 283 | const scale = useFramerSpring(initialScale, springConfig); 284 | 285 | function onMount() { 286 | scale.set(1); 287 | opacity.set(1); 288 | } 289 | 290 | function onHide({unmount}) { 291 | const cleanup = scale.onChange(value => { 292 | if (value <= initialScale) { 293 | cleanup(); 294 | unmount(); 295 | } 296 | }); 297 | 298 | scale.set(0.5); 299 | opacity.set(0); 300 | } 301 | 302 | return ( 303 | ( 305 | 306 | Hello 307 | 308 | )} 309 | animation={true} 310 | onMount={onMount} 311 | onHide={onHide} 312 | > 313 | 314 | 315 | ); 316 | } 317 | 318 | function FullyControlledOnClick() { 319 | const [isOpen, setIsOpen] = useState(false); 320 | const open = () => setIsOpen(true); 321 | const close = () => setIsOpen(false); 322 | 323 | return ( 324 | 327 | 328 |
329 | } 330 | interactive={true} 331 | visible={isOpen} 332 | onClickOutside={close} 333 | > 334 | 335 |
336 | ); 337 | } 338 | 339 | function NestedSingleton() { 340 | const [source, target] = useSingleton({ 341 | overrides: ['placement'], 342 | }); 343 | 344 | return ( 345 |
346 | 349 | 354 | 355 | 356 | 357 | 358 | 359 | 360 |
361 | } 362 | interactive 363 | delay={250} 364 | placement="bottom" 365 | > 366 | 367 | 368 | 369 | ); 370 | } 371 | 372 | function ReferenceProp() { 373 | const ref = useRef(); 374 | 375 | return ( 376 | <> 377 | 378 | 379 | 380 | ); 381 | } 382 | 383 | function App() { 384 | return ( 385 | <> 386 |

Content

387 | 388 | 389 |

Special

390 | 391 | 392 |

Singleton

393 | 394 |

Singleton Headless

395 | 396 |

Singleton Headless Dynamic Content

397 | 398 |

Nested Singleton

399 | 400 |

Plugins

401 | 402 |

Headless Tippy w/ React Spring

403 | 404 |

Headless Tippy w/ Framer Motion

405 | 406 |

Fully Controlled on Click

407 | 408 |

Reference prop

409 | 410 |

Nested update

411 | 412 |

Lazy Tippy

413 | 414 | 415 | ); 416 | } 417 | 418 | function NestedUpdate() { 419 | const [value, setValue] = useState(Math.random()); 420 | 421 | useEffect(() => { 422 | const id = setInterval(() => setValue(Math.random()), 1000); 423 | return () => clearInterval(id); 424 | }, []); 425 | 426 | return ( 427 |
428 |

{value}

429 | } interactive trigger="click"> 430 | Hover me 431 | 432 |
433 | ); 434 | } 435 | 436 | function NestedContent() { 437 | return ( 438 | 439 | Click me 440 | 441 | ); 442 | } 443 | 444 | ReactDOM.render(, document.getElementById('root')); 445 | -------------------------------------------------------------------------------- /headless/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "tippy-react-headless", 3 | "private": true, 4 | "version": "0.1.0", 5 | "description": "Headless rendering for Tippy.js React", 6 | "types": "../index.d.ts", 7 | "main": "dist/tippy-react-headless.umd.js", 8 | "module": "dist/tippy-react-headless.esm.js", 9 | "unpkg": "dist/tippy-react-headless.umd.min.js", 10 | "sideEffects": false, 11 | "files": [ 12 | "dist/" 13 | ], 14 | "author": "atomiks", 15 | "license": "MIT" 16 | } 17 | -------------------------------------------------------------------------------- /index.d.ts: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import {default as tippyCore, Instance, Props, Placement} from 'tippy.js'; 3 | 4 | type Content = React.ReactNode; 5 | 6 | export interface TippyProps extends Partial> { 7 | children?: React.ReactElement; 8 | content?: Content; 9 | visible?: boolean; 10 | disabled?: boolean; 11 | className?: string; 12 | singleton?: SingletonObject; 13 | reference?: React.RefObject | Element | null; 14 | ref?: React.Ref; 15 | render?: ( 16 | attrs: { 17 | 'data-placement': Placement; 18 | 'data-reference-hidden'?: string; 19 | 'data-escaped'?: string; 20 | }, 21 | content?: Content, 22 | instance?: Instance 23 | ) => React.ReactNode; 24 | } 25 | 26 | declare const Tippy: React.ForwardRefExoticComponent; 27 | export default Tippy; 28 | 29 | export const tippy: typeof tippyCore; 30 | 31 | type SingletonHookArgs = { 32 | instance: Instance; 33 | content: Content; 34 | props: Props; 35 | }; 36 | 37 | type SingletonObject = { 38 | data?: any; 39 | hook(args: SingletonHookArgs): void; 40 | }; 41 | 42 | export interface UseSingletonProps { 43 | disabled?: boolean; 44 | overrides?: Array; 45 | } 46 | 47 | export const useSingleton: ( 48 | props?: UseSingletonProps, 49 | ) => [SingletonObject, SingletonObject]; 50 | -------------------------------------------------------------------------------- /logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/atomiks/tippyjs-react/2699f0450c28a92cd5bbd402573ce9ed64252899/logo.png -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@tippyjs/react", 3 | "version": "4.2.5", 4 | "description": "React component for Tippy.js", 5 | "main": "dist/tippy-react.umd.js", 6 | "module": "dist/tippy-react.esm.js", 7 | "unpkg": "dist/tippy-react.umd.min.js", 8 | "types": "index.d.ts", 9 | "sideEffects": false, 10 | "scripts": { 11 | "dev": "parcel demo/index.html -d .devserver --no-cache", 12 | "build": "rollup --config", 13 | "test": "jest --coverage", 14 | "lint": "eslint \"{src,test}/**/*.js\"", 15 | "format": "prettier --write \"{src,test,demo}/**/*.{js,ts,json,css,md}\"" 16 | }, 17 | "author": "atomiks", 18 | "license": "MIT", 19 | "keywords": [ 20 | "tooltip", 21 | "popover", 22 | "tippy", 23 | "react" 24 | ], 25 | "files": [ 26 | "dist/", 27 | "headless/", 28 | "index.d.ts" 29 | ], 30 | "jest": { 31 | "setupFilesAfterEnv": [ 32 | "test/setup.js", 33 | "@testing-library/jest-dom/extend-expect" 34 | ], 35 | "coveragePathIgnorePatterns": [ 36 | "test/setup.js" 37 | ] 38 | }, 39 | "husky": { 40 | "hooks": { 41 | "pre-commit": "lint-staged" 42 | } 43 | }, 44 | "lint-staged": { 45 | "{src,test,demo}/**/*.{js,ts,json,css,md}": [ 46 | "prettier --write", 47 | "git add" 48 | ], 49 | "{src,test}/**/*.js": [ 50 | "eslint --fix", 51 | "git add" 52 | ] 53 | }, 54 | "dependencies": { 55 | "tippy.js": "^6.3.1" 56 | }, 57 | "peerDependencies": { 58 | "react": ">=16.8", 59 | "react-dom": ">=16.8" 60 | }, 61 | "devDependencies": { 62 | "@babel/core": "^7.8.7", 63 | "@babel/preset-env": "^7.8.7", 64 | "@babel/preset-react": "^7.0.0", 65 | "@testing-library/jest-dom": "^5.12.0", 66 | "@testing-library/react": "^11.2.7", 67 | "@types/react": "^16.8.2", 68 | "babel-eslint": "^10.0.1", 69 | "babel-jest": "^25.1.0", 70 | "babel-plugin-annotate-pure-calls": "^0.4.0", 71 | "core-js": "^3.6.4", 72 | "eslint": "^5.14.1", 73 | "eslint-config-prettier": "^3.6.0", 74 | "eslint-plugin-react": "^7.12.4", 75 | "eslint-plugin-react-hooks": "^1.7.0", 76 | "framer-motion": "^1.10.3", 77 | "husky": "^1.3.1", 78 | "jest": "^24.1.0", 79 | "lint-staged": "^8.1.0", 80 | "parcel": "^1.12.3", 81 | "prettier": "^1.16.1", 82 | "react": "^16.8.1", 83 | "react-dom": "^16.8.1", 84 | "react-spring": "^8.0.27", 85 | "rollup": "^1.14.3", 86 | "rollup-plugin-babel": "^4.3.2", 87 | "rollup-plugin-node-resolve": "^5.2.0", 88 | "rollup-plugin-replace": "^2.2.0", 89 | "rollup-plugin-terser": "^5.2.0", 90 | "styled-components": "^5.0.1", 91 | "typescript": "^3.6.3" 92 | }, 93 | "directories": { 94 | "test": "test" 95 | }, 96 | "repository": { 97 | "type": "git", 98 | "url": "git+https://github.com/atomiks/tippyjs-react.git" 99 | }, 100 | "bugs": { 101 | "url": "https://github.com/atomiks/tippyjs-react/issues" 102 | }, 103 | "homepage": "https://github.com/atomiks/tippyjs-react#readme" 104 | } 105 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import babel from 'rollup-plugin-babel'; 2 | import {terser} from 'rollup-plugin-terser'; 3 | import resolve from 'rollup-plugin-node-resolve'; 4 | import replace from 'rollup-plugin-replace'; 5 | 6 | const pluginBabel = babel(); 7 | const pluginMinify = terser(); 8 | const pluginResolve = resolve(); 9 | const pluginReplaceEnvProduction = replace({ 10 | 'process.env.NODE_ENV': JSON.stringify('production'), 11 | }); 12 | 13 | const COMMON_INPUT = { 14 | input: './src/index.js', 15 | external: ['react', 'react-dom', 'tippy.js', 'tippy.js/headless'], 16 | }; 17 | 18 | const COMMON_OUTPUT = { 19 | name: 'Tippy', 20 | exports: 'named', 21 | sourcemap: true, 22 | globals: { 23 | react: 'React', 24 | 'react-dom': 'ReactDOM', 25 | 'tippy.js': 'tippy', 26 | 'tippy.js/headless': 'tippy', 27 | }, 28 | }; 29 | 30 | export default [ 31 | { 32 | ...COMMON_INPUT, 33 | plugins: [pluginBabel, pluginResolve], 34 | output: { 35 | ...COMMON_OUTPUT, 36 | format: 'umd', 37 | file: './dist/tippy-react.umd.js', 38 | }, 39 | }, 40 | { 41 | ...COMMON_INPUT, 42 | plugins: [ 43 | pluginBabel, 44 | pluginResolve, 45 | pluginMinify, 46 | pluginReplaceEnvProduction, 47 | ], 48 | output: { 49 | ...COMMON_OUTPUT, 50 | format: 'umd', 51 | file: './dist/tippy-react.umd.min.js', 52 | }, 53 | }, 54 | { 55 | ...COMMON_INPUT, 56 | plugins: [pluginBabel, pluginResolve], 57 | output: { 58 | ...COMMON_OUTPUT, 59 | format: 'esm', 60 | file: './dist/tippy-react.esm.js', 61 | }, 62 | }, 63 | { 64 | ...COMMON_INPUT, 65 | input: './src/headless.js', 66 | plugins: [pluginBabel, pluginResolve], 67 | output: { 68 | ...COMMON_OUTPUT, 69 | format: 'umd', 70 | file: './headless/dist/tippy-react-headless.umd.js', 71 | }, 72 | }, 73 | { 74 | ...COMMON_INPUT, 75 | input: './src/headless.js', 76 | plugins: [ 77 | pluginBabel, 78 | pluginResolve, 79 | pluginMinify, 80 | pluginReplaceEnvProduction, 81 | ], 82 | output: { 83 | ...COMMON_OUTPUT, 84 | format: 'umd', 85 | file: './headless/dist/tippy-react-headless.umd.min.js', 86 | }, 87 | }, 88 | { 89 | ...COMMON_INPUT, 90 | input: './src/headless.js', 91 | plugins: [pluginBabel, pluginResolve], 92 | output: { 93 | ...COMMON_OUTPUT, 94 | format: 'esm', 95 | file: './headless/dist/tippy-react-headless.esm.js', 96 | }, 97 | }, 98 | ]; 99 | -------------------------------------------------------------------------------- /src/Tippy.js: -------------------------------------------------------------------------------- 1 | import React, {cloneElement, useState} from 'react'; 2 | import {createPortal} from 'react-dom'; 3 | import { 4 | preserveRef, 5 | ssrSafeCreateDiv, 6 | toDataAttributes, 7 | deepPreserveProps, 8 | } from './utils'; 9 | import {useMutableBox, useIsomorphicLayoutEffect} from './util-hooks'; 10 | import {classNamePlugin} from './className-plugin'; 11 | 12 | export default function TippyGenerator(tippy) { 13 | function Tippy({ 14 | children, 15 | content, 16 | visible, 17 | singleton, 18 | render, 19 | reference, 20 | disabled = false, 21 | ignoreAttributes = true, 22 | // Filter React development reserved props 23 | // added by babel-preset-react dev plugins: 24 | // transform-react-jsx-self and transform-react-jsx-source 25 | __source, 26 | __self, 27 | ...restOfNativeProps 28 | }) { 29 | const isControlledMode = visible !== undefined; 30 | const isSingletonMode = singleton !== undefined; 31 | 32 | const [mounted, setMounted] = useState(false); 33 | const [attrs, setAttrs] = useState({}); 34 | const [singletonContent, setSingletonContent] = useState(); 35 | const mutableBox = useMutableBox(() => ({ 36 | container: ssrSafeCreateDiv(), 37 | renders: 1, 38 | })); 39 | 40 | const props = { 41 | ignoreAttributes, 42 | ...restOfNativeProps, 43 | content: mutableBox.container, 44 | }; 45 | 46 | if (isControlledMode) { 47 | if (process.env.NODE_ENV !== 'production') { 48 | ['trigger', 'hideOnClick', 'showOnCreate'].forEach(nativeStateProp => { 49 | if (props[nativeStateProp] !== undefined) { 50 | console.warn( 51 | [ 52 | `@tippyjs/react: Cannot specify \`${nativeStateProp}\` prop in`, 53 | `controlled mode (\`visible\` prop)`, 54 | ].join(' '), 55 | ); 56 | } 57 | }); 58 | } 59 | 60 | props.trigger = 'manual'; 61 | props.hideOnClick = false; 62 | } 63 | 64 | if (isSingletonMode) { 65 | disabled = true; 66 | } 67 | 68 | let computedProps = props; 69 | const plugins = props.plugins || []; 70 | 71 | if (render) { 72 | computedProps = { 73 | ...props, 74 | plugins: 75 | isSingletonMode && singleton.data != null 76 | ? [ 77 | ...plugins, 78 | { 79 | fn() { 80 | return { 81 | onTrigger(instance, event) { 82 | const node = singleton.data.children.find( 83 | ({instance}) => 84 | instance.reference === event.currentTarget, 85 | ); 86 | instance.state.$$activeSingletonInstance = 87 | node.instance; 88 | setSingletonContent(node.content); 89 | }, 90 | }; 91 | }, 92 | }, 93 | ] 94 | : plugins, 95 | render: () => ({popper: mutableBox.container}), 96 | }; 97 | } 98 | 99 | const deps = [reference].concat(children ? [children.type] : []); 100 | 101 | // CREATE 102 | useIsomorphicLayoutEffect(() => { 103 | let element = reference; 104 | if (reference && reference.hasOwnProperty('current')) { 105 | element = reference.current; 106 | } 107 | 108 | const instance = tippy(element || mutableBox.ref || ssrSafeCreateDiv(), { 109 | ...computedProps, 110 | plugins: [classNamePlugin, ...(props.plugins || [])], 111 | }); 112 | 113 | mutableBox.instance = instance; 114 | 115 | if (disabled) { 116 | instance.disable(); 117 | } 118 | 119 | if (visible) { 120 | instance.show(); 121 | } 122 | 123 | if (isSingletonMode) { 124 | singleton.hook({ 125 | instance, 126 | content, 127 | props: computedProps, 128 | setSingletonContent, 129 | }); 130 | } 131 | 132 | setMounted(true); 133 | 134 | return () => { 135 | instance.destroy(); 136 | singleton?.cleanup(instance); 137 | }; 138 | }, deps); 139 | 140 | // UPDATE 141 | useIsomorphicLayoutEffect(() => { 142 | // Prevent this effect from running on 1st render 143 | if (mutableBox.renders === 1) { 144 | mutableBox.renders++; 145 | return; 146 | } 147 | 148 | const {instance} = mutableBox; 149 | 150 | instance.setProps(deepPreserveProps(instance.props, computedProps)); 151 | 152 | // Fixes #264 153 | instance.popperInstance?.forceUpdate(); 154 | 155 | if (disabled) { 156 | instance.disable(); 157 | } else { 158 | instance.enable(); 159 | } 160 | 161 | if (isControlledMode) { 162 | if (visible) { 163 | instance.show(); 164 | } else { 165 | instance.hide(); 166 | } 167 | } 168 | 169 | if (isSingletonMode) { 170 | singleton.hook({ 171 | instance, 172 | content, 173 | props: computedProps, 174 | setSingletonContent, 175 | }); 176 | } 177 | }); 178 | 179 | useIsomorphicLayoutEffect(() => { 180 | if (!render) { 181 | return; 182 | } 183 | 184 | const {instance} = mutableBox; 185 | 186 | instance.setProps({ 187 | popperOptions: { 188 | ...instance.props.popperOptions, 189 | modifiers: [ 190 | ...(instance.props.popperOptions?.modifiers || []).filter( 191 | ({name}) => name !== '$$tippyReact', 192 | ), 193 | { 194 | name: '$$tippyReact', 195 | enabled: true, 196 | phase: 'beforeWrite', 197 | requires: ['computeStyles'], 198 | fn({state}) { 199 | const hideData = state.modifiersData?.hide; 200 | 201 | // WARNING: this is a high-risk path that can cause an infinite 202 | // loop. This expression _must_ evaluate to false when required 203 | if ( 204 | attrs.placement !== state.placement || 205 | attrs.referenceHidden !== hideData?.isReferenceHidden || 206 | attrs.escaped !== hideData?.hasPopperEscaped 207 | ) { 208 | setAttrs({ 209 | placement: state.placement, 210 | referenceHidden: hideData?.isReferenceHidden, 211 | escaped: hideData?.hasPopperEscaped, 212 | }); 213 | } 214 | 215 | state.attributes.popper = {}; 216 | }, 217 | }, 218 | ], 219 | }, 220 | }); 221 | }, [attrs.placement, attrs.referenceHidden, attrs.escaped, ...deps]); 222 | 223 | return ( 224 | <> 225 | {children 226 | ? cloneElement(children, { 227 | ref(node) { 228 | mutableBox.ref = node; 229 | preserveRef(children.ref, node); 230 | }, 231 | }) 232 | : null} 233 | {mounted && 234 | createPortal( 235 | render 236 | ? render( 237 | toDataAttributes(attrs), 238 | singletonContent, 239 | mutableBox.instance, 240 | ) 241 | : content, 242 | mutableBox.container, 243 | )} 244 | 245 | ); 246 | } 247 | 248 | return Tippy; 249 | } 250 | -------------------------------------------------------------------------------- /src/className-plugin.js: -------------------------------------------------------------------------------- 1 | function updateClassName(box, action, classNames) { 2 | classNames.split(/\s+/).forEach(name => { 3 | if (name) { 4 | box.classList[action](name); 5 | } 6 | }); 7 | } 8 | 9 | export const classNamePlugin = { 10 | name: 'className', 11 | defaultValue: '', 12 | fn(instance) { 13 | const box = instance.popper.firstElementChild; 14 | const isDefaultRenderFn = () => !!instance.props.render?.$$tippy; 15 | 16 | function add() { 17 | if (instance.props.className && !isDefaultRenderFn()) { 18 | if (process.env.NODE_ENV !== 'production') { 19 | console.warn( 20 | [ 21 | '@tippyjs/react: Cannot use `className` prop in conjunction with', 22 | '`render` prop. Place the className on the element you are', 23 | 'rendering.', 24 | ].join(' '), 25 | ); 26 | } 27 | 28 | return; 29 | } 30 | 31 | updateClassName(box, 'add', instance.props.className); 32 | } 33 | 34 | function remove() { 35 | if (isDefaultRenderFn()) { 36 | updateClassName(box, 'remove', instance.props.className); 37 | } 38 | } 39 | 40 | return { 41 | onCreate: add, 42 | onBeforeUpdate: remove, 43 | onAfterUpdate: add, 44 | }; 45 | }, 46 | }; 47 | -------------------------------------------------------------------------------- /src/forwardRef.js: -------------------------------------------------------------------------------- 1 | import React, {cloneElement, forwardRef} from 'react'; 2 | import {preserveRef} from './utils'; 3 | 4 | export default (Tippy, defaultProps) => 5 | forwardRef(function TippyWrapper({children, ...props}, ref) { 6 | return ( 7 | // If I spread them separately here, Babel adds the _extends ponyfill for 8 | // some reason 9 | 10 | {children 11 | ? cloneElement(children, { 12 | ref(node) { 13 | preserveRef(ref, node); 14 | preserveRef(children.ref, node); 15 | }, 16 | }) 17 | : null} 18 | 19 | ); 20 | }); 21 | -------------------------------------------------------------------------------- /src/headless.js: -------------------------------------------------------------------------------- 1 | import tippy, {createSingleton} from 'tippy.js/headless'; 2 | import TippyGenerator from './Tippy'; 3 | import useSingletonGenerator from './useSingleton'; 4 | import forwardRef from './forwardRef'; 5 | 6 | const useSingleton = useSingletonGenerator(createSingleton); 7 | 8 | export default forwardRef(TippyGenerator(tippy), {render: () => ''}); 9 | export {useSingleton, tippy}; 10 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import tippy, {createSingleton} from 'tippy.js'; 2 | import TippyGenerator from './Tippy'; 3 | import useSingletonGenerator from './useSingleton'; 4 | import forwardRef from './forwardRef'; 5 | 6 | const useSingleton = useSingletonGenerator(createSingleton); 7 | 8 | export default forwardRef(TippyGenerator(tippy)); 9 | export {useSingleton, tippy}; 10 | -------------------------------------------------------------------------------- /src/useSingleton.js: -------------------------------------------------------------------------------- 1 | import {useMutableBox, useIsomorphicLayoutEffect} from './util-hooks'; 2 | import {deepPreserveProps} from './utils'; 3 | import {classNamePlugin} from './className-plugin'; 4 | import {useMemo, useState} from 'react'; 5 | 6 | export default function useSingletonGenerator(createSingleton) { 7 | return function useSingleton({disabled = false, overrides = []} = {}) { 8 | const [mounted, setMounted] = useState(false); 9 | const mutableBox = useMutableBox({ 10 | children: [], 11 | renders: 1, 12 | }); 13 | 14 | useIsomorphicLayoutEffect(() => { 15 | if (!mounted) { 16 | setMounted(true); 17 | return; 18 | } 19 | 20 | const {children, sourceData} = mutableBox; 21 | 22 | if (!sourceData) { 23 | if (process.env.NODE_ENV !== 'production') { 24 | console.error( 25 | [ 26 | '@tippyjs/react: The `source` variable from `useSingleton()` has', 27 | 'not been passed to a component.', 28 | ].join(' '), 29 | ); 30 | } 31 | 32 | return; 33 | } 34 | 35 | const instance = createSingleton( 36 | children.map(child => child.instance), 37 | { 38 | ...sourceData.props, 39 | popperOptions: sourceData.instance.props.popperOptions, 40 | overrides, 41 | plugins: [classNamePlugin, ...(sourceData.props.plugins || [])], 42 | }, 43 | ); 44 | 45 | mutableBox.instance = instance; 46 | 47 | if (disabled) { 48 | instance.disable(); 49 | } 50 | 51 | return () => { 52 | instance.destroy(); 53 | mutableBox.children = children.filter( 54 | ({instance}) => !instance.state.isDestroyed, 55 | ); 56 | }; 57 | }, [mounted]); 58 | 59 | useIsomorphicLayoutEffect(() => { 60 | if (!mounted) { 61 | return; 62 | } 63 | 64 | if (mutableBox.renders === 1) { 65 | mutableBox.renders++; 66 | return; 67 | } 68 | 69 | const {children, instance, sourceData} = mutableBox; 70 | 71 | if (!(instance && sourceData)) { 72 | return; 73 | } 74 | 75 | const {content, ...props} = sourceData.props; 76 | 77 | instance.setProps( 78 | deepPreserveProps(instance.props, { 79 | ...props, 80 | overrides, 81 | }), 82 | ); 83 | 84 | instance.setInstances(children.map(child => child.instance)); 85 | 86 | if (disabled) { 87 | instance.disable(); 88 | } else { 89 | instance.enable(); 90 | } 91 | }); 92 | 93 | return useMemo(() => { 94 | const source = { 95 | data: mutableBox, 96 | hook(data) { 97 | mutableBox.sourceData = data; 98 | mutableBox.setSingletonContent = data.setSingletonContent; 99 | }, 100 | cleanup() { 101 | mutableBox.sourceData = null; 102 | }, 103 | }; 104 | 105 | const target = { 106 | hook(data) { 107 | mutableBox.children = mutableBox.children.filter( 108 | ({instance}) => data.instance !== instance, 109 | ); 110 | mutableBox.children.push(data); 111 | 112 | if ( 113 | mutableBox.instance?.state.isMounted && 114 | mutableBox.instance?.state.$$activeSingletonInstance === 115 | data.instance 116 | ) { 117 | mutableBox.setSingletonContent?.(data.content); 118 | } 119 | 120 | if (mutableBox.instance && !mutableBox.instance.state.isDestroyed) { 121 | mutableBox.instance.setInstances( 122 | mutableBox.children.map(child => child.instance), 123 | ); 124 | } 125 | }, 126 | cleanup(instance) { 127 | mutableBox.children = mutableBox.children.filter( 128 | data => data.instance !== instance, 129 | ); 130 | 131 | if (mutableBox.instance && !mutableBox.instance.state.isDestroyed) { 132 | mutableBox.instance.setInstances( 133 | mutableBox.children.map(child => child.instance), 134 | ); 135 | } 136 | }, 137 | }; 138 | 139 | return [source, target]; 140 | }, []); 141 | }; 142 | } 143 | -------------------------------------------------------------------------------- /src/util-hooks.js: -------------------------------------------------------------------------------- 1 | import {isBrowser} from './utils'; 2 | import {useLayoutEffect, useEffect, useRef} from 'react'; 3 | 4 | export const useIsomorphicLayoutEffect = isBrowser 5 | ? useLayoutEffect 6 | : useEffect; 7 | 8 | export function useMutableBox(initialValue) { 9 | // Using refs instead of state as it's recommended to not store imperative 10 | // values in state due to memory problems in React(?) 11 | const ref = useRef(); 12 | 13 | if (!ref.current) { 14 | ref.current = 15 | typeof initialValue === 'function' ? initialValue() : initialValue; 16 | } 17 | 18 | return ref.current; 19 | } 20 | -------------------------------------------------------------------------------- /src/utils.js: -------------------------------------------------------------------------------- 1 | export const isBrowser = 2 | typeof window !== 'undefined' && typeof document !== 'undefined'; 3 | 4 | export function preserveRef(ref, node) { 5 | if (ref) { 6 | if (typeof ref === 'function') { 7 | ref(node); 8 | } 9 | if ({}.hasOwnProperty.call(ref, 'current')) { 10 | ref.current = node; 11 | } 12 | } 13 | } 14 | 15 | export function ssrSafeCreateDiv() { 16 | return isBrowser && document.createElement('div'); 17 | } 18 | 19 | export function toDataAttributes(attrs) { 20 | const dataAttrs = { 21 | 'data-placement': attrs.placement, 22 | }; 23 | 24 | if (attrs.referenceHidden) { 25 | dataAttrs['data-reference-hidden'] = ''; 26 | } 27 | 28 | if (attrs.escaped) { 29 | dataAttrs['data-escaped'] = ''; 30 | } 31 | 32 | return dataAttrs; 33 | } 34 | 35 | function deepEqual(x, y) { 36 | if (x === y) { 37 | return true; 38 | } else if ( 39 | typeof x === 'object' && 40 | x != null && 41 | typeof y === 'object' && 42 | y != null 43 | ) { 44 | if (Object.keys(x).length !== Object.keys(y).length) { 45 | return false; 46 | } 47 | 48 | for (const prop in x) { 49 | if (y.hasOwnProperty(prop)) { 50 | if (!deepEqual(x[prop], y[prop])) { 51 | return false; 52 | } 53 | } else { 54 | return false; 55 | } 56 | } 57 | 58 | return true; 59 | } else { 60 | return false; 61 | } 62 | } 63 | 64 | export function uniqueByShape(arr) { 65 | const output = []; 66 | 67 | arr.forEach(item => { 68 | if (!output.find(outputItem => deepEqual(item, outputItem))) { 69 | output.push(item); 70 | } 71 | }); 72 | 73 | return output; 74 | } 75 | 76 | export function deepPreserveProps(instanceProps, componentProps) { 77 | return { 78 | ...componentProps, 79 | popperOptions: { 80 | ...instanceProps.popperOptions, 81 | ...componentProps.popperOptions, 82 | modifiers: uniqueByShape([ 83 | ...(instanceProps.popperOptions?.modifiers || []), 84 | ...(componentProps.popperOptions?.modifiers || []), 85 | ]), 86 | }, 87 | }; 88 | } 89 | -------------------------------------------------------------------------------- /test/Tippy.test.js: -------------------------------------------------------------------------------- 1 | import React, {useRef, useState} from 'react'; 2 | import TippyBase from '../src'; 3 | import { 4 | render, 5 | screen, 6 | waitForElementToBeRemoved, 7 | } from '@testing-library/react'; 8 | 9 | jest.useFakeTimers(); 10 | 11 | describe('', () => { 12 | let instance = null; 13 | 14 | afterEach(() => { 15 | instance = null; 16 | }); 17 | 18 | function Tippy(props) { 19 | return (instance = i)} />; 20 | } 21 | 22 | test('renders only the child element', () => { 23 | const stringContent = render( 24 | 25 | '); 30 | 31 | const reactElementContent = render( 32 | tooltip}> 33 | '); 38 | }); 39 | 40 | test('adds a tippy instance to the child node', () => { 41 | render( 42 | 43 | 307 | 308 | 309 | , 310 | ); 311 | 312 | expect(document.querySelectorAll('.tippy-box').length).toBe(3); 313 | }); 314 | 315 | test('props.disabled initially `false`', () => { 316 | const {rerender} = render( 317 | 318 |