Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix(Tooltip): Support for multiple target elements #1465

Merged
merged 7 commits into from Oct 15, 2019
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);

Choose a reason for hiding this comment

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

This PR broke support for refs as target prop. When props.target is a React ref or an HTMLElement, this will return an HTMLElement, not an array. Array.from(newTarget) will then return an empty array, and Popper will error that target was set to undefined.

Copy link
Member

Choose a reason for hiding this comment

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

fixed in #1687

if (newTarget !== this._targets) {

Choose a reason for hiding this comment

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

Using reference equality here to compare two arrays doesn't look right

Copy link
Member

Choose a reason for hiding this comment

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

Yes, it seems like this would always be true since we would be getting a new array from getTarget every time. Probably need to perform a deep comparison here.

Choose a reason for hiding this comment

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

And then only remove the event listeners from the targets that actually got removed (vs removing from all the targets if one was removed)

Copy link
Member

Choose a reason for hiding this comment

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

it might be easier and quicker to just remove them all and add the newTarget back 🤷‍♂

Choose a reason for hiding this comment

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

True

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