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.
Related
I wanted to create a dropdown menu, which shows itself and hides on hovering, and disappears after clicking its item. I thought I found a way to do it - but it works only sometimes. (Or maybe it doesn't work - but sometimes it does.) Details below:
I gotta DropdownMenu2 component, which display is being toggled by onMouseEnter/Leave events. This component (my dropdown menu) holds inside <NavLink> menu items.
I wanted the dropdown menu to disappear after clicking on menu item, so inside <Navlink> I created onClick event which triggers handleClick. This functions sets a click variable - to a CSS className with display:none. click is then passed to <div> that contains the Dropdown menu.
To toggle the dropdown menu display again on mouse hover, I had to get rid of the click class from the div. For that I created useEffect hook, with click dependency - so it fires every time click state changes. And function inside this hook - changes click value, so it no longer represents the CSS display:none class. So after (2.) - div containing dropdown menu has display:none, disapears, and useEffect erases that - making it hover ready.
problem:
this works only sometimes - sometimes useEffect is triggered so fast after onClick, that the dropdown menu doesn't even drop. ( click changes so fast that div container gets the "erasing" class immediately after display:none class )
NaviMainButtonDrop2
import DropdownMenu2 from "./DropdownMenu2";
import useHoverButton from "./sub-components/useHoverButton";
const NaviMainButtonDrop2 = () => {
const { disp, hoverOn, hoverOff } = useHoverButton();
return (
<li
className={`nav-main__button dropdown-us`}
>
<a
className="hover-pointer"
onMouseEnter={hoverOn}
onMouseLeave={hoverOff}
>
title
</a>
{ disp && <DropdownMenu2 /> }
</li>
)
}
export default NaviMainButtonDrop2
useHoverButton (custom hook for NaviMainButtonDrop2)
import { useState } from "react";
const useHoverButton = () => {
const [disp, setDisp] = useState(false);
const hoverOn = () => setDisp(true)
const hoverOff = () => setDisp(false)
return { disp, hoverOn, hoverOff }
}
export default useHoverButton
DropdownMenu2
import "./DropdownMenu.css"
import { NavLink } from "react-router-dom";
import { MenuItemContentSchool } from "./sub-components/MenuItemContentSchool"
import { useEffect } from "react";
import useAddClass from "./sub-components/useAddClass";
const DropdownMenu2 = () => {
const { click, setClick, handleClick } = useAddClass("hide-menu");
useEffect(() => {
console.log("[usEffect]")
setClick("");
}, [click]);
return (
<div className={`dropdown-holder-us ${click}`}>
{/* here menu unfolds */}
{MenuItemContentSchool.map((item) => {
return (
<NavLink
to={item.link}
className={(navData) => (navData.isActive ? "d-content-us active-style" : 'd-content-us')}
onClick={handleClick}
key={item.id}
>
{item.title}
</NavLink>
)
})}
</div>
)
}
export default DropdownMenu2
useAddClass (custom hook for DropdownMenu2)
import { useState } from "react"
const useAddClass = (className) => {
const [click, setClick] = useState("");
const handleClick = () => setClick(className);
return { click , handleClick }
}
export default useAddClass
I think the issue here is that you are not able to get the latest state whenever you update the next state that is why it works sometimes and sometimes it doesn't.
According to me there could be 2 solutions to this, either use a setTimeout or get the latest state when setting the state.
setTimeout solution-
useEffect(() => {
setTimeout(() => {
setClick("")
},2000)
Try and always get the latest state when you update the next state.
useEffect(() => {
console.log("[usEffect]")
setClick((clickLatest) => "");
}, [click]);
and
const handleClick = () => setClick((clickLatest) => className);
This callback will help the useState wait for the latest state and then update the state further.
I think I just found a simple solution to this. I don't understand why useEffect seems to work in a random timing, but using setTimeOut inside it, and delaying the execution of setClick - seems to do the job.
useEffect(() => {
setTimeout(() => {
setClick("")
},2000)
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.
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
So I have this weird problem (and I am sorry a newbie on this still) but I have a dropdown list that I want to be able to select from and pass back to a form to submit. it works fine when I do the drop down line items manually, but when I retrieve it from the backend and then map, and try to handle the onSelect but the event on onSelect keeps returning null - so confused - do you see anything obvious in this code?
import React, { useEffect, useState } from 'react';
import { useHttpClient } from '../hooks/http-hook';
import { validate } from '../util/validators';
import 'bootstrap/dist/css/bootstrap.min.css';
import DropdownButton from 'react-bootstrap/DropdownButton';
import Dropdown from 'react-bootstrap/Dropdown';
import './Input.css';
const Select = props => {
console.log('props.id=' + props.id);
console.log('props.label=' + props.label);
const [selValue, setSelValue] = useState('');
const { isLoading, error, sendRequest, clearError } = useHttpClient();
const [loadedFoodgroups, setLoadedFoodgroups] = useState([]);
useEffect(() => {
const fetchFoodgroups = async () => {
try {
const responseData = await sendRequest('http://localhost:5000/api/foodgroups')
setLoadedFoodgroups(responseData);
console.log('ResponseData' + JSON.stringify(responseData));
} catch (err) { }
};
fetchFoodgroups();
}, [sendRequest]);
const handleSelect = (event) => {
event.preventDefault();
console.log('Select.js: handleSelect- event e=' + event.target.value);
setSelValue(event.target.value);
}
return (
<React.Fragment>
<DropdownButton
className="form-control__select"
alignRight
title="Foodgroups"
id="dropdown-menu-align-right"
onSelect={handleSelect}
value={selValue}
>
<label htmlFor={props.id}>{props.label}</label>
{loadedFoodgroups.map(selectOptions => (
<Dropdown.Item
key={selectOptions.id}
className="form-control__select"
eventkey={selectOptions.id}>{selectOptions.name}
</Dropdown.Item>
))}
</DropdownButton>
</React.Fragment>
);
};
export default Select;
While the underlying issue is answered in some other questions (kind of), you have a few things you have to change, so here is a more specific answer for you:
First, the signature of onSelect is (eventKey, event) => ..., but in my testing the second param is not useful (target is null). So even if you switch to using the second param in your handler, it probably won't work. Instead, most people seem to be using the eventKey param like this:
const handleSelect = eventKey => {
event.preventDefault();
setSelValue(eventKey);
}
Which would probably meet your needs.
Before this will work for you, there are three typos to fix:
<Dropdown.Item
key={selectOptions._id}. <--you have ".id" i your code but the data is "._id"
className="form-control__select"
eventKey={selectOptions.name} <--you have "eventkey", with lowercase "k",
// should be upper case.
// You probably want the ".name" property here rather than
// "id", this is what will be passed into "onSelect"
>
So if you change your handler to use first param as eventKey and fix typos above, you should be able to set the state to the value selected from the dropdown button.
Hi guys) I have a strange question may be, but I'm at a dead end.
I have my own custom hook.
const useModal = (Content?: ReactNode, options?: ModalOptions) => {
const { isOpen, close: contextClose, open: contextOpen, setContent } = useContext(
ModalContext,
)
const [customOpenContent, setCustomOpenContent] = useState<ReactNode>()
const showModal = useCallback(
(customContent?: ReactNode) => {
if (!isNil(customContent)) {
setCustomOpenContent(customContent)
contextOpen(customContent, options)
} else contextOpen(Content, options)
},
[contextOpen, Content, options],
)
const hideModal = useCallback(() => {
contextClose()
}, [contextClose])
return { isOpen, close: hideModal, open: showModal, setContent }
}
It is quite simple.
Also i have component which uses this hook
const App: React.FC = () => {
const [loading, setLoading] = useState(false)
const { open } = useModal(null, { deps: [loading] })
useEffect(() => {
setTimeout(() => {
setLoading(true)
}, 10000)
})
const buttonCallback = useCallback(() => {
open(<Button disabled={!loading}>Loading: {loading.toString()}</Button>)
}, [loading, open])
return (
<Page title="App">
<Button onClick={buttonCallback}>Open Modal</Button>
</Page>
)
}
Main problem is - Button didn't became enabled because useModal hook doesn't know anything about changes.
May be you have an idea how to update this component while it's props are updated? And how to do it handsomely ))
Context isn't the best solution to this problem. What you want is a Portal instead. Portals are React's solution to rendering outside of the current React component hierarchy. How to use React Portal? is a basic example, but as you can see, just going with the base React.Portal just gives you the location to render.
Here's a library that does a lot of the heavy lifting for you: https://github.com/wellyshen/react-cool-portal. It has typescript definitions and provides an easy API to work with.
Here's your example using react-cool-portal.
import usePortal from "react-cool-portal";
const App = () => {
const [loading, setLoading] = useState(false);
const { Portal, isShow, toggle } = usePortal({ defaultShow: false });
useEffect(() => {
setTimeout(() => {
setLoading(true);
}, 10000);
});
const buttonCallback = useCallback(() => {
toggle();
}, [toggle]);
return (
<div title="App" style={{ backgroundColor: "hotpink" }}>
<button onClick={buttonCallback}>
{isShow ? "Close" : "Open"} Modal
</button>
<Portal>
<button disabled={!loading}>Loading: {loading.toString()}</button>
</Portal>
<div>{loading.toString()}</div>
</div>
);
};
Basic CodeSandbox Example
There are more detailed ones within the react-cool-portal documentation.
For more detail of the issues with the Context solution you were trying, is that React Elements are just a javascript object. React then uses the object, it's location in the tree, and it's key to determine if they are the same element. React doesn't actually care or notice where you create the object, only it's location in the tree when it is rendered.
The disconnect in your solution is that when you pass the element to the open function in buttonCallback, the element is created at that point. It's a javascript object that then is set as the content in your context. At that point, the object is set and won't change until you called open again. If you set up your component to call open every time the relevant state changes, you could get it working that way. But as I mentioned earlier, context wasn't built for rendering components outside of the current component; hence why some really weird workarounds would be required to get it working.