Daniel Werner has uploaded a new change for review. https://gerrit.wikimedia.org/r/57449
Change subject: Moves jQuery.ui.suggester from Wikibase into ValueView extension ...................................................................... Moves jQuery.ui.suggester from Wikibase into ValueView extension See Ib39a72c73ead15b8fe4ba6d02cbe5eff902da39d for related commit in Wikibase. Change-Id: I0e7eeee9d94c1dd68fb6af7ff63ef30062595ea8 --- M ValueView/ValueView.resources.mw.php M ValueView/ValueView.tests.qunit.php A ValueView/resources/jquery.ui/jquery.ui.suggester.css A ValueView/resources/jquery.ui/jquery.ui.suggester.js A ValueView/tests/qunit/jquery.ui/jquery.ui.suggester.tests.js 5 files changed, 982 insertions(+), 4 deletions(-) git pull ssh://gerrit.wikimedia.org:29418/mediawiki/extensions/DataValues refs/changes/49/57449/1 diff --git a/ValueView/ValueView.resources.mw.php b/ValueView/ValueView.resources.mw.php index 55227f3..6fce860 100644 --- a/ValueView/ValueView.resources.mw.php +++ b/ValueView/ValueView.resources.mw.php @@ -27,7 +27,7 @@ * @since 0.1 * * @file - * @ingroup DataValues + * @ingroup ValueView * * @licence GNU GPL v2+ * @author Daniel Werner < [email protected] > @@ -70,6 +70,18 @@ 'jquery.eachchange' ) ), + + 'jquery.ui.suggester' => $moduleTemplate + array( + 'scripts' => array( + 'jquery.ui/jquery.ui.suggester.js' + ), + 'styles' => array( + 'jquery.ui/jquery.ui.suggester.css' + ), + 'dependencies' => array( + 'jquery.ui.autocomplete' + ) + ), ); // return jQuery.valueview's native resources plus those required by the MW extension: diff --git a/ValueView/ValueView.tests.qunit.php b/ValueView/ValueView.tests.qunit.php index 24ff89f..7f438f8 100644 --- a/ValueView/ValueView.tests.qunit.php +++ b/ValueView/ValueView.tests.qunit.php @@ -22,7 +22,7 @@ * @since 0.1 * * @file - * @ingroup DataTypes + * @ingroup ValueView * * @licence GNU GPL v2+ * @author Daniel Werner < [email protected] > @@ -43,7 +43,7 @@ 'jquery.eachchange', ), ), - + 'jquery.inputAutoExpand.tests' => array( 'scripts' => array( "$bp/jquery/jquery.inputAutoExpand.tests.js", @@ -51,7 +51,16 @@ 'dependencies' => array( 'jquery.inputAutoExpand', ), - ) + ), + + 'jquery.ui.suggester.tests' => array( + 'scripts' => array( + "$bp/jquery.ui/jquery.ui.suggester.tests.js", + ), + 'dependencies' => array( + 'jquery.ui.suggester', + ), + ), ); } ); diff --git a/ValueView/resources/jquery.ui/jquery.ui.suggester.css b/ValueView/resources/jquery.ui/jquery.ui.suggester.css new file mode 100644 index 0000000..98cc6f7 --- /dev/null +++ b/ValueView/resources/jquery.ui/jquery.ui.suggester.css @@ -0,0 +1,35 @@ +/** + * Default/Vector styles for jquery.ui.suggester + * + * @license GNU GPL v2+ + * @author H. Snater <[email protected]> + */ + +.ui-suggester-list { + border-color: #C9C9C9; + background: white; +} + +.ui-suggester-list .ui-state-hover { + border: none; + background: #4C59A6; + color: white; +} + +.ui-suggester-list .ui-state-hover a, +.ui-suggester-list .ui-state-hover a:hover { + color: white; +} + +.ui-suggester-list { + padding: 0; +} + +.ui-suggester-list .ui-menu-item a { + padding: 0 0.2em; +} + +.ui-suggester-list .ui-menu-item a.ui-state-hover, +.ui-suggester-list .ui-menu-item a.ui-state-active { + margin: 0; +} diff --git a/ValueView/resources/jquery.ui/jquery.ui.suggester.js b/ValueView/resources/jquery.ui/jquery.ui.suggester.js new file mode 100644 index 0000000..db1ca3e --- /dev/null +++ b/ValueView/resources/jquery.ui/jquery.ui.suggester.js @@ -0,0 +1,622 @@ +/** + * Suggester widget enhancing jquery.ui.autocomplete + * + * @licence GNU GPL v2+ + * @author H. Snater < [email protected] > + * + * jquery.ui.suggester adds a few enhancements to jquery.ui.autocomplete, e.g. adding a scrollbar + * when a certain number of items is listed in the suggestion list, highlighting matching characters + * in the suggestions and dealing with language direction. + * Specifying 'ajax.url' and 'ajax.params' parameters will trigger using a custom function to + * handle the server response (_request()). Alternatively, an array may be passed as + * source or a completely custom function - both is covered by native jquery.ui.autocomplete + * functionality. + * See jquery.ui.autocomplete for further documentation - just listing additional options here. + * + * @example $( 'input' ).suggester( { source: ['a', 'b', 'c'] } ); + * @desc Creates a simple auto-completion input element passing an array as result set. + * + * @example $( 'input' ).suggester( { + * ajax: { + * url: <url>, + * params: { <additional parameters> } + * } + * } ); + * @desc Creates an auto-completion input element fetching suggestions via AJAX. + * + * @option maxItems {Number|null} (optional) If the number of suggestions is higher than maxItems, + * the suggestion list will be made scrollable. Setting maxItems to null will automatically + * resize the suggestion list's height. + * Default value: 10 + * + * @option ajax.url {String} (optional) URL to fetch suggestions from (if these shall be queried + * via AJAX) + * Default value: null + * + * @option ajax.params {Object} (optional) Additional AJAX parameters (if suggestions shall be + * retrieved via AJAX) + * Default value: {} + * + * @option ajax.timeout {Number} (optional) AJAX timeout in milliseconds. + * Default value: 8000 + * + * @option adaptLetterCase {String|Boolean} (optional) Defines whether to adjust the letter case + * according to the suggestion list's first value whenever the suggestion list is filled. + * Possible values: false, 'first', 'all' + * Default value: false + * + * @option replace {Array} (optional) Array containing a regular expression and a replacement + * pattern (e.g. [/^File:/, '']) that is applied to each result returned by the API. + * Default value: null (no replacing) + * + * @option customListItem {Object|Boolean} (optional) A custom item appended to the suggestion list. + * Default value: false (no custom list item) + * Example: + * { + * content: 'custom item label', + * action: function( event, suggester ) { + * console.log( suggester.element.val() ); + * suggester.close(); + * } + * } + * @option customListItem.content {jQuery|String} The content of the additional list item. The + * content will be wrapped in a link node inside a list node (<li><a>content</a></li>). + * For custom styling, the css class 'ui-suggester-custom' is assigned to the <li/> node. + * @option customListItem.action {Function} The action to perform when selecting the additional + * list item. + * Parameters: (1) {jQuery.Event} Event that has triggered the custom action + * (2) {$.ui.suggester} Reference to the suggester widget + * @option customListItem.cssClass {String} (optional) Additional css class(es) to assign to the custom + * item's <li/> node. + * + * @event response Triggered when the API call returned successful. + * (1) {jQuery.Event} + * (2) {Array} List of retrieved items. + * + * @event error Triggered when the API call was not successful. + * (1) {jQuery.Event} + * (2) {String} Error text status. + * (3) {Object} Detailed error information. + * + * @dependency jquery.ui.autocomplete + */ +( function( $ ) { + 'use strict'; + + $.widget( 'ui.suggester', $.ui.autocomplete, { + + /** + * Additional options + * @type {Object} + */ + options: { + maxItems: 10, + ajax: { + url: null, + params: {}, + timeout: 8000 + }, + adaptLetterCase: false, + replace: null, + customListItem: false + }, + + /** + * Caching the last pressed key's code + * @type {Number} + */ + _lastKeyDown: null, + + /** + * @see ui.autocomplete._create + */ + _create: function() { + var self = this; + + if ( this.options.source === null ) { + this.options.source = this._request; + } + + $.ui.autocomplete.prototype._create.call( this ); + + if ( $.isArray( this.options.source ) ) { + this.source = this._filterArray; + } + + // Replace input value with active suggestion list item while hovering the list with the + // mouse cursor. + var fnNativeMenuFocus = this.menu.option( 'focus' ); + this.menu.option( 'focus', function( event, ui ) { + fnNativeMenuFocus( event, ui ); + if ( /^mouse/.test( event.originalEvent.type ) ) { + self.element.val( ui.item.data( 'item.autocomplete' ).value ); + } + } ); + + /** + * @see ui.menu.refresh + */ + this.menu.refresh = function() { + self._trigger( 'refreshmenu' ); + $.ui.menu.prototype.refresh.call( this ); + }; + + this.element + .addClass( 'ui-suggester-input' ) + .on( this.widgetName + 'open.' + this.widgetName, function( event ) { + self._updateDirection(); + self._highlightMatchingCharacters(); + } ) + .on( this.widgetName + 'refreshmenu.' + this.widgetName, function( event ) { + if ( self.options.customListItem ) { + self._renderCustomListItem( self.options.customListItem ); + } + } ) + .on( 'keydown.' + this.widgetName, function( event ) { + if ( event.keyCode === $.ui.keyCode.ENTER ) { + if ( self.menu.active ) { + var item = self.menu.active.data( 'item.autocomplete' ); + if ( item && item.isCustom && item.customAction ) { + // Custom actions are supposed to be suggester-specific. If, for some + // reason, they should interact with external components, the action(s) + // may trigger custom events. + item.customAction( event, self ); + return; + } + } + } + self._lastKeyDown = event.keyCode; + } ); + + this.menu.element.addClass( 'ui-suggester-list' ); + + // Extend menu's selected method to be able to trigger custom item's action. + var fnNativeMenuSelected = this.menu.option( 'selected' ); + this.menu.option( 'selected', function( event, ui ) { + var item = ui.item.data( 'item.autocomplete' ); + if ( !item.isCustom ) { + fnNativeMenuSelected( event, ui ); + } else if ( $.isFunction( item.customAction ) ) { + item.customAction( event, self ); + } + } ); + + // since results list does not reposition automatically on resize, just close it + // (one resize event handler is enough for all widgets) + $( window ) + .off( '.' + this.widgetName ) + .on( 'resize.' + this.widgetName, function( event ) { + if ( event.originalEvent === undefined && $( '.ui-suggester-input' ).length > 0 ) { + $( '.ui-suggester-input' ).data( self.widgetName ).close( {} ); + } + } ); + }, + + /** + * @see ui.autocomplete.destroy + */ + destroy: function() { + // about to remove the last suggester instance on the page + if ( $( '.ui-suggester-input' ).length === 1 ) { + $( window ).off( '.' + this.widgetName ); + } + this.element.off( '.' + this.widgetName ); + this.element.removeClass( 'ui-suggester-input' ); + $.ui.autocomplete.prototype.destroy.call( this ); + }, + + /** + * Disables the suggester. + */ + disable: function() { + this.close(); + this.element.prop( 'disabled', true ).addClass( 'ui-state-disabled' ); + }, + + /** + * Enables the suggester. + */ + enable: function() { + this.element.prop( 'disabled', false ).addClass( 'ui-state-disabled' ); + }, + + /** + * Filters an array passed as suggestion source. + * + * @param {Object} request + * @param {Function} response + */ + _filterArray: function( request, response ) { + var resultSet = $.ui.autocomplete.filter( this.options.source, request.term ); + + if ( resultSet.length && this.options.adaptLetterCase ) { + this.term = this._adaptLetterCase( this.term, resultSet[0] ); + this.element.val( this.term ); + } + + response( resultSet ); + }, + + /** + * Performs the AJAX request. + * + * @param request {Object} Contains request parameters + * @param suggest {Function} Callback putting results into auto-complete menu + */ + _request: function( request, suggest ) { + $.ajax( { + url: this.options.ajax.url, + dataType: 'jsonp', + data: $.extend( {}, this.options.ajax.params, { 'search': request.term } ), + timeout: this.options.ajax.timeout, + success: $.proxy( this._success, this ), + error: $.proxy( function( jqXHR, textStatus, errorThrown ) { + suggest(); + this.element.focus(); + this._trigger( 'error', $.Event(), [textStatus, errorThrown] ); + }, this ) + } ); + }, + + /** + * @see jquery.ui.autocomplete.__response + */ + __response: function( content ) { + $.ui.autocomplete.prototype.__response.call( this, content ); + // There is no content but the menu should be visible if there is a custom list item: + if ( !this.options.disabled && ( !content || !content.length ) && this.customListItem ) { + this._suggest( [] ); + this._trigger( 'open' ); + } + }, + + /** + * Handles the response when the API call returns successfully. + * + * @param {Object} response + */ + _success: function( response ) { + var suggest = this._response(); + if ( response[0] === this.element.val() ) { + + var self = this; + if ( this.options.replace !== null ) { + $.each( response[1], function( i, value ) { + response[1][i] = value.replace( self.options.replace[0], self.options.replace[1] ); + } ); + } + + // auto-complete input box text (because of the API call lag, this is + // avoided when hitting backspace, since the value would be reset too slow) + if ( this._lastKeyDown !== 8 && response[1].length > 0 ) { + this.autocompleteString( + response[0], + response[1][0] + ); + } + + suggest( response[1] ); // pass array of returned values to callback + + this._trigger( 'response', $.Event(), [response[1]] ); + } else { + // suggest nothing when the response does not match with the current input value + // informing autocomplete that there is one less pending request + suggest(); + } + }, + + /** + * @see ui.autocomplete._suggest + */ + _suggest: function( items ) { + $.ui.autocomplete.prototype._suggest.call( this, items ); + // In $.ui.autocomplete, _resizeMenu() is called before positioning the menu. However, + // resizing the menu width has to be performed after positioning since the width shall + // be constrained by the browser viewport width. + this._scaleMenu(); + }, + + /** + * Scales the menu's height to the height of maximum list items and takes care of the menu + * width not reaching out of the browser viewport. + */ + _scaleMenu: function() { + this._resetMenuStyle(); + var $menu = this.menu.element; + + if ( this.options.maxItems ) { + if ( $menu.children().length > this.options.maxItems ) { + var fixedHeight = 0; + for ( var i = 0; i < this.options.maxItems; i++ ) { + fixedHeight += $( $menu.children()[i] ).height(); + } + $menu.width( $menu.width() + this._getScrollbarWidth() ); + $menu.height( fixedHeight ); + $menu.css( 'overflowY', 'scroll' ); + } + } + + $menu.css( + 'minWidth', + this.element.outerWidth( true ) - ( $menu.outerWidth( true ) - $menu.width() ) + 'px' + ); + + $menu.width( $menu.outerWidth( true ) ); + + // menu reaches out of the browser viewport + if ( $menu.offset().left + $menu.outerWidth( true ) > $( window ).width() ) { + // force maximum menu width + $menu.width( + $( window ).width() + - $menu.offset().left + - ( $menu.outerWidth( true ) - $menu.width() ) + - 20 // safe space + ); + } + }, + + /** + * Renders a custom list item and appends it to the suggestion list. + * @see ui.autocomplete._renderItem + * + * @param {Object} customListItem Custom list item definition (see option description) + * @return {jQuery} The new list item + */ + _renderCustomListItem: function( customListItem ) { + var content = customListItem.content, + $li = $( '<li/>' ) + .addClass( 'ui-suggester-custom' ) + .data( 'item.autocomplete', { + isCustom: true, + customAction: customListItem.action, + // internal autocomplete logic needs a value (e.g. for activating) + value: this.term + } ), + $a = $( '<a/>' ).appendTo( $li ); + + if ( customListItem.cssClass ) { + $li.addClass( customListItem.cssClass ); + } + + if ( typeof content === 'string' ) { + $a.text( content ); + } else if ( content instanceof $ ) { + $a.append( content ); + } else { + throw new Error( 'suggester: Custom list item is invalid.' ); + } + + if ( this.menu.element.children( '.ui-suggester-custom' ).length > 0 ) { + // TODO: This is entity selector "more" button specific. There should be a method + // to specify a position where to add the custom list item. + return this.menu.element.children( '.ui-suggester-custom' ).first().before( $li ); + } else { + return $li.appendTo( this.menu.element ); + } + }, + + /** + * Sets (updates) or gets the custom list item. + * + * @param {Object} [customListItem] Custom list item (omit to get the current custom list + * item in the form of a jQuery node). For the object structure of this parameter see + * the customListItem option description. + * @return {jQuery|String|Boolean} The custom list item's content or false if none is + * defined + */ + customListItem: function( customListItem ) { + if ( customListItem === undefined ) { + if ( !this.options.customListItem ) { + return false; + } + var $a = this.menu.element.children( '.ui-suggester-custom a' ); + if ( typeof this.options.customListItem === 'string' ) { + return $a.text(); + } else { + return $a.children(); + } + } else { + this.options.customListItem = customListItem; + this.menu.refresh(); + return this.customListItem(); + } + }, + + /** + * Resets the menu css styles. + */ + _resetMenuStyle: function() { + this.menu.element + .css( 'minWidth', 'auto' ) + .width( 'auto' ) + .height( 'auto' ) + .css( 'overflowY', 'ellipsis' ); + }, + + /** + * Calculates the menu height (including all menu items - even those out of the viewport). + * + * @return {Number} menu height + */ + _getMenuHeight: function() { + this._resetMenuStyle(); + var height = 0; + this.menu.element.children( 'li' ).each( function( i ) { + height += $( this ).height(); + } ); + return height; + }, + + /** + * Calculates the width of the browser's scrollbar. + * + * @returns {Number} scrollbar width + */ + _getScrollbarWidth: function() { + var $inner = $( '<p/>', { style: 'width:100px;height:100px' } ), + $outer = $( '<div/>', { + style: 'position:absolute;top:-1000px;left:-1000px;visibility:hidden;' + + 'width:50px;height:50px;overflow:hidden;' + } ).append( $inner ).appendTo( $( 'body' ) ), + majorWidth = $outer[0].clientWidth; + + $outer.css( 'overflow', 'scroll' ); + var minorWidth = $outer[0].clientWidth; + $outer.remove(); + return ( majorWidth - minorWidth ); + }, + + /** + * Makes autocomplete results list stretch from the right side of the input box in rtl. + */ + _updateDirection: function() { + if ( + this.element.attr( 'dir' ) === 'rtl' || + ( + this.element.attr( 'dir' ) === undefined + && document.documentElement.dir === 'rtl' + ) + ) { + this.options.position.my = 'right top'; + this.options.position.at = 'right bottom'; + this.menu.element.position( $.extend( { + of: this.element + }, this.options.position ) ); + + // to display rtl and ltr correctly + // sometimes a rtl wiki can have ltr page names, etc. (try ".gov") + this.menu.element.children().attr( { + 'dir': 'auto' + } ); + } + }, + + /** + * Highlights matching characters in the result list. + */ + _highlightMatchingCharacters: function() { + var term = ( this.term ) ? this.term : '', + escapedTerm = $.ui.autocomplete.escapeRegex( term ), + regExp = new RegExp( + '((?:(?!' + escapedTerm +').)*?)(' + escapedTerm + ')(.*)', '' + ); + + this.menu.element.children( '.ui-menu-item' ).each( function() { + if ( !$( this ).data( 'item.autocomplete' ).isCustom ) { + var $itemLink = $( this ).find( 'a' ); + + // only replace if suggestions actually starts with the current input + if ( $itemLink.text().indexOf( term ) === 0 ) { + var matches = $itemLink.text().match( regExp ); + + $itemLink + .text( matches[1] ) + .append( $( '<b/>' ).text( matches[2] ) ) + .append( document.createTextNode( matches[3] ) ); + } + } + } ); + }, + + /** + * Adjusts the letter case of a source string to the letter case in a destination string + * according to the adaptLetterCase option. + * + * @param {String} source + * @param {String} destination + * @return {String} Altered source string + */ + _adaptLetterCase: function( source, destination ) { + if ( this.options.adaptLetterCase === 'all' ) { + return destination.substr( 0, source.length ); + } else if ( this.options.adaptLetterCase === 'first' ) { + return destination.substr( 0, 1 ) + source.substr( 1 ); + } else { + return source; + } + }, + + /** + * Sets/gets the plain input box value. + * + * @param {String} [value] Value to be set + * @return {String} value Current/new value + */ + value: function( value ) { + if ( value !== undefined ) { + this.element.val( value ); + } + return this.element.val(); + }, + + /** + * Resets/updates the menu position. + */ + repositionMenu: function() { + this.menu.element.position( $.extend( { + of: this.element + }, this.options.position ) ); + }, + + /** + * Completes the input box with the remaining characters of a given string. The characters + * of the remaining part are text-highlighted, so the will be overwritten if typing + * characters is continue. Tabbing or clicking outside of the input box will leave the + * completed string in the input box. + * + * @param incomplete {String} + * @param complete {String} + * @return {Number} number of characters added (and highlighted) at the end of the + * incomplete string + */ + autocompleteString: function( incomplete, complete ) { + if( + // if nothing to complete, just return and don't move the cursor + // (can be annoying in this situation) + incomplete === complete + // The following statement is a work-around for a technically unexpected search + // behaviour: e.g. in English Wikipedia opensearch for "Allegro [...]" returns + // "Allegro" as first result instead of "Allegro (music)", so auto-completion should + // probably be prevented here since it would always reset the input box's value to + // "Allegro" + || complete.toLowerCase().indexOf( this.element.val().toLowerCase() ) === -1 + ) { + return 0; + } + + // set value to complete value... + if ( this.options.adaptLetterCase ) { + this.term = this._adaptLetterCase( incomplete, complete ); + if ( complete.indexOf( this.term ) === 0 ) { + this.element.val( complete ); + } + } else if ( incomplete === complete.substr( 0, incomplete.length ) ) { + this.element.val( incomplete + complete.substr( incomplete.length ) ); + } + + // ... and select the suggested, not manually typed part of the value + var start = incomplete.length, + end = complete.length, + node = this.element[0]; + + // highlighting takes some browser specific implementation + if( node.createTextRange ) { // opera < 10.5 and IE + var selRange = node.createTextRange(); + selRange.collapse( true ); + selRange.moveStart( 'character', start); + selRange.moveEnd( 'character', end); + selRange.select(); + } else if( node.setSelectionRange ) { // major modern browsers + // make a 'backward' selection so pressing arrow left won't put the cursor near the + // selections end but rather at the typing position + node.setSelectionRange( start, end, 'backward' ); + } else if( node.selectionStart ) { + node.selectionStart = start; + node.selectionEnd = end; + } + return ( end - start ); + } + + } ); + +} )( jQuery ); diff --git a/ValueView/tests/qunit/jquery.ui/jquery.ui.suggester.tests.js b/ValueView/tests/qunit/jquery.ui/jquery.ui.suggester.tests.js new file mode 100644 index 0000000..f8f4885 --- /dev/null +++ b/ValueView/tests/qunit/jquery.ui/jquery.ui.suggester.tests.js @@ -0,0 +1,300 @@ +/** + * QUnit tests for suggester jQuery widget + * + * @since 0.1 + * @file + * @ingroup ValueView + * + * @licence GNU GPL v2+ + * @author H. Snater < [email protected] > + */ + +( function( $, QUnit ) { + 'use strict'; + + /** + * Factory for creating a jquery.ui.suggester widget suitable for testing. + * + * @param {Object} [options] + * default: { source: [ 'a', 'ab', 'abc', 'd' ], maxItems: 4 } + */ + var newTestSuggester = function( options ) { + options = options || { + maxItems: 4 // will be used in test 'automatic height adjustment' + }; + if ( !options.source ) { + options.source = [ + 'a', + 'ab', + 'abc', + 'd', + 'EFG' + ]; + } + + // element needs to be in the DOM for setting text selection range + var $input = $( '<input/>' ).addClass( 'test_suggester').appendTo( 'body' ).suggester( options ); + + /** + * Shorthand function to reopen the menu by searching for a string that will produce at + * least one suggestion. + * + * @param {String} [search] + * default: 'a' + */ + $input.data( 'suggester' ).reopenMenu = function( search ) { + search = search || 'a'; + this.close(); + this.search( search ); + }; + + return $input; + }; + + QUnit.module( 'jquery.ui.suggester', QUnit.newMwEnvironment( { + teardown: function() { + $( '.test_suggester' ).remove(); + } + } ) ); + + QUnit.test( 'basic tests', function( assert ) { + var $input = newTestSuggester(); + var suggester = $input.data( 'suggester' ); + + $input.on( 'suggesterresponse', function( event, items ) { + assert.deepEqual( + items, + ['ab', 'abc'], + 'Fired response event passing result set.' + ); + } ); + $input.val( 'a' ); + suggester._success( ['a', ['ab', 'abc']] ); + $input.off( 'suggesterresponse' ); + + assert.equal( + $input.val(), + 'ab', + 'Replaced input element\'s value with first result (remaining part of the string is highlighted).' + ); + + suggester.close(); + + $input.val( 'a' ); + suggester.search( 'a' ); // trigger opening menu + + assert.equal( + suggester.menu.element.find( 'a' ).children( 'b' ).length, + 3, + 'Highlighted matching characters within the suggestions.' + ); + + var fullHeight = suggester.menu.element.height(); // height of all found items + suggester.options.maxItems = 2; + suggester.search( 'a' ); + + assert.ok( + fullHeight > suggester.menu.element.height(), + 'Suggestion menu gets resized.' + ); + + assert.ok( + suggester._getScrollbarWidth() > 0, + 'Detected scrollbar width.' + ); + + // Firefox will throw an error when the input element is not part of the DOM while trying to + // set the selection range which is part of the following assertion + $( 'body' ).append( $input ); + assert.equal( + suggester.autocompleteString( $input.val(), 'ab' ), + 1, + 'Auto-completed text.' + ); + + suggester.destroy(); + $input.remove(); + + } ); + + QUnit.test( 'Adapt letter case', function( assert ) { + var $input = newTestSuggester(); + var suggester = $input.data( 'suggester' ); + + assert.equal( + suggester._adaptLetterCase( 'abc', 'AbC' ), + 'abc', + "adaptLetterCase: Did not adapt any letter case." + ); + + $input.val( 'ef' ); + suggester.search( 'EF' ); // simulate case-insensitive search + + assert.equal( + $input.val(), + 'ef', + "Did not adjusted input value's letter case according to suggestion list's first result set." + ); + + suggester.destroy(); + $input.remove(); + + $input = newTestSuggester( { adaptLetterCase: 'all' } ); + suggester = $input.data( 'suggester' ); + + assert.equal( + suggester._adaptLetterCase( 'abc', 'AbC' ), + 'AbC', + "adjustLetterCase: Adapted the case of all letters." + ); + + $input.val( 'ef' ); + suggester.search( 'EF' ); + + assert.equal( + $input.val(), + 'EF', + "Adjusted input value's letter case according to suggestion list's first result set." + ); + + suggester.destroy(); + $input.remove(); + + $input = newTestSuggester( { adaptLetterCase: 'first' } ); + suggester = $input.data( 'suggester' ); + + assert.equal( + suggester._adaptLetterCase( 'abc', 'AbC' ), + 'Abc', + "adaptLetterCase: Adapted the case of the first letter." + ); + + $input.val( 'ef' ); + suggester.search( 'EF' ); + + assert.equal( + $input.val(), + 'Ef', + "Capitalized input value's letters according to suggestion list's first result set." + ); + + suggester.destroy(); + $input.remove(); + } ); + + QUnit.test( 'automatic height adjustment', function( assert ) { + var $input = newTestSuggester(); + var suggester = $input.data( 'suggester' ); + + var additionalResults = [ + 'a1', + 'a2', + 'a3' + ]; + + suggester.search( 'a' ); + + var initHeight = suggester.menu.element.height(); + suggester.options.source.push( additionalResults[0] ); + suggester.reopenMenu(); + + // testing (MAX_ITEMS - 1)++ + assert.ok( + suggester.menu.element.height() > initHeight, + 'height changed after adding another item to result set' + ); + + // adding one more item (MAX_ITEMS + 1) first, since there might be side effects adding the scrollbar + suggester.options.source.push( additionalResults[1] ); + suggester.reopenMenu(); + initHeight = suggester.menu.element.height(); + + suggester.options.source.push( additionalResults[2] ); + suggester.reopenMenu(); + + // testing (MAX_ITEMS + 1)++ + assert.equal( + suggester.menu.element.height(), + initHeight, + 'height unchanged after adding more than maximum items' + ); + + suggester.destroy(); + $input.remove(); + + } ); + + QUnit.test( 'Custom list item', function( assert ) { + var $input = newTestSuggester( { + customListItem: { content: 'plain text' } + } ), + suggester = $input.data( 'suggester' ), + check = false; + + suggester.search( 'd' ); + + assert.equal( + suggester.menu.element.children( '.ui-menu-item' ).length, + 2, + 'Appended custom list item.' + ); + + assert.equal( + suggester.menu.element.children( '.ui-menu-item' ).last().children( 'a' ).text(), + 'plain text', + 'Set plain text on custom list item.' + ); + + suggester.customListItem( { + content: $( '<span/>' ).text( 'jQuery node' ), + action: function( suggester ) { + check = true; + } + } ); + + suggester.search( 'a' ); + + assert.equal( + suggester.menu.element.find( '.ui-menu-item a span' ).length, + 1, + 'Attached jQuery node as custom list item.' + ); + + suggester.menu.element.find( '.ui-menu-item a' ).last().mouseenter(); + suggester.menu.element.find( '.ui-menu-item a' ).last().click(); + + assert.ok( + check, + 'Issued custom list item action via click event.' + ); + + check = false; + + suggester.search( 'a' ); + + assert.equal( + suggester.menu.element.find( '.ui-menu-item a span' ).length, + 1, + 'Attached jQuery node as custom list item.' + ); + + var keydownEvent = $.Event( 'keydown' ); + + // move up to select custom list item which is the last one in the list + keydownEvent.keyCode = $.ui.keyCode.UP; + suggester.element.trigger( keydownEvent ); + + // simulate hitting enter key + keydownEvent.keyCode = $.ui.keyCode.ENTER; + suggester.element.trigger( keydownEvent ); + + assert.ok( + check, + 'Issued custom list item action via keydown event.' + ); + + suggester.destroy(); + $input.remove(); + } ); + +}( jQuery, QUnit ) ); -- To view, visit https://gerrit.wikimedia.org/r/57449 To unsubscribe, visit https://gerrit.wikimedia.org/r/settings Gerrit-MessageType: newchange Gerrit-Change-Id: I0e7eeee9d94c1dd68fb6af7ff63ef30062595ea8 Gerrit-PatchSet: 1 Gerrit-Project: mediawiki/extensions/DataValues Gerrit-Branch: master Gerrit-Owner: Daniel Werner <[email protected]> _______________________________________________ MediaWiki-commits mailing list [email protected] https://lists.wikimedia.org/mailman/listinfo/mediawiki-commits
