From 57342159dab992bcd28d1e8fc4c1ca804bf61962 Mon Sep 17 00:00:00 2001 From: Brad Jorsch Date: Sun, 3 Jan 2016 17:34:42 -0800 Subject: [PATCH] Add datetime input widget Since OOJS-UI isn't currently in a position to accept such things, the decision is to put it in MediaWiki instead. Once OOJS-UI is un-monolithicized and the i18n issue is solved, this should be somehow moved there instead. Change-Id: Ia3942c76804c865c1039904d170ee6eafcdc6793 --- languages/i18n/en.json | 3 + languages/i18n/qqq.json | 3 + resources/Resources.php | 63 ++ .../CalendarWidget.js | 593 +++++++++++++ .../CalendarWidget.less | 74 ++ .../DateTimeFormatter.js | 623 ++++++++++++++ .../DateTimeInputWidget.js | 812 ++++++++++++++++++ .../DateTimeInputWidget.less | 155 ++++ .../DiscordianDateTimeFormatter.js | 562 ++++++++++++ .../ProlepticGregorianDateTimeFormatter.js | 661 ++++++++++++++ ...ediawiki.widgets.datetime.definitions.less | 37 + .../mediawiki.widgets.datetime.js | 2 + 12 files changed, 3588 insertions(+) create mode 100644 resources/src/mediawiki.widgets.datetime/CalendarWidget.js create mode 100644 resources/src/mediawiki.widgets.datetime/CalendarWidget.less create mode 100644 resources/src/mediawiki.widgets.datetime/DateTimeFormatter.js create mode 100644 resources/src/mediawiki.widgets.datetime/DateTimeInputWidget.js create mode 100644 resources/src/mediawiki.widgets.datetime/DateTimeInputWidget.less create mode 100644 resources/src/mediawiki.widgets.datetime/DiscordianDateTimeFormatter.js create mode 100644 resources/src/mediawiki.widgets.datetime/ProlepticGregorianDateTimeFormatter.js create mode 100644 resources/src/mediawiki.widgets.datetime/mediawiki.widgets.datetime.definitions.less create mode 100644 resources/src/mediawiki.widgets.datetime/mediawiki.widgets.datetime.js diff --git a/languages/i18n/en.json b/languages/i18n/en.json index 0b31abc918..3a4857bdbf 100644 --- a/languages/i18n/en.json +++ b/languages/i18n/en.json @@ -115,6 +115,8 @@ "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\"", @@ -3403,6 +3405,7 @@ "signature": "[[{{ns:user}}:$1|$2]] ([[{{ns:user_talk}}:$1|talk]])", "signature-anon": "[[{{#special:Contributions}}/$1|$2]]", "timezone-utc": "UTC", + "timezone-local": "Local", "duplicate-defaultsort": "Warning: Default sort key \"$2\" overrides earlier default sort key \"$1\".", "duplicate-displaytitle": "Warning: Display title \"$2\" overrides earlier display title \"$1\".", "invalid-indicator-name": "Error: Page status indicators' name attribute must not be empty.", diff --git a/languages/i18n/qqq.json b/languages/i18n/qqq.json index 60ae69eec7..b0cfb61be1 100644 --- a/languages/i18n/qqq.json +++ b/languages/i18n/qqq.json @@ -290,6 +290,8 @@ "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}}", @@ -3578,6 +3580,7 @@ "signature": "This will be substituted in the signature (~~~ or ~~~~ 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.", diff --git a/resources/Resources.php b/resources/Resources.php index 987b97a6de..e8cb843e71 100644 --- a/resources/Resources.php +++ b/resources/Resources.php @@ -2068,6 +2068,69 @@ return array( ), 'targets' => array( 'desktop', 'mobile' ), ), + 'mediawiki.widgets.datetime' => array( + 'scripts' => array( + 'resources/src/mediawiki.widgets.datetime/mediawiki.widgets.datetime.js', + 'resources/src/mediawiki.widgets.datetime/CalendarWidget.js', + 'resources/src/mediawiki.widgets.datetime/DateTimeFormatter.js', + 'resources/src/mediawiki.widgets.datetime/DateTimeInputWidget.js', + 'resources/src/mediawiki.widgets.datetime/ProlepticGregorianDateTimeFormatter.js', + ), + 'skinStyles' => array( + 'default' => array( + 'resources/src/mediawiki.widgets.datetime/CalendarWidget.less', + 'resources/src/mediawiki.widgets.datetime/DateTimeInputWidget.less', + ), + ), + 'messages' => array( + 'timezone-utc', + 'timezone-local', + 'january', + 'february', + 'march', + 'april', + 'may_long', + 'june', + 'july', + 'august', + 'september', + 'october', + 'november', + 'december', + 'jan', + 'feb', + 'mar', + 'apr', + 'may', + 'jun', + 'jul', + 'aug', + 'sep', + 'oct', + 'nov', + 'dec', + 'sunday', + 'monday', + 'tuesday', + 'wednesday', + 'thursday', + 'friday', + 'saturday', + 'sun', + 'mon', + 'tue', + 'wed', + 'thu', + 'fri', + 'sat', + 'period-am', + 'period-pm', + ), + 'dependencies' => array( + 'oojs-ui', + ), + 'targets' => array( 'desktop', 'mobile' ), + ), 'mediawiki.widgets.CategorySelector' => array( 'scripts' => array( 'resources/src/mediawiki.widgets/mw.widgets.CategoryCapsuleItemWidget.js', diff --git a/resources/src/mediawiki.widgets.datetime/CalendarWidget.js b/resources/src/mediawiki.widgets.datetime/CalendarWidget.js new file mode 100644 index 0000000000..31b1cd5b44 --- /dev/null +++ b/resources/src/mediawiki.widgets.datetime/CalendarWidget.js @@ -0,0 +1,593 @@ +( function ( $, mw ) { + + /** + * CalendarWidget displays a calendar that can be used to select a date. It + * uses {@link mw.widgets.datetime.DateTimeFormatter DateTimeFormatter} to get the details of + * the calendar. + * + * This widget is mainly intended to be used as a popup from a + * {@link mw.widgets.datetime.DateTimeInputWidget DateTimeInputWidget}, but may also be used + * standalone. + * + * @class + * @extends OO.ui.Widget + * @mixins OO.ui.mixin.TabIndexedElement + * + * @constructor + * @param {Object} [config] Configuration options + * @cfg {Object|mw.widgets.datetime.DateTimeFormatter} [formatter={}] Configuration options for + * mw.widgets.datetime.ProlepticGregorianDateTimeFormatter, or an mw.widgets.datetime.DateTimeFormatter + * instance to use. + * @cfg {OO.ui.Widget|null} [widget=null] Widget associated with the calendar. + * Specifying this configures the calendar to be used as a popup from the + * specified widget (e.g. absolute positioning, automatic hiding when clicked + * outside). + * @cfg {Date|null} [min=null] Minimum allowed date + * @cfg {Date|null} [max=null] Maximum allowed date + * @cfg {Date} [focusedDate] Initially focused date. + * @cfg {Date|Date[]|null} [selected=null] Selected date(s). + */ + mw.widgets.datetime.CalendarWidget = function MwWidgetsDatetimeCalendarWidget( config ) { + var $colgroup, $headTR, headings, i; + + // Configuration initialization + config = $.extend( { + min: null, + max: null, + focusedDate: new Date(), + selected: null, + formatter: {} + }, config ); + + // Parent constructor + mw.widgets.datetime.CalendarWidget[ 'super' ].call( this, config ); + + // Mixin constructors + OO.ui.mixin.TabIndexedElement.call( this, $.extend( {}, config, { $tabIndexed: this.$element } ) ); + + // Properties + if ( config.min instanceof Date && config.min.getTime() >= -62167219200000 ) { + this.min = config.min; + } else { + this.min = new Date( -62167219200000 ); // 0000-01-01T00:00:00.000Z + } + if ( config.max instanceof Date && config.max.getTime() <= 253402300799999 ) { + this.max = config.max; + } else { + this.max = new Date( 253402300799999 ); // 9999-12-31T12:59:59.999Z + } + + if ( config.focusedDate instanceof Date ) { + this.focusedDate = config.focusedDate; + } else { + this.focusedDate = new Date(); + } + + this.selected = []; + + if ( config.formatter instanceof mw.widgets.datetime.DateTimeFormatter ) { + this.formatter = config.formatter; + } else if ( $.isPlainObject( config.formatter ) ) { + this.formatter = new mw.widgets.datetime.ProlepticGregorianDateTimeFormatter( config.formatter ); + } else { + throw new Error( '"formatter" must be an mw.widgets.datetime.DateTimeFormatter or a plain object' ); + } + + this.calendarData = null; + + this.widget = config.widget; + this.$widget = config.widget ? config.widget.$element : null; + this.onDocumentMouseDownHandler = this.onDocumentMouseDown.bind( this ); + + this.$head = $( '
' ); + this.$header = $( '' ); + this.$table = $( '' ); + this.cols = []; + this.colNullable = []; + this.headings = []; + this.$tableBody = $( '' ); + 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 = $( '' ); + $headTR = $( '' ); + this.$table + .addClass( 'mw-widgets-datetime-calendarWidget-grid' ) + .append( $colgroup ) + .append( $( '' ).append( $headTR ) ) + .append( this.$tableBody ); + + headings = this.formatter.getCalendarHeadings(); + for ( i = 0; i < headings.length; i++ ) { + this.cols[ i ] = $( '' ); + this.headings[ i ] = $( '' ); + } 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 ] = $( '
' ); + 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 ] = $( '
' ); + } + $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: $( '' ), + classes: [ + 'mw-widgets-datetime-calendarWidget-cell', + day.extra ? 'mw-widgets-datetime-calendarWidget-extra' : '' + ], + framed: true, + label: day.display, + tabIndex: -1 + } ); + this.buttons[ k ].connect( this, { click: [ 'onDayClick', this.buttons[ k ] ] } ); + } + this.buttons[ k ] + .setData( day.date ) + .setDisabled( day.date < this.min || day.date > this.max ); + $cell = this.buttons[ k ].$element; + $cell.toggleClass( 'mw-widgets-datetime-calendarWidget-focused', + this.formatter.datePartIsEqual( focusedDate, day.date ) ); + $cell.toggleClass( 'mw-widgets-datetime-calendarWidget-selected', + selected.some( isSelected, day.date ) ); + } + this.rows[ r ].append( $cell ); + } + } + + for ( c = 0; c < this.cols.length; c++ ) { + if ( nullCols[ c ] ) { + this.cols[ c ].width( 0 ); + } else { + this.cols[ c ].width( width + 'em' ); + } + this.cols[ c ].toggleClass( 'oo-ui-element-hidden', nullCols[ c ] ); + this.headings[ c ].toggleClass( 'oo-ui-element-hidden', nullCols[ c ] ); + } + }; + + /** + * Handles formatter 'local' flag changing + * + * @protected + */ + mw.widgets.datetime.CalendarWidget.prototype.onLocalChange = function () { + if ( this.formatter.localChangesDatePart( this.getFocusedDate() ) ) { + this.emit( 'page', this.getFocusedDate() ); + } + + this.updateUI(); + }; + + /** + * Handles previous button click + * + * @protected + */ + mw.widgets.datetime.CalendarWidget.prototype.onPrevClick = function () { + this.setFocusedDate( this.adjustDate( this.getFocusedDate(), 'month', -1 ) ); + if ( !this.$widget || OO.ui.contains( this.$element[ 0 ], document.activeElement, true ) ) { + this.$element.focus(); + } + }; + + /** + * Handles next button click + * + * @protected + */ + mw.widgets.datetime.CalendarWidget.prototype.onNextClick = function () { + this.setFocusedDate( this.adjustDate( this.getFocusedDate(), 'month', 1 ) ); + if ( !this.$widget || OO.ui.contains( this.$element[ 0 ], document.activeElement, true ) ) { + this.$element.focus(); + } + }; + + /** + * Handles day button click + * + * @protected + * @param {OO.ui.ButtonWidget} $button + */ + mw.widgets.datetime.CalendarWidget.prototype.onDayClick = function ( $button ) { + this.setFocusedDate( $button.getData() ); + this.setSelected( [ $button.getData() ] ); + if ( !this.$widget || OO.ui.contains( this.$element[ 0 ], document.activeElement, true ) ) { + this.$element.focus(); + } + }; + + /** + * Handles document mouse down events. + * + * @protected + * @param {jQuery.Event} e Mouse down event + */ + mw.widgets.datetime.CalendarWidget.prototype.onDocumentMouseDown = function ( e ) { + if ( this.$widget && + !OO.ui.contains( this.$element[ 0 ], e.target, true ) && + !OO.ui.contains( this.$widget[ 0 ], e.target, true ) + ) { + this.toggle( false ); + } + }; + + /** + * Handles key presses. + * + * @protected + * @param {jQuery.Event} e Key down event + */ + mw.widgets.datetime.CalendarWidget.prototype.onKeyDown = function ( e ) { + var focusedDate = this.getFocusedDate(); + + if ( !this.isDisabled() ) { + switch ( e.which ) { + case OO.ui.Keys.ENTER: + case OO.ui.Keys.SPACE: + this.setSelected( [ focusedDate ] ); + return false; + + case OO.ui.Keys.LEFT: + this.setFocusedDate( this.adjustDate( focusedDate, 'day', -1 ) ); + return false; + + case OO.ui.Keys.RIGHT: + this.setFocusedDate( this.adjustDate( focusedDate, 'day', 1 ) ); + return false; + + case OO.ui.Keys.UP: + this.setFocusedDate( this.adjustDate( focusedDate, 'week', -1 ) ); + return false; + + case OO.ui.Keys.DOWN: + this.setFocusedDate( this.adjustDate( focusedDate, 'week', 1 ) ); + return false; + + case OO.ui.Keys.PAGEUP: + this.setFocusedDate( this.adjustDate( focusedDate, 'month', -1 ) ); + return false; + + case OO.ui.Keys.PAGEDOWN: + this.setFocusedDate( this.adjustDate( focusedDate, 'month', 1 ) ); + return false; + } + } + }; + + /** + * Handles focusout events in dependent mode + * + * @private + */ + mw.widgets.datetime.CalendarWidget.prototype.onFocusOut = function () { + setTimeout( this.checkFocusHandler ); + }; + + /** + * When we or our widget lost focus, check if the calendar should be hidden. + * + * @private + */ + mw.widgets.datetime.CalendarWidget.prototype.checkFocus = function () { + var containers = [ this.$element[ 0 ], this.$widget[ 0 ] ], + activeElement = document.activeElement; + + if ( !activeElement || !OO.ui.contains( containers, activeElement, true ) ) { + this.toggle( false ); + } + }; + + /** + * @inheritdoc + */ + mw.widgets.datetime.CalendarWidget.prototype.toggle = function ( visible ) { + var change; + + visible = ( visible === undefined ? !this.visible : !!visible ); + change = visible !== this.isVisible(); + + // Parent method + mw.widgets.datetime.CalendarWidget[ 'super' ].prototype.toggle.call( this, visible ); + + if ( change ) { + if ( visible ) { + // Auto-hide + if ( this.$widget ) { + this.getElementDocument().addEventListener( + 'mousedown', this.onDocumentMouseDownHandler, true + ); + } + this.updateUI(); + } else { + this.getElementDocument().removeEventListener( + 'mousedown', this.onDocumentMouseDownHandler, true + ); + } + } + + return this; + }; + +}( jQuery, mediaWiki ) ); diff --git a/resources/src/mediawiki.widgets.datetime/CalendarWidget.less b/resources/src/mediawiki.widgets.datetime/CalendarWidget.less new file mode 100644 index 0000000000..a7beb0df55 --- /dev/null +++ b/resources/src/mediawiki.widgets.datetime/CalendarWidget.less @@ -0,0 +1,74 @@ +@import "mediawiki.widgets.datetime.definitions"; + +.mw-widgets-datetime-calendarWidget { + display: inline-block; + position: relative; + vertical-align: middle; + padding: .5em; + + &.mw-widgets-datetime-calendarWidget-dependent { + display: block; + position: absolute; + z-index: 4; + } + + &-grid { + table-layout: fixed; + + .mw-widgets-datetime-calendarWidget-cell { + display: table-cell; + white-space: nowrap; + } + } + + background-color: white; + border: 1px solid #ccc; + + &.mw-widgets-datetime-calendarWidget-dependent { + margin-top: -1px; + border-top: 1px solid white; + } + + &-heading { + text-align: center; + vertical-align: middle; + font-weight: bold; + white-space: nowrap; + + .mw-widgets-datetime-calendarWidget-previous { + float: left; + } + .mw-widgets-datetime-calendarWidget-next { + float: right; + } + } + + &-grid { + margin: 0 auto; + + .mw-widgets-datetime-calendarWidget-cell { + text-align: center; + + .oo-ui-buttonElement-button { + width: 100%; + border: 1px dotted rgba(255,255,255,0.0); + .oo-ui-box-sizing( border-box ); + } + + &.mw-widgets-datetime-calendarWidget-extra .oo-ui-buttonElement-button .oo-ui-labelElement-label { + color: #bbb; + } + + &.mw-widgets-datetime-calendarWidget-selected .oo-ui-buttonElement-button { + background-color: #def; + .oo-ui-labelElement-label { + color: #38f; + } + } + } + } + + &:focus &-grid &-cell&-focused .oo-ui-buttonElement-button { + border-color: rgba(0,0,0,0.3); + } +} diff --git a/resources/src/mediawiki.widgets.datetime/DateTimeFormatter.js b/resources/src/mediawiki.widgets.datetime/DateTimeFormatter.js new file mode 100644 index 0000000000..1c542341d3 --- /dev/null +++ b/resources/src/mediawiki.widgets.datetime/DateTimeFormatter.js @@ -0,0 +1,623 @@ +( function ( $, mw ) { + + /** + * Provides various methods needed for formatting dates and times. + * + * @class + * @abstract + * @mixins OO.EventEmitter + * + * @constructor + * @param {Object} [config] Configuration options + * @cfg {string} [format='@default'] May be a key from the {@link #static-formats static formats}, + * or a format specification as defined by {@link #method-parseFieldSpec parseFieldSpec} + * and {@link #method-getFieldForTag getFieldForTag}. + * @cfg {boolean} [local=false] Whether dates are local time or UTC + * @cfg {string[]} [fullZones] Time zone indicators. Array of 2 strings, for + * UTC and local time. + * @cfg {string[]} [shortZones] Abbreviated time zone indicators. Array of 2 + * strings, for UTC and local time. + * @cfg {Date} [defaultDate] Default date, for filling unspecified components. + * Defaults to the current date and time (with 0 milliseconds). + */ + mw.widgets.datetime.DateTimeFormatter = function MwWidgetsDatetimeDateTimeFormatter( config ) { + var statick = this.constructor[ 'static' ]; + + statick.setupDefaults(); + + config = $.extend( { + format: '@default', + local: false, + fullZones: statick.fullZones, + shortZones: statick.shortZones + }, config ); + + // Mixin constructors + OO.EventEmitter.call( this ); + + // Properties + if ( statick.formats[ config.format ] ) { + this.format = statick.formats[ config.format ]; + } else { + this.format = config.format; + } + this.local = !!config.local; + this.fullZones = config.fullZones; + this.shortZones = config.shortZones; + if ( config.defaultDate instanceof Date ) { + this.defaultDate = config.defaultDate; + } else { + this.defaultDate = new Date(); + if ( this.local ) { + this.defaultDate.setMilliseconds( 0 ); + } else { + this.defaultDate.setUTCMilliseconds( 0 ); + } + } + }; + + /* Setup */ + + OO.initClass( mw.widgets.datetime.DateTimeFormatter ); + OO.mixinClass( mw.widgets.datetime.DateTimeFormatter, OO.EventEmitter ); + + /* Static */ + + /** + * Default format specifications. See the {@link #format format} parameter. + * + * @static + * @inheritable + * @property {Object} + */ + mw.widgets.datetime.DateTimeFormatter[ 'static' ].formats = {}; + + /** + * Default time zone indicators + * + * @static + * @inheritable + * @property {string[]} + */ + mw.widgets.datetime.DateTimeFormatter[ 'static' ].fullZones = null; + + /** + * Default abbreviated time zone indicators + * + * @static + * @inheritable + * @property {string[]} + */ + mw.widgets.datetime.DateTimeFormatter[ 'static' ].shortZones = null; + + mw.widgets.datetime.DateTimeFormatter[ 'static' ].setupDefaults = function () { + if ( !this.fullZones ) { + this.fullZones = [ + mw.msg( 'timezone-utc' ), + mw.msg( 'timezone-local' ) + ]; + } + if ( !this.shortZones ) { + this.shortZones = [ + 'Z', + this.fullZones[ 1 ].substr( 0, 1 ).toUpperCase() + ]; + if ( this.shortZones[ 1 ] === 'Z' ) { + this.shortZones[ 1 ] = 'L'; + } + } + }; + + /* Events */ + + /** + * A `local` event is emitted when the 'local' flag is changed. + * + * @event local + */ + + /* Methods */ + + /** + * Whether dates are in local time or UTC + * + * @return {boolean} True if local time + */ + mw.widgets.datetime.DateTimeFormatter.prototype.getLocal = function () { + return this.local; + }; + + /** + * Toggle whether dates are in local time or UTC + * + * @param {boolean} [flag] Set the flag instead of toggling it + * @fires local + * @chainable + */ + mw.widgets.datetime.DateTimeFormatter.prototype.toggleLocal = function ( flag ) { + if ( flag === undefined ) { + flag = !this.local; + } else { + flag = !!flag; + } + if ( this.local !== flag ) { + this.local = flag; + this.emit( 'local', this.local ); + } + return this; + }; + + /** + * Get the default date + * + * @return {Date} + */ + mw.widgets.datetime.DateTimeFormatter.prototype.getDefaultDate = function () { + return new Date( this.defaultDate.getTime() ); + }; + + /** + * Fetch the field specification array for this object. + * + * See {@link #parseFieldSpec parseFieldSpec} for details on the return value structure. + * + * @return {Array} + */ + mw.widgets.datetime.DateTimeFormatter.prototype.getFieldSpec = function () { + return this.parseFieldSpec( this.format ); + }; + + /** + * Parse a format string into a field specification + * + * The input is a string containing tags formatted as ${tag|param|param...} + * (for editable fields) and $!{tag|param|param...} (for non-editable fields). + * Most tags are defined by {@link #getFieldForTag getFieldForTag}, but a few + * are defined here: + * - ${intercalary|X|text}: Text that is only displayed when the 'intercalary' + * component is X. + * - ${not-intercalary|X|text}: Text that is displayed unless the 'intercalary' + * component is X. + * + * Elements of the returned array are strings or objects. Strings are meant to + * be displayed as-is. Objects are as returned by {@link #getFieldForTag getFieldForTag}. + * + * @protected + * @param {string} format + * @return {Array} + */ + mw.widgets.datetime.DateTimeFormatter.prototype.parseFieldSpec = function ( format ) { + var m, last, tag, params, spec, + ret = [], + re = /(.*?)(\$(!?)\{([^}]+)\})/g; + + last = 0; + while ( ( m = re.exec( format ) ) !== null ) { + last = re.lastIndex; + + if ( m[ 1 ] !== '' ) { + ret.push( m[ 1 ] ); + } + + params = m[ 4 ].split( '|' ); + tag = params.shift(); + spec = this.getFieldForTag( tag, params ); + if ( spec ) { + if ( m[ 3 ] === '!' ) { + spec.editable = false; + } + ret.push( spec ); + } else { + ret.push( m[ 2 ] ); + } + } + if ( last < format.length ) { + ret.push( format.substr( last ) ); + } + + return ret; + }; + + /** + * Turn a tag into a field specification object + * + * Fields implemented here are: + * - ${intercalary|X|text}: Text that is only displayed when the 'intercalary' + * component is X. + * - ${not-intercalary|X|text}: Text that is displayed unless the 'intercalary' + * component is X. + * - ${zone|#}: Timezone offset, "+0000" format. + * - ${zone|:}: Timezone offset, "+00:00" format. + * - ${zone|short}: Timezone from 'shortZones' configuration setting. + * - ${zone|full}: Timezone from 'fullZones' configuration setting. + * + * @protected + * @abstract + * @param {string} tag + * @param {string[]} params + * @return {Object|null} Field specification object, or null if the tag+params are unrecognized. + * @return {string|null} return.component Date component corresponding to this field, if any. + * @return {boolean} return.editable Whether this field is editable. + * @return {string} return.type What kind of field this is: + * - 'static': The field is a static string; component will be null. + * - 'number': The field is generally numeric. + * - 'string': The field is generally textual. + * - 'boolean': The field is a boolean. + * - 'toggleLocal': The field represents {@link #getLocal this.getLocal()}. + * Editing should directly call {@link #toggleLocal this.toggleLocal()}. + * @return {number} return.size Maximum number of characters in the field (when + * the 'intercalary' component is falsey). If 0, the field should be hidden entirely. + * @return {Object.} return.intercalarySize Map from + * 'intercalary' component values to overridden sizes. + * @return {string} return.value For type='static', the string to display. + * @return {function(Mixed): string} return.formatValue A function to format a + * component value as a display string. + * @return {function(string): Mixed} return.parseValue A function to parse a + * display string into a component value. If parsing fails, returns undefined. + */ + mw.widgets.datetime.DateTimeFormatter.prototype.getFieldForTag = function ( tag, params ) { + var c, spec = null; + + switch ( tag ) { + case 'intercalary': + case 'not-intercalary': + if ( params.length < 2 || !params[ 0 ] ) { + return null; + } + spec = { + component: null, + editable: false, + type: 'static', + value: params.slice( 1 ).join( '|' ), + size: 0, + intercalarySize: {} + }; + if ( tag === 'intercalary' ) { + spec.intercalarySize[ params[ 0 ] ] = spec.value.length; + } else { + spec.size = spec.value.length; + spec.intercalarySize[ params[ 0 ] ] = 0; + } + return spec; + + case 'zone': + switch ( params[ 0 ] ) { + case '#': + case ':': + c = params[ 0 ] === '#' ? '' : ':'; + return { + component: 'zone', + editable: true, + type: 'toggleLocal', + size: 5 + c.length, + formatValue: function ( v ) { + var o, r; + if ( v ) { + o = new Date().getTimezoneOffset(); + r = String( Math.abs( o ) % 60 ); + while ( r.length < 2 ) { + r = '0' + r; + } + r = String( Math.floor( Math.abs( o ) / 60 ) ) + c + r; + while ( r.length < 4 + c.length ) { + r = '0' + r; + } + return ( o <= 0 ? '+' : '−' ) + r; + } else { + return '+00' + c + '00'; + } + }, + parseValue: function ( v ) { + var m; + v = String( v ).trim(); + if ( ( m = /^([+-−])([0-9]{1,2}):?([0-9]{2})$/.test( v ) ) ) { + return ( m[ 2 ] * 60 + m[ 3 ] ) * ( m[ 1 ] === '+' ? -1 : 1 ); + } else { + return undefined; + } + } + }; + + case 'short': + case 'full': + spec = { + component: 'zone', + editable: true, + type: 'toggleLocal', + values: params[ 0 ] === 'short' ? this.shortZones : this.fullZones, + formatValue: this.formatSpecValue, + parseValue: this.parseSpecValue + }; + spec.size = Math.max.apply( + null, $.map( spec.values, function ( v ) { return v.length; } ) + ); + return spec; + } + return null; + + default: + return null; + } + }; + + /** + * Format a value for a field specification + * + * 'this' must be the field specification object. The intention is that you + * could just assign this function as the 'formatValue' for each field spec. + * + * Besides the publicly-documented fields, uses the following: + * - values: Enumerated values for the field + * - zeropad: Whether to pad the number with zeros. + * + * @protected + * @param {Mixed} v + * @return {string} + */ + mw.widgets.datetime.DateTimeFormatter.prototype.formatSpecValue = function ( v ) { + if ( v === undefined || v === null ) { + return ''; + } + + if ( typeof v === 'boolean' || this.type === 'toggleLocal' ) { + v = v ? 1 : 0; + } + + if ( this.values ) { + return this.values[ v ]; + } + + v = String( v ); + if ( this.zeropad ) { + while ( v.length < this.size ) { + v = '0' + v; + } + } + return v; + }; + + /** + * Parse a value for a field specification + * + * 'this' must be the field specification object. The intention is that you + * could just assign this function as the 'parseValue' for each field spec. + * + * Besides the publicly-documented fields, uses the following: + * - values: Enumerated values for the field + * + * @protected + * @param {string} v + * @return {number|string|null} + */ + mw.widgets.datetime.DateTimeFormatter.prototype.parseSpecValue = function ( v ) { + var k, re; + + if ( v === '' ) { + return null; + } + + if ( !this.values ) { + v = +v; + if ( this.type === 'boolean' || this.type === 'toggleLocal' ) { + return isNaN( v ) ? undefined : !!v; + } else { + return isNaN( v ) ? undefined : v; + } + } + + if ( v.normalize ) { + v = v.normalize(); + } + re = new RegExp( '^\\s*' + v.replace( /([\\{}()|.?*+\-\^$\[\]])/g, '\\$1' ), 'i' ); + for ( k in this.values ) { + k = +k; + if ( !isNaN( k ) && re.test( this.values[ k ] ) ) { + if ( this.type === 'boolean' || this.type === 'toggleLocal' ) { + return !!k; + } else { + return k; + } + } + } + return undefined; + }; + + /** + * Get components from a Date object + * + * Most specific components are defined by the subclass. "Global" components + * are: + * - intercalary: {string} Non-falsey values are used to indicate intercalary days. + * - zone: {number} Timezone offset in minutes. + * + * @abstract + * @param {Date|null} date + * @return {Object} Components + */ + mw.widgets.datetime.DateTimeFormatter.prototype.getComponentsFromDate = function ( date ) { + // Should be overridden by subclass + return { + zone: this.local ? date.getTimezoneOffset() : 0 + }; + }; + + /** + * Get a Date object from components + * + * @param {Object} components Date components + * @return {Date} + */ + mw.widgets.datetime.DateTimeFormatter.prototype.getDateFromComponents = function ( /* components */ ) { + // Should be overridden by subclass + return new Date(); + }; + + /** + * Adjust a date + * + * @param {Date|null} date To be adjusted + * @param {string} component To adjust + * @param {number} delta Adjustment amount + * @param {string} mode Adjustment mode: + * - 'overflow': "Jan 32" => "Feb 1", "Jan 33" => "Feb 2", "Feb 0" => "Jan 31", etc. + * - 'wrap': "Jan 32" => "Jan 1", "Jan 33" => "Jan 2", "Jan 0" => "Jan 31", etc. + * - 'clip': "Jan 32" => "Jan 31", "Feb 32" => "Feb 28" (or 29), "Feb 0" => "Feb 1", etc. + * @return {Date} Adjusted date + */ + mw.widgets.datetime.DateTimeFormatter.prototype.adjustComponent = function ( date /*, component, delta, mode */ ) { + // Should be overridden by subclass + return date; + }; + + /** + * Get the column headings (weekday abbreviations) for a calendar grid + * + * Null-valued columns are hidden if getCalendarData() returns no "day" object + * for all days in that column. + * + * @abstract + * @return {Array} string or null + */ + mw.widgets.datetime.DateTimeFormatter.prototype.getCalendarHeadings = function () { + // Should be overridden by subclass + return []; + }; + + /** + * Test whether two dates are in the same calendar grid + * + * @abstract + * @param {Date} date1 + * @param {Date} date2 + * @return {boolean} + */ + mw.widgets.datetime.DateTimeFormatter.prototype.sameCalendarGrid = function ( date1, date2 ) { + // Should be overridden by subclass + return date1.getTime() === date2.getTime(); + }; + + /** + * Test whether the date parts of two Dates are equal + * + * @param {Date} date1 + * @param {Date} date2 + * @return {boolean} + */ + mw.widgets.datetime.DateTimeFormatter.prototype.datePartIsEqual = function ( date1, date2 ) { + if ( this.local ) { + return ( + date1.getFullYear() === date2.getFullYear() && + date1.getMonth() === date2.getMonth() && + date1.getDate() === date2.getDate() + ); + } else { + return ( + date1.getUTCFullYear() === date2.getUTCFullYear() && + date1.getUTCMonth() === date2.getUTCMonth() && + date1.getUTCDate() === date2.getUTCDate() + ); + } + }; + + /** + * Test whether the time parts of two Dates are equal + * + * @param {Date} date1 + * @param {Date} date2 + * @return {boolean} + */ + mw.widgets.datetime.DateTimeFormatter.prototype.timePartIsEqual = function ( date1, date2 ) { + if ( this.local ) { + return ( + date1.getHours() === date2.getHours() && + date1.getMinutes() === date2.getMinutes() && + date1.getSeconds() === date2.getSeconds() && + date1.getMilliseconds() === date2.getMilliseconds() + ); + } else { + return ( + date1.getUTCHours() === date2.getUTCHours() && + date1.getUTCMinutes() === date2.getUTCMinutes() && + date1.getUTCSeconds() === date2.getUTCSeconds() && + date1.getUTCMilliseconds() === date2.getUTCMilliseconds() + ); + } + }; + + /** + * Test whether toggleLocal() changes the date part + * + * @param {Date} date + * @return {boolean} + */ + mw.widgets.datetime.DateTimeFormatter.prototype.localChangesDatePart = function ( date ) { + return ( + date.getUTCFullYear() !== date.getFullYear() || + date.getUTCMonth() !== date.getMonth() || + date.getUTCDate() !== date.getDate() + ); + }; + + /** + * Create a new Date by merging the date part from one with the time part from + * another. + * + * @param {Date} datepart + * @param {Date} timepart + * @return {Date} + */ + mw.widgets.datetime.DateTimeFormatter.prototype.mergeDateAndTime = function ( datepart, timepart ) { + var ret = new Date( datepart.getTime() ); + + if ( this.local ) { + ret.setHours( + timepart.getHours(), + timepart.getMinutes(), + timepart.getSeconds(), + timepart.getMilliseconds() + ); + } else { + ret.setUTCHours( + timepart.getUTCHours(), + timepart.getUTCMinutes(), + timepart.getUTCSeconds(), + timepart.getUTCMilliseconds() + ); + } + + return ret; + }; + + /** + * Get data for a calendar grid + * + * A "day" object is: + * - display: {string} Display text for the day. + * - date: {Date} Date to use when the day is selected. + * - extra: {string|null} 'prev' or 'next' on days used to fill out the weeks + * at the start and end of the month. + * + * In any one result object, 'extra' + 'display' will always be unique. + * + * @abstract + * @param {Date|null} current Current date + * @return {Object} Data + * @return {string} return.header String to display as the calendar header + * @return {string} return.monthComponent Component to adjust by ±1 to change months. + * @return {string} return.dayComponent Component to adjust by ±1 to change days. + * @return {string} [return.weekComponent] Component to adjust by ±1 to change + * weeks. If omitted, the dayComponent should be adjusted by ±the number of + * non-nullable columns returned by this.getCalendarHeadings() to change weeks. + * @return {Array} return.rows Array of arrays of "day" objects or null/undefined. + */ + mw.widgets.datetime.DateTimeFormatter.prototype.getCalendarData = function ( /* components */ ) { + // Should be overridden by subclass + return { + header: '', + monthComponent: 'month', + dayComponent: 'day', + rows: [] + }; + }; + +}( jQuery, mediaWiki ) ); diff --git a/resources/src/mediawiki.widgets.datetime/DateTimeInputWidget.js b/resources/src/mediawiki.widgets.datetime/DateTimeInputWidget.js new file mode 100644 index 0000000000..df148c7403 --- /dev/null +++ b/resources/src/mediawiki.widgets.datetime/DateTimeInputWidget.js @@ -0,0 +1,812 @@ +( function ( $, mw ) { + + /** + * DateTimeInputWidgets can be used to input a date, a time, or a date and + * time, in either UTC or the user's local timezone. + * Please see the [OOjs UI documentation on MediaWiki] [1] for more information and examples. + * + * This widget can be used inside a HTML form, such as a OO.ui.FormLayout. + * + * @example + * // Example of a text input widget + * var dateTimeInput = new mw.widgets.datetime.DateTimeInputWidget( {} ) + * $( 'body' ).append( dateTimeInput.$element ); + * + * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Inputs + * + * @class + * @extends OO.ui.InputWidget + * @mixins OO.ui.mixin.IconElement + * @mixins OO.ui.mixin.IndicatorElement + * @mixins OO.ui.mixin.PendingElement + * + * @constructor + * @param {Object} [config] Configuration options + * @cfg {string} [type='datetime'] Whether to act like a 'date', 'time', or 'datetime' input. + * Affects values stored in the relevant 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 = $( '' ); + this.$fields = $( '' ); + 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' ) { + $( '' ) + .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 = $( '' ) + .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 = $( '' ) + .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 = $( '' ) + .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 $( '' ); + }; + + /** + * @inheritdoc + */ + mw.widgets.datetime.DateTimeInputWidget.prototype.setDisabled = function ( disabled ) { + mw.widgets.datetime.DateTimeInputWidget[ 'super' ].prototype.setDisabled.call( this, disabled ); + + // Flag all our fields as disabled + if ( this.$fields ) { + this.$fields.find( 'input' ).prop( 'disabled', this.isDisabled() ); + this.$fields.find( '[tabindex]' ).attr( 'tabindex', this.isDisabled() ? -1 : 0 ); + } + + if ( this.clearButton ) { + this.clearButton.setDisabled( disabled ); + } + + return this; + }; + + /** + * @inheritdoc + */ + mw.widgets.datetime.DateTimeInputWidget.prototype.focus = function () { + if ( !this.$fields.find( document.activeElement ).length ) { + this.$fields.find( '.mw-widgets-datetime-dateTimeInputWidget-editField' ).first().focus(); + } + return this; + }; + + /** + * @inheritdoc + */ + mw.widgets.datetime.DateTimeInputWidget.prototype.blur = function () { + this.$fields.find( document.activeElement ).blur(); + return this; + }; + + /** + * @inheritdoc + */ + mw.widgets.datetime.DateTimeInputWidget.prototype.simulateLabelClick = function () { + this.focus(); + }; + +}( jQuery, mediaWiki ) ); diff --git a/resources/src/mediawiki.widgets.datetime/DateTimeInputWidget.less b/resources/src/mediawiki.widgets.datetime/DateTimeInputWidget.less new file mode 100644 index 0000000000..bc387df97f --- /dev/null +++ b/resources/src/mediawiki.widgets.datetime/DateTimeInputWidget.less @@ -0,0 +1,155 @@ +@import "mediawiki.widgets.datetime.definitions"; + +.mw-widgets-datetime-dateTimeInputWidget { + display: inline-block; + position: relative; + vertical-align: middle; + + &-fields { + position: relative; + display: table; + z-index: 2; + .oo-ui-unselectable(); + + > .mw-widgets-datetime-dateTimeInputWidget-field { + .oo-ui-box-sizing(border-box); + + display: table-cell; + white-space: pre; + } + } + + &-handle { + width: 100%; + display: inline-block; + overflow: hidden; + + // Needed for proper behavior with overflow: hidden. + vertical-align: bottom; + + .oo-ui-unselectable(); + .oo-ui-box-sizing(border-box); + + > .oo-ui-indicatorElement-indicator, + > .oo-ui-iconElement-icon { + position: absolute; + background-position: center center; + background-repeat: no-repeat; + z-index: 1; + } + } + + margin: 0.25em 0; + width: 100%; + max-width: 50em; + + .oo-ui-inline-spacing(0.5em); + + &-handle { + height: 2.5em; + border: 1px solid #ccc; + padding: 0 1em; + margin: 0; + background-color: #fff; + color: black; + border: solid 1px #ccc; + box-shadow: inset 0 0 0 0 @progressive; + border-radius: 0.1em; + .oo-ui-transition(box-shadow @quick-ease); + .oo-ui-box-sizing(border-box); + + > .oo-ui-indicatorElement-indicator { + right: 0; + } + + > .oo-ui-iconElement-icon { + left: 0.25em; + } + + > .oo-ui-indicatorElement-indicator { + top: 0; + width: @indicator-size; + height: @indicator-size; + margin: 0.775em; + } + + > .oo-ui-iconElement-icon { + top: 0; + width: @icon-size; + height: @icon-size; + margin: 0.3em; + } + } + + &-empty &-handle { + color: #777; + } + + &-field { + padding: 0; + margin: 0; + font-size: inherit; + font-family: inherit; + background-color: transparent; + color: inherit; + border: none; + box-shadow: none; + text-align: center; + vertical-align: middle; + .oo-ui-box-sizing(border-box); + } + + &.oo-ui-widget-disabled { + .mw-widgets-datetime-dateTimeInputWidget-handle { + color: #ccc; + text-shadow: 0 1px 1px #fff; + border-color: #ddd; + background-color: #f3f3f3; + + > .oo-ui-iconElement-icon, + > .oo-ui-indicatorElement-indicator { + opacity: 0.2; + } + } + } + + &.oo-ui-widget-enabled { + .mw-widgets-datetime-dateTimeInputWidget-editField:hover { + background-color: #eee; + } + + &.oo-ui-flaggedElement-invalid { + .mw-widgets-datetime-dateTimeInputWidget-handle { + border-color: red; + box-shadow: inset 0 0 0 0 red; + } + + .mw-widgets-datetime-dateTimeInputWidget-handle:focus { + border-color: red; + box-shadow: inset 0 0 0 0.1em red; + } + } + } + + input.mw-widgets-datetime-dateTimeInputWidget-field { + padding: 0.5em 0; + } + + &-editField.mw-widgets-datetime-dateTimeInputWidget-invalid { + border: 1px solid red; + box-shadow: inset 0 0 0 0 red; + + &:focus { + border: 1px solid red; + box-shadow: inset 0 0 0 0.1em red; + } + } + + &.oo-ui-iconElement .mw-widgets-datetime-dateTimeInputWidget-handle { + padding-left: 3em; + } + + &.oo-ui-indicatorElement .mw-widgets-datetime-dateTimeInputWidget-handle { + padding-right: 2em; + } +} diff --git a/resources/src/mediawiki.widgets.datetime/DiscordianDateTimeFormatter.js b/resources/src/mediawiki.widgets.datetime/DiscordianDateTimeFormatter.js new file mode 100644 index 0000000000..fbf323844b --- /dev/null +++ b/resources/src/mediawiki.widgets.datetime/DiscordianDateTimeFormatter.js @@ -0,0 +1,562 @@ +( function ( $, mw ) { + + /** + * Provides various methods needed for formatting dates and times. This + * implementation implments the [Discordian calendar][1], mainly for testing with + * something very different from the usual Gregorian calendar. + * + * Being intended mainly for testing, niceties like i18n and better + * configurability have been omitted. + * + * [1]: https://en.wikipedia.org/wiki/Discordian_calendar + * + * @class + * @extends mw.widgets.datetime.DateTimeFormatter + * + * @constructor + * @param {Object} [config] Configuration options + */ + mw.widgets.datetime.DiscordianDateTimeFormatter = function MwWidgetsDatetimeDiscordianDateTimeFormatter( config ) { + config = $.extend( {}, config ); + + // Parent constructor + mw.widgets.datetime.DiscordianDateTimeFormatter[ 'super' ].call( this, config ); + }; + + /* Setup */ + + OO.inheritClass( mw.widgets.datetime.DiscordianDateTimeFormatter, mw.widgets.datetime.DateTimeFormatter ); + + /* Static */ + + /** + * @inheritdoc + */ + mw.widgets.datetime.DiscordianDateTimeFormatter[ 'static' ].formats = { + '@time': '${hour|0}:${minute|0}:${second|0}', + '@date': '$!{dow|full}${not-intercalary|1|, }${season|full}${not-intercalary|1| }${day|#}, ${year|#}', + '@datetime': '$!{dow|full}${not-intercalary|1|, }${season|full}${not-intercalary|1| }${day|#}, ${year|#} ${hour|0}:${minute|0}:${second|0} $!{zone|short}', + '@default': '$!{dow|full}${not-intercalary|1|, }${season|full}${not-intercalary|1| }${day|#}, ${year|#} ${hour|0}:${minute|0}:${second|0} $!{zone|short}' + }; + + /* Methods */ + + /** + * @inheritdoc + * + * Additional fields implemented here are: + * - ${year|#}: Year as a number + * - ${season|#}: Season as a number + * - ${season|full}: Season as a string + * - ${day|#}: Day of the month as a number + * - ${day|0}: Day of the month as a number with leading 0 + * - ${dow|full}: Day of the week as a string + * - ${hour|#}: Hour as a number + * - ${hour|0}: Hour as a number with leading 0 + * - ${minute|#}: Minute as a number + * - ${minute|0}: Minute as a number with leading 0 + * - ${second|#}: Second as a number + * - ${second|0}: Second as a number with leading 0 + * - ${millisecond|#}: Millisecond as a number + * - ${millisecond|0}: Millisecond as a number, zero-padded to 3 digits + */ + mw.widgets.datetime.DiscordianDateTimeFormatter.prototype.getFieldForTag = function ( tag, params ) { + var spec = null; + + switch ( tag + '|' + params[ 0 ] ) { + case 'year|#': + spec = { + component: 'Year', + type: 'number', + size: 4, + zeropad: false + }; + break; + + case 'season|#': + spec = { + component: 'Season', + type: 'number', + size: 1, + intercalarySize: { 1: 0 }, + zeropad: false + }; + break; + + case 'season|full': + spec = { + component: 'Season', + type: 'string', + intercalarySize: { 1: 0 }, + values: { + 1: 'Chaos', + 2: 'Discord', + 3: 'Confusion', + 4: 'Bureaucracy', + 5: 'The Aftermath' + } + }; + break; + + case 'dow|full': + spec = { + component: 'DOW', + editable: false, + type: 'string', + intercalarySize: { 1: 0 }, + values: { + '-1': 'N/A', + 0: 'Sweetmorn', + 1: 'Boomtime', + 2: 'Pungenday', + 3: 'Prickle-Prickle', + 4: 'Setting Orange' + } + }; + break; + + case 'day|#': + case 'day|0': + spec = { + component: 'Day', + type: 'string', + size: 2, + intercalarySize: { 1: 13 }, + zeropad: params[ 0 ] === '0', + formatValue: function ( v ) { + if ( v === 'tib' ) { + return 'St. Tib\'s Day'; + } + return mw.widgets.datetime.DateTimeFormatter.prototype.formatSpecValue.call( this, v ); + }, + parseValue: function ( v ) { + if ( /^\s*(st.?\s*)?tib('?s)?(\s*day)?\s*$/i.test( v ) ) { + return 'tib'; + } + return mw.widgets.datetime.DateTimeFormatter.prototype.parseSpecValue.call( this, v ); + } + }; + break; + + case 'hour|#': + case 'hour|0': + case 'minute|#': + case 'minute|0': + case 'second|#': + case 'second|0': + spec = { + component: tag.charAt( 0 ).toUpperCase() + tag.slice( 1 ), + type: 'number', + size: 2, + zeropad: params[ 0 ] === '0' + }; + break; + + case 'millisecond|#': + case 'millisecond|0': + spec = { + component: 'Millisecond', + type: 'number', + size: 3, + zeropad: params[ 0 ] === '0' + }; + break; + + default: + return mw.widgets.datetime.DiscordianDateTimeFormatter[ 'super' ].prototype.getFieldForTag.call( this, tag, params ); + } + + if ( spec ) { + if ( spec.editable === undefined ) { + spec.editable = true; + } + if ( spec.component !== 'Day' ) { + spec.formatValue = this.formatSpecValue; + spec.parseValue = this.parseSpecValue; + } + if ( spec.values ) { + spec.size = Math.max.apply( + null, $.map( spec.values, function ( v ) { return v.length; } ) + ); + } + } + + return spec; + }; + + /** + * Get components from a Date object + * + * Components are: + * - Year {number} + * - Season {number} 1-5 + * - Day {number|string} 1-73 or 'tib' + * - DOW {number} 0-4, or -1 on St. Tib's Day + * - Hour {number} 0-23 + * - Minute {number} 0-59 + * - Second {number} 0-59 + * - Millisecond {number} 0-999 + * - intercalary {string} '1' on St. Tib's Day + * + * @param {Date|null} date + * @return {Object} Components + */ + mw.widgets.datetime.DiscordianDateTimeFormatter.prototype.getComponentsFromDate = function ( date ) { + var ret, day, month, + monthDays = [ 0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 304, 334 ]; + + if ( !( date instanceof Date ) ) { + date = this.defaultDate; + } + + if ( this.local ) { + day = date.getDate(); + month = date.getMonth(); + ret = { + Year: date.getFullYear() + 1166, + Hour: date.getHours(), + Minute: date.getMinutes(), + Second: date.getSeconds(), + Millisecond: date.getMilliseconds(), + zone: date.getTimezoneOffset() + }; + } else { + day = date.getUTCDate(); + month = date.getUTCMonth(); + ret = { + Year: date.getUTCFullYear() + 1166, + Hour: date.getUTCHours(), + Minute: date.getUTCMinutes(), + Second: date.getUTCSeconds(), + Millisecond: date.getUTCMilliseconds(), + zone: 0 + }; + } + + if ( month === 1 && day === 29 ) { + ret.Season = 1; + ret.Day = 'tib'; + ret.DOW = -1; + ret.intercalary = '1'; + } else { + day = monthDays[ month ] + day - 1; + ret.Season = Math.floor( day / 73 ) + 1; + ret.Day = ( day % 73 ) + 1; + ret.DOW = day % 5; + ret.intercalary = ''; + } + + return ret; + }; + + /** + * @inheritdoc + */ + mw.widgets.datetime.DiscordianDateTimeFormatter.prototype.adjustComponent = function ( date, component, delta, mode ) { + return this.getDateFromComponents( + this.adjustComponentInternal( + this.getComponentsFromDate( date ), component, delta, mode + ) + ); + }; + + /** + * Adjust the components directly + * + * @private + * @param {Object} components Modified in place + * @param {string} component + * @param {number} delta + * @param {string} mode + * @return {Object} components + */ + mw.widgets.datetime.DiscordianDateTimeFormatter.prototype.adjustComponentInternal = function ( components, component, delta, mode ) { + var i, min, max, range, next, preTib, postTib, wasTib; + + if ( delta === 0 ) { + return components; + } + + switch ( component ) { + case 'Year': + min = 1166; + max = 11165; + next = null; + break; + case 'Season': + min = 1; + max = 5; + next = 'Year'; + break; + case 'Week': + if ( components.Day === 'tib' ) { + components.Day = 59; // Could choose either one... + components.Season = 1; + } + min = 1; + max = 73; + next = 'Season'; + break; + case 'Day': + min = 1; + max = 73; + next = 'Season'; + break; + case 'Hour': + min = 0; + max = 23; + next = 'Day'; + break; + case 'Minute': + min = 0; + max = 59; + next = 'Hour'; + break; + case 'Second': + min = 0; + max = 59; + next = 'Minute'; + break; + case 'Millisecond': + min = 0; + max = 999; + next = 'Second'; + break; + default: + return components; + } + + switch ( mode ) { + case 'overflow': + case 'clip': + case 'wrap': + } + + if ( component === 'Day' ) { + i = Math.abs( delta ); + delta = delta < 0 ? -1 : 1; + preTib = delta > 0 ? 59 : 60; + postTib = delta > 0 ? 60 : 59; + while ( i-- > 0 ) { + if ( components.Day === preTib && components.Season === 1 && this.isLeapYear( components.Year ) ) { + components.Day = 'tib'; + } else if ( components.Day === 'tib' ) { + components.Day = postTib; + components.Season = 1; + } else { + components.Day += delta; + if ( components.Day < min ) { + switch ( mode ) { + case 'overflow': + components.Day = max; + this.adjustComponentInternal( components, 'Season', -1, mode ); + break; + case 'wrap': + components.Day = max; + break; + case 'clip': + components.Day = min; + i = 0; + break; + } + } + if ( components.Day > max ) { + switch ( mode ) { + case 'overflow': + components.Day = min; + this.adjustComponentInternal( components, 'Season', 1, mode ); + break; + case 'wrap': + components.Day = min; + break; + case 'clip': + components.Day = max; + i = 0; + break; + } + } + } + } + } else { + if ( component === 'Week' ) { + component = 'Day'; + delta *= 5; + } + if ( components.Day === 'tib' ) { + // For sanity + components.Season = 1; + } + switch ( mode ) { + case 'overflow': + if ( components.Day === 'tib' && ( component === 'Season' || component === 'Year' ) ) { + components.Day = 59; // Could choose either one... + wasTib = true; + } else { + wasTib = false; + } + i = Math.abs( delta ); + delta = delta < 0 ? -1 : 1; + while ( i-- > 0 ) { + components[ component ] += delta; + if ( components[ component ] < min ) { + components[ component ] = max; + components = this.adjustComponentInternal( components, next, -1, mode ); + } + if ( components[ component ] > max ) { + components[ component ] = min; + components = this.adjustComponentInternal( components, next, 1, mode ); + } + } + if ( wasTib && components.Season === 1 && this.isLeapYear( components.Year ) ) { + components.Day = 'tib'; + } + break; + case 'wrap': + range = max - min + 1; + components[ component ] += delta; + while ( components[ component ] < min ) { + components[ component ] += range; + } + while ( components[ component ] > max ) { + components[ component ] -= range; + } + break; + case 'clip': + components[ component ] += delta; + if ( components[ component ] < min ) { + components[ component ] = min; + } + if ( components[ component ] > max ) { + components[ component ] = max; + } + break; + } + if ( components.Day === 'tib' && + ( components.Season !== 1 || !this.isLeapYear( components.Year ) ) + ) { + components.Day = 59; // Could choose either one... + } + } + + return components; + }; + + /** + * @inheritdoc + */ + mw.widgets.datetime.DiscordianDateTimeFormatter.prototype.getDateFromComponents = function ( components ) { + var month, day, days, + date = new Date(), + monthDays = [ 0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 304, 334, 365 ]; + + components = $.extend( {}, this.getComponentsFromDate( null ), components ); + if ( components.Day === 'tib' ) { + month = 1; + day = 29; + } else { + days = components.Season * 73 + components.Day - 74; + month = 0; + while ( days >= monthDays[ month + 1 ] ) { + month++; + } + day = days - monthDays[ month ] + 1; + } + + if ( components.zone ) { + // Can't just use the constructor because that's stupid about ancient years. + date.setFullYear( components.Year - 1166, month, day ); + date.setHours( components.Hour, components.Minute, components.Second, components.Millisecond ); + } else { + // Date.UTC() is stupid about ancient years too. + date.setUTCFullYear( components.Year - 1166, month, day ); + date.setUTCHours( components.Hour, components.Minute, components.Second, components.Millisecond ); + } + + return date; + }; + + /** + * Get whether the year is a leap year + * + * @private + * @param {number} year + * @return {boolean} + */ + mw.widgets.datetime.DiscordianDateTimeFormatter.prototype.isLeapYear = function ( year ) { + year -= 1166; + if ( year % 4 ) { + return false; + } else if ( year % 100 ) { + return true; + } + return ( year % 400 ) === 0; + }; + + /** + * @inheritdoc + */ + mw.widgets.datetime.DiscordianDateTimeFormatter.prototype.getCalendarHeadings = function () { + return [ 'SM', 'BT', 'PD', 'PP', null, 'SO' ]; + }; + + /** + * @inheritdoc + */ + mw.widgets.datetime.DiscordianDateTimeFormatter.prototype.sameCalendarGrid = function ( date1, date2 ) { + var components1 = this.getComponentsFromDate( date1 ), + components2 = this.getComponentsFromDate( date2 ); + + return components1.Year === components2.Year && components1.Season === components2.Season; + }; + + /** + * @inheritdoc + */ + mw.widgets.datetime.DiscordianDateTimeFormatter.prototype.getCalendarData = function ( date ) { + var dt, components, season, i, row, + ret = { + dayComponent: 'Day', + weekComponent: 'Week', + monthComponent: 'Season' + }, + seasons = [ 'Chaos', 'Discord', 'Confusion', 'Bureaucracy', 'The Aftermath' ], + seasonStart = [ 0, -3, -1, -4, -2 ]; + + if ( !( date instanceof Date ) ) { + date = this.defaultDate; + } + + components = this.getComponentsFromDate( date ); + components.Day = 1; + season = components.Season; + + ret.header = seasons[ season - 1 ] + ' ' + components.Year; + + if ( seasonStart[ season - 1 ] ) { + this.adjustComponentInternal( components, 'Day', seasonStart[ season - 1 ], 'overflow' ); + } + + ret.rows = []; + do { + row = []; + for ( i = 0; i < 6; i++ ) { + dt = this.getDateFromComponents( components ); + row[ i ] = { + display: components.Day === 'tib' ? 'Tib' : String( components.Day ), + date: dt, + extra: components.Season < season ? 'prev' : components.Season > season ? 'next' : null + }; + + this.adjustComponentInternal( components, 'Day', 1, 'overflow' ); + if ( components.Day !== 'tib' && i === 3 ) { + row[ ++i ] = null; + } + } + + ret.rows.push( row ); + } while ( components.Season === season ); + + return ret; + }; + +}( jQuery, mediaWiki ) ); diff --git a/resources/src/mediawiki.widgets.datetime/ProlepticGregorianDateTimeFormatter.js b/resources/src/mediawiki.widgets.datetime/ProlepticGregorianDateTimeFormatter.js new file mode 100644 index 0000000000..f60b34bdcc --- /dev/null +++ b/resources/src/mediawiki.widgets.datetime/ProlepticGregorianDateTimeFormatter.js @@ -0,0 +1,661 @@ +( function ( $, mw ) { + + /** + * Provides various methods needed for formatting dates and times. This + * implementation implments the proleptic Gregorian calendar over years + * 0000–9999. + * + * @class + * @extends mw.widgets.datetime.DateTimeFormatter + * + * @constructor + * @param {Object} [config] Configuration options + * @cfg {Object} [fullMonthNames] Mapping 1–12 to full month names. + * @cfg {Object} [shortMonthNames] Mapping 1–12 to abbreviated month names. + * If {@link #fullMonthNames fullMonthNames} is given and this is not, + * defaults to the first three characters from that setting. + * @cfg {Object} [fullDayNames] Mapping 0–6 to full day of week names. 0 is Sunday, 6 is Saturday. + * @cfg {Object} [shortDayNames] Mapping 0–6 to abbreviated day of week names. 0 is Sunday, 6 is Saturday. + * If {@link #fullDayNames fullDayNames} is given and this is not, defaults to + * the first three characters from that setting. + * @cfg {string[]} [dayLetters] Weekday column headers for a calendar. Array of 7 strings. + * If {@link #fullDayNames fullDayNames} or {@link #shortDayNames shortDayNames} + * are given and this is not, defaults to the first character from + * shortDayNames. + * @cfg {string[]} [hour12Periods] AM and PM texts. Array of 2 strings, AM and PM. + * @cfg {number} [weekStartsOn=0] What day the week starts on: 0 is Sunday, 1 is Monday, 6 is Saturday. + */ + mw.widgets.datetime.ProlepticGregorianDateTimeFormatter = function MwWidgetsDatetimeProlepticGregorianDateTimeFormatter( config ) { + var statick = this.constructor[ 'static' ]; + + statick.setupDefaults(); + + config = $.extend( { + weekStartsOn: 0, + hour12Periods: statick.hour12Periods + }, config ); + + if ( config.fullMonthNames && !config.shortMonthNames ) { + config.shortMonthNames = {}; + $.each( config.fullMonthNames, function ( k, v ) { + config.shortMonthNames[ k ] = v.substr( 0, 3 ); + }.bind( this ) ); + } + if ( config.shortDayNames && !config.dayLetters ) { + config.dayLetters = []; + $.each( config.shortDayNames, function ( k, v ) { + config.dayLetters[ k ] = v.substr( 0, 1 ); + }.bind( this ) ); + } + if ( config.fullDayNames && !config.dayLetters ) { + config.dayLetters = []; + $.each( config.fullDayNames, function ( k, v ) { + config.dayLetters[ k ] = v.substr( 0, 1 ); + }.bind( this ) ); + } + if ( config.fullDayNames && !config.shortDayNames ) { + config.shortDayNames = {}; + $.each( config.fullDayNames, function ( k, v ) { + config.shortDayNames[ k ] = v.substr( 0, 3 ); + }.bind( this ) ); + } + config = $.extend( { + fullMonthNames: statick.fullMonthNames, + shortMonthNames: statick.shortMonthNames, + fullDayNames: statick.fullDayNames, + shortDayNames: statick.shortDayNames, + dayLetters: statick.dayLetters + }, config ); + + // Parent constructor + mw.widgets.datetime.ProlepticGregorianDateTimeFormatter[ 'super' ].call( this, config ); + + // Properties + this.weekStartsOn = config.weekStartsOn % 7; + this.fullMonthNames = config.fullMonthNames; + this.shortMonthNames = config.shortMonthNames; + this.fullDayNames = config.fullDayNames; + this.shortDayNames = config.shortDayNames; + this.dayLetters = config.dayLetters; + this.hour12Periods = config.hour12Periods; + }; + + /* Setup */ + + OO.inheritClass( mw.widgets.datetime.ProlepticGregorianDateTimeFormatter, mw.widgets.datetime.DateTimeFormatter ); + + /* Static */ + + /** + * @inheritdoc + */ + mw.widgets.datetime.ProlepticGregorianDateTimeFormatter[ 'static' ].formats = { + '@time': '${hour|0}:${minute|0}:${second|0}', + '@date': '$!{dow|short} ${day|#} ${month|short} ${year|#}', + '@datetime': '$!{dow|short} ${day|#} ${month|short} ${year|#} ${hour|0}:${minute|0}:${second|0} $!{zone|short}', + '@default': '$!{dow|short} ${day|#} ${month|short} ${year|#} ${hour|0}:${minute|0}:${second|0} $!{zone|short}' + }; + + /** + * Default full month names. + * + * @static + * @inheritable + * @property {Object} + */ + mw.widgets.datetime.ProlepticGregorianDateTimeFormatter[ 'static' ].fullMonthNames = null; + + /** + * Default abbreviated month names. + * + * @static + * @inheritable + * @property {Object} + */ + mw.widgets.datetime.ProlepticGregorianDateTimeFormatter[ 'static' ].shortMonthNames = null; + + /** + * Default full day of week names. + * + * @static + * @inheritable + * @property {Object} + */ + mw.widgets.datetime.ProlepticGregorianDateTimeFormatter[ 'static' ].fullDayNames = null; + + /** + * Default abbreviated day of week names. + * + * @static + * @inheritable + * @property {Object} + */ + mw.widgets.datetime.ProlepticGregorianDateTimeFormatter[ 'static' ].shortDayNames = null; + + /** + * Default day letters. + * + * @static + * @inheritable + * @property {string[]} + */ + mw.widgets.datetime.ProlepticGregorianDateTimeFormatter[ 'static' ].dayLetters = null; + + /** + * Default AM/PM indicators + * + * @static + * @inheritable + * @property {string[]} + */ + mw.widgets.datetime.ProlepticGregorianDateTimeFormatter[ 'static' ].hour12Periods = null; + + mw.widgets.datetime.ProlepticGregorianDateTimeFormatter[ 'static' ].setupDefaults = function () { + mw.widgets.datetime.DateTimeFormatter[ 'static' ].setupDefaults.call( this ); + + if ( this.fullMonthNames && !this.shortMonthNames ) { + this.shortMonthNames = {}; + $.each( this.fullMonthNames, function ( k, v ) { + this.shortMonthNames[ k ] = v.substr( 0, 3 ); + }.bind( this ) ); + } + if ( this.shortDayNames && !this.dayLetters ) { + this.dayLetters = []; + $.each( this.shortDayNames, function ( k, v ) { + this.dayLetters[ k ] = v.substr( 0, 1 ); + }.bind( this ) ); + } + if ( this.fullDayNames && !this.dayLetters ) { + this.dayLetters = []; + $.each( this.fullDayNames, function ( k, v ) { + this.dayLetters[ k ] = v.substr( 0, 1 ); + }.bind( this ) ); + } + if ( this.fullDayNames && !this.shortDayNames ) { + this.shortDayNames = {}; + $.each( this.fullDayNames, function ( k, v ) { + this.shortDayNames[ k ] = v.substr( 0, 3 ); + }.bind( this ) ); + } + + if ( !this.fullMonthNames ) { + this.fullMonthNames = { + 1: mw.msg( 'january' ), + 2: mw.msg( 'february' ), + 3: mw.msg( 'march' ), + 4: mw.msg( 'april' ), + 5: mw.msg( 'may_long' ), + 6: mw.msg( 'june' ), + 7: mw.msg( 'july' ), + 8: mw.msg( 'august' ), + 9: mw.msg( 'september' ), + 10: mw.msg( 'october' ), + 11: mw.msg( 'november' ), + 12: mw.msg( 'december' ) + }; + } + if ( !this.shortMonthNames ) { + this.shortMonthNames = { + 1: mw.msg( 'jan' ), + 2: mw.msg( 'feb' ), + 3: mw.msg( 'mar' ), + 4: mw.msg( 'apr' ), + 5: mw.msg( 'may' ), + 6: mw.msg( 'jun' ), + 7: mw.msg( 'jul' ), + 8: mw.msg( 'aug' ), + 9: mw.msg( 'sep' ), + 10: mw.msg( 'oct' ), + 11: mw.msg( 'nov' ), + 12: mw.msg( 'dec' ) + }; + } + + if ( !this.fullDayNames ) { + this.fullDayNames = { + 0: mw.msg( 'sunday' ), + 1: mw.msg( 'monday' ), + 2: mw.msg( 'tuesday' ), + 3: mw.msg( 'wednesday' ), + 4: mw.msg( 'thursday' ), + 5: mw.msg( 'friday' ), + 6: mw.msg( 'saturday' ) + }; + } + if ( !this.shortDayNames ) { + this.shortDayNames = { + 0: mw.msg( 'sun' ), + 1: mw.msg( 'mon' ), + 2: mw.msg( 'tue' ), + 3: mw.msg( 'wed' ), + 4: mw.msg( 'thu' ), + 5: mw.msg( 'fri' ), + 6: mw.msg( 'sat' ) + }; + } + if ( !this.dayLetters ) { + this.dayLetters = []; + $.each( this.shortDayNames, function ( k, v ) { + this.dayLetters[ k ] = v.substr( 0, 1 ); + }.bind( this ) ); + } + + if ( !this.hour12Periods ) { + this.hour12Periods = [ + mw.msg( 'period-am' ), + mw.msg( 'period-pm' ) + ]; + } + }; + + /* Methods */ + + /** + * @inheritdoc + * + * Additional fields implemented here are: + * - ${year|#}: Year as a number + * - ${year|0}: Year as a number, zero-padded to 4 digits + * - ${month|#}: Month as a number + * - ${month|0}: Month as a number with leading 0 + * - ${month|short}: Month from 'shortMonthNames' configuration setting + * - ${month|full}: Month from 'fullMonthNames' configuration setting + * - ${day|#}: Day of the month as a number + * - ${day|0}: Day of the month as a number with leading 0 + * - ${dow|short}: Day of the week from 'shortDayNames' configuration setting + * - ${dow|full}: Day of the week from 'fullDayNames' configuration setting + * - ${hour|#}: Hour as a number + * - ${hour|0}: Hour as a number with leading 0 + * - ${hour|12}: Hour in a 12-hour clock as a number + * - ${hour|012}: Hour in a 12-hour clock as a number, with leading 0 + * - ${hour|period}: Value from 'hour12Periods' configuration setting + * - ${minute|#}: Minute as a number + * - ${minute|0}: Minute as a number with leading 0 + * - ${second|#}: Second as a number + * - ${second|0}: Second as a number with leading 0 + * - ${millisecond|#}: Millisecond as a number + * - ${millisecond|0}: Millisecond as a number, zero-padded to 3 digits + */ + mw.widgets.datetime.ProlepticGregorianDateTimeFormatter.prototype.getFieldForTag = function ( tag, params ) { + var spec = null; + + switch ( tag + '|' + params[ 0 ] ) { + case 'year|#': + case 'year|0': + spec = { + component: 'year', + type: 'number', + size: 4, + zeropad: params[ 0 ] === '0' + }; + break; + + case 'month|short': + case 'month|full': + spec = { + component: 'month', + type: 'string', + values: params[ 0 ] === 'short' ? this.shortMonthNames : this.fullMonthNames + }; + break; + + case 'dow|short': + case 'dow|full': + spec = { + component: 'dow', + editable: false, + type: 'string', + values: params[ 0 ] === 'short' ? this.shortDayNames : this.fullDayNames + }; + break; + + case 'month|#': + case 'month|0': + case 'day|#': + case 'day|0': + case 'hour|#': + case 'hour|0': + case 'minute|#': + case 'minute|0': + case 'second|#': + case 'second|0': + spec = { + component: tag, + type: 'number', + size: 2, + zeropad: params[ 0 ] === '0' + }; + break; + + case 'hour|12': + case 'hour|012': + spec = { + component: 'hour12', + type: 'number', + size: 2, + zeropad: params[ 0 ] === '012' + }; + break; + + case 'hour|period': + spec = { + component: 'hour12period', + type: 'boolean', + values: this.hour12Periods + }; + break; + + case 'millisecond|#': + case 'millisecond|0': + spec = { + component: 'millisecond', + type: 'number', + size: 3, + zeropad: params[ 0 ] === '0' + }; + break; + + default: + return mw.widgets.datetime.ProlepticGregorianDateTimeFormatter[ 'super' ].prototype.getFieldForTag.call( this, tag, params ); + } + + if ( spec ) { + if ( spec.editable === undefined ) { + spec.editable = true; + } + spec.formatValue = this.formatSpecValue; + spec.parseValue = this.parseSpecValue; + if ( spec.values ) { + spec.size = Math.max.apply( + null, $.map( spec.values, function ( v ) { return v.length; } ) + ); + } + } + + return spec; + }; + + /** + * Get components from a Date object + * + * Components are: + * - year {number} + * - month {number} (1-12) + * - day {number} (1-31) + * - dow {number} (0-6, 0 is Sunday) + * - hour {number} (0-23) + * - hour12 {number} (1-12) + * - hour12period {boolean} + * - minute {number} (0-59) + * - second {number} (0-59) + * - millisecond {number} (0-999) + * - zone {number} + * + * @param {Date|null} date + * @return {Object} Components + */ + mw.widgets.datetime.ProlepticGregorianDateTimeFormatter.prototype.getComponentsFromDate = function ( date ) { + var ret; + + if ( !( date instanceof Date ) ) { + date = this.defaultDate; + } + + if ( this.local ) { + ret = { + year: date.getFullYear(), + month: date.getMonth() + 1, + day: date.getDate(), + dow: date.getDay() % 7, + hour: date.getHours(), + minute: date.getMinutes(), + second: date.getSeconds(), + millisecond: date.getMilliseconds(), + zone: date.getTimezoneOffset() + }; + } else { + ret = { + year: date.getUTCFullYear(), + month: date.getUTCMonth() + 1, + day: date.getUTCDate(), + dow: date.getUTCDay() % 7, + hour: date.getUTCHours(), + minute: date.getUTCMinutes(), + second: date.getUTCSeconds(), + millisecond: date.getUTCMilliseconds(), + zone: 0 + }; + } + + ret.hour12period = ret.hour >= 12 ? 1 : 0; + ret.hour12 = ret.hour % 12; + if ( ret.hour12 === 0 ) { + ret.hour12 = 12; + } + + return ret; + }; + + /** + * @inheritdoc + */ + mw.widgets.datetime.ProlepticGregorianDateTimeFormatter.prototype.getDateFromComponents = function ( components ) { + var date = new Date(); + + components = $.extend( {}, components ); + if ( components.hour === undefined && components.hour12 !== undefined && components.hour12period !== undefined ) { + components.hour = ( components.hour12 % 12 ) + ( components.hour12period ? 12 : 0 ); + } + components = $.extend( {}, this.getComponentsFromDate( null ), components ); + + if ( components.zone ) { + // Can't just use the constructor because that's stupid about ancient years. + date.setFullYear( components.year, components.month - 1, components.day ); + date.setHours( components.hour, components.minute, components.second, components.millisecond ); + } else { + // Date.UTC() is stupid about ancient years too. + date.setUTCFullYear( components.year, components.month - 1, components.day ); + date.setUTCHours( components.hour, components.minute, components.second, components.millisecond ); + } + + return date; + }; + + /** + * @inheritdoc + */ + mw.widgets.datetime.ProlepticGregorianDateTimeFormatter.prototype.adjustComponent = function ( date, component, delta, mode ) { + var min, max, range, components; + + if ( !( date instanceof Date ) ) { + date = this.defaultDate; + } + components = this.getComponentsFromDate( date ); + + switch ( component ) { + case 'year': + min = 0; + max = 9999; + break; + case 'month': + min = 1; + max = 12; + break; + case 'day': + min = 1; + max = this.getDaysInMonth( components.month, components.year ); + break; + case 'hour': + min = 0; + max = 23; + break; + case 'minute': + case 'second': + min = 0; + max = 59; + break; + case 'millisecond': + min = 0; + max = 999; + break; + case 'hour12period': + component = 'hour'; + min = 0; + max = 23; + delta *= 12; + break; + case 'hour12': + component = 'hour'; + min = components.hour12period ? 12 : 0; + max = components.hour12period ? 23 : 11; + break; + default: + return new Date( date.getTime() ); + } + + components[ component ] += delta; + range = max - min + 1; + switch ( mode ) { + case 'overflow': + // Date() will mostly handle it automatically. But months need + // manual handling to prevent e.g. Jan 31 => Mar 3. + if ( component === 'month' || component === 'year' ) { + while ( components.month < 1 ) { + components[ component ] += 12; + components.year--; + } + while ( components.month > 12 ) { + components[ component ] -= 12; + components.year++; + } + } + break; + case 'wrap': + while ( components[ component ] < min ) { + components[ component ] += range; + } + while ( components[ component ] > max ) { + components[ component ] -= range; + } + break; + case 'clip': + if ( components[ component ] < min ) { + components[ component ] = min; + } + if ( components[ component ] < max ) { + components[ component ] = max; + } + break; + } + if ( component === 'month' || component === 'year' ) { + components.day = Math.min( components.day, this.getDaysInMonth( components.month, components.year ) ); + } + + return this.getDateFromComponents( components ); + }; + + /** + * Get the number of days in a month + * + * @protected + * @param {number} month + * @param {number} year + * @return {number} + */ + mw.widgets.datetime.ProlepticGregorianDateTimeFormatter.prototype.getDaysInMonth = function ( month, year ) { + switch ( month ) { + case 4: + case 6: + case 9: + case 11: + return 30; + case 2: + if ( year % 4 ) { + return 28; + } else if ( year % 100 ) { + return 29; + } + return ( year % 400 ) ? 28 : 29; + default: + return 31; + } + }; + + /** + * @inheritdoc + */ + mw.widgets.datetime.ProlepticGregorianDateTimeFormatter.prototype.getCalendarHeadings = function () { + var a = this.dayLetters; + + if ( this.weekStartsOn ) { + return a.slice( this.weekStartsOn ).concat( a.slice( 0, this.weekStartsOn ) ); + } else { + return a.slice( 0 ); // clone + } + }; + + /** + * @inheritdoc + */ + mw.widgets.datetime.ProlepticGregorianDateTimeFormatter.prototype.sameCalendarGrid = function ( date1, date2 ) { + if ( this.local ) { + return date1.getFullYear() === date2.getFullYear() && date1.getMonth() === date2.getMonth(); + } else { + return date1.getUTCFullYear() === date2.getUTCFullYear() && date1.getUTCMonth() === date2.getUTCMonth(); + } + }; + + /** + * @inheritdoc + */ + mw.widgets.datetime.ProlepticGregorianDateTimeFormatter.prototype.getCalendarData = function ( date ) { + var dt, t, d, e, i, row, + getDate = this.local ? 'getDate' : 'getUTCDate', + setDate = this.local ? 'setDate' : 'setUTCDate', + ret = { + dayComponent: 'day', + monthComponent: 'month' + }; + + if ( !( date instanceof Date ) ) { + date = this.defaultDate; + } + + dt = new Date( date.getTime() ); + dt[ setDate ]( 1 ); + t = dt.getTime(); + + if ( this.local ) { + ret.header = this.fullMonthNames[ dt.getMonth() + 1 ] + ' ' + dt.getFullYear(); + d = dt.getDay() % 7; + e = this.getDaysInMonth( dt.getMonth() + 1, dt.getFullYear() ); + } else { + ret.header = this.fullMonthNames[ dt.getUTCMonth() + 1 ] + ' ' + dt.getUTCFullYear(); + d = dt.getUTCDay() % 7; + e = this.getDaysInMonth( dt.getUTCMonth() + 1, dt.getUTCFullYear() ); + } + + if ( this.weekStartsOn ) { + d = ( d + 7 - this.weekStartsOn ) % 7; + } + d = 1 - d; + + ret.rows = []; + while ( d <= e ) { + row = []; + for ( i = 0; i < 7; i++, d++ ) { + dt = new Date( t ); + dt[ setDate ]( d ); + row[ i ] = { + display: String( dt[ getDate ]() ), + date: dt, + extra: d < 1 ? 'prev' : d > e ? 'next' : null + }; + } + ret.rows.push( row ); + } + + return ret; + }; + +}( jQuery, mediaWiki ) ); diff --git a/resources/src/mediawiki.widgets.datetime/mediawiki.widgets.datetime.definitions.less b/resources/src/mediawiki.widgets.datetime/mediawiki.widgets.datetime.definitions.less new file mode 100644 index 0000000000..ee0e66e2e2 --- /dev/null +++ b/resources/src/mediawiki.widgets.datetime/mediawiki.widgets.datetime.definitions.less @@ -0,0 +1,37 @@ +/*! + * OOJS-UI defines used by the existing CSS (will make it easier to put this + * widget in OOJS-UI once OOJS-UI is capable of handling it) + */ + +.oo-ui-box-sizing( @type: border-box ) { + -webkit-box-sizing: @type; + -moz-box-sizing: @type; + box-sizing: @type; +} + +.oo-ui-unselectable() { + -webkit-touch-callout: none; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; +} + +.oo-ui-inline-spacing( @spacing, @cancelled-spacing: 0 ) { + margin-right: @spacing; + &:last-child { + margin-right: @cancelled-spacing; + } +} + +.oo-ui-transition( @value1, @value2: X, ... ) { + @value: ~`"@{arguments}".replace(/[\[\]]|\,\sX/g, '')`; + -webkit-transition: @value; + -moz-transition: @value; + transition: @value; +} + +@indicator-size: unit(12 / 16 / 0.8, em); +@icon-size: unit(24 / 16 / 0.8, em); +@quick-ease: 100ms ease; +@progressive: #347bff; diff --git a/resources/src/mediawiki.widgets.datetime/mediawiki.widgets.datetime.js b/resources/src/mediawiki.widgets.datetime/mediawiki.widgets.datetime.js new file mode 100644 index 0000000000..8d4be8c523 --- /dev/null +++ b/resources/src/mediawiki.widgets.datetime/mediawiki.widgets.datetime.js @@ -0,0 +1,2 @@ +// Create the namespace object +mediaWiki.widgets.datetime = {}; -- 2.20.1