Add datetime input widget
authorBrad Jorsch <bjorsch@wikimedia.org>
Mon, 4 Jan 2016 01:34:42 +0000 (17:34 -0800)
committerBrad Jorsch <bjorsch@wikimedia.org>
Tue, 5 Jan 2016 05:14:55 +0000 (21:14 -0800)
Since OOJS-UI isn't currently in a position to accept such things, the
decision is to put it in MediaWiki instead. Once OOJS-UI is
un-monolithicized and the i18n issue is solved, this should be somehow
moved there instead.

Change-Id: Ia3942c76804c865c1039904d170ee6eafcdc6793

12 files changed:
languages/i18n/en.json
languages/i18n/qqq.json
resources/Resources.php
resources/src/mediawiki.widgets.datetime/CalendarWidget.js [new file with mode: 0644]
resources/src/mediawiki.widgets.datetime/CalendarWidget.less [new file with mode: 0644]
resources/src/mediawiki.widgets.datetime/DateTimeFormatter.js [new file with mode: 0644]
resources/src/mediawiki.widgets.datetime/DateTimeInputWidget.js [new file with mode: 0644]
resources/src/mediawiki.widgets.datetime/DateTimeInputWidget.less [new file with mode: 0644]
resources/src/mediawiki.widgets.datetime/DiscordianDateTimeFormatter.js [new file with mode: 0644]
resources/src/mediawiki.widgets.datetime/ProlepticGregorianDateTimeFormatter.js [new file with mode: 0644]
resources/src/mediawiki.widgets.datetime/mediawiki.widgets.datetime.definitions.less [new file with mode: 0644]
resources/src/mediawiki.widgets.datetime/mediawiki.widgets.datetime.js [new file with mode: 0644]

index 0b31abc..3a4857b 100644 (file)
        "october-date": "October $1",
        "november-date": "November $1",
        "december-date": "December $1",
+       "period-am": "AM",
+       "period-pm": "PM",
        "pagecategories": "{{PLURAL:$1|Category|Categories}}",
        "pagecategorieslink": "Special:Categories",
        "category_header": "Pages in category \"$1\"",
        "signature": "[[{{ns:user}}:$1|$2]] ([[{{ns:user_talk}}:$1|talk]])",
        "signature-anon": "[[{{#special:Contributions}}/$1|$2]]",
        "timezone-utc": "UTC",
+       "timezone-local": "Local",
        "duplicate-defaultsort": "<strong>Warning:</strong> Default sort key \"$2\" overrides earlier default sort key \"$1\".",
        "duplicate-displaytitle": "<strong>Warning:</strong> Display title \"$2\" overrides earlier display title \"$1\".",
        "invalid-indicator-name": "<strong>Error:</strong> Page status indicators' <code>name</code> attribute must not be empty.",
index 60ae69e..b0cfb61 100644 (file)
        "october-date": "A date in the Gregorian month of October. $1 is the numerical date, for example \"23\".",
        "november-date": "A date in the Gregorian month of November. $1 is the numerical date, for example \"23\".\n{{Identical|November}}",
        "december-date": "A date in the Gregorian month of December. $1 is the numerical date, for example \"23\".",
+       "period-am": "Text indicating the first period of the day when using a 12-hour calendar.",
+       "period-pm": "Text indicating the second period of the day when using a 12-hour calendar.",
        "pagecategories": "Used in the categories section of pages.\n\nFollowed by a colon and a list of categories.\n\nParameters:\n* $1 - number of categories\n{{Identical|Category}}",
        "pagecategorieslink": "{{notranslate}}",
        "category_header": "In category description page. Parameters:\n* $1 - category name\nSee also:\n* {{msg-mw|Category-media-header}}",
        "signature": "This will be substituted in the signature (~<nowiki></nowiki>~~ or ~~<nowiki></nowiki>~~ excluding timestamp).\n\nParameters:\n* $1 - the username that is currently login\n* $2 - the customized signature which is specified in [[Special:Preferences|user's preferences]] as non-raw\nUse your language default parentheses ({{msg-mw|parentheses}}), but not use the message direct.\n\nSee also:\n* {{msg-mw|Signature-anon}} - signature for anonymous user",
        "signature-anon": "{{notranslate}}\nUsed as signature for anonymous user. Parameters:\n* $1 - username (IP address?)\n* $2 - nickname (IP address?)\nSee also:\n* {{msg-mw|Signature}} - signature for registered user",
        "timezone-utc": "{{optional}}",
+       "timezone-local": "Label to indicate that a time is in the user's local timezone.",
        "duplicate-defaultsort": "See definition of [[w:Sorting|sort key]] on Wikipedia. Parameters:\n* $1 - old default sort key\n* $2 - new default sort key",
        "duplicate-displaytitle": "Warning shown when a page has its display title set multiple times. Parameters:\n* $1 - old display title\n* $2 - new display title",
        "invalid-indicator-name": "Warning shown when the [https://www.mediawiki.org/wiki/Help:Page_status_indicators &lt;indicator name=\"''unique-identifier''\">''content''&lt;/indicator>] parser tag is used incorrectly.",
index 987b97a..e8cb843 100644 (file)
@@ -2068,6 +2068,69 @@ return array(
                ),
                'targets' => array( 'desktop', 'mobile' ),
        ),
+       'mediawiki.widgets.datetime' => array(
+               'scripts' => array(
+                       'resources/src/mediawiki.widgets.datetime/mediawiki.widgets.datetime.js',
+                       'resources/src/mediawiki.widgets.datetime/CalendarWidget.js',
+                       'resources/src/mediawiki.widgets.datetime/DateTimeFormatter.js',
+                       'resources/src/mediawiki.widgets.datetime/DateTimeInputWidget.js',
+                       'resources/src/mediawiki.widgets.datetime/ProlepticGregorianDateTimeFormatter.js',
+               ),
+               'skinStyles' => array(
+                       'default' => array(
+                               'resources/src/mediawiki.widgets.datetime/CalendarWidget.less',
+                               'resources/src/mediawiki.widgets.datetime/DateTimeInputWidget.less',
+                       ),
+               ),
+               'messages' => array(
+                       'timezone-utc',
+                       'timezone-local',
+                       'january',
+                       'february',
+                       'march',
+                       'april',
+                       'may_long',
+                       'june',
+                       'july',
+                       'august',
+                       'september',
+                       'october',
+                       'november',
+                       'december',
+                       'jan',
+                       'feb',
+                       'mar',
+                       'apr',
+                       'may',
+                       'jun',
+                       'jul',
+                       'aug',
+                       'sep',
+                       'oct',
+                       'nov',
+                       'dec',
+                       'sunday',
+                       'monday',
+                       'tuesday',
+                       'wednesday',
+                       'thursday',
+                       'friday',
+                       'saturday',
+                       'sun',
+                       'mon',
+                       'tue',
+                       'wed',
+                       'thu',
+                       'fri',
+                       'sat',
+                       'period-am',
+                       'period-pm',
+               ),
+               'dependencies' => array(
+                       'oojs-ui',
+               ),
+               'targets' => array( 'desktop', 'mobile' ),
+       ),
        'mediawiki.widgets.CategorySelector' => array(
                'scripts' => array(
                        'resources/src/mediawiki.widgets/mw.widgets.CategoryCapsuleItemWidget.js',
diff --git a/resources/src/mediawiki.widgets.datetime/CalendarWidget.js b/resources/src/mediawiki.widgets.datetime/CalendarWidget.js
new file mode 100644 (file)
index 0000000..31b1cd5
--- /dev/null
@@ -0,0 +1,593 @@
+( function ( $, mw ) {
+
+       /**
+        * CalendarWidget displays a calendar that can be used to select a date. It
+        * uses {@link mw.widgets.datetime.DateTimeFormatter DateTimeFormatter} to get the details of
+        * the calendar.
+        *
+        * This widget is mainly intended to be used as a popup from a
+        * {@link mw.widgets.datetime.DateTimeInputWidget DateTimeInputWidget}, but may also be used
+        * standalone.
+        *
+        * @class
+        * @extends OO.ui.Widget
+        * @mixins OO.ui.mixin.TabIndexedElement
+        *
+        * @constructor
+        * @param {Object} [config] Configuration options
+        * @cfg {Object|mw.widgets.datetime.DateTimeFormatter} [formatter={}] Configuration options for
+        *  mw.widgets.datetime.ProlepticGregorianDateTimeFormatter, or an mw.widgets.datetime.DateTimeFormatter
+        *  instance to use.
+        * @cfg {OO.ui.Widget|null} [widget=null] Widget associated with the calendar.
+        *  Specifying this configures the calendar to be used as a popup from the
+        *  specified widget (e.g. absolute positioning, automatic hiding when clicked
+        *  outside).
+        * @cfg {Date|null} [min=null] Minimum allowed date
+        * @cfg {Date|null} [max=null] Maximum allowed date
+        * @cfg {Date} [focusedDate] Initially focused date.
+        * @cfg {Date|Date[]|null} [selected=null] Selected date(s).
+        */
+       mw.widgets.datetime.CalendarWidget = function MwWidgetsDatetimeCalendarWidget( config ) {
+               var $colgroup, $headTR, headings, i;
+
+               // Configuration initialization
+               config = $.extend( {
+                       min: null,
+                       max: null,
+                       focusedDate: new Date(),
+                       selected: null,
+                       formatter: {}
+               }, config );
+
+               // Parent constructor
+               mw.widgets.datetime.CalendarWidget[ 'super' ].call( this, config );
+
+               // Mixin constructors
+               OO.ui.mixin.TabIndexedElement.call( this, $.extend( {}, config, { $tabIndexed: this.$element } ) );
+
+               // Properties
+               if ( config.min instanceof Date && config.min.getTime() >= -62167219200000 ) {
+                       this.min = config.min;
+               } else {
+                       this.min = new Date( -62167219200000 ); // 0000-01-01T00:00:00.000Z
+               }
+               if ( config.max instanceof Date && config.max.getTime() <= 253402300799999 ) {
+                       this.max = config.max;
+               } else {
+                       this.max = new Date( 253402300799999 ); // 9999-12-31T12:59:59.999Z
+               }
+
+               if ( config.focusedDate instanceof Date ) {
+                       this.focusedDate = config.focusedDate;
+               } else {
+                       this.focusedDate = new Date();
+               }
+
+               this.selected = [];
+
+               if ( config.formatter instanceof mw.widgets.datetime.DateTimeFormatter ) {
+                       this.formatter = config.formatter;
+               } else if ( $.isPlainObject( config.formatter ) ) {
+                       this.formatter = new mw.widgets.datetime.ProlepticGregorianDateTimeFormatter( config.formatter );
+               } else {
+                       throw new Error( '"formatter" must be an mw.widgets.datetime.DateTimeFormatter or a plain object' );
+               }
+
+               this.calendarData = null;
+
+               this.widget = config.widget;
+               this.$widget = config.widget ? config.widget.$element : null;
+               this.onDocumentMouseDownHandler = this.onDocumentMouseDown.bind( this );
+
+               this.$head = $( '<div>' );
+               this.$header = $( '<span>' );
+               this.$table = $( '<table>' );
+               this.cols = [];
+               this.colNullable = [];
+               this.headings = [];
+               this.$tableBody = $( '<tbody>' );
+               this.rows = [];
+               this.buttons = {};
+               this.minWidth = 1;
+               this.daysPerWeek = 0;
+
+               // Events
+               this.$element.on( {
+                       keydown: this.onKeyDown.bind( this )
+               } );
+               this.formatter.connect( this, {
+                       local: 'onLocalChange'
+               } );
+               if ( this.$widget ) {
+                       this.checkFocusHandler = this.checkFocus.bind( this );
+                       this.$element.on( {
+                               focusout: this.onFocusOut.bind( this )
+                       } );
+                       this.$widget.on( {
+                               focusout: this.onFocusOut.bind( this )
+                       } );
+               }
+
+               // Initialization
+               this.$head
+                       .addClass( 'mw-widgets-datetime-calendarWidget-heading' )
+                       .append(
+                               new OO.ui.ButtonWidget( {
+                                       icon: 'previous',
+                                       framed: false,
+                                       classes: [ 'mw-widgets-datetime-calendarWidget-previous' ],
+                                       tabIndex: -1
+                               } ).connect( this, { click: 'onPrevClick' } ).$element,
+                               new OO.ui.ButtonWidget( {
+                                       icon: 'next',
+                                       framed: false,
+                                       classes: [ 'mw-widgets-datetime-calendarWidget-next' ],
+                                       tabIndex: -1
+                               } ).connect( this, { click: 'onNextClick' } ).$element,
+                               this.$header
+                       );
+               $colgroup = $( '<colgroup>' );
+               $headTR = $( '<tr>' );
+               this.$table
+                       .addClass( 'mw-widgets-datetime-calendarWidget-grid' )
+                       .append( $colgroup )
+                       .append( $( '<thead>' ).append( $headTR ) )
+                       .append( this.$tableBody );
+
+               headings = this.formatter.getCalendarHeadings();
+               for ( i = 0; i < headings.length; i++ ) {
+                       this.cols[ i ] = $( '<col>' );
+                       this.headings[ i ] = $( '<th>' );
+                       this.colNullable[ i ] = headings[ i ] === null;
+                       if ( headings[ i ] !== null ) {
+                               this.headings[ i ].text( headings[ i ] );
+                               this.minWidth = Math.max( this.minWidth, headings[ i ].length );
+                               this.daysPerWeek++;
+                       }
+                       $colgroup.append( this.cols[ i ] );
+                       $headTR.append( this.headings[ i ] );
+               }
+
+               this.setSelected( config.selected );
+               this.$element
+                       .addClass( 'mw-widgets-datetime-calendarWidget' )
+                       .append( this.$head, this.$table );
+
+               if ( this.widget ) {
+                       this.$element.addClass( 'mw-widgets-datetime-calendarWidget-dependent' );
+
+                       // Initially hidden - using #toggle may cause errors if subclasses override toggle with methods
+                       // that reference properties not initialized at that time of parent class construction
+                       // TODO: Find a better way to handle post-constructor setup
+                       this.visible = false;
+                       this.$element.addClass( 'oo-ui-element-hidden' );
+               } else {
+                       this.updateUI();
+               }
+       };
+
+       /* Setup */
+
+       OO.inheritClass( mw.widgets.datetime.CalendarWidget, OO.ui.Widget );
+       OO.mixinClass( mw.widgets.datetime.CalendarWidget, OO.ui.mixin.TabIndexedElement );
+
+       /* Events */
+
+       /**
+        * A `change` event is emitted when the selected dates change
+        *
+        * @event change
+        */
+
+       /**
+        * A `focusChange` event is emitted when the focused date changes
+        *
+        * @event focusChange
+        */
+
+       /**
+        * A `page` event is emitted when the current "month" changes
+        *
+        * @event page
+        */
+
+       /* Methods */
+
+       /**
+        * Return the current selected dates
+        *
+        * @return {Date[]}
+        */
+       mw.widgets.datetime.CalendarWidget.prototype.getSelected = function () {
+               return this.selected;
+       };
+
+       /**
+        * Set the selected dates
+        *
+        * @param {Date|Date[]|null} dates
+        * @fires change
+        * @chainable
+        */
+       mw.widgets.datetime.CalendarWidget.prototype.setSelected = function ( dates ) {
+               var i, changed = false;
+
+               if ( dates instanceof Date ) {
+                       dates = [ dates ];
+               } else if ( Array.isArray( dates ) ) {
+                       dates = $.grep( dates, function ( dt ) { return dt instanceof Date; } );
+                       dates.sort();
+               } else {
+                       dates = [];
+               }
+
+               if ( this.selected.length !== dates.length ) {
+                       changed = true;
+               } else {
+                       for ( i = 0; i < dates.length; i++ ) {
+                               if ( dates[ i ].getTime() !== this.selected[ i ].getTime() ) {
+                                       changed = true;
+                                       break;
+                               }
+                       }
+               }
+
+               if ( changed ) {
+                       this.selected = dates;
+                       this.emit( 'change', dates );
+                       this.updateUI();
+               }
+
+               return this;
+       };
+
+       /**
+        * Return the currently-focused date
+        *
+        * @return {Date}
+        */
+       mw.widgets.datetime.CalendarWidget.prototype.getFocusedDate = function () {
+               return this.focusedDate;
+       };
+
+       /**
+        * Set the currently-focused date
+        *
+        * @param {Date} date
+        * @fires page
+        * @chainable
+        */
+       mw.widgets.datetime.CalendarWidget.prototype.setFocusedDate = function ( date ) {
+               var changePage = false,
+                       updateUI = false;
+
+               if ( this.focusedDate.getTime() === date.getTime() ) {
+                       return this;
+               }
+
+               if ( !this.formatter.sameCalendarGrid( this.focusedDate, date ) ) {
+                       changePage = true;
+                       updateUI = true;
+               } else if (
+                       !this.formatter.timePartIsEqual( this.focusedDate, date ) ||
+                       !this.formatter.datePartIsEqual( this.focusedDate, date )
+               ) {
+                       updateUI = true;
+               }
+
+               this.focusedDate = date;
+               this.emit( 'focusChanged', this.focusedDate );
+               if ( changePage ) {
+                       this.emit( 'page', date );
+               }
+               if ( updateUI ) {
+                       this.updateUI();
+               }
+
+               return this;
+       };
+
+       /**
+        * Adjust a date
+        *
+        * @protected
+        * @param {Date} date Date to adjust
+        * @param {string} component Component: 'month', 'week', or 'day'
+        * @param {number} delta Integer, usually -1 or 1
+        * @param {boolean} [enforceRange=true] Whether to enforce this.min and this.max
+        * @return {Date}
+        */
+       mw.widgets.datetime.CalendarWidget.prototype.adjustDate = function ( date, component, delta ) {
+               var newDate,
+                       data = this.calendarData;
+
+               if ( !data ) {
+                       return date;
+               }
+
+               switch ( component ) {
+                       case 'month':
+                               newDate = this.formatter.adjustComponent( date, data.monthComponent, delta, 'overflow' );
+                               break;
+
+                       case 'week':
+                               if ( data.weekComponent === undefined ) {
+                                       newDate = this.formatter.adjustComponent(
+                                               date, data.dayComponent, delta * this.daysPerWeek, 'overflow' );
+                               } else {
+                                       newDate = this.formatter.adjustComponent( date, data.weekComponent, delta, 'overflow' );
+                               }
+                               break;
+
+                       case 'day':
+                               newDate = this.formatter.adjustComponent( date, data.dayComponent, delta, 'overflow' );
+                               break;
+
+                       default:
+                               throw new Error( 'Unknown component' );
+               }
+
+               while ( newDate < this.min ) {
+                       newDate = this.formatter.adjustComponent( newDate, data.dayComponent, 1, 'overflow' );
+               }
+               while ( newDate > this.max ) {
+                       newDate = this.formatter.adjustComponent( newDate, data.dayComponent, -1, 'overflow' );
+               }
+
+               return newDate;
+       };
+
+       /**
+        * Update the user interface
+        *
+        * @protected
+        */
+       mw.widgets.datetime.CalendarWidget.prototype.updateUI = function () {
+               var r, c, row, day, k, $cell,
+                       width = this.minWidth,
+                       nullCols = [],
+                       focusedDate = this.getFocusedDate(),
+                       selected = this.getSelected(),
+                       datePartIsEqual = this.formatter.datePartIsEqual.bind( this.formatter ),
+                       isSelected = function ( dt ) {
+                               return datePartIsEqual( this, dt );
+                       };
+
+               this.calendarData = this.formatter.getCalendarData( focusedDate );
+
+               this.$header.text( this.calendarData.header );
+
+               for ( c = 0; c < this.colNullable.length; c++ ) {
+                       nullCols[ c ] = this.colNullable[ c ];
+                       if ( nullCols[ c ] ) {
+                               for ( r = 0; r < this.calendarData.rows.length; r++ ) {
+                                       if ( this.calendarData.rows[ r ][ c ] ) {
+                                               nullCols[ c ] = false;
+                                               break;
+                                       }
+                               }
+                       }
+               }
+
+               this.$tableBody.children().detach();
+               for ( r = 0; r < this.calendarData.rows.length; r++ ) {
+                       if ( !this.rows[ r ] ) {
+                               this.rows[ r ] = $( '<tr>' );
+                       } else {
+                               this.rows[ r ].children().detach();
+                       }
+                       this.$tableBody.append( this.rows[ r ] );
+                       row = this.calendarData.rows[ r ];
+                       for ( c = 0; c < row.length; c++ ) {
+                               day = row[ c ];
+                               if ( day === null ) {
+                                       k = 'empty-' + r + '-' + c;
+                                       if ( !this.buttons[ k ] ) {
+                                               this.buttons[ k ] = $( '<td>' );
+                                       }
+                                       $cell = this.buttons[ k ];
+                                       $cell.toggleClass( 'oo-ui-element-hidden', nullCols[ c ] );
+                               } else {
+                                       k = ( day.extra ? day.extra : '' ) + day.display;
+                                       width = Math.max( width, day.display.length );
+                                       if ( !this.buttons[ k ] ) {
+                                               this.buttons[ k ] = new OO.ui.ButtonWidget( {
+                                                       $element: $( '<td>' ),
+                                                       classes: [
+                                                               'mw-widgets-datetime-calendarWidget-cell',
+                                                               day.extra ? 'mw-widgets-datetime-calendarWidget-extra' : ''
+                                                       ],
+                                                       framed: true,
+                                                       label: day.display,
+                                                       tabIndex: -1
+                                               } );
+                                               this.buttons[ k ].connect( this, { click: [ 'onDayClick', this.buttons[ k ] ] } );
+                                       }
+                                       this.buttons[ k ]
+                                               .setData( day.date )
+                                               .setDisabled( day.date < this.min || day.date > this.max );
+                                       $cell = this.buttons[ k ].$element;
+                                       $cell.toggleClass( 'mw-widgets-datetime-calendarWidget-focused',
+                                               this.formatter.datePartIsEqual( focusedDate, day.date ) );
+                                       $cell.toggleClass( 'mw-widgets-datetime-calendarWidget-selected',
+                                               selected.some( isSelected, day.date ) );
+                               }
+                               this.rows[ r ].append( $cell );
+                       }
+               }
+
+               for ( c = 0; c < this.cols.length; c++ ) {
+                       if ( nullCols[ c ] ) {
+                               this.cols[ c ].width( 0 );
+                       } else {
+                               this.cols[ c ].width( width + 'em' );
+                       }
+                       this.cols[ c ].toggleClass( 'oo-ui-element-hidden', nullCols[ c ] );
+                       this.headings[ c ].toggleClass( 'oo-ui-element-hidden', nullCols[ c ] );
+               }
+       };
+
+       /**
+        * Handles formatter 'local' flag changing
+        *
+        * @protected
+        */
+       mw.widgets.datetime.CalendarWidget.prototype.onLocalChange = function () {
+               if ( this.formatter.localChangesDatePart( this.getFocusedDate() ) ) {
+                       this.emit( 'page', this.getFocusedDate() );
+               }
+
+               this.updateUI();
+       };
+
+       /**
+        * Handles previous button click
+        *
+        * @protected
+        */
+       mw.widgets.datetime.CalendarWidget.prototype.onPrevClick = function () {
+               this.setFocusedDate( this.adjustDate( this.getFocusedDate(), 'month', -1 ) );
+               if ( !this.$widget || OO.ui.contains( this.$element[ 0 ], document.activeElement, true ) ) {
+                       this.$element.focus();
+               }
+       };
+
+       /**
+        * Handles next button click
+        *
+        * @protected
+        */
+       mw.widgets.datetime.CalendarWidget.prototype.onNextClick = function () {
+               this.setFocusedDate( this.adjustDate( this.getFocusedDate(), 'month', 1 ) );
+               if ( !this.$widget || OO.ui.contains( this.$element[ 0 ], document.activeElement, true ) ) {
+                       this.$element.focus();
+               }
+       };
+
+       /**
+        * Handles day button click
+        *
+        * @protected
+        * @param {OO.ui.ButtonWidget} $button
+        */
+       mw.widgets.datetime.CalendarWidget.prototype.onDayClick = function ( $button ) {
+               this.setFocusedDate( $button.getData() );
+               this.setSelected( [ $button.getData() ] );
+               if ( !this.$widget || OO.ui.contains( this.$element[ 0 ], document.activeElement, true ) ) {
+                       this.$element.focus();
+               }
+       };
+
+       /**
+        * Handles document mouse down events.
+        *
+        * @protected
+        * @param {jQuery.Event} e Mouse down event
+        */
+       mw.widgets.datetime.CalendarWidget.prototype.onDocumentMouseDown = function ( e ) {
+               if ( this.$widget &&
+                       !OO.ui.contains( this.$element[ 0 ], e.target, true ) &&
+                       !OO.ui.contains( this.$widget[ 0 ], e.target, true )
+               ) {
+                       this.toggle( false );
+               }
+       };
+
+       /**
+        * Handles key presses.
+        *
+        * @protected
+        * @param {jQuery.Event} e Key down event
+        */
+       mw.widgets.datetime.CalendarWidget.prototype.onKeyDown = function ( e ) {
+               var focusedDate = this.getFocusedDate();
+
+               if ( !this.isDisabled() ) {
+                       switch ( e.which ) {
+                               case OO.ui.Keys.ENTER:
+                               case OO.ui.Keys.SPACE:
+                                       this.setSelected( [ focusedDate ] );
+                                       return false;
+
+                               case OO.ui.Keys.LEFT:
+                                       this.setFocusedDate( this.adjustDate( focusedDate, 'day', -1 ) );
+                                       return false;
+
+                               case OO.ui.Keys.RIGHT:
+                                       this.setFocusedDate( this.adjustDate( focusedDate, 'day', 1 ) );
+                                       return false;
+
+                               case OO.ui.Keys.UP:
+                                       this.setFocusedDate( this.adjustDate( focusedDate, 'week', -1 ) );
+                                       return false;
+
+                               case OO.ui.Keys.DOWN:
+                                       this.setFocusedDate( this.adjustDate( focusedDate, 'week', 1 ) );
+                                       return false;
+
+                               case OO.ui.Keys.PAGEUP:
+                                       this.setFocusedDate( this.adjustDate( focusedDate, 'month', -1 ) );
+                                       return false;
+
+                               case OO.ui.Keys.PAGEDOWN:
+                                       this.setFocusedDate( this.adjustDate( focusedDate, 'month', 1 ) );
+                                       return false;
+                       }
+               }
+       };
+
+       /**
+        * Handles focusout events in dependent mode
+        *
+        * @private
+        */
+       mw.widgets.datetime.CalendarWidget.prototype.onFocusOut = function () {
+               setTimeout( this.checkFocusHandler );
+       };
+
+       /**
+        * When we or our widget lost focus, check if the calendar should be hidden.
+        *
+        * @private
+        */
+       mw.widgets.datetime.CalendarWidget.prototype.checkFocus = function () {
+               var containers = [ this.$element[ 0 ], this.$widget[ 0 ] ],
+                       activeElement = document.activeElement;
+
+               if ( !activeElement || !OO.ui.contains( containers, activeElement, true ) ) {
+                       this.toggle( false );
+               }
+       };
+
+       /**
+        * @inheritdoc
+        */
+       mw.widgets.datetime.CalendarWidget.prototype.toggle = function ( visible ) {
+               var change;
+
+               visible = ( visible === undefined ? !this.visible : !!visible );
+               change = visible !== this.isVisible();
+
+               // Parent method
+               mw.widgets.datetime.CalendarWidget[ 'super' ].prototype.toggle.call( this, visible );
+
+               if ( change ) {
+                       if ( visible ) {
+                               // Auto-hide
+                               if ( this.$widget ) {
+                                       this.getElementDocument().addEventListener(
+                                               'mousedown', this.onDocumentMouseDownHandler, true
+                                       );
+                               }
+                               this.updateUI();
+                       } else {
+                               this.getElementDocument().removeEventListener(
+                                       'mousedown', this.onDocumentMouseDownHandler, true
+                               );
+                       }
+               }
+
+               return this;
+       };
+
+}( jQuery, mediaWiki ) );
diff --git a/resources/src/mediawiki.widgets.datetime/CalendarWidget.less b/resources/src/mediawiki.widgets.datetime/CalendarWidget.less
new file mode 100644 (file)
index 0000000..a7beb0d
--- /dev/null
@@ -0,0 +1,74 @@
+@import "mediawiki.widgets.datetime.definitions";
+
+.mw-widgets-datetime-calendarWidget {
+       display: inline-block;
+       position: relative;
+       vertical-align: middle;
+       padding: .5em;
+
+       &.mw-widgets-datetime-calendarWidget-dependent {
+               display: block;
+               position: absolute;
+               z-index: 4;
+       }
+
+       &-grid {
+               table-layout: fixed;
+
+               .mw-widgets-datetime-calendarWidget-cell {
+                       display: table-cell;
+                       white-space: nowrap;
+               }
+       }
+
+       background-color: white;
+       border: 1px solid #ccc;
+
+       &.mw-widgets-datetime-calendarWidget-dependent {
+               margin-top: -1px;
+               border-top: 1px solid white;
+       }
+
+       &-heading {
+               text-align: center;
+               vertical-align: middle;
+               font-weight: bold;
+               white-space: nowrap;
+
+               .mw-widgets-datetime-calendarWidget-previous {
+                       float: left;
+               }
+               .mw-widgets-datetime-calendarWidget-next {
+                       float: right;
+               }
+       }
+
+       &-grid {
+               margin: 0 auto;
+
+               .mw-widgets-datetime-calendarWidget-cell {
+                       text-align: center;
+
+                       .oo-ui-buttonElement-button {
+                               width: 100%;
+                               border: 1px dotted rgba(255,255,255,0.0);
+                               .oo-ui-box-sizing( border-box );
+                       }
+
+                       &.mw-widgets-datetime-calendarWidget-extra .oo-ui-buttonElement-button .oo-ui-labelElement-label {
+                               color: #bbb;
+                       }
+
+                       &.mw-widgets-datetime-calendarWidget-selected .oo-ui-buttonElement-button {
+                               background-color: #def;
+                               .oo-ui-labelElement-label {
+                                       color: #38f;
+                               }
+                       }
+               }
+       }
+
+       &:focus &-grid &-cell&-focused .oo-ui-buttonElement-button {
+               border-color: rgba(0,0,0,0.3);
+       }
+}
diff --git a/resources/src/mediawiki.widgets.datetime/DateTimeFormatter.js b/resources/src/mediawiki.widgets.datetime/DateTimeFormatter.js
new file mode 100644 (file)
index 0000000..1c54234
--- /dev/null
@@ -0,0 +1,623 @@
+( function ( $, mw ) {
+
+       /**
+        * Provides various methods needed for formatting dates and times.
+        *
+        * @class
+        * @abstract
+        * @mixins OO.EventEmitter
+        *
+        * @constructor
+        * @param {Object} [config] Configuration options
+        * @cfg {string} [format='@default'] May be a key from the {@link #static-formats static formats},
+        *  or a format specification as defined by {@link #method-parseFieldSpec parseFieldSpec}
+        *  and {@link #method-getFieldForTag getFieldForTag}.
+        * @cfg {boolean} [local=false] Whether dates are local time or UTC
+        * @cfg {string[]} [fullZones] Time zone indicators. Array of 2 strings, for
+        *  UTC and local time.
+        * @cfg {string[]} [shortZones] Abbreviated time zone indicators. Array of 2
+        *  strings, for UTC and local time.
+        * @cfg {Date} [defaultDate] Default date, for filling unspecified components.
+        *  Defaults to the current date and time (with 0 milliseconds).
+        */
+       mw.widgets.datetime.DateTimeFormatter = function MwWidgetsDatetimeDateTimeFormatter( config ) {
+               var statick = this.constructor[ 'static' ];
+
+               statick.setupDefaults();
+
+               config = $.extend( {
+                       format: '@default',
+                       local: false,
+                       fullZones: statick.fullZones,
+                       shortZones: statick.shortZones
+               }, config );
+
+               // Mixin constructors
+               OO.EventEmitter.call( this );
+
+               // Properties
+               if ( statick.formats[ config.format ] ) {
+                       this.format = statick.formats[ config.format ];
+               } else {
+                       this.format = config.format;
+               }
+               this.local = !!config.local;
+               this.fullZones = config.fullZones;
+               this.shortZones = config.shortZones;
+               if ( config.defaultDate instanceof Date ) {
+                       this.defaultDate = config.defaultDate;
+               } else {
+                       this.defaultDate = new Date();
+                       if ( this.local ) {
+                               this.defaultDate.setMilliseconds( 0 );
+                       } else {
+                               this.defaultDate.setUTCMilliseconds( 0 );
+                       }
+               }
+       };
+
+       /* Setup */
+
+       OO.initClass( mw.widgets.datetime.DateTimeFormatter );
+       OO.mixinClass( mw.widgets.datetime.DateTimeFormatter, OO.EventEmitter );
+
+       /* Static */
+
+       /**
+        * Default format specifications. See the {@link #format format} parameter.
+        *
+        * @static
+        * @inheritable
+        * @property {Object}
+        */
+       mw.widgets.datetime.DateTimeFormatter[ 'static' ].formats = {};
+
+       /**
+        * Default time zone indicators
+        *
+        * @static
+        * @inheritable
+        * @property {string[]}
+        */
+       mw.widgets.datetime.DateTimeFormatter[ 'static' ].fullZones = null;
+
+       /**
+        * Default abbreviated time zone indicators
+        *
+        * @static
+        * @inheritable
+        * @property {string[]}
+        */
+       mw.widgets.datetime.DateTimeFormatter[ 'static' ].shortZones = null;
+
+       mw.widgets.datetime.DateTimeFormatter[ 'static' ].setupDefaults = function () {
+               if ( !this.fullZones ) {
+                       this.fullZones = [
+                               mw.msg( 'timezone-utc' ),
+                               mw.msg( 'timezone-local' )
+                       ];
+               }
+               if ( !this.shortZones ) {
+                       this.shortZones = [
+                               'Z',
+                               this.fullZones[ 1 ].substr( 0, 1 ).toUpperCase()
+                       ];
+                       if ( this.shortZones[ 1 ] === 'Z' ) {
+                               this.shortZones[ 1 ] = 'L';
+                       }
+               }
+       };
+
+       /* Events */
+
+       /**
+        * A `local` event is emitted when the 'local' flag is changed.
+        *
+        * @event local
+        */
+
+       /* Methods */
+
+       /**
+        * Whether dates are in local time or UTC
+        *
+        * @return {boolean} True if local time
+        */
+       mw.widgets.datetime.DateTimeFormatter.prototype.getLocal = function () {
+               return this.local;
+       };
+
+       /**
+        * Toggle whether dates are in local time or UTC
+        *
+        * @param {boolean} [flag] Set the flag instead of toggling it
+        * @fires local
+        * @chainable
+        */
+       mw.widgets.datetime.DateTimeFormatter.prototype.toggleLocal = function ( flag ) {
+               if ( flag === undefined ) {
+                       flag = !this.local;
+               } else {
+                       flag = !!flag;
+               }
+               if ( this.local !== flag ) {
+                       this.local = flag;
+                       this.emit( 'local', this.local );
+               }
+               return this;
+       };
+
+       /**
+        * Get the default date
+        *
+        * @return {Date}
+        */
+       mw.widgets.datetime.DateTimeFormatter.prototype.getDefaultDate = function () {
+               return new Date( this.defaultDate.getTime() );
+       };
+
+       /**
+        * Fetch the field specification array for this object.
+        *
+        * See {@link #parseFieldSpec parseFieldSpec} for details on the return value structure.
+        *
+        * @return {Array}
+        */
+       mw.widgets.datetime.DateTimeFormatter.prototype.getFieldSpec = function () {
+               return this.parseFieldSpec( this.format );
+       };
+
+       /**
+        * Parse a format string into a field specification
+        *
+        * The input is a string containing tags formatted as ${tag|param|param...}
+        * (for editable fields) and $!{tag|param|param...} (for non-editable fields).
+        * Most tags are defined by {@link #getFieldForTag getFieldForTag}, but a few
+        * are defined here:
+        * - ${intercalary|X|text}: Text that is only displayed when the 'intercalary'
+        *   component is X.
+        * - ${not-intercalary|X|text}: Text that is displayed unless the 'intercalary'
+        *   component is X.
+        *
+        * Elements of the returned array are strings or objects. Strings are meant to
+        * be displayed as-is. Objects are as returned by {@link #getFieldForTag getFieldForTag}.
+        *
+        * @protected
+        * @param {string} format
+        * @return {Array}
+        */
+       mw.widgets.datetime.DateTimeFormatter.prototype.parseFieldSpec = function ( format ) {
+               var m, last, tag, params, spec,
+                       ret = [],
+                       re = /(.*?)(\$(!?)\{([^}]+)\})/g;
+
+               last = 0;
+               while ( ( m = re.exec( format ) ) !== null ) {
+                       last = re.lastIndex;
+
+                       if ( m[ 1 ] !== '' ) {
+                               ret.push( m[ 1 ] );
+                       }
+
+                       params = m[ 4 ].split( '|' );
+                       tag = params.shift();
+                       spec = this.getFieldForTag( tag, params );
+                       if ( spec ) {
+                               if ( m[ 3 ] === '!' ) {
+                                       spec.editable = false;
+                               }
+                               ret.push( spec );
+                       } else {
+                               ret.push( m[ 2 ] );
+                       }
+               }
+               if ( last < format.length ) {
+                       ret.push( format.substr( last ) );
+               }
+
+               return ret;
+       };
+
+       /**
+        * Turn a tag into a field specification object
+        *
+        * Fields implemented here are:
+        * - ${intercalary|X|text}: Text that is only displayed when the 'intercalary'
+        *   component is X.
+        * - ${not-intercalary|X|text}: Text that is displayed unless the 'intercalary'
+        *   component is X.
+        * - ${zone|#}: Timezone offset, "+0000" format.
+        * - ${zone|:}: Timezone offset, "+00:00" format.
+        * - ${zone|short}: Timezone from 'shortZones' configuration setting.
+        * - ${zone|full}: Timezone from 'fullZones' configuration setting.
+        *
+        * @protected
+        * @abstract
+        * @param {string} tag
+        * @param {string[]} params
+        * @return {Object|null} Field specification object, or null if the tag+params are unrecognized.
+        * @return {string|null} return.component Date component corresponding to this field, if any.
+        * @return {boolean} return.editable Whether this field is editable.
+        * @return {string} return.type What kind of field this is:
+        *  - 'static': The field is a static string; component will be null.
+        *  - 'number': The field is generally numeric.
+        *  - 'string': The field is generally textual.
+        *  - 'boolean': The field is a boolean.
+        *  - 'toggleLocal': The field represents {@link #getLocal this.getLocal()}.
+        *    Editing should directly call {@link #toggleLocal this.toggleLocal()}.
+        * @return {number} return.size Maximum number of characters in the field (when
+        *  the 'intercalary' component is falsey). If 0, the field should be hidden entirely.
+        * @return {Object.<string,number>} return.intercalarySize Map from
+        *  'intercalary' component values to overridden sizes.
+        * @return {string} return.value For type='static', the string to display.
+        * @return {function(Mixed): string} return.formatValue A function to format a
+        *  component value as a display string.
+        * @return {function(string): Mixed} return.parseValue A function to parse a
+        *  display string into a component value. If parsing fails, returns undefined.
+        */
+       mw.widgets.datetime.DateTimeFormatter.prototype.getFieldForTag = function ( tag, params ) {
+               var c, spec = null;
+
+               switch ( tag ) {
+                       case 'intercalary':
+                       case 'not-intercalary':
+                               if ( params.length < 2 || !params[ 0 ] ) {
+                                       return null;
+                               }
+                               spec = {
+                                       component: null,
+                                       editable: false,
+                                       type: 'static',
+                                       value: params.slice( 1 ).join( '|' ),
+                                       size: 0,
+                                       intercalarySize: {}
+                               };
+                               if ( tag === 'intercalary' ) {
+                                       spec.intercalarySize[ params[ 0 ] ] = spec.value.length;
+                               } else {
+                                       spec.size = spec.value.length;
+                                       spec.intercalarySize[ params[ 0 ] ] = 0;
+                               }
+                               return spec;
+
+                       case 'zone':
+                               switch ( params[ 0 ] ) {
+                                       case '#':
+                                       case ':':
+                                               c = params[ 0 ] === '#' ? '' : ':';
+                                               return {
+                                                       component: 'zone',
+                                                       editable: true,
+                                                       type: 'toggleLocal',
+                                                       size: 5 + c.length,
+                                                       formatValue: function ( v ) {
+                                                               var o, r;
+                                                               if ( v ) {
+                                                                       o = new Date().getTimezoneOffset();
+                                                                       r = String( Math.abs( o ) % 60 );
+                                                                       while ( r.length < 2 ) {
+                                                                               r = '0' + r;
+                                                                       }
+                                                                       r = String( Math.floor( Math.abs( o ) / 60 ) ) + c + r;
+                                                                       while ( r.length < 4 + c.length ) {
+                                                                               r = '0' + r;
+                                                                       }
+                                                                       return ( o <= 0 ? '+' : '−' ) + r;
+                                                               } else {
+                                                                       return '+00' + c + '00';
+                                                               }
+                                                       },
+                                                       parseValue: function ( v ) {
+                                                               var m;
+                                                               v = String( v ).trim();
+                                                               if ( ( m = /^([+-−])([0-9]{1,2}):?([0-9]{2})$/.test( v ) ) ) {
+                                                                       return ( m[ 2 ] * 60 + m[ 3 ] ) * ( m[ 1 ] === '+' ? -1 : 1 );
+                                                               } else {
+                                                                       return undefined;
+                                                               }
+                                                       }
+                                               };
+
+                                       case 'short':
+                                       case 'full':
+                                               spec = {
+                                                       component: 'zone',
+                                                       editable: true,
+                                                       type: 'toggleLocal',
+                                                       values: params[ 0 ] === 'short' ? this.shortZones : this.fullZones,
+                                                       formatValue: this.formatSpecValue,
+                                                       parseValue: this.parseSpecValue
+                                               };
+                                               spec.size = Math.max.apply(
+                                                       null, $.map( spec.values, function ( v ) { return v.length; } )
+                                               );
+                                               return spec;
+                               }
+                               return null;
+
+                       default:
+                               return null;
+               }
+       };
+
+       /**
+        * Format a value for a field specification
+        *
+        * 'this' must be the field specification object. The intention is that you
+        * could just assign this function as the 'formatValue' for each field spec.
+        *
+        * Besides the publicly-documented fields, uses the following:
+        * - values: Enumerated values for the field
+        * - zeropad: Whether to pad the number with zeros.
+        *
+        * @protected
+        * @param {Mixed} v
+        * @return {string}
+        */
+       mw.widgets.datetime.DateTimeFormatter.prototype.formatSpecValue = function ( v ) {
+               if ( v === undefined || v === null ) {
+                       return '';
+               }
+
+               if ( typeof v === 'boolean' || this.type === 'toggleLocal' ) {
+                       v = v ? 1 : 0;
+               }
+
+               if ( this.values ) {
+                       return this.values[ v ];
+               }
+
+               v = String( v );
+               if ( this.zeropad ) {
+                       while ( v.length < this.size ) {
+                               v = '0' + v;
+                       }
+               }
+               return v;
+       };
+
+       /**
+        * Parse a value for a field specification
+        *
+        * 'this' must be the field specification object. The intention is that you
+        * could just assign this function as the 'parseValue' for each field spec.
+        *
+        * Besides the publicly-documented fields, uses the following:
+        * - values: Enumerated values for the field
+        *
+        * @protected
+        * @param {string} v
+        * @return {number|string|null}
+        */
+       mw.widgets.datetime.DateTimeFormatter.prototype.parseSpecValue = function ( v ) {
+               var k, re;
+
+               if ( v === '' ) {
+                       return null;
+               }
+
+               if ( !this.values ) {
+                       v = +v;
+                       if ( this.type === 'boolean' || this.type === 'toggleLocal' ) {
+                               return isNaN( v ) ? undefined : !!v;
+                       } else {
+                               return isNaN( v ) ? undefined : v;
+                       }
+               }
+
+               if ( v.normalize ) {
+                       v = v.normalize();
+               }
+               re = new RegExp( '^\\s*' + v.replace( /([\\{}()|.?*+\-\^$\[\]])/g, '\\$1' ), 'i' );
+               for ( k in this.values ) {
+                       k = +k;
+                       if ( !isNaN( k ) && re.test( this.values[ k ] ) ) {
+                               if ( this.type === 'boolean' || this.type === 'toggleLocal' ) {
+                                       return !!k;
+                               } else {
+                                       return k;
+                               }
+                       }
+               }
+               return undefined;
+       };
+
+       /**
+        * Get components from a Date object
+        *
+        * Most specific components are defined by the subclass. "Global" components
+        * are:
+        * - intercalary: {string} Non-falsey values are used to indicate intercalary days.
+        * - zone: {number} Timezone offset in minutes.
+        *
+        * @abstract
+        * @param {Date|null} date
+        * @return {Object} Components
+        */
+       mw.widgets.datetime.DateTimeFormatter.prototype.getComponentsFromDate = function ( date ) {
+               // Should be overridden by subclass
+               return {
+                       zone: this.local ? date.getTimezoneOffset() : 0
+               };
+       };
+
+       /**
+        * Get a Date object from components
+        *
+        * @param {Object} components Date components
+        * @return {Date}
+        */
+       mw.widgets.datetime.DateTimeFormatter.prototype.getDateFromComponents = function ( /* components */ ) {
+               // Should be overridden by subclass
+               return new Date();
+       };
+
+       /**
+        * Adjust a date
+        *
+        * @param {Date|null} date To be adjusted
+        * @param {string} component To adjust
+        * @param {number} delta Adjustment amount
+        * @param {string} mode Adjustment mode:
+        *  - 'overflow': "Jan 32" => "Feb 1", "Jan 33" => "Feb 2", "Feb 0" => "Jan 31", etc.
+        *  - 'wrap': "Jan 32" => "Jan 1", "Jan 33" => "Jan 2", "Jan 0" => "Jan 31", etc.
+        *  - 'clip': "Jan 32" => "Jan 31", "Feb 32" => "Feb 28" (or 29), "Feb 0" => "Feb 1", etc.
+        * @return {Date} Adjusted date
+        */
+       mw.widgets.datetime.DateTimeFormatter.prototype.adjustComponent = function ( date /*, component, delta, mode */ ) {
+               // Should be overridden by subclass
+               return date;
+       };
+
+       /**
+        * Get the column headings (weekday abbreviations) for a calendar grid
+        *
+        * Null-valued columns are hidden if getCalendarData() returns no "day" object
+        * for all days in that column.
+        *
+        * @abstract
+        * @return {Array} string or null
+        */
+       mw.widgets.datetime.DateTimeFormatter.prototype.getCalendarHeadings = function () {
+               // Should be overridden by subclass
+               return [];
+       };
+
+       /**
+        * Test whether two dates are in the same calendar grid
+        *
+        * @abstract
+        * @param {Date} date1
+        * @param {Date} date2
+        * @return {boolean}
+        */
+       mw.widgets.datetime.DateTimeFormatter.prototype.sameCalendarGrid = function ( date1, date2 ) {
+               // Should be overridden by subclass
+               return date1.getTime() === date2.getTime();
+       };
+
+       /**
+        * Test whether the date parts of two Dates are equal
+        *
+        * @param {Date} date1
+        * @param {Date} date2
+        * @return {boolean}
+        */
+       mw.widgets.datetime.DateTimeFormatter.prototype.datePartIsEqual = function ( date1, date2 ) {
+               if ( this.local ) {
+                       return (
+                               date1.getFullYear() === date2.getFullYear() &&
+                               date1.getMonth() === date2.getMonth() &&
+                               date1.getDate() === date2.getDate()
+                       );
+               } else {
+                       return (
+                               date1.getUTCFullYear() === date2.getUTCFullYear() &&
+                               date1.getUTCMonth() === date2.getUTCMonth() &&
+                               date1.getUTCDate() === date2.getUTCDate()
+                       );
+               }
+       };
+
+       /**
+        * Test whether the time parts of two Dates are equal
+        *
+        * @param {Date} date1
+        * @param {Date} date2
+        * @return {boolean}
+        */
+       mw.widgets.datetime.DateTimeFormatter.prototype.timePartIsEqual = function ( date1, date2 ) {
+               if ( this.local ) {
+                       return (
+                               date1.getHours() === date2.getHours() &&
+                               date1.getMinutes() === date2.getMinutes() &&
+                               date1.getSeconds() === date2.getSeconds() &&
+                               date1.getMilliseconds() === date2.getMilliseconds()
+                       );
+               } else {
+                       return (
+                               date1.getUTCHours() === date2.getUTCHours() &&
+                               date1.getUTCMinutes() === date2.getUTCMinutes() &&
+                               date1.getUTCSeconds() === date2.getUTCSeconds() &&
+                               date1.getUTCMilliseconds() === date2.getUTCMilliseconds()
+                       );
+               }
+       };
+
+       /**
+        * Test whether toggleLocal() changes the date part
+        *
+        * @param {Date} date
+        * @return {boolean}
+        */
+       mw.widgets.datetime.DateTimeFormatter.prototype.localChangesDatePart = function ( date ) {
+               return (
+                       date.getUTCFullYear() !== date.getFullYear() ||
+                       date.getUTCMonth() !== date.getMonth() ||
+                       date.getUTCDate() !== date.getDate()
+               );
+       };
+
+       /**
+        * Create a new Date by merging the date part from one with the time part from
+        * another.
+        *
+        * @param {Date} datepart
+        * @param {Date} timepart
+        * @return {Date}
+        */
+       mw.widgets.datetime.DateTimeFormatter.prototype.mergeDateAndTime = function ( datepart, timepart ) {
+               var ret = new Date( datepart.getTime() );
+
+               if ( this.local ) {
+                       ret.setHours(
+                               timepart.getHours(),
+                               timepart.getMinutes(),
+                               timepart.getSeconds(),
+                               timepart.getMilliseconds()
+                       );
+               } else {
+                       ret.setUTCHours(
+                               timepart.getUTCHours(),
+                               timepart.getUTCMinutes(),
+                               timepart.getUTCSeconds(),
+                               timepart.getUTCMilliseconds()
+                       );
+               }
+
+               return ret;
+       };
+
+       /**
+        * Get data for a calendar grid
+        *
+        * A "day" object is:
+        * - display: {string} Display text for the day.
+        * - date: {Date} Date to use when the day is selected.
+        * - extra: {string|null} 'prev' or 'next' on days used to fill out the weeks
+        *   at the start and end of the month.
+        *
+        * In any one result object, 'extra' + 'display' will always be unique.
+        *
+        * @abstract
+        * @param {Date|null} current Current date
+        * @return {Object} Data
+        * @return {string} return.header String to display as the calendar header
+        * @return {string} return.monthComponent Component to adjust by Â±1 to change months.
+        * @return {string} return.dayComponent Component to adjust by Â±1 to change days.
+        * @return {string} [return.weekComponent] Component to adjust by Â±1 to change
+        *   weeks. If omitted, the dayComponent should be adjusted by Â±the number of
+        *   non-nullable columns returned by this.getCalendarHeadings() to change weeks.
+        * @return {Array} return.rows Array of arrays of "day" objects or null/undefined.
+        */
+       mw.widgets.datetime.DateTimeFormatter.prototype.getCalendarData = function ( /* components */ ) {
+               // Should be overridden by subclass
+               return {
+                       header: '',
+                       monthComponent: 'month',
+                       dayComponent: 'day',
+                       rows: []
+               };
+       };
+
+}( jQuery, mediaWiki ) );
diff --git a/resources/src/mediawiki.widgets.datetime/DateTimeInputWidget.js b/resources/src/mediawiki.widgets.datetime/DateTimeInputWidget.js
new file mode 100644 (file)
index 0000000..df148c7
--- /dev/null
@@ -0,0 +1,812 @@
+( function ( $, mw ) {
+
+       /**
+        * DateTimeInputWidgets can be used to input a date, a time, or a date and
+        * time, in either UTC or the user's local timezone.
+        * Please see the [OOjs UI documentation on MediaWiki] [1] for more information and examples.
+        *
+        * This widget can be used inside a HTML form, such as a OO.ui.FormLayout.
+        *
+        *     @example
+        *     // Example of a text input widget
+        *     var dateTimeInput = new mw.widgets.datetime.DateTimeInputWidget( {} )
+        *     $( 'body' ).append( dateTimeInput.$element );
+        *
+        * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Inputs
+        *
+        * @class
+        * @extends OO.ui.InputWidget
+        * @mixins OO.ui.mixin.IconElement
+        * @mixins OO.ui.mixin.IndicatorElement
+        * @mixins OO.ui.mixin.PendingElement
+        *
+        * @constructor
+        * @param {Object} [config] Configuration options
+        * @cfg {string} [type='datetime'] Whether to act like a 'date', 'time', or 'datetime' input.
+        *  Affects values stored in the relevant <input> and the formatting and
+        *  interpretation of values passed to/from getValue() and setValue(). It's up
+        *  to the user to configure the DateTimeFormatter correctly.
+        * @cfg {Object|mw.widgets.datetime.DateTimeFormatter} [formatter={}] Configuration options for
+        *  mw.widgets.datetime.ProlepticGregorianDateTimeFormatter (with 'format' defaulting to
+        *  '@date', '@time', or '@datetime' depending on 'type'), or an
+        *  mw.widgets.datetime.DateTimeFormatter instance to use.
+        * @cfg {Object|null} [calendar={}] Configuration options for
+        *  mw.widgets.datetime.CalendarWidget; note certain settings will be forced based on the
+        *  settings passed to this widget. Set null to disable the calendar.
+        * @cfg {boolean} [required=false] Whether a value is required.
+        * @cfg {boolean} [clearable=true] Whether to provide for blanking the value.
+        * @cfg {Date|null} [value=null] Default value for the widget
+        * @cfg {Date|string|null} [min=null] Minimum allowed date
+        * @cfg {Date|string|null} [max=null] Maximum allowed date
+        */
+       mw.widgets.datetime.DateTimeInputWidget = function MwWidgetsDatetimeDateTimeInputWidget( config ) {
+               // Configuration initialization
+               config = $.extend( {
+                       type: 'datetime',
+                       clearable: true,
+                       required: false,
+                       min: null,
+                       max: null,
+                       formatter: {},
+                       calendar: {}
+               }, config );
+
+               if ( $.isPlainObject( config.formatter ) && config.formatter.format === undefined ) {
+                       config.formatter.format = '@' + config.type;
+               }
+
+               // Parent constructor
+               mw.widgets.datetime.DateTimeInputWidget[ 'super' ].call( this, config );
+
+               // Mixin constructors
+               OO.ui.mixin.IconElement.call( this, config );
+               OO.ui.mixin.IndicatorElement.call( this, config );
+               OO.ui.mixin.PendingElement.call( this, config );
+
+               // Properties
+               this.type = config.type;
+               this.$handle = $( '<span>' );
+               this.$fields = $( '<span>' );
+               this.fields = [];
+               this.clearable = !!config.clearable;
+               this.required = !!config.required;
+
+               if ( typeof config.min === 'string' ) {
+                       config.min = this.parseDateValue( config.min );
+               }
+               if ( config.min instanceof Date && config.min.getTime() >= -62167219200000 ) {
+                       this.min = config.min;
+               } else {
+                       this.min = new Date( -62167219200000 ); // 0000-01-01T00:00:00.000Z
+               }
+
+               if ( typeof config.max === 'string' ) {
+                       config.max = this.parseDateValue( config.max );
+               }
+               if ( config.max instanceof Date && config.max.getTime() <= 253402300799999 ) {
+                       this.max = config.max;
+               } else {
+                       this.max = new Date( 253402300799999 ); // 9999-12-31T12:59:59.999Z
+               }
+
+               switch ( this.type ) {
+                       case 'date':
+                               this.min.setUTCHours( 0, 0, 0, 0 );
+                               this.max.setUTCHours( 23, 59, 59, 999 );
+                               break;
+                       case 'time':
+                               this.min.setUTCFullYear( 1970, 0, 1 );
+                               this.max.setUTCFullYear( 1970, 0, 1 );
+                               break;
+               }
+               if ( this.min > this.max ) {
+                       throw new Error(
+                               '"min" (' + this.min.toISOString() + ') must not be greater than ' +
+                               '"max" (' + this.max.toISOString() + ')'
+                       );
+               }
+
+               if ( config.formatter instanceof mw.widgets.datetime.DateTimeFormatter ) {
+                       this.formatter = config.formatter;
+               } else if ( $.isPlainObject( config.formatter ) ) {
+                       this.formatter = new mw.widgets.datetime.ProlepticGregorianDateTimeFormatter( config.formatter );
+               } else {
+                       throw new Error( '"formatter" must be an mw.widgets.datetime.DateTimeFormatter or a plain object' );
+               }
+
+               if ( this.type === 'time' || config.calendar === null ) {
+                       this.calendar = null;
+               } else {
+                       config.calendar = $.extend( {}, config.calendar, {
+                               formatter: this.formatter,
+                               widget: this,
+                               min: this.min,
+                               max: this.max
+                       } );
+                       this.calendar = new mw.widgets.datetime.CalendarWidget( config.calendar );
+               }
+
+               // Events
+               this.$handle.on( {
+                       click: this.onHandleClick.bind( this )
+               } );
+               this.connect( this, {
+                       change: 'onChange'
+               } );
+               this.formatter.connect( this, {
+                       local: 'onChange'
+               } );
+               if ( this.calendar ) {
+                       this.calendar.connect( this, {
+                               change: 'onCalendarChange'
+                       } );
+               }
+
+               // Initialization
+               this.setTabIndex( -1 );
+
+               this.$fields.addClass( 'mw-widgets-datetime-dateTimeInputWidget-fields' );
+               this.setupFields();
+
+               this.$handle
+                       .addClass( 'mw-widgets-datetime-dateTimeInputWidget-handle' )
+                       .append( this.$icon, this.$indicator, this.$fields );
+
+               this.$element
+                       .addClass( 'mw-widgets-datetime-dateTimeInputWidget' )
+                       .append( this.$handle );
+
+               if ( this.calendar ) {
+                       this.$element.append( this.calendar.$element );
+               }
+       };
+
+       /* Setup */
+
+       OO.inheritClass( mw.widgets.datetime.DateTimeInputWidget, OO.ui.InputWidget );
+       OO.mixinClass( mw.widgets.datetime.DateTimeInputWidget, OO.ui.mixin.IconElement );
+       OO.mixinClass( mw.widgets.datetime.DateTimeInputWidget, OO.ui.mixin.IndicatorElement );
+       OO.mixinClass( mw.widgets.datetime.DateTimeInputWidget, OO.ui.mixin.PendingElement );
+
+       /* Static properties */
+
+       mw.widgets.datetime.DateTimeInputWidget[ 'static' ].supportsSimpleLabel = false;
+
+       /* Events */
+
+       /* Methods */
+
+       /**
+        * Convert a date string to a Date
+        *
+        * @private
+        * @param {string} value
+        * @return {Date|null}
+        */
+       mw.widgets.datetime.DateTimeInputWidget.prototype.parseDateValue = function ( value ) {
+               var date, m;
+
+               value = String( value );
+               switch ( this.type ) {
+                       case 'date':
+                               value = value + 'T00:00:00Z';
+                               break;
+                       case 'time':
+                               value = '1970-01-01T' + value + 'Z';
+                               break;
+               }
+               m = /^(\d{4,})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.(\d{1,3}))?Z$/.exec( value );
+               if ( m ) {
+                       if ( m[ 7 ] ) {
+                               while ( m[ 7 ].length < 3 ) {
+                                       m[ 7 ] += '0';
+                               }
+                       } else {
+                               m[ 7 ] = 0;
+                       }
+                       date = new Date();
+                       date.setUTCFullYear( m[ 1 ], m[ 2 ] - 1, m[ 3 ] );
+                       date.setUTCHours( m[ 4 ], m[ 5 ], m[ 6 ], m[ 7 ] );
+                       if ( date.getTime() < -62167219200000 || date.getTime() > 253402300799999 ||
+                               date.getUTCFullYear() !== +m[ 1 ] ||
+                               date.getUTCMonth() + 1 !== +m[ 2 ] ||
+                               date.getUTCDate() !== +m[ 3 ] ||
+                               date.getUTCHours() !== +m[ 4 ] ||
+                               date.getUTCMinutes() !== +m[ 5 ] ||
+                               date.getUTCSeconds() !== +m[ 6 ] ||
+                               date.getUTCMilliseconds() !== +m[ 7 ]
+                       ) {
+                               date = null;
+                       }
+               } else {
+                       date = null;
+               }
+
+               return date;
+       };
+
+       /**
+        * @inheritdoc
+        */
+       mw.widgets.datetime.DateTimeInputWidget.prototype.cleanUpValue = function ( value ) {
+               var date, pad;
+
+               if ( value === '' ) {
+                       return '';
+               }
+
+               if ( value instanceof Date ) {
+                       date = value;
+               } else {
+                       date = this.parseDateValue( value );
+               }
+
+               if ( date instanceof Date ) {
+                       pad = function ( v, l ) {
+                               v = String( v );
+                               while ( v.length < l ) {
+                                       v = '0' + v;
+                               }
+                               return v;
+                       };
+
+                       switch ( this.type ) {
+                               case 'date':
+                                       value = pad( date.getUTCFullYear(), 4 ) +
+                                               '-' + pad( date.getUTCMonth() + 1, 2 ) +
+                                               '-' + pad( date.getUTCDate(), 2 );
+                                       break;
+
+                               case 'time':
+                                       value = pad( date.getUTCHours(), 2 ) +
+                                               ':' + pad( date.getUTCMinutes(), 2 ) +
+                                               ':' + pad( date.getUTCSeconds(), 2 ) +
+                                               '.' + pad( date.getUTCMilliseconds(), 3 );
+                                       value = value.replace( /\.?0+$/, '' );
+                                       break;
+
+                               default:
+                                       value = date.toISOString();
+                                       break;
+                       }
+               } else {
+                       value = '';
+               }
+
+               return value;
+       };
+
+       /**
+        * Get the value of the input as a Date object
+        *
+        * @return {Date|null}
+        */
+       mw.widgets.datetime.DateTimeInputWidget.prototype.getValueAsDate = function () {
+               return this.parseDateValue( this.getValue() );
+       };
+
+       /**
+        * Set up the UI fields
+        *
+        * @private
+        */
+       mw.widgets.datetime.DateTimeInputWidget.prototype.setupFields = function () {
+               var i, $field, spec, placeholder, sz, maxlength,
+                       spanValFunc = function ( v ) {
+                               if ( v === undefined ) {
+                                       return this.data( 'mw-widgets-datetime-dateTimeInputWidget-value' );
+                               } else {
+                                       v = String( v );
+                                       this.data( 'mw-widgets-datetime-dateTimeInputWidget-value', v );
+                                       if ( v === '' ) {
+                                               v = this.data( 'mw-widgets-datetime-dateTimeInputWidget-placeholder' );
+                                       }
+                                       this.text( v );
+                                       return this;
+                               }
+                       },
+                       reduceFunc = function ( k, v ) {
+                               maxlength = Math.max( maxlength, v );
+                       },
+                       disabled = this.isDisabled(),
+                       specs = this.formatter.getFieldSpec();
+
+               this.$fields.empty();
+               this.clearButton = null;
+               this.fields = [];
+
+               for ( i = 0; i < specs.length; i++ ) {
+                       spec = specs[ i ];
+                       if ( typeof spec === 'string' ) {
+                               $( '<span>' )
+                                       .addClass( 'mw-widgets-datetime-dateTimeInputWidget-field' )
+                                       .text( spec )
+                                       .appendTo( this.$fields );
+                               continue;
+                       }
+
+                       placeholder = '';
+                       while ( placeholder.length < spec.size ) {
+                               placeholder += '_';
+                       }
+
+                       if ( spec.type === 'number' ) {
+                               // Numbers ''should'' be the same width. But we need some extra for
+                               // IE, apparently.
+                               sz = ( spec.size * 1.15 ) + 'ch';
+                       } else {
+                               // Add a little for padding
+                               sz = ( spec.size * 1.15 ) + 'ch';
+                       }
+                       if ( spec.editable && spec.type !== 'static' ) {
+                               if ( spec.type === 'boolean' || spec.type === 'toggleLocal' ) {
+                                       $field = $( '<span>' )
+                                               .attr( {
+                                                       tabindex: disabled ? -1 : 0
+                                               } )
+                                               .width( sz )
+                                               .data( 'mw-widgets-datetime-dateTimeInputWidget-placeholder', placeholder );
+                                       $field.on( {
+                                               keydown: this.onFieldKeyDown.bind( this, $field ),
+                                               focus: this.onFieldFocus.bind( this, $field ),
+                                               click: this.onFieldClick.bind( this, $field ),
+                                               'wheel mousewheel DOMMouseScroll': this.onFieldWheel.bind( this, $field )
+                                       } );
+                                       $field.val = spanValFunc;
+                               } else {
+                                       maxlength = spec.size;
+                                       if ( spec.intercalarySize ) {
+                                               $.each( spec.intercalarySize, reduceFunc );
+                                       }
+                                       $field = $( '<input type="text">' )
+                                               .attr( {
+                                                       tabindex: disabled ? -1 : 0,
+                                                       size: spec.size,
+                                                       maxlength: maxlength
+                                               } )
+                                               .prop( {
+                                                       disabled: disabled,
+                                                       placeholder: placeholder
+                                               } )
+                                               .width( sz );
+                                       $field.on( {
+                                               keydown: this.onFieldKeyDown.bind( this, $field ),
+                                               click: this.onFieldClick.bind( this, $field ),
+                                               focus: this.onFieldFocus.bind( this, $field ),
+                                               blur: this.onFieldBlur.bind( this, $field ),
+                                               change: this.onFieldChange.bind( this, $field ),
+                                               'wheel mousewheel DOMMouseScroll': this.onFieldWheel.bind( this, $field )
+                                       } );
+                               }
+                               $field.addClass( 'mw-widgets-datetime-dateTimeInputWidget-editField' );
+                       } else {
+                               $field = $( '<span>' )
+                                       .width( sz )
+                                       .data( 'mw-widgets-datetime-dateTimeInputWidget-placeholder', placeholder );
+                               if ( spec.type === 'static' ) {
+                                       $field.text( spec.value );
+                               } else {
+                                       $field.val = spanValFunc;
+                               }
+                       }
+
+                       this.fields.push( $field );
+                       $field
+                               .addClass( 'mw-widgets-datetime-dateTimeInputWidget-field' )
+                               .data( 'mw-widgets-datetime-dateTimeInputWidget-fieldSpec', spec )
+                               .appendTo( this.$fields );
+               }
+
+               if ( this.clearable ) {
+                       this.clearButton = new OO.ui.ButtonWidget( {
+                               classes: [ 'mw-widgets-datetime-dateTimeInputWidget-field', 'mw-widgets-datetime-dateTimeInputWidget-clearButton' ],
+                               framed: false,
+                               icon: 'remove',
+                               disabled: disabled
+                       } ).connect( this, {
+                               click: 'onClearClick'
+                       } );
+                       this.$fields.append( this.clearButton.$element );
+               }
+
+               this.updateFieldsFromValue();
+       };
+
+       /**
+        * Update the UI fields from the current value
+        *
+        * @private
+        */
+       mw.widgets.datetime.DateTimeInputWidget.prototype.updateFieldsFromValue = function () {
+               var i, $field, spec, intercalary, sz,
+                       date = this.getValueAsDate();
+
+               if ( date === null ) {
+                       this.components = null;
+
+                       for ( i = 0; i < this.fields.length; i++ ) {
+                               $field = this.fields[ i ];
+                               spec = $field.data( 'mw-widgets-datetime-dateTimeInputWidget-fieldSpec' );
+
+                               $field
+                                       .removeClass( 'mw-widgets-datetime-dateTimeInputWidget-invalid oo-ui-element-hidden' )
+                                       .val( '' );
+
+                               if ( spec.intercalarySize ) {
+                                       if ( spec.type === 'number' ) {
+                                               // Numbers ''should'' be the same width. But we need some extra for
+                                               // IE, apparently.
+                                               $field.width( ( spec.size * 1.15 ) + 'ch' );
+                                       } else {
+                                               // Add a little for padding
+                                               $field.width( ( spec.size * 1.15 ) + 'ch' );
+                                       }
+                               }
+                       }
+
+                       this.setFlags( { invalid: this.required } );
+               } else {
+                       this.components = this.formatter.getComponentsFromDate( date );
+                       intercalary = this.components.intercalary;
+
+                       for ( i = 0; i < this.fields.length; i++ ) {
+                               $field = this.fields[ i ];
+                               $field.removeClass( 'mw-widgets-datetime-dateTimeInputWidget-invalid' );
+                               spec = $field.data( 'mw-widgets-datetime-dateTimeInputWidget-fieldSpec' );
+                               if ( spec.type !== 'static' ) {
+                                       $field.val( spec.formatValue( this.components[ spec.component ] ) );
+                               }
+                               if ( spec.intercalarySize ) {
+                                       if ( intercalary && spec.intercalarySize[ intercalary ] !== undefined ) {
+                                               sz = spec.intercalarySize[ intercalary ];
+                                       } else {
+                                               sz = spec.size;
+                                       }
+                                       $field.toggleClass( 'oo-ui-element-hidden', sz <= 0 );
+                                       if ( spec.type === 'number' ) {
+                                               // Numbers ''should'' be the same width. But we need some extra for
+                                               // IE, apparently.
+                                               this.fields[ i ].width( ( sz * 1.15 ) + 'ch' );
+                                       } else {
+                                               // Add a little for padding
+                                               this.fields[ i ].width( ( sz * 1.15 ) + 'ch' );
+                                       }
+                               }
+                       }
+
+                       this.setFlags( { invalid: date < this.min || date > this.max } );
+               }
+
+               this.$element.toggleClass( 'mw-widgets-datetime-dateTimeInputWidget-empty', date === null );
+       };
+
+       /**
+        * Update the value with data from the UI fields
+        *
+        * @private
+        */
+       mw.widgets.datetime.DateTimeInputWidget.prototype.updateValueFromFields = function () {
+               var i, v, $field, spec, curDate, newDate,
+                       components = {},
+                       anyInvalid = false,
+                       anyEmpty = false,
+                       allEmpty = true;
+
+               for ( i = 0; i < this.fields.length; i++ ) {
+                       $field = this.fields[ i ];
+                       spec = $field.data( 'mw-widgets-datetime-dateTimeInputWidget-fieldSpec' );
+                       if ( spec.editable ) {
+                               $field.removeClass( 'mw-widgets-datetime-dateTimeInputWidget-invalid' );
+                               v = $field.val();
+                               if ( v === '' ) {
+                                       $field.addClass( 'mw-widgets-datetime-dateTimeInputWidget-invalid' );
+                                       anyEmpty = true;
+                               } else {
+                                       allEmpty = false;
+                                       v = spec.parseValue( v );
+                                       if ( v === undefined ) {
+                                               $field.addClass( 'mw-widgets-datetime-dateTimeInputWidget-invalid' );
+                                               anyInvalid = true;
+                                       } else {
+                                               components[ spec.component ] = v;
+                                       }
+                               }
+                       }
+               }
+
+               if ( allEmpty ) {
+                       for ( i = 0; i < this.fields.length; i++ ) {
+                               this.fields[ i ].removeClass( 'mw-widgets-datetime-dateTimeInputWidget-invalid' );
+                       }
+               } else if ( anyEmpty ) {
+                       anyInvalid = true;
+               }
+
+               if ( !anyInvalid ) {
+                       curDate = this.getValueAsDate();
+                       newDate = this.formatter.getDateFromComponents( components );
+                       if ( !curDate || !newDate || curDate.getTime() !== newDate.getTime() ) {
+                               this.setValue( newDate );
+                       }
+               }
+       };
+
+       /**
+        * Handle change event
+        *
+        * @private
+        */
+       mw.widgets.datetime.DateTimeInputWidget.prototype.onChange = function () {
+               var date;
+
+               this.updateFieldsFromValue();
+
+               if ( this.calendar ) {
+                       date = this.getValueAsDate();
+                       this.calendar.setSelected( date );
+                       if ( date ) {
+                               this.calendar.setFocusedDate( date );
+                       }
+               }
+       };
+
+       /**
+        * Handle clear button click event
+        *
+        * @private
+        */
+       mw.widgets.datetime.DateTimeInputWidget.prototype.onClearClick = function () {
+               this.blur();
+               this.setValue( '' );
+       };
+
+       /**
+        * Handle click on the widget background
+        *
+        * @private
+        * @param {jQuery.Event} e Click event
+        */
+       mw.widgets.datetime.DateTimeInputWidget.prototype.onHandleClick = function () {
+               this.focus();
+       };
+
+       /**
+        * Handle key down events on our field inputs.
+        *
+        * @private
+        * @param {jQuery} $field
+        * @param {jQuery.Event} e Key down event
+        */
+       mw.widgets.datetime.DateTimeInputWidget.prototype.onFieldKeyDown = function ( $field, e ) {
+               var spec = $field.data( 'mw-widgets-datetime-dateTimeInputWidget-fieldSpec' );
+
+               if ( !this.isDisabled() ) {
+                       switch ( e.which ) {
+                               case OO.ui.Keys.ENTER:
+                               case OO.ui.Keys.SPACE:
+                                       if ( spec.type === 'boolean' ) {
+                                               this.setValue(
+                                                       this.formatter.adjustComponent( this.getValueAsDate(), spec.component, 1, 'wrap' )
+                                               );
+                                               return false;
+                                       } else if ( spec.type === 'toggleLocal' ) {
+                                               this.formatter.toggleLocal();
+                                       }
+                                       break;
+
+                               case OO.ui.Keys.UP:
+                               case OO.ui.Keys.DOWN:
+                                       if ( spec.type === 'toggleLocal' ) {
+                                               this.formatter.toggleLocal();
+                                       } else {
+                                               this.setValue(
+                                                       this.formatter.adjustComponent( this.getValueAsDate(), spec.component,
+                                                               e.keyCode === OO.ui.Keys.UP ? -1 : 1, 'wrap' )
+                                               );
+                                       }
+                                       if ( $field.is( ':input' ) ) {
+                                               $field.select();
+                                       }
+                                       return false;
+                       }
+               }
+       };
+
+       /**
+        * Handle focus events on our field inputs.
+        *
+        * @private
+        * @param {jQuery} $field
+        * @param {jQuery.Event} e Focus event
+        */
+       mw.widgets.datetime.DateTimeInputWidget.prototype.onFieldFocus = function ( $field ) {
+               if ( !this.isDisabled() ) {
+                       if ( this.getValueAsDate() === null ) {
+                               this.setValue( this.formatter.getDefaultDate() );
+                       }
+                       if ( $field.is( ':input' ) ) {
+                               $field.select();
+                       }
+
+                       if ( this.calendar ) {
+                               this.calendar.toggle( true );
+                       }
+               }
+       };
+
+       /**
+        * Handle click events on our field inputs.
+        *
+        * @private
+        * @param {jQuery} $field
+        * @param {jQuery.Event} e Click event
+        */
+       mw.widgets.datetime.DateTimeInputWidget.prototype.onFieldClick = function ( $field ) {
+               var spec = $field.data( 'mw-widgets-datetime-dateTimeInputWidget-fieldSpec' );
+
+               if ( !this.isDisabled() ) {
+                       if ( spec.type === 'boolean' ) {
+                               this.setValue(
+                                       this.formatter.adjustComponent( this.getValueAsDate(), spec.component, 1, 'wrap' )
+                               );
+                       } else if ( spec.type === 'toggleLocal' ) {
+                               this.formatter.toggleLocal();
+                       }
+               }
+       };
+
+       /**
+        * Handle blur events on our field inputs.
+        *
+        * @private
+        * @param {jQuery} $field
+        * @param {jQuery.Event} e Blur event
+        */
+       mw.widgets.datetime.DateTimeInputWidget.prototype.onFieldBlur = function ( $field ) {
+               var v, date,
+                       spec = $field.data( 'mw-widgets-datetime-dateTimeInputWidget-fieldSpec' );
+
+               this.updateValueFromFields();
+
+               // Normalize
+               date = this.getValueAsDate();
+               if ( !date ) {
+                       $field.val( '' );
+               } else {
+                       v = spec.formatValue( this.formatter.getComponentsFromDate( date )[ spec.component ] );
+                       if ( v !== $field.val() ) {
+                               $field.val( v );
+                       }
+               }
+       };
+
+       /**
+        * Handle change events on our field inputs.
+        *
+        * @private
+        * @param {jQuery} $field
+        * @param {jQuery.Event} e Change event
+        */
+       mw.widgets.datetime.DateTimeInputWidget.prototype.onFieldChange = function () {
+               this.updateValueFromFields();
+       };
+
+       /**
+        * Handle wheel events on our field inputs.
+        *
+        * @private
+        * @param {jQuery} $field
+        * @param {jQuery.Event} e Change event
+        */
+       mw.widgets.datetime.DateTimeInputWidget.prototype.onFieldWheel = function ( $field, e ) {
+               var delta = 0,
+                       spec = $field.data( 'mw-widgets-datetime-dateTimeInputWidget-fieldSpec' );
+
+               if ( this.isDisabled() ) {
+                       return;
+               }
+
+               // Standard 'wheel' event
+               if ( e.originalEvent.deltaMode !== undefined ) {
+                       this.sawWheelEvent = true;
+               }
+               if ( e.originalEvent.deltaY ) {
+                       delta = -e.originalEvent.deltaY;
+               } else if ( e.originalEvent.deltaX ) {
+                       delta = e.originalEvent.deltaX;
+               }
+
+               // Non-standard events
+               if ( !this.sawWheelEvent ) {
+                       if ( e.originalEvent.wheelDeltaX ) {
+                               delta = -e.originalEvent.wheelDeltaX;
+                       } else if ( e.originalEvent.wheelDeltaY ) {
+                               delta = e.originalEvent.wheelDeltaY;
+                       } else if ( e.originalEvent.wheelDelta ) {
+                               delta = e.originalEvent.wheelDelta;
+                       } else if ( e.originalEvent.detail ) {
+                               delta = -e.originalEvent.detail;
+                       }
+               }
+
+               if ( delta && spec ) {
+                       if ( spec.type === 'toggleLocal' ) {
+                               this.formatter.toggleLocal();
+                       } else {
+                               this.setValue(
+                                       this.formatter.adjustComponent( this.getValueAsDate(), spec.component, delta < 0 ? -1 : 1, 'wrap' )
+                               );
+                       }
+                       return false;
+               }
+       };
+
+       /**
+        * Handle calendar change event
+        *
+        * @private
+        */
+       mw.widgets.datetime.DateTimeInputWidget.prototype.onCalendarChange = function () {
+               var curDate = this.getValueAsDate(),
+                       newDate = this.calendar.getSelected()[ 0 ];
+
+               if ( newDate ) {
+                       if ( !curDate || newDate.getTime() !== curDate.getTime() ) {
+                               this.setValue( newDate );
+                       }
+               }
+       };
+
+       /**
+        * @inheritdoc
+        * @private
+        */
+       mw.widgets.datetime.DateTimeInputWidget.prototype.getInputElement = function () {
+               return $( '<input type="hidden" />' );
+       };
+
+       /**
+        * @inheritdoc
+        */
+       mw.widgets.datetime.DateTimeInputWidget.prototype.setDisabled = function ( disabled ) {
+               mw.widgets.datetime.DateTimeInputWidget[ 'super' ].prototype.setDisabled.call( this, disabled );
+
+               // Flag all our fields as disabled
+               if ( this.$fields ) {
+                       this.$fields.find( 'input' ).prop( 'disabled', this.isDisabled() );
+                       this.$fields.find( '[tabindex]' ).attr( 'tabindex', this.isDisabled() ? -1 : 0 );
+               }
+
+               if ( this.clearButton ) {
+                       this.clearButton.setDisabled( disabled );
+               }
+
+               return this;
+       };
+
+       /**
+        * @inheritdoc
+        */
+       mw.widgets.datetime.DateTimeInputWidget.prototype.focus = function () {
+               if ( !this.$fields.find( document.activeElement ).length ) {
+                       this.$fields.find( '.mw-widgets-datetime-dateTimeInputWidget-editField' ).first().focus();
+               }
+               return this;
+       };
+
+       /**
+        * @inheritdoc
+        */
+       mw.widgets.datetime.DateTimeInputWidget.prototype.blur = function () {
+               this.$fields.find( document.activeElement ).blur();
+               return this;
+       };
+
+       /**
+        * @inheritdoc
+        */
+       mw.widgets.datetime.DateTimeInputWidget.prototype.simulateLabelClick = function () {
+               this.focus();
+       };
+
+}( jQuery, mediaWiki ) );
diff --git a/resources/src/mediawiki.widgets.datetime/DateTimeInputWidget.less b/resources/src/mediawiki.widgets.datetime/DateTimeInputWidget.less
new file mode 100644 (file)
index 0000000..bc387df
--- /dev/null
@@ -0,0 +1,155 @@
+@import "mediawiki.widgets.datetime.definitions";
+
+.mw-widgets-datetime-dateTimeInputWidget {
+       display: inline-block;
+       position: relative;
+       vertical-align: middle;
+
+       &-fields {
+               position: relative;
+               display: table;
+               z-index: 2;
+               .oo-ui-unselectable();
+
+               > .mw-widgets-datetime-dateTimeInputWidget-field {
+                       .oo-ui-box-sizing(border-box);
+
+                       display: table-cell;
+                       white-space: pre;
+               }
+       }
+
+       &-handle {
+               width: 100%;
+               display: inline-block;
+               overflow: hidden;
+
+               // Needed for proper behavior with overflow: hidden.
+               vertical-align: bottom;
+
+               .oo-ui-unselectable();
+               .oo-ui-box-sizing(border-box);
+
+               > .oo-ui-indicatorElement-indicator,
+               > .oo-ui-iconElement-icon {
+                       position: absolute;
+                       background-position: center center;
+                       background-repeat: no-repeat;
+                       z-index: 1;
+               }
+       }
+
+       margin: 0.25em 0;
+       width: 100%;
+       max-width: 50em;
+
+       .oo-ui-inline-spacing(0.5em);
+
+       &-handle {
+               height: 2.5em;
+               border: 1px solid #ccc;
+               padding: 0 1em;
+               margin: 0;
+               background-color: #fff;
+               color: black;
+               border: solid 1px #ccc;
+               box-shadow: inset 0 0 0 0 @progressive;
+               border-radius: 0.1em;
+               .oo-ui-transition(box-shadow @quick-ease);
+               .oo-ui-box-sizing(border-box);
+
+               > .oo-ui-indicatorElement-indicator {
+                       right: 0;
+               }
+
+               > .oo-ui-iconElement-icon {
+                       left: 0.25em;
+               }
+
+               > .oo-ui-indicatorElement-indicator {
+                       top: 0;
+                       width: @indicator-size;
+                       height: @indicator-size;
+                       margin: 0.775em;
+               }
+
+               > .oo-ui-iconElement-icon {
+                       top: 0;
+                       width: @icon-size;
+                       height: @icon-size;
+                       margin: 0.3em;
+               }
+       }
+
+       &-empty &-handle {
+               color: #777;
+       }
+
+       &-field {
+               padding: 0;
+               margin: 0;
+               font-size: inherit;
+               font-family: inherit;
+               background-color: transparent;
+               color: inherit;
+               border: none;
+               box-shadow: none;
+               text-align: center;
+               vertical-align: middle;
+               .oo-ui-box-sizing(border-box);
+       }
+
+       &.oo-ui-widget-disabled {
+               .mw-widgets-datetime-dateTimeInputWidget-handle {
+                       color: #ccc;
+                       text-shadow: 0 1px 1px #fff;
+                       border-color: #ddd;
+                       background-color: #f3f3f3;
+
+                       > .oo-ui-iconElement-icon,
+                       > .oo-ui-indicatorElement-indicator {
+                               opacity: 0.2;
+                       }
+               }
+       }
+
+       &.oo-ui-widget-enabled {
+               .mw-widgets-datetime-dateTimeInputWidget-editField:hover {
+                       background-color: #eee;
+               }
+
+               &.oo-ui-flaggedElement-invalid {
+                       .mw-widgets-datetime-dateTimeInputWidget-handle {
+                               border-color: red;
+                               box-shadow: inset 0 0 0 0 red;
+                       }
+
+                       .mw-widgets-datetime-dateTimeInputWidget-handle:focus {
+                               border-color: red;
+                               box-shadow: inset 0 0 0 0.1em red;
+                       }
+               }
+       }
+
+       input.mw-widgets-datetime-dateTimeInputWidget-field {
+               padding: 0.5em 0;
+       }
+
+       &-editField.mw-widgets-datetime-dateTimeInputWidget-invalid {
+               border: 1px solid red;
+               box-shadow: inset 0 0 0 0 red;
+
+               &:focus {
+                       border: 1px solid red;
+                       box-shadow: inset 0 0 0 0.1em red;
+               }
+       }
+
+       &.oo-ui-iconElement .mw-widgets-datetime-dateTimeInputWidget-handle {
+               padding-left: 3em;
+       }
+
+       &.oo-ui-indicatorElement .mw-widgets-datetime-dateTimeInputWidget-handle {
+               padding-right: 2em;
+       }
+}
diff --git a/resources/src/mediawiki.widgets.datetime/DiscordianDateTimeFormatter.js b/resources/src/mediawiki.widgets.datetime/DiscordianDateTimeFormatter.js
new file mode 100644 (file)
index 0000000..fbf3238
--- /dev/null
@@ -0,0 +1,562 @@
+( function ( $, mw ) {
+
+       /**
+        * Provides various methods needed for formatting dates and times. This
+        * implementation implments the [Discordian calendar][1], mainly for testing with
+        * something very different from the usual Gregorian calendar.
+        *
+        * Being intended mainly for testing, niceties like i18n and better
+        * configurability have been omitted.
+        *
+        * [1]: https://en.wikipedia.org/wiki/Discordian_calendar
+        *
+        * @class
+        * @extends mw.widgets.datetime.DateTimeFormatter
+        *
+        * @constructor
+        * @param {Object} [config] Configuration options
+        */
+       mw.widgets.datetime.DiscordianDateTimeFormatter = function MwWidgetsDatetimeDiscordianDateTimeFormatter( config ) {
+               config = $.extend( {}, config );
+
+               // Parent constructor
+               mw.widgets.datetime.DiscordianDateTimeFormatter[ 'super' ].call( this, config );
+       };
+
+       /* Setup */
+
+       OO.inheritClass( mw.widgets.datetime.DiscordianDateTimeFormatter, mw.widgets.datetime.DateTimeFormatter );
+
+       /* Static */
+
+       /**
+        * @inheritdoc
+        */
+       mw.widgets.datetime.DiscordianDateTimeFormatter[ 'static' ].formats = {
+               '@time': '${hour|0}:${minute|0}:${second|0}',
+               '@date': '$!{dow|full}${not-intercalary|1|, }${season|full}${not-intercalary|1| }${day|#}, ${year|#}',
+               '@datetime': '$!{dow|full}${not-intercalary|1|, }${season|full}${not-intercalary|1| }${day|#}, ${year|#} ${hour|0}:${minute|0}:${second|0} $!{zone|short}',
+               '@default': '$!{dow|full}${not-intercalary|1|, }${season|full}${not-intercalary|1| }${day|#}, ${year|#} ${hour|0}:${minute|0}:${second|0} $!{zone|short}'
+       };
+
+       /* Methods */
+
+       /**
+        * @inheritdoc
+        *
+        * Additional fields implemented here are:
+        * - ${year|#}: Year as a number
+        * - ${season|#}: Season as a number
+        * - ${season|full}: Season as a string
+        * - ${day|#}: Day of the month as a number
+        * - ${day|0}: Day of the month as a number with leading 0
+        * - ${dow|full}: Day of the week as a string
+        * - ${hour|#}: Hour as a number
+        * - ${hour|0}: Hour as a number with leading 0
+        * - ${minute|#}: Minute as a number
+        * - ${minute|0}: Minute as a number with leading 0
+        * - ${second|#}: Second as a number
+        * - ${second|0}: Second as a number with leading 0
+        * - ${millisecond|#}: Millisecond as a number
+        * - ${millisecond|0}: Millisecond as a number, zero-padded to 3 digits
+        */
+       mw.widgets.datetime.DiscordianDateTimeFormatter.prototype.getFieldForTag = function ( tag, params ) {
+               var spec = null;
+
+               switch ( tag + '|' + params[ 0 ] ) {
+                       case 'year|#':
+                               spec = {
+                                       component: 'Year',
+                                       type: 'number',
+                                       size: 4,
+                                       zeropad: false
+                               };
+                               break;
+
+                       case 'season|#':
+                               spec = {
+                                       component: 'Season',
+                                       type: 'number',
+                                       size: 1,
+                                       intercalarySize: { 1: 0 },
+                                       zeropad: false
+                               };
+                               break;
+
+                       case 'season|full':
+                               spec = {
+                                       component: 'Season',
+                                       type: 'string',
+                                       intercalarySize: { 1: 0 },
+                                       values: {
+                                               1: 'Chaos',
+                                               2: 'Discord',
+                                               3: 'Confusion',
+                                               4: 'Bureaucracy',
+                                               5: 'The Aftermath'
+                                       }
+                               };
+                               break;
+
+                       case 'dow|full':
+                               spec = {
+                                       component: 'DOW',
+                                       editable: false,
+                                       type: 'string',
+                                       intercalarySize: { 1: 0 },
+                                       values: {
+                                               '-1': 'N/A',
+                                               0: 'Sweetmorn',
+                                               1: 'Boomtime',
+                                               2: 'Pungenday',
+                                               3: 'Prickle-Prickle',
+                                               4: 'Setting Orange'
+                                       }
+                               };
+                               break;
+
+                       case 'day|#':
+                       case 'day|0':
+                               spec = {
+                                       component: 'Day',
+                                       type: 'string',
+                                       size: 2,
+                                       intercalarySize: { 1: 13 },
+                                       zeropad: params[ 0 ] === '0',
+                                       formatValue: function ( v ) {
+                                               if ( v === 'tib' ) {
+                                                       return 'St. Tib\'s Day';
+                                               }
+                                               return mw.widgets.datetime.DateTimeFormatter.prototype.formatSpecValue.call( this, v );
+                                       },
+                                       parseValue: function ( v ) {
+                                               if ( /^\s*(st.?\s*)?tib('?s)?(\s*day)?\s*$/i.test( v ) ) {
+                                                       return 'tib';
+                                               }
+                                               return mw.widgets.datetime.DateTimeFormatter.prototype.parseSpecValue.call( this, v );
+                                       }
+                               };
+                               break;
+
+                       case 'hour|#':
+                       case 'hour|0':
+                       case 'minute|#':
+                       case 'minute|0':
+                       case 'second|#':
+                       case 'second|0':
+                               spec = {
+                                       component: tag.charAt( 0 ).toUpperCase() + tag.slice( 1 ),
+                                       type: 'number',
+                                       size: 2,
+                                       zeropad: params[ 0 ] === '0'
+                               };
+                               break;
+
+                       case 'millisecond|#':
+                       case 'millisecond|0':
+                               spec = {
+                                       component: 'Millisecond',
+                                       type: 'number',
+                                       size: 3,
+                                       zeropad: params[ 0 ] === '0'
+                               };
+                               break;
+
+                       default:
+                               return mw.widgets.datetime.DiscordianDateTimeFormatter[ 'super' ].prototype.getFieldForTag.call( this, tag, params );
+               }
+
+               if ( spec ) {
+                       if ( spec.editable === undefined ) {
+                               spec.editable = true;
+                       }
+                       if ( spec.component !== 'Day' ) {
+                               spec.formatValue = this.formatSpecValue;
+                               spec.parseValue = this.parseSpecValue;
+                       }
+                       if ( spec.values ) {
+                               spec.size = Math.max.apply(
+                                       null, $.map( spec.values, function ( v ) { return v.length; } )
+                               );
+                       }
+               }
+
+               return spec;
+       };
+
+       /**
+        * Get components from a Date object
+        *
+        * Components are:
+        * - Year {number}
+        * - Season {number} 1-5
+        * - Day {number|string} 1-73 or 'tib'
+        * - DOW {number} 0-4, or -1 on St. Tib's Day
+        * - Hour {number} 0-23
+        * - Minute {number} 0-59
+        * - Second {number} 0-59
+        * - Millisecond {number} 0-999
+        * - intercalary {string} '1' on St. Tib's Day
+        *
+        * @param {Date|null} date
+        * @return {Object} Components
+        */
+       mw.widgets.datetime.DiscordianDateTimeFormatter.prototype.getComponentsFromDate = function ( date ) {
+               var ret, day, month,
+                       monthDays = [ 0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 304, 334 ];
+
+               if ( !( date instanceof Date ) ) {
+                       date = this.defaultDate;
+               }
+
+               if ( this.local ) {
+                       day = date.getDate();
+                       month = date.getMonth();
+                       ret = {
+                               Year: date.getFullYear() + 1166,
+                               Hour: date.getHours(),
+                               Minute: date.getMinutes(),
+                               Second: date.getSeconds(),
+                               Millisecond: date.getMilliseconds(),
+                               zone: date.getTimezoneOffset()
+                       };
+               } else {
+                       day = date.getUTCDate();
+                       month = date.getUTCMonth();
+                       ret = {
+                               Year: date.getUTCFullYear() + 1166,
+                               Hour: date.getUTCHours(),
+                               Minute: date.getUTCMinutes(),
+                               Second: date.getUTCSeconds(),
+                               Millisecond: date.getUTCMilliseconds(),
+                               zone: 0
+                       };
+               }
+
+               if ( month === 1 && day === 29 ) {
+                       ret.Season = 1;
+                       ret.Day = 'tib';
+                       ret.DOW = -1;
+                       ret.intercalary = '1';
+               } else {
+                       day = monthDays[ month ] + day - 1;
+                       ret.Season = Math.floor( day / 73 ) + 1;
+                       ret.Day = ( day % 73 ) + 1;
+                       ret.DOW = day % 5;
+                       ret.intercalary = '';
+               }
+
+               return ret;
+       };
+
+       /**
+        * @inheritdoc
+        */
+       mw.widgets.datetime.DiscordianDateTimeFormatter.prototype.adjustComponent = function ( date, component, delta, mode ) {
+               return this.getDateFromComponents(
+                       this.adjustComponentInternal(
+                               this.getComponentsFromDate( date ), component, delta, mode
+                       )
+               );
+       };
+
+       /**
+        * Adjust the components directly
+        *
+        * @private
+        * @param {Object} components Modified in place
+        * @param {string} component
+        * @param {number} delta
+        * @param {string} mode
+        * @return {Object} components
+        */
+       mw.widgets.datetime.DiscordianDateTimeFormatter.prototype.adjustComponentInternal = function ( components, component, delta, mode ) {
+               var i, min, max, range, next, preTib, postTib, wasTib;
+
+               if ( delta === 0 ) {
+                       return components;
+               }
+
+               switch ( component ) {
+                       case 'Year':
+                               min = 1166;
+                               max = 11165;
+                               next = null;
+                               break;
+                       case 'Season':
+                               min = 1;
+                               max = 5;
+                               next = 'Year';
+                               break;
+                       case 'Week':
+                               if ( components.Day === 'tib' ) {
+                                       components.Day = 59; // Could choose either one...
+                                       components.Season = 1;
+                               }
+                               min = 1;
+                               max = 73;
+                               next = 'Season';
+                               break;
+                       case 'Day':
+                               min = 1;
+                               max = 73;
+                               next = 'Season';
+                               break;
+                       case 'Hour':
+                               min = 0;
+                               max = 23;
+                               next = 'Day';
+                               break;
+                       case 'Minute':
+                               min = 0;
+                               max = 59;
+                               next = 'Hour';
+                               break;
+                       case 'Second':
+                               min = 0;
+                               max = 59;
+                               next = 'Minute';
+                               break;
+                       case 'Millisecond':
+                               min = 0;
+                               max = 999;
+                               next = 'Second';
+                               break;
+                       default:
+                               return components;
+               }
+
+               switch ( mode ) {
+                       case 'overflow':
+                       case 'clip':
+                       case 'wrap':
+               }
+
+               if ( component === 'Day' ) {
+                       i = Math.abs( delta );
+                       delta = delta < 0 ? -1 : 1;
+                       preTib = delta > 0 ? 59 : 60;
+                       postTib = delta > 0 ? 60 : 59;
+                       while ( i-- > 0 ) {
+                               if ( components.Day === preTib && components.Season === 1 && this.isLeapYear( components.Year ) ) {
+                                       components.Day = 'tib';
+                               } else if ( components.Day === 'tib' ) {
+                                       components.Day = postTib;
+                                       components.Season = 1;
+                               } else {
+                                       components.Day += delta;
+                                       if ( components.Day < min ) {
+                                               switch ( mode ) {
+                                                       case 'overflow':
+                                                               components.Day = max;
+                                                               this.adjustComponentInternal( components, 'Season', -1, mode );
+                                                               break;
+                                                       case 'wrap':
+                                                               components.Day = max;
+                                                               break;
+                                                       case 'clip':
+                                                               components.Day = min;
+                                                               i = 0;
+                                                               break;
+                                               }
+                                       }
+                                       if ( components.Day > max ) {
+                                               switch ( mode ) {
+                                                       case 'overflow':
+                                                               components.Day = min;
+                                                               this.adjustComponentInternal( components, 'Season', 1, mode );
+                                                               break;
+                                                       case 'wrap':
+                                                               components.Day = min;
+                                                               break;
+                                                       case 'clip':
+                                                               components.Day = max;
+                                                               i = 0;
+                                                               break;
+                                               }
+                                       }
+                               }
+                       }
+               } else {
+                       if ( component === 'Week' ) {
+                               component = 'Day';
+                               delta *= 5;
+                       }
+                       if ( components.Day === 'tib' ) {
+                               // For sanity
+                               components.Season = 1;
+                       }
+                       switch ( mode ) {
+                               case 'overflow':
+                                       if ( components.Day === 'tib' && ( component === 'Season' || component === 'Year' ) ) {
+                                               components.Day = 59; // Could choose either one...
+                                               wasTib = true;
+                                       } else {
+                                               wasTib = false;
+                                       }
+                                       i = Math.abs( delta );
+                                       delta = delta < 0 ? -1 : 1;
+                                       while ( i-- > 0 ) {
+                                               components[ component ] += delta;
+                                               if ( components[ component ] < min ) {
+                                                       components[ component ] = max;
+                                                       components = this.adjustComponentInternal( components, next, -1, mode );
+                                               }
+                                               if ( components[ component ] > max ) {
+                                                       components[ component ] = min;
+                                                       components = this.adjustComponentInternal( components, next, 1, mode );
+                                               }
+                                       }
+                                       if ( wasTib && components.Season === 1 && this.isLeapYear( components.Year ) ) {
+                                               components.Day = 'tib';
+                                       }
+                                       break;
+                               case 'wrap':
+                                       range = max - min + 1;
+                                       components[ component ] += delta;
+                                       while ( components[ component ] < min ) {
+                                               components[ component ] += range;
+                                       }
+                                       while ( components[ component ] > max ) {
+                                               components[ component ] -= range;
+                                       }
+                                       break;
+                               case 'clip':
+                                       components[ component ] += delta;
+                                       if ( components[ component ] < min ) {
+                                               components[ component ] = min;
+                                       }
+                                       if ( components[ component ] > max ) {
+                                               components[ component ] = max;
+                                       }
+                                       break;
+                       }
+                       if ( components.Day === 'tib' &&
+                               ( components.Season !== 1 || !this.isLeapYear( components.Year ) )
+                       ) {
+                               components.Day = 59; // Could choose either one...
+                       }
+               }
+
+               return components;
+       };
+
+       /**
+        * @inheritdoc
+        */
+       mw.widgets.datetime.DiscordianDateTimeFormatter.prototype.getDateFromComponents = function ( components ) {
+               var month, day, days,
+                       date = new Date(),
+                       monthDays = [ 0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 304, 334, 365 ];
+
+               components = $.extend( {}, this.getComponentsFromDate( null ), components );
+               if ( components.Day === 'tib' ) {
+                       month = 1;
+                       day = 29;
+               } else {
+                       days = components.Season * 73 + components.Day - 74;
+                       month = 0;
+                       while ( days >= monthDays[ month + 1 ] ) {
+                               month++;
+                       }
+                       day = days - monthDays[ month ] + 1;
+               }
+
+               if ( components.zone ) {
+                       // Can't just use the constructor because that's stupid about ancient years.
+                       date.setFullYear( components.Year - 1166, month, day );
+                       date.setHours( components.Hour, components.Minute, components.Second, components.Millisecond );
+               } else {
+                       // Date.UTC() is stupid about ancient years too.
+                       date.setUTCFullYear( components.Year - 1166, month, day );
+                       date.setUTCHours( components.Hour, components.Minute, components.Second, components.Millisecond );
+               }
+
+               return date;
+       };
+
+       /**
+        * Get whether the year is a leap year
+        *
+        * @private
+        * @param {number} year
+        * @return {boolean}
+        */
+       mw.widgets.datetime.DiscordianDateTimeFormatter.prototype.isLeapYear = function ( year ) {
+               year -= 1166;
+               if ( year % 4 ) {
+                       return false;
+               } else if ( year % 100 ) {
+                       return true;
+               }
+               return ( year % 400 ) === 0;
+       };
+
+       /**
+        * @inheritdoc
+        */
+       mw.widgets.datetime.DiscordianDateTimeFormatter.prototype.getCalendarHeadings = function () {
+               return [ 'SM', 'BT', 'PD', 'PP', null, 'SO' ];
+       };
+
+       /**
+        * @inheritdoc
+        */
+       mw.widgets.datetime.DiscordianDateTimeFormatter.prototype.sameCalendarGrid = function ( date1, date2 ) {
+               var components1 = this.getComponentsFromDate( date1 ),
+                       components2 = this.getComponentsFromDate( date2 );
+
+               return components1.Year === components2.Year && components1.Season === components2.Season;
+       };
+
+       /**
+        * @inheritdoc
+        */
+       mw.widgets.datetime.DiscordianDateTimeFormatter.prototype.getCalendarData = function ( date ) {
+               var dt, components, season, i, row,
+                       ret = {
+                               dayComponent: 'Day',
+                               weekComponent: 'Week',
+                               monthComponent: 'Season'
+                       },
+                       seasons = [ 'Chaos', 'Discord', 'Confusion', 'Bureaucracy', 'The Aftermath' ],
+                       seasonStart = [ 0, -3, -1, -4, -2 ];
+
+               if ( !( date instanceof Date ) ) {
+                       date = this.defaultDate;
+               }
+
+               components = this.getComponentsFromDate( date );
+               components.Day = 1;
+               season = components.Season;
+
+               ret.header = seasons[ season - 1 ] + ' ' + components.Year;
+
+               if ( seasonStart[ season - 1 ] ) {
+                       this.adjustComponentInternal( components, 'Day', seasonStart[ season - 1 ], 'overflow' );
+               }
+
+               ret.rows = [];
+               do {
+                       row = [];
+                       for ( i = 0; i < 6; i++ ) {
+                               dt = this.getDateFromComponents( components );
+                               row[ i ] = {
+                                       display: components.Day === 'tib' ? 'Tib' : String( components.Day ),
+                                       date: dt,
+                                       extra: components.Season < season ? 'prev' : components.Season > season ? 'next' : null
+                               };
+
+                               this.adjustComponentInternal( components, 'Day', 1, 'overflow' );
+                               if ( components.Day !== 'tib' && i === 3 ) {
+                                       row[ ++i ] = null;
+                               }
+                       }
+
+                       ret.rows.push( row );
+               } while ( components.Season === season );
+
+               return ret;
+       };
+
+}( jQuery, mediaWiki ) );
diff --git a/resources/src/mediawiki.widgets.datetime/ProlepticGregorianDateTimeFormatter.js b/resources/src/mediawiki.widgets.datetime/ProlepticGregorianDateTimeFormatter.js
new file mode 100644 (file)
index 0000000..f60b34b
--- /dev/null
@@ -0,0 +1,661 @@
+( function ( $, mw ) {
+
+       /**
+        * Provides various methods needed for formatting dates and times. This
+        * implementation implments the proleptic Gregorian calendar over years
+        * 0000–9999.
+        *
+        * @class
+        * @extends mw.widgets.datetime.DateTimeFormatter
+        *
+        * @constructor
+        * @param {Object} [config] Configuration options
+        * @cfg {Object} [fullMonthNames] Mapping 1–12 to full month names.
+        * @cfg {Object} [shortMonthNames] Mapping 1–12 to abbreviated month names.
+        *  If {@link #fullMonthNames fullMonthNames} is given and this is not,
+        *  defaults to the first three characters from that setting.
+        * @cfg {Object} [fullDayNames] Mapping 0–6 to full day of week names. 0 is Sunday, 6 is Saturday.
+        * @cfg {Object} [shortDayNames] Mapping 0–6 to abbreviated day of week names. 0 is Sunday, 6 is Saturday.
+        *  If {@link #fullDayNames fullDayNames} is given and this is not, defaults to
+        *  the first three characters from that setting.
+        * @cfg {string[]} [dayLetters] Weekday column headers for a calendar. Array of 7 strings.
+        *  If {@link #fullDayNames fullDayNames} or {@link #shortDayNames shortDayNames}
+        *  are given and this is not, defaults to the first character from
+        *  shortDayNames.
+        * @cfg {string[]} [hour12Periods] AM and PM texts. Array of 2 strings, AM and PM.
+        * @cfg {number} [weekStartsOn=0] What day the week starts on: 0 is Sunday, 1 is Monday, 6 is Saturday.
+        */
+       mw.widgets.datetime.ProlepticGregorianDateTimeFormatter = function MwWidgetsDatetimeProlepticGregorianDateTimeFormatter( config ) {
+               var statick = this.constructor[ 'static' ];
+
+               statick.setupDefaults();
+
+               config = $.extend( {
+                       weekStartsOn: 0,
+                       hour12Periods: statick.hour12Periods
+               }, config );
+
+               if ( config.fullMonthNames && !config.shortMonthNames ) {
+                       config.shortMonthNames = {};
+                       $.each( config.fullMonthNames, function ( k, v ) {
+                               config.shortMonthNames[ k ] = v.substr( 0, 3 );
+                       }.bind( this ) );
+               }
+               if ( config.shortDayNames && !config.dayLetters ) {
+                       config.dayLetters = [];
+                       $.each( config.shortDayNames, function ( k, v ) {
+                               config.dayLetters[ k ] = v.substr( 0, 1 );
+                       }.bind( this ) );
+               }
+               if ( config.fullDayNames && !config.dayLetters ) {
+                       config.dayLetters = [];
+                       $.each( config.fullDayNames, function ( k, v ) {
+                               config.dayLetters[ k ] = v.substr( 0, 1 );
+                       }.bind( this ) );
+               }
+               if ( config.fullDayNames && !config.shortDayNames ) {
+                       config.shortDayNames = {};
+                       $.each( config.fullDayNames, function ( k, v ) {
+                               config.shortDayNames[ k ] = v.substr( 0, 3 );
+                       }.bind( this ) );
+               }
+               config = $.extend( {
+                       fullMonthNames: statick.fullMonthNames,
+                       shortMonthNames: statick.shortMonthNames,
+                       fullDayNames: statick.fullDayNames,
+                       shortDayNames: statick.shortDayNames,
+                       dayLetters: statick.dayLetters
+               }, config );
+
+               // Parent constructor
+               mw.widgets.datetime.ProlepticGregorianDateTimeFormatter[ 'super' ].call( this, config );
+
+               // Properties
+               this.weekStartsOn = config.weekStartsOn % 7;
+               this.fullMonthNames = config.fullMonthNames;
+               this.shortMonthNames = config.shortMonthNames;
+               this.fullDayNames = config.fullDayNames;
+               this.shortDayNames = config.shortDayNames;
+               this.dayLetters = config.dayLetters;
+               this.hour12Periods = config.hour12Periods;
+       };
+
+       /* Setup */
+
+       OO.inheritClass( mw.widgets.datetime.ProlepticGregorianDateTimeFormatter, mw.widgets.datetime.DateTimeFormatter );
+
+       /* Static */
+
+       /**
+        * @inheritdoc
+        */
+       mw.widgets.datetime.ProlepticGregorianDateTimeFormatter[ 'static' ].formats = {
+               '@time': '${hour|0}:${minute|0}:${second|0}',
+               '@date': '$!{dow|short} ${day|#} ${month|short} ${year|#}',
+               '@datetime': '$!{dow|short} ${day|#} ${month|short} ${year|#} ${hour|0}:${minute|0}:${second|0} $!{zone|short}',
+               '@default': '$!{dow|short} ${day|#} ${month|short} ${year|#} ${hour|0}:${minute|0}:${second|0} $!{zone|short}'
+       };
+
+       /**
+        * Default full month names.
+        *
+        * @static
+        * @inheritable
+        * @property {Object}
+        */
+       mw.widgets.datetime.ProlepticGregorianDateTimeFormatter[ 'static' ].fullMonthNames = null;
+
+       /**
+        * Default abbreviated month names.
+        *
+        * @static
+        * @inheritable
+        * @property {Object}
+        */
+       mw.widgets.datetime.ProlepticGregorianDateTimeFormatter[ 'static' ].shortMonthNames = null;
+
+       /**
+        * Default full day of week names.
+        *
+        * @static
+        * @inheritable
+        * @property {Object}
+        */
+       mw.widgets.datetime.ProlepticGregorianDateTimeFormatter[ 'static' ].fullDayNames = null;
+
+       /**
+        * Default abbreviated day of week names.
+        *
+        * @static
+        * @inheritable
+        * @property {Object}
+        */
+       mw.widgets.datetime.ProlepticGregorianDateTimeFormatter[ 'static' ].shortDayNames = null;
+
+       /**
+        * Default day letters.
+        *
+        * @static
+        * @inheritable
+        * @property {string[]}
+        */
+       mw.widgets.datetime.ProlepticGregorianDateTimeFormatter[ 'static' ].dayLetters = null;
+
+       /**
+        * Default AM/PM indicators
+        *
+        * @static
+        * @inheritable
+        * @property {string[]}
+        */
+       mw.widgets.datetime.ProlepticGregorianDateTimeFormatter[ 'static' ].hour12Periods = null;
+
+       mw.widgets.datetime.ProlepticGregorianDateTimeFormatter[ 'static' ].setupDefaults = function () {
+               mw.widgets.datetime.DateTimeFormatter[ 'static' ].setupDefaults.call( this );
+
+               if ( this.fullMonthNames && !this.shortMonthNames ) {
+                       this.shortMonthNames = {};
+                       $.each( this.fullMonthNames, function ( k, v ) {
+                               this.shortMonthNames[ k ] = v.substr( 0, 3 );
+                       }.bind( this ) );
+               }
+               if ( this.shortDayNames && !this.dayLetters ) {
+                       this.dayLetters = [];
+                       $.each( this.shortDayNames, function ( k, v ) {
+                               this.dayLetters[ k ] = v.substr( 0, 1 );
+                       }.bind( this ) );
+               }
+               if ( this.fullDayNames && !this.dayLetters ) {
+                       this.dayLetters = [];
+                       $.each( this.fullDayNames, function ( k, v ) {
+                               this.dayLetters[ k ] = v.substr( 0, 1 );
+                       }.bind( this ) );
+               }
+               if ( this.fullDayNames && !this.shortDayNames ) {
+                       this.shortDayNames = {};
+                       $.each( this.fullDayNames, function ( k, v ) {
+                               this.shortDayNames[ k ] = v.substr( 0, 3 );
+                       }.bind( this ) );
+               }
+
+               if ( !this.fullMonthNames ) {
+                       this.fullMonthNames = {
+                               1: mw.msg( 'january' ),
+                               2: mw.msg( 'february' ),
+                               3: mw.msg( 'march' ),
+                               4: mw.msg( 'april' ),
+                               5: mw.msg( 'may_long' ),
+                               6: mw.msg( 'june' ),
+                               7: mw.msg( 'july' ),
+                               8: mw.msg( 'august' ),
+                               9: mw.msg( 'september' ),
+                               10: mw.msg( 'october' ),
+                               11: mw.msg( 'november' ),
+                               12: mw.msg( 'december' )
+                       };
+               }
+               if ( !this.shortMonthNames ) {
+                       this.shortMonthNames = {
+                               1: mw.msg( 'jan' ),
+                               2: mw.msg( 'feb' ),
+                               3: mw.msg( 'mar' ),
+                               4: mw.msg( 'apr' ),
+                               5: mw.msg( 'may' ),
+                               6: mw.msg( 'jun' ),
+                               7: mw.msg( 'jul' ),
+                               8: mw.msg( 'aug' ),
+                               9: mw.msg( 'sep' ),
+                               10: mw.msg( 'oct' ),
+                               11: mw.msg( 'nov' ),
+                               12: mw.msg( 'dec' )
+                       };
+               }
+
+               if ( !this.fullDayNames ) {
+                       this.fullDayNames = {
+                               0: mw.msg( 'sunday' ),
+                               1: mw.msg( 'monday' ),
+                               2: mw.msg( 'tuesday' ),
+                               3: mw.msg( 'wednesday' ),
+                               4: mw.msg( 'thursday' ),
+                               5: mw.msg( 'friday' ),
+                               6: mw.msg( 'saturday' )
+                       };
+               }
+               if ( !this.shortDayNames ) {
+                       this.shortDayNames = {
+                               0: mw.msg( 'sun' ),
+                               1: mw.msg( 'mon' ),
+                               2: mw.msg( 'tue' ),
+                               3: mw.msg( 'wed' ),
+                               4: mw.msg( 'thu' ),
+                               5: mw.msg( 'fri' ),
+                               6: mw.msg( 'sat' )
+                       };
+               }
+               if ( !this.dayLetters ) {
+                       this.dayLetters = [];
+                       $.each( this.shortDayNames, function ( k, v ) {
+                               this.dayLetters[ k ] = v.substr( 0, 1 );
+                       }.bind( this ) );
+               }
+
+               if ( !this.hour12Periods ) {
+                       this.hour12Periods = [
+                               mw.msg( 'period-am' ),
+                               mw.msg( 'period-pm' )
+                       ];
+               }
+       };
+
+       /* Methods */
+
+       /**
+        * @inheritdoc
+        *
+        * Additional fields implemented here are:
+        * - ${year|#}: Year as a number
+        * - ${year|0}: Year as a number, zero-padded to 4 digits
+        * - ${month|#}: Month as a number
+        * - ${month|0}: Month as a number with leading 0
+        * - ${month|short}: Month from 'shortMonthNames' configuration setting
+        * - ${month|full}: Month from 'fullMonthNames' configuration setting
+        * - ${day|#}: Day of the month as a number
+        * - ${day|0}: Day of the month as a number with leading 0
+        * - ${dow|short}: Day of the week from 'shortDayNames' configuration setting
+        * - ${dow|full}: Day of the week from 'fullDayNames' configuration setting
+        * - ${hour|#}: Hour as a number
+        * - ${hour|0}: Hour as a number with leading 0
+        * - ${hour|12}: Hour in a 12-hour clock as a number
+        * - ${hour|012}: Hour in a 12-hour clock as a number, with leading 0
+        * - ${hour|period}: Value from 'hour12Periods' configuration setting
+        * - ${minute|#}: Minute as a number
+        * - ${minute|0}: Minute as a number with leading 0
+        * - ${second|#}: Second as a number
+        * - ${second|0}: Second as a number with leading 0
+        * - ${millisecond|#}: Millisecond as a number
+        * - ${millisecond|0}: Millisecond as a number, zero-padded to 3 digits
+        */
+       mw.widgets.datetime.ProlepticGregorianDateTimeFormatter.prototype.getFieldForTag = function ( tag, params ) {
+               var spec = null;
+
+               switch ( tag + '|' + params[ 0 ] ) {
+                       case 'year|#':
+                       case 'year|0':
+                               spec = {
+                                       component: 'year',
+                                       type: 'number',
+                                       size: 4,
+                                       zeropad: params[ 0 ] === '0'
+                               };
+                               break;
+
+                       case 'month|short':
+                       case 'month|full':
+                               spec = {
+                                       component: 'month',
+                                       type: 'string',
+                                       values: params[ 0 ] === 'short' ? this.shortMonthNames : this.fullMonthNames
+                               };
+                               break;
+
+                       case 'dow|short':
+                       case 'dow|full':
+                               spec = {
+                                       component: 'dow',
+                                       editable: false,
+                                       type: 'string',
+                                       values: params[ 0 ] === 'short' ? this.shortDayNames : this.fullDayNames
+                               };
+                               break;
+
+                       case 'month|#':
+                       case 'month|0':
+                       case 'day|#':
+                       case 'day|0':
+                       case 'hour|#':
+                       case 'hour|0':
+                       case 'minute|#':
+                       case 'minute|0':
+                       case 'second|#':
+                       case 'second|0':
+                               spec = {
+                                       component: tag,
+                                       type: 'number',
+                                       size: 2,
+                                       zeropad: params[ 0 ] === '0'
+                               };
+                               break;
+
+                       case 'hour|12':
+                       case 'hour|012':
+                               spec = {
+                                       component: 'hour12',
+                                       type: 'number',
+                                       size: 2,
+                                       zeropad: params[ 0 ] === '012'
+                               };
+                               break;
+
+                       case 'hour|period':
+                               spec = {
+                                       component: 'hour12period',
+                                       type: 'boolean',
+                                       values: this.hour12Periods
+                               };
+                               break;
+
+                       case 'millisecond|#':
+                       case 'millisecond|0':
+                               spec = {
+                                       component: 'millisecond',
+                                       type: 'number',
+                                       size: 3,
+                                       zeropad: params[ 0 ] === '0'
+                               };
+                               break;
+
+                       default:
+                               return mw.widgets.datetime.ProlepticGregorianDateTimeFormatter[ 'super' ].prototype.getFieldForTag.call( this, tag, params );
+               }
+
+               if ( spec ) {
+                       if ( spec.editable === undefined ) {
+                               spec.editable = true;
+                       }
+                       spec.formatValue = this.formatSpecValue;
+                       spec.parseValue = this.parseSpecValue;
+                       if ( spec.values ) {
+                               spec.size = Math.max.apply(
+                                       null, $.map( spec.values, function ( v ) { return v.length; } )
+                               );
+                       }
+               }
+
+               return spec;
+       };
+
+       /**
+        * Get components from a Date object
+        *
+        * Components are:
+        * - year {number}
+        * - month {number} (1-12)
+        * - day {number} (1-31)
+        * - dow {number} (0-6, 0 is Sunday)
+        * - hour {number} (0-23)
+        * - hour12 {number} (1-12)
+        * - hour12period {boolean}
+        * - minute {number} (0-59)
+        * - second {number} (0-59)
+        * - millisecond {number} (0-999)
+        * - zone {number}
+        *
+        * @param {Date|null} date
+        * @return {Object} Components
+        */
+       mw.widgets.datetime.ProlepticGregorianDateTimeFormatter.prototype.getComponentsFromDate = function ( date ) {
+               var ret;
+
+               if ( !( date instanceof Date ) ) {
+                       date = this.defaultDate;
+               }
+
+               if ( this.local ) {
+                       ret = {
+                               year: date.getFullYear(),
+                               month: date.getMonth() + 1,
+                               day: date.getDate(),
+                               dow: date.getDay() % 7,
+                               hour: date.getHours(),
+                               minute: date.getMinutes(),
+                               second: date.getSeconds(),
+                               millisecond: date.getMilliseconds(),
+                               zone: date.getTimezoneOffset()
+                       };
+               } else {
+                       ret = {
+                               year: date.getUTCFullYear(),
+                               month: date.getUTCMonth() + 1,
+                               day: date.getUTCDate(),
+                               dow: date.getUTCDay() % 7,
+                               hour: date.getUTCHours(),
+                               minute: date.getUTCMinutes(),
+                               second: date.getUTCSeconds(),
+                               millisecond: date.getUTCMilliseconds(),
+                               zone: 0
+                       };
+               }
+
+               ret.hour12period = ret.hour >= 12 ? 1 : 0;
+               ret.hour12 = ret.hour % 12;
+               if ( ret.hour12 === 0 ) {
+                       ret.hour12 = 12;
+               }
+
+               return ret;
+       };
+
+       /**
+        * @inheritdoc
+        */
+       mw.widgets.datetime.ProlepticGregorianDateTimeFormatter.prototype.getDateFromComponents = function ( components ) {
+               var date = new Date();
+
+               components = $.extend( {}, components );
+               if ( components.hour === undefined && components.hour12 !== undefined && components.hour12period !== undefined ) {
+                       components.hour = ( components.hour12 % 12 ) + ( components.hour12period ? 12 : 0 );
+               }
+               components = $.extend( {}, this.getComponentsFromDate( null ), components );
+
+               if ( components.zone ) {
+                       // Can't just use the constructor because that's stupid about ancient years.
+                       date.setFullYear( components.year, components.month - 1, components.day );
+                       date.setHours( components.hour, components.minute, components.second, components.millisecond );
+               } else {
+                       // Date.UTC() is stupid about ancient years too.
+                       date.setUTCFullYear( components.year, components.month - 1, components.day );
+                       date.setUTCHours( components.hour, components.minute, components.second, components.millisecond );
+               }
+
+               return date;
+       };
+
+       /**
+        * @inheritdoc
+        */
+       mw.widgets.datetime.ProlepticGregorianDateTimeFormatter.prototype.adjustComponent = function ( date, component, delta, mode ) {
+               var min, max, range, components;
+
+               if ( !( date instanceof Date ) ) {
+                       date = this.defaultDate;
+               }
+               components = this.getComponentsFromDate( date );
+
+               switch ( component ) {
+                       case 'year':
+                               min = 0;
+                               max = 9999;
+                               break;
+                       case 'month':
+                               min = 1;
+                               max = 12;
+                               break;
+                       case 'day':
+                               min = 1;
+                               max = this.getDaysInMonth( components.month, components.year );
+                               break;
+                       case 'hour':
+                               min = 0;
+                               max = 23;
+                               break;
+                       case 'minute':
+                       case 'second':
+                               min = 0;
+                               max = 59;
+                               break;
+                       case 'millisecond':
+                               min = 0;
+                               max = 999;
+                               break;
+                       case 'hour12period':
+                               component = 'hour';
+                               min = 0;
+                               max = 23;
+                               delta *= 12;
+                               break;
+                       case 'hour12':
+                               component = 'hour';
+                               min = components.hour12period ? 12 : 0;
+                               max = components.hour12period ? 23 : 11;
+                               break;
+                       default:
+                               return new Date( date.getTime() );
+               }
+
+               components[ component ] += delta;
+               range = max - min + 1;
+               switch ( mode ) {
+                       case 'overflow':
+                               // Date() will mostly handle it automatically. But months need
+                               // manual handling to prevent e.g. Jan 31 => Mar 3.
+                               if ( component === 'month' || component === 'year' ) {
+                                       while ( components.month < 1 ) {
+                                               components[ component ] += 12;
+                                               components.year--;
+                                       }
+                                       while ( components.month > 12 ) {
+                                               components[ component ] -= 12;
+                                               components.year++;
+                                       }
+                               }
+                               break;
+                       case 'wrap':
+                               while ( components[ component ] < min ) {
+                                       components[ component ] += range;
+                               }
+                               while ( components[ component ] > max ) {
+                                       components[ component ] -= range;
+                               }
+                               break;
+                       case 'clip':
+                               if ( components[ component ] < min ) {
+                                       components[ component ] = min;
+                               }
+                               if ( components[ component ] < max ) {
+                                       components[ component ] = max;
+                               }
+                               break;
+               }
+               if ( component === 'month' || component === 'year' ) {
+                       components.day = Math.min( components.day, this.getDaysInMonth( components.month, components.year ) );
+               }
+
+               return this.getDateFromComponents( components );
+       };
+
+       /**
+        * Get the number of days in a month
+        *
+        * @protected
+        * @param {number} month
+        * @param {number} year
+        * @return {number}
+        */
+       mw.widgets.datetime.ProlepticGregorianDateTimeFormatter.prototype.getDaysInMonth = function ( month, year ) {
+               switch ( month ) {
+                       case 4:
+                       case 6:
+                       case 9:
+                       case 11:
+                               return 30;
+                       case 2:
+                               if ( year % 4 ) {
+                                       return 28;
+                               } else if ( year % 100 ) {
+                                       return 29;
+                               }
+                               return ( year % 400 ) ? 28 : 29;
+                       default:
+                               return 31;
+               }
+       };
+
+       /**
+        * @inheritdoc
+        */
+       mw.widgets.datetime.ProlepticGregorianDateTimeFormatter.prototype.getCalendarHeadings = function () {
+               var a = this.dayLetters;
+
+               if ( this.weekStartsOn ) {
+                       return a.slice( this.weekStartsOn ).concat( a.slice( 0, this.weekStartsOn ) );
+               } else {
+                       return a.slice( 0 ); // clone
+               }
+       };
+
+       /**
+        * @inheritdoc
+        */
+       mw.widgets.datetime.ProlepticGregorianDateTimeFormatter.prototype.sameCalendarGrid = function ( date1, date2 ) {
+               if ( this.local ) {
+                       return date1.getFullYear() === date2.getFullYear() && date1.getMonth() === date2.getMonth();
+               } else {
+                       return date1.getUTCFullYear() === date2.getUTCFullYear() && date1.getUTCMonth() === date2.getUTCMonth();
+               }
+       };
+
+       /**
+        * @inheritdoc
+        */
+       mw.widgets.datetime.ProlepticGregorianDateTimeFormatter.prototype.getCalendarData = function ( date ) {
+               var dt, t, d, e, i, row,
+                       getDate = this.local ? 'getDate' : 'getUTCDate',
+                       setDate = this.local ? 'setDate' : 'setUTCDate',
+                       ret = {
+                               dayComponent: 'day',
+                               monthComponent: 'month'
+                       };
+
+               if ( !( date instanceof Date ) ) {
+                       date = this.defaultDate;
+               }
+
+               dt = new Date( date.getTime() );
+               dt[ setDate ]( 1 );
+               t = dt.getTime();
+
+               if ( this.local ) {
+                       ret.header = this.fullMonthNames[ dt.getMonth() + 1 ] + ' ' + dt.getFullYear();
+                       d = dt.getDay() % 7;
+                       e = this.getDaysInMonth( dt.getMonth() + 1, dt.getFullYear() );
+               } else {
+                       ret.header = this.fullMonthNames[ dt.getUTCMonth() + 1 ] + ' ' + dt.getUTCFullYear();
+                       d = dt.getUTCDay() % 7;
+                       e = this.getDaysInMonth( dt.getUTCMonth() + 1, dt.getUTCFullYear() );
+               }
+
+               if ( this.weekStartsOn ) {
+                       d = ( d + 7 - this.weekStartsOn ) % 7;
+               }
+               d = 1 - d;
+
+               ret.rows = [];
+               while ( d <= e ) {
+                       row = [];
+                       for ( i = 0; i < 7; i++, d++ ) {
+                               dt = new Date( t );
+                               dt[ setDate ]( d );
+                               row[ i ] = {
+                                       display: String( dt[ getDate ]() ),
+                                       date: dt,
+                                       extra: d < 1 ? 'prev' : d > e ? 'next' : null
+                               };
+                       }
+                       ret.rows.push( row );
+               }
+
+               return ret;
+       };
+
+}( jQuery, mediaWiki ) );
diff --git a/resources/src/mediawiki.widgets.datetime/mediawiki.widgets.datetime.definitions.less b/resources/src/mediawiki.widgets.datetime/mediawiki.widgets.datetime.definitions.less
new file mode 100644 (file)
index 0000000..ee0e66e
--- /dev/null
@@ -0,0 +1,37 @@
+/*!
+ * OOJS-UI defines used by the existing CSS (will make it easier to put this
+ * widget in OOJS-UI once OOJS-UI is capable of handling it)
+ */
+
+.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;
+       }
+}
+
+.oo-ui-transition( @value1, @value2: X, ... ) {
+       @value: ~`"@{arguments}".replace(/[\[\]]|\,\sX/g, '')`;
+       -webkit-transition: @value;
+       -moz-transition: @value;
+       transition: @value;
+}
+
+@indicator-size: unit(12 / 16 / 0.8, em);
+@icon-size: unit(24 / 16 / 0.8, em);
+@quick-ease: 100ms ease;
+@progressive: #347bff;
diff --git a/resources/src/mediawiki.widgets.datetime/mediawiki.widgets.datetime.js b/resources/src/mediawiki.widgets.datetime/mediawiki.widgets.datetime.js
new file mode 100644 (file)
index 0000000..8d4be8c
--- /dev/null
@@ -0,0 +1,2 @@
+// Create the namespace object
+mediaWiki.widgets.datetime = {};