12/03/15 10:54:55 E:\Projects\jslib\js\component\Sticky.js
   1 
/**
   2 
* @module component/Sticky
   3 
*/
   4 
define(
   5 
    [
   6 
        'domReady',             //
   7 
        'lib/defineClass',      //
   8 
        'lib/enforceFactory',   //
   9 
        'lib/queries',          //
  10 
        'lib/throttle'          //
  11 
    ],                          //--
  12 
    function(                   //--
  13 
        domReady,               //
  14 
        defineClass,            //
  15 
        enforceFactory,         //
  16 
        q,                      //
  17 
        throttle                //
  18 
    ) {
  19 
        /**
  20 
         * CSS classes used by the Sticky component.
  21 
         *
  22 
         * @namespace
  23 
         * @readonly
  24 
         * @property {string} AT_BOTTOM    - Sticks to the scroll bottom.
  25 
         * @property {string} AT_TOP       - Sticks to the scroll top.
  26 
         * @property {string} PLACEHOLDER  - Placeholder element that maintains layout and flow.
  27 
         * @property {string} READY        - Component has been initialized.
  28 
         * @property {string} SEMI         -
  29 
         * @property {string} STUCK        - Component is in the "stuck" state.
  30 
         * @property {string} TO_CONTAINER - Sticks inside a specified container.
  31 
         * @property {string} TO_PAGE      - Sticks inside the full viewport.
  32 
         */
  33 
        var $CLASS = {
  34 
            AT_BOTTOM:    "sticky--stick-at-bottom",
  35 
            AT_TOP:       "sticky--stick-at-top",
  36 
            PLACEHOLDER:  "sticky__placeholder",
  37 
            READY:        "sticky--ready",
  38 
            SEMI:         "sticky--semi-stuck",
  39 
            STUCK:        "sticky--stuck",
  40 
            TO_CONTAINER: "sticky--stick-to-container",
  41 
            TO_PAGE:      "sticky--stick-to-page"
  42 
        };
  43 
 
  44 
 
  45 
        /**
  46 
         * Static messages.
  47 
         *
  48 
         * @namespace
  49 
         * @readonly
  50 
         * @property {string} EX_BAD_KEY             - For exception on invalid param to {@link Sticky.getInstance}.
  51 
         * @property {string} EX_BAD_MODE            - For exception on invalid mode.
  52 
         * @property {string} EX_CONTAINER_NOT_FOUND - For exception on failure to find container.
  53 
         * @property {string} EX_CONTAINER_SELECTOR  - For exception on missing container selector.
  54 
         * @property {string} EX_MIXED_CONTAINER     - For exception on mixed stick-to- modifiers.
  55 
         * @property {string} EX_MIXED_MODE          - For exception on mixed stick-at- modifiers.
  56 
         * @property {string} EX_PARENT              - For exception on missing parent node.
  57 
         */
  58 
        var $MSG = {
  59 
            EX_BAD_KEY:             "Param to getInstance must be either a number or a string",
  60 
            EX_BAD_MODE:            "Mode must be one of 'bottom' or 'top'",
  61 
            EX_CONTAINER_NOT_FOUND: "Container element not found",
  62 
            EX_CONTAINER_SELECTOR:  "Need to specify container selector in data-sticky attribute",
  63 
            EX_MIXED_CONTAINER: (
  64 
                "Cannot have both of the " + $CLASS.AT_BOTTOM +
  65 
                " and " + $CLASS.AT_TOP +
  66 
                " modifiers"
  67 
            ),
  68 
            EX_MIXED_MODE: (
  69 
                "Cannot have both of the " + $CLASS.TO_CONTAINER +
  70 
                " and " + $CLASS.TO_PAGE +
  71 
                " modifiers"
  72 
            ),
  73 
            EX_PARENT:              "Component element has no parent node"
  74 
        };
  75 
 
  76 
 
  77 
        /**
  78 
         * A StickyException is thrown when something goes wrong either in the initialization or
  79 
         * operation of a Sticky component.
  80 
         *
  81 
         * @class
  82 
         */
  83 
        var StickyException = defineClass(
  84 
            /**
  85 
             * Creates a new StickyException.
  86 
             *
  87 
             * @constructs
  88 
             * @param {string} message - A short human-friendly message describing the nature of
  89 
             * the exception.
  90 
             */
  91 
            function StickyException(message) { this.message = message; },
  92 
           
  93 
           
  94 
            /** @lends StickyException.prototype */
  95 
            {
  96 
                /**
  97 
                 * Returns a string representation of the exception.
  98 
                 *
  99 
                 * @returns {string}
 100 
                 */
 101 
                toString: function() { return "StickyException: " + this.message; }
 102 
            }
 103 
        );
 104 
 
 105 
 
 106 
        /**
 107 
         * Enforce that the given expression is not false, null, or undefined. If so, throw a
 108 
         * {@link StickyException} with the provided message, otherwise return the value of the expression.
 109 
         *
 110 
         * @function
 111 
         * @param          expression - Expression to test and, if valid, return.
 112 
         * @param {string} message    - Message to give the potential exception.
 113 
         * @returns The value of the provided expression.
 114 
         */
 115 
        var enforce = enforceFactory(StickyException);
 116 
       
 117 
 
 118 
        /**
 119 
         * An array of all instances of Sticky.
 120 
         *
 121 
         * @type {Sticky[]}
 122 
         */
 123 
        var instances = [];
 124 
 
 125 
 
 126 
        /**
 127 
         * A mapping of Sticky ID's to instances.
 128 
         *
 129 
         * @type {Object.<string, Sticky>}
 130 
         */
 131 
        var instancesById = {};
 132 
 
 133 
 
 134 
        /**
 135 
         * Determines the configuration of a Sticky component, based on the classes and possible
 136 
         * data attribute of the component element.
 137 
         *
 138 
         * @param {HTMLElement} sticky - The sticky component.
 139 
         */
 140 
        function loadConfig(sticky) {
 141 
            var element   = sticky.element;
 142 
            var classList = element.classList;
 143 
 
 144 
            // determine the mode
 145 
            var hasAtBottom = classList.contains($CLASS.AT_BOTTOM);
 146 
            var hasAtTop    = classList.contains($CLASS.AT_TOP);
 147 
            enforce(hasAtBottom != hasAtTop, $MSG.EX_MIXED_CONTAINER);
 148 
            sticky.mode = (hasAtBottom ? "bottom" : "top");
 149 
 
 150 
            // determine the container
 151 
            var hasToContainer = classList.contains($CLASS.TO_CONTAINER);
 152 
            var hasToPage      = classList.contains($CLASS.TO_PAGE);
 153 
            enforce(hasToContainer != hasToPage, $MSG.EX_MIXED_MODE);
 154 
            if (hasToContainer) {
 155 
                var containerSelector = enforce(element.getAttribute("data-sticky"), $MSG.EX_CONTAINER_SELECTOR);
 156 
                sticky.container = enforce(q.first(containerSelector), $MSG.EX_CONTAINER_NOT_FOUND);
 157 
                sticky.containerIsBody = false;
 158 
            } else {
 159 
                sticky.container = document.body;
 160 
                sticky.containerIsBody = true;
 161 
            }
 162 
        }
 163 
 
 164 
 
 165 
        /**
 166 
         * Handles custom scroll:sticky events (throttled scroll events). Calls
 167 
         * {@link Sticky#update} on all live instances.
 168 
         *
 169 
         * @param {CustomEvent} event
 170 
         */
 171 
        function onStickyUpdateEvent(event) {
 172 
            for (var i = 0, len = instances.length; i < len; ++i) {
 173 
                var sticky = instances[i];
 174 
                if (null != sticky && !sticky.ignoreEvents) {
 175 
                    sticky.update();
 176 
                }
 177 
            }
 178 
        }
 179 
        throttle(window, "scroll", "component:sticky:update", { type: "animation" });
 180 
        throttle(window, "resize", "component:sticky:update", { type: "animation" });
 181 
        window.addEventListener("component:sticky:update", onStickyUpdateEvent);
 182 
        domReady(function() { window.dispatchEvent(new CustomEvent("scroll")); });
 183 
 
 184 
 
 185 
        /**
 186 
         * @class
 187 
         * @alias module:component/Sticky
 188 
         */
 189 
        var Sticky = defineClass(
 190 
            /**
 191 
             * @constructs
 192 
             * @param {HTMLElement} element - The sticky component element.
 193 
             */
 194 
            function Sticky(element, ignoreEvents) {
 195 
                this.element      = element;
 196 
                this.ignoreEvents = ignoreEvents || false;
 197 
               
 198 
                // load config options from element attributes
 199 
                loadConfig(this);
 200 
 
 201 
                // create the placeholder and make the sticky element its child
 202 
                var parent      = enforce(element.parentNode, $MSG.EX_PARENT);
 203 
                var placeholder = this.placeholder = document.createElement("div");
 204 
                var rect        = element.getBoundingClientRect();
 205 
                var height      = rect.height || (rect.bottom - rect.top);
 206 
                var width       = rect.width || (rect.right - rect.left);
 207 
                placeholder.classList.add($CLASS.PLACEHOLDER);
 208 
                placeholder.setAttribute("style", "width: " + width + "px; height: " + height + "px;");
 209 
                parent.replaceChild(placeholder, element);
 210 
                placeholder.appendChild(element);
 211 
 
 212 
                // set ID attribute (if not already set) and register instance
 213 
                var id            = element.id;               
 214 
                var instanceIndex = this.instanceIndex = instances.length;
 215 
                if (null == id) {
 216 
                    id = element.id = "sticky-" + instanceIndex;
 217 
                }
 218 
                enforce(instancesById[id] == null, "Instance has duplicate ID: #" + id);
 219 
                instancesById[id] = this;
 220 
                instances.push(this);
 221 
               
 222 
                // mark component as initialized
 223 
                element.classList.add($CLASS.READY);
 224 
            },
 225 
           
 226 
 
 227 
            /** @lends Sticky.prototype */
 228 
            {
 229 
                /**
 230 
                 *
 231 
                 */
 232 
                clear: function(destruct, deregister) {
 233 
                    if (destruct) {
 234 
                        var element     = this.element;
 235 
                        var placeholder = this.placeholder;
 236 
                        var parent      = this.placeholder.parentNode;
 237 
 
 238 
                        this.unstick();
 239 
                        placeholder.removeChild(element);
 240 
                        parent.replaceChild(element, placeholder);
 241 
                        element.classList.remove($CLASS.READY);
 242 
                    }
 243 
                    if (deregister) {
 244 
                        instances[this.instanceIndex] = null;
 245 
                        delete instancesById[this.id];
 246 
                    }
 247 
                },
 248 
 
 249 
 
 250 
                /**
 251 
                 * Get the ID attribute of the component element.
 252 
                 *
 253 
                 * @returns {string}
 254 
                 */
 255 
                getId: function() { return this.element.id; },
 256 
 
 257 
 
 258 
                /**
 259 
                 * Get whether this component is currently in the "stuck" state.
 260 
                 *
 261 
                 * @returns {boolean}
 262 
                 */
 263 
                isStuck: function() { return this.element.classList.contains($CLASS.STUCK); },
 264 
 
 265 
 
 266 
                /**
 267 
                 * Programmatically put this component into the "stuck" state.
 268 
                 *
 269 
                 * @returns {Sticky} This component instance.
 270 
                 */
 271 
                stick: function() { return this.toggleStuck(true); },
 272 
 
 273 
 
 274 
                /**
 275 
                 *
 276 
                 */
 277 
                threshold: 1,
 278 
 
 279 
 
 280 
                /**
 281 
                 * Programmatically toggle the "stuck" state of this component. If the optional
 282 
                 * condition parameter is given, then stick on true or unstick on false. Otherwise,
 283 
                 * invert the current state.
 284 
                 *
 285 
                 * @param [condition]
 286 
                 * @returns {Sticky} This component instance.
 287 
                 */
 288 
                toggleStuck: function(condition, semi) {
 289 
                    if (undefined === condition) {
 290 
                        condition = !this.isStuck();
 291 
                    }
 292 
                    this.element.classList.toggle($CLASS.STUCK, condition);
 293 
                    this.placeholder.classList.toggle($CLASS.STUCK, condition);
 294 
                    this.element.classList.toggle($CLASS.SEMI, !!semi);
 295 
                    return this;
 296 
                },
 297 
 
 298 
 
 299 
                /**
 300 
                 * Programmatically take this component out of the "stuck" state.
 301 
                 *
 302 
                 * @returns {Sticky} This component instance.
 303 
                 */
 304 
                unstick: function(skipCheck) { return this.toggleStuck(false); },
 305 
 
 306 
 
 307 
                /**
 308 
                 *
 309 
                 *
 310 
                 * @returns {Sticky} This component instance.
 311 
                 */
 312 
                update: function() {
 313 
                    var mode     = this.mode;
 314 
                    var rect     = this.placeholder.getBoundingClientRect();
 315 
                    var position = enforce(rect[mode], $MSG.EX_BAD_MODE);
 316 
                    if ("bottom" === mode) {
 317 
                        position = window.innerHeight - position;
 318 
                    }
 319 
                    if (this.containerIsBody) {
 320 
                        this.toggleStuck(position <= this.threshold, false);
 321 
                    } else {
 322 
                        var container  = this.container;
 323 
                        var contRect   = container.getBoundingClientRect();
 324 
                        var height     = rect.height || (rect.bottom - rect.top);
 325 
                        if ("top" === mode && 0 >= position) {
 326 
                            this.toggleStuck(true, contRect.bottom <= height);
 327 
                        } else if ("bottom" === mode && 0 <= contRect.top) {
 328 
                            this.toggleStuck(true, (window.innerHeight - contRect.top) <= height);
 329 
                        } else if (this.isStuck()) {
 330 
                            this.toggleStuck(false, false);
 331 
                        }
 332 
                    }
 333 
                    return this;
 334 
                }
 335 
            },
 336 
 
 337 
 
 338 
            /** @lends Sticky */
 339 
            {
 340 
                /**
 341 
                 *
 342 
                 */
 343 
                clearInstances: function(destruct) {
 344 
                    if (destruct) {
 345 
                        for (var i = 0, len = instances.length; i < len; ++i) {
 346 
                            instaces[i].clear(destruct);
 347 
                        }
 348 
                    }
 349 
                    instances = [];
 350 
                    instancesById = {};
 351 
                },
 352 
 
 353 
 
 354 
                /**
 355 
                 *
 356 
                 */
 357 
                getInstance: function(key) {
 358 
                    switch (typeof key) {
 359 
                        case "number": return instances[key];
 360 
                        case "string": return instancesById[key];
 361 
                        default      : throw new StickyException($MSG.EX_BAD_KEY);
 362 
                    }
 363 
                },
 364 
 
 365 
 
 366 
                /**
 367 
                 *
 368 
                 */
 369 
                getInstances: function() { return instances; },
 370 
 
 371 
 
 372 
                /**
 373 
                 *
 374 
                 */
 375 
                instantiateAll: function() {
 376 
                    var exceptions = [];
 377 
                    if (0 === instances.length) {
 378 
                        var elements = q.byClass("sticky");
 379 
                        for (var i = 0, len = elements.length; i < len; ++i) {
 380 
                            var element = elements[i];
 381 
                            try {
 382 
                                new Sticky(elements[i]);
 383 
                            } catch (ex) {
 384 
                                exceptions.push(ex);
 385 
                            }
 386 
                        }
 387 
                    }
 388 
                    return {
 389 
                        exceptions: exceptions,
 390 
                        instances:  instances
 391 
                    };
 392 
                }
 393 
            }
 394 
        );
 395 
 
 396 
 
 397 
        /* Provides class Sticky. */
 398 
        return Sticky;
 399 
    }
 400 
);