'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',
),
'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',
),
),
'jquery.autoEllipsis',
'mediawiki.Title',
'mediawiki.api',
+ 'moment',
'oojs-ui',
),
'messages' => array(
--- /dev/null
+/*!
+ * 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 = $( '<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 );
+
+ /* 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(
+ $( '<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.$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 ) );
--- /dev/null
+/*!
+ * 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);
+ }
+}
--- /dev/null
+/*!
+ * 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 $( '<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
+ 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 ) );
--- /dev/null
+/*!
+ * 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;
+ }
+ }
+}