Anomie has uploaded a new change for review.

  https://gerrit.wikimedia.org/r/216909

Change subject: Date and time picker widget
......................................................................

Date and time picker widget

This adds a few new classes:
* OO.ui.DateTimeFormatter, a base class for converting Date objects into
  user-visible format. The idea being that we can support different
  calendar formats by simply implementing a new formatter subclass and
  the UI classes will automagically adjust.
  * OO.ui.ProlepticGregorianDateTimeFormatter, an implementation for the
    usual calendar.
  * OO.ui.DiscordianDateTimeFormatter, as a test that the UI classes
    actually work with something different.
* OO.ui.DateTimeInputWidget, an input widget that works like
  <input type="date">, <input type="time">, and <input type="datetime">.
  But with a custom UI, of course.
* OO.ui.CalendarWidget, mostly intended to handle the popup calendar
  from OO.ui.DateTimeInputWidget but can be used standalone.

Bug: T91148
Change-Id: I442179387e723e54be631975fb41ef0da7642f5c
---
M build/modules.json
M demos/pages/widgets.js
M i18n/en.json
M i18n/qqq.json
A src/DateTimeFormatter.js
A src/DiscordianDateTimeFormatter.js
A src/ProlepticGregorianDateTimeFormatter.js
M src/core.js
M src/styles/core.less
M src/styles/theme.less
A src/styles/widgets/CalendarWidget.less
A src/styles/widgets/DateTimeInputWidget.less
M src/themes/apex/widgets.less
M src/themes/blank/widgets.less
M src/themes/mediawiki/widgets.less
A src/widgets/CalendarWidget.js
A src/widgets/DateTimeInputWidget.js
17 files changed, 3,720 insertions(+), 3 deletions(-)


  git pull ssh://gerrit.wikimedia.org:29418/oojs/ui refs/changes/09/216909/1

diff --git a/build/modules.json b/build/modules.json
index 3daef88..05cb53a 100644
--- a/build/modules.json
+++ b/build/modules.json
@@ -8,6 +8,9 @@
                        "src/mixins/PendingElement.js",
 
                        "src/ActionSet.js",
+                       "src/DateTimeFormatter.js",
+                               "src/ProlepticGregorianDateTimeFormatter.js",
+                               "src/DiscordianDateTimeFormatter.js",
                        "src/Element.js",
                                "src/Layout.js",
                                "src/Widget.js",
@@ -72,6 +75,7 @@
                                "src/widgets/ActionWidget.js",
                                "src/widgets/PopupButtonWidget.js",
                                "src/widgets/ToggleButtonWidget.js",
+                       "src/widgets/CalendarWidget.js",
                        "src/widgets/DropdownWidget.js",
                        "src/widgets/SelectFileWidget.js",
                        "src/widgets/IconWidget.js",
@@ -79,6 +83,7 @@
                        "src/widgets/InputWidget.js",
                                "src/widgets/ButtonInputWidget.js",
                                "src/widgets/CheckboxInputWidget.js",
+                               "src/widgets/DateTimeInputWidget.js",
                                "src/widgets/DropdownInputWidget.js",
                                "src/widgets/RadioInputWidget.js",
                                "src/widgets/RadioSelectInputWidget.js",
diff --git a/demos/pages/widgets.js b/demos/pages/widgets.js
index 8af8bdb..5a89a48 100644
--- a/demos/pages/widgets.js
+++ b/demos/pages/widgets.js
@@ -1081,6 +1081,107 @@
                                                align: 'top',
                                                label: 'ButtonInputWidget 
(using <input/>)\u200E'
                                        }
+                               ),
+                               new OO.ui.FieldLayout(
+                                       new OO.ui.DateTimeInputWidget( {
+                                               type: 'datetime',
+                                               clearable: false
+                                       } ),
+                                       {
+                                               align: 'top',
+                                               label: 'DateTimeInputWidget 
(datetime)\u200E'
+                                       }
+                               ),
+                               new OO.ui.FieldLayout(
+                                       new OO.ui.DateTimeInputWidget( {
+                                               type: 'date',
+                                               clearable: false
+                                       } ),
+                                       {
+                                               align: 'top',
+                                               label: 'DateTimeInputWidget 
(date)\u200E'
+                                       }
+                               ),
+                               new OO.ui.FieldLayout(
+                                       new OO.ui.DateTimeInputWidget( {
+                                               type: 'time',
+                                               clearable: false
+                                       } ),
+                                       {
+                                               align: 'top',
+                                               label: 'DateTimeInputWidget 
(time)\u200E'
+                                       }
+                               ),
+                               new OO.ui.FieldLayout(
+                                       new OO.ui.DateTimeInputWidget( {
+                                               formatter: {
+                                                       defaultDate: new Date( 
'2017-02-01T12:00:00Z' ),
+                                               },
+                                               type: 'datetime',
+                                               clearable: true,
+                                               required: true,
+                                               min: '2017-01-30T12:00:00Z',
+                                               max: '2017-02-02T12:00:00Z'
+                                       } ),
+                                       {
+                                               align: 'top',
+                                               label: 'DateTimeInputWidget 
(required, valid dates are 2017-01-30T12:00:00Z to 2017-02-02T12:00:00Z)\u200E'
+                                       }
+                               ),
+                               new OO.ui.FieldLayout(
+                                       new OO.ui.DateTimeInputWidget( {
+                                               formatter: {
+                                                       format: '${dow|full} 
${month|full} ${day|#}, ${year|#} 
${hour|012}:${minute|0}:${second|0}.${millisecond|0} ${hour|period} 
${zone|full}'
+                                               },
+                                               calendar: null,
+                                               icon: 'tag',
+                                               indicator: 'required'
+                                       } ),
+                                       {
+                                               align: 'top',
+                                               label: 'DateTimeInputWidget 
(datetime with custom format, icon, indicator, no calendar)\u200E'
+                                       }
+                               ),
+                               new OO.ui.FieldLayout(
+                                       new OO.ui.DateTimeInputWidget( {
+                                               formatter: {
+                                                       format: 
'${year|0}-${month|0}-${day|0}T${hour|0}:${minute|0}:${second|0}${zone|:}',
+                                                       weekStartsOn: 1,
+                                               },
+                                               icon: 'tag',
+                                               indicator: 'required'
+                                       } ),
+                                       {
+                                               align: 'top',
+                                               label: 'DateTimeInputWidget 
(datetime with custom format, icon, indicator, week starts on Monday)\u200E'
+                                       }
+                               ),
+                               new OO.ui.FieldLayout(
+                                       new OO.ui.DateTimeInputWidget( {
+                                               icon: 'tag',
+                                               indicator: 'required',
+                                               disabled: true
+                                       } ),
+                                       {
+                                               align: 'top',
+                                               label: 'DateTimeInputWidget 
(icon, indicator, disabled)\u200E'
+                                       }
+                               ),
+                               new OO.ui.FieldLayout(
+                                       new OO.ui.DateTimeInputWidget( {
+                                               formatter: new 
OO.ui.DiscordianDateTimeFormatter()
+                                       } ),
+                                       {
+                                               align: 'top',
+                                               label: 'DateTimeInputWidget 
(ddate)\u200E'
+                                       }
+                               ),
+                               new OO.ui.FieldLayout(
+                                       new OO.ui.CalendarWidget(),
+                                       {
+                                               align: 'top',
+                                               label: 'CalendarWidget 
(standalone)\u200E'
+                                       }
                                )
                        ]
                } ),
diff --git a/i18n/en.json b/i18n/en.json
index 9812ec6..d081dfd 100644
--- a/i18n/en.json
+++ b/i18n/en.json
@@ -30,5 +30,47 @@
        "ooui-dialog-process-continue": "Continue",
        "ooui-selectfile-not-supported": "File selection is not supported",
        "ooui-selectfile-placeholder": "No file is selected",
-       "ooui-semicolon-separator": "; "
+       "ooui-semicolon-separator": "; ",
+       "ooui-january": "January",
+       "ooui-february": "February",
+       "ooui-march": "March",
+       "ooui-april": "April",
+       "ooui-may_long": "May",
+       "ooui-june": "June",
+       "ooui-july": "July",
+       "ooui-august": "August",
+       "ooui-september": "September",
+       "ooui-october": "October",
+       "ooui-november": "November",
+       "ooui-december": "December",
+       "ooui-jan": "Jan",
+       "ooui-feb": "Feb",
+       "ooui-mar": "Mar",
+       "ooui-apr": "Apr",
+       "ooui-may": "May",
+       "ooui-jun": "Jun",
+       "ooui-jul": "Jul",
+       "ooui-aug": "Aug",
+       "ooui-sep": "Sep",
+       "ooui-oct": "Oct",
+       "ooui-nov": "Nov",
+       "ooui-dec": "Dec",
+       "ooui-sunday": "Sunday",
+       "ooui-monday": "Monday",
+       "ooui-tuesday": "Tuesday",
+       "ooui-wednesday": "Wednesday",
+       "ooui-thursday": "Thursday",
+       "ooui-friday": "Friday",
+       "ooui-saturday": "Saturday",
+       "ooui-sun": "Sun",
+       "ooui-mon": "Mon",
+       "ooui-tue": "Tue",
+       "ooui-wed": "Wed",
+       "ooui-thu": "Thu",
+       "ooui-fri": "Fri",
+       "ooui-sat": "Sat",
+       "ooui-period-am": "AM",
+       "ooui-period-pm": "PM",
+       "ooui-timezone-utc": "UTC",
+       "ooui-timezone-local": "Local"
 }
diff --git a/i18n/qqq.json b/i18n/qqq.json
index bef65ed..3b76986 100644
--- a/i18n/qqq.json
+++ b/i18n/qqq.json
@@ -34,5 +34,47 @@
        "ooui-dialog-process-continue": "Label for process dialog retry action 
button, visible when describing only warnings\n{{Identical|Continue}}",
        "ooui-selectfile-not-supported": "Label for the file selection dialog 
if file selection is not supported",
        "ooui-selectfile-placeholder": "Label for the file selection dialog 
when no file is currently selected",
-       "ooui-semicolon-separator": "{{optional}} Semicolon used as a separator"
+       "ooui-semicolon-separator": "{{optional}} Semicolon used as a 
separator",
+       "ooui-january": "{{doc-months|1}}\n{{Identical|January}}",
+       "ooui-february": "{{doc-months|2}}\n{{Identical|February}}",
+       "ooui-march": "{{doc-months|3}}\n{{Identical|March}}",
+       "ooui-april": "{{doc-months|4}}\n{{Identical|April}}",
+       "ooui-may_long": "{{doc-months|5}}\n{{Identical|May}}",
+       "ooui-june": "{{doc-months|6}}\n{{Identical|June}}",
+       "ooui-july": "{{doc-months|7}}\n{{Identical|July}}",
+       "ooui-august": "{{doc-months|8}}\n{{Identical|August}}",
+       "ooui-september": "{{doc-months|9}}\n{{Identical|September}}",
+       "ooui-october": "{{doc-months|10}}\n{{Identical|October}}",
+       "ooui-november": "{{doc-months|11}}\n{{Identical|November}}",
+       "ooui-december": "{{doc-months|12}}\n{{Identical|December}}",
+       "ooui-jan": "{{doc-months|1|short}}",
+       "ooui-feb": "{{doc-months|2|short}}",
+       "ooui-mar": "{{doc-months|3|short}}",
+       "ooui-apr": "{{doc-months|4|short}}",
+       "ooui-may": "{{doc-months|5|short}}",
+       "ooui-jun": "{{doc-months|6|short}}",
+       "ooui-jul": "{{doc-months|7|short}}",
+       "ooui-aug": "{{doc-months|8|short}}",
+       "ooui-sep": "{{doc-months|9|short}}",
+       "ooui-oct": "{{doc-months|10|short}}",
+       "ooui-nov": "{{doc-months|11|short}}",
+       "ooui-dec": "{{doc-months|12|short}}",
+       "ooui-sunday": "Name of the day of the week.",
+       "ooui-monday": "Name of the day of the week.",
+       "ooui-tuesday": "Name of the day of the week.",
+       "ooui-wednesday": "Name of the day of the week.",
+       "ooui-thursday": "Name of the day of the week.",
+       "ooui-friday": "Name of the day of the week.",
+       "ooui-saturday": "Name of the day of the week.",
+       "ooui-sun": "Abbreviation for Sunday, a day of the week.",
+       "ooui-mon": "Abbreviation for Monday, a day of the week.",
+       "ooui-tue": "Abbreviation for Tuesday, a day of the week.",
+       "ooui-wed": "Abbreviation for Wednesday, a day of the week.",
+       "ooui-thu": "Abbreviation for Thursday, a day of the week.",
+       "ooui-fri": "Abbreviation for Friday, a day of the week.",
+       "ooui-sat": "Abbreviation for Saturday, a day of the week.",
+       "ooui-period-am": "Used in connection with a [[w:12-hour clock]] to 
indicate the first period of the day.",
+       "ooui-period-pm": "Used in connection with a [[w:12-hour clock]] to 
indicate the second period of the day.",
+       "ooui-timezone-utc": "{{optional}} Used to indicate the standard UTC 
timezone.",
+       "ooui-timezone-local": "Used to indicate the local timezone in the 
user's browser."
 }
diff --git a/src/DateTimeFormatter.js b/src/DateTimeFormatter.js
new file mode 100644
index 0000000..334b223
--- /dev/null
+++ b/src/DateTimeFormatter.js
@@ -0,0 +1,673 @@
+/**
+ * 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).
+ */
+OO.ui.DateTimeFormatter = function OoUiDateTimeFormatter( 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( OO.ui.DateTimeFormatter );
+OO.mixinClass( OO.ui.DateTimeFormatter, OO.EventEmitter );
+
+/* Static */
+
+/**
+ * Default format specifications. See the {@link #format format} parameter.
+ * @static
+ * @inheritable
+ * @property {Object}
+ */
+OO.ui.DateTimeFormatter.static.formats = {};
+
+/**
+ * Default time zone indicators
+ * @static
+ * @inheritable
+ * @property {string[]}
+ */
+OO.ui.DateTimeFormatter.static.fullZones = null;
+
+/**
+ * Default abbreviated time zone indicators
+ * @static
+ * @inheritable
+ * @property {string[]}
+ */
+OO.ui.DateTimeFormatter.static.shortZones = null;
+
+OO.ui.DateTimeFormatter.static.setupDefaults = function () {
+       if ( !this.fullZones ) {
+               this.fullZones = [
+                       OO.ui.msg( 'ooui-timezone-utc' ),
+                       OO.ui.msg( 'ooui-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
+ */
+OO.ui.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
+ */
+OO.ui.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}
+ */
+OO.ui.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.
+ *
+ * @public
+ * @return {Array}
+ */
+OO.ui.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}
+ */
+OO.ui.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.
+ */
+OO.ui.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} value
+ * @return {string}
+ */
+OO.ui.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} value
+ * @return {number|string|null}
+ */
+OO.ui.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.
+ *
+ * @public
+ * @abstract
+ * @param {Date|null} date
+ * @return {Object} Components
+ */
+OO.ui.DateTimeFormatter.prototype.getComponentsFromDate = function ( date ) {
+       // Should be overridden by subclass
+       return {
+               zone: this.local ? date.getTimezoneOffset() : 0
+       };
+};
+
+/**
+ * Get a Date object from components
+ *
+ * @public
+ * @param {Object} components Date components
+ * @return {Date}
+ */
+OO.ui.DateTimeFormatter.prototype.getDateFromComponents = function ( /* 
components */ ) {
+       // Should be overridden by subclass
+       return new Date();
+};
+
+/**
+ * Adjust a date
+ *
+ * @public
+ * @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
+ */
+OO.ui.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.
+ *
+ * @public
+ * @abstract
+ * @return {Array} string or null
+ */
+OO.ui.DateTimeFormatter.prototype.getCalendarHeadings = function () {
+       // Should be overridden by subclass
+       return [];
+};
+
+/**
+ * Test whether two dates are in the same calendar grid
+ *
+ * @public
+ * @abstract
+ * @param {Date} date1
+ * @param {Date} date2
+ * @return {boolean}
+ */
+OO.ui.DateTimeFormatter.prototype.sameCalendarGrid = function ( date1, date2 ) 
{
+       // Should be overridden by subclass
+       return date1.getTime() === date2.getTime();
+};
+
+/**
+ * Test whether the time parts of two Dates are equal
+ *
+ * @public
+ * @param {Date} date1
+ * @param {Date} date2
+ * @return {boolean}
+ */
+OO.ui.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()
+               );
+       }
+};
+
+/**
+ * Create a new Date by merging the date part from one with the time part from
+ * another.
+ *
+ * @public
+ * @param {Date} datepart
+ * @param {Date} timepart
+ * @return {Date}
+ */
+OO.ui.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.
+ * - inrange: {boolean} Whether this date is in the min–max range
+ * - selected: {boolean} Whether this is a selected date
+ *
+ * In any one result object, 'extra' + 'display' will always be unique.
+ *
+ * @public
+ * @abstract
+ * @param {Date|null} current Current date
+ * @param {Date|null} [min] Minimum date (see 'inrange')
+ * @param {Date|null} [max] Maximum date (see 'inrange')
+ * @param {Date[]} [selected] Selected date(s), if any (see 'selected')
+ * @return {Object} Data
+ * @return {string} return.header String to display as the calendar header
+ * @return {Date} return.prev Date to pass to this function to get
+ *  data for the previous month.
+ * @return {Date} return.next Date to pass to this function to get
+ *  data for the next month.
+ * @return {Array} return.rows Array of arrays of "day" objects or 
null/undefined.
+ */
+OO.ui.DateTimeFormatter.prototype.getCalendarData = function ( /* components, 
min, max, selected */ ) {
+       // Should be overridden by subclass
+       return {
+               header: '',
+               prev: {},
+               next: {},
+               rows: []
+       };
+};
+
+/**
+ * Get helpers for getCalendarData()
+ *
+ * @protected
+ * @abstract
+ * @param {Date|null} [min] Minimum date (see 'inrange')
+ * @param {Date|null} [max] Maximum date (see 'inrange')
+ * @param {Date[]} [selected] Selected date(s), if any (see 'selected')
+ * @return {Object} Data
+ * @return {Function} return.inrange Helper to test if a date is in range
+ * @return {Function} return.selected Helper to test if a date is selected
+ */
+OO.ui.DateTimeFormatter.prototype.getCalendarDataHelpers = function ( min, 
max, selected ) {
+       var i, y, m,
+               helpers = {},
+               sel = {};
+
+       if ( min instanceof Date ) {
+               if ( max instanceof Date ) {
+                       helpers.inrange = function ( dt ) {
+                               return dt >= min && dt <= max;
+                       };
+               } else {
+                       helpers.inrange = function ( dt ) {
+                               return dt >= min;
+                       };
+               }
+       } else if ( max instanceof Date ) {
+               helpers.inrange = function ( dt ) {
+                       return dt <= max;
+               };
+       } else {
+               helpers.inrange = function () { return true; };
+       }
+
+       if ( Array.isArray( selected ) && selected.length ) {
+               if ( this.local ) {
+                       for ( i = 0; i < selected.length; i++ ) {
+                               y = selected[i].getFullYear();
+                               m = selected[i].getMonth();
+                               if ( !sel[y] ) {
+                                       sel[y] = {};
+                               }
+                               if ( !sel[y][m] ) {
+                                       sel[y][m] = {};
+                               }
+                               sel[y][m][selected[i].getDate()] = true;
+                       }
+                       helpers.selected = function ( dt ) {
+                               return !!(
+                                       sel[dt.getFullYear()] &&
+                                       sel[dt.getFullYear()][dt.getMonth()] &&
+                                       
sel[dt.getFullYear()][dt.getMonth()][dt.getDate()]
+                               );
+                       };
+               } else {
+                       for ( i = 0; i < selected.length; i++ ) {
+                               y = selected[i].getUTCFullYear();
+                               m = selected[i].getUTCMonth();
+                               if ( !sel[y] ) {
+                                       sel[y] = {};
+                               }
+                               if ( !sel[y][m] ) {
+                                       sel[y][m] = {};
+                               }
+                               sel[y][m][selected[i].getUTCDate()] = true;
+                       }
+                       helpers.selected = function ( dt ) {
+                               return !!(
+                                       sel[dt.getUTCFullYear()] &&
+                                       
sel[dt.getUTCFullYear()][dt.getUTCMonth()] &&
+                                       
sel[dt.getUTCFullYear()][dt.getUTCMonth()][dt.getUTCDate()]
+                               );
+                       };
+               }
+       } else {
+               helpers.selected = function () { return false; };
+       }
+
+       return helpers;
+};
diff --git a/src/DiscordianDateTimeFormatter.js 
b/src/DiscordianDateTimeFormatter.js
new file mode 100644
index 0000000..1a0d2f5
--- /dev/null
+++ b/src/DiscordianDateTimeFormatter.js
@@ -0,0 +1,526 @@
+/**
+ * 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.
+ *
+ * @class
+ * @extends OO.ui.DateTimeFormatter
+ *
+ * @constructor
+ * @param {Object} [config] Configuration options
+ */
+OO.ui.DiscordianDateTimeFormatter = function OoUiDiscordianDateTimeFormatter( 
config ) {
+       config = $.extend( {}, config );
+
+       // Parent constructor
+       OO.ui.DiscordianDateTimeFormatter.super.call( this, config );
+};
+
+/* Setup */
+
+OO.inheritClass( OO.ui.DiscordianDateTimeFormatter, OO.ui.DateTimeFormatter );
+
+/* Static */
+
+/**
+ * @inheritdoc
+ */
+OO.ui.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
+ */
+OO.ui.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 
OO.ui.DateTimeFormatter.prototype.formatSpecValue.call( this, v );
+                               },
+                               parseValue: function ( v ) {
+                                       if ( 
/^\s*(st.?\s*)?tib('?s)?(\s*day)?\s*$/i.test( v ) ) {
+                                               return 'tib';
+                                       }
+                                       return 
OO.ui.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 
OO.ui.ProlepticGregorianDateTimeFormatter.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
+ *
+ * @public
+ * @param {Date|null} date
+ * @return {Object} Components
+ */
+OO.ui.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
+ */
+OO.ui.DateTimeFormatter.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
+ */
+OO.ui.DateTimeFormatter.prototype.adjustComponentInternal = function ( 
components, component, delta, mode ) {
+       var i, min, max, range, next, preTib, postTib;
+
+       switch ( component ) {
+               case 'Year':
+                       min = 1166;
+                       max = 11165;
+                       next = null;
+                       break;
+               case 'Season':
+                       min = 1;
+                       max = 5;
+                       next = 'Year';
+                       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 = Math.sign( delta );
+               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 {
+               switch ( mode ) {
+                       case 'overflow':
+                               i = Math.abs( delta );
+                               delta = Math.sign( delta );
+                               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 );
+                                       }
+                               }
+                               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;
+               }
+       }
+
+       return components;
+};
+
+/**
+ * @inheritdoc
+ */
+OO.ui.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}
+ */
+OO.ui.DiscordianDateTimeFormatter.prototype.isLeapYear = function ( year ) {
+       year -= 1166;
+       if ( year % 4 ) {
+               return false;
+       } else if ( year % 100 ) {
+               return true;
+       }
+       return ( year % 400 ) === 0;
+};
+
+/**
+ * @inheritdoc
+ */
+OO.ui.DiscordianDateTimeFormatter.prototype.getCalendarHeadings = function () {
+       return [ 'SM', 'BT', 'PD', 'PP', null, 'SO' ];
+};
+
+/**
+ * @inheritdoc
+ */
+OO.ui.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
+ */
+OO.ui.DiscordianDateTimeFormatter.prototype.getCalendarData = function ( date, 
min, max, selected ) {
+       var dt, components, season, i, row,
+               helpers = this.getCalendarDataHelpers( min, max, selected ),
+               ret = {},
+               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;
+       ret.prev = this.getDateFromComponents(
+               this.adjustComponentInternal( $.extend( {}, components ), 
'Season', -1, 'overflow' )
+       );
+       ret.next = this.getDateFromComponents(
+               this.adjustComponentInternal( $.extend( {}, components ), 
'Season', 1, 'overflow' )
+       );
+
+       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,
+                               inrange: helpers.inrange( dt ),
+                               selected: helpers.selected( dt )
+                       };
+
+                       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;
+};
diff --git a/src/ProlepticGregorianDateTimeFormatter.js 
b/src/ProlepticGregorianDateTimeFormatter.js
new file mode 100644
index 0000000..0e11029
--- /dev/null
+++ b/src/ProlepticGregorianDateTimeFormatter.js
@@ -0,0 +1,658 @@
+/**
+ * Provides various methods needed for formatting dates and times. This
+ * implementation implments the proleptic Gregorian calendar over years
+ * 0000–9999.
+ *
+ * @class
+ * @extends OO.ui.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.
+ */
+OO.ui.ProlepticGregorianDateTimeFormatter = function 
OoUiProlepticGregorianDateTimeFormatter( 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
+       OO.ui.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( OO.ui.ProlepticGregorianDateTimeFormatter, 
OO.ui.DateTimeFormatter );
+
+/* Static */
+
+/**
+ * @inheritdoc
+ */
+OO.ui.ProlepticGregorianDateTimeFormatter.static.formats = {
+       '@time': '${hour|0}:${minute|0}:${second|0}',
+       '@date': '$!{dow|short} ${month|short} ${day|#}, ${year|#}',
+       '@datetime': '$!{dow|short} ${month|short} ${day|#}, ${year|#} 
${hour|0}:${minute|0}:${second|0} $!{zone|short}',
+       '@default': '$!{dow|short} ${month|short} ${day|#}, ${year|#} 
${hour|0}:${minute|0}:${second|0} $!{zone|short}'
+};
+
+/**
+ * Default full month names.
+ * @static
+ * @inheritable
+ * @property {Object}
+ */
+OO.ui.ProlepticGregorianDateTimeFormatter.static.fullMonthNames = null;
+
+/**
+ * Default abbreviated month names.
+ * @static
+ * @inheritable
+ * @property {Object}
+ */
+OO.ui.ProlepticGregorianDateTimeFormatter.static.shortMonthNames = null;
+
+/**
+ * Default full day of week names.
+ * @static
+ * @inheritable
+ * @property {Object}
+ */
+OO.ui.ProlepticGregorianDateTimeFormatter.static.fullDayNames = null;
+
+/**
+ * Default abbreviated day of week names.
+ * @static
+ * @inheritable
+ * @property {Object}
+ */
+OO.ui.ProlepticGregorianDateTimeFormatter.static.shortDayNames = null;
+
+/**
+ * Default day letters.
+ * @static
+ * @inheritable
+ * @property {string[]}
+ */
+OO.ui.ProlepticGregorianDateTimeFormatter.static.dayLetters = null;
+
+/**
+ * Default AM/PM indicators
+ * @static
+ * @inheritable
+ * @property {string[]}
+ */
+OO.ui.ProlepticGregorianDateTimeFormatter.static.hour12Periods = null;
+
+OO.ui.ProlepticGregorianDateTimeFormatter.static.setupDefaults = function () {
+       OO.ui.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: OO.ui.msg( 'ooui-january' ),
+                       2: OO.ui.msg( 'ooui-february' ),
+                       3: OO.ui.msg( 'ooui-march' ),
+                       4: OO.ui.msg( 'ooui-april' ),
+                       5: OO.ui.msg( 'ooui-may_long' ),
+                       6: OO.ui.msg( 'ooui-june' ),
+                       7: OO.ui.msg( 'ooui-july' ),
+                       8: OO.ui.msg( 'ooui-august' ),
+                       9: OO.ui.msg( 'ooui-september' ),
+                       10: OO.ui.msg( 'ooui-october' ),
+                       11: OO.ui.msg( 'ooui-november' ),
+                       12: OO.ui.msg( 'ooui-december' )
+               };
+       }
+       if ( !this.shortMonthNames ) {
+               this.shortMonthNames = {
+                       1: OO.ui.msg( 'ooui-jan' ),
+                       2: OO.ui.msg( 'ooui-feb' ),
+                       3: OO.ui.msg( 'ooui-mar' ),
+                       4: OO.ui.msg( 'ooui-apr' ),
+                       5: OO.ui.msg( 'ooui-may' ),
+                       6: OO.ui.msg( 'ooui-jun' ),
+                       7: OO.ui.msg( 'ooui-jul' ),
+                       8: OO.ui.msg( 'ooui-aug' ),
+                       9: OO.ui.msg( 'ooui-sep' ),
+                       10: OO.ui.msg( 'ooui-oct' ),
+                       11: OO.ui.msg( 'ooui-nov' ),
+                       12: OO.ui.msg( 'ooui-dec' )
+               };
+       }
+
+       if ( !this.fullDayNames ) {
+               this.fullDayNames = {
+                       0: OO.ui.msg( 'ooui-sunday' ),
+                       1: OO.ui.msg( 'ooui-monday' ),
+                       2: OO.ui.msg( 'ooui-tuesday' ),
+                       3: OO.ui.msg( 'ooui-wednesday' ),
+                       4: OO.ui.msg( 'ooui-thursday' ),
+                       5: OO.ui.msg( 'ooui-friday' ),
+                       6: OO.ui.msg( 'ooui-saturday' )
+               };
+       }
+       if ( !this.shortDayNames ) {
+               this.shortDayNames = {
+                       0: OO.ui.msg( 'ooui-sun' ),
+                       1: OO.ui.msg( 'ooui-mon' ),
+                       2: OO.ui.msg( 'ooui-tue' ),
+                       3: OO.ui.msg( 'ooui-wed' ),
+                       4: OO.ui.msg( 'ooui-thu' ),
+                       5: OO.ui.msg( 'ooui-fri' ),
+                       6: OO.ui.msg( 'ooui-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 = [
+                       OO.ui.msg( 'ooui-period-am' ),
+                       OO.ui.msg( 'ooui-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
+ */
+OO.ui.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 
OO.ui.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}
+ *
+ * @public
+ * @param {Date|null} date
+ * @return {Object} Components
+ */
+OO.ui.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
+ */
+OO.ui.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
+ */
+OO.ui.ProlepticGregorianDateTimeFormatter.prototype.adjustComponent = function 
( date, component, delta, mode ) {
+       if ( !( date instanceof Date ) ) {
+               date = this.defaultDate;
+       }
+
+       var min, max, range,
+               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}
+ */
+OO.ui.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
+ */
+OO.ui.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
+ */
+OO.ui.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
+ */
+OO.ui.ProlepticGregorianDateTimeFormatter.prototype.getCalendarData = function 
( date, min, max, selected ) {
+       var dt, t, d, e, i, row,
+               helpers = this.getCalendarDataHelpers( min, max, selected ),
+               getDate = this.local ? 'getDate' : 'getUTCDate',
+               setDate = this.local ? 'setDate' : 'setUTCDate',
+               ret = {};
+
+       if ( !( date instanceof Date ) ) {
+               date = this.defaultDate;
+       }
+
+       dt = new Date( date.getTime() );
+       dt[setDate]( 1 );
+       t = dt.getTime();
+
+       ret.prev = new Date( t );
+       ret.next = new Date( t );
+       if ( this.local ) {
+               ret.header = this.fullMonthNames[dt.getMonth() + 1] + ' ' + 
dt.getFullYear();
+               ret.prev.setMonth( ret.prev.getMonth() - 1 );
+               ret.next.setMonth( ret.next.getMonth() + 1 );
+               d = dt.getDay() % 7;
+               e = this.getDaysInMonth( dt.getMonth() + 1, dt.getFullYear() );
+       } else {
+               ret.header = this.fullMonthNames[dt.getUTCMonth() + 1] + ' ' + 
dt.getUTCFullYear();
+               ret.prev.setUTCMonth( ret.prev.getUTCMonth() - 1 );
+               ret.next.setUTCMonth( ret.next.getUTCMonth() + 1 );
+               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,
+                               inrange: helpers.inrange( dt ),
+                               selected: helpers.selected( dt )
+                       };
+               }
+               ret.rows.push( row );
+       }
+
+       return ret;
+};
diff --git a/src/core.js b/src/core.js
index 09a5502..0a566ff 100644
--- a/src/core.js
+++ b/src/core.js
@@ -237,7 +237,57 @@
                // Default placeholder for file selection widgets
                'ooui-selectfile-placeholder': 'No file is selected',
                // Semicolon separator
-               'ooui-semicolon-separator': '; '
+               'ooui-semicolon-separator': '; ',
+
+               // Weekday names
+               'ooui-sunday': 'Sunday',
+               'ooui-monday': 'Monday',
+               'ooui-tuesday': 'Tuesday',
+               'ooui-wednesday': 'Wednesday',
+               'ooui-thursday': 'Thursday',
+               'ooui-friday': 'Friday',
+               'ooui-saturday': 'Saturday',
+               // Weekday abbreviations
+               'ooui-sun': 'Sun',
+               'ooui-mon': 'Mon',
+               'ooui-tue': 'Tue',
+               'ooui-wed': 'Wed',
+               'ooui-thu': 'Thu',
+               'ooui-fri': 'Fri',
+               'ooui-sat': 'Sat',
+               // Month names
+               'ooui-january': 'January',
+               'ooui-february': 'February',
+               'ooui-march': 'March',
+               'ooui-april': 'April',
+               // jscs:disable requireCamelCaseOrUpperCaseIdentifiers
+               'ooui-may_long': 'May',
+               // jscs:enable
+               'ooui-june': 'June',
+               'ooui-july': 'July',
+               'ooui-august': 'August',
+               'ooui-september': 'September',
+               'ooui-october': 'October',
+               'ooui-november': 'November',
+               'ooui-december': 'December',
+               // Month abbreviations
+               'ooui-jan': 'Jan',
+               'ooui-feb': 'Feb',
+               'ooui-mar': 'Mar',
+               'ooui-apr': 'Apr',
+               'ooui-may': 'May',
+               'ooui-jun': 'Jun',
+               'ooui-jul': 'Jul',
+               'ooui-aug': 'Aug',
+               'ooui-sep': 'Sep',
+               'ooui-oct': 'Oct',
+               'ooui-nov': 'Nov',
+               'ooui-dec': 'Dec',
+               // Other date and time indicators
+               'ooui-period-am': 'AM',
+               'ooui-period-pm': 'PM',
+               'ooui-timezone-utc': 'UTC',
+               'ooui-timezone-local': 'Local'
        };
 
        /**
diff --git a/src/styles/core.less b/src/styles/core.less
index f284925..283e277 100644
--- a/src/styles/core.less
+++ b/src/styles/core.less
@@ -68,6 +68,7 @@
 @import 'widgets/LabelWidget.less';
 @import 'widgets/IconWidget.less';
 @import 'widgets/IndicatorWidget.less';
+@import 'widgets/CalendarWidget.less';
 
 @import 'widgets/ButtonWidget.less';
 @import 'widgets/ButtonGroupWidget.less';
@@ -86,6 +87,7 @@
 @import 'widgets/InputWidget.less';
 @import 'widgets/ButtonInputWidget.less';
 @import 'widgets/CheckboxInputWidget.less';
+@import 'widgets/DateTimeInputWidget.less';
 @import 'widgets/DropdownInputWidget.less';
 @import 'widgets/RadioInputWidget.less';
 @import 'widgets/RadioSelectInputWidget.less';
diff --git a/src/styles/theme.less b/src/styles/theme.less
index cb8167d..ba0d7ba 100644
--- a/src/styles/theme.less
+++ b/src/styles/theme.less
@@ -67,6 +67,7 @@
 .theme-oo-ui-inputWidget () {}
 .theme-oo-ui-buttonInputWidget () {}
 .theme-oo-ui-checkboxInputWidget () {}
+.theme-oo-ui-dateTimeInputWidget () {}
 .theme-oo-ui-dropdownInputWidget () {}
 .theme-oo-ui-radioInputWidget () {}
 .theme-oo-ui-radioSelectInputWidget () {}
diff --git a/src/styles/widgets/CalendarWidget.less 
b/src/styles/widgets/CalendarWidget.less
new file mode 100644
index 0000000..bff60ba
--- /dev/null
+++ b/src/styles/widgets/CalendarWidget.less
@@ -0,0 +1,25 @@
+@import '../common';
+
+.oo-ui-calendarWidget {
+       display: inline-block;
+       position: relative;
+       vertical-align: middle;
+       padding: .5em;
+
+       &.oo-ui-calendarWidget-dependent {
+               display: block;
+               position: absolute;
+               z-index: 4;
+       }
+
+       &-grid {
+               table-layout: fixed;
+
+               .oo-ui-calendarWidget-cell {
+                       display: table-cell;
+                       white-space: nowrap;
+               }
+       }
+
+       .theme-oo-ui-calendarWidget();
+}
diff --git a/src/styles/widgets/DateTimeInputWidget.less 
b/src/styles/widgets/DateTimeInputWidget.less
new file mode 100644
index 0000000..7009ab3
--- /dev/null
+++ b/src/styles/widgets/DateTimeInputWidget.less
@@ -0,0 +1,43 @@
+@import '../common';
+
+.oo-ui-dateTimeInputWidget {
+       display: inline-block;
+       position: relative;
+       vertical-align: middle;
+
+       &-fields {
+               position: relative;
+               display: table;
+               z-index: 2;
+               .oo-ui-unselectable();
+
+               > .oo-ui-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;
+               }
+       }
+
+       .theme-oo-ui-dateTimeInputWidget();
+}
diff --git a/src/themes/apex/widgets.less b/src/themes/apex/widgets.less
index fb3b1b7..39d05db 100644
--- a/src/themes/apex/widgets.less
+++ b/src/themes/apex/widgets.less
@@ -101,6 +101,55 @@
        }
 }
 
+.theme-oo-ui-calendarWidget () {
+       background-color: white;
+       border: 1px solid rgba(0,0,0,0.1);
+
+       &.oo-ui-calendarWidget-dependent {
+               margin-top: -1px;
+               border-top: 1px solid white;
+       }
+
+       &-heading {
+               text-align: center;
+               vertical-align: middle;
+               font-weight: bold;
+               white-space: nowrap;
+
+               .oo-ui-calendarWidget-previous {
+                       float: left;
+               }
+               .oo-ui-calendarWidget-next {
+                       float: right;
+               }
+       }
+
+       &-grid {
+               margin: 0 auto;
+
+               .oo-ui-calendarWidget-cell {
+                       text-align: center;
+
+                       .oo-ui-buttonElement-button {
+                               width: 100%;
+                               border: none;
+                               .oo-ui-box-sizing( border-box );
+                       }
+
+                       &.oo-ui-calendarWidget-extra 
.oo-ui-buttonElement-button .oo-ui-labelElement-label {
+                               color: #bbb;
+                       }
+
+                       &.oo-ui-calendarWidget-selected 
.oo-ui-buttonElement-button {
+                               .oo-ui-vertical-gradient(#fff, #def);
+                               .oo-ui-labelElement-label {
+                                       color: #38f;
+                               }
+                       }
+               }
+       }
+}
+
 .theme-oo-ui-dropdownWidget () {
        margin: 0.25em 0;
        width: 100%;
@@ -289,6 +338,115 @@
 
 .theme-oo-ui-checkboxInputWidget () {}
 
+.theme-oo-ui-dateTimeInputWidget () {
+       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 rgba(0,0,0,0.1);
+               box-shadow: 0 0 0 white, inset 0 0.1em 0.2em #ddd;
+               border-radius: 0.25em;
+               .oo-ui-transition(border-color 200ms, box-shadow 200ms);
+               .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;
+               .oo-ui-box-sizing(border-box);
+       }
+
+       &.oo-ui-widget-disabled {
+               .oo-ui-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 {
+               .oo-ui-dateTimeInputWidget-editField:hover {
+                       background-color: #eee;
+               }
+
+               &.oo-ui-flaggedElement-invalid {
+                       .oo-ui-dateTimeInputWidget-handle {
+                               background-color: #fdd;
+                       }
+               }
+       }
+
+       input.oo-ui-dateTimeInputWidget-field {
+               padding: 0.5em 0;
+       }
+
+       &-editField.oo-ui-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 .oo-ui-dateTimeInputWidget-handle {
+               padding-left: 3em;
+       }
+
+       &.oo-ui-indicatorElement .oo-ui-dateTimeInputWidget-handle {
+               padding-right: 2em;
+       }
+}
+
 .theme-oo-ui-dropdownInputWidget () {
        width: 100%;
        max-width: 50em;
diff --git a/src/themes/blank/widgets.less b/src/themes/blank/widgets.less
index e0e4c88..0f65f18 100644
--- a/src/themes/blank/widgets.less
+++ b/src/themes/blank/widgets.less
@@ -20,6 +20,8 @@
 
 .theme-oo-ui-indicatorWidget () {}
 
+.theme-oo-ui-calendarWidget () {}
+
 .theme-oo-ui-dropdownWidget () {}
 
 .theme-oo-ui-selectFileWidget () {}
@@ -30,6 +32,8 @@
 
 .theme-oo-ui-checkboxInputWidget () {}
 
+.theme-oo-ui-dateTimeInputWidget () {}
+
 .theme-oo-ui-dropdownInputWidget () {}
 
 .theme-oo-ui-radioInputWidget () {}
diff --git a/src/themes/mediawiki/widgets.less 
b/src/themes/mediawiki/widgets.less
index d2e3ee9..382e85e 100644
--- a/src/themes/mediawiki/widgets.less
+++ b/src/themes/mediawiki/widgets.less
@@ -101,6 +101,55 @@
        }
 }
 
+.theme-oo-ui-calendarWidget () {
+       background-color: white;
+       border: 1px solid #ccc;
+
+       &.oo-ui-calendarWidget-dependent {
+               margin-top: -1px;
+               border-top: 1px solid white;
+       }
+
+       &-heading {
+               text-align: center;
+               vertical-align: middle;
+               font-weight: bold;
+               white-space: nowrap;
+
+               .oo-ui-calendarWidget-previous {
+                       float: left;
+               }
+               .oo-ui-calendarWidget-next {
+                       float: right;
+               }
+       }
+
+       &-grid {
+               margin: 0 auto;
+
+               .oo-ui-calendarWidget-cell {
+                       text-align: center;
+
+                       .oo-ui-buttonElement-button {
+                               width: 100%;
+                               border: none;
+                               .oo-ui-box-sizing( border-box );
+                       }
+
+                       &.oo-ui-calendarWidget-extra 
.oo-ui-buttonElement-button .oo-ui-labelElement-label {
+                               color: #bbb;
+                       }
+
+                       &.oo-ui-calendarWidget-selected 
.oo-ui-buttonElement-button {
+                               background-color: #def;
+                               .oo-ui-labelElement-label {
+                                       color: #38f;
+                               }
+                       }
+               }
+       }
+}
+
 .theme-oo-ui-dropdownWidget () {
        margin: 0.25em 0;
        width: 100%;
@@ -371,6 +420,121 @@
        }
 }
 
+.theme-oo-ui-dateTimeInputWidget () {
+       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;
+               .oo-ui-box-sizing(border-box);
+       }
+
+       &.oo-ui-widget-disabled {
+               .oo-ui-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 {
+               .oo-ui-dateTimeInputWidget-editField:hover {
+                       background-color: #eee;
+               }
+
+               &.oo-ui-flaggedElement-invalid {
+                       .oo-ui-dateTimeInputWidget-handle {
+                               border-color: red;
+                               box-shadow: inset 0 0 0 0 red;
+                       }
+
+                       .oo-ui-dateTimeInputWidget-handle:focus {
+                               border-color: red;
+                               box-shadow: inset 0 0 0 0.1em red;
+                       }
+               }
+       }
+
+       input.oo-ui-dateTimeInputWidget-field {
+               padding: 0.5em 0;
+       }
+
+       &-editField.oo-ui-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 .oo-ui-dateTimeInputWidget-handle {
+               padding-left: 3em;
+       }
+
+       &.oo-ui-indicatorElement .oo-ui-dateTimeInputWidget-handle {
+               padding-right: 2em;
+       }
+}
+
 .theme-oo-ui-dropdownInputWidget () {
        width: 100%;
        max-width: 50em;
diff --git a/src/widgets/CalendarWidget.js b/src/widgets/CalendarWidget.js
new file mode 100644
index 0000000..ce2c404
--- /dev/null
+++ b/src/widgets/CalendarWidget.js
@@ -0,0 +1,401 @@
+/**
+ * CalendarWidget displays a calendar that can be used to select a date. It
+ * uses {@link OO.ui.DateTimeFormatter DateTimeFormatter} to get the details of
+ * the calendar.
+ *
+ * This widget is mainly intended to be used as a popup from a
+ * {@link OO.ui.DateTimeInputWidget DateTimeInputWidget}, but may also be used
+ * standalone.
+ *
+ * @class
+ * @extends OO.ui.Widget
+ *
+ * @constructor
+ * @param {Object} [config] Configuration options
+ * @cfg {Object|OO.ui.DateTimeFormatter} [formatter={}] Configuration options 
for
+ *  OO.ui.ProlepticGregorianDateTimeFormatter, or an OO.ui.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} [current] Current date.
+ * @cfg {Date|Date[]|null} [selected=null] Selected date(s).
+ */
+OO.ui.CalendarWidget = function OoUiCalendarWidget( config ) {
+       var $colgroup, $headTR, headings, i;
+
+       // Configuration initialization
+       config = $.extend( {
+               min: null,
+               max: null,
+               current: new Date(),
+               selected: null,
+               formatter: {}
+       }, config );
+
+       // Parent constructor
+       OO.ui.CalendarWidget.super.call( this, config );
+
+       // 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.current instanceof Date ) {
+               this.current = config.current;
+       } else {
+               this.current = new Date();
+       }
+
+       this.selected = [];
+
+       if ( config.formatter instanceof OO.ui.DateTimeFormatter ) {
+               this.formatter = config.formatter;
+       } else if ( $.isPlainObject( config.formatter ) ) {
+               this.formatter = new OO.ui.ProlepticGregorianDateTimeFormatter( 
config.formatter );
+       } else {
+               throw new Error( '"formatter" must be an 
OO.ui.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;
+
+       // Initialization
+       this.$head
+               .addClass( 'oo-ui-calendarWidget-heading' )
+               .append(
+                       new OO.ui.ButtonWidget( {
+                               icon: 'previous',
+                               framed: false,
+                               classes: [ 'oo-ui-calendarWidget-previous' ]
+                       } ).connect( this, { click: 'onPrevClick' } ).$element,
+                       new OO.ui.ButtonWidget( {
+                               icon: 'next',
+                               framed: false,
+                               classes: [ 'oo-ui-calendarWidget-next' ]
+                       } ).connect( this, { click: 'onNextClick' } ).$element,
+                       this.$header
+               );
+       $colgroup = $( '<colgroup>' );
+       $headTR = $( '<tr>' );
+       this.$table
+               .addClass( 'oo-ui-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 );
+               }
+               $colgroup.append( this.cols[i] );
+               $headTR.append( this.headings[i] );
+       }
+
+       this.setSelected( config.selected );
+       this.$element
+               .addClass( 'oo-ui-calendarWidget' )
+               .append( this.$head, this.$table );
+
+       if ( this.widget ) {
+               this.$element.addClass( 'oo-ui-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( OO.ui.CalendarWidget, OO.ui.Widget );
+
+/* Events */
+
+/**
+ * A `change` event is emitted when the selected dates change
+ *
+ * @event change
+ */
+
+/**
+ * A `page` event is emitted when the current "month" changes
+ *
+ * @event page
+ */
+
+/* Methods */
+
+/**
+ * Return the current selected dates
+ *
+ * @return {Date[]}
+ */
+OO.ui.CalendarWidget.prototype.getSelected = function () {
+       return this.selected;
+};
+
+/**
+ * Set the selected dates
+ *
+ * @param {Date|Date[]|null} dates
+ * @fires change
+ * @chainable
+ */
+OO.ui.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 a date in the current "month"
+ *
+ * The specific date chosen is not defined.
+ *
+ * @return {Date}
+ */
+OO.ui.CalendarWidget.prototype.getCurrent = function () {
+       return this.current;
+};
+
+/**
+ * Set the current "month"
+ *
+ * @param {Date} date
+ * @fires page
+ * @chainable
+ */
+OO.ui.CalendarWidget.prototype.setCurrent = function ( date ) {
+       var changed = false;
+
+       if ( !this.formatter.sameCalendarGrid( this.current, date ) ) {
+               changed = true;
+       } else if ( !this.formatter.timePartIsEqual( this.current, date ) ) {
+               date = this.formatter.mergeDateAndTime( this.current, date );
+               changed = true;
+       }
+
+       if ( changed ) {
+               this.current = date;
+               this.emit( 'page', date );
+               this.updateUI();
+       }
+
+       return this;
+};
+
+/**
+ * Update the user interface
+ *
+ * @protected
+ */
+OO.ui.CalendarWidget.prototype.updateUI = function () {
+       var r, c, row, day, k, $cell,
+               width = this.minWidth,
+               nullCols = [];
+
+       this.calendarData = this.formatter.getCalendarData( this.current, 
this.min, this.max, this.selected );
+
+       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( {
+                                               classes: [
+                                                       
'oo-ui-calendarWidget-cell',
+                                                       day.extra ? 
'oo-ui-calendarWidget-extra' : ''
+                                               ],
+                                               framed: true,
+                                               label: day.display
+                                       } );
+                                       this.buttons[k].connect( this, { click: 
[ 'onDayClick', this.buttons[k] ] } );
+                               }
+                               this.buttons[k]
+                                       .setData( day.date )
+                                       .setDisabled( !day.inrange );
+                               $cell = this.buttons[k].$element;
+                               $cell.toggleClass( 
'oo-ui-calendarWidget-selected', day.selected );
+                       }
+                       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 previous button click
+ *
+ * @protected
+ */
+OO.ui.CalendarWidget.prototype.onPrevClick = function () {
+       if ( this.calendarData && this.calendarData.prev ) {
+               this.setCurrent( this.calendarData.prev );
+       }
+};
+
+/**
+ * Handles next button click
+ *
+ * @protected
+ */
+OO.ui.CalendarWidget.prototype.onNextClick = function () {
+       if ( this.calendarData && this.calendarData.next ) {
+               this.setCurrent( this.calendarData.next );
+       }
+};
+
+/**
+ * Handles day button click
+ *
+ * @protected
+ * @param {OO.ui.ButtonWidget} $button
+ */
+OO.ui.CalendarWidget.prototype.onDayClick = function ( $button ) {
+       this.setSelected( [ $button.getData() ] );
+};
+
+/**
+ * Handles document mouse down events.
+ *
+ * @protected
+ * @param {jQuery.Event} e Key down event
+ */
+OO.ui.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 );
+       }
+};
+
+/**
+ * @inheritdoc
+ */
+OO.ui.CalendarWidget.prototype.toggle = function ( visible ) {
+       visible = ( visible === undefined ? !this.visible : !!visible );
+
+       var change = visible !== this.isVisible();
+
+       // Parent method
+       OO.ui.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;
+};
diff --git a/src/widgets/DateTimeInputWidget.js 
b/src/widgets/DateTimeInputWidget.js
new file mode 100644
index 0000000..9a050fe
--- /dev/null
+++ b/src/widgets/DateTimeInputWidget.js
@@ -0,0 +1,822 @@
+/**
+ * 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 OO.ui.DateTimeInputWidget( {} )
+ *     $( 'body' ).append( dateTimeInput.$element );
+ *
+ * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Inputs
+ *
+ * @class
+ * @extends OO.ui.InputWidget
+ * @mixins OO.ui.IconElement
+ * @mixins OO.ui.IndicatorElement
+ * @mixins OO.ui.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|OO.ui.DateTimeFormatter} [formatter={}] Configuration options 
for
+ *  OO.ui.ProlepticGregorianDateTimeFormatter (with 'format' defaulting to
+ *  '@date', '@time', or '@datetime' depending on 'type'), or an
+ *  OO.ui.DateTimeFormatter instance to use.
+ * @cfg {Object|null} [calendar={}] Configuration options for
+ *  OO.ui.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
+ */
+OO.ui.DateTimeInputWidget = function OoUiDateTimeInputWidget( 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
+       OO.ui.DateTimeInputWidget.super.call( this, config );
+
+       // Mixin constructors
+       OO.ui.IconElement.call( this, config );
+       OO.ui.IndicatorElement.call( this, config );
+       OO.ui.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 OO.ui.DateTimeFormatter ) {
+               this.formatter = config.formatter;
+       } else if ( $.isPlainObject( config.formatter ) ) {
+               this.formatter = new OO.ui.ProlepticGregorianDateTimeFormatter( 
config.formatter );
+       } else {
+               throw new Error( '"formatter" must be an 
OO.ui.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 OO.ui.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( 'oo-ui-dateTimeInputWidget-fields' );
+       this.setupFields();
+
+       this.$handle
+               .addClass( 'oo-ui-dateTimeInputWidget-handle' )
+               .append( this.$icon, this.$indicator, this.$fields );
+
+       this.$element
+               .addClass( 'oo-ui-dateTimeInputWidget' )
+               .append( this.$handle );
+
+       if ( this.calendar ) {
+               this.$element.append( this.calendar.$element );
+       }
+};
+
+/* Setup */
+
+OO.inheritClass( OO.ui.DateTimeInputWidget, OO.ui.InputWidget );
+OO.mixinClass( OO.ui.DateTimeInputWidget, OO.ui.IconElement );
+OO.mixinClass( OO.ui.DateTimeInputWidget, OO.ui.IndicatorElement );
+OO.mixinClass( OO.ui.DateTimeInputWidget, OO.ui.PendingElement );
+
+/* Static properties */
+
+/* Events */
+
+/* Methods */
+
+/**
+ * Convert a date string to a Date
+ * @private
+ * @param {string} value
+ * @return {Date|null}
+ */
+OO.ui.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
+ */
+OO.ui.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}
+ */
+OO.ui.InputWidget.prototype.getValueAsDate = function () {
+       return this.parseDateValue( this.getValue() );
+};
+
+/**
+ * Set up the UI fields
+ * @private
+ */
+OO.ui.DateTimeInputWidget.prototype.setupFields = function () {
+       var i, $field, spec, placeholder, sz, maxlength,
+               spanValFunc = function ( v ) {
+                       if ( v === undefined ) {
+                               return this.data( 
'oo-ui-dateTimeInputWidget-value' );
+                       } else {
+                               v = String( v );
+                               this.data( 'oo-ui-dateTimeInputWidget-value', v 
);
+                               if ( v === '' ) {
+                                       v = this.data( 
'oo-ui-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( 'oo-ui-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
+                       sz = spec.size + 'ch';
+               } else {
+                       // Add a little for padding
+                       sz = ( spec.size * 1.1 ) + 'ch';
+               }
+               if ( spec.editable && spec.type !== 'static' ) {
+                       if ( spec.type === 'boolean' || spec.type === 
'toggleLocal' ) {
+                               $field = $( '<span>' )
+                                       .attr( {
+                                               tabindex: disabled ? -1 : 0
+                                       } )
+                                       .width( sz )
+                                       .data( 
'oo-ui-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( 'oo-ui-dateTimeInputWidget-editField' 
);
+               } else {
+                       $field = $( '<span>' )
+                               .width( sz )
+                               .data( 'oo-ui-dateTimeInputWidget-placeholder', 
placeholder );
+                       if ( spec.type === 'static' ) {
+                               $field.text( spec.value );
+                       } else {
+                               $field.val = spanValFunc;
+                       }
+               }
+
+               this.fields.push( $field );
+               $field
+                       .addClass( 'oo-ui-dateTimeInputWidget-field' )
+                       .data( 'oo-ui-dateTimeInputWidget-fieldSpec', spec )
+                       .appendTo( this.$fields );
+       }
+
+       if ( this.clearable ) {
+               this.clearButton = new OO.ui.ButtonWidget( {
+                       classes: [ 'oo-ui-dateTimeInputWidget-field', 
'oo-ui-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
+ */
+OO.ui.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( 
'oo-ui-dateTimeInputWidget-fieldSpec' );
+
+                       $field
+                               .removeClass( 
'oo-ui-dateTimeInputWidget-invalid oo-ui-element-hidden' )
+                               .val( '' );
+
+                       if ( spec.intercalarySize ) {
+                               if ( spec.type === 'number' ) {
+                                       // Numbers ''should'' be the same width
+                                       $field.width( spec.size + 'ch' );
+                               } else {
+                                       // Add a little for padding
+                                       $field.width( ( spec.size * 1.1 ) + 
'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( 'oo-ui-dateTimeInputWidget-invalid' 
);
+                       spec = $field.data( 
'oo-ui-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
+                                       this.fields[i].width( sz + 'ch' );
+                               } else {
+                                       // Add a little for padding
+                                       this.fields[i].width( ( sz * 1.1 ) + 
'ch' );
+                               }
+                       }
+               }
+
+               this.setFlags( { invalid: date < this.min || date > this.max } 
);
+       }
+
+       this.$element.toggleClass( 'oo-ui-dateTimeInputWidget-empty', date === 
null );
+};
+
+/**
+ * Update the value with data from the UI fields
+ * @private
+ */
+OO.ui.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( 'oo-ui-dateTimeInputWidget-fieldSpec' );
+               if ( spec.editable ) {
+                       $field.removeClass( 'oo-ui-dateTimeInputWidget-invalid' 
);
+                       v = $field.val();
+                       if ( v === '' ) {
+                               $field.addClass( 
'oo-ui-dateTimeInputWidget-invalid' );
+                               anyEmpty = true;
+                       } else {
+                               allEmpty = false;
+                               v = spec.parseValue( v );
+                               if ( v === undefined ) {
+                                       $field.addClass( 
'oo-ui-dateTimeInputWidget-invalid' );
+                                       anyInvalid = true;
+                               } else {
+                                       components[spec.component] = v;
+                               }
+                       }
+               }
+       }
+
+       if ( allEmpty ) {
+               for ( i = 0; i < this.fields.length; i++ ) {
+                       this.fields[i].removeClass( 
'oo-ui-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
+ */
+OO.ui.DateTimeInputWidget.prototype.onChange = function () {
+       var date;
+
+       this.updateFieldsFromValue();
+
+       if ( this.calendar ) {
+               date = this.getValueAsDate();
+               this.calendar.setSelected( date );
+               if ( date ) {
+                       this.calendar.setCurrent( date );
+               }
+       }
+};
+
+/**
+ * Handle clear button click event
+ * @private
+ */
+OO.ui.DateTimeInputWidget.prototype.onClearClick = function () {
+       this.blur();
+       this.setValue( '' );
+};
+
+/**
+ * Handle click on the widget background
+ * @private
+ * @param {jQuery.Event} e Click event
+ */
+OO.ui.DateTimeInputWidget.prototype.onHandleClick = function () {
+       this.focus();
+
+       // If there's a containing <label>, we need to prevent it from stealing 
our
+       // click events. Sigh.
+       return false;
+};
+
+/**
+ * Handle key down events on our field inputs.
+ *
+ * @private
+ * @param {jQuery} $field
+ * @param {jQuery.Event} e Key down event
+ */
+OO.ui.DateTimeInputWidget.prototype.onFieldKeyDown = function ( $field, e ) {
+       var spec = $field.data( 'oo-ui-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
+ */
+OO.ui.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.
+ *
+ * We manually focus the clicked element because we have to cancel the default
+ * behavior in case of a containing <label>.
+ *
+ * @private
+ * @param {jQuery} $field
+ * @param {jQuery.Event} e Click event
+ */
+OO.ui.DateTimeInputWidget.prototype.onFieldClick = function ( $field ) {
+       var spec = $field.data( 'oo-ui-dateTimeInputWidget-fieldSpec' );
+
+       if ( !this.isDisabled() ) {
+               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();
+               }
+
+               if ( $field[0] !== document.activeElement ) {
+                       $field.focus();
+               }
+       }
+};
+
+/**
+ * Handle blur events on our field inputs.
+ *
+ * @private
+ * @param {jQuery} $field
+ * @param {jQuery.Event} e Blur event
+ */
+OO.ui.DateTimeInputWidget.prototype.onFieldBlur = function ( $field ) {
+       var v, date,
+               spec = $field.data( 'oo-ui-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
+ */
+OO.ui.DateTimeInputWidget.prototype.onFieldChange = function () {
+       this.updateValueFromFields();
+};
+
+/**
+ * Handle wheel events on our field inputs.
+ *
+ * @private
+ * @param {jQuery} $field
+ * @param {jQuery.Event} e Change event
+ */
+OO.ui.DateTimeInputWidget.prototype.onFieldWheel = function ( $field, e ) {
+       var delta = 0,
+               spec = $field.data( 'oo-ui-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, Math.sign( delta ), 'wrap' )
+                       );
+               }
+               return false;
+       }
+};
+
+/**
+ * Handle calendar change event
+ * @private
+ */
+OO.ui.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
+ */
+OO.ui.DateTimeInputWidget.prototype.getInputElement = function () {
+       // I'd much rather use type=hidden here, but browsers have a misfeature
+       // where label:hover applies the styles for input:hover on the input
+       // targeted by the label.
+       var widget = this;
+
+       return $( '<input type="text" />' )
+               .attr( {
+                       tabindex: -1,
+                       'aria-hidden': 'true'
+               } )
+               .addClass( 'oo-ui-element-hidden' )
+               .on( {
+                       focus: function () {
+                               widget.focus();
+                       }
+               } );
+};
+
+/**
+ * @inheritdoc
+ */
+OO.ui.DateTimeInputWidget.prototype.setDisabled = function ( disabled ) {
+       OO.ui.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
+ */
+OO.ui.DateTimeInputWidget.prototype.focus = function () {
+       if ( !this.$fields.find( document.activeElement ).length ) {
+               this.$fields.find( '.oo-ui-dateTimeInputWidget-editField' 
).first().focus();
+       }
+       return this;
+};
+
+/**
+ * @inheritdoc
+ */
+OO.ui.DateTimeInputWidget.prototype.blur = function () {
+       this.$fields.find( document.activeElement ).blur();
+       return this;
+};
+
+/**
+ * @inheritdoc
+ */
+OO.ui.InputWidget.prototype.simulateLabelClick = function () {
+       this.focus();
+};

-- 
To view, visit https://gerrit.wikimedia.org/r/216909
To unsubscribe, visit https://gerrit.wikimedia.org/r/settings

Gerrit-MessageType: newchange
Gerrit-Change-Id: I442179387e723e54be631975fb41ef0da7642f5c
Gerrit-PatchSet: 1
Gerrit-Project: oojs/ui
Gerrit-Branch: master
Gerrit-Owner: Anomie <bjor...@wikimedia.org>

_______________________________________________
MediaWiki-commits mailing list
MediaWiki-commits@lists.wikimedia.org
https://lists.wikimedia.org/mailman/listinfo/mediawiki-commits

Reply via email to