Wednesday, December 19, 2007

Accordion select list

Kevin Miller has created an accordion widget using prototype and script.aculo.us. In this article I'll demonstrate how to build a selection list that has sub-sections utilizing the accordion control. Here is a demonstation comparing a basic select list control and the accordion select list: To make the control work, you first add a read-only text box with the drop down button image floating to the right of it, for example:

<span style="float: right">
 <img id="accSelectToggle" src="images/btn_dropdown.png" width="21" height="16" alt="Expand list" title="Expand list" />
</span>
<input type="text" id="accSelect" title="List" readonly="readonly" value="Option 1.1" />
You then define a div which contains the accordion control, such as:
<div id="accSelectOptions" class="accordion_container" style="display: none">
<h1 class="accordion_toggle">Category 1</h1>
<div class="accordion_content">
<ul>
 <li value="option11">Option 1.1</li>
 <li value="option12">Option 1.2</li>
 <li value="option13">Option 1.3</li>
</ul>
</div>
...
</div>
Add your preferred CSS styling. In my example the CSS looks like:
#accSelect {
 width: 169px;
 height: 14px;
 border-style: none;
 padding-left: 4px;
}

.accordion_container {
 z-index: 10;
 position: absolute;
 width: 198px;
 border: solid 1px black;
}

.accordion_toggle {
 display: block;
 padding: 0.3em 0.5em;
 font-weight: normal;
 font-size: 1em;
 text-decoration: none;
 outline: none;
 border-bottom: 1px solid #def;
 color: #000000;
 cursor: pointer;
 margin: 0px;
 background-image: url('images/expand.gif');
 background-position: 99% 50%;
 background-repeat: no-repeat;
 padding-right: 5px;
 background-color: #9BE;
}

.accordion_toggle_active {
 color: #ffffff;
 font-weight: bolder;
 background-image: url('images/ns-expand.gif');
 background-color: #47B;
}

.accordion_content {
 background-color: #ffffff;
 overflow: auto;
 display: block;
}

.accordion_content ul {
 padding-left: 1em;
 margin: 0;
 padding-right: 0;
 padding-bottom: 0px;
 list-style-type: none
}

.accordion_content li {
 margin: auto;
 padding: 0px;
}

.highlight {
 background-color: #DDD;
}
And finally add a little JavaScript to make the magic happen:
    function  toggleAccordionSelect(event) {
        var image = event.target;
        var collapseIcon = "images/btn_dropdown-selected.png";
        var expandIcon = "images/btn_dropdown.png";

        if ($('accSelectOptions').style.display === 'none') {
            image.src = collapseIcon;
            image.alt = "Collapse List";
            image.title = "Collapse List";
            $('accSelectOptions').show();
        }
        else {
            image.src = expandIcon;
            image.alt = "Expand List";
            image.title = "Expand List";
            $('accSelectOptions').hide();
        }
    }

    function toggleHighlight(event) {
        var item = event.target;
        item.toggleClassName('highlight');
    }
    
    function selectAccordionOption(event) {
        var selectedOption = Event.element(event);

        // update the selection input field value to the selected display value
        $('accSelect').value = selectedOption.innerHTML;

        // internal value is stored as an attribute, do whatever you need to with it
        var realValue = selectedOption.getAttribute('value');
        
        // hide the accordion list
        toggleAccordionSelect({target: $('accSelectToggle')});       
    }
    

    var accordionSelect = new accordion('accSelectOptions', {
        classNames : {
            toggle : 'accordion_toggle',
            toggleActive : 'accordion_toggle_active',
            content : 'accordion_content'
            },
        onEvent : 'mousedown'
        });

    // By default, open first accordion in the drop down
    var accordionToggles = $$('#accSelectOptions .accordion_toggle');
    accordionSelect.activate(accordionToggles[0]);

    // register event observers        
    Event.observe('accSelectToggle', 'mousedown', toggleAccordionSelect);
    $$('#accSelectOptions .accordion_content li').each(function(accSelectOption) {
        Event.observe(accSelectOption, 'mousedown', selectAccordionOption);
        Event.observe(accSelectOption, 'mouseover', toggleHighlight);
        Event.observe(accSelectOption, 'mouseout', toggleHighlight);
    });

Aborting Ajax requests (for prototype.js)

Sometimes your slick Ajax application may have submitted a request that you no longer care about and want to abort. For example, perhaps you've implemented a type-ahead feature and the user has now typed another character prior to the first results returning. Or perhaps you fetch values for other parts of a form based on user selections and the user changed their choice prior to the first response returning.

I was surprised to see that the prototype.js library does not include an abort method for its Ajax.Request object. So, here's my implementation of Ajax.Request.abort():


/**
* Ajax.Request.abort
* extend the prototype.js Ajax.Request object so that it supports an abort method
*/
Ajax.Request.prototype.abort = function() {
// prevent and state change callbacks from being issued
this.transport.onreadystatechange = Prototype.emptyFunction;
// abort the XHR
this.transport.abort();
// update the request counter
Ajax.activeRequestCount--;
};


To use this function, you need to keep a handle on the Ajax request you want to abort. So, somewhere in you code you'll have something like:

var myAjaxRequest = new Ajax.Request(requestUrl, {[request options]});


If you want to abort that request, simply call:


myAjaxRequest.abort();


Update - Feb. 22, 2008:

It was pointed out in the comments that the Ajax.activeRequestCount can occasionally become negative. I have been able to replicate that situation, while at the same time confirming that it does not consistently happen. This leads me to believe that it's most likely a timing issue such as the abort is issued after the response has already started to be received and/or processed so that both the response processing decrements the counter as well as the abort.

My personal work-around for this is to add these lines to the end of my onComplete handler:

if (Ajax.activeRequestCount < 0) {
Ajax.activeRequestCount = 0;
}

I don't want to remove the counter decrement from the abort function or else cleanly aborted requests will leave the activeRequestCount > 0 when there are no real outstanding requests.

If anyone has a better solution, I'd be interested to hear from you.

Thursday, December 13, 2007

Performance-based Web App Functionality

In the development of some CPU intensive web applications, I've come to realize that sometimes I need to throttle down some of the features of the application in order to maintain good response times for a better user experience. So, I've added what I refer to as jsBogoMips to some of my applications.



The above example shows throttling of visual effects, but it can be used to throttle other things as well. For example, in the application I actually developed this for, while it does use script.aculo.us visual effects, what I really needed to be throttled was the generation of an SVG chart from an arbitrary amount data elements received in XML data. So, based on the jsBogoMips value, I control how many data points are charted.

If you're interested in some alterative JavaScript performance testing, you might also be interested in Celtic Kane's JavaScript Speed Test and Performance Tests for Opera 9.5.