I have created a custom Slider component that works perfectly except for one issue.
If it is present in a modal, then the mouseUp event for it fires on it for all events below it (z-index-wise) before on the element itself.
This is because to have a nice slider you need to attach the mouseMove and mouseUp events to the document instead of the slider itself so that you can still move the thumb and capture mouse up events even when the mouse moves off the slider while sliding the thumb.
As a result, I am now having issues where the mouseUp event causes the click events for elements below it to run before the slider element. This causes issues like buttons below it clicking and the modal closing (since these are both click events).
A potential solution is to just add checks to all the potential event handlers that might be intercepted (something like if (thisModalOpened) { return false; } to all the various other elements), but that solution seems a bit messy.
Is there any better way? A way to invert the bubbling order seems like it could work, because then I could just call stopPropagation in the mouseUp for the Slider component. However, I'm not sure if that can be done, because currently the event listener is registered in the document so that it can be detected outside of the element itself.
Some code snippets:
componentDidMount() {
document.addEventListener("pointermove", this.onThumbMove, false);
document.addEventListener("pointerup", this.onThumbUp, false);
}
onThumbDown = e => {
this.setState({ dragging: true, startX: this.getX(e) });
}
onThumbMove = e => {
if (this.state.dragging) {
this.setState({ position: e.clientX });
}
}
onThumbUp = e => {
if (this.state.dragging) {
let value = this.getValue();
this.setState({ dragging: false }, () =>
this.props.onSubmit(value);
});
}
}
<div onPointerDown={this.onThumbDown} ref={this.sliderRef}>
<div>+</div>
</div>
Edit: Okay, I think I have finally figured out the problem (but I don't know the solution yet): So, modals often (mine does as well) have the tinted black backdrop that you can click on to close the modal easily. I can either have that black backdrop as a direct parent or a parent's sibling of the model's contents.
If I have it as a direct parent, then I have to call stopPropagation / return false on literally every single possible child component, so that clicking them doesn't cause the click to propagate up and click the backdrop modal and cause it to close. So I don't want it as a parent because that's a huge hassle.
On the other hand, if I have it as a parent's sibling, then I cannot call stopPropagation on the document thumbUp event, because the modal backdrop mask is not a direct parent, so it will be registered as a click and close the modal despite the stopPropagation call, because it's not a direct parent.
Basically, both approaches don't work. I don't know how to reconcile this.
As #Sarvesh mentioned in the comments, you can add a third argument to your event listener but instead of passing a boolean, you need to use the capture option to fire events from top to bottom instead of bottom to top as you wanted like this:
document.addEventListener("pointerup", this.onThumbUp, { capture: true });
You can now add a stopPropagation() at the mouseUp event or at any other event depending on what you want.
Related
I have made a Sidepanel component in React and have attached a click listener to close the panel when user clicks anywhere outside the Sidepanel component, like so:
function Sidepanel({ isOpen, children }) {
const [isPanelOpen, setPanelOpen] = useState(isOpen);
const hidePanel = () => setPanelOpen(false);
...
useEffect(() => {
document.addEventListener('click', hidePanel);
return () => {
document.removeEventListener('click', hidePanel);
};
});
return (
<aside
className={`side-panel ${isPanelOpen ? 'side-panel--open' : ''}`}
onClick={stopEventPropagation}
>
<div className="side-panel__body">{children}</div>
</aside>
);
}
function stopEventPropagation (e) {
e.stopPropagation(); // <-- DOESN'T SEEM TO WORK
};
export default Sidepanel;
But, this code doesn't work as expected since the panel starts to close on a click even inside the <aside> element itself. It seemed like e.stopPropagation() did nothing so I updated the stopEventPropagation code to:
function stopEventPropagation (e) {
e.nativeEvent && e.nativeEvent.stopPropagation();
};
...which also didn't work. But, when I did this:
function stopEventPropagation (e) {
e.nativeEvent && e.nativeEvent.stopImmediatePropagation(); // <- IT WORKED!
};
...it worked. Weird!
I read some docs about stopImmediatePropagation I found on Google and realised that although it makes the code work, it just doesn't make any sense here. Am I missing something?
Replace document.addEventListener by window.addEventListener so that event.stopPropagation() can stop event propagate to window.
I hope this will help you.
When talking about event.stopPropagation() or event.stopImmediatePropagation(), we are talking about the term Event bubbling (or Bubbling for short). So make sure you have knowledge about it, otherwise, please take a look at this article
In the above article, you may find this useful one:
If an element has multiple event handlers on a single event, then even if one of them stops the bubbling, the other ones still execute.
1. event.stopPropagation() stops the move upwards, but on the current element all other handlers will run.
2. To stop the bubbling and prevent handlers on the current element from running, there’s a method event.stopImmediatePropagation(). After it no other handlers execute.
Back to your problem, it belongs to the later case. You have two onClick handlers attached to document, so using event.stopPropagation() is not the case here, but event.stopImmediatePropagation().
---Update 1:
React (currently) uses a listener on the document for (almost) all events, so by the time your component receives the event it has already bubbled all the way up to the document.
That means, the later onClick handler which is attached to the aside element will bubble up to document.
If you added the event listener normally (by window.addEventListener()) then e.stopPropagation() should work. But if you added it via React (by onClick) then it won't work, because React uses its own event processing which itself listens on document. So by the time the onClick handler on aside runs, the event may have been bubbled up and reached document, where your hidePanel() listener fired.
e.nativeEvent.stopImmediatePropagation() might cancel all listeners before that happens and solve your problem, but blindly bypassing event handlers (possibly added by some UI library) might cause side effects. So a better approach that is not affected by all the confusing event propagation, might be handling the logic within the document click listener.
const hidePanel = (e) => {
// Only hide panel if clicked outside it
if (!e.target.classList.contains('side-panel')) {
setPanelOpen(false);
}
}
what is the best way to catch and handle a click event on "anything except specific DOM-node(s)" in a React app?
The handler of the click event is the easy part: this can be any method.
The registration of the event, and the trigger to invoke the handler, is the hard part.
There is no clean way to capture a "clicks outside ...." event.
There are however various (HTML/ CSS/ Javascript) tricks you could apply:
If it is a modal page/ popup, you could also render a full page background rectangle (e.g. slightly transparent grey), which is in front of the whole page, but behind the popup. Add a click-event-listener to this background to remove the modal + the grey background.
Another method is to use the focusout javascript event on your top-react component:
the top HTML component rendered by react should be able to get focus (needs to be an <a> or similar HTML, or - somewhat less clean - needs a tabindex=... to work)
give the element focus as soon as it is rendered (inside componentDidMount()`)
add a focusout event listener, which triggers the handler to do something with the click outside.
The focusout event is fired as soon as the component no longer has focus:
- if a child of the component gets focus (e.g. you click something inside the component) focusout is also fired: usually no problem for menu's, but undesired for popups with forms
- the focusout is also fired if the user presses TAB.
There's no React-specific way to do this; all React event handlers are tied to the component they're set on. The best way to accomplish this depends on the details of what you need to get done, but a fairly straightforward way to address this would be to add a delegated click handler to the body element, or the closest ancestor element that includes the area you want to capture clicks from. You'd attach this event handler either on the component's componentDidMount() or whenever it becomes relevant, for example, after toggling the component's state so that it shows a dropdown menu.
Attach the event handler however you normally would – element.addEventListener or jQuery's $().on or what-have-you - and evaluate the event target when it fires to determine whether you need to execute your custom logic.
Simple example, without jQuery:
componentDidMount() {
document.body.addEventListener('click', (e) => {
if (e.target !== [your dom node]) {
// do something
}
}
}
Attaching a single event handler on the body element shouldn't pose any performance issues, but best practice for most cases where you'd use something like this would be to remove the event handler when it's no longer needed.
I'm implementing an interactive tutorial for a js-heavy web application. I highlight some container and expect the user to click on some element inside it. At the same time, I want to prevent the user from doing anything else, e.g. clicking on a different link.
The main problem is that I don't want to unbind any events - when the tutorial's closed, the application must work like it did before.
I started with registering a handler on all the containter's descendant elements:
element.on("click.tutorialLock", function(e){
e.stopPropagation();
e.stopImmediatePropagation();
e.preventDefault();
}
Then I set its "priority", so that it executes before any other events:
handlers = element.data("events").click;
our = handlers.pop();
handlers.splice(0, 0, our);
Finally, when I want to unlock some element, I just disable the event on it:
elementToEnable.off(".tutorialLock")
That works, but is very heavy. I tried registering the event only on elements which have some custom event handlers defined, but it omits anchors and other basic elements. Maybe you could come up with some good idea?
I would get the active parent element and pass it into a function which would disable every event other than the events in parent
$('.className').live ('click', function (e)
{
if (!$(this).parents('#targetParent').length))
return false; // same as e.preventDefault() & e.stopPropogation()
});
Hope this is similar to what you want
I have a div on a page that shows some info about a particular category (Image, Name etc).
When I click on the edit image it puts the category into edit mode which allows me to update the name. As you can see from the image below it shows that "Soup" is currently in edit mode, the others are in normal view mode. This all works as expected with the cancel / save buttons doing everything right. (I tried adding an image but wouldn't let me, need more love)
However once in edit mode if I click anywhere else on the page (Outside of the div) the expected result would be that the soup category would go back to view mode. Upon an event firing of some sort, this should also allow me to ask if they wanted to save changes.
So what I then decided to do is create an blur event on the "soups" parent div. This works as expected if I click anywhere on the page, however if I click on the inner element of the div it also causes the parents blur event to be fired, thus causing the category to go back to view mode.
So, is there a way to prevent the parent div from firing the blur event if any one of its children receive focus?
<div tabindex="-1" onblur="alert('outer')">
<input type="text" value="Soup" />
</div>
I just wrote the code without a compiler so not sure if that even works but with that hopefully you get the idea.
I'm using Knockout.js to update the GUI on the fly but that shouldn't effect this answer I wouldn't have thought.
I faced the same issue. This what worked for me.
handleBlur(event) {
// if the blur was because of outside focus
// currentTarget is the parent element, relatedTarget is the clicked element
if (!event.currentTarget.contains(event.relatedTarget)) {
.....
}
}
Enjoy :)
I've had to tackle this problem before. I am not sure if it is the best solution, but it is what I ended up using.
Since the click event fires after the blur, there is no (cross-browser, reliable) way to tell what element is gaining focus.
Mousedown, however, fires before blur. This means that you can set some flag in the mousedown of your children elements, and interrogate that flag in the blur of your parent.
Working example: http://jsfiddle.net/L5Cts/
Note that you will also have to handle keydown (and check for tab/shift-tab) if you want to also catch blurs caused by the keyboard.
I don't think there is any guarantee mousedown will happen before the focus events in all browsers, so a better way to handle this might be to use evt.relatedTarget. For the focusin event, the eventTarget property is a reference to the element that is currently losing focus. You can check if that element is a descendant of the parent, and if its not, you know focus is entering the parent from the outside. For the focusout event, relatedTarget is a reference to the element that is currently receiving focus. Use the same logic to determine if focus is fully leaving the parent:
const parent = document.getElementById('parent');
parent.addEventListener('focusin', e => {
const enteringParent = !parent.contains(e.relatedTarget);
if (enteringParent) {
// do things in response to focus on any child of the parent or the parent itself
}
});
parent.addEventListener('focusout', e => {
const leavingParent = !parent.contains(e.relatedTarget);
if (leavingParent) {
// do things in response to fully leaving the parent element and all of its children
}
});
I have a help popup that I want to close when somewhere else is clicked. Here's what I have:
$('.help[data-info]').click(function(){
$('.info[a complicated selector]').toggle(400);
$('*:not(.info[the complicated selector]).one('click','',function(){
.info[the complicated selector].hide(400);
});
})
But one() isn't what I want before it fires for each element on the page. I only want it to fire once.
It looks like you are attaching event handlers to every element in your dom except the help popup? Hmm...
How about this:
Create a single "mask" div that overlays the entire screen, but is transparent (opacity: 0.0). Attach the click event handler only to that mask div. Then open up the info div on top of the overlay div. Clicking anywhere on the page, other than the info div, the event will be captured by the mask div before it gets to anything under it. In your event handler, hide() the info div, and remove the mask div altogether. While testing/experimenting with this, start with a partially opaque mask, not fully transparent).
Make use of a boolean variable and set it to true after first click, so it doesn't trigger the action again:
$('.help[data-info]').click(function() {
var clicked = false;
$('.info[a complicated selector]').toggle(400);
$('*:not(.info[the complicated selector]').one('click','',function() {
if (!clicked) {
.info[the complicated selector].hide(400);
clicked = true;
}
});
})
A couple of options:
You can use the blur event instead of binding a click event to everything but your popup.
Add a transparent, full-page div between the popup and the rest of the page. A click event on the transparent div could handle the hiding.
If you only want to fire once across all your elements, then you may have to manually unbind all the event handlers when any one is clicked or use a flag:
$('.help[data-info]').click(function(){
$('.info[a complicated selector]').toggle(400);
var sel = $('*:not(.info[the complicated selector]);
function hideInfo() {
.info[the complicated selector].hide(400);
sel.unbind('click', hideInfo);
}
sel.bind('click', hideInfo);
})