/* eslint-disable @angular-eslint/no-conflicting-lifecycle */
import {
Directive, Input, ElementRef,
Renderer2, Output, EventEmitter, Inject,
LOCALE_ID, OnChanges, SimpleChanges, HostListener, OnInit
} from '@angular/core';
import {
ControlValueAccessor,
Validator, AbstractControl, ValidationErrors, NG_VALIDATORS, NG_VALUE_ACCESSOR,
} from '@angular/forms';
import { DOCUMENT } from '@angular/common';
import { IgxMaskDirective } from '../mask/mask.directive';
import { MaskParsingService } from '../mask/mask-parsing.service';
import { isDate, PlatformUtil } from '../../core/utils';
import { IgxDateTimeEditorEventArgs, DatePartInfo, DatePart } from './date-time-editor.common';
import { noop } from 'rxjs';
import { DatePartDeltas } from './date-time-editor.common';
import { DateTimeUtil } from '../../date-common/util/date-time.util';
/**
* Date Time Editor provides a functionality to input, edit and format date and time.
*
* @igxModule IgxDateTimeEditorModule
*
* @igxParent IgxInputGroup
*
* @igxTheme igx-input-theme
*
* @igxKeywords date, time, editor
*
* @igxGroup Scheduling
*
* @remarks
*
* The Ignite UI Date Time Editor Directive makes it easy for developers to manipulate date/time user input.
* It requires input in a specified or default input format which is visible in the input element as a placeholder.
* It allows the input of only date (ex: 'dd/MM/yyyy'), only time (ex:'HH:mm tt') or both at once, if needed.
* Supports display format that may differ from the input format.
* Provides methods to increment and decrement any specific/targeted `DatePart`.
*
* @example
* ```html
*
*
*
* ```
*/
@Directive({
selector: '[igxDateTimeEditor]',
exportAs: 'igxDateTimeEditor',
providers: [
{ provide: NG_VALUE_ACCESSOR, useExisting: IgxDateTimeEditorDirective, multi: true },
{ provide: NG_VALIDATORS, useExisting: IgxDateTimeEditorDirective, multi: true }
],
standalone: true
})
export class IgxDateTimeEditorDirective extends IgxMaskDirective implements OnChanges, OnInit, Validator, ControlValueAccessor {
/**
* Locale settings used for value formatting.
*
* @remarks
* Uses Angular's `LOCALE_ID` by default. Affects both input mask and display format if those are not set.
* If a `locale` is set, it must be registered via `registerLocaleData`.
* Please refer to https://angular.io/guide/i18n#i18n-pipes.
* If it is not registered, `Intl` will be used for formatting.
*
* @example
* ```html
*
* ```
*/
@Input()
public locale: string;
/**
* Minimum value required for the editor to remain valid.
*
* @remarks
* If a `string` value is passed, it must be in the defined input format.
*
* @example
* ```html
*
* ```
*/
public get minValue(): string | Date {
return this._minValue;
}
@Input()
public set minValue(value: string | Date) {
this._minValue = value;
this.onValidatorChange();
}
/**
* Maximum value required for the editor to remain valid.
*
* @remarks
* If a `string` value is passed in, it must be in the defined input format.
*
* @example
* ```html
*
* ```
*/
public get maxValue(): string | Date {
return this._maxValue;
}
@Input()
public set maxValue(value: string | Date) {
this._maxValue = value;
this.onValidatorChange();
}
/**
* Specify if the currently spun date segment should loop over.
*
* @example
* ```html
*
* ```
*/
@Input()
public spinLoop = true;
/**
* Set both pre-defined format options such as `shortDate` and `longDate`,
* as well as constructed format string using characters supported by `DatePipe`, e.g. `EE/MM/yyyy`.
*
* @example
* ```html
*
* ```
*/
@Input()
public displayFormat: string;
/**
* Expected user input format (and placeholder).
*
* @example
* ```html
*
* ```
*/
@Input(`igxDateTimeEditor`)
public set inputFormat(value: string) {
if (value) {
this.setMask(value);
this._inputFormat = value;
}
}
public get inputFormat(): string {
return this._inputFormat || this._defaultInputFormat;
}
/**
* Editor value.
*
* @example
* ```html
*
* ```
*/
@Input()
public set value(value: Date | string) {
this._value = value;
this.setDateValue(value);
this.onChangeCallback(value);
this.updateMask();
}
public get value(): Date | string {
return this._value;
}
/**
* Delta values used to increment or decrement each editor date part on spin actions.
* All values default to `1`.
*
* @example
* ```html
*
* ```
*/
@Input()
public spinDelta: DatePartDeltas;
/**
* Emitted when the editor's value has changed.
*
* @example
* ```html
*
* ```
*/
@Output()
public valueChange = new EventEmitter();
/**
* Emitted when the editor is not within a specified range or when the editor's value is in an invalid state.
*
* @example
* ```html
*
* ```
*/
@Output()
public validationFailed = new EventEmitter();
private _inputFormat: string;
private _oldValue: Date;
private _dateValue: Date;
private _onClear: boolean;
private document: Document;
private _isFocused: boolean;
private _defaultInputFormat: string;
private _value: Date | string;
private _minValue: Date | string;
private _maxValue: Date | string;
private _inputDateParts: DatePartInfo[];
private _datePartDeltas: DatePartDeltas = {
date: 1,
month: 1,
year: 1,
hours: 1,
minutes: 1,
seconds: 1
};
private onTouchCallback: (...args: any[]) => void = noop;
private onChangeCallback: (...args: any[]) => void = noop;
private onValidatorChange: (...args: any[]) => void = noop;
private get datePartDeltas(): DatePartDeltas {
return Object.assign({}, this._datePartDeltas, this.spinDelta);
}
private get emptyMask(): string {
return this.maskParser.applyMask(null, this.maskOptions);
}
private get targetDatePart(): DatePart {
// V.K. May 16th, 2022 #11554 Get correct date part in shadow DOM
if (this.document.activeElement === this.nativeElement ||
this.document.activeElement?.shadowRoot?.activeElement === this.nativeElement) {
return this._inputDateParts
.find(p => p.start <= this.selectionStart && this.selectionStart <= p.end && p.type !== DatePart.Literal)?.type;
} else {
if (this._inputDateParts.some(p => p.type === DatePart.Date)) {
return DatePart.Date;
} else if (this._inputDateParts.some(p => p.type === DatePart.Hours)) {
return DatePart.Hours;
}
}
}
private get hasDateParts(): boolean {
return this._inputDateParts.some(
p => p.type === DatePart.Date
|| p.type === DatePart.Month
|| p.type === DatePart.Year);
}
private get hasTimeParts(): boolean {
return this._inputDateParts.some(
p => p.type === DatePart.Hours
|| p.type === DatePart.Minutes
|| p.type === DatePart.Seconds);
}
private get dateValue(): Date {
return this._dateValue;
}
constructor(
renderer: Renderer2,
elementRef: ElementRef,
maskParser: MaskParsingService,
platform: PlatformUtil,
@Inject(DOCUMENT) private _document: any,
@Inject(LOCALE_ID) private _locale: any) {
super(elementRef, maskParser, renderer, platform);
this.document = this._document as Document;
this.locale = this.locale || this._locale;
}
@HostListener('wheel', ['$event'])
public onWheel(event: WheelEvent): void {
if (!this._isFocused) {
return;
}
event.preventDefault();
event.stopPropagation();
if (event.deltaY > 0) {
this.decrement();
} else {
this.increment();
}
}
public override ngOnInit(): void {
this.updateDefaultFormat();
this.setMask(this.inputFormat);
this.updateMask();
}
/** @hidden @internal */
public ngOnChanges(changes: SimpleChanges): void {
if (changes['locale'] && !changes['locale'].firstChange) {
this.updateDefaultFormat();
if (!this._inputFormat) {
this.setMask(this.inputFormat);
this.updateMask();
}
}
if (changes['inputFormat'] && !changes['inputFormat'].firstChange) {
this.updateMask();
}
}
/** Clear the input element value. */
public clear(): void {
this._onClear = true;
this.updateValue(null);
this.setSelectionRange(0, this.inputValue.length);
this._onClear = false;
}
/**
* Increment specified DatePart.
*
* @param datePart The optional DatePart to increment. Defaults to Date or Hours (when Date is absent from the inputFormat - ex:'HH:mm').
* @param delta The optional delta to increment by. Overrides `spinDelta`.
*/
public increment(datePart?: DatePart, delta?: number): void {
const targetPart = datePart || this.targetDatePart;
if (!targetPart) {
return;
}
const newValue = this.trySpinValue(targetPart, delta);
this.updateValue(newValue);
}
/**
* Decrement specified DatePart.
*
* @param datePart The optional DatePart to decrement. Defaults to Date or Hours (when Date is absent from the inputFormat - ex:'HH:mm').
* @param delta The optional delta to decrement by. Overrides `spinDelta`.
*/
public decrement(datePart?: DatePart, delta?: number): void {
const targetPart = datePart || this.targetDatePart;
if (!targetPart) {
return;
}
const newValue = this.trySpinValue(targetPart, delta, true);
this.updateValue(newValue);
}
/** @hidden @internal */
public override writeValue(value: any): void {
this._value = value;
this.setDateValue(value);
this.updateMask();
}
/** @hidden @internal */
public validate(control: AbstractControl): ValidationErrors | null {
if (!control.value) {
return null;
}
// InvalidDate handling
if (isDate(control.value) && !DateTimeUtil.isValidDate(control.value)) {
return { value: true };
}
let errors = {};
const value = DateTimeUtil.isValidDate(control.value) ? control.value : DateTimeUtil.parseIsoDate(control.value);
const minValueDate = DateTimeUtil.isValidDate(this.minValue) ? this.minValue : this.parseDate(this.minValue);
const maxValueDate = DateTimeUtil.isValidDate(this.maxValue) ? this.maxValue : this.parseDate(this.maxValue);
if (minValueDate || maxValueDate) {
errors = DateTimeUtil.validateMinMax(value,
minValueDate, maxValueDate,
this.hasTimeParts, this.hasDateParts);
}
return Object.keys(errors).length > 0 ? errors : null;
}
/** @hidden @internal */
public registerOnValidatorChange?(fn: () => void): void {
this.onValidatorChange = fn;
}
/** @hidden @internal */
public override registerOnChange(fn: any): void {
this.onChangeCallback = fn;
}
/** @hidden @internal */
public override registerOnTouched(fn: any): void {
this.onTouchCallback = fn;
}
/** @hidden @internal */
public setDisabledState?(_isDisabled: boolean): void { }
/** @hidden @internal */
public override onCompositionEnd(): void {
super.onCompositionEnd();
this.updateValue(this.parseDate(this.inputValue));
this.updateMask();
}
/** @hidden @internal */
public override onInputChanged(event): void {
super.onInputChanged(event);
if (this._composing) {
return;
}
if (this.inputIsComplete()) {
const parsedDate = this.parseDate(this.inputValue);
if (DateTimeUtil.isValidDate(parsedDate)) {
this.updateValue(parsedDate);
} else {
const oldValue = this.value && new Date(this.dateValue.getTime());
const args: IgxDateTimeEditorEventArgs = { oldValue, newValue: parsedDate, userInput: this.inputValue };
this.validationFailed.emit(args);
if (DateTimeUtil.isValidDate(args.newValue)) {
this.updateValue(args.newValue);
} else {
this.updateValue(null);
}
}
} else {
this.updateValue(null);
}
}
/** @hidden @internal */
public override onKeyDown(event: KeyboardEvent): void {
if (this.nativeElement.readOnly) {
return;
}
super.onKeyDown(event);
const key = event.key;
if (event.altKey) {
return;
}
if (key === this.platform.KEYMAP.ARROW_DOWN || key === this.platform.KEYMAP.ARROW_UP) {
this.spin(event);
return;
}
if (event.ctrlKey && key === this.platform.KEYMAP.SEMICOLON) {
this.updateValue(new Date());
}
this.moveCursor(event);
}
/** @hidden @internal */
public override onFocus(): void {
if (this.nativeElement.readOnly) {
return;
}
this._isFocused = true;
this.onTouchCallback();
this.updateMask();
super.onFocus();
this.nativeElement.select();
}
/** @hidden @internal */
public override onBlur(value: string): void {
this._isFocused = false;
if (!this.inputIsComplete() && this.inputValue !== this.emptyMask) {
this.updateValue(this.parseDate(this.inputValue));
} else {
this.updateMask();
}
// TODO: think of a better way to set displayValuePipe in mask directive
if (this.displayValuePipe) {
return;
}
super.onBlur(value);
}
// the date editor sets its own inputFormat as its placeholder if none is provided
/** @hidden */
protected override setPlaceholder(_value: string): void { }
private updateDefaultFormat(): void {
this._defaultInputFormat = DateTimeUtil.getDefaultInputFormat(this.locale);
}
private updateMask(): void {
if (this._isFocused) {
// store the cursor position as it will be moved during masking
const cursor = this.selectionEnd;
this.inputValue = this.getMaskedValue();
this.setSelectionRange(cursor);
} else {
if (!this.dateValue || !DateTimeUtil.isValidDate(this.dateValue)) {
this.inputValue = '';
return;
}
if (this.displayValuePipe) {
// TODO: remove when formatter func has been deleted
this.inputValue = this.displayValuePipe.transform(this.value);
return;
}
const format = this.displayFormat || this.inputFormat;
if (format) {
this.inputValue = DateTimeUtil.formatDate(this.dateValue, format.replace('tt', 'aa'), this.locale);
} else {
this.inputValue = this.dateValue.toLocaleString();
}
}
}
private setMask(inputFormat: string): void {
const oldFormat = this._inputDateParts?.map(p => p.format).join('');
this._inputDateParts = DateTimeUtil.parseDateTimeFormat(inputFormat);
inputFormat = this._inputDateParts.map(p => p.format).join('');
const mask = (inputFormat || DateTimeUtil.DEFAULT_INPUT_FORMAT)
.replace(new RegExp(/(?=[^t])[\w]/, 'g'), '0');
this.mask = mask.indexOf('tt') !== -1 ? mask.replace(new RegExp('tt', 'g'), 'LL') : mask;
const placeholder = this.nativeElement.placeholder;
if (!placeholder || oldFormat === placeholder) {
this.renderer.setAttribute(this.nativeElement, 'placeholder', inputFormat);
}
}
private parseDate(val: string): Date | null {
if (!val) {
return null;
}
return DateTimeUtil.parseValueFromMask(val, this._inputDateParts, this.promptChar);
}
private getMaskedValue(): string {
let mask = this.emptyMask;
if (DateTimeUtil.isValidDate(this.value)) {
for (const part of this._inputDateParts) {
if (part.type === DatePart.Literal) {
continue;
}
const targetValue = this.getPartValue(part, part.format.length);
mask = this.maskParser.replaceInMask(mask, targetValue, this.maskOptions, part.start, part.end).value;
}
return mask;
}
if (!this.inputIsComplete() || !this._onClear) {
return this.inputValue;
}
return mask;
}
private valueInRange(value: Date): boolean {
if (!value) {
return false;
}
let errors = {};
const minValueDate = DateTimeUtil.isValidDate(this.minValue) ? this.minValue : this.parseDate(this.minValue);
const maxValueDate = DateTimeUtil.isValidDate(this.maxValue) ? this.maxValue : this.parseDate(this.maxValue);
if (minValueDate || maxValueDate) {
errors = DateTimeUtil.validateMinMax(value,
this.minValue, this.maxValue,
this.hasTimeParts, this.hasDateParts);
}
return Object.keys(errors).length === 0;
}
private spinValue(datePart: DatePart, delta: number): Date {
if (!this.dateValue || !DateTimeUtil.isValidDate(this.dateValue)) {
return null;
}
const newDate = new Date(this.dateValue.getTime());
switch (datePart) {
case DatePart.Date:
DateTimeUtil.spinDate(delta, newDate, this.spinLoop);
break;
case DatePart.Month:
DateTimeUtil.spinMonth(delta, newDate, this.spinLoop);
break;
case DatePart.Year:
DateTimeUtil.spinYear(delta, newDate);
break;
case DatePart.Hours:
DateTimeUtil.spinHours(delta, newDate, this.spinLoop);
break;
case DatePart.Minutes:
DateTimeUtil.spinMinutes(delta, newDate, this.spinLoop);
break;
case DatePart.Seconds:
DateTimeUtil.spinSeconds(delta, newDate, this.spinLoop);
break;
case DatePart.AmPm:
const formatPart = this._inputDateParts.find(dp => dp.type === DatePart.AmPm);
const amPmFromMask = this.inputValue.substring(formatPart.start, formatPart.end);
return DateTimeUtil.spinAmPm(newDate, this.dateValue, amPmFromMask);
}
return newDate;
}
private trySpinValue(datePart: DatePart, delta?: number, negative = false): Date {
if (!delta) {
// default to 1 if a delta is set to 0 or any other falsy value
delta = this.datePartDeltas[datePart] || 1;
}
const spinValue = negative ? -Math.abs(delta) : Math.abs(delta);
return this.spinValue(datePart, spinValue) || new Date();
}
private setDateValue(value: Date | string): void {
this._dateValue = DateTimeUtil.isValidDate(value)
? value
: DateTimeUtil.parseIsoDate(value);
}
private updateValue(newDate: Date): void {
this._oldValue = this.dateValue;
this.value = newDate;
// TODO: should we emit events here?
if (this.inputIsComplete() || this.inputValue === this.emptyMask) {
this.valueChange.emit(this.dateValue);
}
if (this.dateValue && !this.valueInRange(this.dateValue)) {
this.validationFailed.emit({ oldValue: this._oldValue, newValue: this.dateValue, userInput: this.inputValue });
}
}
private toTwelveHourFormat(value: string): number {
let hour = parseInt(value.replace(new RegExp(this.promptChar, 'g'), '0'), 10);
if (hour > 12) {
hour -= 12;
} else if (hour === 0) {
hour = 12;
}
return hour;
}
private getPartValue(datePartInfo: DatePartInfo, partLength: number): string {
let maskedValue;
const datePart = datePartInfo.type;
switch (datePart) {
case DatePart.Date:
maskedValue = this.dateValue.getDate();
break;
case DatePart.Month:
// months are zero based
maskedValue = this.dateValue.getMonth() + 1;
break;
case DatePart.Year:
if (partLength === 2) {
maskedValue = this.prependValue(
parseInt(this.dateValue.getFullYear().toString().slice(-2), 10), partLength, '0');
} else {
maskedValue = this.dateValue.getFullYear();
}
break;
case DatePart.Hours:
if (datePartInfo.format.indexOf('h') !== -1) {
maskedValue = this.prependValue(
this.toTwelveHourFormat(this.dateValue.getHours().toString()), partLength, '0');
} else {
maskedValue = this.dateValue.getHours();
}
break;
case DatePart.Minutes:
maskedValue = this.dateValue.getMinutes();
break;
case DatePart.Seconds:
maskedValue = this.dateValue.getSeconds();
break;
case DatePart.AmPm:
maskedValue = this.dateValue.getHours() >= 12 ? 'PM' : 'AM';
break;
}
if (datePartInfo.type !== DatePart.AmPm) {
return this.prependValue(maskedValue, partLength, '0');
}
return maskedValue;
}
private prependValue(value: number, partLength: number, prependChar: string): string {
return (prependChar + value.toString()).slice(-partLength);
}
private spin(event: KeyboardEvent): void {
event.preventDefault();
switch (event.key) {
case this.platform.KEYMAP.ARROW_UP:
this.increment();
break;
case this.platform.KEYMAP.ARROW_DOWN:
this.decrement();
break;
}
}
private inputIsComplete(): boolean {
return this.inputValue.indexOf(this.promptChar) === -1;
}
private moveCursor(event: KeyboardEvent): void {
const value = (event.target as HTMLInputElement).value;
switch (event.key) {
case this.platform.KEYMAP.ARROW_LEFT:
if (event.ctrlKey) {
event.preventDefault();
this.setSelectionRange(this.getNewPosition(value));
}
break;
case this.platform.KEYMAP.ARROW_RIGHT:
if (event.ctrlKey) {
event.preventDefault();
this.setSelectionRange(this.getNewPosition(value, 1));
}
break;
}
}
/**
* Move the cursor in a specific direction until it reaches a date/time separator.
* Then return its index.
*
* @param value The string it operates on.
* @param direction 0 is left, 1 is right. Default is 0.
*/
private getNewPosition(value: string, direction = 0): number {
const literals = this._inputDateParts.filter(p => p.type === DatePart.Literal);
let cursorPos = this.selectionStart;
if (!direction) {
do {
cursorPos = cursorPos > 0 ? --cursorPos : cursorPos;
} while (!literals.some(l => l.end === cursorPos) && cursorPos > 0);
return cursorPos;
} else {
do {
cursorPos++;
} while (!literals.some(l => l.start === cursorPos) && cursorPos < value.length);
return cursorPos;
}
}
}