diff --git a/package.json b/package.json index 25acf722..6f704332 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,8 @@ "release:dry-run": "npm run release -- --dry-run", "postinstall": "patch-package", "start": "ng serve", - "prettier": "npx prettier . --write" + "prettier": "npx prettier . --write", + "nx": "nx" }, "workspaces": [ "projects/core", diff --git a/projects/date-picker/package.json b/projects/date-picker/package.json index 560e31eb..8b95301f 100644 --- a/projects/date-picker/package.json +++ b/projects/date-picker/package.json @@ -31,7 +31,9 @@ }, "peerDependencies": { "@angular/common": ">= 14", - "@angular/core": ">= 14" + "@angular/core": ">= 14", + "@hug/ngx-core": "1.1.7", + "@hug/ngx-time-picker": "1.1.3" }, "dependencies": { "tslib": "^2.6.3" diff --git a/projects/date-picker/src/date-adapter/date-time-adapter.ts b/projects/date-picker/src/date-adapter/date-time-adapter.ts new file mode 100644 index 00000000..390f8ccd --- /dev/null +++ b/projects/date-picker/src/date-adapter/date-time-adapter.ts @@ -0,0 +1,11 @@ +import { InjectionToken } from '@angular/core'; + +// eslint-disable-next-line @typescript-eslint/naming-convention +export const DATE_TIME_ADAPTER = new InjectionToken>('DATE_TIME_ADAPTER'); + +export interface DateTimeAdapter { + setHours: (date: D, hours: number) => D; + setMinutes: (date: D, minutes: number) => D; + setSeconds: (date: D, seconds: number) => D; + setTime: (date: D, hours: number, minutes: number, seconds: number) => D; +} diff --git a/projects/date-picker/src/date-adapter/date.util.ts b/projects/date-picker/src/date-adapter/date.util.ts new file mode 100644 index 00000000..90899d97 --- /dev/null +++ b/projects/date-picker/src/date-adapter/date.util.ts @@ -0,0 +1,64 @@ +import { isMatch as isMatchFns, isValid, parse, toDate } from 'date-fns'; + +const yearFormat = 'yyyy'; +const yearPattern = '[0-9]{4}'; +const otherThanYearPatternToFind = '[dmMLsHXZ]+'; +const otherThanYearPatternToReplace = '[A-Za-z0-9éû]*'; + +export const isMatch = (dateString: string, formatString: string, locale: Locale): boolean => isMatchFns(dateString, formatString, { locale: locale }); + +/** + * Check, if a year is expected in dateString, if dateString include the year on 4 digit. Return false for Oct 20, true for Oct 2020. + * @param dateString The date in its string representation + * @param formatString The format to analyze + * @returns true if the format use 'yyyy' and the dateString match it, true if the format does not use the format 'yyyy', false otherwise + */ +const isYearFormatStrictlyMatch = (dateString: string, formatString: string): boolean => { + let isYearFormatStrictMatch = true; + if (formatString?.includes(yearFormat)) { + const otherThanYear = new RegExp(otherThanYearPatternToFind, 'g'); + const onlyYearCapture = formatString.replace(otherThanYear, otherThanYearPatternToReplace).replace(yearFormat, yearPattern); + const regExpOnlyYear = new RegExp(`^${onlyYearCapture}`); + isYearFormatStrictMatch = regExpOnlyYear.test(dateString); + } + return isYearFormatStrictMatch; +}; + +export const parseDateStrictly = (dateString: string, formatString: string, locale: Locale): Date | undefined => { + let date: Date | undefined; + if (isMatch(dateString, formatString, locale) && isYearFormatStrictlyMatch(dateString, formatString)) { + date = parse(dateString, formatString, new Date(), { locale: locale }); + } + return date; +}; + +const stringToDate = (valueA: string, locale: Locale, acceptedFormatDate?: readonly string[]): Date | undefined => { + if (!acceptedFormatDate?.length) { + return undefined; + } + + return acceptedFormatDate.filterMap(dateFormat => { + const stringDateToDate = parseDateStrictly(valueA, dateFormat, locale); + if (isValid(stringDateToDate)) { + return stringDateToDate; + } + return undefined; + }).shift(); +}; + +export const validateAndParseDateStr = (value: unknown, dateFormats: readonly string[], locale: Locale): Date | undefined => { + if (value instanceof Date) { + return value; + } else { + let date: Date | undefined; + if (typeof value === 'number') { + date = toDate(value); + } else if (typeof value === 'string') { + date = stringToDate(value, locale, dateFormats); + } + if (isValid(date)) { + return date; + } + } + return undefined; +}; diff --git a/projects/date-picker/src/date-adapter/multi-format-date-adapter.ts b/projects/date-picker/src/date-adapter/multi-format-date-adapter.ts new file mode 100644 index 00000000..43baebce --- /dev/null +++ b/projects/date-picker/src/date-adapter/multi-format-date-adapter.ts @@ -0,0 +1,236 @@ +import { Inject, Injectable, InjectionToken, Optional, Type } from '@angular/core'; +import { DateAdapter, MAT_DATE_LOCALE } from '@angular/material/core'; +import { format, isValid, Locale, setHours, setMinutes, setSeconds } from 'date-fns'; + +import { validateAndParseDateStr } from './date.util'; +import { DateTimeAdapter } from './date-time-adapter'; + +// eslint-disable-next-line @typescript-eslint/naming-convention +export const ACCEPTED_NON_DATE_VALUES = new InjectionToken('ACCEPTED_NON_DATE_VALUES'); +// eslint-disable-next-line @typescript-eslint/naming-convention +export const MULTI_FORMAT_ACCEPTED_FORMATS = new InjectionToken('MULTI_FORMAT_ACCEPTED_FORMATS'); +// eslint-disable-next-line @typescript-eslint/naming-convention +export const MULTI_FORMAT_DATE_DELEGATE = new InjectionToken>('MULTI_FORMAT_DATE_DELEGATE'); + +type TypeForAdapter = Date | string; + +/** + * An Angular Material DateAdapter allowing to accept multiple date formats. It also can be used to accept arbitrary strings as valid date values (see ACCEPTED_NON_DATE_VALUES token definition below). + * + * Users must provide the following tokens: + * - MULTI_FORMAT_ACCEPTED_FORMATS: a list of accepted date formats + * - MULTI_FORMAT_DATE_DELEGATE: the DateAdapter delegate which will be used to delegate operation on date objects + * + * Optional tokens are: + * - MAT_DATE_LOCALE: the locale to use for date object parsing and formatting + * - ACCEPTED_NON_DATE_VALUES: a list of string or regular expression for accepting arbitrary non date values + * + * This adapter accept and produce Date objects or strings + * + */ +@Injectable() +export class MultiFormatDateAdapter extends DateAdapter implements DateTimeAdapter { + + private delegate: DateAdapter; + + public constructor( + @Inject(MULTI_FORMAT_ACCEPTED_FORMATS) private acceptedDateFormats: readonly string[], + @Inject(MULTI_FORMAT_DATE_DELEGATE) delegateType: Type>, + @Optional() @Inject(MAT_DATE_LOCALE) matDateLocale: Record, + @Optional() @Inject(ACCEPTED_NON_DATE_VALUES) private acceptedValues: readonly (string | RegExp)[] + ) { + super(); + this.locale = matDateLocale; + this.delegate = new delegateType(matDateLocale); + } + + public getYear(date: TypeForAdapter): number { + return this.appyOnParsed(date, parsed => this.delegate.getYear(parsed), this.delegate.getYear(new Date())); + } + + public getMonth(date: TypeForAdapter): number { + return this.appyOnParsed(date, parsed => this.delegate.getMonth(parsed), this.delegate.getMonth(new Date())); + } + + public getDate(date: TypeForAdapter): number { + return this.appyOnParsed(date, parsed => this.delegate.getDate(parsed), this.delegate.getDate(new Date())); + } + + public getDayOfWeek(date: TypeForAdapter): number { + return this.appyOnParsed(date, parsed => this.delegate.getDayOfWeek(parsed), this.delegate.getDayOfWeek(new Date())); + } + + public getMonthNames(style: 'long' | 'short' | 'narrow'): string[] { + return this.delegate.getMonthNames(style); + } + + public getDateNames(): string[] { + return this.delegate.getDateNames(); + } + + public getDayOfWeekNames(style: 'long' | 'short' | 'narrow'): string[] { + return this.delegate.getDayOfWeekNames(style); + } + + public getYearName(date: TypeForAdapter): string { + return this.appyOnParsed(date, parsed => this.delegate.getYearName(parsed), ''); + } + + public getFirstDayOfWeek(): number { + return this.delegate.getFirstDayOfWeek(); + } + + public getNumDaysInMonth(date: TypeForAdapter): number { + return this.appyOnParsed(date, parsed => this.delegate.getNumDaysInMonth(parsed), this.delegate.getNumDaysInMonth(new Date())); + } + + public clone(date: TypeForAdapter): TypeForAdapter { + return this.appyOnParsed(date, parsed => this.delegate.clone(parsed), date); + } + + public createDate(year: number, month: number, date: number): TypeForAdapter { + return this.delegate.createDate(year, month, date); + } + + public today(): TypeForAdapter { + return this.delegate.today(); + } + + public parse(value: unknown): TypeForAdapter | null { + if (!value) { + return null; + } + if (value instanceof Date) { + return isValid(value) ? value : this.invalid(); + } + if (typeof value === 'string') { + if (this.isAcceptedValue(value)) { + return value; + } + const parsed = this._parse(value); + if (parsed) { + return value; + } + } + return this.invalid(); + } + + public format(date: TypeForAdapter, displayFormat: string): string { + if (!date) { + return ''; + } + const parsed = this._parse(date); + if (date instanceof Date && parsed) { + return this._format(parsed, displayFormat) || ''; + } + if (date && this.isValid(date)) { + return date.toLocaleString(); + } + return ''; + } + + public addCalendarYears(date: TypeForAdapter, years: number): TypeForAdapter { + return this.appyOnParsed(date, parsed => this.delegate.addCalendarYears(parsed, years), date); + } + + public addCalendarMonths(date: TypeForAdapter, months: number): TypeForAdapter { + return this.appyOnParsed(date, parsed => this.delegate.addCalendarMonths(parsed, months), date); + } + + public addCalendarDays(date: TypeForAdapter, days: number): TypeForAdapter { + return this.appyOnParsed(date, parsed => this.delegate.addCalendarDays(parsed, days), date); + } + + public toIso8601(date: TypeForAdapter): string { + const parsed = this._parse(date); + if (!parsed) { + return ''; + } + return this.delegate.toIso8601(parsed); + } + + public isDateInstance(obj: unknown): boolean { + const isString = typeof obj === 'string'; + if (isString || obj instanceof Date || typeof obj === 'number') { + return isString && this.isAcceptedValue(obj) || !!this._parse(obj); + } + return false; + } + + public isValid(date: TypeForAdapter): boolean { + return this.isAcceptedValue(date) || !!this._parse(date); + } + + public invalid(): TypeForAdapter { + return 'Invalide'; + } + + public setHours(date: TypeForAdapter, hours: number): TypeForAdapter { + return this.appyOnParsed(date, parsed => setHours(parsed, hours || 0), date); + } + + public setMinutes(date: TypeForAdapter, minutes: number): TypeForAdapter { + return this.appyOnParsed(date, parsed => setMinutes(parsed, minutes || 0), date); + } + + public setSeconds(date: TypeForAdapter, seconds: number): TypeForAdapter { + return this.appyOnParsed(date, parsed => setSeconds(parsed, seconds || 0), date); + } + + public setTime(date: TypeForAdapter, hours: number, minutes: number, seconds: number): TypeForAdapter { + date = this.setHours(date, hours); + date = this.setMinutes(date, minutes); + date = this.setSeconds(date, seconds); + return date; + } + + public override compareDate(first: TypeForAdapter, second: TypeForAdapter): number { + if (typeof first === 'string' && typeof second === 'string') { + return first.localeCompare(second); + } + const parsedFirst = this._parse(first); + const parsedSecond = this._parse(second); + if (!parsedFirst || !parsedSecond) { + return 0; + } + if (!parsedFirst) { + return -1; + } + if (!parsedSecond) { + return 1; + } + return this.delegate.compareDate(parsedFirst, parsedSecond); + } + + private isAcceptedValue(value: TypeForAdapter): boolean { + if (value instanceof Date) { + return false; + } + return this.acceptedValues?.some(acceptedValue => acceptedValue instanceof RegExp ? acceptedValue.test(value) : acceptedValue === value) ?? false; + } + + // eslint-disable-next-line @typescript-eslint/naming-convention + private _parse(value: unknown): Date | undefined { + return validateAndParseDateStr(value, this.acceptedDateFormats, this.locale); + } + + // eslint-disable-next-line @typescript-eslint/naming-convention + private _format(value: Date, dateFormat?: string): string | undefined { + if (!isValid(value)) { + return undefined; + } + if (!dateFormat) { + return value.toDateString(); + } + return format(value, dateFormat, { locale: this.locale }); + } + + private appyOnParsed(date: TypeForAdapter, fn: (d: Date) => T, defaultReturnValue: T): T { + const parsed = this._parse(date); + if (!parsed) { + return defaultReturnValue; + } + return fn(parsed); + } +} + diff --git a/projects/date-picker/src/datepicker-buttons/datepicker-buttons.component.html b/projects/date-picker/src/datepicker-buttons/datepicker-buttons.component.html new file mode 100644 index 00000000..e2aed2b9 --- /dev/null +++ b/projects/date-picker/src/datepicker-buttons/datepicker-buttons.component.html @@ -0,0 +1,9 @@ + + + diff --git a/projects/date-picker/src/datepicker-buttons/datepicker-buttons.component.scss b/projects/date-picker/src/datepicker-buttons/datepicker-buttons.component.scss new file mode 100644 index 00000000..f77a43bb --- /dev/null +++ b/projects/date-picker/src/datepicker-buttons/datepicker-buttons.component.scss @@ -0,0 +1,29 @@ +datepicker-buttons { + font-size: 80%; + + .date-picker-button { + max-width: 20px; + max-height: 20px; + + .mat-icon { + display: flex; + align-items: baseline; + justify-content: center; + font-size: 20px; + } + + &:not(.mat-button-disabled) { + color: rgba(0, 0, 0, 0.54); + } + } +} + +.mat-form-field.mat-form-field-appearance-legacy { + font-size: inherit; + + datepicker-buttons { + .mat-icon-button { + font-size: 130%; + } + } +} diff --git a/projects/date-picker/src/datepicker-buttons/datepicker-buttons.component.ts b/projects/date-picker/src/datepicker-buttons/datepicker-buttons.component.ts new file mode 100644 index 00000000..c064616a --- /dev/null +++ b/projects/date-picker/src/datepicker-buttons/datepicker-buttons.component.ts @@ -0,0 +1,116 @@ +import { BooleanInput, coerceBooleanProperty } from '@angular/cdk/coercion'; +import { NgIf } from '@angular/common'; +import { ChangeDetectionStrategy, ChangeDetectorRef, Component, EventEmitter, Input, OnInit, Output, ViewEncapsulation } from '@angular/core'; +import { AbstractControl } from '@angular/forms'; +import { MatButtonModule } from '@angular/material/button'; +import { DateAdapter } from '@angular/material/core'; +import { MatDatepicker, MatDateRangeInput, MatDateRangePicker } from '@angular/material/datepicker'; +import { MatFormFieldControl } from '@angular/material/form-field'; +import { MatIconModule } from '@angular/material/icon'; +import { MatTooltipModule } from '@angular/material/tooltip'; +import { Destroy } from '@hug/ngx-core'; +import { set } from 'date-fns'; +import { filter, ReplaySubject, switchMap, takeUntil, tap } from 'rxjs'; + +@Component({ + selector: 'datepicker-buttons', + templateUrl: './datepicker-buttons.component.html', + styleUrls: ['./datepicker-buttons.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.None, + standalone: true, + imports: [ + NgIf, + MatButtonModule, + MatIconModule, + MatTooltipModule + ] +}) +export class DatepickerButtonsComponent extends Destroy implements OnInit { + @Output() public readonly dateChange = new EventEmitter(); + + @Input() public forInput!: MatFormFieldControl; + @Input() public forPicker!: MatDatepicker | MatDateRangePicker; + + private _hideToday = false; + private _hideClear = false; + private setToday$ = new ReplaySubject(1); + + @Input() + public set setTodayOnOpen(value: BooleanInput) { + this.setToday$.next(coerceBooleanProperty(value)); + } + + @Input() + public set hideToday(value: BooleanInput) { + this._hideToday = coerceBooleanProperty(value); + } + + public get hideToday(): BooleanInput { + return this._hideToday; + } + + @Input() + public set hideClear(value: BooleanInput) { + this._hideClear = coerceBooleanProperty(value); + } + + public get hideClear(): BooleanInput { + return this._hideClear; + } + + public constructor(private changeDetectorRef: ChangeDetectorRef, private dateAdater: DateAdapter) { + super(); + } + + public ngOnInit(): void { + this.setToday$.pipe( + filter(setToday => setToday && !!this.forPicker), + switchMap(() => this.forPicker.openedStream.pipe( + tap(() => { + if (this.forInput && ((this.forInput instanceof MatDateRangeInput && !this.forInput.getStartValue()) || !this.forInput.value)) { + this.forPicker.select(new Date()); + } + }) + )), + takeUntil(this.destroyed$) + ).subscribe(); + + if (this.forInput.ngControl?.valueChanges) { + this.forInput.ngControl.valueChanges.pipe( + takeUntil(this.destroyed$) + ).subscribe(() => { + this.changeDetectorRef.markForCheck(); + }); + } + } + + public setValue(event: Event, today: boolean): boolean { + const updateDateControl = (control: AbstractControl | undefined, date: unknown, dateAdpater: DateAdapter): void => { + if (!control || !control.value && !date) { + return; + } + if (!dateAdpater.sameDate(control.value, date)) { + control.setValue(date); + control.markAsDirty(); + } + }; + + const date = today ? set(new Date(), { seconds: 0, milliseconds: 0 }) : undefined; + if (this.forInput instanceof MatDateRangeInput) { + updateDateControl(this.forInput._startInput.ngControl.control ?? undefined, date, this.dateAdater); + updateDateControl(this.forInput._endInput.ngControl.control ?? undefined, date, this.dateAdater); + } else { + updateDateControl(this.forInput.ngControl?.control ?? undefined, date, this.dateAdater); + } + event.preventDefault(); + this.dateChange.emit(date); + return false; + } + + public openCalendar(): void { + if (this.forPicker) { + this.forPicker.open(); + } + } +} diff --git a/projects/date-picker/src/datepicker-mask/datepicker-mask-validator.service.ts b/projects/date-picker/src/datepicker-mask/datepicker-mask-validator.service.ts new file mode 100644 index 00000000..e2483d11 --- /dev/null +++ b/projects/date-picker/src/datepicker-mask/datepicker-mask-validator.service.ts @@ -0,0 +1,26 @@ +import { Injectable } from '@angular/core'; +import { FormControl, ValidationErrors, Validator } from '@angular/forms'; +import { isValid } from 'date-fns'; + +@Injectable() +export class DatepickerMaskValidatorService implements Validator { + private invalid = false; + + public validate({ value }: FormControl): ValidationErrors | null { + return this.isInvalid(value) ? { + invalidDate: { } + } : null; + } + + public markAsValid(): void { + this.invalid = false; + } + + public markAsInvalid(): void { + this.invalid = true; + } + + private isInvalid(value: Date): boolean { + return this.invalid || value && !isValid(value); + } +} diff --git a/projects/date-picker/src/datepicker-mask/datepicker-mask.directive.ts b/projects/date-picker/src/datepicker-mask/datepicker-mask.directive.ts new file mode 100644 index 00000000..fb3aba07 --- /dev/null +++ b/projects/date-picker/src/datepicker-mask/datepicker-mask.directive.ts @@ -0,0 +1,399 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +import { Directive, ElementRef, forwardRef, Inject, Input, OnInit, Optional, Renderer2 } from '@angular/core'; +import { AbstractControl, NG_VALIDATORS, NgControl } from '@angular/forms'; +import { DateAdapter, MAT_DATE_FORMATS, MatDateFormats } from '@angular/material/core'; +import { Destroy, filterMap, KeyCodes, subscribeWith } from '@hug/ngx-core'; +import { addDays, addHours, addMinutes, addMonths, addSeconds, addYears, isValid, parse, set } from 'date-fns'; +import { isNil } from 'lodash-es'; +import { delay, EMPTY, filter, fromEvent, mergeWith, of, startWith, switchMap, takeUntil, tap, timeInterval } from 'rxjs'; + +import { DatepickerMaskValidatorService } from './datepicker-mask-validator.service'; + + +@Directive({ + selector: '[matDatepicker][dateFormat],[matDatepicker][dateTimeFormat],[matStartDate][dateFormat],[matEndDate][dateFormat],[matStartDate][dateTimeFormat],[matEndDate][dateTimeFormat]', + providers: [ + DatepickerMaskValidatorService, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => DatepickerMaskValidatorService), + multi: true + } + ] +}) +export class DatepickerMaskDirective extends Destroy implements OnInit { + // eslint-disable-next-line @angular-eslint/no-input-rename + @Input('dateTimeFormat') + public set dateTimeFormat(value: string) { + this.formatExpression = value || this.dateFormats.display.dateInput as string; + } + + // eslint-disable-next-line @angular-eslint/no-input-rename + @Input('dateFormat') + public set dateFormat(value: string) { + this.formatExpression = value || this.dateFormats.display.dateInput as string; + } + + private set formatExpression(value: string) { + this._formatExpression = value; + this.applyMask(); + } + + @Input() + public set placeHolderCharacter(value: string) { + this._placeHolderCharacter = value || '_'; + this.applyMask(); + } + + private _placeHolderCharacter = '_'; + private _formatExpression?: string; + private maskValue = ''; + + private formatCharRegExp = /[mdyhs]/i; + private forwardToInputKeyCodes = ['LeftArrow', 'RightArrow', 'UpArrow', 'DownArrow', 'PageDown', 'PageUp', 'End', 'Home', 'Tab']; + + public constructor( + @Optional() @Inject(MAT_DATE_FORMATS) private dateFormats: MatDateFormats, + private elementRef: ElementRef, + private ngControl: NgControl, + private renderer: Renderer2, + private validator: DatepickerMaskValidatorService, + private dateAdapter: DateAdapter + ) { + super(); + + elementRef.nativeElement.setAttribute('autocomplete', 'off'); + + const dblClick$ = fromEvent(elementRef.nativeElement, 'mousedown').pipe( + timeInterval(), + filter(intervalEvent => intervalEvent.interval < 400) + ); + + const selectAll$ = fromEvent(elementRef.nativeElement, 'focus').pipe( + delay(400), + mergeWith(fromEvent(elementRef.nativeElement, 'keydown')), + timeInterval(), + tap(intervalEvent => { + const selectionStart = this.elementRef.nativeElement.selectionStart; + const selectionEnd = this.elementRef.nativeElement.selectionEnd; + if (intervalEvent.interval < 400 || intervalEvent.value instanceof KeyboardEvent || !selectionEnd || !selectionStart || selectionEnd > selectionStart) { + return; + } + + const relatedTarget = intervalEvent.value.relatedTarget as HTMLElement; + const isDatePickerButton = relatedTarget?.classList.contains('date-picker-button'); + if (this.ngControl.control?.value && !isDatePickerButton) { + this.elementRef.nativeElement.setSelectionRange(0, -1); + } + }) + ); + + fromEvent(elementRef.nativeElement, 'paste').pipe( + takeUntil(this.destroyed$) + ).subscribe(event => { + // Get pasted data via clipboard + const pastedData = event.clipboardData?.getData('Text'); + if (!pastedData) { + return undefined; + } + this.parseAndSetValue(pastedData); + event.preventDefault(); + return false; + }); + + dblClick$.pipe( + switchMap(intervalEvent => { + // Double click + if (!this._formatExpression) { + return EMPTY; + } + const pos = this.elementRef.nativeElement.selectionStart; + if (pos === null) { + return EMPTY; + } + + let start: number | undefined; + if (this.formatCharRegExp.exec(this._formatExpression[pos])) { + start = pos; + } else if (this.formatCharRegExp.exec(this._formatExpression[pos - 1])) { + start = pos - 1; + } + + const charAtPos = start !== undefined && this._formatExpression[start]; + if (!start || !charAtPos) { + return of([intervalEvent.value, undefined, undefined] as const); + } + + // Find end + let end = start + 1; + // eslint-disable-next-line no-loops/no-loops + while (this._formatExpression[end] === charAtPos) { + end++; + } + + // Find real start + // eslint-disable-next-line no-loops/no-loops + while (this._formatExpression[start - 1] === charAtPos) { + start--; + } + + return of([intervalEvent.value, start, end] as const); + }), + delay(1), + subscribeWith(selectAll$), + takeUntil(this.destroyed$) + ).subscribe(([event, start, end]) => { + if (start !== undefined && end !== undefined) { + this.elementRef.nativeElement.setSelectionRange(start, end); + } + + event.preventDefault(); + return false; + }); + + fromEvent(elementRef.nativeElement, 'keydown').pipe( + takeUntil(this.destroyed$) + ).subscribe(e => { + const formatExpression = this._formatExpression; + if (!this.maskValue || !formatExpression) { + return undefined; + } + + const el = e.target as HTMLInputElement; + const start = el.selectionStart; + const end = el.selectionEnd; + + if (start === null || end === null) { + return undefined; + } + + const getPosition = (pos: number, direction: -1 | 1): number | undefined => { + const offset = direction === -1 ? -1 : 0; + let formatChar = formatExpression[pos + offset]; + // eslint-disable-next-line no-loops/no-loops + while (formatChar) { + if (this.formatCharRegExp.exec(formatChar) ?? formatChar === this._placeHolderCharacter) { + break; + } + pos += direction; + formatChar = formatExpression[pos + offset]; + } + + return formatChar ? pos : undefined; + }; + + const replaceRange = (value: string, from: number, to: number, mask: string): string => Array.from(value).map((c, index) => index >= from && index <= to ? mask[index] : c).join(''); + const replaceAt = (value: string, index: number, char: string): string => value.substring(0, index) + char + value.substring(index + char.length); + + if ((e.code === 'UpArrow' || e.code === 'DownArrow') && !e.altKey && !e.ctrlKey && !e.shiftKey && !e.metaKey) { + if (this.ngControl.value instanceof Date) { + const date = this.ngControl.value; + const step = e.code === 'UpArrow' ? 1 : -1; + let formatChar = formatExpression[start]; + if (!formatChar || !this.formatCharRegExp.exec(formatChar)) { + formatChar = formatExpression[start - 1]; + } + if (this.formatCharRegExp.exec(formatChar)) { + let newDate: Date | undefined; + switch (formatChar) { + case 'y': + case 'Y': + newDate = addYears(date, step); + break; + case 'M': + newDate = addMonths(date, step); + newDate.setFullYear(date.getFullYear()); + break; + case 'd': + case 'D': + newDate = addDays(date, step); + newDate.setMonth(date.getMonth()); + break; + case 'h': + case 'H': + newDate = addHours(date, step); + newDate.setDate(date.getDate()); + break; + case 'm': + newDate = addMinutes(date, step); + newDate.setHours(date.getHours()); + break; + case 's': + case 'S': + newDate = addSeconds(date, step); + newDate.setMinutes(date.getMinutes()); + break; + default: + } + + this.setValue(newDate); + this.elementRef.nativeElement.setSelectionRange(start, start); + } + } + + } else if (e.code === 'Backspace') { + let value = this.elementRef.nativeElement.value; + const char = value.substring(start - 1, (end - start) || 1); + + const selectPreviousChar = (): void => { + let previousStart = getPosition(start, -1); + if (previousStart === undefined) { + previousStart = 0; + } + this.elementRef.nativeElement.setSelectionRange(previousStart, previousStart); + }; + + if (this.maskValue && (/[0-9]+/.exec(char) ?? char === this._placeHolderCharacter)) { + let newStart = start; + let newEnd = end; + if (start === end) { + newStart -= 1; + newEnd = newStart; + } else { + newEnd -= 1; + } + + value = replaceRange(value, newStart, newEnd, this.maskValue); + this.setValue(new Date(NaN)); + void Promise.resolve().then(() => { + this.renderer.setProperty(this.elementRef.nativeElement, 'value', value); + if (newStart === newEnd) { + selectPreviousChar(); + } else { + this.elementRef.nativeElement.setSelectionRange(newStart, newStart); + } + }); + } else { + selectPreviousChar(); + } + + } else if (e.code === 'Delete') { + this.setValue(undefined); + this.applyMask(); + + } else if ((e.code === 'KeyA' && e.ctrlKey) || (e.code === 'KeyA' && e.metaKey)) { // Ctrl+ A + Cmd + A (Mac) + this.elementRef.nativeElement.setSelectionRange(0, -1); + + } else if (/^[0-9]$/.exec(e.key)) { + let value = this.elementRef.nativeElement.value; + if (end > start) { + value = replaceRange(value, start, end, this.maskValue); + } + + const newStart = getPosition(start, 1); + if (newStart === undefined) { + e.preventDefault(); + return false; + } + + value = replaceAt(value, newStart, e.key); + + const selectNextChar = (): void => { + let nextStart = getPosition(newStart + 1, 1); + if (nextStart === undefined) { + nextStart = -1; + } + this.elementRef.nativeElement.setSelectionRange(nextStart, nextStart); + }; + + const newDate = this.parseAndSetValue(value); + if (isValid(newDate)) { + selectNextChar(); + } else { + void Promise.resolve().then(() => { + this.renderer.setProperty(this.elementRef.nativeElement, 'value', value); + selectNextChar(); + }); + } + + } else if (e.code === 'KeyD') { + const today = set(new Date(), { seconds: 0, milliseconds: 0 }); + this.setValue(today); + this.elementRef.nativeElement.setSelectionRange(0, -1); + + } else if (this.forwardToInputKeyCodes.includes(e.key as KeyCodes)) { + return undefined; + + } else { + console.log('DatepickerMaskDirective ignored code', e.code); + } + + e.preventDefault(); + return false; + }); + } + + public ngOnInit(): void { + // Assure que le valueChange est toujours enregistré au focus au cas ou la form aurait changé. + // En cas de réassignation d'une nouvelle form, le valueChange n'est pas renouvelé et l'événement + // n'est plus jamais levé. + fromEvent(this.elementRef.nativeElement, 'focus').pipe( + tap(() => { + if (this.elementRef.nativeElement.value === '') { + this.applyMask(); + } + }), + startWith(undefined), + filterMap(() => this.ngControl.valueChanges), + switchMap(valueChanges$ => valueChanges$.pipe( + tap(value => { + if (value) { + this.validator.markAsValid(); + } + this.applyMask(); + }) + )), + takeUntil(this.destroyed$) + ).subscribe(); + } + + private modelIsValid(): boolean { + const value = this.ngControl.value as unknown; + if (!value || !(value instanceof Date)) { + return false; + } + + return isValid(value); + } + + private applyMask(): void { + this.maskValue = this._formatExpression?.replace(/[ymdhs]/gi, this._placeHolderCharacter) || ''; + void Promise.resolve().then(() => { + if (!this._formatExpression || !this._placeHolderCharacter || this.modelIsValid()) { + return; + } + + this.renderer.setProperty(this.elementRef.nativeElement, 'value', this.maskValue); + this.elementRef.nativeElement.setSelectionRange(0, 0); + }); + } + + private parseAndSetValue(str: string): Date { + if (!this._formatExpression) { + return new Date(); + } + const newDate = parse(str, this._formatExpression, new Date()); + this.setValue(newDate); + return newDate; + } + + private setValue(date: Date | undefined): void { + const updateDateControl = (control: AbstractControl | null, d: unknown, dateAdpater: DateAdapter): void => { + if (!control || !control.value && !d) { + return; + } + if (!dateAdpater.sameDate(control.value, d)) { + control.setValue(d); + control.markAsDirty(); + } + }; + + if (isNil(date) || isValid(date)) { + this.validator.markAsValid(); + updateDateControl(this.ngControl.control, date, this.dateAdapter); + } else { + this.validator.markAsInvalid(); + updateDateControl(this.ngControl.control, null, this.dateAdapter); + } + } +} diff --git a/projects/date-picker/src/datepicker-with-time/datepicker-with-time.component.html b/projects/date-picker/src/datepicker-with-time/datepicker-with-time.component.html new file mode 100644 index 00000000..37c529c1 --- /dev/null +++ b/projects/date-picker/src/datepicker-with-time/datepicker-with-time.component.html @@ -0,0 +1,6 @@ + + +
+ +
+
diff --git a/projects/date-picker/src/datepicker-with-time/datepicker-with-time.component.scss b/projects/date-picker/src/datepicker-with-time/datepicker-with-time.component.scss new file mode 100644 index 00000000..75160528 --- /dev/null +++ b/projects/date-picker/src/datepicker-with-time/datepicker-with-time.component.scss @@ -0,0 +1,41 @@ +deja-time-picker.datepicker-with-time { + .time-range { + display: flex; + align-items: center; + + .time-picker-separator { + text-align: center; + margin: 0 0.5rem; + } + } +} + +.datepicker-with-time.action-buttons { + padding: 1.5rem 0 1rem 0; + display: flex; + justify-content: center; +} + +.mat-datepicker-content-container[layout='h'] { + flex-direction: row; + flex-wrap: wrap; + justify-content: space-evenly; + + .mat-calendar { + height: auto; + + .mat-calendar-header { + padding: 0 8px 0 8px; + } + } + + .mat-calendar, + deja-time-picker.datepicker-with-time { + flex: 0 0 auto; + } + + .datepicker-with-time.action-buttons { + flex: 1 0 100%; + padding: 0 0 1rem 0; + } +} diff --git a/projects/date-picker/src/datepicker-with-time/datepicker-with-time.component.ts b/projects/date-picker/src/datepicker-with-time/datepicker-with-time.component.ts new file mode 100644 index 00000000..0cdd9b55 --- /dev/null +++ b/projects/date-picker/src/datepicker-with-time/datepicker-with-time.component.ts @@ -0,0 +1,109 @@ +import { TemplatePortal } from '@angular/cdk/portal'; +import { AfterViewInit, ChangeDetectionStrategy, Component, ElementRef, Inject, OnDestroy, Optional, TemplateRef, ViewChild, ViewContainerRef, ViewEncapsulation } from '@angular/core'; +import { MatButtonModule } from '@angular/material/button'; +import { DateAdapter } from '@angular/material/core'; +import { MatDatepicker, MatDatepickerInput, MatDateSelectionModel } from '@angular/material/datepicker'; +import { Destroy } from '@hug/ngx-core'; +import { DateOrDuration, TimePickerComponent } from '@hug/ngx-time-picker'; +import { cloneDeep } from 'lodash-es'; +import { delay, filter, map, takeUntil, tap } from 'rxjs'; + +import { DATE_TIME_ADAPTER, DateTimeAdapter } from '../date-adapter/date-time-adapter'; + + +@Component({ + selector: 'datepicker-with-time', + templateUrl: './datepicker-with-time.component.html', + styleUrls: ['./datepicker-with-time.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.None, + standalone: true, + imports: [ + MatButtonModule, + TimePickerComponent + ] +}) +export class DatepickerWithTimeComponent extends Destroy implements AfterViewInit, OnDestroy { + @ViewChild(TemplateRef) + private template?: TemplateRef; + + @ViewChild(TimePickerComponent, { read: ElementRef, static: false }) + private timePickerElement?: ElementRef; + + protected time?: DateOrDuration; + + private portal?: TemplatePortal; + + public constructor( + private datepicker: MatDatepicker, + private viewContainerRef: ViewContainerRef, + private globalModel: MatDateSelectionModel, + @Optional() @Inject(DATE_TIME_ADAPTER) private dateAdapter: DateTimeAdapter & DateAdapter + ) { + super(); + + datepicker.openedStream.pipe( + tap(() => { + const datePickerInput = datepicker.datepickerInput as MatDatepickerInput; + this.time = datePickerInput.value ?? new Date(); + }), + delay(1), + map(() => this.timePickerElement?.nativeElement.parentElement), + filter(Boolean), + takeUntil(this.destroyed$) + ).subscribe(datePickerContainer => { + const containerRef = this.viewContainerRef.element.nativeElement as HTMLElement; + datePickerContainer.setAttribute('layout', containerRef?.ownerDocument?.body?.clientHeight <= 500 ? 'h' : 'v'); + }); + } + + public onDateTimeClosed(): void { + let date = cloneDeep(this.globalModel.selection) || new Date() as unknown; + if (this.time) { + let hours: number; + let minutes: number; + let seconds: number; + if (this.time instanceof Date) { + hours = this.time.getHours(); + minutes = this.time.getMinutes(); + seconds = this.time.getSeconds(); + } else { + hours = this.time.hours || 0; + minutes = this.time.minutes || 0; + seconds = this.time.seconds || 0; + } + if (date instanceof Date) { + date.setHours(hours, minutes, seconds); + } else if (this.dateAdapter) { + date = this.dateAdapter.setTime(date, hours, minutes, seconds); + } + } + this.globalModel.updateSelection(date, this); + const datePickerInput = this.datepicker.datepickerInput as MatDatepickerInput; + datePickerInput.writeValue(date); + } + + public ngAfterViewInit(): void { + if (!this.template) { + return; + } + + this.portal = new TemplatePortal(this.template, this.viewContainerRef); + this.datepicker.registerActions(this.portal); + } + + public override ngOnDestroy(): void { + super.ngOnDestroy(); + + if (!this.portal) { + return; + } + + this.datepicker.removeActions(this.portal); + + // Needs to be null checked since we initialize it in `ngAfterViewInit`. + if (this.portal.isAttached) { + this.portal.detach(); + } + } +} diff --git a/projects/date-picker/src/index.ts b/projects/date-picker/src/index.ts index e69de29b..9f027d8d 100644 --- a/projects/date-picker/src/index.ts +++ b/projects/date-picker/src/index.ts @@ -0,0 +1,7 @@ +export * from './date-adapter/date-time-adapter'; +export * from './date-adapter/multi-format-date-adapter'; +export * from './datepicker-buttons/datepicker-buttons.component'; +export * from './datepicker-mask/datepicker-mask-validator.service'; +export * from './datepicker-mask/datepicker-mask.directive'; +export * from './datepicker-with-time/datepicker-with-time.component'; +