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