Constructing an Overlay - Building Our UI - Developing Web Components (2015)

Developing Web Components (2015)

Part II. Building Our UI

Chapter 6. Constructing an Overlay

Jason Strimpel

Anytime you see a tooltip, dialog, drop-down menu, etc. on the Web, an overlay is likely driving the component. This is especially likely if reusability was a design concern or if the requirements called for more than CSS alone could provide.

An overlay is a widget that provides basic functionality for positioning an element relative to the document or another element. You can think of an overlay as a new window that is part of the document. It is an element in the DOM: a blank canvas that can contain any content that you want. This lower-level widget can then be extended, mixed in, or applied to another widget to create a widget with which a user interacts (such as our dialog widget).

An overlay should have two primary functions. First, it should be able to position an element relative to the viewport. This is useful for directing a user’s focus to a critical item such as an error. Second, the overlay should be able to position an element relative to another element in the document. This functionality allows a developer to bring attention to a specific item on the page—e.g., a tooltip—or position related items, such as a drop-down for constructing UI controls.

This chapter will discuss how to construct an overlay. This overlay will be used to add more functionality to the dialog widget.

Defining an API

Before getting into the details of the overlay code, let’s stub out an API to help us determine the necessary supporting private methods and where we can gain reusage across the public API methods. Our overlay library will be called Duvet:

// pass in dependencies to closure - window, jQuery, and z-index manager

var Duvet = (function (global, $, jenga) {

'use strict';

// default options

var defaults = {

alignToEl: null,

align: 'M',

fixed: true,

offsets: {

top: 0,

right: 0,

bottom: 0,

left: 0

}

};

// creates an overlay instance

function Duvet(el, options) {

// create references to overlay element

this.el = el;

this.$el = $(el);

// extend default options with developer-defined options

this.setOptions(options);

// return instance reference

return this;

}

// positions the overlay element

Duvet.prototype.position = function (options) {};

// sets instance options

Duvet.prototype.setOptions = function (options) {};

// clears out any developer-defined references to ensure

// that no element references remain - i.e., helps prevent

// memory leaks!

Duvet.prototype.destroy = function (options) {};

return Duvet;

})(window, jQuery, jenga);

NOTE

There is a great deal of functionality that can be added to an overlay widget. The overlay in this chapter is intentionally simple and meant to be a starting point for understanding how an overlay works.

Utilities

There are some utility methods and functions that can be defined within the overlay widget’s closure. These are introduced in the following sections.

TIP

These methods could potentially be moved to a component utility library to be shared across all components that need to perform calculations for sizing and positioning elements.

Detecting Scrollbar Width

When positioning an element, it is sometimes necessary to account for the width of the containing element’s scrollbar. The space the scrollbar occupies is not automatically subtracted from an element’s offsetWidth and offsetHeight, and this space needs to be accounted for so that the overlay can be positioned precisely. You can get the width of an element’s scrollbar with the following function:

function getScrollbarWidth(parentEl) {

var innerCss = {

width: '100%',

height: '200px'

};

var outerCss = {

width: '200px',

// outer element height is smaller than inner element height;

// this will cause a scrollbar

height: '150px',

position: 'absolute',

top: 0,

left: 0,

visibility: 'hidden',

overflow: 'hidden'

};

var $inner = $('<div>test</div>').css(innerCss);

var $outer = $('<div></div>').css(outerCss).append($inner);

var innerEl = $inner[0];

var outerEl = $outer[0];

$(parentEl || 'body').append(outerEl);

// get the layout width of the inner element, including the scrollbar

var innerWidth = innerEl.offsetWidth;

$outer.css('overflow', 'scroll');

// get the inner width of the outer element, NOT including the scrollbar

var outerWidth = $outer[0].clientWidth;

// remove the elements from the DOM

$outer.remove();

// subtract the outer element width from the inner element width -

// this difference is the width of the scrollbar

return (innerWidth - outerWidth);

}

// cache value for cases where scrollbar widths are consistent

var scrollbarWidth = getScrollbarWidth();

Caution

If there is a possibility that containing elements will have differing scrollbar widths, then getScrollbarWidth needs to be executed each time an overlay is instantiated and the value saved as a property of that instance. The containing element’s calculated property for the scrollbar width will have to be applied to the element against which the measurement is being done. Duvet will assume the worst-case scenario and execute getScrollbarWidth every time it positions an element.

Accounting for the Scrollbar When Calculating a Containing Element’s Width

Once the scrollbar width for an element has been determined, it can be used to determine the actual amount of available space in the containing element. This can be done by subtracting the containing element’s scrollWidth and scrollHeight from the element’s offsetWidth andoffsetHeight, respectively, enabling you to calculate the proper position for the overlay:

// scrollWidth and scrollHeight values will be larger than the actual

// width or height of the element itself if the content exceeds the

// width or height; in this case, the scrollbar width needs to be

// accounted for when positioning the overlay element

function getScrollbarOffset(el) {

var $el = $(el);

var $body = $('body');

var scrollWidth = el.scrollWidth === undefined ? $body[0].scrollWidth :

el.scrollWidth;

var scrollHeight = el.scrollHeight === undefined ? $body[0].scrollHeight :

el.scrollHeight;

var scrollbarWidth = getScrollbarWidth();

return {

x: scrollWidth > $el.outerWidth() ? scrollbarWidth : 0,

y: scrollHeight > $el.outerHeight() ? scrollbarWidth : 0

};

}

Getting an Element’s Dimensions and Coordinates

The positioning of an overlay is done by using the overlay’s dimensions and coordinates relative to the containing or aligning element’s dimensions and coordinates. The overlay is then positioned absolutely relative to the containing element or the aligning element.

NOTE

An aligning element is an element next to which an overlay is positioned; e.g., an element over which a user hovers and a tooltip (overlay) is displayed.

We can use this function to get the dimensions of an element:

function getDimensions(el) {

// https://developer.mozilla.org/en-US

// /docs/Web/API/Element.getBoundingClientRect

// relative to the viewport

var rect;

// https://api.jquery.com/position/

// relative to the offset parent

var offset = el === window ? { top: 0, left: 0 } : $(el).position();

// if containing element is the window object

// then use $ methods for getting the width and height

if (el === window) {

var width = $(window).width();

var height = $(window).height();

rect = {

right: width,

left: 0,

top: 0,

bottom: height

};

} else {

rect = el.getBoundingClientRect();

}

return {

width: rect.right - rect.left,

height: rect.bottom - rect.top,

// top relative to the element's offset parent

top: offset.top,

// bottom relative to the element's offset parent

bottom: offset.top + (rect.bottom - rect.top),

// left relative to the element's offset parent

left: offset.left,

right: rect.right

};

}

Listening for Resize and Scrolling Events

When an overlay is in a fixed position it should not move from its position relative to its containing element when the containing element has an overflow and is scrolled, or when the document window is resized. In order to maintain the fixed position, the overflow must reposition itself as the containing element is scrolled or resized.

TIP

“Fixed position” in this case refers to the element being visually fixed, not having a position value of fixed.

Listeners are bound by passing a callback that adjusts the overlay element position to event handlers that listen for scroll and resize events:

function bindListeners($offsetParent, callback) {

// unbind event to ensure that event listener is never bound more than once

$offsetParent.off('scroll.duvet').on('scroll.duvet', function (e) {

callback();

});

$offsetParent.off('resize.duvet').on('resize.duvet', function (e) {

callback();

});

}

Updating Options

An overlay’s functionality may need to change at runtime. For instance, if it is being used to create a tooltip component, then you might not want to incur the overhead of setup and teardown every time the tooltip is triggered to be shown. This could be costly, because in each case an element would have to be created and destroyed. This could happen frequently if, for example, the user were hovering over a table containing cells that trigger tooltips to reveal additional information related to the cells’ content. In the case of tooltips, it’s much more efficient to have a function that can be used to reset the el and alignTo properties. Fortunately, there is just such a function:

Duvet.prototype.setOptions = function (options) {

this.options = options ? $.extend(this.options, options) : this.options;

};

Destroying

A good practice for widget cleanup is to leave the DOM as you found it. The destroy function will simply null out the el and alignTo properties and unbind any event handlers to prevent potential memory leaks. The responsibility for the actual destruction of these elements lies outside the scope of the overlay widget, as it only applies a limited set of functionality to an element and should not make any assumptions as to the state of the element outside of its scope.

We use the destroy function as follows:

Duvet.prototype.destroy = function (options) {

var $parent = $(el.parentNode);

// unbind event handlers

$parent.off('scroll.duvet');

$parent.off('resize.duvet');

// null out references

this.el = null;

this.$el = null;

// clear out any developer-defined options

this.options = defaults;

};

Positioning

When <instance>.position is called it will position the element using one of two private functions, defined in “JavaScript overlay”. align will be called if an element is to be aligned relative to another element. position will called if an element is to be positioned absolutely relative to its offsetParent:

Duvet.prototype.position = function (options) {

// allow modification of options before positioning

this.setOptions(options);

// call private functions (will be defined later)

if (this.options.alignToEl) {

// if alignToEl is body, then reassign to window since body height

// is equal to content height

this.options.alignToEl = this.options.alignToEl.tagName === 'BODY' ?

$(window)[0] : this.options.alignToEl;

align(this.el, this.options);

} else {

position(this.el, this.options);

}

};

Positioning an Element Relative to the Viewport or Another Element

There are a few different ways to position an element relative to the viewport or another element, depending upon the requirements. The simplest cases (e.g., where the element is centered and its position is fixed) can be handled with CSS alone, while more complicated examples, such as positioning an element that is draggable, require performing calculations based on the element’s position relative to the viewport. Some of the code used to support this will be used to position an element relative to another element as well. The first method will be shown for informational purposes, but the latter method will be used in all other examples since it is more applicable to our working example—creating a dialog that could be draggable, but that has to be positioned absolutely. That said, there is no reason that the CSS example could not be incorporated into an overlay widget as an optimization.

CSS overlay

The following example (from “Fixed positioning”) shows how to create a simple centered overlay using only CSS:

/*

Covers the viewport when applied to an

element that is a child of the <body>.

It stretches the element across the <body> by

fixing its position and setting all the position

properties to 0. This makes it impossible to

interact with any elements that are in lower rendering

layers.

*/

.modal-overlay {

position: fixed;

top: 0;

left: 0;

right: 0;

bottom: 0;

background: #000;

opacity: .5;

}

/*

Centers an element in the viewport by setting the top and left

properties to 50%. It then accounts for the height and width of the element

by taking half the height and width, and sets the top and left properties

respectively by negating these values.

*/

.modal-content {

position: fixed;

top:50%;

left:50%;

margin-top: -100px;

margin-left: -200px;

width: 400px;

height: 200px;

background: #fff;

padding: 10px;

overflow: auto;

}

<div class="modal-overlay"></div>

<div class="modal-content">I am the modal content. Fear me.</div>

JavaScript overlay

The next example shows how to create an overlay that is positioned relative to the viewport or another element using JavaScript. This is useful for overlays that start out centered, but can be dragged or moved about in the document:

function position(el, options) {

var pos = {};

var $parent = el.parentNode.tagName === 'BODY' ? $(window) : $(el.parentNode);

var $el = $(el);

// get the scrollbar offset

var scrollbarOffset = getScrollbarOffset(el.parentNode.tagName === 'BODY' ?

window : el.parentNode);

// parent el is the offset parent

if (el.parentNode !== el.offsetParent) {

el.parentNode.style.position = 'relative';

}

switch (options.align) {

case 'TL':

pos.top = 0;

pos.left = 0;

break;

case 'TR':

pos.top = 0;

pos.right = 0;

break;

case 'BL':

pos.bottom = 0;

pos.left = 0;

break;

case 'BR':

pos.bottom = 0;

pos.right = 0;

break;

case 'BC':

pos.bottom = 0;

pos.left = ((($parent.outerWidth() -

scrollbarOffset.y - $el.outerWidth()) / 2) +

$parent.scrollLeft());

break;

case 'TC':

pos.top = 0;

break;

case 'M':

pos.left = ((($parent.outerWidth() -

scrollbarOffset.y - $el.outerWidth()) / 2) +

$parent.scrollLeft());

pos.top = ((($parent.outerHeight() -

scrollbarOffset.x - $el.outerHeight()) / 2) +

$parent.scrollTop());

break;

}

// if the positions are less than 0 then the

// element being positioned is larger than

// its container

pos.left = pos.left > 0 ? pos.left : 0;

pos.top = pos.top > 0 ? pos.top : 0;

// position the element absolutely and

// set the top and left properties

$el.css($.extend({

position: 'absolute',

display: 'block'

}, pos));

// if the element should not move when the containing

// element is resized or scrolled then bind event listeners

// and call the position function

if (options.fixed && options.align === 'M' && !options.bound) {

options.bound = true;

bindListeners($parent, function () {

position(el, options);

});

}

}

Positioning an Element Relative to Another Element

It is often useful to position an element relative to another element. Common use cases include tooltips, drop-downs, and UI controls (sliders). These types of use cases require the ability to define different positions, such as top center, bottom right, middle, bottom left, etc.

The following functions can be used to position one element relative to another:

// used to get the margins for offset parents

function getMargins(el) {

var $el = $(el);

var marginTop = parseInt($el.css('margin-top'), 10);

var marginLeft = parseInt($el.css('margin-left'), 10);

return {

top: isNaN(marginTop) ? 0 : marginTop,

left: isNaN(marginLeft) ? 0 : marginLeft

};

}

// align the overlay el to another element in the DOM

function align(el, options) {

var alignToElDim = getDimensions(options.alignToEl);

var css = { display: 'block', visibility: 'visible', position: 'absolute' };

var $el = $(el);

var parentAlignToElMargins = getMargins(options.alignToEl.parentNode);

// hide element, but keep dimensions by setting the visibility to hidden

$el.css({

visibility: 'hidden',

display: 'block',

'z-index': -1000

});

// get element's dimensions

var elDim = getDimensions(el);

// ensure that alignToEl parent el is the offset parent

if (options.alignToEl.parentNode !== options.alignToEl.offsetParent) {

options.alignToEl.parentNode.style.position = 'relative';

}

// use the alignToEl and el dimensions and positions to calculate

// the el's position

switch (options.align) {

case 'TL':

css.top = (alignToElDim.top - elDim.height) -

parentAlignToElMargins.top;

css.left = alignToElDim.left - parentAlignToElMargins.left;

break;

case 'TR':

css.top = (alignToElDim.top - elDim.height) -

parentAlignToElMargins.top;

css.left = (alignToElDim.right - elDim.width) -

parentAlignToElMargins.left;

break;

case 'BL':

css.top = alignToElDim.bottom - parentAlignToElMargins.top;

css.left = alignToElDim.left - parentAlignToElMargins.left;

break;

case 'BR':

css.top = alignToElDim.bottom - parentAlignToElMargins.top;

css.left = (alignToElDim.right - elDim.width) -

parentAlignToElMargins.left;

break;

case 'BC':

css.top = alignToElDim.bottom - parentAlignToElMargins.top;

css.left = (((alignToElDim.width - elDim.width) / 2) +

alignToElDim.left) - parentAlignToElMargins.left;

break;

case 'TC':

css.top = (alignToElDim.top - elDim.height) -

parentAlignToElMargins.top;

css.left = (((alignToElDim.width - elDim.width) / 2) +

alignToElDim.left) - parentAlignToElMargins.left;

break;

case 'M':

css.top = (((alignToElDim.height - elDim.height) / 2) +

alignToElDim.top) - parentAlignToElMargins.top;

css.left = (((alignToElDim.width - elDim.width) / 2) +

alignToElDim.left) - parentAlignToElMargins.left;

break;

}

jenga.bringToFront(el, true);

$el.css(css);

}

Adding the Overlay to the Dialog Widget

In the previous chapter we added z-index management to the dialog widget. Since Jenga, the z-index manager, is now a dependency of the overlay library, the call to Jenga can be completely replaced by a call to the overlay library:

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 overlay instance

this.overlay = new Duvet(this.$el[0]);

return this;

}

Dialog.prototype.show = function () {

// this will adjust z-index, set the display property,

// and position the dialog

this.overlay.position();

};

Dialog.prototype.destroy = function () {

// clean up overlay

this.overlay.destroy();

// call superclass

Voltron.prototype.destroy.call(this);

};

Summary

In this chapter we constructed a fully functioning overlay. In order to accomplish that, we first created utility methods to measure and account for scrollbars, and to get the dimensions of an element. Next, we covered resize and scroll event listeners for overlays that are fixed in a container—either an element or the document’s <body>. Then we covered both CSS and JavaScript techniques for positioning and aligning an element. Finally, we applied this knowledge to the dialog widget.