Skip to content

Commit 573d47e

Browse files
committed
feat(TetherContent): support Tethering Content to Targets
1 parent efb2aa0 commit 573d47e

3 files changed

Lines changed: 383 additions & 1 deletion

File tree

lib/TetherContent.jsx

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
import React, { PropTypes } from 'react';
2+
import ReactDOM from 'react-dom';
3+
import omit from 'lodash.omit';
4+
import isFunction from 'lodash.isfunction';
5+
import Tether from 'tether';
6+
7+
const propTypes = {
8+
arrow: PropTypes.string,
9+
disabled: PropTypes.bool,
10+
isOpen: PropTypes.bool.isRequired,
11+
toggle: PropTypes.func.isRequired,
12+
tether: PropTypes.object.isRequired
13+
};
14+
15+
const defaultProps = {
16+
isOpen: false
17+
};
18+
19+
class TetherContent extends React.Component {
20+
constructor(props) {
21+
super(props);
22+
23+
this.handleDocumentClick = this.handleDocumentClick.bind(this);
24+
this.toggle = this.toggle.bind(this);
25+
}
26+
27+
componentDidMount() {
28+
this.handleProps();
29+
}
30+
31+
componentDidUpdate(prevProps) {
32+
if (this.props.isOpen !== prevProps.isOpen) {
33+
this.handleProps();
34+
}
35+
}
36+
37+
componentWillUnmount() {
38+
this.hide();
39+
}
40+
41+
getTarget() {
42+
const target = this.props.tether.target;
43+
44+
if (isFunction(target)) {
45+
return ReactDOM.findDOMNode(target());
46+
}
47+
48+
return target;
49+
}
50+
51+
getTetherConfig() {
52+
let config = {
53+
...this.props.tether
54+
};
55+
56+
config.element = ReactDOM.findDOMNode(this._element);
57+
config.target = this.getTarget();
58+
return config;
59+
}
60+
61+
handleDocumentClick(e) {
62+
const container = ReactDOM.findDOMNode(this._element);
63+
if (e.target === container || !container.contains(e.target)) {
64+
this.toggle();
65+
}
66+
}
67+
68+
handleProps() {
69+
if (this.props.isOpen) {
70+
this.show();
71+
} else {
72+
this.hide();
73+
}
74+
}
75+
76+
hide() {
77+
document.removeEventListener('click', this.handleDocumentClick);
78+
79+
if (this._element) {
80+
document.body.removeChild(this._element);
81+
ReactDOM.unmountComponentAtNode(this._element);
82+
this._element = null;
83+
}
84+
85+
if (this._tether) {
86+
this._tether.destroy();
87+
this._tether = null;
88+
}
89+
}
90+
91+
show() {
92+
document.addEventListener('click', this.handleDocumentClick);
93+
94+
this._element = document.createElement('div');
95+
document.body.appendChild(this._element);
96+
ReactDOM.unstable_renderSubtreeIntoContainer(
97+
this,
98+
this.renderChildren(),
99+
this._element
100+
);
101+
102+
if (this.props.arrow) {
103+
let arrow = document.createElement('div');
104+
arrow.className = this.props.arrow + '-arrow';
105+
this._element.appendChild(arrow);
106+
}
107+
108+
this._tether = new Tether(this.getTetherConfig());
109+
this._tether.position();
110+
this._element.childNodes[0].focus();
111+
}
112+
113+
toggle(e) {
114+
if (this.props.disabled) {
115+
return e && e.preventDefault();
116+
}
117+
118+
this.props.toggle();
119+
}
120+
121+
renderChildren() {
122+
const props = omit(this.props, 'children');
123+
return React.cloneElement(
124+
this.props.children,
125+
{
126+
...props,
127+
handleDocumentClick: this.handleDocumentClick,
128+
isOpen: this.props.isOpen,
129+
toggle: this.toggle,
130+
style: { position: 'relative', ...this.props.style }
131+
}
132+
);
133+
}
134+
135+
render() {
136+
return null;
137+
}
138+
}
139+
140+
TetherContent.propTypes = propTypes;
141+
TetherContent.defaultProps = defaultProps;
142+
143+
export default TetherContent;

lib/index.js

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import Dropdown from './Dropdown';
66
import DropdownItem from './DropdownItem';
77
import DropdownMenu from './DropdownMenu';
88
import DropdownToggle from './DropdownToggle';
9+
import TetherContent from './TetherContent';
910

1011
export {
1112
Button,
@@ -15,5 +16,6 @@ export {
1516
Dropdown,
1617
DropdownItem,
1718
DropdownMenu,
18-
DropdownToggle
19+
DropdownToggle,
20+
TetherContent
1921
};

test/TetherContent.spec.js

Lines changed: 237 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,237 @@
1+
/* eslint react/no-multi-comp: 0, react/prop-types: 0 */
2+
import React from 'react';
3+
import { mount } from 'enzyme';
4+
import { TetherContent } from '../lib';
5+
6+
describe('TetherContent', () => {
7+
let state;
8+
let toggle;
9+
let tetherConfig;
10+
11+
beforeEach(() => {
12+
state = false;
13+
toggle = () => state = !state;
14+
tetherConfig = {
15+
target: () => document.body,
16+
attachment: 'middle left',
17+
targetAttachment: 'middle right'
18+
};
19+
});
20+
21+
it('should not return children', () => {
22+
const wrapper = mount(<TetherContent tether={tetherConfig} isOpen={state} toggle={toggle}><p>Content</p></TetherContent>);
23+
const instance = wrapper.instance();
24+
expect(wrapper.children().length).toBe(0);
25+
expect(instance._element).toBe(undefined);
26+
});
27+
28+
it('should renderChildren when isOpen is true', () => {
29+
state = true;
30+
spyOn(TetherContent.prototype, 'componentDidMount').and.callThrough();
31+
spyOn(TetherContent.prototype, 'renderChildren').and.callThrough();
32+
const wrapper = mount(<TetherContent tether={tetherConfig} isOpen={state} toggle={toggle}><p>Content</p></TetherContent>);
33+
const instance = wrapper.instance();
34+
35+
expect(TetherContent.prototype.componentDidMount.calls.count()).toBe(1);
36+
expect(TetherContent.prototype.renderChildren.calls.count()).toBe(1);
37+
expect(instance.props.isOpen).toBe(true);
38+
expect(instance._element.className.indexOf('tether') > -1).toBe(true);
39+
});
40+
41+
it('should render an arrow dom node when prop is true', () => {
42+
state = true;
43+
const wrapper = mount(<TetherContent tether={tetherConfig} isOpen={state} arrow="tooltip" toggle={toggle}><p>Content</p></TetherContent>);
44+
const instance = wrapper.instance();
45+
46+
expect(instance._element.textContent).toBe('Content');
47+
expect(instance._tether.enabled).toBe(true);
48+
expect(instance._element.innerHTML.indexOf('<div class="tooltip-arrow"></div>') > -1).toBe(true);
49+
});
50+
51+
it('should not call props.toggle when disabled ', () => {
52+
state = true;
53+
let props = jasmine.createSpyObj('props', ['toggle']);
54+
const wrapper = mount(<TetherContent disabled tether={tetherConfig} isOpen={state} toggle={props.toggle}><p>Content</p></TetherContent>);
55+
const instance = wrapper.instance();
56+
57+
instance.toggle({ preventDefault: () => { } });
58+
expect(props.toggle).not.toHaveBeenCalled();
59+
});
60+
61+
describe('hide', () => {
62+
it('should be called on componentWillUnmount', () => {
63+
state = true;
64+
spyOn(TetherContent.prototype, 'componentWillUnmount').and.callThrough();
65+
spyOn(TetherContent.prototype, 'hide').and.callThrough();
66+
const wrapper = mount(<TetherContent tether={tetherConfig} isOpen={state} toggle={toggle}><p>Content</p></TetherContent>);
67+
const instance = wrapper.instance();
68+
69+
expect(TetherContent.prototype.componentWillUnmount.calls.count()).toBe(0);
70+
expect(TetherContent.prototype.hide.calls.count()).toBe(0);
71+
expect(instance._element.textContent).toBe('Content');
72+
expect(instance._tether.enabled).toBe(true);
73+
74+
wrapper.unmount();
75+
76+
expect(TetherContent.prototype.componentWillUnmount.calls.count()).toBe(1);
77+
expect(TetherContent.prototype.hide.calls.count()).toBe(1);
78+
expect(instance._element).toBe(null);
79+
expect(instance._tether).toBe(null);
80+
});
81+
});
82+
83+
describe('show', () => {
84+
it('should be called on componentWillUnmount', () => {
85+
state = true;
86+
spyOn(TetherContent.prototype, 'componentWillUnmount').and.callThrough();
87+
spyOn(TetherContent.prototype, 'show').and.callThrough();
88+
const wrapper = mount(<TetherContent tether={tetherConfig} isOpen={state} toggle={toggle}><p>Content</p></TetherContent>);
89+
const instance = wrapper.instance();
90+
91+
expect(TetherContent.prototype.componentWillUnmount.calls.count()).toBe(0);
92+
expect(TetherContent.prototype.show.calls.count()).toBe(1);
93+
expect(instance._element.textContent).toBe('Content');
94+
expect(instance._tether.enabled).toBe(true);
95+
});
96+
});
97+
98+
describe('getTarget', () => {
99+
it('should grab dom node from function', () => {
100+
state = true;
101+
spyOn(tetherConfig, 'target').and.callThrough();
102+
const wrapper = mount(<TetherContent tether={tetherConfig} isOpen={state} toggle={toggle}><p>Content</p></TetherContent>);
103+
const instance = wrapper.instance();
104+
105+
expect(instance._element.textContent).toBe('Content');
106+
expect(instance._tether.enabled).toBe(true);
107+
expect(tetherConfig.target).toHaveBeenCalled();
108+
});
109+
110+
it('should grab dom node from string', () => {
111+
state = true;
112+
tetherConfig.target = 'body';
113+
const wrapper = mount(<TetherContent tether={tetherConfig} isOpen={state} toggle={toggle}><p>Content</p></TetherContent>);
114+
const instance = wrapper.instance();
115+
116+
expect(instance._element.textContent).toBe('Content');
117+
expect(instance._tether.enabled).toBe(true);
118+
});
119+
});
120+
121+
describe('handleDocumentClick', () => {
122+
it('should call toggle on document click', () => {
123+
state = true;
124+
spyOn(TetherContent.prototype, 'handleDocumentClick').and.callThrough();
125+
spyOn(TetherContent.prototype, 'toggle').and.callThrough();
126+
127+
mount(<TetherContent tether={tetherConfig} isOpen={state} toggle={toggle}><p>Content</p></TetherContent>);
128+
129+
expect(TetherContent.prototype.handleDocumentClick.calls.count()).toBe(0);
130+
expect(TetherContent.prototype.toggle.calls.count()).toBe(0);
131+
132+
document.body.click();
133+
134+
expect(TetherContent.prototype.handleDocumentClick.calls.count()).toBe(1);
135+
expect(TetherContent.prototype.toggle.calls.count()).toBe(1);
136+
});
137+
138+
it('should call toggle on container click', () => {
139+
state = true;
140+
spyOn(TetherContent.prototype, 'handleDocumentClick').and.callThrough();
141+
spyOn(TetherContent.prototype, 'toggle').and.callThrough();
142+
143+
const wrapper = mount(<TetherContent tether={tetherConfig} isOpen={state} toggle={toggle}><p>Content</p></TetherContent>);
144+
const instance = wrapper.instance();
145+
146+
expect(TetherContent.prototype.handleDocumentClick.calls.count()).toBe(0);
147+
expect(TetherContent.prototype.toggle.calls.count()).toBe(0);
148+
149+
instance._element.click();
150+
151+
expect(TetherContent.prototype.handleDocumentClick.calls.count()).toBe(1);
152+
expect(TetherContent.prototype.toggle.calls.count()).toBe(1);
153+
});
154+
155+
it('should not call toggle on tethered element click', () => {
156+
state = true;
157+
spyOn(TetherContent.prototype, 'handleDocumentClick').and.callThrough();
158+
spyOn(TetherContent.prototype, 'toggle').and.callThrough();
159+
160+
const wrapper = mount(<TetherContent tether={tetherConfig} isOpen={state} toggle={toggle}><p>Content</p></TetherContent>);
161+
const instance = wrapper.instance();
162+
163+
expect(TetherContent.prototype.handleDocumentClick.calls.count()).toBe(0);
164+
expect(TetherContent.prototype.toggle.calls.count()).toBe(0);
165+
166+
instance._element.childNodes[0].click();
167+
168+
expect(TetherContent.prototype.handleDocumentClick.calls.count()).toBe(1);
169+
expect(TetherContent.prototype.toggle.calls.count()).toBe(0);
170+
});
171+
});
172+
173+
describe('handleProps', () => {
174+
it('should call .hide when false', () => {
175+
spyOn(TetherContent.prototype, 'componentDidMount').and.callThrough();
176+
spyOn(TetherContent.prototype, 'hide').and.callThrough();
177+
spyOn(TetherContent.prototype, 'handleProps').and.callThrough();
178+
const wrapper = mount(<TetherContent tether={tetherConfig} isOpen={state} toggle={toggle}><p>Content</p></TetherContent>);
179+
const instance = wrapper.instance();
180+
181+
expect(TetherContent.prototype.componentDidMount.calls.count()).toBe(1);
182+
expect(TetherContent.prototype.hide.calls.count()).toBe(1);
183+
expect(TetherContent.prototype.handleProps.calls.count()).toBe(1);
184+
expect(instance.props.isOpen).toBe(false);
185+
});
186+
187+
it('should call .show when true', () => {
188+
state = true;
189+
spyOn(TetherContent.prototype, 'componentDidMount').and.callThrough();
190+
spyOn(TetherContent.prototype, 'show').and.callThrough();
191+
spyOn(TetherContent.prototype, 'handleProps').and.callThrough();
192+
const wrapper = mount(<TetherContent tether={tetherConfig} isOpen={state} toggle={toggle}><p>Content</p></TetherContent>);
193+
const instance = wrapper.instance();
194+
195+
expect(TetherContent.prototype.componentDidMount.calls.count()).toBe(1);
196+
expect(TetherContent.prototype.show.calls.count()).toBe(1);
197+
expect(TetherContent.prototype.handleProps.calls.count()).toBe(1);
198+
expect(instance.props.isOpen).toBe(true);
199+
expect(instance._element.className.indexOf('tether') > -1).toBe(true);
200+
});
201+
202+
it('should be called on componentDidUpdate when isOpen changed', () => {
203+
spyOn(TetherContent.prototype, 'componentDidUpdate').and.callThrough();
204+
spyOn(TetherContent.prototype, 'handleProps').and.callThrough();
205+
const wrapper = mount(<TetherContent tether={tetherConfig} isOpen={state} toggle={toggle}><p>Content</p></TetherContent>);
206+
const instance = wrapper.instance();
207+
208+
expect(TetherContent.prototype.componentDidUpdate.calls.count()).toBe(0);
209+
expect(TetherContent.prototype.handleProps.calls.count()).toBe(1);
210+
expect(instance.props.isOpen).toBe(false);
211+
212+
state = true;
213+
wrapper.setProps({ isOpen: state });
214+
215+
expect(TetherContent.prototype.componentDidUpdate.calls.count()).toBe(1);
216+
expect(TetherContent.prototype.handleProps.calls.count()).toBe(2);
217+
expect(instance.props.isOpen).toBe(true);
218+
});
219+
220+
it('should not be called on componentDidUpdate when isOpen did not change', () => {
221+
spyOn(TetherContent.prototype, 'componentDidUpdate').and.callThrough();
222+
spyOn(TetherContent.prototype, 'handleProps').and.callThrough();
223+
const wrapper = mount(<TetherContent tether={tetherConfig} isOpen={state} toggle={toggle}><p>Content</p></TetherContent>);
224+
const instance = wrapper.instance();
225+
226+
expect(TetherContent.prototype.componentDidUpdate.calls.count()).toBe(0);
227+
expect(TetherContent.prototype.handleProps.calls.count()).toBe(1);
228+
expect(instance.props.isOpen).toBe(false);
229+
230+
wrapper.setProps({ foo: 'bar' });
231+
232+
expect(TetherContent.prototype.componentDidUpdate.calls.count()).toBe(1);
233+
expect(TetherContent.prototype.handleProps.calls.count()).toBe(1);
234+
expect(instance.props.isOpen).toBe(false);
235+
});
236+
});
237+
});

0 commit comments

Comments
 (0)