71

Using Bootstrap 3, how can I place the dropdown menu at the cursor and open it from code?

I need to use it on a table as a context menu for its rows.

letiagoalves
  • 11,224
  • 4
  • 40
  • 66
MojoDK
  • 4,410
  • 10
  • 42
  • 80
  • I made a context-menu plugin for bootstrap. Check this out https://github.com/sydcanem/bootstrap-contextmenu. – James Apr 17 '14 at 03:38

4 Answers4

156

I just wanted to improve on letiagoalves great answer with a couple more suggestions.
Here's a walkthrough on how to add a context menu to any html element.

Let's start off with a working demo in jsFiddle

Markup:

First, let's add a menu from the bootstrap dropdown control. Add it anywhere to your HTML, preferably at the root level of the body. The .dropdown-menu class will set display:none so it's initially invisible.
It should look like this:

<ul id="contextMenu" class="dropdown-menu" role="menu">
    <li><a tabindex="-1" href="#">Action</a></li>
    <li><a tabindex="-1" href="#">Another action</a></li>
    <li><a tabindex="-1" href="#">Something else here</a></li>
    <li class="divider"></li>
    <li><a tabindex="-1" href="#">Separated link</a></li>
</ul>

Extension Settings:

To keep our design modular, we'll add our JavaScript code as a jQuery extension called contextMenu.

When we call $.contextMenu, we'll pass in a settings object with 2 properties:

  1. menuSelector takes the jQuery selector of the menu we created earlier in HTML.
  2. menuSelected will be called when the context menu action is clicked.
$("#myTable").contextMenu({
    menuSelector: "#contextMenu",
    menuSelected: function (invokedOn, selectedMenu) {
        // context menu clicked
    });
});

Plugin Template:

Based off the jQuery boilerplate plugin template, we'll use an Immediately-Invoked Function Expression so we don't muddle up the global namespace. Since we have dependencies on jQuery and need access to the window, we'll pass them in as variables so we can survive minification. It will look like this:

(function($, window){

    $.fn.contextMenu = function(settings) {  
        return this.each(function() {  
            // Code Goes Here
        }  
    };

})(jQuery, window);

Okay, no more plumbing. Here's the meat of the function:

Handle Right Click Events:

We'll handle the contextmenu mouse event on the object that called the extension. When the event fires, we'll grab the dropdown menu that we added in the beginning. We'll locate it by using the selector string passed in by the settings when we initialized the function. We'll modify the menu by doing the following:

  • We'll grab the e.target property and store it as a data attribute called invokedOn, so we can later identify the element that raised the context menu.
  • We'll toggle the menu's display to visible using .show()
  • We'll position the element using .css().
    • We need to make sure it's position is set to absolute.
    • Then we'll set the left and top location using the pageX and pageY properties of the event.
  • Finally, to prevent the right click action from opening up it's own menu, we'll return false to stop the javascript from handling anything else.

It will look like this:

$(this).on("contextmenu", function (e) {
    $(settings.menuSelector)
        .data("invokedOn", $(e.target))
        .show()
        .css({
            position: "absolute",
            left: e.pageX,
            top: e.pageY
        });

    return false;
});

Fix Menu Edge Cases:

This will open the menu to the bottom right of the cursor that opened it. However, if the cursor is to the far right of the screen, the menu should open to the left. Likewise, if the cursor is on the bottom, the menu should open to the top. It's also important to differentiate between the bottom of the window, which contains the physical frame, and the bottom of the document which represents the entire html DOM and can scroll far past the window.

To accomplish this, we'll set the location using the following functions:

We'll call them like this:

.css({
    left: getMenuPosition(e.clientX, 'width', 'scrollLeft'),
    top: getMenuPosition(e.clientY, 'height', 'scrollTop')
});

Which will call this function to return the appropriate position:

function getMenuPosition(mouse, direction, scrollDir) {
    var win = $(window)[direction](),
        scroll = $(window)[scrollDir](),
        menu = $(settings.menuSelector)[direction](),
        position = mouse + scroll;

    // opening menu would pass the side of the page
    if (mouse + menu > win && menu < mouse) 
        position -= menu;

    return position
}

Bind Click Events on the Menu Element:

After we display the context menu, we need to add an event handler to listen for click events on it. We'll remove any other bindings that might have already been added so that we won't fire the same event twice. These can occur anytime the menu was opened, but nothing was selected due to clicking off. Then we can add a new binding on the click event where we'll handle the logic in the next section.

As valepu noted, we don't want to register clicks on anything other than menu items, so we setup a delegated handler by passing a selector into the on function which will "filter the descendants of the selected elements that trigger the event".

So far, the function should look like this:

$(settings.menuSelector)
    .off('click')
    .on( 'click', "a", function (e) {
        //CODE IN NEXT SECTION GOES HERE
});

Handle Menu Clicks

Once we know a click has occurred on the menu, we'll do the following things: We'll hide the menu from the screen with .hide(). Next, we want to save the element on which the menu was originally invoked as well as the selection from the current menu. Finally, we'll fire the function option that was passed into the extension by using .call() on the property and passing in the event targets as arguments.

$menu.hide();

var $invokedOn = $menu.data("invokedOn");
var $selectedMenu = $(e.target);

settings.menuSelected.call($(this), $invokedOn, $selectedMenu);

Hide When Clicked Off:

Finally, as with most context menus, we want to close the menu when a user clicks off of it as well. To do so, we'll listen for any click events on the body and close the context menu if it's open like this:

$('body').click(function () {
    $(settings.menuSelector).hide();
});

Note: Thanks to Sadhir's comment, Firefox linux triggers the click event on document during a right click, so you have to setup the listener on body.

Example Syntax:

The extension will return with the original object that raised the context menu and the menu item that was clicked. You may have to traverse the dom using jQuery to find something meaningful from the event targets, but this should provide a good layer of base functionality.

Here's a example to return info for the item and action selected:

$("#myTable").contextMenu({
    menuSelector: "#contextMenu",
    menuSelected: function (invokedOn, selectedMenu) {
        var msg = "You selected the menu item '" + 
                  selectedMenu.text() +
                  "' on the value '" + 
                  invokedOn.text() + "'";
        alert(msg);
    }
});

Screenshot:

Context Menu Screenshot

Update Note:

This answer has been updated substantially by wrapping it in a jQuery extension method. If you'd like to see my original, you can view the post history, but I believe this final version utilizes much better coding practices.

Bonus Feature:

If you want to add some nice functionality for powerusers or yourself in developing features, you can bypass the context menu based on any key combinations being held when your right click. For example, if you wanted to allow the original browser context menu to display when holding Ctrl, you could add this as the first line of the contextMenu handler:

// return native menu if pressing control
if (e.ctrlKey) return;
Community
  • 1
  • 1
KyleMit
  • 30,350
  • 66
  • 462
  • 664
  • 1
    How can we avoid menu from creating horizontal scrllbar if clicked near end of right/left of screen. Say if we clicked too close to right end of screen and when menu opens, it opens at left detecting that it is near screen end and should be opened in other direction. By adding pull-left or pull-right intelligently i mean? – django Apr 12 '14 at 06:22
  • Nice work. Will it work for dynamically added rows ? – django Apr 12 '14 at 18:50
  • Any suggestion on how to make this work on multiple items rather than the whole table - i.e.
  • items so only those are clickable. If you apply it to each for example the callback function for the selected item in the menu is called multiple times - http://jsfiddle.net/HLqTQ/1/
  • – Simon May 16 '14 at 16:18
  • 1
    @Simon, Done! The problem was that when each element that got passed into the `contextMenu` plugin would automatically add it's own click handler on the Menu Popup right away. As soon as the element was clicked, each event handler would fire back to back. To fix this, the handler is now dynamically added at the time the right click event is handled so only a single element is ever responsible for handling the menu click. – KyleMit May 16 '14 at 19:50
  • elegant solution thanks for this. I'll be implementing this on my project – newbie Jun 01 '14 at 16:09
  • anyone noticed that this doesn't work on firefox in Linux (I assume OS X too)? In Linux, events are fired in the order mousedown, contextmenu, mouseup. The mouseup event in Linux causes the dropdown to hide. On windows events are fired more like a left click, mousedown mouseup, contextmenu. – Dave Nov 24 '14 at 18:01
  • @KyleMit.I just tested http://jsfiddle.net/KyleMit/X9tgY/ in Firefox linux (latest) and found that contextMenu immediately disappears after it is shown. However if I keep right moused pressed and keeping it pressed hover it on menu then it does not hide. I have checked on multiple system with linux as OS and firefox. works fine with linux chrome as well as windows firefox and chrome. Helllpppppp!!! – django Nov 28 '14 at 06:21
  • @KyleMit: Problem seems to be with linux fireFox. – django Nov 28 '14 at 06:22
  • 1
    @KyleMit: Thanks, I am stealing this for my project. I noticed one minor issue though. The calculations for the top and left locations are using the window's height and width - this is fine as long as there is no scrolling. If you add more rows to the table, so that there is scrolling, you will notice that for rows that appear at the bottom initially , the context menu appears above the cursor , even if there is enough room at the bottom when you have scrolled down a little. The fix is simple - just replace $(window).height() with $(document).height() – Sadhir Apr 13 '15 at 08:58
  • 1
    @Sadhir, thanks for your comment. It looks like `document` comes with it's own share of problems because the document still has plenty of room if not scrolled down so the menu will drop down past the bottom of the window. [Here's an example of that issue in fiddle](http://jsfiddle.net/X9tgY/800/). It looks like you need to take into account [`$(window).scrollTop()`](http://stackoverflow.com/a/10429969/1366033). I'll update my answer. – KyleMit Apr 13 '15 at 15:46
  • 2
    Thanks again @KyleMit , that fixes all the issues with scrolling. Btw, regarding the comments about this not working in firefox - it seems to be a bug in firefox where the click event on document is fired on right-click. so the context menu is hidden immediately on right click. using $('body').on('click'.. instead of $(document).click() seems to have solved the issue for me – Sadhir Apr 14 '15 at 08:50
  • Hi, your code is amazing! I have found 2 issues though: 1. If i click on a point inside the dropdown which is not a link (the separator for instance) i'll get all the values at once (i got an alert saying "you have selected the menu item Action Other Action...." all the links in the same alert) 2. If i click on a div which has another div inside, i'll get the data from that div even though that div is not being selected for contextmenu. – valepu Jul 02 '15 at 15:10
  • For instance:
    Inner
    Outer
    $(".outer").contextMenu(....); and then click inside the inner div, i'll get inner's text instead of outer's. I hope i have been clear
    – valepu Jul 02 '15 at 15:12
  • @valepu, note my statement: "`You may have to traverse the dom using jQuery to find something meaningful from the event targets`" I don't know what your DOM looks like, but you should be able to traverse around to find the right thing. The most information that is known about the event is where the click occurred. I'd prefer to return it raw and let the handling happen after the fact rather than before. – KyleMit Jul 02 '15 at 15:12
  • Sorry, i completely missed that – valepu Jul 02 '15 at 15:14
  • No problem! I'm sure other people have had the same curiosity. – KyleMit Jul 02 '15 at 15:14
  • Other people probably read the whole post instead of going straight for the fiddle after giving a quick read to the comments :) But at least now if someone else would just read the comments it'll find out. What about the 1st issue (clicking inside the dropdown in a point which is not a link) – valepu Jul 02 '15 at 15:29
  • @valepu, **see updated answer**. Since we're assuming that the dropdown menu is going to be a regular Bootstrap Menu, we can only listen for clicks that originated on an anchor element to prevent unwanted clicks on the divider. – KyleMit Jul 02 '15 at 15:46
  • @KyleMit How can I enable the context menu only in some columns of my table? – Guilherme Apr 15 '16 at 19:59
  • @Guilherme, the context menu can be applied to any element. Just mark the elements in some way that you can find via a jQuery selector and apply to those i.e. `$("#myTable .col-sel").contextMenu({...})`. – KyleMit Apr 15 '16 at 20:12
  • @KyleMit it is taking me to the home page after the menu goes off. Can you help, I am really new to javascript? – Deep Kalra Jun 22 '16 at 17:50
  • What would be the best way to add support for sub-menus (i.e multi-level context menu)? The way it is currently handled, adding a
  • – Amnon Aug 28 '16 at 13:50
  • This was very helpful. I did make one change though. The way you have it, if you're adding hundreds of these right-clickable elements (as I am) to the page, then the `body` click handler in your demo (for closing the contextmenu) is bound for every single element. So you end up with 1000 click handlers (for example). What I did was simply to move the registration of the `click` handler to immediately under the `$.fn.contextMenu = function (settings) {` line - now it all works smoothly again. – patrickdavey Feb 15 '17 at 20:42
  • 1
    Ufff! Very, very hepful answer. I take my hat off with you. Solutions like these, are counted in network. Thanks a lot, man. – 1antares1 Nov 23 '18 at 14:58