Layout page

Learn how the browser lays out elements in the page and how to get an elements dimensions and location.

Slides

Slider Demo

Understanding how the DOM is positioned helps build dynamic widgets. The following

<style>
.slider {
    border: solid 1px blue;
    background-color: red;
    height: 40px;
    width: 40px;
    cursor: ew-resize;
    position: relative;
}
.container, percent-slider {
    border: solid 4px black;
    padding: 5px;
    display: block;
}
</style>

<slider-demo></slider-demo>

<script type="module">
import { Component } from "//unpkg.com/can@5/core.mjs";

function width(el) {
    var cs = window.getComputedStyle(el,null)
    return el.clientWidth - parseFloat( cs.getPropertyValue("padding-left") )
        - parseFloat( cs.getPropertyValue("padding-left") );
}

Component.extend({
    tag: "percent-slider",
    view: `
        <div class='slider'

        on:mousedown='startDrag(scope.event.clientX)'/>`,
    ViewModel: {
        start: {type: "number", default: 0},
        end: {type: "number", default: 1},
        currentValue: {
            default: function(){
                return this.value || 0;
            },
            set(value) {
                return Math.min( Math.max(this.start, value), this.end) || 0;
            }
        },
        width: {type: "number", default: 0},
        get left(){
            var left = ( (this.currentValue - this.start) / (this.end - this.start) ) * this.width;
            return Math.min( Math.max(0, left), this.width) || 0;
        },
        connectedCallback(el) {
            // derive the width
            this.width = width(el) - el.firstElementChild.offsetWidth;
            this.listenTo(window,"resize", () => {
                this.width = width(el) - el.firstElementChild.offsetWidth;
            });

            // Produce dragmove and dragup events on the view-model
            this.listenTo("startClientX", () => {
                var startLeft = this.left;
                this.listenTo(document,"mousemove", (event)=>{
                    this.dispatch("dragmove", [event.clientX - this.startClientX + startLeft]);
                });
                this.listenTo(document,"mouseup", (event)=>{
                    this.dispatch("dragup", [event.clientX - this.startClientX + startLeft]);
                    this.stopListening(document);
                })
            });
            // Update the slider position when currentValue changes
            this.listenTo("dragmove", (ev, left)=> {
                this.currentValue = this.start + (left / this.width) * (this.end - this.start);
            },"notify");

            // If the value is set, update the current value
            this.listenTo("value", (ev, newValue) => {
                this.currentValue = newValue;
            }, "notify");

            // Update the value on a dragmove
            this.listenTo("dragup", (ev, left)=> {
                this.currentValue = this.start + (left / this.width) * (this.end - this.start);
            },"notify");

            this.listenTo("left", function(ev, left){
                el.firstElementChild.style.left = ""+left+"px";
            },"notify");
            el.firstElementChild.style.left = ""+this.left+"px";

            return this.stopListening.bind(this);
        },
        startClientX: "any",
        startDrag(clientX) {
            this.startClientX = clientX;
        }
    }
});

Component.extend({
    tag: "slider-demo",
    view: `
        <percent-slider currentValue:bind='this.value' el:this:to="this.valueSlider"/>
        <div>Value <strong id='progress'>{{this.value}}</strong></div>

        <percent-slider start:from='0' end:from='40' currentValue:bind='this.padding' />
        <div>Container padding <strong id='progress'>{{this.padding}}</strong></div>

        <percent-slider start:from='10' end:from='70' currentValue:bind='this.width' />
        <div>Height / Width <strong id='progress'>{{this.width}}</strong></div>
    `,
    ViewModel: {
        value: {default: 0.5},
        padding: {default: 20},
        width: {default: 40},
        valueSlider: "any",
        connectedCallback(el) {
            const setPadding = (ev, newPadding) => {
                this.valueSlider.style.padding = ""+newPadding+"px";
                window.dispatchEvent( new Event("resize") );
            }
            this.listenTo("padding", setPadding);
            setPadding(null, this.padding);

            const setHeightWidth = (ev, newWidth) => {
                 this.valueSlider.firstElementChild.style.width = ""+newWidth+"px";
                 this.valueSlider.firstElementChild.style.height = ""+newWidth+"px";
                 window.dispatchEvent( new Event("resize") );
            };
            this.listenTo("width", setHeightWidth);
            setHeightWidth(null, this.width);
        }
    }
})

</script>

Setup

Run the following example in CodePen:

<div id="qunit"></div>
<div id="qunit-fixture"></div>
<link rel="stylesheet" href="//code.jquery.com/qunit/qunit-1.12.0.css">
<script src="//code.jquery.com/qunit/qunit-1.12.0.js"></script>
<script src="//bitovi.github.io/academy/static/scripts/jquery-test.js"></script>
<link rel="stylesheet" href="//bitovi.github.io/academy/static/scripts/jquery-test.css">
<script type="module">
(function() {
  $ = function(selector) {
    if ( !(this instanceof $) ) {
      return new $(selector);
    }
    var elements;
    if (typeof selector === "string") {
      elements = document.querySelectorAll(selector);
    } else if ($.isArrayLike(selector)) {
      elements = selector;
    }
    [].push.apply(this, elements);
  };

  $.extend = function(target, object) {
    for (var prop in object) {
      if (object.hasOwnProperty(prop)) {
        target[prop] = object[prop];
      }
    }
    return target;
  };

  // Static methods
  $.extend($, {
    isArray: function(obj) {
      return Object.prototype.toString.call(obj) === "[object Array]";
    },
    isArrayLike: function(obj) {
      return obj &&
        typeof obj === "object" &&
        (   obj.length === 0 ||
            typeof obj.length === "number" &&
            obj.length > 0 &&
            obj.length - 1 in obj );

    },
    each: function(collection, cb) {
      if ($.isArrayLike(collection)) {
        for (var i = 0; i < collection.length; i++) {
          if (cb.call(this, i, collection[i]) === false) {
            break;
          }
        }
      } else {
        for (var prop in collection) {
          if (collection.hasOwnProperty(prop)) {
            if (cb.call(this, prop, collection[prop]) === false) {
              break;
            }
          }
        }
      }
      return collection;
    },
    makeArray: function(arr) {
      if ($.isArray(arr)) {
        return arr;
      }
      var array = [];
      $.each(arr, function(i, item) {
        array[i] = item;
      });
      return array;
    },
    proxy: function(fn, context) {
      return function() {
        return fn.apply(context, arguments);
      };
    }
  });

  function makeTraverser(traverser) {
    return function() {
      var elements = [], args = arguments;
      $.each(this, function(i, element) {
        var els = traverser.apply(element, args);
        if ($.isArrayLike(els)) {
          elements.push.apply(elements, els);
        } else if (els) {
          elements.push(els);
        }
      });
      return $(elements);
    };
  }

  $.extend($.prototype, {
    html: function(newHtml) {
      if(arguments.length) {
        return $.each(this, function(i, element) {
          element.innerHTML = newHtml;
        });
      } else {
        return this[0].innerHTML;
      }
    },
    val: function(newVal) {
      if(arguments.length) {
        return $.each(this, function(i, element) {
          element.value = newVal;
        });
      } else {
        return this[0].value;
      }
    },
    text: function(newText) {
      if (arguments.length) {
        return $.each(this, function(i, element) {
          element.textContent = newText;
        });
      } else {
        return this[0].textContent;
      }
    },
    find: makeTraverser(function(selector) {
      return this.querySelectorAll(selector);
    }),
    parent: makeTraverser(function() {
      return this.parentNode;
    }),
    next: makeTraverser(function() {
      return this.nextElementSibling;
    }),
    prev: makeTraverser(function() {
      return this.previousElementSibling;
    }),
    children: makeTraverser(function() {
      return this.children;
    }),
    attr: function(attrName, value) {
      if (arguments.length == 2) {
        return $.each(this, function(i, element) {
          element.setAttribute(attrName, value);
        });
      } else {
        return this[0] && this[0].getAttribute(attrName);
      }
    },
    css: function(cssPropName, value) {
      if (arguments.length == 2) {
        return $.each(this, function(i, element) {
          element.style[cssPropName] = value;
        });
      } else {
        return this[0] &&
          window.getComputedStyle(this[0])
            .getPropertyValue(cssPropName);
      }
    },
    addClass: function(className) {
      return $.each(this, function(i, element) {
        element.classList.add(className);
      });
    },
    removeClass: function(className) {
      return $.each(this, function(i, element) {
        element.classList.remove(className);
      });
    },
    width: function() { },
    hide: function() { },
    show: function() { },
    offset: function() { }
  });

})();
</script>

Each exercise builds on the previous exercise. There is a completed solution at the end of this page.

Exercise: collection.width() -> number

The problem

collection.width gets the current computed width for the first element in the set of matched elements.

<div class="outer"><div class="inner">Hi</div></div>
<style>
.outer {width: 100px;}
.inner { border: solid 5px green; padding: 10px }
</style>
<script type="module">
import "https://unpkg.com/jquery@3/dist/jquery.js";

console.log( $(".inner").width() ) //logs 70
</script>

Click to see test code

QUnit.test('$.fn.width', function(){
    // .big-width { width: 1000px; ... }
    // #qunit-fixture div { padding: 20px; border: solid 10px black; }
    $('#qunit-fixture')
        .html('<div class="big-width"><div>Element</div></div>');
    equal(
        $('#qunit-fixture .big-width div').width(),
        1000 - 60,
        'width is correct');
});

What you need to know

  • The clientWidth property returns the width of the element including the padding.
  • You can read the computed padding-left and padding-right style properties.
  • Use parseInt to convert a string to a number. Make sure to include a radix argument!
    console.log( parseInt("011") ) //-> 11 or 9 depending on the browser!
    console.log( parseInt("011", 10) )   //-> 11
    console.log( parseInt("011px", 10) ) //-> 11
    

The solution

Click to see the solution

    width: function() {
      var paddingLeft = parseInt(this.css("padding-left"), 10),
      paddingRight = parseInt(this.css("padding-right"), 10);
      return this[0].clientWidth - paddingLeft - paddingRight;
    },

Exercise: collection.show() and collection.hide()

The problem

collection.show() and collection.hide() show or hide the elements in the collection.

Click to see test code

QUnit.test('$.fn.show and $.fn.hide', function(){
    $('#qunit-fixture').html('<div id="el">text</div>');

    equal( $('#el').hide()[0].style.display, 'none');
    equal( $('#el').show()[0].style.display, '');
});

What you need to know

  • To hide an element, set its display to "none".
  • To show an element, set its display to "".

The solution

Click to see the solution

    hide: function() {
      return this.css("display", "none");
    },
    show: function() {
      return this.css("display", "");
    },

Bonus Exercise: collection.offset()

The problem

collection.offset() returns the current coordinates of the first element relative to the document.

<div class="outer"><div class="inner">Hi</div></div>
<style>
.outer {
    position: absolute;
    left: 20px; top: 30px;
}
.inner {
    border: solid 1px green; padding: 10px;
    position: relative;
    left: 10px; top: 10px;
}
</style>
<script type="module">
import "https://unpkg.com/jquery@3/dist/jquery.js";

console.log( $(".inner").offset() )
//logs { left: 30, top: 40 }
</script>

Click to see test code

QUnit.test('$.fn.offset', function(){
    var bigWidth = document.createElement('div'),
    row1 = document.createElement('div'),
    row2 = document.createElement('div'),
    pos = document.createElement('div');

    bigWidth.className = 'big-width';
    row1.className = 'row';
    row2.className = 'row';
    pos.id = 'pos';

    bigWidth.appendChild(row1);
    bigWidth.appendChild(row2);
    row2.appendChild(pos);

    document.body.appendChild(bigWidth);

    var offset = $('#pos').offset();

    equal( offset.top, 120, 'top' );
    equal( offset.left, -990, 'left');

    //cleaning up after our test
    var node = $('.big-width')[0];
    node.parentNode.removeChild(node);
});

What you need to know

The solution

Click to see the solution

    offset: function() {
      var offset = this[0].getBoundingClientRect();
      return {
        top: offset.top + window.pageYOffset,
        left: offset.left + window.pageXOffset
      };
    }

Complete solution

<div id="qunit"></div>
<div id="qunit-fixture"></div>
<link rel="stylesheet" href="//code.jquery.com/qunit/qunit-1.12.0.css">
<script src="//code.jquery.com/qunit/qunit-1.12.0.js"></script>
<script src="//bitovi.github.io/academy/static/scripts/jquery-test.js"></script>
<link rel="stylesheet" href="//bitovi.github.io/academy/static/scripts/jquery-test.css">
<script type="module">
(function() {
  $ = function(selector) {
    if ( !(this instanceof $) ) {
      return new $(selector);
    }
    var elements;
    if (typeof selector === "string") {
      elements = document.querySelectorAll(selector);
    } else if ($.isArrayLike(selector)) {
      elements = selector;
    }
    [].push.apply(this, elements);
  };

  $.extend = function(target, object) {
    for (var prop in object) {
      if (object.hasOwnProperty(prop)) {
        target[prop] = object[prop];
      }
    }
    return target;
  };

  // Static methods
  $.extend($, {
    isArray: function(obj) {
      return Object.prototype.toString.call(obj) === "[object Array]";
    },
    isArrayLike: function(obj) {
      return obj &&
        typeof obj === "object" &&
        (   obj.length === 0 ||
            typeof obj.length === "number" &&
            obj.length > 0 &&
            obj.length - 1 in obj );

    },
    each: function(collection, cb) {
      if ($.isArrayLike(collection)) {
        for (var i = 0; i < collection.length; i++) {
          if (cb.call(this, i, collection[i]) === false) {
            break;
          }
        }
      } else {
        for (var prop in collection) {
          if (collection.hasOwnProperty(prop)) {
            if (cb.call(this, prop, collection[prop]) === false) {
              break;
            }
          }
        }
      }
      return collection;
    },
    makeArray: function(arr) {
      if ($.isArray(arr)) {
        return arr;
      }
      var array = [];
      $.each(arr, function(i, item) {
        array[i] = item;
      });
      return array;
    },
    proxy: function(fn, context) {
      return function() {
        return fn.apply(context, arguments);
      };
    }
  });

  function makeTraverser(traverser) {
    return function() {
      var elements = [], args = arguments;
      $.each(this, function(i, element) {
        var els = traverser.apply(element, args);
        if ($.isArrayLike(els)) {
          elements.push.apply(elements, els);
        } else if (els) {
          elements.push(els);
        }
      });
      return $(elements);
    };
  }

  $.extend($.prototype, {
    html: function(newHtml) {
      if(arguments.length) {
        return $.each(this, function(i, element) {
          element.innerHTML = newHtml;
        });
      } else {
        return this[0].innerHTML;
      }
    },
    val: function(newVal) {
      if(arguments.length) {
        return $.each(this, function(i, element) {
          element.value = newVal;
        });
      } else {
        return this[0].value;
      }
    },
    text: function(newText) {
      if (arguments.length) {
        return $.each(this, function(i, element) {
          element.textContent = newText;
        });
      } else {
        return this[0].textContent;
      }
    },
    find: makeTraverser(function(selector) {
      return this.querySelectorAll(selector);
    }),
    parent: makeTraverser(function() {
      return this.parentNode;
    }),
    next: makeTraverser(function() {
      return this.nextElementSibling;
    }),
    prev: makeTraverser(function() {
      return this.previousElementSibling;
    }),
    children: makeTraverser(function() {
      return this.children;
    }),
    attr: function(attrName, value) {
      if (arguments.length == 2) {
        return $.each(this, function(i, element) {
          element.setAttribute(attrName, value);
        });
      } else {
        return this[0] && this[0].getAttribute(attrName);
      }
    },
    css: function(cssPropName, value) {
      if (arguments.length == 2) {
        return $.each(this, function(i, element) {
          element.style[cssPropName] = value;
        });
      } else {
        return this[0] &&
          window.getComputedStyle(this[0])
            .getPropertyValue(cssPropName);
      }
    },
    addClass: function(className) {
      return $.each(this, function(i, element) {
        element.classList.add(className);
      });
    },
    removeClass: function(className) {
      return $.each(this, function(i, element) {
        element.classList.remove(className);
      });
    },
    width: function() {
      var paddingLeft = parseInt(this.css("padding-left"), 10),
      paddingRight = parseInt(this.css("padding-right"), 10);
      return this[0].clientWidth - paddingLeft - paddingRight;
    },
    hide: function() {
      return this.css("display", "none");
    },
    show: function() {
      return this.css("display", "");
    },
    offset: function() {
      var offset = this[0].getBoundingClientRect();
      return {
        top: offset.top + window.pageYOffset,
        left: offset.left + window.pageXOffset
      };
    }
  });

})();
</script>