Skip to content

Commit

Permalink
Browse files Browse the repository at this point in the history
fix(Tooltip): Support for multiple target elements (#1465)
* fix(Tooltip single target):Support for multiple target elements using css selector #1185

* fix(Tooltip single target):Removed onClosed handler as it was redundant #1185

* fix(Tooltip single target):isOpen state can control first element only #1185

* fix(Tooltip single target):Added unit tests #1185
  • Loading branch information
akhlesh authored and TheSharpieOne committed Oct 15, 2019
1 parent b8a33f8 commit 45775c0
Show file tree
Hide file tree
Showing 6 changed files with 106 additions and 31 deletions.
4 changes: 2 additions & 2 deletions src/PopperContent.js
Expand Up @@ -73,7 +73,7 @@ class PopperContent extends React.Component {
}

setTargetNode(node) {
this.targetNode = node;
this.targetNode = typeof node === 'string' ? getTarget(node) : node;
}

getTargetNode() {
Expand Down Expand Up @@ -177,7 +177,7 @@ class PopperContent extends React.Component {
}

render() {
this.setTargetNode(getTarget(this.props.target));
this.setTargetNode(this.props.target);

if (this.state.isOpen) {
return this.props.container === 'inline' ?
Expand Down
69 changes: 42 additions & 27 deletions src/TooltipPopoverWrapper.js
Expand Up @@ -61,11 +61,16 @@ function isInDOMSubtree(element, subtreeRoot) {
return subtreeRoot && (element === subtreeRoot || subtreeRoot.contains(element));
}

function isInDOMSubtrees(element, subtreeRoots = []) {
return subtreeRoots && subtreeRoots.length && subtreeRoots.find(subTreeRoot=> isInDOMSubtree(element, subTreeRoot));
}

class TooltipPopoverWrapper extends React.Component {
constructor(props) {
super(props);

this._target = null;
this._targets = [];
this.currentTargetElement = null;
this.addTargetEvents = this.addTargetEvents.bind(this);
this.handleDocumentClick = this.handleDocumentClick.bind(this);
this.removeTargetEvents = this.removeTargetEvents.bind(this);
Expand All @@ -80,7 +85,6 @@ class TooltipPopoverWrapper extends React.Component {
this.hide = this.hide.bind(this);
this.onEscKeyDown = this.onEscKeyDown.bind(this);
this.getRef = this.getRef.bind(this);
this.onClosed = this.onClosed.bind(this);
this.state = { isOpen: props.isOpen };
this._isMounted = false;
}
Expand All @@ -93,6 +97,7 @@ class TooltipPopoverWrapper extends React.Component {
componentWillUnmount() {
this._isMounted = false;
this.removeTargetEvents();
this._targets = null;
this.clearShowTimeout();
this.clearHideTimeout();
}
Expand Down Expand Up @@ -157,6 +162,7 @@ class TooltipPopoverWrapper extends React.Component {
show(e) {
if (!this.props.isOpen) {
this.clearShowTimeout();
this.currentTargetElement = e && e.target;
this.toggle(e);
}
}
Expand All @@ -173,6 +179,7 @@ class TooltipPopoverWrapper extends React.Component {
hide(e) {
if (this.props.isOpen) {
this.clearHideTimeout();
this.currentTargetElement = null;
this.toggle(e);
}
}
Expand Down Expand Up @@ -201,7 +208,7 @@ class TooltipPopoverWrapper extends React.Component {
handleDocumentClick(e) {
const triggers = this.props.trigger.split(' ');

if (triggers.indexOf('legacy') > -1 && (this.props.isOpen || isInDOMSubtree(e.target, this._target))) {
if (triggers.indexOf('legacy') > -1 && (this.props.isOpen || isInDOMSubtrees(e.target, this._targets))) {
if (this._hideTimeout) {
this.clearHideTimeout();
}
Expand All @@ -210,7 +217,7 @@ class TooltipPopoverWrapper extends React.Component {
} else if (!this.props.isOpen) {
this.showWithDelay(e);
}
} else if (triggers.indexOf('click') > -1 && isInDOMSubtree(e.target, this._target)) {
} else if (triggers.indexOf('click') > -1 && isInDOMSubtrees(e.target, this._targets)) {
if (this._hideTimeout) {
this.clearHideTimeout();
}
Expand All @@ -223,6 +230,18 @@ class TooltipPopoverWrapper extends React.Component {
}
}

addEventOnTargets(type, handler, isBubble) {
this._targets.forEach(target=> {
target.addEventListener(type, handler, isBubble);
});
}

removeEventOnTargets(type, handler, isBubble) {
this._targets.forEach(target=> {
target.removeEventListener(type, handler, isBubble);
});
}

addTargetEvents() {
if (this.props.trigger) {
let triggers = this.props.trigger.split(' ');
Expand All @@ -231,54 +250,55 @@ class TooltipPopoverWrapper extends React.Component {
document.addEventListener('click', this.handleDocumentClick, true);
}

if (this._target) {
if (this._targets && this._targets.length) {
if (triggers.indexOf('hover') > -1) {
this._target.addEventListener(
this.addEventOnTargets(
'mouseover',
this.showWithDelay,
true
);
this._target.addEventListener(
this.addEventOnTargets(
'mouseout',
this.hideWithDelay,
true
);
}
if (triggers.indexOf('focus') > -1) {
this._target.addEventListener('focusin', this.show, true);
this._target.addEventListener('focusout', this.hide, true);
this.addEventOnTargets('focusin', this.show, true);
this.addEventOnTargets('focusout', this.hide, true);
}
this._target.addEventListener('keydown', this.onEscKeyDown, true);
this.addEventOnTargets('keydown', this.onEscKeyDown, true);
}
}
}
}

removeTargetEvents() {
if (this._target) {
this._target.removeEventListener(
if (this._targets) {
this.removeEventOnTargets(
'mouseover',
this.showWithDelay,
true
);
this._target.removeEventListener(
this.removeEventOnTargets(
'mouseout',
this.hideWithDelay,
true
);
this._target.removeEventListener('keydown', this.onEscKeyDown, true);
this._target.removeEventListener('focusin', this.show, true);
this._target.removeEventListener('focusout', this.hide, true);
this.removeEventOnTargets('keydown', this.onEscKeyDown, true);
this.removeEventOnTargets('focusin', this.show, true);
this.removeEventOnTargets('focusout', this.hide, true);
}

document.removeEventListener('click', this.handleDocumentClick, true)
}

updateTarget() {
const newTarget = getTarget(this.props.target);
if (newTarget !== this._target) {
const newTarget = getTarget(this.props.target, true);
if (newTarget !== this._targets) {
this.removeTargetEvents();
this._target = newTarget;
this._targets = newTarget ? Array.from(newTarget) : [];
this.currentTargetElement = this.currentTargetElement || this._targets[0];
this.addTargetEvents();
}
}
Expand All @@ -287,16 +307,12 @@ class TooltipPopoverWrapper extends React.Component {
if (this.props.disabled || !this._isMounted) {
return e && e.preventDefault();
}

return this.props.toggle(e);
}

onClosed() {
this.setState({ isOpen: false });
}

render() {
if (!this.state.isOpen) {
if (!this.props.isOpen) {
return null;
}

Expand Down Expand Up @@ -330,7 +346,7 @@ class TooltipPopoverWrapper extends React.Component {
return (
<PopperContent
className={className}
target={target}
target={this.currentTargetElement || this._targets[0]}
isOpen={isOpen}
hideArrow={hideArrow}
boundariesElement={boundariesElement}
Expand All @@ -342,7 +358,6 @@ class TooltipPopoverWrapper extends React.Component {
modifiers={modifiers}
offset={offset}
cssModule={cssModule}
onClosed={this.onClosed}
fade={fade}
flip={flip}
>
Expand Down
8 changes: 8 additions & 0 deletions src/__tests__/PopperContent.spec.js
Expand Up @@ -42,6 +42,14 @@ describe('PopperContent', () => {
expect(wrapper.text()).toBe('Yo!');
});

it('should render children when isOpen is true and container is inline and DOM node passed directly for target', () => {
const targetElement = element.querySelector('#target');

const wrapper = mount(<PopperContent target={targetElement} isOpen container="inline">Yo!</PopperContent>);
expect(targetElement).toBeDefined();
expect(wrapper.text()).toBe('Yo!');
});

it('should render an Arrow in the Popper when isOpen is true and container is inline', () => {
const wrapper = mount(<PopperContent target="target" isOpen container="inline" arrowClassName="custom-arrow">Yo!</PopperContent>);

Expand Down
38 changes: 38 additions & 0 deletions src/__tests__/TooltipPopoverWrapper.spec.js
Expand Up @@ -375,6 +375,44 @@ describe('Tooltip', () => {
wrapper.detach();
});

describe('multi target', () => {
let targets, targetContainer;
beforeEach(() => {
targetContainer = document.createElement('div');
targetContainer.innerHTML = `<span class='example'>Target 1</span><span class='example'>Target 2</span>`
element.appendChild(targetContainer);
targets = targetContainer.querySelectorAll('.example');
});

afterEach(() => {
element.removeChild(targetContainer);
targets = null;
});

it("should attach tooltip on multiple target when a target selector matches multiple elements", () => {
const wrapper = mount(
<TooltipPopoverWrapper target=".example" isOpen={isOpen} toggle={toggle} delay={0}>Yo!</TooltipPopoverWrapper>,
{ attachTo: container });

targets[0].dispatchEvent(new Event('click'));
jest.runTimersToTime(0)
expect(isOpen).toBe(true);

targets[0].dispatchEvent(new Event('click'));
jest.runTimersToTime(0)
expect(isOpen).toBe(false);

targets[1].dispatchEvent(new Event('click'));
jest.runTimersToTime(0)
expect(isOpen).toBe(true);

targets[1].dispatchEvent(new Event('click'));
jest.runTimersToTime(0)
expect(isOpen).toBe(false);
wrapper.detach();
});
});

describe('delay', () => {
it('should accept a number', () => {
isOpen = true;
Expand Down
14 changes: 14 additions & 0 deletions src/__tests__/utils.spec.js
Expand Up @@ -116,6 +116,20 @@ describe('Utils', () => {
expect(spy).toHaveBeenCalled();
});

it('should return all matching elements if allElement param is true', () => {
const element = document.createElement('div');
element.innerHTML = `<span class='example'>span 1</span>
<span class='example'>span 2</span>`;
document.body.appendChild(element);

jest.spyOn(document, 'querySelectorAll');
const elements = Utils.getTarget('.example', true);
expect(elements.length).toEqual(2);
expect(elements[1].textContent).toEqual('span 2');
expect(document.querySelectorAll).toHaveBeenCalledWith('.example');
document.querySelectorAll.mockRestore();
});

it('should query the document for the target if the target is a string', () => {
const element = document.createElement('div');
element.className = 'thing';
Expand Down
4 changes: 2 additions & 2 deletions src/utils.js
Expand Up @@ -299,9 +299,9 @@ export function isArrayOrNodeList(els) {
return Array.isArray(els) || (canUseDOM && typeof els.length === 'number');
}

export function getTarget(target) {
export function getTarget(target, allElements) {
const els = findDOMElements(target);
if (isArrayOrNodeList(els)) {
if (isArrayOrNodeList(els) && !allElements) {
return els[0];
}
return els;
Expand Down

0 comments on commit 45775c0

Please sign in to comment.