Global event handler on click outside prevents firing react events - reactjs

I have implemented the click-outside hook to close my menu component on mousedown on the document:
const useClickOutside = onClickOutside => {
const ref = useRef(null);
useEffect(
() => {
const handleClickOutside = e => {
if (ref.current && !ref.current.contains(e.target)) {
onClickOutside(e);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => {
document.removeEventListener("mousedown", handleClickOutside);
};
},
[onClickOutside, ref]
);
return ref;
};
The menu has an input which attaches an onBlur event handler:
const Input = ({ onValueEnter }) => {
const [value, setValue] = useState("");
const handleValueChange = e => setValue(e.target.value);
const handleBlur = () => onValueEnter(value);
return (
<input onBlur={handleBlur} value={value} onChange={handleValueChange} />
);
};
const Menu = ({ onClose }) => {
const ref = useClickOutside(onClose);
return (
<div ref={ref} className="menu">
<h1>Enter value</h1>
<Input onValueEnter={handleValueEnter} />
</div>
);
};
The problem is that the onBlur event on the input never fires if I have focus inside the input and click outside the menu. Codesandbox example is here.
Apparently since react has implemented its own event delegation system by attaching events to the top level instead of the actual dom nodes, global event handlers (like those registered with document.addEventListener) run before the react event handlers (github issue).
So my question is, how to work around this problem? Is it even possible to somehow make the onBlur event handler run first and then run the global event handler?
EDIT: I am already using a hack with setTimeout inside the onClose to temporarily make it work but I would really like to avoid it and find a better solution instead.

I hacked something together to make it work. It revolves around using forwardRef and imperativeHandle to access the value from child when closing. I don't know if that solves your question though.
https://codesandbox.io/embed/zw7jjopqq4

Related

React Modal and Tooltip issue when using Portal

I have a simple Modal component and a simple Tooltip component. Both of them can be opened by clicking on the triggering button and dismissed by clicking outside. To detect clicking outside, I use this simple hook:
const useClickAway = (
ref: Ref,
condition: boolean,
handler: Handler
): void => {
useEffect(() => {
const listener = (e: Event) => {
if (!ref.current || ref.current.contains(e.target as Node)) {
return;
}
handler(e);
};
if (condition) {
document.addEventListener('mouseup', listener);
document.addEventListener('touchend', listener);
}
return () => {
document.removeEventListener('mouseup', listener);
document.removeEventListener('touchend', listener);
};
}, [ref, handler, condition]);
};
And this is how I use it:
/*
* ref - reference to the modal container
* isOpen - The modal state.
* handleClose - Handler that closes the modal.
*/
useClickAway(ref, isOpen, handleClose)
It has been working fine so far, but the issue appeared when I try to render my tooltip (which uses Portal to render it into the body element, instead of the react tree) inside this Modal.
When I open the modal and then open the tooltip inside it, clicking on the tooltip is causing the modal to close. Because clicking on the tooltip is considered as clicking outside for the Modal.
Can anyone provide clean solution to this problem?
I believe you can take advantage of forwardRef to pass a ref that is defined in the Modal Tooltip shared parent.
Here is how I would do it:
First, I would re-write both Tooltip and Modal to accept an optional external prop ref. such as:
const Tooltip = React.forwardRef((props, ref) => {
const tooltipLocalRef = useRef(null);
const tooltipRef = ref || tooltipLocalRef;
//
});
// usage:
const tooltipRef = useRef(null);
<Tooltip ref={tooltipRef} anotherProps={someValue} />
Same goes for Modal component, but, in addition to the ref that we'll use for the Modal itself, we'll also send the tooltipRef as an extra prop.
const tooltipRef = useRef(null);
const modalRef = useRef(null);
<Tooltip ref={tooltipRef} anotherProp={someValue} />
<Modal ref={modalRef} tooltipRef={tooltipRef} anotherProp={someValue} />
By doing that, I believe we can check against the click outside the modal and make an exception for when that target is within the tooltipRef.current Node.
An extra work on the modal handleClose handler:
function handleClose(e) {
if (!props.tooltipRef.current || props.tooltipRef.current.contains(e.target as Node)) {
return;
}
setModalOpen(false)
}
I haven't tested that, let me know how it turns out.

React state not updating in click event with two functions, but updating with one function

This one has turned out to be a head scratcher for a while now...
I have a react component that updates state on a click event. The state is a simple boolean so I'm using a ternary operator to toggle state.
This works however as soon as I add a second function to the click event state no longer updates. Any ideas why this is happening and what I'm doing wrong?
Working code...
export default function Activity(props) {
const [selected, setSelected] = useState(false);
const selectActivity = () => {
selected ? setSelected(false) : setSelected(true);
return null;
};
const clickHandler = (e) => {
e.preventDefault();
selectActivity();
};
return (
<div
onClick={(e) => clickHandler(e)}
className={`visit card unassigned ${selected ? 'selected' : null}`}
>
//... some content here
</div>
);
}
State not updating...
export default function Activity(props) {
const [selected, setSelected] = useState(false);
const selectActivity = () => {
selected ? setSelected(false) : setSelected(true);
return null;
};
const clickHandler = (e) => {
e.preventDefault();
selectActivity();
props.collectVisitsForShift(
props.day,
props.startTime,
props.endTime,
props.customer
);
};
return (
<div
onClick={(e) => clickHandler(e)}
className={`visit card unassigned ${selected ? 'selected' : null}`}
>
//... some content here
</div>
);
}
I went for a walk and figured this one out. I'm changing state in the parent component from the same onClick event, which means the child component re-renders and gets its default state of 'false'.
I removed the state change from the parent and it works.
Thanks to Andrei for pointing me towards useCallback!
I loaded your code in a CodeSandbox environment and experienced no problems with the state getting updated. But I don't have access to your collectVisitsForShift function, so I couldn't fully reproduce your code.
However, the way you're toggling the state variable doesn't respect the official guidelines, specifically:
If the next state depends on the current state, we recommend using the updater function form
Here's what I ended up with in the function body (before returning JSX):
const [selected, setSelected] = useState(false);
// - we make use of useCallback so toggleSelected
// doesn't get re-defined on every re-render.
// - setSelected receives a function that negates the previous value
const toggleSelected = useCallback(() => setSelected(prev => !prev), []);
const clickHandler = (e) => {
e.preventDefault();
toggleSelected();
props.collectVisitsForShift(
props.day,
props.startTime,
props.endTime,
props.customer
);
};
The documentation for useCallback.

Test document listener with React Testing Library

I'm attempting to test a React component similar to the following:
import React, { useState, useEffect, useRef } from "react";
export default function Tooltip({ children }) {
const [open, setOpen] = useState(false);
const wrapperRef = useRef(null);
const handleClickOutside = (event) => {
if (
open &&
wrapperRef.current &&
!wrapperRef.current.contains(event.target)
) {
setOpen(false);
}
};
useEffect(() => {
document.addEventListener("click", handleClickOutside);
return () => {
document.removeEventListener("click", handleClickOutside);
};
});
const className = `tooltip-wrapper${(open && " open") || ""}`;
return (
<span ref={wrapperRef} className={className}>
<button type="button" onClick={() => setOpen(!open)} />
<span>{children}</span>
<br />
<span>DEBUG: className is {className}</span>
</span>
);
}
Clicking on the tooltip button changes the state to open (changing the className), and clicking again outside of the component changes it to closed.
The component works (with appropriate styling), and all of the React Testing Library (with user-event) tests work except for clicking outside.
it("should close the tooltip on click outside", () => {
// Arrange
render(
<div>
<p>outside</p>
<Tooltip>content</Tooltip>
</div>
);
const button = screen.getByRole("button");
userEvent.click(button);
// Temporary assertion - passes
expect(button.parentElement).toHaveClass("open");
// Act
const outside = screen.getByText("outside");
// Gives should be wrapped into act(...) warning otherwise
act(() => {
userEvent.click(outside);
});
// Assert
expect(button.parentElement).not.toHaveClass("open"); // FAILS
});
I don't understand why I had to wrap the click event in act - that's generally not necessary with React Testing Library.
I also don't understand why the final assertion fails. The click handler is called twice, but open is true both times.
There are a bunch of articles about limitations of React synthetic events, but it's not clear to me how to put all of this together.
I finally got it working.
it("should close the tooltip on click outside", async () => {
// Arrange
render(
<div>
<p data-testid="outside">outside</p>
<Tooltip>content</Tooltip>
</div>
);
const button = screen.getByRole("button");
userEvent.click(button);
// Verify initial state
expect(button.parentElement).toHaveClass("open");
const outside = screen.getByTestId("outside");
// Act
userEvent.click(outside);
// Assert
await waitFor(() => expect(button.parentElement).not.toHaveClass("open"));
});
The key seems to be to be sure that all activity completes before the test ends.
Say a test triggers a click event that in turn sets state. Setting state typically causes a rerender, and your test will need to wait for that to occur. Normally you do that by waiting for the new state to be displayed.
In this particular case waitFor was appropriate.

clickOutside hook triggers on inside select

I have a card component which consists of 2 selects and a button, select1 is always shown and select2 is invisible until you press the button changing the state. I also have an onClickOutside hook that reverts the state and hides select2 when you click outside the card.
The problem Im having is that in the case when select2 is visible, if you use any select and click on an option it registers as a click outside the card and hides select2, how can I fix this?
Heres the relevant code from my card component:
const divRef = useRef() as React.MutableRefObject<HTMLInputElement>;
const [disableSelect2, setDisableSelect2] = useState(true);
const handleActionButtonClick = () => {
setDisableSelect2(!disableSelect2)
}
useOutsideClick(divRef, () => {
if (!disableSelect2) {
setDisableSelect2(!disableSelect2);
}
});
return (
<div ref={divRef}>
<Card>
<Select1>[options]</Select1>
!disableSelect2 ?
<Select2>[options]</Select2>
: null
<div
className="d-c_r_action-button"
onClick={handleActionButtonClick}
>
</Card>
</div>
);
};
And this is my useoutsideClick hook
const useOutsideClick = (ref:React.MutableRefObject<HTMLInputElement>, callback:any) => {
const handleClick = (e:any) => {
if (ref.current && !ref.current.contains(e.target)) {
callback();
}
};
useEffect(() => {
document.addEventListener("click", handleClick);
return () => {
document.removeEventListener("click", handleClick);
};
});
};
Extra informtaion: Im using customized antd components and cant use MaterialUI
I tried to recreate your case from the code you shared. But the version I 'built' works.
Perhaps you can make it fail by adding in other special features from your case and then raise the issue again, or perhaps you could use the working code from there to fix yours?
See the draft of your problem I made at https://codesandbox.io/s/serverless-dust-njw0f?file=/src/Component.tsx

input onClick and onFocus collision

I have an <input> element, and I want to show content (div) below the input if it has focus.
Also, on every click on the input, I want to hide/show the div.
The problem is that if the input is not focused, than clicking the input will trigger both onClick and onFocus events, so onFocusHandler will run first so the menu will appear but immidately after that onClickHandler will run and hide it.
This is the code (that doesn't work):
import React from 'react';
const MyComp = props => {
/*
...
state: focused, showContent (booleans)
...
*/
const onFocusHandler = () => {
showContent(true);
setFocused(true);
}
const onClickHandler = () => {
if (focused) {
showContent(false);
} else {
showContent(true);
}
}
return (
<>
<input
onFocus={onFocusHandler}
onClick={onClickHandler}
/>
{
showContent &&
<div>MyContent</div>
}
</>
);
};
export default MyComp;
How can I solve this issue?
This is a very odd desired behavior as an active toggle is rather opposed to an element being focused or not. At first I couldn't see any clear way to achieve the behavior you desire. But I thought it could be achieved.
Use the onMouseDown handler instead of the onClick handler.
Use onFocus handler to toggle on the extra content.
Use onBlur handler to toggle off the extra content.
Code:
const MyComp = (props) => {
const [showContent, setShowContent] = useState(false);
const onFocusHandler = () => {
setShowContent(true);
};
const onBlurHandler = () => {
setShowContent(false);
};
const onClickHandler = () => {
setShowContent((show) => !show);
};
return (
<>
<input
onBlur={onBlurHandler}
onFocus={onFocusHandler}
onMouseDown={onClickHandler}
/>
{showContent && <div>MyContent</div>}
</>
);
};
Note: It should be noted that onMouseDown isn't a true replacement to onClick. With a click event the mouse down and up have to occur in the same element, so if. user accidentally clicks into the input but then drags out and releases, the onClick event should not fire. This may be ok though since the focus event fires before the click event so the input may get focus and toggle the content on anyway. Maybe this quirk is acceptable for your use-case.
You should you onBlur event to setContent to false. There is no need to use the onClick handler for that.

Resources