1
votes

I've been working on a context menu module in ReactJS, and it's got me thinking about how to deal with non-hierarchical components.

The problem I'm running into is that many different items in an application may want to use a context menu. Normally in React, you pass a callback from a parent object to the children that need to communicate with the parent. For example, my first thought was to have an openContextMenu(mousePosition, optionsObject) function passed from my ContextMenu class to all the elements that want to display a context menu on right-click.

But it doesn't make sense for all such elements (or maybe even any) to be children of a context menu! The context menu is not hierarchical with respect to other components of the application. In Angular, I would probably write a ContextMenu service that components required if they wanted access to such a menu.

Is this a situation in which a global event handler should be used? Am I thinking about this all wrong? What's the React way to handle this kind of horizontal interaction between components?

1
Can you provide your code so that we can have a better understanding of where you are stuck? - Edward Lee

1 Answers

6
votes

Context menus are special. There should never be more than one context menu open at any time. They're also special because it can be opened from anywhere. Try the demo to get an idea of how this looks when put together.

To solve our global problem, we'll create a mixin which wraps a private event emitter.

var menuEvents = new events.EventEmitter();
var ContextMenuMixin = {
  // this.openContextMenu(['foo', 'bar'], (err, choice) => void)
  openContextMenu: function(options, callback){
    menuEvents.emit('open', {
      options: options, 
      callback: callback
    });
  },
  closeContextMenu: function(){
    menuEvents.emit('close');
  }
};

Now for the component, we need to do a few things. Here's the initialization part. Just binding to some events, and lightweight mouse tracking.

var mouse = {x: 0, y: 0};
var updateMouse = function(e){
  mouse.x = e.pageX;
  mouse.y = e.pageY;
};

var ContextMenu = React.createClass({
  getInitialState: function(){
    return {options: null, callback: null};
  },
  componentDidMount: function(){
    menuEvents.addListener('open', this.handleOpenEvent);
    menuEvents.addListener('close', this.closeMenu);
    addEventListener('mousemove', updateMouse);
  },

These event handlers are very simple. handleOpenEvent just stores the event payload and mouse position in state, which effectively locks in the mouse position until it's opened next. And the counterpart simply resets the state, and calls the callback with an error.

 handleOpenEvent: function(payload){
    this.setState(_.merge({}, payload, mouse));
  },

  closeMenu: function(){
    if (this.state.callback) {
      this.replaceState(this.getInitialState());
      this.state.callback(new Error('no selection made'));
    }
  },

And finally, we render a list of options passed to the event, and we create click handlers for each.

render: function(){
    if (!this.state.options) {
      return <div />
    }

    var style = {
      left: this.state.x,
      top: this.state.y,
      position: 'fixed'
    };

    return (
      <div className="react-contextmenu" style={style}>
        <ul className="react-contextmenu-options">
          {this.state.options.map(function(x, i){
            return <li key={i} 
                       onClick={this.makeClickHandler(x)}>
                    {x}
                   </li>
          }, this)}
        </ul>
      </div>
    );
  },

  makeClickHandler: function(option){
    return function(){
      if (this.state.callback) {
        this.state.callback(null, option);
        this.replaceState(this.getInitialState());
      }
    }.bind(this);
  }