├── .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 |
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 | My button
66 |
67 | );
68 |
69 | const JSXContent = () => (
70 | Tooltip}>
71 | My button
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 | My button
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 | Reference
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 | Reference
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
Reference ;
182 | }
183 |
184 | const ThisWillWork = forwardRef((props, ref) => {
185 | return
Reference ;
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 | Reference
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 | Reference
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 | Reference
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 | Reference
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 |
287 |
288 | >
289 | );
290 | }
291 | ```
292 |
293 | ### Plugins
294 |
295 | Tippy.js splits certain props into separate pieces of code called plugins to
296 | enable tree-shaking, so that components or routes that don't need the prop's
297 | functionality are not burdened with the bundle size cost of it. In addition,
298 | they enable a neat way to extend the functionality of tippy instances.
299 |
300 | ```jsx
301 | import Tippy from '@tippyjs/react';
302 | // ⚠️ import from 'tippy.js/headless' if using Headless Tippy
303 | import {followCursor} from 'tippy.js';
304 |
305 | function App() {
306 | return (
307 |
308 | Reference
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 | Reference
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 | Reference
365 |
366 |
367 | Reference
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 | Reference
407 |
408 |
409 | Reference
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 | Lazy tippy
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 | ContentString
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 | Next color
121 | Hello
122 | >
123 | }
124 | interactive={true}
125 | >
126 | ContentElement
127 |
128 | );
129 | }
130 |
131 | function DisabledProp() {
132 | const [disabled, setDisabled] = useState(false);
133 |
134 | return (
135 |
136 | setDisabled(disabled => !disabled)}>
137 | disabled: {String(disabled)}
138 |
139 |
140 | );
141 | }
142 |
143 | function VisibleProp() {
144 | const [visible, setVisible] = useState(false);
145 |
146 | return (
147 |
148 | setVisible(visible => !visible)}>
149 | visible: {String(visible)}
150 |
151 |
152 | );
153 | }
154 |
155 | function Singleton() {
156 | const [source, target] = useSingleton();
157 |
158 | return (
159 | <>
160 |
161 |
162 | Reference
163 |
164 |
165 | Reference
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 | Reference
191 |
192 |
193 | Reference
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 | Reference
221 |
222 | )}
223 |
224 | Reference
225 |
226 |
227 | Reference
228 |
229 | >
230 | );
231 | }
232 |
233 | function FollowCursor() {
234 | return (
235 |
236 | followCursor
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 | react-spring
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 | framer-motion
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 | Close it
328 |
329 | }
330 | interactive={true}
331 | visible={isOpen}
332 | onClickOutside={close}
333 | >
334 | Open
335 |
336 | );
337 | }
338 |
339 | function NestedSingleton() {
340 | const [source, target] = useSingleton({
341 | overrides: ['placement'],
342 | });
343 |
344 | return (
345 |
346 |
349 |
354 |
355 | hover me
356 |
357 |
358 | hover me
359 |
360 |
361 | }
362 | interactive
363 | delay={250}
364 | placement="bottom"
365 | >
366 | hover me test
367 |
368 |
369 | );
370 | }
371 |
372 | function ReferenceProp() {
373 | const ref = useRef();
374 |
375 | return (
376 | <>
377 | Reference
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 |
26 | ,
27 | );
28 |
29 | expect(stringContent.container.innerHTML).toBe(' ');
30 |
31 | const reactElementContent = render(
32 | tooltip}>
33 |
34 | ,
35 | );
36 |
37 | expect(reactElementContent.container.innerHTML).toBe(' ');
38 | });
39 |
40 | test('adds a tippy instance to the child node', () => {
41 | render(
42 |
43 |
44 | ,
45 | );
46 |
47 | expect(instance).not.toBeNull();
48 | });
49 |
50 | test('renders react element content inside the content prop', () => {
51 | render(
52 | tooltip}>
53 |
54 | ,
55 | );
56 |
57 | expect(instance.popper.querySelector('strong')).not.toBeNull();
58 | });
59 |
60 | test('cleans up after unmounting in tests', async () => {
61 | render(
62 |
68 |
69 | ,
70 | );
71 |
72 | // open up the tooltip
73 | screen.getByRole('button').click();
74 | expect(screen.getByText('tooltip')).toBeInTheDocument();
75 |
76 | // close the tooltip
77 | screen.getByRole('button').click();
78 | await waitForElementToBeRemoved(() => screen.queryByText('tooltip'));
79 | });
80 |
81 | test('props.className: single name is added to tooltip', () => {
82 | const className = 'hello';
83 |
84 | render(
85 |
86 |
87 | ,
88 | );
89 |
90 | expect(instance.popper.querySelector(`.${className}`)).not.toBeNull();
91 | });
92 |
93 | test('props.className: multiple names are added to tooltip', () => {
94 | const classNames = 'hello world';
95 |
96 | render(
97 |
98 |
99 | ,
100 | );
101 |
102 | expect(instance.popper.querySelector('.hello')).not.toBeNull();
103 | expect(instance.popper.querySelector('.world')).not.toBeNull();
104 | });
105 |
106 | test('props.className: extra whitespace is ignored', () => {
107 | const className = ' hello world ';
108 |
109 | render(
110 |
111 |
112 | ,
113 | );
114 |
115 | const box = instance.popper.firstElementChild;
116 |
117 | expect(box.className).toBe('tippy-box hello world');
118 | });
119 |
120 | test('props.className: updating does not leave stale className behind', () => {
121 | const {rerender} = render(
122 |
123 |
124 | ,
125 | );
126 |
127 | const box = instance.popper.firstElementChild;
128 |
129 | expect(box.classList.contains('one')).toBe(true);
130 |
131 | rerender(
132 |
133 |
134 | ,
135 | );
136 |
137 | expect(box.classList.contains('one')).toBe(false);
138 | expect(box.classList.contains('two')).toBe(true);
139 | });
140 |
141 | test('props.className: syncs with children.type', () => {
142 | const {rerender} = render(
143 |
144 |
145 | ,
146 | );
147 |
148 | rerender(
149 |
150 |
151 | ,
152 | );
153 |
154 | const box = instance.popper.firstElementChild;
155 |
156 | expect(box.classList.contains('one')).toBe(true);
157 | });
158 |
159 | test('unmount destroys the tippy instance and allows garbage collection', () => {
160 | const {container, unmount} = render(
161 |
162 |
163 | ,
164 | );
165 | const button = container.querySelector('button');
166 |
167 | unmount();
168 |
169 | expect(button._tippy).toBeUndefined();
170 | expect(instance.state.isDestroyed).toBe(true);
171 | });
172 |
173 | test('updating children destroys old instance and creates new one', () => {
174 | const Button = (_, ref) => ;
175 | const Main = (_, ref) => ;
176 | const Component1 = React.forwardRef(Button);
177 | const Component2 = React.forwardRef(Main);
178 |
179 | const {container, rerender} = render(
180 |
181 |
182 | ,
183 | );
184 | const div = container.querySelector('div');
185 |
186 | rerender(
187 |
188 |
189 | ,
190 | );
191 |
192 | const span = container.querySelector('span');
193 |
194 | expect(div._tippy).toBeUndefined();
195 | expect(span._tippy).toBeDefined();
196 |
197 | rerender(
198 |
199 |
200 | ,
201 | );
202 |
203 | const button = container.querySelector('button');
204 |
205 | expect(span._tippy).toBeUndefined();
206 | expect(button._tippy).toBeDefined();
207 |
208 | rerender(
209 |
210 |
211 | ,
212 | );
213 |
214 | expect(button._tippy).toBeUndefined();
215 | expect(container.querySelector('main')._tippy).toBeDefined();
216 | });
217 |
218 | test('updating props updates the tippy instance', () => {
219 | const {rerender} = render(
220 |
221 |
222 | ,
223 | );
224 |
225 | expect(instance.props.arrow).toBe(false);
226 |
227 | rerender(
228 |
229 |
230 | ,
231 | );
232 |
233 | expect(instance.props.arrow).toBe(true);
234 | });
235 |
236 | test('props containing refs updates the tippy instance on mount', () => {
237 | const App = () => {
238 | const [triggerTarget, setTriggerTarget] = React.useState(null);
239 | return (
240 | setTriggerTarget(el)}>
241 | Trigger Target
242 |
243 |
244 |
245 |
246 | );
247 | };
248 |
249 | const {container} = render( );
250 |
251 | const instanceNode = container.querySelector('button');
252 | const instance = instanceNode._tippy;
253 |
254 | expect(instance.props.triggerTarget).toBe(instanceNode.parentNode);
255 | });
256 |
257 | test('component as a child', () => {
258 | const Child = React.forwardRef(function Comp(_, ref) {
259 | return ;
260 | });
261 |
262 | render(
263 |
264 |
265 | ,
266 | );
267 |
268 | expect(instance).not.toBeNull();
269 | });
270 |
271 | test('refs are preserved on the child', done => {
272 | class App extends React.Component {
273 | constructor(props) {
274 | super(props);
275 | this.refObject = React.createRef();
276 | }
277 |
278 | componentDidMount() {
279 | expect(this.callbackRef instanceof Element).toBe(true);
280 | expect(this.refObject.current instanceof Element).toBe(true);
281 | done();
282 | }
283 |
284 | render() {
285 | return (
286 | <>
287 |
288 | (this.callbackRef = node)} />
289 |
290 |
291 |
292 |
293 | >
294 | );
295 | }
296 | }
297 |
298 | render( );
299 | });
300 |
301 | test('nesting', () => {
302 | render(
303 |
304 |
305 |
306 | Text
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 |
319 | ,
320 | );
321 |
322 | expect(instance.state.isEnabled).toBe(true);
323 |
324 | rerender(
325 |
326 |
327 | ,
328 | );
329 |
330 | expect(instance.state.isEnabled).toBe(false);
331 | });
332 |
333 | test('props.disabled initially `true`', () => {
334 | const {rerender} = render(
335 |
336 |
337 | ,
338 | );
339 |
340 | expect(instance.state.isEnabled).toBe(false);
341 |
342 | rerender(
343 |
344 |
345 | ,
346 | );
347 |
348 | expect(instance.state.isEnabled).toBe(true);
349 | });
350 |
351 | test('props.visible initially `true`', () => {
352 | const {rerender} = render(
353 |
354 |
355 | ,
356 | );
357 |
358 | expect(instance.state.isVisible).toBe(true);
359 |
360 | rerender(
361 |
362 |
363 | ,
364 | );
365 |
366 | expect(instance.state.isVisible).toBe(false);
367 | });
368 |
369 | test('props.visible initially `false`', () => {
370 | const {rerender} = render(
371 |
372 |
373 | ,
374 | );
375 |
376 | expect(instance.state.isVisible).toBe(false);
377 |
378 | rerender(
379 |
380 |
381 | ,
382 | );
383 |
384 | expect(instance.state.isVisible).toBe(true);
385 | });
386 |
387 | test('props.visible uses hideOnClick: false by default', () => {
388 | const {rerender} = render(
389 |
390 |
391 | ,
392 | );
393 |
394 | jest.runAllTimers();
395 |
396 | expect(instance.props.hideOnClick).toBe(false);
397 |
398 | rerender(
399 |
400 |
401 | ,
402 | );
403 |
404 | expect(instance.props.hideOnClick).toBe(false);
405 |
406 | rerender(
407 |
408 |
409 | ,
410 | );
411 |
412 | expect(instance.props.hideOnClick).toBe(false);
413 |
414 | rerender(
415 |
416 |
417 | ,
418 | );
419 |
420 | expect(instance.props.hideOnClick).toBe('toggle');
421 | });
422 |
423 | test('controlled mode warnings', () => {
424 | const spy = jest.spyOn(console, 'warn');
425 |
426 | const {rerender} = render(
427 |
428 |
429 | ,
430 | );
431 |
432 | expect(spy).not.toHaveBeenCalled();
433 |
434 | rerender(
435 |
436 |
437 | ,
438 | );
439 |
440 | expect(spy).toHaveBeenCalledWith(
441 | [
442 | '@tippyjs/react: Cannot specify `hideOnClick` prop in controlled',
443 | 'mode (`visible` prop)',
444 | ].join(' '),
445 | );
446 |
447 | rerender(
448 |
449 |
450 | ,
451 | );
452 |
453 | expect(spy).toHaveBeenCalledWith(
454 | [
455 | '@tippyjs/react: Cannot specify `trigger` prop in controlled',
456 | 'mode (`visible` prop)',
457 | ].join(' '),
458 | );
459 | });
460 |
461 | test('props.plugins', () => {
462 | const plugins = [{fn: () => ({})}];
463 |
464 | render(
465 |
466 |
467 | ,
468 | );
469 |
470 | expect(instance.plugins).toMatchSnapshot();
471 | });
472 |
473 | test('render prop', () => {
474 | render(
475 | Hello
} showOnCreate={true}>
476 |
477 | ,
478 | );
479 |
480 | jest.runAllTimers();
481 |
482 | expect(instance.popper.firstElementChild).toMatchSnapshot();
483 | });
484 |
485 | test('render prop instance', () => {
486 | let _instance;
487 | render(
488 | {
490 | _instance = instance;
491 | return Hello
;
492 | }}
493 | showOnCreate={true}
494 | >
495 |
496 | ,
497 | );
498 |
499 | jest.runAllTimers();
500 |
501 | expect(_instance).toBe(instance);
502 | });
503 |
504 | test('render prop preserve popperOptions', () => {
505 | const element = (
506 | Hello
}
508 | popperOptions={{
509 | strategy: 'fixed',
510 | modifiers: [{name: 'x', enabled: true, phase: 'main', fn: () => {}}],
511 | }}
512 | >
513 |
514 |
515 | );
516 |
517 | const {rerender} = render(element);
518 | rerender(element);
519 |
520 | expect(instance.props.popperOptions).toMatchSnapshot();
521 | });
522 |
523 | test('render + className prop warning', () => {
524 | const spy = jest.spyOn(console, 'warn');
525 |
526 | render(
} className="x" />);
527 |
528 | expect(spy).toHaveBeenCalledWith(
529 | [
530 | '@tippyjs/react: Cannot use `className` prop in conjunction with',
531 | '`render` prop. Place the className on the element you are',
532 | 'rendering.',
533 | ].join(' '),
534 | );
535 |
536 | spy.mockReset();
537 |
538 | render(
} />);
539 | render( );
540 |
541 | expect(spy).not.toHaveBeenCalled();
542 | });
543 |
544 | test('`reference` prop as RefObject', () => {
545 | function App() {
546 | const ref = useRef();
547 |
548 | return (
549 | <>
550 |
551 |
552 | >
553 | );
554 | }
555 |
556 | render( );
557 |
558 | expect(instance.reference.getAttribute('data-testid')).toBe(
559 | 'reference-prop',
560 | );
561 | });
562 |
563 | test('`reference` prop as Element', () => {
564 | function App() {
565 | const [element, setElement] = useState(null);
566 |
567 | return (
568 | <>
569 |
570 |
571 | >
572 | );
573 | }
574 |
575 | render( );
576 |
577 | expect(instance.reference.getAttribute('data-testid')).toBe(
578 | 'reference-prop',
579 | );
580 | });
581 | });
582 |
583 | describe('Tippy.propTypes', () => {
584 | const originalEnv = process.env.NODE_ENV;
585 |
586 | beforeEach(() => {
587 | jest.resetModules();
588 | });
589 |
590 | afterEach(() => {
591 | process.env.NODE_ENV = originalEnv;
592 | });
593 |
594 | test('is undefined if NODE_ENV=production', () => {
595 | process.env.NODE_ENV = 'production';
596 |
597 | const TippyGenerator = require('../src/Tippy').default;
598 | expect(TippyGenerator().propTypes).toBeUndefined();
599 | });
600 | });
601 |
--------------------------------------------------------------------------------
/test/__snapshots__/Tippy.test.js.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[` props.plugins 1`] = `
4 | Array [
5 | Object {
6 | "defaultValue": "",
7 | "fn": [Function],
8 | "name": "className",
9 | },
10 | Object {
11 | "fn": [Function],
12 | },
13 | ]
14 | `;
15 |
16 | exports[` render prop 1`] = `
17 |
22 | Hello
23 |
24 | `;
25 |
26 | exports[` render prop preserve popperOptions 1`] = `
27 | Object {
28 | "modifiers": Array [
29 | Object {
30 | "enabled": true,
31 | "fn": [Function],
32 | "name": "x",
33 | "phase": "main",
34 | },
35 | Object {
36 | "enabled": true,
37 | "fn": [Function],
38 | "name": "$$tippyReact",
39 | "phase": "beforeWrite",
40 | "requires": Array [
41 | "computeStyles",
42 | ],
43 | },
44 | ],
45 | "strategy": "fixed",
46 | }
47 | `;
48 |
--------------------------------------------------------------------------------
/test/__snapshots__/useSingleton.test.js.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[`useSingleton headless mode updates content correctly 1`] = `
4 |
13 | `;
14 |
15 | exports[`useSingleton headless mode updates content correctly 2`] = `
16 |
25 | `;
26 |
--------------------------------------------------------------------------------
/test/setup.js:
--------------------------------------------------------------------------------
1 | global.console = {
2 | log: console.log,
3 | };
4 |
5 | beforeEach(() => {
6 | global.console.warn = jest.fn();
7 | global.console.error = jest.fn();
8 | });
9 |
--------------------------------------------------------------------------------
/test/useSingleton.test.js:
--------------------------------------------------------------------------------
1 | import React, {useState} from 'react';
2 | import Tippy, {useSingleton} from '../src';
3 | import TippyHeadless, {
4 | useSingleton as useSingletonHeadless,
5 | } from '../src/headless';
6 | import {render, cleanup, fireEvent} from '@testing-library/react';
7 |
8 | jest.useFakeTimers();
9 |
10 | afterEach(cleanup);
11 |
12 | let instance;
13 | function onCreate(i) {
14 | instance = i;
15 | }
16 |
17 | it('changes the singleton content correctly', () => {
18 | function App() {
19 | const [source, target] = useSingleton();
20 |
21 | return (
22 | <>
23 |
29 |
30 |
31 |
32 |
33 |
34 |
35 | >
36 | );
37 | }
38 |
39 | const {getByTestId} = render( );
40 |
41 | const buttonA = getByTestId('a');
42 | const buttonB = getByTestId('b');
43 |
44 | fireEvent.click(buttonA);
45 |
46 | expect(instance.state.isVisible).toBe(true);
47 | expect(instance.props.content.textContent).toBe('a');
48 |
49 | fireEvent.click(buttonB);
50 |
51 | expect(instance.props.content.textContent).toBe('b');
52 | });
53 |
54 | it('updates `className` correctly', () => {
55 | function App({className}) {
56 | const [source, target] = useSingleton();
57 |
58 | return (
59 | <>
60 |
61 |
62 |
63 |
64 |
65 |
66 |
67 | >
68 | );
69 | }
70 |
71 | const {rerender} = render( );
72 |
73 | expect(
74 | instance.popper.firstElementChild.className.includes('some class names'),
75 | ).toBe(true);
76 |
77 | rerender( );
78 |
79 | expect(
80 | instance.popper.firstElementChild.className.includes('other names'),
81 | ).toBe(true);
82 | });
83 |
84 | it('errors if source variable has not been passed to a ', () => {
85 | const spy = jest.spyOn(console, 'error');
86 |
87 | function App1() {
88 | // eslint-disable-next-line
89 | const [source, target] = useSingleton();
90 |
91 | return (
92 | <>
93 |
94 |
95 |
96 | >
97 | );
98 | }
99 |
100 | function App2() {
101 | const [source, target] = useSingleton();
102 |
103 | return (
104 | <>
105 |
106 |
107 |
108 | >
109 | );
110 | }
111 |
112 | render( );
113 |
114 | expect(spy).toHaveBeenCalledWith(
115 | [
116 | '@tippyjs/react: The `source` variable from `useSingleton()` has',
117 | 'not been passed to a component.',
118 | ].join(' '),
119 | );
120 |
121 | spy.mockReset();
122 |
123 | render( );
124 |
125 | expect(spy).not.toHaveBeenCalled();
126 | });
127 |
128 | describe('disabled prop', () => {
129 | function App({disabled}) {
130 | const [source, target] = useSingleton({disabled});
131 |
132 | return (
133 | <>
134 |
140 |
141 |
142 |
143 |
144 |
145 |
146 | >
147 | );
148 | }
149 |
150 | it('it is set correctly on mount and on updates', () => {
151 | const {getByTestId, rerender} = render( );
152 | const buttonA = getByTestId('a');
153 |
154 | fireEvent.click(buttonA);
155 |
156 | expect(instance.state.isVisible).toBe(false);
157 |
158 | rerender( );
159 |
160 | fireEvent.click(buttonA);
161 |
162 | expect(instance.state.isVisible).toBe(true);
163 |
164 | rerender( );
165 |
166 | fireEvent.click(buttonA);
167 |
168 | expect(instance.state.isVisible).toBe(false);
169 | });
170 | });
171 |
172 | it('when new Tippys are added to DOM, they are registered with singleton', () => {
173 | const TippyCreator = ({target}) => {
174 | const [isTippyAdded, setIsTippyAdded] = useState(false);
175 |
176 | return (
177 |
178 | setIsTippyAdded(true)} data-testid="add-tippy" />
179 | {isTippyAdded && (
180 |
181 |
182 |
183 | )}
184 |
185 | );
186 | };
187 |
188 | function App() {
189 | const [source, target] = useSingleton();
190 |
191 | return (
192 | <>
193 |
199 |
200 | >
201 | );
202 | }
203 |
204 | const {getByTestId} = render( );
205 |
206 | const addTippyButton = getByTestId('add-tippy');
207 |
208 | fireEvent.click(addTippyButton);
209 |
210 | const buttonA = getByTestId('a');
211 |
212 | fireEvent.click(buttonA);
213 |
214 | expect(instance.state.isVisible).toBe(true);
215 | expect(instance.props.content.textContent).toBe('a');
216 | });
217 |
218 | describe('useSingleton headless mode', () => {
219 | function App() {
220 | const [source, target] = useSingletonHeadless();
221 |
222 | return (
223 | <>
224 | {content}
}
227 | singleton={source}
228 | trigger="click"
229 | hideOnClick={false}
230 | />
231 |
232 |
233 |
234 |
235 |
236 |
237 |
238 | >
239 | );
240 | }
241 |
242 | it('updates content correctly', () => {
243 | const {getByTestId} = render( );
244 |
245 | const buttonA = getByTestId('a');
246 | const buttonB = getByTestId('b');
247 |
248 | fireEvent.click(buttonA);
249 |
250 | expect(instance.state.isVisible).toBe(true);
251 | expect(instance.popper).toMatchSnapshot();
252 |
253 | fireEvent.click(buttonB);
254 |
255 | expect(instance.popper).toMatchSnapshot();
256 | });
257 | });
258 |
--------------------------------------------------------------------------------
/test/utils.test.js:
--------------------------------------------------------------------------------
1 | import {uniqueByShape} from '../src/utils';
2 |
3 | const date = new Date();
4 | const element = document.body;
5 |
6 | describe('uniqueByShape', () => {
7 | it('filters duplicates', () => {
8 | expect(
9 | uniqueByShape([
10 | {name: 'hello'},
11 | {name: 'hello'},
12 | {name: 'hello', enabled: false, options: {date, element}},
13 | {name: 'hello', enabled: false, options: {date, element}},
14 | {name: 'hello', options: {element, x: true}},
15 | {name: 'hello2'},
16 | ]),
17 | ).toEqual([
18 | {name: 'hello'},
19 | {name: 'hello', enabled: false, options: {date, element}},
20 | {name: 'hello', options: {x: true, element}},
21 | {name: 'hello2'},
22 | ]);
23 | });
24 | });
25 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | // only used for typechecking
3 | "compilerOptions": {
4 | "moduleResolution": "node"
5 | }
6 | }
7 |
--------------------------------------------------------------------------------