MATERIAL-UI React - Popper of another Popper - javascript

I'm working on a calendar app.
The problem: clicking popper of a popper closes both poppers, because it fires the click outside event of the first popper which closes it.
I have a component <Event /> which uses Material-UI React <Popper /> and it works fine with it. combining it with the <AwayClickListener /> it closes when clicking out side, and stay open when clicking inside the popper. I created <Events /> which is a list of <Event />.
when click the + more text, popper with all the events in that day should appear, on top of the cell.
the popper children is also <Events />:
clicking an event should open a popper with the event details, as clicking it in the cell was.
since i use the same component <Events /> it does that, but not fully as expected:
clicking the event details popper closes both poppers.
That is the issue: the requirement is that clicking out side of the poppers will close the poppers, but clicking inside will leave them open and interactive
debugging shows that clicking the second popper, fires the outside clicked event of the first popper which closes it. also, taking out the click away listener function from the first popper leave the second popper open for most of the clicks - clicking some places in it, fires it's clicked away function which closes it. e.g: clicking the title closes it, clicking the location or summary divs does not.
I tried wrapping the entire cell with <ClickAwayListener />.
I tried wrapping the children of the popper with <ClickAwayListener />
Tried using the material-ui-popup-state npm, and gave the popper id attribute. than when click away, compare the target id to 'popper', and if equal stay open. but, the id that was extracted from the event object of onClickAway event was empty string. even when clicking the popper.
CODE
<Popper> - costume wrapper for the material ui popper
const popper = ({
placement,
open,
anchorEl,
handleClickAway=null,
title,
handleCloseClick=null,
children,
popperStyle = {},
calendarPopoverClass = ''
}) => {
const useStyles = makeStyles({
Popper: popperStyle
})
const styles = useStyles();
return (
<Popper modifiers={{
flip: {
enabled: false,
},
preventOverflow: {
enabled: false,
boundariesElement: 'scrollParent',
}
}}
className={styles.Popper}
placement={placement}
open={open}
anchorEl={anchorEl}
>
<ClickAwayListener onClickAway={handleClickAway}>
<CalendarPopover className={st(classes[calendarPopoverClass])} isShown withArrow={false} title={title} onClose={handleCloseClick}>
{children}
</CalendarPopover>
</ClickAwayListener>
</Popper>
)
}
<Event />
const event = ({ PROPS }) => {
const [expanded, setExpanded] = React.useState(null);
const closeExpanded = () => setExpanded(null)
return (
<>
<div
className={st(classes.Event, { isTimeShown, isNextWeekFirstFiller, isLastFiller, isMultiDay, isAllDay, isFiller })}
style={inlineStyle}
onClick={onEventClick}
>
<div className={classes.Time}>{timeToDisplay}</div>
<div className={classes.Title}>{title}</div>
</div>
<Popper
placement={popperPlacement}
title={title}
handleCloseClick={closeExpanded}
handleClickAway={closeExpanded}
open={Boolean(expanded)}
anchorEl={expanded}
popperStyle={popperStyle}
calendarPopoverClass='Event'
>
<ExpandedEvent
startDate={startDate}
endDate={endDate}
location={location}
summary={summary}
/>
</Popper>
</>
);
}
<Events />
const Events = ({ events, isTimeShown, localeToggle, popperPlacement, popperStyle, handleShowMoreClick=null }) => {
const eventsToShow: JSX.Element[] = [];
if (events.length > 0) {
let eventsToShowAmount = 3;
const moreEventsCount = events.length - eventsToShowAmount;
eventsToShowAmount = moreEventsCount > 0 ? eventsToShowAmount : events.length;
for (let i = 0; i < eventsToShowAmount; i++) {
eventsToShow.push(
<Event
key={events[i].id}
{...events[i]}
isTimeShown={isTimeShown}
popperPlacement={popperPlacement}
popperStyle={popperStyle}
/>
)
}
if (moreEventsCount > 0) {
eventsToShow.push(<ShowMore key='ShowMore' handleClick={handleShowMoreClick} moreEventsCount={moreEventsCount} />)
}
}
return (
<div className={classes.Events}>
{eventsToShow}
</div>
);
}
<MonthlyCell />
const MonthlyCell = ({
events,
isTimeShown,
popperPlacement,
popperStyle
}) => {
const [expandedEvents, setExpandedEvents] = React.useState(null);
const cell = React.useRef<HTMLDivElement>(null)
const eventsList = (handleShowMoreClick = null) => (
<Events
events={events}
isTimeShown={isTimeShown}
localeToggle={true}
popperPlacement={popperPlacement}
popperStyle={popperStyle}
handleShowMoreClick={handleShowMoreClick}
/>
);
const handleShowMoreClick = () => setExpandedEvents(eventsList());
const closeExpandedEvents = () => {
setExpandedEvents(null);
}
return (
<>
<div ref={cell} className={classes.MonthlyCell} >
{eventsList(handleShowMoreClick)}
</div>
<Popper
placement='left'
open={Boolean(expandedEvents)}
title='hello'
handleClickAway={closeExpandedEvents}
anchorEl={cell.current}
popperStyle={{ left: '17% !important' }}
handleCloseClick={closeExpandedEvents}
>
{eventsList()}
</Popper>
</>
);
}
hope it was clear enough. let me know if anything else is needed.
Thank you
EDIT 1
another attempt was giving the parent popper bigger z-index, but it didn't work

the solution was surrounding the popper children in a div.
component i used caused this un-wanted behaviour, because it didn't had forwardRef support. so adding div wrapper solved that.
also, dropping the modifiers attribute:
<Popper
// modifiers={{
// flip: {
// enabled: false,
// },
// preventOverflow: {
// enabled: false,
// boundariesElement: 'scrollParent',
// }
// }}
link for working solution: https://codesandbox.io/s/popper-in-a-popper-s6dfr?file=/src/Popper/Popper.js:372-519

Related

JavaScript element.contains returns false for conditionally rendered components

I am building a form selector component and I want the dropdown element to close when a user clicks elsewhere on the screen.
The component is designed as follows:
export const FormSelect = ({
defaultText,
disabled,
formError,
formSuccess,
formWarning,
groupTitle,
items,
isOpen,
selected,
selectorId,
selectorWidth,
setSelected,
setIsOpen,
textCaption,
textCaptionError,
textCaptionSuccess,
textLabel,
}) => {
const toggling = () => setIsOpen(!isOpen);
const mainSelector = React.useRef(null);
const closeMenu = () => {
setIsOpen(false);
};
React.useEffect(() => {
var ignoreClickOnMeElement = document.getElementById("option-container");
document.addEventListener("click", function (event) {
var isClickInsideElement = ignoreClickOnMeElement.contains(event.target);
if (!isClickInsideElement) {
closeMenu();
}
});
}, []);
return (
<div id={"option-container"}>
<SelectContainer>
{isOpen ? (
<ArrowUp />
) : (
<ArrowDownward />
)}
</SelectContainer>
<div>
{isOpen ? (
<OptionContainer>
<OptionList>
{groupTitle ? (
{items.map((item, index) => (
<ListItem/>
))}
</OptionList>
</OptionContainer>
) : null}
</div>
</div>)
The useEffect hook contains the logic to close the component if a user clicks outside of the "option-container". However, the form will not open if a user clicks directly on ArrowDown. I am fairly certain this is because the logic checking if the user click is within "option-container" is resulting in false.
if (!isClickInsideElement) {
closeMenu();
}
Why is the conditionally rendered element not considered inside the container around it?
This element:
var ignoreClickOnMeElement = document.getElementById("option-container");
is retrieved once, right after the component mounts. But there's no guarantee that that exact element is the same one that exists in the DOM when the click listener runs. If you try logging ignoreClickOnMeElement.isConnected, it may give you false, indicating that it's not in the DOM anymore.
Retrieve the element only inside the click listener instead - or, even better, use a ref instead of using getElementById.
Live demo:
const FormSelect = () => {
React.useEffect(() => {
document.addEventListener("click", function (event) {
var ignoreClickOnMeElement = document.getElementById("option-container");
var isClickInsideElement = ignoreClickOnMeElement.contains(event.target);
console.log('isClickInsideElement', isClickInsideElement);
});
}, []);
const [open, setOpen] = React.useState();
return (
<div id="option-container">
option container content
<button onClick={() => setOpen(!open)}>toggle</button>
{open ? <div>open</div> : <div>closed</div>}
</div>
);
};
ReactDOM.render(<FormSelect />, document.querySelector('.react'));
<script crossorigin src="https://unpkg.com/react#16/umd/react.development.js"></script>
<script crossorigin src="https://unpkg.com/react-dom#16/umd/react-dom.development.js"></script>
<div>
other content outside option container
<div class='react'></div>
other content outside option container
</div>

Two or more buttons triggering the same Material-UI Popper element

So, I got into a situation where I need to trigger the same Material-UI <Popper /> component from multiple different clickable elements.
As is it is described on the Popper component API on Material-UI website, when calling the Popper component you might want to pass a property named anchorEl which defines where the popper will be positioned on the canvas. My intention is for that anchorEl to always be the same, thus the popper will always open at the same position on the UI.
Most of the examples on Material-UI documentation suggest the following approach for using Popper:
const SimplePopper = () => {
const [anchorEl, setAnchorEl] = React.useState(null);
const handleClick = (event) => {
setAnchorEl(anchorEl ? null : event.currentTarget);
};
const open = Boolean(anchorEl);
return (
<div>
<button type="button" onClick={handleClick}>
Toggle Popper
</button>
<Popper open={open} anchorEl={anchorEl}>
<div className={classes.paper}>The content of the Popper.</div>
</Popper>
</div>
);
}
The first problem with this usage is that your code depends on the handleClick function to be called for it to define the anchorEl state value.
The second problem is that, even if you have more than one button element calling the handleClick function, the value on event.currentTarget would vary and the Popper element would show up in different positions of the canvas.
So, the question is:
How could I have two or more buttons triggering the same <Popper /> element in a way it always renders under the same anchorEl.
One way to do it would be to add a ref to the anchor element and pass it to Popper and use a state variable to manage the popper open state. You can then toggle the open state when the trigger elements are clicked.
const App = () => {
const anchorRef = useRef(null)
const [open, setOpen] = useState(false)
const handleClick = () => {
setOpen(!open)
}
return (
<>
<Button onClick={handleClick}>Toggle Popper</Button>
<Button onClick={handleClick} ref={anchorRef}>
Toggle Popper
</Button>
<Popper open={open} anchorEl={anchorRef.current}>
The content of the Popper.
</Popper>
</>
)
}
CodeSandbox

React: override internal components with custom component

I have a modal that is completely self contained. The modal is opened via going to the modal route and all the functionality to close the modal from button or outside clicks is within the modal component. Basically the modal is not controlled by any parent passing state. I was given a task of making the modals button customizable, meaning passing in a new button component, so we can add the modal to our lib instead of copy pasting the code in projects. Lol this seemed simple enough, and maybe it is and I am just overthinking this.
I cant paste the actual code but I can use a contrived example. This is a very simplified version of the modal, keeping in mind it opens via route so there's really no state and setState in the actual code. Also here is a fiddle
const ModalHeader = ({ onClose }) => {
return (
<div className="modal__header">
<button
className="modal__close-btn"
data-testid="modal-close-button"
onClick={onClose}
/>
</div>
);
};
const Modal = ({ children }) => {
const [state, setState] = React.useState(true);
const handleCloseOutsideClick = () => {
setState(false);
};
const handleCloseButtonClick = () => {
setState(false);
};
const renderModal = () => {
return (
<div className="modal-overlay" onClick={handleCloseOutsideClick}>
<div className="modal">
<ModalHeader onClose={handleCloseButtonClick} />
{children}
</div>
</div>
);
};
return state ? renderModal() : null;
};
const App = () => {
return (
<Modal>
<div>Modal Children</div>
</Modal>
);
};
ReactDOM.render(<App />, document.querySelector('#app'));
I tried a few things, initially I attempted to find a way to pass in a new header component containing a button. Then as I got into the code I realized what I was doing would lose the self contained functionality of the modal. My approach was along the lines of below but obviously the onClick would be an issue since invoking the close functionality is internal.
So I tried using cloneElement to add props within the component if the custom header was detected:
// inside modal component
React.useEffect(() => {
React.Children.map(children, (child: React.ReactElement) => {
if (child && child.type === ModalHeader) {
setHederFound(true);
}
});
}, []);
// inside modal render:
<div className={modalClasses} onClick={stopPropagation}>
{!headerFound ? (
<ModalDefaultHeader onClose={handleCloseButtonClick} />
) : (
React.Children.map(children, (child: React.ReactElement) => {
if (child && child.type === ModalHeader) {
return React.cloneElement(child, {
onClose: handleCloseButtonClick,
});
}
})
)}
{children}
</div>;
Obviously that did not work because there's no onClick in the custom button. Anyways I am thinking that I am over complicating this. I just need a way to pass in a custom button while leaving the functionality internal to the modal. Any assistance would be appreciated.
Thanks in advance.

How to avoid re-render in React?

I am making a simple accordion which has text editor inside it.
If we click expand text then the text editor gets opened and if we enter some text inside the editor and click shrink, then the accordion gets closed.
Again if click on the expand text of accordion where we made the changes, then the text already entered is missing inside it.
I can understand that this re render every time we click on the expand text. Also this code,
<Text> {toggleValue === index && item.content && <EditorContainer />} </Text>
check for the item clicked then it gets opened so re render happens here and hence I am losing the entered text.
Complete working example:
https://codesandbox.io/s/react-accordion-forked-dcqbo
Could you please kindly help me to retain the value entered inside the text editor despite of the clicks over the text Expand/Shrink?
Put the editor's state into a persistent parent component. Since the NormalAccordion encompasses all editors, and you want persistent state just one editor, use another component, so that the state doesn't get lost when the editor unmounts, then pass it down for the editor to use:
const OuterEditorContainer = ({ toggleValue, setToggleValue, item, index }) => {
const [editorState, setEditorState] = useState(EditorState.createEmpty());
const toggleHandler = (index) => {
index === toggleValue ? setToggleValue(-1) : setToggleValue(index);
};
return (
<Accordion>
<Heading>
<div
style={{ padding: "10px", cursor: "pointer" }}
className="heading"
onClick={() => toggleHandler(index)}
>
{toggleValue !== index ? `Expand` : `Shrink`}
</div>
</Heading>
<Text>
{toggleValue === index && item.content && (
<EditorContainer {...{ editorState, setEditorState }} />
)}
</Text>
</Accordion>
);
};
const NormalAccordion = () => {
const [toggleValue, setToggleValue] = useState(-1);
return (
<div className="wrapper">
{accordionData.map((item, index) => (
<OuterEditorContainer
{...{ toggleValue, setToggleValue, item, index }}
/>
))}
</div>
);
};
// text_editor.js
export default ({ editorState, setEditorState }) => (
<div className="editor">
<Editor
editorState={editorState}
onEditorStateChange={setEditorState}
toolbar={{
inline: { inDropdown: true },
list: { inDropdown: true },
textAlign: { inDropdown: true },
link: { inDropdown: true },
history: { inDropdown: true }
}}
/>
</div>
);
You could also put the state into the text_editor itself, and always render that container, but only conditionally render the <Editor.
You need to save the entered text and pass it as props from the parent component to EditorContainer.
Right now everytime you render it (e.g. when we click expand)
It looks like you set an empty state.
Something like:
EditorContainer
editorState: this.props.editorState || EditorState.createEmpty()
onEditorStateChange = (editorState) => {
// console.log(editorState)
this.props.setEditorState(editorState);
};
And in Accordion:
{toggleValue === index &&
item.content &&
<EditorContainer
editorState={this.state.editorState[index]}
setEditorState={newText => this.setState({...this.state, newText}) />}
Didn't try to execute it, but I think that's the way to achieve it.
Ps: Class components are almost not used anymore. Try to use function components and learn about useState hook, looks so much cleaner in my opinion

React - How do I detect if a child component is clicked

I have a test site HERE
Please do:
Visit the site and click on the hamburger icon in the top right. The
side nav should open.
Perform the same action again and you will see the problem I am
currently having.
The side nav does not close properly because I have two conflicting functions operating.
The first conflicting function is an onClick toggle function within the actual hamburger component which toggles an associated context state.
The second conflicting function is used by the side nav panel component. It is bound to a hook which uses its ref to check whether the user has clicked inside or outside of the component.
These functions work up until the user clicks on the hamburger menu whilst the side nav is open. The hamburger is technically outside of the side nav component but overlayed on top of it, so the click does not register as outside. This results in both functions firing one after the other. The "click outside" hook fires and sets the side nav context to false, closing it. The hamburger toggle function then fires, finds the context set to false and changes it back to true. Thus instantly closing and then reopening the side nav.
The solution I have attempted looks like the below. I tried to assign a ref within the hamburger child component and pass it to the side nav parent. I wanted to try and compare the callback returned from useClickedOutside to the toggleRef given to the parent but the hamburger component and then if they match then do nothing. This, I was hoping, would knock one of the functions out of action for that interaction. Not entirely sure this is the best way to attempt to achieve something like this.
Perhaps I could get the side nav bounding box and then check if the click coordinates land within it?
//SideNav.jsx
const SideToggle = ({ sideNavToggle, sideIsOpen, setToggleRef }) => {
useEffect(() => {
const toggleRef = React.createRef();
setToggleRef(toggleRef);
}, []);
return (
<Toggle onClick={sideNavToggle}>
<Bars>
<Bar sideIsOpen={sideIsOpen} />
<Bar sideIsOpen={sideIsOpen} />
<Bar sideIsOpen={sideIsOpen} />
</Bars>
</Toggle>
);
};
export default function SideNav() {
const [toggleRef, setToggleRef] = useState(null);
const { sideIsOpen, sideNavToggle, setSideIsOpen } = useContext(
NavigationContext
);
const handlers = useSwipeable({
onSwipedRight: () => {
sideNavToggle();
},
preventDefaultTouchmoveEvent: true,
trackMouse: true,
});
const sideRef = React.createRef();
useClickedOutside(sideRef, (callback) => {
if (callback) {
if (callback === toggleRef) {
return null;
} else setSideIsOpen(false);
}
});
return (
<>
<SideToggle
sideIsOpen={sideIsOpen}
sideNavToggle={sideNavToggle}
setToggleRef={setToggleRef}
/>
<div ref={sideRef}>
<Side sideIsOpen={sideIsOpen} {...handlers}>
<Section>
<Container>
<Col xs={12}>{/* <Menu /> */}</Col>
</Container>
</Section>
</Side>
</div>
</>
);
}
The useClickedOutside hook used above.
//use-clicked-outside.js
export default function useClickedOutside(ref, callback) {
useEffect(() => {
function handleClickOutside(event) {
if (ref.current && !ref.current.contains(event.target)) {
callback(event.target);
} else return null;
}
document.addEventListener("mousedown", handleClickOutside);
return () => {
document.removeEventListener("mousedown", handleClickOutside);
};
}, [ref]);
}
EDIT
Managed to fix part of the problem by moving the toggle component inside of the side nav component. It now has its own problem in that the hamburger component completely rerenders when the side nav props change. Trying to figure out how to stop the hamburger rerendering in a hooks scenario such as this. I've been reading that React.memo may be the answer but unsure how to implement it as of yet.
//SideNav.jsx
export default function SideNav() {
const { sideIsOpen, sideNavClose, sideNavOpen } = useContext(
NavigationContext
);
const handlers = useSwipeable({
onSwipedRight: () => {
sideNavClose();
},
preventDefaultTouchmoveEvent: true,
trackMouse: true,
});
const sideRef = React.createRef();
useClickedOutside(sideRef, (callback) => {
if (callback) {
sideNavClose();
}
});
const SideToggle = () => {
const handleClick = () => {
if (!sideIsOpen) {
sideNavOpen();
}
};
return (
<Toggle onClick={() => handleClick()}>
<Bars>
<Bar sideIsOpen={sideIsOpen} />
<Bar sideIsOpen={sideIsOpen} />
<Bar sideIsOpen={sideIsOpen} />
</Bars>
</Toggle>
);
};
return (
<>
<SideToggle />
<div ref={sideRef}>
<Side sideIsOpen={sideIsOpen} {...handlers}>
<Section>
<Container>
<Col xs={12}>{/* <Menu /> */}</Col>
</Container>
</Section>
</Side>
</div>
</>
);
}
I'd recommend using this package: https://github.com/airbnb/react-outside-click-handler . It handles some edge cases you mention, you can check their source code if you are interested in the details.
Then you need to keep the information about whether the sidebar is opened in the state and pass it down to the affected components somewhat like this (this way you can also change how the site looks in other places depending on the toolbar state, eg. disable scrollbar etc):
function Site() {
const [isOpened, setToolbarState] = React.useState(false);
return (
<React.Fragment>
<Toolbar isOpened={isOpened} setToolbarState={setToolbarState} />
{isOpened ? (<div>draw some background if needed</div>) : null}
<RestOfTheSite />
</React.Fragment>
);
}
function Toolbar(props) {
const setIsClosed = React.useCallback(function () {
props.setToolbarState(false);
}, [props.setToolbarState]);
const setIsOpened = React.useCallback(function () {
props.setToolbarState(true);
}, [props.setToolbarState]);
return (
<OutsideClickHandler onOutsideClick={setIsClosed}>
<div className="toolbar">
<button onClick={setIsOpened}>open</button>
...
{props.isOpened ? (<div>render links, etc here</div>) : null}
</div>
</OutsideClickHandler>
);
}
This way you won't necessary need to juggle refs and handlers around too much.
The way I answered this was to avoid detecting a click outside of the side nav altogether and instead detected if the click was outside of the main body of the site.
If true, then I call the navigation context and set side nav to false the same as I was trying in the side nav itself.

Categories

Resources