Namespacing events and data

In lesson 6 you learned about the possibility of namespacing events. This feature is particularly useful when authoring plugins. It’s a best practice that plugins that attach handlers to events namespace them. Following this principle, if you later need to unbind them, you can perform the action without fear of interfering with other plug-ins that may be listening for the same events.

In addition to listening for events, some plugins need to store data on one or more elements of a web page. They can be useful to keep track of the state of an element or to check if the plugin has already been called on that element. This can be done using jQuery’s data() method we introduced in lesson 4. Easy to retrieve, easy to delete.

By following all the guidelines described so far, you’ll end up with a great plugin structure. But you’re still missing the most important pieces: the methods body. Let’s start with init().

the init() method of Jqia Context Menu

The init() method has the following responsibilities:

1.  Check that the options are passed to the plugin, especially that mandatory ones are provided.

2.  Merge the passed options with the default values.

3.  Test if the plugin has already been initialized on the selected element(s).

4.  Store the options on the element(s) in the jQuery collection.

5.  Listen for the mouse right button’s click event, named contextmenu, on the element(s) in the jQuery collection to show the custom menu. Optionally, listen for the mouse left button’s click event (click).

6.  Hide the custom menu when a click event is fired outside the element(s) in the jQuery collection.

To perform the first step, you must verify that idMenu, the property containing the ID of the element that acts as the custom menu, is set and the element exists on the page. This is accomplished with the following code:

if (!options.idMenu) {
   $.error('No menu specified');
} else if ($('#' + options.idMenu).length === 0) {
   $.error('The menu specified does not exist');
}

In this code, you use the length property to test if the element exists on the page.

The second step is also easy to achieve. You need to call jQuery’s extend() utility function to merge the values:

options = $.extend(true, {}, defaults, options);

As you can see in this statement, you’re reusing options to avoid adding an extra (unneeded) variable.

Steps three and four are closely related to each other. Once the plugin has been initialized on the selected elements, you use jQuery’s data() method to store the options under the same name. In this case you’ll also use them to verify whether the element has already been initialized by your plugin. You can use the stored information with other functionalities you may want to add, like changing the configuration for a given element later on.

To store the data you can write the following statement:

this.data('jqiaContextMenu', options);

When the plugin is executed for the first time on the page, you’re sure that no elements have already been initialized. What if you run Jqia Context Menu on the same set of elements? The double initialization on an element is something you want to avoid because, for example, it’ll add the same event handler twice. You need to check that each element in the set of matched elements doesn’t have any data stored using your plugin’s namespace (jqiaContextMenu). This task is performed with the following code:

if (
   this.filter(function() {
      return $(this).data('jqiaContextMenu');
   }).length !== 0
) {
   $.error('The plugin has already been initialized');
}

Although short, this snippet gives us the opportunity to reinforce an important point: the meaning of this in a plugin. Within the function attached to $.fn, the this keyword refers to the jQuery instance (the jQuery collection on which the plugin is called). You can use every jQuery method directly without the need to wrap it using the $() method (for example, $(this)). The same is true for the functions defined in the methods object because you’ve changed their context using apply(). If you didn’t use apply(), inside init() the this keyword would refer to the methods object.

Due to how you’ve structured the plugin, inside a callback executed within the plugin, the this keyword refers to a specific DOM element. In your code you use jQuery’s filter() method, which iterates over the elements in the set. At the first iteration, the this of the callback will refer to the first element in the set of matched elements, at the second iteration this will refer to the second element in the set, and so on. That’s why inside the anonymous function passed to filter() you passed this as the argument of $(): to use jQuery’s data() method.

Now that you have a better understanding of the meaning of this inside a jQuery plugin, let’s continue our discussion of the init() method.

Step five is the core of your project. To accomplish it you need to add a callback to the contextmenu event, which is typically fired when the user on a PC clicks the right mouse button. As you’ll recall, you’re also providing the opportunity to listen for a click performed using the left mouse button. Based on the options passed by the developer, you may need to listen for both contextmenu and click.

Inside the callback you need to prevent the default behavior; otherwise, the native context menu will be displayed as well. Once that’s done, you have to set the position of the custom menu according to the position of the mouse at the time the click was performed (this information is stored in the Event object passed to your callback). Finally, you have to show the menu.

The code that performs these actions is shown here:

this.on(
    'contextmenu.jqiaContextMenu' +
       (options.bindLeftClick ? ' click.jqiaContextMenu' : ''),
    function(event) {
        event.preventDefault();

        $('#' + options.idMenu)
            .css({
                top: event.pageY,
                left: event.pageX
            })
            .show();
    }
);

In this code you’re using the ternary operator to establish if you need to listen for the click event, too. You’re passing an object to jQuery’s css() method to set the position of the menu (you also need to set position: absolute on the menu, but this declaration is set in a CSS file). You aren’t setting the unit (pixels, in this case), because when not specified, jQuery assumes the value is in pixels.

The last step consists of hiding the custom menu when a click is performed, regardless of the mouse’s button, outside the elements initialized by Jqia Context Menu. This means that you should attach a handler that hides the custom menu to all the elements of the page except the ones initialized by your plugin. Attaching a listener to every element on the page has serious drawbacks in terms of performance, so you’ll take advantage of event delegation. You’ll attach only one listener to the root of the document, the html element, as follows:

$('html').on(
   'contextmenu.jqiaContextMenu click.jqiaContextMenu',
   function() {
      $('#' + options.idMenu).hide();
   }
);

With this snippet in place, it may seem that you’ve completed your init() method, but this isn’t true.

In its current state, your project has a serious bug. Once a click is performed on an initialized element, a custom menu is shown. Then, due to event bubbling, the event is propagated toward the root of the DOM tree. Once it reaches the html element, the callback you attached is executed, hiding the custom menu. The result is that the custom menu is shown for a few milliseconds (so fast you can’t even see it). To fix this issue, you have to call event.stopPropagation() inside the callback of the initialized elements.

With this last consideration, you’ve completed the init() method. Let’s now explore how to develop the destroy() method. (If you’re curious about how the complete plugin will look, you can jump to listing 12.3.)

The destroy() method of Jqia Context Menu

The destroy() method is responsible for cleaning up the resources used by your plugin, which consist of the data stored on the initialized elements and the listener attached, including those attached to the html element. Besides, you want to ensure the custom menu is hidden before destroy() is completed; otherwise it’ll be displayed until the page is reloaded.

One of the possible versions of code that implements these needs is shown here:

this
   .each(function() {
      var options = $(this).data('jqiaContextMenu');
      if (options !== undefined) {
         $('#' + options.idMenu).hide();
      }
   })
   .removeData('jqiaContextMenu')
   .add('html')
   .off('.jqiaContextMenu');

As the first thing, you loop over each element in the set of matched elements to retrieve the custom menu attached and hide it (if this element was initialized by your plugin). Then you remove the data stored on each element, but this time you don’t need to iterate over them to perform special checks, so you can let jQuery’s removeData() method do it for you.

The last action to perform is to unbind all the handlers attached to events namespaced with jqiaContextMenu. To do that, you add the html element to the current jQuery set and call the off() method, passing the string ".jqiaContextMenu" to it.

The most observant of you may have noted that you used the string "jqiaContextMenu" a lot of times in the snippets shown previously, although for different purposes. To avoid these repetitions you can store it in a private variable (accessible only inside your plugin) and then change your code accordingly.

Assuming the following statement is added beneath the defaults variable,

var namespace = 'jqiaContextMenu';

the body of destroy() can be rewritten as follows:

this
   .each(function() {
      var options = $(this).data(namespace);
      if (options !== undefined) {
         $('#' + options.idMenu).hide();
      }
   })
   .removeData(namespace)
   .add('html')
   .off('.' + namespace);

At this point your plugin is working, but there are two additional gems of wisdom to discover.


by

Tags:

Comments

Leave a Reply

Your email address will not be published. Required fields are marked *