1

Context -

I have a chat component and each individual chat message has a dropdown.

enter image description here

And the dropdown menu is opened by clicking the "More Options icon"(3 dots).

Each individual chat message is a "backbone item view"

One solution is to listen to click on "body", loop through all the menus and then close the dropdown by removing a class on it.

$("body").on("click", function() {
  $(".drop-down-menu").each(function(idx, item) {
      $(item).removeClass("open"); // open class indicated it is open via CSS
  });
});

The CSS -

.drop-down-menu {
    visibility: hidden;
    opacity: 0;
    &.open {
       opacity: 1;
       visibility: visible;
    }
}

Will there be any performance impact if there are 10,000 messages or more?

Hence, I am looking for the best solution to hide the drop down if user clicks anywhere on the screen. Thanks.

Ajey
  • 7,924
  • 12
  • 62
  • 86
  • 3
    10,000 messages aren't going to fit on the screen at once. Remove them from the DOM when they're well outside the viewport. – Jordan Running Jan 12 '17 at 04:51
  • @Jordan Sorry right now we cannot remove them from the DOM and again put it back in. Need a solution where the messages are in the DOM – Ajey Jan 12 '17 at 04:53
  • can you close the previous dropdown menu when a new one is opened? – hackerrdave Jan 12 '17 at 04:54
  • 2
    10000 elements are way too many to hope for DOM operations on them to be cheap and fast. Any decision would imply looping, hidden or explicit. Backbone is powerful, flexible and unopinionated enough to allow redesigning to dynamic creating, rendering and removing of these dropdowns. – xmike Jan 12 '17 at 14:51
  • What have you used in the end? – Emile Bergeron Apr 16 '17 at 00:39
  • 1
    on click of (...) I added a class "open" and prevented default & on body attached a click listener which removed the class "open" – Ajey Apr 17 '17 at 11:10

3 Answers3

3

You can make some trivial changes that should improve the performance of your code. The first thing is that there's no reason to loop like you are doing. jQuery objects are collections and jQuery operations usually loop over the elements of a jQuery object. So:

$("body").on("click", function() {
  $(".drop-down-menu").removeClass("open");
});

This will automatically remove the class open from all elements matched by the selector ".drop-down-menu". jQuery will still go over a loop internally, but it is faster to let jQuery iterate by itself than to have .each call your own callback and then inside the callback create a new jQuery object on which to call .removeClass.

Furthermore, you logically know that removing the open class from elements that do not have this class is pointless. So you can narrow the operation to only those elements where removing open makes sense:

$("body").on("click", function() {
  $(".drop-down-menu.open").removeClass("open");
});

These are principles that are widely applicable and that have trivial cost to implement. Anything more than this runs into the realm of optimizations that may have downsides, and should be supported by actually profiling your code. You could replace the jQuery code with code that only uses stock DOM calls but then if you need support for old browsers the cost of dealing with this and that quirk may not be worth it. And if you are using stock DOM methods, there are different approaches that may yield different performance increases, at the cost of code complexity.

Louis
  • 146,715
  • 28
  • 274
  • 320
  • Thanks for the detailed answer. But what if there are 10000 dropdown menu items and your solution $(".drop-down-menu.open").removeClass("open"); would still loop internally. Best thing would be to avoid looping. Still thinking/looking for alternatives – Ajey Jan 12 '17 at 09:15
  • 1
    What you are asking is running into the realm of "optimizations that may have downsizes, and *should be supported by actually profiling your code*". One of the other answers suggest maintaining an index to the open menu. Yes, in theory this can work. However, you've just increased the complexity of your code and now have to maintain this index. Is the additional maintenance code worth the performance benefits?? If this *were* my project I would not consider adding a manually maintained index until I knew for a fact that well written jQuery code (or a stock DOM equivalent) won't be sufficient. – Louis Jan 12 '17 at 09:28
0

Louis is offering a quick fix with efficient jQuery selectors.

For the long run, I would suggest making each message a MessageView component which has a ContextMenuView component. That way, each view only has one menu to take care of.

Catching clicks outside of an element

Then, use the following ClickOutside view as the context menu base view. It looks complicated, but it only wraps the blur and focus DOM events to know if you clicked outside the view.

It offers a simple onClickOutside callback for the view itself and a click:outside event which is triggered on the element.

The menu view now only has to implement the following:

var ContextMenuView = ClickOutside.extend({
    toggle: function(val) {
        this.$el.toggleClass("open", val);
        this.focus(); // little requirement
    },
    // here's where the magic happens!
    onClickOutside: function() {
        this.$el.removeClass("open");
    }
});

See the demo

enter image description here

var app = {};

(function() {

  var $body = Backbone.$(document.body);
  /**
   * Backbone view mixin that enables the view to catch simulated
   * "click:outside" events (or simple callback) by tracking the 
   * mouse and focusing the element.
   *
   * Additional information: Since the blur event is triggered on a mouse
   * button pressed and the click is triggered on mouse button released, the
   * blur callback gets called first which then listen for click event on the
   * body to trigger the simulated outside click.
   */
  var ClickOutside = app.ClickOutside = Backbone.View.extend({
    events: {
      "mouseleave": "_onMouseLeave",
      "mouseenter": "_onMouseEnter",
      "blur": "_onBlur",
    },
    /**
     * Overwrite the default constructor to extends events.
     */
    constructor: function() {

      this.mouseInside = false;

      var proto = ClickOutside.prototype;
      this.events = _.extend({}, proto.events, this.events);

      ClickOutside.__super__.constructor.apply(this, arguments);
      this.clickOnceEventName = 'click.once' + this.cid;
    },

    /**
     * Hijack this private method to ensure the element has
     * the tabindex attribute and is ready to be used.
     */
    _setElement: function(el) {
      ClickOutside.__super__._setElement.apply(this, arguments);

      var focusEl = this.focusEl;

      if (focusEl && !this.$focusElem) {
        this.$focusElem = focusEl;
        if (!(focusEl instanceof Backbone.$)) {
          this.$focusElem = Backbone.$(focusEl);
        }
      } else {
        this.$focusElem = this.$el;
      }
      this.$focusElem.attr('tabindex', -1);
    },

    focus: function() {
      this.$focusElem.focus();
    },

    unfocus: function() {
      this.$focusElem.blur();
      $body.off(this.clickOnceEventName);
    },

    isMouseInside: function() {
      return this.mouseInside;
    },

    ////////////////////////////
    // private Event handlers //
    ////////////////////////////
    onClickOutside: _.noop,
    _onClickOutside: function(e) {
      this.onClickOutside(e);
      this.$focusElem.trigger("click:outside", e);
    },

    _onBlur: function(e) {
      var $focusElem = this.$focusElem;
      if (!this.isMouseInside() && $focusElem.is(':visible')) {
        $body.one(this.clickOnceEventName, this._onClickOutside.bind(this));
      } else {
        $focusElem.focus(); // refocus on inside click
      }
    },

    _onMouseEnter: function(e) {
      this.mouseInside = true;
    },
    _onMouseLeave: function(e) {
      this.mouseInside = false;
    },

  });

  var DropdownView = app.Dropdown = ClickOutside.extend({
    toggle: function(val) {
      this.$el.toggle(val);
      this.focus();
    },
    onClickOutside: function() {
      this.$el.hide();
    }
  });


})();


var DemoView = Backbone.View.extend({
  className: "demo-view",
  template: $("#demo-template").html(),
  events: {
    "click .toggle": "onToggleClick",
  },
  initialize: function() {
    this.dropdown = new app.Dropdown();
  },
  render: function() {
    this.$el.html(this.template);
    this.dropdown.setElement(this.$(".dropdown"));
    return this;
  },
  onToggleClick: function() {
    this.dropdown.toggle(true);
  },

});

$("#app")
  .append(new DemoView().render().el)
  .append(new DemoView().render().el);
html,
body {
  height: 100%;
  width: 100%;
}

.demo-view {
  position: relative;
  margin-bottom: 10px;
}

.dropdown {
  z-index: 2;
  position: absolute;
  top: 100%;
  background-color: gray;
  padding: 10px;
  outline: none;
}
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.1.1/jquery.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/underscore.js/1.8.3/underscore-min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/backbone.js/1.3.3/backbone-min.js"></script>

<div id="app"></div>

<script type="text/template" id="demo-template">
  <button type="button" class="toggle">Toggle</button>
  <div class="dropdown" style="display:none;">
    This is a drop down menu.
  </div>
</script>

Alternatives to detect a click outside an element

If you don't want, or can't use blur and focus events, take a look at How do I detect a click outside an element? for alternative techniques.

Lazy initialization of views

Another way to make an SPA more efficient is to delay the creation of new view to the very moment you need it. Instead a creating 10k context menu views, wait for the first time the user clicks on the toggle button and create a new view if it doesn't exist yet.

toggleMenu: function(){
    var menuView = this.menuView;
    if (!menuView) {
        menuView = this.menuView = new ContextMenuView();
        this.$('.dropdown').html(menuView.render().el);
    }
    menuView.toggle();
}

Pagination

Passed a certain threshold of HTML inside a webpage, the browser starts to lag and it impedes the user experience. Instead of dumping 10k views into a div, only show like a 100, or the minimum to cover the visible space.

Then, when scrolling to an edge (top or bottom), append or prepend new views on demand. Like the message list in any web-based chat app, like messenger.com.

Emile Bergeron
  • 17,074
  • 5
  • 83
  • 129
-1

Since you will only have one drop down menu open at a time, maybe you can keep a pointer to the element or index of the element it is attached to, instead of looping through all the menus.

flintlock
  • 97
  • 7
  • You could use a global variable (name it "parentOfOpenMenu" or something) to keep the dom element of the parent of the currently expanded menu. If all menus are closed, set it to null. You can then update the `parentOfOpenMenu` in all the functions that involve openning/closing of menus. I'm not a web dev so that's as much as I can think of off the top of my head. Hope that helps. – flintlock Jan 12 '17 at 05:18
  • Nice that makes sense. At least something to start with. – Ajey Jan 12 '17 at 05:28