14
votes

I'm creating menu that appears at the top of the screen. When user clicks one of menu items div with a lot of links appear. I wan't to be able to hide this div by clicking another menu item (no problem, already implemented it) but also by clicking anywhere else then this div and menuitems.

I heard about two solutions:

  • Show invisible div that is behind the menu and covers whole screen and add click handler to it.
  • Attach event handler to document.body.

First one is not good for me because that div will cover links that are on the page and I want them to be clickable even after menuitem is clicked and it's corresponding div appeared. So I tried second soultion. But the problem is that jquery click handler on body is fired before my component's handler. I have no idea how to make it first call my components handler and then prevent event propagation.

Here is the code and js fiddle:

/** @jsx React.DOM */
var Menu = React.createClass({
    click: function(e) {
        console.log('component handled click - should be called before jquery one and prevent jquery handler from running at all');
        e.stopPropagation();
    },
    render: function(){
    console.log("component renders");
        return (
            <div>
                <div>| MenuItem1 | MenuItem2 | MenuItem 3 |</div>
                <br />
                <div className="drop">
                    MenuItem 1 (This div appears when you click menu item)
                    <ul>
                        <li><a href="#" onClick={this.click}>Item 1 - when I click this I don't want document.body.click to fire</a></li>
                        <li><a href="#" onClick={this.click}>Item 1 - when I click this I don't want document.body.click to fire</a></li>
                    </ul>
                </div>
            </div>
        );
    }
});

React.renderComponent(<Menu />, document.getElementById("menu"));

$(document).ready(function() {
    console.log('document is ready');
    $("body").click(function() {
        console.log('jquery handled click - should not happen before component handled click');
    });
});

http://jsfiddle.net/psonsx6j/

startup console before you run it for explanation of problem

2
There are known issues with using jquery AND react events together. You'll find that often stopping propagation doesn't matter to either because the event isn't actually bound in javascript to the dom object you think it is, it's actually bound to the document and then handled from there behind the scenes. As a result stopping propagation almost never works as expected except WITHIN react itself because it handles it for all React bindings.Mike Driver

2 Answers

17
votes

So I solved it. When you add event handler to document using jQuery this event handler is fired first. So you can't catch event and stop it's propagation. But when you attach handler with document.addEventlistener this handler is being called last and you have opportunity to react :) to click in React components.

I modified Mike's code to make it work (http://jsfiddle.net/s8hmstzz/):

/** @jsx React.DOM */
var Menu = React.createClass({
    getInitialState: function() {
        return {
            show: true
        }
    },
    componentDidMount: function() {
        // when attaching with jquery this event handler is run before other handlers
        //$('body').bind('click', this.bodyClickHandler);
        document.addEventListener('click', this.bodyClickHandler);
    },
    componentWillUnmount: function() {
        //$('body').unbind('click', this.bodyClickHandler);
        document.removeEventListener('click', this.bodyClickHandler);
    },
    anchorClickHandler: function(e) {
        console.log('click on anchor - doing some stuff and then stopping propation');
        e.stopPropagation();
        e.nativeEvent.stopImmediatePropagation();
    },
    bodyClickHandler: function(e) {
        console.log('bodyclickhandler');
        this.setState({show: false})
    },
    clickHandler: function(e) {
        // react to click on menu item
    },
    preventEventBubbling: function(e) {
        console.log('click on div - stopping propagation');
        e.stopPropagation();
        e.nativeEvent.stopImmediatePropagation();
    },
    render: function() {
        return (
            <div>
                <a href="#" onClick={this.anchorClickHandler}>Link - will stop propagation</a>
                <div id="menudrop" onClick={this.preventEventBubbling} style={{display: this.state.show ? 'block' : 'none'}}>
                    <ul>
                        <li onClick={this.clickHandler}>Menu Item</li>
                    </ul>
                </div>
            </div>
        )
    }
});

React.renderComponent(<Menu />, document.getElementById('menu'));

Start console and then click div, anchor and body to see how it works. I don't know why using addEventListener instead of jQuery changes order of event handlers fired.

9
votes

See my comment above.

Basically you can't rely on being able to stop propagation of an event like onClick in React when using jquery (or normal JS events) in conjunction.

What you can do is check in your $('body').click() function that you're not clicking on the menu (so it doesn't close your menu after clicking an item in it). Also it's worth noting that you should be binding this body event INSIDE the react component so that you can cleanly add/remove it just by mounting the component and not have the component's functionality be significantly altered by something that the component itself does not know anything about.

So what you do is something like this (nb: untested code but based on a component that I have with this same issue)

var Menu = React.createClass({
    getInitialState: function() {
        return {
            show: true
        }
    },
    componentDidMount: function() {
        $('body').bind('click', this.bodyClickHandler);
    },
    componentWillUnmount: function() {
        $('body').unbind('click', this.bodyClickHandler);
    },
    bodyClickHandler: function(e) {
        if ($(e.target).parents('div').get(0) !== this.getDOMNode()) {
            this.setState({
                show: false
            })
        }
    },
    clickHandler: function(e) {
        // react to click on menu item
    },
    render: function() {
        return (
            <div style={{display: this.state.show ? 'block' : 'none'}}>
                <ul>
                    <li onClick={this.clickHandler}>Menu Item</li>
                </ul>
            </div>
        )
    }
});

So in the above code, we assume you want to show the menu dropdown as soon as the component is mounted to the dom. This may not be the case as you probably want to mount the component and then show/hide it based on hover. In which case you'd just need to move the $('body').bind() event binding from componentDidMount to a callback wherever you are showing the menu. $('body').unbind() should probably be moved to whenever the component is hidden. You then end up with a perfect situation where when the menu is shown, we bind a body event waiting for a click, and unbind it whenever it's hidden, and/or unmounted.

What occurs is that the bodyClickHandler is called before clickHandler when clicking on the menu, however bodyClickHandler only hides the menu if where you clicked's parent tree does not contain the root div of our component (this.getDOMNode() gives the html element of the root node inside render()'s return).

As a result clicking outside the component will close it, clicking inside the component will do nothing, clicking on the <li> will fire clickHandler. All working as expected.

Hope this helps.