diff --git a/css/jquery.multiselect.css b/css/jquery.multiselect.css index bf9daa9..0344579 100644 --- a/css/jquery.multiselect.css +++ b/css/jquery.multiselect.css @@ -1,25 +1,35 @@ -.ui-multiselect { padding:2px 0 2px 4px; text-align:left } -.ui-multiselect .ui-icon { float:right } -.ui-multiselect-single .ui-multiselect-checkboxes input { left:-9999px; position:absolute !important; top: auto !important; } -.ui-multiselect-single .ui-multiselect-checkboxes label { padding:5px !important } +.ui-multiselect {box-sizing: border-box; padding:2px 0 2px 4px; text-align:left;} +.ui-multiselect .ui-multiselect-open { float:right } + +.ui-multiselect-menu { display:none; box-sizing:border-box; position:absolute; text-align:left; z-index:1010; width:auto; padding:3px;} -.ui-multiselect-header { margin-bottom:3px; padding:3px 0 3px 4px; } +.ui-multiselect-header { display:block; box-sizing:border-box; position:relative; width:auto; padding:3px 0 3px 4px; margin-bottom:3px;} .ui-multiselect-header > ul { font-size:0.9em } -.ui-multiselect-header > ul > li { float:left; padding:0 10px 0 0; } +.ui-multiselect-header > ul > li { float:left; padding:0 10px 0 0;} .ui-multiselect-header a { text-decoration:none; } .ui-multiselect-header a:hover { text-decoration:underline; cursor: pointer;} .ui-multiselect-header .ui-icon { float:left; } .ui-multiselect-header .ui-multiselect-close { float:right; padding-right:0; text-align:right; } -.ui-multiselect-menu { display:none; padding:3px; position:absolute; text-align: left; } -.ui-multiselect-checkboxes { overflow-y:auto; position:relative; } -.ui-multiselect-checkboxes label { border:1px solid transparent; cursor:default; display:block; padding:3px 1px; } -.ui-multiselect-checkboxes label > input { position:relative; top:1px } +.ui-multiselect-checkboxes { display:block; box-sizing:border-box; position:relative; overflow:auto; width: auto; border: 0;} +.ui-multiselect-checkboxes label { border:1px solid transparent; cursor:default; display:block; padding:3px 1px;} +.ui-multiselect-checkboxes label > input { position:relative; top:1px; } .ui-multiselect-checkboxes label img { height: 30px; vertical-align: middle; padding-right: 3px;} -.ui-multiselect-checkboxes li { clear:both; font-size:0.9em; list-style: none; padding-right:3px; } -.ui-multiselect-checkboxes > .ui-multiselect-optgroup { padding: 3px; } -.ui-multiselect-columns { display: inline-block; vertical-align: top; } .ui-multiselect-checkboxes > .ui-multiselect-optgroup > a { border-bottom:1px solid; cursor: pointer; display:block; font-weight:bold; margin:1px 0; padding:3px; text-align:center; text-decoration:none; } +.ui-multiselect-checkboxes > .ui-multiselect-optgroup > ul { padding: 3px; } +.ui-multiselect-checkboxes li:not(.ui-multiselect-optgroup) { clear:both; font-size:0.9em; list-style: none; padding-right:3px;} +.ui-multiselect-columns { display: inline-block; vertical-align: top; } + +.ui-multiselect-single .ui-multiselect-checkboxes input { left:-9999px; position:absolute !important; top: auto !important; } +.ui-multiselect-single .ui-multiselect-checkboxes label { padding:5px !important } + +.ui-multiselect.ui-multiselect-nowrap { white-space: nowrap} +.ui-multiselect.ui-multiselect-nowrap > span {display: inline-block} +.ui-multiselect-checkboxes.ui-multiselect-nowrap li, +.ui-multiselect-checkboxes.ui-multiselect-nowrap a{ white-space: nowrap} + +.ui-multiselect-measure > .ui-multiselect-header, +.ui-multiselect-measure > .ui-multiselect-checkboxes { float:left; } @media print{ .ui-multiselect-menu {display: none;} diff --git a/src/jquery.multiselect.js b/src/jquery.multiselect.js index e1b2a7a..29dfb60 100644 --- a/src/jquery.multiselect.js +++ b/src/jquery.multiselect.js @@ -19,6 +19,8 @@ (function($, undefined) { // Counter used to prevent collisions var multiselectID = 0; + // Scroll bar width saved for auto menu width determinations. + var _scrollbarWidth = 0; var defaultIcons = { 'open': ' selectedMax or if function returns 1, then message is displayed, and new selection is undone. @@ -49,13 +52,13 @@ autoOpen: false, // (true | false) If true, then the menu will be opening immediately after initialization. position: {}, // (object) A jQuery UI position object that constrains how the pop-up menu is positioned. appendTo: null, // (jQuery | DOM element | selector str) If provided, this specifies what element to append the widget to in the DOM. - menuWidth: null, // (int | null) If a number is provided, sets the exact menu width. selectedListSeparator: ', ', // (str) This allows customization of the list separator. Use ',
' to make the button grow vertically showing 1 selection per line. htmlButtonText: false, // (true | false) If true, then the text used for the button's label is treated as html rather than plain text. htmlOptionText: false, // (true | false) If true, then the text for option label is treated as html rather than plain text. addInputNames: true, // (true | false) If true, names are created for each option input in the multi-select. + wrapText: 'button,header,menu', // (list of button, header, &/or menu) Comma separated list defining what parts of the widget to wrap text for. disableInputsOnToggle: true, // (true | false) - groupColumns: false // (true | false) + groupColumns: false // (true | false) Displays groups in a horizonal column layout. }, /** @@ -65,6 +68,7 @@ * 3. If still do not have a valid DOM element to append to, then append to the document body. * * NOTE: this.element and this.document are jQuery objects per the jQuery UI widget API. + * @returns {object} jQuery object to append to or document body. */ _getAppendEl: function() { var elem = this.options.appendTo; // jQuery object or selector, DOM element or null. @@ -72,11 +76,11 @@ if (elem) { // NOTE: The find below handles the jQuery selector case elem = !!elem.jquery ? elem : ( !!elem.nodeType ? $(elem) : this.document.find(elem).eq(0) ); } - if(!elem || !elem[0]) { + if (!elem || !elem[0]) { elem = this.element.closest(".ui-front, dialog"); } - if(!elem.length) { - elem = this.document[0].body; // Position at end of body. Note that this returns a DOM element. + if (!elem.length) { + elem = document.body; // Position at end of body. Note that this returns a DOM element. } return elem; }, @@ -94,16 +98,22 @@ * - Calls refresh to populate the menu */ _create: function() { - var $element = this.element.hide(); - var elSelect = $element.get(0); + + var $element = this.element; + var elSelect = $element[0]; var options = this.options; var classes = options.classes; var headerOn = options.header; - // Do an extend here to address icons missing from options.iconSet--missing icons default to those in msIcons. var checkAllText = options.checkAllText; + // Do an extend here to address icons missing from options.iconSet--missing icons default to those in defaultIcons. var iconSet = $.extend({}, defaultIcons, options.iconSet || {}); var uncheckAllText = options.uncheckAllText; var flipAllText = options.flipAllText; + var wrapText = options.wrapText || ''; + + // grab select width before hiding it + this._selectWidth = elSelect.getBoundingClientRect().width; + $element.hide(); // default speed for effects this.speed = $.fx.speeds._default; @@ -118,18 +128,21 @@ // The button that opens the widget menu. Note that this is inserted later below. var $button = (this.$button = $( document.createElement('button') ) ) - .addClass('ui-multiselect ui-widget ui-state-default ui-corner-all' + (classes ? ' ' + classes : '')) + .addClass('ui-multiselect ui-widget ui-state-default ui-corner-all' + + (/\bbutton\b/i.test(wrapText) ? '' : ' ui-multiselect-nowrap') + + (classes ? ' ' + classes : '') + ) .attr({ - 'type': 'button', - 'title': elSelect.title, - 'tabIndex': elSelect.tabIndex, - 'id': elSelect.id ? elSelect.id + '_ms' : null + 'type': 'button', + 'title': elSelect.title, + 'tabIndex': elSelect.tabIndex, + 'id': elSelect.id ? elSelect.id + '_ms' : null }) .prop('aria-haspopup', true) .html('' + iconSet.open + ''); // Necessary to simplify dynamically changing the open icon. this.$buttonlabel = $( document.createElement('span') ) - .html(options.noneSelectedText) + .html(options.noneSelectedText || $element[0].placeholder) .appendTo( $button ); // Header controls, will contain the check all/uncheck all buttons @@ -142,9 +155,9 @@ this.$headerLinkContainer = $( document.createElement('ul') ) .addClass('ui-helper-reset') - .html(headerLinksHTML - + '
  • ' - + iconSet.close + .html(headerLinksHTML + + '
  • ' + + iconSet.close + '
  • '); // Menu header to hold controls for the menu @@ -153,15 +166,15 @@ .append( this.$headerLinkContainer ); // Holds the actual check boxes for inputs - var $checkboxContainer = (this.$checkboxContainer = $( document.createElement('ul') ) ) - .addClass('ui-multiselect-checkboxes ui-helper-reset'); + var $checkboxes = (this.$checkboxes = $( document.createElement('ul') ) ) + .addClass('ui-multiselect-checkboxes ui-helper-reset' + (/\bmenu\b/i.test(wrapText) ? '' : ' ui-multiselect-nowrap')); // This is the menu that will hold all the options. var $menu = (this.$menu = $( document.createElement('div') ) ) - .addClass('ui-multiselect-menu ui-widget ui-widget-content ui-corner-all ' - + (elSelect.multiple ? '' : 'ui-multiselect-single ') - + classes) - .append($header, $checkboxContainer); + .addClass('ui-multiselect-menu ui-widget ui-widget-content ui-corner-all' + + (elSelect.multiple ? '' : ' ui-multiselect-single ') + + (classes ? ' ' + classes : '')) + .append($header, $checkboxes); $button.insertAfter($element); // This is an empty menu at this point. @@ -183,18 +196,23 @@ _init: function() { var elSelect = this.element.get(0); - if (this.options.header) + if (this.options.header) { this.$headerLinkContainer .find('.ui-multiselect-all, .ui-multiselect-none, .ui-multiselect-flip') .toggle( !!elSelect.multiple ); - else + } + else { this.$header.hide(); + } - if(this.options.autoOpen) + if (this.options.autoOpen) { this.open(); + } - if(elSelect.disabled) + if (elSelect.disabled) { this.disable(); + } + }, /** @@ -212,7 +230,7 @@ var self = this; var title = option.title || null; var elSelect = self.element.get(0); - // unique ID for the label & option tags + // Determine unique ID for the label & option tags var id = elSelect.id || self.multiselectID; var inputID = 'ui-multiselect-' + self.multiselectID + '-' + (option.id || id + '-option-' + self.inputIdCounter++); // Pick up the select type from the underlying element @@ -235,25 +253,27 @@ for (var name in inputAttribs) { if (inputAttribs[name] !== null) { input.setAttribute(name,inputAttribs[name]); - } + } } + // Clone data attributes var optionAttribs = option.attributes; var len = optionAttribs.length; - // Clone data attributes - for (x = 0; x < len; x++) { - attribute = optionAttribs[x]; - if ( /^data\-.+/.test(attribute.name) ) - input.setAttribute(attribute.name, attribute.value) + for (var x = 0; x < len; x++) { + var attribute = optionAttribs[x]; + if ( /^data\-.+/.test(attribute.name) ) { + input.setAttribute(attribute.name, attribute.value); + } } // Option text or html var span = document.createElement('span'); if (self.options.htmlOptionText) { span.innerHTML = option.innerHTML; - } else { + } + else { span.textContent = option.textContent; - } - + } + // Icon images for each item. var optionImageSrc = option.getAttribute('data-image-src'); if (optionImageSrc) { @@ -266,17 +286,16 @@ label.setAttribute('for', inputID); if (title !== null) { label.setAttribute('title', title); - } - label.className += (isDisabled ? ' ui-state-disabled' : '') - + (isSelected && !isMultiple ? ' ui-state-active' : '') + } + label.className += (isDisabled ? ' ui-state-disabled' : '') + + (isSelected && !isMultiple ? ' ui-state-active' : '') + ' ui-corner-all'; label.appendChild(input); label.appendChild(span); var item = document.createElement('li'); - item.className += (isDisabled ? ' ui-multiselect-disabled' : '') - + (' ' + option.className || '') - + ' ui-multiselect-nowrap'; + item.className = (isDisabled ? 'ui-multiselect-disabled ' : '') + + (option.className || ''); item.appendChild(label); return item; @@ -284,19 +303,19 @@ /** * Processes option and optgroup tags from underlying select to construct the menu's option list - * This clears the items currently in $checkboxContainer + * This clears the items currently in this.$checkboxes * Defers to _makeOption to actually build the options * Resets the input ID counter - * @param {object} $element element whose option/group tags need to be converted - * @param {object} $checkboxContainer widget's container to append the built options to + + */ - _buildOptionList: function($element, $checkboxContainer) { + _buildOptionList: function() { var self = this; var list = []; this.inputIdCounter = 0; - $element.children().each( function() { + this.element.children().each( function() { var elem = this; if (elem.tagName === 'OPTGROUP') { @@ -307,14 +326,13 @@ }); // Build the list section for this optgroup, complete w/ option inputs... - var $optGroupLabel = $( document.createElement('a') ).text( elem.getAttribute('label') ); var $optGroupItem = $( document.createElement('li') ) - .addClass('ui-multiselect-optgroup' - + (self.options.groupColumns ? ' ui-multiselect-columns' : '') - + (elem.className && ' ') + elem.className) - var $optionGroup = $( document.createElement('ul') ).append(options) - $optGroupItem.append($optGroupLabel, $optionGroup) - + .addClass('ui-multiselect-optgroup' + + (self.options.groupColumns ? ' ui-multiselect-columns' : '') + + (elem.className ? ' ' + elem.className : '')); + var $optGroupLabel = $( document.createElement('a') ).text( elem.getAttribute('label') ); + var $optionGroup = $( document.createElement('ul') ).append(options); + $optGroupItem.append($optGroupLabel, $optionGroup); list.push($optGroupItem); } else { @@ -322,7 +340,7 @@ } }); - $checkboxContainer.empty().append(list); + this.$checkboxes.empty().append(list); }, /** @@ -336,32 +354,37 @@ var $element = this.element; // update header link container visibility if needed - if (this.options.header) + if (this.options.header) { this.$headerLinkContainer .find('.ui-multiselect-all, .ui-multiselect-none, .ui-multiselect-flip') .toggle( !!$element[0].multiple ); - - this._buildOptionList($element, this.$checkboxContainer); // Clear and rebuild the menu. + } + this._buildOptionList(); // Clear and rebuild the menu. this._updateCache(); // cache some more useful elements this._setButtonWidth(); this.update(true); // broadcast refresh event; useful for widgets - if (!init) + if (!init) { this._trigger('refresh'); + } }, /** * Updates cached values used elsewhere in the widget */ _updateCache: function() { - // Invalidate cached dimensions and positioning state. + // Invalidate cached dimensions and positioning state to force recalcs. this._savedButtonWidth = 0; this._savedMenuWidth = 0; this._ulHeight = 0; this._positioned = false; + // Recreate important cached jQuery objects + this.$header = this.$menu.children('.ui-multiselect-header'); + this.$checkboxes = this.$menu.children('.ui-multiselect-checkboxes'); + // Update saved labels and inputs this.$labels = this.$menu.find('label'); this.$inputs = this.$labels.children('input'); @@ -386,21 +409,30 @@ var value; if (numChecked) { - if (typeof selectedText === 'function') + if (typeof selectedText === 'function') { value = selectedText.call(self, numChecked, inputCount, $checked.get()); - else if(/\d/.test(selectedList) && selectedList > 0 && numChecked <= selectedList) + } + else if (/\d/.test(selectedList) && selectedList > 0 && numChecked <= selectedList) { value = $checked.map(function() { return $(this).next().text() }).get().join(options.selectedListSeparator); - else + } + else { value = selectedText.replace('#', numChecked).replace('#', inputCount); + } } - else + else { value = options.noneSelectedText; + } self._setButtonValue(value, isDefault); + if ( !/\bbutton\b/.test( options.wrapText ) ) { + this._setButtonWidth(true); + } + // Check if the menu needs to be repositioned due to button height changing from adding/removing selections. - if (self._isOpen && self._savedButtonHeight != self.$button.outerHeight(false)) + if (self._isOpen && self._savedButtonHeight != self.$button.outerHeight(false)) { self._position(true); + } }, /** @@ -411,8 +443,9 @@ _setButtonValue: function(value, isDefault) { this.$buttonlabel[this.options.htmlButtonText ? 'html' : 'text'](value); - if (!!isDefault) + if (!!isDefault) { this.$button[0].defaultValue = value; + } }, /** @@ -444,7 +477,7 @@ } }, mouseenter: function() { - if(!$button.hasClass('ui-state-disabled')) { + if (!$button.hasClass('ui-state-disabled')) { $button.addClass('ui-state-hover'); } }, @@ -452,7 +485,7 @@ $button.removeClass('ui-state-hover'); }, focus: function() { - if(!$button.hasClass('ui-state-disabled')) { + if (!$button.hasClass('ui-state-disabled')) { $button.addClass('ui-state-focus'); } }, @@ -481,7 +514,7 @@ var label = this.textContent; // trigger before callback and bail if the return is false - if(self._trigger('beforeoptgrouptoggle', e, { inputs:nodes, label:label }) === false) { + if (self._trigger('beforeoptgrouptoggle', e, { inputs:nodes, label:label }) === false) { return; } @@ -498,7 +531,7 @@ }); }) .on('mouseenter.multiselect', 'label', function() { - if(!$(this).hasClass('ui-state-disabled')) { + if (!$(this).hasClass('ui-state-disabled')) { self.$labels.removeClass('ui-state-hover'); $(this).addClass('ui-state-hover').find('input').focus(); } @@ -506,21 +539,24 @@ // Keyboard navigation of the menu .on('keydown.multiselect', 'label', function(e) { // Don't capture function keys or 'r' - if(e.which === 82) + if (e.which === 82) { return; // r + } - if(e.which > 111 && e.which < 124) + if (e.which > 111 && e.which < 124) { return; // Function keys. + } e.preventDefault(); switch(e.which) { case 9: // tab - if(e.shiftKey) { + if (e.shiftKey) { self.$menu.find(".ui-state-hover").removeClass("ui-state-hover"); self.$header.find("li").last().find("a").focus(); } - else + else { self.close(); + } break; case 27: // esc self.close(); @@ -536,12 +572,14 @@ $(this).find('input')[0].click(); break; case 65: // Ctrl-A - if (e.altKey) + if (e.altKey) { self.checkAll(); + } break; case 85: // Ctrl-U - if (e.altKey) + if (e.altKey) { self.uncheckAll(); + } break; } }) @@ -563,12 +601,12 @@ var selectedMax = options.selectedMax; // bail if this input is disabled or the event is cancelled - if(input.disabled || self._trigger('click', e, { value: val, text: optionText, checked: checked }) === false) { + if (input.disabled || self._trigger('click', e, { value: val, text: optionText, checked: checked }) === false) { e.preventDefault(); return; } - if ( selectedMax && checked + if ( selectedMax && checked && ( typeof selectedMax === 'function' ? !!selectedMax.call(input, $allInputs) : numChecked > selectedMax ) ) { var saveText = options.selectedText; @@ -598,7 +636,7 @@ }); // some additional single select-specific logic - if(!isMultiple) { + if (!isMultiple) { self.$labels.removeClass('ui-state-active'); $input.closest('label').toggleClass('ui-state-active', checked); @@ -627,9 +665,9 @@ // Reference to this anchor element var $this = $(this); var headerLinks = { - 'ui-multiselect-close' : 'close', - 'ui-multiselect-all' : 'checkAll', - 'ui-multiselect-none' : 'uncheckAll', + 'ui-multiselect-close' : 'close', + 'ui-multiselect-all' : 'checkAll', + 'ui-multiselect-none' : 'uncheckAll', 'ui-multiselect-flip' : 'flipAll' }; for (hdgClass in headerLinks) { @@ -648,9 +686,9 @@ break; case 9: var $target = $(e.target); - if((e.shiftKey - && !$target.parent().prev().length - && !self.$header.find(".ui-multiselect-filter").length) + if ((e.shiftKey + && !$target.parent().prev().length + && !self.$header.find(".ui-multiselect-filter").length) || (!$target.parent().next().length && !self.$labels.length && !e.shiftKey)) { self.close(); e.preventDefault(); @@ -676,8 +714,9 @@ var button = self.$button.get(0); var menu = self.$menu.get(0); - if ( self._isOpen && button !== target && !$.contains(button, target) && menu !== target && !$.contains(menu, target) ) + if ( self._isOpen && button !== target && !$.contains(button, target) && menu !== target && !$.contains(menu, target) ) { self.close(); + } }); // deal with form resets. the problem here is that buttons aren't @@ -690,47 +729,92 @@ }, /** - * Determines minimum width for the button and menu - * minWidth can be a number, string, or a percentage + * Converts dimensions specified in options to pixel values. + * Determines if specified value is a minimum, maximum or exact value. + * The value can be a number or a string with px, pts, ems, or % units. * Number/Numeric string treated as pixel measurements * - 30 * - '30' + * - '>30px' + * - '1.3em' + * - '20 pt' * - '30%' + * @param {string} dimText Option text (or number) containing possibly < or >, number, and a unit. + * @param {object} $elem jQuery object (or node) to reference for % calculations. + * @param {boolean} isHeight T/F to change from using width in % calculations. + * @returns {pixels, minimax} object containing pixels and -1/1/0 indicating min/max/exact. */ - _getMinWidth: function() { - var minWidth = this.options.minWidth; - var width = 0; - - switch (typeof minWidth) { - case 'number': - width = minWidth; - break; - case 'string': - width = parseInt(minWidth, 10); - - if ( minWidth.slice(-1) === '%' ) - width = this.element.parent().innerWidth() * (width/100); + _parse2px: function(dimText, $elem, isHeight) { + if (typeof dimText !== 'string') { + return {px: dimText, minimax: 0}; } - return width; + var parts = dimText.match(/([<>])?=?\s*([.\d]+)\s*([eimnptx%]*)s?/i); + var minimax = parts[1]; + var value = parseFloat(parts[2]); + var unit = parts[3].toLowerCase(); + var pixels = -1; + switch (unit) { + case 'pt': + case 'in': + case 'cm': + case 'mm': + pixels = {'pt': 4.0 / 3.0, 'in': 96.0, 'cm': 96.0 / 2.54, 'mm': 96.0 / 25.4}[unit] * value; + break; + case 'em': + var bodyFontSize = ( window.getComputedStyle + ? getComputedStyle(document.body).fontSize + : document.body.currentStyle.fontSize ) || '16px'; + pixels = parseFloat(bodyFontSize) * value; + break; + case '%': + if ( !!$elem ) { + if (typeof $elem === 'string' || !$elem.jquery) { + $elem = $($elem); + } + pixels = ( !!isHeight ? $elem.parent().height() : $elem.parent().width() ) * (value / 100.0); + } // else returns -1 default value from above. + break; + default: + pixels = value; + } + // minimax: -1 => minimum value, 1 => maximum value, 0 => exact value + return {px: pixels, minimax: minimax == '>' ? -1 : ( minimax == '<' ? 1 : 0 ) }; }, /** * Sets and caches the width of the button - * Will use the minWidth option's value if less than calculated width + * Can set a minimum value if less than calculated width of native select. * If the cache is cleared, the menu will be re-positioned on the next open * @param {boolean} recalc true if cached value needs to be re-calculated */ _setButtonWidth: function(recalc) { - if (this._savedButtonWidth && !recalc) + if (this._savedButtonWidth && !recalc) { return; + } this._positioned = false; - var width = this.element.outerWidth(); - var minWidth = this._getMinWidth(); - this._savedButtonWidth = width < minWidth ? minWidth : width; - this.$button.outerWidth(this._savedButtonWidth); + // this._selectWidth set in _create() for native select element before hiding it. + var width = this._selectWidth || this._getBCRWidth( this.element ); + var buttonWidth = this.options.buttonWidth || ''; + if (/\d/.test(buttonWidth)) { + var parsed = this._parse2px(buttonWidth, this.element); + var pixels = parsed.px; + var minimax = parsed.minimax; + width = minimax < 0 ? Math.max(width, pixels) : ( minimax > 0 ? Math.min(width, pixels) : pixels ); + } + else { // keywords + buttonWidth = buttonWidth.toLowerCase(); + } + + this._savedButtonWidth = width; + if (buttonWidth === 'auto') { + this.$button.css('width', 'auto'); + } + else { + this.$button.outerWidth(width); + } }, /** @@ -740,22 +824,49 @@ * @param {boolean} recalc true if cached value needs to be re-calculated */ _setMenuWidth: function(recalc) { - if (this._savedMenuWidth && !recalc) + if (this._savedMenuWidth && !recalc) { return; + } this._positioned = false; - var width = this.options.menuWidth; - if (!width) { - // Make width match button's width. - width = this._savedButtonWidth || this.$button.outerWidth(); - if (width <= 0) { - width = this._getMinWidth(); - } + // Note that it is assumed that the button width was set prior. + var width = this._savedButtonWidth || this._getBCRWidth( this.$button ); + + var menuWidth = this.options.menuWidth || ''; + if ( /\d/.test(menuWidth) ) { + var parsed = this._parse2px(menuWidth, this.element); + var pixels = parsed.px; + var minimax = parsed.minimax; + width = minimax < 0 ? Math.max(width, pixels) : ( minimax > 0 ? Math.min(width, pixels) : pixels ); + } + else { // keywords + menuWidth = menuWidth.toLowerCase(); } - this._savedMenuWidth = width; - this.$menu.outerWidth(width); + // Note that the menu width defaults to the button width if menuWidth option is null or blank. + if (menuWidth !== 'auto') { + this._savedMenuWidth = width; + this.$menu.outerWidth(width); + return; + } + + // Auto width determination: get intrinsic / "shrink-wrapped" outer widths w/ margins by applying floats. + // Note that a correction is made for jQuery floating point round-off errors below. + this.$menu.addClass('ui-multiselect-measure'); + var headerWidth = this.$header.outerWidth(true) + this._jqWidthFix(this.$header); + var cbWidth = this.$checkboxes.outerWidth(true) + this._jqWidthFix(this.$checkboxes); + this.$menu.removeClass('ui-multiselect-measure'); + + // Need extra width to account for increased width of highlighted item (.ui-hover-state). + var uiHoverStateIncrease = 4; + var contentWidth = Math.max(/\bheader\b/.test(this.options.wrapText) ? 0 : headerWidth, + cbWidth + this._getScrollBarWidth() + uiHoverStateIncrease); + + // Use $().width() to set menu width not including padding or border. + this.$menu.width(contentWidth); + // Save width including padding and border for consistency w/ normal width setting. + this._savedMenuWidth = this._getBCRWidth( this.$menu ); }, /** @@ -768,21 +879,36 @@ */ _setMenuHeight: function(recalc) { var self = this; - if (self._ulHeight && !recalc) + if (self._ulHeight && !recalc) { return; + } self._positioned = false; var $menu = self.$menu; - var headerHeight = $menu.children('.ui-multiselect-header').filter(':visible').outerHeight(true); - var $checkboxes = $menu.children(".ui-multiselect-checkboxes"); - // Retrieves native select's size attribute or defaults to 4 (like native select). - var elSelectSize = self.element[0].size || 4; - var optionHeight = self.options.height; - // Determine if overall height should be based on native select 'size' attribute? - var useSelectSize = (optionHeight === 'size'); - // The maximum available height for the $checkboxes. - var availableHeight = window.innerHeight - headerHeight; - var maxHeight = (useSelectSize || optionHeight > availableHeight ? availableHeight : optionHeight); + var $header = self.$header.filter(':visible'); + var headerHeight = $header.outerHeight(true) + self._jqHeightFix($header); + var $checkboxes = self.$checkboxes; + + // The maximum available height for the $checkboxes: + var maxHeight = $(window).height() + - headerHeight + - this._parse2px( $menu.css('padding-top'), this.element, true ).px + - this._parse2px( $menu.css('padding-bottom'), this.element, true ).px; + + var optionHeight = self.options.height || ''; + var useSelectSize = false; + var elSelectSize = 4; + if ( /\d/.test(optionHeight) ) { + optionHeight = this._parse2px(optionHeight, this.element, true).px; + maxHeight = Math.min(optionHeight, maxHeight); + } + else if (optionHeight.toLowerCase() === 'size') { + // Overall height based on native select 'size' attribute + useSelectSize = true; + // Retrieves native select's size attribute or defaults to 4 (like native select). + elSelectSize = self.element[0].size || elSelectSize; + } + var overflowSetting = 'hidden'; var itemCount = 0; var ulHeight = 0; @@ -791,7 +917,7 @@ // of item heights summed equal or exceed the native select size attribute, the loop is aborted. // If the loop is aborted, this means that the menu must be scrolled to see all the items. $checkboxes.find('li,a').each( function() { - ulHeight += $(this).outerHeight(true); + ulHeight += $(this).outerHeight(true) + self._jqHeightFix(this); if (useSelectSize && ++itemCount >= elSelectSize || ulHeight > maxHeight) { overflowSetting = 'auto'; if (!useSelectSize) { @@ -801,11 +927,104 @@ } }); - $checkboxes.css("overflow", overflowSetting).height(ulHeight); + $checkboxes.css('overflow', overflowSetting).height(ulHeight); $menu.height(headerHeight + ulHeight); self._ulHeight = ulHeight; }, + + /** + * Calculate accurate outerWidth(false) using getBoundingClientRect() + * Note that this presumes that the element is visible in the layout. + * @param {node} DOM node or jQuery equivalent get width for. + * @returns {float} Decimal floating point value for the width. + */ + _getBCRWidth: function(elem) { + if (!elem || !!elem.jquery && !elem[0]) { + return null; + } + var domRect = !!elem.jquery ? elem[0].getBoundingClientRect() : elem.getBoundingClientRect(); + return domRect.right - domRect.left; + }, + + /** + * Calculate accurate outerHeight(false) using getBoundingClientRect() + * Note that this presumes that the element is visible in the layout. + * @param {node} DOM node or jQuery equivalent get height for. + * @returns {float} Decimal floating point value for the height. + */ + _getBCRHeight: function(elem) { + if (!elem || !!elem.jquery && !elem[0]) { + return null; + } + var domRect = !!elem.jquery ? elem[0].getBoundingClientRect() : elem.getBoundingClientRect(); + return domRect.bottom - domRect.top; + }, + + /** + * Calculate jQuery width correction factor to fix floating point round-off errors. + * Note that this presumes that the element is visible in the layout. + * @param {node} DOM node or jQuery equivalent get width for. + * @returns {float} Correction value for the width--typically a decimal < 1.0 + */ + _jqWidthFix: function(elem) { + if (!elem || !!elem.jquery && !elem[0]) { + return null; + } + return !!elem.jquery + ? this._getBCRWidth(elem[0]) - elem.outerWidth(false) + : this._getBCRWidth(elem) - $(elem).outerWidth(false); + }, + + /** + * Calculate jQuery height correction factor to fix floating point round-off errors. + * Note that this presumes that the element is visible in the layout. + * @param {node} DOM node or jQuery equivalent get height for. + * @returns {float} Correction value for the height--typically a decimal < 1.0 + */ + _jqHeightFix: function(elem) { + if (!elem || !!elem.jquery && !elem[0]) { + return null; + } + return !!elem.jquery + ? this._getBCRHeight(elem[0]) - elem.outerHeight(false) + : this._getBCRHeight(elem) - $(elem).outerHeight(false); + }, + + /** + * Determines scroll bar width for automatic width determinations. + * Only needs to be ran once--width saved for all instances. + * @returns {integer} width of the scroll bar. + */ + _getScrollBarWidth: function() { + if (_scrollbarWidth) { + return _scrollbarWidth; + } + if ($.ui && $.ui.position) { + _scrollbarWidth = $.position.scrollbarWidth(); + } + if (_scrollbarWidth) { + return _scrollbarWidth; + } + + // https://davidwalsh.name/detect-scrollbar-width + // Create the measurement node + var scrollDiv = document.createElement("div"); + scrollDiv.style.width = 100; + scrollDiv.style.height = 100; + scrollDiv.style.overflow = 'scroll'; + scrollDiv.style.position = 'absolute'; + scrollDiv.style.top = -9999; + document.body.appendChild(scrollDiv); + + // Get the scrollbar width + _scrollbarWidth = scrollDiv.offsetWidth - scrollDiv.clientWidth; + + // Delete the DIV + document.body.removeChild(scrollDiv); + return _scrollbarWidth; + }, + // Resizes the menu, called every time the menu is opened _resizeMenu: function() { this._setMenuWidth(); @@ -837,10 +1056,12 @@ // set scroll position $container.scrollTop(moveToLast ? $container.height() : 0); - } else { + } + else { $next.find('label').filter(':visible')[ moveToLast ? "last" : "first" ]().trigger('mouseover'); } }, + /** * Internal function to toggle checked property and related attributes on a checkbox * The context of this function should be a checkbox; do not proxy it. @@ -851,14 +1072,16 @@ return function() { var state = (flag === '!') ? !this[prop] : flag; - if( !this.disabled ) + if ( !this.disabled ) { this[ prop ] = state; + } - if (state) + if (state) { this.setAttribute('aria-' + prop, true); - else + } + else { this.removeAttribute('aria-' + prop); - + } }; }, @@ -893,7 +1116,7 @@ $element[0].selectedIndex = -1; $element.find('option') .each( function() { - if(!this.disabled && values[this.value]) { + if (!this.disabled && values[this.value]) { self._toggleState('selected', flag).call(this); } }); @@ -901,7 +1124,7 @@ // trigger the change event on the select if ($inputs.length) { $element.trigger("change"); - } + } }, /** @@ -927,7 +1150,8 @@ matchedInputs[i].setAttribute("aria-disabled", "disabled"); matchedInputs[i].parentNode.className = matchedInputs[i].parentNode.className + " ui-state-disabled"; } - } else { + } + else { matchedInputs = checkboxes.querySelectorAll("input:disabled"); for (i = 0; i < matchedInputs.length; i++) { if (matchedInputs[i].hasAttribute(key)) { @@ -954,9 +1178,9 @@ var $button = this.$button; // bail if the multiselect open event returns false, this widget is disabled, or is already open - if(this._trigger('beforeopen') === false || $button.hasClass('ui-state-disabled') || this._isOpen) { + if (this._trigger('beforeopen') === false || $button.hasClass('ui-state-disabled') || this._isOpen) { return; - } + } var $menu = this.$menu; var $header = this.$header; @@ -976,7 +1200,12 @@ // show the menu, maybe with a speed/effect combo // if there's an effect, assume jQuery UI is in use - $.fn.show.apply($menu, effect ? [ effect, speed ] : []); + if (effect) { + $.fn.show.apply($menu, effect ? [ effect, speed ] : []); + } + else { + $menu.css('display','block'); + } this._resizeMenu(); this._position(); @@ -985,9 +1214,11 @@ var filter = $header.find(".ui-multiselect-filter"); if (filter.length) { filter.first().find('input').trigger('focus'); - } else if ($labels.length) { + } + else if ($labels.length) { $labels.filter(':not(.ui-state-disabled)').eq(0).trigger('mouseover').trigger('mouseenter').find('input').trigger('focus'); - } else { + } + else { $header.find('a').first().trigger('focus'); } @@ -1001,8 +1232,9 @@ var self = this; // bail if the multiselect close event returns false - if (this._trigger('beforeclose') === false) + if (this._trigger('beforeclose') === false) { return; + } var options = this.options; var effect = options.hide; @@ -1015,16 +1247,26 @@ speed = options.hide[1] || this.speed; } - $.fn.hide.apply(this.$menu, effect ? [ effect, speed ] : []); + // hide the menu, maybe with a speed/effect combo + // if there's an effect, assume jQuery UI is in use + if (effect) { + $.fn.hide.apply(this.$menu, effect ? [ effect, speed ] : []); + } + else { + this.$menu.css('display','none'); + } + $button.removeClass('ui-state-active').trigger('blur').trigger('mouseleave'); this._isOpen = false; this._trigger('close'); $button.trigger('focus'); }, + // Enable widget enable: function() { this._toggleDisabled(false); }, + // Disable widget disable: function() { this._toggleDisabled(true); @@ -1037,9 +1279,10 @@ uncheckAll: function() { this._toggleChecked(false); - if ( !this.element[0].multiple ) + if ( !this.element[0].multiple ) { // Forces the underlying single-select to have no options selected. this.element[0].selectedIndex = -1; + } this._trigger('uncheckAll'); }, @@ -1047,6 +1290,7 @@ this._toggleChecked('!'); this._trigger('flipAll'); }, + /** * Provides a list of all checked options * @returns {array} list of inputs @@ -1054,6 +1298,7 @@ getChecked: function() { return this.$menu.find('input:checked'); }, + /** * Provides a list of all options that are not checked * @returns {array} list of inputs @@ -1061,6 +1306,7 @@ getUnchecked: function() { return this.$menu.find('input:not(:checked)'); }, + /** * Destroys the widget instance * @returns {object} reference to widget @@ -1079,24 +1325,28 @@ return this; }, + /** * @returns {boolean} indicates whether the menu is open */ isOpen: function() { return this._isOpen; }, + /** * @returns {object} jQuery object for menu */ widget: function() { return this.$menu; }, + /** * @returns {object} jQuery object for button */ getButton: function() { return this.$button; }, + /** * Essentially an alias for widget * @returns {object} jQuery object for menu @@ -1104,6 +1354,7 @@ getMenu: function() { return this.$menu; }, + /** * @returns {array} List of the option labels */ @@ -1139,18 +1390,21 @@ self._updateCache(); }, + /** * Removes an option from the widget and underlying select * @param {string} value attribute corresponding to option being removed */ removeOption: function(value) { - if (!value) + if (!value) { return; + } this.element.find("option[value=" + value + "]").remove(); this.$labels.find("input[value=" + value + "]").parents("li").remove(); this._updateCache(); }, + /** * Public version of _position, always ignores the cache */ @@ -1163,17 +1417,18 @@ * @param {boolean} reposition forces the menu to reposition if true */ _position: function(reposition) { - if (!!this._positioned && !reposition) + if (!!this._positioned && !reposition) { return; - + } var $button = this.$button; // Save this so that we can determine when the button height has changed due adding/removing selections. this._savedButtonHeight = this.$button.outerHeight(false); - var pos = $.extend({'my': 'top', 'at': 'bottom', 'of': $button}, this.options.position || {}); + var pos = $.extend({'my': 'left top', 'at': 'left bottom', 'of': $button}, this.options.position || {}); - if($.ui && $.ui.position) + if ($.ui && $.ui.position) { this.$menu.position(pos); + } else { pos = $button.position(); pos.top += this._savedButtonHeight; @@ -1193,8 +1448,9 @@ switch(key) { case 'header': - if (typeof value === 'boolean') + if (typeof value === 'boolean') { this.$header.toggle( value ); + } else if (typeof value === 'string') { this.$headerLinkContainer.children('li:not(:last-child)').remove(); this.$headerLinkContainer.prepend('
  • ' + value + '
  • '); @@ -1220,7 +1476,7 @@ this.options[key] = value; this._setMenuHeight(true); // true forces recalc of cached value. break; - case 'minWidth': + case 'buttonWidth': case 'menuWidth': this.options[key] = value; this._setButtonWidth(true); // true forces recalc of cached value. diff --git a/tests/unit/core.js b/tests/unit/core.js index dcfafa9..183b034 100644 --- a/tests/unit/core.js +++ b/tests/unit/core.js @@ -12,7 +12,8 @@ }); QUnit.test("init", function (assert) { - el = $("select").multiselect(), $header = header(); + el = $("select").multiselect(); + var $header = header(); assert.ok($header.find('a.ui-multiselect-all').css('display') !== 'none', 'select all is visible'); assert.ok($header.find('a.ui-multiselect-none').css('display') !== 'none', 'select none is visible'); assert.ok($header.find('a.ui-multiselect-close').css('display') !== 'none', 'close link is visible'); @@ -101,7 +102,7 @@ // select multiple radios to ensure that, in the underlying select, only one // will remain selected - radios = menu().find(":radio"); + var radios = menu().find(":radio"); radios[0].click(); radios[2].click(); radios[1].click(); diff --git a/tests/unit/events.js b/tests/unit/events.js index 4093053..20f4576 100644 --- a/tests/unit/events.js +++ b/tests/unit/events.js @@ -127,8 +127,8 @@ assert.equal($('.ui-multiselect').length, 2, "two mutliselects are on the page"); first.multiselect("refresh"); second.multiselect("refresh"); - $label = $(second.multiselect("getLabels")[0]); - $wrongInput = $(first.multiselect("getLabels")[0]).find("input"); + var $label = $(second.multiselect("getLabels")[0]); + var $wrongInput = $(first.multiselect("getLabels")[0]).find("input"); $label.click(); assert.equal($label.find("input").prop("checked"), true, "the input for that label should be checked"); assert.equal($wrongInput.prop("checked"), false, "the input for the corresponding label on the first widget should not be checked"); diff --git a/tests/unit/options.js b/tests/unit/options.js index 3a4c242..cb2889f 100644 --- a/tests/unit/options.js +++ b/tests/unit/options.js @@ -89,7 +89,7 @@ selectedMax: 2 }); - checkboxes = el.multiselect("widget").find(":checkbox"); + var checkboxes = el.multiselect("widget").find(":checkbox"); checkboxes.eq(0).trigger('click'); checkboxes.eq(1).trigger('click'); checkboxes.eq(2).trigger('click'); @@ -146,7 +146,7 @@ var height = 100; el = $("select").multiselect({ height: height }).multiselect("open"); - assert.equal(height, menu().find("ul.ui-multiselect-checkboxes").height(), 'height after opening property set to ' + height); + assert.equal(height, menu().find(".ui-multiselect-checkboxes").height(), 'height after opening property set to ' + height); // change height and re-test height = 300; @@ -156,33 +156,33 @@ el.multiselect("destroy"); }); - QUnit.test("minWidth", function (assert) { - var minWidth = 321; + QUnit.test("buttonWidth", function (assert) { + var buttonWidth = 321; - el = $("select").multiselect({ minWidth: minWidth }).multiselect("open"); - assert.equal(minWidth, button().outerWidth(), 'outerWidth of button is ' + minWidth); + el = $("select").multiselect({ buttonWidth: '>=' + buttonWidth }).multiselect("open"); + assert.equal(buttonWidth, button().outerWidth(), 'outerWidth of button is ' + buttonWidth); - // change height and re-test - minWidth = 351; - el.multiselect("option", "minWidth", minWidth); - assert.equal(minWidth, button().outerWidth(), 'changing value through api to ' + minWidth); + // change width and re-test + buttonWidth = 351; + el.multiselect("option", "buttonWidth", '>=' + buttonWidth); + assert.equal(buttonWidth, button().outerWidth(), 'changing value through api to ' + buttonWidth); - // change height to something that should fail. - minWidth = 10; - el.multiselect("option", "minWidth", minWidth); + // change width to something that should fail. + buttonWidth = 10; + el.multiselect("option", "buttonWidth", '>=' + buttonWidth); var outerWidth = button().outerWidth(); - assert.ok(minWidth !== outerWidth, 'changing value through api to ' + minWidth + ' (too small), outerWidth is actually ' + outerWidth); + assert.ok(buttonWidth !== outerWidth, 'changing value through api to ' + buttonWidth + ' (too small), outerWidth is actually ' + outerWidth); // Reference: https://www.wired.com/2010/12/why-percentage-based-designs-dont-work-in-every-browser/ - minWidth = "50%"; - el.multiselect("option", "minWidth", minWidth); + buttonWidth = "50%"; + el.multiselect("option", "buttonWidth", '>=' + buttonWidth); var outerWidthX2 = Math.floor(button().outerWidth() * 2); // Double to reduce chance of fractions var parentWidth = Math.floor(el.parent().outerWidth()); assert.ok(Math.abs(outerWidthX2 - parentWidth) <= 1, 'changing value to 50%'); // Off by 1 is assert.ok due to floating point rounding discrepancies between browsers. - minWidth = "351px"; - el.multiselect("option", "minWidth", minWidth); - assert.equal(351, button().outerWidth(), 'minWidth supports strings suffixed with px as well as integer px values'); + buttonWidth = "351px"; + el.multiselect("option", "buttonWidth", '>=' + buttonWidth); + assert.equal(351, button().outerWidth(), 'buttonWidth supports strings suffixed with px as well as integer px values'); el.multiselect("destroy"); }); @@ -190,15 +190,19 @@ QUnit.test("menuWidth", function (assert) { var width = 50; - el = $("select").multiselect({ minWidth: 100, menuWidth: width }).multiselect("open"); + el = $("select").multiselect({ buttonWidth: 100, menuWidth: width }).multiselect("open"); assert.equal(menu().parent().find(".ui-multiselect-menu").outerWidth(), width, 'width after opening, property set to ' + width); - // change height and re-test + // change width and re-test width = 300; el.multiselect("option", "menuWidth", width).multiselect('refresh'); assert.equal(menu().parent().find(".ui-multiselect-menu").outerWidth(), width, 'changing value through api to ' + width); + width = "3in"; + el.multiselect("option", "menuWidth", width).multiselect('refresh'); + assert.equal(menu().parent().find(".ui-multiselect-menu").outerWidth(), 3 * 96.0, 'menuWidth supports strings suffixed with "in" unit as well as integer "px" values'); + el.multiselect("destroy"); });