//
// AdPlaceholder
//
var AdPlaceholder = Control.subclass({
    className: "AdPlaceholder",
    content: [
        " ",
        {
            html: "<div />",
            id: "container",
            content: [
                " ",
                {
                    html: "<div>Advertisement</div>",
                    id: "label"
                },
                " ",
                {
                    html: "<div />",
                    id: "AdPlaceholder_content"
                },
                " "
            ]
        },
        " "
    ]
});
AdPlaceholder.prototype.extend({
    
    /*
     * Ad unit content. By default, this shows the unit/dimensions.
     */
    content: Control.chain( "$AdPlaceholder_content", "content" ),
    
    /*
     * Ad unit dimensions. Should include two values separated by an "x",
     * e.g., "300 x 250". Can also be one of the following ad unit names:
     * 
     *      Medium Rectangle
     *      Rectangle
     *      Leaderboard
     *      Wide Skyscraper
     *      Half Page Ad
     *      Button 2
     *      Micro Bar
     */
    dimensions: Control.property( function( dimensions ) {
        
        var s = AdPlaceholder.standardUnits[ dimensions ] || dimensions;
        var parts = s.toLowerCase().split( "x" );
        var width = parseInt( parts[0] );
        var height = parseInt( parts[1] );
        
        this.$container().css({
                height: height,
                "min-height": height,
                "min-width": width,
                width: width
        });
        this
            .content( width + " x " + height )
            .trigger( "sizeChanged" );
            
    }),

    initialize: function() {
        this.genericIfClassIs( AdPlaceholder );
        
        if ( !this.dimensions() ) {
            // Use default size.
            this.dimensions( "300 x 250" );
        }
    }

});

// Class methods
AdPlaceholder.extend({
    
    /*
     * Names of all core standard ad units as of 2/28/2011.
     * See http://www.iab.net/iab_products_and_industry_services/1421/1443/1452
     */
    standardUnits: {
        "Medium Rectangle": "300 x 250",
        "Rectangle": "180 x 150",
        "Leaderboard": "728 x 90",
        "Wide Skyscraper": "160 x 600",
        "Half Page Ad": "300 x 600",
        "Button 2": "120 x 60",
        "Micro Bar": "88 x 31"
    }
});

//
// ArrayTable
//
var ArrayTable = Control.subclass({
    className: "ArrayTable"
});
ArrayTable.prototype.extend({
    
    /*
     * The array-of-arrays to show as the control's content.
     * 
     * A row will be created for each outer array item, and a cell for each item
     * in the inner arrays.
     */
    content: Control.property( function( outerArray ) {
        var rows = $.map( outerArray, function( innerArray, index ) {
            var cells = $.map( innerArray, function( item, index ) {
                return Control( "<div/>" ).content( item );
            });
            var row = $( "<div/>" );
            row.append.apply( row, cells );
            return row;
        });
        Control( this ).content( rows );  
    })
    
});

//
// AutoSizeTextBox
//
var AutoSizeTextBox = Control.subclass({
    className: "AutoSizeTextBox",
    content: [
        " ",
        {
            html: "<textarea />",
            id: "textBox"
        },
        " ",
        {
            html: "<pre />",
            id: "textCopy"
        },
        " "
    ]
});
AutoSizeTextBox.prototype.extend({

    /*
     * Resize the text box to exactly contain its content.
     */
    autoSize: Control.iterator( function( addExtraLine ) {

        /*
         * We resize by copying the text box contents to the hidden copy.
         * That copy will size appropriately, which will make the overall control
         * the right height, which will then size the text box.
         */
        var content = this.$textBox().content();
        
        if ( addExtraLine ) {
            content += "\n";
        }
        
        // See if last line of content ends in a newline (extra or otherwise).
        if ( content.slice( -1 ) === "\n" ) {
            // Add an extra space so that the last line will get fully rendered.
            content += " "; 
        }
        
        this.$textCopy().text( content );
    }),
    
    /*
     * The content of the text box.
     */
    content: Control.chain( "$textBox", "content", function() {
        this.autoSize();
    }),
    
    initialize: function() {
        
        var self = this;
        this.$textBox().on({
            "change keyup": function( event ) {
                self.autoSize();
            },
            keypress: function( event ) {
                if ( event.which === 13 /* Enter */ ) {
                    // Speculatively add a line to our copy of the text.
                    /*
                     * We're not sure what the exact effect of typing this
                     * character will be, and at this point it's not reflected
                     * yet in the text box's content. We speculate that it
                     * will add a line to the text and size accordingly.
                     * (One other possibility is that the user's replacing
                     * a selected chunk of text with a newline.) In any event,
                     * once we get the keyup or change event, we'll make any
                     * final adjustments.
                     */
                    self.autoSize( true );
                }
            }
        });
        
        this.inDocument( function( $control  ) {
            $control._refresh();
        });
    },
    
    /*
     * The minimum number of lines that should be shown.
     * The default value is 2, which creates a textarea at least two lines
     * tall. This helps the user ascertain the ability to type multiple
     * lines of text.
     */
    minimumLines: Control.property.integer( function( minimumLines ) {
        if ( this.inDocument() ) {
            this._refresh();
        }
    }, 2 ),
    
    /*
     * True if the text box should expose the browser's built-in spell-checking.
     */
    spellcheck: Control.chain( "$textBox", "prop/spellcheck" ),
    
    // For the following, we need to wait until the control's in the DOM.    
    _refresh: Control.iterator( function() {

        var $textBox = this.$textBox();
        var $textCopy = this.$textCopy();
        
        // Copy the control's font to the textarea and text copy.
        // This ensures both end up with the same text metrics.
        this.children().css({
            "font-family": this.css( "font-family" ),
            "font-size": this.css( "font-size" ),
            "font-style": this.css( "font-style" ),
            "font-weight": this.css( "font-weight" )
        });

        // Try to get the text box's line height. Unfortunately some browsers
        // return the useful value "normal", in which case we have to make
        // an estimate based on font size.
        var lineHeight = parseInt( $textBox.css( "line-height" ) );
        if ( isNaN( lineHeight ) ) {
            // line-height values like "normal" don't give us a measurement
            // we can use. We fall back to estimating a line height
            // based on font size. We then apply this to both the text box
            // and the copy so they both have the same font-size.
            lineHeight = Math.floor( parseInt( $textBox.css( "font-size" ) ) * 1.25 );
            $textBox.css( "line-height", lineHeight + "px" );
        } 
        $textCopy.css( "line-height", lineHeight + "px" );
        
        // Mirror the textarea's padding and borders on the text copy.
        // NOTE: Firefox seems to report 0px padding for the text box, even when there
        // appears to be 1px padding on a standard textarea. WebKit gets this right.
        // Perhaps there's some way in Firefox to detect and correct the error, but
        // for the time being this seems to work okay.
        var borderBottomWidth = $textBox.css( "border-bottom-width" );
        var borderLeftWidth = $textBox.css( "border-left-width" );
        var borderRigthWidth = $textBox.css( "border-right-width" );
        var borderTopWidth = $textBox.css( "border-top-width" );
        var paddingBottom = $textBox.css( "padding-bottom" );
        var paddingLeft = $textBox.css( "padding-left" );
        var paddingRight = $textBox.css( "padding-right" );
        var paddingTop = $textBox.css( "padding-top" );
        $textCopy.css({
            "border-bottom-width": borderBottomWidth,
            "border-left-width": borderLeftWidth,
            "border-right-width": borderRigthWidth,
            "border-top-width": borderTopWidth,  
            "padding-bottom": paddingBottom,  
            "padding-left": paddingLeft,
            "padding-right": paddingRight,
            "padding-top": paddingTop
        });

        var minimumLines = this.minimumLines();
        if ( minimumLines ) {
            
            // Convert the number of lines into a minimum height.
            var height = minimumLines * lineHeight;
            
            // Mozilla incorrectly includes padding+border in height when
            // -moz-box-sizing is border-box. The other browsers do not,
            // so for those browsers we need to add it in.
            if ( !$.browser.mozilla ) {
                height += parseInt( borderTopWidth )
                        + parseInt( paddingTop )
                        + parseInt( paddingBottom )
                        + parseInt( borderBottomWidth );
            }
            
            this.$textCopy().css( "min-height", height + "px" );
        }
    })
    
});

//
// BasicButton
//
var BasicButton = Control.subclass({
    className: "BasicButton"
});
BasicButton.prototype.extend({
    
    /*
     * The current state of the button. Read-only.
     * This will return one of the following values:
     * 
     *  BasicButton.normal
     *  BasicButton.hovered
     *  BasicButton.focused
     *  BasicButton.pressed
     *  BasicButton.disabled
     */
    buttonState: function() {
        if ( this.disabled() ) {
            return BasicButton.state.disabled;
        } else if ( (this.isMouseButtonDown() && this.isMouseOverControl() )
            || this.isKeyPressed()) {
            return BasicButton.state.pressed;
        } else if ( this.isFocused() ) {
            return BasicButton.state.focused;
        } else if ( this.isMouseOverControl() /* || this.isMouseButtonDown() */ )
        {
            return BasicButton.state.hovered;
        }

        return BasicButton.state.normal;
    },

    /*
     * True if the control is disabled.
     * This is mapped to the disabled property on the control top-level element.
     * Setting this also applies "disabled" class in case the :disabled pseudo-class
     * is not supported.
     */
    disabled: function( disabled ) {
        if ( disabled === undefined ) {
            return this.prop( "disabled" );
        } else {
            var disabledBool = (String( disabled ) === "true" );
            this
                .prop( "disabled", disabledBool )
                .toggleClass( "disabled", disabledBool )
                ._renderButton();
            return this;
        }
    },

    initialize: function() {
        var self = this;
        this
            .on({
                blur: function( event ) { self._trackBlur( event ); },
                click: function( event ) {
                    if ( self.disabled() ) {
                        event.stopImmediatePropagation();
                    }
                },
                focus: function( event ) { self._trackFocus( event ); },
                keydown: function( event ) { self._trackKeydown( event ); },
                keyup: function( event ) { self._trackKeyup( event ); },
                mousedown: function( event ) { self._trackMousedown( event ); },
                mouseup: function( event ) { self._trackMouseup( event ); }
            })
            .genericIfClassIs( BasicButton )
            .hover(
                function( event ) { self._trackMousein( event ); },
                function( event ) { self._trackMouseout( event ); }
            )
            ._renderButton();
    },
    
    /*
     * True if the button currently has the focus.
     */
    isFocused: Control.property.bool( null, false ),
    
    /*
     * True if the user is currently pressing down a key.
     */
    isKeyPressed: Control.property.bool( null, false ),
    
    /*
     * True if the mouse button is currently down.
     */
    isMouseButtonDown: Control.property.bool( null, false ),
    
    /*
     * True if the mouse is currently over the button.
     */
    isMouseOverControl: Control.property.bool( null, false ),
    
    _renderButtonState: function( buttonState ) {},
    
    _renderButton: function() {
        this._renderButtonState( this.buttonState() );
    },
    
    _trackBlur: function( event ) {
        
        this.removeClass( "focused" );

        // Losing focus causes the button to override any key that had been pressed.
        this.isKeyPressed( false );

        this.isFocused( false );
        this._renderButton();
    },
    
    _trackFocus: function( event ) {
        if ( !this.disabled() )  {
            this.addClass( "focused" );
            this.isFocused( true );
            this._renderButton();
        }
    },
    
    _trackKeydown: function( event ) {
        if ( !this.disabled() && (event.keyCode == 32 /* space */ || event.keyCode == 13 /* return */) ) {
            this.isKeyPressed( true );        
            this._renderButton();
        }
    },
    
    _trackKeyup: function( event ) {
        this.isKeyPressed( false );
        this._renderButton();
    },
    
    _trackMousedown: function( event ) {
        if ( !this.disabled() ) {
            this.addClass( "pressed" );
            this.isMouseButtonDown( true );
            this._renderButton();
        }
    },
    
    _trackMousein: function( event ) {
        if (!this.disabled() )  {
            this.addClass( "hovered" );
            this.isMouseOverControl( true );
            this._renderButton();
        }
    },
    
    _trackMouseout: function(event) {
        this
            .removeClass( "focused" )
            .removeClass( "hovered" )
            .removeClass( "pressed" );
        this.isMouseOverControl( false );
        this._renderButton();
    },
    
    _trackMouseup: function( event ) {
        this.removeClass( "pressed" );
        this.isMouseButtonDown( false );
        this._renderButton();
    }

});

/*
 * Class members
 */
BasicButton.extend({
    state: {
        normal: 0,
        hovered: 1,
        focused: 2,
        pressed: 3,
        disabled: 4
    }
});

//
// Blog
//
var Blog = Control.subclass({
    className: "Blog",
    content: [
        " ",
        {
            control: "List",
            id: "postList",
            itemClass: "BlogPost",
            mapFunction: "entry"
        },
        " "
    ]
});
$.extend(Blog.prototype, {
    
    apiKey: Control.property(),
    
    count: Control.property.integer(),
    
    feed: Control.property(),
    
    initialize: function() {
        this.reload();
    },
    
    reload: Control.iterator( function() {
        
        if ( !this.apiKey() || !this.feed() ) {
            return;
        }
        
        var url = "https://ajax.googleapis.com/ajax/services/feed/load?v=1.0";
        url += this._urlParam( "key", this.apiKey() );
        url += this._urlParam( "q", this.feed() );
        url += this._urlParam( "num", this.count() );
        url += this._urlParam( "callback", "?" );
        console.log( url );
        
        var self = this;
        $.getJSON( url )
            .success( function( data ) {
                var entries = ( data.responseData
                                && data.responseData.feed
                                && data.responseData.feed.entries )
                    ? data.responseData.feed.entries
                    : null;
                self._entries( entries );
            });
    }),
    
    _entries: Control.chain( "$postList", "items" ),
    
    _urlParam: function( key, value ) {
        return value
            ? "&" + key + "=" + value
            : ""; 
    }
});

//
// BlogPost
//
var BlogPost = Control.subclass({
    className: "BlogPost",
    content: [
        " ",
        {
            html: "<a target=\"_blank\" />",
            id: "BlogPost_postTitle"
        },
        " ",
        {
            html: "<div />",
            id: "BlogPost_content"
        },
        " "
    ]
});
BlogPost.prototype.extend({
    
    content: Control.chain( "$BlogPost_content", "content" ),
    
    entry: Control.property( function( entry ) {
        this.postTitle( entry.title );
        this.link( entry.link );
        this.content( entry.content );
    }),
    
    initialize: function() {
        this._super();
        this.genericIfClassIs( BlogPost );
    },
    
    postTitle: Control.chain( "$BlogPost_postTitle", "content" ),
    
    link: Control.chain( "$BlogPost_postTitle", "prop/href" )
    
});

//
// BrowserSpecific
//
var BrowserSpecific = Control.subclass({
    className: "BrowserSpecific"
});
BrowserSpecific.prototype.extend({

    /*
     * Content to show if none of the specified browsers apply.
     */
    "default": Control.property(),
    
    initialize: function() {
        var content;
        if ( $.browser.mozilla ) {
            content = this.mozilla();
        }
        else if ( $.browser.msie ) {
            content = this.msie();
        }
        if ( $.browser.opera ) {
            content = this.opera();
        }
        if ( $.browser.webkit ) {
            content = this.webkit();
        }
        if ( content === undefined ) {
            content = this[ "default" ]();
        }
        this.content( content );
    },
    
    /*
     * Content to show to Mozilla (Firefox) users.
     */
    mozilla: Control.property(),
    
    /*
     * Content to show to Microsoft Internet Explorer users.
     */
    msie: Control.property(),
    
    /*
     * Content to show to Opera users.
     */
    opera: Control.property(),
    
    /*
     * Content to show to WebKit (Chrome, Safari) users.
     */
    webkit: Control.property()

});

//
// CalendarDay
//
var CalendarDay = Control.subclass({
    className: "CalendarDay"
});
CalendarDay.prototype.extend({
    
    alternateMonth: Control.chain( "applyClass/alternateMonth" ),
    firstDayOfMonth: Control.chain( "applyClass/firstDayOfMonth" ),
    firstWeek: Control.chain( "applyClass/firstWeek" ),
    future: Control.chain( "applyClass/future" ),
    lastDayOfMonth: Control.chain( "applyClass/lastDayOfMonth" ),
    past: Control.chain( "applyClass/past" ),
    saturday: Control.chain( "applyClass/saturday" ),
    sunday: Control.chain( "applyClass/sunday" ),
    today: Control.chain( "applyClass/today" ),
    weekday: Control.chain( "applyClass/weekday" ),

    /*
     * The date to show.
     */
    date: Control.property.date( function( date ) {
        
        var today = CalendarDay.today();
        var dayOfWeek = date.getDay();
        var dayOfMonth = date.getDate();
        var nextDate = CalendarDay.addDays( date, 1 );
        var daysFromToday = Math.round( (date.getTime() - today.getTime()) / CalendarDay.MILLISECONDS_IN_DAY );
        
        this
            .past( date < today )
            .future( date > today )
            .firstDayOfMonth( dayOfMonth === 1 )
            .lastDayOfMonth( date.getMonth() !== nextDate.getMonth() )
            .firstWeek( dayOfMonth <= 7 )
            .sunday( dayOfWeek === 0 )
            .saturday( dayOfWeek === 6 )
            .weekday( dayOfWeek > 0 && dayOfWeek < 6 )
            .today( daysFromToday === 0 )
            .alternateMonth( Math.abs( date.getMonth() - today.getMonth()) % 2 === 1 )
            .content( date.getDate() );
    }),
    
    initialize: function() {
        
        this._super();
        this.genericIfClassIs( CalendarDay );
        
        var self = this;
        this.click( function( event ) {
            self.trigger( "dateSelected", [ self.date() ] );
        });
        
        if ( !this.date() ) {
            this.date( CalendarDay.today() );
        }
    }
    
});


// Class methods. These are general date utilities.
CalendarDay.extend({

    MILLISECONDS_IN_DAY: 24 * 60 * 60 * 1000,

    // Return the result of adding the specified number of days to the given date.
    addDays: function( date, days ) {
        
        // Use noon hour for date math, since adding/subtracting multiples of 24 hours
        // starting from noon is guaranteed to end up on the correct date (although
        // the hours might have changed).
        var noon = new Date( date.getTime() );
        noon.setHours( 11 );
        var result = new Date( noon.getTime() + (days * this.MILLISECONDS_IN_DAY) );
        
        // Restore original hours
        result.setHours( date.getHours() );
        return result;
    },
    
    midnightOnDate: function( date ) {
        var d = new Date( date.getTime() );
        d.setHours( 0 );
        d.setMinutes( 0 );
        d.setSeconds( 0 );
        d.setMilliseconds( 0 );
        return d;
    },
    
    today: function() {
        return this.midnightOnDate( new Date() );
    }

});

//
// CalendarDayButton
//
var CalendarDayButton = CalendarDay.subclass({
    className: "CalendarDayButton",
    content: [
        " ",
        {
            control: "BasicButton",
            id: "button",
            generic: "false"
        },
        " "
    ]
});
CalendarDayButton.prototype.extend({
    
    content: Control.chain( "$button", "content" ),
    
    initialize: function() {
        this._super();
        this.genericIfClassIs( CalendarDayButton );
    }
});

//
// CalendarMonth
//
var CalendarMonth = Control.subclass({
    className: "CalendarMonth",
    content: [
        " ",
        {
            control: "CalendarWeek"
        },
        " ",
        {
            control: "CalendarWeek"
        },
        " ",
        {
            control: "CalendarWeek"
        },
        " ",
        {
            control: "CalendarWeek"
        },
        " ",
        {
            control: "CalendarWeek"
        },
        " ",
        {
            control: "CalendarWeek"
        },
        " "
    ]
});
CalendarMonth.prototype.extend({

    $days: Control.chain( "find/.CalendarDay", "control" ),
    $weeks: Control.chain( "children", "control" ),
    
    /*
     * The control's current culture.
     */
    culture: function( culture ) {
        var result = this._super( culture );
        if ( culture !== undefined ) {
            this.$weeks().culture( culture );
            this._refresh();
        }
        return result;
    },

    /*
     * The class used to represent days in the month.
     */
    dayClass: Control.chain( "$weeks", "dayClass", function() { this._refresh(); } ),

    /*
     * The date that will be included in this month (can be any day of the month).
     */
    date: Control.property( function() {
        this
            ._refresh()
            .trigger( "dateChanged", [ this.date() ] );
    }),

    // Return the day control for the given date.
    dayControlForDate: function( date ) {
        return this.weekControlForDate( date ).dayControlForDate( date );
    },
    
    initialize: function() {
        
        this.genericIfClassIs( CalendarMonth );
        
        if ( !this.date() ) {
            // By default, show current month.
            this.date( CalendarDay.today() );
        }
    },
    
    /*
     * Returns the week control for the given date.
     */
    weekControlForDate: function( date ) {
        // TODO: Return null if date is not within this month.
        var dayOMonth = date.getDate();
        var weeksWithDate = this.map( function( index, month ) {
            var $weeks = $( month ).control().$weeks();
            var firstDayOfMonth = new Date( date.getTime() );
            firstDayOfMonth.setDate(1);
            var offset = $weeks.daysSinceFirstDayOfWeek( firstDayOfMonth );
            var week = Math.floor( ( date.getDate() + offset - 1 ) / 7 );
            return $weeks[ week ];
        });
        var $weeksWithDate = $().add( weeksWithDate ).control();
        return $weeksWithDate;
    },
    
    _refresh: function() {

        // Use midnight on the given date as a reference point.
        var firstDayOfMonth = CalendarDay.midnightOnDate( this.date() );
        firstDayOfMonth.setDate(1);
        
        // Get last day of month by going to first day of next month and backing up a day.
        var lastDayOfMonth = new Date( firstDayOfMonth.getTime() );
        lastDayOfMonth.setMonth( lastDayOfMonth.getMonth() + 1 );
        lastDayOfMonth.setDate( lastDayOfMonth.getDate() - 1);
        
        // Fill in the weeks.
        var month = firstDayOfMonth.getMonth();
        this.$weeks().eachControl( function( weekRow, $week) {

            $week.date( CalendarDay.addDays( firstDayOfMonth, 7 * weekRow ) );
            
            // Hide weeks completely in another month (i.e., the next month).
            // Apply "hidden" class to preserve week's original "display" property.
            var $days = $week.$days();
            var firstDayOfWeek = $days.eq(0).date();
            var lastDayOfWeek = $days.eq(6).date();
            var isWeekInMonth = ( firstDayOfWeek.getMonth() === month || lastDayOfWeek.getMonth() === month );
            $week.toggleClass( "hidden", !isWeekInMonth );
        });
        
        // Paint days inside and outside range.
        this.$days().eachControl( function( index, $day ) {
            var date = $day.date();
            var insideMonth = date
                ? ( date >= firstDayOfMonth && date <= lastDayOfMonth )
                : false;
            $day
                .toggleClass( "insideMonth", insideMonth )
                .toggleClass( "outsideMonth", !insideMonth );

        });
        
        return this;
    }
    
});

//
// CalendarMonthWithHeadings
//
var CalendarMonthWithHeadings = Control.subclass({
    className: "CalendarMonthWithHeadings",
    content: [
        " ",
        {
            control: "MonthName",
            id: "monthName"
        },
        " ",
        {
            html: "<div />",
            id: "monthTable",
            content: [
                " ",
                {
                    control: "DaysOfWeek",
                    id: "daysOfWeek",
                    format: "namesShort"
                },
                " ",
                {
                    control: "CalendarMonth",
                    id: "calendar"
                },
                " "
            ]
        },
        " "
    ]
});
CalendarMonthWithHeadings.prototype.extend({
    
    $days: Control.chain( "$calendar", "$days" ),
    
    /*
     * The control's current culture.
     */
    culture: function( culture ) {
        var result = this._super( culture );
        if ( culture !== undefined ) {
            this.$monthName().culture( culture );
            this.$daysOfWeek().culture( culture );
            this.$calendar().culture( culture );
            var date = this.date();
            if ( date ) {
                this.date( date );
            }
        }
        return result;
    },
    
    /* The date shown in the calendar */
    date: Control.chain( "$calendar", "date", function( date ) {
        this.$monthName().month( date.getMonth() );
    }),
    
    /*
     * The class used to represent days in the month.
     */
    dayClass: Control.chain( "$calendar", "dayClass" ),

    /*
     * Returns the control currently showing the given date.
     */
    dayControlForDate: function( date ) {
        return this.$calendar().dayControlForDate( date );
    },

    /*
     * The format used to show day headings. See DaysOfWeek.
     */
    dayNameFormat: Control.chain( "$daysOfWeek", "format" ),
    
    initialize: function() {
        
        this.genericIfClassIs( CalendarMonthWithHeadings );
        
        if ( !this.date() ) {
            // By default, show current month.
            this.date( CalendarDay.today() );
        }
    },

    /*
     * True if the name of the month should be shown.
     */
    showMonthName: Control.chain( "$monthName", "visibility" )
    
});

//
// CalendarWeek
//
var CalendarWeek = Control.subclass({
    className: "CalendarWeek",
    content: [
        " ",
        {
            control: "CalendarDay"
        },
        " ",
        {
            control: "CalendarDay"
        },
        " ",
        {
            control: "CalendarDay"
        },
        " ",
        {
            control: "CalendarDay"
        },
        " ",
        {
            control: "CalendarDay"
        },
        " ",
        {
            control: "CalendarDay"
        },
        " ",
        {
            control: "CalendarDay"
        },
        " "
    ]
});
CalendarWeek.prototype.extend({
    
    /*
     * The control's current culture.
     */
    culture: function( culture ) {
        var result = this._super( culture );
        if ( culture !== undefined ) {
            this._refresh();
        }
        return result;
    },
    
    /*
     * The date that will be included in this week (can be any day of the week).
     */
    date: Control.property( function() { this._refresh(); }),

    /*
     * Returns the control currently used to represent the given date.
     */
    dayControlForDate: function( date ) {
        // TODO: Return null if date is not within this week.
        var days = this.map( function( index, week ) {
            var $week = $( week ).control();
            var dayIndex = $week.daysSinceFirstDayOfWeek( date );
            return $week.$days()[ dayIndex ];
        });
        var $days = $().add( days ).control();
        return $days;
    },

    /* The collection of day cells */
    $days: Control.chain( "children", "control" ),
    
    /*
     * The class used to represent days in the week.
     */
    dayClass: Control.chain( "$days", "transmute", function() { this._refresh(); } ),
    
    initialize: function() {
        if ( !this.date() ) {
            // Default date range is the current week.
            this.date( CalendarDay.today() );
        }
    },
    
    daysSinceFirstDayOfWeek: function( date ) {
        var firstDayOfWeek = this.firstDayOfWeek();
        return ( date.getDay() - firstDayOfWeek + 7 ) % 7;
    },
    
    // Return the index of the "first day of the week" in the current culture.
    // In English, this is 0 (Sunday), but in many places its 1 (Monday).
    firstDayOfWeek: function() {
        var culture = this.culture();
        return culture ? culture.calendar.firstDay : 0;
    },
    
    /*
     * Set the dates on all controls in the week.
     */
    _refresh: function() {

        // Use midnight on the given date as a reference point.
        date = CalendarDay.midnightOnDate( this.date() );

        // Get the first day of the week containing this date (e.g., Sunday).
        var dateStart = CalendarDay.addDays( date, -this.daysSinceFirstDayOfWeek( date ) );
        
        // Fill in the date range.
        this.$days().eachControl( function( index, $day) {
            $day.date( CalendarDay.addDays( dateStart, index ) );
        });
    }
    
});

//
// Collapsible
//
var Collapsible = Control.subclass({
    className: "Collapsible",
    content: [
        " ",
        {
            html: "<div />",
            id: "Collapsible_heading"
        },
        " ",
        {
            html: "<div />",
            id: "Collapsible_content"
        },
        " "
    ]
});
Collapsible.prototype.extend({
    
    /*
     * The control's contents which can be expanded and collapsed.
     */
    content: Control.chain( "$Collapsible_content", "content" ),
    
    /*
     * The speed of the expand/collapse animation, in milliseconds.
     */
    duration: Control.property( null, "fast" ),
    
    /*
     * Get or set the control's collapsed state.
     * When called as a setter, a true value collapsed the control;
     * a false value expands the control.
     */
	collapsed: Control.iterator( function( value ) {
	    if ( value === undefined )
	    {
	        // Getter
	        return this._collapsed();
	    } else {
	        // Setter
	        if ( this.inDocument() ) {
	            // Animate if in document.
                var result = value ? "hide" : "show";
                var self = this;
                this.$Collapsible_content().animate(
                    { 
                        height: result,
                        opacity: result
                    },
                    this.duration(),
                    null,
                    function() {
                        /* Wait until animation completes to apply collapsed style. */
                        self.toggleClass( "collapsed", value );
                    }
                );
	        } else {
	            // Not in document, animation won't work.
	            this
	               .toggleClass( "collapsed", value )
	               .$Collapsible_content().toggle( !value );
	        }
            
            if ( this._collapsed() !== value ) {
                this.trigger( "collapsedChanged" );
                this._collapsed( value );
            }
	    }
	}),
    
    /*
     * The control's heading. By default, a click anywhere within the heading
     * toggles the control's collapsed state.
     * 
     * This can be empty if the application wants to programmatically control
     * the collapsed state in some other means.
     */
    heading: Control.chain( "$Collapsible_heading", "content" ),
    
    initialize: function() {
        
        this._super();
        this.genericIfClassIs( Collapsible );
        
        var self = this;
        this.$Collapsible_heading().click( function() {
            if ( self.toggleOnClick() ) {
                self.toggleCollapse();
            }
        });
    },
	
	/*
	 * Toggle the collapsed state of the control.
	 */
	toggleCollapse: function()
	{
		this.collapsed( !this.collapsed() );
	},
	
	/*
	 * True if the control should toggle its state when the user clicks in
	 * the heading. Default is true.
	 */
    toggleOnClick: Control.property.bool( null, true ),
    
    _collapsed: Control.property.bool( null, false )

});

//
// CollapsibleWithHeadingButton
//
var CollapsibleWithHeadingButton = Collapsible.subclass({
    className: "CollapsibleWithHeadingButton",
    heading: [
        " ",
        {
            control: "BasicButton",
            id: "headingButton",
            content: [
                " ",
                {
                    html: "<div>+</div>",
                    id: "collapsedButtonContent"
                },
                " ",
                {
                    html: "<div>−</div>",
                    id: "expandedButtonContent"
                },
                " "
            ]
        },
        " ",
        {
            control: "Fader",
            id: "CollapsibleWithHeadingButton_heading"
        },
        " "
    ]
});
CollapsibleWithHeadingButton.prototype.extend({
    
    /*
     * The class of the heading button.
     */
    buttonClass: Control.chain( "$headingButton", "transmute" ),
    
    /*
     * The content of the heading button when the panel is collapsed.
     */
    collapsedButtonContent: Control.chain( "$collapsedButtonContent", "content" ), 
    
    /*
     * The content of the heading button when the panel is expanded.
     */
    expandedButtonContent: Control.chain( "$expandedButtonContent", "content" ),
    
    /*
     * The heading shown at the top of the panel.
     */
    heading: Control.chain( "$CollapsibleWithHeadingButton_heading", "content" ),
    
    initialize: function() {
        this._super();
        this.genericIfClassIs( CollapsibleWithHeadingButton );
        
        var $button = this.$headingButton();
        this.$CollapsibleWithHeadingButton_heading().hover(
            function hoverIn() { $button.addClass( "hovered" ); },
            function hoverOut() { $button.removeClass( "hovered" ); }
        );
    }
    
});

//
// ColorSwatch
//
var ColorSwatch = Control.subclass({
    className: "ColorSwatch"
});
ColorSwatch.prototype.extend({
    
    /*
     * The color to show. This will become the control's background color.
     */
    color: function( color ) {
        if ( color === undefined ) {
            return this.css( "background-color" );
        } else {
            
            this
                .css( "background-color", "white" ) // Apply white first
                .css( "background-color", color );  // Apply new color
        
            /* Validate the color value. */
            var colorValid;
            if ( color === "" || color === null ) {
                colorValid = false;
            } else if ( color === "white" || color === "rgb(255, 255, 255)" ) {
                // White color values are known to be good.
                colorValid = true;
            } else {
                // See if the new value "stuck", or is still white.
                var colorValue = this.css( "background-color" );
                colorValid = !( colorValue === "white" || colorValue === "rgb(255, 255, 255)" );
            }
            return this.toggleClass( "none", !colorValid );
        }
    }
    
});

//
// ColorSwatchButton
//
var ColorSwatchButton = BasicButton.subclass({
    className: "ColorSwatchButton",
    content: [
        " ",
        {
            control: "ColorSwatch",
            id: "swatch"
        },
        {
            html: "<div />",
            id: "ColorSwatchButton_content"
        },
        " "
    ]
});
ColorSwatchButton.prototype.extend({
    
    /*
     * The color to show.
     */
    color: Control.chain( "$swatch", "color" ),
    
    /*
     * The swatch's label. Setting this implicitly sets the color to show
     * the color with the indicated name.
     */
    content: Control.chain( "$ColorSwatchButton_content", "content", function( content ) {
        this.$swatch().color( content );
    }),

    initialize: function() {
        this._super();
        this.genericIfClassIs( ColorSwatchButton );
    }
    
});

//
// ColorSwatchTextBox
//
var ColorSwatchTextBox = Control.subclass({
    className: "ColorSwatchTextBox",
    content: [
        " ",
        {
            control: "ColorSwatch",
            id: "swatch"
        },
        {
            html: "<input type=\"text\" />",
            id: "ColorSwatchTextBox_content"
        },
        " "
    ]
});
ColorSwatchTextBox.prototype.extend({

    /*
     * The text box's content. Setting this to a color name or RGB value
     * will show a color swatch of the color with that name.
     */
    content: Control.chain( "$ColorSwatchTextBox_content", "content", function( content ) {
        this._refresh();
    }),
    
    initialize: function() {
        var self = this;
        this.keyup( function() {
            self._refresh();
        });
    },
    
    _refresh: function() {
        this.$swatch().color( this.content() );
    }
    
});

//
// DaysOfWeek
//
var DaysOfWeek = Control.subclass({
    className: "DaysOfWeek",
    content: " <div class=\"dayOfWeek\" /> <div class=\"dayOfWeek\" /> <div class=\"dayOfWeek\" /> <div class=\"dayOfWeek\" /> <div class=\"dayOfWeek\" /> <div class=\"dayOfWeek\" /> <div class=\"dayOfWeek\" /> "
});
DaysOfWeek.prototype.extend({
    
    /*
     * The control's current culture.
     */
    culture: function( culture ) {
        var result = this._super( culture );
        if ( culture !== undefined ) {
            this.format( this.format() );
        }
        return result;
    },
    
    /*
     * The format used to show the names of the day. These are defined by
     * the Globalize library:
     * 
     *  "names": the full name, e.g. "Sunday".
     *  "namesAbbreviated": an abbreviated name, e.g., "Sun".
     *  "namesShort": an even shorter name, e.g., "Su".
     */
    format: Control.property( function( format ) {
        
        var culture = this.culture();
        var dayNameEnum = culture ? culture.calendar.days : DaysOfWeek.days;
        var dayNames = dayNameEnum[ format ];
        
        var firstDay = culture ? culture.calendar.firstDay : 0;
        
        var $children = this.children();
        for ( var i = 0; i < dayNames.length; i++ ) {
            var day = (i + firstDay) % 7;
            var dayName = dayNames[ day ];
            $children.eq(i).content( dayName );
        }
    }),

    initialize: function() {
        this.genericIfClassIs( DaysOfWeek );
        if ( !this.format() ) {
            this.format( "namesAbbr" );
        }
    }
    
});

DaysOfWeek.extend({
    
    // Default names; used if Globalize is not loaded.
    days: {
        // full day names
        names: [ "Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday" ],
        
        // abbreviated day names
        namesAbbr: [ "Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat" ],
        
        // shortest day names
        namesShort: [ "Su", "Mo", "Tu", "We", "Th", "Fr", "Sa" ]
    }
    
})

//
// DeviceSpecific
//
var DeviceSpecific = Control.subclass({
    className: "DeviceSpecific"
});
DeviceSpecific.prototype.extend({

    /*
     * The content to use when the current device is not a mobile device.
     */
    "default": Control.property(),
    
    /*
     * The class of the control when the current device is not a mobile device.
     */
    "defaultClass": Control.property[ "class" ](),
    
    initialize: function() {

        var deviceClass;
        var deviceClasses;
        var deviceContent;

        // Determine which content, class, and styles to apply.        
        if ( DeviceSpecific.isMobile() ) {
            deviceClass = this.mobileClass();
            deviceClasses = "mobile";
            deviceContent = this.mobile();
        }
        if ( deviceClass === undefined ) {
            deviceClass = this.defaultClass();
        }
        if ( deviceContent === undefined ) {
            deviceContent = this["default"]();
        }
        
        // Transmute, if requested. After this, we need to take care to 
        // reference the control with the new class; "this" will be the old class. 
        var $control = deviceClass
            ? this.transmute( deviceClass, false, true )
            : this;
        
        // Apply device-specific content, if defined.
        if ( deviceContent ) {
            $control.content( deviceContent );
        }
        
        // Apply device-specific CSS classes, if defined.
        if ( deviceClasses ) {
            $control.addClass( deviceClasses );
        }
    },
    
    /*
     * The content to use when the current device is a mobile device.
     */
    "mobile": Control.property(),
    
    /*
     * The class of the control when the current device is a mobile device.
     */
    "mobileClass": Control.property[ "class" ]()

});

// Class methods
DeviceSpecific.extend({
    isMobile: function() {
        var userAgent = navigator.userAgent;
        return ( userAgent.indexOf("Mobile") >= 0 && userAgent.indexOf("iPad") < 0 ); 
    }    
});

//
// DictionaryTable
//
var DictionaryTable = ArrayTable.subclass({
    className: "DictionaryTable"
});
DictionaryTable.prototype.extend({

    /*
     * A standard JavaScript { key: value } dictionary to render as a table.
     * Each item will be a row with two columns for its key and value.
     */
    content: Control.property( function( dictionary ) {
        var array = $.map( dictionary, function( value, key ) {
            return [ [ key, value ] ];
        });
        ArrayTable( this ).content( array );
    })
    
});

//
// Fader
//
var Fader = Control.subclass({
    className: "Fader",
    content: [
        " ",
        {
            html: "<div />",
            id: "Fader_content"
        },
        " ",
        {
            control: "Gradient",
            id: "gradient",
            direction: "horizontal"
        },
        " "
    ]
});
Control.prototype.extend({
    
});

Fader.prototype.extend({
    
    content: Control.chain( "$Fader_content", "content" ),
    
    /*
     * The direction in which the content should fade. If "horizontal" (the
     * default), the content will fade to the right. If "vertical", the content
     * will fade to the bottom. 
     */
    direction: Control.property( function( direction ) {
        this
            .toggleClass( "vertical", direction !== "horizontal" )
            ._redraw()
            .$gradient()
                .direction(direction);
    }),
    
    initialize: function() {
        this.inDocument( function( $control ) {
            $control._redraw();
        });
    },
    
    // Expand a color like #abc into #aabbcc.
    _expandShortHexValue: function( s ) {
        var shortHex = s.slice( 1 ); // Remove "#"
        var longHex = "";
        for ( var i = 0; i < shortHex.length; i++ ) {
            var c = shortHex[i];
            longHex += c + c;
        }
        return "#" + longHex;
    },

    _hexByte: function( n ) {
        var s = ( new Number( n & 0xFF ) ).toString( 16 );
        if ( s.length === 1 )
        {
            s = "0" + s;
        }
        return s;
    },
    
    _redraw: Control.iterator( function() {
        var backgroundColor = this.css("background-color");
        var backgroundHex = ( backgroundColor.length === 4 )
            ? this._expandShortHexValue( backgroundColor ) 
            : ( backgroundColor.substr( 0, 3 ).toLowerCase() === "rgb" )
                ? this._rgbStringToHexColor( backgroundColor )
                : backgroundColor;
        this.$gradient()
            .start( backgroundHex + "00" )
            .end( backgroundHex );
    }),
    
    _rgbStringToHexColor: function( rgbString ) {
        rgb = rgbString.match(/^rgb\((\d+),\s*(\d+),\s*(\d+)\)$/);
        return "#"
            + this._hexByte( rgb[1] )
            + this._hexByte( rgb[2] )
            + this._hexByte( rgb[3] );
    }
    
});

//
// FlickrInterestingDay
//
var FlickrInterestingDay = CalendarDay.subclass({
    className: "FlickrInterestingDay",
    content: [
        " ",
        {
            html: "<div />",
            id: "FlickrInterestingDay_content"
        },
        " ",
        {
            html: "<a />",
            id: "link",
            content: [
                " ",
                {
                    html: "<img />",
                    id: "image"
                },
                " "
            ]
        },
        " "
    ]
});
FlickrInterestingDay.prototype.extend({
    
    /*
     * True if the control should automatically load the photo when the date
     * is set. Default is false.
     */
    autoLoad: Control.property.bool( function( autoLoad ) {
        if ( autoLoad && this.image() == null ) {
            this.loadPhoto();
        }
    }),
    
    /*
     * The date to show.
     */
    date: function( date ) {
        var result = this._super( date );
        if ( date !== undefined ) {
            this
                .image( null )
                .href( null );
            if ( this.autoLoad() ) {
                this.loadPhoto();
            }
        }
        return result;
    },

    content: Control.chain( "$FlickrInterestingDay_content", "content" ),
    
    /*
     * The location of the Flickr page for the photo.
     */
    href: Control.chain( "$link", "attr/href" ),
    
    /*
     * The location of the photo image on Flickr.
     */
    image: Control.chain( "$image", "attr/src" ),
    
    initialize: function() {
        if ( !this.date() ) {
            // Default day is *yesterday* (since we need a date in the past).
            var date = CalendarDay.today();
            date.setDate( date.getDate() - 1 );
            this.date( date );
        }
    },
    
    /*
     * Load the photo for the given date.
     */
    loadPhoto: Control.iterator( function() {
        var date = this.date();
        // Flickr only has a photo for dates entirely in the past (not for today).
        if ( date && date < CalendarDay.today() ) {
            
            var self = this;
            FlickrInterestingDay.getInterestingPhotoForDate( date, function( photo ) {
                // Double-check we got a photo, and also check that the date
                // hasn't been changed since the photo was requested.
                if ( photo && date === self.date() ) {
                    self.image( photo.src );
                }
            });
            
            // Clicking the day navigates to list of the day's interesting photos.
            var baseUrl = "http://www.flickr.com/explore/interesting/";
            var url = baseUrl
                + date.getFullYear() + "/"
                + ( date.getMonth() + 1 ) + "/"
                + date.getDate();
            this.href( url );
        }
    })

});

// Class methods
FlickrInterestingDay.extend({
    
    // Please replace with your own API key.
    _flickrApiKey: "c3685bc8d8cefcc1d25949e4c528cbb0",
    
    // Cache of photos already loaded, indexed by Flickr-style date string. 
    _cache: {},
    
    getInterestingPhotoForDate: function( date, callback ) {
        
        var flickrDate = this._formatFlickrDate( date );
        var cachedPhoto = this._cache[ flickrDate ];
        if ( cachedPhoto ) {
            callback( cachedPhoto );
            return;
        }
        
        var params = {
            method: "flickr.interestingness.getList",
            date: flickrDate,
            per_page: 1
        };
        var self = this;
        this.getFlickrPhotos( params, function( flickrPhotos ) {
            if ( flickrPhotos && flickrPhotos.length > 0 ) {
                var first = flickrPhotos[0];
                var photo = {
                    src: self.getFlickrImageSrc( first, "s" /* Small thumbnail */ ),
                    href: self.getFlickrImageHref( first )
                };
                self._cache[ flickrDate ] = photo;
                callback( photo );
            }
        });
    },
    
    getFlickrPhotos: function( params, callback ) {

        var baseUrl = "http://api.flickr.com/services/rest/";
        
        // Note: JSONP in jQuery usually calls for callback=?, but the Flickr
        // API wants jsoncallback=?. Thankfully, jQuery supports that.
        var url = baseUrl
                    + "?api_key=" + this._flickrApiKey
                    + this._formatUrlParams( params )
                    + "&format=json"
                    + "&jsoncallback=?";

        $.getJSON( url )
            .success( function( data ) {
                if ( data && data.photos ) {
                    callback( data.photos.photo );
                }
            });
    },
    
    getFlickrImageSrc: function( flickrPhoto, size ) {
        var sizeParam = ( size ? "_" + size : "" );
        return "http://farm" + flickrPhoto.farm +
               ".static.flickr.com/" + flickrPhoto.server +
               "/" + flickrPhoto.id +
               "_" + flickrPhoto.secret +
               sizeParam +
               ".jpg";
    },
    
    getFlickrImageHref: function(flickrPhoto) {
        return "http://flickr.com/photo.gne?id=" + flickrPhoto.id;
    },
    
    // Return a date in YYYY-MM-DD format.
    _formatFlickrDate: function( date ) {
        var year = date.getFullYear();
        var month = date.getMonth() + 1;
        var day = date.getDate();
        var s = year + "-" +
                ( ( month < 10 ) ? "0" : "" ) + month + "-" +
                ( ( day < 10 ) ? "0" : "" ) + day;
        return s;
    },
    
    // Convert the given params dictionary into a string that can be
    // passed on a URL.
    _formatUrlParams: function( params ) {
        var s = "";
        $.each( params, function( key, value ) {
            s += "&" + key + "=" + value;
        });
        return s;
    }

});

//
// FlickrInterestingPhoto
//
var FlickrInterestingPhoto = Control.subclass({
    className: "FlickrInterestingPhoto",
    tag: "img"});
FlickrInterestingPhoto.prototype.extend({
    
    initialize: function() {
        
        var self = this;
        this.on( "load" , function() {
            
            /*
             * HACK for IE. When the load event is triggered, IE seems to report
             * the width of the img element as the width of its own little
             * image placeholder icon. By forcing the width to "auto"
             * value, IE reports the correct photo width (and height) instead.
             * This allows anyone listening for the sizeChanged event to get
             * the correct dimensions of the photo, instead of the dimensions
             * of the image placeholder icon. 
             */
            self.css( "width", "auto" );

            self.trigger( "sizeChanged" );
        });

        var photo = this.photo();
        if ( !photo || photo.length === 0 ) {
            this.reload();
        }
    },
    
    /*
     * Reload the photo.
     */
    reload: Control.iterator( function() {
        var self = this;
        FlickrInterestingPhoto.getRandomPhoto( function( photo ) {
            self.prop( "src", photo );
        }, this.photoSize());
    }),
    
    /*
     * The location of the current photo image.
     */
    photo: Control.chain( "attr/src" ),
    
    /*
     * The size of photo to show.
     * 
     * This uses the size suffixes from http://www.flickr.com/services/api/misc.urls.html
     * s   small square 75x75
     * t   thumbnail, 100 on longest side
     * m   small, 240 on longest side
     * -   medium, 500 on longest side
     * z   medium 640, 640 on longest side
     * o   original image, either a jpg, gif or png, depending on source format
     * 
     * If this property is not set, the photo will be medium size.
     */
    photoSize: Control.property( function() {
        var photo = this.photo();
        if ( photo && photo.length > 0 ) {
            this.reload();
        }
    })
    
});

// Class methods
FlickrInterestingPhoto.extend({
    
    /*
     * Flickr API key.
     * By default, this uses the QuickUI account API key. Set
     * FlickrInterestingPhoto.apiKey = "<your key here>" before the first
     * call to this control to use your own key.
     */
    apiKey: "c3685bc8d8cefcc1d25949e4c528cbb0",
    
    getRandomPhoto: function( callback, size ) {
        var self = this;
        this.getFlickrInterestingPhotos( function( flickrPhotos ) {
            self._counter = ( self._counter >= 0 )
                ? ( self._counter + 1 ) % flickrPhotos.length 
                : 0;
            var flickrPhoto = flickrPhotos[ self._counter ];
            var photo = self.getFlickrImageSrc( flickrPhoto, size );
            callback( photo );
        });
    },
    
    getFlickrInterestingPhotos: function( callback ) {
        
        if ( this._flickrPhotos ) {
            callback( this._flickrPhotos );
            return;
        }
        
        var day = new Date();
        day.setDate( day.getDate() - 2 );   // Day before yesterday
        var flickrDate = this._formatFlickrDate( day );
        
        var params = {
            method: "flickr.interestingness.getList",
            date: flickrDate,
            per_page: 100
        };

        var self = this;
        this.getFlickrPhotos( params, function( flickrPhotos ) {
            self._shuffle( flickrPhotos );
            self._flickrPhotos = flickrPhotos;
            callback( self._flickrPhotos );
        });
    },
    
    getFlickrPhotos: function( params, callback ) {
        var baseUrl = "http://api.flickr.com/services/rest/";
        var url = baseUrl 
                    + "?api_key=" + this.apiKey
                    + this._formatUrlParams( params )
                    + "&format=json"
                    + "&jsoncallback=?";
        $.getJSON( url, function( data ) {
            if ( data && data.photos ) {
                callback( data.photos.photo );
            }
        });
    },
    
    getFlickrImageSrc: function( flickrPhoto, size ) {
        var sizeParam = ( size ? "_" + size : "" );
        return "http://farm" + flickrPhoto.farm +
               ".static.flickr.com/" + flickrPhoto.server +
               "/" + flickrPhoto.id +
               "_" + flickrPhoto.secret +
               sizeParam +
               ".jpg";
    },
    
    getFlickrImageHref: function(flickrPhoto) {
        return "http://flickr.com/photo.gne?id=" + flickrPhoto.id;
    },
    
    // Return a date in YYYY-MM-DD format.
    _formatFlickrDate: function( date ) {
        var year = date.getFullYear();
        var month = date.getMonth() + 1;
        var day = date.getDate();
        var s = year + "-" +
                ( ( month < 10 ) ? "0" : "" ) + month + "-" +
                ( ( day < 10 ) ? "0" : "" ) + day;
        return s;
    },
    
    // Convert the given params dictionary into a string that can be
    // passed on a URL.
    _formatUrlParams: function( params ) {
        var s = "";
        $.each( params, function( key, value ) {
            s += "&" + key + "=" + value;
        });
        return s;
    },

    /*
     * Perform a Fisher-Yates shuffle.
     * From http://sedition.com/perl/javascript-fy.html
     */
    _shuffle: function( array ) {
        var i = array.length;
        while (--i >= 0)
        {
            var j = Math.floor( Math.random() * ( i + 1 ) );
            var temp = array[i];
            array[i] = array[j];
            array[j] = temp;
        }
    }

});

//
// Gradient
//
var Gradient = Control.subclass({
    className: "Gradient"
});
Gradient.prototype.extend({

    /*
     * The direction of the gradient: "horizontal" or "vertical" (the default).
     */    
    direction: Control.property(function() { this._redraw(); }, "vertical"),
    
    /*
     * The ending value for the gradient.
     */
    end: Control.property(function() { this._redraw(); }),
    
    initialize: function() {
        this._redraw();
    },

    /*
     * The starting value for the gradient.
     */
    start: Control.property(function() { this._redraw(); }),
    
    _redraw: function() {
        var direction = this.direction();
        var start = this.start();
        var end = this.end();
        if (direction && start && end)
        {
            var horizontal = (direction === "horizontal");
            var startColorString = this._hexColorToRgbString(start);
            var endColorString = this._hexColorToRgbString(end);
            var property;
            var value;
            if ($.browser.mozilla)
            {
                property = "background-image";
                var position = horizontal ? "left" : "top";
                value = "-moz-linear-gradient(" + position + ", " + startColorString + ", " + endColorString + ")";
            }
            else if ($.browser.webkit)
            {
                property = "background-image"; 
                var position2 = horizontal ? "right top" : "left bottom";
                value = "-webkit-gradient(linear, left top, " + position2 + ", from(" + startColorString + "), to(" + endColorString + "))";
            }
            else if ($.browser.msie)
            {
                property = "filter";
                var gradientType = horizontal ? 1 : 0;
                value = "progid:DXImageTransform.Microsoft.gradient(gradientType=" + gradientType + ", startColorStr=" + startColorString + ", endColorStr=" + endColorString + ")"; 
            }

            this.css(property, value);
        }
    },
    
    /* Convert a hex color like #00ff00 to "rgb(0, 255, 0)" */
    _hexColorToRgbString: function(hex) {
        
        if (hex.substr(0, 1) == "#")
        {
            // Remove "#"
            hex = hex.substring(1);
        }
        var hasAlpha = (hex.length == 8);
        var color = parseInt(hex, 16);
        var a;
        
        var rgbString;
        if ($.browser.msie)
        {
            // Internet Explorer
            rgbString = hex;
            if (hasAlpha)
            {
                // Move alpha to front, from RGBA to ARGB.
                a = rgbString.slice(6);
                rgbString = a + rgbString.substr(0, 6);
            }
            rgbString = "#" + rgbString; 
        }
        else
        {
            // WebKit, Mozilla
            var colorStringType = hasAlpha ? "rgba" : "rgb";
            var alphaString = "";
            if (hasAlpha)
            {
                // Convert alpha from hex to decimal.
                a = (color & 0xFF) / 255;
                alphaString = "," + a;
                color = color >> 8;
            }
            
            var r = (color >> 16) & 0xFF;
            var g = (color >> 8)  & 0xFF;
            var b = color         & 0xFF;
            
            rgbString = colorStringType + "(" + r + "," + g + "," + b + alphaString + ")";
        }
        
        return rgbString;
    }
    
});

//
// HasPopup
//
var HasPopup = Control.subclass({
    className: "HasPopup",
    content: [
        " ",
        {
            html: "<div />",
            id: "HasPopup_content"
        },
        " ",
        {
            control: "Popup",
            id: "HasPopup_popup"
        },
        " "
    ]
});
HasPopup.prototype.extend({

    /*
     * Cancels the popup.
     */    
    cancel: Control.chain( "$HasPopup_popup", "cancel" ),

    /*
     * True if the user can cancel an open popup by pressing the Escape key.
     * Default is true.
     */
    cancelOnEscapeKey: Control.chain( "$HasPopup_popup", "cancelOnEscapeKey" ),

    /*
     * True if the popup should be cancelled if the user clicks outside it.
     * Default is true.
     */
    cancelOnOutsideClick: Control.chain( "$HasPopup_popup", "cancelOnOutsideClick" ),

    /*
     * The element(s) with which the popup will be associated. By default,
     * clicking in the content will open the popup, and the popup will be
     * positioned with respect to this content.
     */
    content: Control.chain( "$HasPopup_content", "content" ),

    /*
     * Close the popup normally.
     */
    close: Control.chain( "$HasPopup_popup", "close" ),

    /*
     * True if the popup should be closed normally if the user clicks inside
     * it. Default is true.
     */
    closeOnInsideClick: Control.chain( "$HasPopup_popup", "closeOnInsideClick" ),

    initialize: function()
    {
        var self = this;
        this.$HasPopup_content().click( function() {
            if ( self.openOnClick() ) {
                self.open();
            }
        });
        this.$HasPopup_popup().positionPopup( function() {
            self._positionPopup();
        });
    },

    /*
     * True if the popup should open when the user clicks in the control's
     * content. Default is true. 
     */
    openOnClick: Control.property.bool(),

    /*
     * Open the popup.
     */
    open: Control.chain( "$HasPopup_popup", "open" ),
    
    /*
     * True if the popup is currently opened.
     */
    opened: Control.chain( "$HasPopup_popup", "opened" ),
    
    /*
     * The content of the popup associated with the control.
     */
    popup: Control.chain( "$HasPopup_popup", "content" ),
    
    _positionPopup: function() {
        
        var offset = this.offset();
        var position = this.position();
        var top = Math.round( offset.top );
        var left = Math.round( offset.left );
        var height = this.outerHeight();
        var width = this.outerWidth();
        var bottom = top + height;
        var right = left + width;
        
        var $popup = this.$HasPopup_popup();
        var popupHeight = $popup.outerHeight( true );
        var popupWidth = $popup.outerWidth( true );

        var scrollTop = $( document ).scrollTop();
        var scrollLeft = $( document ).scrollLeft();
        var windowHeight = $( window ).height();
        var windowWidth = $( window ).width();

        // Vertically position below (preferred) or above the content.
        var popupFitsBelow = ( bottom + popupHeight <= windowHeight + scrollTop );
        var popupFitsAbove = ( top - popupHeight >= scrollTop ); 
        var popupTop = ( popupFitsBelow || !popupFitsAbove )
                            ? position.top + height         // Show below
                            : position.top - popupHeight;   // Show above
        
        // Horizontally left (preferred) or right align w.r.t. content.
        var popupFitsLeftAligned = ( left + popupWidth <= windowWidth + scrollLeft );
        var popupFitsRightAligned = ( right - popupWidth >= scrollLeft );
        var popupLeft = ( popupFitsLeftAligned || !popupFitsRightAligned )
                            ? position.left                         // Left align
                            : position.left + width - popupWidth;   // Right align 

        $popup.css({
            "top": popupTop,
            "left": popupLeft
        });
    }
});

//
// HighlightEffects
//
var HighlightEffects = Control.subclass({
    className: "HighlightEffects",
    content: [
        " ",
        {
            html: "<div />",
            id: "HighlightEffects_content"
        },
        " "
    ]
});
HighlightEffects.prototype.extend({
    
    _originalState: Control.property(),
    
    /*
     * The control's content.
     */
    content: Control.chain( "$HighlightEffects_content", "content", function() {
        if ( this.inDocument() ) {
            this._recalc();
        }
    }),
    
    /*
     * The speed with which animations are applied.
     * 
     * This uses a faster default than $.animate(), since hover animations
     * should generally respond quickly.
     * 
     * Set this to 0 to have effects applied instantaneously.
     */
    duration: Control.property( null, 100 ),
    
    /*
     * The effects that will be applied on hover.
     */
    effects: Control.property( function() {
        this._originalState( this._getCurrentState() );
    }),
    
    initialize: function() {
        
        this.genericIfClassIs( HighlightEffects );

        var self = this;
        this
            .on( "sizeChanged", function() {
                self._recalc();
            })
            .hover(
                function() { self._hoverIn(); },
                function() { self._hoverOut(); }
            )
            .inDocument( function( $control ) {
                $control
                    ._originalState( $control._getCurrentState() )
                    ._recalc();
            });
    },

    /*
     * Get the current values of all CSS attributes which will be overwritten
     * by the effects. This snapshot is used on hover out to restore the
     * original state.
     */
    _getCurrentState: function() {
        var currentState = {};
        var $content = this.$HighlightEffects_content();
        var effects = this.effects();
        for ( var key in effects ) {
            var value;
            switch ( key ) {

                /*
                 * When border properties are applied, they may get split up
                 * into border-<side> properties, leaving the overall border
                 * properties empty. So, use the properties of one of the
                 * border sides as a proxy for the overall border properties.
                 */
                case "border-color":
                    value = $content.css( "border-top-color" );
                    break;
                case "border-width":
                    value = $content.css( "border-top-width" );
                    break;
                
                /*
                 * Map dimensions of "auto" to "0" so that the dimension can
                 * be animated. 
                 */
                case "bottom":
                case "left":
                case "right":
                case "top":
                    value = $content.css( key );
                    if ( value === "auto" ) {
                        value = "0";
                    }
                    break;
                    
                default:
                    value = $content.css( key );
                    break;
            }
            currentState[ key ] = value;
        }
        return currentState;
    },

    _hoverIn: function() {
        this.$HighlightEffects_content()
            .stop() // In case this was doing its _hoverOut animation
            .css({
                "position": "absolute",
                "z-index": "2"  // In front of any element doing _hoverOut
            })
            .animate( this.effects(), this.duration() );
    },
    
    _hoverOut: function() {
        var savedState = this._originalState() || {};
        this.$HighlightEffects_content()
            .stop() // In case this was doing its _hoverIn animation
            .css({
                // Show in front of peer elements, but behind _hoverIn element.
                "z-index": "1" 
            })
            .animate( savedState, this.duration(), null, function() {
                // Restore normal positioning when animation completes.
                $( this ).css({
                    "position": "inherit",
                    "z-index": "inherit"
                });
            });
    },
    
    /*
     * Update the control's size to match the contents. This lets us
     * apply absolute positioning to the contents on hover while still
     * preserving room for the content in the normal document flow.
     */
    _recalc: function() {
        this.height( this.$HighlightEffects_content().outerHeight() );
        this.width( this.$HighlightEffects_content().outerWidth() );
    }
    
});

//
// HintTextBox
//
var HintTextBox = Control.subclass({
    className: "HintTextBox",
    content: [
        " ",
        {
            html: "<input type=\"text\" />",
            id: "HintTextBox_textBox"
        },
        " ",
        {
            html: "<div />",
            id: "HintTextBox_hint"
        },
        " "
    ]
});
HintTextBox.prototype.extend({
    
    /*
     * The content of the text box.
     */
    content: Control.chain("$HintTextBox_textBox", "content", function() {
        this._showHintIfEmpty();
    }),
    
    /*
     * The "hint" shown within the text box that suggests what the user should
     * type there. This hint is hidden if: the text box has content, the
     * text box has just acquired the keyboard focus, or if the user clicks in
     * the text box. 
     */
    hint: Control.chain("$HintTextBox_hint", "content"),
    
    initialize: function() {
        var self = this;
        this.on({
            "click": function() { self._hideHint(); },
            "focus": function() {
                if (!self._isTextBoxFocused())
                {
                    self.$HintTextBox_textBox().focus();
                }
            }
        });
        this.$HintTextBox_textBox().on({
            "blur": function() {
                self
                    ._isTextBoxFocused(false)
                    ._showHintIfEmpty();
            },
            "keydown keyup": function() { self._showHintIfEmpty(); },
            "focus": function() { self._isTextBoxFocused(true); }
        });
        this.$HintTextBox_hint().click(function() {
            self._hideHint();
        });
    },
    
    _isTextBoxFocused: Control.property(null, false),
    
    _hideHint: function() {
        this.$HintTextBox_hint().hide(); 
        this.$HintTextBox_textBox().focus();
    },
    
    _showHintIfEmpty: function() {
        this.$HintTextBox_hint().toggle(this.content().length == 0);
    }
    
});

//
// LabeledInput
//
var LabeledInput = Control.subclass({
    className: "LabeledInput",
    tag: "label",
    content: [
        " ",
        "<input />",
        {
            html: "<span />",
            id: "LabeledInput_content"
        },
        " "
    ]
});
LabeledInput.prototype.extend({
    
    /*
     * True if the input control is checked, false if unchecked.
     */
    checked: Control.chain( "_inputControl", "prop/checked" ),
    
    /*
     * The label for the input control.
     * This can be arbitrary content, not just text.
     */
    content: Control.chain( "$LabeledInput_content", "content" ),
    
    /*
     * True if the input control should be disabled.
     */
    disabled: Control.chain( "_inputControl", "prop/disabled" ),
    
    /*
     * Return the input control.
     * We restrict our search to direct children, in case the label also
     * includes input controls.
     */
    _inputControl: Control.chain( "children", "filter/input" ),
    
    /*
     * Sets the input control's type.
     * This is set in subclasses CheckBox and RadioButton.
     */
    _type: function( type ) {
        var input = this._inputControl();        
        if ( type !== undefined && $.browser.msie && parseInt( $.browser.version ) < 9 ) {
            // IE8 can't change an input's "type" attribute.
            for ( i = 0; i < this.length; i++ ) {
                var oldInput = input.eq(i);
                // Create a new input to replace the existing one.
                var newInput = $( "<input type='" + type + "'/>" ).prop({
                    // Copy old input's properties to new one.
                    checked: oldInput.prop( "checked" ),
                    disabled: oldInput.prop( "disabled" )
                });
                oldInput.replaceWith( newInput );
            }
            return this;
        } else {
            return input.prop( "type", type );
        }
    }

});

//
// LateralNavigator
//
var LateralNavigator = Control.subclass({
    className: "LateralNavigator",
    content: [
        " ",
        {
            html: "<div />",
            id: "header",
            content: [
                " ",
                {
                    control: "HorizontalPanels",
                    left: [
                        " ",
                        {
                            control: "BasicButton",
                            id: "LateralNavigator_previousButton",
                            "class": "navigatorButton",
                            generic: "false",
                            content: "◀"
                        },
                        " "
                    ],
                    content: [
                        " ",
                        {
                            html: "<div />",
                            id: "LateralNavigator_heading"
                        },
                        " "
                    ],
                    right: [
                        " ",
                        {
                            control: "BasicButton",
                            id: "LateralNavigator_nextButton",
                            "class": "navigatorButton",
                            generic: "false",
                            content: "▶"
                        },
                        " "
                    ]
                },
                " "
            ]
        },
        " ",
        {
            html: "<div />",
            id: "LateralNavigator_content"
        },
        " "
    ]
});
LateralNavigator.prototype.extend({

    /*
     * The content for the current position in the sequence.
     */
    content: Control.chain( "$LateralNavigator_content", "content" ),

    initialize: function() {

        this.genericIfClassIs( LateralNavigator );

        var self = this;
        this.$LateralNavigator_previousButton().click( function() {
            self.previous();
        });
        this.$LateralNavigator_nextButton().click( function() {
            self.next();
        });
    },
    
    // Use generic buttons if control itself is generic.
    generic: function( generic ) {
        var result = this._super( generic );
        if ( generic !== undefined ) {
            this.find( ".navigatorButton" ).control().generic( generic );
        }
        return result;
    },
    
    /*
     * The content of the "Next" button. By default, this is a right-pointing
     * arrow.
     */
    nextButtonContent: Control.chain( "$LateralNavigator_nextButton", "content" ),
    
    /*
     * True if the "Next" button should be disabled.
     */
    nextButtonDisabled: Control.chain( "$LateralNavigator_nextButton", "disabled" ),
    
    /*
     * The content of the "Previous" button. By default, this is a left-pointing
     * arrow.
     */
    previousButtonContent: Control.chain( "$LateralNavigator_previousButton", "content" ),
    
    /*
     * True if the "Previous" button should be disabled.
     */
    previousButtonDisabled: Control.chain( "$LateralNavigator_previousButton", "disabled" ),
    
    /*
     * The content of the heading area.
     */
    heading: Control.chain( "$LateralNavigator_heading", "content" ),
    
    /*
     * Move to the next step in the sequence.
     */
    next: function() {},
    
    /*
     * Move to the previous step in the sequence.
     */
    previous: function() {}
});

//
// Layout
//
var Layout = Control.subclass({
    className: "Layout"
});
Layout.prototype.extend({
    
    initialize: function() {
        var self = this;
        this.on( "sizeChanged", function() {
            if ( self.inDocument() ) {
                self.layout();
            }
        });
        Layout.track( this );
    },
    
    // Base implementation does nothing.
    layout: function() {
        return this;
    }
    
    /* For debugging
    _log: function(s) {
        console.log(this.className + "#" + this.attr("id") + " " + s);
        return this;
    }
    */
    
});

// Class methods.
// TODO: Most of this has been factored into the new inDocument() event,
// so rewrite this to use that.
Layout.extend({
    
    /*
     * Re-layout any controls in the DOM.
     */
    recalc: function() {

        //console.log("recalc");
        if ( !this._controlsToLayout ) {
            return;
        }

        // Call the layout() method of any control whose size has changed.
        var i = 0;
        while ( i < this._controlsToLayout.length ) {
            var $control = Control( this._controlsToLayout[i] ).control();
            if ( $control ) {
                var previousSize = $control.data( "_size" );
                var size = {
                    height: $control.height(),
                    width: $control.width()
                };
                if ( previousSize === undefined ||
                    size.height != previousSize.height ||
                    size.width != previousSize.width ) {
                        
                    $control
                        .data( "_size", size )
                        .layout();
                }
                i++;
            }
            else
            {
                // Control unavailable, likely no longer in DOM;
                // remove it from our list of controls to track.
                this._controlsToLayout.splice( i, 1 );
            }
        }
    },
    
    /*
     * Start tracking the indicated controls for layout purposes.
     * We won't actually lay them out until they're added to the DOM.
     */
    track: function( $controls ) {
        //$controls._log("tracking");
        this._initialize();
        $controls.inDocument( function( $control ) {
            $control.layout();
            Layout._controlsToLayout = Layout._controlsToLayout.concat( $control );
        });
    },

    /* TODO: Allow a control to be stop being tracked for layout purposes.
    untrack: function($controls) {
        ...
        this.recalc();
    },
    */
    
    /*
     * Initialize layout engine overall (not a specific instance).
     */
    _initialize: function() {
        
        if ( this._initialized ) {
            // Already did this.
            return;
        }
        
        // The following control array is maintained in order such that
        // DOM parents come before their children. 
        this._controlsToLayout = [];
        
        // Recalc layout whenever the window size changes.
        $( window ).resize( function() {
            Layout.recalc();
        });
        
        this._initialized = true;
    }
        
});

//
// Link
//
var Link = Control.subclass({
    className: "Link",
    tag: "a"});
Link.prototype.extend({

    /*
     * The location that will be opened if the user clicks the link.
     */
    href: Control.chain( "prop/href" ),
    
    initialize: function() {
        if ( !this.href() ) {
            // Set a placeholder href which will force the display of an
            // underline, and use of a hand cursor.
            this.href( "javascript:" );
        }
    },

    /*
     * The target of the link.
     */
    target: Control.chain( "prop/target" )

});

//
// List
//
var List = Control.subclass({
    className: "List"
});
List.prototype.extend({
    
    /*
     * The class used to render items in the list as controls.
     */
    itemClass: Control.property[ "class" ]( function() { this._refresh(); }, Control ),
    
    /*
     * True if the control should mark itself dirty when it gets a change event.
     */
    dirtyOnChange: Control.property.bool( null, false ),
    
    /*
     * True if the list's items have been changed since the controls were first created.
     */
    isDirty: Control.property.bool( null, true ),
    
    initialize: function() {
        var self = this;
        this.change( function( event ) {
            if ( self.dirtyOnChange() ) {
                // Assume the list is dirty.
                self.isDirty( true );
            }
        });
    },
    
    /*
     * collection of controls in the list generated by setting the items() property.
     * Read-only. This is always returned as an instance of itemClass.
     */
    controls: function() {
        var itemClass = this.itemClass();
        return itemClass( this ).children();
    },
    
    /*
     * The array of items in the list.
     */
    items: function( items ) {
        if ( items === undefined ) {
            if ( this.isDirty() ) {
                this
                    ._itemsCache( this._getItemsFromControls() )
                    .isDirty( false );
            }
            return this._itemsCache();
        } else {
            return this
                ._itemsCache( items )
                ._createControlsForItems( items )
                .isDirty( false );
        }
    },
    
    /*
     * Used to map an incoming list item to property setters on the control
     * class indicated by itemClass. This can be a simple string,
     * in which case it will be taken as the name of a control class property.
     * It can also be a dictionary mapping item properties to control class
     * properties. Finally, the map can also be a function of the form:
     *
     *      function foo( item ) { ... }
     *
     * where item is the list item.
     *
     * If item is undefined, the map function is being invoked as a getter,
     * and should extract the item from the control (available via "this").
     * If item is defined, the map function is being invoked as a setter, and
     * should pass the item to the control (e.g., by setting properties on it).
     */
    mapFunction: Control.property(
        function() { this._refresh(); },
        "content" // By default, items are passed to the content() property.
    ),
    
    _createControlsForItems: function( items ) {
        var itemClass = this.itemClass();
        var mapFunction = this._getMapFunction();
        var self = this;
        var controls = $.map( items || [], function( item, index ) {
            var $control = itemClass.create();
            mapFunction.call( $control, item );
            self._setupControl( $control );
            return $control;
        });
        this.content( controls );
        return this;
    },
    
    _getItemsFromControls: function() {
        var mapFunction = this._getMapFunction();
        return this.controls().map( function( index, element ) {
            var $control = $( element ).control();
            return mapFunction.call( $control );
        }).get();
    },
    
    /*
     * If the list's mapFunction property is a simple string, create a
     * function that invokes the item control's property getter/setter with
     * that string name. Otherwise, return the mapFunction value as is.  
     */
    _getMapFunction: function() {
        var mapFunction = this.mapFunction();
        if ( typeof mapFunction === "string" ) {
            // Invoke the property with the given name.
            return function( item ) {
                return this[ mapFunction ]( item );
            }
        } else if ( $.isFunction( mapFunction) ) {
            // Use function as is.
            return mapFunction;
        } else {
            // Object dictionary mapping item members -> control properties.
            return function( item ) {
                if ( item === undefined ) {
                    // Getter
                    var result = {};
                    for ( key in mapFunction ) {
                        var propertyName = mapFunction[ key ];
                        var value = this[ propertyName ]();
                        result[ propertyName ] = value;
                    }
                    return result;
                } else {
                    // Setter
                    for ( key in mapFunction ) {
                        var propertyName = mapFunction[ key ];
                        var value = item[ key ];
                        this[ propertyName ]( value );
                    }
                }
            }
        }
    },
    
    // A copy of the items the last time they were created or refreshed.
    _itemsCache: Control.property(),

    // Get all the items, then recreate them again (possibly as different controls).        
    _refresh: function() {
        this.items( this.items() );
    },
    
    /*
     * This can be extended by subclasses who want to perform per-control
     * set-up.
     */
    _setupControl: function( $control ) {}

});

//
// ListBox
//
var ListBox = List.subclass({
    className: "ListBox",
    itemClass: "BasicButton"
});
ListBox.prototype.extend({
    
    initialize: function() {
        
        this._super();
        this.genericIfClassIs( ListBox );
        
        /*
         * Try to convince the browser that the list is focusable, but without
         * forcing it into the tab order (as a positive tabindex would do).
         * Firefox, Chrome, and IE seem to handle this as desired if tabindex
         * is set to a negative number.
         */
        this.attr( "tabindex", "-1" );
        
        var self = this;
        this.on("click", function( event ) {
                var control = self._getControlContainingElement( event.target );
                if ( control ) {
                    self._controlClick( control );
                }
            })
            .on("keydown", function( event ) {
                self._keydown( event );
            });
    },
    
    /*
     * The array of items shown in the list box.
     */
    items: function( value ) {
        /* Preserve selection index when items change */ 
        var previousIndex = this.selectedIndex();
        var result = this._super( value );
        if ( value !== undefined && value.length > 0 ) {
            var index = ( previousIndex >= 0 && previousIndex <= value.length )
                ? previousIndex     // Restore previous selection.
                : -1;               // Nothing will be selected.
            this.selectedIndex( index );
        }
        return result;
    },
    
    /*
     * Toggles the selected state of a control in the list.
     * 
     * If the select parameter is true, this applies the "selected" class to the
     * control, which the list uses to track which control is selected. If the
     * control supports a selected() function, that will be invoked as well.
     * Subclasses can perform additional manipulations here.
     */
    selectControl: function( control, select ) {
        control.toggleClass( "selected", select );
        if ( $.isFunction( control.selected ) ) {
            control.selected( select );
        }
    },
    
    /*
     * The control in the list which is currently selected.
     */
    selectedControl: Control.iterator( function( selectedControl ) {
        if ( selectedControl === undefined ) {
            var control = this.controls().filter( ".selected" ).eq(0);
            return control.length > 0
                ? control
                : null;
        } else {
            var previousControl = this.selectedControl();
            var selectedElement = selectedControl ? selectedControl[0] : null;
            var self = this;
            this.controls().eachControl( function( index, control) {
                self.selectControl( control, control[0] === selectedElement );
            });
            if ( selectedControl ) {
                this._scrollToControl( selectedControl );
            }
            if ( selectedControl !== previousControl ) {
                this.trigger( "selectionChanged" );
            }
        }
    }),
    
    /*
     * The index of the currently-selected control.
     */
    selectedIndex: Control.iterator( function( selectedIndex ) {
        if ( selectedIndex === undefined ) {
            var control = this.selectedControl();
            return control
                ? this.controls().index( control )
                : -1;
        } else {
            var index = parseInt( selectedIndex );
            var control = ( index >= 0 )
                ? this.controls().eq( index )
                : null;
            this.selectedControl( control );
        }
    }),
    
    /*
     * The item represented by the currently-selected control.
     */
    selectedItem: Control.iterator( function( selectedItem ) {
        if ( selectedItem === undefined ) {
            var index = this.selectedIndex();
            return index >= 0
                ? this.items()[ index ]
                : null;
        } else {
            var index = $.inArray( selectedItem, this.items() );
            this.selectedIndex( index );
        }
    }),
    
    _controlClick: function( control ) {
        this.selectedControl( control );
    },
    
    _getControlContainingElement: function( element ) {
        return $( element ).closest( this.controls() ).control();
    },
    
    /*
     * Return the control that spans the given y position, or -1 if not found.
     * If downward is true, move down the list of controls to find the
     * first control found at the given y position; if downward is false,
     * move up the list of controls to find the last control at that position. 
     */
    _getControlAtY: function( y, downward ) {
        
        var controls = this.controls();
        var start = downward ? 0 : controls.length - 1;
        var end = downward ? controls.length : 0;
        var step = downward ? 1 : -1;
        for ( var i = start; i !== end; i += step ) {
            var $control = controls.eq(i);
            var controlTop = Math.round( $control.offset().top );
            var controlBottom = controlTop + $control.outerHeight();
            if ( controlTop <= y && controlBottom >= y ) {
                return i;
            }
        }
        
        return -1;
    },
    
    /*
     * Handle a keydown event.
     */
    _keydown: function( event ) {
        
        var handled;
        switch ( event.which ) {

            case 33: // Page Up
                handled = this._pageUp();
                break;
                
            case 34: // Page Down
                handled = this._pageDown();
                break;
            
            case 35: // End
                handled = this._selectLastControl();
                break;
            
            case 36: // Home
                handled = this._selectFirstControl();
                break;
            
            case 38: // Up
                handled = event.altKey
                    ? this._selectFirstControl()
                    : this._selectPreviousControl();
                break;
                
            case 40: // Down
                handled = event.altKey
                    ? this._selectLastControl()
                    : this._selectNextControl();
                break;
                
            default:
                handled = false;
                break;
        }
        
        if (handled)
        {
            event.stopPropagation();
            event.preventDefault();
        }
    },
    
    _pageDown: function() {
        return this._scrollOnePage( true );
    },
    
    _pageUp: function() {
        return this._scrollOnePage( false );
    },
    
    /*
     * Move by one page downward (if downward is true), or upward (if false).
     */
    _scrollOnePage: function( downward ) {

        var selectedIndex = this.selectedIndex();
        
        // Find the control at the bottom/top edge of the viewport.
        var viewPortDimensions = this._viewPortDimensions();
        var edge = downward ? viewPortDimensions.bottom : viewPortDimensions.top;
        var index = this._getControlAtY( edge, downward );
        
        if ( index >= 0 && selectedIndex === index ) {
            // The control at that edge is already selected.
            // Move one page further down/up.
            var delta = downward
                ? viewPortDimensions.height
                : -viewPortDimensions.height; 
            index = this._getControlAtY( edge + delta, downward );
        }

        if ( index < 0 ) {
            // Would have scrolled too far in that direction.
            // Just select the last/first control.
            index = downward
                ? this.controls().length - 1
                : 0;
        }
        
        if ( index !== this.selectedIndex() ) {
            this.selectedIndex( index );
            return true;
        }
        
        return false;
    },
    
    /*
     * Scroll the given control into view.
     */
    _scrollToControl: function( $control ) {
        
        var controlTop = $control.offset().top;
        var controlBottom = controlTop + $control.outerHeight();

        var viewPortDimensions = this._viewPortDimensions();
        var scrollTop = this.scrollTop();
        
        if ( controlBottom > viewPortDimensions.bottom ) {
            // Scroll up until control is entirely visible.
            this.scrollTop( scrollTop + controlBottom - viewPortDimensions.bottom );
        } else if ( controlTop < viewPortDimensions.top ) {
            // Scroll down until control is entirely visible.
            this.scrollTop( scrollTop - ( viewPortDimensions.top - controlTop ) );
        }
    },
    
    _selectFirstControl: function() {
        if ( this.controls().length > 0 ) {
            this.selectedIndex( 0 );
            return true;
        }
        return false;
    },
    
    _selectLastControl: function() {
        if ( this.controls().length > 0 ) {
            this.selectedIndex( this.controls().length - 1 );
            return true;
        }
        return false;
    },
    
    _selectNextControl: function() {
        var index = this.selectedIndex() + 1;
        if ( index < this.controls().length ) {
            this.selectedIndex( index );
            return true;
        }
        return false;
    },
    
    _selectPreviousControl: function() {
        var index = this.selectedIndex() - 1;
        if ( index >= 0 && this.controls().length > 0 ) {
            this.selectedIndex( index );
            return true;
        }
        return false;
    },
    
    _viewPortDimensions: function() {
        var viewPortTop = this.offset().top;
        var viewPortHeight = this.height();
        return {
            top: viewPortTop,
            height: viewPortHeight,
            bottom: viewPortTop + viewPortHeight
        };
    }
    
});

//
// ListInlay
//
var ListInlay = ListBox.subclass({
    className: "ListInlay",
    itemClass: "Collapsible"
});
ListInlay.prototype.extend({
    
    initialize: function() {
        this._super();
        this.genericIfClassIs( ListInlay );
        this.mapFunction({
            description: "heading",
            content: "content"
        });
    },

    selectControl: function( control, select ) {
        this._super( control, select );
        if ( select ) {
            control.toggleCollapse();
        } else {
            control.collapsed( true );
        }
    },
    
    _setupControl: function( control ) {
        // Let ListBox manage toggling instead of Collapsible
        control.toggleOnClick( false );
    }
    
});

//
// LoremIpsum
//
var LoremIpsum = Control.subclass({
    className: "LoremIpsum"
});
LoremIpsum.prototype.extend({
    
    initialize: function() {
        if ( !LoremIpsum._usedLorem ) {
            // This is the first LoremIpsum control instance, so it gets
            // the special lead sentence by default.
            this.lorem( true );
            LoremIpsum._usedLorem = true;
        }
        var content = this.content();
        if ( content && content.length === 0 ) {
            this._refresh();
        }
    },
    
    /*
     * True if the first sentence should definitely be (or not be)
     * the standard "Lorem ipsum dolor sit amet..." If this is undefined,
     * the first instance of this control class will start with this sentence;
     * subsequent instances won't.
     */
    lorem: Control.property.bool( function() {
        this._refresh();
    }),
    
    /*
     * The number of paragraphs to show. Default is one paragraph.
     */
    paragraphs: Control.property.integer( function( paragraphs ) {
        this._refresh();
    }, 1),
    
    /*
     * A specific number of sentences to show per paragraph.
     * If not set, each paragraph will have a variable number of sentences.
     */
    sentences: Control.property.integer( function( sentences ) {
        this._refresh();
    }),

    /*
     * Generate a random paragraph.
     */    
    _generateParagraph: function( useLorem ) {
        
        // Default is 5 and 12 sentences per paragraph.
        var sentenceCount = this.sentences() || Math.floor( Math.random() * 8 ) + 5;
        var sentencesAvailable = LoremIpsum.sentences.length;
        
        var paragraph = "";
        if ( sentenceCount > 0 ) {
            
            // Use special first sentence?
            if ( useLorem ) {
                paragraph = LoremIpsum.loremSentence;
                sentenceCount--;
            }
            
            // Pick remaining sentences.
            for ( var i = 0; i < sentenceCount; i++ ) {
                if ( paragraph.length > 0 ) {
                    paragraph += " ";
                }
                var sentenceIndex = Math.floor( Math.random() * ( sentencesAvailable) );
                paragraph += LoremIpsum.sentences[ sentenceIndex ];
            }
        }
        paragraph = "<p>" + paragraph + "</p>";
        
        return paragraph;
    },
    
    _refresh: function() {
        var content = [];
        for ( var i = 0, length = this.paragraphs(); i < length; i++ ) {
            var useLorem = ( i === 0 && this.lorem() );
            var paragraph = this._generateParagraph( useLorem );
            content.push( paragraph );
        }
        this.content( content );
    }
    
});

// Class members
LoremIpsum.extend({
    
    loremSentence: "Lorem ipsum dolor sit amet, consectetur adipiscing elit.", 
    
    sentences: [
        "Duis et adipiscing mi.",
        "Class aptent taciti sociosqu ad litora torquent per conubia nostra, per inceptos himenaeos.",
        "Mauris vestibulum orci sed justo lobortis viverra.",
        "Suspendisse blandit dolor nunc, nec facilisis metus.",
        "Ut vestibulum ornare eros id vestibulum.",
        "Phasellus aliquam pellentesque urna, eu ullamcorper odio sollicitudin vel.",
        "Aliquam lacinia dolor at elit viverra ullamcorper.",
        "Vestibulum ac quam augue.",
        "Fusce tortor risus, commodo in molestie vitae, rutrum eu metus.",
        "Nunc tellus justo, consequat in ultrices elementum, gravida a mi.",
        "Praesent in lorem erat, quis dictum magna.",
        "Aenean et eros ligula, quis sodales justo.",
        "Quisque egestas imperdiet dignissim.",
        "Aenean commodo nulla sit amet urna ornare quis dignissim libero tristique.",
        "Praesent non justo metus.",
        "Nam ut adipiscing enim.",
        "In hac habitasse platea dictumst.",
        "Nulla et enim sit amet leo laoreet lacinia ut molestie magna.",
        "Vestibulum bibendum venenatis eros sit amet eleifend.",
        "Fusce eget metus orci.",
        "Fusce tincidunt laoreet lacinia.",
        "Proin a arcu purus, nec semper quam.",
        "Mauris viverra vestibulum sagittis.",
        "Ut commodo, dolor malesuada aliquet lacinia, dui est congue massa, vel sagittis metus quam vel elit.",
        "Nulla vel condimentum odio.",
        "Aliquam cursus velit ut tellus ultrices rutrum.",
        "Vivamus sollicitudin rhoncus purus, luctus lobortis dui viverra vitae.",
        "Nam mauris elit, aliquet at congue sed, volutpat feugiat eros.",
        "Nulla quis nulla ac lectus dapibus viverra.",
        "Pellentesque commodo mauris vitae sapien molestie sit amet pharetra quam pretium.",
        "Maecenas scelerisque rhoncus risus, in pharetra dui euismod ac.",
        "Mauris ut turpis sapien, sed molestie odio.",
        "Vivamus nec lectus nunc, vel ultricies felis.",
        "Mauris iaculis rhoncus dictum.",
        "Vivamus at mi tellus.",
        "Etiam nec dui eu risus placerat adipiscing non at nisl.",
        "Curabitur commodo nunc accumsan purus hendrerit mollis.",
        "Fusce lacinia urna nec eros consequat sed tempus mi rhoncus.",
        "Morbi eu tortor sit amet tortor elementum dapibus.",
        "Suspendisse tincidunt lorem quis urna sollicitudin lobortis.",
        "Nam eu ante ut tellus vulputate ultrices eu sed mi.",
        "Aliquam lobortis ultricies urna, in imperdiet lacus tempus a.",
        "Duis nec velit eros, ut volutpat neque.",
        "Sed quam purus, tempus vitae porta eget, porta sit amet eros.",
        "Vestibulum dignissim ullamcorper est id molestie.",
        "Nunc erat ante, lobortis id dictum in, ultrices sit amet nisl.",
        "Nunc blandit pellentesque sapien, quis egestas risus auctor quis.",
        "Fusce quam quam, ultrices quis convallis sed, pulvinar auctor tellus.",
        "Etiam dolor velit, hendrerit et auctor sit amet, ornare nec erat.",
        "Nam tellus mi, rutrum a pretium et, dignissim sed sapien.",
        "Sed accumsan dapibus ipsum ut facilisis.",
        "Curabitur vel diam massa, ut ultrices est.",
        "Sed nec nunc arcu.",
        "Nullam lobortis, enim nec gravida molestie, orci risus blandit orci, et suscipit nunc odio eget nisl.",
        "Praesent lectus tellus, gravida ut sagittis non, convallis a leo.",
        "Mauris tempus feugiat fermentum.",
        "Phasellus nibh mi, convallis eu pulvinar eget, posuere in nunc.",
        "Morbi volutpat laoreet mauris vel porta.",
        "Aenean vel venenatis nisi.",
        "Ut tristique mauris sed libero malesuada quis rhoncus augue convallis.",
        "Fusce pellentesque turpis arcu.",
        "Nunc bibendum, odio id faucibus malesuada, diam leo congue urna, sed sodales orci turpis id sem.",
        "Ut convallis fringilla dapibus.",
        "Ut quis orci magna.",
        "Mauris nec erat massa, vitae pellentesque tortor.",
        "Sed in ipsum nec enim feugiat aliquam et id arcu.",
        "Nunc ut massa sit amet nisl semper ultrices eu id lacus.",
        "Integer eleifend aliquam interdum.",
        "Cras a sapien sapien.",
        "Duis non orci lacus.",
        "Integer commodo pharetra nulla eget ultrices.",
        "Etiam congue, enim at vehicula posuere, urna lorem hendrerit erat, id condimentum quam lectus ac ipsum.",
        "Aliquam lorem purus, tempor ac mollis in, varius eget metus.",
        "Nam faucibus accumsan sapien vitae ultrices.",
        "Morbi justo velit, bibendum non porta vel, tristique quis odio.",
        "In id neque augue.",
        "Cras interdum felis sed dui ultricies laoreet sit amet eu elit.",
        "Vestibulum condimentum arcu in massa lobortis vitae blandit neque mattis.",
        "Nulla imperdiet luctus mollis.",
        "Donec eget lorem ipsum, eu posuere mi.",
        "Duis lorem est, iaculis sit amet molestie a, tincidunt rutrum magna.",
        "Integer facilisis suscipit tortor, id facilisis urna dictum et.",
        "Suspendisse potenti.",
        "Aenean et mollis arcu.",
        "Nullam at nulla risus, vitae fermentum nisl.",
        "Nunc faucibus porta volutpat.",
        "Sed pretium semper libero, vitae luctus erat lacinia vel.",
        "Class aptent taciti sociosqu ad litora torquent per conubia nostra, per inceptos himenaeos.",
        "Integer facilisis tempus tellus, rhoncus pretium orci semper sed.",
        "Morbi non lectus leo, quis semper diam.",
        "Suspendisse ac urna massa, vitae egestas metus.",
        "Pellentesque viverra mattis semper.",
        "Cras tristique bibendum leo, laoreet ultrices urna condimentum at.",
        "Praesent at tincidunt velit.",
        "Nam fringilla nibh quis nulla volutpat lacinia.",
        "Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas.",
        "Sed ultrices sollicitudin neque ut molestie.",
        "Sed at lectus in lacus scelerisque suscipit non id risus.",
        "Aliquam lorem nibh, convallis vitae molestie in, commodo feugiat nibh."
    ],
    
    _usedLorem: false
})

//
// MonthName
//
var MonthName = Control.subclass({
    className: "MonthName",
    tag: "span"});
MonthName.prototype.extend({

    /*
     * The control's current culture.
     */
    culture: function( culture ) {
        var result = this._super( culture );
        if ( culture !== undefined ) {
            this.month( this.month() );
        }
        return result;
    },
    
    initialize: function() {
        if ( !this.month() ) {
            var today = new Date();
            this.month( today.getMonth() );
        }
    },
    
    /*
     * The index of the month to show: 0 = January, 1 = February, etc.
     */
    month: Control.property( function( month ) {
        var culture = this.culture();
        var monthNameEnum = culture ? culture.calendar.months.names : MonthName.names;
        this.content( monthNameEnum[ month ] );
    })
    
});

// Class methods
MonthName.extend({
    
    // Default names, used if Globalize is not avaialble.
    names: [
        "January",
        "February",
        "March",
        "April",
        "May",
        "June",
        "July",
        "August",
        "September",
        "October",
        "November",
        "December"
    ]
    
});

//
// MultiListBox
//
var MultiListBox = ListBox.subclass({
    className: "MultiListBox"
});
MultiListBox.prototype.extend({

    initialize: function() {
        this._super();
        this.genericIfClassIs( MultiListBox );
    },
    
    /*
     * The controls in the list which are currently selected.
     */
    selectedControls: Control.iterator( function( selectedControls ) {
        if ( selectedControls === undefined ) {
            return this.controls().filter( ".selected" );
        } else {
            var self = this;
            this.controls().eachControl( function( index, control ) {
                var filter = selectedControls.filter( control );
                var select = ( filter && filter.length > 0 );
                self.selectControl( control, select );
            });
            this.trigger( "selectionChanged" );
        }
    }),
    
    /*
     * The indices of the currently-selected controls.
     */
    selectedIndices: Control.iterator( function( selectedIndices ) {
        var controls = this.controls();
        if ( selectedIndices === undefined ) {
            var indices = [];
            for ( var i = 0; i < controls.length; i++ ) {
                if ( controls.eq(i).hasClass( "selected" ) ) {
                    indices.push( i );
                }
            }
            return indices;
        } else {
            var selectedControls = [];
            if ( selectedIndices ) {
                for ( var i = 0; i < selectedIndices.length; i++ ) {
                    var index = selectedIndices[i];
                    selectedControls.push( controls[ index ] );
                }
            }
            this.selectedControls( selectedControls );
        }
    }),
    
    /*
     * The items represented by the currently-selected controls.
     */
    selectedItems: Control.iterator( function( selectedItems ) {
        if ( selectedItems === undefined ) {
            var indices = this.selectedIndices();
            var items = this.items();
            var selectedItems = [];
            for ( var i = 0; i < indices.length; i++ ) {
                var index = indices[i];
                selectedItems.push( items[i] );
            }
            return selectedItems;
        } else {
            var selectedControls = [];
            if ( selectedItems ) {
                var controls = this.controls();
                var items = this.items();
                for ( var i = 0; i < selectedItems.length; i++ ) {
                    var item = selectedItems[i];
                    var index = $.inArray( item, items );
                    if ( index >= 0 ) {
                        selectedControls.push( controls[ index ] );
                    }
                }
            }
            this.selectedControls( selectedControls );
        }
    }),
    
    /*
     * Toggle the selected state of the given control (if toggle is undefined),
     * or set the selected state to the indicated toggle value.
     */
    toggleControl: function( control, toggle ) {
        var toggle = toggle || !control.hasClass( "selected" );
        this.selectControl( control, toggle );
        this.trigger( "selectionChanged" );
        return this;
    },
    
    _controlClick: function( control ) {
        this.toggleControl( control );
    }

});

//
// MultiListInlay
//
var MultiListInlay = MultiListBox.subclass({
    className: "MultiListInlay",
    itemClass: "CollapsibleWithHeadingButton"
});
MultiListInlay.prototype.extend({
    
    initialize: function() {
        this._super();
        this.genericIfClassIs( ListInlay );
        this.mapFunction( function( item ) {
            if ( item === undefined ) {
                return {
                    description: this.heading(),
                    content: this.content()
                };
            } else {
                this
                    .heading( item.description )
                    .content( item.content );
                if ( this instanceof Collapsible ) {
                    // Let ListBox manage toggling instead of Collapsible
                    this.toggleOnClick( false );
                }
            }
        });
    },
    
    selectControl: function( control, select ) {
        this._super( control, select );
        control.collapsed( !select );
    }
});

//
// Page
//
var Page = Control.subclass({
    className: "Page"
});
/*
 * General page utility functions.
 */
Page.prototype.extend({
    
    /*
     * True if the page should fill its container. Default is false.
     */
    fill: Control.chain( "applyClass/fill" ),

    /*
     * The URL parameters for the current page. Read-only.
     */
    urlParameters: function() {
        return Page.urlParameters();
    },
        
    /*
     * The title of the page. This will generally be shown in the browser's
     * window title bar, etc.
     */
    title: function( title ) {
        if ( title === undefined ) {
            return document.title;
        } else {
            document.title = title;
            return this;
        }
    }

});

/*
 * Class members.
 */
Page.extend({

    /*
     * Start actively tracking changes in a page specified on the URL.
     * For a URL like www.example.com/index.html#page=Foo, load class Foo.
     * If the page then navigates to www.example.com/index.html#page=Bar, this
     * will load class Bar in situ, without forcing the browser to reload the page. 
     */
    trackClassFromUrl: function( defaultPageClass, target ) {
        
        var $control = Control( target || "body" );
        
        // Watch for changes in the URL after the hash.
        $( window ).hashchange( function() {
            var pageClass = Page.urlParameters().page || defaultPageClass;
            $control.transmute( pageClass );
        })
            
        // Trigger a page class load now.
        $( window ).hashchange();
    },
    
    /*
     * Return the URL parameters (after "&" and/or "#") as a JavaScript object.
     * E.g., if the URL looks like http://www.example.com/index.html?foo=hello&bar=world
     * then this returns the object
     *
     *    { foo: "hello", bar: "world" }
     *
     */
    urlParameters: function() {
        var regex = /[?#&](\w+)=([^?#&]*)/g;
        var results = {};
        var match = regex.exec( window.location.href );
        while (match != null) {
            var parameterName = match[1];
            var parameterValue = match[2];
            results[ parameterName ] = parameterValue;
            match = regex.exec( window.location.href );
        }
        return results;
    }    
    
});

/*
 * General utility functions made available to all controls.
 */
Control.prototype.extend({
    
    /*
     * Look up the page hosting a control.
     */
    page: function() {
        // Get the containing DOM element subclassing Page that contains the element
        var pages = this.closest( ".Page" );
        
        // From the DOM element, get the associated QuickUI control.
        return ( pages.length > 0 ) ? pages.control() : null;
    }
    
});

//
// PanelWithOverflow
//
var PanelWithOverflow = Layout.subclass({
    className: "PanelWithOverflow",
    content: [
        " ",
        {
            control: "HasPopup",
            id: "menuButton",
            closeOnInsideClick: "true",
            openOnClick: "true",
            content: [
                " ",
                {
                    control: "BasicButton",
                    id: "PanelWithOverflow_indicator",
                    content: "»"
                },
                " "
            ]
        },
        " ",
        {
            html: "<div />",
            id: "PanelWithOverflow_content"
        },
        " "
    ]
});
PanelWithOverflow.prototype.extend({
    
    /*
     * The contents of the control.
     */
    content: Control.chain( "$PanelWithOverflow_content", "content", function() {
        this.layout();
    }),
    
    /*
     * The indicator used to show when contents have overflowed
     * the contorl's bounds.
     */
    indicator: Control.chain( "$PanelWithOverflow_indicator", "content", function() {
        this.layout();
    }),
    
    initialize: function() {
        
        this._super();
        this.genericIfClassIs( PanelWithOverflow );
        
        var self = this;
        this.$menuButton().on({
            "canceled closed": function() { self._menuClosed(); },
            "opened": function() { self._menuOpened(); }
        });
    },
    
    /*
     * Force the control to layout its contents.
     */
    layout: Control.iterator( function() {
        
        // Don't bother laying out until we're visible, or if the popup
        // is currently open. The latter case, while it'd be nice to support,
        // quickly gets quite hairy.
        if ( !this.is( ":visible" ) || this.$menuButton().opened() ) {
            return;
        }
        
        var availableWidth = this.width();
        var showMenu = false;
        var $children = this.$PanelWithOverflow_content().children();
        
        // Work from right to left 
        for ( var i = $children.length - 1; i > 0; i-- ) {
            var $child = $children.eq(i);
            // Look at right edge, not counting right margin
            var marginLeft = parseInt( $child.css( "margin-left" ) ) || 0;
            var right = marginLeft + $child.position().left + $child.outerWidth();
            var overflowed = ( right > availableWidth );
            $child.toggleClass( "overflowed", overflowed );
            if ( overflowed ) {
                if ( !showMenu ) {
                    // Turn on menu, and allocate room for it.
                    showMenu = true;
                    availableWidth -= this.$menuButton().outerWidth( true );
                }
            } else {
                // Everything to the left fits.
                $children.slice( 0, i ).removeClass( "overflowed" );
                break;
            }
        }
        
        this.$menuButton().toggle( showMenu );
    }),
    
    _menuClosed: Control.iterator( function() {
        // Return the overflow menu's children to the main content area.
        var $overflowed = this.$menuButton().popup();
        this.$PanelWithOverflow_content().append( $overflowed );
        this.layout();
    }),
    
    _menuOpened: Control.iterator( function() {
        // Temporarily move the overflowed items into the menu.
        var content = this.$PanelWithOverflow_content().content();
        var $overflowed = $( content ).filter( ".overflowed" );
        this.$menuButton().popup( $overflowed );
    })

});

//
// PersistentPanel
//
var PersistentPanel = Control.subclass({
    className: "PersistentPanel",
    content: [
        " ",
        {
            html: "<div />",
            id: "PersistentPanel_content"
        },
        " "
    ]
});
PersistentPanel.prototype.extend({

    /*
     * The content's background. See top notes.
     */    
    background: Control.chain( "$PersistentPanel_content", "css/background" ),
    
    /*
     * The control's content.
     */
    content: Control.chain( "$PersistentPanel_content", "content", function() {
        this.trigger( "sizeChanged" );
    }),
    
    /*
     * True if the control is currently docked to the top of the viewport.
     */
    docked: Control.chain( "applyClass/docked" ),
    
    initialize: function() {
        
        this.genericIfClassIs( PersistentPanel );
        
        var self = this;
        this
            .on( "sizeChanged", function() {
                if ( self.inDocument() ) {
                    self._recalc();
                }
            })
            .inDocument( function( $control ) {
                if ( !$control.scrollingParent() ) {
                    // No scrolling parent has been set; look for one.
                    $control.scrollingParent( $control._findScrollingParent() );
                }
                $control._recalc();
            });

        $( window ).resize( function() {
            self._recalc();
        });
    },
    
    /*
     * The content's padding. See top notes.
     */
    padding: Control.chain( "$PersistentPanel_content", "css/padding" ),
    
    /*
     * The parent of this control used to determine whether the control is
     * in or out of view. The default value for this property is the closest
     * parent element with overflow-y set to "auto" or "scroll".
     */
    scrollingParent: Control.property( function( scrollingParent ) {
        var self = this;
        $( scrollingParent ).scroll( function() {
            self._recalc();
        });
    }),

    _adjustSizes: function() {
        
        // Make the panel the same width as the container.
        this.$PersistentPanel_content().width( this.width() );
        
        // Make the container the same height as the panel, so that when
        // the panel pops out in fixed mode, the container can continue
        // to occupy the same amount of vertical space.
        this.height( this.$PersistentPanel_content().outerHeight( true ) );
    },
    
    /*
     * Determine which parent of the control scrolls vertically.
     */
    _findScrollingParent: function() {
        
        // By default, assume the window is what is scrolling.
        var scrollingParent = window;
        
        var parents = this.parents();
        for ( var i = 0; i < parents.length; i++ ) {
            
            if ( parents[i] === document.body ) {
                /*
                 * It doesn't appear possible to bind to the scroll event
                 * for the document body. Instead, if the body is the
                 * scrolling parent, we use the window instead, which has
                 * the same effect.
                 */
                break;
            }
            
            var overflowY = parents.eq(i).css( "overflow-y" );
            if ( overflowY === "auto" || overflowY === "scroll" ) {
                // Found a parent that explicitly asks for scrolling; use that.
                scrollingParent = parents[i];
                break;
            }
        }
        
        return scrollingParent;
    },

    /*
     * Do the real work of the control: determine whether the panel contents
     * should flow with the document, or pop out into a docked position at the
     * top or bottom of the viewport.
     */
    _recalc: function() {
        var scrollingParent = this.scrollingParent();
        if ( scrollingParent ) {
            
            var isScrollingParentWindow = ( scrollingParent === window );
            var $scrollingParent = $( scrollingParent );
            
            var scrollTop = $scrollingParent.scrollTop();
            var containerTop = this.position().top;
            var aboveViewPort = ( containerTop < scrollTop );

            var scrollBottom = scrollTop + $scrollingParent.height();
            var containerBottom = containerTop + this.height();
            var belowViewPort = ( containerBottom > scrollBottom );
            
            var dock = ( aboveViewPort || belowViewPort );

            if ( dock ) {
                /*
                 * Docking the content puts it outside the normal document.
                 * The control (the outer container) will collapse in size,
                 * which we don't want to happen -- anything below the control
                 * will suddenly jump in position. To ensure smooth movement,
                 * we force the control and content to match sizes.
                 * 
                 * We first set the content's width to match the container's
                 * width. This may cause the content to change in height.
                 * We then set the container's height to match the content's.
                 */
                this.$PersistentPanel_content().width( this.width() );
                this.height( this.$PersistentPanel_content().outerHeight( true ) );

                var css;
                var viewPortTop = isScrollingParentWindow
                    ? 0
                    : $scrollingParent.offset().top;
                if ( aboveViewPort ) {
                    css = { "top": viewPortTop + "px" };
                } else {
                    var viewPortBottom = isScrollingParentWindow
                        ? 0
                        : viewPortTop + $scrollingParent.height();
                    css = { "bottom": viewPortBottom };
                }
                this.$PersistentPanel_content().css( css );
                
            } else {
                // Reset any dimensions we set while docked.
                this.$PersistentPanel_content().css({
                    "bottom": "",
                    "top": "",
                    "width": ""
                });
                this.css( "height", "" );
            }
            
            this.docked( dock );
        }
    }
    
});

//
// Popup
//
var Popup = Control.subclass({
    className: "Popup"
});
Popup.prototype.extend({

    blanket: Control.property(),
    blanketColor: Control.property(),
    blanketOpacity: Control.property(),
    
    /*
     * True if the user can cancel an open popup by pressing the Escape key.
     * Default is true.
     */
    cancelOnEscapeKey: Control.property.bool( null, true ),
    
    /*
     * True if the popup should be cancelled if the user clicks outside it.
     * Default is true.
     */
    cancelOnOutsideClick: Control.property.bool( null, true ),
    
    /*
     * True if the popup should be closed normally if the user clicks inside
     * it. Default is true.
     */
    closeOnInsideClick: Control.property.bool(),
    
    initialize: function()
    {
        var self = this;
        this
            .click( function() {
                if ( self.closeOnInsideClick() )
                {
                    self.close();
                }
            })
            .genericIfClassIs( Popup );

        // When window loses focus, cancel the popup. This is wired up later.
        this._windowBlurHandler( function() {
            self.cancel();
        });
    },
    
    /*
     * Cancel the popup. This is just like closing it, but raises a "canceled"
     * event instead.
     * 
     * This has no effect if the popup is already closed.
     */
    cancel: function() {
        return !this.opened()
            ? this
            : this
                .trigger( "canceled" )
                ._hidePopup();
    },
    
    /*
     * Close the popup (dismiss it).
     * 
     * This has no effect if the popup is already closed.
     */
    close: function() {
        return !this.opened()
            ? this
            : this
                .trigger("closed")
                ._hidePopup();
    },

    /*
     * Open the popup (show it).
     * 
     * This has no effect if the popup is already opened.
     */
    open: function()
    {
        if ( this.opened() ) {
            return this;
        }
        
        if ( this.blanket() == null ) {
            this.blanket( this._createBlanket() );
        }
        
        /* Show control and blanket at the top of the Z-order. */
        var maximumZIndex = this._maximumZIndex();
        this.blanket()
            .css( "z-index", maximumZIndex + 1 )
            .show();
            
        this
            ._bindKeydownHandler( true )
            .css( "z-index", maximumZIndex + 2 )
            .show()
            .trigger( "opened" );
            
        // In case the overlay wants to resize anything now that it's visible.
        Layout.recalc();
        
        // Position the popup after the layout recalc.
        var positionPopup = this.positionPopup();
        if ( positionPopup ) {
            positionPopup.call( this );
        }
        
        // Listen for window blur event.
        $( window ).on( "blur", this._windowBlurHandler() );
        
        return this;
    },
    
    /*
     * Open the popup.
     */
    opened: function() {
        return this.blanket() != null;
    },
    
    /*
     * A callback function that will be invoked to position the popup.
     * This is implemented as a property instead of an overrideable method
     * so that the position logic can be set on a per-instance basis without
     * having to create a new subclass.
     */
    positionPopup: Control.property(),
    
    _bindKeydownHandler: function( handleKeydown ) {
        var handler;
        if ( handleKeydown ) {
            var self = this;
            handler = function( event) {
                if ( self.cancelOnEscapeKey() && event.keyCode === 27 /* Escape */ ) {
                    self.cancel();
                }
            }
            this.data( "_keydownHandler", handler );
            $( document ).on( "keydown", handler );
        } else {
            handler = this.data( "_keydownHandler" );
            if ( handler ) {
                $( document ).unbind( "keydown", handler );
            }
        }
        return this;
    },

    _createBlanket: function() {
        
        var $blanket = this
            .after( "<div id='blanket'/>" )
            .next();
            
        var cancelOnOutsideClick = this.cancelOnOutsideClick();
        var color = this.blanketColor() ||
                        ( cancelOnOutsideClick ? false : "black" );
        var opacity = this.blanketOpacity() ||
                        ( cancelOnOutsideClick ? 0.01 : 0.25 );
            
        var self = this;
        $blanket
            .click( function() {
                if ( self.cancelOnOutsideClick() ) {
                    self.cancel();
                }
            })
            .css({
                cursor: "default",
                position: "fixed",
                opacity: opacity,
                top: 0,
                left: 0,
                width: "100%",
                height: "100%"
            });
        if ( color ) {
            $blanket.css( "background-color", color );
        }
        
        return $blanket;
    },

    _hidePopup: function()
    {
        this
            ._bindKeydownHandler( false )
            .hide()
            .css( "z-index", null ); // No need to define Z-order any longer.
        if ( this.blanket() != null ) {
            this.blanket().remove();
            this.blanket(null);
        }
        
        // Stop listening for window blur.
        // Listen for window blur event.
        $( window ).unbind( "blur", this._windowBlurHandler() );
        
        return this;
    },

    /* Return the maximum Z-index in use by the page and its top-level controls. */
    _maximumZIndex: function()
    {
        var topLevelElements = $( "body" ).children().andSelf();
        var zIndices = $.map( topLevelElements, function( element ) {
            switch ( $(element).css("position") ) {
                case "absolute":
                case "fixed":
                    var zIndex = parseInt( $( element ).css( "z-index" ) ) || 1;
                    return zIndex;
            }
        });
        return Math.max.apply( null, zIndices );
    },
    
    _windowBlurHandler: Control.property(),

});

//
// RadioButton
//
var RadioButton = LabeledInput.subclass({
    className: "RadioButton",
    _type: "radio"
});
RadioButton.prototype.extend({
    
    /*
     * True if the control should automatically select a name() property value
     * if no name is specified. This automatic name will match the name of other
     * autonamed sibling radio buttons, allowing them to work as a radio button
     * group without the need for an explicit name.
     */
    autoName: Control.property.bool( null, true, function() {
        this._checkName();
    }),
    
    initialize: function() {
        this._super();
        this.inDocument( function() {
            this._checkName();
        });
    },
    
    /*
     * The text name of the radio button group.
     * 
     * You can set the name() of all radio buttons in the same group to the same
     * value to ensure the radio buttons are mutually exclusive. Alternatively,
     * you can rely on the autoName() property to automatically select a name
     * which will group the control with auto-named sibiling radio buttons.
     */
    name: Control.chain( "_inputControl", "prop/name", function() {
        this._checkName();
    }),
    
    _checkName: function() {
        if ( this.inDocument() && this.autoName() && !this.name() ) {
            /*
             * Pick a name.
             * First look for an autonamed sibling.
             */
            var named;
            this.siblings().eachControl( function( index, control ) {
                if ( control instanceof RadioButton
                    && control.autoName()
                    && control.name() ) {
                    named = control;
                    return false;
                }
            });
            var name = named
                ? named.name()  /* Use sibling's name. */
                : RadioButton.generateUniqueName(); /* Generate a name. */
            this.name( name );
        }
    }
});

/* Class members */
RadioButton.extend({
    
    generateUniqueName: function() {
        return "_group" + this._count++;
    },
    
    _count: 0
    
});

//
// Repeater
//
var Repeater = Control.subclass({
    className: "Repeater"
});
Repeater.prototype.extend({

    /*
     * The content which will be repeated in each instance.
     */
    content: Control.property( function( content ) {
        this._refreshContent( this.controls() );
    }),

    /*
     * The class that will be repeated.
     */
    controlClass: Control.property[ "class" ]( function() { this._refresh(); }),
    
    /*
     * The generated collection of controls.
     */
    controls: Control.chain( "children", "control" ),
    
    /*
     * The number of repetitions to create.
     */
    count: Control.property.integer( function() { this._refresh(); }, 0 ),
    
    initialize: function() {
        this._refresh();
    },
    
    /*
     * True if the Repeater should append "1", "2", "3", etc., after the
     * content of each instance.
     */
    increment: Control.property.bool( function() {
        this._refreshContent( this.controls() );
    }),

    _refresh: function() {
        var controlClass = this.controlClass();
        var count = this.count();
        if ( controlClass && count > 0 ) {
            var controls = [];
            for ( var i = 0; i < count; i++ ) {
                var $control = controlClass.create();
                controls.push( $control );
            }
            this._refreshContent( controls );
        }
        // Use base .content() property since we've overridden it.
        Control( this ).content( controls );
    },
    
    _refreshContent: function( controls ) {
        if ( controls == null ) {
            return;
        }
        var content = this.content();
        var increment = this.increment();
        for ( var i = 0; i < controls.length; i++ ) {
            var $control = $( controls[i] ).control();
            // TODO: if content is jQuery object, should clone elements. 
            var instanceContent;
            if ( content && increment ) {
                instanceContent = content + " " + ( i + 1 );
            } else if ( content ) {
                instanceContent = content;
            } else if ( increment ) {
                instanceContent = i + 1;
            }
            if ( instanceContent ) {
                $control.content( instanceContent );
            }
        }
    }
    
});

//
// SearchBox
//
var SearchBox = Control.subclass({
    className: "SearchBox",
    content: [
        " ",
        {
            control: "TextBoxWithButton",
            textBox: [
                " ",
                {
                    control: "HintTextBox",
                    id: "searchTerms",
                    hint: "Enter search"
                },
                " "
            ],
            goButton: [
                " ",
                {
                    control: "SampleSpriteButton",
                    id: "searchButton",
                    content: "Search"
                },
                " "
            ]
        },
        " "
    ]
});
SearchBox.prototype.extend({

    /*
     * A hint to show in the text box.
     */    
    hint: Control.chain("$searchTerms", "hint"),

    initialize: function() {
        var self = this;
        this.on("goButtonClick", function() {
            var searchTerms = self.$searchTerms().content();
            var url = self.query().replace("%s", searchTerms);
            window.location.href = url;
        });
        this.$searchTerms().focus();
    },
    
    /*
     * The search query that should be executed when the user presses the "Go"
     * button. This should be string containing the sequence "%s", which will
     * be replaced with the search terms the user has entered in the text box.
     */
    query: Control.property(null, "%s")
    
});

//
// SlidingPages
//
var SlidingPages = Control.subclass({
    className: "SlidingPages",
    content: [
        " ",
        {
            html: "<div />",
            id: "SlidingPages_content"
        },
        " "
    ]
});
SlidingPages.prototype.extend({
    
    /*
     * The index of the page currently being shown.
     */
    activeIndex: Control.property.integer( function( activeIndex ) {
        var page = this.pages().eq( activeIndex );
        if ( page.length > 0 ) {
            var left = page.position().left;
            this.$SlidingPages_content().animate({
                "left": -left
            }, "fast" );
        }
    }),

    /*
     * The array of elements to use as pages.
     */
    content: Control.chain( "$SlidingPages_content", "content", function() {
        this._adjustWidths();
    }),
    
    initialize: function() {

        var self = this;
        this
            .inDocument( function( $control ) {
                $control._adjustWidths();
            })
            .on( "sizeChanged", function() {
                self._adjustWidths();
            });

        if ( !this.activeIndex() ) {
            this.activeIndex(0);
        }
    },
    
    pages: Control.chain( "$SlidingPages_content", "children" ),
    
    // Force all pages and the control itself to the maximium width of the pages.
    _adjustWidths: function() {

        var pages = this.pages();
        if ( pages.length === 0 ) {
            return;
        }
        
        var pageWidths = pages.map( function( index, page ) {
            return $( page ).width();
        }).get();
        var maxPageWidth = Math.max.apply( this, pageWidths );
        if ( maxPageWidth > 0 ) {
            pages.width( maxPageWidth );
        }

        var pageOuterWidths = pages.map( function( index, page ) {
            return $( page ).outerWidth( true );
        }).get();
        var maxPageOuterWidth = Math.max.apply( this, pageOuterWidths );
        if ( maxPageOuterWidth > 0 ) {
            this.width( maxPageOuterWidth );
        }
    }
    
});

//
// SlidingPagesWithDots
//
var SlidingPagesWithDots = Control.subclass({
    className: "SlidingPagesWithDots",
    content: [
        " ",
        {
            control: "SlidingPages",
            id: "pages"
        },
        " ",
        {
            html: "<div />",
            id: "buttonPanel",
            content: [
                " ",
                {
                    control: "Repeater",
                    id: "pageButtons"
                },
                " "
            ]
        },
        " "
    ]
});
SlidingPagesWithDots.prototype.extend({
    
    /*
     * The index of the page currently being shown.
     */
    activeIndex: Control.property( function( activeIndex ) {
        this.$pages().activeIndex( activeIndex );
        this.pageButtons()
            .removeClass( "selected" )
            .eq( activeIndex )
                .addClass( "selected" );
        return this;
    }),
    
    /*
     * The array of elements to show as pages.
     */
    content: Control.chain( "$pages", "content", function() {
        this.$pageButtons().count( this.pages().length );
    }),
    
    initialize: function() {
        
        this.genericIfClassIs( SlidingPagesWithDots );
        if ( !this.pageButtonClass() ) {
            this.pageButtonClass( BasicButton );
        }

        var self = this;
        this.$pageButtons().click( function( event ) {
            // Which button was clicked?
            var pageButton = $( event.target ).closest( self.pageButtons() ).control();
            if ( pageButton ) {
                var index = self.pageButtons().index( pageButton );
                if ( index >= 0 ) {
                    self.activeIndex( index );
                }
            }
        });
        
        if ( !this.activeIndex() ) {
            this.activeIndex(0);
        }
    },
    
    pageButtons: Control.chain( "$pageButtons", "children" ),

    /*
     * The class used to render the buttons to navigate between pages.
     */    
    pageButtonClass: Control.chain( "$pageButtons", "controlClass" ),
    
    pages: Control.chain( "$pages", "pages" )

});

//
// Sprite
//
var Sprite = Control.subclass({
    className: "Sprite"
});
Sprite.prototype.extend({
    
    /*
     * The sprite image.
     */
    image: Control.chain( "css/background-image" ),

    /*
     * The height of a single cell in the sprite image, in pixels.
     */
    cellHeight: Control.property( function( value ) {
        this.css( "height", value + "px" );
        this._shiftBackground();
    }),
    
    /*
     * The sprite cell currently shown.
     */
    currentCell: Control.property( function( value ) {
        this._shiftBackground();
    }, 0),

    _shiftBackground: Control.iterator( function() {
        if ( this.currentCell() != null && this.cellHeight() != null ) {
            var y = ( this.currentCell() * -this.cellHeight() ) + "px";
            if ( $.browser.mozilla ) {
                // Firefox 3.5.x doesn't support background-position-y,
                // use background-position instead.
                var backgroundPosition = this.css( "background-position" ).split(" ");
                backgroundPosition[1] = y;
                this.css( "background-position", backgroundPosition.join(" ") );          
            } else {
                // Not Firefox
                this.css( "background-position-y", y );
            }
        }
    })
});

//
// SpriteButton
//
var SpriteButton = BasicButton.subclass({
    className: "SpriteButton",
    content: [
        " ",
        {
            control: "Sprite",
            id: "backgroundLeft"
        },
        " ",
        {
            control: "Sprite",
            id: "backgroundRight"
        },
        " ",
        {
            html: "<button />",
            id: "SpriteButton_content"
        },
        " "
    ]
});
SpriteButton.prototype.extend({

    /*
     * The height of the sprite image, in pixels.
     */    
    cellHeight: Control.chain("css/height", function(value) {
        this.$SpriteButton_content().height(value + "px");
        this.$sprites().cellHeight(value);
    }),
    
    content: Control.chain("$SpriteButton_content", "content"),
    
    disabled: function(value) {
        if (value !== undefined)
        {
            this.$SpriteButton_content().attr("disabled", String(value) == "true");
        }
        return this._super( value );
    },
    
    /*
     * The sprite image.
     */
    image: Control.chain("$sprites", "image"),

    initialize: function() {
        this._super();
        var self = this;
        this.$SpriteButton_content()
            .blur(function() { self.blur(); })
            .focus(function() { self.focus(); });
    },
    
    $sprites: function() {
        return this.children().filter(".Sprite").cast();
    },
    
    _renderButtonState: function(buttonState) {
        this.$sprites().currentCell(buttonState);
    }

});

//
// Switch
//
var Switch = Control.subclass({
    className: "Switch"
});
Switch.prototype.extend({
    
    /*
     * The currently visible child, cast to a control (if applicable).
     */
    activeChild: Control.iterator( function( activeChild ) {
        if ( activeChild === undefined ) {
            return this.children().not( ".hidden" ).eq(0).cast( jQuery );
        } else {
            
            /*
             * Apply a "hidden" style instead of just forcing display to none.
             * If we did that, we would have no good way to undo the hiding.
             * A simple .toggle(true) would set display: block, which wouldn't
             * be what we'd want for inline elements.
             */
            this.children().not( activeChild ).toggleClass( "hidden", true );

            var activeChildIndex = this.children().index( activeChild );

            // Tell the child it's now active, and show it.
            $( activeChild )
                .trigger( "active" )
                .toggleClass( "hidden", false );
            
            this
                // Trigger our own activeChildChanged event.
                .trigger( "activeChildChanged", [ activeChildIndex, activeChild ] )
            
                // In case the new child wants to re-lay itself out.
                .trigger( "sizeChanged" );
            
            return this;
        }
    }),
    
    /*
     * The index of the currently visible child.
     */
    activeIndex: function( index ) {
        if ( index === undefined ) {
            return this.children().index( this.activeChild() );
        } else {
            return this.activeChild( this.children().eq( index ) );
        }
    },
    
    /*
     * The array of elements that will be held; only one will be shown at a time.
     * 
     * If the set changes, this will attempt to preserve the one that was
     * previously active. Otherwise, the first element is made active.
     */
    content: function( value ) {
        
        var previousChild = this.activeChild();
        var result = this._super( value );
        
        if ( value !== undefined ) {
            if ( previousChild && previousChild.parent()[0] === this[0] ) {
                // Still have previously active child; hide other children.
                this.activeChild( previousChild );
            } else {
                this.activeIndex( 0 );
            }
        }
        
        return result;
    },
    
    initialize: function() {
        
        var self = this;
        this.on( "sizeChanged", function() {
            self._childSizeChanged();
        });

        // TODO: Call _childSizeChanged() on a window resize event too.        
        this.inDocument( function() {
            this._childSizeChanged();
        });
        
        if ( this.activeIndex() < 0 ) {
            // Show first child by default. 
            this.activeIndex(0);
        }
        
    },
    
    /*
     * True if the control should always adjust its own height to be as tall
     * as its tallest child, whether or not that child is currently active.
     */
    maximize: Control.chain( "applyClass/maximize" ),
    
    /*
     * The size of a child may have changed. Make the control as tall as the
     * tallest child.
     */
    _childSizeChanged: function() {
        
        if ( !this.maximize() ) {
            return;
        }
        
        var children = this.children();
        if ( children.length === 0 ) {
            return;
        }
        
        var childHeights = children.map( function( index, child ) {
            return $( child ).outerHeight( true );
        }).get();
        
        var maxChildHeight = Math.max.apply( this, childHeights );
        if ( maxChildHeight > 0 ) {
            this.height( maxChildHeight );
        }
    }
        
});

//
// Tab
//
var Tab = Control.subclass({
    className: "Tab"
});
Tab.prototype.extend({
    
    /*
     * The content of the tab.
     */
    content: function( value ) {
        var result = this._super( value );
        if ( value !== undefined ) {
            // The parent (e.g., a TabSet) may want to know that the size
            // of this element has changed.
            this.trigger( "sizeChanged" );  
        }
        return result;
    },
    
    /*
     * The description which should be rendered in the button for the tab.
     */
    description: Control.property()
    
});

//
// TabSet
//
var TabSet = Control.subclass({
    className: "TabSet",
    content: [
        " ",
        {
            control: "VerticalPanels",
            id: "tabPanels",
            top: [
                " ",
                {
                    control: "List",
                    id: "tabButtons",
                    itemClass: "BasicButton"
                },
                " "
            ],
            content: [
                " ",
                " ",
                {
                    control: "Switch",
                    id: "TabSet_content",
                    maximize: "true"
                },
                " "
            ]
        },
        " "
    ]
});
TabSet.prototype.extend({
    
    /*
     * The array of elements to be shown as tabs.
     */
    content: Control.chain( "$TabSet_content", "content", function() {
        this._createButtons();
    }),

    /*
     * True if the TabSet should vertically fill its container.
     */    
    fill: Control.chain( "$tabPanels", "fill" ),
    
    initialize: function() {
        
        this.genericIfClassIs( TabSet );
        
        var self = this;
        this.$tabButtons().click( function( event ) {
            var tabButtonCssClass = "." + self.tabButtonClass().className;
            var tabButton = $( event.target ).closest( tabButtonCssClass ).control();
            if ( tabButton ) {
                var index = self.tabButtons().index( tabButton );
                if ( index >= 0 ) {
                    var tab = self.tabs()[ index ];
                    self.trigger( "tabButtonClick", [ index, tab ]);
                    if ( self.selectTabOnClick() ) {
                        self.selectedTabIndex( index );
                    }
                }
            }
        });
        
        this.$TabSet_content().on({
            "activeChildChanged": function( event, index, child ) {
                /*
                 * Map the Switch's activeChildChanged event to a more
                 * semantically specific activeTabChanged event.
                 * 
                 * Only map active events coming from our own Switch; ignore
                 * events coming from any Switch within a tab.
                 */
                var tab = $( event.target ).filter( self.tabs() );
                if ( tab.length > 0 ) {
                    event.stopPropagation();
                    self.trigger( "activeTabChanged", [ index, child ] );
                }
            }
        });
        
        if ( this.tabs().length > 0 && !this.selectedTabIndex() ) {
            // Select first tab by default.
            this.selectedTabIndex(0);
        }
    },
    
    /*
     * True if a tab should be selected on click; false if the showing of the
     * clicked tab will be handled separately. 
     */
    selectTabOnClick: Control.property.bool( null, true ),

    /*
     * The child currently shown as the selected tab.
     */
    selectedTab: Control.chain( "$TabSet_content", "activeChild" ),
    
    /*
     * The index of the selected tab.
     */
    selectedTabIndex: Control.chain( "$TabSet_content", "activeIndex", function( index ) {
        this.tabButtons()
            .removeClass( "selected" )    // Deselect all tab buttons.
            .eq( index )
            .addClass( "selected" );      // Select the indicated button.
    }),

    /*
     * The current set of tab button controls.
     */
    tabButtons: Control.chain( "$tabButtons", "children" ),
    
    /*
     * The class which should be used to create tab buttons for the set.
     */
    tabButtonClass: Control.chain( "$tabButtons", "itemClass", function() {
        this._createButtons();
    }),
    
    /*
     * The content of the current set of tabs.
     */
    tabs: Control.chain( "$TabSet_content", "children", "cast" ),

    /*
     * Called whenever the set of buttons needs to be regenerated.
     */
    _createButtons: function() {
        
        if ( this.tabButtonClass() === undefined ) {
            return;
        }
        
        // Show the description for each tab as a button.
        var descriptions = this.tabs()
            .map( function( index, tab ) {
                var $tab = $( tab ).control();
                var description = ( $tab && $.isFunction( $tab.description ) )
                        ? $tab.description()
                        : "";
                return description;
            })
            .get();
            
        this.$tabButtons().items( descriptions );
        
        var selectedTabIndex = this.selectedTabIndex(); 
        if ( selectedTabIndex != null ) {
            // Ensure the indicated button is shown as selected.
            this.selectedTabIndex( selectedTabIndex );
        }
    }

});

//
// TextBox
//
var TextBox = Control.subclass({
    className: "TextBox",
    content: [
        " ",
        {
            html: "<input type=\"text\" />",
            id: "textBox"
        },
        " "
    ]
});
TextBox.prototype.extend({
    content: Control.chain( "$textBox", "content", function() {
        /*
         * Setting content programmatically generates a change event
         * so that the UI can react accordingly.
         */
        this.trigger( "change" );
    })
});

//
// TextBoxWithButton
//
var TextBoxWithButton = Control.subclass({
    className: "TextBoxWithButton",
    content: [
        " ",
        {
            html: "<div />",
            id: "TextBoxWithButton_textBox"
        },
        " ",
        {
            html: "<div />",
            id: "TextBoxWithButton_goButton"
        },
        " "
    ]
});
TextBoxWithButton.prototype.extend({
    
    /*
     * The button shown next to the text box. This button is disabled if the
     * text box is currently empty.
     */
    goButton: Control.chain("$TextBoxWithButton_goButton", "content"),
    
    initialize: function() {
        var self = this;
        this.$TextBoxWithButton_textBox().on("change keydown keyup", function(event) {
            self._disableButtonIfContentEmpty();
            var keyCode = event.keyCode || event.which;
            if (!self._isContentEmpty() && keyCode == 13 /* Enter */)
            {
                self.trigger("goButtonClick");
            }
        });
        this.$TextBoxWithButton_goButton().click(function() {
            self.trigger("goButtonClick");
        });
        this._disableButtonIfContentEmpty();
    },

    /*
     * The content of the text box.
     */    
    content: function(value) {
        result = this.$TextBoxWithButton_textBox().content(value); 
        if (value !== undefined) 
        {
            this._disableButtonIfContentEmpty();
        }
        return result;
    },
    
    /*
     * The text box.
     */    
    textBox: Control.chain("$TextBoxWithButton_textBox", "content"),
    
    _disableButtonIfContentEmpty: function() {
        var content = this.content();
        var $goButton = this.$TextBoxWithButton_goButton();
        if ($goButton.children().length > 0)
        {
            var buttonControl = Control($goButton.children()[0]);
            if (buttonControl != null && buttonControl instanceof BasicButton)
            {
                buttonControl.disabled(this._isContentEmpty());
            }
        }
    },
    
    _isContentEmpty: function() {
        var content = this.content();
        return !(content && content.length > 0);
    }
    
});

//
// TextCondenser
//
var TextCondenser = Layout.subclass({
    className: "TextCondenser",
    content: [
        " ",
        {
            html: "<span />",
            id: "normal"
        },
        " ",
        {
            html: "<span />",
            id: "condensed"
        },
        " "
    ]
});
TextCondenser.prototype.extend({
    
    /*
     * The font family to use for condensed text.
     */
    condensedFontFamily: Control.chain("$condensed", "css/font-family"),

    content: Control.chain("$normal", "content", function(content) {
        this.$condensed().content(content); // Make a copy of the text.
        this.layout();
    }),
    
    layout: function() {
        return this.eachControl(function(index, $control) {
            var tooWide = this.$normal().width() > this.width();
            this.applyClass("condensed", tooWide);
        });
    }
});

//
// ToggleButton
//
var ToggleButton = BasicButton.subclass({
    className: "ToggleButton"
});
ToggleButton.prototype.extend({
    
    initialize: function() {
        this._super();
        var self = this;
        this
            .click( function() {
                if ( !self.disabled() ) {
                    self.toggleSelected();
                }
            })
            .genericIfClassIs( ToggleButton );
    },

    /*
     * True if the button is currently in the selected state.
     */    
    selected: Control.chain( "applyClass/selected" ),

    /*
     * Toggle the button's selected state.
     */    
    toggleSelected: function( value ) {
        this.selected( value || !this.selected() );
    }
});

//
// TransientMessage
//
var TransientMessage = Popup.subclass({
    className: "TransientMessage"
});
TransientMessage.prototype.extend({
    
    /*
     * Cancel display of the message.
     */
    cancel: function() {
        this._super();
        this.remove();
    },
    
    /*
     * Close display of the message normally.
     */
    close: function() {
        this._super();
        this.remove();
    },
    
    /*
     * The time before the message begins to fade out.
     * 
     * If undefined (the default value), the duration will be calculated
     * from the length of the message.
     */
    duration: Control.property(),
    
    initialize: function() {
        
        this._super();
        this.genericIfClassIs( TransientMessage );
        
        var self = this;
        this.positionPopup( function() {
            return self.css({
                left: ( $( window ).width() - this.outerWidth() ) / 2
            });
        });
    },

    /*
     * Show the message.
     */    
    open: function() {
        
        this._super();
        
        var duration = this.duration();
        if ( !duration ) {
            var content = this.content();
            var length = ( typeof content === "string" )
                ? content.length
                : $( content ).text().length;
            duration = 750 + ( length * 20 );
        }
        
        var self = this;
        var timeout = setTimeout( function() {
            self._fadeOut();
        }, duration );
        this._timeout( timeout );
        
        return this;
    },

    _fadeOut: function() {
        var self = this;
        this.fadeOut( null, function() {
            self.close();
        });
    },
    
    _timeout: Control.property()
    
});

/* Class methods */
TransientMessage.extend({
    
    /*
     * Show the given content for the indicated (optional) duration.
     */
    showMessage: function( content, duration ) {
        
        var transientMessage = TransientMessage.create();
        if ( content ) {
            transientMessage.content( content );
        }
        if ( duration ) {
            transientMessage.duration( duration );
        }
        
        $( "body" )
            .append( transientMessage );
        transientMessage.open();
        
        return transientMessage;
    },
    
});

//
// ValidatingTextBox
//
var ValidatingTextBox = TextBox.subclass({
    className: "ValidatingTextBox"
});
ValidatingTextBox.prototype.extend({
    
    /*
     * The control's content. Setting this implicitly performs validation.
     */
    content: function( content ) {
        var result = this._super( content );
        if ( content !== undefined ) {
            this.validate( true );
        }
        return result;
    },
    
    initialize: function() {
        
        this._super();
        this.genericIfClassIs( ValidatingTextBox );
        
        var self = this;
        this.$textBox().on({
            "blur": function() {
                if ( self.validateOnBlur() ) {
                    self.validate( true );
                }
            },
            "keyup": function() {
                self.validate();
            }
        });
    },
    
    invalid: Control.chain( "applyClass/invalid" ),
    
    /*
     * True if the text box must be non-empty to be valid.
     */
    required: Control.property.bool(),
    
    /*
     * Returns true if the control's contents are valid.
     * The default implementation simply looks as the required() property and,
     * if true, ensures the content is non-empty.
     *  
     * Subclasses can override this to validate their contents. E.g.:
     * 
     *      valid: function() {
     *          var valid = this._super();
     *          valid = valid && ... Perform additional checks here ...
     *          return valid;
     *      }
     */
    valid: function() {
        var valid;
        if ( this.required() )
        {
            var content = this.content();
            valid = !!content && content.length > 0;
        } else {
            valid = true;
        }
        return valid;
    },
    
    /*
     * Check to see if the control's contents are valid.
     * 
     * If the strict parameter is true, apply the invalid state if the contents
     * are invalid. If the strict parameter is false, then the control can move
     * out of the invalid state (if the contents are now valid), but won't move
     * into the invalid state (even if the contents are actually invalid).
     */
    validate: Control.iterator( function( strict ) {
        var valid = this.valid();
        if ( strict || this.invalid() ) {
            this.invalid( !valid );
        }
    }),
    
    /*
     * True if validation should be automatically be performed when the control
     * loses focus. Default is true.
     */
    validateOnBlur: Control.property.bool( null, true )

});

//
// VerticalAlign
//
var VerticalAlign = Control.subclass({
    className: "VerticalAlign"
});

//
// VerticalPanels
//
var VerticalPanels = Layout.subclass({
    className: "VerticalPanels",
    content: [
        " ",
        {
            html: "<div />",
            id: "VerticalPanels_top"
        },
        " ",
        {
            html: "<div />",
            id: "VerticalPanels_content"
        },
        " ",
        {
            html: "<div />",
            id: "VerticalPanels_bottom"
        },
        " "
    ]
});
VerticalPanels.prototype.extend({

    /*
     * The content of the bottom panel.
     */    
    bottom: Control.chain( "$VerticalPanels_bottom", "content", function() {
        this.trigger( "sizeChanged" );        
    }),
    
    /*
     * The content of the main center panel.
     */
    content: Control.chain( "$VerticalPanels_content", "content", function() {
        this.trigger( "sizeChanged" );        
    }),
    
    /*
     * True if the control should fill its container.
     */
    fill: Control.chain( "applyClass/fill", function() {
        this.trigger( "sizeChanged" );
    }),
    
    layout: function() {
        //this._log("layout");
        if ( this.fill() ) {
            var panelHeight = this.$VerticalPanels_top().outerHeight() + this.$VerticalPanels_bottom().outerHeight();
            var availableHeight = this.height() - panelHeight;
            this.$VerticalPanels_content().height( availableHeight );
        } else {
            this.$VerticalPanels_content().height( "" );
        }
        return this;
    },
    
    /*
     * The content of the top panel.
     */
    top: Control.chain( "$VerticalPanels_top", "content", function() {
        this.trigger( "sizeChanged" );        
    })

});

//
// CalendarMonthNavigator
//
var CalendarMonthNavigator = LateralNavigator.subclass({
    className: "CalendarMonthNavigator",
    heading: [
        " ",
        {
            control: "MonthName",
            id: "monthName"
        },
        " ",
        {
            html: "<span />",
            id: "year"
        },
        " "
    ],
    content: [
        " ",
        " ",
        {
            control: "CalendarMonthWithHeadings",
            id: "calendar",
            showMonthName: "false"
        },
        " ",
        {
            html: "<div />",
            id: "todayContainer",
            content: [
                " ",
                {
                    control: "BasicButton",
                    id: "buttonToday",
                    content: "Today"
                },
                " "
            ]
        },
        " "
    ]
});
CalendarMonthNavigator.prototype.extend({

    /*
     * The control's current culture.
     */
    culture: function( culture ) {
        var result = this._super( culture );
        if ( culture !== undefined ) {
            this.$monthName().culture( culture );
            this.$calendar().culture( culture );
        }
        return result;
    },
    
    /*
     * The date that will be included in the month (can be any day of the month).
     */
    date: Control.property( function( date ) {
        if ( this.$calendar().date().getTime() !== date.getTime() ) {
            this.$calendar().date( date );
        }
        this.$monthName().month( date.getMonth() );
        this.$year().content( date.getFullYear() );
        this._applySelection();
    }), 
    
    /*
     * The class used to represent days in the month.
     */
    dayClass: Control.property[ "class" ]( function( dayClass ) {
        /*
         * We define our own dayClass property so we can tell if its been set.
         * This lets us provide a default dayClass.
         */
        this.$calendar().dayClass( dayClass );
    }),
    
    /*
     * The format used to show day headings. See DaysOfWeek.
     */
    dayNameFormat: Control.chain( "$calendar", "dayNameFormat" ),

    initialize: function() {
        
        CalendarMonthNavigator.superclass.prototype.initialize.call( this );
        this.genericIfClassIs( CalendarMonthNavigator );
        
        var self = this;
        this.on({
            "dateChanged": function( event, date ) {
                self.date( date );
            },
            "dateSelected": function( event, date ) {
                self.$calendar().date( date );
            }
        });
        this.$buttonToday().click( function() {
            self.trigger( "dateSelected", [ CalendarDay.today() ]);
        });
        
        if ( !this.dayClass() ) {
            this.dayClass( CalendarDayButton );
        }
        
        if ( !this.date() ) {
            this.date( this.$calendar().date() );
        }
    },

    /*
     * Show the next month.
     */
    next: function() {
        this._adjustMonth( 1 );
    },

    /*
     * Show the previous month.
     */    
    previous: function() {
        this._adjustMonth( -1 );
    },
    
    /*
     * True if the selected date should have the "selected" style applied to it.
     */
    showSelectedDate: Control.property.bool( function( showSelectedDate ) {
        // Force selection (or not) of currently-selected date.
        this._applySelection();
    }, true ),

    /*
     * True if the "Today" button should be shown.
     */
    showTodayButton: Control.chain( "$todayContainer", "visibility" ),
    
    // Move one month forward (if direction is positive) or backward
    // (if direction is negative).
    _adjustMonth: function( direction ) {
        var adjustment = (direction > 0) ? 1 : -1;
        var newDate = new Date( this.date().getTime() );
        var dayOfMonth = newDate.getDate();
        newDate.setMonth( newDate.getMonth() + adjustment );
        if ( newDate.getDate() != dayOfMonth ) {
            // We either overshot (tried to go from Oct 31 to "Nov 31") going
            // forward, or undershot (tried to go from Dec 31 to "Nov 31")
            // going backward. In either case, the fix is to back up to the last
            // date of the previous month, which can be accomplished by the
            // trick of setting the day of the month to zero.
            newDate.setDate(0);
        }
        this.date( newDate );
    },
    
    _applySelection: function() {
        this.$calendar().$days().removeClass( "selected" );
        if ( this.showSelectedDate() ) {
            var dayControl = this.$calendar().dayControlForDate( this.date() );
            dayControl.addClass( "selected" );
        }
    }
    
});

//
// CheckBox
//
var CheckBox = LabeledInput.subclass({
    className: "CheckBox",
    _type: "checkbox"
});

//
// ComboBox
//
var ComboBox = HasPopup.subclass({
    className: "ComboBox",
    content: [
        " ",
        {
            html: "<div />",
            id: "ComboBox_content"
        },
        " ",
        {
            control: "ToggleButton",
            id: "dropdownButton",
            content: "▼"
        },
        " "
    ]
});
ComboBox.prototype.extend({

    /*
     * True if the dropdown portion should automatically close if the user
     * presses Enter. Default is true.
     */
    closeOnEnter: Control.property.bool( null, true ),
    
    /*
     * The content of the combo box's input portion.
     */
    content: Control.chain( "$ComboBox_content", "content" ),
    
    /*
     * The content of the dropdown button. By default, this shows a
     * downward-pointing arrow.
     */
    dropdownButtonContent: Control.chain( "$dropdownButton", "content" ),
    
    initialize: function() {
        
        this._super();
        this.genericIfClassIs( ComboBox );
        
        var self = this;
        this.$HasPopup_popup().on({
            "canceled": function() {
                self.$dropdownButton().selected( false );
            },
            "closed": function() {
                // Closing the popup leaves the text selected.
                // HACK for IE: If we set focus to the input while the popup is
                // being closed, IE won't hide the popup. It seems quite hard
                // to prevent this behavior, so we simply disable the selection
                // behavior in IE.
                if ( !$.browser.msie ) {
                    var content = self.content();
                    self.inputControl().focus();
                    self._selectText( 0, content.length );
                }
                self.$dropdownButton().selected( false );
            }
        });
        
        this.on({
            // Open the popup when any part of the control gets focus.
            "focusin": function( event ) {
                if ( self.openOnFocus() && !self.opened() ) {
                    self.open();
                }
            },
            // Close the popup when the control loses focus.
            "focusout": function( event ) {
                /*
                 * We want to close the popup if the focus moves completely
                 * outside the combo box; i.e., is not within the input box or
                 * the popup. Unfortunately, if the user clicks in the popup,
                 * the input will blur before we've had a chance to even
                 * register the click. And at the point the blur handler here
                 * is invoked, the new activeElement is not yet known, so we
                 * can't test that.  
                 * 
                 * Our solution is to set a timeout which will defer testing
                 * of activeElement until after the normal focusout sequence
                 * has completed and focus has been placed in the new control.
                 */
                if ( self.opened() ) {
                    setTimeout( function() {
                        var focusInControl = $.contains( self[0], document.activeElement );
                        if ( !focusInControl && self.opened() ) { // Still open?
                            self.cancel();
                        }
                    }, 1);
                }
            }
        });
        
        this.$dropdownButton().click( function() {
            self.open();
        });
        
        // Allow the popup container itself to receive the focus.
        // This allows clicks on the popup to still keep focus within the
        // overall ComboBox controls.
        this.$HasPopup_popup().prop( "tabindex", -1 );
       
        if ( !this.textBoxClass() ) {
            // Set a default text box class
            this.textBoxClass( TextBox );
        }
    },
    
    /*
     * Returns the combo box's input control. By default this is the first
     * text input element in the content. Subclasses can override this to
     * indicate that a different element should be used for input.
     */
    inputControl: function() {
        return this.$ComboBox_content().find( "input[type='text']" ).eq(0);
    },
    
    /*
     * Open the combo box.
     */
    open: function() {
        if ( !this.opened() ) {
        
            if ( this.hasClass( "generic" ) ) {
                // Make popup at least as wide as content.
                this.eachControl( function( index, $control ) {
                    var width = $control.outerWidth();
                    this.$HasPopup_popup().css( "min-width", width + "px" );
                });
            }
            
            // User may have invoked popup by clicking in text box with
            // openOnFocus true, in which case we should ensure button looks
            // pressed while popup is open.
            this.$dropdownButton().selected( true );
            
        }
        return this._super();
    },

    /*
     * True if the control should automatically open when it receives the
     * keyboard focus. Default is true.
     */
    openOnFocus: Control.property.bool( null, true ),

    /*
     * The class of the text box portion of the combo box.
     */
    textBoxClass: Control.property[ "class" ]( function( textBoxClass ) {
        
        this.$ComboBox_content().transmute( textBoxClass, true );
        
        // Rebind any content events we want to track.
        this._bindContentEvents();
    }),
    
    _bindContentEvents: function() {
        this._super();
        var self = this;
        this.$ComboBox_content().on({
            "click": function( event ) {
                if ( self.openOnFocus() && !self.opened() ) {
                    self.open();
                }
            },
            "keydown": function( event ) {
                if ( event.which === 13 /* Enter key */
                    && self.closeOnEnter()
                    && self.$HasPopup_popup().opened() ) {
                    self.close();
                }
            }
        });
    },

    /*
     * Select the text at the indicated positions in the input control.
     */
    _selectText: function( start, end ) {
        
        var inputControl = this.inputControl()[0];
        if ( !inputControl ) {
            return; // Can't find input control.
        }
        
        if ( inputControl.setSelectionRange ) {
            // Mozilla/WebKit
            inputControl.setSelectionRange( start, end );
        } else if ( inputControl.createTextRange ) {
            // IE
            var range = inputControl.createTextRange();
            range.moveStart( "character", start );
            range.moveEnd( "character", end );
            range.select();
        }
    }

});

//
// DateComboBox
//
var DateComboBox = ComboBox.subclass({
    className: "DateComboBox",
    textBoxClass: "DateTextBox",
    popup: [
        " ",
        {
            control: "CalendarMonthNavigator",
            id: "navigator"
        },
        " "
    ]
});
DateComboBox.prototype.extend({
    
    /*
     * The date indicated in the control.
     */
    date: Control.property( function( date ) {
        
        var time = date && date.getTime();
        
        var textBoxDate = this.$ComboBox_content().date();
        if ( !textBoxDate || textBoxDate.getTime() !== time ) {
            this.$ComboBox_content().date( date );
        }
        
        // Navigator can only handle non-null dates.
        if ( date ) {
            var navigatorDate = this.$navigator().date();
            if ( !navigatorDate || navigatorDate.getTime() !== time ) {
                this.$navigator().date( date );
            }
        }
    }),
    
    initialize: function() {
        
        this._super();
        this.genericIfClassIs( DateComboBox );
        
        // Sync up dates
        this.date( this.$navigator().date() );

        // Changing text updates navigator, and vice versa.
        var self = this;
        this.on({
            "dateChanged": function( event, date ) {
                self.date( date );
            },
            "dateSelected": function( event, date ) {
                self.date( date );
                self.close();
            }
        });
    },

    /*
     * The class used for the dropdown portion of the combo box.
     * By default this is a CalendarMonthNavigator, but it can be set to any
     * class that exposes a date() property.
     */    
    navigatorClass: Control.chain( "$navigator", "transmute" ),
    
    /*
     * True if the user must enter a value in this field.
     */
    required: Control.chain( "$ComboBox_content", "required" )

});

//
// DateTextBox
//
var DateTextBox = ValidatingTextBox.subclass({
    className: "DateTextBox"
});
DateTextBox.prototype.extend({

    /*
     * The control's current culture.
     */
    culture: function( culture ) {
        var result = this._super( culture );
        if ( culture !== undefined ) {
            this._updateDatePatterns();
            this._refresh();
        }
        return result;
    },

    /*
     * The date indicated in the text box.
     */
    date: Control.property.date( function( date ) {
        var previousDate = this._previousDate();
        var previousTime = previousDate && previousDate.getTime();
        var time = date && date.getTime();
        var dateChanged = ( previousTime !== time ); 
        if ( dateChanged ) {
            
            var hasFocus = $.contains( this[0], document.activeElement );
            if ( !hasFocus ) {
                // We updating the content only if the user isn't typing,
                // so as not to confuse them.
                this._refresh();
            }
            
            this
                ._previousDate( date )
                .trigger( "dateChanged", [ date ] );
        }
    }),
    
    initialize: function() {
        
        this._super();
        this.genericIfClassIs( DateTextBox );
        
        var self = this;
        this.$textBox().blur( function() {
            self._refresh();
        });
        
        this._updateDatePatterns();
    },
    
    /*
     * Returns true if the current date is valid. 
     */
    valid: function() {

        var valid = this._super();

        // Convert content text to a date.
        var content = this.content();
        var date = this._parseDate( content );
        this.date( date );

        // If a date is supplied, it has to be valid.
        if ( content && content.length > 0 ) {
            valid = valid && !!date;
        }
        
        return valid;
    },
    
    /*
     * Use a culture's "short date" pattern (e.g., "M/d/yyyy") to determine
     * some abbreviated date patterns.
     * 
     * The first abbreviated pattern uses a short two-digit year ("M/d/yy")
     * instead of a full four-digit year. The second pattern omits the year
     * ("M/d"). These patterns are determined by looking for a full year
     * placeholder ("yyyy") and the culture's date separator ("/") immediately
     * before or after the year.
     */
    _abbreviatedDatePatterns: function( culture ) {
        
        var patterns = [];
        var calendar = culture.calendar;
        var shortPattern = calendar.patterns.d;
        var fullYearPlaceholder = "yyyy";
        
        // Try replacing full four-digit year with short two-digit year.
        if ( shortPattern.indexOf ( fullYearPlaceholder ) ) {
            patterns.push( shortPattern.replace( fullYearPlaceholder, "yy" ));
        }
        
        // Try removing separator + year, then try removing year + separator.
        var separator = calendar[ "/" ];
        var separatorThenYear = separator + fullYearPlaceholder;
        var yearThenSeparator = fullYearPlaceholder + separator;
        if ( shortPattern.indexOf( separatorThenYear ) >= 0 ) {
            patterns.push( shortPattern.replace( separatorThenYear, "" ) );
        } else if ( shortPattern.indexOf( yearThenSeparator ) >= 0 ) {
            patterns.push( shortPattern.replace( yearThenSeparator, "" ) );
        }
        
        return patterns;
    },
    
    _datePatterns: Control.property(),
    
    // Return the separator between dates.
    _dateSeparator: function() {
        var culture = this.culture();
        var calendar = culture ? culture.calendar : DateTextBox;
        return calendar[ "/" ];
    },
    
    _formatDate: function( date ) {
        var culture = this.culture();
        var formattedDate;
        if ( culture ) {
            formattedDate = Globalize.format( date, culture.calendar.patterns.d, culture );
        } else {
            formattedDate = (date.getMonth() + 1) + 
                this._dateSeparator() + date.getDate() +
                this._dateSeparator() + date.getFullYear();
        }
        return formattedDate;
    },

    /*
     * Parse the given text as a date.
     * Use the culture's parser if available, otherwise use a default parser.
     */
    _parseDate: function( text ) {
        var date = this.culture()
            ? Globalize.parseDate( text, this._datePatterns(), this.culture() )
            : this._parseDateDefault( text );
        return date;
    },
    
    /*
     * Basic date parser.
     * Parses the given text as a date and return the result.
     * Returns null if the text couldn't be parsed.
     * 
     * This handles the formats supported by the standard Date.parse(),
     * as well as handling a short year ("1/1/12") or missing year ("1/1").
     */
    _parseDateDefault: function( text ) {

        if ( text === "" ) {
            return null;
        }

        var dateSeparator = this._dateSeparator();
        var parts = text.split( dateSeparator );
        var currentYear = ( new Date() ).getFullYear().toString();
        
        var munged;
        if ( parts.length === 2 ) {
            // Add on year
            munged = text + dateSeparator + currentYear;
        } else if ( parts.length === 3 && parts[2].length == 2 ) {
            // Convert short year to long year
            var fullYear = currentYear.substring(0, 2) + parts[2];
            munged = parts[0] + dateSeparator
                   + parts[1] + dateSeparator
                   + fullYear;
        } else {
            // Parse as is
            munged = text;
        }
        
        var milliseconds = Date.parse( munged );
        var date = isNaN( milliseconds )
            ? null
            : new Date( milliseconds );
        return date;
    },
    
    _refresh: function() {
        var date = this.date();
        if ( !!date ) {
            var formattedDate = this._formatDate( date );
            if ( formattedDate !== this.content() ) {
                this.content( formattedDate );
            }
        }
        return this;
    },
    
    _previousDate: Control.property.date(),
    
    /*
     * If the culture's been set, we amend the list of support date patterns
     * to include some abbreviated patterns.
     */
    _updateDatePatterns: function() {
        var datePatterns = null;
        var culture = this.culture();
        if ( culture ) {
            // Update our date patterns based on the new culture.
            var abbreviatedDatePatterns = this._abbreviatedDatePatterns( culture );
            if ( abbreviatedDatePatterns.length > 0 ) {
                // Add our abbreviated patterns to all the culture's patterns.
                datePatterns = $.map( this.culture().calendar.patterns, function( pattern, name ) {
                    return pattern;
                });
                datePatterns = datePatterns.concat( abbreviatedDatePatterns );
            }
        }
        this._datePatterns( datePatterns );
    }
    
});

/*
 * Class properties.
 */
DateTextBox.extend({
    // Date separator, used when Globalize is not present.
	"/": "/"
});

//
// Dialog
//
var Dialog = Popup.subclass({
    className: "Dialog",
    cancelOnOutsideClick: "false"
});
Dialog.prototype.extend({

    /*
     * Cancel the dialog. This implicitly closes the dialog.
     */    
    cancel: function() {
        this._super();
        this.remove();
    },
    
    /*
     * Close the dialog normally.
     */
    close: function() {
        this._super();
        this.remove();
    },
    
    initialize: function() {
        this._super();
        this.genericIfClassIs( Dialog );
        var self = this;
        this.positionPopup( function() {
            self._positionPopup();
        });
    },
    
    /*
     * Position the dialog.
     * By default, center dialog horizontally and vertically. 
     */
    _positionPopup: function() {
        return this.css({
            left: ( $( window ).width() - this.outerWidth() ) / 2,
            top: ( $( window ).height() - this.outerHeight() ) / 2
        });
    }

});

// Class methods
Dialog.extend({
    showDialog: function( dialogClass, properties, callbackOk, callbackCancel ) {
        $("body")
            .append( "<div/>" )
            .find( ":last" )
            .on({
                closed: function() {
                    if ( callbackOk ) {
                        callbackOk.call( $( this ).control() );
                    }
                },
                canceled: function() {
                    if ( callbackCancel ) {
                        callbackCancel.call( $( this ).control() );
                    }
                }
            })
            .control( dialogClass, properties )
            .open();
    }
});

//
// FlickrInterestingNavigator
//
var FlickrInterestingNavigator = CalendarMonthNavigator.subclass({
    className: "FlickrInterestingNavigator",
    dayClass: "FlickrInterestingDay",
    dayNameFormat: "namesAbbr",
    showTodayButton: "false",
    previousButtonContent: [
        " ",
        "<span class=\"chevron\">«</span>",
        " ",
        {
            control: "MonthName",
            id: "previousMonthName",
            "class": "monthButtonName"
        },
        " "
    ],
    nextButtonContent: [
        " ",
        {
            control: "MonthName",
            id: "nextMonthName",
            "class": "monthButtonName"
        },
        " ",
        "<span class=\"chevron\">»</span>",
        " "
    ]
});
FlickrInterestingNavigator.prototype.extend({
    
    /*
     * The control's current culture.
     */
    culture: function( culture ) {
        var result = this._super( culture );
        if ( culture !== undefined ) {
            this.$previousMonthName().culture( culture );
            this.$nextMonthName().culture( culture );
        }
        return result;
    },
    
    /*
     * The date that will be included in the month (can be any day of the month).
     */
    date: function( date ) {
        result = this._super( date );
        if ( date !== undefined ) {
            
            // Show next/previous month names.
            var previousMonth = new Date( date.getTime() );
            previousMonth.setMonth( previousMonth.getMonth() - 1 );
            this.$previousMonthName()
                .month( previousMonth.getMonth() )
                .trigger( "sizeChanged" );

            var nextMonth = new Date( date.getTime() );
            nextMonth.setMonth( nextMonth.getMonth() + 1 );
            this.$nextMonthName()
                .month( nextMonth.getMonth() )
                .trigger( "sizeChanged" );
            
            // Disable navigation into future.
            var today = new Date();
            nextMonth.setDate( 1 );
            this.nextButtonDisabled( nextMonth > today );
                        
            this.$calendar().$days().loadPhoto();
        }
        return result;
    }
        
});

//
// HorizontalPanels
//
var HorizontalPanels = Layout.subclass({
    className: "HorizontalPanels",
    content: [
        " ",
        {
            html: "<div />",
            id: "HorizontalPanels_left"
        },
        " ",
        {
            html: "<div />",
            id: "HorizontalPanels_content"
        },
        " ",
        {
            html: "<div />",
            id: "HorizontalPanels_right"
        },
        " "
    ]
});
HorizontalPanels.prototype.extend({
    
    /*
     * The content of the main center panel.
     */
    content: Control.chain( "$HorizontalPanels_content", "content", function() {
        this.trigger( "sizeChanged" );        
    }),
    
    /*
     * True if the control should fill its container.
     */
    fill: Control.chain( "applyClass/fill" ),
    
    layout: function() {
        var panelLeftWidth = this.$HorizontalPanels_left().outerWidth();
        var panelRightWidth = this.$HorizontalPanels_right().outerWidth();
        this.$HorizontalPanels_content().css({
            left: panelLeftWidth,
            right: panelRightWidth
        });
        return this;
    },
    
    /*
     * The content of the left panel.
     */
    left: Control.chain( "$HorizontalPanels_left", "content", function() {
        this.trigger( "sizeChanged" );        
    }),
    
    /*
     * The content of the right panel.
     */
    right: Control.chain( "$HorizontalPanels_right", "content", function() {
        this.trigger( "sizeChanged" );        
    })

});

//
// ListComboBox
//
var ListComboBox = ComboBox.subclass({
    className: "ListComboBox",
    popup: [
        " ",
        {
            control: "ListBox",
            id: "list"
        },
        " "
    ]
});
ListComboBox.prototype.extend({
    
    
    /*
     * The array of items in the dropdown list. See List for details.
     */
    items: Control.chain( "$list", "items", function() { this._updateItemContents(); } ),
    
    /*
     * A mapping of items to controls and back. See List for details.
     */
    mapFunction: Control.chain( "$list", "mapFunction" ),
    
    initialize: function() {
        
        this._super();
        this.genericIfClassIs( ListComboBox );
        
        // Clicking an item in the list puts its content into the text box portion.
        var self = this;
        this.$list().on({
            
                click: function( event ) {
                    var $closestItem = $( event.target ).closest( self.$list().children() );
                    if ( $closestItem ) {
                        var itemContent = $closestItem.control().content();
                        self
                            .content( itemContent )
                            .close();
                    }
                },
                
                keydown: function( event ) {
                    if ( event.which === 13 /* Enter */) {
                        if ( self.opened() ) {
                            self.close();
                            event.stopPropagation();
                            event.preventDefault();
                        }
                    }
                },
                
                selectionChanged: function() {
                    var selectedControl = self.$list().selectedControl();
                    if ( selectedControl ) {
                        var content = selectedControl.content();
                        if ( content !== self.content() ) {
                            self.content( content );
                            self._selectText( 0, content.length );
                        }
                    }
                }
                
            });
        
        if ( !this.itemClass() ) {
             this.itemClass( BasicButton );
        }
    },
    
    /*
     * The class which should be used to render the list items as controls.
     */
    itemClass: Control.property[ "class" ]( function( itemClass ) {
        this.$list().itemClass( itemClass );
    }),
    
    open: function() {

        // See if current text is in the list and, if so, select it.
        var content = this.content();
        var index = $.inArray( content, this._itemContents() );
        if ( index >= 0 ) {
            this.$list().selectedIndex( index );
        }
        
        var result = this._super();
        
        // Give the input control focus if it doesn't already have it.
        var inputControl = this.inputControl();
        if ( document.activeElement !== inputControl[0] ) {
            this.inputControl().focus();
        }
        
        return result;
    },
    
    // Try to auto-complete the current text against the item contents.
    _autoComplete: function() {
        
        var content = this.content();
        // console.log( content );
        
        var match = this._matchingItem( content );
        if ( !match ) {
            this.$list().selectedControl( null );
            return;
        }

        this.content( match );
        
        // Select the auto-completed text.
        this._selectText( content.length, match.length );
        
        this._selectTextInList();
    },
    
    _bindContentEvents: function() {
        
        this._super();
        
        // See notes at _contentKeydown.
        var self = this;
        this.inputControl().keydown( function( event ) {
            self._contentKeydown( event );
        });
    },
    
    /*
     * Handle a keydown event. Keydown gives the best AutoComplete performance
     * and behavior: among other things, the AutoComplete happens as soon as
     * the user begins typing. However, using keydown creates a problem that
     * the input control's content won't actually reflect the effects key the
     * user just pressed down. So we set a timeout to give the keydown event a
     * chance to bubble up and do its work, then do our AutoComplete work
     * against the resulting text.
     */
    _contentKeydown: function( event ) {
        
        var handled = false;
        var navigationKeys = [
            33, // Page Up
            34, // Page Down
            38, // Up
            40 // Down
        ];
        var self = this;
        
        // Do AutoComplete on Space, or characters from zero (0) and up,
        // ignoring any combinations that involve Alt or Ctrl.
        if ( ( event.which === 32 || event.which >= 48 ) 
            && !( event.altKey || event.ctrlKey || event.metaKey) ) {

            this._setTimeout( function() { self._autoComplete(); });
            
        } else if ( this.opened() && $.inArray( event.which, navigationKeys ) >= 0 ) {
            
            // Forward navigation keys to opened list.
            this.$list().trigger( event );
            handled = true;

        } else if ( event.which === 8 || event.which === 46 ) {
            
            // On Backspace or Delete, clear list select if text is empty.
            this._setTimeout( function() {
                self._selectTextInList();
            });
            
        } else if ( event.which === 40 ) {
            
            // Pressing Down when list is closed will open list.
            this.open();
            
            // If the input text is empty, select the first list item.
            var content = this.content();
            if ( content == null || content.length === 0 ) {
                this.$list().selectedIndex( 0 );
            }

            handled = true;
            
        }
        
        if ( handled ) {
            event.stopPropagation();
            event.preventDefault();
        }
    },

    _itemContents: Control.property(),
    
    // Return the item whose prefix matches the given string, ignoring case.
    // Return null if not found.
    _matchingItem: function( s ) {
        var length = s.length;
        if ( length > 0 ) {
            var lower = s.toLowerCase();
            var itemContents = this._itemContents();
            for ( var i = 0, itemCount = itemContents.length; i < itemCount; i++ ) {
                var itemContent = itemContents[i]; 
                if ( length <= itemContent.length
                    && itemContent.substr( 0, length ).toLowerCase() === lower ) {
                    return itemContent;
                }
            }
        }
        return null;
    },

    // Select the current input text in the list if it's there.
    // Clear the list selection if the text is not found.
    _selectTextInList: function() {
        if ( this.opened() ) {
            var content = this.content();
            var index = $.inArray( content, this._itemContents() );
            this.$list().selectedIndex( index );
        }
    },
    
    // Arrange for a callback to be performed via a timeout.
    // See notes at _contentKeydown.
    _setTimeout: function( callback ) {
        
        // Cancel any pending AutoComplete timeout.
        var timeout = this._timeout();
        if ( timeout ) {
            clearTimeout( timeout );
        }
        
        // Queue a new timeout.
        var self = this;
        timeout = window.setTimeout( callback, 50 );
        this._timeout( timeout );
    },
    
    _timeout: Control.property(),
    
    /*
     * Extract a copy of all the items so we can match against them when
     * the user types. We get the contents from the list's controls, rather
     * than from the list's items() property, since the items could be
     * arbitrary JavaScript objects. Once the list's mapFunction has mapped
     * those objects into the controls, the controls' content should best
     * reflect the text to map against. 
     */
    _updateItemContents: function() {
        var itemContents = [];
        this.$list().controls().eachControl( function( index, $control ) {
            itemContents.push( $control.content() );
        });
        this._itemContents( itemContents );
    }

});

//
// RotatingPagesWithDots
//
var RotatingPagesWithDots = SlidingPagesWithDots.subclass({
    className: "RotatingPagesWithDots"
});
RotatingPagesWithDots.prototype.extend({
    
    initialize: function() {
        
        this._super();
        this.genericIfClassIs( RotatingPagesWithDots );
        
        var self = this;
        this
            .click( function() { self.stop(); })
            .inDocument( function( $control ) { $control._queueRotation(); });
    },
    
    /*
     * Rotates to the next page. When it hits the last one, it rotates
     * back to the first page and stops.
     */
    rotate: Control.iterator( function () {
        var count = this.pages().length;
        if ( count > 0 ) {
            
            var index = this.activeIndex();
            index = ( index + 1 ) % count;
            this.activeIndex( index );
            if ( index > 0 ) {
                this._queueRotation();
            }
        }
    }),
    
    /*
     * Interval between rotation animations. This does not include the duration
     * of the sliding animation. The default value is 1000 (one second).
     */
    rotationInterval: Control.property.integer( null, 1000 ),

    /*
     * Stop the rotation in progress.
     */
    stop: Control.iterator( function() {
        clearTimeout( this._timeout() );
        this._timeout( null );
        return this;
    }),
    
    _queueRotation: function() {
        var rotationInterval = this.rotationInterval();
        var self = this;
        this._timeout( setTimeout( function() { self.rotate(); }, rotationInterval ) );
    },
    
    _timeout: Control.property(),

});

//
// SampleSpriteButton
//
var SampleSpriteButton = SpriteButton.subclass({
    className: "SampleSpriteButton",
    image: "url(/catalog/resources/sampleButtonStates.png)",
    cellHeight: "32"
});

//
// ColorSwatchComboBox
//
var ColorSwatchComboBox = ListComboBox.subclass({
    className: "ColorSwatchComboBox",
    textBoxClass: "ColorSwatchTextBox",
    itemClass: "ColorSwatchButton"
});
ColorSwatchComboBox.prototype.extend({
    initialize: function() {
        
        this._super();
        this.genericIfClassIs( ColorSwatchComboBox );
        
        if ( this.items() == null || this.items().length === 0 ) {
            this.items([
                "Black",
                "Blue",
                "Gray",
                "Green",
                "Red",
                "Orange",
                "Pink",
                "Purple",
                "Yellow"
            ]);
        }
    }
});


