Developing Web Components (2015)
Part II. Building Our UI
Chapter 8. Resizing Elements
Jason Strimpel
Oftentimes in web applications you will see a drag handle, which is used to adjust the size of an element. This may be done to improve usability, or to support some functionality. For instance, the developer might make an element resizable to allow users to alter the line wrapping of text inside the element, to make it easier to read. A functional application could be for a matrix of elements that needs to be resized. In this chapter we will create a widget that can be used to make any element resizable. We will then integrate this widget into the dialog widget, so that dialog instances can optionally be made resizable.
Mouse Events and Best Practices (Recap)
The same mouse events and best practices described in Chapter 7 apply when resizing an element. If you are jumping around the book and did not read the previous chapter, you’ll find a brief synopsis of the mouse events and best practices here (although I’d advise reading the previous chapter before going further!)
Events
jQuery supports eleven different mouse events. However, making an element draggable only requires understanding three of these events:
§ $.mousemove is used to get the mouse coordinates. jQuery normalizes these in the event object as the properties e.pageX and e.pageY.
§ $.mousedown is used to bind the $.mousemove handler and get the initial mouse coordinates.
§ $.mouseup is used to unbind the $mousemove handler to prevent excessive $mousemove events from being triggered.
Best Practices
In addition to knowing the required mouse events for making an element resizable, it is equally important to know how to optimize the usage of these events. Here are a few best practices:
§ Bind $.mousemove on $.mousedown to prevent unnecessary mousemove events from being triggered.
§ Unbind $.mousemove on $.mouseup to prevent unnecessary mousemove events from being triggered.
§ Bind the $.mouseup event handler to the <body>. The API documentation recommends this because in some cases, “the mouseup event might be sent to a different HTML element than the mousemove event was.”
Resizing an Element
The same basic mouse movement and positioning best practices that apply to dragging an element also apply to resizing an element. However, additional properties can be set depending on the direction of the resizing. There are eight different possible directions when resizing an element:
§ North or top middle
§ Northeast or top right
§ East or middle right
§ Southeast or bottom right
§ South or bottom middle
§ Southwest or bottom left
§ West or middle left
§ Northwest or top left
In the case of right (east) or bottom (south) resizing the element’s width or height property is adjusted, respectively. In the case of left (west) or top (north) resizing the element’s left or top property is adjusted, in addition to the width or height property.
NOTE
The examples from this point forward will use position-based descriptors (top, left, bottom, and right) instead of direction-based descriptors (north, east, south, and west) as position-based descriptors relate directly to CSS positioning, which makes things easier for me to visualize. Also, I’m an engineer, not a navigator! The direction-based descriptors were listed because some libraries, such as jQuery UI, use them. This is probably because they correspond with the cursor properties.
Making a Resizable API
As in the previous chapters, the first step is to stub out an API:
// Eh-neeek-chock
var ApacheChief = (function (global, $) {
'use strict';
// default resize handle CSS
var handlesCss = {
width: '10px',
height: '10px',
cursor: 'se-resize'
};
// options defaults
var defaults = {
handles: ['BR'],
handlesCss: {
BR: handlesCss
}
};
// merge default CSS and developer-defined CSS -
// this is necessary because $.extend is shallow
function mergeResizeHandleCss(defaultCss, instanceCss) {
}
// create resizable instance
function ApacheChief(el, options) {
this.el = el;
this.$el = $(el);
// extend options with developer-defined options
this.options = $.extend({}, defaults, options);
// extend isn't deep, so ensure that resize handle CSS
// is merged properly
mergeResizeHandleCss(this.options, options || {});
// create resize handles
this.createResizeHandles();
// bind event handlers
this.bind();
}
// create resize handles
ApacheChief.prototype.createResizeHandles = function () {
};
// resize function
ApacheChief.prototype.resize = function () {
};
// bind event handlers
ApacheChief.prototype.bind = function () {
};
// clean up instance
ApacheChief.prototype.destroy = function () {
};
return ApacheChief;
})(window, jQuery);
Defining Drag Handles
In order for an element to be resizable, it must have drag handles. We can implement these via a function that accepts an element and positions object as arguments.
The first step is to merge any developer-defined resize handle CSS with the default CSS by filling in the mergeResizeHandleCss function defined in the API stub:
// merge default CSS and developer-defined CSS -
// this is necessary because $.extend is shallow
function mergeResizeHandleCss(defaultCss, instanceCss) {
var retVal = {};
// iterate over default CSS properties
for (var k in defaultCss) {
// set return value property equal to the instance property defined
// by the developer or the default CSS property value; it is also
// possible to go down one more layer, but this assumes wholesale
// property replacement
retVal[k] = instanceCss[k] || defaultCss[k];
}
return retVal;
}
The next step is to create the resize handles by creating new DOM elements and applying the resize handle CSS to these newly defined elements. These elements are then inserted into the DOM:
// create resize handles
ApacheChief.prototype.createResizeHandles = function () {
var handlesCss = this.options.handlesCss;
var handles = this.options.handles;
var $handles;
// loop the resize handles CSS hash, create elements,
// and append them to this.$el
// data-handle attribute is used to help determine what element
// properties should be adjusted when resizing
for (var i = 0; i < handles.length; i++) {
if (handlesCss[handles[i]]) {
this.$el
.append($('<div class="apache-chief-resize" data-handle="' +
handles[i] + '">')
.css(handlesCss[handles[i]]));
}
}
$handles = this.$el.find('.apache-chief-resize');
// ensure that container is an offset parent for positioning handles
if (this.$el !== $handles.offsetParent()) {
this.$el.css('position', 'relative');
}
$handles.css('display', 'block');
};
Binding Event Handlers
The event handlers bound in ApacheChief.prototype.bind are very similar to the ones bound in Shamen.prototype.bind from the previous chapter and follow the same mouse events best practices.
$.mousedown Handler
In addition to capturing the initial mouse coordinates like the draggable $.mousedown handler, the resizable $.mousedown handler extracts the direction from the handle element, which was set by ApacheChief.prototype.createResizeHandles:
$('.apache-chief-resize').on('mousedown.apache-chief', function (e) {
var $handle = $(this);
var direction = $handle.attr('data-handle');
// if true then the handle moves in a position
// that only affects width and height
var adjustPosition = direction !== 'BM' &&
direction !== 'MR' && direction !== 'BR';
// get the initial mouse position
var mousePos = {
x: e.pageX,
y: e.pageY
};
// this will be used by the mousemove handler
// get coordinates for resizing
function getPositionDiffs(adjustPosition, e, mousePos, direction) {
var diffs = {
xDim: direction === 'BM' ? 0 : e.pageX - mousePos.x,
yDim: direction === 'MR' ? 0 : e.pageY - mousePos.y,
xPos: 0,
yPos: 0
};
if (!adjustPosition) {
return diffs;
}
switch (direction) {
case 'TR':
diffs.yPos = diffs.yDim;
diffs.yDim = -diffs.yDim;
break;
case 'TL':
diffs.xPos = diffs.xDim;
diffs.xDim = -diffs.xDim;
diffs.yPos = diffs.yDim;
diffs.yDim = -diffs.yDim;
break;
case 'BL':
diffs.xPos = diffs.xDim;
diffs.xDim = -diffs.xDim;
break;
case 'ML':
diffs.xPos = diffs.xDim;
diffs.xDim = -diffs.xDim;
diffs.yDim = 0;
break;
case 'TM':
diffs.yPos = diffs.yDim;
diffs.yDim = -diffs.yDim;
diffs.xDim = 0;
break;
}
return diffs;
}
});
$.mousemove Handler
The $.mousemove handler is bound in the $.mousedown handler. Just like with the draggable $.mousemove handler, the previous mouse coordinates are subtracted from the current mouse coordinates. These values are then used to adjust the width and height properties and, depending on the resize direction, the top and left properties:
$(window).on('mousemove.apache-chief', function (e) {
// get the differences between the mousedown position and the
// positions from the mousemove events
var diffs = getPositionDiffs(adjustPosition, e, mousePos, direction);
// get the draggable el's current position relative to the document
var elPos;
// adjust the width and height
self.$el.css({
width: self.$el.width() + diffs.xDim,
height: self.$el.height() + diffs.yDim
});
// adjust the top and left
if (adjustPosition) {
elPos = self.$el.offset();
self.$el.css({
top: elPos.top + diffs.yPos,
left: elPos.left + diffs.xPos,
position: 'absolute'
});
}
// store the current mouse position
// to diff with the next mousemove positions
mousePos = {
x: e.pageX,
y: e.pageY
};
});
$.mouseup Handler
The $.mouseup handler is bound to the <body>, per the best practices described in the previous chapter. It unbinds the $.mousemove handler to prevent unnecessary memory consumption (it is then rebound when the next mousedown event is captured on a resize handle):
$('body').on('mouseup.apache-chief', function (e) {
$(window).off('mousemove.apache-chief');
});
Destroying a Resizable Instance
Just as with any other widget, it is good practice to clean up to help prevent memory leaks. In addition to nulling out the element references and unbinding the event handlers, we’ll remove the drag handle elements from the DOM:
// clean up instance
ApacheChief.prototype.destroy = function () {
this.$el.off('mousedown.apache-chief');
// remove the resize handles
this.$el.find('.apache-chief-resize').remove();
this.el = null;
this.$el = null;
this.options = defaults;
};
Completed Resizing Library
A few parts were missing from the original API stub, such as the full definitions for the drag handles. These missing pieces, along with all the code from the previous sections, have been added here so that the widget can be easily viewed in its entirety:
// Eh-neeek-chock
var ApacheChief = (function (global, $) {
'use strict';
// default resize handle CSS
var handlesCss = {
width: '10px',
height: '10px',
cursor: 'se-resize',
position: 'absolute',
display: 'none',
'background-color': '#000'
};
// options defaults
var defaults = {
handles: ['BR'],
handlesCss: {
TM: $.extend({}, handlesCss, {
cursor: 'n-resize', top: 0, left: '50%'
}),
TR: $.extend({}, handlesCss, {
cursor: 'ne-resize', top: 0, right: 0
}),
MR: $.extend({}, handlesCss, {
cursor: 'e-resize', bottom: '50%', right: 0
}),
BR: $.extend({}, handlesCss, { bottom: 0, right: 0 }),
BM: $.extend({}, handlesCss, {
cursor: 's-resize', bottom: 0, left: '50%'
}),
ML: $.extend({}, handlesCss, {
cursor: 'w-resize', bottom: '50%', left: 0
}),
BL: $.extend({}, handlesCss, {
cursor: 'sw-resize', bottom: 0, left: 0
}),
TL: $.extend({}, handlesCss, { cursor: 'nw-resize' }),
}
};
// merge default CSS and developer-defined CSS -
// this is necessary because $.extend is shallow
function mergeResizeHandleCss(defaultCss, instanceCss) {
var retVal = {};
// iterate over default CSS properties
for (var k in defaultCss) {
// set return value property equal to the instance property defined
// by the developer or the default CSS property value; it is also
// possible to go down one more layer, but this assumes wholesale
// property replacement
retVal[k] = instanceCss[k] || defaultCss[k];
}
return retVal;
}
// create resizable instance
function ApacheChief(el, options) {
this.el = el;
this.$el = $(el);
// extend options with developer-defined options
this.options = $.extend({}, defaults, options);
// extend isn't deep, so ensure that resize handle CSS is merged
// properly
mergeResizeHandleCss(this.options, options || {});
// create resize handles
this.createResizeHandles();
// bind event handlers
this.bind();
}
// create resize handles
ApacheChief.prototype.createResizeHandles = function () {
var handlesCss = this.options.handlesCss;
var handles = this.options.handles;
var $handles;
// loop the resize handles CSS hash, create elements,
// and append them to this.$el
// data-handle attribute is used to help determine what element
// properties should be adjusted when resizing
for (var i = 0; i < handles.length; i++) {
if (handlesCss[handles[i]]) {
this.$el
.append($('<div class="apache-chief-resize" data-handle="' +
handles[i] + '">')
.css(handlesCss[handles[i]]));
}
}
$handles = this.$el.find('.apache-chief-resize');
// ensure that container is an offset parent for positioning handles
if (this.$el !== $handles.offsetParent()) {
this.$el.css('position', 'relative');
}
$handles.css('display', 'block');
};
// bind event handlers
ApacheChief.prototype.bind = function () {
var self = this;
$('body').on('mouseup.apache-chief', function (e) {
$(window).off('mousemove.apache-chief');
});
this.$el.find('.apache-chief-resize').on('mousedown.apache-chief',
function (e) {
var $handle = $(this);
var direction = $handle.attr('data-handle');
// if true then the handle moves in a position
// that only affects width and height
var adjustPosition = direction !== 'BM' &&
direction !== 'MR' && direction !== 'BR';
// get the initial mouse position
var mousePos = {
x: e.pageX,
y: e.pageY
};
// get coordinates for resizing
function getPositionDiffs(adjustPosition, e, mousePos, direction) {
var diffs = {
xDim: direction === 'BM' ? 0 : e.pageX - mousePos.x,
yDim: direction === 'MR' ? 0 : e.pageY - mousePos.y,
xPos: 0,
yPos: 0
};
if (!adjustPosition) {
return diffs;
}
switch (direction) {
case 'TR':
diffs.yPos = diffs.yDim;
diffs.yDim = -diffs.yDim;
break;
case 'TL':
diffs.xPos = diffs.xDim;
diffs.xDim = -diffs.xDim;
diffs.yPos = diffs.yDim;
diffs.yDim = -diffs.yDim;
break;
case 'BL':
diffs.xPos = diffs.xDim;
diffs.xDim = -diffs.xDim;
break;
case 'ML':
diffs.xPos = diffs.xDim;
diffs.xDim = -diffs.xDim;
diffs.yDim = 0;
break;
case 'TM':
diffs.yPos = diffs.yDim;
diffs.yDim = -diffs.yDim;
diffs.xDim = 0;
break;
}
return diffs;
}
$(window).on('mousemove.apache-chief', function (e) {
// get the differences between the mousedown position and the
// positions from the mousemove events
var diffs = getPositionDiffs(adjustPosition, e, mousePos,
direction);
// get the draggable el's current position relative to the
// document
var elPos;
// adjust the width and height
self.$el.css({
width: self.$el.width() + diffs.xDim,
height: self.$el.height() + diffs.yDim
});
// adjust the top and left
if (adjustPosition) {
elPos = self.$el.offset();
self.$el.css({
top: elPos.top + diffs.yPos,
left: elPos.left + diffs.xPos,
position: 'absolute'
});
}
// store the current mouse position
// to diff with the next mousemove positions
mousePos = {
x: e.pageX,
y: e.pageY
};
});
});
};
// clean up instance
ApacheChief.prototype.destroy = function () {
this.$el.off('mousedown.apache-chief');
// remove the resize handles
this.$el.find('.apache-chief-resize').remove();
this.el = null;
this.$el = null;
this.options = defaults;
};
return ApacheChief;
})(window, jQuery);
Making the Dialog Widget Resizable
In some cases you may want a dialog widget instance to be resizable, so that a user can better interact with the dialog’s content. For instance, it could be that a form that would normally fit within the dialog could have fields dynamically appended to it depending on a user’s actions, causing it to overflow. Making the dialog resizable would make it much easier for the user to view and interact with all the form fields without having to scroll.
We can use the following code to make our dialog widget resizable:
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 a resizable instance
if (options.resizable) {
this.apacheChief = new ApacheChief(this.$el[0], {
handles: ['BR']
});
}
// 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();
}
// clean up resizable
if (this.apacheChief) {
this.apacheChief.destroy();
}
// call superclass
Voltron.prototype.destroy.call(this);
};
Summary
In this chapter we examined the events and patterns used to implement a drag handle on an element to make it resizable. We encapsulated this knowledge into a reusable widget that we then incorporated into the dialog widget, making it optionally resizable.
The information in this chapter enhanced the knowledge gained from the previous chapter, providing a richer understanding of handling mouse events. For more information on the mouse events from these chapters and others, please refer to the MDN documentation and the jQuery documentation on mouse events.