Reputation: 349
I have a problem. I added an onClick
event to an element. Its event handler's work is to change a state. After that state changed, I display a popup. I get access to that popup using useRef
hook. Then I add a click
event to the document
and its event handler work is to check that user clicked outside the popup or not.
But the problem is here: when user clicks on the element, immediately the added event handler to the document
will be executed! But how? look at these steps and you'll understand my point better:
user clicked on the show popup button --> onClick event handler executed --> the state changed --> an other event added to the document(for outside click purpose) --> immediately the event handler of click event on document executed (all these happen when you click on show popup button just once!!).
Option popup component:
import { useRef } from "react";
import useAxis from "../../hooks/useAxis";
import useOutSideClick from "../../hooks/useOutSideClick";
const Options = (props) => {
const { children, setShowOptions, showOptions } = props;
const boxRef = useRef();
const { childrens, offset } = useAxis(children, {
/* add an onClick to this components children(it has just one child and it is the open
popup button)*/
onClick: () => {
console.log("test1");
//show the options popup
setShowOptions(!showOptions);
},
});
//close the popup if user clicked outside the popup
useOutSideClick(boxRef, () => {
console.log("test2");
//close the popup
setShowOptions((prevState) => !prevState);
}, showOptions);
return (
<>
{showOptions && (
<div
ref={boxRef}
className="absolute rounded-[20px] bg-[#0d0d0d] border border-[#1e1e1e] border-solid w-[250px] overflow-y-auto h-[250px]"
style={{ left: offset.x + 25 + "px", top: offset.y + "px" }}
></div>
)}
{childrens}
</>
);
};
export default Options;
useOutSideClick custom hook:
import { useEffect } from "react";
//a custom hook to detect that user clicked
const useOutSideClick = (ref, outSideClickHandler, condition = true) => {
useEffect(() => {
if (condition) {
const handleClickOutside = (event) => {
console.log("hellloooo");
//if ref.current doesnt contain event.target it means that user clicked outside
if (ref.current && !ref.current.contains(event.target)) {
outSideClickHandler();
}
};
document.addEventListener("click", handleClickOutside);
return () => {
document.removeEventListener("click", handleClickOutside);
};
}
}, [ref, condition]);
};
export default useOutSideClick;
useAxis custom hook:
import React, { useEffect, useRef, useState } from "react";
const useAxis = (children, events) => {
const childRef = useRef();
const [offset, setOffset] = useState({
x: "",
y: "",
});
useEffect(() => {
Object.keys(events).forEach((event) => {
let eventHandler;
let callBack = events[event];
if (event === "onClick" || event === "onMouseEnter") {
eventHandler = (e) => {
callBack();
};
events[event] = eventHandler;
}
});
}, [JSON.stringify(events)]);
//setting mouse enter and leave event for the components children
const childrens = React.Children.map(children, (child) => {
return React.cloneElement(child, {
...events,
ref: childRef,
});
});
//initialize the position of child component at the first render
useEffect(() => {
setOffset(() => {
return {
x: childRef.current.offsetLeft,
y: childRef.current.offsetTop,
};
});
}, []);
return { childrens, offset };
};
export default useAxis;
the button(actually the element) component:
//basic styles for icons
const iconsStyles = "w-[24px] h-[24px] transition duration-300 cursor-pointer";
const NavBar = () => {
const [showOptions, setShowOptions] = useState(false);
return (
<Options
setShowOptions={setShowOptions}
showOptions={showOptions}
>
//onClick will be applied to this div only
<div
>
<TooltipContainer tooltipText="options">
<div>
<DotsHorizontalIcon
className={`${iconsStyles} ${
showOptions
? "text-[#fafafa]"
: "text-[#828282] hover:text-[#fafafa]"
}`}
/>
</div>
</TooltipContainer>
</span>
</Options>
//there are some other items that theres no need to be here
);
};
export default NavBar;
You can see my codes and the part of my app that you need to see in this CodeSandbox link.
so what should I do to avoid executing the event handler(for outside click purpose) immediately after the event added to the document
by clicking on open popup button for the first time?
Upvotes: 1
Views: 1623
Reputation: 751
Equally you can set capture to equal true in your addEventListeners {capture: true}
document.addEventListener("click", handleClickOutside, true);
return () => {
document.removeEventListener("click", handleClickOutside, true);
};
Upvotes: 1
Reputation: 46191
You are returning the below JSX inside Options.jsx
. Where childrens
contains the button that opens the modal. If you look at your JSX, this button will not be rendered as a child of the div
with boxRef
, which is why this if (ref.current && !ref.current.contains(event.target))
inside useOutSideClick
will always pass, so it closes the modal right after.
return (
<>
{showOptions && (
<div
ref={boxRef}
className="absolute rounded-[20px] bg-[#0d0d0d] border border-[#1e1e1e] border-solid w-[250px] overflow-y-auto h-[250px]"
style={{ left: offset.x + 25 + "px", top: offset.y + "px" }}
></div>
)}
{childrens}
</>
);
A quick solution is to change the above return
with the below one. Notice the ref
is being added to a outer div
, so the button for opening the modal is rendered as its child.
return (
<div ref={boxRef}>
{showOptions && (
<div
className="absolute rounded-[20px] bg-[#0d0d0d] border border-[#1e1e1e] border-solid w-[250px] overflow-y-auto h-[250px]"
style={{ left: offset.x + 25 + "px", top: offset.y + "px" }}
></div>
)}
{childrens}
</div>
);
Working CodeSandbox here.
Upvotes: 1
Reputation: 551
I believe the event of the on click bubbles up, while the state is already toggled, and the ref is initialized, therefore at some point reaching the documents, whose listener is now registered and then called. to stop this behaviour you need to call e.stopPropagation()
in useAxis
for the click event. However the signed up listener to the button is not the one you would expect. It will sign up the listener you passed to the useAxis
hook, instead of the modified one in useAxis
itself. To circumvent this, put the code from inside the useEffect
in useAxis
simply in the render function. If you then call e.stopPropagation()
on the event, it should work. Here the final partial code in useAxis
:
// now correctly updates events in time before cloning the children
Object.keys(events).forEach((event) => {
let eventHandler;
let callBack = events[event];
if (event === "onClick" || event === "onMouseEnter") {
eventHandler = (e) => {
// stop the propagation of the event
e.stopPropagation();
callBack();
};
events[event] = eventHandler;
console.log("inner events", events);
}
});
//setting updated mouse enter and leave event for the components children
const childrens = React.Children.map(children, (child) => {
console.log(events);
return React.cloneElement(child, {
...events,
ref: childRef
});
});
Upvotes: 2