diff --git a/README.md b/README.md
index 79c08a7..c33f13f 100644
--- a/README.md
+++ b/README.md
@@ -291,18 +291,24 @@ You can nest the components like so:
```
-## 📚 ` `
+## 📚 Singleton
Wraps the
[`createSingleton()`](https://atomiks.github.io/tippyjs/addons/#singleton)
method.
+Depending on your component tree, you can use one of the following:
+
+### ` `
+
+If each of your reference elements are adjacent to one another, with no nesting in the tree.
+
```jsx
import Tippy, {TippySingleton} from '@tippy.js/react';
function App() {
return (
-
+
@@ -314,6 +320,32 @@ function App() {
}
```
+### `useSingleton()` (v3.1)
+
+If each of your reference elements are not adjacent to one another, or there is nesting in the tree.
+
+```jsx
+import Tippy, {useSingleton} from '@tippy.js/react';
+
+function App() {
+ const singleton = useSingleton({delay: 500});
+
+ return (
+ <>
+
+
+
+
+
+
+
+
+
+ >
+ );
+}
+```
+
## 📦 Bundle size
- `popper.js` ≈ 7 kB
diff --git a/demo/index.js b/demo/index.js
index a4374f4..0cd1ce8 100644
--- a/demo/index.js
+++ b/demo/index.js
@@ -1,6 +1,6 @@
import React, {useState, useEffect} from 'react';
import ReactDOM from 'react-dom';
-import Tippy, {TippySingleton} from '../src';
+import Tippy, {TippySingleton, useSingleton} from '../src';
import {followCursor} from 'tippy.js';
import 'tippy.js/dist/tippy.css';
@@ -90,6 +90,28 @@ function Singleton() {
return {children} ;
}
+function SingletonHook() {
+ const singleton = useSingleton({delay: 500});
+ const [count, setCount] = useState(3);
+
+ let children = [];
+ for (let i = 0; i < count; i++) {
+ children.push(
+
+ {i}
+ ,
+ );
+ }
+
+ useEffect(() => {
+ setInterval(() => {
+ setCount(count => (count === 5 ? 1 : count + 1));
+ }, 5000);
+ }, []);
+
+ return <>{children}>;
+}
+
function FollowCursor() {
return (
@@ -109,6 +131,8 @@ function App() {
Singleton dynamic children
+ Singleton (via useSingleton hook)
+
Plugins
>
diff --git a/src/Tippy.js b/src/Tippy.js
index e023eeb..9ada843 100644
--- a/src/Tippy.js
+++ b/src/Tippy.js
@@ -15,12 +15,14 @@ export function Tippy({
className,
plugins,
visible,
+ singleton,
enabled = true,
multiple = true,
ignoreAttributes = true,
...restOfNativeProps
}) {
const isControlledMode = visible !== undefined;
+ const isSingletonMode = singleton !== undefined;
const [mounted, setMounted] = useState(false);
const component = useInstance(() => ({
@@ -39,6 +41,10 @@ export function Tippy({
props.trigger = 'manual';
}
+ if (isSingletonMode) {
+ enabled = false;
+ }
+
// CREATE
useIsomorphicLayoutEffect(() => {
const instance = tippy(component.ref, props, plugins);
@@ -53,6 +59,10 @@ export function Tippy({
instance.show();
}
+ if (isSingletonMode) {
+ singleton(instance);
+ }
+
setMounted(true);
return () => {
@@ -116,6 +126,7 @@ if (process.env.NODE_ENV !== 'production') {
visible: PropTypes.bool,
enabled: PropTypes.bool,
className: PropTypes.string,
+ singleton: PropTypes.func,
};
}
diff --git a/src/hooks.js b/src/hooks.js
index c54b92a..d255e23 100644
--- a/src/hooks.js
+++ b/src/hooks.js
@@ -1,5 +1,6 @@
import {isBrowser, updateClassName} from './utils';
import {useLayoutEffect, useEffect, useRef} from 'react';
+import {createSingleton} from 'tippy.js';
export const useIsomorphicLayoutEffect = isBrowser
? useLayoutEffect
@@ -29,3 +30,47 @@ export function useInstance(initialValue) {
return ref.current;
}
+
+export function useSingleton({
+ className,
+ ignoreAttributes = true,
+ ...restOfNativeProps
+} = {}) {
+ const component = useInstance({
+ instance: null,
+ instances: [],
+ renders: 1,
+ });
+
+ const props = {
+ ignoreAttributes,
+ ...restOfNativeProps,
+ };
+
+ useIsomorphicLayoutEffect(() => {
+ const {instances} = component;
+ const instance = createSingleton(instances, props);
+
+ component.instance = instance;
+
+ return () => {
+ instance.destroy();
+ component.instances = instances.filter(i => !i.state.isDestroyed);
+ };
+ }, [component.instances.length]);
+
+ useIsomorphicLayoutEffect(() => {
+ if (component.renders === 1) {
+ component.renders++;
+ return;
+ }
+
+ component.instance.setProps(props);
+ });
+
+ useUpdateClassName(component, className, component.instances.length);
+
+ return instance => {
+ component.instances.push(instance);
+ };
+}
diff --git a/src/index.js b/src/index.js
index d61260a..12a9ebd 100644
--- a/src/index.js
+++ b/src/index.js
@@ -1,6 +1,7 @@
import tippy from 'tippy.js';
import Tippy from './Tippy';
import TippySingleton from './TippySingleton';
+import {useSingleton} from './hooks';
export default Tippy;
-export {TippySingleton, tippy};
+export {TippySingleton, useSingleton, tippy};
diff --git a/test/useSingleton.test.js b/test/useSingleton.test.js
new file mode 100644
index 0000000..112d160
--- /dev/null
+++ b/test/useSingleton.test.js
@@ -0,0 +1,204 @@
+import React from 'react';
+import TippyBase, {useSingleton as useSingletonBase} from '../src';
+import {render, cleanup} from '@testing-library/react';
+
+jest.useFakeTimers();
+
+afterEach(cleanup);
+
+describe('The useSingleton hook', () => {
+ let singletonInstance;
+ let instances = [];
+
+ const useSingleton = ({onCreate = noop => noop, ...config} = {}) => {
+ return useSingletonBase({
+ onCreate: instance => {
+ singletonInstance = instance;
+ onCreate(instance);
+ },
+ ...config,
+ });
+ };
+
+ const Tippy = ({onCreate = noop => noop, ...props} = {}) => {
+ return (
+ instances.push(i) && onCreate(i)} />
+ );
+ };
+
+ beforeEach(() => {
+ singletonInstance = null;
+ instances = [];
+ });
+
+ it('renders without crashing', () => {
+ function TestComponent() {
+ const singleton = useSingleton();
+ return (
+ <>
+
+
+
+
+
+
+ >
+ );
+ }
+
+ render( );
+ });
+
+ it('indicates the instances have been combined into a singleton', () => {
+ function TestComponent() {
+ const singleton = useSingleton();
+
+ return (
+ <>
+
+
+
+
+
+
+ >
+ );
+ }
+
+ render( );
+
+ instances.forEach(instance => {
+ expect(instance.state.isEnabled).toBe(false);
+ });
+ });
+
+ test('props.className: single name is added to tooltip', () => {
+ const className = 'hello';
+
+ function TestComponent() {
+ const singleton = useSingleton({className});
+
+ return (
+ <>
+
+
+
+
+
+
+ >
+ );
+ }
+
+ render( );
+
+ expect(
+ singletonInstance.popper.querySelector(`.${className}`),
+ ).not.toBeNull();
+ });
+
+ test('props.className: multiple names are added to tooltip', () => {
+ const className = 'hello world';
+
+ function TestComponent() {
+ const singleton = useSingleton({className});
+
+ return (
+ <>
+
+
+
+
+
+
+ >
+ );
+ }
+
+ render( );
+
+ expect(singletonInstance.popper.querySelector('.hello')).not.toBeNull();
+ expect(singletonInstance.popper.querySelector('.world')).not.toBeNull();
+ });
+
+ test('props.className: extra whitespace is ignored', () => {
+ const className = ' hello world ';
+
+ function TestComponent() {
+ const singleton = useSingleton({className});
+
+ return (
+ <>
+
+
+
+
+
+
+ >
+ );
+ }
+
+ render( );
+
+ const {tooltip} = singletonInstance.popperChildren;
+
+ expect(tooltip.className).toBe('tippy-tooltip hello world');
+ });
+
+ test('props.className: updating does not leave stale className behind', () => {
+ function TestComponent({className}) {
+ const singleton = useSingleton({className});
+
+ return (
+ <>
+
+
+
+
+
+
+ >
+ );
+ }
+
+ const {rerender} = render( );
+
+ expect(
+ singletonInstance.popperChildren.tooltip.classList.contains('one'),
+ ).toBe(true);
+
+ rerender( );
+
+ expect(
+ singletonInstance.popperChildren.tooltip.classList.contains('one'),
+ ).toBe(false);
+ expect(
+ singletonInstance.popperChildren.tooltip.classList.contains('two'),
+ ).toBe(true);
+ });
+
+ test('props.className: syncs with children.length', () => {
+ function TestComponent({count = 2}) {
+ const singleton = useSingleton({className: 'one'});
+ const tippies = [];
+
+ for (let i = 0; i < count; i++) {
+ tippies.push(
+
+
+ ,
+ );
+ }
+
+ return <>{tippies}>;
+ }
+
+ const {rerender} = render( );
+ rerender( );
+
+ expect(
+ singletonInstance.popperChildren.tooltip.classList.contains('one'),
+ ).toBe(true);
+ });
+});