George Reith George Reith - 6 months ago 19
HTML Question

jQuery animating height show/hide getting stuck

I think I know why this is happening but I am not sure of the best way to tackle it. Here is a jsFiddle you can refer to.

If you attempt to open and close a sub-menu in the jsFiddle (Click the + icon next to any link) and then open it again before it has fully closed it will become stuck. Now open the menu and attempt to open one of it's child sub-menu's and you will see that it's parent doesn't expand to accommodate it.

I believe this problem is caused because during the

hide
procedure
jQuery
applies an inline height to the element and if you attempt to open it before it finishes animating it assumes that it is the final height of the element.

I considered storing the height of each element at the very start and using that to animate towards, however the problem with this approach is that menus with sub-menus height changes all the time depending on whether it's sub-menus are open and this value is never a constant.

Is there anyway to tell jQuery to ignore the element's inline height and calculate what it's true height should be?

Here is the jQuery used, for the HTML and CSS please see the jsFiddle as they are rather lengthy:

jQuery(document).ready(function($){
var legacyMode = $('html').hasClass('oldie');
var titles = {normal: "Show sub-topics", active: "Hide sub-topics"};

var sub_sections = $('ul#map li:has(ul.child)');

sub_sections.each(function() {
if (!$(this).find('li.active').length && !$(this).hasClass('active')) {
var toggle = $('<a class="toggle" href="#"></a>').attr('title', titles.normal).insertBefore($(this).children('ul.child'));
var child = $(this).children('ul.child').hide();
toggle.data('container', this);
}
});

$('a.toggle').click(function (event) {
event.preventDefault();
var target = $(this).siblings('ul.child');
if($(this).hasClass('active')) {
toggleDisplay(target, false);
$(this).removeClass('active').attr('title', titles.normal);
} else {
toggleDisplay(target, true);
$(this).addClass('active').attr('title', titles.active);
}
function toggleDisplay(target, on) {
var mode = (on) ? "show" : "hide";
if (!legacyMode) {
target.stop(true, false).animate({height: mode, opacity: mode}, 500);
} else {
// Omits opacity to avoid using proprietary filters in legacy browsers
target.stop(true, false).animate({height: mode}, 500);
}
}
});
});​

Answer

Okay so I made a working solution... it isn't quite as straight forward as I would of hoped but anyway here is the working code:

jQuery(document).ready(function($){
    var legacyMode = $('html').hasClass('oldie');
    var titles = {normal: "Show sub-topics", active: "Hide sub-topics"};

    var sub_sections = $('ul#map li:has(ul.child)');

    sub_sections.each(function() {
        if (!$(this).find('li.active').length && !$(this).hasClass('active')) {
            var child = $(this).children('ul.child');
            if (!legacyMode) {
                child.css({height : '0px', opacity : 0, display: 'none'});
            } else {
                // Omits opacity to avoid using proprietary filters in legacy browsers
                child.css({height : '0px', display: 'none'});
            }
            var toggle = $('<a class="toggle" href="#"></a>').attr('title', titles.normal).insertBefore(child);
            toggle.data('container', this);
        }
    });

    $('a.toggle').click(function (event) {
        event.preventDefault();
        var target = $(this).siblings('ul.child');
        if($(this).hasClass('active')) {
            toggleDisplay(target, false);
            $(this).removeClass('active').attr('title', titles.normal);
        } else {
            toggleDisplay(target, true);
            $(this).addClass('active').attr('title', titles.active);
        }
        function toggleDisplay(target, on) {
            var targetOpacity = 0;
            var targetHeight = 0;
            if (on) { 
                // Get height of element once expanded by creating invisible clone
                var clone = target.clone().attr("id", false).css({visibility:"hidden", display:"block", position:"absolute", height:""}).appendTo(target.parent());
                targetHeight = clone.height();
                targetOpacity = 1;
                console.log(clone.height());
                clone.remove();
                target.css({display : 'block'});
            }
            if (!legacyMode) {
                target.stop(true, false).animate({height: targetHeight + "px" , opacity: targetOpacity}, {
                    duration: 500,
                    complete: function() {
                        if (on) {
                            $(this).css({height : '', opacity : ''});
                        } else {
                            $(this).css({display : 'none'});
                        }
                    }
                });
            } else {
                // Omits opacity to avoid using proprietary filters in legacy browsers
                target.stop(true, false).animate({height: targetHeight + "px"}, {
                    duration: 500,
                    complete: function() {
                        if (on) {
                            $(this).css({height : ''});
                        } else {
                            $(this).css({display : 'none'});
                        }
                    }
                });           
            }
        }
    });
});​

See it in action: http://jsfiddle.net/xetCd/24/ (Click any combination of toggle's in any order any amount of times and it should stay smooth).

Tested with: IE7, IE8 (set var legacyMode to true for best results), Firefox 15, Chrome 23

How does it work? Well I no longer use the show and hide animation targets as these are not flexible enough. For this reason I have to hide the uls at initialization a little differently:

var child = $(this).children('ul.child');
if (!legacyMode) {
    child.css({height : '0px', opacity : 0, display: 'none'});
} else {
    // Omits opacity to avoid using proprietary filters in legacy browsers
    child.css({height : '0px', display: 'none'});
}
var toggle = $('<a class="toggle" href="#"></a>').attr('title', titles.normal).insertBefore(child);​

Now on to the juicy bit, how to calculate the target height of the element when it could be at any stage of the animation and have any amount of sub-menu's open or closed. I got around this by creating a visibility : hidden duplicate of the element and forcing it to take up it's regular height, I also gave it position : absolute so that it doesn't appear to take up any space in the document. I grab it's height and delete it:

var targetOpacity = 0;
var targetHeight = 0;
if (on) { 
    // Get height of element once expanded by creating invisible clone
    var clone = target.clone().attr("id", false).css({visibility:"hidden", display:"block", position:"absolute", height:""}).appendTo(target.parent());
    targetHeight = clone.height();
    targetOpacity = 1;
    console.log(clone.height());
    clone.remove();
    target.css({display : 'block'});
}​

Then I animate it and make sure to reset the display and remove the height (so sub-menu's can expand their parent) properties:

if (!legacyMode) {
    target.stop(true, false).animate({height: targetHeight + "px" , opacity: targetOpacity}, {
        duration: 500,
        complete: function() {
            if (on) {
                $(this).css({height : '', opacity : ''});
            } else {
                $(this).css({display : 'none'});
            }
        }
    });
} else {
    // Omits opacity to avoid using proprietary filters in legacy browsers
    target.stop(true, false).animate({height: targetHeight + "px"}, {
        duration: 500,
        complete: function() {
            if (on) {
                $(this).css({height : ''});
            } else {
                $(this).css({display : 'none'});
            }
        }
    });           
}​

Thanks for reading.