diff options
Diffstat (limited to 'resources/src/mediawiki.widgets')
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 ) ); |