mediawiki.widgets: Create TitleSearchWidget
authorEd Sanders <esanders@wikimedia.org>
Fri, 25 Sep 2015 20:39:01 +0000 (21:39 +0100)
committerEd Sanders <esanders@wikimedia.org>
Fri, 25 Sep 2015 22:56:32 +0000 (23:56 +0100)
TitleSearchWidget inherits from SearchWidget instead of TextInputWidget,
showing search results even when blurred.

To avoid duplication, much of the logic from TitleInputWidget has been
moved into the TitleWidget mixin.

Bug: T101169
Change-Id: I335bd912d4f5139646ba05a6a85a0d9ff3d772fa

resources/Resources.php
resources/src/mediawiki.widgets/mw.widgets.TitleInputWidget.css [deleted file]
resources/src/mediawiki.widgets/mw.widgets.TitleInputWidget.js
resources/src/mediawiki.widgets/mw.widgets.TitleOptionWidget.js
resources/src/mediawiki.widgets/mw.widgets.TitleSearchWidget.js [new file with mode: 0644]
resources/src/mediawiki.widgets/mw.widgets.TitleWidget.js [new file with mode: 0644]
resources/src/mediawiki.widgets/mw.widgets.TitleWidget.less [new file with mode: 0644]

index b35ebad..6aeb206 100644 (file)
@@ -1893,7 +1893,9 @@ return array(
                        'resources/src/mediawiki.widgets/mw.widgets.DateInputWidget.js',
                        'resources/src/mediawiki.widgets/mw.widgets.NamespaceInputWidget.js',
                        'resources/src/mediawiki.widgets/mw.widgets.ComplexNamespaceInputWidget.js',
+                       'resources/src/mediawiki.widgets/mw.widgets.TitleWidget.js',
                        'resources/src/mediawiki.widgets/mw.widgets.TitleInputWidget.js',
+                       'resources/src/mediawiki.widgets/mw.widgets.TitleSearchWidget.js',
                        'resources/src/mediawiki.widgets/mw.widgets.ComplexTitleInputWidget.js',
                        'resources/src/mediawiki.widgets/mw.widgets.TitleOptionWidget.js',
                        'resources/src/mediawiki.widgets/mw.widgets.UserInputWidget.js',
@@ -1903,7 +1905,7 @@ return array(
                        'default' => array(
                                'resources/src/mediawiki.widgets/mw.widgets.CalendarWidget.less',
                                'resources/src/mediawiki.widgets/mw.widgets.DateInputWidget.less',
-                               'resources/src/mediawiki.widgets/mw.widgets.TitleInputWidget.css',
+                               'resources/src/mediawiki.widgets/mw.widgets.TitleWidget.less',
                        ),
                ),
                'dependencies' => array(
diff --git a/resources/src/mediawiki.widgets/mw.widgets.TitleInputWidget.css b/resources/src/mediawiki.widgets/mw.widgets.TitleInputWidget.css
deleted file mode 100644 (file)
index 2c24b2b..0000000
+++ /dev/null
@@ -1,57 +0,0 @@
-/*!
- * MediaWiki Widgets - TitleInputWidget styles.
- *
- * @copyright 2011-2015 MediaWiki Widgets Team and others; see AUTHORS.txt
- * @license The MIT License (MIT); see LICENSE.txt
- */
-
-.mw-widget-titleInputWidget-menu-withImages .mw-widget-titleOptionWidget {
-       -webkit-box-sizing: border-box;
-       -moz-box-sizing: border-box;
-       box-sizing: border-box;
-       min-height: 3.75em;
-       margin-left: 3.75em;
-}
-
-.mw-widget-titleInputWidget-menu-withImages .mw-widget-titleOptionWidget:not(:last-child) {
-       margin-bottom: 1px;
-}
-
-.mw-widget-titleInputWidget-menu-withImages .oo-ui-iconElement .oo-ui-iconElement-icon {
-       display: block;
-       width: 3.75em;
-       height: 3.75em;
-       left: -3.75em;
-       background-color: #ccc;
-       opacity: 0.4;
-}
-
-.mw-widget-titleInputWidget-menu-withImages .oo-ui-iconElement .mw-widget-titleOptionWidget-hasImage {
-       border: 0;
-       background-size: cover;
-       opacity: 1;
-}
-
-.mw-widget-titleInputWidget-menu-withImages .mw-widget-titleOptionWidget .oo-ui-labelElement-label {
-       line-height: 2.8em;
-}
-
-.mw-widget-titleOptionWidget-description {
-       display: none;
-}
-
-.mw-widget-titleInputWidget-menu-withDescriptions .mw-widget-titleOptionWidget .oo-ui-labelElement-label {
-       line-height: 1.5em;
-}
-
-.mw-widget-titleInputWidget-menu-withDescriptions .mw-widget-titleOptionWidget-description {
-       display: block;
-       white-space: nowrap;
-       text-overflow: ellipsis;
-       overflow: hidden;
-}
-
-.oo-ui-menuOptionWidget:not(.oo-ui-optionWidget-selected) .mw-widget-titleOptionWidget-description,
-.oo-ui-menuOptionWidget.oo-ui-optionWidget-highlighted .mw-widget-titleOptionWidget-description {
-       color: #888;
-}
index d5a7abc..fc1007e 100644 (file)
         *
         * @class
         * @extends OO.ui.TextInputWidget
+        * @mixins mw.widgets.TitleWidget
         * @mixins OO.ui.mixin.LookupElement
         *
         * @constructor
-        * @param {Object} [config] Configuration options
-        * @cfg {number} [limit=10] Number of results to show
-        * @cfg {number} [namespace] Namespace to prepend to queries
-        * @cfg {boolean} [relative=true] If a namespace is set, return a title relative to it
         * @cfg {boolean} [suggestions=true] Display search suggestions
-        * @cfg {boolean} [showRedirectTargets=true] Show the targets of redirects
-        * @cfg {boolean} [showRedlink] Show red link to exact match if it doesn't exist
-        * @cfg {boolean} [showImages] Show page images
-        * @cfg {boolean} [showDescriptions] Show page descriptions
-        * @cfg {Object} [cache] Result cache which implements a 'set' method, taking keyed values as an argument
         */
        mw.widgets.TitleInputWidget = function MwWidgetsTitleInputWidget( config ) {
-               var widget = this;
-
-               // Config initialization
-               config = $.extend( {
-                       maxLength: 255,
-                       limit: 10
-               }, config );
+               config = config || {};
 
                // Parent constructor
-               mw.widgets.TitleInputWidget.parent.call( this, $.extend( {}, config, { autocomplete: false } ) );
+               mw.widgets.TitleInputWidget.parent.call( this, $.extend( {}, config, {
+                       validate: this.isQueryValid.bind( this ),
+                       autocomplete: false
+               } ) );
 
                // Mixin constructors
+               mw.widgets.TitleWidget.call( this, config );
                OO.ui.mixin.LookupElement.call( this, config );
 
                // Properties
-               this.limit = config.limit;
-               this.maxLength = config.maxLength;
-               this.namespace = config.namespace !== undefined ? config.namespace : null;
-               this.relative = config.relative !== undefined ? config.relative : true;
                this.suggestions = config.suggestions !== undefined ? config.suggestions : true;
-               this.showRedirectTargets = config.showRedirectTargets !== false;
-               this.showRedlink = !!config.showRedlink;
-               this.showImages = !!config.showImages;
-               this.showDescriptions = !!config.showDescriptions;
-               this.cache = config.cache;
 
                // Initialization
                this.$element.addClass( 'mw-widget-titleInputWidget' );
-               this.lookupMenu.$element.addClass( 'mw-widget-titleInputWidget-menu' );
+               this.lookupMenu.$element.addClass( 'mw-widget-titleWidget-menu' );
                if ( this.showImages ) {
-                       this.lookupMenu.$element.addClass( 'mw-widget-titleInputWidget-menu-withImages' );
+                       this.lookupMenu.$element.addClass( 'mw-widget-titleWidget-menu-withImages' );
                }
                if ( this.showDescriptions ) {
-                       this.lookupMenu.$element.addClass( 'mw-widget-titleInputWidget-menu-withDescriptions' );
+                       this.lookupMenu.$element.addClass( 'mw-widget-titleWidget-menu-withDescriptions' );
                }
                this.setLookupsDisabled( !this.suggestions );
-
-               this.interwikiPrefixes = [];
-               this.interwikiPrefixesPromise = new mw.Api().get( {
-                       action: 'query',
-                       meta: 'siteinfo',
-                       siprop: 'interwikimap'
-               } ).done( function ( data ) {
-                       $.each( data.query.interwikimap, function ( index, interwiki ) {
-                               widget.interwikiPrefixes.push( interwiki.prefix );
-                       } );
-               } );
        };
 
        /* Setup */
 
        OO.inheritClass( mw.widgets.TitleInputWidget, OO.ui.TextInputWidget );
+       OO.mixinClass( mw.widgets.TitleInputWidget, mw.widgets.TitleWidget );
        OO.mixinClass( mw.widgets.TitleInputWidget, OO.ui.mixin.LookupElement );
 
        /* Methods */
 
        /**
-        * Get the namespace to prepend to titles in suggestions, if any.
-        *
-        * @return {number|null} Namespace number
+        * @inheritdoc mw.widgets.TitleWidget
         */
-       mw.widgets.TitleInputWidget.prototype.getNamespace = function () {
-               return this.namespace;
+       mw.widgets.TitleInputWidget.prototype.getQueryValue = function () {
+               return this.getValue();
        };
 
        /**
-        * Set the namespace to prepend to titles in suggestions, if any.
-        *
-        * @param {number|null} namespace Namespace number
+        * @inheritdoc mw.widgets.TitleWidget
         */
        mw.widgets.TitleInputWidget.prototype.setNamespace = function ( namespace ) {
-               this.namespace = namespace;
+               // Mixin method
+               mw.widgets.TitleWidget.prototype.setNamespace.call( this, namespace );
+
                this.lookupCache = {};
                this.closeLookupMenu();
        };
 
+       /**
+        * @inheritdoc
+        */
+       mw.widgets.TitleInputWidget.prototype.getLookupRequest = function () {
+               return this.getSuggestionsPromise();
+       };
+
+       /**
+        * @inheritdoc OO.ui.mixin.LookupElement
+        */
+       mw.widgets.TitleInputWidget.prototype.getLookupCacheDataFromResponse = function ( response ) {
+               return response.query || {};
+       };
+
+       /**
+        * @inheritdoc OO.ui.mixin.LookupElement
+        */
+       mw.widgets.TitleInputWidget.prototype.getLookupMenuOptionsFromData = function ( response ) {
+               return this.getOptionsFromData( response );
+       };
+
        /**
         * @inheritdoc
         */
                return retval;
        };
 
-       /**
-        * @inheritdoc
-        */
-       mw.widgets.TitleInputWidget.prototype.getLookupRequest = function () {
-               var req,
-                       widget = this,
-                       promiseAbortObject = { abort: function () {
-                               // Do nothing. This is just so OOUI doesn't break due to abort being undefined.
-                       } };
-
-               if ( mw.Title.newFromText( this.value ) ) {
-                       return this.interwikiPrefixesPromise.then( function () {
-                               var params, props,
-                                       interwiki = widget.value.substring( 0, widget.value.indexOf( ':' ) );
-                               if (
-                                       interwiki && interwiki !== '' &&
-                                       widget.interwikiPrefixes.indexOf( interwiki ) !== -1
-                               ) {
-                                       return $.Deferred().resolve( { query: {
-                                               pages: [ {
-                                                       title: widget.value
-                                               } ]
-                                       } } ).promise( promiseAbortObject );
-                               } else {
-                                       params = {
-                                               action: 'query',
-                                               generator: 'prefixsearch',
-                                               gpssearch: widget.value,
-                                               gpsnamespace: widget.namespace !== null ? widget.namespace : undefined,
-                                               gpslimit: widget.limit,
-                                               ppprop: 'disambiguation'
-                                       };
-                                       props = [ 'info', 'pageprops' ];
-                                       if ( widget.showRedirectTargets ) {
-                                               params.redirects = '1';
-                                       }
-                                       if ( widget.showImages ) {
-                                               props.push( 'pageimages' );
-                                               params.pithumbsize = 80;
-                                               params.pilimit = widget.limit;
-                                       }
-                                       if ( widget.showDescriptions ) {
-                                               props.push( 'pageterms' );
-                                               params.wbptterms = 'description';
-                                       }
-                                       params.prop = props.join( '|' );
-                                       req = new mw.Api().get( params );
-                                       promiseAbortObject.abort = req.abort.bind( req ); // todo: ew
-                                       return req;
-                               }
-                       } ).promise( promiseAbortObject );
-               } else {
-                       // Don't send invalid titles to the API.
-                       // Just pretend it returned nothing so we can show the 'invalid title' section
-                       return $.Deferred().resolve( {} ).promise( promiseAbortObject );
-               }
-       };
-
-       /**
-        * Get lookup cache item from server response data.
-        *
-        * @method
-        * @param {Mixed} response Response from server
-        */
-       mw.widgets.TitleInputWidget.prototype.getLookupCacheDataFromResponse = function ( response ) {
-               return response.query || {};
-       };
-
-       /**
-        * Get list of menu items from a server response.
-        *
-        * @param {Object} data Query result
-        * @returns {OO.ui.MenuOptionWidget[]} Menu items
-        */
-       mw.widgets.TitleInputWidget.prototype.getLookupMenuOptionsFromData = function ( data ) {
-               var i, len, index, pageExists, pageExistsExact, suggestionPage, page, redirect, redirects,
-                       items = [],
-                       titles = [],
-                       titleObj = mw.Title.newFromText( this.value ),
-                       redirectsTo = {},
-                       pageData = {};
-
-               if ( data.redirects ) {
-                       for ( i = 0, len = data.redirects.length; i < len; i++ ) {
-                               redirect = data.redirects[ i ];
-                               redirectsTo[ redirect.to ] = redirectsTo[ redirect.to ] || [];
-                               redirectsTo[ redirect.to ].push( redirect.from );
-                       }
-               }
-
-               for ( index in data.pages ) {
-                       suggestionPage = data.pages[ index ];
-                       pageData[ suggestionPage.title ] = {
-                               missing: suggestionPage.missing !== undefined,
-                               redirect: suggestionPage.redirect !== undefined,
-                               disambiguation: OO.getProp( suggestionPage, 'pageprops', 'disambiguation' ) !== undefined,
-                               imageUrl: OO.getProp( suggestionPage, 'thumbnail', 'source' ),
-                               description: OO.getProp( suggestionPage, 'terms', 'description' )
-                       };
-
-                       // Throw away pages from wrong namespaces. This can happen when 'showRedirectTargets' is true
-                       // and we encounter a cross-namespace redirect.
-                       if ( this.namespace === null || this.namespace === suggestionPage.ns ) {
-                               titles.push( suggestionPage.title );
-                       }
-
-                       redirects = redirectsTo[ suggestionPage.title ] || [];
-                       for ( i = 0, len = redirects.length; i < len; i++ ) {
-                               pageData[ redirects[ i ] ] = {
-                                       missing: false,
-                                       redirect: true,
-                                       disambiguation: false,
-                                       description: mw.msg( 'mw-widgets-titleinput-description-redirect', suggestionPage.title )
-                               };
-                               titles.push( redirects[ i ] );
-                       }
-               }
-
-               // If not found, run value through mw.Title to avoid treating a match as a
-               // mismatch where normalisation would make them matching (bug 48476)
-
-               pageExistsExact = titles.indexOf( this.value ) !== -1;
-               pageExists = pageExistsExact || (
-                       titleObj && titles.indexOf( titleObj.getPrefixedText() ) !== -1
-               );
-
-               if ( !pageExists ) {
-                       pageData[ this.value ] = {
-                               missing: true, redirect: false, disambiguation: false,
-                               description: mw.msg( 'mw-widgets-titleinput-description-new-page' )
-                       };
-               }
-
-               if ( this.cache ) {
-                       this.cache.set( pageData );
-               }
-
-               // Offer the exact text as a suggestion if the page exists
-               if ( pageExists && !pageExistsExact ) {
-                       titles.unshift( this.value );
-               }
-               // Offer the exact text as a new page if the title is valid
-               if ( this.showRedlink && !pageExists && titleObj ) {
-                       titles.push( this.value );
-               }
-               for ( i = 0, len = titles.length; i < len; i++ ) {
-                       page = pageData[ titles[ i ] ] || {};
-                       items.push( new mw.widgets.TitleOptionWidget( this.getOptionWidgetData( titles[ i ], page ) ) );
-               }
-
-               return items;
-       };
-
-       /**
-        * Get menu option widget data from the title and page data
-        *
-        * @param {mw.Title} title Title object
-        * @param {Object} data Page data
-        * @return {Object} Data for option widget
-        */
-       mw.widgets.TitleInputWidget.prototype.getOptionWidgetData = function ( title, data ) {
-               var mwTitle = new mw.Title( title );
-               return {
-                       data: this.namespace !== null && this.relative
-                               ? mwTitle.getRelativeText( this.namespace )
-                               : title,
-                       title: mwTitle,
-                       imageUrl: this.showImages ? data.imageUrl : null,
-                       description: this.showDescriptions ? data.description : null,
-                       missing: data.missing,
-                       redirect: data.redirect,
-                       disambiguation: data.disambiguation,
-                       query: this.value
-               };
-       };
-
-       /**
-        * Get title object corresponding to given value, or #getValue if not given.
-        *
-        * @param {string} [value] Value to get a title for
-        * @returns {mw.Title|null} Title object, or null if value is invalid
-        */
-       mw.widgets.TitleInputWidget.prototype.getTitle = function ( value ) {
-               var title = value !== undefined ? value : this.getValue(),
-                       // mw.Title doesn't handle null well
-                       titleObj = mw.Title.newFromText( title, this.namespace !== null ? this.namespace : undefined );
-
-               return titleObj;
-       };
-
        /**
         * @inheritdoc
         */
        mw.widgets.TitleInputWidget.prototype.cleanUpValue = function ( value ) {
                var widget = this;
+
+               // Parent method
                value = mw.widgets.TitleInputWidget.parent.prototype.cleanUpValue.call( this, value );
+
                return $.trimByteLength( this.value, value, this.maxLength, function ( value ) {
                        var title = widget.getTitle( value );
                        return title ? title.getMain() : value;
                } ).newVal;
        };
 
-       /**
-        * @inheritdoc
-        */
-       mw.widgets.TitleInputWidget.prototype.isValid = function () {
-               return $.Deferred().resolve( !!this.getTitle() ).promise();
-       };
-
 }( jQuery, mediaWiki ) );
index ec0c935..1154c9f 100644 (file)
                                        .text( config.description )
                        );
                }
+
+               // Events
+               this.$link.on( 'click', function () {
+                       return false;
+               } );
        };
 
        /* Setup */
diff --git a/resources/src/mediawiki.widgets/mw.widgets.TitleSearchWidget.js b/resources/src/mediawiki.widgets/mw.widgets.TitleSearchWidget.js
new file mode 100644 (file)
index 0000000..0e2546f
--- /dev/null
@@ -0,0 +1,83 @@
+/*!
+ * MediaWiki Widgets - TitleSearchWidget class.
+ *
+ * @copyright 2011-2015 MediaWiki Widgets Team and others; see AUTHORS.txt
+ * @license The MIT License (MIT); see LICENSE.txt
+ */
+( function ( $, mw ) {
+
+       /**
+        * Creates an mw.widgets.TitleSearchWidget object.
+        *
+        * @class
+        * @extends OO.ui.SearchWidget
+        * @mixins mw.widgets.TitleWidget
+        *
+        * @constructor
+        */
+       mw.widgets.TitleSearchWidget = function MwWidgetsTitleSearchWidget( config ) {
+               config = config || {};
+
+               // Parent constructor
+               mw.widgets.TitleSearchWidget.parent.call( this, config );
+
+               // Mixin constructors
+               mw.widgets.TitleWidget.call( this, config );
+
+               this.query.setValidation( this.isQueryValid.bind( this ) );
+
+               // Events
+               this.results.connect( this, { choose: 'onTitleSearchResultsChoose' } );
+
+               // Initialization
+               this.$element.addClass( 'mw-widget-titleSearchWidget' );
+               this.results.$element.addClass( 'mw-widget-titleWidget-menu' );
+               if ( this.showImages ) {
+                       this.results.$element.addClass( 'mw-widget-titleWidget-menu-withImages' );
+               }
+               if ( this.showDescriptions ) {
+                       this.results.$element.addClass( 'mw-widget-titleWidget-menu-withDescriptions' );
+               }
+               if ( this.maxLength !== undefined ) {
+                       this.getQuery().$input.attr( 'maxlength', this.maxLength );
+               }
+       };
+
+       /* Setup */
+
+       OO.inheritClass( mw.widgets.TitleSearchWidget, OO.ui.SearchWidget );
+       OO.mixinClass( mw.widgets.TitleSearchWidget, mw.widgets.TitleWidget );
+
+       /* Methods */
+
+       /**
+        * @inheritdoc mw.widgets.TitleWidget
+        */
+       mw.widgets.TitleSearchWidget.prototype.getQueryValue = function () {
+               return this.getQuery().getValue();
+       };
+
+       /**
+        * Handle choose events from the result widget
+        *
+        * @param {OO.ui.OptionWidget} item Chosen item
+        */
+       mw.widgets.TitleSearchWidget.prototype.onTitleSearchResultsChoose = function ( item ) {
+               this.getQuery().setValue( item.getData() );
+       };
+
+       /**
+        * @inheritdoc
+        */
+       mw.widgets.TitleSearchWidget.prototype.onQueryChange = function () {
+               var widget = this;
+
+               this.getSuggestionsPromise().done( function ( response ) {
+                       // Parent method
+                       mw.widgets.TitleSearchWidget.parent.prototype.onQueryChange.call( widget );
+
+                       widget.results.addItems( widget.getOptionsFromData( response.query || {} ) );
+               } );
+       };
+
+}( jQuery, mediaWiki ) );
diff --git a/resources/src/mediawiki.widgets/mw.widgets.TitleWidget.js b/resources/src/mediawiki.widgets/mw.widgets.TitleWidget.js
new file mode 100644 (file)
index 0000000..f51c559
--- /dev/null
@@ -0,0 +1,285 @@
+/*!
+ * MediaWiki Widgets - TitleWidget class.
+ *
+ * @copyright 2011-2015 MediaWiki Widgets Team and others; see AUTHORS.txt
+ * @license The MIT License (MIT); see LICENSE.txt
+ */
+( function ( $, mw ) {
+
+       /**
+        * Mixin for title widgets
+        *
+        * @class
+        * @abstract
+        *
+        * @constructor
+        * @param {Object} [config] Configuration options
+        * @cfg {number} [limit=10] Number of results to show
+        * @cfg {number} [namespace] Namespace to prepend to queries
+        * @cfg {number} [maxLength=255] Maximum query length
+        * @cfg {boolean} [relative=true] If a namespace is set, return a title relative to it
+        * @cfg {boolean} [suggestions=true] Display search suggestions
+        * @cfg {boolean} [showRedirectTargets=true] Show the targets of redirects
+        * @cfg {boolean} [showRedlink] Show red link to exact match if it doesn't exist
+        * @cfg {boolean} [showImages] Show page images
+        * @cfg {boolean} [showDescriptions] Show page descriptions
+        * @cfg {Object} [cache] Result cache which implements a 'set' method, taking keyed values as an argument
+        */
+       mw.widgets.TitleWidget = function MwWidgetsTitleWidget( config ) {
+               var widget = this;
+
+               // Config initialization
+               config = $.extend( {
+                       maxLength: 255,
+                       limit: 10
+               }, config );
+
+               // Properties
+               this.limit = config.limit;
+               this.maxLength = config.maxLength;
+               this.namespace = config.namespace !== undefined ? config.namespace : null;
+               this.relative = config.relative !== undefined ? config.relative : true;
+               this.suggestions = config.suggestions !== undefined ? config.suggestions : true;
+               this.showRedirectTargets = config.showRedirectTargets !== false;
+               this.showRedlink = !!config.showRedlink;
+               this.showImages = !!config.showImages;
+               this.showDescriptions = !!config.showDescriptions;
+               this.cache = config.cache;
+
+               // Initialization
+               this.interwikiPrefixes = [];
+               this.interwikiPrefixesPromise = new mw.Api().get( {
+                       action: 'query',
+                       meta: 'siteinfo',
+                       siprop: 'interwikimap'
+               } ).done( function ( data ) {
+                       $.each( data.query.interwikimap, function ( index, interwiki ) {
+                               widget.interwikiPrefixes.push( interwiki.prefix );
+                       } );
+               } );
+       };
+
+       /* Setup */
+
+       OO.initClass( mw.widgets.TitleWidget );
+
+       /* Methods */
+
+       /**
+        * Get the current value of the search query
+        *
+        * @abstract
+        * @return {string} Search query
+        */
+       mw.widgets.TitleWidget.prototype.getQueryValue = null;
+
+       /**
+        * Get the namespace to prepend to titles in suggestions, if any.
+        *
+        * @return {number|null} Namespace number
+        */
+       mw.widgets.TitleWidget.prototype.getNamespace = function () {
+               return this.namespace;
+       };
+
+       /**
+        * Set the namespace to prepend to titles in suggestions, if any.
+        *
+        * @param {number|null} namespace Namespace number
+        */
+       mw.widgets.TitleWidget.prototype.setNamespace = function ( namespace ) {
+               this.namespace = namespace;
+       };
+
+       /**
+        * Get a promise which resolves with an API repsonse for suggested
+        * links for the current query.
+        */
+       mw.widgets.TitleWidget.prototype.getSuggestionsPromise = function () {
+               var req,
+                       query = this.getQueryValue(),
+                       widget = this,
+                       promiseAbortObject = { abort: function () {
+                               // Do nothing. This is just so OOUI doesn't break due to abort being undefined.
+                       } };
+
+               if ( mw.Title.newFromText( query ) ) {
+                       return this.interwikiPrefixesPromise.then( function () {
+                               var params, props,
+                                       interwiki = query.substring( 0, query.indexOf( ':' ) );
+                               if (
+                                       interwiki && interwiki !== '' &&
+                                       widget.interwikiPrefixes.indexOf( interwiki ) !== -1
+                               ) {
+                                       return $.Deferred().resolve( { query: {
+                                               pages: [ {
+                                                       title: query
+                                               } ]
+                                       } } ).promise( promiseAbortObject );
+                               } else {
+                                       params = {
+                                               action: 'query',
+                                               generator: 'prefixsearch',
+                                               gpssearch: query,
+                                               gpsnamespace: widget.namespace !== null ? widget.namespace : undefined,
+                                               gpslimit: widget.limit,
+                                               ppprop: 'disambiguation'
+                                       };
+                                       props = [ 'info', 'pageprops' ];
+                                       if ( widget.showRedirectTargets ) {
+                                               params.redirects = '1';
+                                       }
+                                       if ( widget.showImages ) {
+                                               props.push( 'pageimages' );
+                                               params.pithumbsize = 80;
+                                               params.pilimit = widget.limit;
+                                       }
+                                       if ( widget.showDescriptions ) {
+                                               props.push( 'pageterms' );
+                                               params.wbptterms = 'description';
+                                       }
+                                       params.prop = props.join( '|' );
+                                       req = new mw.Api().get( params );
+                                       promiseAbortObject.abort = req.abort.bind( req ); // todo: ew
+                                       return req;
+                               }
+                       } ).promise( promiseAbortObject );
+               } else {
+                       // Don't send invalid titles to the API.
+                       // Just pretend it returned nothing so we can show the 'invalid title' section
+                       return $.Deferred().resolve( {} ).promise( promiseAbortObject );
+               }
+       };
+
+       /**
+        * Get option widgets from the server response
+        *
+        * @param {Object} data Query result
+        * @returns {OO.ui.OptionWidget[]} Menu items
+        */
+       mw.widgets.TitleWidget.prototype.getOptionsFromData = function ( data ) {
+               var i, len, index, pageExists, pageExistsExact, suggestionPage, page, redirect, redirects,
+                       items = [],
+                       titles = [],
+                       titleObj = mw.Title.newFromText( this.getQueryValue() ),
+                       redirectsTo = {},
+                       pageData = {};
+
+               if ( data.redirects ) {
+                       for ( i = 0, len = data.redirects.length; i < len; i++ ) {
+                               redirect = data.redirects[ i ];
+                               redirectsTo[ redirect.to ] = redirectsTo[ redirect.to ] || [];
+                               redirectsTo[ redirect.to ].push( redirect.from );
+                       }
+               }
+
+               for ( index in data.pages ) {
+                       suggestionPage = data.pages[ index ];
+                       pageData[ suggestionPage.title ] = {
+                               missing: suggestionPage.missing !== undefined,
+                               redirect: suggestionPage.redirect !== undefined,
+                               disambiguation: OO.getProp( suggestionPage, 'pageprops', 'disambiguation' ) !== undefined,
+                               imageUrl: OO.getProp( suggestionPage, 'thumbnail', 'source' ),
+                               description: OO.getProp( suggestionPage, 'terms', 'description' )
+                       };
+
+                       // Throw away pages from wrong namespaces. This can happen when 'showRedirectTargets' is true
+                       // and we encounter a cross-namespace redirect.
+                       if ( this.namespace === null || this.namespace === suggestionPage.ns ) {
+                               titles.push( suggestionPage.title );
+                       }
+
+                       redirects = redirectsTo[ suggestionPage.title ] || [];
+                       for ( i = 0, len = redirects.length; i < len; i++ ) {
+                               pageData[ redirects[ i ] ] = {
+                                       missing: false,
+                                       redirect: true,
+                                       disambiguation: false,
+                                       description: mw.msg( 'mw-widgets-titleinput-description-redirect', suggestionPage.title )
+                               };
+                               titles.push( redirects[ i ] );
+                       }
+               }
+
+               // If not found, run value through mw.Title to avoid treating a match as a
+               // mismatch where normalisation would make them matching (bug 48476)
+
+               pageExistsExact = titles.indexOf( this.getQueryValue() ) !== -1;
+               pageExists = pageExistsExact || (
+                       titleObj && titles.indexOf( titleObj.getPrefixedText() ) !== -1
+               );
+
+               if ( !pageExists ) {
+                       pageData[ this.getQueryValue() ] = {
+                               missing: true, redirect: false, disambiguation: false,
+                               description: mw.msg( 'mw-widgets-titleinput-description-new-page' )
+                       };
+               }
+
+               if ( this.cache ) {
+                       this.cache.set( pageData );
+               }
+
+               // Offer the exact text as a suggestion if the page exists
+               if ( pageExists && !pageExistsExact ) {
+                       titles.unshift( this.getQueryValue() );
+               }
+               // Offer the exact text as a new page if the title is valid
+               if ( this.showRedlink && !pageExists && titleObj ) {
+                       titles.push( this.getQueryValue() );
+               }
+               for ( i = 0, len = titles.length; i < len; i++ ) {
+                       page = pageData[ titles[ i ] ] || {};
+                       items.push( new mw.widgets.TitleOptionWidget( this.getOptionWidgetData( titles[ i ], page ) ) );
+               }
+
+               return items;
+       };
+
+       /**
+        * Get menu option widget data from the title and page data
+        *
+        * @param {mw.Title} title Title object
+        * @param {Object} data Page data
+        * @return {Object} Data for option widget
+        */
+       mw.widgets.TitleWidget.prototype.getOptionWidgetData = function ( title, data ) {
+               var mwTitle = new mw.Title( title );
+               return {
+                       data: this.namespace !== null && this.relative
+                               ? mwTitle.getRelativeText( this.namespace )
+                               : title,
+                       title: mwTitle,
+                       imageUrl: this.showImages ? data.imageUrl : null,
+                       description: this.showDescriptions ? data.description : null,
+                       missing: data.missing,
+                       redirect: data.redirect,
+                       disambiguation: data.disambiguation,
+                       query: this.getQueryValue()
+               };
+       };
+
+       /**
+        * Get title object corresponding to given value, or #getQueryValue if not given.
+        *
+        * @param {string} [value] Value to get a title for
+        * @returns {mw.Title|null} Title object, or null if value is invalid
+        */
+       mw.widgets.TitleWidget.prototype.getTitle = function ( value ) {
+               var title = value !== undefined ? value : this.getQueryValue(),
+                       // mw.Title doesn't handle null well
+                       titleObj = mw.Title.newFromText( title, this.namespace !== null ? this.namespace : undefined );
+
+               return titleObj;
+       };
+
+       /**
+        * Check if the query is valid
+        *
+        * @return {boolean} The query is valid
+        */
+       mw.widgets.TitleWidget.prototype.isQueryValid = function () {
+               return !!this.getTitle();
+       };
+
+}( jQuery, mediaWiki ) );
diff --git a/resources/src/mediawiki.widgets/mw.widgets.TitleWidget.less b/resources/src/mediawiki.widgets/mw.widgets.TitleWidget.less
new file mode 100644 (file)
index 0000000..93c4b20
--- /dev/null
@@ -0,0 +1,63 @@
+/*!
+ * MediaWiki Widgets - TitleWidget styles.
+ *
+ * @copyright 2011-2015 MediaWiki Widgets Team and others; see AUTHORS.txt
+ * @license The MIT License (MIT); see LICENSE.txt
+ */
+
+.mw-widget-titleOptionWidget-description {
+       display: none;
+}
+
+.mw-widget-titleWidget {
+       &-menu-withImages {
+               .mw-widget-titleOptionWidget {
+                       -webkit-box-sizing: border-box;
+                       -moz-box-sizing: border-box;
+                       box-sizing: border-box;
+                       min-height: 3.75em;
+                       margin-left: 3.75em;
+               }
+
+               .mw-widget-titleOptionWidget:not(:last-child) {
+                       margin-bottom: 1px;
+               }
+
+               .oo-ui-iconElement .oo-ui-iconElement-icon {
+                       display: block;
+                       width: 3.75em;
+                       height: 3.75em;
+                       left: -3.75em;
+                       background-color: #ccc;
+                       opacity: 0.4;
+               }
+
+               .oo-ui-iconElement .mw-widget-titleOptionWidget-hasImage {
+                       border: 0;
+                       background-size: cover;
+                       opacity: 1;
+               }
+
+               .mw-widget-titleOptionWidget .oo-ui-labelElement-label {
+                       line-height: 2.8em;
+               }
+       }
+
+       &-menu-withDescriptions {
+               .mw-widget-titleOptionWidget .oo-ui-labelElement-label {
+                       line-height: 1.5em;
+               }
+
+               .mw-widget-titleOptionWidget-description {
+                       display: block;
+                       white-space: nowrap;
+                       text-overflow: ellipsis;
+                       overflow: hidden;
+               }
+       }
+}
+
+.oo-ui-menuOptionWidget:not(.oo-ui-optionWidget-selected) .mw-widget-titleOptionWidget-description,
+.oo-ui-menuOptionWidget.oo-ui-optionWidget-highlighted .mw-widget-titleOptionWidget-description {
+       color: #888;
+}