From 76b630185496ef7c8e29efd4b5d99f756bfaaea7 Mon Sep 17 00:00:00 2001 From: =?utf8?q?Bartosz=20Dziewo=C5=84ski?= Date: Thu, 23 Jul 2015 23:47:08 +0200 Subject: [PATCH] Implement CalendarWidget and DateInputWidget Example usage: I193fcd3175ebc96297f9d2cdd0f4de428388dd8e Bug: T97425 Change-Id: I6f760f7c32e2e6ed2008e897af72fb9e17dd663b --- resources/Resources.php | 5 + .../mw.widgets.CalendarWidget.js | 519 ++++++++++++++++++ .../mw.widgets.CalendarWidget.less | 259 +++++++++ .../mw.widgets.DateInputWidget.js | 355 ++++++++++++ .../mw.widgets.DateInputWidget.less | 107 ++++ 5 files changed, 1245 insertions(+) create mode 100644 resources/src/mediawiki.widgets/mw.widgets.CalendarWidget.js create mode 100644 resources/src/mediawiki.widgets/mw.widgets.CalendarWidget.less create mode 100644 resources/src/mediawiki.widgets/mw.widgets.DateInputWidget.js create mode 100644 resources/src/mediawiki.widgets/mw.widgets.DateInputWidget.less diff --git a/resources/Resources.php b/resources/Resources.php index 0fc8ade62e..f7a06f5ad9 100644 --- a/resources/Resources.php +++ b/resources/Resources.php @@ -1755,6 +1755,8 @@ return array( 'mediawiki.widgets' => array( 'scripts' => array( 'resources/src/mediawiki.widgets/mw.widgets.js', + 'resources/src/mediawiki.widgets/mw.widgets.CalendarWidget.js', + 'resources/src/mediawiki.widgets/mw.widgets.DateInputWidget.js', 'resources/src/mediawiki.widgets/mw.widgets.NamespaceInputWidget.js', 'resources/src/mediawiki.widgets/mw.widgets.TitleInputWidget.js', 'resources/src/mediawiki.widgets/mw.widgets.TitleOptionWidget.js', @@ -1762,6 +1764,8 @@ return array( ), 'skinStyles' => array( 'default' => array( + 'resources/src/mediawiki.widgets/mw.widgets.CalendarWidget.less', + 'resources/src/mediawiki.widgets/mw.widgets.DateInputWidget.less', 'resources/src/mediawiki.widgets/mw.widgets.TitleInputWidget.css', ), ), @@ -1770,6 +1774,7 @@ return array( 'jquery.autoEllipsis', 'mediawiki.Title', 'mediawiki.api', + 'moment', 'oojs-ui', ), 'messages' => array( 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 0000000000..0d743e48b0 --- /dev/null +++ b/resources/src/mediawiki.widgets/mw.widgets.CalendarWidget.js @@ -0,0 +1,519 @@ +/*! + * 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 */ +/*jshint es3: false */ +( function ( $, mw ) { + + /** + * Creates an mw.widgets.CalendarWidget object. + * + * @class + * @extends OO.ui.Widget + * @mixins OO.ui.mixin.TabIndexedElement + * + * @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, defaults to current date. + */ + 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 } ) ); + + // 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 = $( '
' ).addClass( 'mw-widget-calendarWidget-header' ); + this.$bodyOuterWrapper = $( '
' ).addClass( 'mw-widget-calendarWidget-body-outer-wrapper' ); + this.$bodyWrapper = $( '
' ).addClass( 'mw-widget-calendarWidget-body-wrapper' ); + this.$body = $( '
' ).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 ); + + /* 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' or 'up' + * @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.previousMoment && + this.previousMoment.isSame( this.moment, this.precision === 'month' ? 'month' : 'day' ) + ) { + // Already displayed + return; + } + + 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( + $( '
' ) + .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( + $( '
' ) + .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( + $( '
' ) + .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( + $( '
' ) + .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.$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 + previousMoment = moment( this.moment ), + $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( + this.precision === 'day' && this.moment.isBefore( previousMoment, 'month' ) ? 'previous' : + this.precision === 'day' && this.moment.isAfter( previousMoment, 'month' ) ? 'next' : null + ); + } 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, defaults to current date. 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; + this.setDateFromMoment(); + 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 = moment( this.getDate(), this.getDateFormat() ); + 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} 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 + dir = OO.ui.Element.static.getDir( this.$element ), + nextDirectionKey = dir === 'ltr' ? OO.ui.Keys.RIGHT : OO.ui.Keys.LEFT, + prevDirectionKey = dir === 'ltr' ? OO.ui.Keys.LEFT : OO.ui.Keys.RIGHT, + updateInDirection = null; + + if ( !this.isDisabled() ) { + switch ( e.which ) { + case prevDirectionKey: + this.moment.subtract( 1, this.precision === 'month' ? 'month' : 'day' ); + updateInDirection = 'previous'; + break; + case nextDirectionKey: + this.moment.add( 1, this.precision === 'month' ? 'month' : 'day' ); + updateInDirection = 'next'; + break; + case OO.ui.Keys.UP: + this.moment.subtract( 1, this.precision === 'month' ? 'month' : 'week' ); + updateInDirection = 'previous'; + break; + case OO.ui.Keys.DOWN: + this.moment.add( 1, this.precision === 'month' ? 'month' : 'week' ); + updateInDirection = 'next'; + break; + case OO.ui.Keys.PAGEUP: + this.moment.subtract( 1, this.precision === 'month' ? 'year' : 'month' ); + updateInDirection = 'previous'; + break; + case OO.ui.Keys.PAGEDOWN: + this.moment.add( 1, this.precision === 'month' ? 'year' : 'month' ); + updateInDirection = 'next'; + break; + } + + if ( updateInDirection ) { + this.displayLayer = this.getDisplayLayers()[ 0 ]; + this.setDateFromMoment(); + this.updateUI( updateInDirection ); + return false; + } + } + }; + +}( 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 0000000000..276bc65e85 --- /dev/null +++ b/resources/src/mediawiki.widgets/mw.widgets.CalendarWidget.less @@ -0,0 +1,259 @@ +/*! + * 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 inside DateInputWidget 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 inside DateInputWidget 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 inside DateInputWidget 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-day { + color: #444; +} + +.mw-widget-calendarWidget-day-heading { + font-weight: bold; + color: #555; +} + +.mw-widget-calendarWidget-day-additional { + color: #aaa; +} + +.mw-widget-calendarWidget-day-today { + // Intentionally left blank. +} + +.mw-widget-calendarWidget-item-selected { + background-color: #d8e6fe; + color: #3787fb; + + &.mw-widget-calendarWidget-day, + &.mw-widget-calendarWidget-day-heading { + border-radius: ((@calendarHeight / 7) / 2); + } + + &.mw-widget-calendarWidget-month { + border-radius: ((@calendarHeight / 6) / 2); + } + + &.mw-widget-calendarWidget-year { + border-radius: ((@calendarHeight / 4) / 2); + } +} + +.mw-widget-calendarWidget-item:hover { + background-color: #eee; + + &.mw-widget-calendarWidget-day, + &.mw-widget-calendarWidget-day-heading { + border-radius: ((@calendarHeight / 7) / 4); + } + + &.mw-widget-calendarWidget-month { + border-radius: ((@calendarHeight / 6) / 4); + } + + &.mw-widget-calendarWidget-year { + border-radius: ((@calendarHeight / 4) / 4); + } +} 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 0000000000..1820dda463 --- /dev/null +++ b/resources/src/mediawiki.widgets/mw.widgets.DateInputWidget.js @@ -0,0 +1,355 @@ +/*! + * 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. + * + * @class + * @extends OO.ui.InputWidget + * + * @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, defaults to current date. + * @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. + */ + mw.widgets.DateInputWidget = function MWWDateInputWidget( config ) { + // Config initialization + config = config || {}; + + // Properties (must be set before parent constructor, which calls #setValue) + this.handle = new OO.ui.LabelWidget(); + this.textInput = new OO.ui.TextInputWidget( { + validate: this.validateDate.bind( this ) + } ); + this.calendar = new mw.widgets.CalendarWidget( { + precision: config.precision + } ); + this.inCalendar = 0; + this.inTextInput = 0; + this.inputFormat = config.inputFormat; + this.displayFormat = config.displayFormat; + + // Parent constructor + mw.widgets.DateInputWidget.parent.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( { + keypress: this.onCalendarKeyPress.bind( this ) + } ); + this.handle.$element.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.$element ); + this.handle.$element + .addClass( 'mw-widget-dateInputWidget-handle' ); + this.$element + .addClass( 'mw-widget-dateInputWidget' ) + .append( this.handle.$element, this.textInput.$element, this.calendar.$element ); + // Set handle label and hide stuff + this.deactivate(); + }; + + /* Inheritance */ + + OO.inheritClass( mw.widgets.DateInputWidget, OO.ui.InputWidget ); + + /* Methods */ + + /** + * @inheritdoc + * @protected + */ + mw.widgets.DateInputWidget.prototype.getInputElement = function () { + return $( '' ); + }; + + /** + * 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 + widget = this, + value = this.textInput.getValue(); + this.inTextInput++; + this.textInput.isValid().done( function ( valid ) { + if ( valid ) { + // Well-formed date value, parse and set it + var 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 ) { + if ( value === undefined || value === null ) { + // Default to today + value = this.calendar.getDate(); + } + + var oldValue = this.value; + + mw.widgets.DateInputWidget.parent.prototype.setValue.call( this, value ); + + if ( this.value !== oldValue ) { + if ( !this.inCalendar ) { + this.calendar.setDate( this.getValue() ); + } + if ( !this.inTextInput ) { + this.textInput.setValue( this.getMoment().format( this.getInputFormat() ) ); + } + } + + 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 ) ) { + widget.deactivate(); + } + }, 0 ); + }; + + /** + * Deactivate this input field for data entry. Opens the calendar and shows the text field. + * + * @private + */ + mw.widgets.DateInputWidget.prototype.deactivate = function () { + this.textInput.setValue( this.getMoment().format( this.getInputFormat() ) ); + this.calendar.setDate( this.getValue() ); + this.handle.setLabel( this.getMoment().format( this.getDisplayFormat() ) ); + + this.$element.removeClass( 'mw-widget-dateInputWidget-active' ); + this.handle.toggle( true ); + this.textInput.toggle( false ); + this.calendar.toggle( false ); + }; + + /** + * Activate this input field for data entry. Closes the calendar and hides the text field. + * + * @private + */ + mw.widgets.DateInputWidget.prototype.activate = function () { + this.setValue( this.getValue() ); + + this.$element.addClass( 'mw-widget-dateInputWidget-active' ); + this.handle.toggle( false ); + 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.$element.focus(); + return false; + } + }; + + /** + * Handle text input enter events. + * + * @private + */ + mw.widgets.DateInputWidget.prototype.onEnter = function () { + this.deactivate(); + this.handle.$element.focus(); + }; + + /** + * @private + * @param {string} date Date string, must be in 'YYYY-MM-DD' or 'YYYY-MM' format to be valid + */ + mw.widgets.DateInputWidget.prototype.validateDate = 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 #isValid). + var + mom = moment( date, this.getInputFormat() ), + flags = mom.parsingFlags(); + + return mom.isValid() && flags.charsLeftOver === 0 && flags.unusedTokens.length === 0; + }; + +}( 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 0000000000..33e3406c33 --- /dev/null +++ b/resources/src/mediawiki.widgets/mw.widgets.DateInputWidget.less @@ -0,0 +1,107 @@ +/*! + * 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; + } +} + +.mw-widget-dateInputWidget { + display: inline-block; + position: relative; + + &-handle { + width: 100%; + display: inline-block; + cursor: pointer; + + .oo-ui-unselectable(); + .oo-ui-box-sizing(border-box); + } + + &.oo-ui-widget-disabled .oo-ui-dropdownWidget-handle { + cursor: default; + } + + > .mw-widget-calendarWidget { + 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; + + &:hover { + border-color: #347bff; + } + } + + > .oo-ui-textInputWidget input { + padding-left: 1em; + } + + > .mw-widget-calendarWidget { + background-color: white; + } + + &-active > .mw-widget-calendarWidget { + margin-top: -2px; + // Immitate focussed input styles + // First shadow generates bottom and right "border", second shadow generates bottom and left, + // resulting in no "border" at the top. Note that this generates a 2px-wide "border", not 1px. + // It makes sense when you think about it long enough and look up what each value means. Enjoy. + // (This is symmetrical anyway, and CSSJanus can't flip it correctly. T62805) + /* @noflip */ + box-shadow: inset -1px -1px 0 1px #347bff, inset 1px -1px 0 1px #347bff; + border-top: 1px solid #ccc; + + &:focus { + outline: none; + // Add border at the top on focus + margin-top: -3px; + border-top: 2px solid #347bff; + } + } + + &:hover .oo-ui-dropdownWidget-handle { + border-color: #aaa; + } + + &.oo-ui-widget-disabled { + .oo-ui-dropdownWidget-handle { + color: #ccc; + text-shadow: 0 1px 1px #fff; + border-color: #ddd; + background-color: #f3f3f3; + } + } +} -- 2.20.1