Resizing Elements - Building Our UI - Developing Web Components (2015)

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.