I have a handler that toggles a state that determines whether a popover is open or not. To toggle, there is an <span> element that has an onClick. Furthermore, I also have a hook that sets the state of the same popover to false if you click anywhere else on the window. So when I click the asterisk () the state first goes to false, because of the hook changing the state, then to true on the same click because of the asterisk () (so it does not close).
I’ve looked at event.stopPropagation() but either I’m fundamentally not understanding it or implementing it wrong.
Here is my code:
const [openDisclaimerView, setOpenDisclaimerView] = useState(false);
const handleShowDisclaimerView = (event: React.MouseEvent<HTMLSpanElement, MouseEvent>) => {
console.log('clicked', event);
event.stopPropagation();
setOpenDisclaimerView(!openDisclaimerView);
};
return (
<>
{formCompleted && srp != null && (
<div className={styles.totalSRP}>
<Typography variant="legal1">
{srpText}
<span onClick={e => handleShowDisclaimerView(e)}>*</span> ${srp}
</Typography>
</div>
)}
</>
);
<DisclaimerPopover isOpen={openDisclaimerView} onClose={() => setOpenDisclaimerView(false)} disclaimers={totalSrpDisclaimer ? [totalSrpDisclaimer] : []} />
interface DisclaimerPopoverProps {
isOpen?: boolean;
onClose: () => void;
disclaimers: Disclaimer[];
priceBarWidth?: number;
}
const DisclaimerPopover = ({ isOpen, onClose, disclaimers, priceBarWidth }: DisclaimerPopoverProps) => {
const ref = useRef<HTMLDivElement>(null);
useOutsideClick(ref, onClose); // here is the hook
useEffect(() => {
if (!ref.current) {
return;
}
const disclaimerList = ref.current.querySelector(`ul.${styles.disclaimerList}`);
const topGradient = ref.current.querySelector(`.${styles.topGradient}`) as HTMLElement;
const bottomGradient = ref.current.querySelector(`.${styles.bottomGradient}`) as HTMLElement;
const handleScroll = () => {
if (!disclaimerList) {
return;
}
const { scrollTop, scrollHeight, clientHeight } = disclaimerList;
topGradient.style.opacity = `${scrollPercentage * 0.9}`;
bottomGradient.style.opacity = `${1 - scrollPercentage * 0.9}`;
};
disclaimerList?.addEventListener('scroll', handleScroll);
return () => {
disclaimerList?.removeEventListener('scroll', handleScroll);
};
}, []);
return (
<section ref={ref} className={cx(styles.disclaimerBox, isOpen && styles.isOpen)} style={priceBarWidth ? { width: `${priceBarWidth}px` } : {}}>
<div className={cx(styles.disclaimerBoxGradient, styles.topGradient)} style={{ opacity: 0 }} />
<ul className={cx(styles.disclaimerList)}>
{disclaimers?.map(disclaimer => (
<li key={disclaimer.id} className={cx(styles.disclaimerItem)}>
{disclaimer.description}
</li>
))}
</ul>
<button className={cx(styles.closeButton)} aria-label="close overlay" onClick={onClose} />
<div className={cx(styles.disclaimerBoxGradient, styles.bottomGradient)} style={{ opacity: 1 }} />
</section>
);
};
export default DisclaimerPopover;
export const useOutsideClick = (ref: RefObject<HTMLElement>, callback: () => void) => {
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (ref.current && !ref.current.contains(event.target as Node)) {
callback();
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [ref, callback]);
};
Based on your description and code, it seems you’re facing a classic event handling issue in React, where the click event is being captured by both your custom handler and the global click listener set up by the useOutsideClick hook. Here’s a suggested solution:
-
Modify handleShowDisclaimerView: Instead of toggling the openDisclaimerView state directly, you should determine if the click is coming from the asterisk or elsewhere. If it’s from the asterisk, you want to ensure it only opens or closes the popover, not both.
-
Revise useOutsideClick Hook: You may need to ensure that the outside click handler doesn’t interfere with your specific handleShowDisclaimerView function.
Here’s how you can adjust your code:
1. Adjust handleShowDisclaimerView
Modify the handler to check the current state and act accordingly:
jsxCopy code
const handleShowDisclaimerView = (event: React.MouseEvent<HTMLSpanElement, MouseEvent>) => { event.stopPropagation(); // Only toggle if the disclaimer is currently not visible if (!openDisclaimerView) { setOpenDisclaimerView(true); } };
2. Adjust useOutsideClick
Ensure that your useOutsideClick function does not close the popover if it is already being handled by handleShowDisclaimerView. You can do this by adding a condition to check whether the popover is already open:
jsxCopy code
export const useOutsideClick = (ref: RefObject<HTMLElement>, callback: () => void, isOpen: boolean) => { useEffect(() => { const handleClickOutside = (event: MouseEvent) => { if (isOpen && ref.current && !ref.current.contains(event.target as Node)) { callback(); } }; document.addEventListener(‘mousedown’, handleClickOutside); return () => { document.removeEventListener(‘mousedown’, handleClickOutside); }; }, [ref, callback, isOpen]); };
Now, modify the useOutsideClick usage in your DisclaimerPopover to pass the isOpen state:
jsxCopy code
useOutsideClick(ref, () => setOpenDisclaimerView(false), openDisclaimerView);
Explanation
-
Event Propagation: When you click the asterisk, the event bubbles up to the document level, where your outside click handler is also listening. By stopping the propagation in handleShowDisclaimerView, you prevent the event from reaching the global listener when the asterisk is clicked.
-
Conditional Handling: The useOutsideClick hook now includes a condition to check if the popover is already open. If it’s open, and the click is outside, it will invoke the callback. If the click is inside or the popover is closed, it won’t do anything.
This approach should solve the issue of the popover closing and then reopening immediately when the asterisk is clicked, while still allowing it to close when clicking outside of it.