Developing Web Components (2015)
Part II. Building Our UI
Chapter 7. Making Elements Draggable
Frequently in web applications you will see elements that are draggable—slider handles, dialogs, column resizing handles, etc. If you have ever wondered exactly how this is accomplished, or needed to implement this behavior, then this is the chapter for you! The first step in the process is learning about mouse events.
NOTE
This and subsequent chapters will utilize jQuery events, which, for the most part, are wrappers to native JavaScript events that are normalized for cross-browser consistency.
Mouse Events
jQuery supports eleven different mouse events. However, making an element draggable only requires understanding three of these events: $.mousemove, $.mousedown, and $.mouseup.
$.mousemove
Tracking mouse movements is fundamental to making an element draggable because the mouse position will be used to coordinate the movement of the element being dragged. The native JavaScript mousemove event provides the mouse position data, such as the values of clientX andclientY, which are properties of the native event object. However, support for these properties varies between browsers. Fortunately, jQuery normalizes these properties as part of the jQuery event object. We bind the $.mousemove event listener as follows:
$('body').on('mousemove', function (e) {
// log the normalized mouse coordinates
console.log('pageX: ' + e.pageX + ' ' + 'pageY: ' + e.pageY);
});
TIP
mousemove is useful for tracking user mouse movements, which can be used to gather analytics. These analytics can be used to improve the functionality of a UI by helping users to get to information and complete frequent actions faster.
$.mousedown
Determining when a user presses the mouse button (mousedown event) is required for dragging elements because this is the event that marks the moment when the mousemove event translates into moving the element marked to be dragged. We bind the $.mousedown event listener as follows:
$('body').on('mousedown', function (e) {
console.log('Yo. You just pressed the mouse down.');
});
$.mouseup
Knowing when to stop moving an element is the final step during a drag sequence. As soon as the mouseup event is fired, the last mouse position is used to determine the final location of the element being dragged. We bind this event listener as follows:
$('body').on('mouseup', function (e) {
console.log('Dude. You just let the mouse go up.');
});
Mouse Events Best Practices
In addition to knowing the required mouse events for making an element draggable, it is equally important to know how to optimize the usage of these events. I do not advocate premature optimization, but these are not strictly optimizations: they are common patterns and best practices that have been vetted by UI widget libraries such as jQuery UI. jQuery advocates their usage as well.
1. Bind $.mousemove on $.mousedown
mousemove is triggered every time the mouse is moved, even if it is only by a pixel. This means hundreds of events can easily be triggered in a matter of seconds if a user is interacting with a UI that requires frequent mouse movements. To ensure that mousemove events are only processed when necessary, it is best to bind the $.mousemove handler on $.mousedown:
$('.some-element').on('mousedown', function (e) {
$('.some-element').on('mousemove', function (e) {
// this is where the dragging magic will happen
});
});
2. Unbind $.mousemove on $.mouseup
Just as you should only bind the $.mousemove handler on $.mousedown, you should unbind the $.mousemove handler on $.mouseup—for every action. This will prevent any unnecessary mousemove events from being processed after the dragging of the element has been completed:
$('body').on('mouseup', function (e) {
// stop processing mousemove events
$('.some-element').off('mousemove');
});
3. Bind $.mouseup to the <body>
You may have noticed in the previous code block that the mouseup event was bound to the <body>. This is because, according to the API documentation, in some cases “the mouseup event might be sent to a different HTML element than the mousemove event was.” For this reason alone, it is best to always bind the $.mouseup handler to the <body> tag.
4. Namespace All Event Bindings
It is always a good practice to namespace all event bindings. This ensures that events that are bound by a widget do not conflict with other event handlers, which makes cleanup easier as you don’t have to worry about destroying event handlers bound by processes outside of the widget. Since the $.mouseup event handler is bound to the <body>, the namespace is also used to filter out other mouse events that have bubbled up to the <body>:
// namespace all events
$('body').on('mouseup.draggable', function (e) {
});
Defining an API
In order to make our code for making an element draggable more reusable, it is necessary to create an object with a convenient API that can be applied to any element:
// I can move any mountain
var Shamen = (function (global, $) {
'use strict';
// instance default options
var defaults = {
dragHandle: null
};
// create draggable instance
function Shamen(el, options) {
this.options = $.extend({}, defaults, options);
var css = { cursor: (this.options.cursor || 'move') };
// check if element is a child of a document fragment (i.e.,
// a shadow DOM); this will be important later
this.isChildOfDocFragment = isChildOfDocFragment(el);
this.el = el;
this.$el = $(el);
this.$dragHandle = this.options.dragHandle ?
this.$el.find(this.options.dragHandle) : this.$el;
this.bind();
this.originalDragHandleCursor = this.$dragHandle.css('cursor');
// apply cursor css
this.$dragHandle.css(css);
}
// bind mousedown event handler
Shamen.prototype.bind = function () {
};
// clean up to prevent memory leaks
Shamen.prototype.destroy = function () {
};
return Shamen;
})(window, jQuery);
Creating a Drag Handle
In some cases you only want a specific child element of the draggable element to be the drag handle, as opposed to the entire element. For instance, in the case of a dialog it is often the title or header bar that is the drag handle.
NOTE
A drag handle is an element that responds to a drag or resize event when a mousedown event occurs, allowing a user to move or resize an ancestor node of the drag handle. In this book the ancestor element will be the widget’s el or $el property.
We can implement a drag handle as follows:
.drag-handle {
cursor: move;
}
<div class="draggable">
<div class="drag-handle"></div>
</div>
$('.draggable').on('mousedown.draggable', '.drag-handle', function (e) {
});
Making Things Move
Now that the nuts and bolts of making an element draggable have been defined, it is time to use these pieces to make something move!
$.mousedown Handler
The first step is to write the $.mousedown handler, which gets the mouse’s current position:
// bind mousedown event handler
Shamen.prototype.bind = function () {
// filter on drag handle if developer defined one
var selector = this.options.dragHandle || null;
var self = this;
// account for margins if element is position absolutely;
// code reused from Duvet.js
var parentMargins = this.$el.attr('position') === 'absolute' ?
getMargins(this.$el.parent()[0]) : { top: 0, left: 0};;
this.$el.on('mousedown.shamen', selector, function (e) {
// get the initial mouse position
var mousePos = {
x: e.pageX,
y: e.pageY
};
});
};
$.mousemove Handler
This is where the moving of an element occurs! On mousemove the $.mousemove handler uses the element dimension and position values set in the $.mousedown handler to move the draggable element pixel for pixel as mousemove events are triggered:
// bind mousedown event handler
Shamen.prototype.bind = function () {
// filter on drag handle if developer defined one
var selector = this.options.dragHandle || null;
var self = this;
// account for margins if element is position absolutely;
// code reused from Duvet.js
var parentMargins = this.$el.attr('position') === 'absolute' ?
getMargins(this.$el.parent()[0]) : { top: 0, left: 0};
this.$el.on('mousedown.shamen', selector, function (e) {
// get the initial mouse position
var mousePos = {
x: e.pageX,
y: e.pageY
};
// bind mousemove event handler
$(window).on('mousemove.shamen', function (e) {
// get the differences between the mousedown position and the
// positions from the mousemove events
var xDiff = e.pageX - mousePos.x;
var yDiff = e.pageY - mousePos.y;
// get the draggable el's current position relative to the document
var elPos = {
left: el.offsetLeft,
top: el.offsetTop
};
// apply the mouse position differences to the el's position
self.$el.css({
top: elPos.top + yDiff,
left: elPos.left + xDiff
});
// store the current mouse position
// to diff with the next mousemove positions
mousePos = {
x: e.pageX,
y: e.pageY
};
});
});
};
$.mouseup Handler
When the mouseup event is fired the $.mousemove handler is unbound, and the draggable element is in its final resting place (at least, until dragging is initiated again by another mousedown event). The $.mouseup event handler looks like this:
// bind mousedown event handler
Shamen.prototype.bind = function () {
// unbind mousemove handler on mouseup
$('body').on('mouseup.shamen', function (e) {
$(window).off('mousemove.shamen');
});
};
Now that have seen the $.mouseup handler binding, we need to fill in the details:
// bind mousedown event handler
Shamen.prototype.bind = function () {
// filter on drag handle if developer defined one
var selector = this.options.dragHandle || null;
var self = this;
// account for margins if element is position absolutely;
// code reused from Duvet.js
var parentMargins = this.$el.attr('position') === 'absolute' ?
getMargins(this.$el.parent()[0]) : { top: 0, left: 0};
// unbind mousemove handler on mouseup
$('body').on('mouseup.shamen', function (e) {
$(window).off('mousemove.shamen');
});
this.$el.on('mousedown.shamen', selector, function (e) {
// get the initial mouse position
var mousePos = {
x: e.pageX,
y: e.pageY
};
// bind mousemove event handler
$(window).on('mousemove.shamen', function (e) {
// get the differences between the mousedown position and the
// positions from the mousemove events
var xDiff = e.pageX - mousePos.x;
var yDiff = e.pageY - mousePos.y;
// get the draggable el's current position relative to the document
var elPos = self.$el.offset();
// apply the mouse position differences to the el's position
self.$el.css({
top: elPos.top + yDiff,
left: elPos.left + xDiff,
position: 'absolute'
});
// store the current mouse position
// to diff with the next mousemove positions
mousePos = {
x: e.pageX,
y: e.pageY
};
});
});
};
Destroying a Draggable Instance
Like with the overlay widget and any other widget, it is always good practice to clean up. This helps to prevent memory leaks and reduces the possibility of a destroyed widget having an impact on the application (e.g., through a lingering node property or event handler):
// clean up to prevent memory leaks
Shamen.prototype.destroy = function () {
// unbind mousedown
this.$el.off('mousedown.shamen');
// revert cursor for drag handle
this.$dragHandle.css({ cursor: this.originalDragHandleCursor });
// null out jQuery object, element references
this.el = null;
this.$el = null;
this.$dragHandle = null;
// revert options to defaults
this.options = defaults;
};
Making the Dialog Widget Draggable
In some cases you may want a dialog to be draggable (for example, so that a user can see content that is obfuscated by the dialog). However, this may not always be the case, so making the dialog draggable will be optional.
We can use the following code to make our dialog widget draggable:
function Dialog (options) {
// optionally clone dialog $el
options.$el = options.clone ? $(options.$el).clone() :
$(options.$el);
// append to body
if (options.appendToEl) {
$(options.appendToEl).append(options.$el);
}
Voltron.call(this, options);
// create a draggable instance
if (options.draggable) {
this.shamen = new Shamen(this.$el[0], {
// dialog header is the drag handle
dragHandle: '#title'
});
}
// create overlay instance
this.overlay = new Duvet(this.$el[0], {
fixed: options.draggable ? false : true
});
return this;
}
Dialog.prototype.destroy = function () {
// clean up overlay
this.overlay.destroy();
// clean up draggable
if (this.shamen) {
this.shamen.destroy();
}
// call superclass
Voltron.prototype.destroy.call(this);
};
Summary
In this chapter we demystified how elements are moved along the x- and y-axes by a user via a mouse. This demystification involved understanding the relevant mouse events: mousedown, mousemove, and mouseup. We covered best practices for the different events, then used this knowledge to create a small library that could be used to make any element draggable. We then integrated this library into the dialog widget, so you know how to create a draggable dialog instance when a use case requires that the dialog be movable.
This was just one possible use case and implementation, but it highlighted common patterns that are useful in general when handling different mouse events. This knowledge is useful for other use cases too, such as creating drop-down menus, and we will leverage it in the next chapter to make elements resizable.