Anomie has uploaded a new change for review. https://gerrit.wikimedia.org/r/216909
Change subject: Date and time picker widget ...................................................................... Date and time picker widget This adds a few new classes: * OO.ui.DateTimeFormatter, a base class for converting Date objects into user-visible format. The idea being that we can support different calendar formats by simply implementing a new formatter subclass and the UI classes will automagically adjust. * OO.ui.ProlepticGregorianDateTimeFormatter, an implementation for the usual calendar. * OO.ui.DiscordianDateTimeFormatter, as a test that the UI classes actually work with something different. * OO.ui.DateTimeInputWidget, an input widget that works like <input type="date">, <input type="time">, and <input type="datetime">. But with a custom UI, of course. * OO.ui.CalendarWidget, mostly intended to handle the popup calendar from OO.ui.DateTimeInputWidget but can be used standalone. Bug: T91148 Change-Id: I442179387e723e54be631975fb41ef0da7642f5c --- M build/modules.json M demos/pages/widgets.js M i18n/en.json M i18n/qqq.json A src/DateTimeFormatter.js A src/DiscordianDateTimeFormatter.js A src/ProlepticGregorianDateTimeFormatter.js M src/core.js M src/styles/core.less M src/styles/theme.less A src/styles/widgets/CalendarWidget.less A src/styles/widgets/DateTimeInputWidget.less M src/themes/apex/widgets.less M src/themes/blank/widgets.less M src/themes/mediawiki/widgets.less A src/widgets/CalendarWidget.js A src/widgets/DateTimeInputWidget.js 17 files changed, 3,720 insertions(+), 3 deletions(-) git pull ssh://gerrit.wikimedia.org:29418/oojs/ui refs/changes/09/216909/1 diff --git a/build/modules.json b/build/modules.json index 3daef88..05cb53a 100644 --- a/build/modules.json +++ b/build/modules.json @@ -8,6 +8,9 @@ "src/mixins/PendingElement.js", "src/ActionSet.js", + "src/DateTimeFormatter.js", + "src/ProlepticGregorianDateTimeFormatter.js", + "src/DiscordianDateTimeFormatter.js", "src/Element.js", "src/Layout.js", "src/Widget.js", @@ -72,6 +75,7 @@ "src/widgets/ActionWidget.js", "src/widgets/PopupButtonWidget.js", "src/widgets/ToggleButtonWidget.js", + "src/widgets/CalendarWidget.js", "src/widgets/DropdownWidget.js", "src/widgets/SelectFileWidget.js", "src/widgets/IconWidget.js", @@ -79,6 +83,7 @@ "src/widgets/InputWidget.js", "src/widgets/ButtonInputWidget.js", "src/widgets/CheckboxInputWidget.js", + "src/widgets/DateTimeInputWidget.js", "src/widgets/DropdownInputWidget.js", "src/widgets/RadioInputWidget.js", "src/widgets/RadioSelectInputWidget.js", diff --git a/demos/pages/widgets.js b/demos/pages/widgets.js index 8af8bdb..5a89a48 100644 --- a/demos/pages/widgets.js +++ b/demos/pages/widgets.js @@ -1081,6 +1081,107 @@ align: 'top', label: 'ButtonInputWidget (using <input/>)\u200E' } + ), + new OO.ui.FieldLayout( + new OO.ui.DateTimeInputWidget( { + type: 'datetime', + clearable: false + } ), + { + align: 'top', + label: 'DateTimeInputWidget (datetime)\u200E' + } + ), + new OO.ui.FieldLayout( + new OO.ui.DateTimeInputWidget( { + type: 'date', + clearable: false + } ), + { + align: 'top', + label: 'DateTimeInputWidget (date)\u200E' + } + ), + new OO.ui.FieldLayout( + new OO.ui.DateTimeInputWidget( { + type: 'time', + clearable: false + } ), + { + align: 'top', + label: 'DateTimeInputWidget (time)\u200E' + } + ), + new OO.ui.FieldLayout( + new OO.ui.DateTimeInputWidget( { + formatter: { + defaultDate: new Date( '2017-02-01T12:00:00Z' ), + }, + type: 'datetime', + clearable: true, + required: true, + min: '2017-01-30T12:00:00Z', + max: '2017-02-02T12:00:00Z' + } ), + { + align: 'top', + label: 'DateTimeInputWidget (required, valid dates are 2017-01-30T12:00:00Z to 2017-02-02T12:00:00Z)\u200E' + } + ), + new OO.ui.FieldLayout( + new OO.ui.DateTimeInputWidget( { + formatter: { + format: '${dow|full} ${month|full} ${day|#}, ${year|#} ${hour|012}:${minute|0}:${second|0}.${millisecond|0} ${hour|period} ${zone|full}' + }, + calendar: null, + icon: 'tag', + indicator: 'required' + } ), + { + align: 'top', + label: 'DateTimeInputWidget (datetime with custom format, icon, indicator, no calendar)\u200E' + } + ), + new OO.ui.FieldLayout( + new OO.ui.DateTimeInputWidget( { + formatter: { + format: '${year|0}-${month|0}-${day|0}T${hour|0}:${minute|0}:${second|0}${zone|:}', + weekStartsOn: 1, + }, + icon: 'tag', + indicator: 'required' + } ), + { + align: 'top', + label: 'DateTimeInputWidget (datetime with custom format, icon, indicator, week starts on Monday)\u200E' + } + ), + new OO.ui.FieldLayout( + new OO.ui.DateTimeInputWidget( { + icon: 'tag', + indicator: 'required', + disabled: true + } ), + { + align: 'top', + label: 'DateTimeInputWidget (icon, indicator, disabled)\u200E' + } + ), + new OO.ui.FieldLayout( + new OO.ui.DateTimeInputWidget( { + formatter: new OO.ui.DiscordianDateTimeFormatter() + } ), + { + align: 'top', + label: 'DateTimeInputWidget (ddate)\u200E' + } + ), + new OO.ui.FieldLayout( + new OO.ui.CalendarWidget(), + { + align: 'top', + label: 'CalendarWidget (standalone)\u200E' + } ) ] } ), diff --git a/i18n/en.json b/i18n/en.json index 9812ec6..d081dfd 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -30,5 +30,47 @@ "ooui-dialog-process-continue": "Continue", "ooui-selectfile-not-supported": "File selection is not supported", "ooui-selectfile-placeholder": "No file is selected", - "ooui-semicolon-separator": "; " + "ooui-semicolon-separator": "; ", + "ooui-january": "January", + "ooui-february": "February", + "ooui-march": "March", + "ooui-april": "April", + "ooui-may_long": "May", + "ooui-june": "June", + "ooui-july": "July", + "ooui-august": "August", + "ooui-september": "September", + "ooui-october": "October", + "ooui-november": "November", + "ooui-december": "December", + "ooui-jan": "Jan", + "ooui-feb": "Feb", + "ooui-mar": "Mar", + "ooui-apr": "Apr", + "ooui-may": "May", + "ooui-jun": "Jun", + "ooui-jul": "Jul", + "ooui-aug": "Aug", + "ooui-sep": "Sep", + "ooui-oct": "Oct", + "ooui-nov": "Nov", + "ooui-dec": "Dec", + "ooui-sunday": "Sunday", + "ooui-monday": "Monday", + "ooui-tuesday": "Tuesday", + "ooui-wednesday": "Wednesday", + "ooui-thursday": "Thursday", + "ooui-friday": "Friday", + "ooui-saturday": "Saturday", + "ooui-sun": "Sun", + "ooui-mon": "Mon", + "ooui-tue": "Tue", + "ooui-wed": "Wed", + "ooui-thu": "Thu", + "ooui-fri": "Fri", + "ooui-sat": "Sat", + "ooui-period-am": "AM", + "ooui-period-pm": "PM", + "ooui-timezone-utc": "UTC", + "ooui-timezone-local": "Local" } diff --git a/i18n/qqq.json b/i18n/qqq.json index bef65ed..3b76986 100644 --- a/i18n/qqq.json +++ b/i18n/qqq.json @@ -34,5 +34,47 @@ "ooui-dialog-process-continue": "Label for process dialog retry action button, visible when describing only warnings\n{{Identical|Continue}}", "ooui-selectfile-not-supported": "Label for the file selection dialog if file selection is not supported", "ooui-selectfile-placeholder": "Label for the file selection dialog when no file is currently selected", - "ooui-semicolon-separator": "{{optional}} Semicolon used as a separator" + "ooui-semicolon-separator": "{{optional}} Semicolon used as a separator", + "ooui-january": "{{doc-months|1}}\n{{Identical|January}}", + "ooui-february": "{{doc-months|2}}\n{{Identical|February}}", + "ooui-march": "{{doc-months|3}}\n{{Identical|March}}", + "ooui-april": "{{doc-months|4}}\n{{Identical|April}}", + "ooui-may_long": "{{doc-months|5}}\n{{Identical|May}}", + "ooui-june": "{{doc-months|6}}\n{{Identical|June}}", + "ooui-july": "{{doc-months|7}}\n{{Identical|July}}", + "ooui-august": "{{doc-months|8}}\n{{Identical|August}}", + "ooui-september": "{{doc-months|9}}\n{{Identical|September}}", + "ooui-october": "{{doc-months|10}}\n{{Identical|October}}", + "ooui-november": "{{doc-months|11}}\n{{Identical|November}}", + "ooui-december": "{{doc-months|12}}\n{{Identical|December}}", + "ooui-jan": "{{doc-months|1|short}}", + "ooui-feb": "{{doc-months|2|short}}", + "ooui-mar": "{{doc-months|3|short}}", + "ooui-apr": "{{doc-months|4|short}}", + "ooui-may": "{{doc-months|5|short}}", + "ooui-jun": "{{doc-months|6|short}}", + "ooui-jul": "{{doc-months|7|short}}", + "ooui-aug": "{{doc-months|8|short}}", + "ooui-sep": "{{doc-months|9|short}}", + "ooui-oct": "{{doc-months|10|short}}", + "ooui-nov": "{{doc-months|11|short}}", + "ooui-dec": "{{doc-months|12|short}}", + "ooui-sunday": "Name of the day of the week.", + "ooui-monday": "Name of the day of the week.", + "ooui-tuesday": "Name of the day of the week.", + "ooui-wednesday": "Name of the day of the week.", + "ooui-thursday": "Name of the day of the week.", + "ooui-friday": "Name of the day of the week.", + "ooui-saturday": "Name of the day of the week.", + "ooui-sun": "Abbreviation for Sunday, a day of the week.", + "ooui-mon": "Abbreviation for Monday, a day of the week.", + "ooui-tue": "Abbreviation for Tuesday, a day of the week.", + "ooui-wed": "Abbreviation for Wednesday, a day of the week.", + "ooui-thu": "Abbreviation for Thursday, a day of the week.", + "ooui-fri": "Abbreviation for Friday, a day of the week.", + "ooui-sat": "Abbreviation for Saturday, a day of the week.", + "ooui-period-am": "Used in connection with a [[w:12-hour clock]] to indicate the first period of the day.", + "ooui-period-pm": "Used in connection with a [[w:12-hour clock]] to indicate the second period of the day.", + "ooui-timezone-utc": "{{optional}} Used to indicate the standard UTC timezone.", + "ooui-timezone-local": "Used to indicate the local timezone in the user's browser." } diff --git a/src/DateTimeFormatter.js b/src/DateTimeFormatter.js new file mode 100644 index 0000000..334b223 --- /dev/null +++ b/src/DateTimeFormatter.js @@ -0,0 +1,673 @@ +/** + * Provides various methods needed for formatting dates and times. + * + * @class + * @abstract + * @mixins OO.EventEmitter + * + * @constructor + * @param {Object} [config] Configuration options + * @cfg {string} [format='@default'] May be a key from the {@link #static-formats static formats}, + * or a format specification as defined by {@link #method-parseFieldSpec parseFieldSpec} + * and {@link #method-getFieldForTag getFieldForTag}. + * @cfg {boolean} [local=false] Whether dates are local time or UTC + * @cfg {string[]} [fullZones] Time zone indicators. Array of 2 strings, for + * UTC and local time. + * @cfg {string[]} [shortZones] Abbreviated time zone indicators. Array of 2 + * strings, for UTC and local time. + * @cfg {Date} [defaultDate] Default date, for filling unspecified components. + * Defaults to the current date and time (with 0 milliseconds). + */ +OO.ui.DateTimeFormatter = function OoUiDateTimeFormatter( config ) { + var statick = this.constructor.static; + + statick.setupDefaults(); + + config = $.extend( { + format: '@default', + local: false, + fullZones: statick.fullZones, + shortZones: statick.shortZones + }, config ); + + // Mixin constructors + OO.EventEmitter.call( this ); + + // Properties + if ( statick.formats[config.format] ) { + this.format = statick.formats[config.format]; + } else { + this.format = config.format; + } + this.local = !!config.local; + this.fullZones = config.fullZones; + this.shortZones = config.shortZones; + if ( config.defaultDate instanceof Date ) { + this.defaultDate = config.defaultDate; + } else { + this.defaultDate = new Date(); + if ( this.local ) { + this.defaultDate.setMilliseconds( 0 ); + } else { + this.defaultDate.setUTCMilliseconds( 0 ); + } + } +}; + +/* Setup */ + +OO.initClass( OO.ui.DateTimeFormatter ); +OO.mixinClass( OO.ui.DateTimeFormatter, OO.EventEmitter ); + +/* Static */ + +/** + * Default format specifications. See the {@link #format format} parameter. + * @static + * @inheritable + * @property {Object} + */ +OO.ui.DateTimeFormatter.static.formats = {}; + +/** + * Default time zone indicators + * @static + * @inheritable + * @property {string[]} + */ +OO.ui.DateTimeFormatter.static.fullZones = null; + +/** + * Default abbreviated time zone indicators + * @static + * @inheritable + * @property {string[]} + */ +OO.ui.DateTimeFormatter.static.shortZones = null; + +OO.ui.DateTimeFormatter.static.setupDefaults = function () { + if ( !this.fullZones ) { + this.fullZones = [ + OO.ui.msg( 'ooui-timezone-utc' ), + OO.ui.msg( 'ooui-timezone-local' ) + ]; + } + if ( !this.shortZones ) { + this.shortZones = [ + 'Z', + this.fullZones[1].substr( 0, 1 ).toUpperCase() + ]; + if ( this.shortZones[1] === 'Z' ) { + this.shortZones[1] = 'L'; + } + } +}; + +/* Events */ + +/** + * A `local` event is emitted when the 'local' flag is changed. + * + * @event local + */ + +/* Methods */ + +/** + * Whether dates are in local time or UTC + * + * @return {boolean} True if local time + */ +OO.ui.DateTimeFormatter.prototype.getLocal = function () { + return this.local; +}; + +/** + * Toggle whether dates are in local time or UTC + * + * @param {boolean} [flag] Set the flag instead of toggling it + * @fires local + * @chainable + */ +OO.ui.DateTimeFormatter.prototype.toggleLocal = function ( flag ) { + if ( flag === undefined ) { + flag = !this.local; + } else { + flag = !!flag; + } + if ( this.local !== flag ) { + this.local = flag; + this.emit( 'local', this.local ); + } + return this; +}; + +/** + * Get the default date + * @return {Date} + */ +OO.ui.DateTimeFormatter.prototype.getDefaultDate = function () { + return new Date( this.defaultDate.getTime() ); +}; + +/** + * Fetch the field specification array for this object. + * + * See {@link #parseFieldSpec parseFieldSpec} for details on the return value structure. + * + * @public + * @return {Array} + */ +OO.ui.DateTimeFormatter.prototype.getFieldSpec = function () { + return this.parseFieldSpec( this.format ); +}; + +/** + * Parse a format string into a field specification + * + * The input is a string containing tags formatted as ${tag|param|param...} + * (for editable fields) and $!{tag|param|param...} (for non-editable fields). + * Most tags are defined by {@link #getFieldForTag getFieldForTag}, but a few + * are defined here: + * - ${intercalary|X|text}: Text that is only displayed when the 'intercalary' + * component is X. + * - ${not-intercalary|X|text}: Text that is displayed unless the 'intercalary' + * component is X. + * + * Elements of the returned array are strings or objects. Strings are meant to + * be displayed as-is. Objects are as returned by {@link #getFieldForTag getFieldForTag}. + * + * @protected + * @param {string} format + * @return {Array} + */ +OO.ui.DateTimeFormatter.prototype.parseFieldSpec = function ( format ) { + var m, last, tag, params, spec, + ret = [], + re = /(.*?)(\$(!?)\{([^}]+)\})/g; + + last = 0; + while ( ( m = re.exec( format ) ) !== null ) { + last = re.lastIndex; + + if ( m[1] !== '' ) { + ret.push( m[1] ); + } + + params = m[4].split( '|' ); + tag = params.shift(); + spec = this.getFieldForTag( tag, params ); + if ( spec ) { + if ( m[3] === '!' ) { + spec.editable = false; + } + ret.push( spec ); + } else { + ret.push( m[2] ); + } + } + if ( last < format.length ) { + ret.push( format.substr( last ) ); + } + + return ret; +}; + +/** + * Turn a tag into a field specification object + * + * Fields implemented here are: + * - ${intercalary|X|text}: Text that is only displayed when the 'intercalary' + * component is X. + * - ${not-intercalary|X|text}: Text that is displayed unless the 'intercalary' + * component is X. + * - ${zone|#}: Timezone offset, "+0000" format. + * - ${zone|:}: Timezone offset, "+00:00" format. + * - ${zone|short}: Timezone from 'shortZones' configuration setting. + * - ${zone|full}: Timezone from 'fullZones' configuration setting. + * + * @protected + * @abstract + * @param {string} tag + * @param {string[]} params + * @return {Object|null} Field specification object, or null if the tag+params are unrecognized. + * @return {string|null} return.component Date component corresponding to this field, if any. + * @return {boolean} return.editable Whether this field is editable. + * @return {string} return.type What kind of field this is: + * - 'static': The field is a static string; component will be null. + * - 'number': The field is generally numeric. + * - 'string': The field is generally textual. + * - 'boolean': The field is a boolean. + * - 'toggleLocal': The field represents {@link #getLocal this.getLocal()}. + * Editing should directly call {@link #toggleLocal this.toggleLocal()}. + * @return {number} return.size Maximum number of characters in the field (when + * the 'intercalary' component is falsey). If 0, the field should be hidden entirely. + * @return {Object<string,number>} return.intercalarySize Map from + * 'intercalary' component values to overridden sizes. + * @return {string} return.value For type='static', the string to display. + * @return {function(Mixed): string} return.formatValue A function to format a + * component value as a display string. + * @return {function(string): Mixed} return.parseValue A function to parse a + * display string into a component value. If parsing fails, returns undefined. + */ +OO.ui.DateTimeFormatter.prototype.getFieldForTag = function ( tag, params ) { + var c, spec = null; + + switch ( tag ) { + case 'intercalary': + case 'not-intercalary': + if ( params.length < 2 || !params[0] ) { + return null; + } + spec = { + component: null, + editable: false, + type: 'static', + value: params.slice( 1 ).join( '|' ), + size: 0, + intercalarySize: {} + }; + if ( tag === 'intercalary' ) { + spec.intercalarySize[params[0]] = spec.value.length; + } else { + spec.size = spec.value.length; + spec.intercalarySize[params[0]] = 0; + } + return spec; + + case 'zone': + switch ( params[0] ) { + case '#': + case ':': + c = params[0] === '#' ? '' : ':'; + return { + component: 'zone', + editable: true, + type: 'toggleLocal', + size: 5 + c.length, + formatValue: function ( v ) { + var o, r; + if ( v ) { + o = new Date().getTimezoneOffset(); + r = String( Math.abs( o ) % 60 ); + while ( r.length < 2 ) { + r = '0' + r; + } + r = String( Math.floor( Math.abs( o ) / 60 ) ) + c + r; + while ( r.length < 4 + c.length ) { + r = '0' + r; + } + return ( o <= 0 ? '+' : '−' ) + r; + } else { + return '+00' + c + '00'; + } + }, + parseValue: function ( v ) { + var m; + v = String( v ).trim(); + if ( ( m = /^([+-−])([0-9]{1,2}):?([0-9]{2})$/.test( v ) ) ) { + return ( m[2] * 60 + m[3] ) * ( m[1] === '+' ? -1 : 1 ); + } else { + return undefined; + } + } + }; + + case 'short': + case 'full': + spec = { + component: 'zone', + editable: true, + type: 'toggleLocal', + values: params[0] === 'short' ? this.shortZones : this.fullZones, + formatValue: this.formatSpecValue, + parseValue: this.parseSpecValue + }; + spec.size = Math.max.apply( + null, $.map( spec.values, function ( v ) { return v.length; } ) + ); + return spec; + } + return null; + + default: + return null; + } +}; + +/** + * Format a value for a field specification + * + * 'this' must be the field specification object. The intention is that you + * could just assign this function as the 'formatValue' for each field spec. + * + * Besides the publicly-documented fields, uses the following: + * - values: Enumerated values for the field + * - zeropad: Whether to pad the number with zeros. + * + * @protected + * @param {Mixed} value + * @return {string} + */ +OO.ui.DateTimeFormatter.prototype.formatSpecValue = function ( v ) { + if ( v === undefined || v === null ) { + return ''; + } + + if ( typeof v === 'boolean' || this.type === 'toggleLocal' ) { + v = v ? 1 : 0; + } + + if ( this.values ) { + return this.values[v]; + } + + v = String( v ); + if ( this.zeropad ) { + while ( v.length < this.size ) { + v = '0' + v; + } + } + return v; +}; + +/** + * Parse a value for a field specification + * + * 'this' must be the field specification object. The intention is that you + * could just assign this function as the 'parseValue' for each field spec. + * + * Besides the publicly-documented fields, uses the following: + * - values: Enumerated values for the field + * + * @protected + * @param {string} value + * @return {number|string|null} + */ +OO.ui.DateTimeFormatter.prototype.parseSpecValue = function ( v ) { + var k, re; + + if ( v === '' ) { + return null; + } + + if ( !this.values ) { + v = +v; + if ( this.type === 'boolean' || this.type === 'toggleLocal' ) { + return isNaN( v ) ? undefined : !!v; + } else { + return isNaN( v ) ? undefined : v; + } + } + + if ( v.normalize ) { + v = v.normalize(); + } + re = new RegExp( '^\s*' + v.replace( /([\\{}()|.?*+\-\^$\[\]])/g, '\\$1' ), 'i' ); + for ( k in this.values ) { + k = +k; + if ( !isNaN( k ) && re.test( this.values[k] ) ) { + if ( this.type === 'boolean' || this.type === 'toggleLocal' ) { + return !!k; + } else { + return k; + } + } + } + return undefined; +}; + +/** + * Get components from a Date object + * + * Most specific components are defined by the subclass. "Global" components + * are: + * - intercalary: {string} Non-falsey values are used to indicate intercalary days. + * - zone: {number} Timezone offset in minutes. + * + * @public + * @abstract + * @param {Date|null} date + * @return {Object} Components + */ +OO.ui.DateTimeFormatter.prototype.getComponentsFromDate = function ( date ) { + // Should be overridden by subclass + return { + zone: this.local ? date.getTimezoneOffset() : 0 + }; +}; + +/** + * Get a Date object from components + * + * @public + * @param {Object} components Date components + * @return {Date} + */ +OO.ui.DateTimeFormatter.prototype.getDateFromComponents = function ( /* components */ ) { + // Should be overridden by subclass + return new Date(); +}; + +/** + * Adjust a date + * + * @public + * @param {Date|null} date To be adjusted + * @param {string} component To adjust + * @param {number} delta Adjustment amount + * @param {string} mode Adjustment mode: + * - 'overflow': "Jan 32" => "Feb 1", "Jan 33" => "Feb 2", "Feb 0" => "Jan 31", etc. + * - 'wrap': "Jan 32" => "Jan 1", "Jan 33" => "Jan 2", "Jan 0" => "Jan 31", etc. + * - 'clip': "Jan 32" => "Jan 31", "Feb 32" => "Feb 28" (or 29), "Feb 0" => "Feb 1", etc. + * @return {Date} Adjusted date + */ +OO.ui.DateTimeFormatter.prototype.adjustComponent = function ( date /*, component, delta, mode */ ) { + // Should be overridden by subclass + return date; +}; + +/** + * Get the column headings (weekday abbreviations) for a calendar grid + * + * Null-valued columns are hidden if getCalendarData() returns no "day" object + * for all days in that column. + * + * @public + * @abstract + * @return {Array} string or null + */ +OO.ui.DateTimeFormatter.prototype.getCalendarHeadings = function () { + // Should be overridden by subclass + return []; +}; + +/** + * Test whether two dates are in the same calendar grid + * + * @public + * @abstract + * @param {Date} date1 + * @param {Date} date2 + * @return {boolean} + */ +OO.ui.DateTimeFormatter.prototype.sameCalendarGrid = function ( date1, date2 ) { + // Should be overridden by subclass + return date1.getTime() === date2.getTime(); +}; + +/** + * Test whether the time parts of two Dates are equal + * + * @public + * @param {Date} date1 + * @param {Date} date2 + * @return {boolean} + */ +OO.ui.DateTimeFormatter.prototype.timePartIsEqual = function ( date1, date2 ) { + if ( this.local ) { + return ( + date1.getHours() === date2.getHours() && + date1.getMinutes() === date2.getMinutes() && + date1.getSeconds() === date2.getSeconds() && + date1.getMilliseconds() === date2.getMilliseconds() + ); + } else { + return ( + date1.getUTCHours() === date2.getUTCHours() && + date1.getUTCMinutes() === date2.getUTCMinutes() && + date1.getUTCSeconds() === date2.getUTCSeconds() && + date1.getUTCMilliseconds() === date2.getUTCMilliseconds() + ); + } +}; + +/** + * Create a new Date by merging the date part from one with the time part from + * another. + * + * @public + * @param {Date} datepart + * @param {Date} timepart + * @return {Date} + */ +OO.ui.DateTimeFormatter.prototype.mergeDateAndTime = function ( datepart, timepart ) { + var ret = new Date( datepart.getTime() ); + + if ( this.local ) { + ret.setHours( + timepart.getHours(), + timepart.getMinutes(), + timepart.getSeconds(), + timepart.getMilliseconds() + ); + } else { + ret.setUTCHours( + timepart.getUTCHours(), + timepart.getUTCMinutes(), + timepart.getUTCSeconds(), + timepart.getUTCMilliseconds() + ); + } + + return ret; +}; + +/** + * Get data for a calendar grid + * + * A "day" object is: + * - display: {string} Display text for the day. + * - date: {Date} Date to use when the day is selected. + * - extra: {string|null} 'prev' or 'next' on days used to fill out the weeks + * at the start and end of the month. + * - inrange: {boolean} Whether this date is in the min–max range + * - selected: {boolean} Whether this is a selected date + * + * In any one result object, 'extra' + 'display' will always be unique. + * + * @public + * @abstract + * @param {Date|null} current Current date + * @param {Date|null} [min] Minimum date (see 'inrange') + * @param {Date|null} [max] Maximum date (see 'inrange') + * @param {Date[]} [selected] Selected date(s), if any (see 'selected') + * @return {Object} Data + * @return {string} return.header String to display as the calendar header + * @return {Date} return.prev Date to pass to this function to get + * data for the previous month. + * @return {Date} return.next Date to pass to this function to get + * data for the next month. + * @return {Array} return.rows Array of arrays of "day" objects or null/undefined. + */ +OO.ui.DateTimeFormatter.prototype.getCalendarData = function ( /* components, min, max, selected */ ) { + // Should be overridden by subclass + return { + header: '', + prev: {}, + next: {}, + rows: [] + }; +}; + +/** + * Get helpers for getCalendarData() + * + * @protected + * @abstract + * @param {Date|null} [min] Minimum date (see 'inrange') + * @param {Date|null} [max] Maximum date (see 'inrange') + * @param {Date[]} [selected] Selected date(s), if any (see 'selected') + * @return {Object} Data + * @return {Function} return.inrange Helper to test if a date is in range + * @return {Function} return.selected Helper to test if a date is selected + */ +OO.ui.DateTimeFormatter.prototype.getCalendarDataHelpers = function ( min, max, selected ) { + var i, y, m, + helpers = {}, + sel = {}; + + if ( min instanceof Date ) { + if ( max instanceof Date ) { + helpers.inrange = function ( dt ) { + return dt >= min && dt <= max; + }; + } else { + helpers.inrange = function ( dt ) { + return dt >= min; + }; + } + } else if ( max instanceof Date ) { + helpers.inrange = function ( dt ) { + return dt <= max; + }; + } else { + helpers.inrange = function () { return true; }; + } + + if ( Array.isArray( selected ) && selected.length ) { + if ( this.local ) { + for ( i = 0; i < selected.length; i++ ) { + y = selected[i].getFullYear(); + m = selected[i].getMonth(); + if ( !sel[y] ) { + sel[y] = {}; + } + if ( !sel[y][m] ) { + sel[y][m] = {}; + } + sel[y][m][selected[i].getDate()] = true; + } + helpers.selected = function ( dt ) { + return !!( + sel[dt.getFullYear()] && + sel[dt.getFullYear()][dt.getMonth()] && + sel[dt.getFullYear()][dt.getMonth()][dt.getDate()] + ); + }; + } else { + for ( i = 0; i < selected.length; i++ ) { + y = selected[i].getUTCFullYear(); + m = selected[i].getUTCMonth(); + if ( !sel[y] ) { + sel[y] = {}; + } + if ( !sel[y][m] ) { + sel[y][m] = {}; + } + sel[y][m][selected[i].getUTCDate()] = true; + } + helpers.selected = function ( dt ) { + return !!( + sel[dt.getUTCFullYear()] && + sel[dt.getUTCFullYear()][dt.getUTCMonth()] && + sel[dt.getUTCFullYear()][dt.getUTCMonth()][dt.getUTCDate()] + ); + }; + } + } else { + helpers.selected = function () { return false; }; + } + + return helpers; +}; diff --git a/src/DiscordianDateTimeFormatter.js b/src/DiscordianDateTimeFormatter.js new file mode 100644 index 0000000..1a0d2f5 --- /dev/null +++ b/src/DiscordianDateTimeFormatter.js @@ -0,0 +1,526 @@ +/** + * Provides various methods needed for formatting dates and times. This + * implementation implments the [Discordian calendar][1], mainly for testing with + * something very different from the usual Gregorian calendar. + * + * Being intended mainly for testing, niceties like i18n and better + * configurability have been omitted. + * + * @class + * @extends OO.ui.DateTimeFormatter + * + * @constructor + * @param {Object} [config] Configuration options + */ +OO.ui.DiscordianDateTimeFormatter = function OoUiDiscordianDateTimeFormatter( config ) { + config = $.extend( {}, config ); + + // Parent constructor + OO.ui.DiscordianDateTimeFormatter.super.call( this, config ); +}; + +/* Setup */ + +OO.inheritClass( OO.ui.DiscordianDateTimeFormatter, OO.ui.DateTimeFormatter ); + +/* Static */ + +/** + * @inheritdoc + */ +OO.ui.DiscordianDateTimeFormatter.static.formats = { + '@time': '${hour|0}:${minute|0}:${second|0}', + '@date': '$!{dow|full}${not-intercalary|1|, }${season|full}${not-intercalary|1| }${day|#}, ${year|#}', + '@datetime': '$!{dow|full}${not-intercalary|1|, }${season|full}${not-intercalary|1| }${day|#}, ${year|#} ${hour|0}:${minute|0}:${second|0} $!{zone|short}', + '@default': '$!{dow|full}${not-intercalary|1|, }${season|full}${not-intercalary|1| }${day|#}, ${year|#} ${hour|0}:${minute|0}:${second|0} $!{zone|short}' +}; + +/* Methods */ + +/** + * @inheritdoc + * + * Additional fields implemented here are: + * - ${year|#}: Year as a number + * - ${season|#}: Season as a number + * - ${season|full}: Season as a string + * - ${day|#}: Day of the month as a number + * - ${day|0}: Day of the month as a number with leading 0 + * - ${dow|full}: Day of the week as a string + * - ${hour|#}: Hour as a number + * - ${hour|0}: Hour as a number with leading 0 + * - ${minute|#}: Minute as a number + * - ${minute|0}: Minute as a number with leading 0 + * - ${second|#}: Second as a number + * - ${second|0}: Second as a number with leading 0 + * - ${millisecond|#}: Millisecond as a number + * - ${millisecond|0}: Millisecond as a number, zero-padded to 3 digits + */ +OO.ui.DiscordianDateTimeFormatter.prototype.getFieldForTag = function ( tag, params ) { + var spec = null; + + switch ( tag + '|' + params[0] ) { + case 'year|#': + spec = { + component: 'Year', + type: 'number', + size: 4, + zeropad: false + }; + break; + + case 'season|#': + spec = { + component: 'Season', + type: 'number', + size: 1, + intercalarySize: { 1: 0 }, + zeropad: false + }; + break; + + case 'season|full': + spec = { + component: 'Season', + type: 'string', + intercalarySize: { 1: 0 }, + values: { + 1: 'Chaos', + 2: 'Discord', + 3: 'Confusion', + 4: 'Bureaucracy', + 5: 'The Aftermath' + } + }; + break; + + case 'dow|full': + spec = { + component: 'DOW', + editable: false, + type: 'string', + intercalarySize: { 1: 0 }, + values: { + '-1': 'N/A', + 0: 'Sweetmorn', + 1: 'Boomtime', + 2: 'Pungenday', + 3: 'Prickle-Prickle', + 4: 'Setting Orange' + } + }; + break; + + case 'day|#': + case 'day|0': + spec = { + component: 'Day', + type: 'string', + size: 2, + intercalarySize: { 1: 13 }, + zeropad: params[0] === '0', + formatValue: function ( v ) { + if ( v === 'tib' ) { + return 'St. Tib\'s Day'; + } + return OO.ui.DateTimeFormatter.prototype.formatSpecValue.call( this, v ); + }, + parseValue: function ( v ) { + if ( /^\s*(st.?\s*)?tib('?s)?(\s*day)?\s*$/i.test( v ) ) { + return 'tib'; + } + return OO.ui.DateTimeFormatter.prototype.parseSpecValue.call( this, v ); + } + }; + break; + + case 'hour|#': + case 'hour|0': + case 'minute|#': + case 'minute|0': + case 'second|#': + case 'second|0': + spec = { + component: tag.charAt( 0 ).toUpperCase() + tag.slice( 1 ), + type: 'number', + size: 2, + zeropad: params[0] === '0' + }; + break; + + case 'millisecond|#': + case 'millisecond|0': + spec = { + component: 'Millisecond', + type: 'number', + size: 3, + zeropad: params[0] === '0' + }; + break; + + default: + return OO.ui.ProlepticGregorianDateTimeFormatter.super.prototype.getFieldForTag.call( this, tag, params ); + } + + if ( spec ) { + if ( spec.editable === undefined ) { + spec.editable = true; + } + if ( spec.component !== 'Day' ) { + spec.formatValue = this.formatSpecValue; + spec.parseValue = this.parseSpecValue; + } + if ( spec.values ) { + spec.size = Math.max.apply( + null, $.map( spec.values, function ( v ) { return v.length; } ) + ); + } + } + + return spec; +}; + +/** + * Get components from a Date object + * + * Components are: + * - Year {number} + * - Season {number} 1-5 + * - Day {number|string} 1-73 or 'tib' + * - DOW {number} 0-4, or -1 on St. Tib's Day + * - Hour {number} 0-23 + * - Minute {number} 0-59 + * - Second {number} 0-59 + * - Millisecond {number} 0-999 + * - intercalary {string} '1' on St. Tib's Day + * + * @public + * @param {Date|null} date + * @return {Object} Components + */ +OO.ui.DiscordianDateTimeFormatter.prototype.getComponentsFromDate = function ( date ) { + var ret, day, month, + monthDays = [ 0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 304, 334 ]; + + if ( !( date instanceof Date ) ) { + date = this.defaultDate; + } + + if ( this.local ) { + day = date.getDate(); + month = date.getMonth(); + ret = { + Year: date.getFullYear() + 1166, + Hour: date.getHours(), + Minute: date.getMinutes(), + Second: date.getSeconds(), + Millisecond: date.getMilliseconds(), + zone: date.getTimezoneOffset() + }; + } else { + day = date.getUTCDate(); + month = date.getUTCMonth(); + ret = { + Year: date.getUTCFullYear() + 1166, + Hour: date.getUTCHours(), + Minute: date.getUTCMinutes(), + Second: date.getUTCSeconds(), + Millisecond: date.getUTCMilliseconds(), + zone: 0 + }; + } + + if ( month === 1 && day === 29 ) { + ret.Season = 1; + ret.Day = 'tib'; + ret.DOW = -1; + ret.intercalary = '1'; + } else { + day = monthDays[month] + day - 1; + ret.Season = Math.floor( day / 73 ) + 1; + ret.Day = ( day % 73 ) + 1; + ret.DOW = day % 5; + ret.intercalary = ''; + } + + return ret; +}; + +/** + * @inheritdoc + */ +OO.ui.DateTimeFormatter.prototype.adjustComponent = function ( date, component, delta, mode ) { + return this.getDateFromComponents( + this.adjustComponentInternal( + this.getComponentsFromDate( date ), component, delta, mode + ) + ); +}; + +/** + * Adjust the components directly + * @private + * @param {Object} components Modified in place + * @param {string} component + * @param {number} delta + * @param {string} mode + * @return {Object} components + */ +OO.ui.DateTimeFormatter.prototype.adjustComponentInternal = function ( components, component, delta, mode ) { + var i, min, max, range, next, preTib, postTib; + + switch ( component ) { + case 'Year': + min = 1166; + max = 11165; + next = null; + break; + case 'Season': + min = 1; + max = 5; + next = 'Year'; + break; + case 'Day': + min = 1; + max = 73; + next = 'Season'; + break; + case 'Hour': + min = 0; + max = 23; + next = 'Day'; + break; + case 'Minute': + min = 0; + max = 59; + next = 'Hour'; + break; + case 'Second': + min = 0; + max = 59; + next = 'Minute'; + break; + case 'Millisecond': + min = 0; + max = 999; + next = 'Second'; + break; + default: + return components; + } + + switch ( mode ) { + case 'overflow': + case 'clip': + case 'wrap': + } + + if ( component === 'Day' ) { + i = Math.abs( delta ); + delta = Math.sign( delta ); + preTib = delta > 0 ? 59 : 60; + postTib = delta > 0 ? 60 : 59; + while ( i-- > 0 ) { + if ( components.Day === preTib && components.Season === 1 && this.isLeapYear( components.Year ) ) { + components.Day = 'tib'; + } else if ( components.Day === 'tib' ) { + components.Day = postTib; + components.Season = 1; + } else { + components.Day += delta; + if ( components.Day < min ) { + switch ( mode ) { + case 'overflow': + components.Day = max; + this.adjustComponentInternal( components, 'Season', -1, mode ); + break; + case 'wrap': + components.Day = max; + break; + case 'clip': + components.Day = min; + i = 0; + break; + } + } + if ( components.Day > max ) { + switch ( mode ) { + case 'overflow': + components.Day = min; + this.adjustComponentInternal( components, 'Season', 1, mode ); + break; + case 'wrap': + components.Day = min; + break; + case 'clip': + components.Day = max; + i = 0; + break; + } + } + } + } + } else { + switch ( mode ) { + case 'overflow': + i = Math.abs( delta ); + delta = Math.sign( delta ); + while ( i-- > 0 ) { + components[component] += delta; + if ( components[component] < min ) { + components[component] = max; + components = this.adjustComponentInternal( components, next, -1, mode ); + } + if ( components[component] > max ) { + components[component] = min; + components = this.adjustComponentInternal( components, next, 1, mode ); + } + } + break; + case 'wrap': + range = max - min + 1; + components[component] += delta; + while ( components[component] < min ) { + components[component] += range; + } + while ( components[component] > max ) { + components[component] -= range; + } + break; + case 'clip': + components[component] += delta; + if ( components[component] < min ) { + components[component] = min; + } + if ( components[component] > max ) { + components[component] = max; + } + break; + } + } + + return components; +}; + +/** + * @inheritdoc + */ +OO.ui.DiscordianDateTimeFormatter.prototype.getDateFromComponents = function ( components ) { + var month, day, days, + date = new Date(), + monthDays = [ 0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 304, 334, 365 ]; + + components = $.extend( {}, this.getComponentsFromDate( null ), components ); + if ( components.Day === 'tib' ) { + month = 1; + day = 29; + } else { + days = components.Season * 73 + components.Day - 74; + month = 0; + while ( days >= monthDays[month + 1] ) { + month++; + } + day = days - monthDays[month] + 1; + } + + if ( components.zone ) { + // Can't just use the constructor because that's stupid about ancient years. + date.setFullYear( components.Year - 1166, month, day ); + date.setHours( components.Hour, components.Minute, components.Second, components.Millisecond ); + } else { + // Date.UTC() is stupid about ancient years too. + date.setUTCFullYear( components.Year - 1166, month, day ); + date.setUTCHours( components.Hour, components.Minute, components.Second, components.Millisecond ); + } + + return date; +}; + +/** + * Get whether the year is a leap year + * + * @private + * @param {number} year + * @return {boolean} + */ +OO.ui.DiscordianDateTimeFormatter.prototype.isLeapYear = function ( year ) { + year -= 1166; + if ( year % 4 ) { + return false; + } else if ( year % 100 ) { + return true; + } + return ( year % 400 ) === 0; +}; + +/** + * @inheritdoc + */ +OO.ui.DiscordianDateTimeFormatter.prototype.getCalendarHeadings = function () { + return [ 'SM', 'BT', 'PD', 'PP', null, 'SO' ]; +}; + +/** + * @inheritdoc + */ +OO.ui.DiscordianDateTimeFormatter.prototype.sameCalendarGrid = function ( date1, date2 ) { + var components1 = this.getComponentsFromDate( date1 ), + components2 = this.getComponentsFromDate( date2 ); + + return components1.Year === components2.Year && components1.Season === components2.Season; +}; + +/** + * @inheritdoc + */ +OO.ui.DiscordianDateTimeFormatter.prototype.getCalendarData = function ( date, min, max, selected ) { + var dt, components, season, i, row, + helpers = this.getCalendarDataHelpers( min, max, selected ), + ret = {}, + seasons = [ 'Chaos', 'Discord', 'Confusion', 'Bureaucracy', 'The Aftermath' ], + seasonStart = [ 0, -3, -1, -4, -2 ]; + + if ( !( date instanceof Date ) ) { + date = this.defaultDate; + } + + components = this.getComponentsFromDate( date ); + components.Day = 1; + season = components.Season; + + ret.header = seasons[season - 1] + ' ' + components.Year; + ret.prev = this.getDateFromComponents( + this.adjustComponentInternal( $.extend( {}, components ), 'Season', -1, 'overflow' ) + ); + ret.next = this.getDateFromComponents( + this.adjustComponentInternal( $.extend( {}, components ), 'Season', 1, 'overflow' ) + ); + + if ( seasonStart[season - 1] ) { + this.adjustComponentInternal( components, 'Day', seasonStart[season - 1], 'overflow' ); + } + + ret.rows = []; + do { + row = []; + for ( i = 0; i < 6; i++ ) { + dt = this.getDateFromComponents( components ); + row[i] = { + display: components.Day === 'tib' ? 'Tib' : String( components.Day ), + date: dt, + extra: components.Season < season ? 'prev' : components.Season > season ? 'next' : null, + inrange: helpers.inrange( dt ), + selected: helpers.selected( dt ) + }; + + this.adjustComponentInternal( components, 'Day', 1, 'overflow' ); + if ( components.Day !== 'tib' && i === 3 ) { + row[++i] = null; + } + } + + ret.rows.push( row ); + } while ( components.Season === season ); + + return ret; +}; diff --git a/src/ProlepticGregorianDateTimeFormatter.js b/src/ProlepticGregorianDateTimeFormatter.js new file mode 100644 index 0000000..0e11029 --- /dev/null +++ b/src/ProlepticGregorianDateTimeFormatter.js @@ -0,0 +1,658 @@ +/** + * Provides various methods needed for formatting dates and times. This + * implementation implments the proleptic Gregorian calendar over years + * 0000–9999. + * + * @class + * @extends OO.ui.DateTimeFormatter + * + * @constructor + * @param {Object} [config] Configuration options + * @cfg {Object} [fullMonthNames] Mapping 1–12 to full month names. + * @cfg {Object} [shortMonthNames] Mapping 1–12 to abbreviated month names. + * If {@link #fullMonthNames fullMonthNames} is given and this is not, + * defaults to the first three characters from that setting. + * @cfg {Object} [fullDayNames] Mapping 0–6 to full day of week names. 0 is Sunday, 6 is Saturday. + * @cfg {Object} [shortDayNames] Mapping 0–6 to abbreviated day of week names. 0 is Sunday, 6 is Saturday. + * If {@link #fullDayNames fullDayNames} is given and this is not, defaults to + * the first three characters from that setting. + * @cfg {string[]} [dayLetters] Weekday column headers for a calendar. Array of 7 strings. + * If {@link #fullDayNames fullDayNames} or {@link #shortDayNames shortDayNames} + * are given and this is not, defaults to the first character from + * shortDayNames. + * @cfg {string[]} [hour12Periods] AM and PM texts. Array of 2 strings, AM and PM. + * @cfg {number} [weekStartsOn=0] What day the week starts on: 0 is Sunday, 1 is Monday, 6 is Saturday. + */ +OO.ui.ProlepticGregorianDateTimeFormatter = function OoUiProlepticGregorianDateTimeFormatter( config ) { + var statick = this.constructor.static; + + statick.setupDefaults(); + + config = $.extend( { + weekStartsOn: 0, + hour12Periods: statick.hour12Periods + }, config ); + + if ( config.fullMonthNames && !config.shortMonthNames ) { + config.shortMonthNames = {}; + $.each( config.fullMonthNames, function ( k, v ) { + config.shortMonthNames[k] = v.substr( 0, 3 ); + }.bind( this ) ); + } + if ( config.shortDayNames && !config.dayLetters ) { + config.dayLetters = []; + $.each( config.shortDayNames, function ( k, v ) { + config.dayLetters[k] = v.substr( 0, 1 ); + }.bind( this ) ); + } + if ( config.fullDayNames && !config.dayLetters ) { + config.dayLetters = []; + $.each( config.fullDayNames, function ( k, v ) { + config.dayLetters[k] = v.substr( 0, 1 ); + }.bind( this ) ); + } + if ( config.fullDayNames && !config.shortDayNames ) { + config.shortDayNames = {}; + $.each( config.fullDayNames, function ( k, v ) { + config.shortDayNames[k] = v.substr( 0, 3 ); + }.bind( this ) ); + } + config = $.extend( { + fullMonthNames: statick.fullMonthNames, + shortMonthNames: statick.shortMonthNames, + fullDayNames: statick.fullDayNames, + shortDayNames: statick.shortDayNames, + dayLetters: statick.dayLetters + }, config ); + + // Parent constructor + OO.ui.ProlepticGregorianDateTimeFormatter.super.call( this, config ); + + // Properties + this.weekStartsOn = config.weekStartsOn % 7; + this.fullMonthNames = config.fullMonthNames; + this.shortMonthNames = config.shortMonthNames; + this.fullDayNames = config.fullDayNames; + this.shortDayNames = config.shortDayNames; + this.dayLetters = config.dayLetters; + this.hour12Periods = config.hour12Periods; +}; + +/* Setup */ + +OO.inheritClass( OO.ui.ProlepticGregorianDateTimeFormatter, OO.ui.DateTimeFormatter ); + +/* Static */ + +/** + * @inheritdoc + */ +OO.ui.ProlepticGregorianDateTimeFormatter.static.formats = { + '@time': '${hour|0}:${minute|0}:${second|0}', + '@date': '$!{dow|short} ${month|short} ${day|#}, ${year|#}', + '@datetime': '$!{dow|short} ${month|short} ${day|#}, ${year|#} ${hour|0}:${minute|0}:${second|0} $!{zone|short}', + '@default': '$!{dow|short} ${month|short} ${day|#}, ${year|#} ${hour|0}:${minute|0}:${second|0} $!{zone|short}' +}; + +/** + * Default full month names. + * @static + * @inheritable + * @property {Object} + */ +OO.ui.ProlepticGregorianDateTimeFormatter.static.fullMonthNames = null; + +/** + * Default abbreviated month names. + * @static + * @inheritable + * @property {Object} + */ +OO.ui.ProlepticGregorianDateTimeFormatter.static.shortMonthNames = null; + +/** + * Default full day of week names. + * @static + * @inheritable + * @property {Object} + */ +OO.ui.ProlepticGregorianDateTimeFormatter.static.fullDayNames = null; + +/** + * Default abbreviated day of week names. + * @static + * @inheritable + * @property {Object} + */ +OO.ui.ProlepticGregorianDateTimeFormatter.static.shortDayNames = null; + +/** + * Default day letters. + * @static + * @inheritable + * @property {string[]} + */ +OO.ui.ProlepticGregorianDateTimeFormatter.static.dayLetters = null; + +/** + * Default AM/PM indicators + * @static + * @inheritable + * @property {string[]} + */ +OO.ui.ProlepticGregorianDateTimeFormatter.static.hour12Periods = null; + +OO.ui.ProlepticGregorianDateTimeFormatter.static.setupDefaults = function () { + OO.ui.DateTimeFormatter.static.setupDefaults.call( this ); + + if ( this.fullMonthNames && !this.shortMonthNames ) { + this.shortMonthNames = {}; + $.each( this.fullMonthNames, function ( k, v ) { + this.shortMonthNames[k] = v.substr( 0, 3 ); + }.bind( this ) ); + } + if ( this.shortDayNames && !this.dayLetters ) { + this.dayLetters = []; + $.each( this.shortDayNames, function ( k, v ) { + this.dayLetters[k] = v.substr( 0, 1 ); + }.bind( this ) ); + } + if ( this.fullDayNames && !this.dayLetters ) { + this.dayLetters = []; + $.each( this.fullDayNames, function ( k, v ) { + this.dayLetters[k] = v.substr( 0, 1 ); + }.bind( this ) ); + } + if ( this.fullDayNames && !this.shortDayNames ) { + this.shortDayNames = {}; + $.each( this.fullDayNames, function ( k, v ) { + this.shortDayNames[k] = v.substr( 0, 3 ); + }.bind( this ) ); + } + + if ( !this.fullMonthNames ) { + this.fullMonthNames = { + 1: OO.ui.msg( 'ooui-january' ), + 2: OO.ui.msg( 'ooui-february' ), + 3: OO.ui.msg( 'ooui-march' ), + 4: OO.ui.msg( 'ooui-april' ), + 5: OO.ui.msg( 'ooui-may_long' ), + 6: OO.ui.msg( 'ooui-june' ), + 7: OO.ui.msg( 'ooui-july' ), + 8: OO.ui.msg( 'ooui-august' ), + 9: OO.ui.msg( 'ooui-september' ), + 10: OO.ui.msg( 'ooui-october' ), + 11: OO.ui.msg( 'ooui-november' ), + 12: OO.ui.msg( 'ooui-december' ) + }; + } + if ( !this.shortMonthNames ) { + this.shortMonthNames = { + 1: OO.ui.msg( 'ooui-jan' ), + 2: OO.ui.msg( 'ooui-feb' ), + 3: OO.ui.msg( 'ooui-mar' ), + 4: OO.ui.msg( 'ooui-apr' ), + 5: OO.ui.msg( 'ooui-may' ), + 6: OO.ui.msg( 'ooui-jun' ), + 7: OO.ui.msg( 'ooui-jul' ), + 8: OO.ui.msg( 'ooui-aug' ), + 9: OO.ui.msg( 'ooui-sep' ), + 10: OO.ui.msg( 'ooui-oct' ), + 11: OO.ui.msg( 'ooui-nov' ), + 12: OO.ui.msg( 'ooui-dec' ) + }; + } + + if ( !this.fullDayNames ) { + this.fullDayNames = { + 0: OO.ui.msg( 'ooui-sunday' ), + 1: OO.ui.msg( 'ooui-monday' ), + 2: OO.ui.msg( 'ooui-tuesday' ), + 3: OO.ui.msg( 'ooui-wednesday' ), + 4: OO.ui.msg( 'ooui-thursday' ), + 5: OO.ui.msg( 'ooui-friday' ), + 6: OO.ui.msg( 'ooui-saturday' ) + }; + } + if ( !this.shortDayNames ) { + this.shortDayNames = { + 0: OO.ui.msg( 'ooui-sun' ), + 1: OO.ui.msg( 'ooui-mon' ), + 2: OO.ui.msg( 'ooui-tue' ), + 3: OO.ui.msg( 'ooui-wed' ), + 4: OO.ui.msg( 'ooui-thu' ), + 5: OO.ui.msg( 'ooui-fri' ), + 6: OO.ui.msg( 'ooui-sat' ) + }; + } + if ( !this.dayLetters ) { + this.dayLetters = []; + $.each( this.shortDayNames, function ( k, v ) { + this.dayLetters[k] = v.substr( 0, 1 ); + }.bind( this ) ); + } + + if ( !this.hour12Periods ) { + this.hour12Periods = [ + OO.ui.msg( 'ooui-period-am' ), + OO.ui.msg( 'ooui-period-pm' ) + ]; + } +}; + +/* Methods */ + +/** + * @inheritdoc + * + * Additional fields implemented here are: + * - ${year|#}: Year as a number + * - ${year|0}: Year as a number, zero-padded to 4 digits + * - ${month|#}: Month as a number + * - ${month|0}: Month as a number with leading 0 + * - ${month|short}: Month from 'shortMonthNames' configuration setting + * - ${month|full}: Month from 'fullMonthNames' configuration setting + * - ${day|#}: Day of the month as a number + * - ${day|0}: Day of the month as a number with leading 0 + * - ${dow|short}: Day of the week from 'shortDayNames' configuration setting + * - ${dow|full}: Day of the week from 'fullDayNames' configuration setting + * - ${hour|#}: Hour as a number + * - ${hour|0}: Hour as a number with leading 0 + * - ${hour|12}: Hour in a 12-hour clock as a number + * - ${hour|012}: Hour in a 12-hour clock as a number, with leading 0 + * - ${hour|period}: Value from 'hour12Periods' configuration setting + * - ${minute|#}: Minute as a number + * - ${minute|0}: Minute as a number with leading 0 + * - ${second|#}: Second as a number + * - ${second|0}: Second as a number with leading 0 + * - ${millisecond|#}: Millisecond as a number + * - ${millisecond|0}: Millisecond as a number, zero-padded to 3 digits + */ +OO.ui.ProlepticGregorianDateTimeFormatter.prototype.getFieldForTag = function ( tag, params ) { + var spec = null; + + switch ( tag + '|' + params[0] ) { + case 'year|#': + case 'year|0': + spec = { + component: 'year', + type: 'number', + size: 4, + zeropad: params[0] === '0' + }; + break; + + case 'month|short': + case 'month|full': + spec = { + component: 'month', + type: 'string', + values: params[0] === 'short' ? this.shortMonthNames : this.fullMonthNames + }; + break; + + case 'dow|short': + case 'dow|full': + spec = { + component: 'dow', + editable: false, + type: 'string', + values: params[0] === 'short' ? this.shortDayNames : this.fullDayNames + }; + break; + + case 'month|#': + case 'month|0': + case 'day|#': + case 'day|0': + case 'hour|#': + case 'hour|0': + case 'minute|#': + case 'minute|0': + case 'second|#': + case 'second|0': + spec = { + component: tag, + type: 'number', + size: 2, + zeropad: params[0] === '0' + }; + break; + + case 'hour|12': + case 'hour|012': + spec = { + component: 'hour12', + type: 'number', + size: 2, + zeropad: params[0] === '012' + }; + break; + + case 'hour|period': + spec = { + component: 'hour12period', + type: 'boolean', + values: this.hour12Periods + }; + break; + + case 'millisecond|#': + case 'millisecond|0': + spec = { + component: 'millisecond', + type: 'number', + size: 3, + zeropad: params[0] === '0' + }; + break; + + default: + return OO.ui.ProlepticGregorianDateTimeFormatter.super.prototype.getFieldForTag.call( this, tag, params ); + } + + if ( spec ) { + if ( spec.editable === undefined ) { + spec.editable = true; + } + spec.formatValue = this.formatSpecValue; + spec.parseValue = this.parseSpecValue; + if ( spec.values ) { + spec.size = Math.max.apply( + null, $.map( spec.values, function ( v ) { return v.length; } ) + ); + } + } + + return spec; +}; + +/** + * Get components from a Date object + * + * Components are: + * - year {number} + * - month {number} (1-12) + * - day {number} (1-31) + * - dow {number} (0-6, 0 is Sunday) + * - hour {number} (0-23) + * - hour12 {number} (1-12) + * - hour12period {boolean} + * - minute {number} (0-59) + * - second {number} (0-59) + * - millisecond {number} (0-999) + * - zone {number} + * + * @public + * @param {Date|null} date + * @return {Object} Components + */ +OO.ui.ProlepticGregorianDateTimeFormatter.prototype.getComponentsFromDate = function ( date ) { + var ret; + + if ( !( date instanceof Date ) ) { + date = this.defaultDate; + } + + if ( this.local ) { + ret = { + year: date.getFullYear(), + month: date.getMonth() + 1, + day: date.getDate(), + dow: date.getDay() % 7, + hour: date.getHours(), + minute: date.getMinutes(), + second: date.getSeconds(), + millisecond: date.getMilliseconds(), + zone: date.getTimezoneOffset() + }; + } else { + ret = { + year: date.getUTCFullYear(), + month: date.getUTCMonth() + 1, + day: date.getUTCDate(), + dow: date.getUTCDay() % 7, + hour: date.getUTCHours(), + minute: date.getUTCMinutes(), + second: date.getUTCSeconds(), + millisecond: date.getUTCMilliseconds(), + zone: 0 + }; + } + + ret.hour12period = ret.hour >= 12 ? 1 : 0; + ret.hour12 = ret.hour % 12; + if ( ret.hour12 === 0 ) { + ret.hour12 = 12; + } + + return ret; +}; + +/** + * @inheritdoc + */ +OO.ui.ProlepticGregorianDateTimeFormatter.prototype.getDateFromComponents = function ( components ) { + var date = new Date(); + + components = $.extend( {}, components ); + if ( components.hour === undefined && components.hour12 !== undefined && components.hour12period !== undefined ) { + components.hour = ( components.hour12 % 12 ) + ( components.hour12period ? 12 : 0 ); + } + components = $.extend( {}, this.getComponentsFromDate( null ), components ); + + if ( components.zone ) { + // Can't just use the constructor because that's stupid about ancient years. + date.setFullYear( components.year, components.month - 1, components.day ); + date.setHours( components.hour, components.minute, components.second, components.millisecond ); + } else { + // Date.UTC() is stupid about ancient years too. + date.setUTCFullYear( components.year, components.month - 1, components.day ); + date.setUTCHours( components.hour, components.minute, components.second, components.millisecond ); + } + + return date; +}; + +/** + * @inheritdoc + */ +OO.ui.ProlepticGregorianDateTimeFormatter.prototype.adjustComponent = function ( date, component, delta, mode ) { + if ( !( date instanceof Date ) ) { + date = this.defaultDate; + } + + var min, max, range, + components = this.getComponentsFromDate( date ); + + switch ( component ) { + case 'year': + min = 0; + max = 9999; + break; + case 'month': + min = 1; + max = 12; + break; + case 'day': + min = 1; + max = this.getDaysInMonth( components.month, components.year ); + break; + case 'hour': + min = 0; + max = 23; + break; + case 'minute': + case 'second': + min = 0; + max = 59; + break; + case 'millisecond': + min = 0; + max = 999; + break; + case 'hour12period': + component = 'hour'; + min = 0; + max = 23; + delta *= 12; + break; + case 'hour12': + component = 'hour'; + min = components.hour12period ? 12 : 0; + max = components.hour12period ? 23 : 11; + break; + default: + return new Date( date.getTime() ); + } + + components[component] += delta; + range = max - min + 1; + switch ( mode ) { + case 'overflow': + // Date() will mostly handle it automatically. But months need + // manual handling to prevent e.g. Jan 31 => Mar 3. + if ( component === 'month' || component === 'year' ) { + while ( components.month < 1 ) { + components[component] += 12; + components.year--; + } + while ( components.month > 12 ) { + components[component] -= 12; + components.year++; + } + } + break; + case 'wrap': + while ( components[component] < min ) { + components[component] += range; + } + while ( components[component] > max ) { + components[component] -= range; + } + break; + case 'clip': + if ( components[component] < min ) { + components[component] = min; + } + if ( components[component] < max ) { + components[component] = max; + } + break; + } + if ( component === 'month' || component === 'year' ) { + components.day = Math.min( components.day, this.getDaysInMonth( components.month, components.year ) ); + } + + return this.getDateFromComponents( components ); +}; + +/** + * Get the number of days in a month + * + * @protected + * @param {number} month + * @param {number} year + * @return {number} + */ +OO.ui.ProlepticGregorianDateTimeFormatter.prototype.getDaysInMonth = function ( month, year ) { + switch ( month ) { + case 4: + case 6: + case 9: + case 11: + return 30; + case 2: + if ( year % 4 ) { + return 28; + } else if ( year % 100 ) { + return 29; + } + return ( year % 400 ) ? 28 : 29; + default: + return 31; + } +}; + +/** + * @inheritdoc + */ +OO.ui.ProlepticGregorianDateTimeFormatter.prototype.getCalendarHeadings = function () { + var a = this.dayLetters; + + if ( this.weekStartsOn ) { + return a.slice( this.weekStartsOn ).concat( a.slice( 0, this.weekStartsOn ) ); + } else { + return a.slice( 0 ); // clone + } +}; + +/** + * @inheritdoc + */ +OO.ui.ProlepticGregorianDateTimeFormatter.prototype.sameCalendarGrid = function ( date1, date2 ) { + if ( this.local ) { + return date1.getFullYear() === date2.getFullYear() && date1.getMonth() === date2.getMonth(); + } else { + return date1.getUTCFullYear() === date2.getUTCFullYear() && date1.getUTCMonth() === date2.getUTCMonth(); + } +}; + +/** + * @inheritdoc + */ +OO.ui.ProlepticGregorianDateTimeFormatter.prototype.getCalendarData = function ( date, min, max, selected ) { + var dt, t, d, e, i, row, + helpers = this.getCalendarDataHelpers( min, max, selected ), + getDate = this.local ? 'getDate' : 'getUTCDate', + setDate = this.local ? 'setDate' : 'setUTCDate', + ret = {}; + + if ( !( date instanceof Date ) ) { + date = this.defaultDate; + } + + dt = new Date( date.getTime() ); + dt[setDate]( 1 ); + t = dt.getTime(); + + ret.prev = new Date( t ); + ret.next = new Date( t ); + if ( this.local ) { + ret.header = this.fullMonthNames[dt.getMonth() + 1] + ' ' + dt.getFullYear(); + ret.prev.setMonth( ret.prev.getMonth() - 1 ); + ret.next.setMonth( ret.next.getMonth() + 1 ); + d = dt.getDay() % 7; + e = this.getDaysInMonth( dt.getMonth() + 1, dt.getFullYear() ); + } else { + ret.header = this.fullMonthNames[dt.getUTCMonth() + 1] + ' ' + dt.getUTCFullYear(); + ret.prev.setUTCMonth( ret.prev.getUTCMonth() - 1 ); + ret.next.setUTCMonth( ret.next.getUTCMonth() + 1 ); + d = dt.getUTCDay() % 7; + e = this.getDaysInMonth( dt.getUTCMonth() + 1, dt.getUTCFullYear() ); + } + + if ( this.weekStartsOn ) { + d = ( d + 7 - this.weekStartsOn ) % 7; + } + d = 1 - d; + + ret.rows = []; + while ( d <= e ) { + row = []; + for ( i = 0; i < 7; i++, d++ ) { + dt = new Date( t ); + dt[setDate]( d ); + row[i] = { + display: String( dt[getDate]() ), + date: dt, + extra: d < 1 ? 'prev' : d > e ? 'next' : null, + inrange: helpers.inrange( dt ), + selected: helpers.selected( dt ) + }; + } + ret.rows.push( row ); + } + + return ret; +}; diff --git a/src/core.js b/src/core.js index 09a5502..0a566ff 100644 --- a/src/core.js +++ b/src/core.js @@ -237,7 +237,57 @@ // Default placeholder for file selection widgets 'ooui-selectfile-placeholder': 'No file is selected', // Semicolon separator - 'ooui-semicolon-separator': '; ' + 'ooui-semicolon-separator': '; ', + + // Weekday names + 'ooui-sunday': 'Sunday', + 'ooui-monday': 'Monday', + 'ooui-tuesday': 'Tuesday', + 'ooui-wednesday': 'Wednesday', + 'ooui-thursday': 'Thursday', + 'ooui-friday': 'Friday', + 'ooui-saturday': 'Saturday', + // Weekday abbreviations + 'ooui-sun': 'Sun', + 'ooui-mon': 'Mon', + 'ooui-tue': 'Tue', + 'ooui-wed': 'Wed', + 'ooui-thu': 'Thu', + 'ooui-fri': 'Fri', + 'ooui-sat': 'Sat', + // Month names + 'ooui-january': 'January', + 'ooui-february': 'February', + 'ooui-march': 'March', + 'ooui-april': 'April', + // jscs:disable requireCamelCaseOrUpperCaseIdentifiers + 'ooui-may_long': 'May', + // jscs:enable + 'ooui-june': 'June', + 'ooui-july': 'July', + 'ooui-august': 'August', + 'ooui-september': 'September', + 'ooui-october': 'October', + 'ooui-november': 'November', + 'ooui-december': 'December', + // Month abbreviations + 'ooui-jan': 'Jan', + 'ooui-feb': 'Feb', + 'ooui-mar': 'Mar', + 'ooui-apr': 'Apr', + 'ooui-may': 'May', + 'ooui-jun': 'Jun', + 'ooui-jul': 'Jul', + 'ooui-aug': 'Aug', + 'ooui-sep': 'Sep', + 'ooui-oct': 'Oct', + 'ooui-nov': 'Nov', + 'ooui-dec': 'Dec', + // Other date and time indicators + 'ooui-period-am': 'AM', + 'ooui-period-pm': 'PM', + 'ooui-timezone-utc': 'UTC', + 'ooui-timezone-local': 'Local' }; /** diff --git a/src/styles/core.less b/src/styles/core.less index f284925..283e277 100644 --- a/src/styles/core.less +++ b/src/styles/core.less @@ -68,6 +68,7 @@ @import 'widgets/LabelWidget.less'; @import 'widgets/IconWidget.less'; @import 'widgets/IndicatorWidget.less'; +@import 'widgets/CalendarWidget.less'; @import 'widgets/ButtonWidget.less'; @import 'widgets/ButtonGroupWidget.less'; @@ -86,6 +87,7 @@ @import 'widgets/InputWidget.less'; @import 'widgets/ButtonInputWidget.less'; @import 'widgets/CheckboxInputWidget.less'; +@import 'widgets/DateTimeInputWidget.less'; @import 'widgets/DropdownInputWidget.less'; @import 'widgets/RadioInputWidget.less'; @import 'widgets/RadioSelectInputWidget.less'; diff --git a/src/styles/theme.less b/src/styles/theme.less index cb8167d..ba0d7ba 100644 --- a/src/styles/theme.less +++ b/src/styles/theme.less @@ -67,6 +67,7 @@ .theme-oo-ui-inputWidget () {} .theme-oo-ui-buttonInputWidget () {} .theme-oo-ui-checkboxInputWidget () {} +.theme-oo-ui-dateTimeInputWidget () {} .theme-oo-ui-dropdownInputWidget () {} .theme-oo-ui-radioInputWidget () {} .theme-oo-ui-radioSelectInputWidget () {} diff --git a/src/styles/widgets/CalendarWidget.less b/src/styles/widgets/CalendarWidget.less new file mode 100644 index 0000000..bff60ba --- /dev/null +++ b/src/styles/widgets/CalendarWidget.less @@ -0,0 +1,25 @@ +@import '../common'; + +.oo-ui-calendarWidget { + display: inline-block; + position: relative; + vertical-align: middle; + padding: .5em; + + &.oo-ui-calendarWidget-dependent { + display: block; + position: absolute; + z-index: 4; + } + + &-grid { + table-layout: fixed; + + .oo-ui-calendarWidget-cell { + display: table-cell; + white-space: nowrap; + } + } + + .theme-oo-ui-calendarWidget(); +} diff --git a/src/styles/widgets/DateTimeInputWidget.less b/src/styles/widgets/DateTimeInputWidget.less new file mode 100644 index 0000000..7009ab3 --- /dev/null +++ b/src/styles/widgets/DateTimeInputWidget.less @@ -0,0 +1,43 @@ +@import '../common'; + +.oo-ui-dateTimeInputWidget { + display: inline-block; + position: relative; + vertical-align: middle; + + &-fields { + position: relative; + display: table; + z-index: 2; + .oo-ui-unselectable(); + + > .oo-ui-dateTimeInputWidget-field { + .oo-ui-box-sizing(border-box); + + display: table-cell; + white-space: pre; + } + } + + &-handle { + width: 100%; + display: inline-block; + overflow: hidden; + + // Needed for proper behavior with overflow: hidden. + vertical-align: bottom; + + .oo-ui-unselectable(); + .oo-ui-box-sizing(border-box); + + > .oo-ui-indicatorElement-indicator, + > .oo-ui-iconElement-icon { + position: absolute; + background-position: center center; + background-repeat: no-repeat; + z-index: 1; + } + } + + .theme-oo-ui-dateTimeInputWidget(); +} diff --git a/src/themes/apex/widgets.less b/src/themes/apex/widgets.less index fb3b1b7..39d05db 100644 --- a/src/themes/apex/widgets.less +++ b/src/themes/apex/widgets.less @@ -101,6 +101,55 @@ } } +.theme-oo-ui-calendarWidget () { + background-color: white; + border: 1px solid rgba(0,0,0,0.1); + + &.oo-ui-calendarWidget-dependent { + margin-top: -1px; + border-top: 1px solid white; + } + + &-heading { + text-align: center; + vertical-align: middle; + font-weight: bold; + white-space: nowrap; + + .oo-ui-calendarWidget-previous { + float: left; + } + .oo-ui-calendarWidget-next { + float: right; + } + } + + &-grid { + margin: 0 auto; + + .oo-ui-calendarWidget-cell { + text-align: center; + + .oo-ui-buttonElement-button { + width: 100%; + border: none; + .oo-ui-box-sizing( border-box ); + } + + &.oo-ui-calendarWidget-extra .oo-ui-buttonElement-button .oo-ui-labelElement-label { + color: #bbb; + } + + &.oo-ui-calendarWidget-selected .oo-ui-buttonElement-button { + .oo-ui-vertical-gradient(#fff, #def); + .oo-ui-labelElement-label { + color: #38f; + } + } + } + } +} + .theme-oo-ui-dropdownWidget () { margin: 0.25em 0; width: 100%; @@ -289,6 +338,115 @@ .theme-oo-ui-checkboxInputWidget () {} +.theme-oo-ui-dateTimeInputWidget () { + margin: 0.25em 0; + width: 100%; + max-width: 50em; + + .oo-ui-inline-spacing(0.5em); + + &-handle { + height: 2.5em; + border: 1px solid #ccc; + padding: 0 1em; + margin: 0; + background-color: #fff; + color: black; + border: solid 1px rgba(0,0,0,0.1); + box-shadow: 0 0 0 white, inset 0 0.1em 0.2em #ddd; + border-radius: 0.25em; + .oo-ui-transition(border-color 200ms, box-shadow 200ms); + .oo-ui-box-sizing(border-box); + + > .oo-ui-indicatorElement-indicator { + right: 0; + } + + > .oo-ui-iconElement-icon { + left: 0.25em; + } + + > .oo-ui-indicatorElement-indicator { + top: 0; + width: @indicator-size; + height: @indicator-size; + margin: 0.775em; + } + + > .oo-ui-iconElement-icon { + top: 0; + width: @icon-size; + height: @icon-size; + margin: 0.3em; + } + } + + &-empty &-handle { + color: #777; + } + + &-field { + padding: 0; + margin: 0; + font-size: inherit; + font-family: inherit; + background-color: transparent; + color: inherit; + border: none; + box-shadow: none; + text-align: center; + .oo-ui-box-sizing(border-box); + } + + &.oo-ui-widget-disabled { + .oo-ui-dateTimeInputWidget-handle { + color: #ccc; + text-shadow: 0 1px 1px #fff; + border-color: #ddd; + background-color: #f3f3f3; + + > .oo-ui-iconElement-icon, + > .oo-ui-indicatorElement-indicator { + opacity: 0.2; + } + } + } + + &.oo-ui-widget-enabled { + .oo-ui-dateTimeInputWidget-editField:hover { + background-color: #eee; + } + + &.oo-ui-flaggedElement-invalid { + .oo-ui-dateTimeInputWidget-handle { + background-color: #fdd; + } + } + } + + input.oo-ui-dateTimeInputWidget-field { + padding: 0.5em 0; + } + + &-editField.oo-ui-dateTimeInputWidget-invalid { + border: 1px solid red; + box-shadow: inset 0 0 0 0 red; + + &:focus { + border: 1px solid red; + box-shadow: inset 0 0 0 0.1em red; + } + } + + &.oo-ui-iconElement .oo-ui-dateTimeInputWidget-handle { + padding-left: 3em; + } + + &.oo-ui-indicatorElement .oo-ui-dateTimeInputWidget-handle { + padding-right: 2em; + } +} + .theme-oo-ui-dropdownInputWidget () { width: 100%; max-width: 50em; diff --git a/src/themes/blank/widgets.less b/src/themes/blank/widgets.less index e0e4c88..0f65f18 100644 --- a/src/themes/blank/widgets.less +++ b/src/themes/blank/widgets.less @@ -20,6 +20,8 @@ .theme-oo-ui-indicatorWidget () {} +.theme-oo-ui-calendarWidget () {} + .theme-oo-ui-dropdownWidget () {} .theme-oo-ui-selectFileWidget () {} @@ -30,6 +32,8 @@ .theme-oo-ui-checkboxInputWidget () {} +.theme-oo-ui-dateTimeInputWidget () {} + .theme-oo-ui-dropdownInputWidget () {} .theme-oo-ui-radioInputWidget () {} diff --git a/src/themes/mediawiki/widgets.less b/src/themes/mediawiki/widgets.less index d2e3ee9..382e85e 100644 --- a/src/themes/mediawiki/widgets.less +++ b/src/themes/mediawiki/widgets.less @@ -101,6 +101,55 @@ } } +.theme-oo-ui-calendarWidget () { + background-color: white; + border: 1px solid #ccc; + + &.oo-ui-calendarWidget-dependent { + margin-top: -1px; + border-top: 1px solid white; + } + + &-heading { + text-align: center; + vertical-align: middle; + font-weight: bold; + white-space: nowrap; + + .oo-ui-calendarWidget-previous { + float: left; + } + .oo-ui-calendarWidget-next { + float: right; + } + } + + &-grid { + margin: 0 auto; + + .oo-ui-calendarWidget-cell { + text-align: center; + + .oo-ui-buttonElement-button { + width: 100%; + border: none; + .oo-ui-box-sizing( border-box ); + } + + &.oo-ui-calendarWidget-extra .oo-ui-buttonElement-button .oo-ui-labelElement-label { + color: #bbb; + } + + &.oo-ui-calendarWidget-selected .oo-ui-buttonElement-button { + background-color: #def; + .oo-ui-labelElement-label { + color: #38f; + } + } + } + } +} + .theme-oo-ui-dropdownWidget () { margin: 0.25em 0; width: 100%; @@ -371,6 +420,121 @@ } } +.theme-oo-ui-dateTimeInputWidget () { + margin: 0.25em 0; + width: 100%; + max-width: 50em; + + .oo-ui-inline-spacing(0.5em); + + &-handle { + height: 2.5em; + border: 1px solid #ccc; + padding: 0 1em; + margin: 0; + background-color: #fff; + color: black; + border: solid 1px #ccc; + box-shadow: inset 0 0 0 0 @progressive; + border-radius: 0.1em; + .oo-ui-transition(box-shadow @quick-ease); + .oo-ui-box-sizing(border-box); + + > .oo-ui-indicatorElement-indicator { + right: 0; + } + + > .oo-ui-iconElement-icon { + left: 0.25em; + } + + > .oo-ui-indicatorElement-indicator { + top: 0; + width: @indicator-size; + height: @indicator-size; + margin: 0.775em; + } + + > .oo-ui-iconElement-icon { + top: 0; + width: @icon-size; + height: @icon-size; + margin: 0.3em; + } + } + + &-empty &-handle { + color: #777; + } + + &-field { + padding: 0; + margin: 0; + font-size: inherit; + font-family: inherit; + background-color: transparent; + color: inherit; + border: none; + box-shadow: none; + text-align: center; + .oo-ui-box-sizing(border-box); + } + + &.oo-ui-widget-disabled { + .oo-ui-dateTimeInputWidget-handle { + color: #ccc; + text-shadow: 0 1px 1px #fff; + border-color: #ddd; + background-color: #f3f3f3; + + > .oo-ui-iconElement-icon, + > .oo-ui-indicatorElement-indicator { + opacity: 0.2; + } + } + } + + &.oo-ui-widget-enabled { + .oo-ui-dateTimeInputWidget-editField:hover { + background-color: #eee; + } + + &.oo-ui-flaggedElement-invalid { + .oo-ui-dateTimeInputWidget-handle { + border-color: red; + box-shadow: inset 0 0 0 0 red; + } + + .oo-ui-dateTimeInputWidget-handle:focus { + border-color: red; + box-shadow: inset 0 0 0 0.1em red; + } + } + } + + input.oo-ui-dateTimeInputWidget-field { + padding: 0.5em 0; + } + + &-editField.oo-ui-dateTimeInputWidget-invalid { + border: 1px solid red; + box-shadow: inset 0 0 0 0 red; + + &:focus { + border: 1px solid red; + box-shadow: inset 0 0 0 0.1em red; + } + } + + &.oo-ui-iconElement .oo-ui-dateTimeInputWidget-handle { + padding-left: 3em; + } + + &.oo-ui-indicatorElement .oo-ui-dateTimeInputWidget-handle { + padding-right: 2em; + } +} + .theme-oo-ui-dropdownInputWidget () { width: 100%; max-width: 50em; diff --git a/src/widgets/CalendarWidget.js b/src/widgets/CalendarWidget.js new file mode 100644 index 0000000..ce2c404 --- /dev/null +++ b/src/widgets/CalendarWidget.js @@ -0,0 +1,401 @@ +/** + * CalendarWidget displays a calendar that can be used to select a date. It + * uses {@link OO.ui.DateTimeFormatter DateTimeFormatter} to get the details of + * the calendar. + * + * This widget is mainly intended to be used as a popup from a + * {@link OO.ui.DateTimeInputWidget DateTimeInputWidget}, but may also be used + * standalone. + * + * @class + * @extends OO.ui.Widget + * + * @constructor + * @param {Object} [config] Configuration options + * @cfg {Object|OO.ui.DateTimeFormatter} [formatter={}] Configuration options for + * OO.ui.ProlepticGregorianDateTimeFormatter, or an OO.ui.DateTimeFormatter + * instance to use. + * @cfg {OO.ui.Widget|null} [widget=null] Widget associated with the calendar. + * Specifying this configures the calendar to be used as a popup from the + * specified widget (e.g. absolute positioning, automatic hiding when clicked + * outside). + * @cfg {Date|null} [min=null] Minimum allowed date + * @cfg {Date|null} [max=null] Maximum allowed date + * @cfg {Date} [current] Current date. + * @cfg {Date|Date[]|null} [selected=null] Selected date(s). + */ +OO.ui.CalendarWidget = function OoUiCalendarWidget( config ) { + var $colgroup, $headTR, headings, i; + + // Configuration initialization + config = $.extend( { + min: null, + max: null, + current: new Date(), + selected: null, + formatter: {} + }, config ); + + // Parent constructor + OO.ui.CalendarWidget.super.call( this, config ); + + // Properties + if ( config.min instanceof Date && config.min.getTime() >= -62167219200000 ) { + this.min = config.min; + } else { + this.min = new Date( -62167219200000 ); // 0000-01-01T00:00:00.000Z + } + if ( config.max instanceof Date && config.max.getTime() <= 253402300799999 ) { + this.max = config.max; + } else { + this.max = new Date( 253402300799999 ); // 9999-12-31T12:59:59.999Z + } + + if ( config.current instanceof Date ) { + this.current = config.current; + } else { + this.current = new Date(); + } + + this.selected = []; + + if ( config.formatter instanceof OO.ui.DateTimeFormatter ) { + this.formatter = config.formatter; + } else if ( $.isPlainObject( config.formatter ) ) { + this.formatter = new OO.ui.ProlepticGregorianDateTimeFormatter( config.formatter ); + } else { + throw new Error( '"formatter" must be an OO.ui.DateTimeFormatter or a plain object' ); + } + + this.calendarData = null; + + this.widget = config.widget; + this.$widget = config.widget ? config.widget.$element : null; + this.onDocumentMouseDownHandler = this.onDocumentMouseDown.bind( this ); + + this.$head = $( '<div>' ); + this.$header = $( '<span>' ); + this.$table = $( '<table>' ); + this.cols = []; + this.colNullable = []; + this.headings = []; + this.$tableBody = $( '<tbody>' ); + this.rows = []; + this.buttons = {}; + this.minWidth = 1; + + // Initialization + this.$head + .addClass( 'oo-ui-calendarWidget-heading' ) + .append( + new OO.ui.ButtonWidget( { + icon: 'previous', + framed: false, + classes: [ 'oo-ui-calendarWidget-previous' ] + } ).connect( this, { click: 'onPrevClick' } ).$element, + new OO.ui.ButtonWidget( { + icon: 'next', + framed: false, + classes: [ 'oo-ui-calendarWidget-next' ] + } ).connect( this, { click: 'onNextClick' } ).$element, + this.$header + ); + $colgroup = $( '<colgroup>' ); + $headTR = $( '<tr>' ); + this.$table + .addClass( 'oo-ui-calendarWidget-grid' ) + .append( $colgroup ) + .append( $( '<thead>' ).append( $headTR ) ) + .append( this.$tableBody ); + + headings = this.formatter.getCalendarHeadings(); + for ( i = 0; i < headings.length; i++ ) { + this.cols[i] = $( '<col>' ); + this.headings[i] = $( '<th>' ); + this.colNullable[i] = headings[i] === null; + if ( headings[i] !== null ) { + this.headings[i].text( headings[i] ); + this.minWidth = Math.max( this.minWidth, headings[i].length ); + } + $colgroup.append( this.cols[i] ); + $headTR.append( this.headings[i] ); + } + + this.setSelected( config.selected ); + this.$element + .addClass( 'oo-ui-calendarWidget' ) + .append( this.$head, this.$table ); + + if ( this.widget ) { + this.$element.addClass( 'oo-ui-calendarWidget-dependent' ); + + // Initially hidden - using #toggle may cause errors if subclasses override toggle with methods + // that reference properties not initialized at that time of parent class construction + // TODO: Find a better way to handle post-constructor setup + this.visible = false; + this.$element.addClass( 'oo-ui-element-hidden' ); + } else { + this.updateUI(); + } +}; + +/* Setup */ + +OO.inheritClass( OO.ui.CalendarWidget, OO.ui.Widget ); + +/* Events */ + +/** + * A `change` event is emitted when the selected dates change + * + * @event change + */ + +/** + * A `page` event is emitted when the current "month" changes + * + * @event page + */ + +/* Methods */ + +/** + * Return the current selected dates + * + * @return {Date[]} + */ +OO.ui.CalendarWidget.prototype.getSelected = function () { + return this.selected; +}; + +/** + * Set the selected dates + * + * @param {Date|Date[]|null} dates + * @fires change + * @chainable + */ +OO.ui.CalendarWidget.prototype.setSelected = function ( dates ) { + var i, changed = false; + + if ( dates instanceof Date ) { + dates = [ dates ]; + } else if ( Array.isArray( dates ) ) { + dates = $.grep( dates, function ( dt ) { return dt instanceof Date; } ); + dates.sort(); + } else { + dates = []; + } + + if ( this.selected.length !== dates.length ) { + changed = true; + } else { + for ( i = 0; i < dates.length; i++ ) { + if ( dates[i].getTime() !== this.selected[i].getTime() ) { + changed = true; + break; + } + } + } + + if ( changed ) { + this.selected = dates; + this.emit( 'change', dates ); + this.updateUI(); + } + + return this; +}; + +/** + * Return a date in the current "month" + * + * The specific date chosen is not defined. + * + * @return {Date} + */ +OO.ui.CalendarWidget.prototype.getCurrent = function () { + return this.current; +}; + +/** + * Set the current "month" + * + * @param {Date} date + * @fires page + * @chainable + */ +OO.ui.CalendarWidget.prototype.setCurrent = function ( date ) { + var changed = false; + + if ( !this.formatter.sameCalendarGrid( this.current, date ) ) { + changed = true; + } else if ( !this.formatter.timePartIsEqual( this.current, date ) ) { + date = this.formatter.mergeDateAndTime( this.current, date ); + changed = true; + } + + if ( changed ) { + this.current = date; + this.emit( 'page', date ); + this.updateUI(); + } + + return this; +}; + +/** + * Update the user interface + * + * @protected + */ +OO.ui.CalendarWidget.prototype.updateUI = function () { + var r, c, row, day, k, $cell, + width = this.minWidth, + nullCols = []; + + this.calendarData = this.formatter.getCalendarData( this.current, this.min, this.max, this.selected ); + + this.$header.text( this.calendarData.header ); + + for ( c = 0; c < this.colNullable.length; c++ ) { + nullCols[c] = this.colNullable[c]; + if ( nullCols[c] ) { + for ( r = 0; r < this.calendarData.rows.length; r++ ) { + if ( this.calendarData.rows[r][c] ) { + nullCols[c] = false; + break; + } + } + } + } + + this.$tableBody.children().detach(); + for ( r = 0; r < this.calendarData.rows.length; r++ ) { + if ( !this.rows[r] ) { + this.rows[r] = $( '<tr>' ); + } else { + this.rows[r].children().detach(); + } + this.$tableBody.append( this.rows[r] ); + row = this.calendarData.rows[r]; + for ( c = 0; c < row.length; c++ ) { + day = row[c]; + if ( day === null ) { + k = 'empty-' + r + '-' + c; + if ( !this.buttons[k] ) { + this.buttons[k] = $( '<td>' ); + } + $cell = this.buttons[k]; + $cell.toggleClass( 'oo-ui-element-hidden', nullCols[c] ); + } else { + k = ( day.extra ? day.extra : '' ) + day.display; + width = Math.max( width, day.display.length ); + if ( !this.buttons[k] ) { + this.buttons[k] = new OO.ui.ButtonWidget( { + classes: [ + 'oo-ui-calendarWidget-cell', + day.extra ? 'oo-ui-calendarWidget-extra' : '' + ], + framed: true, + label: day.display + } ); + this.buttons[k].connect( this, { click: [ 'onDayClick', this.buttons[k] ] } ); + } + this.buttons[k] + .setData( day.date ) + .setDisabled( !day.inrange ); + $cell = this.buttons[k].$element; + $cell.toggleClass( 'oo-ui-calendarWidget-selected', day.selected ); + } + this.rows[r].append( $cell ); + } + } + + for ( c = 0; c < this.cols.length; c++ ) { + if ( nullCols[c] ) { + this.cols[c].width( 0 ); + } else { + this.cols[c].width( width + 'em' ); + } + this.cols[c].toggleClass( 'oo-ui-element-hidden', nullCols[c] ); + this.headings[c].toggleClass( 'oo-ui-element-hidden', nullCols[c] ); + } +}; + +/** + * Handles previous button click + * + * @protected + */ +OO.ui.CalendarWidget.prototype.onPrevClick = function () { + if ( this.calendarData && this.calendarData.prev ) { + this.setCurrent( this.calendarData.prev ); + } +}; + +/** + * Handles next button click + * + * @protected + */ +OO.ui.CalendarWidget.prototype.onNextClick = function () { + if ( this.calendarData && this.calendarData.next ) { + this.setCurrent( this.calendarData.next ); + } +}; + +/** + * Handles day button click + * + * @protected + * @param {OO.ui.ButtonWidget} $button + */ +OO.ui.CalendarWidget.prototype.onDayClick = function ( $button ) { + this.setSelected( [ $button.getData() ] ); +}; + +/** + * Handles document mouse down events. + * + * @protected + * @param {jQuery.Event} e Key down event + */ +OO.ui.CalendarWidget.prototype.onDocumentMouseDown = function ( e ) { + if ( this.$widget && + !OO.ui.contains( this.$element[ 0 ], e.target, true ) && + !OO.ui.contains( this.$widget[ 0 ], e.target, true ) + ) { + this.toggle( false ); + } +}; + +/** + * @inheritdoc + */ +OO.ui.CalendarWidget.prototype.toggle = function ( visible ) { + visible = ( visible === undefined ? !this.visible : !!visible ); + + var change = visible !== this.isVisible(); + + // Parent method + OO.ui.CalendarWidget.super.prototype.toggle.call( this, visible ); + + if ( change ) { + if ( visible ) { + // Auto-hide + if ( this.$widget ) { + this.getElementDocument().addEventListener( + 'mousedown', this.onDocumentMouseDownHandler, true + ); + } + this.updateUI(); + } else { + this.getElementDocument().removeEventListener( + 'mousedown', this.onDocumentMouseDownHandler, true + ); + } + } + + return this; +}; diff --git a/src/widgets/DateTimeInputWidget.js b/src/widgets/DateTimeInputWidget.js new file mode 100644 index 0000000..9a050fe --- /dev/null +++ b/src/widgets/DateTimeInputWidget.js @@ -0,0 +1,822 @@ +/** + * DateTimeInputWidgets can be used to input a date, a time, or a date and + * time, in either UTC or the user's local timezone. + * Please see the [OOjs UI documentation on MediaWiki] [1] for more information and examples. + * + * This widget can be used inside a HTML form, such as a OO.ui.FormLayout. + * + * @example + * // Example of a text input widget + * var dateTimeInput = new OO.ui.DateTimeInputWidget( {} ) + * $( 'body' ).append( dateTimeInput.$element ); + * + * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Inputs + * + * @class + * @extends OO.ui.InputWidget + * @mixins OO.ui.IconElement + * @mixins OO.ui.IndicatorElement + * @mixins OO.ui.PendingElement + * + * @constructor + * @param {Object} [config] Configuration options + * @cfg {string} [type='datetime'] Whether to act like a 'date', 'time', or 'datetime' input. + * Affects values stored in the relevant <input> and the formatting and + * interpretation of values passed to/from getValue() and setValue(). It's up + * to the user to configure the DateTimeFormatter correctly. + * @cfg {Object|OO.ui.DateTimeFormatter} [formatter={}] Configuration options for + * OO.ui.ProlepticGregorianDateTimeFormatter (with 'format' defaulting to + * '@date', '@time', or '@datetime' depending on 'type'), or an + * OO.ui.DateTimeFormatter instance to use. + * @cfg {Object|null} [calendar={}] Configuration options for + * OO.ui.CalendarWidget; note certain settings will be forced based on the + * settings passed to this widget. Set null to disable the calendar. + * @cfg {boolean} [required=false] Whether a value is required. + * @cfg {boolean} [clearable=true] Whether to provide for blanking the value. + * @cfg {Date|null} [value=null] Default value for the widget + * @cfg {Date|string|null} [min=null] Minimum allowed date + * @cfg {Date|string|null} [max=null] Maximum allowed date + */ +OO.ui.DateTimeInputWidget = function OoUiDateTimeInputWidget( config ) { + // Configuration initialization + config = $.extend( { + type: 'datetime', + clearable: true, + required: false, + min: null, + max: null, + formatter: {}, + calendar: {} + }, config ); + + if ( $.isPlainObject( config.formatter ) && config.formatter.format === undefined ) { + config.formatter.format = '@' + config.type; + } + + // Parent constructor + OO.ui.DateTimeInputWidget.super.call( this, config ); + + // Mixin constructors + OO.ui.IconElement.call( this, config ); + OO.ui.IndicatorElement.call( this, config ); + OO.ui.PendingElement.call( this, config ); + + // Properties + this.type = config.type; + this.$handle = $( '<span>' ); + this.$fields = $( '<span>' ); + this.fields = []; + this.clearable = !!config.clearable; + this.required = !!config.required; + + if ( typeof config.min === 'string' ) { + config.min = this.parseDateValue( config.min ); + } + if ( config.min instanceof Date && config.min.getTime() >= -62167219200000 ) { + this.min = config.min; + } else { + this.min = new Date( -62167219200000 ); // 0000-01-01T00:00:00.000Z + } + + if ( typeof config.max === 'string' ) { + config.max = this.parseDateValue( config.max ); + } + if ( config.max instanceof Date && config.max.getTime() <= 253402300799999 ) { + this.max = config.max; + } else { + this.max = new Date( 253402300799999 ); // 9999-12-31T12:59:59.999Z + } + + switch ( this.type ) { + case 'date': + this.min.setUTCHours( 0, 0, 0, 0 ); + this.max.setUTCHours( 23, 59, 59, 999 ); + break; + case 'time': + this.min.setUTCFullYear( 1970, 0, 1 ); + this.max.setUTCFullYear( 1970, 0, 1 ); + break; + } + if ( this.min > this.max ) { + throw new Error( + '"min" (' + this.min.toISOString() + ') must not be greater than ' + + '"max" (' + this.max.toISOString() + ')' + ); + } + + if ( config.formatter instanceof OO.ui.DateTimeFormatter ) { + this.formatter = config.formatter; + } else if ( $.isPlainObject( config.formatter ) ) { + this.formatter = new OO.ui.ProlepticGregorianDateTimeFormatter( config.formatter ); + } else { + throw new Error( '"formatter" must be an OO.ui.DateTimeFormatter or a plain object' ); + } + + if ( this.type === 'time' || config.calendar === null ) { + this.calendar = null; + } else { + config.calendar = $.extend( {}, config.calendar, { + formatter: this.formatter, + widget: this, + min: this.min, + max: this.max + } ); + this.calendar = new OO.ui.CalendarWidget( config.calendar ); + } + + // Events + this.$handle.on( { + click: this.onHandleClick.bind( this ) + } ); + this.connect( this, { + change: 'onChange' + } ); + this.formatter.connect( this, { + local: 'onChange' + } ); + if ( this.calendar ) { + this.calendar.connect( this, { + change: 'onCalendarChange' + } ); + } + + // Initialization + this.setTabIndex( -1 ); + + this.$fields.addClass( 'oo-ui-dateTimeInputWidget-fields' ); + this.setupFields(); + + this.$handle + .addClass( 'oo-ui-dateTimeInputWidget-handle' ) + .append( this.$icon, this.$indicator, this.$fields ); + + this.$element + .addClass( 'oo-ui-dateTimeInputWidget' ) + .append( this.$handle ); + + if ( this.calendar ) { + this.$element.append( this.calendar.$element ); + } +}; + +/* Setup */ + +OO.inheritClass( OO.ui.DateTimeInputWidget, OO.ui.InputWidget ); +OO.mixinClass( OO.ui.DateTimeInputWidget, OO.ui.IconElement ); +OO.mixinClass( OO.ui.DateTimeInputWidget, OO.ui.IndicatorElement ); +OO.mixinClass( OO.ui.DateTimeInputWidget, OO.ui.PendingElement ); + +/* Static properties */ + +/* Events */ + +/* Methods */ + +/** + * Convert a date string to a Date + * @private + * @param {string} value + * @return {Date|null} + */ +OO.ui.DateTimeInputWidget.prototype.parseDateValue = function ( value ) { + var date, m; + + value = String( value ); + switch ( this.type ) { + case 'date': + value = value + 'T00:00:00Z'; + break; + case 'time': + value = '1970-01-01T' + value + 'Z'; + break; + } + m = /^(\d{4,})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.(\d{1,3}))?Z$/.exec( value ); + if ( m ) { + if ( m[7] ) { + while ( m[7].length < 3 ) { + m[7] += '0'; + } + } else { + m[7] = 0; + } + date = new Date(); + date.setUTCFullYear( m[1], m[2] - 1, m[3] ); + date.setUTCHours( m[4], m[5], m[6], m[7] ); + if ( date.getTime() < -62167219200000 || date.getTime() > 253402300799999 || + date.getUTCFullYear() !== +m[1] || + date.getUTCMonth() + 1 !== +m[2] || + date.getUTCDate() !== +m[3] || + date.getUTCHours() !== +m[4] || + date.getUTCMinutes() !== +m[5] || + date.getUTCSeconds() !== +m[6] || + date.getUTCMilliseconds() !== +m[7] + ) { + date = null; + } + } else { + date = null; + } + + return date; +}; + +/** + * @inheritdoc + */ +OO.ui.DateTimeInputWidget.prototype.cleanUpValue = function ( value ) { + var date, pad; + + if ( value === '' ) { + return ''; + } + + if ( value instanceof Date ) { + date = value; + } else { + date = this.parseDateValue( value ); + } + + if ( date instanceof Date ) { + pad = function ( v, l ) { + v = String( v ); + while ( v.length < l ) { + v = '0' + v; + } + return v; + }; + + switch ( this.type ) { + case 'date': + value = pad( date.getUTCFullYear(), 4 ) + + '-' + pad( date.getUTCMonth() + 1, 2 ) + + '-' + pad( date.getUTCDate(), 2 ); + break; + + case 'time': + value = pad( date.getUTCHours(), 2 ) + + ':' + pad( date.getUTCMinutes(), 2 ) + + ':' + pad( date.getUTCSeconds(), 2 ) + + '.' + pad( date.getUTCMilliseconds(), 3 ); + value = value.replace( /\.?0+$/, '' ); + break; + + default: + value = date.toISOString(); + break; + } + } else { + value = ''; + } + + return value; +}; + +/** + * Get the value of the input as a Date object + * + * @return {Date|null} + */ +OO.ui.InputWidget.prototype.getValueAsDate = function () { + return this.parseDateValue( this.getValue() ); +}; + +/** + * Set up the UI fields + * @private + */ +OO.ui.DateTimeInputWidget.prototype.setupFields = function () { + var i, $field, spec, placeholder, sz, maxlength, + spanValFunc = function ( v ) { + if ( v === undefined ) { + return this.data( 'oo-ui-dateTimeInputWidget-value' ); + } else { + v = String( v ); + this.data( 'oo-ui-dateTimeInputWidget-value', v ); + if ( v === '' ) { + v = this.data( 'oo-ui-dateTimeInputWidget-placeholder' ); + } + this.text( v ); + return this; + } + }, + reduceFunc = function ( k, v ) { + maxlength = Math.max( maxlength, v ); + }, + disabled = this.isDisabled(), + specs = this.formatter.getFieldSpec(); + + this.$fields.empty(); + this.clearButton = null; + this.fields = []; + + for ( i = 0; i < specs.length; i++ ) { + spec = specs[i]; + if ( typeof spec === 'string' ) { + $( '<span>' ) + .addClass( 'oo-ui-dateTimeInputWidget-field' ) + .text( spec ) + .appendTo( this.$fields ); + continue; + } + + placeholder = ''; + while ( placeholder.length < spec.size ) { + placeholder += '_'; + } + + if ( spec.type === 'number' ) { + // Numbers ''should'' be the same width + sz = spec.size + 'ch'; + } else { + // Add a little for padding + sz = ( spec.size * 1.1 ) + 'ch'; + } + if ( spec.editable && spec.type !== 'static' ) { + if ( spec.type === 'boolean' || spec.type === 'toggleLocal' ) { + $field = $( '<span>' ) + .attr( { + tabindex: disabled ? -1 : 0 + } ) + .width( sz ) + .data( 'oo-ui-dateTimeInputWidget-placeholder', placeholder ); + $field.on( { + keydown: this.onFieldKeyDown.bind( this, $field ), + focus: this.onFieldFocus.bind( this, $field ), + click: this.onFieldClick.bind( this, $field ), + 'wheel mousewheel DOMMouseScroll': this.onFieldWheel.bind( this, $field ) + } ); + $field.val = spanValFunc; + } else { + maxlength = spec.size; + if ( spec.intercalarySize ) { + $.each( spec.intercalarySize, reduceFunc ); + } + $field = $( '<input type="text">' ) + .attr( { + tabindex: disabled ? -1 : 0, + size: spec.size, + maxlength: maxlength + } ) + .prop( { + disabled: disabled, + placeholder: placeholder + } ) + .width( sz ); + $field.on( { + keydown: this.onFieldKeyDown.bind( this, $field ), + click: this.onFieldClick.bind( this, $field ), + focus: this.onFieldFocus.bind( this, $field ), + blur: this.onFieldBlur.bind( this, $field ), + change: this.onFieldChange.bind( this, $field ), + 'wheel mousewheel DOMMouseScroll': this.onFieldWheel.bind( this, $field ) + } ); + } + $field.addClass( 'oo-ui-dateTimeInputWidget-editField' ); + } else { + $field = $( '<span>' ) + .width( sz ) + .data( 'oo-ui-dateTimeInputWidget-placeholder', placeholder ); + if ( spec.type === 'static' ) { + $field.text( spec.value ); + } else { + $field.val = spanValFunc; + } + } + + this.fields.push( $field ); + $field + .addClass( 'oo-ui-dateTimeInputWidget-field' ) + .data( 'oo-ui-dateTimeInputWidget-fieldSpec', spec ) + .appendTo( this.$fields ); + } + + if ( this.clearable ) { + this.clearButton = new OO.ui.ButtonWidget( { + classes: [ 'oo-ui-dateTimeInputWidget-field', 'oo-ui-dateTimeInputWidget-clearButton' ], + framed: false, + icon: 'remove', + disabled: disabled + } ).connect( this, { + click: 'onClearClick' + } ); + this.$fields.append( this.clearButton.$element ); + } + + this.updateFieldsFromValue(); +}; + +/** + * Update the UI fields from the current value + * @private + */ +OO.ui.DateTimeInputWidget.prototype.updateFieldsFromValue = function () { + var i, $field, spec, intercalary, sz, + date = this.getValueAsDate(); + + if ( date === null ) { + this.components = null; + + for ( i = 0; i < this.fields.length; i++ ) { + $field = this.fields[i]; + spec = $field.data( 'oo-ui-dateTimeInputWidget-fieldSpec' ); + + $field + .removeClass( 'oo-ui-dateTimeInputWidget-invalid oo-ui-element-hidden' ) + .val( '' ); + + if ( spec.intercalarySize ) { + if ( spec.type === 'number' ) { + // Numbers ''should'' be the same width + $field.width( spec.size + 'ch' ); + } else { + // Add a little for padding + $field.width( ( spec.size * 1.1 ) + 'ch' ); + } + } + } + + this.setFlags( { invalid: this.required } ); + } else { + this.components = this.formatter.getComponentsFromDate( date ); + intercalary = this.components.intercalary; + + for ( i = 0; i < this.fields.length; i++ ) { + $field = this.fields[i]; + $field.removeClass( 'oo-ui-dateTimeInputWidget-invalid' ); + spec = $field.data( 'oo-ui-dateTimeInputWidget-fieldSpec' ); + if ( spec.type !== 'static' ) { + $field.val( spec.formatValue( this.components[spec.component] ) ); + } + if ( spec.intercalarySize ) { + if ( intercalary && spec.intercalarySize[intercalary] !== undefined ) { + sz = spec.intercalarySize[intercalary]; + } else { + sz = spec.size; + } + $field.toggleClass( 'oo-ui-element-hidden', sz <= 0 ); + if ( spec.type === 'number' ) { + // Numbers ''should'' be the same width + this.fields[i].width( sz + 'ch' ); + } else { + // Add a little for padding + this.fields[i].width( ( sz * 1.1 ) + 'ch' ); + } + } + } + + this.setFlags( { invalid: date < this.min || date > this.max } ); + } + + this.$element.toggleClass( 'oo-ui-dateTimeInputWidget-empty', date === null ); +}; + +/** + * Update the value with data from the UI fields + * @private + */ +OO.ui.DateTimeInputWidget.prototype.updateValueFromFields = function () { + var i, v, $field, spec, curDate, newDate, + components = {}, + anyInvalid = false, + anyEmpty = false, + allEmpty = true; + + for ( i = 0; i < this.fields.length; i++ ) { + $field = this.fields[i]; + spec = $field.data( 'oo-ui-dateTimeInputWidget-fieldSpec' ); + if ( spec.editable ) { + $field.removeClass( 'oo-ui-dateTimeInputWidget-invalid' ); + v = $field.val(); + if ( v === '' ) { + $field.addClass( 'oo-ui-dateTimeInputWidget-invalid' ); + anyEmpty = true; + } else { + allEmpty = false; + v = spec.parseValue( v ); + if ( v === undefined ) { + $field.addClass( 'oo-ui-dateTimeInputWidget-invalid' ); + anyInvalid = true; + } else { + components[spec.component] = v; + } + } + } + } + + if ( allEmpty ) { + for ( i = 0; i < this.fields.length; i++ ) { + this.fields[i].removeClass( 'oo-ui-dateTimeInputWidget-invalid' ); + } + } else if ( anyEmpty ) { + anyInvalid = true; + } + + if ( !anyInvalid ) { + curDate = this.getValueAsDate(); + newDate = this.formatter.getDateFromComponents( components ); + if ( !curDate || !newDate || curDate.getTime() !== newDate.getTime() ) { + this.setValue( newDate ); + } + } +}; + +/** + * Handle change event + * @private + */ +OO.ui.DateTimeInputWidget.prototype.onChange = function () { + var date; + + this.updateFieldsFromValue(); + + if ( this.calendar ) { + date = this.getValueAsDate(); + this.calendar.setSelected( date ); + if ( date ) { + this.calendar.setCurrent( date ); + } + } +}; + +/** + * Handle clear button click event + * @private + */ +OO.ui.DateTimeInputWidget.prototype.onClearClick = function () { + this.blur(); + this.setValue( '' ); +}; + +/** + * Handle click on the widget background + * @private + * @param {jQuery.Event} e Click event + */ +OO.ui.DateTimeInputWidget.prototype.onHandleClick = function () { + this.focus(); + + // If there's a containing <label>, we need to prevent it from stealing our + // click events. Sigh. + return false; +}; + +/** + * Handle key down events on our field inputs. + * + * @private + * @param {jQuery} $field + * @param {jQuery.Event} e Key down event + */ +OO.ui.DateTimeInputWidget.prototype.onFieldKeyDown = function ( $field, e ) { + var spec = $field.data( 'oo-ui-dateTimeInputWidget-fieldSpec' ); + + if ( !this.isDisabled() ) { + switch ( e.which ) { + case OO.ui.Keys.ENTER: + case OO.ui.Keys.SPACE: + if ( spec.type === 'boolean' ) { + this.setValue( + this.formatter.adjustComponent( this.getValueAsDate(), spec.component, 1, 'wrap' ) + ); + return false; + } else if ( spec.type === 'toggleLocal' ) { + this.formatter.toggleLocal(); + } + break; + + case OO.ui.Keys.UP: + case OO.ui.Keys.DOWN: + if ( spec.type === 'toggleLocal' ) { + this.formatter.toggleLocal(); + } else { + this.setValue( + this.formatter.adjustComponent( this.getValueAsDate(), spec.component, + e.keyCode === OO.ui.Keys.UP ? 1 : -1, 'wrap' ) + ); + } + if ( $field.is( ':input' ) ) { + $field.select(); + } + return false; + } + } +}; + +/** + * Handle focus events on our field inputs. + * + * @private + * @param {jQuery} $field + * @param {jQuery.Event} e Focus event + */ +OO.ui.DateTimeInputWidget.prototype.onFieldFocus = function ( $field ) { + if ( !this.isDisabled() ) { + if ( this.getValueAsDate() === null ) { + this.setValue( this.formatter.getDefaultDate() ); + } + if ( $field.is( ':input' ) ) { + $field.select(); + } + + if ( this.calendar ) { + this.calendar.toggle( true ); + } + } +}; + +/** + * Handle click events on our field inputs. + * + * We manually focus the clicked element because we have to cancel the default + * behavior in case of a containing <label>. + * + * @private + * @param {jQuery} $field + * @param {jQuery.Event} e Click event + */ +OO.ui.DateTimeInputWidget.prototype.onFieldClick = function ( $field ) { + var spec = $field.data( 'oo-ui-dateTimeInputWidget-fieldSpec' ); + + if ( !this.isDisabled() ) { + if ( spec.type === 'boolean' ) { + this.setValue( + this.formatter.adjustComponent( this.getValueAsDate(), spec.component, 1, 'wrap' ) + ); + return false; + } else if ( spec.type === 'toggleLocal' ) { + this.formatter.toggleLocal(); + } + + if ( $field[0] !== document.activeElement ) { + $field.focus(); + } + } +}; + +/** + * Handle blur events on our field inputs. + * + * @private + * @param {jQuery} $field + * @param {jQuery.Event} e Blur event + */ +OO.ui.DateTimeInputWidget.prototype.onFieldBlur = function ( $field ) { + var v, date, + spec = $field.data( 'oo-ui-dateTimeInputWidget-fieldSpec' ); + + this.updateValueFromFields(); + + // Normalize + date = this.getValueAsDate(); + if ( !date ) { + $field.val( '' ); + } else { + v = spec.formatValue( this.formatter.getComponentsFromDate( date )[spec.component] ); + if ( v !== $field.val() ) { + $field.val( v ); + } + } +}; + +/** + * Handle change events on our field inputs. + * + * @private + * @param {jQuery} $field + * @param {jQuery.Event} e Change event + */ +OO.ui.DateTimeInputWidget.prototype.onFieldChange = function () { + this.updateValueFromFields(); +}; + +/** + * Handle wheel events on our field inputs. + * + * @private + * @param {jQuery} $field + * @param {jQuery.Event} e Change event + */ +OO.ui.DateTimeInputWidget.prototype.onFieldWheel = function ( $field, e ) { + var delta = 0, + spec = $field.data( 'oo-ui-dateTimeInputWidget-fieldSpec' ); + + if ( this.isDisabled() ) { + return; + } + + // Standard 'wheel' event + if ( e.originalEvent.deltaMode !== undefined ) { + this.sawWheelEvent = true; + } + if ( e.originalEvent.deltaY ) { + delta = -e.originalEvent.deltaY; + } else if ( e.originalEvent.deltaX ) { + delta = e.originalEvent.deltaX; + } + + // Non-standard events + if ( !this.sawWheelEvent ) { + if ( e.originalEvent.wheelDeltaX ) { + delta = -e.originalEvent.wheelDeltaX; + } else if ( e.originalEvent.wheelDeltaY ) { + delta = e.originalEvent.wheelDeltaY; + } else if ( e.originalEvent.wheelDelta ) { + delta = e.originalEvent.wheelDelta; + } else if ( e.originalEvent.detail ) { + delta = -e.originalEvent.detail; + } + } + + if ( delta && spec ) { + if ( spec.type === 'toggleLocal' ) { + this.formatter.toggleLocal(); + } else { + this.setValue( + this.formatter.adjustComponent( this.getValueAsDate(), spec.component, Math.sign( delta ), 'wrap' ) + ); + } + return false; + } +}; + +/** + * Handle calendar change event + * @private + */ +OO.ui.DateTimeInputWidget.prototype.onCalendarChange = function () { + var curDate = this.getValueAsDate(), + newDate = this.calendar.getSelected()[0]; + + if ( newDate ) { + if ( !curDate || newDate.getTime() !== curDate.getTime() ) { + this.setValue( newDate ); + } + } +}; + +/** + * @inheritdoc + * @private + */ +OO.ui.DateTimeInputWidget.prototype.getInputElement = function () { + // I'd much rather use type=hidden here, but browsers have a misfeature + // where label:hover applies the styles for input:hover on the input + // targeted by the label. + var widget = this; + + return $( '<input type="text" />' ) + .attr( { + tabindex: -1, + 'aria-hidden': 'true' + } ) + .addClass( 'oo-ui-element-hidden' ) + .on( { + focus: function () { + widget.focus(); + } + } ); +}; + +/** + * @inheritdoc + */ +OO.ui.DateTimeInputWidget.prototype.setDisabled = function ( disabled ) { + OO.ui.DateTimeInputWidget.super.prototype.setDisabled.call( this, disabled ); + + // Flag all our fields as disabled + if ( this.$fields ) { + this.$fields.find( 'input' ).prop( 'disabled', this.isDisabled() ); + this.$fields.find( '[tabindex]' ).attr( 'tabindex', this.isDisabled() ? -1 : 0 ); + } + + if ( this.clearButton ) { + this.clearButton.setDisabled( disabled ); + } + + return this; +}; + +/** + * @inheritdoc + */ +OO.ui.DateTimeInputWidget.prototype.focus = function () { + if ( !this.$fields.find( document.activeElement ).length ) { + this.$fields.find( '.oo-ui-dateTimeInputWidget-editField' ).first().focus(); + } + return this; +}; + +/** + * @inheritdoc + */ +OO.ui.DateTimeInputWidget.prototype.blur = function () { + this.$fields.find( document.activeElement ).blur(); + return this; +}; + +/** + * @inheritdoc + */ +OO.ui.InputWidget.prototype.simulateLabelClick = function () { + this.focus(); +}; -- To view, visit https://gerrit.wikimedia.org/r/216909 To unsubscribe, visit https://gerrit.wikimedia.org/r/settings Gerrit-MessageType: newchange Gerrit-Change-Id: I442179387e723e54be631975fb41ef0da7642f5c Gerrit-PatchSet: 1 Gerrit-Project: oojs/ui Gerrit-Branch: master Gerrit-Owner: Anomie <bjor...@wikimedia.org> _______________________________________________ MediaWiki-commits mailing list MediaWiki-commits@lists.wikimedia.org https://lists.wikimedia.org/mailman/listinfo/mediawiki-commits