React Modal and Tooltip issue when using Portal - reactjs

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.

Related

React (Next.js) hook useOutsideClick cancels my links

i'm using the following hook to handle "click away" feature to show/hide a dropdown:
const useOutsideClick = (ref: NonNullable<RefObject<HTMLButtonElement>>) => {
const [outsideClick, setOutsideClick] = useState<boolean | null>(null)
useEffect(() => {
const handleClickOutside = (e: React.MouseEvent | Event) => {
if (
ref &&
!(ref?.current as unknown as RequiredCurrentRef).contains(
e?.target as Node
)
) {
setOutsideClick(true)
} else {
setOutsideClick(false)
}
setOutsideClick(null)
}
document.addEventListener('mousedown', handleClickOutside)
return () => {
document.removeEventListener('mousedown', handleClickOutside)
}
}, [ref])
return outsideClick
}
export default useOutsideClick
the hook works fine but once i click on <a href> links (separated component from the dropdown) it does not redirect, so links don't work
how do i solve this?
Edit i'm using bulma.css for dropdowns
I think you dont need to create an extra hook at all.
If you want do du something if the user clicks on or out of the element you can use the onBlur and onFocus callbacks.
If you want to blur it for some other reason (like on the click of a button) you can use the reference of the anchor and call the blur() method whenever you like.

How can you prevent propagation of events from a MUI (v4) Slider Component

I have a MUI v4 Slider (specifically used as a range slider: https://v4.mui.com/components/slider/#range-slider) component inside an expandable component in a form, however, the onChange handler for the Slider component immediately propagates up into the parent and triggers the onClick handler which controls the hide/show.
In the child:
import { Slider } from '#material-ui/core';
export const MySliderComponent = ({ setSliderValue }) => {
let onChange = (e, value) => {
e.stopPropagation();
setSliderValue(value);
}
return <Slider onChange={onChange} />
}
In the parent:
let [expanded, setExpanded] = useState(false);
let toggle = (e) => setExpanded(!expanded);
return (
<React.Fragment>
<div className={'control'} onClick={toggle}>Label Text</div>
<div hidden={!expanded}>
<MySliderComponent />
</div>
</React.Fragment>
);
Points:
when I click inside the slider component, but not on the slider control, it does not trigger the toggle in the parent
when I click on the slider control, the event immediately (on mouse down) triggers the toggle on the parent
throwing a e.preventDefault() in the onChange handler has no effect
using Material UI v4 (no I can't migrate to 5)
I don't understand why the onChange would trigger the parent's onClick. How do I prevent this, or otherwise include a Slider in expandable content at all?
Edit:
After further debugging I found that if I removed the call to setSliderValue, that the parent did not collapse/hide the expanded content. Then I checked the state of expanded and it seems to be resetting without a call to setExpanded. So it looks like the parent component is re-rendering, and wiping out the state of the useState hook each time.
Following solution worked for me in a simmilar problem:
Give your parent component a unique id (for readability)
div id="parent" className={'control'} onClick={toggle}
Modify the parent's onClick handler (toggle):
let toggle = (e) => {
if (wasParentCLicked()) setExpanded(!expanded);
function wasParentCLicked() {
try {
if (e.target.id === "parent") return true;
} catch(error) {
}
return false;
}
}
For further help refer to the official documentation of the Event.target API: https://developer.mozilla.org/en-US/docs/Web/API/Event/target

React-Bootstrap Modals cause problems for window EventListener

I have built a React app which uses React-Bootstrap Modals.
In my return() function I have button which onClick changes state and shows/hides a div element.
const [showInfo, setShowInfo] = useState(false);
const toggleInfo = React.useCallback(() => {
setShowInfo(!showInfo);
}, [showInfo]);
useEffect(() => {
if (showInfo) {
document.addEventListener("click", toggleInfo);
return () => {
document.removeEventListener("click", toggleInfo);
};
}
}, [showInfo, toggleInfo]);
return (
...
<Button onClick={() => toggleInfo()}>
...
)
After loading the page, pressing the button changes the state and the div element is shown/hidden depending on the state. If I click anywhere on the window it hides the div element.
Everything works fine until I open any Modal dialog.
After that, when I click my button that shows/hides div the document.addEventListener("click", toggleInfo) and document.removeEventListener("click", toggleInfo) execute immediately one after the other and the div element does not get displayed.
If I reload the page, it works again and I made sure that this problem occurs only after opening the Modal dialog.
Any help or tips would be greatly appreciated
Fixed the issue by adding e.stopPropagation() to the toggleInfo() function:
const toggleInfo = React.useCallback(
(e) => {
e.stopPropagation();
setShowInfo(!showInfo);
},
[showInfo]
);
return (
...
<Button onClick={(e) => toggleInfo(e)}>
...
)

React -- Material UI -- Popover, setAnchorEl(null) onClose of the popover does not set it to null for some reason

I have a Popover (imported from MaterialUI) inside of a MenuItem (also imported from MaterialUI). I have the open prop for the popover set to the boolean value of anchorEl. the onClose is what handles the anchorEl, and if I click outside of the popover it should set anchorEl to null. This is not the case, however. anchorEl never gets set to null once it has been given the value of the DOMelement, and I'm not sure what I'm doing wrong here.
Here are the parts of my code that are important for this question
//state for the anchorEl (also handles the open/close for the popover)
const [usernamePopoverAnchorEl, setUsernamePopoverAnchorEl] = React.useState<null | HTMLElement>(null);
//handle popover open/close
const handleUsernamePopoverClick = (e: any) => {
setUsernamePopoverAnchorEl(e.currentTarget);
}
const usernamePopoverOpen = Boolean(usernamePopoverAnchorEl);
const popoverHandleClose = () => {
setUsernamePopoverAnchorEl(null);
console.log(usernamePopoverAnchorEl);
}
//menuItems with the popovers that don't close
<MenuItem onClick={handleUsernamePopoverClick}>
<span>
<p>Change username</p>
</span>
<Popover open={usernamePopoverOpen} anchorEl={usernamePopoverAnchorEl} onClose={popoverHandleClose}>
<p>Testing helloooo!</p>
</Popover>
</MenuItem>
The value of anchorEl remains the HTML element (the anchor for the popover) even though I have setAnchorEl(null) in the handleClose. Here is a picture of the logs in the console for anchorEl when I click outside of the popover.
I have basically copied this from the documentation (other than the menuItem), so I have no idea why the popover won't close...
try adding event.stopPropagation() worked for me.
const popoverHandleClose = (event) => {
event.stopPropagation();
setUsernamePopoverAnchorEl(null);
console.log(usernamePopoverAnchorEl);
}
const popoverHandleClose = () => {
setUsernamePopoverAnchorEl(null);
console.log(usernamePopoverAnchorEl);
}
You are logging the variable before the state gets updated. Try this:
const popoverHandleClose = () => {
setUsernamePopoverAnchorEl(null);
}
console.log(usernamePopoverAnchorEl);

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