UNPKG

@fooloomanzoo/property-mixins

Version:

mixin for custom elements to extends property mixins for data formats

985 lines (900 loc) 28.8 kB
import { dedupingMixin } from '@polymer/polymer/lib/utils/mixin.js'; import { IntlDatetimeFormatMixin } from './intl-datetime-format-mixin.js'; import { isNegative } from './number-utilities.js'; /** * Regular Expression for parsing a datetime-string */ export const regexpDatetime = /^([+-]?\d+-?\d\d-?\d\d)?(?:T?(?:(\d\d:?\d\d(?::?\d\d(?:\.?\d\d\d)?)?)([+-]\d\d:?\d\d|Z)?)?)$/; /** * Regular Expression for parsing a timezone-string */ export const regexpTimezone = /(?:([+-]\d\d):?(\d\d)|Z)$/; /** * pad a string with 0 * @param {number} n number to pad * @param {number} padLength total length of strings * @return {string} padded string */ export const pad = function(n, padLength) { const sign = n < 0 ? '-' : ''; let str = '' + Math.abs(n); while (str.length < padLength) str = '0' + str; return sign + str; } /** * get date string from date components * @param {number} year * @param {number} month * @param {number} day * @return {string} date string */ export const toDateStringByComponents = function(year, month, day) { return pad(year, year < 0 ? 6 : 4) + '-' + pad(month, 2) + '-' + pad(day, 2); } /** * get time string from date components * @param {number} hour * @param {number} minute * @param {number} second * @param {number} millisecond * @return {string} time string */ export const toTimeStringByComponents = function(hour, minute, second, millisecond) { return pad(hour || 0, 2) + ':' + pad(minute || 0, 2) + (second !== undefined ? (':' + pad(second, 2) + (millisecond !== undefined ? ('.' + pad(millisecond, 3)) : '')) : ''); } /** * @typedef {object} TimeZoneProperties * @property {string} timezone The timezone-string * @property {number} offsetMinutes The offset minutes * @property {number} _timeZoneHours The hours of the timezone * @property {number} _timeZoneMinutes The minutes of the timezone */ /** * compute the timezone properties from given offset minutes * @param {number} offsetMinutes The offset minutes * @return {TimeZoneProperties} The timezone properties */ export const computeTimezone = function(offsetMinutes) { // offset in minute if (isNaN(offsetMinutes)) { return {}; } const offsetIsNegative = isNegative(offsetMinutes); if (offsetMinutes === 0) { return { timezone: '+00:00', offsetMinutes: offsetMinutes, _timeZoneHours: offsetIsNegative ? 0 : -0, _timeZoneMinutes: 0 }; } const hour = (offsetIsNegative ? 1 : -1) * Math.floor(Math.abs(offsetMinutes) / 60), minute = Math.abs(offsetMinutes) % 60; return { timezone: (offsetIsNegative ? '+' : '-') + pad(Math.abs(hour), 2) + ':' + pad(minute, 2), offsetMinutes: offsetMinutes, _timeZoneHours: hour, _timeZoneMinutes: minute } } /** * compute the timezone properties from a given timezone-string * @param {string} timezone The timezone-string * @return {TimeZoneProperties} The timezone properties */ export const computeTimezoneOffset = function(timezone) { if (timezone === 'Z') { return { timezone: '+00:00', offsetMinutes: 0, _timeZoneHours: 0, _timeZoneMinutes: 0 }; } const match = regexpTimezone.exec(timezone); if (match) { const hour = +match[1], minute = +match[2], hourIsNegative = isNegative(hour), offsetMinutes = (hourIsNegative ? 1 : -1) * (Math.abs(hour) * 60 + minute); if (offsetMinutes === 0) { return { timezone: '+00:00', offsetMinutes: offsetMinutes, _timeZoneHours: hourIsNegative ? -0 : 0, _timeZoneMinutes: 0 }; } return { timezone: timezone, offsetMinutes: offsetMinutes, _timeZoneHours: hour, _timeZoneMinutes: minute }; } } /** * compute the last day of a month in a year * @param {number} year the year * @param {number} month the month * @return {number} the last day of the month */ export const maxDayOfMonth = function(year, month) { const d = new Date(year, month, 0); if (!isNaN(d)) { d.setFullYear(year); return d.getDate(); } return 31; } /** * @typedef {object} DateTimezone * @property {Date} valueAsDate The date object * @property {number} offsetMinutes The offset minutes */ /** * compute a date object * @param {string|number|object} datetime * @param {number} offsetMinutes * @param {boolean} timeOnly Defies weather the the datetime-string should be treated as timeOnly * @return {DateTimezone} date */ export const fromDatetime = function(datetime, offsetMinutes, timeOnly) { let d; switch (typeof datetime) { case 'object': // falls through if (datetime && datetime.getDate) { d = new Date(datetime); } break; case 'number': if (!isNaN(datetime)) { d = new Date(datetime); } break; case 'string': const match = regexpDatetime.exec(datetime); if (match) { d = new Date(`${match[1] || '1970-01-01'}T${match[2] || '00:00:00'}${match[3] || 'Z'}`); if (match[3]) { offsetMinutes = computeTimezoneOffset(match[3]).offsetMinutes; } else { if (isNaN(offsetMinutes)) { if (match[1] || !timeOnly) { // apply local offset minutes offsetMinutes = d.getTimezoneOffset(); } else { offsetMinutes = 0; } } d.setMinutes(d.getMinutes() + offsetMinutes); } } } if (!isNaN(d)) { if (isNaN(offsetMinutes)) { offsetMinutes = d.getTimezoneOffset(); } } return { valueAsDate: d, offsetMinutes: offsetMinutes } } /** * Mixin that provides datetime-properties * * @mixinFunction * @polymer * * @appliesMixin IntlDatetimeFormatMixin * * @demo demo/datetime-demo.html */ export const DatetimeMixin = dedupingMixin( superClass => { return class extends IntlDatetimeFormatMixin(superClass) { /** * overwritten of polymer to handle -0 */ _shouldPropertyChange(property, value, old) { if (value === 0 && old === 0) { // differs if sign is different return (1/value !== 1/old) } return super._shouldPropertyChange(property, value, old); } static get properties() { return { /** * The year of the selected date */ year: { type: Number, notify: true }, /** * The month of the selected date (starts with 1) */ month: { type: Number, notify: true }, /** * The day of the selected date */ day: { type: Number, notify: true }, /** * The hour of the selected time */ hour: { type: Number, notify: true }, /** * hour in 12-hour-format * @type {number} */ hour12: { type: Number, notify: true, observer: '_hour12Changed' }, /** * true, when A.M. (when `hour` < 12) * @type {boolean} */ isAm: { type: Boolean, notify: true, observer: '_isAmChanged' }, /** * The minute of the selected time */ minute: { type: Number, notify: true }, /** * The second of the selected time */ second: { type: Number, notify: true }, /** * The millisecond of the selected time */ millisecond: { type: Number, notify: true }, /** * the selected date and time (format: iso8601) */ datetime: { type: String, notify: true }, /** * the selected date (format: iso8601) */ date: { type: String, notify: true }, /** * the selected time (format: iso8601) */ time: { type: String, notify: true }, /** * The date-object of the selected date */ valueAsDate: { type: Date, notify: true, observer: '_valueAsDateChanged' }, /** * The value of the selected date */ valueAsNumber: { type: Number, notify: true, observer: '_valueAsNumberChanged' }, /** * The default value of the input, could be a number, a date-object or an iso-string in time, date or datetime-notation */ default: { type: Object, observer: '_defaultChanged' }, /** * The minimal date, could be a number, a date-object or an iso-string in time, date or datetime-notation */ min: { type: Object, notify: true, observer: '_minChanged' }, /** * value if the minimum date */ _minValue: { type: Number }, /** * The maximal date, could be a number, a date-object or an iso-string in time, date or datetime-notation */ max: { type: Object, notify: true, observer: '_maxChanged' }, /** * value if the maximum date */ _maxValue: { type: Number }, /** * when true, 12-hour time format, else 24-hour * @type {boolean} */ hour12Format: { type: Boolean, reflectToAttribute: true, notify: true }, /** * Clamp datetime to a property * possible values:'month', 'day', 'hour', 'minute', 'second', 'millisecond' */ clamp: { type: String, notify: true, observer: '_clampChanged' }, /** * The timezone offset in '±hh:mm' format */ timezone: { type: String, notify: true, observer: '_timezoneChanged' }, /** * The offset minutes of the set timezone */ offsetMinutes: { type: Number, notify: true, observer: '_offsetMinutesChanged' }, _timeZoneHours: { type: Number }, _timeZoneMinutes: { type: Number }, /** * The offset minute of the current datetime */ _recentLocalTimezoneOffset: { type: Number }, /** * if true perspective starts at 0 (1970-01-01) */ _timeOnly: { type: Boolean, value: false }, /** * if true, time will be `00:00:00.000` */ _dateLocked: { type: Boolean, computed: '_ifClamped(clamp, "hour")' } } } static get observers() { return [ '_computeDatetime(year, month, day, hour, minute, second, millisecond)', '_datetimeChanged(datetime)', '_dateTimeChanged(date, time)', '_timeZoneHoursMinutesChanged(_timeZoneHours, _timeZoneMinutes)', '_minMaxValueChanged(_minValue, _maxValue)' ] } /** * Sets value to the actual date **/ now() { let d = new Date(); if (!this.timezone) { if (this._timeOnly && !this.date) { this.__updatingTimezoneOffset = true; this.offsetMinutes = 0; this.__updatingTimezoneOffset = false; d.setUTCFullYear(1970); d.setUTCMonth(0); d.setUTCDate(1); } else { this._checkDefaultTimezone(d); } } d.setMinutes(d.getMinutes() - d.getTimezoneOffset() + this.offsetMinutes); this.setDate(d); } /** * sets date to all necessary properties * @param {Date} d [the date to set] */ setDate(d) { if (!isNaN(d)) { d = this._checkThreshold(d); this._checkDefaultTimezone(d); let toSet = {}, value = +d; let offsetMinutes = this._computeTimezoneShift(d); if (offsetMinutes !== this.offsetMinutes) { // timezone shift occured // NOTE: timezone and offsetMinutes change before other values, so it is unsave to bind to them toSet = computeTimezone(offsetMinutes); } if (this.valueAsNumber !== value) { toSet.valueAsNumber = value; } if (+this.valueAsDate !== value) { toSet.valueAsDate = new Date(value); } // shift date, so that utc-date-properties are according to timezone let transformedDate = new Date(value); transformedDate.setMinutes(d.getMinutes() - offsetMinutes); transformedDate = this._clampUTCComponents(transformedDate, this.clamp); const year = transformedDate.getUTCFullYear(), month = transformedDate.getUTCMonth() + 1, day = transformedDate.getUTCDate(), hour = this._dateLocked ? 0 : transformedDate.getUTCHours(), minute = this._dateLocked ? 0 : transformedDate.getUTCMinutes(), second = this._dateLocked ? 0 : transformedDate.getUTCSeconds(), millisecond = this._dateLocked ? 0 : transformedDate.getUTCMilliseconds(), hour12 = (hour === 0) ? 12 : (hour > 12 ? hour - 12 : hour), isAm = hour < 12, date = toDateStringByComponents(year, month, day), time = this._dateLocked ? '00:00:00.000' : toTimeStringByComponents(hour, minute, second, millisecond), datetime = date + 'T' + time + (toSet.timezone || this.timezone); if(!(year === this.year && month === this.month && day === this.day && hour === this.hour && hour12 === this.hour12 && isAm === this.isAm && minute === this.minute && second === this.second && millisecond === this.millisecond)) { toSet.year = year; toSet.month = month; toSet.day = day; toSet.hour = hour; toSet.hour12 = hour12; toSet.minute = minute; toSet.second = second; toSet.millisecond = millisecond; toSet.isAm = isAm; } if (!(datetime === this.datetime && date === this.date && time === this.time)) { toSet.date = date; toSet.time = time; toSet.datetime = datetime; } this.setProperties(toSet); } else if (!isNaN(this._defaultValue)) { this.resetDate(); } } /** * resets the date (if `default` is set, it will be used for the new value) * @param {Event} e [a causing event will not propagated] */ resetDate(e) { if (e && e.stopPropagation) { e.stopPropagation(); } this.setProperties({ valueAsDate: undefined, valueAsNumber: undefined, datetime: undefined, date: undefined, time: undefined, year: undefined, month: undefined, day: undefined, hour: undefined, hour12: undefined, isAm: undefined, minute: undefined, second: undefined, millisecond: undefined, timezone: undefined, offsetMinutes: undefined, _timeZoneHours: undefined, _timeZoneMinutes: undefined, _recentLocalTimezoneOffset: undefined }); const def = fromDatetime(this.default, undefined, this._timeOnly); if (!isNaN(def.valueAsDate)) { this.__updatingTimezoneOffset = true; this.setProperties(computeTimezone(def.offsetMinutes)); this._recentLocalTimezoneOffset = def.valueAsDate.getTimezoneOffset(); this.__updatingTimezoneOffset = false; this.setDate(def.valueAsDate); } } _defaultChanged(def) { if (def === undefined) { return; } if (isNaN(this.valueAsDate) || isNaN(this.valueAsNumber)) { this.resetDate(); } } /** * compute date by date properties * @param {number} year * @param {number} month * @param {number} day * @param {number} hour * @param {number} minute * @param {number} second * @param {number} millisecond */ _computeDatetime(year, month, day, hour, minute, second, millisecond) { if (this.__updatingTimezoneOffset) { return; } if (isNaN(year) && isNaN(month) && isNaN(day) && isNaN(hour) && isNaN(minute) && isNaN(second) && isNaN(millisecond)) { if (this.valueAsDate !== undefined) { this.resetDate(); } return; } // if existent modify the set date let d = new Date(this.valueAsDate); if (isNaN(d)) { d = new Date(this.valueAsNumber !== undefined ? this.valueAsNumber : this.datetime); if (isNaN(d)) { // when a component is timeOnly and no date is set if (this._timeOnly && !this.date) { if (this.time) { d = new Date('1970-01-01T' + this.time + 'Z'); } else { d = new Date(0); } d.setMinutes(d.getMinutes() + d.getTimezoneOffset()); } else if (this.date) { d = new Date(this.date + 'T' + (this.time || '00:00') + (this.timezone || 'Z')); if (!this.timezone) { d.setMinutes(d.getMinutes() + d.getTimezoneOffset()); } } if (isNaN(d)) { d = new Date((new Date).getFullYear(), 0, 1, 0, 0, 0, 0); } } } let localTimezoneOffset = d.getTimezoneOffset(); let offsetMinutes = this.offsetMinutes; if (isNaN(offsetMinutes)) { offsetMinutes = localTimezoneOffset; } // shift to UTC d.setMinutes(d.getMinutes() - offsetMinutes); if (year !== undefined) { d.setUTCFullYear(year); } if (month !== undefined) { d.setUTCMonth(month - 1); } if (day !== undefined) { d.setUTCDate(day); } if (hour !== undefined) { d.setUTCHours(hour); } if (minute !== undefined) { d.setUTCMinutes(minute); } if (second !== undefined) { d.setUTCSeconds(second); } if (millisecond !== undefined) { d.setUTCMilliseconds(millisecond); } // correct timezone shift and shift back from UTC to timezone d.setMinutes(d.getMinutes() + offsetMinutes - localTimezoneOffset + d.getTimezoneOffset()); this.setDate(d); } _dateTimeChanged(date, time) { if (this.__updatingTimezoneOffset) { return; } if (date === undefined && time === undefined) { if (this.valueAsDate !== undefined) { this.resetDate(); } return; } // when a component is timeOnly and date is not set use GMT if (!date && this._timeOnly) { this.__updatingTimezoneOffset = true; this.offsetMinutes = 0; this.__updatingTimezoneOffset = false; } date = date || '1970-01-01'; time = time || '00:00:00.000'; if (!this.timezone) { this._checkDefaultTimezone(new Date(date + 'T' + time + 'Z')); } this.datetime = date + 'T' + time + this.timezone; } _datetimeChanged(datetime) { if (this.__updatingTimezoneOffset) { return; } if (datetime === undefined) { if (this.valueAsDate !== undefined) { this.resetDate(); } return; } if (typeof datetime === 'object' && datetime instanceof Date) { // 'date' is a Date Object this._recentLocalTimezoneOffset = datetime.getTimezoneOffset(); this.setDate(datetime); return; } let d; const match = regexpDatetime.exec(datetime); if (match === null) { return; } if (match[3] === undefined) { d = new Date((match[1] || '1970-01-01') + 'T' + match[2] + 'Z'); this._checkDefaultTimezone(d); d.setMinutes(d.getMinutes() + this.offsetMinutes); } else { if (match[1] === undefined) { match[0] = '1970-01-01T' + match[2] + match[3]; } d = new Date(match[0]); if (match[3] !== this.timezone) { this.__updatingTimezoneOffset = true; this.setProperties(computeTimezoneOffset(match[3])); this.__updatingTimezoneOffset = false; } } this._recentLocalTimezoneOffset = d.getTimezoneOffset(); this.setDate(d); } _valueAsNumberChanged(value) { if (isNaN(value) || value === '' || typeof value === 'boolean') { this.resetDate(); return; } this.setDate(new Date(+value)); } _valueAsDateChanged(d) { switch (typeof d) { case 'number': // falls through case 'string': // falls through d = new Date(d); } if (isNaN(d)) { this.resetDate(); return; } this.setDate(new Date(d)); } /** * test date object against thresholds * @param {Date} d * @return {Date} ether the threshold when the date is exceeding or the date object itself */ _checkThreshold(d) { if (isNaN(d)) { return; } if (this._minValue > d) { return new Date(this._minValue); } if (this._maxValue < d) { return new Date(this._maxValue); } return d; } /** * clamp UTC values * @param {Date} d The Date to clamp * @param {string} clamp The date component to clamp * @return {Date} The clamped date */ _clampUTCComponents(d, clamp) { switch (clamp) { case 'year': case 'month': d.setUTCMonth(0); // falls through case 'day': d.setUTCDate(1); // falls through case 'hour': d.setUTCHours(0); // falls through case 'minute': d.setUTCMinutes(0); // falls through case 'second': d.setUTCSeconds(0); // falls through case 'millisecond': d.setUTCMilliseconds(0); } return d; } /** * clamp to date component */ _ifClamped(clamp, comp, hidden) { const features = ['month', 'day', 'hour', 'minute', 'second', 'millisecond']; const pos = features.indexOf(clamp); return hidden || (pos !== -1 && pos <= features.indexOf(comp)); } /** * clamp to date component */ _ifNotClamped(clamp, comp, hidden) { return !this._ifClamped(clamp, comp, hidden); } /** * set the default timezone if needed * @param {Date} d */ _checkDefaultTimezone(d) { if (isNaN(this.offsetMinutes) || this.timezone === undefined) { this.__updatingTimezoneOffset = true; if (this.timezone) { this.setProperties(computeTimezoneOffset(this.timezone)); } else { this.setProperties(computeTimezone((d !== undefined ? d : new Date()).getTimezoneOffset())); } this.__updatingTimezoneOffset = false; } if (isNaN(this._recentLocalTimezoneOffset)) { this._recentLocalTimezoneOffset = (d !== undefined ? d : new Date()).getTimezoneOffset(); } } /** * correct a timezone shift when date changes from winter to summertime (locally) * @param {Date} d */ _computeTimezoneShift(d) { const localTimezoneOffset = d.getTimezoneOffset(); let offsetMinutes = this.offsetMinutes; if (this._recentLocalTimezoneOffset !== localTimezoneOffset) { offsetMinutes = offsetMinutes - this._recentLocalTimezoneOffset + localTimezoneOffset; } this._recentLocalTimezoneOffset = localTimezoneOffset; return offsetMinutes; } _clampChanged(clamp) { if (clamp === undefined) { return; } if (!isNaN(this.valueAsNumber)) { this.setDate(new Date(this.valueAsNumber)); } } _minChanged(min) { const d = fromDatetime(min, this.offsetMinutes, this._timeOnly).valueAsDate; if (isNaN(d)) { this._minValue = undefined; return; } if (!isNaN(this._maxValue) && d > this._maxValue) { // switch min and max this.setProperties({ min: this.max, max: min, _minValue: this._maxValue, _maxValue: +d }); return; } this._minValue = +d; } _maxChanged(max) { const d = fromDatetime(max, this.offsetMinutes, this._timeOnly).valueAsDate; if (isNaN(d)) { this._maxValue = undefined; return; } if (!isNaN(this._minValue) && this._minValue > d) { // switch min and max this.setProperties({ min: max, max: this.min, _minValue: +d, _maxValue: this._minValue }); return; } this._maxValue = +d; } _minMaxValueChanged() { if (!isNaN(this.valueAsNumber)) { this.setDate(new Date(this.valueAsNumber)); } } _hour12Changed(hour12, old) { if (hour12 === undefined || hour12 === old) return; /** * `hour12` is the hour in hour12-format, that starts is minimal 1 and maximal 12, midnight is at 12am and noon is 12pm */ this.hour = (hour12 === 12) ? (this.isAm ? 0 : 12) : (this.isAm ? hour12 : hour12 + 12); } _isAmChanged(isAm, old) { if (isAm === undefined || isAm === old) return; this._hour12Changed(this.hour12); } _timezoneChanged(timezone, oldValue) { if (timezone === undefined) { if (this.valueAsDate !== undefined) { this.resetDate(); } return; } else if (!(regexpTimezone.exec(timezone))) { if (regexpTimezone.exec(oldValue)) { this.setProperties(computeTimezoneOffset(oldValue)); return; } this.setProperties(computeTimezone((isNaN(this.valueAsDate) ? new Date() : this.valueAsDate.getTimezoneOffset()))); return; } const toSet = computeTimezoneOffset(timezone); if (toSet.offsetMinutes !== this.offsetMinutes || toSet.timezone !== timezone) { this.__updatingTimezoneOffset = true; this.setProperties(toSet); this.__updatingTimezoneOffset = false; } else if (this.valueAsDate) { // NOTE: binding to datetime and timezone properties on one element to another element could cause stackoverflow this.datetime = this.date + 'T' + this.time + timezone; } } _offsetMinutesChanged(offsetMinutes) { if (isNaN(offsetMinutes)) { return; } const toSet = computeTimezone(offsetMinutes); this.setProperties(toSet); if (this.date && this.time) { // NOTE: binding to datetime and timezone properties on one element to another element could cause stackoverflow this.datetime = this.date + 'T' + this.time + toSet.timezone; } } _timeZoneHoursMinutesChanged(hour, minute) { if (isNaN(hour) || isNaN(minute)) { return; } const hourIsNegative = isNegative(hour) if (hour === 0 && minute === 0) { this.setProperties({ offsetMinutes: hourIsNegative ? 0 : -0, timezone: '+00:00' }); return; } const offsetMinutes = (hourIsNegative ? 1 : -1) * (Math.abs(hour) * 60 + minute), timezone = (hourIsNegative ? '-' : '+') + pad(Math.abs(hour), 2) + ':' + pad(minute, 2); this.setProperties({ offsetMinutes: offsetMinutes, timezone: timezone }); } } });