blob: 02ae26394622fc13ab79f10a47e36f2522487473 [file] [log] [blame]
/*
* custom "selectmenu" plugin
*/
//>>excludeStart("jqmBuildExclude", pragmas.jqmBuildExclude);
//>>description: Extension to select menus to support menu styling, placeholder options, and multi-select features.
//>>label: Selects: Custom menus
//>>group: Forms
//>>css.structure: ../css/structure/jquery.mobile.forms.select.css
//>>css.theme: ../css/themes/default/jquery.mobile.theme.css
define( [
"jquery",
"../../jquery.mobile.buttonMarkup",
"../../jquery.mobile.core",
"../dialog",
"./select",
"../listview",
"../page",
"../popup",
// NOTE expects ui content in the defined page, see selector for menuPageContent definition
"../page.sections" ], function( jQuery ) {
//>>excludeEnd("jqmBuildExclude");
(function( $, undefined ) {
var extendSelect = function( widget ) {
var select = widget.select,
selectID = widget.selectID,
label = widget.label,
thisPage = widget.select.closest( ".ui-page" ),
selectOptions = widget._selectOptions(),
isMultiple = widget.isMultiple = widget.select[ 0 ].multiple,
buttonId = selectID + "-button",
menuId = selectID + "-menu",
menuPage = $( "<div data-" + $.mobile.ns + "role='dialog' data-" +$.mobile.ns + "theme='"+ widget.options.theme +"' data-" +$.mobile.ns + "overlay-theme='"+ widget.options.overlayTheme +"'>" +
"<div data-" + $.mobile.ns + "role='header'>" +
"<div class='ui-title'>" + label.getEncodedText() + "</div>"+
"</div>"+
"<div data-" + $.mobile.ns + "role='content'></div>"+
"</div>" ),
listbox = $( "<div>", { "class": "ui-selectmenu" } ).insertAfter( widget.select ).popup( { theme: "a" } ),
list = $( "<ul>", {
"class": "ui-selectmenu-list",
"id": menuId,
"role": "listbox",
"aria-labelledby": buttonId
}).attr( "data-" + $.mobile.ns + "theme", widget.options.theme ).appendTo( listbox ),
header = $( "<div>", {
"class": "ui-header ui-bar-" + widget.options.theme
}).prependTo( listbox ),
headerTitle = $( "<h1>", {
"class": "ui-title"
}).appendTo( header ),
menuPageContent,
menuPageClose,
headerClose;
if ( widget.isMultiple ) {
headerClose = $( "<a>", {
"text": widget.options.closeText,
"href": "#",
"class": "ui-btn-left"
}).attr( "data-" + $.mobile.ns + "iconpos", "notext" ).attr( "data-" + $.mobile.ns + "icon", "delete" ).appendTo( header ).buttonMarkup();
}
$.extend( widget, {
select: widget.select,
selectID: selectID,
buttonId: buttonId,
menuId: menuId,
thisPage: thisPage,
menuPage: menuPage,
label: label,
selectOptions: selectOptions,
isMultiple: isMultiple,
theme: widget.options.theme,
listbox: listbox,
list: list,
header: header,
headerTitle: headerTitle,
headerClose: headerClose,
menuPageContent: menuPageContent,
menuPageClose: menuPageClose,
placeholder: "",
build: function() {
var self = this;
// Create list from select, update state
self.refresh();
self.select.attr( "tabindex", "-1" ).focus(function() {
$( this ).blur();
self.button.focus();
});
// Button events
self.button.bind( "vclick keydown" , function( event ) {
if (event.type === "vclick" ||
event.keyCode && (event.keyCode === $.mobile.keyCode.ENTER ||
event.keyCode === $.mobile.keyCode.SPACE)) {
self.open();
event.preventDefault();
}
});
// Events for list items
self.list.attr( "role", "listbox" )
.bind( "focusin", function( e ) {
$( e.target )
.attr( "tabindex", "0" )
.trigger( "vmouseover" );
})
.bind( "focusout", function( e ) {
$( e.target )
.attr( "tabindex", "-1" )
.trigger( "vmouseout" );
})
.delegate( "li:not(.ui-disabled, .ui-li-divider)", "click", function( event ) {
// index of option tag to be selected
var oldIndex = self.select[ 0 ].selectedIndex,
newIndex = self.list.find( "li:not(.ui-li-divider)" ).index( this ),
option = self._selectOptions().eq( newIndex )[ 0 ];
// toggle selected status on the tag for multi selects
option.selected = self.isMultiple ? !option.selected : true;
// toggle checkbox class for multiple selects
if ( self.isMultiple ) {
$( this ).find( ".ui-icon" )
.toggleClass( "ui-icon-checkbox-on", option.selected )
.toggleClass( "ui-icon-checkbox-off", !option.selected );
}
// trigger change if value changed
if ( self.isMultiple || oldIndex !== newIndex ) {
self.select.trigger( "change" );
}
// hide custom select for single selects only - otherwise focus clicked item
// We need to grab the clicked item the hard way, because the list may have been rebuilt
if ( self.isMultiple ) {
self.list.find( "li:not(.ui-li-divider)" ).eq( newIndex )
.addClass( "ui-btn-down-" + widget.options.theme ).find( "a" ).first().focus();
}
else {
self.close();
}
event.preventDefault();
})
.keydown(function( event ) { //keyboard events for menu items
var target = $( event.target ),
li = target.closest( "li" ),
prev, next;
// switch logic based on which key was pressed
switch ( event.keyCode ) {
// up or left arrow keys
case 38:
prev = li.prev().not( ".ui-selectmenu-placeholder" );
if ( prev.is( ".ui-li-divider" ) ) {
prev = prev.prev();
}
// if there's a previous option, focus it
if ( prev.length ) {
target
.blur()
.attr( "tabindex", "-1" );
prev.addClass( "ui-btn-down-" + widget.options.theme ).find( "a" ).first().focus();
}
return false;
// down or right arrow keys
case 40:
next = li.next();
if ( next.is( ".ui-li-divider" ) ) {
next = next.next();
}
// if there's a next option, focus it
if ( next.length ) {
target
.blur()
.attr( "tabindex", "-1" );
next.addClass( "ui-btn-down-" + widget.options.theme ).find( "a" ).first().focus();
}
return false;
// If enter or space is pressed, trigger click
case 13:
case 32:
target.trigger( "click" );
return false;
}
});
// button refocus ensures proper height calculation
// by removing the inline style and ensuring page inclusion
self.menuPage.bind( "pagehide", function() {
self.list.appendTo( self.listbox );
self._focusButton();
// TODO centralize page removal binding / handling in the page plugin.
// Suggestion from @jblas to do refcounting
//
// TODO extremely confusing dependency on the open method where the pagehide.remove
// bindings are stripped to prevent the parent page from disappearing. The way
// we're keeping pages in the DOM right now sucks
//
// rebind the page remove that was unbound in the open function
// to allow for the parent page removal from actions other than the use
// of a dialog sized custom select
//
// doing this here provides for the back button on the custom select dialog
$.mobile._bindPageRemove.call( self.thisPage );
});
// Events on the popup
self.listbox.bind( "popupafterclose", function( event ) {
self.close();
});
// Close button on small overlays
if ( self.isMultiple ) {
self.headerClose.click(function() {
if ( self.menuType === "overlay" ) {
self.close();
return false;
}
});
}
// track this dependency so that when the parent page
// is removed on pagehide it will also remove the menupage
self.thisPage.addDependents( this.menuPage );
},
_isRebuildRequired: function() {
var list = this.list.find( "li" ),
options = this._selectOptions();
// TODO exceedingly naive method to determine difference
// ignores value changes etc in favor of a forcedRebuild
// from the user in the refresh method
return options.text() !== list.text();
},
selected: function() {
return this._selectOptions().filter( ":selected:not( :jqmData(placeholder='true') )" );
},
refresh: function( forceRebuild , foo ) {
var self = this,
select = this.element,
isMultiple = this.isMultiple,
indicies;
if ( forceRebuild || this._isRebuildRequired() ) {
self._buildList();
}
indicies = this.selectedIndices();
self.setButtonText();
self.setButtonCount();
self.list.find( "li:not(.ui-li-divider)" )
.removeClass( $.mobile.activeBtnClass )
.attr( "aria-selected", false )
.each(function( i ) {
if ( $.inArray( i, indicies ) > -1 ) {
var item = $( this );
// Aria selected attr
item.attr( "aria-selected", true );
// Multiple selects: add the "on" checkbox state to the icon
if ( self.isMultiple ) {
item.find( ".ui-icon" ).removeClass( "ui-icon-checkbox-off" ).addClass( "ui-icon-checkbox-on" );
} else {
if ( item.is( ".ui-selectmenu-placeholder" ) ) {
item.next().addClass( $.mobile.activeBtnClass );
} else {
item.addClass( $.mobile.activeBtnClass );
}
}
}
});
},
close: function() {
if ( this.options.disabled || !this.isOpen ) {
return;
}
var self = this;
if ( self.menuType === "page" ) {
// doesn't solve the possible issue with calling change page
// where the objects don't define data urls which prevents dialog key
// stripping - changePage has incoming refactor
$.mobile.back();
} else {
self.listbox.popup( "close" );
self.list.appendTo( self.listbox );
self._focusButton();
}
// allow the dialog to be closed again
self.isOpen = false;
},
open: function() {
if ( this.options.disabled ) {
return;
}
var self = this,
$window = $.mobile.$window,
selfListParent = self.list.parent(),
menuHeight = selfListParent.outerHeight(),
menuWidth = selfListParent.outerWidth(),
activePage = $( "." + $.mobile.activePageClass ),
scrollTop = $window.scrollTop(),
btnOffset = self.button.offset().top,
screenHeight = $window.height(),
screenWidth = $window.width();
//add active class to button
self.button.addClass( $.mobile.activeBtnClass );
//remove after delay
setTimeout( function() {
self.button.removeClass( $.mobile.activeBtnClass );
}, 300);
function focusMenuItem() {
var selector = self.list.find( "." + $.mobile.activeBtnClass + " a" );
if ( selector.length === 0 ) {
selector = self.list.find( "li.ui-btn:not( :jqmData(placeholder='true') ) a" );
}
selector.first().focus().closest( "li" ).addClass( "ui-btn-down-" + widget.options.theme );
}
if ( menuHeight > screenHeight - 80 || !$.support.scrollTop ) {
self.menuPage.appendTo( $.mobile.pageContainer ).page();
self.menuPageContent = menuPage.find( ".ui-content" );
self.menuPageClose = menuPage.find( ".ui-header a" );
// prevent the parent page from being removed from the DOM,
// otherwise the results of selecting a list item in the dialog
// fall into a black hole
self.thisPage.unbind( "pagehide.remove" );
//for WebOS/Opera Mini (set lastscroll using button offset)
if ( scrollTop === 0 && btnOffset > screenHeight ) {
self.thisPage.one( "pagehide", function() {
$( this ).jqmData( "lastScroll", btnOffset );
});
}
self.menuPage.one( "pageshow", function() {
focusMenuItem();
self.isOpen = true;
});
self.menuType = "page";
self.menuPageContent.append( self.list );
self.menuPage.find("div .ui-title").text(self.label.text());
$.mobile.changePage( self.menuPage, {
transition: $.mobile.defaultDialogTransition
});
} else {
self.menuType = "overlay";
self.listbox
.one( "popupafteropen", focusMenuItem )
.popup( "open", {
x: self.button.offset().left + self.button.outerWidth() / 2,
y: self.button.offset().top + self.button.outerHeight() / 2
});
// duplicate with value set in page show for dialog sized selects
self.isOpen = true;
}
},
_buildList: function() {
var self = this,
o = this.options,
placeholder = this.placeholder,
needPlaceholder = true,
optgroups = [],
lis = [],
dataIcon = self.isMultiple ? "checkbox-off" : "false";
self.list.empty().filter( ".ui-listview" ).listview( "destroy" );
var $options = self.select.find( "option" ),
numOptions = $options.length,
select = this.select[ 0 ],
dataPrefix = 'data-' + $.mobile.ns,
dataIndexAttr = dataPrefix + 'option-index',
dataIconAttr = dataPrefix + 'icon',
dataRoleAttr = dataPrefix + 'role',
dataPlaceholderAttr = dataPrefix + 'placeholder',
fragment = document.createDocumentFragment(),
isPlaceholderItem = false,
optGroup;
for (var i = 0; i < numOptions;i++, isPlaceholderItem = false) {
var option = $options[i],
$option = $( option ),
parent = option.parentNode,
text = $option.text(),
anchor = document.createElement( 'a' ),
classes = [];
anchor.setAttribute( 'href', '#' );
anchor.appendChild( document.createTextNode( text ) );
// Are we inside an optgroup?
if ( parent !== select && parent.nodeName.toLowerCase() === "optgroup" ) {
var optLabel = parent.getAttribute( 'label' );
if ( optLabel !== optGroup ) {
var divider = document.createElement( 'li' );
divider.setAttribute( dataRoleAttr, 'list-divider' );
divider.setAttribute( 'role', 'option' );
divider.setAttribute( 'tabindex', '-1' );
divider.appendChild( document.createTextNode( optLabel ) );
fragment.appendChild( divider );
optGroup = optLabel;
}
}
if ( needPlaceholder && ( !option.getAttribute( "value" ) || text.length === 0 || $option.jqmData( "placeholder" ) ) ) {
needPlaceholder = false;
isPlaceholderItem = true;
// If we have identified a placeholder, mark it retroactively in the select as well
option.setAttribute( dataPlaceholderAttr, true );
if ( o.hidePlaceholderMenuItems ) {
classes.push( "ui-selectmenu-placeholder" );
}
if (!placeholder) {
placeholder = self.placeholder = text;
}
}
var item = document.createElement('li');
if ( option.disabled ) {
classes.push( "ui-disabled" );
item.setAttribute('aria-disabled',true);
}
item.setAttribute( dataIndexAttr,i );
item.setAttribute( dataIconAttr, dataIcon );
if ( isPlaceholderItem ) {
item.setAttribute( dataPlaceholderAttr, true );
}
item.className = classes.join( " " );
item.setAttribute( 'role', 'option' );
anchor.setAttribute( 'tabindex', '-1' );
item.appendChild( anchor );
fragment.appendChild( item );
}
self.list[0].appendChild( fragment );
// Hide header if it's not a multiselect and there's no placeholder
if ( !this.isMultiple && !placeholder.length ) {
this.header.hide();
} else {
this.headerTitle.text( this.placeholder );
}
// Now populated, create listview
self.list.listview();
},
_button: function() {
return $( "<a>", {
"href": "#",
"role": "button",
// TODO value is undefined at creation
"id": this.buttonId,
"aria-haspopup": "true",
// TODO value is undefined at creation
"aria-owns": this.menuId
});
}
});
};
// issue #3894 - core doesn't trigger events on disabled delegates
$.mobile.$document.bind( "selectmenubeforecreate", function( event ) {
var selectmenuWidget = $( event.target ).data( "selectmenu" );
if ( !selectmenuWidget.options.nativeMenu &&
selectmenuWidget.element.parents( ":jqmData(role='popup')" ).length === 0 ) {
extendSelect( selectmenuWidget );
}
});
})( jQuery );
//>>excludeStart("jqmBuildExclude", pragmas.jqmBuildExclude);
});
//>>excludeEnd("jqmBuildExclude");