diff --git a/README.md b/README.md index 3347832..2f75335 100644 --- a/README.md +++ b/README.md @@ -43,23 +43,27 @@ class SimpleDemo extends React.Component { attachment: 'together', }, ]} - > - {/* First child: This is what the item will be tethered to */} - - {/* Second child: If present, this item will be tethered to the the first child */} - {isOpen && ( -
-

Tethered Content

-

A paragraph to accompany the title.

-
+ /* renderTarget: This is what the item will be tethered to, make sure to attach the ref */ + renderTarget={ref => ( + )} - + /* renderElement: If present, this item will be tethered to the the component returned by renderTarget */ + renderElement={ref => + isOpen && ( +
+

Tethered Content

+

A paragraph to accompany the title.

+
+ ) + } + /> ); } } @@ -67,9 +71,13 @@ class SimpleDemo extends React.Component { ## Props -#### `children`: PropTypes.node.isRequired (2 Max) +#### `renderTarget`: PropTypes.func -The first child is used as the Tether's `target` and the second child (which is optional) is used as Tether's `element` that will be moved. +This is a [render prop](https://reactjs.org/docs/render-props.html), the component returned from this function will be Tether's `target`. One argument, ref, is passed into this function. This is a ref that must be attached to the highest possible DOM node in the tree. If this is not done the element will not render. + +#### `renderElement`: PropTypes.func + +This is a [render prop](https://reactjs.org/docs/render-props.html), the component returned from this function will be Tether's `element`, that will be moved. If no component is returned, the target will still render, but with no element tethered. One argument, ref, is passed into this function. This is a ref that must be attached to the highest possible DOM node in the tree. If this is not done the element will not render. #### `renderElementTag`: PropTypes.string @@ -85,6 +93,10 @@ Tether requires this element to be `position: static;`, otherwise it will defaul Any valid [Tether options](http://tether.io/#options). +#### `children`: + +Previous versions of react-tether used children to render the target and component, using children will now throw an error. Please use renderTarget and renderElement instead + ## Imperative API The following methods are exposed on the component instance: diff --git a/bin/test b/bin/test index edaa4be..6b025ac 100755 --- a/bin/test +++ b/bin/test @@ -8,11 +8,6 @@ npm run build npm run lint npm run typescript -# Unit test React 15 and collect coverage -npm run react:15 -npm run unit -- --coverage -mv coverage/coverage-final.json coverage/coverage-react15.json - # Unit test React 16 and collect coverage npm run react:16 npm run unit -- --coverage @@ -22,7 +17,6 @@ mv coverage/coverage-final.json coverage/coverage-react16.json mkdir -p .nyc_output npx istanbul-merge \ --out .nyc_output/coverage-final.json \ - coverage/coverage-react15.json \ coverage/coverage-react16.json rm -rf coverage diff --git a/example/components/demo.js b/example/components/demo.js index c024de8..0e393d7 100644 --- a/example/components/demo.js +++ b/example/components/demo.js @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { RefObject } from 'react'; import styled from 'styled-components'; import Draggable from 'react-draggable'; import chroma from 'chroma-js'; @@ -30,16 +30,21 @@ type DraggableTargetProps = { id: string, width: number, }; -const DraggableTarget = ({ - color, - height, - id, - width, - ...props -}: DraggableTargetProps) => ( - - - +const DraggableTarget = React.forwardRef( + ( + { color, height, id, width, ...props }: DraggableTargetProps, + ref: RefObject + ) => ( + + + + ) ); const Text = styled.p` @@ -152,25 +157,29 @@ export default class Demo extends React.Component { attachment: 'together', }, ]} - > - - this.tether.getTetherInstance() && this.tether.position() - } - defaultPosition={{ x: 25, y: 125 }} - /> - {this.state.on && ( - - Drag the box around - I'll stay within the outline - + renderTarget={ref => ( + + this.tether.getTetherInstance() && this.tether.position() + } + defaultPosition={{ x: 25, y: 125 }} + /> )} - + renderElement={ref => + this.state.on && ( + + Drag the box around + I'll stay within the outline + + ) + } + /> )} diff --git a/example/components/page-title.js b/example/components/page-title.js index 71af267..c2c7dfb 100644 --- a/example/components/page-title.js +++ b/example/components/page-title.js @@ -77,16 +77,20 @@ class PageTitle extends React.Component { attachment: 'together', }, ]} - > - - - {children} - - + renderTarget={ref => ( + + )} + renderElement={ref => ( + + {children} + + )} + /> ); } diff --git a/example/index.js b/example/index.js index 6e57bd1..67ec7a9 100644 --- a/example/index.js +++ b/example/index.js @@ -61,9 +61,14 @@ class App extends Component { import TetherComponent from 'react-tether'; const TetheredThing = () => ( - -

The target component

-

The tethered component

+ ( +

The target component

+ )} + renderElement={ref => ( +

The tethered component

+ )}
) `} diff --git a/package.json b/package.json index aa383b0..d6b3bb8 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,6 @@ "test": "npm run lint && npm run typescript && npm run unit", "tdd": "npm run unit -- --watch", "react:16": "enzyme-adapter-react-install 16", - "react:15": "enzyme-adapter-react-install 15.5", "danger": "danger ci", "typescript": "tsc -p tsconfig.json", "lint": "xo" @@ -45,8 +44,8 @@ "tether": "^1.4.5" }, "peerDependencies": { - "react": "^0.14.0 || ^15.0.0 || ^16.0.0", - "react-dom": "^0.14.0 || ^15.0.0 || ^16.0.0" + "react": "^16.3.0", + "react-dom": "^16.3.0" }, "devDependencies": { "@babel/cli": "^7.0.0", diff --git a/src/TetherComponent.jsx b/src/TetherComponent.jsx index d651b28..1406a0a 100644 --- a/src/TetherComponent.jsx +++ b/src/TetherComponent.jsx @@ -1,4 +1,4 @@ -import { Component, Children } from 'react'; +import React, { Component, Children, isValidElement } from 'react'; import PropTypes from 'prop-types'; import ReactDOM from 'react-dom'; import Tether from 'tether'; @@ -9,8 +9,6 @@ if (!Tether) { ); } -const hasCreatePortal = ReactDOM.createPortal !== undefined; - const renderElementToPropTypes = [ PropTypes.string, PropTypes.shape({ @@ -20,14 +18,11 @@ const renderElementToPropTypes = [ const childrenPropType = ({ children }, propName, componentName) => { const childCount = Children.count(children); - if (childCount <= 0) { + if (childCount > 0) { return new Error( - `${componentName} expects at least one child to use as the target element.` + `${componentName} no longer uses children to render components. Please use renderTarget and renderElement instead.` ); } - if (childCount > 2) { - return new Error(`Only a max of two children allowed in ${componentName}.`); - } }; const attachmentPositions = [ @@ -62,6 +57,8 @@ class TetherComponent extends Component { style: PropTypes.object, onUpdate: PropTypes.func, onRepositioned: PropTypes.func, + renderTarget: PropTypes.func, + renderElement: PropTypes.func, children: childrenPropType, }; @@ -70,42 +67,35 @@ class TetherComponent extends Component { renderElementTo: null, }; - _targetNode = null; - - _elementParentNode = null; + // The DOM node of the target, obtained using ref in the render prop + _targetNode = React.createRef(); - _tether = null; + // The DOM node of the element, obtained using ref in the render prop + _elementNode = React.createRef(); - _elementComponent = null; - - _targetComponent = null; + _elementParentNode = null; - constructor(props) { - super(props); + _tetherInstance = null; - this.updateChildrenComponents(this.props); + componentDidMount() { + this._createContainer(); + // The container is created after mounting + // so we need to force an update to + // enable tether + // Cannot move _createContainer into the constructor + // because of is a side effect: https://reactjs.org/docs/strict-mode.html#detecting-unexpected-side-effects + this.forceUpdate(); } - updateChildrenComponents(props) { - const childArray = Children.toArray(props.children); - this._targetComponent = childArray[0]; - this._elementComponent = childArray[1]; - - if (this._targetComponent && this._elementComponent) { + componentDidUpdate(prevProps) { + // If the container related props have changed, then update the container + if ( + prevProps.renderElementTag !== this.props.renderElementTag || + prevProps.renderElementTo !== this.props.renderElementTo + ) { this._createContainer(); } - } - - // eslint-disable-next-line react/no-deprecated - componentWillUpdate(nextProps) { - this.updateChildrenComponents(nextProps); - } - componentDidMount() { - this._update(); - } - - componentDidUpdate() { this._update(); } @@ -114,31 +104,75 @@ class TetherComponent extends Component { } getTetherInstance() { - return this._tether; + return this._tetherInstance; } disable() { - this._tether.disable(); + this._tetherInstance.disable(); } enable() { - this._tether.enable(); + this._tetherInstance.enable(); } on(event, handler, ctx) { - this._tether.on(event, handler, ctx); + this._tetherInstance.on(event, handler, ctx); } once(event, handler, ctx) { - this._tether.once(event, handler, ctx); + this._tetherInstance.once(event, handler, ctx); } off(event, handler) { - this._tether.off(event, handler); + this._tetherInstance.off(event, handler); } position() { - this._tether.position(); + this._tetherInstance.position(); + } + + _runRenders() { + // To obtain the components, we run the render functions and pass in the ref + // Later, when the component is mounted, the ref functions will be called + // and trigger a tether update + let targetComponent = + typeof this.props.renderTarget === 'function' + ? this.props.renderTarget(this._targetNode) + : null; + let elementComponent = + typeof this.props.renderElement === 'function' + ? this.props.renderElement(this._elementNode) + : null; + + // Check if what has been returned is a valid react element + if (!isValidElement(targetComponent)) { + targetComponent = null; + } + if (!isValidElement(elementComponent)) { + elementComponent = null; + } + + return { + targetComponent, + elementComponent, + }; + } + + _createTetherInstance(tetherOptions) { + if (this._tetherInstance) { + this._destroy(); + } + + this._tetherInstance = new Tether(tetherOptions); + this._registerEventListeners(); + } + + _destroyTetherInstance() { + if (this._tetherInstance) { + this._tetherInstance.destroy(); + + this._tetherInstance = null; + } } _registerEventListeners() { @@ -162,67 +196,43 @@ class TetherComponent extends Component { } _destroy() { - if (this._elementParentNode) { - if (!hasCreatePortal) { - ReactDOM.unmountComponentAtNode(this._elementParentNode); - } - this._elementParentNode.parentNode.removeChild(this._elementParentNode); - } - - if (this._tether) { - this._tether.destroy(); - } - - this._elementParentNode = null; - this._tether = null; - this._targetNode = null; - this._targetComponent = null; - this._elementComponent = null; + this._destroyTetherInstance(); + this._removeContainer(); } _createContainer() { // Create element node container if it hasn't been yet - if (!this._elementParentNode) { - const { renderElementTag } = this.props; + this._removeContainer(); - // Create a node that we can stick our content Component in - this._elementParentNode = document.createElement(renderElementTag); + const { renderElementTag } = this.props; - // Append node to the render node - this._renderNode.appendChild(this._elementParentNode); + // Create a node that we can stick our content Component in + this._elementParentNode = document.createElement(renderElementTag); + } + + _addContainerToDOM() { + // Append node to the render node + this._renderNode.appendChild(this._elementParentNode); + } + + _removeContainer() { + if (this._elementParentNode && this._elementParentNode.parentNode) { + this._elementParentNode.parentNode.removeChild(this._elementParentNode); } } _update() { // If no element component provided, bail out - let shouldDestroy = !this._elementComponent || !this._targetComponent; - if (!shouldDestroy) { - this._targetNode = ReactDOM.findDOMNode(this); - shouldDestroy = !this._targetNode; - } + const shouldDestroy = + !this._elementNode.current || !this._targetNode.current; if (shouldDestroy) { - // Destroy Tether element, or parent node, if those has been created + // Destroy Tether element if it has been created this._destroy(); return; } - if (hasCreatePortal) { - this._updateTether(); - } else { - // Render element component into the DOM - ReactDOM.unstable_renderSubtreeIntoContainer( - this, - this._elementComponent, - this._elementParentNode, - () => { - // If we're not destroyed, update Tether once the subtree has finished rendering - if (this._elementParentNode) { - this._updateTether(); - } - } - ); - } + this._updateTether(); } _updateTether() { @@ -233,10 +243,12 @@ class TetherComponent extends Component { id, className, style, + renderTarget, + renderElement, ...options } = this.props; const tetherOptions = { - target: this._targetNode, + target: this._targetNode.current, element: this._elementParentNode, ...options, }; @@ -260,29 +272,31 @@ class TetherComponent extends Component { }); } - if (this._tether) { - this._tether.setOptions(tetherOptions); + this._addContainerToDOM(); + + if (this._tetherInstance) { + this._tetherInstance.setOptions(tetherOptions); } else { - this._tether = new Tether(tetherOptions); - this._registerEventListeners(); + this._createTetherInstance(tetherOptions); } - this._tether.position(); + this._tetherInstance.position(); } render() { - if (!this._targetComponent) { - return null; - } + const { targetComponent, elementComponent } = this._runRenders(); - if (!hasCreatePortal || !this._elementComponent) { - return this._targetComponent; + if (!targetComponent || !this._elementParentNode) { + return null; } - return [ - this._targetComponent, - ReactDOM.createPortal(this._elementComponent, this._elementParentNode), - ]; + return ( + + {targetComponent} + {elementComponent && + ReactDOM.createPortal(elementComponent, this._elementParentNode)} + + ); } } diff --git a/src/react-tether.d.ts b/src/react-tether.d.ts index 3f88eb2..a588b0c 100644 --- a/src/react-tether.d.ts +++ b/src/react-tether.d.ts @@ -37,11 +37,13 @@ declare namespace ReactTether { attachment: TetherAttachment; targetAttachment: TetherAttachment; }; + type RenderProp = (ref: React.RefObject) => React.ReactNode; interface TetherComponentProps extends React.Props, Tether.ITetherOptions { - children: React.ReactNode; + renderTarget?: RenderProp; + renderElement?: RenderProp; renderElementTag?: string; renderElementTo?: Element | string; className?: string; diff --git a/tests/unit/component.test.js b/tests/unit/component.test.js index fdd9acd..56c3d17 100644 --- a/tests/unit/component.test.js +++ b/tests/unit/component.test.js @@ -1,10 +1,7 @@ import React from 'react'; -import ReactDOM from 'react-dom'; import { mount } from 'enzyme'; import TetherComponent from '../../src/react-tether'; -const hasCreatePortal = ReactDOM.createPortal !== undefined; - describe('TetherComponent', () => { let wrapper; @@ -15,44 +12,35 @@ describe('TetherComponent', () => { } }); - it('should render the first child', () => { + it('should render the target', () => { + wrapper = mount( +
} + renderElement={ref =>
} + /> + ); + expect(wrapper.find('#target').exists()).toBeTruthy(); + }); + + it('should render the element', () => { wrapper = mount( - -
-
- +
} + renderElement={ref =>
} + /> ); - expect(wrapper.find('#child1').exists()).toBeTruthy(); - }); - - if (hasCreatePortal) { - it('should render the second child', () => { - wrapper = mount( - -
-
- - ); - expect(wrapper.find('#child2').exists()).toBeTruthy(); - }); - } else { - it('should not render the second child', () => { - wrapper = mount( - -
-
- - ); - expect(wrapper.find('#child2').exists()).toBeFalsy(); - }); - } + expect(wrapper.find('#element').exists()).toBeTruthy(); + }); it('should create a tether element', () => { wrapper = mount( - -
-
- +
} + renderElement={ref =>
} + /> ); const tetherElement = document.querySelector('.tether-element'); expect(tetherElement).toBeTruthy(); @@ -60,50 +48,54 @@ describe('TetherComponent', () => { it('should render the second child in the tether element', () => { wrapper = mount( - -
-
- +
} + renderElement={ref =>
} + /> ); - const child2 = document.querySelector('.tether-element #child2'); - expect(child2).toBeTruthy(); + const element = document.querySelector('.tether-element #element'); + expect(element).toBeTruthy(); }); - it('should render a single child', () => { + it('should render a just a target', () => { wrapper = mount( - -
- +
} + /> ); - expect(wrapper.find('#child1').exists()).toBeTruthy(); + expect(wrapper.find('#target').exists()).toBeTruthy(); }); - it('should not create a tether element if there is a single child', () => { + it('should not create a tether element if there is no renderElement', () => { wrapper = mount( - -
- +
} + /> ); expect(document.querySelector('.tether-element')).toBeFalsy(); }); - it('should not create a tether element if there is no target', () => { + it('should not create a tether element if there is no renderTarget', () => { wrapper = mount( - - {null} -
- +
} + /> ); expect(document.querySelector('.tether-element')).toBeFalsy(); }); - it('should not create a tether element if there is no dom node for target', () => { + it('should not create a tether element if ref is not bound to a dom node', () => { const FalsyComponent = () => null; wrapper = mount( - - - - + } + renderElement={() => } + /> ); expect(document.querySelector('.tether-element')).toBeFalsy(); }); @@ -114,48 +106,75 @@ describe('TetherComponent', () => { render() { return ( - - {this.state.firstOn &&
} - {this.state.secondOn &&
} - + + this.state.firstOn &&
+ } + renderElement={ref => + this.state.secondOn &&
+ } + /> ); } } wrapper = mount(); - expect(wrapper.find('#child1').exists()).toBeTruthy(); - expect(document.querySelector('.tether-element')).toBeTruthy(); - expect(document.querySelector('.tether-element #child2')).toBeTruthy(); + expect(wrapper.find('#target').exists()).toBeTruthy(); + expect(document.querySelector('.tether-element #element')).toBeTruthy(); wrapper.setState({ secondOn: false }); - expect(wrapper.find('#child1').exists()).toBeTruthy(); - expect(document.querySelector('.tether-element')).toBeFalsy(); - expect(document.querySelector('.tether-element #child2')).toBeFalsy(); + expect(wrapper.find('#target').exists()).toBeTruthy(); + expect(document.querySelector('.tether-element #element')).toBeFalsy(); wrapper.setState({ firstOn: false, secondOn: true }); - expect(wrapper.find('#child1').exists()).toBeFalsy(); - expect(document.querySelector('.tether-element')).toBeFalsy(); - expect(document.querySelector('.tether-element #child2')).toBeFalsy(); + expect(wrapper.find('#target').exists()).toBeFalsy(); + expect(document.querySelector('.tether-element #element')).toBeFalsy(); wrapper.setState({ firstOn: false, secondOn: false }); - expect(wrapper.find('#child1').exists()).toBeFalsy(); - expect(document.querySelector('.tether-element')).toBeFalsy(); - expect(document.querySelector('.tether-element #child2')).toBeFalsy(); + expect(wrapper.find('#target').exists()).toBeFalsy(); + expect(document.querySelector('.tether-element #element')).toBeFalsy(); }); it('allows changing the tether element tag', () => { wrapper = mount( - -
-
- +
} + renderElement={ref =>
} + /> ); expect(document.querySelector('.tether-element').nodeName).toBe('ASIDE'); }); + it('allows changing the tether element tag on the fly', () => { + class DifferentTagsComponent extends React.Component { + state = { isAside: false }; + + render() { + return ( +
} + renderElement={ref =>
} + /> + ); + } + } + wrapper = mount(); + + expect(document.querySelector('.tether-element').nodeName).toBe('DIV'); + + wrapper.setState({ isAside: true }); + + expect(document.querySelector('.tether-element').nodeName).toBe('ASIDE'); + }); + it('allows changing the tether element tag', () => { const container = document.createElement('div'); container.setAttribute('id', 'test-container'); @@ -164,10 +183,12 @@ describe('TetherComponent', () => { document.body.appendChild(container); wrapper = mount( - -
-
- +
} + renderElement={ref =>
} + /> ); expect(document.querySelector('#test-container')).toBeTruthy(); @@ -176,6 +197,49 @@ describe('TetherComponent', () => { ).toBeTruthy(); }); + it('allows changing the tether element tag on the fly', () => { + const container = document.createElement('div'); + container.setAttribute('id', 'test-container'); + // Tether requires the container element to have position static + container.style.position = 'static'; + document.body.appendChild(container); + + const container2 = document.createElement('div'); + container2.setAttribute('id', 'test-container2'); + container2.style.position = 'static'; + document.body.appendChild(container2); + + class DifferentRenderElementToComponent extends React.Component { + state = { isOne: true }; + + render() { + return ( +
} + renderElement={ref =>
} + /> + ); + } + } + wrapper = mount(); + + expect(document.querySelector('#test-container')).toBeTruthy(); + expect( + document.querySelector('#test-container .tether-element') + ).toBeTruthy(); + + wrapper.setState({ isOne: false }); + + expect(document.querySelector('#test-container2')).toBeTruthy(); + expect( + document.querySelector('#test-container2 .tether-element') + ).toBeTruthy(); + }); + it('passes arguments when on onUpdate() is called', () => { const onUpdate = jest.fn(); const updateData = { @@ -183,11 +247,14 @@ describe('TetherComponent', () => { targetAttachment: { top: 'bottom', left: 'right' }, }; wrapper = mount( - -
-
- +
} + renderElement={ref =>
} + /> ); + wrapper .instance() .getTetherInstance() @@ -203,11 +270,14 @@ describe('TetherComponent', () => { bar: 'bar', }; wrapper = mount( - -
-
- +
} + renderElement={ref =>
} + /> ); + wrapper .instance() .getTetherInstance() diff --git a/tests/unit/proptypes.test.js b/tests/unit/proptypes.test.js index 2aadbf3..8e57415 100644 --- a/tests/unit/proptypes.test.js +++ b/tests/unit/proptypes.test.js @@ -5,24 +5,25 @@ describe('propTypes', () => { describe('children', () => { const childrenProp = TetherComponent.propTypes.children; - it('should return an error if it has no children', () => { + it('should return an error if children are used', () => { const err = childrenProp( - { children: null }, + { children: [1, 2] }, 'children', 'TetherComponent' ); expect(err).toBeInstanceOf(Error); - expect(err.toString()).toContain('expects at least one child'); + expect(err.toString()).toContain( + 'TetherComponent no longer uses children to render components' + ); }); - it('should return an error if it has more than 2 children', () => { + it('should not return an error if there are no children', () => { const err = childrenProp( - { children: [1, 2, 3] }, + { children: null }, 'children', 'TetherComponent' ); - expect(err).toBeInstanceOf(Error); - expect(err.toString()).toContain('Only a max of two children allowed'); + expect(err).toBeUndefined(); }); }); diff --git a/tests/unit/public.test.js b/tests/unit/public.test.js index 29367f7..46eea67 100644 --- a/tests/unit/public.test.js +++ b/tests/unit/public.test.js @@ -5,10 +5,11 @@ import TetherComponent from '../../src/react-tether'; let wrapper; const render = () => { wrapper = mount( - -
-
- +
} + renderElement={ref =>
} + /> ); return wrapper; };