X-Git-Url: https://git.heureux-cyclage.org/?a=blobdiff_plain;f=resources%2Fsrc%2Fjquery%2Fjquery.suggestions.js;h=884ecb6e96dd9d6d42334410da281952640458f7;hb=4b282b3ffafce6c8f12bef4e5a7bbb8b80c51571;hp=4d5354633b8334252685ba9f15830e4e9ed5d268;hpb=b2c0705b819f5da7a7c07919a76b05fc31b01c34;p=lhc%2Fweb%2Fwiklou.git diff --git a/resources/src/jquery/jquery.suggestions.js b/resources/src/jquery/jquery.suggestions.js index 4d5354633b..884ecb6e96 100644 --- a/resources/src/jquery/jquery.suggestions.js +++ b/resources/src/jquery/jquery.suggestions.js @@ -14,11 +14,12 @@ * * $( '#textbox' ).suggestions(); * - * Uses jQuery.suggestions singleteon internally. + * Uses jQuery.suggestions singleton internally. * * @class jQuery.plugin.suggestions */ + // jscs:disable checkParamNames /** * @method suggestions * @chainable @@ -93,667 +94,697 @@ * @param {boolean} [options.highlightInput=false] Whether to highlight matched portions of the * input or not. */ + // jscs:enable checkParamNames ( function ( $ ) { -var hasOwn = Object.hasOwnProperty; - -/** - * Used by jQuery.plugin.suggestions. - * - * @class jQuery.suggestions - * @singleton - * @private - */ -$.suggestions = { - /** - * Cancel any delayed maybeFetch() call and callback the context so - * they can cancel any async fetching if they use AJAX or something. - */ - cancel: function ( context ) { - if ( context.data.timerID !== null ) { - clearTimeout( context.data.timerID ); - } - if ( $.isFunction( context.config.cancel ) ) { - context.config.cancel.call( context.data.$textbox ); - } - }, - - /** - * Hide the element with suggestions and clean up some state. - */ - hide: function ( context ) { - // Remove any highlights, including on "special" items - context.data.$container.find( '.suggestions-result-current' ).removeClass( 'suggestions-result-current' ); - // Hide the container - context.data.$container.hide(); - }, - - /** - * Restore the text the user originally typed in the textbox, before it - * was overwritten by highlight(). This restores the value the currently - * displayed suggestions are based on, rather than the value just before - * highlight() overwrote it; the former is arguably slightly more sensible. - */ - restore: function ( context ) { - context.data.$textbox.val( context.data.prevText ); - }, + var hasOwn = Object.hasOwnProperty; /** - * Ask the user-specified callback for new suggestions. Any previous delayed - * call to this function still pending will be canceled. If the value in the - * textbox is empty or hasn't changed since the last time suggestions were fetched, - * this function does nothing. + * Used by jQuery.plugin.suggestions. * - * @param {boolean} delayed Whether or not to delay this by the currently configured amount of time + * @class jQuery.suggestions + * @singleton + * @private */ - update: function ( context, delayed ) { - function maybeFetch() { - var val = context.data.$textbox.val(), - cache = context.data.cache, - cacheHit; - - if ( typeof context.config.update.before === 'function' ) { - context.config.update.before.call( context.data.$textbox ); + $.suggestions = { + /** + * Cancel any delayed maybeFetch() call and callback the context so + * they can cancel any async fetching if they use AJAX or something. + * + * @param {Object} context + */ + cancel: function ( context ) { + if ( context.data.timerID !== null ) { + clearTimeout( context.data.timerID ); } - - // Only fetch if the value in the textbox changed and is not empty, or if the results were hidden - // if the textbox is empty then clear the result div, but leave other settings intouched - if ( val.length === 0 ) { - $.suggestions.hide( context ); - context.data.prevText = ''; - } else if ( - val !== context.data.prevText || - !context.data.$container.is( ':visible' ) - ) { - context.data.prevText = val; - // Try cache first - if ( context.config.cache && hasOwn.call( cache, val ) ) { - if ( +new Date() - cache[ val ].timestamp < context.config.cacheMaxAge ) { - context.data.$textbox.suggestions( 'suggestions', cache[ val ].suggestions ); - if ( typeof context.config.update.after === 'function' ) { - context.config.update.after.call( context.data.$textbox ); - } - cacheHit = true; - } else { - // Cache expired - delete cache[ val ]; - } + if ( $.isFunction( context.config.cancel ) ) { + context.config.cancel.call( context.data.$textbox ); + } + }, + + /** + * Hide the element with suggestions and clean up some state. + * + * @param {Object} context + */ + hide: function ( context ) { + // Remove any highlights, including on "special" items + context.data.$container.find( '.suggestions-result-current' ).removeClass( 'suggestions-result-current' ); + // Hide the container + context.data.$container.hide(); + }, + + /** + * Restore the text the user originally typed in the textbox, before it + * was overwritten by highlight(). This restores the value the currently + * displayed suggestions are based on, rather than the value just before + * highlight() overwrote it; the former is arguably slightly more sensible. + * + * @param {Object} context + */ + restore: function ( context ) { + context.data.$textbox.val( context.data.prevText ); + }, + + /** + * Ask the user-specified callback for new suggestions. Any previous delayed + * call to this function still pending will be canceled. If the value in the + * textbox is empty or hasn't changed since the last time suggestions were fetched, + * this function does nothing. + * + * @param {Object} context + * @param {boolean} delayed Whether or not to delay this by the currently configured amount of time + */ + update: function ( context, delayed ) { + function maybeFetch() { + var val = context.data.$textbox.val(), + cache = context.data.cache, + cacheHit; + + if ( typeof context.config.update.before === 'function' ) { + context.config.update.before.call( context.data.$textbox ); } - if ( !cacheHit && typeof context.config.fetch === 'function' ) { - context.config.fetch.call( - context.data.$textbox, - val, - function ( suggestions ) { - suggestions = suggestions.slice( 0, context.config.maxRows ); - context.data.$textbox.suggestions( 'suggestions', suggestions ); + + // Only fetch if the value in the textbox changed and is not empty, or if the results were hidden + // if the textbox is empty then clear the result div, but leave other settings intouched + if ( val.length === 0 ) { + $.suggestions.hide( context ); + context.data.prevText = ''; + } else if ( + val !== context.data.prevText || + !context.data.$container.is( ':visible' ) + ) { + context.data.prevText = val; + // Try cache first + if ( context.config.cache && hasOwn.call( cache, val ) ) { + if ( +new Date() - cache[ val ].timestamp < context.config.cacheMaxAge ) { + context.data.$textbox.suggestions( 'suggestions', cache[ val ].suggestions ); if ( typeof context.config.update.after === 'function' ) { - context.config.update.after.call( context.data.$textbox ); + context.config.update.after.call( context.data.$textbox, cache[ val ].metadata ); } - if ( context.config.cache ) { - cache[ val ] = { - suggestions: suggestions, - timestamp: +new Date() - }; - } - }, - context.config.maxRows - ); + cacheHit = true; + } else { + // Cache expired + delete cache[ val ]; + } + } + if ( !cacheHit && typeof context.config.fetch === 'function' ) { + context.config.fetch.call( + context.data.$textbox, + val, + function ( suggestions, metadata ) { + suggestions = suggestions.slice( 0, context.config.maxRows ); + context.data.$textbox.suggestions( 'suggestions', suggestions ); + if ( typeof context.config.update.after === 'function' ) { + context.config.update.after.call( context.data.$textbox, metadata ); + } + if ( context.config.cache ) { + cache[ val ] = { + suggestions: suggestions, + metadata: metadata, + timestamp: +new Date() + }; + } + }, + context.config.maxRows + ); + } } - } - - // Always update special rendering - $.suggestions.special( context ); - } - // Cancels any delayed maybeFetch call, and invokes context.config.cancel. - $.suggestions.cancel( context ); + // Always update special rendering + $.suggestions.special( context ); + } - if ( delayed ) { - // To avoid many started/aborted requests while typing, we're gonna take a short - // break before trying to fetch data. - context.data.timerID = setTimeout( maybeFetch, context.config.delay ); - } else { - maybeFetch(); - } - }, - - special: function ( context ) { - // Allow custom rendering - but otherwise don't do any rendering - if ( typeof context.config.special.render === 'function' ) { - // Wait for the browser to update the value - setTimeout( function () { - // Render special - var $special = context.data.$container.find( '.suggestions-special' ); - context.config.special.render.call( $special, context.data.$textbox.val(), context ); - }, 1 ); - } - }, + // Cancels any delayed maybeFetch call, and invokes context.config.cancel. + $.suggestions.cancel( context ); - /** - * Sets the value of a property, and updates the widget accordingly - * - * @param {string} property Name of property - * @param {Mixed} value Value to set property with - */ - configure: function ( context, property, value ) { - var newCSS, - $result, $results, $spanForWidth, childrenWidth, - i, expWidth, maxWidth, text; - - // Validate creation using fallback values - switch ( property ) { - case 'fetch': - case 'cancel': - case 'special': - case 'result': - case 'update': - case '$region': - case 'expandFrom': - context.config[ property ] = value; - break; - case 'suggestions': - context.config[ property ] = value; - // Update suggestions - if ( context.data !== undefined ) { - if ( context.data.$textbox.val().length === 0 ) { - // Hide the div when no suggestion exist - $.suggestions.hide( context ); - } else { - // Rebuild the suggestions list - context.data.$container.show(); - // Update the size and position of the list - newCSS = { - top: context.config.$region.offset().top + context.config.$region.outerHeight(), - bottom: 'auto', - width: context.config.$region.outerWidth(), - height: 'auto' - }; - - // Process expandFrom, after this it is set to left or right. - context.config.expandFrom = ( function ( expandFrom ) { - var regionWidth, docWidth, regionCenter, docCenter, - docDir = $( document.documentElement ).css( 'direction' ), - $region = context.config.$region; - - // Backwards compatible - if ( context.config.positionFromLeft ) { - expandFrom = 'left'; - - // Catch invalid values, default to 'auto' - } else if ( $.inArray( expandFrom, [ 'left', 'right', 'start', 'end', 'auto' ] ) === -1 ) { - expandFrom = 'auto'; - } + if ( delayed ) { + // To avoid many started/aborted requests while typing, we're gonna take a short + // break before trying to fetch data. + context.data.timerID = setTimeout( maybeFetch, context.config.delay ); + } else { + maybeFetch(); + } + }, + + /** + * @param {Object} context + */ + special: function ( context ) { + // Allow custom rendering - but otherwise don't do any rendering + if ( typeof context.config.special.render === 'function' ) { + // Wait for the browser to update the value + setTimeout( function () { + // Render special + var $special = context.data.$container.find( '.suggestions-special' ); + context.config.special.render.call( $special, context.data.$textbox.val(), context ); + }, 1 ); + } + }, + + /** + * Sets the value of a property, and updates the widget accordingly + * + * @param {Object} context + * @param {string} property Name of property + * @param {Mixed} value Value to set property with + */ + configure: function ( context, property, value ) { + var newCSS, + $result, $results, $spanForWidth, childrenWidth, + i, expWidth, maxWidth, text; + + // Validate creation using fallback values + switch ( property ) { + case 'fetch': + case 'cancel': + case 'special': + case 'result': + case 'update': + case '$region': + case 'expandFrom': + context.config[ property ] = value; + break; + case 'suggestions': + context.config[ property ] = value; + // Update suggestions + if ( context.data !== undefined ) { + if ( context.data.$textbox.val().length === 0 ) { + // Hide the div when no suggestion exist + $.suggestions.hide( context ); + } else { + // Rebuild the suggestions list + context.data.$container.show(); + // Update the size and position of the list + newCSS = { + top: context.config.$region.offset().top + context.config.$region.outerHeight(), + bottom: 'auto', + width: context.config.$region.outerWidth(), + height: 'auto' + }; + + // Process expandFrom, after this it is set to left or right. + context.config.expandFrom = ( function ( expandFrom ) { + var regionWidth, docWidth, regionCenter, docCenter, + docDir = $( document.documentElement ).css( 'direction' ), + $region = context.config.$region; + + // Backwards compatible + if ( context.config.positionFromLeft ) { + expandFrom = 'left'; + + // Catch invalid values, default to 'auto' + } else if ( $.inArray( expandFrom, [ 'left', 'right', 'start', 'end', 'auto' ] ) === -1 ) { + expandFrom = 'auto'; + } - if ( expandFrom === 'auto' ) { - if ( $region.data( 'searchsuggest-expand-dir' ) ) { - // If the markup explicitly contains a direction, use it. - expandFrom = $region.data( 'searchsuggest-expand-dir' ); - } else { - regionWidth = $region.outerWidth(); - docWidth = $( document ).width(); - if ( regionWidth > ( 0.85 * docWidth ) ) { - // If the input size takes up more than 85% of the document horizontally - // expand the suggestions to the writing direction's native end. - expandFrom = 'start'; + if ( expandFrom === 'auto' ) { + if ( $region.data( 'searchsuggest-expand-dir' ) ) { + // If the markup explicitly contains a direction, use it. + expandFrom = $region.data( 'searchsuggest-expand-dir' ); } else { - // Calculate the center points of the input and document - regionCenter = $region.offset().left + regionWidth / 2; - docCenter = docWidth / 2; - if ( Math.abs( regionCenter - docCenter ) < ( 0.10 * docCenter ) ) { - // If the input's center is within 10% of the document center - // use the writing direction's native end. + regionWidth = $region.outerWidth(); + docWidth = $( document ).width(); + if ( regionWidth > ( 0.85 * docWidth ) ) { + // If the input size takes up more than 85% of the document horizontally + // expand the suggestions to the writing direction's native end. expandFrom = 'start'; } else { - // Otherwise expand the input from the closest side of the page, - // towards the side of the page with the most free open space - expandFrom = regionCenter > docCenter ? 'right' : 'left'; + // Calculate the center points of the input and document + regionCenter = $region.offset().left + regionWidth / 2; + docCenter = docWidth / 2; + if ( Math.abs( regionCenter - docCenter ) < ( 0.10 * docCenter ) ) { + // If the input's center is within 10% of the document center + // use the writing direction's native end. + expandFrom = 'start'; + } else { + // Otherwise expand the input from the closest side of the page, + // towards the side of the page with the most free open space + expandFrom = regionCenter > docCenter ? 'right' : 'left'; + } } } } - } - - if ( expandFrom === 'start' ) { - expandFrom = docDir === 'rtl' ? 'right' : 'left'; - } else if ( expandFrom === 'end' ) { - expandFrom = docDir === 'rtl' ? 'left' : 'right'; - } + if ( expandFrom === 'start' ) { + expandFrom = docDir === 'rtl' ? 'right' : 'left'; - return expandFrom; + } else if ( expandFrom === 'end' ) { + expandFrom = docDir === 'rtl' ? 'left' : 'right'; + } - }( context.config.expandFrom ) ); + return expandFrom; - if ( context.config.expandFrom === 'left' ) { - // Expand from left - newCSS.left = context.config.$region.offset().left; - newCSS.right = 'auto'; - } else { - // Expand from right - newCSS.left = 'auto'; - newCSS.right = $( 'body' ).width() - ( context.config.$region.offset().left + context.config.$region.outerWidth() ); - } + }( context.config.expandFrom ) ); - context.data.$container.css( newCSS ); - $results = context.data.$container.children( '.suggestions-results' ); - $results.empty(); - expWidth = -1; - for ( i = 0; i < context.config.suggestions.length; i++ ) { - /*jshint loopfunc:true */ - text = context.config.suggestions[ i ]; - $result = $( '
' ) - .addClass( 'suggestions-result' ) - .attr( 'rel', i ) - .data( 'text', context.config.suggestions[ i ] ) - .mousemove( function () { - context.data.selectedWithMouse = true; - $.suggestions.highlight( - context, - $( this ).closest( '.suggestions-results .suggestions-result' ), - false - ); - } ) - .appendTo( $results ); - // Allow custom rendering - if ( typeof context.config.result.render === 'function' ) { - context.config.result.render.call( $result, context.config.suggestions[ i ], context ); + if ( context.config.expandFrom === 'left' ) { + // Expand from left + newCSS.left = context.config.$region.offset().left; + newCSS.right = 'auto'; } else { - $result.text( text ); + // Expand from right + newCSS.left = 'auto'; + newCSS.right = $( 'body' ).width() - ( context.config.$region.offset().left + context.config.$region.outerWidth() ); } - if ( context.config.highlightInput ) { - $result.highlightText( context.data.prevText ); - } + context.data.$container.css( newCSS ); + $results = context.data.$container.children( '.suggestions-results' ); + $results.empty(); + expWidth = -1; + for ( i = 0; i < context.config.suggestions.length; i++ ) { + /*jshint loopfunc:true */ + text = context.config.suggestions[ i ]; + $result = $( '
' ) + .addClass( 'suggestions-result' ) + .attr( 'rel', i ) + .data( 'text', context.config.suggestions[ i ] ) + .mousemove( function () { + context.data.selectedWithMouse = true; + $.suggestions.highlight( + context, + $( this ).closest( '.suggestions-results .suggestions-result' ), + false + ); + } ) + .appendTo( $results ); + // Allow custom rendering + if ( typeof context.config.result.render === 'function' ) { + context.config.result.render.call( $result, context.config.suggestions[ i ], context ); + } else { + $result.text( text ); + } + + if ( context.config.highlightInput ) { + $result.highlightText( context.data.prevText ); + } - // Widen results box if needed (new width is only calculated here, applied later). - - // The monstrosity below accomplishes two things: - // * Wraps the text contents in a DOM element, so that we can know its width. There is - // no way to directly access the width of a text node, and we can't use the parent - // node width as it has text-overflow: ellipsis; and overflow: hidden; applied to - // it, which trims it to a smaller width. - // * Temporarily applies position: absolute; to the wrapper to pull it out of normal - // document flow. Otherwise the CSS text-overflow: ellipsis; and overflow: hidden; - // rules would cause some browsers (at least all versions of IE from 6 to 11) to - // still report the "trimmed" width. This should not be done in regular CSS - // stylesheets as we don't want this rule to apply to other elements, like - // the ones generated by jquery.highlightText. - $spanForWidth = $result.wrapInner( '' ).children(); - childrenWidth = $spanForWidth.css( 'position', 'absolute' ).outerWidth(); - $spanForWidth.contents().unwrap(); - - if ( childrenWidth > $result.width() && childrenWidth > expWidth ) { - // factor in any padding, margin, or border space on the parent - expWidth = childrenWidth + ( context.data.$container.width() - $result.width() ); + // Widen results box if needed (new width is only calculated here, applied later). + + // The monstrosity below accomplishes two things: + // * Wraps the text contents in a DOM element, so that we can know its width. There is + // no way to directly access the width of a text node, and we can't use the parent + // node width as it has text-overflow: ellipsis; and overflow: hidden; applied to + // it, which trims it to a smaller width. + // * Temporarily applies position: absolute; to the wrapper to pull it out of normal + // document flow. Otherwise the CSS text-overflow: ellipsis; and overflow: hidden; + // rules would cause some browsers (at least all versions of IE from 6 to 11) to + // still report the "trimmed" width. This should not be done in regular CSS + // stylesheets as we don't want this rule to apply to other elements, like + // the ones generated by jquery.highlightText. + $spanForWidth = $result.wrapInner( '' ).children(); + childrenWidth = $spanForWidth.css( 'position', 'absolute' ).outerWidth(); + $spanForWidth.contents().unwrap(); + + if ( childrenWidth > $result.width() && childrenWidth > expWidth ) { + // factor in any padding, margin, or border space on the parent + expWidth = childrenWidth + ( context.data.$container.width() - $result.width() ); + } } - } - // Apply new width for results box, if any - if ( expWidth > context.data.$container.width() ) { - maxWidth = context.config.maxExpandFactor * context.data.$textbox.width(); - context.data.$container.width( Math.min( expWidth, maxWidth ) ); + // Apply new width for results box, if any + if ( expWidth > context.data.$container.width() ) { + maxWidth = context.config.maxExpandFactor * context.data.$textbox.width(); + context.data.$container.width( Math.min( expWidth, maxWidth ) ); + } } } - } - break; - case 'maxRows': - context.config[ property ] = Math.max( 1, Math.min( 100, value ) ); - break; - case 'delay': - context.config[ property ] = Math.max( 0, Math.min( 1200, value ) ); - break; - case 'cacheMaxAge': - context.config[ property ] = Math.max( 1, value ); - break; - case 'maxExpandFactor': - context.config[ property ] = Math.max( 1, value ); - break; - case 'cache': - case 'submitOnClick': - case 'positionFromLeft': - case 'highlightInput': - context.config[ property ] = !!value; - break; - } - }, + break; + case 'maxRows': + context.config[ property ] = Math.max( 1, Math.min( 100, value ) ); + break; + case 'delay': + context.config[ property ] = Math.max( 0, Math.min( 1200, value ) ); + break; + case 'cacheMaxAge': + context.config[ property ] = Math.max( 1, value ); + break; + case 'maxExpandFactor': + context.config[ property ] = Math.max( 1, value ); + break; + case 'cache': + case 'submitOnClick': + case 'positionFromLeft': + case 'highlightInput': + context.config[ property ] = !!value; + break; + } + }, + + /** + * Highlight a result in the results table + * + * @param {Object} context + * @param {jQuery|string} result `` to highlight, or 'prev' or 'next' + * @param {boolean} updateTextbox If true, put the suggestion in the textbox + */ + highlight: function ( context, result, updateTextbox ) { + var selected = context.data.$container.find( '.suggestions-result-current' ); + if ( !result.get || selected.get( 0 ) !== result.get( 0 ) ) { + if ( result === 'prev' ) { + if ( selected.hasClass( 'suggestions-special' ) ) { + result = context.data.$container.find( '.suggestions-result:last' ); + } else { + result = selected.prev(); + if ( !( result.length && result.hasClass( 'suggestions-result' ) ) ) { + // there is something in the DOM between selected element and the wrapper, bypass it + result = selected.parents( '.suggestions-results > *' ).prev().find( '.suggestions-result' ).eq( 0 ); + } - /** - * Highlight a result in the results table - * - * @param {jQuery|string} result `` to highlight, or 'prev' or 'next' - * @param {boolean} updateTextbox If true, put the suggestion in the textbox - */ - highlight: function ( context, result, updateTextbox ) { - var selected = context.data.$container.find( '.suggestions-result-current' ); - if ( !result.get || selected.get( 0 ) !== result.get( 0 ) ) { - if ( result === 'prev' ) { - if ( selected.hasClass( 'suggestions-special' ) ) { - result = context.data.$container.find( '.suggestions-result:last' ); - } else { - result = selected.prev(); - if ( !( result.length && result.hasClass( 'suggestions-result' ) ) ) { - // there is something in the DOM between selected element and the wrapper, bypass it - result = selected.parents( '.suggestions-results > *' ).prev().find( '.suggestions-result' ).eq( 0 ); + if ( selected.length === 0 ) { + // we are at the beginning, so lets jump to the last item + if ( context.data.$container.find( '.suggestions-special' ).html() !== '' ) { + result = context.data.$container.find( '.suggestions-special' ); + } else { + result = context.data.$container.find( '.suggestions-results .suggestions-result:last' ); + } + } } - + } else if ( result === 'next' ) { if ( selected.length === 0 ) { - // we are at the beginning, so lets jump to the last item - if ( context.data.$container.find( '.suggestions-special' ).html() !== '' ) { + // No item selected, go to the first one + result = context.data.$container.find( '.suggestions-results .suggestions-result:first' ); + if ( result.length === 0 && context.data.$container.find( '.suggestions-special' ).html() !== '' ) { + // No suggestion exists, go to the special one directly result = context.data.$container.find( '.suggestions-special' ); - } else { - result = context.data.$container.find( '.suggestions-results .suggestions-result:last' ); } - } - } - } else if ( result === 'next' ) { - if ( selected.length === 0 ) { - // No item selected, go to the first one - result = context.data.$container.find( '.suggestions-results .suggestions-result:first' ); - if ( result.length === 0 && context.data.$container.find( '.suggestions-special' ).html() !== '' ) { - // No suggestion exists, go to the special one directly - result = context.data.$container.find( '.suggestions-special' ); - } - } else { - result = selected.next(); - if ( !( result.length && result.hasClass( 'suggestions-result' ) ) ) { - // there is something in the DOM between selected element and the wrapper, bypass it - result = selected.parents( '.suggestions-results > *' ).next().find( '.suggestions-result' ).eq( 0 ); - } + } else { + result = selected.next(); + if ( !( result.length && result.hasClass( 'suggestions-result' ) ) ) { + // there is something in the DOM between selected element and the wrapper, bypass it + result = selected.parents( '.suggestions-results > *' ).next().find( '.suggestions-result' ).eq( 0 ); + } - if ( selected.hasClass( 'suggestions-special' ) ) { - result = $( [] ); - } else if ( - result.length === 0 && - context.data.$container.find( '.suggestions-special' ).html() !== '' - ) { - // We were at the last item, jump to the specials! - result = context.data.$container.find( '.suggestions-special' ); + if ( selected.hasClass( 'suggestions-special' ) ) { + result = $( [] ); + } else if ( + result.length === 0 && + context.data.$container.find( '.suggestions-special' ).html() !== '' + ) { + // We were at the last item, jump to the specials! + result = context.data.$container.find( '.suggestions-special' ); + } } } + selected.removeClass( 'suggestions-result-current' ); + result.addClass( 'suggestions-result-current' ); } - selected.removeClass( 'suggestions-result-current' ); - result.addClass( 'suggestions-result-current' ); - } - if ( updateTextbox ) { - if ( result.length === 0 || result.is( '.suggestions-special' ) ) { - $.suggestions.restore( context ); - } else { - context.data.$textbox.val( result.data( 'text' ) ); - // .val() doesn't call any event handlers, so - // let the world know what happened - context.data.$textbox.change(); - } - context.data.$textbox.trigger( 'change' ); - } - }, - - /** - * Respond to keypress event - * - * @param {number} key Code of key pressed - */ - keypress: function ( e, context, key ) { - var selected, - wasVisible = context.data.$container.is( ':visible' ), - preventDefault = false; - - switch ( key ) { - // Arrow down - case 40: - if ( wasVisible ) { - $.suggestions.highlight( context, 'next', true ); - context.data.selectedWithMouse = false; + if ( updateTextbox ) { + if ( result.length === 0 || result.is( '.suggestions-special' ) ) { + $.suggestions.restore( context ); } else { - $.suggestions.update( context, false ); - } - preventDefault = true; - break; - // Arrow up - case 38: - if ( wasVisible ) { - $.suggestions.highlight( context, 'prev', true ); - context.data.selectedWithMouse = false; + context.data.$textbox.val( result.data( 'text' ) ); + // .val() doesn't call any event handlers, so + // let the world know what happened + context.data.$textbox.change(); } - preventDefault = wasVisible; - break; - // Escape - case 27: - $.suggestions.hide( context ); - $.suggestions.restore( context ); - $.suggestions.cancel( context ); context.data.$textbox.trigger( 'change' ); - preventDefault = wasVisible; - break; - // Enter - case 13: - preventDefault = wasVisible; - selected = context.data.$container.find( '.suggestions-result-current' ); - $.suggestions.hide( context ); - if ( selected.length === 0 || context.data.selectedWithMouse ) { - // If nothing is selected or if something was selected with the mouse - // cancel any current requests and allow the form to be submitted - // (simply don't prevent default behavior). + } + }, + + /** + * Respond to keypress event + * + * @param {jQuery.Event} e + * @param {Object} context + * @param {number} key Code of key pressed + */ + keypress: function ( e, context, key ) { + var selected, + wasVisible = context.data.$container.is( ':visible' ), + preventDefault = false; + + switch ( key ) { + // Arrow down + case 40: + if ( wasVisible ) { + $.suggestions.highlight( context, 'next', true ); + context.data.selectedWithMouse = false; + } else { + $.suggestions.update( context, false ); + } + preventDefault = true; + break; + // Arrow up + case 38: + if ( wasVisible ) { + $.suggestions.highlight( context, 'prev', true ); + context.data.selectedWithMouse = false; + } + preventDefault = wasVisible; + break; + // Escape + case 27: + $.suggestions.hide( context ); + $.suggestions.restore( context ); $.suggestions.cancel( context ); - preventDefault = false; - } else if ( selected.is( '.suggestions-special' ) ) { - if ( typeof context.config.special.select === 'function' ) { - // Allow the callback to decide whether to prevent default or not - if ( context.config.special.select.call( selected, context.data.$textbox ) === true ) { - preventDefault = false; + context.data.$textbox.trigger( 'change' ); + preventDefault = wasVisible; + break; + // Enter + case 13: + preventDefault = wasVisible; + selected = context.data.$container.find( '.suggestions-result-current' ); + $.suggestions.hide( context ); + if ( selected.length === 0 || context.data.selectedWithMouse ) { + // If nothing is selected or if something was selected with the mouse + // cancel any current requests and allow the form to be submitted + // (simply don't prevent default behavior). + $.suggestions.cancel( context ); + preventDefault = false; + } else if ( selected.is( '.suggestions-special' ) ) { + if ( typeof context.config.special.select === 'function' ) { + // Allow the callback to decide whether to prevent default or not + if ( context.config.special.select.call( selected, context.data.$textbox, 'keyboard' ) === true ) { + preventDefault = false; + } } - } - } else { - if ( typeof context.config.result.select === 'function' ) { - // Allow the callback to decide whether to prevent default or not - if ( context.config.result.select.call( selected, context.data.$textbox ) === true ) { - preventDefault = false; + } else { + if ( typeof context.config.result.select === 'function' ) { + // Allow the callback to decide whether to prevent default or not + if ( context.config.result.select.call( selected, context.data.$textbox, 'keyboard' ) === true ) { + preventDefault = false; + } } } - } - break; - default: - $.suggestions.update( context, true ); - break; - } - if ( preventDefault ) { - e.preventDefault(); - e.stopPropagation(); - } - } -}; - -// See file header for method documentation -$.fn.suggestions = function () { - - // Multi-context fields - var returnValue, - args = arguments; - - $( this ).each( function () { - var context, key; - - /* Construction and Loading */ - - context = $( this ).data( 'suggestions-context' ); - if ( context === undefined || context === null ) { - context = { - config: { - fetch: function () {}, - cancel: function () {}, - special: {}, - result: {}, - update: {}, - $region: $( this ), - suggestions: [], - maxRows: 10, - delay: 120, - cache: false, - cacheMaxAge: 60000, - submitOnClick: false, - maxExpandFactor: 3, - expandFrom: 'auto', - highlightInput: false - } - }; + break; + default: + $.suggestions.update( context, true ); + break; + } + if ( preventDefault ) { + e.preventDefault(); + e.stopPropagation(); + } } + }; + + // See file header for method documentation + $.fn.suggestions = function () { + + // Multi-context fields + var returnValue, + args = arguments; + + $( this ).each( function () { + var context, key; + + /* Construction and Loading */ + + context = $( this ).data( 'suggestions-context' ); + if ( context === undefined || context === null ) { + context = { + config: { + fetch: function () {}, + cancel: function () {}, + special: {}, + result: {}, + update: {}, + $region: $( this ), + suggestions: [], + maxRows: 10, + delay: 120, + cache: false, + cacheMaxAge: 60000, + submitOnClick: false, + maxExpandFactor: 3, + expandFrom: 'auto', + highlightInput: false + } + }; + } - /* API */ + /* API */ - // Handle various calling styles - if ( args.length > 0 ) { - if ( typeof args[ 0 ] === 'object' ) { - // Apply set of properties - for ( key in args[ 0 ] ) { - $.suggestions.configure( context, key, args[ 0 ][ key ] ); - } - } else if ( typeof args[ 0 ] === 'string' ) { - if ( args.length > 1 ) { - // Set property values - $.suggestions.configure( context, args[ 0 ], args[ 1 ] ); - } else if ( returnValue === null || returnValue === undefined ) { - // Get property values, but don't give access to internal data - returns only the first - returnValue = ( args[ 0 ] in context.config ? undefined : context.config[ args[ 0 ] ] ); + // Handle various calling styles + if ( args.length > 0 ) { + if ( typeof args[ 0 ] === 'object' ) { + // Apply set of properties + for ( key in args[ 0 ] ) { + $.suggestions.configure( context, key, args[ 0 ][ key ] ); + } + } else if ( typeof args[ 0 ] === 'string' ) { + if ( args.length > 1 ) { + // Set property values + $.suggestions.configure( context, args[ 0 ], args[ 1 ] ); + } else if ( returnValue === null || returnValue === undefined ) { + // Get property values, but don't give access to internal data - returns only the first + returnValue = ( args[ 0 ] in context.config ? undefined : context.config[ args[ 0 ] ] ); + } } } - } - /* Initialization */ - - if ( context.data === undefined ) { - context.data = { - // ID of running timer - timerID: null, - - // Text in textbox when suggestions were last fetched - prevText: null, - - // Cache of fetched suggestions - cache: {}, - - // Number of results visible without scrolling - visibleResults: 0, - - // Suggestion the last mousedown event occurred on - mouseDownOn: $( [] ), - $textbox: $( this ), - selectedWithMouse: false - }; - - context.data.$container = $( '
' ) - .css( 'display', 'none' ) - .addClass( 'suggestions' ) - .append( - $( '
' ).addClass( 'suggestions-results' ) - // Can't use click() because the container div is hidden when the - // textbox loses focus. Instead, listen for a mousedown followed - // by a mouseup on the same div. - .mousedown( function ( e ) { - context.data.mouseDownOn = $( e.target ).closest( '.suggestions-results .suggestions-result' ); - } ) - .mouseup( function ( e ) { - var $result = $( e.target ).closest( '.suggestions-results .suggestions-result' ), - $other = context.data.mouseDownOn; - - context.data.mouseDownOn = $( [] ); - if ( $result.get( 0 ) !== $other.get( 0 ) ) { - return; - } - // Do not interfere with non-left clicks or if modifier keys are pressed (e.g. ctrl-click). - if ( !( e.which !== 1 || e.altKey || e.ctrlKey || e.shiftKey || e.metaKey ) ) { + /* Initialization */ + + if ( context.data === undefined ) { + context.data = { + // ID of running timer + timerID: null, + + // Text in textbox when suggestions were last fetched + prevText: null, + + // Cache of fetched suggestions + cache: {}, + + // Number of results visible without scrolling + visibleResults: 0, + + // Suggestion the last mousedown event occurred on + mouseDownOn: $( [] ), + $textbox: $( this ), + selectedWithMouse: false + }; + + context.data.$container = $( '
' ) + .css( 'display', 'none' ) + .addClass( 'suggestions' ) + .append( + $( '
' ).addClass( 'suggestions-results' ) + // Can't use click() because the container div is hidden when the + // textbox loses focus. Instead, listen for a mousedown followed + // by a mouseup on the same div. + .mousedown( function ( e ) { + context.data.mouseDownOn = $( e.target ).closest( '.suggestions-results .suggestions-result' ); + } ) + .mouseup( function ( e ) { + var $result = $( e.target ).closest( '.suggestions-results .suggestions-result' ), + $other = context.data.mouseDownOn; + + context.data.mouseDownOn = $( [] ); + if ( $result.get( 0 ) !== $other.get( 0 ) ) { + return; + } $.suggestions.highlight( context, $result, true ); if ( typeof context.config.result.select === 'function' ) { - context.config.result.select.call( $result, context.data.$textbox ); + context.config.result.select.call( $result, context.data.$textbox, 'mouse' ); + } + // Don't interfere with special clicks (e.g. to open in new tab) + if ( !( e.which !== 1 || e.altKey || e.ctrlKey || e.shiftKey || e.metaKey ) ) { + // This will hide the link we're just clicking on, which causes problems + // when done synchronously in at least Firefox 3.6 (bug 62858). + setTimeout( function () { + $.suggestions.hide( context ); + }, 0 ); + } + // Always bring focus to the textbox, as that's probably where the user expects it + // if they were just typing. + context.data.$textbox.focus(); + } ) + ) + .append( + $( '
' ).addClass( 'suggestions-special' ) + // Can't use click() because the container div is hidden when the + // textbox loses focus. Instead, listen for a mousedown followed + // by a mouseup on the same div. + .mousedown( function ( e ) { + context.data.mouseDownOn = $( e.target ).closest( '.suggestions-special' ); + } ) + .mouseup( function ( e ) { + var $special = $( e.target ).closest( '.suggestions-special' ), + $other = context.data.mouseDownOn; + + context.data.mouseDownOn = $( [] ); + if ( $special.get( 0 ) !== $other.get( 0 ) ) { + return; } - // This will hide the link we're just clicking on, which causes problems - // when done synchronously in at least Firefox 3.6 (bug 62858). - setTimeout( function () { - $.suggestions.hide( context ); - }, 0 ); - } - // Always bring focus to the textbox, as that's probably where the user expects it - // if they were just typing. - context.data.$textbox.focus(); - } ) - ) - .append( - $( '
' ).addClass( 'suggestions-special' ) - // Can't use click() because the container div is hidden when the - // textbox loses focus. Instead, listen for a mousedown followed - // by a mouseup on the same div. - .mousedown( function ( e ) { - context.data.mouseDownOn = $( e.target ).closest( '.suggestions-special' ); - } ) - .mouseup( function ( e ) { - var $special = $( e.target ).closest( '.suggestions-special' ), - $other = context.data.mouseDownOn; - - context.data.mouseDownOn = $( [] ); - if ( $special.get( 0 ) !== $other.get( 0 ) ) { - return; - } - // Do not interfere with non-left clicks or if modifier keys are pressed (e.g. ctrl-click). - if ( !( e.which !== 1 || e.altKey || e.ctrlKey || e.shiftKey || e.metaKey ) ) { if ( typeof context.config.special.select === 'function' ) { - context.config.special.select.call( $special, context.data.$textbox ); + context.config.special.select.call( $special, context.data.$textbox, 'mouse' ); } - // This will hide the link we're just clicking on, which causes problems - // when done synchronously in at least Firefox 3.6 (bug 62858). - setTimeout( function () { - $.suggestions.hide( context ); - }, 0 ); - } - // Always bring focus to the textbox, as that's probably where the user expects it - // if they were just typing. - context.data.$textbox.focus(); - } ) - .mousemove( function ( e ) { - context.data.selectedWithMouse = true; - $.suggestions.highlight( - context, $( e.target ).closest( '.suggestions-special' ), false - ); - } ) - ) - .appendTo( $( 'body' ) ); - - $( this ) - // Stop browser autocomplete from interfering - .attr( 'autocomplete', 'off' ) - .keydown( function ( e ) { - // Store key pressed to handle later - context.data.keypressed = e.which; - context.data.keypressedCount = 0; - } ) - .keypress( function ( e ) { - context.data.keypressedCount++; - $.suggestions.keypress( e, context, context.data.keypressed ); - } ) - .keyup( function ( e ) { - // Some browsers won't throw keypress() for arrow keys. If we got a keydown and a keyup without a - // keypress in between, solve it - if ( context.data.keypressedCount === 0 ) { + // Don't interfere with special clicks (e.g. to open in new tab) + if ( !( e.which !== 1 || e.altKey || e.ctrlKey || e.shiftKey || e.metaKey ) ) { + // This will hide the link we're just clicking on, which causes problems + // when done synchronously in at least Firefox 3.6 (bug 62858). + setTimeout( function () { + $.suggestions.hide( context ); + }, 0 ); + } + // Always bring focus to the textbox, as that's probably where the user expects it + // if they were just typing. + context.data.$textbox.focus(); + } ) + .mousemove( function ( e ) { + context.data.selectedWithMouse = true; + $.suggestions.highlight( + context, $( e.target ).closest( '.suggestions-special' ), false + ); + } ) + ) + .appendTo( $( 'body' ) ); + + $( this ) + // Stop browser autocomplete from interfering + .attr( 'autocomplete', 'off' ) + .keydown( function ( e ) { + // Store key pressed to handle later + context.data.keypressed = e.which; + context.data.keypressedCount = 0; + } ) + .keypress( function ( e ) { + context.data.keypressedCount++; $.suggestions.keypress( e, context, context.data.keypressed ); - } - } ) - .blur( function () { - // When losing focus because of a mousedown - // on a suggestion, don't hide the suggestions - if ( context.data.mouseDownOn.length > 0 ) { - return; - } - $.suggestions.hide( context ); - $.suggestions.cancel( context ); - } ); - } + } ) + .keyup( function ( e ) { + // The keypress event is fired when a key is pressed down and that key normally + // produces a character value. We also want to handle some keys that don't + // produce a character value so we also attach to the keydown/keyup events. + // List of codes sourced from + // https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/keyCode + var allowed = [ + 40, // up arrow + 38, // down arrow + 27, // escape + 13, // enter + 46, // delete + 8 // backspace + ]; + if ( context.data.keypressedCount === 0 + && e.which === context.data.keypressed + && $.inArray( e.which, allowed ) !== -1 + ) { + $.suggestions.keypress( e, context, context.data.keypressed ); + } + } ) + .blur( function () { + // When losing focus because of a mousedown + // on a suggestion, don't hide the suggestions + if ( context.data.mouseDownOn.length > 0 ) { + return; + } + $.suggestions.hide( context ); + $.suggestions.cancel( context ); + } ); + } - // Store the context for next time - $( this ).data( 'suggestions-context', context ); - } ); - return returnValue !== undefined ? returnValue : $( this ); -}; + // Store the context for next time + $( this ).data( 'suggestions-context', context ); + } ); + return returnValue !== undefined ? returnValue : $( this ); + }; -/** - * @class jQuery - * @mixins jQuery.plugin.suggestions - */ + /** + * @class jQuery + * @mixins jQuery.plugin.suggestions + */ }( jQuery ) );