MTJ Hax

Sticky Footers Break Bootstrap ScrollSpy

Posted in bootstrap, code, jquery by mtjhax on February 12, 2013

If you saw my previous post, you know that I am currently playing with the Twitter Bootstrap ScrollSpy plugin. I reviewed their sources (which took a bit of effort as the code was written for compactness, not readability) and got my examples working. When I tried it with a real site, however, everything stopped working. ScrollSpy would only highlight the last element in my nav.

I fired up the debugger on an un-minified bootstrap.js to look at the size and position variables and saw that the value for the maxScroll var was 0. maxScroll holds the difference between the total scrollable height and the actual/visible height (as calculated by jQuery height()). This value should only be 0 if the scrollable content fits entirely in the visible area, yet my content was 1400px in height and the visible height was only around 400.

DOM inspection revealed that the document body had the correct scrollHeight value of 1400, but $('body').height() was 1400 as well. Normally, $('body').height() would return 400, the visible size. I had one of those “what the actual f…” moments for a few seconds.

Light Dawns

The site layout I am using is employing the sticky footer technique, which forces your footer to the bottom of the visible browser window, even when the page is smaller than the window. The relevant CSS is as follows:

* {
  margin: 0;
}
html, body {
  height: 100%;
}
.wrapper {
  min-height: 100%;
  height: auto !important;
  height: 100%;
  margin: 0 auto -142px; /* the bottom margin is the negative value of the footer's height */
}
.footer, .push {
  height: 142px; /* .push must be the same height as .footer */
}

/*
Sticky Footer by Ryan Fait
http://ryanfait.com/
*/

Oops. We are explicitly setting the height of body to 100% of the document height, so $('body').height() is no longer just the visible area, but the same as document.body.scrollHeight.

What’s the solution?

It turns out that someone already tried to get a workaround merged into Bootstrap, but they didn’t follow Bootstrap’s procedures for submitting a pull request and were summarily rejected. I’m not sure that fix would work anyway — it uses the sticky footer #wrap div to get a different scrollHeight, but the scrollHeight is not the problem — it’s $scrollElement.height() that is returning the full document height instead of the visible height.

Some options for a fix include:

  • Override the sticky footer styles for the page in question (just use html,body { height: auto }) or use a different stylesheet with the ScrollSpy page. This doesn’t feel right to me. Page-specific conditional styles are the path to the dark side.
  • Skip the sticky footer styles based on some clever CSS that conditionally removes them. This is still a conditional but it seems a bit more expressive and makes the solution all CSS. Imagine an HTML element like <div class="container unstick_footer">. The only problem is that this won’t be possible until CSS4.
  • Stop using sticky footers? Maybe you don’t really need them. A lot of people just add them to every project before they even have a design.
  • Put your scrollable content inside an element that has a fixed height and use ScrollSpy on that instead of the body. This would not work for everyone and makes it a bit more difficult for your scrollable area to adjust to different page heights. You would need some JavaScript and/or Responsive CSS styles to avoid just having a fixed-height box.
  • Submit a patch for Bootstrap that gets the visible height of the body even if some nutcase has defined it to have height 100% of the document height.
  • My favorite (which may not be possible) is to fix the sticky footers CSS so they don’t increase the height of the body beyond the visible window. I may need to call in a CSS ringer to help me with that one.

I’d love to explore all these approaches and compare their pros and cons, but time isn’t my friend. I’d love to hear about your ideas and any approaches that work.

Action and reaction: event-driven AJAX updates with jQuery

Posted in code, jquery by mtjhax on September 7, 2010

Newton's CradleAs I increasingly use AJAX to let users perform actions in a page without navigating away, I find myself constantly having to solve the problem of updating page content that depends on these actions. For example, a user logs in with a popup bubble, then parts of the page that depend on login state must be updated. This is a relatively trivial example and there are many approaches to handling this sort of update.

A straightforward method that is commonly used is to avoid AJAX in these cases–submit the login form and redirect back to refresh the entire page. While using AJAX sparingly is a worthwhile goal, avoiding it entirely doesn’t always result in the most engaging user experience. Another approach is to hard-code everything that needs to be updated after an action is complete:

// submit my login form with jQuery .ajax()
$.ajax({ url: login_url, data: login_data, complete: function() {
  // update page contents that depend on login state
  $('#mainmenu').load('mainmenu');
  $('#login_form').fadeOut('slow', function() {$(this).remove()});
  $('.welcome_text').show();
});

Fine for one or two actions, but this turns into a spaghetti mess pretty quickly in more complicated situations. A better solution is to have some way of registering update callbacks, e.g.:

$('#mainmenu').updateOnEvents('login', function() {
  $(this).load('mainmenu');
});

$('#login_form').updateOnEvents('login', function() {
  $(this).fadeOut('slow', function() {$(this).remove()});
});

$('.welcome_text').updateOnEvents('login', function(){
  $(this).show();
});

$.ajax({ url: login_url, data: login_data, complete: function() {
  // globally trigger the event 'login' somehow
});

So the question becomes, how do you globally trigger the event so any element at any level can register a handler? After considering a number of designs involving .live() or .delegate() (so the handler setups would affect both existing elements and new elements added later) I realized that good old .bind() would do the trick.

Solutions involving .live() and .delegate() are tricky because when you use something like $('.foo').live() or $(document).delegate('.foo'), you need to use $('.foo').trigger() for the event to be handled. Calling something higher-level like $(document).trigger() does not invoke the handlers. The trick is to roll your simple .live()-style function similar to the following:

$.fn.updateOnEvents = function(events, callback) {
  $(document).bind(
    events,
    { selector: $(this).selector, context: $(this).context },
    function(event, trigger_data) {
      $(event.data.selector, event.data.context).each(function(){
        if (typeof callback == 'function') {
          extra_data = callback(event, trigger_data);
        }
      });
    }
  );
};

// example usage
$(document).ready(function() {
  $('.welcome_text').updateOnEvents('login logout', function(event, data){
    $(this).text(data.message).show();
  });

  $(document).trigger('login', { message: "Welcome back!" });
  $(document).trigger('logout', { message: "See you next time." });
});

The trick is that when we call $('.foo').updateOnEvent(), the selector string ‘.foo’ is saved (and the context, if specified) and are passed to the handler function in event.data. The handler uses the selector and invokes the callback function for each matching element with .each(), so the value of $(this) in your callback is the element itself instead of $(document). Since the selector is evaluated at the time of the callback, any recently-added elements that match are included in the update.

The parameter trigger_data is whatever extra data you pass with the .trigger() call and your callback can take advantage of that data.

Commonly-used patterns can be expressed as a shortcut in the code. For example, in this variant if the callback parameter is a string instead of a function, the code treats the string as an AJAX URL and assumes you want to update the selected elements with .load():

$.fn.updateOnEvents = function(events, callback) {
  $(document).bind(
    events,
    { selector: $(this).selector, context: $(this).context },
    function(event, trigger_data) {
      if (typeof callback == 'function') {
        $(event.data.selector, event.data.context).each(function(){
          extra_data = callback(event, trigger_data);
        });
      } else if (typeof callback == 'string') {
        $(event.data.selector, event.data.context).load(callback, trigger_data);
        });
      }
    }
  );
};

// slightly absurd example of using AJAX to retrieve a welcome message
$(document).ready(function() {
  $('.welcome_text').updateOnEvents('login logout', '/ajax/welcome_msg');
  $(document).trigger('login', { type: "login" });
  $(document).trigger('logout', { type: "logout" });
});

In yet another variant where I always want to update the element with AJAX via .load(), instead of passing a callback function to perform the updates, I passed a callback function that returns the parameters for the .load() call — in this way, the .trigger() function doesn’t need to know anything about the parameters needed by the update callbacks:

$.fn.updateOnEvents = function(events, ajax_url, params_callback) {
  $(document).bind(
    events,
    { selector: $(this).selector, context: $(this).context, url: ajax_url },
    function(event) {
      var data = extra_data;
      $(event.data.selector, event.data.context).each(function(){
        // get AJAX request parameters from callback function
        if (typeof params_callback == 'function') {
          data = params_callback(event);
        }
        // convert params to string so .load() always uses GET method
        if (typeof data == 'object') {
          var new_params = "";
          for (var key in data) {
            if (new_params.length > 0) new_params += "&";
            new_params += key + "=" + data[key];
          }
          data = new_params;
        }
        // update the element with AJAX
        $(this).load(e.data.ajax_url, data);
      });
    }
  );
};

// another slightly ludicrous usage example
$(document).ready(function() {
  $('.welcome_text').updateOnEvents('login logout', '/ajax/welcome_msg', function(event){
    if (event.type == 'login')
      return { type: "login" };
    else
      return { type: "logout" };
  });
});

I have to profess that I am not a jQuery god (yet). I am certain that there are improvements that could be made to this function in terms of performance and simplicity, maybe a potential error or two, or a completely better way to approach the problem. I welcome your suggestions, comments, and criticism!

Tagged with: , , , , ,

jQuery fadeIn / fadeOut problem in IE

Posted in code, jquery by mtjhax on May 7, 2010

Say you have a series of DIVs displayed side-by-side with the CSS style display:inline, and you want to use jQuery fadeIn and fadeOut to make the appear and disappear. This will not work under IE8, and I assume other versions of IE. To fix it, give your elements style="display:inline-block" instead.

Some people have reported that using absolute and relative positioning of elements causes jQuery animation problems in IE, but I have yet to see positioning cause any problems. I very commonly use a position:relative DIV, then position:absolute elements within the DIV (probably rely on it too much, never claimed to be a CSS designer!)

UPDATE:

I ran into a similar problem under IE8 where a DIV could be hidden and displayed with jQuery .hide() and .show(), and .fadeOut() and .slideUp() would work, but .fadeIn() and .slideDown() would not work at all. The HTML looked something like this:

<div id="page_help">
<div id="help_toggle">Click here to hide help</div>
<div
id="help_text">This is some help text</div>
</div>

I had a CSS definition for the help_toggle class setting it to display:inline-block and the fadeIn/slideDown problems started appearing in IE8.  When I set it to display:block the problem went away. Bizarrely, if I inserted an HTML comment between the help_toggle and help_text DIVs, the problem also went away! E.g.:

<div id="page_help">
<div id="help_toggle">Click here to hide help</div>
<!-- bizarrely enough, this fixes the jquery fadein problem -->
<div
id="help_text">This is some help text</div>
</div>

This is baffling. The only moral to the story I can figure out is that jQuery animations do not appreciate block and inline elements being siblings, and sometimes setting inline-block as the display style is not sufficient to get things working again.