Modal keeps on closing automatically with onClick Event in ReactJs - reactjs

I made a menu to display options. The code was working fine before. I copied it from a udemy course. He used the same code. Now all of sudden it is not working anymore. It keeps on hiding the menu whenever I try to open it. I cant seem to find the bug. I have added some css classes like animate-scale for animation.
Header.js
import React, { useEffect, useRef, useState } from "react";
import { AiOutlinePlus } from "react-icons/ai";
import {
BsFillNodePlusFill,
BsFillSunFill,
BsNodeMinusFill,
} from "react-icons/bs";
import { useTheme } from "../hooks";
export default function Header({ onAddMovieClick, onAddActorClick }) {
const [showOptions, setShowOptions] = useState(false);
const { toggleTheme } = useTheme();
const options = [
{
title: "Add Movie",
onClick: onAddMovieClick,
},
{
title: "Add Actor",
onClick: onAddActorClick,
},
];
return (
<div className="flex justify-between items-center relative">
<input
type="text"
className="border-2 border-light-subtle dark:border-dark-subtle outline-none bg-transparent focus:border-primary dark:border-white transition p-1 rounded"
placeholder="Search Movies..."
/>
<div className="flex items-center space-x-3">
<button
onClick={toggleTheme}
className="dark:text-white text-light-subtle p-1 rounded "
>
<BsFillSunFill size={24} />
</button>
{/* <div className="relative"> */}
<button
onClick={() => setShowOptions(true)}
className="flex space-x-2 items-center border-secondary text-secondary border-2 font-semibold text-lg px-3 rounded"
>
<span>Create</span>
<AiOutlinePlus />
</button>
</div>
<CreateOptions
visible={showOptions}
onClose={() => setShowOptions(false)}
// onClose={() => setShowOptions(false)}
options={options}
/>
</div>
// </div>
);
}
const CreateOptions = ({ visible, onClose, options }) => {
const container = useRef();
const containerID = "option-container";
useEffect(() => {
const handleClose = (e) => {
if (!visible) return;
const { parentElement, id } = e.target;
if (parentElement.id === containerID || id === containerID) return;
container.current.classList.remove("animate-scale");
container.current.classList.add("animate-scale-reverse");
};
document.addEventListener("click", handleClose);
return () => {
document.removeEventListener("click", handleClose);
};
}, [visible]);
const handleAnimationEnd = (e) => {
if (e.target.classList.contains("animate-scale-reverse")) {
console.log("triggered");
onClose();
}
e.target.classList.remove("animate-scale");
};
if (!visible) return null;
return (
<div
id={containerID}
ref={container}
onAnimationEnd={handleAnimationEnd}
className="absolute top-12 right-1 drop-shadow-lg bg-white flex flex-col p-5 space-y-3 dark:bg-secondary animate-scale"
>
{options.map(({ title, onClick }) => (
<Option key={title} onClick={onClick}>
{title}
</Option>
))}
{/* <Option>Add Movie</Option> */}
{/* <Option>Add Actor</Option> */}
</div>
);
};
const Option = ({ children, onClick }) => {
return (
<button onClick={onClick} className="text-secondary dark:text-white">
{children}
</button>
);
};
index.css
.animate-scale {
transform-origin: top;
animation: scale 0.2s;
}
.animate-scale-reverse {
transform-origin: top;
animation: scale 0.2s reverse forwards;
}
#keyframes scale {
0% {
transform: scaleY(0);
}
100% {
transform: scaleY(1);
}
}
I have shared it on codeSandBox. Just so one can see the bug.
https://codesandbox.io/s/practical-jackson-6xut0y?file=/src/App.js

I did some fixes in your codesandbox. Let me explain what I did.
The problem is that a click on button propagates down to your custom component and it catches a click event, which closes your modal. To fix that, use e.stopPropagation(); on your button.
Also, seems like you were trying to close the modal, when clicking outside of it (where you checked parent id). A better way to catch a click outside of a component is by using container.current && !container.current.contains(e.target), where container is the ref, that you've created. In this condition check, you are checking, if the clicked target is inside of your component or not.

I took a look at your codesandbox link and it looks like this line in your useEffect is causing the problem:
container.current.classList.add("animate-scale-reverse")
By the looks of how your function is set up, that function is being called each time you click the button its automatically adding the reverse class which will close the modal.

I think that your problem occurs in useEffect in CreateOptions component, You are setting an event listener on the whole document for closing the options dropdown but keep in mind that the button that opens that dropdown is also part of the document so as soon as you click to open the options dropdown, the document also sees that there is a click and a click listener for the whole document and ultimately closes the options dropdown
I hope you can understand what I am trying to say
Here is a hook that I found on the internet that can detect click outside of an element > https://usehooks.com/useOnClickOutside/
function useOnClickOutside(ref, handler) {
useEffect(() => {
const listener = (event) => {
if (!ref.current || ref.current.contains(event.target)) {
return;
}
handler(event);
};
document.addEventListener("mousedown", listener);
document.addEventListener("touchstart", listener);
return () => {
document.removeEventListener("mousedown", listener);
document.removeEventListener("touchstart", listener);
};
},
[ref, handler]
);
}
You can use the hook like so,
useOnClickOutside(ref, onClickHander)
the ref is for the element that you want to detect click outside of, In your case this would be the options dropdown
the the click hander is the function which runs when a click is detected, In your case this would be handleClose
Hope my answer was helpful to you, If not then my apologies for wasting your time,
Ayan Ali

Related

How to dynamically close TEMPORARY MUI DRAWER on event completion in React

I'm using MUI Drawer in my project and i'd like to close the drawer when a user initiated event completed, my problem is the component at which the event was initiated and the compenent that render the Drawer are not the same.
Below is my ButtomNavigation.js that rendered the drawer
const ButtomNavigation = (props) => {
const [state, setState] = React.useState({
top: false,
left: false,
bottom: false,
right: false,
});
const toggleDrawer = (anchor, open) => (event) => {
if (
event &&
event.type === 'keydown' &&
(event.key === 'Tab' || event.key === 'Shift')
) {
return;
}
setState({ ...state, [anchor]: open });
};
return (
<>
<div className='d-flex justify-content-between item-bottom-nav'>
<div className='d-flex justify-content-end cart-and-buy-now'>
<button id="addCart" onClick={toggleDrawer('bottom', true)}>Add to cart</button>
<button id="buyNow" onClick={toggleDrawer('bottom', true)}>Buy now</button>
</div>
</div>
<div >
{['bottom'].map((anchor) => (
<React.Fragment key={anchor}>
<SwipeableDrawer
anchor={anchor}
open={state[anchor]}
onClose={toggleDrawer(anchor, false)}
onOpen={toggleDrawer(anchor, true)}
PaperProps={{
sx:{height: 'calc(100% - 60px)', top: 60,borderTopLeftRadius:'10px',borderTopRightRadius:'10px'}
}}
>
{props.Data !=null &&
<>
<ItemModule Data={props.Data}/>
</>
}
</SwipeableDrawer>
</React.Fragment>
))}
</div>
</>
);
};
export default ButtomNavigation;
now in my ItemModule.js component ii have a function that make a remote call to add item to cart and on success i want to close the drawer
const addToCart = async (cartItem) => {
try {
const response = await axiosPrivate.post("/cart",
JSON.stringify({ cartItem }), {
signal: controller.signal
});
console.log(response?.data);
//Here i want to close the Drawer after the response has being received
}catch(err){
handle error
}
The Drawer has an "open" property, that you're using. Move this property in a provider (in a state variable). Wrap both components in this provider, and give them access to the "open" variable, and the setOpen to the one that need it, in your case the component having "addToCart". Call setOpen when the event occured.

How to unit test a UI change caused by a user interaction

I'm using vitest and react testing library and the situation is the following:
I'm trying to test whether the UI updates after a user interacts with an input checkbox.
Then the question is: how to do it to be sure that when a user clicks the input, the parent component gets a blue border (I'm using tailwind).
The component that I'm testing:
export const AddOn: FC<props> = ({
title,
desc,
price,
type,
handleAdd,
handleRemove,
checked,
}) => {
const [isChecked, toggleCheck] = useState(checked);
useEffect(() => {
isChecked
? handleAdd(title.toLowerCase(), price)
: handleRemove(title.toLowerCase(), price);
}, [isChecked]); // eslint-disable-line react-hooks/exhaustive-deps
return (
<div
className={
"relative w-full border border-n-light-gray rounded-md p-3 lg:px-6 lg:py-3 flex gap-4 items-center hover:opacity-70 " +
(**checked ? " border-p-purplish-blue bg-n-alabaster"** : "")
}
*data-testid={`addon-${title}-container`}*
>
<div className="flex gap-4">
<input
autoComplete="off"
className="w-6 h-6 self-center cursor-pointer"
*data-testid={`addon-${title}`}*
defaultChecked={checked}
type="checkbox"
*onClick={() => toggleCheck(!checked)}*
onKeyPress={(e) => {
e.preventDefault();
toggleCheck(!checked);
}}
/>
<div className="w-8/12 sm:w-full text-left">
<h3 className="text-base text-p-marine-blue font-bold">{title}</h3>
<p className="text-n-cool-gray justify-self-start">{desc}</p>
</div>
</div>
<p className="text-p-purplish-blue text-base font-medium absolute right-2 lg:right-6">
{type === "monthly" ? `+$${price}/mo` : `+$${price}/yr`}
</p>
</div>
);
};
And the test I wrote is:
test("UI TEST: should show the container with a blue border after user clicks on", async () => {
render(
<AddOn
checked={false}
desc="test"
handleAdd={() => {}}
handleRemove={() => {}}
price={0}
title="title-test"
type="test"
/>
);
const addOnOnlineService: HTMLInputElement = await screen.findByTestId(
"addon-title-test"
);
await userEvent.click(addOnOnlineService);
const testContainer: HTMLDivElement = await screen.findByTestId(
"addon-title-test-container"
);
await waitFor(() => {
expect(Array.from(testContainer.classList)).toContain(
"border-p-purplish-blue"
);
});
});
I tried running my test but I couldn't see the HTML updated in the test output. I got the same without the class "border-p-purplish-blue bg-n-alabaster" added because of the state change.
My example could help but I did not using your test way.
1.import act
import { act } from "react-dom/test-utils";
2.using dispatch event then check when text changes, testing class name
it("password length", () => {
act(() => {
render(<ChangePasswordState />, container);
});
expect(state).toBeNull();
let buttonChange = document.getElementsByClassName("blue")[2];
let txtPassword = document.getElementsByTagName("input")[0];
let txtConfirmPassword = document.getElementsByTagName("input")[1];
act(() => {
txtPassword.setAttribute("value", "1234567");
txtPassword.dispatchEvent(new CustomEvent("change", { bubbles: true }))
txtConfirmPassword.setAttribute("value", "1234567");
txtConfirmPassword.dispatchEvent(new CustomEvent("change", { bubbles: true }))
});
expect(buttonChange.className).toContain("disabled");
});

Why can't I interact with my components within my modal?

I am trying to create a modal and for some reason I cannot interact with any components like buttons or inputs within my modal.
I am building a React Portal, so that my modal and page are separate from each other and that the modal is not "running" in the background when it is not in use
import {useState, useLayoutEffect} from "react"
import { createPortal } from "react-dom"
const createWrapperAndAppendToBody = (wrapperId: string) => {
if(!document) return null
const wrapperElement = document.createElement("div")
wrapperElement.setAttribute('id', wrapperId)
document.body.appendChild(wrapperElement)
return wrapperElement
}
function ReactPortal({children, wrapperId}: {children: React.ReactElement; wrapperId: string}) {
const [wrapperElement, setWrapperElement] = useState<HTMLElement>()
useLayoutEffect(() => {
let element = document.getElementById(wrapperId)
let systemCreated = false
if(!element) {
systemCreated = true
element = createWrapperAndAppendToBody(wrapperId)
}
setWrapperElement(element!)
return () => {
if(systemCreated && element?.parentNode) {
element.parentNode.removeChild(element)
}
}
},[wrapperId])
if(!wrapperElement) return null
return createPortal(children, wrapperElement)
}
export default ReactPortal
I am pretty sure this is fine.
import React, {useEffect} from "react";
import ReactPortal from "./ReactPortal";
interface ConfirmationModalProps {
isOpen: boolean;
handleClose: () => void
children: React.ReactNode
}
export const ConfirmationModal =({children, isOpen, handleClose}:ConfirmationModalProps) => {
//allows to press Escape Key
useEffect(() => {
const closeOnEscapeKey = (e: KeyboardEvent) =>
e.key === 'Escape' ? handleClose() : null
document.body.addEventListener('keydown', closeOnEscapeKey)
return () => {
document.body.removeEventListener('keydown', closeOnEscapeKey)
};
},[handleClose])
//stops scrolling fuction when modal is open
useEffect(() => {
document.body.style.overflow = 'hidden';
return (): void => {
document.body.style.overflow = 'unset'
}
},[isOpen])
if(!isOpen) return null
return (
<ReactPortal wrapperId='react-portal-modal-container'>
<>
<div className='fixed top-0 left-0 w-screen h-screen z-40 bg-neutral-800 opacity-50' />
<div className='fixed rounded flex flex-col box-border min-w-fit overflow-hidden p-5 bg-zinc-800 inset-y-32 inset-x-32'>
<button className='py-2 px-8 self-end font-bold hover:bg-violet-600 border rounded'
onClick={() => console.log('Pressed')}>
Close
</button>
<div className='box-border h-5/6'>{children}</div>
</div>
</>
</ReactPortal>
)
}
This is my reusable Modal component so that I do not have to build a new modal from scratch. The close button within here is no also not working/can't event click on it via a console.log('pressed')
Here is a picture of the modal, but none of the buttons work, however, my Escape key function does work. Any thoughts would be great!

Grab the url fragment and expand the related accordion, scrolling to it

I want to send mails with links to my faq-site, like http://www.test.com/faq#lorem_ipsum.
But my faq-site is made in react with multiple accordion components.
const faqQuestions = [
{
title: <h1 className="font-bold">question?</h1>,
content: (
<div className="px-4">
test
</div>
),
accordionId: 'lorem_ipsum',
},
]
const Faq = () => {
// const [isExpanded, setIsExpanded] = useState(false);
useLayoutEffect(() => {
const anchor = window.location.hash.split('#')[1];
if (anchor) {
const anchorEl = document.getElementById(anchor);
if (anchorEl) {
anchorEl.scrollIntoView();
}
}
}, []);
return (
<div className="container mx-auto px-10 lg:px-0">
<div className="py-6">
<p className="font-semibold text-3xl text-primary">Häufige Fragen</p>
</div>
<div className="pb-10">
{faqQuestions.map((question) => (
<Accordion key={Math.random()} id={question.accordionId}>
<AccordionSummary
expandIcon={<FontAwesomeIcon icon={faChevronDown} />}
aria-controls="panel1a-content"
>
<div>{question.title}</div>
</AccordionSummary>
<AccordionDetails>{question.content}</AccordionDetails>
</Accordion>
))}
</div>
</div>
);
};
I want that, when the user clicks on the link with anchor and jumps to the specific accordion AND the expand it. But i can't figure out, how to identify the accordion i'm jumping to. With plain javascript it's easy, but I can't find a solution with React.
Hope, that somebody could help me.
As described in the mui documentation, you need to use a controlled accordion, using a state.
First, add a state to keep the name/id of the open accordion.
const [expanded, setExpanded] = useState(false);
Then, change you update function in order to grab the hash from the URL, check if a matching question exists inside your array, set the related accordion component as expanded, and finally scroll to it.
useLayoutEffect(() => {
const anchor = window.location.hash.split('#')[1];
if (anchor) {
const accExists = faqQuestions.find(q => q.accordionId === anchor)
if (accExists) {
setExpandend(anchor);
const anchorEl = document.getElementById(anchor);
anchorEl.scrollIntoView();
}
}
}, []);
You also need to add an handler for clicks on the controlled accordion, to update the state with the name of the clicked accordion.
const handleChange = (panel) => (event, isExpanded) => {
setExpanded(isExpanded ? panel : false);
};
Finally, change the JSX code in order to use this logic.
{faqQuestions.map((question) => (
<Accordion
key={question.accordionId}
id={question.accordionId}
expanded={expanded === question.accordionId}
onChange={handleChange(question.accordionId)}>
// ... your accordion content
</Accordion>
))}

Problem with using setTimeout function together with an arrow function that sets a particular state

I am trying to replicate how when you hover over a particular movie tile on Netflix, after sometime the movie tile expands to display more information.
Currently I have succeeded on expanding the tile to display more information on hover but setting a timeout such that the information is only displayed after the mouse has hovered for over 1000ms is proving to produce unwanted results.
So far i have tried this but the problem is that when i hover the state is changed for all other movie tiles instead of only the one that was hovered
function RowPoster({ movie, isTrending }) {
const [isHovered, setisHovered] = useState(false);
const trailerUrl = movie.trailer_url;
return (
<div
className={`RowPoster ${isTrending && "isTrending"}`}
onMouseEnter={setTimeout(() => setisHovered(true), 1000)}
onMouseLeave={() => setisHovered(false)}
>
<img src={movie.cover_image} alt={movie.titles.en} />
{isHovered && (
<>
{
<ReactPlayer
className="video"
width="100%"
height="160px"
loop={true}
playing={false}
url={trailerUrl}
/>
}
<div className="item__info">
<h4>{movie.titles.en}</h4>
<div className="icons">
<PlayArrow className="icon" />
<Add />
<ThumbUpAltOutlined className="icon" />
<ThumbDownOutlined className="icon" />
<KeyboardArrowDown className="icon" />
</div>
<div className="stats">
<span className="stats_score">{`Score ${movie.score}%`}</span>
</div>
<div className="genre">
<ul className="genre_items">
<li>{movie.genres[0]}</li>
<li>{movie.genres[1]}</li>
<li>{movie.genres[2]}</li>
<li>{movie.genres[3]}</li>
</ul>
</div>
</div>
</>
)}
</div>
);
}
export default RowPoster;
In the code you provided, a timeout is called when rendering. For this reason, the state changes for each movie tiles that is rendered. To call setTimeout when an event is fired, you need to wrap it in a function:
...
onMouseEnter={() => setTimeout(() => setisHovered(true), 1000)}
...
For behavior "display information after the mouse has hovered for more than 1000 ms", you will need a little more code:
function RowPoster({ movie, isTrending }) {
const [isHovered, setisHovered] = useState(false);
const trailerUrl = movie.trailer_url;
const hoverTimerRef = useRef();
const handleCancelHover = useCallback(() => {
if (hoverTimerRef.current) {
clearTimeout(hoverTimerRef.current);
}
}, []);
const handleMouseEnter = useCallback(() => {
// save the timer id in the hoverTimerRef
hoverTimerRef.current = setTimeout(() => setisHovered(true), 1000);
}, []);
const handleMouseLeave = useCallback(() => {
// cancel the scheduled hover if the mouseLeave event is fired before the timer is triggered
handleCancelHover();
setisHovered(false);
}, [handleCancelHover]);
useEffect(() => {
return () => {
// cancel the scheduled hover when unmounting the component
handleCancelHover();
};
}, [handleCancelHover]);
return (
<div
className={`RowPoster ${isTrending && "isTrending"}`}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
...

Resources