igniteui-angular-sovn
Version:
Ignite UI for Angular is a dependency-free Angular toolkit for building modern web apps
623 lines (551 loc) • 22.9 kB
text/typescript
import { DatePart, DatePartInfo } from '../../directives/date-time-editor/date-time-editor.common';
import { formatDate, FormatWidth, getLocaleDateFormat } from '@angular/common';
import { ValidationErrors } from '@angular/forms';
import { isDate } from '../../core/utils';
/** @hidden */
const enum FormatDesc {
Numeric = 'numeric',
TwoDigits = '2-digit'
}
const DATE_CHARS = ['h', 'H', 'm', 's', 'S', 't', 'T'];
const TIME_CHARS = ['d', 'D', 'M', 'y', 'Y'];
/** @hidden */
const enum DateParts {
Day = 'day',
Month = 'month',
Year = 'year'
}
/** @hidden */
export abstract class DateTimeUtil {
public static readonly DEFAULT_INPUT_FORMAT = 'MM/dd/yyyy';
public static readonly DEFAULT_TIME_INPUT_FORMAT = 'hh:mm tt';
private static readonly SEPARATOR = 'literal';
private static readonly DEFAULT_LOCALE = 'en';
/**
* Parse a Date value from masked string input based on determined date parts
*
* @param inputData masked value to parse
* @param dateTimeParts Date parts array for the mask
*/
public static parseValueFromMask(inputData: string, dateTimeParts: DatePartInfo[], promptChar?: string): Date | null {
const parts: { [key in DatePart]: number } = {} as any;
dateTimeParts.forEach(dp => {
let value = parseInt(DateTimeUtil.getCleanVal(inputData, dp, promptChar), 10);
if (!value) {
value = dp.type === DatePart.Date || dp.type === DatePart.Month ? 1 : 0;
}
parts[dp.type] = value;
});
parts[DatePart.Month] -= 1;
if (parts[DatePart.Month] < 0 || 11 < parts[DatePart.Month]) {
return null;
}
// TODO: Century threshold
if (parts[DatePart.Year] < 50) {
parts[DatePart.Year] += 2000;
}
if (parts[DatePart.Date] > DateTimeUtil.daysInMonth(parts[DatePart.Year], parts[DatePart.Month])) {
return null;
}
if (parts[DatePart.Hours] > 23 || parts[DatePart.Minutes] > 59 || parts[DatePart.Seconds] > 59) {
return null;
}
const amPm = dateTimeParts.find(p => p.type === DatePart.AmPm);
if (amPm) {
parts[DatePart.Hours] %= 12;
}
if (amPm && DateTimeUtil.getCleanVal(inputData, amPm, promptChar).toLowerCase() === 'pm') {
parts[DatePart.Hours] += 12;
}
return new Date(
parts[DatePart.Year] || 2000,
parts[DatePart.Month] || 0,
parts[DatePart.Date] || 1,
parts[DatePart.Hours] || 0,
parts[DatePart.Minutes] || 0,
parts[DatePart.Seconds] || 0
);
}
/** Parse the mask into date/time and literal parts */
public static parseDateTimeFormat(mask: string, locale?: string): DatePartInfo[] {
const format = mask || DateTimeUtil.getDefaultInputFormat(locale);
const dateTimeParts: DatePartInfo[] = [];
const formatArray = Array.from(format);
let currentPart: DatePartInfo = null;
let position = 0;
for (let i = 0; i < formatArray.length; i++, position++) {
const type = DateTimeUtil.determineDatePart(formatArray[i]);
if (currentPart) {
if (currentPart.type === type) {
currentPart.format += formatArray[i];
if (i < formatArray.length - 1) {
continue;
}
}
DateTimeUtil.addCurrentPart(currentPart, dateTimeParts);
position = currentPart.end;
}
currentPart = {
start: position,
end: position + formatArray[i].length,
type,
format: formatArray[i]
};
}
// make sure the last member of a format like H:m:s is not omitted
if (!dateTimeParts.filter(p => p.format.includes(currentPart.format)).length) {
DateTimeUtil.addCurrentPart(currentPart, dateTimeParts);
}
// formats like "y" or "yyy" are treated like "yyyy" while editing
const yearPart = dateTimeParts.filter(p => p.type === DatePart.Year)[0];
if (yearPart && yearPart.format !== 'yy') {
yearPart.end += 4 - yearPart.format.length;
yearPart.format = 'yyyy';
}
return dateTimeParts;
}
public static getPartValue(value: Date, datePartInfo: DatePartInfo, partLength: number): string {
let maskedValue;
const datePart = datePartInfo.type;
switch (datePart) {
case DatePart.Date:
maskedValue = value.getDate();
break;
case DatePart.Month:
// months are zero based
maskedValue = value.getMonth() + 1;
break;
case DatePart.Year:
if (partLength === 2) {
maskedValue = this.prependValue(
parseInt(value.getFullYear().toString().slice(-2), 10), partLength, '0');
} else {
maskedValue = value.getFullYear();
}
break;
case DatePart.Hours:
if (datePartInfo.format.indexOf('h') !== -1) {
maskedValue = this.prependValue(
this.toTwelveHourFormat(value.getHours().toString()), partLength, '0');
} else {
maskedValue = value.getHours();
}
break;
case DatePart.Minutes:
maskedValue = value.getMinutes();
break;
case DatePart.Seconds:
maskedValue = value.getSeconds();
break;
case DatePart.AmPm:
maskedValue = value.getHours() >= 12 ? 'PM' : 'AM';
break;
}
if (datePartInfo.type !== DatePart.AmPm) {
return this.prependValue(maskedValue, partLength, '0');
}
return maskedValue;
}
/** Builds a date-time editor's default input format based on provided locale settings. */
public static getDefaultInputFormat(locale: string): string {
locale = locale || DateTimeUtil.DEFAULT_LOCALE;
if (!Intl || !Intl.DateTimeFormat || !Intl.DateTimeFormat.prototype.formatToParts) {
// TODO: fallback with Intl.format for IE?
return DateTimeUtil.DEFAULT_INPUT_FORMAT;
}
const parts = DateTimeUtil.getDefaultLocaleMask(locale);
parts.forEach(p => {
if (p.type !== DatePart.Year && p.type !== DateTimeUtil.SEPARATOR) {
p.formatType = FormatDesc.TwoDigits;
}
});
return DateTimeUtil.getMask(parts);
}
/** Tries to format a date using Angular's DatePipe. Fallbacks to `Intl` if no locale settings have been loaded. */
public static formatDate(value: number | Date, format: string, locale: string, timezone?: string): string {
let formattedDate: string;
try {
formattedDate = formatDate(value, format, locale, timezone);
} catch {
DateTimeUtil.logMissingLocaleSettings(locale);
const formatter = new Intl.DateTimeFormat(locale);
formattedDate = formatter.format(value);
}
return formattedDate;
}
/**
* Returns the date format based on a provided locale.
* Supports Angular's DatePipe format options such as `shortDate`, `longDate`.
*/
public static getLocaleDateFormat(locale: string, displayFormat?: string): string {
const formatKeys = Object.keys(FormatWidth) as (keyof FormatWidth)[];
const targetKey = formatKeys.find(k => k.toLowerCase() === displayFormat?.toLowerCase().replace('date', ''));
if (!targetKey) {
// if displayFormat is not shortDate, longDate, etc.
// or if it is not set by the user
return displayFormat;
}
let format: string;
try {
format = getLocaleDateFormat(locale, FormatWidth[targetKey]);
} catch {
DateTimeUtil.logMissingLocaleSettings(locale);
format = DateTimeUtil.getDefaultInputFormat(locale);
}
return format;
}
/** Determines if a given character is `d/M/y` or `h/m/s`. */
public static isDateOrTimeChar(char: string): boolean {
return DATE_CHARS.indexOf(char) !== -1 || TIME_CHARS.indexOf(char) !== -1;
}
/** Spins the date portion in a date-time editor. */
public static spinDate(delta: number, newDate: Date, spinLoop: boolean): void {
const maxDate = DateTimeUtil.daysInMonth(newDate.getFullYear(), newDate.getMonth());
let date = newDate.getDate() + delta;
if (date > maxDate) {
date = spinLoop ? date % maxDate : maxDate;
} else if (date < 1) {
date = spinLoop ? maxDate + (date % maxDate) : 1;
}
newDate.setDate(date);
}
/** Spins the month portion in a date-time editor. */
public static spinMonth(delta: number, newDate: Date, spinLoop: boolean): void {
const maxDate = DateTimeUtil.daysInMonth(newDate.getFullYear(), newDate.getMonth() + delta);
if (newDate.getDate() > maxDate) {
newDate.setDate(maxDate);
}
const maxMonth = 11;
const minMonth = 0;
let month = newDate.getMonth() + delta;
if (month > maxMonth) {
month = spinLoop ? (month % maxMonth) - 1 : maxMonth;
} else if (month < minMonth) {
month = spinLoop ? maxMonth + (month % maxMonth) + 1 : minMonth;
}
newDate.setMonth(month);
}
/** Spins the year portion in a date-time editor. */
public static spinYear(delta: number, newDate: Date): void {
const maxDate = DateTimeUtil.daysInMonth(newDate.getFullYear() + delta, newDate.getMonth());
if (newDate.getDate() > maxDate) {
// clip to max to avoid leap year change shifting the entire value
newDate.setDate(maxDate);
}
newDate.setFullYear(newDate.getFullYear() + delta);
}
/** Spins the hours portion in a date-time editor. */
public static spinHours(delta: number, newDate: Date, spinLoop: boolean): void {
const maxHour = 23;
const minHour = 0;
let hours = newDate.getHours() + delta;
if (hours > maxHour) {
hours = spinLoop ? hours % maxHour - 1 : maxHour;
} else if (hours < minHour) {
hours = spinLoop ? maxHour + (hours % maxHour) + 1 : minHour;
}
newDate.setHours(hours);
}
/** Spins the minutes portion in a date-time editor. */
public static spinMinutes(delta: number, newDate: Date, spinLoop: boolean): void {
const maxMinutes = 59;
const minMinutes = 0;
let minutes = newDate.getMinutes() + delta;
if (minutes > maxMinutes) {
minutes = spinLoop ? minutes % maxMinutes - 1 : maxMinutes;
} else if (minutes < minMinutes) {
minutes = spinLoop ? maxMinutes + (minutes % maxMinutes) + 1 : minMinutes;
}
newDate.setMinutes(minutes);
}
/** Spins the seconds portion in a date-time editor. */
public static spinSeconds(delta: number, newDate: Date, spinLoop: boolean): void {
const maxSeconds = 59;
const minSeconds = 0;
let seconds = newDate.getSeconds() + delta;
if (seconds > maxSeconds) {
seconds = spinLoop ? seconds % maxSeconds - 1 : maxSeconds;
} else if (seconds < minSeconds) {
seconds = spinLoop ? maxSeconds + (seconds % maxSeconds) + 1 : minSeconds;
}
newDate.setSeconds(seconds);
}
/** Spins the AM/PM portion in a date-time editor. */
public static spinAmPm(newDate: Date, currentDate: Date, amPmFromMask: string): Date {
switch (amPmFromMask) {
case 'AM':
newDate = new Date(newDate.setHours(newDate.getHours() + 12));
break;
case 'PM':
newDate = new Date(newDate.setHours(newDate.getHours() - 12));
break;
}
if (newDate.getDate() !== currentDate.getDate()) {
return currentDate;
}
return newDate;
}
/**
* Determines whether the provided value is greater than the provided max value.
*
* @param includeTime set to false if you want to exclude time portion of the two dates
* @param includeDate set to false if you want to exclude the date portion of the two dates
* @returns true if provided value is greater than provided maxValue
*/
public static greaterThanMaxValue(value: Date, maxValue: Date, includeTime = true, includeDate = true): boolean {
if (includeTime && includeDate) {
return value.getTime() > maxValue.getTime();
}
const _value = new Date(value.getTime());
const _maxValue = new Date(maxValue.getTime());
if (!includeTime) {
_value.setHours(0, 0, 0, 0);
_maxValue.setHours(0, 0, 0, 0);
}
if (!includeDate) {
_value.setFullYear(0, 0, 0);
_maxValue.setFullYear(0, 0, 0);
}
return _value.getTime() > _maxValue.getTime();
}
/**
* Determines whether the provided value is less than the provided min value.
*
* @param includeTime set to false if you want to exclude time portion of the two dates
* @param includeDate set to false if you want to exclude the date portion of the two dates
* @returns true if provided value is less than provided minValue
*/
public static lessThanMinValue(value: Date, minValue: Date, includeTime = true, includeDate = true): boolean {
if (includeTime && includeDate) {
return value.getTime() < minValue.getTime();
}
const _value = new Date(value.getTime());
const _minValue = new Date(minValue.getTime());
if (!includeTime) {
_value.setHours(0, 0, 0, 0);
_minValue.setHours(0, 0, 0, 0);
}
if (!includeDate) {
_value.setFullYear(0, 0, 0);
_minValue.setFullYear(0, 0, 0);
}
return _value.getTime() < _minValue.getTime();
}
/**
* Validates a value within a given min and max value range.
*
* @param value The value to validate
* @param minValue The lowest possible value that `value` can take
* @param maxValue The largest possible value that `value` can take
*/
public static validateMinMax(value: Date, minValue: Date | string, maxValue: Date | string,
includeTime = true, includeDate = true): ValidationErrors {
if (!value) {
return null;
}
const errors = {};
const min = DateTimeUtil.isValidDate(minValue) ? minValue : DateTimeUtil.parseIsoDate(minValue);
const max = DateTimeUtil.isValidDate(maxValue) ? maxValue : DateTimeUtil.parseIsoDate(maxValue);
if (min && value && DateTimeUtil.lessThanMinValue(value, min, includeTime, includeDate)) {
Object.assign(errors, { minValue: true });
}
if (max && value && DateTimeUtil.greaterThanMaxValue(value, max, includeTime, includeDate)) {
Object.assign(errors, { maxValue: true });
}
return errors;
}
/** Parse an ISO string to a Date */
public static parseIsoDate(value: string): Date | null {
let regex = /^\d{4}/g;
const timeLiteral = 'T';
if (regex.test(value)) {
return new Date(value + `${value.indexOf(timeLiteral) === -1 ? 'T00:00:00' : ''}`);
}
regex = /^\d{2}/g;
if (regex.test(value)) {
const dateNow = new Date().toISOString();
// eslint-disable-next-line prefer-const
let [datePart, _timePart] = dateNow.split(timeLiteral);
return new Date(`${datePart}T${value}`);
}
return null;
}
/**
* Returns whether the input is valid date
*
* @param value input to check
* @returns true if provided input is a valid date
*/
public static isValidDate(value: any): value is Date {
if (isDate(value)) {
return !isNaN(value.getTime());
}
return false;
}
private static addCurrentPart(currentPart: DatePartInfo, dateTimeParts: DatePartInfo[]): void {
DateTimeUtil.ensureLeadingZero(currentPart);
currentPart.end = currentPart.start + currentPart.format.length;
dateTimeParts.push(currentPart);
}
private static daysInMonth(fullYear: number, month: number): number {
return new Date(fullYear, month + 1, 0).getDate();
}
private static trimEmptyPlaceholders(value: string, promptChar?: string): string {
const result = value.replace(new RegExp(promptChar || '_', 'g'), '');
return result;
}
private static getMask(dateStruct: any[]): string {
const mask = [];
for (const part of dateStruct) {
switch (part.formatType) {
case FormatDesc.Numeric: {
if (part.type === DateParts.Day) {
mask.push('d');
} else if (part.type === DateParts.Month) {
mask.push('M');
} else {
mask.push('yyyy');
}
break;
}
case FormatDesc.TwoDigits: {
if (part.type === DateParts.Day) {
mask.push('dd');
} else if (part.type === DateParts.Month) {
mask.push('MM');
} else {
mask.push('yy');
}
}
}
if (part.type === DateTimeUtil.SEPARATOR) {
mask.push(part.value);
}
}
return mask.join('');
}
private static logMissingLocaleSettings(locale: string): void {
console.warn(`Missing locale data for the locale ${locale}. Please refer to https://angular.io/guide/i18n#i18n-pipes`);
console.warn('Using default browser locale settings.');
}
private static prependValue(value: number, partLength: number, prependChar: string): string {
return (prependChar + value.toString()).slice(-partLength);
}
private static toTwelveHourFormat(value: string, promptChar = '_'): number {
let hour = parseInt(value.replace(new RegExp(promptChar, 'g'), '0'), 10);
if (hour > 12) {
hour -= 12;
} else if (hour === 0) {
hour = 12;
}
return hour;
}
private static ensureLeadingZero(part: DatePartInfo) {
switch (part.type) {
case DatePart.Date:
case DatePart.Month:
case DatePart.Hours:
case DatePart.Minutes:
case DatePart.Seconds:
if (part.format.length === 1) {
part.format = part.format.repeat(2);
}
break;
}
}
private static getCleanVal(inputData: string, datePart: DatePartInfo, promptChar?: string): string {
return DateTimeUtil.trimEmptyPlaceholders(inputData.substring(datePart.start, datePart.end), promptChar);
}
private static determineDatePart(char: string): DatePart {
switch (char) {
case 'd':
case 'D':
return DatePart.Date;
case 'M':
return DatePart.Month;
case 'y':
case 'Y':
return DatePart.Year;
case 'h':
case 'H':
return DatePart.Hours;
case 'm':
return DatePart.Minutes;
case 's':
case 'S':
return DatePart.Seconds;
case 't':
case 'T':
return DatePart.AmPm;
default:
return DatePart.Literal;
}
}
private static getDefaultLocaleMask(locale: string) {
const dateStruct = [];
const formatter = new Intl.DateTimeFormat(locale);
const formatToParts = formatter.formatToParts(new Date());
for (const part of formatToParts) {
if (part.type === DateTimeUtil.SEPARATOR) {
dateStruct.push({
type: DateTimeUtil.SEPARATOR,
value: part.value
});
} else {
dateStruct.push({
type: part.type
});
}
}
const formatterOptions = formatter.resolvedOptions();
for (const part of dateStruct) {
switch (part.type) {
case DateParts.Day: {
part.formatType = formatterOptions.day;
break;
}
case DateParts.Month: {
part.formatType = formatterOptions.month;
break;
}
case DateParts.Year: {
part.formatType = formatterOptions.year;
break;
}
}
}
DateTimeUtil.fillDatePartsPositions(dateStruct);
return dateStruct;
}
private static fillDatePartsPositions(dateArray: any[]): void {
let currentPos = 0;
for (const part of dateArray) {
// Day|Month part positions
if (part.type === DateParts.Day || part.type === DateParts.Month) {
// Offset 2 positions for number
part.position = [currentPos, currentPos + 2];
currentPos += 2;
} else if (part.type === DateParts.Year) {
// Year part positions
switch (part.formatType) {
case FormatDesc.Numeric: {
// Offset 4 positions for full year
part.position = [currentPos, currentPos + 4];
currentPos += 4;
break;
}
case FormatDesc.TwoDigits: {
// Offset 2 positions for short year
part.position = [currentPos, currentPos + 2];
currentPos += 2;
break;
}
}
} else if (part.type === DateTimeUtil.SEPARATOR) {
// Separator positions
part.position = [currentPos, currentPos + 1];
currentPos++;
}
}
}
}