blog

Photo by Kelly Repreza on Unsplash

Hidden Options: A Workaround

by

Here’s the situation:

You’ve got a select. Maybe a whole bunch of selects, with a ton of options each (metric ton – let’s keep our imaginary hyperbolic units straight here); and these are meant to be complex interactive elements, with options made visible or not as some programmatic condition dictates.

Traditionally, if you wanted to selectively display options, you had to do it the hard way – remove the non-visible option nodes entirely. What, did you want to filter on some state information stored on the node? Too bad – you’ll have to keep track of the full structure outside of the DOM and filter on that, inserting or removing elements as needed.

This is sub-optimal. It’s much tidier if we can just set display:none on our options elements, and have them hidden like any other DOM element:

option[disabled]{
    display:none;
}

and in most modern browsers (Firefox, IE9+, Safari), this works just fine. We can then filter in place on the elements, and selectively display them.

Have you noticed the glaring omission yet? Yes, Chrome isn’t among the browsers this works ‘just fine’ in.

You can set display:none on your disabled options to hide them, and that will work – but as stackoverflow user JMack discovered, and per this long-standing chromium bug (open since Jul 30 2012, with the most recent activity on it a downgrade in priority), when you have hidden options in your select, the select dropdown will fail to resize itself appropriately – to the point that the dropdown may not show anything beyond the initial visible option, with the rest of the visible options hidden beneath. They’re still selectable, and can be scrolled to, but the dropdown list will be tiny and scrolling won’t work quite right, often leaving you with the top and bottom of the next two options visible.

We’re not here to complain, though – we’re here to get things done. Let’s whip up a workaround, and then discuss how to use it, how and why it works.

(function($){
    var userAgent = window.navigator.userAgent,
        needsWrap = (userAgent.indexOf('Trident') !== -1 || userAgent.indexOf('AppleWebKit') !== -1);
    /**
     * Workaround for browsers that respect hidden options elements, but then fail to resize the select dropdown
     * to display any visible elements beyond those that appear before any hidden elements - namely, Chrome.
     * Based on the filter function, we either select all options that match (or, if invert is true, all that
     * don't match), set them to disabled (with the expectation that there's a css rule hiding disabled options
     * somewhere), and then pull the disabled options out of the DOM and insert them back in at the end of the
     * select - this is tested as working in the most recent version of Chrome (as of this writing, v34).
     * See also http://code.google.com/p/chromium/issues/detail?id=139595 and
     * http://stackoverflow.com/questions/17203826/chrome-bug-on-select-element-dropdown-when-many-options-are-hidden
     * for reports of the browser bug this works around.
     *
     * Additionally, we handle browsers that DON'T respect hidden options, by agent-sniffing such browsers
     * and wrapping and unwrapping as necessary options that we want hidden in a span tag.
     *
     * @note This works, but DOM manipulation in IE is SLOW - much slower to perform an appendChild operation
     * than any of the other major browsers. Wrapping/unwrapping large sets of options will take a relative long time (>2s)
     * and have the potential to hang the UI thread.
     * @param {string|HtmlElement|jQuery} el
     * @param {function} filter
     * @param {boolean=} invert
     * @returns {jQuery}
     */
    $.elideOptions = function(el, filter, invert){
        var $el = (el instanceof $) ? el : $(el);
        $el.each(function(){
            if (this.tagName !== 'SELECT') return;
            var $this = $(this),
                opts = $this.find('option').prop('disabled', false),
                spans;
            // Unwrap all options from their span tags
            if (needsWrap && (spans = $this.find('span')) && spans.length){
                spans.children().unwrap();
            }
            opts = (invert) ? opts.not(filter).prop('disabled', true) :
                opts.filter(filter).prop('disabled', true);
            // Wrap options in a single hidden span to hide them on browsers that don't support
            // display:none for options
            if (needsWrap) {
                opts.wrapAll('<span class="hide"></span>');
                opts = opts.parent('span');
            }
            opts.detach().appendTo($this);
        });
        return $el;
    };
    /**
     * Allow for the $(element) form of invocation.
     * @param {function} filter
     * @param {boolean=} invert
     * @returns {jQuery}
     */
    $.fn.elideOptions = function(filter, invert){
        return $.elideOptions(this, filter, invert);
    };
})(jQuery);

Usage for this plugin looks like:

$.elideOptions('#mydiv select', function(){
    /* return true to keep option in selected set */
});

OR

$('#mydiv select').elideOptions(function(){
    /* return true to keep option in selected set */
});

Additionally, you can specify a third optional parameter, invert – if true, the logic of the filter function is inverted (that is, options that match the criteria [ie. you return true from the filter function] will be omitted from the options that will be acted upon – essentially, using not instead of filter on the set).

As per the comments at the top of the above gist, we work around our issue with Chrome by performing the disable as per usual, then detaching the disabled options from the select, and appending them back onto the end of the select.

The behaviour of this bug is such that any visible options that occur after hidden elements aren’t counted towards the height of the list box – by ensuring all visible options will be above any hidden options, we avoid this behaviour. Now we can still filter on the elements within the select, not needing to create a shadow element or object to store state for this select, while still having the select dropdown expand to the dimensions of the visible options as expected.

So, we know the how – but are you curious as to the why? The answer lurks somewhere in this diff. I’ve only had time to give it a cursory read-through – there’s not a lot to go on, given the lack of documentation (come on guys, a doc-block per function wouldn’t be that hard), but given the bug behaviour, it probably has something to do with either the calculation of the list box height only including those items encountered before the first hidden item, or with the lastIndex being calculated as the last visible element before the first hidden element.

Need to get the job done today? My plugin can help with that.
Want to be a hero? Go fix this for realsies.

+ more