Skip to content
This repository was archived by the owner on Nov 9, 2024. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions index.d.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
import React from 'react';
import { Instance, Props } from 'tippy.js';
import React from 'react'
import { Instance, Props } from 'tippy.js'

type Omit<T, K extends keyof T> = Pick<T, Exclude<keyof T, K>>

export interface TippyProps extends Omit<Props, 'content'> {
content: React.ReactNode | string
children: React.ReactNode
onCreate?: (tip: Instance) => void
isVisible?: boolean
isEnabled?: boolean
}

export default class Tippy extends React.Component<TippyProps> {}
66 changes: 52 additions & 14 deletions src/Tippy.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,17 @@ import ReactDOM from 'react-dom'
import PropTypes from 'prop-types'
import tippy from 'tippy.js'

// Avoid Babel's large '_objectWithoutProperties' helper function
const getNativeTippyProps = props => {
const nativeProps = {}
for (const prop in props) {
if (prop !== 'children' && prop !== 'onCreate') {
nativeProps[prop] = props[prop]
// These props are not native to `tippy.js` and are specific to React only.
const REACT_ONLY_PROPS = ['children', 'onCreate', 'isVisible', 'isEnabled']

// Avoid Babel's large '_objectWithoutProperties' helper function.
function getNativeTippyProps(props) {
return Object.keys(props).reduce((acc, key) => {
if (REACT_ONLY_PROPS.indexOf(key) === -1) {
acc[key] = props[key]
}
}
return nativeProps
return acc
}, {})
}

class Tippy extends React.Component {
Expand All @@ -23,7 +25,9 @@ class Tippy extends React.Component {
content: PropTypes.oneOfType([PropTypes.string, PropTypes.element])
.isRequired,
children: PropTypes.element.isRequired,
onCreate: PropTypes.func
onCreate: PropTypes.func,
isVisible: PropTypes.bool,
isEnabled: PropTypes.bool
}

get isReactElementContent() {
Expand All @@ -37,21 +41,55 @@ class Tippy extends React.Component {
}
}

get isManualTrigger() {
return this.props.trigger === 'manual'
}

componentDidMount() {
this.setState({ isMounted: true })

this.tip = tippy.one(ReactDOM.findDOMNode(this), this.options)
this.props.onCreate && this.props.onCreate(this.tip)

const { onCreate, isEnabled, isVisible } = this.props

if (onCreate) {
onCreate(this.tip)
}

if (isEnabled === false) {
this.tip.disable()
}

if (this.isManualTrigger && isVisible === true) {
this.tip.show()
Copy link

@jwinn jwinn Nov 14, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Per atomiks/tippyjs#230, shouldn't this be?

Suggested change
this.tip.show()
setTimeout(this.tip.show)

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm going to release a new tippy.js patch that changes the global click listener to use capture phase (instead of bubbling) by default. That will prevent the need to do this.

Copy link

@jwinn jwinn Nov 14, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

May want to update the docs on how isVisible and showOnInit relate (or don't) to help avoid confusion down the road. Nice job on the project and thank you for all the work.

}
}

componentDidUpdate() {
this.tip.set(this.options)

const { isEnabled, isVisible } = this.props

if (isEnabled === true) {
this.tip.enable()
}
if (isEnabled === false) {
this.tip.disable()
}

if (this.isManualTrigger) {
if (isVisible === true) {
this.tip.show()
}
if (isVisible === false) {
this.tip.hide()
}
}
}

componentWillUnmount() {
if (this.tip) {
this.tip.destroy()
this.tip = null
}
this.tip.destroy()
this.tip = null
}

render() {
Expand Down
221 changes: 148 additions & 73 deletions test/Tippy.test.js
Original file line number Diff line number Diff line change
@@ -1,121 +1,104 @@
import React from 'react'
import Tippy from '../src/Tippy'
import { mount } from 'enzyme'
import ReactDOMServer from 'react-dom/server'
import { render, fireEvent, cleanup } from 'react-testing-library'

afterEach(cleanup)

describe('<Tippy />', () => {
test('renders only the child element', () => {
const wrapper = mount(
const stringContent = render(
<Tippy content="tooltip">
<button />
</Tippy>
)
expect(
wrapper
.children()
.first()
.equals(<button />)
).toBe(true)
wrapper.unmount()
expect(stringContent.container.innerHTML).toBe('<button></button>')
const reactElementContent = render(
<Tippy content={<div>tooltip</div>}>
<button />
</Tippy>
)
expect(reactElementContent.container.innerHTML).toBe('<button></button>')
})

test('adds a tippy instance to the child node', () => {
const wrapper = mount(
const { container } = render(
<Tippy content="tooltip">
<button />
</Tippy>
)
expect(wrapper.getDOMNode()._tippy).toBeDefined()
wrapper.unmount()
expect(container.querySelector('button')._tippy).toBeDefined()
})

test('calls onCreate() on mount, passing the instance back', () => {
const spy = jest.fn()
const wrapper = mount(
<div>
<Tippy onCreate={spy} content="tooltip">
<button>Hello</button>
</Tippy>
</div>
render(
<Tippy content="tooltip" onCreate={spy}>
<button />
</Tippy>
)
expect(spy.mock.calls.length).toBe(1)
expect(spy).toHaveBeenCalledTimes(1)
const arg = spy.mock.calls[0][0]
expect(arg.popper).toBeDefined()
expect(arg.reference).toBeDefined()
wrapper.unmount()
expect(arg.popper).toBeDefined()
})

test('renders JSX inside content prop', () => {
const wrapper = mount(
<div>
<Tippy content={<strong />}>
<button />
</Tippy>
</div>
test('renders react element content inside the content prop', () => {
const { container } = render(
<Tippy content={<strong>tooltip</strong>}>
<button />
</Tippy>
)
expect(
wrapper
.find('button')
.getDOMNode()
._tippy.popperChildren.content.querySelector('strong')
).not.toBe(null)
wrapper.unmount()
const tip = container.querySelector('button')._tippy
expect(tip.popper.querySelector('strong')).not.toBeNull()
})

test('unmount destroys the tippy instance and allows garbage collection', () => {
const wrapper = mount(
<div>
<Tippy content="tooltip">
<button />
</Tippy>
</div>
const { container, unmount } = render(
<Tippy content="tooltip">
<button />
</Tippy>
)
const tip = wrapper.find('button').getDOMNode()._tippy
expect(tip.state.isDestroyed).toBe(false)
wrapper
.find(Tippy)
.instance()
.componentWillUnmount()
expect(tip.state.isDestroyed).toBe(true)
wrapper.unmount()
const button = container.querySelector('button')
unmount()
expect(button._tippy).toBeUndefined()
})

test('updating state updates the tippy instance', done => {
class App extends React.Component {
state = { arrow: false }
state = { arrow: false, interactive: false }
componentDidUpdate() {
expect(tip.props.arrow).toBe(true)
expect(tip.props.interactive).toBe(true)
done()
}
render() {
const { arrow, interactive } = this.state
return (
<Tippy content="tooltip" arrow={this.state.arrow}>
<button onClick={() => this.setState({ arrow: true })} />
<Tippy content="tooltip" arrow={arrow} interactive={interactive}>
<button
onClick={() => this.setState({ arrow: true, interactive: true })}
/>
</Tippy>
)
}
}
const wrapper = mount(<App />)
const instance = wrapper.getDOMNode()._tippy
expect(instance.props.arrow).toBe(false)
wrapper.setState({ arrow: true }, () => {
expect(instance.props.arrow).toBe(true)
wrapper.unmount()
done()
})
const { container } = render(<App />)
const button = container.querySelector('button')
const tip = button._tippy
expect(tip.props.arrow).toBe(false)
expect(tip.props.interactive).toBe(false)
fireEvent.click(button)
})

test('component as a child', () => {
class Button extends React.Component {
render() {
return <button>My button</button>
}
}
const wrapper = mount(
<div>
<Tippy content="tooltip">
<Button />
</Tippy>
</div>
const Child = () => <button />
const { container } = render(
<Tippy content="tooltip">
<Child />
</Tippy>
)
expect(wrapper.find(Tippy).getDOMNode()._tippy).toBeDefined()
wrapper.unmount()
expect(container.querySelector('button')._tippy).toBeDefined()
})

test('tooltip content is not rendered to the DOM', () => {
Expand All @@ -127,4 +110,96 @@ describe('<Tippy />', () => {
).includes('<div>Tooltip</div>')
).toBe(false)
})

test('props.isEnabled initially `true`', done => {
class App extends React.Component {
state = { isEnabled: true }
componentDidUpdate() {
expect(button._tippy.state.isEnabled).toBe(false)
done()
}
render() {
return (
<Tippy content="tooltip" isEnabled={this.state.isEnabled}>
<button onClick={() => this.setState({ isEnabled: false })} />
</Tippy>
)
}
}
const { container } = render(<App />)
const button = container.querySelector('button')
expect(button._tippy.state.isEnabled).toBe(true)
fireEvent.click(button)
})

test('props.isEnabled initially `false`', done => {
class App extends React.Component {
state = { isEnabled: false }
componentDidUpdate() {
expect(button._tippy.state.isEnabled).toBe(true)
done()
}
render() {
return (
<Tippy content="tooltip" isEnabled={this.state.isEnabled}>
<button onClick={() => this.setState({ isEnabled: true })} />
</Tippy>
)
}
}
const { container } = render(<App />)
const button = container.querySelector('button')
expect(button._tippy.state.isEnabled).toBe(false)
fireEvent.click(button)
})

test('props.isVisible initially `true`', done => {
class App extends React.Component {
state = { isVisible: true }
componentDidUpdate() {
expect(button._tippy.state.isVisible).toBe(false)
done()
}
render() {
return (
<Tippy
content="tooltip"
trigger="manual"
isVisible={this.state.isVisible}
>
<button onClick={() => this.setState({ isVisible: false })} />
</Tippy>
)
}
}
const { container } = render(<App />)
const button = container.querySelector('button')
expect(button._tippy.state.isVisible).toBe(true)
fireEvent.click(button)
})

test('props.isVisible initially `false`', done => {
class App extends React.Component {
state = { isVisible: false }
componentDidUpdate() {
expect(button._tippy.state.isVisible).toBe(true)
done()
}
render() {
return (
<Tippy
content="tooltip"
trigger="manual"
isVisible={this.state.isVisible}
>
<button onClick={() => this.setState({ isVisible: true })} />
</Tippy>
)
}
}
const { container } = render(<App />)
const button = container.querySelector('button')
expect(button._tippy.state.isVisible).toBe(false)
fireEvent.click(button)
})
})