summaryrefslogtreecommitdiff
path: root/resources/src/mediawiki.widgets
diff options
context:
space:
mode:
Diffstat (limited to 'resources/src/mediawiki.widgets')
-rw-r--r--resources/src/mediawiki.widgets/AUTHORS.txt10
-rw-r--r--resources/src/mediawiki.widgets/LICENSE.txt25
-rw-r--r--resources/src/mediawiki.widgets/mw.widgets.CalendarWidget.js558
-rw-r--r--resources/src/mediawiki.widgets/mw.widgets.CalendarWidget.less243
-rw-r--r--resources/src/mediawiki.widgets/mw.widgets.CategoryCapsuleItemWidget.js189
-rw-r--r--resources/src/mediawiki.widgets/mw.widgets.CategorySelector.js378
-rw-r--r--resources/src/mediawiki.widgets/mw.widgets.ComplexNamespaceInputWidget.base.css26
-rw-r--r--resources/src/mediawiki.widgets/mw.widgets.ComplexNamespaceInputWidget.js118
-rw-r--r--resources/src/mediawiki.widgets/mw.widgets.ComplexTitleInputWidget.base.css20
-rw-r--r--resources/src/mediawiki.widgets/mw.widgets.ComplexTitleInputWidget.js63
-rw-r--r--resources/src/mediawiki.widgets/mw.widgets.DateInputWidget.js629
-rw-r--r--resources/src/mediawiki.widgets/mw.widgets.DateInputWidget.less134
-rw-r--r--resources/src/mediawiki.widgets/mw.widgets.NamespaceInputWidget.js69
-rw-r--r--resources/src/mediawiki.widgets/mw.widgets.TitleInputWidget.css57
-rw-r--r--resources/src/mediawiki.widgets/mw.widgets.TitleInputWidget.js341
-rw-r--r--resources/src/mediawiki.widgets/mw.widgets.TitleOptionWidget.js82
-rw-r--r--resources/src/mediawiki.widgets/mw.widgets.UserInputWidget.js119
17 files changed, 3061 insertions, 0 deletions
diff --git a/resources/src/mediawiki.widgets/AUTHORS.txt b/resources/src/mediawiki.widgets/AUTHORS.txt
new file mode 100644
index 00000000..10064b24
--- /dev/null
+++ b/resources/src/mediawiki.widgets/AUTHORS.txt
@@ -0,0 +1,10 @@
+Authors (alphabetically)
+
+Alex Monk <krenair@wikimedia.org>
+Bartosz Dziewoński <bdziewonski@wikimedia.org>
+Ed Sanders <esanders@wikimedia.org>
+James D. Forrester <jforrester@wikimedia.org>
+Roan Kattouw <roan@wikimedia.org>
+Sucheta Ghoshal <sghoshal@wikimedia.org>
+Timo Tijhof <timo@wikimedia.org>
+Trevor Parscal <trevor@wikimedia.org>
diff --git a/resources/src/mediawiki.widgets/LICENSE.txt b/resources/src/mediawiki.widgets/LICENSE.txt
new file mode 100644
index 00000000..b03ca801
--- /dev/null
+++ b/resources/src/mediawiki.widgets/LICENSE.txt
@@ -0,0 +1,25 @@
+Copyright (c) 2011-2015 MediaWiki Widgets Team and others under the
+terms of The MIT License (MIT), as follows:
+
+This software consists of voluntary contributions made by many
+individuals (AUTHORS.txt) For exact contribution history, see the
+revision history and logs, available at https://gerrit.wikimedia.org
+
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of this software and associated documentation files (the
+"Software"), to deal in the Software without restriction, including
+without limitation the rights to use, copy, modify, merge, publish,
+distribute, sublicense, and/or sell copies of the Software, and to
+permit persons to whom the Software is furnished to do so, subject to
+the following conditions:
+
+The above copyright notice and this permission notice shall be
+included in all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
+LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
+OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
diff --git a/resources/src/mediawiki.widgets/mw.widgets.CalendarWidget.js b/resources/src/mediawiki.widgets/mw.widgets.CalendarWidget.js
new file mode 100644
index 00000000..af83c5f2
--- /dev/null
+++ b/resources/src/mediawiki.widgets/mw.widgets.CalendarWidget.js
@@ -0,0 +1,558 @@
+/*!
+ * MediaWiki Widgets – CalendarWidget class.
+ *
+ * @copyright 2011-2015 MediaWiki Widgets Team and others; see AUTHORS.txt
+ * @license The MIT License (MIT); see LICENSE.txt
+ */
+/*global moment */
+( function ( $, mw ) {
+
+ /**
+ * Creates an mw.widgets.CalendarWidget object.
+ *
+ * You will most likely want to use mw.widgets.DateInputWidget instead of CalendarWidget directly.
+ *
+ * @class
+ * @extends OO.ui.Widget
+ * @mixins OO.ui.mixin.TabIndexedElement
+ * @mixins OO.ui.mixin.FloatableElement
+ *
+ * @constructor
+ * @param {Object} [config] Configuration options
+ * @cfg {string} [precision='day'] Date precision to use, 'day' or 'month'
+ * @cfg {string|null} [date=null] Day or month date (depending on `precision`), in the format
+ * 'YYYY-MM-DD' or 'YYYY-MM'. When null, the calendar will show today's date, but not select
+ * it.
+ */
+ mw.widgets.CalendarWidget = function MWWCalendarWidget( config ) {
+ // Config initialization
+ config = config || {};
+
+ // Parent constructor
+ mw.widgets.CalendarWidget.parent.call( this, config );
+
+ // Mixin constructors
+ OO.ui.mixin.TabIndexedElement.call( this, $.extend( {}, config, { $tabIndexed: this.$element } ) );
+ OO.ui.mixin.FloatableElement.call( this, config );
+
+ // Properties
+ this.precision = config.precision || 'day';
+ // Currently selected date (day or month)
+ this.date = null;
+ // Current UI state (date and precision we're displaying right now)
+ this.moment = null;
+ this.displayLayer = this.getDisplayLayers()[ 0 ]; // 'month', 'year', 'duodecade'
+
+ this.$header = $( '<div>' ).addClass( 'mw-widget-calendarWidget-header' );
+ this.$bodyOuterWrapper = $( '<div>' ).addClass( 'mw-widget-calendarWidget-body-outer-wrapper' );
+ this.$bodyWrapper = $( '<div>' ).addClass( 'mw-widget-calendarWidget-body-wrapper' );
+ this.$body = $( '<div>' ).addClass( 'mw-widget-calendarWidget-body' );
+ this.labelButton = new OO.ui.ButtonWidget( {
+ tabIndex: -1,
+ label: '',
+ framed: false,
+ classes: [ 'mw-widget-calendarWidget-labelButton' ]
+ } );
+ this.upButton = new OO.ui.ButtonWidget( {
+ tabIndex: -1,
+ framed: false,
+ icon: 'collapse',
+ classes: [ 'mw-widget-calendarWidget-upButton' ]
+ } );
+ this.prevButton = new OO.ui.ButtonWidget( {
+ tabIndex: -1,
+ framed: false,
+ icon: 'previous',
+ classes: [ 'mw-widget-calendarWidget-prevButton' ]
+ } );
+ this.nextButton = new OO.ui.ButtonWidget( {
+ tabIndex: -1,
+ framed: false,
+ icon: 'next',
+ classes: [ 'mw-widget-calendarWidget-nextButton' ]
+ } );
+
+ // Events
+ this.labelButton.connect( this, { click: 'onUpButtonClick' } );
+ this.upButton.connect( this, { click: 'onUpButtonClick' } );
+ this.prevButton.connect( this, { click: 'onPrevButtonClick' } );
+ this.nextButton.connect( this, { click: 'onNextButtonClick' } );
+ this.$element.on( {
+ focus: this.onFocus.bind( this ),
+ mousedown: this.onClick.bind( this ),
+ keydown: this.onKeyDown.bind( this )
+ } );
+
+ // Initialization
+ this.$element
+ .addClass( 'mw-widget-calendarWidget' )
+ .append( this.$header, this.$bodyOuterWrapper.append( this.$bodyWrapper.append( this.$body ) ) );
+ this.$header.append(
+ this.prevButton.$element,
+ this.nextButton.$element,
+ this.upButton.$element,
+ this.labelButton.$element
+ );
+ this.setDate( config.date !== undefined ? config.date : null );
+ };
+
+ /* Inheritance */
+
+ OO.inheritClass( mw.widgets.CalendarWidget, OO.ui.Widget );
+ OO.mixinClass( mw.widgets.CalendarWidget, OO.ui.mixin.TabIndexedElement );
+ OO.mixinClass( mw.widgets.CalendarWidget, OO.ui.mixin.FloatableElement );
+
+ /* Events */
+
+ /**
+ * @event change
+ *
+ * A change event is emitted when the chosen date changes.
+ *
+ * @param {string} date Day or month date, in the format 'YYYY-MM-DD' or 'YYYY-MM'
+ */
+
+ /* Methods */
+
+ /**
+ * Get the date format ('YYYY-MM-DD' or 'YYYY-MM', depending on precision), which is used
+ * internally and for dates accepted by #setDate and returned by #getDate.
+ *
+ * @private
+ * @returns {string} Format
+ */
+ mw.widgets.CalendarWidget.prototype.getDateFormat = function () {
+ return {
+ day: 'YYYY-MM-DD',
+ month: 'YYYY-MM'
+ }[ this.precision ];
+ };
+
+ /**
+ * Get the date precision this calendar uses, 'day' or 'month'.
+ *
+ * @private
+ * @returns {string} Precision, 'day' or 'month'
+ */
+ mw.widgets.CalendarWidget.prototype.getPrecision = function () {
+ return this.precision;
+ };
+
+ /**
+ * Get list of possible display layers.
+ *
+ * @private
+ * @returns {string[]} Layers
+ */
+ mw.widgets.CalendarWidget.prototype.getDisplayLayers = function () {
+ return [ 'month', 'year', 'duodecade' ].slice( this.precision === 'month' ? 1 : 0 );
+ };
+
+ /**
+ * Update the calendar.
+ *
+ * @private
+ * @param {string|null} [fade=null] Direction in which to fade out current calendar contents,
+ * 'previous', 'next', 'up' or 'down'; or 'auto', which has the same result as 'previous' or
+ * 'next' depending on whether the current date is later or earlier than the previous.
+ * @returns {string} Format
+ */
+ mw.widgets.CalendarWidget.prototype.updateUI = function ( fade ) {
+ var items, today, selected, currentMonth, currentYear, currentDay, i, needsFade,
+ $bodyWrapper = this.$bodyWrapper;
+
+ if (
+ this.displayLayer === this.previousDisplayLayer &&
+ this.date === this.previousDate &&
+ this.previousMoment &&
+ this.previousMoment.isSame( this.moment, this.precision === 'month' ? 'month' : 'day' )
+ ) {
+ // Already displayed
+ return;
+ }
+
+ if ( fade === 'auto' ) {
+ if ( !this.previousMoment ) {
+ fade = null;
+ } else if ( this.previousMoment.isBefore( this.moment, this.precision === 'month' ? 'month' : 'day' ) ) {
+ fade = 'next';
+ } else if ( this.previousMoment.isAfter( this.moment, this.precision === 'month' ? 'month' : 'day' ) ) {
+ fade = 'previous';
+ } else {
+ fade = null;
+ }
+ }
+
+ items = [];
+ if ( this.$oldBody ) {
+ this.$oldBody.remove();
+ }
+ this.$oldBody = this.$body.addClass( 'mw-widget-calendarWidget-old-body' );
+ // Clone without children
+ this.$body = $( this.$body[ 0 ].cloneNode( false ) )
+ .removeClass( 'mw-widget-calendarWidget-old-body' )
+ .toggleClass( 'mw-widget-calendarWidget-body-month', this.displayLayer === 'month' )
+ .toggleClass( 'mw-widget-calendarWidget-body-year', this.displayLayer === 'year' )
+ .toggleClass( 'mw-widget-calendarWidget-body-duodecade', this.displayLayer === 'duodecade' );
+
+ today = moment();
+ selected = moment( this.getDate(), this.getDateFormat() );
+
+ switch ( this.displayLayer ) {
+ case 'month':
+ this.labelButton.setLabel( this.moment.format( 'MMMM YYYY' ) );
+ this.upButton.toggle( true );
+
+ // First week displayed is the first week spanned by the month, unless it begins on Monday, in
+ // which case first week displayed is the previous week. This makes the calendar "balanced"
+ // and also neatly handles 28-day February sometimes spanning only 4 weeks.
+ currentDay = moment( this.moment ).startOf( 'month' ).subtract( 1, 'day' ).startOf( 'week' );
+
+ // Day-of-week labels. Localisation-independent: works with weeks starting on Saturday, Sunday
+ // or Monday.
+ for ( i = 0; i < 7; i++ ) {
+ items.push(
+ $( '<div>' )
+ .addClass( 'mw-widget-calendarWidget-day-heading' )
+ .text( currentDay.format( 'dd' ) )
+ );
+ currentDay.add( 1, 'day' );
+ }
+ currentDay.subtract( 7, 'days' );
+
+ // Actual calendar month. Always displays 6 weeks, for consistency (months can span 4 to 6
+ // weeks).
+ for ( i = 0; i < 42; i++ ) {
+ items.push(
+ $( '<div>' )
+ .addClass( 'mw-widget-calendarWidget-item mw-widget-calendarWidget-day' )
+ .toggleClass( 'mw-widget-calendarWidget-day-additional', !currentDay.isSame( this.moment, 'month' ) )
+ .toggleClass( 'mw-widget-calendarWidget-day-today', currentDay.isSame( today, 'day' ) )
+ .toggleClass( 'mw-widget-calendarWidget-item-selected', currentDay.isSame( selected, 'day' ) )
+ .text( currentDay.format( 'D' ) )
+ .data( 'date', currentDay.date() )
+ .data( 'month', currentDay.month() )
+ .data( 'year', currentDay.year() )
+ );
+ currentDay.add( 1, 'day' );
+ }
+ break;
+
+ case 'year':
+ this.labelButton.setLabel( this.moment.format( 'YYYY' ) );
+ this.upButton.toggle( true );
+
+ currentMonth = moment( this.moment ).startOf( 'year' );
+ for ( i = 0; i < 12; i++ ) {
+ items.push(
+ $( '<div>' )
+ .addClass( 'mw-widget-calendarWidget-item mw-widget-calendarWidget-month' )
+ .toggleClass( 'mw-widget-calendarWidget-item-selected', currentMonth.isSame( selected, 'month' ) )
+ .text( currentMonth.format( 'MMMM' ) )
+ .data( 'month', currentMonth.month() )
+ );
+ currentMonth.add( 1, 'month' );
+ }
+ // Shuffle the array to display months in columns rather than rows.
+ items = [
+ items[ 0 ], items[ 6 ], // | January | July |
+ items[ 1 ], items[ 7 ], // | February | August |
+ items[ 2 ], items[ 8 ], // | March | September |
+ items[ 3 ], items[ 9 ], // | April | October |
+ items[ 4 ], items[ 10 ], // | May | November |
+ items[ 5 ], items[ 11 ] // | June | December |
+ ];
+ break;
+
+ case 'duodecade':
+ this.labelButton.setLabel( null );
+ this.upButton.toggle( false );
+
+ currentYear = moment( { year: Math.floor( this.moment.year() / 20 ) * 20 } );
+ for ( i = 0; i < 20; i++ ) {
+ items.push(
+ $( '<div>' )
+ .addClass( 'mw-widget-calendarWidget-item mw-widget-calendarWidget-year' )
+ .toggleClass( 'mw-widget-calendarWidget-item-selected', currentYear.isSame( selected, 'year' ) )
+ .text( currentYear.format( 'YYYY' ) )
+ .data( 'year', currentYear.year() )
+ );
+ currentYear.add( 1, 'year' );
+ }
+ break;
+ }
+
+ this.$body.append.apply( this.$body, items );
+
+ $bodyWrapper
+ .removeClass( 'mw-widget-calendarWidget-body-wrapper-fade-up' )
+ .removeClass( 'mw-widget-calendarWidget-body-wrapper-fade-down' )
+ .removeClass( 'mw-widget-calendarWidget-body-wrapper-fade-previous' )
+ .removeClass( 'mw-widget-calendarWidget-body-wrapper-fade-next' );
+
+ needsFade = this.previousDisplayLayer !== this.displayLayer;
+ if ( this.displayLayer === 'month' ) {
+ needsFade = needsFade || !this.moment.isSame( this.previousMoment, 'month' );
+ } else if ( this.displayLayer === 'year' ) {
+ needsFade = needsFade || !this.moment.isSame( this.previousMoment, 'year' );
+ } else if ( this.displayLayer === 'duodecade' ) {
+ needsFade = needsFade || (
+ Math.floor( this.moment.year() / 20 ) * 20 !==
+ Math.floor( this.previousMoment.year() / 20 ) * 20
+ );
+ }
+
+ if ( fade && needsFade ) {
+ this.$oldBody.find( '.mw-widget-calendarWidget-item-selected' )
+ .removeClass( 'mw-widget-calendarWidget-item-selected' );
+ if ( fade === 'previous' || fade === 'up' ) {
+ this.$body.insertBefore( this.$oldBody );
+ } else if ( fade === 'next' || fade === 'down' ) {
+ this.$body.insertAfter( this.$oldBody );
+ }
+ setTimeout( function () {
+ $bodyWrapper.addClass( 'mw-widget-calendarWidget-body-wrapper-fade-' + fade );
+ }.bind( this ), 0 );
+ } else {
+ this.$oldBody.replaceWith( this.$body );
+ }
+
+ this.previousMoment = moment( this.moment );
+ this.previousDisplayLayer = this.displayLayer;
+ this.previousDate = this.date;
+
+ this.$body.on( 'click', this.onBodyClick.bind( this ) );
+ };
+
+ /**
+ * Handle click events on the "up" button, switching to less precise view.
+ *
+ * @private
+ */
+ mw.widgets.CalendarWidget.prototype.onUpButtonClick = function () {
+ var
+ layers = this.getDisplayLayers(),
+ currentLayer = layers.indexOf( this.displayLayer );
+ if ( currentLayer !== layers.length - 1 ) {
+ // One layer up
+ this.displayLayer = layers[ currentLayer + 1 ];
+ this.updateUI( 'up' );
+ } else {
+ this.updateUI();
+ }
+ };
+
+ /**
+ * Handle click events on the "previous" button, switching to previous pane.
+ *
+ * @private
+ */
+ mw.widgets.CalendarWidget.prototype.onPrevButtonClick = function () {
+ switch ( this.displayLayer ) {
+ case 'month':
+ this.moment.subtract( 1, 'month' );
+ break;
+ case 'year':
+ this.moment.subtract( 1, 'year' );
+ break;
+ case 'duodecade':
+ this.moment.subtract( 20, 'years' );
+ break;
+ }
+ this.updateUI( 'previous' );
+ };
+
+ /**
+ * Handle click events on the "next" button, switching to next pane.
+ *
+ * @private
+ */
+ mw.widgets.CalendarWidget.prototype.onNextButtonClick = function () {
+ switch ( this.displayLayer ) {
+ case 'month':
+ this.moment.add( 1, 'month' );
+ break;
+ case 'year':
+ this.moment.add( 1, 'year' );
+ break;
+ case 'duodecade':
+ this.moment.add( 20, 'years' );
+ break;
+ }
+ this.updateUI( 'next' );
+ };
+
+ /**
+ * Handle click events anywhere in the body of the widget, which contains the matrix of days,
+ * months or years to choose. Maybe change the pane or switch to more precise view, depending on
+ * what gets clicked.
+ *
+ * @private
+ */
+ mw.widgets.CalendarWidget.prototype.onBodyClick = function ( e ) {
+ var
+ $target = $( e.target ),
+ layers = this.getDisplayLayers(),
+ currentLayer = layers.indexOf( this.displayLayer );
+ if ( $target.data( 'year' ) !== undefined ) {
+ this.moment.year( $target.data( 'year' ) );
+ }
+ if ( $target.data( 'month' ) !== undefined ) {
+ this.moment.month( $target.data( 'month' ) );
+ }
+ if ( $target.data( 'date' ) !== undefined ) {
+ this.moment.date( $target.data( 'date' ) );
+ }
+ if ( currentLayer === 0 ) {
+ this.setDateFromMoment();
+ this.updateUI( 'auto' );
+ } else {
+ // One layer down
+ this.displayLayer = layers[ currentLayer - 1 ];
+ this.updateUI( 'down' );
+ }
+ };
+
+ /**
+ * Set the date.
+ *
+ * @param {string|null} [date=null] Day or month date, in the format 'YYYY-MM-DD' or 'YYYY-MM'.
+ * When null, the calendar will show today's date, but not select it. When invalid, the date
+ * is not changed.
+ */
+ mw.widgets.CalendarWidget.prototype.setDate = function ( date ) {
+ var mom = date !== null ? moment( date, this.getDateFormat() ) : moment();
+ if ( mom.isValid() ) {
+ this.moment = mom;
+ if ( date !== null ) {
+ this.setDateFromMoment();
+ } else if ( this.date !== null ) {
+ this.date = null;
+ this.emit( 'change', this.date );
+ }
+ this.displayLayer = this.getDisplayLayers()[ 0 ];
+ this.updateUI();
+ }
+ };
+
+ /**
+ * Reset the user interface of this widget to reflect selected date.
+ */
+ mw.widgets.CalendarWidget.prototype.resetUI = function () {
+ this.moment = this.getDate() !== null ? moment( this.getDate(), this.getDateFormat() ) : moment();
+ this.displayLayer = this.getDisplayLayers()[ 0 ];
+ this.updateUI();
+ };
+
+ /**
+ * Set the date from moment object.
+ *
+ * @private
+ */
+ mw.widgets.CalendarWidget.prototype.setDateFromMoment = function () {
+ // Switch to English locale to avoid number formatting. We want the internal value to be
+ // '2015-07-24' and not '٢٠١٥-٠٧-٢٤' even if the UI language is Arabic.
+ var newDate = moment( this.moment ).locale( 'en' ).format( this.getDateFormat() );
+ if ( this.date !== newDate ) {
+ this.date = newDate;
+ this.emit( 'change', this.date );
+ }
+ };
+
+ /**
+ * Get current date, in the format 'YYYY-MM-DD' or 'YYYY-MM', depending on precision. Digits will
+ * not be localised.
+ *
+ * @returns {string|null} Date string
+ */
+ mw.widgets.CalendarWidget.prototype.getDate = function () {
+ return this.date;
+ };
+
+ /**
+ * Handle focus events.
+ *
+ * @private
+ */
+ mw.widgets.CalendarWidget.prototype.onFocus = function () {
+ this.displayLayer = this.getDisplayLayers()[ 0 ];
+ this.updateUI( 'down' );
+ };
+
+ /**
+ * Handle mouse click events.
+ *
+ * @private
+ * @param {jQuery.Event} e Mouse click event
+ */
+ mw.widgets.CalendarWidget.prototype.onClick = function ( e ) {
+ if ( !this.isDisabled() && e.which === 1 ) {
+ // Prevent unintended focussing
+ return false;
+ }
+ };
+
+ /**
+ * Handle key down events.
+ *
+ * @private
+ * @param {jQuery.Event} e Key down event
+ */
+ mw.widgets.CalendarWidget.prototype.onKeyDown = function ( e ) {
+ var
+ /*jshint -W024*/
+ dir = OO.ui.Element.static.getDir( this.$element ),
+ /*jshint +W024*/
+ nextDirectionKey = dir === 'ltr' ? OO.ui.Keys.RIGHT : OO.ui.Keys.LEFT,
+ prevDirectionKey = dir === 'ltr' ? OO.ui.Keys.LEFT : OO.ui.Keys.RIGHT,
+ changed = true;
+
+ if ( !this.isDisabled() ) {
+ switch ( e.which ) {
+ case prevDirectionKey:
+ this.moment.subtract( 1, this.precision === 'month' ? 'month' : 'day' );
+ break;
+ case nextDirectionKey:
+ this.moment.add( 1, this.precision === 'month' ? 'month' : 'day' );
+ break;
+ case OO.ui.Keys.UP:
+ this.moment.subtract( 1, this.precision === 'month' ? 'month' : 'week' );
+ break;
+ case OO.ui.Keys.DOWN:
+ this.moment.add( 1, this.precision === 'month' ? 'month' : 'week' );
+ break;
+ case OO.ui.Keys.PAGEUP:
+ this.moment.subtract( 1, this.precision === 'month' ? 'year' : 'month' );
+ break;
+ case OO.ui.Keys.PAGEDOWN:
+ this.moment.add( 1, this.precision === 'month' ? 'year' : 'month' );
+ break;
+ default:
+ changed = false;
+ break;
+ }
+
+ if ( changed ) {
+ this.displayLayer = this.getDisplayLayers()[ 0 ];
+ this.setDateFromMoment();
+ this.updateUI( 'auto' );
+ return false;
+ }
+ }
+ };
+
+ /**
+ * @inheritdoc
+ */
+ mw.widgets.CalendarWidget.prototype.toggle = function ( visible ) {
+ // Parent method
+ mw.widgets.CalendarWidget.parent.prototype.toggle.call( this, visible );
+
+ if ( this.$floatableContainer ) {
+ this.togglePositioning( this.isVisible() );
+ }
+
+ return this;
+ };
+
+}( jQuery, mediaWiki ) );
diff --git a/resources/src/mediawiki.widgets/mw.widgets.CalendarWidget.less b/resources/src/mediawiki.widgets/mw.widgets.CalendarWidget.less
new file mode 100644
index 00000000..9d30eb8a
--- /dev/null
+++ b/resources/src/mediawiki.widgets/mw.widgets.CalendarWidget.less
@@ -0,0 +1,243 @@
+/*!
+ * MediaWiki Widgets – CalendarWidget styles.
+ *
+ * @copyright 2011-2015 MediaWiki Widgets Team and others; see AUTHORS.txt
+ * @license The MIT License (MIT); see LICENSE.txt
+ */
+
+@calendarWidth: 21em;
+@calendarHeight: 14em;
+
+.mw-widget-calendarWidget {
+ width: @calendarWidth;
+}
+
+.mw-widget-calendarWidget-header {
+ position: relative;
+ line-height: 2.5em;
+}
+
+.mw-widget-calendarWidget-header .oo-ui-buttonWidget {
+ margin-right: 0;
+}
+
+.mw-widget-calendarWidget-header .mw-widget-calendarWidget-labelButton {
+ margin: 0 auto;
+ display: block;
+ width: @calendarWidth - 2*3em;
+
+ .oo-ui-buttonElement-button {
+ width: @calendarWidth - 2*3em;
+ text-align: center;
+ }
+}
+
+.mw-widget-calendarWidget-upButton {
+ position: absolute;
+ right: 3em;
+}
+
+.mw-widget-calendarWidget-prevButton {
+ float: left;
+}
+
+.mw-widget-calendarWidget-nextButton {
+ float: right;
+}
+
+.mw-widget-calendarWidget-body-outer-wrapper {
+ clear: both;
+ position: relative;
+ overflow: hidden;
+ // Fit 7 days, 3em each
+ width: @calendarWidth;
+ // Fit 6 weeks + heading line, 2em each
+ height: @calendarHeight;
+}
+
+.mw-widget-calendarWidget-body-wrapper {
+ .mw-widget-calendarWidget-body {
+ display: inline-block;
+ // Fit 7 days, 3em each
+ width: @calendarWidth;
+ // Fit 6 weeks + heading line, 2em each
+ height: @calendarHeight;
+ }
+
+ .mw-widget-calendarWidget-old-body {
+ // background: #fdd;
+ }
+
+ .mw-widget-calendarWidget-body:not(.mw-widget-calendarWidget-old-body):first-child {
+ margin-top: -@calendarHeight;
+ margin-left: -@calendarWidth;
+ }
+
+ .mw-widget-calendarWidget-body:not(.mw-widget-calendarWidget-old-body):last-child {
+ margin-top: 0;
+ margin-left: 0;
+ }
+}
+
+.mw-widget-calendarWidget-body-wrapper-fade-previous {
+ width: @calendarWidth * 2;
+ height: @calendarHeight;
+
+ .mw-widget-calendarWidget-body:first-child {
+ margin-top: 0 !important;
+ margin-left: 0 !important;
+ transition: 0.5s margin-left;
+ }
+}
+
+.mw-widget-calendarWidget-body-wrapper-fade-next {
+ width: @calendarWidth * 2;
+ height: @calendarHeight;
+
+ .mw-widget-calendarWidget-body:first-child {
+ margin-left: -@calendarWidth !important;
+ margin-top: 0 !important;
+ transition: 0.5s margin-left;
+ }
+}
+
+.mw-widget-calendarWidget-body-wrapper-fade-up {
+ width: @calendarWidth;
+ height: @calendarHeight * 2;
+
+ .mw-widget-calendarWidget-body {
+ display: block;
+ }
+
+ .mw-widget-calendarWidget-body:first-child {
+ margin-left: 0 !important;
+ margin-top: 0 !important;
+ transition: 0.5s margin-top;
+ }
+}
+
+.mw-widget-calendarWidget-body-wrapper-fade-down {
+ width: @calendarWidth;
+ height: @calendarHeight * 2;
+
+ .mw-widget-calendarWidget-body {
+ display: block;
+ }
+
+ .mw-widget-calendarWidget-body:first-child {
+ margin-left: 0 !important;
+ margin-top: -@calendarHeight !important;
+ transition: 0.5s margin-top;
+ }
+}
+
+.mw-widget-calendarWidget-day,
+.mw-widget-calendarWidget-day-heading,
+.mw-widget-calendarWidget-month,
+.mw-widget-calendarWidget-year {
+ display: inline-block;
+ vertical-align: middle;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ text-align: center;
+}
+
+.mw-widget-calendarWidget-day,
+.mw-widget-calendarWidget-day-heading {
+ // 7x7 grid
+ width: @calendarWidth / 7;
+ line-height: @calendarHeight / 7;
+ // Don't overlap the hacked-up fake box-shadow border we get when focussed
+ &:nth-child(7n) {
+ width: @calendarWidth / 7 - 0.2em;
+ margin-right: 0.2em;
+ }
+ &:nth-child(7n+1) {
+ width: @calendarWidth / 7 - 0.2em;
+ margin-left: 0.2em;
+ }
+ &:nth-child(42) ~ & {
+ line-height: @calendarHeight / 7 - 0.2em;
+ margin-bottom: 0.2em;
+ }
+}
+
+.mw-widget-calendarWidget-month {
+ // 2x6 grid
+ width: @calendarWidth / 2;
+ line-height: @calendarHeight / 6;
+ // Don't overlap the hacked-up fake box-shadow border we get when focussed
+ &:nth-child(2n) {
+ width: @calendarWidth / 2 - 0.2em;
+ margin-right: 0.2em;
+ }
+ &:nth-child(2n+1) {
+ width: @calendarWidth / 2 - 0.2em;
+ margin-left: 0.2em;
+ }
+ &:nth-child(10) ~ & {
+ line-height: @calendarHeight / 6 - 0.2em;
+ margin-bottom: 0.2em;
+ }
+}
+
+.mw-widget-calendarWidget-year {
+ // 5x4 grid
+ width: @calendarWidth / 5;
+ line-height: @calendarHeight / 4;
+ // Don't overlap the hacked-up fake box-shadow border we get when focussed
+ &:nth-child(5n) {
+ width: @calendarWidth / 5 - 0.2em;
+ margin-right: 0.2em;
+ }
+ &:nth-child(5n+1) {
+ width: @calendarWidth / 5 - 0.2em;
+ margin-left: 0.2em;
+ }
+ &:nth-child(15) ~ & {
+ line-height: @calendarHeight / 4 - 0.2em;
+ margin-bottom: 0.2em;
+ }
+}
+
+.mw-widget-calendarWidget-item {
+ cursor: pointer;
+}
+
+/* Theme-specific */
+.mw-widget-calendarWidget {
+ box-shadow: inset 0 0 0 1px #ccc;
+}
+
+.mw-widget-calendarWidget:focus {
+ outline: none;
+ box-shadow: inset 0 0 0 2px #347bff;
+}
+
+.mw-widget-calendarWidget-day {
+ color: #444;
+ border-radius: 0.1em;
+}
+
+.mw-widget-calendarWidget-day-heading {
+ font-weight: bold;
+ color: #555;
+}
+
+.mw-widget-calendarWidget-day-additional {
+ color: #aaa;
+}
+
+.mw-widget-calendarWidget-day-today {
+ box-shadow: inset 0 0 0 1px #3787fb;
+}
+
+.mw-widget-calendarWidget-item-selected {
+ background-color: #d8e6fe;
+ color: #3787fb;
+}
+
+.mw-widget-calendarWidget-item:hover {
+ background-color: #eee;
+}
diff --git a/resources/src/mediawiki.widgets/mw.widgets.CategoryCapsuleItemWidget.js b/resources/src/mediawiki.widgets/mw.widgets.CategoryCapsuleItemWidget.js
new file mode 100644
index 00000000..24b0e72b
--- /dev/null
+++ b/resources/src/mediawiki.widgets/mw.widgets.CategoryCapsuleItemWidget.js
@@ -0,0 +1,189 @@
+/*!
+ * MediaWiki Widgets - CategoryCapsuleItemWidget class.
+ *
+ * @copyright 2011-2015 MediaWiki Widgets Team and others; see AUTHORS.txt
+ * @license The MIT License (MIT); see LICENSE.txt
+ */
+( function ( $, mw ) {
+
+ /**
+ * @class mw.widgets.PageExistenceCache
+ * @private
+ * @param {mw.Api} [api]
+ */
+ function PageExistenceCache( api ) {
+ this.api = api || new mw.Api();
+ this.processExistenceCheckQueueDebounced = OO.ui.debounce( this.processExistenceCheckQueue );
+ this.currentRequest = null;
+ this.existenceCache = {};
+ this.existenceCheckQueue = {};
+ }
+
+ /**
+ * Check for existence of pages in the queue.
+ *
+ * @private
+ */
+ PageExistenceCache.prototype.processExistenceCheckQueue = function () {
+ var queue, titles;
+ if ( this.currentRequest ) {
+ // Don't fire off a million requests at the same time
+ this.currentRequest.always( function () {
+ this.currentRequest = null;
+ this.processExistenceCheckQueueDebounced();
+ }.bind( this ) );
+ return;
+ }
+ queue = this.existenceCheckQueue;
+ this.existenceCheckQueue = {};
+ titles = Object.keys( queue ).filter( function ( title ) {
+ if ( this.existenceCache.hasOwnProperty( title ) ) {
+ queue[ title ].resolve( this.existenceCache[ title ] );
+ }
+ return !this.existenceCache.hasOwnProperty( title );
+ }.bind( this ) );
+ if ( !titles.length ) {
+ return;
+ }
+ this.currentRequest = this.api.get( {
+ action: 'query',
+ prop: [ 'info' ],
+ titles: titles
+ } ).done( function ( response ) {
+ var index, curr, title;
+ for ( index in response.query.pages ) {
+ curr = response.query.pages[ index ];
+ title = new ForeignTitle( curr.title ).getPrefixedText();
+ this.existenceCache[ title ] = curr.missing === undefined;
+ queue[ title ].resolve( this.existenceCache[ title ] );
+ }
+ }.bind( this ) );
+ };
+
+ /**
+ * Register a request to check whether a page exists.
+ *
+ * @private
+ * @param {mw.Title} title
+ * @return {jQuery.Promise} Promise resolved with true if the page exists or false otherwise
+ */
+ PageExistenceCache.prototype.checkPageExistence = function ( title ) {
+ var key = title.getPrefixedText();
+ if ( !this.existenceCheckQueue[ key ] ) {
+ this.existenceCheckQueue[ key ] = $.Deferred();
+ }
+ this.processExistenceCheckQueueDebounced();
+ return this.existenceCheckQueue[ key ].promise();
+ };
+
+ /**
+ * @class mw.widgets.ForeignTitle
+ * @private
+ * @extends mw.Title
+ *
+ * @constructor
+ * @inheritdoc
+ */
+ function ForeignTitle() {
+ ForeignTitle.parent.apply( this, arguments );
+ }
+ OO.inheritClass( ForeignTitle, mw.Title );
+ ForeignTitle.prototype.getNamespacePrefix = function () {
+ // We only need to handle categories here...
+ return 'Category:'; // HACK
+ };
+
+ /**
+ * @class mw.widgets.CategoryCapsuleItemWidget
+ *
+ * Category selector capsule item widget. Extends OO.ui.CapsuleItemWidget with the ability to link
+ * to the given page, and to show its existence status (i.e., whether it is a redlink).
+ *
+ * @uses mw.Api
+ * @extends OO.ui.CapsuleItemWidget
+ *
+ * @constructor
+ * @param {Object} config Configuration options
+ * @cfg {mw.Title} title Page title to use (required)
+ * @cfg {string} [apiUrl] API URL, if not the current wiki's API
+ */
+ mw.widgets.CategoryCapsuleItemWidget = function MWWCategoryCapsuleItemWidget( config ) {
+ // Parent constructor
+ mw.widgets.CategoryCapsuleItemWidget.parent.call( this, $.extend( {
+ data: config.title.getMainText(),
+ label: config.title.getMainText()
+ }, config ) );
+
+ // Properties
+ this.title = config.title;
+ this.apiUrl = config.apiUrl || '';
+ this.$link = $( '<a>' )
+ .text( this.label )
+ .attr( 'target', '_blank' )
+ .on( 'click', function ( e ) {
+ // CapsuleMultiSelectWidget really wants to prevent you from clicking the link, don't let it
+ e.stopPropagation();
+ } );
+
+ // Initialize
+ this.setMissing( false );
+ this.$label.replaceWith( this.$link );
+ this.setLabelElement( this.$link );
+
+ /*jshint -W024*/
+ if ( !this.constructor.static.pageExistenceCaches[ this.apiUrl ] ) {
+ this.constructor.static.pageExistenceCaches[ this.apiUrl ] =
+ new PageExistenceCache( new mw.ForeignApi( this.apiUrl ) );
+ }
+ this.constructor.static.pageExistenceCaches[ this.apiUrl ]
+ .checkPageExistence( new ForeignTitle( this.title.getPrefixedText() ) )
+ .done( function ( exists ) {
+ this.setMissing( !exists );
+ }.bind( this ) );
+ /*jshint +W024*/
+ };
+
+ /* Setup */
+
+ OO.inheritClass( mw.widgets.CategoryCapsuleItemWidget, OO.ui.CapsuleItemWidget );
+
+ /* Static Properties */
+
+ /*jshint -W024*/
+ /**
+ * Map of API URLs to PageExistenceCache objects.
+ *
+ * @static
+ * @inheritable
+ * @property {Object}
+ */
+ mw.widgets.CategoryCapsuleItemWidget.static.pageExistenceCaches = {
+ '': new PageExistenceCache()
+ };
+ /*jshint +W024*/
+
+ /* Methods */
+
+ /**
+ * Update label link href and CSS classes to reflect page existence status.
+ *
+ * @private
+ * @param {boolean} missing Whether the page is missing (does not exist)
+ */
+ mw.widgets.CategoryCapsuleItemWidget.prototype.setMissing = function ( missing ) {
+ var
+ title = new ForeignTitle( this.title.getPrefixedText() ), // HACK
+ prefix = this.apiUrl.replace( '/w/api.php', '' ); // HACK
+
+ if ( !missing ) {
+ this.$link
+ .attr( 'href', prefix + title.getUrl() )
+ .removeClass( 'new' );
+ } else {
+ this.$link
+ .attr( 'href', prefix + title.getUrl( { action: 'edit', redlink: 1 } ) )
+ .addClass( 'new' );
+ }
+ };
+
+}( jQuery, mediaWiki ) );
diff --git a/resources/src/mediawiki.widgets/mw.widgets.CategorySelector.js b/resources/src/mediawiki.widgets/mw.widgets.CategorySelector.js
new file mode 100644
index 00000000..59f1d507
--- /dev/null
+++ b/resources/src/mediawiki.widgets/mw.widgets.CategorySelector.js
@@ -0,0 +1,378 @@
+/*!
+ * MediaWiki Widgets - CategorySelector class.
+ *
+ * @copyright 2011-2015 MediaWiki Widgets Team and others; see AUTHORS.txt
+ * @license The MIT License (MIT); see LICENSE.txt
+ */
+( function ( $, mw ) {
+ var CSP,
+ NS_CATEGORY = mw.config.get( 'wgNamespaceIds' ).category;
+
+ /**
+ * Category selector widget. Displays an OO.ui.CapsuleMultiSelectWidget
+ * and autocompletes with available categories.
+ *
+ * var selector = new mw.widgets.CategorySelector( {
+ * searchTypes: [
+ * mw.widgets.CategorySelector.SearchType.OpenSearch,
+ * mw.widgets.CategorySelector.SearchType.InternalSearch
+ * ]
+ * } );
+ *
+ * $( '#content' ).append( selector.$element );
+ *
+ * selector.setSearchType( [ mw.widgets.CategorySelector.SearchType.SubCategories ] );
+ *
+ * @class mw.widgets.CategorySelector
+ * @uses mw.Api
+ * @extends OO.ui.CapsuleMultiSelectWidget
+ * @mixins OO.ui.mixin.PendingElement
+ *
+ * @constructor
+ * @param {Object} [config] Configuration options
+ * @cfg {mw.Api} [api] Instance of mw.Api (or subclass thereof) to use for queries
+ * @cfg {number} [limit=10] Maximum number of results to load
+ * @cfg {mw.widgets.CategorySelector.SearchType[]} [searchTypes=[mw.widgets.CategorySelector.SearchType.OpenSearch]]
+ * Default search API to use when searching.
+ */
+ function CategorySelector( config ) {
+ // Config initialization
+ config = $.extend( {
+ limit: 10,
+ searchTypes: [ CategorySelector.SearchType.OpenSearch ]
+ }, config );
+ this.limit = config.limit;
+ this.searchTypes = config.searchTypes;
+ this.validateSearchTypes();
+
+ // Parent constructor
+ mw.widgets.CategorySelector.parent.call( this, $.extend( true, {}, config, {
+ menu: {
+ filterFromInput: false
+ },
+ // This allows the user to both select non-existent categories, and prevents the selector from
+ // being wiped from #onMenuItemsChange when we change the available options in the dropdown
+ allowArbitrary: true
+ } ) );
+
+ // Mixin constructors
+ OO.ui.mixin.PendingElement.call( this, $.extend( {}, config, { $pending: this.$handle } ) );
+
+ // Event handler to call the autocomplete methods
+ this.$input.on( 'change input cut paste', OO.ui.debounce( this.updateMenuItems.bind( this ), 100 ) );
+
+ // Initialize
+ this.api = config.api || new mw.Api();
+ }
+
+ /* Setup */
+
+ OO.inheritClass( CategorySelector, OO.ui.CapsuleMultiSelectWidget );
+ OO.mixinClass( CategorySelector, OO.ui.mixin.PendingElement );
+ CSP = CategorySelector.prototype;
+
+ /* Methods */
+
+ /**
+ * Gets new items based on the input by calling
+ * {@link #getNewMenuItems getNewItems} and updates the menu
+ * after removing duplicates based on the data value.
+ *
+ * @private
+ * @method
+ */
+ CSP.updateMenuItems = function () {
+ this.getMenu().clearItems();
+ this.getNewMenuItems( this.$input.val() ).then( function ( items ) {
+ var existingItems, filteredItems,
+ menu = this.getMenu();
+
+ // Never show the menu if the input lost focus in the meantime
+ if ( !this.$input.is( ':focus' ) ) {
+ return;
+ }
+
+ // Array of strings of the data of OO.ui.MenuOptionsWidgets
+ existingItems = menu.getItems().map( function ( item ) {
+ return item.data;
+ } );
+
+ // Remove if items' data already exists
+ filteredItems = items.filter( function ( item ) {
+ return existingItems.indexOf( item ) === -1;
+ } );
+
+ // Map to an array of OO.ui.MenuOptionWidgets
+ filteredItems = filteredItems.map( function ( item ) {
+ return new OO.ui.MenuOptionWidget( {
+ data: item,
+ label: item
+ } );
+ } );
+
+ menu.addItems( filteredItems ).toggle( true );
+ }.bind( this ) );
+ };
+
+ /**
+ * @inheritdoc
+ */
+ CSP.clearInput = function () {
+ CategorySelector.parent.prototype.clearInput.call( this );
+ // Abort all pending requests, we won't need their results
+ this.api.abort();
+ };
+
+ /**
+ * Searches for categories based on the input.
+ *
+ * @private
+ * @method
+ * @param {string} input The input used to prefix search categories
+ * @return {jQuery.Promise} Resolves with an array of categories
+ */
+ CSP.getNewMenuItems = function ( input ) {
+ var i,
+ promises = [],
+ deferred = new $.Deferred();
+
+ if ( $.trim( input ) === '' ) {
+ deferred.resolve( [] );
+ return deferred.promise();
+ }
+
+ // Abort all pending requests, we won't need their results
+ this.api.abort();
+ for ( i = 0; i < this.searchTypes.length; i++ ) {
+ promises.push( this.searchCategories( input, this.searchTypes[ i ] ) );
+ }
+
+ this.pushPending();
+
+ $.when.apply( $, promises ).done( function () {
+ var categories, categoryNames,
+ allData = [],
+ dataSets = Array.prototype.slice.apply( arguments );
+
+ // Collect values from all results
+ allData = allData.concat.apply( allData, dataSets );
+
+ // Remove duplicates
+ categories = allData.filter( function ( value, index, self ) {
+ return self.indexOf( value ) === index;
+ } );
+
+ // Get titles
+ categoryNames = categories.map( function ( name ) {
+ return mw.Title.newFromText( name, NS_CATEGORY ).getMainText();
+ } );
+
+ deferred.resolve( categoryNames );
+
+ } ).always( this.popPending.bind( this ) );
+
+ return deferred.promise();
+ };
+
+ /**
+ * @inheritdoc
+ */
+ CSP.createItemWidget = function ( data ) {
+ return new mw.widgets.CategoryCapsuleItemWidget( {
+ apiUrl: this.api.apiUrl || undefined,
+ title: mw.Title.newFromText( data, NS_CATEGORY )
+ } );
+ };
+
+ /**
+ * Validates the values in `this.searchType`.
+ *
+ * @private
+ * @return {boolean}
+ */
+ CSP.validateSearchTypes = function () {
+ var validSearchTypes = false,
+ searchTypeEnumCount = Object.keys( CategorySelector.SearchType ).length;
+
+ // Check if all values are in the SearchType enum
+ validSearchTypes = this.searchTypes.every( function ( searchType ) {
+ return searchType > -1 && searchType < searchTypeEnumCount;
+ } );
+
+ if ( validSearchTypes === false ) {
+ throw new Error( 'Unknown searchType in searchTypes' );
+ }
+
+ // If the searchTypes has CategorySelector.SearchType.SubCategories
+ // it can be the only search type.
+ if ( this.searchTypes.indexOf( CategorySelector.SearchType.SubCategories ) > -1 &&
+ this.searchTypes.length > 1
+ ) {
+ throw new Error( 'Can\'t have additional search types with CategorySelector.SearchType.SubCategories' );
+ }
+
+ // If the searchTypes has CategorySelector.SearchType.ParentCategories
+ // it can be the only search type.
+ if ( this.searchTypes.indexOf( CategorySelector.SearchType.ParentCategories ) > -1 &&
+ this.searchTypes.length > 1
+ ) {
+ throw new Error( 'Can\'t have additional search types with CategorySelector.SearchType.ParentCategories' );
+ }
+
+ return true;
+ };
+
+ /**
+ * Sets and validates the value of `this.searchType`.
+ *
+ * @param {mw.widgets.CategorySelector.SearchType[]} searchTypes
+ */
+ CSP.setSearchTypes = function ( searchTypes ) {
+ this.searchTypes = searchTypes;
+ this.validateSearchTypes();
+ };
+
+ /**
+ * Searches categories based on input and searchType.
+ *
+ * @private
+ * @method
+ * @param {string} input The input used to prefix search categories
+ * @param {mw.widgets.CategorySelector.SearchType} searchType
+ * @return {jQuery.Promise} Resolves with an array of categories
+ */
+ CSP.searchCategories = function ( input, searchType ) {
+ var deferred = new $.Deferred();
+
+ switch ( searchType ) {
+ case CategorySelector.SearchType.OpenSearch:
+ this.api.get( {
+ action: 'opensearch',
+ namespace: NS_CATEGORY,
+ limit: this.limit,
+ search: input
+ } ).done( function ( res ) {
+ var categories = res[ 1 ];
+ deferred.resolve( categories );
+ } ).fail( deferred.reject.bind( deferred ) );
+ break;
+
+ case CategorySelector.SearchType.InternalSearch:
+ this.api.get( {
+ action: 'query',
+ list: 'allpages',
+ apnamespace: NS_CATEGORY,
+ aplimit: this.limit,
+ apfrom: input,
+ apprefix: input
+ } ).done( function ( res ) {
+ var categories = res.query.allpages.map( function ( page ) {
+ return page.title;
+ } );
+ deferred.resolve( categories );
+ } ).fail( deferred.reject.bind( deferred ) );
+ break;
+
+ case CategorySelector.SearchType.Exists:
+ if ( input.indexOf( '|' ) > -1 ) {
+ deferred.resolve( [] );
+ break;
+ }
+
+ this.api.get( {
+ action: 'query',
+ prop: 'info',
+ titles: 'Category:' + input
+ } ).done( function ( res ) {
+ var page,
+ categories = [];
+
+ for ( page in res.query.pages ) {
+ if ( parseInt( page, 10 ) > -1 ) {
+ categories.push( res.query.pages[ page ].title );
+ }
+ }
+
+ deferred.resolve( categories );
+ } ).fail( deferred.reject.bind( deferred ) );
+ break;
+
+ case CategorySelector.SearchType.SubCategories:
+ if ( input.indexOf( '|' ) > -1 ) {
+ deferred.resolve( [] );
+ break;
+ }
+
+ this.api.get( {
+ action: 'query',
+ list: 'categorymembers',
+ cmtype: 'subcat',
+ cmlimit: this.limit,
+ cmtitle: 'Category:' + input
+ } ).done( function ( res ) {
+ var categories = res.query.categorymembers.map( function ( category ) {
+ return category.title;
+ } );
+ deferred.resolve( categories );
+ } ).fail( deferred.reject.bind( deferred ) );
+ break;
+
+ case CategorySelector.SearchType.ParentCategories:
+ if ( input.indexOf( '|' ) > -1 ) {
+ deferred.resolve( [] );
+ break;
+ }
+
+ this.api.get( {
+ action: 'query',
+ prop: 'categories',
+ cllimit: this.limit,
+ titles: 'Category:' + input
+ } ).done( function ( res ) {
+ var page,
+ categories = [];
+
+ for ( page in res.query.pages ) {
+ if ( parseInt( page, 10 ) > -1 ) {
+ if ( $.isArray( res.query.pages[ page ].categories ) ) {
+ categories.push.apply( categories, res.query.pages[ page ].categories.map( function ( category ) {
+ return category.title;
+ } ) );
+ }
+ }
+ }
+
+ deferred.resolve( categories );
+ } ).fail( deferred.reject.bind( deferred ) );
+ break;
+
+ default:
+ throw new Error( 'Unknown searchType' );
+ }
+
+ return deferred.promise();
+ };
+
+ /**
+ * @enum mw.widgets.CategorySelector.SearchType
+ * Types of search available.
+ */
+ CategorySelector.SearchType = {
+ /** Search using action=opensearch */
+ OpenSearch: 0,
+
+ /** Search using action=query */
+ InternalSearch: 1,
+
+ /** Search for existing categories with the exact title */
+ Exists: 2,
+
+ /** Search only subcategories */
+ SubCategories: 3,
+
+ /** Search only parent categories */
+ ParentCategories: 4
+ };
+
+ mw.widgets.CategorySelector = CategorySelector;
+}( jQuery, mediaWiki ) );
diff --git a/resources/src/mediawiki.widgets/mw.widgets.ComplexNamespaceInputWidget.base.css b/resources/src/mediawiki.widgets/mw.widgets.ComplexNamespaceInputWidget.base.css
new file mode 100644
index 00000000..b60883e9
--- /dev/null
+++ b/resources/src/mediawiki.widgets/mw.widgets.ComplexNamespaceInputWidget.base.css
@@ -0,0 +1,26 @@
+/*!
+ * MediaWiki Widgets - base ComplexNamespaceInputWidget styles.
+ *
+ * @copyright 2011-2015 MediaWiki Widgets Team and others; see AUTHORS.txt
+ * @license The MIT License (MIT); see LICENSE.txt
+ */
+
+.mw-widget-complexNamespaceInputWidget .mw-widget-namespaceInputWidget,
+.mw-widget-complexNamespaceInputWidget .oo-ui-fieldLayout {
+ display: inline-block;
+ margin-right: 1em;
+}
+
+/* TODO FieldLayout is not supposed to be used the way we use it here */
+.mw-widget-complexNamespaceInputWidget .oo-ui-fieldLayout {
+ vertical-align: middle;
+ margin-bottom: 0;
+}
+
+.mw-widget-complexNamespaceInputWidget .oo-ui-fieldLayout.oo-ui-fieldLayout-align-inline.oo-ui-labelElement > .oo-ui-fieldLayout-body > .oo-ui-labelElement-label {
+ padding-left: 0.5em;
+}
+
+.mw-widget-complexNamespaceInputWidget .mw-widget-namespaceInputWidget {
+ max-width: 20em;
+}
diff --git a/resources/src/mediawiki.widgets/mw.widgets.ComplexNamespaceInputWidget.js b/resources/src/mediawiki.widgets/mw.widgets.ComplexNamespaceInputWidget.js
new file mode 100644
index 00000000..f67ed3de
--- /dev/null
+++ b/resources/src/mediawiki.widgets/mw.widgets.ComplexNamespaceInputWidget.js
@@ -0,0 +1,118 @@
+/*!
+ * MediaWiki Widgets - ComplexNamespaceInputWidget class.
+ *
+ * @copyright 2011-2015 MediaWiki Widgets Team and others; see AUTHORS.txt
+ * @license The MIT License (MIT); see LICENSE.txt
+ */
+( function ( $, mw ) {
+
+ /**
+ * Namespace input widget. Displays a dropdown box with the choice of available namespaces, plus
+ * two checkboxes to include associated namespace or to invert selection.
+ *
+ * @class
+ * @extends OO.ui.Widget
+ *
+ * @constructor
+ * @param {Object} [config] Configuration options
+ * @cfg {Object} namespace Configuration for the NamespaceInputWidget dropdown with list
+ * of namespaces
+ * @cfg {string} namespace.includeAllValue If specified, add a "all namespaces"
+ * option to the dropdown, and use this as the input value for it
+ * @cfg {Object} invert Configuration for the "invert selection" CheckboxInputWidget. If
+ * null, the checkbox will not be generated.
+ * @cfg {Object} associated Configuration for the "include associated namespace"
+ * CheckboxInputWidget. If null, the checkbox will not be generated.
+ * @cfg {Object} invertLabel Configuration for the FieldLayout with label wrapping the
+ * "invert selection" checkbox
+ * @cfg {string} invertLabel.label Label text for the label
+ * @cfg {Object} associatedLabel Configuration for the FieldLayout with label wrapping
+ * the "include associated namespace" checkbox
+ * @cfg {string} associatedLabel.label Label text for the label
+ */
+ mw.widgets.ComplexNamespaceInputWidget = function MwWidgetsComplexNamespaceInputWidget( config ) {
+ // Configuration initialization
+ config = $.extend(
+ {
+ // Config options for nested widgets
+ namespace: {},
+ invert: {},
+ invertLabel: {},
+ associated: {},
+ associatedLabel: {}
+ },
+ config
+ );
+
+ // Parent constructor
+ mw.widgets.ComplexNamespaceInputWidget.parent.call( this, config );
+
+ // Properties
+ this.config = config;
+
+ this.namespace = new mw.widgets.NamespaceInputWidget( config.namespace );
+ if ( config.associated !== null ) {
+ this.associated = new OO.ui.CheckboxInputWidget( $.extend(
+ { value: '1' },
+ config.associated
+ ) );
+ // TODO Should use a LabelWidget? But they don't work like HTML <label>s yet
+ this.associatedLabel = new OO.ui.FieldLayout(
+ this.associated,
+ $.extend(
+ { align: 'inline' },
+ config.associatedLabel
+ )
+ );
+ }
+ if ( config.invert !== null ) {
+ this.invert = new OO.ui.CheckboxInputWidget( $.extend(
+ { value: '1' },
+ config.invert
+ ) );
+ // TODO Should use a LabelWidget? But they don't work like HTML <label>s yet
+ this.invertLabel = new OO.ui.FieldLayout(
+ this.invert,
+ $.extend(
+ { align: 'inline' },
+ config.invertLabel
+ )
+ );
+ }
+
+ // Events
+ this.namespace.connect( this, { change: 'updateCheckboxesState' } );
+
+ // Initialization
+ this.$element
+ .addClass( 'mw-widget-complexNamespaceInputWidget' )
+ .append(
+ this.namespace.$element,
+ this.invert ? this.invertLabel.$element : '',
+ this.associated ? this.associatedLabel.$element : ''
+ );
+ this.updateCheckboxesState();
+ };
+
+ /* Setup */
+
+ OO.inheritClass( mw.widgets.ComplexNamespaceInputWidget, OO.ui.Widget );
+
+ /* Methods */
+
+ /**
+ * Update the disabled state of checkboxes when the value of namespace dropdown changes.
+ *
+ * @private
+ */
+ mw.widgets.ComplexNamespaceInputWidget.prototype.updateCheckboxesState = function () {
+ var disabled = this.namespace.getValue() === this.namespace.allValue;
+ if ( this.invert ) {
+ this.invert.setDisabled( disabled );
+ }
+ if ( this.associated ) {
+ this.associated.setDisabled( disabled );
+ }
+ };
+
+}( jQuery, mediaWiki ) );
diff --git a/resources/src/mediawiki.widgets/mw.widgets.ComplexTitleInputWidget.base.css b/resources/src/mediawiki.widgets/mw.widgets.ComplexTitleInputWidget.base.css
new file mode 100644
index 00000000..73a50d8f
--- /dev/null
+++ b/resources/src/mediawiki.widgets/mw.widgets.ComplexTitleInputWidget.base.css
@@ -0,0 +1,20 @@
+/*!
+ * MediaWiki Widgets - base ComplexTitleInputWidget styles.
+ *
+ * @copyright 2011-2015 MediaWiki Widgets Team and others; see AUTHORS.txt
+ * @license The MIT License (MIT); see LICENSE.txt
+ */
+
+.mw-widget-complexTitleInputWidget .mw-widget-namespaceInputWidget,
+.mw-widget-complexTitleInputWidget .mw-widget-titleInputWidget {
+ display: inline-block;
+}
+
+.mw-widget-complexTitleInputWidget .mw-widget-namespaceInputWidget {
+ max-width: 20em;
+ margin-right: 0.5em;
+}
+
+.mw-widget-complexTitleInputWidget .mw-widget-titleInputWidget {
+ max-width: 29.5em;
+}
diff --git a/resources/src/mediawiki.widgets/mw.widgets.ComplexTitleInputWidget.js b/resources/src/mediawiki.widgets/mw.widgets.ComplexTitleInputWidget.js
new file mode 100644
index 00000000..0c6c15e4
--- /dev/null
+++ b/resources/src/mediawiki.widgets/mw.widgets.ComplexTitleInputWidget.js
@@ -0,0 +1,63 @@
+/*!
+ * MediaWiki Widgets - ComplexTitleInputWidget class.
+ *
+ * @copyright 2011-2015 MediaWiki Widgets Team and others; see AUTHORS.txt
+ * @license The MIT License (MIT); see LICENSE.txt
+ */
+( function ( $, mw ) {
+
+ /**
+ * Like TitleInputWidget, but the namespace has to be input through a separate dropdown field.
+ *
+ * @class
+ * @extends OO.ui.Widget
+ *
+ * @constructor
+ * @param {Object} [config] Configuration options
+ * @cfg {Object} namespace Configuration for the NamespaceInputWidget dropdown with list of
+ * namespaces
+ * @cfg {Object} title Configuration for the TitleInputWidget text field
+ */
+ mw.widgets.ComplexTitleInputWidget = function MwWidgetsComplexTitleInputWidget( config ) {
+ // Parent constructor
+ mw.widgets.ComplexTitleInputWidget.parent.call( this, config );
+
+ // Properties
+ this.namespace = new mw.widgets.NamespaceInputWidget( config.namespace );
+ this.title = new mw.widgets.TitleInputWidget( $.extend(
+ {},
+ config.title,
+ {
+ relative: true,
+ namespace: config.namespace.value || null
+ }
+ ) );
+
+ // Events
+ this.namespace.connect( this, { change: 'updateTitleNamespace' } );
+
+ // Initialization
+ this.$element
+ .addClass( 'mw-widget-complexTitleInputWidget' )
+ .append(
+ this.namespace.$element,
+ this.title.$element
+ );
+ this.updateTitleNamespace();
+ };
+
+ /* Setup */
+
+ OO.inheritClass( mw.widgets.ComplexTitleInputWidget, OO.ui.Widget );
+
+ /* Methods */
+
+ /**
+ * Update the namespace to use for search suggestions of the title when the value of namespace
+ * dropdown changes.
+ */
+ mw.widgets.ComplexTitleInputWidget.prototype.updateTitleNamespace = function () {
+ this.title.setNamespace( Number( this.namespace.getValue() ) );
+ };
+
+}( jQuery, mediaWiki ) );
diff --git a/resources/src/mediawiki.widgets/mw.widgets.DateInputWidget.js b/resources/src/mediawiki.widgets/mw.widgets.DateInputWidget.js
new file mode 100644
index 00000000..b1e5151b
--- /dev/null
+++ b/resources/src/mediawiki.widgets/mw.widgets.DateInputWidget.js
@@ -0,0 +1,629 @@
+/*!
+ * MediaWiki Widgets – DateInputWidget class.
+ *
+ * @copyright 2011-2015 MediaWiki Widgets Team and others; see AUTHORS.txt
+ * @license The MIT License (MIT); see LICENSE.txt
+ */
+/*global moment */
+( function ( $, mw ) {
+
+ /**
+ * Creates an mw.widgets.DateInputWidget object.
+ *
+ * @example
+ * // Date input widget showcase
+ * var fieldset = new OO.ui.FieldsetLayout( {
+ * items: [
+ * new OO.ui.FieldLayout(
+ * new mw.widgets.DateInputWidget(),
+ * {
+ * align: 'top',
+ * label: 'Select date'
+ * }
+ * ),
+ * new OO.ui.FieldLayout(
+ * new mw.widgets.DateInputWidget( { precision: 'month' } ),
+ * {
+ * align: 'top',
+ * label: 'Select month'
+ * }
+ * ),
+ * new OO.ui.FieldLayout(
+ * new mw.widgets.DateInputWidget( {
+ * inputFormat: 'DD.MM.YYYY',
+ * displayFormat: 'Do [of] MMMM [anno Domini] YYYY'
+ * } ),
+ * {
+ * align: 'top',
+ * label: 'Select date (custom formats)'
+ * }
+ * )
+ * ]
+ * } );
+ * $( 'body' ).append( fieldset.$element );
+ *
+ * The value is stored in 'YYYY-MM-DD' or 'YYYY-MM' format:
+ *
+ * @example
+ * // Accessing values in a date input widget
+ * var dateInput = new mw.widgets.DateInputWidget();
+ * var $label = $( '<p>' );
+ * $( 'body' ).append( $label, dateInput.$element );
+ * dateInput.on( 'change', function () {
+ * // The value will always be a valid date or empty string, malformed input is ignored
+ * var date = dateInput.getValue();
+ * $label.text( 'Selected date: ' + ( date || '(none)' ) );
+ * } );
+ *
+ * @class
+ * @extends OO.ui.InputWidget
+ * @mixins OO.ui.mixin.IndicatorElement
+ *
+ * @constructor
+ * @param {Object} [config] Configuration options
+ * @cfg {string} [precision='day'] Date precision to use, 'day' or 'month'
+ * @cfg {string} [value] Day or month date (depending on `precision`), in the format 'YYYY-MM-DD'
+ * or 'YYYY-MM'. If not given or empty string, no date is selected.
+ * @cfg {string} [inputFormat] Date format string to use for the textual input field. Displayed
+ * while the widget is active, and the user can type in a date in this format. Should be short
+ * and easy to type. When not given, defaults to 'YYYY-MM-DD' or 'YYYY-MM', depending on
+ * `precision`.
+ * @cfg {string} [displayFormat] Date format string to use for the clickable label. Displayed
+ * while the widget is inactive. Should be as unambiguous as possible (for example, prefer to
+ * spell out the month, rather than rely on the order), even if that makes it longer. When not
+ * given, the default is language-specific.
+ * @cfg {string} [placeholder] User-visible date format string displayed in the textual input
+ * field when it's empty. Should be the same as `inputFormat`, but translated to the user's
+ * language. When not given, defaults to a translated version of 'YYYY-MM-DD' or 'YYYY-MM',
+ * depending on `precision`.
+ * @cfg {boolean} [required=false] Mark the field as required. Implies `indicator: 'required'`.
+ * @cfg {string} [mustBeAfter] Validates the date to be after this. In the 'YYYY-MM-DD' format.
+ * @cfg {string} [mustBeBefore] Validates the date to be before this. In the 'YYYY-MM-DD' format.
+ * @cfg {jQuery} [$overlay] Render the calendar into a separate layer. This configuration is
+ * useful in cases where the expanded calendar is larger than its container. The specified
+ * overlay layer is usually on top of the container and has a larger area. By default, the
+ * calendar uses relative positioning.
+ */
+ mw.widgets.DateInputWidget = function MWWDateInputWidget( config ) {
+ // Config initialization
+ config = $.extend( { precision: 'day', required: false }, config );
+ if ( config.required ) {
+ if ( config.indicator === undefined ) {
+ config.indicator = 'required';
+ }
+ }
+
+ var placeholder, mustBeAfter, mustBeBefore;
+ if ( config.placeholder ) {
+ placeholder = config.placeholder;
+ } else if ( config.inputFormat ) {
+ // We have no way to display a translated placeholder for custom formats
+ placeholder = '';
+ } else {
+ // Messages: mw-widgets-dateinput-placeholder-day, mw-widgets-dateinput-placeholder-month
+ placeholder = mw.msg( 'mw-widgets-dateinput-placeholder-' + config.precision );
+ }
+
+ // Properties (must be set before parent constructor, which calls #setValue)
+ this.$handle = $( '<div>' );
+ this.label = new OO.ui.LabelWidget();
+ this.textInput = new OO.ui.TextInputWidget( {
+ required: config.required,
+ placeholder: placeholder,
+ validate: this.validateDate.bind( this )
+ } );
+ this.calendar = new mw.widgets.CalendarWidget( {
+ // Can't pass `$floatableContainer: this.$element` here, the latter is not set yet.
+ // Instead we call setFloatableContainer() below.
+ precision: config.precision
+ } );
+ this.inCalendar = 0;
+ this.inTextInput = 0;
+ this.inputFormat = config.inputFormat;
+ this.displayFormat = config.displayFormat;
+ this.required = config.required;
+
+ // Validate and set min and max dates as properties
+ mustBeAfter = moment( config.mustBeAfter, 'YYYY-MM-DD' );
+ mustBeBefore = moment( config.mustBeBefore, 'YYYY-MM-DD' );
+ if (
+ config.mustBeAfter !== undefined &&
+ mustBeAfter.isValid()
+ ) {
+ this.mustBeAfter = mustBeAfter;
+ }
+
+ if (
+ config.mustBeBefore !== undefined &&
+ mustBeBefore.isValid()
+ ) {
+ this.mustBeBefore = mustBeBefore;
+ }
+
+ // Parent constructor
+ mw.widgets.DateInputWidget.parent.call( this, config );
+
+ // Mixin constructors
+ OO.ui.mixin.IndicatorElement.call( this, config );
+
+ // Events
+ this.calendar.connect( this, {
+ change: 'onCalendarChange'
+ } );
+ this.textInput.connect( this, {
+ enter: 'onEnter',
+ change: 'onTextInputChange'
+ } );
+ this.$element.on( {
+ focusout: this.onBlur.bind( this )
+ } );
+ this.calendar.$element.on( {
+ click: this.onCalendarClick.bind( this ),
+ keypress: this.onCalendarKeyPress.bind( this )
+ } );
+ this.$handle.on( {
+ click: this.onClick.bind( this ),
+ keypress: this.onKeyPress.bind( this )
+ } );
+
+ // Initialization
+ // Move 'tabindex' from this.$input (which is invisible) to the visible handle
+ this.setTabIndexedElement( this.$handle );
+ this.$handle
+ .append( this.label.$element, this.$indicator )
+ .addClass( 'mw-widget-dateInputWidget-handle' );
+ this.calendar.$element
+ .addClass( 'mw-widget-dateInputWidget-calendar' );
+ this.$element
+ .addClass( 'mw-widget-dateInputWidget' )
+ .append( this.$handle, this.textInput.$element, this.calendar.$element );
+
+ if ( config.$overlay ) {
+ this.calendar.setFloatableContainer( this.$element );
+ config.$overlay.append( this.calendar.$element );
+
+ // The text input and calendar are not in DOM order, so fix up focus transitions.
+ this.textInput.$input.on( 'keydown', function ( e ) {
+ if ( e.which === OO.ui.Keys.TAB ) {
+ if ( e.shiftKey ) {
+ // Tabbing backward from text input: normal browser behavior
+ $.noop();
+ } else {
+ // Tabbing forward from text input: just focus the calendar
+ this.calendar.$element.focus();
+ return false;
+ }
+ }
+ }.bind( this ) );
+ this.calendar.$element.on( 'keydown', function ( e ) {
+ if ( e.which === OO.ui.Keys.TAB ) {
+ if ( e.shiftKey ) {
+ // Tabbing backward from calendar: just focus the text input
+ this.textInput.$input.focus();
+ return false;
+ } else {
+ // Tabbing forward from calendar: focus the text input, then allow normal browser
+ // behavior to move focus to next focusable after it
+ this.textInput.$input.focus();
+ }
+ }
+ }.bind( this ) );
+ }
+
+ // Set handle label and hide stuff
+ this.updateUI();
+ this.textInput.toggle( false );
+ this.calendar.toggle( false );
+ };
+
+ /* Inheritance */
+
+ OO.inheritClass( mw.widgets.DateInputWidget, OO.ui.InputWidget );
+ OO.mixinClass( mw.widgets.DateInputWidget, OO.ui.mixin.IndicatorElement );
+
+ /* Methods */
+
+ /**
+ * @inheritdoc
+ * @protected
+ */
+ mw.widgets.DateInputWidget.prototype.getInputElement = function () {
+ return $( '<input type="hidden">' );
+ };
+
+ /**
+ * Respond to calendar date change events.
+ *
+ * @private
+ */
+ mw.widgets.DateInputWidget.prototype.onCalendarChange = function () {
+ this.inCalendar++;
+ if ( !this.inTextInput ) {
+ // If this is caused by user typing in the input field, do not set anything.
+ // The value may be invalid (see #onTextInputChange), but displayable on the calendar.
+ this.setValue( this.calendar.getDate() );
+ }
+ this.inCalendar--;
+ };
+
+ /**
+ * Respond to text input value change events.
+ *
+ * @private
+ */
+ mw.widgets.DateInputWidget.prototype.onTextInputChange = function () {
+ var mom,
+ widget = this,
+ value = this.textInput.getValue(),
+ valid = this.isValidDate( value );
+ this.inTextInput++;
+
+ if ( value === '' ) {
+ // No date selected
+ widget.setValue( '' );
+ } else if ( valid ) {
+ // Well-formed date value, parse and set it
+ mom = moment( value, widget.getInputFormat() );
+ // Use English locale to avoid number formatting
+ widget.setValue( mom.locale( 'en' ).format( widget.getInternalFormat() ) );
+ } else {
+ // Not well-formed, but possibly partial? Try updating the calendar, but do not set the
+ // internal value. Generally this only makes sense when 'inputFormat' is little-endian (e.g.
+ // 'YYYY-MM-DD'), but that's hard to check for, and might be difficult to handle the parsing
+ // right for weird formats. So limit this trick to only when we're using the default
+ // 'inputFormat', which is the same as the internal format, 'YYYY-MM-DD'.
+ if ( widget.getInputFormat() === widget.getInternalFormat() ) {
+ widget.calendar.setDate( widget.textInput.getValue() );
+ }
+ }
+ widget.inTextInput--;
+
+ };
+
+ /**
+ * @inheritdoc
+ */
+ mw.widgets.DateInputWidget.prototype.setValue = function ( value ) {
+ var oldValue = this.value;
+
+ if ( !moment( value, this.getInternalFormat() ).isValid() ) {
+ value = '';
+ }
+
+ mw.widgets.DateInputWidget.parent.prototype.setValue.call( this, value );
+
+ if ( this.value !== oldValue ) {
+ this.updateUI();
+ this.setValidityFlag();
+ }
+
+ return this;
+ };
+
+ /**
+ * Handle text input and calendar blur events.
+ *
+ * @private
+ */
+ mw.widgets.DateInputWidget.prototype.onBlur = function () {
+ var widget = this;
+ setTimeout( function () {
+ var $focussed = $( ':focus' );
+ // Deactivate unless the focus moved to something else inside this widget
+ if (
+ !OO.ui.contains( widget.$element[ 0 ], $focussed[ 0 ], true ) &&
+ // Calendar might be in an $overlay
+ !OO.ui.contains( widget.calendar.$element[ 0 ], $focussed[ 0 ], true )
+ ) {
+ widget.deactivate();
+ }
+ }, 0 );
+ };
+
+ /**
+ * @inheritdoc
+ */
+ mw.widgets.DateInputWidget.prototype.focus = function () {
+ this.activate();
+ return this;
+ };
+
+ /**
+ * @inheritdoc
+ */
+ mw.widgets.DateInputWidget.prototype.blur = function () {
+ this.deactivate();
+ return this;
+ };
+
+ /**
+ * Update the contents of the label, text input and status of calendar to reflect selected value.
+ *
+ * @private
+ */
+ mw.widgets.DateInputWidget.prototype.updateUI = function () {
+ if ( this.getValue() === '' ) {
+ this.textInput.setValue( '' );
+ this.calendar.setDate( null );
+ this.label.setLabel( mw.msg( 'mw-widgets-dateinput-no-date' ) );
+ this.$element.addClass( 'mw-widget-dateInputWidget-empty' );
+ } else {
+ if ( !this.inTextInput ) {
+ this.textInput.setValue( this.getMoment().format( this.getInputFormat() ) );
+ }
+ if ( !this.inCalendar ) {
+ this.calendar.setDate( this.getValue() );
+ }
+ this.label.setLabel( this.getMoment().format( this.getDisplayFormat() ) );
+ this.$element.removeClass( 'mw-widget-dateInputWidget-empty' );
+ }
+ };
+
+ /**
+ * Deactivate this input field for data entry. Closes the calendar and hides the text field.
+ *
+ * @private
+ */
+ mw.widgets.DateInputWidget.prototype.deactivate = function () {
+ this.$element.removeClass( 'mw-widget-dateInputWidget-active' );
+ this.$handle.show();
+ this.textInput.toggle( false );
+ this.calendar.toggle( false );
+ this.setValidityFlag();
+ };
+
+ /**
+ * Activate this input field for data entry. Opens the calendar and shows the text field.
+ *
+ * @private
+ */
+ mw.widgets.DateInputWidget.prototype.activate = function () {
+ this.calendar.resetUI();
+ this.$element.addClass( 'mw-widget-dateInputWidget-active' );
+ this.$handle.hide();
+ this.textInput.toggle( true );
+ this.calendar.toggle( true );
+
+ this.textInput.$input.focus();
+ };
+
+ /**
+ * Get the date format to be used for handle label when the input is inactive.
+ *
+ * @private
+ * @return {string} Format string
+ */
+ mw.widgets.DateInputWidget.prototype.getDisplayFormat = function () {
+ if ( this.displayFormat !== undefined ) {
+ return this.displayFormat;
+ }
+
+ if ( this.calendar.getPrecision() === 'month' ) {
+ return 'MMMM YYYY';
+ } else {
+ // The formats Moment.js provides:
+ // * ll: Month name, day of month, year
+ // * lll: Month name, day of month, year, time
+ // * llll: Month name, day of month, day of week, year, time
+ //
+ // The format we want:
+ // * ????: Month name, day of month, day of week, year
+ //
+ // We try to construct it as 'llll - (lll - ll)' and hope for the best.
+ // This seems to work well for many languages (maybe even all?).
+
+ var localeData = moment.localeData( moment.locale() ),
+ llll = localeData.longDateFormat( 'llll' ),
+ lll = localeData.longDateFormat( 'lll' ),
+ ll = localeData.longDateFormat( 'll' ),
+ format = llll.replace( lll.replace( ll, '' ), '' );
+
+ return format;
+ }
+ };
+
+ /**
+ * Get the date format to be used for the text field when the input is active.
+ *
+ * @private
+ * @return {string} Format string
+ */
+ mw.widgets.DateInputWidget.prototype.getInputFormat = function () {
+ if ( this.inputFormat !== undefined ) {
+ return this.inputFormat;
+ }
+
+ return {
+ day: 'YYYY-MM-DD',
+ month: 'YYYY-MM'
+ }[ this.calendar.getPrecision() ];
+ };
+
+ /**
+ * Get the date format to be used internally for the value. This is not configurable in any way,
+ * and always either 'YYYY-MM-DD' or 'YYYY-MM'.
+ *
+ * @private
+ * @return {string} Format string
+ */
+ mw.widgets.DateInputWidget.prototype.getInternalFormat = function () {
+ return {
+ day: 'YYYY-MM-DD',
+ month: 'YYYY-MM'
+ }[ this.calendar.getPrecision() ];
+ };
+
+ /**
+ * Get the Moment object for current value.
+ *
+ * @return {Object} Moment object
+ */
+ mw.widgets.DateInputWidget.prototype.getMoment = function () {
+ return moment( this.getValue(), this.getInternalFormat() );
+ };
+
+ /**
+ * Handle mouse click events.
+ *
+ * @private
+ * @param {jQuery.Event} e Mouse click event
+ */
+ mw.widgets.DateInputWidget.prototype.onClick = function ( e ) {
+ if ( !this.isDisabled() && e.which === 1 ) {
+ this.activate();
+ }
+ return false;
+ };
+
+ /**
+ * Handle key press events.
+ *
+ * @private
+ * @param {jQuery.Event} e Key press event
+ */
+ mw.widgets.DateInputWidget.prototype.onKeyPress = function ( e ) {
+ if ( !this.isDisabled() &&
+ ( e.which === OO.ui.Keys.SPACE || e.which === OO.ui.Keys.ENTER )
+ ) {
+ this.activate();
+ return false;
+ }
+ };
+
+ /**
+ * Handle calendar key press events.
+ *
+ * @private
+ * @param {jQuery.Event} e Key press event
+ */
+ mw.widgets.DateInputWidget.prototype.onCalendarKeyPress = function ( e ) {
+ if ( !this.isDisabled() && e.which === OO.ui.Keys.ENTER ) {
+ this.deactivate();
+ this.$handle.focus();
+ return false;
+ }
+ };
+
+ /**
+ * Handle calendar click events.
+ *
+ * @private
+ * @param {jQuery.Event} e Mouse click event
+ */
+ mw.widgets.DateInputWidget.prototype.onCalendarClick = function ( e ) {
+ if (
+ !this.isDisabled() &&
+ e.which === 1 &&
+ $( e.target ).hasClass( 'mw-widget-calendarWidget-day' )
+ ) {
+ this.deactivate();
+ this.$handle.focus();
+ return false;
+ }
+ };
+
+ /**
+ * Handle text input enter events.
+ *
+ * @private
+ */
+ mw.widgets.DateInputWidget.prototype.onEnter = function () {
+ this.deactivate();
+ this.$handle.focus();
+ };
+
+ /**
+ * @private
+ * @param {string} date Date string, to be valid, must be in 'YYYY-MM-DD' or 'YYYY-MM' format or
+ * (unless the field is required) empty
+ * @returns {boolean}
+ */
+ mw.widgets.DateInputWidget.prototype.validateDate = function ( date ) {
+ var isValid;
+ if ( date === '' ) {
+ isValid = !this.required;
+ } else {
+ isValid = this.isValidDate( date ) && this.isInRange( date );
+ }
+ return isValid;
+ };
+
+ /**
+ * @private
+ * @param {string} date Date string, to be valid, must be in 'YYYY-MM-DD' or 'YYYY-MM' format
+ * @returns {boolean}
+ */
+ mw.widgets.DateInputWidget.prototype.isValidDate = function ( date ) {
+ // "Half-strict mode": for example, for the format 'YYYY-MM-DD', 2015-1-3 instead of 2015-01-03
+ // is okay, but 2015-01 isn't, and neither is 2015-01-foo. Use Moment's "fuzzy" mode and check
+ // parsing flags for the details (stoled from implementation of moment#isValid).
+ var
+ mom = moment( date, this.getInputFormat() ),
+ flags = mom.parsingFlags();
+
+ return mom.isValid() && flags.charsLeftOver === 0 && flags.unusedTokens.length === 0;
+ };
+
+ /**
+ * Validates if the date is within the range configured with {@link #cfg-mustBeAfter}
+ * and {@link #cfg-mustBeBefore}.
+ *
+ * @private
+ * @param {string} date Date string, to be valid, must be empty (no date selected) or in
+ * 'YYYY-MM-DD' or 'YYYY-MM' format to be valid
+ * @returns {boolean}
+ */
+ mw.widgets.DateInputWidget.prototype.isInRange = function ( date ) {
+ var momentDate = moment( date, 'YYYY-MM-DD' ),
+ isAfter = ( this.mustBeAfter === undefined || momentDate.isAfter( this.mustBeAfter ) ),
+ isBefore = ( this.mustBeBefore === undefined || momentDate.isBefore( this.mustBeBefore ) );
+
+ return isAfter && isBefore;
+ };
+
+ /**
+ * Get the validity of current value.
+ *
+ * This method returns a promise that resolves if the value is valid and rejects if
+ * it isn't. Uses {@link #validateDate}.
+ *
+ * @return {jQuery.Promise} A promise that resolves if the value is valid, rejects if not.
+ */
+ mw.widgets.DateInputWidget.prototype.getValidity = function () {
+ var isValid = this.validateDate( this.getValue() );
+
+ if ( isValid ) {
+ return $.Deferred().resolve().promise();
+ } else {
+ return $.Deferred().reject().promise();
+ }
+ };
+
+ /**
+ * Sets the 'invalid' flag appropriately.
+ *
+ * @param {boolean} [isValid] Optionally override validation result
+ */
+ mw.widgets.DateInputWidget.prototype.setValidityFlag = function ( isValid ) {
+ var widget = this,
+ setFlag = function ( valid ) {
+ if ( !valid ) {
+ widget.$input.attr( 'aria-invalid', 'true' );
+ } else {
+ widget.$input.removeAttr( 'aria-invalid' );
+ }
+ widget.setFlags( { invalid: !valid } );
+ };
+
+ if ( isValid !== undefined ) {
+ setFlag( isValid );
+ } else {
+ this.getValidity().then( function () {
+ setFlag( true );
+ }, function () {
+ setFlag( false );
+ } );
+ }
+ };
+
+}( jQuery, mediaWiki ) );
diff --git a/resources/src/mediawiki.widgets/mw.widgets.DateInputWidget.less b/resources/src/mediawiki.widgets/mw.widgets.DateInputWidget.less
new file mode 100644
index 00000000..873cca19
--- /dev/null
+++ b/resources/src/mediawiki.widgets/mw.widgets.DateInputWidget.less
@@ -0,0 +1,134 @@
+/*!
+ * MediaWiki Widgets – DateInputWidget styles.
+ *
+ * @copyright 2011-2015 MediaWiki Widgets Team and others; see AUTHORS.txt
+ * @license The MIT License (MIT); see LICENSE.txt
+ */
+
+.oo-ui-box-sizing( @type: border-box ) {
+ -webkit-box-sizing: @type;
+ -moz-box-sizing: @type;
+ box-sizing: @type;
+}
+
+.oo-ui-unselectable() {
+ -webkit-touch-callout: none;
+ -webkit-user-select: none;
+ -moz-user-select: none;
+ -ms-user-select: none;
+ user-select: none;
+}
+
+.oo-ui-inline-spacing( @spacing, @cancelled-spacing: 0 ) {
+ margin-right: @spacing;
+ &:last-child {
+ margin-right: @cancelled-spacing;
+ }
+}
+
+@indicator-size: unit(12 / 16 / 0.8, em);
+
+.mw-widget-dateInputWidget {
+ display: inline-block;
+ position: relative;
+
+ &-handle {
+ width: 100%;
+ display: inline-block;
+ cursor: pointer;
+ position: relative;
+
+ .oo-ui-unselectable();
+ .oo-ui-box-sizing(border-box);
+
+ > .oo-ui-indicatorElement-indicator {
+ display: none;
+ }
+ }
+
+ &.oo-ui-indicatorElement .mw-widget-dateInputWidget-handle > .oo-ui-indicatorElement-indicator {
+ display: block;
+ position: absolute;
+ top: 0;
+ right: 0;
+ height: 100%;
+ }
+
+ &.oo-ui-widget-disabled .mw-widget-dateInputWidget-handle {
+ cursor: default;
+ }
+
+ &-calendar {
+ position: absolute;
+ z-index: 1;
+ }
+
+ // Theme-specific styles
+ width: 21em;
+ margin: 0.25em 0;
+
+ .oo-ui-inline-spacing(0.5em);
+
+ &-handle {
+ padding: 0.5em 1em;
+ border: 1px solid #ccc;
+ border-radius: 0.1em;
+ line-height: 1.275em;
+ background-color: white;
+ }
+
+ &.oo-ui-indicatorElement .mw-widget-dateInputWidget-handle > .oo-ui-indicatorElement-indicator {
+ width: @indicator-size;
+ margin: 0 0.775em;
+ }
+
+ > .oo-ui-textInputWidget input {
+ padding-left: 1em;
+ }
+
+ > .oo-ui-textInputWidget {
+ z-index: 2;
+ }
+
+ &-calendar {
+ background-color: white;
+ margin-top: -2px;
+
+ &:focus {
+ z-index: 3;
+ }
+ }
+
+ &.oo-ui-widget-enabled {
+ .mw-widget-dateInputWidget-handle:hover {
+ border-color: #347bff;
+ }
+ }
+
+ &.oo-ui-widget-disabled {
+ .mw-widget-dateInputWidget-handle {
+ color: #ccc;
+ text-shadow: 0 1px 1px #fff;
+ border-color: #ddd;
+ background-color: #f3f3f3;
+
+ > .oo-ui-indicatorElement-indicator {
+ opacity: 0.2;
+ }
+ }
+
+ }
+
+ &.oo-ui-flaggedElement-invalid {
+ .mw-widget-dateInputWidget-handle {
+ border-color: red;
+ box-shadow: inset 0 0 0 0 red;
+ }
+ }
+
+ &-empty {
+ .mw-widget-dateInputWidget-handle {
+ color: #ccc;
+ }
+ }
+}
diff --git a/resources/src/mediawiki.widgets/mw.widgets.NamespaceInputWidget.js b/resources/src/mediawiki.widgets/mw.widgets.NamespaceInputWidget.js
new file mode 100644
index 00000000..4f1b8749
--- /dev/null
+++ b/resources/src/mediawiki.widgets/mw.widgets.NamespaceInputWidget.js
@@ -0,0 +1,69 @@
+/*!
+ * MediaWiki Widgets - NamespaceInputWidget class.
+ *
+ * @copyright 2011-2015 MediaWiki Widgets Team and others; see AUTHORS.txt
+ * @license The MIT License (MIT); see LICENSE.txt
+ */
+( function ( $, mw ) {
+
+ /**
+ * Namespace input widget. Displays a dropdown box with the choice of available namespaces.
+ *
+ * @class
+ * @extends OO.ui.DropdownInputWidget
+ *
+ * @constructor
+ * @param {Object} [config] Configuration options
+ * @cfg {string|null} [includeAllValue] Value for "all namespaces" option, if any
+ * @cfg {number[]} [exclude] List of namespace numbers to exclude from the selector
+ */
+ mw.widgets.NamespaceInputWidget = function MwWidgetsNamespaceInputWidget( config ) {
+ // Configuration initialization
+ config = $.extend( {}, config, { options: this.getNamespaceDropdownOptions( config ) } );
+
+ // Parent constructor
+ mw.widgets.NamespaceInputWidget.parent.call( this, config );
+
+ // Initialization
+ this.$element.addClass( 'mw-widget-namespaceInputWidget' );
+ };
+
+ /* Setup */
+
+ OO.inheritClass( mw.widgets.NamespaceInputWidget, OO.ui.DropdownInputWidget );
+
+ /* Methods */
+
+ /**
+ * @private
+ */
+ mw.widgets.NamespaceInputWidget.prototype.getNamespaceDropdownOptions = function ( config ) {
+ var options,
+ exclude = config.exclude || [],
+ NS_MAIN = 0;
+
+ options = $.map( mw.config.get( 'wgFormattedNamespaces' ), function ( name, ns ) {
+ if ( ns < NS_MAIN || exclude.indexOf( Number( ns ) ) !== -1 ) {
+ return null; // skip
+ }
+ ns = String( ns );
+ if ( ns === String( NS_MAIN ) ) {
+ name = mw.message( 'blanknamespace' ).text();
+ }
+ return { data: ns, label: name };
+ } ).sort( function ( a, b ) {
+ // wgFormattedNamespaces is an object, and so technically doesn't have to be ordered
+ return a.data - b.data;
+ } );
+
+ if ( config.includeAllValue !== null && config.includeAllValue !== undefined ) {
+ options.unshift( {
+ data: config.includeAllValue,
+ label: mw.message( 'namespacesall' ).text()
+ } );
+ }
+
+ return options;
+ };
+
+}( jQuery, mediaWiki ) );
diff --git a/resources/src/mediawiki.widgets/mw.widgets.TitleInputWidget.css b/resources/src/mediawiki.widgets/mw.widgets.TitleInputWidget.css
new file mode 100644
index 00000000..2c24b2bb
--- /dev/null
+++ b/resources/src/mediawiki.widgets/mw.widgets.TitleInputWidget.css
@@ -0,0 +1,57 @@
+/*!
+ * 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;
+}
diff --git a/resources/src/mediawiki.widgets/mw.widgets.TitleInputWidget.js b/resources/src/mediawiki.widgets/mw.widgets.TitleInputWidget.js
new file mode 100644
index 00000000..d5a7abc6
--- /dev/null
+++ b/resources/src/mediawiki.widgets/mw.widgets.TitleInputWidget.js
@@ -0,0 +1,341 @@
+/*!
+ * MediaWiki Widgets - TitleInputWidget 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.TitleInputWidget object.
+ *
+ * @class
+ * @extends OO.ui.TextInputWidget
+ * @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 );
+
+ // Parent constructor
+ mw.widgets.TitleInputWidget.parent.call( this, $.extend( {}, config, { autocomplete: false } ) );
+
+ // Mixin constructors
+ 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' );
+ if ( this.showImages ) {
+ this.lookupMenu.$element.addClass( 'mw-widget-titleInputWidget-menu-withImages' );
+ }
+ if ( this.showDescriptions ) {
+ this.lookupMenu.$element.addClass( 'mw-widget-titleInputWidget-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, OO.ui.mixin.LookupElement );
+
+ /* Methods */
+
+ /**
+ * Get the namespace to prepend to titles in suggestions, if any.
+ *
+ * @return {number|null} Namespace number
+ */
+ mw.widgets.TitleInputWidget.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.TitleInputWidget.prototype.setNamespace = function ( namespace ) {
+ this.namespace = namespace;
+ this.lookupCache = {};
+ this.closeLookupMenu();
+ };
+
+ /**
+ * @inheritdoc
+ */
+ mw.widgets.TitleInputWidget.prototype.onLookupMenuItemChoose = function ( item ) {
+ this.closeLookupMenu();
+ this.setLookupsDisabled( true );
+ this.setValue( item.getData() );
+ this.setLookupsDisabled( !this.suggestions );
+ };
+
+ /**
+ * @inheritdoc
+ */
+ mw.widgets.TitleInputWidget.prototype.focus = function () {
+ var retval;
+
+ // Prevent programmatic focus from opening the menu
+ this.setLookupsDisabled( true );
+
+ // Parent method
+ retval = mw.widgets.TitleInputWidget.parent.prototype.focus.apply( this, arguments );
+
+ this.setLookupsDisabled( !this.suggestions );
+
+ 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;
+ 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 ) );
diff --git a/resources/src/mediawiki.widgets/mw.widgets.TitleOptionWidget.js b/resources/src/mediawiki.widgets/mw.widgets.TitleOptionWidget.js
new file mode 100644
index 00000000..ec0c9357
--- /dev/null
+++ b/resources/src/mediawiki.widgets/mw.widgets.TitleOptionWidget.js
@@ -0,0 +1,82 @@
+/*!
+ * MediaWiki Widgets - TitleOptionWidget class.
+ *
+ * @copyright 2011-2015 MediaWiki Widgets Team and others; see AUTHORS.txt
+ * @license The MIT License (MIT); see LICENSE.txt
+ */
+( function ( $, mw ) {
+
+ /**
+ * Creates a mw.widgets.TitleOptionWidget object.
+ *
+ * @class
+ * @extends OO.ui.MenuOptionWidget
+ *
+ * @constructor
+ * @param {Object} [config] Configuration options
+ * @cfg {string} [data] Label to display
+ * @cfg {mw.Title} [title] Page title object
+ * @cfg {string} [imageUrl] Thumbnail image URL with URL encoding
+ * @cfg {string} [description] Page description
+ * @cfg {boolean} [missing] Page doesn't exist
+ * @cfg {boolean} [redirect] Page is a redirect
+ * @cfg {boolean} [disambiguation] Page is a disambiguation page
+ * @cfg {string} [query] Matching query string
+ */
+ mw.widgets.TitleOptionWidget = function MwWidgetsTitleOptionWidget( config ) {
+ var icon;
+
+ if ( config.missing ) {
+ icon = 'page-not-found';
+ } else if ( config.redirect ) {
+ icon = 'page-redirect';
+ } else if ( config.disambiguation ) {
+ icon = 'page-disambiguation';
+ } else {
+ icon = 'page-existing';
+ }
+
+ // Config initialization
+ config = $.extend( {
+ icon: icon,
+ label: config.data,
+ href: config.title.getUrl(),
+ autoFitLabel: false
+ }, config );
+
+ // Parent constructor
+ mw.widgets.TitleOptionWidget.parent.call( this, config );
+
+ // Initialization
+ this.$label.wrap( '<a>' );
+ this.$link = this.$label.parent();
+ this.$link.attr( 'href', config.href );
+ this.$element.addClass( 'mw-widget-titleOptionWidget' );
+
+ // Highlight matching parts of link suggestion
+ this.$label.autoEllipsis( { hasSpan: false, tooltip: true, matchText: config.query } );
+
+ if ( config.missing ) {
+ this.$link.addClass( 'new' );
+ }
+
+ if ( config.imageUrl ) {
+ this.$icon
+ .addClass( 'mw-widget-titleOptionWidget-hasImage' )
+ .css( 'background-image', 'url(' + config.imageUrl + ')' );
+ }
+
+ if ( config.description ) {
+ this.$element.append(
+ $( '<span>' )
+ .addClass( 'mw-widget-titleOptionWidget-description' )
+ .text( config.description )
+ );
+ }
+ };
+
+ /* Setup */
+
+ OO.inheritClass( mw.widgets.TitleOptionWidget, OO.ui.MenuOptionWidget );
+
+}( jQuery, mediaWiki ) );
diff --git a/resources/src/mediawiki.widgets/mw.widgets.UserInputWidget.js b/resources/src/mediawiki.widgets/mw.widgets.UserInputWidget.js
new file mode 100644
index 00000000..0d0fb735
--- /dev/null
+++ b/resources/src/mediawiki.widgets/mw.widgets.UserInputWidget.js
@@ -0,0 +1,119 @@
+/*!
+ * MediaWiki Widgets - UserInputWidget class.
+ *
+ * @copyright 2011-2015 MediaWiki Widgets Team and others; see AUTHORS.txt
+ * @license The MIT License (MIT); see LICENSE.txt
+ */
+( function ( $, mw ) {
+
+ /**
+ * Creates a mw.widgets.UserInputWidget object.
+ *
+ * @class
+ * @extends OO.ui.TextInputWidget
+ * @mixins OO.ui.mixin.LookupElement
+ *
+ * @constructor
+ * @param {Object} [config] Configuration options
+ * @cfg {number} [limit=10] Number of results to show
+ */
+ mw.widgets.UserInputWidget = function MwWidgetsUserInputWidget( config ) {
+ // Config initialization
+ config = config || {};
+
+ // Parent constructor
+ mw.widgets.UserInputWidget.parent.call( this, $.extend( {}, config, { autocomplete: false } ) );
+
+ // Mixin constructors
+ OO.ui.mixin.LookupElement.call( this, config );
+
+ // Properties
+ this.limit = config.limit || 10;
+
+ // Initialization
+ this.$element.addClass( 'mw-widget-userInputWidget' );
+ this.lookupMenu.$element.addClass( 'mw-widget-userInputWidget-menu' );
+ };
+
+ /* Setup */
+
+ OO.inheritClass( mw.widgets.UserInputWidget, OO.ui.TextInputWidget );
+ OO.mixinClass( mw.widgets.UserInputWidget, OO.ui.mixin.LookupElement );
+
+ /* Methods */
+
+ /**
+ * @inheritdoc
+ */
+ mw.widgets.UserInputWidget.prototype.onLookupMenuItemChoose = function ( item ) {
+ this.closeLookupMenu();
+ this.setLookupsDisabled( true );
+ this.setValue( item.getData() );
+ this.setLookupsDisabled( false );
+ };
+
+ /**
+ * @inheritdoc
+ */
+ mw.widgets.UserInputWidget.prototype.focus = function () {
+ var retval;
+
+ // Prevent programmatic focus from opening the menu
+ this.setLookupsDisabled( true );
+
+ // Parent method
+ retval = mw.widgets.UserInputWidget.parent.prototype.focus.apply( this, arguments );
+
+ this.setLookupsDisabled( false );
+
+ return retval;
+ };
+
+ /**
+ * @inheritdoc
+ */
+ mw.widgets.UserInputWidget.prototype.getLookupRequest = function () {
+ var inputValue = this.value;
+
+ return new mw.Api().get( {
+ action: 'query',
+ list: 'allusers',
+ // Prefix of list=allusers is case sensitive. Normalise first
+ // character to uppercase so that "fo" may yield "Foo".
+ auprefix: inputValue[ 0 ].toUpperCase() + inputValue.slice( 1 ),
+ aulimit: this.limit
+ } );
+ };
+
+ /**
+ * Get lookup cache item from server response data.
+ *
+ * @method
+ * @param {Mixed} response Response from server
+ */
+ mw.widgets.UserInputWidget.prototype.getLookupCacheDataFromResponse = function ( response ) {
+ return response.query.allusers || {};
+ };
+
+ /**
+ * Get list of menu items from a server response.
+ *
+ * @param {Object} data Query result
+ * @returns {OO.ui.MenuOptionWidget[]} Menu items
+ */
+ mw.widgets.UserInputWidget.prototype.getLookupMenuOptionsFromData = function ( data ) {
+ var len, i, user,
+ items = [];
+
+ for ( i = 0, len = data.length; i < len; i++ ) {
+ user = data[ i ] || {};
+ items.push( new OO.ui.MenuOptionWidget( {
+ label: user.name,
+ data: user.name
+ } ) );
+ }
+
+ return items;
+ };
+
+}( jQuery, mediaWiki ) );