Tuesday, November 1, 2011

jQuery: scrollIntoView revised

I was looking for a decent scrollIntoView- functionality and found one at Robert Koritnik’s blog Erratic software development. It had most of the features I was looking for; simple usage, capable of handling both scrolling at the document level and inside an arbitrary element, and it won’t scroll anything unless necessary. The last is obvious but apparently not every implementation is this smart, most notably not the original DOM method scrollIntoView(). It had one limitation though; it accepts only one (1) element; i.e. only the first element in the supplied jQuery object will be scrolled into view. That’s sufficient in many cases, but what if you want to keep multiple elements in the user’s view, without having any common wrapper to address?

However, I found out the plugin could easily be modified to handle multiple elements; see code below. There is still a limitation though, all elements must reside in the same scrollable element, and only the closest scrollable element will be affected. But I think that's a fair limitation, as I don’t care much of interfaces with nested scrolling panels!

/*!
 * jQuery scrollIntoView() plugin and :scrollable selector filter
 *
 * Original code (v1.8) by Robert Koritnik, see:
 *   http://erraticdev.blogspot.com/2011/02/jquery-scroll-into-view-plugin-with.html
 *
 * This is a simple adaptation to handle multiple elements. Documentation at:
 *    http://regularwebmaster.blogspot.com/2011/11/scrollintoview-revised.html
 *
 * Version 1.8b (1 Nov 2011)
 * Requires jQuery 1.4 or newer
 *
 * Copyright (c) 2011 Robert Koritnik
 * modifications by Martin Boström
 *
 * Licensed under the terms of the MIT license
 * http://www.opensource.org/licenses/mit-license.php
 *
 * 
 */
 
(function ($) {
    var converter = {
        vertical: { x: false, y: true },
        horizontal: { x: true, y: false },
        both: { x: true, y: true },
        x: { x: true, y: false },
        y: { x: false, y: true }
    };
 
    var settings = {
        duration: "fast",
        direction: "both"
    };
 
    var rootrx = /^(?:html)$/i;
 
    // gets border dimensions
    var borders = function (domElement, styles) {
        styles = styles || (document.defaultView && document.defaultView.getComputedStyle ? document.defaultView.getComputedStyle(domElement, null) : domElement.currentStyle);
        var px = document.defaultView && document.defaultView.getComputedStyle ? true : false;
        var b = {
            top: (parseFloat(px ? styles.borderTopWidth : $.css(domElement, "borderTopWidth")) || 0),
            left: (parseFloat(px ? styles.borderLeftWidth : $.css(domElement, "borderLeftWidth")) || 0),
            bottom: (parseFloat(px ? styles.borderBottomWidth : $.css(domElement, "borderBottomWidth")) || 0),
            right: (parseFloat(px ? styles.borderRightWidth : $.css(domElement, "borderRightWidth")) || 0)
        };
        return {
            top: b.top,
            left: b.left,
            bottom: b.bottom,
            right: b.right,
            vertical: b.top + b.bottom,
            horizontal: b.left + b.right
        };
    };
    

    // Calculates the union of two dimension objects, stores result in lhs:
    var union = function (lhs, rhs) {
      for(var key in lhs) {
        for(var property in lhs[key]) {
          if(property ==  'bottom' || property == 'right')
            lhs[key][property] = Math.max(lhs[key][property], rhs[key][property]);
          else
            lhs[key][property] = Math.min(lhs[key][property], rhs[key][property]);
        }
      }
    }
 
    // Gets the extreme dimensions of elements in $scope:
    var dimensions = function ($scope) {
        var win = $(window);
        var dim = {
            border:    { top: Number.MAX_VALUE, left: Number.MAX_VALUE, bottom: Number.MIN_VALUE, right: Number.MIN_VALUE },
            scroll:    { top: Number.MAX_VALUE, left: Number.MAX_VALUE },
            scrollbar: { right: Number.MIN_VALUE, bottom: Number.MIN_VALUE },
            rect:      { top: Number.MAX_VALUE, left: Number.MAX_VALUE, bottom: Number.MIN_VALUE, right: Number.MIN_VALUE }
        };
        $scope.each(function() {
            var $element = $(this);
            var isRoot = rootrx.test($element[0].nodeName);
            
            union(dim, (function() {
                return {
                    border: isRoot ? { top: 0, left: 0, bottom: 0, right: 0} : borders($element[0]),
                    scroll: {
                        top: (isRoot ? win : $element).scrollTop(),
                        left: (isRoot ? win : $element).scrollLeft()
                    },
                    scrollbar: {
                        right: isRoot ? 0 : $element.innerWidth() - $element[0].clientWidth,
                        bottom: isRoot ? 0 : $element.innerHeight() - $element[0].clientHeight
                    },
                    rect: (function () {
                        var r = $element[0].getBoundingClientRect();
                        return {
                            top: isRoot ? 0 : r.top,
                            left: isRoot ? 0 : r.left,
                            bottom: isRoot ? $element[0].clientHeight : r.bottom,
                            right: isRoot ? $element[0].clientWidth : r.right
                        };
                    })()
                };
        })())});
        return dim;
    };
 
    $.fn.extend({
        scrollIntoView: function (options) {
            /// Scrolls the first element in the set into view by scrolling its closest scrollable parent.
            /// Additional options that can configure scrolling:
            ///        duration (default: "fast") - jQuery animation speed (can be a duration string or number of milliseconds)
            ///        direction (default: "both") - select possible scrollings ("vertical" or "y", "horizontal" or "x", "both")
            ///        complete (default: none) - a function to call when scrolling completes (called in context of the DOM element being scrolled)
            /// /// Returns the same jQuery set that this function was run on.
 
            options = $.extend({}, settings, options);
            options.direction = converter[typeof (options.direction) === "string" && options.direction.toLowerCase()] || converter.both;
 
            var dirStr = "";
            if (options.direction.x === true) dirStr = "horizontal";
            if (options.direction.y === true) dirStr = dirStr ? "both" : "vertical";
 
            var scroller = this.closest(":scrollable(" + dirStr + ")");
 
            // check if there's anything to scroll in the first place
            if (scroller.length > 0)
            {
                scroller = scroller.eq(0);
 
                var dim = {
                    e: dimensions(this),
                    s: dimensions(scroller)
                };
 
                var rel = {
                    top: dim.e.rect.top - (dim.s.rect.top + dim.s.border.top),
                    bottom: dim.s.rect.bottom - dim.s.border.bottom - dim.s.scrollbar.bottom - dim.e.rect.bottom,
                    left: dim.e.rect.left - (dim.s.rect.left + dim.s.border.left),
                    right: dim.s.rect.right - dim.s.border.right - dim.s.scrollbar.right - dim.e.rect.right
                };
 
                var animOptions = {};
 
                // vertical scroll
                if (options.direction.y === true)
                {
                    if (rel.top < 0)
                    {
                        animOptions.scrollTop = dim.s.scroll.top + rel.top;
                    }
                    else if (rel.top > 0 && rel.bottom < 0)
                    {
                        animOptions.scrollTop = dim.s.scroll.top + Math.min(rel.top, -rel.bottom);
                    }
                }
 
                // horizontal scroll
                if (options.direction.x === true)
                {
                    if (rel.left < 0)
                    {
                        animOptions.scrollLeft = dim.s.scroll.left + rel.left;
                    }
                    else if (rel.left > 0 && rel.right < 0)
                    {
                        animOptions.scrollLeft = dim.s.scroll.left + Math.min(rel.left, -rel.right);
                    }
                }
 
                // scroll if needed
                if (!$.isEmptyObject(animOptions))
                {
                    if (rootrx.test(scroller[0].nodeName))
                    {
                        scroller = $("html,body");
                    }
                    scroller
                        .animate(animOptions, options.duration)
                        .eq(0) // we want function to be called just once (ref. "html,body")
                        .queue(function (next) {
                            $.isFunction(options.complete) && options.complete.call(scroller[0]);
                            next();
                        });
                }
                else
                {
                    // when there's nothing to scroll, just call the "complete" function
                    $.isFunction(options.complete) && options.complete.call(scroller[0]);
                }
            }
 
            // return set back
            return this;
        }
    });
 
    var scrollValue = {
        auto: true,
        scroll: true,
        visible: false,
        hidden: false
    };
 
    $.extend($.expr[":"], {
        scrollable: function (element, index, meta, stack) {
            var direction = converter[typeof (meta[3]) === "string" && meta[3].toLowerCase()] || converter.both;
            var styles = (document.defaultView && document.defaultView.getComputedStyle ? document.defaultView.getComputedStyle(element, null) : element.currentStyle);
            var overflow = {
                x: scrollValue[styles.overflowX.toLowerCase()] || false,
                y: scrollValue[styles.overflowY.toLowerCase()] || false,
                isRoot: rootrx.test(element.nodeName)
            };
 
            // check if completely unscrollable (exclude HTML element because it's special)
            if (!overflow.x && !overflow.y && !overflow.isRoot)
            {
                return false;
            }
 
            var size = {
                height: {
                    scroll: element.scrollHeight,
                    client: element.clientHeight
                },
                width: {
                    scroll: element.scrollWidth,
                    client: element.clientWidth
                },
                // check overflow.x/y because iPad (and possibly other tablets) don't dislay scrollbars
                scrollableX: function () {
                    return (overflow.x || overflow.isRoot) && this.width.scroll > this.width.client;
                },
                scrollableY: function () {
                    return (overflow.y || overflow.isRoot) && this.height.scroll > this.height.client;
                }
            };
            return direction.y && size.scrollableY() || direction.x && size.scrollableX();
        }
    });
})(jQuery);
The changes from the original code are found in the dimensions() function, now capable of calculating the union of dimensions of multiple elements, and the new helper function union().

Some notes about usability. The purpose of scrollIntoView() is to allow an improved user experience by scrolling elements of interest, e.g. the "current clip" in a play list, into view if necessary. By applying a smooth scroll rather that having the page or scrolling panel just jumping up and down, the user is less likely to loose track of what is going on. When designing my foldingParagraphs() plugin (have content between headings hidden by default, and revealed when clicking corresponding headings), I found that besides scrolling the content into view; the heading itself must never leave the view as that would obstruct the reading. Additionally the (folded) heading following the content should, if possible, be revealed as well to help the user to go on reading the next section without needing to fiddle with the scrollbar. So, I throw the clicked heading, the content, and the optional heading following the content, to the tigers - sorry - the slightly modified scrollIntoView plugin, and that’s it.

1 comment:

  1. Thanks for sharing! This is a very nice addition to the original plugin. I have a table that acts like an 'accordion' (a row with extra content is opened below a table row when it is selected via keyboard or mouse click) and I can now get two rows to remain in view.

    ReplyDelete