From fc242f330715671b5ca802c0a52f5a3f05812097 Mon Sep 17 00:00:00 2001 From: Radoslav Karaivanov Date: Fri, 30 Jan 2026 12:09:22 +0200 Subject: [PATCH] refactor(date-time-input): New parser and date parts logic * Refactored date-time-input to use a new datetime mask parser * Created date-part components to handle individual date/time parts * Updated validators and tests accordingly * Removed old date-util files * Code cleanup and minor improvements in related components * Updated i18n controller for better localization support * Fixed an issue with style parts in the date-time-input component Closes #2072 --- src/components/calendar/helpers.ts | 87 +- src/components/common/i18n/i18n-controller.ts | 68 +- src/components/date-picker/date-picker.ts | 11 +- .../date-range-picker/date-range-picker.ts | 21 +- .../date-range-picker.utils.spec.ts | 6 +- .../date-time-input/date-part.spec.ts | 508 +++++++++++ src/components/date-time-input/date-part.ts | 510 +++++++++++ .../date-time-input/date-time-input.spec.ts | 813 +++++++++--------- .../date-time-input/date-time-input.ts | 734 ++++++++-------- .../date-time-input/date-util.spec.ts | 670 --------------- src/components/date-time-input/date-util.ts | 646 -------------- .../datetime-mask-parser.spec.ts | 226 +++++ .../date-time-input/datetime-mask-parser.ts | 498 +++++++++++ src/components/date-time-input/validators.ts | 22 +- src/components/mask-input/mask-input-base.ts | 5 + src/components/mask-input/mask-input.ts | 2 +- src/index.ts | 4 +- 17 files changed, 2716 insertions(+), 2115 deletions(-) create mode 100644 src/components/date-time-input/date-part.spec.ts create mode 100644 src/components/date-time-input/date-part.ts delete mode 100644 src/components/date-time-input/date-util.spec.ts delete mode 100644 src/components/date-time-input/date-util.ts create mode 100644 src/components/date-time-input/datetime-mask-parser.spec.ts create mode 100644 src/components/date-time-input/datetime-mask-parser.ts diff --git a/src/components/calendar/helpers.ts b/src/components/calendar/helpers.ts index 9a1b58ba1..ba29cd835 100644 --- a/src/components/calendar/helpers.ts +++ b/src/components/calendar/helpers.ts @@ -37,25 +37,31 @@ const WEEK_DAYS_MAP = { thursday: 4, friday: 5, saturday: 6, -}; +} as const; /* Converter functions */ -export function isValidDate(date: Date): Date | null { - return Number.isNaN(date.valueOf()) ? null : date; +/** + * Type guard to check if a value is a valid Date object. + */ +export function isValidDate(value: unknown): value is Date { + if (value instanceof Date) { + return !Number.isNaN(value.getTime()); + } + return false; } export function parseISODate(string: string): Date | null { // ISO date format (YYYY-MM-DD) if (ISO_DATE_PATTERN.test(string)) { const timeComponent = !string.includes('T') ? 'T00:00:00' : ''; - return isValidDate(new Date(`${string}${timeComponent}`)); + return getValidDate(new Date(`${string}${timeComponent}`)); } // Time format (HH:MM:SS) if (TIME_PATTERN.test(string)) { const today = first(new Date().toISOString().split('T')); - return isValidDate(new Date(`${today}T${string}`)); + return getValidDate(new Date(`${today}T${string}`)); } return null; @@ -74,7 +80,7 @@ export function convertToDate(value?: Date | string | null): Date | null { return null; } - return isString(value) ? parseISODate(value) : isValidDate(value); + return isString(value) ? parseISODate(value) : getValidDate(value); } /** @@ -311,3 +317,72 @@ export function createDateConstraints( return constraints.length > 0 ? constraints : undefined; } + +function getValidDate(date: Date): Date | null { + return Number.isNaN(date.valueOf()) ? null : date; +} + +/** + * Checks if a date is greater than a maximum date value. + */ +export function isDateExceedingMax( + value: Date, + maxValue: Date, + includeTime = true, + includeDate = true +): boolean { + return compareDates( + value, + maxValue, + (a, b) => a > b, + includeTime, + includeDate + ); +} + +/** + * Checks if a date is less than a minimum date value. + */ +export function isDateLessThanMin( + value: Date, + minValue: Date, + includeTime = true, + includeDate = true +): boolean { + return compareDates( + value, + minValue, + (a, b) => a < b, + includeTime, + includeDate + ); +} + +/** + * Compares two dates with optional time/date exclusions. + */ +function compareDates( + value: Date, + boundary: Date, + comparator: (a: number, b: number) => boolean, + includeTime: boolean, + includeDate: boolean +): boolean { + if (includeTime && includeDate) { + return comparator(value.getTime(), boundary.getTime()); + } + + const v = new Date(value.getTime()); + const b = new Date(boundary.getTime()); + + if (!includeTime) { + v.setHours(0, 0, 0, 0); + b.setHours(0, 0, 0, 0); + } + if (!includeDate) { + v.setFullYear(0, 0, 0); + b.setFullYear(0, 0, 0); + } + + return comparator(v.getTime(), b.getTime()); +} diff --git a/src/components/common/i18n/i18n-controller.ts b/src/components/common/i18n/i18n-controller.ts index b12bb6623..41e57adc8 100644 --- a/src/components/common/i18n/i18n-controller.ts +++ b/src/components/common/i18n/i18n-controller.ts @@ -1,5 +1,6 @@ import { getCurrentI18n, + getDateFormatter, getDisplayNamesFormatter, getI18nManager, type IResourceChangeEventArgs, @@ -119,7 +120,7 @@ class I18nController implements ReactiveController { /** @internal */ public handleEvent(event: CustomEvent): void { this._defaultResourceStrings = this._getCurrentResourceStrings(); - this._resourceChangeCallback?.(event); + this._resourceChangeCallback?.call(this._host, event); this._host.requestUpdate(); } @@ -203,6 +204,71 @@ class I18nController implements ReactiveController { //#endregion } +/** + * Formats a date for display based on the specified format and locale. + */ +type DateTimeStyle = 'short' | 'long' | 'medium' | 'full'; + +const DATE_TIME_STYLES = new Set(['short', 'long', 'medium', 'full']); + +function extractStyle(format: string, suffix: string): DateTimeStyle { + return format.toLowerCase().split(suffix)[0] as DateTimeStyle; +} + +/** Returns the default date-time input format for a given locale */ +export function getDefaultDateTimeFormat(locale: string): string { + return getDateFormatter().getLocaleDateTimeFormat(locale, true); +} + +/** Returns the date-time format string with the appropriate suffix if it's a predefined style */ +export function getDateTimeFormat( + format?: string, + suffix: 'Date' | 'Time' = 'Date' +): string | undefined { + return format && DATE_TIME_STYLES.has(format) ? `${format}${suffix}` : format; +} + +/** + * Formats a date for display using the specified format. + */ +export function formatDisplayDate( + value: Date, + locale: string, + displayFormat?: string +): string { + if (!displayFormat) { + return getDateFormatter().formatDateTime(value, locale, {}); + } + + // Full date+time styles (short, long, medium, full) + if (DATE_TIME_STYLES.has(displayFormat)) { + const style = displayFormat as DateTimeStyle; + return getDateFormatter().formatDateTime(value, locale, { + dateStyle: style, + timeStyle: style, + }); + } + + // Date-only styles (shortDate, longDate, etc.) + if (displayFormat.endsWith('Date')) { + return getDateFormatter().formatDateTime(value, locale, { + dateStyle: extractStyle(displayFormat, 'date'), + }); + } + + // Time-only styles (shortTime, longTime, etc.) + if (displayFormat.endsWith('Time')) { + return getDateFormatter().formatDateTime(value, locale, { + timeStyle: extractStyle(displayFormat, 'time'), + }); + } + + // Custom format string + return getDateFormatter().formatDateCustomFormat(value, displayFormat, { + locale, + }); +} + /** Factory function to create and attach the I18nController to a host. */ export function addI18nController( host: I18nControllerHost, diff --git a/src/components/date-picker/date-picker.ts b/src/components/date-picker/date-picker.ts index 6b7226015..e273b3cf5 100644 --- a/src/components/date-picker/date-picker.ts +++ b/src/components/date-picker/date-picker.ts @@ -26,7 +26,10 @@ import { IgcCalendarResourceStringEN, type IgcCalendarResourceStrings, } from '../common/i18n/EN/calendar.resources.js'; -import { addI18nController } from '../common/i18n/i18n-controller.js'; +import { + addI18nController, + getDateTimeFormat, +} from '../common/i18n/i18n-controller.js'; import { IgcBaseComboBoxLikeComponent } from '../common/mixins/combo-box.js'; import type { AbstractConstructor } from '../common/mixins/constructor.js'; import { EventEmitterMixin } from '../common/mixins/event-emitter.js'; @@ -39,8 +42,8 @@ import { equal, findElementFromEventPath, } from '../common/util.js'; +import type { DatePart } from '../date-time-input/date-part.js'; import IgcDateTimeInputComponent from '../date-time-input/date-time-input.js'; -import { type DatePart, DateTimeUtil } from '../date-time-input/date-util.js'; import IgcDialogComponent from '../dialog/dialog.js'; import IgcFocusTrapComponent from '../focus-trap/focus-trap.js'; import IgcIconComponent from '../icon/icon.js'; @@ -828,9 +831,7 @@ export default class IgcDatePickerComponent extends FormAssociatedRequiredMixin( } protected _renderInput(id: string) { - const format = DateTimeUtil.predefinedToDateDisplayFormat( - this._displayFormat - ); + const format = getDateTimeFormat(this._displayFormat); // Dialog mode is always readonly, rest depends on configuration const readOnly = !this._isDropDown || this.readOnly || this.nonEditable; diff --git a/src/components/date-range-picker/date-range-picker.ts b/src/components/date-range-picker/date-range-picker.ts index ca1c1d39a..6cb018827 100644 --- a/src/components/date-range-picker/date-range-picker.ts +++ b/src/components/date-range-picker/date-range-picker.ts @@ -35,7 +35,12 @@ import { type IgcDateRangePickerResourceStrings, IgcDateRangePickerResourceStringsEN, } from '../common/i18n/EN/date-range-picker.resources.js'; -import { addI18nController } from '../common/i18n/i18n-controller.js'; +import { + addI18nController, + formatDisplayDate, + getDateTimeFormat, + getDefaultDateTimeFormat, +} from '../common/i18n/i18n-controller.js'; import { IgcBaseComboBoxLikeComponent } from '../common/mixins/combo-box.js'; import type { AbstractConstructor } from '../common/mixins/constructor.js'; import { EventEmitterMixin } from '../common/mixins/event-emitter.js'; @@ -52,7 +57,6 @@ import { isEmpty, } from '../common/util.js'; import IgcDateTimeInputComponent from '../date-time-input/date-time-input.js'; -import { DateTimeUtil } from '../date-time-input/date-util.js'; import IgcDialogComponent from '../dialog/dialog.js'; import IgcFocusTrapComponent from '../focus-trap/focus-trap.js'; import IgcIconComponent from '../icon/icon.js'; @@ -238,9 +242,7 @@ export default class IgcDateRangePickerComponent extends FormAssociatedRequiredM protected readonly _i18nController = addI18nController(this, { defaultEN: IgcDateRangePickerResourceStringsEN, - onResourceChange: () => { - this._updateDefaultMask(); - }, + onResourceChange: this._updateDefaultMask, }); private _activeDate: Date | null = null; @@ -675,7 +677,7 @@ export default class IgcDateRangePickerComponent extends FormAssociatedRequiredM @watch('locale') protected _updateDefaultMask(): void { - this._defaultMask = DateTimeUtil.getDefaultInputMask(this.locale); + this._defaultMask = getDefaultDateTimeFormat(this.locale); this._defaultDisplayFormat = getDateFormatter().getLocaleDateTimeFormat( this.locale ); @@ -891,9 +893,8 @@ export default class IgcDateRangePickerComponent extends FormAssociatedRequiredM return; } - const { formatDisplayDate, predefinedToDateDisplayFormat } = DateTimeUtil; const { start, end } = this.value; - const displayFormat = predefinedToDateDisplayFormat(this.displayFormat); + const displayFormat = getDateTimeFormat(this.displayFormat); const startValue = formatDisplayDate(start, this.locale, displayFormat); const endValue = formatDisplayDate(end, this.locale, displayFormat); @@ -1120,9 +1121,7 @@ export default class IgcDateRangePickerComponent extends FormAssociatedRequiredM const placeholder = picker === 'start' ? this.placeholderStart : this.placeholderEnd; const label = picker === 'start' ? this.labelStart : this.labelEnd; - const format = DateTimeUtil.predefinedToDateDisplayFormat( - this._displayFormat - ); + const format = getDateTimeFormat(this._displayFormat); const value = picker === 'start' ? this.value?.start : this.value?.end; const prefixes = diff --git a/src/components/date-range-picker/date-range-picker.utils.spec.ts b/src/components/date-range-picker/date-range-picker.utils.spec.ts index 7acf36532..825b2f891 100644 --- a/src/components/date-range-picker/date-range-picker.utils.spec.ts +++ b/src/components/date-range-picker/date-range-picker.utils.spec.ts @@ -2,10 +2,10 @@ import { elementUpdated, expect } from '@open-wc/testing'; import IgcCalendarComponent from '../calendar/calendar.js'; import { getCalendarDOM, getDOMDate } from '../calendar/helpers.spec.js'; import type { CalendarDay } from '../calendar/model.js'; +import { formatDisplayDate } from '../common/i18n/i18n-controller.js'; import { equal } from '../common/util.js'; import { checkDatesEqual, simulateClick } from '../common/utils.spec.js'; import IgcDateTimeInputComponent from '../date-time-input/date-time-input.js'; -import { DateTimeUtil } from '../date-time-input/date-util.js'; import IgcInputComponent from '../input/input.js'; import type IgcDateRangePickerComponent from './date-range-picker.js'; import type { DateRangeValue } from './date-range-picker.js'; @@ -52,14 +52,14 @@ export const checkSelectedRange = ( } else { const input = picker.renderRoot.querySelector(IgcInputComponent.tagName)!; const start = expectedValue?.start - ? DateTimeUtil.formatDisplayDate( + ? formatDisplayDate( expectedValue.start, picker.locale, picker.displayFormat ) : ''; const end = expectedValue?.end - ? DateTimeUtil.formatDisplayDate( + ? formatDisplayDate( expectedValue.end, picker.locale, picker.displayFormat diff --git a/src/components/date-time-input/date-part.spec.ts b/src/components/date-time-input/date-part.spec.ts new file mode 100644 index 000000000..4fba578f6 --- /dev/null +++ b/src/components/date-time-input/date-part.spec.ts @@ -0,0 +1,508 @@ +import { expect } from '@open-wc/testing'; +import { createDatePart, DatePartType, type SpinOptions } from './date-part.js'; + +describe('DatePart Classes', () => { + describe('Factory Function', () => { + it('creates Year part', () => { + const part = createDatePart(DatePartType.Year, { + start: 6, + end: 10, + format: 'yyyy', + }); + expect(part.type).to.equal(DatePartType.Year); + expect(part.start).to.equal(6); + expect(part.end).to.equal(10); + expect(part.format).to.equal('yyyy'); + }); + + it('creates Month part', () => { + const part = createDatePart(DatePartType.Month, { + start: 0, + end: 2, + format: 'MM', + }); + expect(part.type).to.equal(DatePartType.Month); + }); + + it('creates Date part', () => { + const part = createDatePart(DatePartType.Date, { + start: 3, + end: 5, + format: 'dd', + }); + expect(part.type).to.equal(DatePartType.Date); + }); + + it('creates Hours part', () => { + const part = createDatePart(DatePartType.Hours, { + start: 0, + end: 2, + format: 'HH', + }); + expect(part.type).to.equal(DatePartType.Hours); + }); + + it('creates Minutes part', () => { + const part = createDatePart(DatePartType.Minutes, { + start: 3, + end: 5, + format: 'mm', + }); + expect(part.type).to.equal(DatePartType.Minutes); + }); + + it('creates Seconds part', () => { + const part = createDatePart(DatePartType.Seconds, { + start: 6, + end: 8, + format: 'ss', + }); + expect(part.type).to.equal(DatePartType.Seconds); + }); + + it('creates AmPm part', () => { + const part = createDatePart(DatePartType.AmPm, { + start: 9, + end: 11, + format: 'tt', + }); + expect(part.type).to.equal(DatePartType.AmPm); + }); + + it('creates Literal part', () => { + const part = createDatePart(DatePartType.Literal, { + start: 2, + end: 3, + format: '/', + }); + expect(part.type).to.equal(DatePartType.Literal); + }); + }); + + describe('getValue', () => { + const date = new Date(2024, 5, 15, 14, 30, 45); // June 15, 2024 14:30:45 + + it('Year returns full year with yyyy format', () => { + const part = createDatePart(DatePartType.Year, { + start: 0, + end: 4, + format: 'yyyy', + }); + expect(part.getValue(date)).to.equal('2024'); + }); + + it('Year returns two-digit year with yy format', () => { + const part = createDatePart(DatePartType.Year, { + start: 0, + end: 2, + format: 'yy', + }); + expect(part.getValue(date)).to.equal('24'); + }); + + it('Month returns padded month', () => { + const part = createDatePart(DatePartType.Month, { + start: 0, + end: 2, + format: 'MM', + }); + expect(part.getValue(date)).to.equal('06'); + }); + + it('Date returns padded date', () => { + const part = createDatePart(DatePartType.Date, { + start: 0, + end: 2, + format: 'dd', + }); + expect(part.getValue(date)).to.equal('15'); + }); + + it('Hours returns 24-hour format with HH', () => { + const part = createDatePart(DatePartType.Hours, { + start: 0, + end: 2, + format: 'HH', + }); + expect(part.getValue(date)).to.equal('14'); + }); + + it('Hours returns 12-hour format with hh', () => { + const part = createDatePart(DatePartType.Hours, { + start: 0, + end: 2, + format: 'hh', + }); + expect(part.getValue(date)).to.equal('02'); + }); + + it('Minutes returns padded minutes', () => { + const part = createDatePart(DatePartType.Minutes, { + start: 0, + end: 2, + format: 'mm', + }); + expect(part.getValue(date)).to.equal('30'); + }); + + it('Seconds returns padded seconds', () => { + const part = createDatePart(DatePartType.Seconds, { + start: 0, + end: 2, + format: 'ss', + }); + expect(part.getValue(date)).to.equal('45'); + }); + + it('AmPm returns PM for afternoon hours', () => { + const part = createDatePart(DatePartType.AmPm, { + start: 0, + end: 2, + format: 'tt', + }); + expect(part.getValue(date)).to.equal('PM'); + }); + + it('AmPm returns AM for morning hours', () => { + const morningDate = new Date(2024, 5, 15, 9, 30, 45); + const part = createDatePart(DatePartType.AmPm, { + start: 0, + end: 2, + format: 'tt', + }); + expect(part.getValue(morningDate)).to.equal('AM'); + }); + + it('Literal returns the format string', () => { + const part = createDatePart(DatePartType.Literal, { + start: 0, + end: 1, + format: '/', + }); + expect(part.getValue(date)).to.equal('/'); + }); + }); + + describe('validate', () => { + it('Year validates any non-negative number', () => { + const part = createDatePart(DatePartType.Year, { + start: 0, + end: 4, + format: 'yyyy', + }); + expect(part.validate(2024)).to.be.true; + expect(part.validate(0)).to.be.true; + expect(part.validate(-1)).to.be.false; + }); + + it('Month validates 0-11 range', () => { + const part = createDatePart(DatePartType.Month, { + start: 0, + end: 2, + format: 'MM', + }); + expect(part.validate(0)).to.be.true; + expect(part.validate(11)).to.be.true; + expect(part.validate(12)).to.be.false; + expect(part.validate(-1)).to.be.false; + }); + + it('Date validates 1-31 without context', () => { + const part = createDatePart(DatePartType.Date, { + start: 0, + end: 2, + format: 'dd', + }); + expect(part.validate(1)).to.be.true; + expect(part.validate(31)).to.be.true; + expect(part.validate(0)).to.be.false; + expect(part.validate(32)).to.be.false; + }); + + it('Date validates against month/year context', () => { + const part = createDatePart(DatePartType.Date, { + start: 0, + end: 2, + format: 'dd', + }); + // February 2024 (leap year) has 29 days + expect(part.validate(29, { year: 2024, month: 1 })).to.be.true; + expect(part.validate(30, { year: 2024, month: 1 })).to.be.false; + // February 2023 (non-leap year) has 28 days + expect(part.validate(28, { year: 2023, month: 1 })).to.be.true; + expect(part.validate(29, { year: 2023, month: 1 })).to.be.false; + }); + + it('Hours validates 0-23 range', () => { + const part = createDatePart(DatePartType.Hours, { + start: 0, + end: 2, + format: 'HH', + }); + expect(part.validate(0)).to.be.true; + expect(part.validate(23)).to.be.true; + expect(part.validate(24)).to.be.false; + expect(part.validate(-1)).to.be.false; + }); + + it('Minutes validates 0-59 range', () => { + const part = createDatePart(DatePartType.Minutes, { + start: 0, + end: 2, + format: 'mm', + }); + expect(part.validate(0)).to.be.true; + expect(part.validate(59)).to.be.true; + expect(part.validate(60)).to.be.false; + }); + + it('Seconds validates 0-59 range', () => { + const part = createDatePart(DatePartType.Seconds, { + start: 0, + end: 2, + format: 'ss', + }); + expect(part.validate(0)).to.be.true; + expect(part.validate(59)).to.be.true; + expect(part.validate(60)).to.be.false; + }); + + it('AmPm always validates', () => { + const part = createDatePart(DatePartType.AmPm, { + start: 0, + end: 2, + format: 'tt', + }); + expect(part.validate(0)).to.be.true; + expect(part.validate(999)).to.be.true; + }); + + it('Literal always validates', () => { + const part = createDatePart(DatePartType.Literal, { + start: 0, + end: 1, + format: '/', + }); + expect(part.validate(0)).to.be.true; + }); + }); + + describe('spin', () => { + function createSpinOptions( + date: Date, + spinLoop = true, + amPmValue?: string, + originalDate?: Date + ): SpinOptions { + return { date, spinLoop, amPmValue, originalDate }; + } + + describe('Year', () => { + it('spins year up', () => { + const part = createDatePart(DatePartType.Year, { + start: 0, + end: 4, + format: 'yyyy', + }); + const date = new Date(2024, 5, 15); + part.spin(1, createSpinOptions(date)); + expect(date.getFullYear()).to.equal(2025); + }); + + it('spins year down', () => { + const part = createDatePart(DatePartType.Year, { + start: 0, + end: 4, + format: 'yyyy', + }); + const date = new Date(2024, 5, 15); + part.spin(-1, createSpinOptions(date)); + expect(date.getFullYear()).to.equal(2023); + }); + + it('adjusts date for leap year transition', () => { + const part = createDatePart(DatePartType.Year, { + start: 0, + end: 4, + format: 'yyyy', + }); + // Feb 29, 2024 (leap year) -> 2025 (non-leap year) + const date = new Date(2024, 1, 29); + part.spin(1, createSpinOptions(date)); + expect(date.getDate()).to.equal(28); + expect(date.getFullYear()).to.equal(2025); + }); + }); + + describe('Month', () => { + it('spins month up', () => { + const part = createDatePart(DatePartType.Month, { + start: 0, + end: 2, + format: 'MM', + }); + const date = new Date(2024, 5, 15); + part.spin(1, createSpinOptions(date)); + expect(date.getMonth()).to.equal(6); + }); + + it('spins month down', () => { + const part = createDatePart(DatePartType.Month, { + start: 0, + end: 2, + format: 'MM', + }); + const date = new Date(2024, 5, 15); + part.spin(-1, createSpinOptions(date)); + expect(date.getMonth()).to.equal(4); + }); + + it('loops from December to January with spinLoop', () => { + const part = createDatePart(DatePartType.Month, { + start: 0, + end: 2, + format: 'MM', + }); + const date = new Date(2024, 11, 15); // December + part.spin(1, createSpinOptions(date, true)); + expect(date.getMonth()).to.equal(0); // January + }); + + it('stops at December without spinLoop', () => { + const part = createDatePart(DatePartType.Month, { + start: 0, + end: 2, + format: 'MM', + }); + const date = new Date(2024, 11, 15); // December + part.spin(1, createSpinOptions(date, false)); + expect(date.getMonth()).to.equal(11); // Still December + }); + }); + + describe('Date', () => { + it('spins date up', () => { + const part = createDatePart(DatePartType.Date, { + start: 0, + end: 2, + format: 'dd', + }); + const date = new Date(2024, 5, 15); + part.spin(1, createSpinOptions(date)); + expect(date.getDate()).to.equal(16); + }); + + it('spins date down', () => { + const part = createDatePart(DatePartType.Date, { + start: 0, + end: 2, + format: 'dd', + }); + const date = new Date(2024, 5, 15); + part.spin(-1, createSpinOptions(date)); + expect(date.getDate()).to.equal(14); + }); + + it('loops to 1 after max day with spinLoop', () => { + const part = createDatePart(DatePartType.Date, { + start: 0, + end: 2, + format: 'dd', + }); + const date = new Date(2024, 5, 30); // June 30 + part.spin(1, createSpinOptions(date, true)); + expect(date.getDate()).to.equal(1); + }); + }); + + describe('Hours', () => { + it('spins hours up', () => { + const part = createDatePart(DatePartType.Hours, { + start: 0, + end: 2, + format: 'HH', + }); + const date = new Date(2024, 5, 15, 14); + part.spin(1, createSpinOptions(date)); + expect(date.getHours()).to.equal(15); + }); + + it('loops from 23 to 0 with spinLoop', () => { + const part = createDatePart(DatePartType.Hours, { + start: 0, + end: 2, + format: 'HH', + }); + const date = new Date(2024, 5, 15, 23); + part.spin(1, createSpinOptions(date, true)); + expect(date.getHours()).to.equal(0); + }); + }); + + describe('Minutes', () => { + it('spins minutes up', () => { + const part = createDatePart(DatePartType.Minutes, { + start: 0, + end: 2, + format: 'mm', + }); + const date = new Date(2024, 5, 15, 14, 30); + part.spin(1, createSpinOptions(date)); + expect(date.getMinutes()).to.equal(31); + }); + }); + + describe('Seconds', () => { + it('spins seconds up', () => { + const part = createDatePart(DatePartType.Seconds, { + start: 0, + end: 2, + format: 'ss', + }); + const date = new Date(2024, 5, 15, 14, 30, 45); + part.spin(1, createSpinOptions(date)); + expect(date.getSeconds()).to.equal(46); + }); + }); + + describe('AmPm', () => { + it('toggles from AM to PM', () => { + const part = createDatePart(DatePartType.AmPm, { + start: 0, + end: 2, + format: 'tt', + }); + const date = new Date(2024, 5, 15, 9); // 9 AM + const original = new Date(date.getTime()); + part.spin(1, createSpinOptions(date, true, 'AM', original)); + expect(date.getHours()).to.equal(21); // 9 PM + }); + + it('toggles from PM to AM', () => { + const part = createDatePart(DatePartType.AmPm, { + start: 0, + end: 2, + format: 'tt', + }); + const date = new Date(2024, 5, 15, 14); // 2 PM + const original = new Date(date.getTime()); + part.spin(1, createSpinOptions(date, true, 'PM', original)); + expect(date.getHours()).to.equal(2); // 2 AM + }); + }); + + describe('Literal', () => { + it('does nothing when spun', () => { + const part = createDatePart(DatePartType.Literal, { + start: 0, + end: 1, + format: '/', + }); + const date = new Date(2024, 5, 15); + const originalTime = date.getTime(); + part.spin(1, createSpinOptions(date)); + expect(date.getTime()).to.equal(originalTime); + }); + }); + }); +}); diff --git a/src/components/date-time-input/date-part.ts b/src/components/date-time-input/date-part.ts new file mode 100644 index 000000000..a6d1a4fd5 --- /dev/null +++ b/src/components/date-time-input/date-part.ts @@ -0,0 +1,510 @@ +/** + * Date Part Classes Module + * + * This module provides structured classes for date/time parts used in date-time input. + * Each part type has its own class with specific validation and spin behavior. + * + * Classes are private to this module - only types and factory function are exported. + */ + +//#region Types and Enums + +export enum DatePart { + Month = 'month', + Year = 'year', + Date = 'date', + Hours = 'hours', + Minutes = 'minutes', + Seconds = 'seconds', + AmPm = 'amPm', +} + +/** Types of date/time parts that can appear in a format string */ +export const DatePartType = { + Month: 'month', + Year: 'year', + Date: 'date', + Hours: 'hours', + Minutes: 'minutes', + Seconds: 'seconds', + AmPm: 'amPm', + Literal: 'literal', +} as const; + +export type DatePartType = (typeof DatePartType)[keyof typeof DatePartType]; + +// Spin delta defaults +export const DEFAULT_DATE_PARTS_SPIN_DELTAS = Object.freeze({ + date: 1, + month: 1, + year: 1, + hours: 1, + minutes: 1, + seconds: 1, +}); + +export interface DatePartDeltas { + date?: number; + month?: number; + year?: number; + hours?: number; + minutes?: number; + seconds?: number; +} + +/** Options for creating a date part */ +export interface DatePartOptions { + /** Start position in the masked string */ + start: number; + /** End position in the masked string */ + end: number; + /** The format string for this part (e.g., 'MM', 'yyyy') */ + format: string; +} + +/** Options for spin operations */ +export interface SpinOptions { + /** The current date value */ + date: Date; + /** Whether to loop values at boundaries */ + spinLoop: boolean; + /** For AM/PM: the current masked value to determine AM or PM */ + amPmValue?: string; + /** For AM/PM: the original date (for rollover prevention) */ + originalDate?: Date; +} + +/** Read-only interface for date part information */ +export interface IDatePart { + /** The type of date part */ + readonly type: DatePartType; + /** Start position in the masked string */ + readonly start: number; + /** End position in the masked string */ + readonly end: number; + /** The format string for this part */ + readonly format: string; + + /** + * Validates a numeric value for this part. + * @param value - The value to validate + * @param context - Optional context (year, month) for date-dependent validation + * @returns true if the value is valid for this part + */ + validate(value: number, context?: DateValidationContext): boolean; + + /** + * Spins (increments/decrements) this part's value on the given date. + * @param delta - The amount to spin (positive = up, negative = down) + * @param options - Spin options including the date and loop behavior + */ + spin(delta: number, options: SpinOptions): void; + + /** + * Extracts the value of this part from a Date object. + * @param date - The date to extract from + * @returns The formatted string value + */ + getValue(date: Date): string; +} + +/** Context for date validation (needed for day-of-month validation) */ +export interface DateValidationContext { + year?: number; + month?: number; +} + +//#endregion + +//#region Constants + +/** Time bounds for validation */ +const TIME_BOUNDS = { + hours: { min: 0, max: 23 }, + minutes: { min: 0, max: 59 }, + seconds: { min: 0, max: 59 }, +} as const; + +/** Date bounds for validation */ +const DATE_BOUNDS = { + month: { min: 0, max: 11 }, + date: { min: 1, max: 31 }, +} as const; + +//#endregion + +//#region Helper Functions + +/** + * Gets the number of days in a specific month/year. + */ +function daysInMonth(year: number, month: number): number { + return new Date(year, month + 1, 0).getDate(); +} + +/** + * Pads a value with zeros to the specified length. + */ +function padValue(value: string | number, length: number): string { + return String(value).padStart(length, '0'); +} + +/** + * Converts 24-hour format to 12-hour format. + */ +function toTwelveHourFormat(hours: number): number { + const h = hours % 12; + return h === 0 ? 12 : h; +} + +/** + * Generic spin helper for time parts. + */ +function spinTimePart( + date: Date, + delta: number, + max: number, + min: number, + setter: (value: number) => number, + getter: () => number, + spinLoop: boolean +): void { + let value = getter.call(date) + delta; + + if (value > max) { + value = spinLoop ? value % (max + 1) : max; + } else if (value < min) { + value = spinLoop ? max + 1 + (value % (max + 1)) : min; + } + + setter.call(date, value); +} + +//#endregion + +//#region Abstract Base Class + +/** + * Abstract base class for all date parts. + * Provides common functionality and defines the contract for concrete implementations. + */ +abstract class DatePartBase implements IDatePart { + readonly type: DatePartType; + readonly start: number; + readonly end: number; + readonly format: string; + + constructor(type: DatePartType, options: DatePartOptions) { + this.type = type; + this.start = options.start; + this.end = options.end; + this.format = options.format; + } + + abstract validate(value: number, context?: DateValidationContext): boolean; + abstract spin(delta: number, options: SpinOptions): void; + abstract getValue(date: Date): string; +} + +//#endregion + +//#region Concrete Implementations + +/** + * Year part (yyyy, yy) + */ +class YearPart extends DatePartBase { + constructor(options: DatePartOptions) { + super(DatePartType.Year, options); + } + + validate(_value: number): boolean { + // Years are always valid (no upper bound) + return _value >= 0; + } + + spin(delta: number, options: SpinOptions): void { + const { date } = options; + const maxDate = daysInMonth(date.getFullYear() + delta, date.getMonth()); + + // Clip to max day to avoid leap year change shifting the entire value + if (date.getDate() > maxDate) { + date.setDate(maxDate); + } + + date.setFullYear(date.getFullYear() + delta); + } + + getValue(date: Date): string { + const length = this.format.length; + const year = + length === 2 + ? date.getFullYear().toString().slice(-2) + : date.getFullYear(); + return padValue(year, length); + } +} + +/** + * Month part (MM, M) + */ +class MonthPart extends DatePartBase { + constructor(options: DatePartOptions) { + super(DatePartType.Month, options); + } + + validate(value: number): boolean { + // Month is 0-based internally, but 1-12 in display + return value >= DATE_BOUNDS.month.min && value <= DATE_BOUNDS.month.max; + } + + spin(delta: number, options: SpinOptions): void { + const { date, spinLoop } = options; + const { min, max } = DATE_BOUNDS.month; + + const maxDate = daysInMonth(date.getFullYear(), date.getMonth() + delta); + if (date.getDate() > maxDate) { + date.setDate(maxDate); + } + + let month = date.getMonth() + delta; + + if (month > max) { + month = spinLoop ? (month % max) - 1 : max; + } else if (month < min) { + month = spinLoop ? max + (month % max) + 1 : min; + } + + date.setMonth(month); + } + + getValue(date: Date): string { + return padValue(date.getMonth() + 1, this.format.length); + } +} + +/** + * Date (day of month) part (dd, d) + */ +class DateOfMonthPart extends DatePartBase { + constructor(options: DatePartOptions) { + super(DatePartType.Date, options); + } + + validate(value: number, context?: DateValidationContext): boolean { + if (value < DATE_BOUNDS.date.min) { + return false; + } + + if (context?.year !== undefined && context?.month !== undefined) { + const maxDays = daysInMonth(context.year, context.month); + return value <= maxDays; + } + + return value <= DATE_BOUNDS.date.max; + } + + spin(delta: number, options: SpinOptions): void { + const { date, spinLoop } = options; + const maxDate = daysInMonth(date.getFullYear(), date.getMonth()); + const { min } = DATE_BOUNDS.date; + + let dayOfMonth = date.getDate() + delta; + + if (dayOfMonth > maxDate) { + dayOfMonth = spinLoop ? dayOfMonth % maxDate : maxDate; + } else if (dayOfMonth < min) { + dayOfMonth = spinLoop ? maxDate + (dayOfMonth % maxDate) : min; + } + + date.setDate(dayOfMonth); + } + + getValue(date: Date): string { + return padValue(date.getDate(), this.format.length); + } +} + +/** + * Hours part (HH, H, hh, h) + */ +class HoursPart extends DatePartBase { + constructor(options: DatePartOptions) { + super(DatePartType.Hours, options); + } + + validate(value: number): boolean { + return value >= TIME_BOUNDS.hours.min && value <= TIME_BOUNDS.hours.max; + } + + spin(delta: number, options: SpinOptions): void { + const { date, spinLoop } = options; + const { min, max } = TIME_BOUNDS.hours; + spinTimePart(date, delta, max, min, date.setHours, date.getHours, spinLoop); + } + + getValue(date: Date): string { + const isTwelveHour = this.format.includes('h'); + const hours = isTwelveHour + ? toTwelveHourFormat(date.getHours()) + : date.getHours(); + return padValue(hours, this.format.length); + } +} + +/** + * Minutes part (mm, m) + */ +class MinutesPart extends DatePartBase { + constructor(options: DatePartOptions) { + super(DatePartType.Minutes, options); + } + + validate(value: number): boolean { + return value >= TIME_BOUNDS.minutes.min && value <= TIME_BOUNDS.minutes.max; + } + + spin(delta: number, options: SpinOptions): void { + const { date, spinLoop } = options; + const { min, max } = TIME_BOUNDS.minutes; + spinTimePart( + date, + delta, + max, + min, + date.setMinutes, + date.getMinutes, + spinLoop + ); + } + + getValue(date: Date): string { + return padValue(date.getMinutes(), this.format.length); + } +} + +/** + * Seconds part (ss, s) + */ +class SecondsPart extends DatePartBase { + constructor(options: DatePartOptions) { + super(DatePartType.Seconds, options); + } + + validate(value: number): boolean { + return value >= TIME_BOUNDS.seconds.min && value <= TIME_BOUNDS.seconds.max; + } + + spin(delta: number, options: SpinOptions): void { + const { date, spinLoop } = options; + const { min, max } = TIME_BOUNDS.seconds; + spinTimePart( + date, + delta, + max, + min, + date.setSeconds, + date.getSeconds, + spinLoop + ); + } + + getValue(date: Date): string { + return padValue(date.getSeconds(), this.format.length); + } +} + +/** + * AM/PM part (tt, t) + */ +class AmPmPart extends DatePartBase { + constructor(options: DatePartOptions) { + super(DatePartType.AmPm, options); + } + + validate(_value: number): boolean { + // AM/PM doesn't have numeric validation + return true; + } + + spin(_delta: number, options: SpinOptions): void { + const { date, amPmValue, originalDate } = options; + + if (!amPmValue) return; + + const isAM = amPmValue.toLowerCase() === 'am'; + const newHours = date.getHours() + (isAM ? 12 : -12); + date.setHours(newHours); + + // Prevent date rollover + if (originalDate && date.getDate() !== originalDate.getDate()) { + date.setTime(originalDate.getTime()); + } + } + + getValue(date: Date): string { + return date.getHours() >= 12 ? 'PM' : 'AM'; + } +} + +/** + * Literal part (separators like /, -, :, space, etc.) + */ +class LiteralPart extends DatePartBase { + constructor(options: DatePartOptions) { + super(DatePartType.Literal, options); + } + + validate(_value: number): boolean { + // Literals don't have validation + return true; + } + + spin(_delta: number, _options: SpinOptions): void { + // Literals can't be spun + } + + getValue(_date: Date): string { + return this.format; + } +} + +//#endregion + +//#region Factory Function + +/** + * Creates a date part instance based on the type. + * This is the only way to create date part instances outside this module. + * + * @param type - The type of date part to create + * @param options - The options for the date part + * @returns A date part instance implementing IDatePart + */ +export function createDatePart( + type: DatePartType, + options: DatePartOptions +): IDatePart { + switch (type) { + case DatePartType.Year: + return new YearPart(options); + case DatePartType.Month: + return new MonthPart(options); + case DatePartType.Date: + return new DateOfMonthPart(options); + case DatePartType.Hours: + return new HoursPart(options); + case DatePartType.Minutes: + return new MinutesPart(options); + case DatePartType.Seconds: + return new SecondsPart(options); + case DatePartType.AmPm: + return new AmPmPart(options); + case DatePartType.Literal: + return new LiteralPart(options); + default: + throw new Error(`Unknown date part type: ${type}`); + } +} + +//#endregion diff --git a/src/components/date-time-input/date-time-input.spec.ts b/src/components/date-time-input/date-time-input.spec.ts index d10f504f3..244e4bb9d 100644 --- a/src/components/date-time-input/date-time-input.spec.ts +++ b/src/components/date-time-input/date-time-input.spec.ts @@ -1,5 +1,7 @@ import { elementUpdated, expect, fixture, html } from '@open-wc/testing'; +import { setCurrentI18n } from 'igniteui-i18n-core'; import { spy } from 'sinon'; +import { isValidDate } from '../calendar/helpers.js'; import { CalendarDay, toCalendarDay } from '../calendar/model.js'; import { altKey, @@ -22,184 +24,210 @@ import { type ValidationContainerTestsParams, ValidityHelpers, } from '../common/validity-helpers.spec.js'; -import { MaskParser } from '../mask-input/mask-parser.js'; +import { DatePart } from './date-part.js'; import IgcDateTimeInputComponent from './date-time-input.js'; -import { DatePart, type DatePartDeltas, DateTimeUtil } from './date-util.js'; +import { DateTimeMaskParser } from './datetime-mask-parser.js'; describe('Date Time Input component', () => { before(() => { defineComponents(IgcDateTimeInputComponent); }); - const parser = new MaskParser(); - const defaultPrompt = '_'; - const defaultFormat = 'MM/dd/yyyy'; + const parser = new DateTimeMaskParser(); + const DEFAULT_PROMPT = '_'; + const DEFAULT_FORMAT = 'MM/dd/yyyy'; - let el: IgcDateTimeInputComponent; + let element: IgcDateTimeInputComponent; let input: HTMLInputElement; describe('', async () => { beforeEach(async () => { - el = await fixture( + element = await fixture( html`` ); - input = el.renderRoot.querySelector('input')!; - parser.prompt = defaultPrompt; - parser.mask = '__/__/____'; + + input = element.renderRoot.querySelector('input')!; + parser.prompt = DEFAULT_PROMPT; + parser.mask = DEFAULT_FORMAT; }); it('should set default values correctly', async () => { - expect(el.value).to.be.null; - expect(el.prompt).to.equal(defaultPrompt); - expect(el.inputFormat).to.equal(defaultFormat); - expect(input.placeholder).to.equal(defaultFormat); + expect(element.value).to.be.null; + expect(element.prompt).to.equal(DEFAULT_PROMPT); + expect(element.inputFormat).to.equal(DEFAULT_FORMAT); + expect(input.placeholder).to.equal(DEFAULT_FORMAT); }); it('should update inputFormat with no value according to locale', async () => { - el.locale = 'no'; - await elementUpdated(el); - expect(el.placeholder).to.equal('dd.MM.yyyy'); - expect(el.inputFormat).to.equal('dd.MM.yyyy'); - expect(el.displayFormat).to.equal('d.M.yyyy'); + element.locale = 'no'; + await elementUpdated(element); + expect(element.placeholder).to.equal('dd.MM.yyyy'); + expect(element.inputFormat).to.equal('dd.MM.yyyy'); + expect(element.displayFormat).to.equal('d.M.yyyy'); }); it('should update inputFormat with value according to locale', async () => { - el.value = new Date(2020, 2, 3); - await elementUpdated(el); + element.value = new Date(2020, 2, 3); + await elementUpdated(element); expect(input.value).to.equal('3/3/2020'); - el.locale = 'no'; - await elementUpdated(el); - expect(el.placeholder).to.equal('dd.MM.yyyy'); - expect(el.inputFormat).to.equal('dd.MM.yyyy'); - expect(el.displayFormat).to.equal('d.M.yyyy'); + element.locale = 'no'; + await elementUpdated(element); + expect(element.placeholder).to.equal('dd.MM.yyyy'); + expect(element.inputFormat).to.equal('dd.MM.yyyy'); + expect(element.displayFormat).to.equal('d.M.yyyy'); + expect(input.value).to.equal('3.3.2020'); + }); + + it('should update inputFormat with no value when using the localization API', async () => { + setCurrentI18n('no'); + await elementUpdated(element); + + expect(element.placeholder).to.equal('dd.MM.yyyy'); + expect(element.inputFormat).to.equal('dd.MM.yyyy'); + expect(element.displayFormat).to.equal('d.M.yyyy'); + + // Restore default locale + setCurrentI18n('en'); + }); + + it('should update inputFormat with value when using the localization API', async () => { + element.value = new Date(2020, 2, 3); + setCurrentI18n('no'); + await elementUpdated(element); + + expect(element.placeholder).to.equal('dd.MM.yyyy'); + expect(element.inputFormat).to.equal('dd.MM.yyyy'); + expect(element.displayFormat).to.equal('d.M.yyyy'); expect(input.value).to.equal('3.3.2020'); + + // Restore default locale + setCurrentI18n('en'); }); it('should use inputFormat if no displayFormat is defined - issue 1114', async () => { - el.inputFormat = 'yyyy#MM#dd'; - await elementUpdated(el); + element.inputFormat = 'yyyy#MM#dd'; + await elementUpdated(element); - expect((el as any)._displayFormat).to.be.undefined; + expect((element as any)._displayFormat).to.be.undefined; expect(input.placeholder).to.equal('yyyy#MM#dd'); - el.value = new Date(2020, 2, 3); - await elementUpdated(el); + element.value = new Date(2020, 2, 3); + await elementUpdated(element); - el.inputFormat = 'yyyy@MM@dd'; - await elementUpdated(el); + element.inputFormat = 'yyyy@MM@dd'; + await elementUpdated(element); expect(input.value).to.equal('2020@03@03'); // displayFormats overwrites - el.displayFormat = '-- yyyy -- MM -- dd --'; - await elementUpdated(el); + element.displayFormat = '-- yyyy -- MM -- dd --'; + await elementUpdated(element); expect(input.value).to.equal('-- 2020 -- 03 -- 03 --'); // Reset - el.displayFormat = undefined as any; - await elementUpdated(el); + element.displayFormat = undefined as any; + await elementUpdated(element); expect(input.value).to.equal('2020@03@03'); }); it('should use displayFormat when defined', async () => { - expect((el as any)._displayFormat).to.be.undefined; + expect((element as any)._displayFormat).to.be.undefined; - el.value = new Date(2020, 2, 3); - await elementUpdated(el); + element.value = new Date(2020, 2, 3); + await elementUpdated(element); expect(input.value).to.equal('3/3/2020'); - el.displayFormat = 'dd.MM/yyyy'; - await elementUpdated(el); + element.displayFormat = 'dd.MM/yyyy'; + await elementUpdated(element); expect(input.value).to.equal('03.03/2020'); - el.displayFormat = 'd.M'; - await elementUpdated(el); + element.displayFormat = 'd.M'; + await elementUpdated(element); expect(input.value).to.equal('3.3'); - el.value = new Date(2020, 9, 12, 15, 5, 5); - await elementUpdated(el); + element.value = new Date(2020, 9, 12, 15, 5, 5); + await elementUpdated(element); expect(input.value).to.equal('12.10'); - el.displayFormat = 'd MMM'; - await elementUpdated(el); + element.displayFormat = 'd MMM'; + await elementUpdated(element); expect(input.value).to.equal('12 Oct'); - el.displayFormat = 'd MMMM'; - await elementUpdated(el); + element.displayFormat = 'd MMMM'; + await elementUpdated(element); expect(input.value).to.equal('12 October'); - el.displayFormat = 'd MMMMM'; - await elementUpdated(el); + element.displayFormat = 'd MMMMM'; + await elementUpdated(element); expect(input.value).to.equal('12 O'); - el.displayFormat = 'd.MM.y'; - await elementUpdated(el); + element.displayFormat = 'd.MM.y'; + await elementUpdated(element); expect(input.value).to.equal('12.10.2020'); - el.displayFormat = 'd.MM.yyy'; - await elementUpdated(el); + element.displayFormat = 'd.MM.yyy'; + await elementUpdated(element); expect(input.value).to.equal('12.10.2020'); //12H format - el.displayFormat = 'd.MM hh:mm tt'; - await elementUpdated(el); + element.displayFormat = 'd.MM hh:mm tt'; + await elementUpdated(element); expect(input.value).to.equal('12.10 03:05 PM'); - el.displayFormat = 'd.MM H:mm'; - await elementUpdated(el); + element.displayFormat = 'd.MM H:mm'; + await elementUpdated(element); expect(input.value).to.equal('12.10 15:05'); - el.value = new Date(2020, 9, 12, 12); - el.displayFormat = 'd.MM hh:mm ttt'; - await elementUpdated(el); + element.value = new Date(2020, 9, 12, 12); + element.displayFormat = 'd.MM hh:mm ttt'; + await elementUpdated(element); expect(input.value).to.equal('12.10 12:00 pm'); - el.displayFormat = 'd.MM hh:mm ttttt'; - await elementUpdated(el); + element.displayFormat = 'd.MM hh:mm ttttt'; + await elementUpdated(element); expect(input.value).to.equal('12.10 12:00 p'); - el.displayFormat = 'd.MM hh:mm bbbb'; - await elementUpdated(el); + element.displayFormat = 'd.MM hh:mm bbbb'; + await elementUpdated(element); expect(input.value).to.equal('12.10 12:00 noon'); - el.displayFormat = 'd.MM hh:mm bbbbb'; - await elementUpdated(el); + element.displayFormat = 'd.MM hh:mm bbbbb'; + await elementUpdated(element); expect(input.value).to.equal('12.10 12:00 n'); }); it('should update the mask according to the inputFormat on focus when value is set - issue #1320', async () => { - // const eventSpy = spy(el, 'emitEvent'); - el.inputFormat = 'dd-MM-yyyy'; - el.displayFormat = 'yyyy-MM-dd'; - el.value = new Date(2024, 6, 22); - await elementUpdated(el); + element.inputFormat = 'dd-MM-yyyy'; + element.displayFormat = 'yyyy-MM-dd'; + element.value = new Date(2024, 6, 22); + await elementUpdated(element); expect(input.value).to.equal('2024-07-22'); input.click(); - await elementUpdated(el); + await elementUpdated(element); - expect(isFocused(el)).to.be.true; + expect(isFocused(element)).to.be.true; expect(input.value).to.equal('22-07-2024'); }); it('should emit igcChange on blur after an incomplete mask has been parsed - issue #1695', async () => { - const eventSpy = spy(el, 'emitEvent'); - el.focus(); - await elementUpdated(el); + const eventSpy = spy(element, 'emitEvent'); + element.focus(); + await elementUpdated(element); simulateInput(input, { value: '0', inputType: 'insertText', }); - await elementUpdated(el); + await elementUpdated(element); - el.blur(); - await elementUpdated(el); + element.blur(); + await elementUpdated(element); expect(eventSpy).calledWith('igcChange'); expect(input.value).to.deep.equal('1/1/2000'); @@ -210,118 +238,118 @@ describe('Date Time Input component', () => { let formattedDate = setDateFormat('short', targetDate); - expect((el as any)._displayFormat).to.be.undefined; + expect((element as any)._displayFormat).to.be.undefined; - el.value = new Date(2020, 2, 3); - el.displayFormat = 'short'; - await elementUpdated(el); + element.value = new Date(2020, 2, 3); + element.displayFormat = 'short'; + await elementUpdated(element); expect(input.value).to.equal(formattedDate); formattedDate = setDateFormat('medium', targetDate); - el.displayFormat = 'medium'; - await elementUpdated(el); + element.displayFormat = 'medium'; + await elementUpdated(element); expect(input.value).to.equal(formattedDate); formattedDate = setDateFormat('long', targetDate); - el.displayFormat = 'long'; - await elementUpdated(el); + element.displayFormat = 'long'; + await elementUpdated(element); expect(input.value).to.equal(formattedDate); formattedDate = setDateFormat('full', targetDate); - el.displayFormat = 'full'; - await elementUpdated(el); + element.displayFormat = 'full'; + await elementUpdated(element); expect(input.value).to.equal(formattedDate); formattedDate = setDateFormat('short', targetDate, true, false); - el.displayFormat = 'shortDate'; - await elementUpdated(el); + element.displayFormat = 'shortDate'; + await elementUpdated(element); expect(input.value).to.equal(formattedDate); formattedDate = setDateFormat('medium', targetDate, true, false); - el.displayFormat = 'mediumDate'; - await elementUpdated(el); + element.displayFormat = 'mediumDate'; + await elementUpdated(element); expect(input.value).to.equal(formattedDate); formattedDate = setDateFormat('long', targetDate, true, false); - el.displayFormat = 'longDate'; - await elementUpdated(el); + element.displayFormat = 'longDate'; + await elementUpdated(element); expect(input.value).to.equal(formattedDate); formattedDate = setDateFormat('full', targetDate, true, false); - el.displayFormat = 'fullDate'; - await elementUpdated(el); + element.displayFormat = 'fullDate'; + await elementUpdated(element); expect(input.value).to.equal(formattedDate); formattedDate = setDateFormat('short', targetDate, false, true); - el.displayFormat = 'shortTime'; - await elementUpdated(el); + element.displayFormat = 'shortTime'; + await elementUpdated(element); expect(input.value).to.equal(formattedDate); formattedDate = setDateFormat('medium', targetDate, false, true); - el.displayFormat = 'mediumTime'; - await elementUpdated(el); + element.displayFormat = 'mediumTime'; + await elementUpdated(element); expect(input.value).to.equal(formattedDate); formattedDate = setDateFormat('long', targetDate, false, true); - el.displayFormat = 'longTime'; - await elementUpdated(el); + element.displayFormat = 'longTime'; + await elementUpdated(element); expect(input.value).to.equal(formattedDate); formattedDate = setDateFormat('full', targetDate, false, true); - el.displayFormat = 'fullTime'; - await elementUpdated(el); + element.displayFormat = 'fullTime'; + await elementUpdated(element); expect(input.value).to.equal(formattedDate); }); it('should clear input date on clear', async () => { - el.value = new Date(2020, 2, 3); - await elementUpdated(el); + element.value = new Date(2020, 2, 3); + await elementUpdated(element); expect(input.value).to.equal('3/3/2020'); - el.clear(); - await elementUpdated(el); + element.clear(); + await elementUpdated(element); expect(input.value).to.equal(''); }); it('set value attribute', async () => { const value = new Date(2020, 2, 3).toISOString(); - el.setAttribute('value', value); + element.setAttribute('value', value); await elementUpdated(input); - expect(el.value?.toISOString()).to.equal(value); + expect(element.value?.toISOString()).to.equal(value); }); it('set value', async () => { const value = new Date(2020, 2, 3); - el.value = value; - await elementUpdated(el); + element.value = value; + await elementUpdated(element); - expect(el.value).to.equal(value); + expect(element.value).to.equal(value); }); it('set value - time portion only', async () => { const target = new Date(); target.setHours(14, 0, 0, 0); - el.inputFormat = 'HH:mm'; - el.value = '14:00'; - await elementUpdated(el); + element.inputFormat = 'HH:mm'; + element.value = '14:00'; + await elementUpdated(element); - expect(el.value?.valueOf()).to.equal(target.valueOf()); + expect(element.value?.valueOf()).to.equal(target.valueOf()); // Invalid time portion - el.value = '23:60'; - await elementUpdated(el); + element.value = '23:60'; + await elementUpdated(element); - expect(el.value).to.be.null; + expect(element.value).to.be.null; }); it('set value - string property binding', async () => { const value = new Date(2020, 2, 3); - el.value = value.toISOString(); - await elementUpdated(el); + element.value = value.toISOString(); + await elementUpdated(element); - expect(el.value?.valueOf()).to.equal(value.valueOf()); + expect(element.value?.valueOf()).to.equal(value.valueOf()); }); it('stepUp should initialize new date if value is empty', async () => { @@ -329,11 +357,11 @@ describe('Date Time Input component', () => { expect(input.value).to.equal(''); - el.stepUp(); - await elementUpdated(el); + element.stepUp(); + await elementUpdated(element); - expect(el.value).to.not.be.null; - expect(el.value!.setHours(0, 0, 0, 0)).to.equal( + expect(element.value).to.not.be.null; + expect(element.value?.setHours(0, 0, 0, 0)).to.equal( today.setHours(0, 0, 0, 0) ); }); @@ -343,11 +371,11 @@ describe('Date Time Input component', () => { expect(input.value).to.equal(''); - el.stepDown(); - await elementUpdated(el); + element.stepDown(); + await elementUpdated(element); - expect(el.value).to.not.be.null; - expect(el.value!.setHours(0, 0, 0, 0)).to.equal( + expect(element.value).to.not.be.null; + expect(element.value!.setHours(0, 0, 0, 0)).to.equal( today.setHours(0, 0, 0, 0) ); }); @@ -355,40 +383,40 @@ describe('Date Time Input component', () => { it('should stepUp correctly', async () => { const value = new Date(2020, 2, 3, 0, 0, 0, 0); - el.value = value; - el.inputFormat = 'dd.MM.yyyy hh:mm'; - el.stepUp(); - await elementUpdated(el); - expect(el.value!.getDate()).to.equal(value.getDate() + 1); + element.value = value; + element.inputFormat = 'dd.MM.yyyy hh:mm'; + element.stepUp(); + await elementUpdated(element); + expect(element.value!.getDate()).to.equal(value.getDate() + 1); - el.inputFormat = 'MM.yy hh:mm'; - el.stepUp(); - await elementUpdated(el); - expect(el.value!.getHours()).to.equal(value.getHours() + 1); + element.inputFormat = 'MM.yy hh:mm'; + element.stepUp(); + await elementUpdated(element); + expect(element.value!.getHours()).to.equal(value.getHours() + 1); - el.inputFormat = 'MM.yy'; - el.stepUp(); - await elementUpdated(el); - expect(el.value!.getMonth()).to.equal(value.getMonth() + 1); + element.inputFormat = 'MM.yy'; + element.stepUp(); + await elementUpdated(element); + expect(element.value!.getMonth()).to.equal(value.getMonth() + 1); - el.inputFormat = 'dd.MM.yy hh:mm:ss tt'; - el.stepUp(DatePart.Year); - await elementUpdated(el); - expect(el.value!.getFullYear()).to.equal(value.getFullYear() + 1); + element.inputFormat = 'dd.MM.yy hh:mm:ss tt'; + element.stepUp(DatePart.Year); + await elementUpdated(element); + expect(element.value!.getFullYear()).to.equal(value.getFullYear() + 1); - el.stepUp(DatePart.Minutes); - await elementUpdated(el); - expect(el.value!.getMinutes()).to.equal(value.getMinutes() + 1); + element.stepUp(DatePart.Minutes); + await elementUpdated(element); + expect(element.value!.getMinutes()).to.equal(value.getMinutes() + 1); - el.stepUp(DatePart.Seconds); - await elementUpdated(el); - expect(el.value!.getSeconds()).to.equal(value.getSeconds() + 1); + element.stepUp(DatePart.Seconds); + await elementUpdated(element); + expect(element.value!.getSeconds()).to.equal(value.getSeconds() + 1); expect(input.value.indexOf('AM')).to.be.greaterThan(-1); expect(input.value.indexOf('PM')).to.equal(-1); - el.stepUp(DatePart.AmPm); - await elementUpdated(el); + element.stepUp(DatePart.AmPm); + await elementUpdated(element); expect(input.value.indexOf('AM')).to.equal(-1); expect(input.value.indexOf('PM')).to.be.greaterThan(-1); }); @@ -396,418 +424,401 @@ describe('Date Time Input component', () => { it('should stepDown correctly', async () => { const value = new Date(2020, 2, 3, 1, 1, 1, 1); - el.value = value; - el.inputFormat = 'dd.MM.yyyy hh:mm'; - el.stepDown(); - await elementUpdated(el); - expect(el.value!.getDate()).to.equal(value.getDate() - 1); - - el.inputFormat = 'MM.yy hh:mm'; - el.stepDown(); - await elementUpdated(el); - expect(el.value!.getHours()).to.equal(value.getHours() - 1); - - el.inputFormat = 'MM.yy'; - el.stepDown(); - await elementUpdated(el); - expect(el.value!.getMonth()).to.equal(value.getMonth() - 1); - - el.stepDown(DatePart.Year); - await elementUpdated(el); - expect(el.value!.getFullYear()).to.equal(value.getFullYear() - 1); - - el.stepDown(DatePart.Minutes); - await elementUpdated(el); - expect(el.value!.getMinutes()).to.equal(value.getMinutes() - 1); - - el.stepDown(DatePart.Seconds); - await elementUpdated(el); - expect(el.value!.getSeconds()).to.equal(value.getSeconds() - 1); + element.value = value; + element.inputFormat = 'dd.MM.yyyy hh:mm'; + element.stepDown(); + await elementUpdated(element); + expect(element.value!.getDate()).to.equal(value.getDate() - 1); + + element.inputFormat = 'MM.yy hh:mm'; + element.stepDown(); + await elementUpdated(element); + expect(element.value!.getHours()).to.equal(value.getHours() - 1); + + element.inputFormat = 'MM.yy'; + element.stepDown(); + await elementUpdated(element); + expect(element.value!.getMonth()).to.equal(value.getMonth() - 1); + + element.stepDown(DatePart.Year); + await elementUpdated(element); + expect(element.value!.getFullYear()).to.equal(value.getFullYear() - 1); + + element.stepDown(DatePart.Minutes); + await elementUpdated(element); + expect(element.value!.getMinutes()).to.equal(value.getMinutes() - 1); + + element.stepDown(DatePart.Seconds); + await elementUpdated(element); + expect(element.value!.getSeconds()).to.equal(value.getSeconds() - 1); }); it('setRangeText()', async () => { const checkSelectionRange = (start: number, end: number) => expect([start, end]).to.eql([input.selectionStart, input.selectionEnd]); - const checkDates = (a: Date, b: Date) => - expect(a.toISOString()).to.equal(b.toISOString()); const startDate = new Date(2024, 1, 15); - el.value = startDate; - el.inputFormat = 'MM/dd/yyyy'; - await elementUpdated(el); + element.value = startDate; + element.inputFormat = 'MM/dd/yyyy'; + await elementUpdated(element); // No boundaries, from current user selection - el.setSelectionRange(2, 2); - el.setRangeText('03'); - await elementUpdated(el); + element.setSelectionRange(2, 2); + element.setRangeText('03'); + await elementUpdated(element); - checkDates(el.value, new Date(2024, 1, 3)); + checkDates(element.value, new Date(2024, 1, 3)); checkSelectionRange(2, 2); // Keep passed selection range - el.value = startDate; - el.setRangeText('03', 0, 2, 'select'); - await elementUpdated(el); + element.value = startDate; + element.setRangeText('03', 0, 2, 'select'); + await elementUpdated(element); - checkDates(el.value, new Date(2024, 2, 15)); + checkDates(element.value, new Date(2024, 2, 15)); checkSelectionRange(0, 2); // Collapse range to start - el.value = startDate; - el.setRangeText('0303', 0, 4, 'start'); - await elementUpdated(el); + element.value = startDate; + element.setRangeText('0303', 0, 4, 'start'); + await elementUpdated(element); - checkDates(el.value, new Date(2024, 2, 3)); + checkDates(element.value, new Date(2024, 2, 3)); checkSelectionRange(0, 0); // Collapse range to end - el.value = startDate; - el.setRangeText('1999', 5, 10, 'end'); - await elementUpdated(el); + element.value = startDate; + element.setRangeText('1999', 5, 10, 'end'); + await elementUpdated(element); - checkDates(el.value, new Date(1999, 1, 15)); + checkDates(element.value, new Date(1999, 1, 15)); checkSelectionRange(10, 10); }); it('should respect spinDelta', async () => { - const spinDelta: DatePartDeltas = { - date: 2, - year: 10, - }; - const value = new Date(2020, 2, 3); - el.value = value; - el.spinDelta = spinDelta; + element.value = value; + element.spinDelta = { date: 2, year: 10 }; - el.stepDown(); - await elementUpdated(el); + element.stepDown(); + await elementUpdated(element); - expect(el.value!.getDate()).to.equal(value.getDate() - 2); + expect(element.value?.getDate()).to.equal(value.getDate() - 2); - el.stepDown(DatePart.Year); - await elementUpdated(el); + element.stepDown(DatePart.Year); + await elementUpdated(element); - expect(el.value!.getFullYear()).to.equal(value.getFullYear() - 10); + expect(element.value.getFullYear()).to.equal(value.getFullYear() - 10); }); it('mouse wheel should correctly step up/down', async () => { const value = new Date(2020, 2, 3); - el.value = value; - el.focus(); - await elementUpdated(el); + element.value = value; + element.focus(); + await elementUpdated(element); simulateWheel(input, { deltaY: -125 }); - await elementUpdated(el); + await elementUpdated(element); - expect(el.value.getFullYear()).to.equal(value.getFullYear() + 1); + expect(element.value.getFullYear()).to.equal(value.getFullYear() + 1); - el.setSelectionRange(0, 0); + element.setSelectionRange(0, 0); simulateWheel(input, { deltaY: 125 }); - await elementUpdated(el); + await elementUpdated(element); - expect(el.value.getMonth()).to.equal(value.getMonth() - 1); + expect(element.value.getMonth()).to.equal(value.getMonth() - 1); }); it('mouse wheel no focus', async () => { const value = new Date(2020, 2, 3); - el.value = value; - await elementUpdated(el); + element.value = value; + await elementUpdated(element); simulateWheel(input, { deltaY: -125 }); - await elementUpdated(el); + await elementUpdated(element); - expect(el.value.getFullYear()).to.equal(value.getFullYear()); + expect(element.value.getFullYear()).to.equal(value.getFullYear()); }); it('mouse wheel readonly', async () => { const value = new Date(2020, 2, 3); - el.value = value; - el.readOnly = true; - el.focus(); - await elementUpdated(el); + + element.value = value; + element.readOnly = true; + + element.focus(); + await elementUpdated(element); simulateWheel(input, { deltaY: -125 }); - await elementUpdated(el); + await elementUpdated(element); - expect(el.value.getFullYear()).to.equal(value.getFullYear()); + expect(element.value.getFullYear()).to.equal(value.getFullYear()); }); it('ArrowUp should stepUp correctly', async () => { const value = new Date(2020, 2, 3); - el.value = value; + element.value = value; - el.focus(); - await elementUpdated(el); + element.focus(); + await elementUpdated(element); simulateKeyboard(input, arrowUp); - await elementUpdated(el); + await elementUpdated(element); - expect(el.value!.getFullYear()).to.equal(value.getFullYear() + 1); + expect(element.value.getFullYear()).to.equal(value.getFullYear() + 1); }); it('ArrowDown should stepDown correctly', async () => { const value = new Date(2020, 2, 3); - el.value = value; + element.value = value; - el.focus(); - await elementUpdated(el); + element.focus(); + await elementUpdated(element); simulateKeyboard(input, arrowDown); - await elementUpdated(el); + await elementUpdated(element); - expect(el.value!.getFullYear()).to.equal(value.getFullYear() - 1); + expect(element.value.getFullYear()).to.equal(value.getFullYear() - 1); }); it('Up/Down arrow readonly is a no-op', async () => { const value = new Date(2020, 2, 3); - el.readOnly = true; - el.value = value; - el.focus(); - await elementUpdated(el); + element.readOnly = true; + element.value = value; + element.focus(); + await elementUpdated(element); - const eventSpy = spy(el, 'emitEvent'); + const eventSpy = spy(element, 'emitEvent'); simulateKeyboard(input, [altKey, arrowUp]); - await elementUpdated(el); + await elementUpdated(element); expect(eventSpy).not.to.have.been.called; simulateKeyboard(input, [altKey, arrowDown]); - await elementUpdated(el); + await elementUpdated(element); expect(eventSpy).not.to.have.been.called; }); it('Alt + ArrowUp/Down is a no-op', async () => { const value = new Date(202, 2, 3); - el.value = value; - el.focus(); - await elementUpdated(el); + element.value = value; + element.focus(); + await elementUpdated(element); - const eventSpy = spy(el, 'emitEvent'); + const eventSpy = spy(element, 'emitEvent'); simulateKeyboard(input, [altKey, arrowUp]); - await elementUpdated(el); + await elementUpdated(element); expect(eventSpy).not.to.have.been.called; simulateKeyboard(input, [altKey, arrowDown]); - await elementUpdated(el); + await elementUpdated(element); expect(eventSpy).not.to.have.been.called; }); it('should not emit change event when readonly', async () => { - const eventSpy = spy(el, 'emitEvent'); + const eventSpy = spy(element, 'emitEvent'); - el.value = new Date(2023, 5, 1); - el.readOnly = true; - el.focus(); - await elementUpdated(el); + element.value = new Date(2023, 5, 1); + element.readOnly = true; + element.focus(); + await elementUpdated(element); - el.blur(); - await elementUpdated(el); + element.blur(); + await elementUpdated(element); expect(eventSpy.getCalls()).empty; }); it('should not move input selection (caret) from a focused part when stepUp/stepDown are invoked', async () => { - el.inputFormat = 'yyyy/MM/dd'; - el.value = new Date(2023, 5, 1); - el.focus(); - await elementUpdated(el); + element.inputFormat = 'yyyy/MM/dd'; + element.value = new Date(2023, 5, 1); + element.focus(); + await elementUpdated(element); // Year part - el.setSelectionRange(0, 1); + element.setSelectionRange(0, 1); let start = input.selectionStart; let end = input.selectionEnd; - el.stepDown(); - await elementUpdated(el); + element.stepDown(); + await elementUpdated(element); - expect(el.value.getFullYear()).to.eq(2022); + expect(element.value.getFullYear()).to.eq(2022); expect(input.selectionStart).to.eq(start); expect(input.selectionEnd).to.eq(end); // Month part - el.setSelectionRange(5, 6); + element.setSelectionRange(5, 6); start = input.selectionStart; end = input.selectionEnd; - el.stepUp(); - expect(el.value.getMonth()).to.eq(6); + element.stepUp(); + expect(element.value.getMonth()).to.eq(6); expect(input.selectionStart).to.eq(start); expect(input.selectionEnd).to.eq(end); }); it('ArrowLeft/Right should navigate to the beginning/end of date section', async () => { const value = new Date(2020, 2, 3); - el.value = value; - el.focus(); - await elementUpdated(el); + element.value = value; + element.focus(); + await elementUpdated(element); //Move selection to the beginning of 'year' part. simulateKeyboard(input, [ctrlKey, arrowLeft]); - await elementUpdated(el); + await elementUpdated(element); expect(input.selectionStart).to.equal(6); expect(input.selectionEnd).to.equal(6); //Move selection to the end of 'year' part. simulateKeyboard(input, [ctrlKey, arrowRight]); - await elementUpdated(el); + await elementUpdated(element); expect(input.selectionStart).to.equal(10); expect(input.selectionEnd).to.equal(10); }); it('non filled parts have default value set on blur', async () => { - el.inputFormat = 'dd.MM.yyyy'; - const parts = DateTimeUtil.parseDateTimeFormat(el.inputFormat, 'en'); + element.inputFormat = 'dd.MM.yyyy'; + parser.mask = 'dd.MM.yyyy'; - el.focus(); - await elementUpdated(el); + element.focus(); + await elementUpdated(element); const value = '1010'; simulateInput(input, { value, inputType: 'insertText' }); - await elementUpdated(el); + await elementUpdated(element); //10.10.____ const parse = parser.replace(input.value, value, 0, 3); expect(input.value).to.equal(parse.value); - expect(el.value).to.be.null; - - el.blur(); - await elementUpdated(el); - const parse2 = DateTimeUtil.parseValueFromMask( - input.value, - parts, - el.prompt - ); + expect(element.value).to.be.null; + + element.blur(); + await elementUpdated(element); + const parse2 = parser.parseDate(input.value); //10.10.2000 - expect(el.value!.setHours(0, 0, 0, 0)).to.equal( - parse2!.setHours(0, 0, 0, 0) + expect(element.value?.setHours(0, 0, 0, 0)).to.equal( + parse2?.setHours(0, 0, 0, 0) ); }); it('invalid date sets null value on blur', async () => { - el.inputFormat = 'dd.MM.yyyy'; - el.focus(); - await elementUpdated(el); + element.inputFormat = 'dd.MM.yyyy'; + element.focus(); + await elementUpdated(element); const value = '1099'; simulateInput(input, { value, inputType: 'insertText' }); - await elementUpdated(el); + await elementUpdated(element); //10.99.____ const parse = parser.replace(input.value, value, 0, 3); expect(input.value).to.equal(parse.value); - expect(el.value).to.be.null; + expect(element.value).to.be.null; - el.blur(); - await elementUpdated(el); + element.blur(); + await elementUpdated(element); - expect(el.value).to.be.null; + expect(element.value).to.be.null; expect(input.value).to.be.empty; }); it('set value when input is complete', async () => { - el.inputFormat = 'dd.MM.yyyy'; - const parts = DateTimeUtil.parseDateTimeFormat(el.inputFormat, 'en'); + element.inputFormat = 'dd.MM.yyyy'; + parser.mask = 'dd.MM.yyyy'; - el.focus(); - await elementUpdated(el); + element.focus(); + await elementUpdated(element); const value = '10102020'; simulateInput(input, { value, inputType: 'insertText' }); - await elementUpdated(el); + await elementUpdated(element); //10.10.2020 const parse = parser.replace(input.value, value, 0, 3); expect(input.value).to.equal(parse.value); - const parse2 = DateTimeUtil.parseValueFromMask( - input.value, - parts, - el.prompt - ); + const parse2 = parser.parseDate(input.value); - expect(DateTimeUtil.isValidDate(parse2)).to.be.true; + expect(isValidDate(parse2)).to.be.true; //10.10.2000 - expect(el.value!.setHours(0, 0, 0, 0)).to.equal( - parse2!.setHours(0, 0, 0, 0) + expect(element.value?.setHours(0, 0, 0, 0)).to.equal( + parse2?.setHours(0, 0, 0, 0) ); }); it('set value to null when input is complete and invalid', async () => { - el.inputFormat = 'dd.MM.yyyy'; - const parts = DateTimeUtil.parseDateTimeFormat(el.inputFormat, 'en'); + element.inputFormat = 'dd.MM.yyyy'; + parser.mask = 'dd.MM.yyyy'; - el.focus(); - await elementUpdated(el); + element.focus(); + await elementUpdated(element); const value = '10992020'; simulateInput(input, { value, inputType: 'insertText' }); - await elementUpdated(el); + await elementUpdated(element); //10.99.2020 const parse = parser.replace(input.value, value, 0, 3); expect(input.value).to.equal(parse.value); - const parse2 = DateTimeUtil.parseValueFromMask( - input.value, - parts, - el.prompt - ); + const parse2 = parser.parseDate(input.value); - expect(DateTimeUtil.isValidDate(parse2)).to.be.false; - expect(el.value).to.be.null; + expect(isValidDate(parse2)).to.be.false; + expect(element.value).to.be.null; }); it('ctrl + ; should set date correctly', async () => { const today = new Date().setHours(0, 0, 0, 0); - el.focus(); - await elementUpdated(el); + element.focus(); + await elementUpdated(element); - expect(el.value).to.be.null; + expect(element.value).to.be.null; simulateKeyboard(input, [ctrlKey, ';']); - await elementUpdated(el); + await elementUpdated(element); - expect(el.value).to.not.be.undefined; - expect(el.value!.setHours(0, 0, 0, 0)).to.equal(today); + expect(element.value).to.not.be.undefined; + expect(element.value!.setHours(0, 0, 0, 0)).to.equal(today); }); it('should respect spinLoop', async () => { const value = new Date(2020, 2, 31); - el.value = value; - el.spinLoop = false; + element.value = value; + element.spinLoop = false; simulateKeyboard(input, arrowUp); - await elementUpdated(el); + await elementUpdated(element); - expect(el.value!.getDate()).to.equal(value.getDate()); + expect(element.value.getDate()).to.equal(value.getDate()); - el.spinLoop = true; + element.spinLoop = true; simulateKeyboard(input, arrowUp); - await elementUpdated(el); + await elementUpdated(element); - expect(el.value!.getDate()).to.equal(1); + expect(element.value.getDate()).to.equal(1); }); //check if needed it('dragEnter', async () => { input.dispatchEvent(new DragEvent('dragenter', { bubbles: true })); - await elementUpdated(el); + await elementUpdated(element); expect(input.value).to.equal(parser.apply()); }); @@ -815,23 +826,23 @@ describe('Date Time Input component', () => { //check if needed it('dragLeave without focus', async () => { input.dispatchEvent(new DragEvent('dragleave', { bubbles: true })); - await elementUpdated(el); + await elementUpdated(element); expect(input.value).to.be.empty; }); //check if needed it('dragLeave with focus', async () => { - el.focus(); + element.focus(); input.dispatchEvent(new DragEvent('dragleave', { bubbles: true })); - await elementUpdated(el); + await elementUpdated(element); expect(input.value).to.equal(parser.apply()); }); it('Drop behavior', async () => { - el.value = new Date(2020, 2, 3); - await elementUpdated(el); + element.value = new Date(2020, 2, 3); + await elementUpdated(element); expect(input.value).to.equal('3/3/2020'); input.value = '1010'; @@ -841,95 +852,95 @@ describe('Date Time Input component', () => { skipValueProperty: true, inputType: 'insertFromDrop', }); - await elementUpdated(el); + await elementUpdated(element); expect(input.value).to.equal('10/10/2020'); }); it('should respect min attribute', async () => { - el.min = new Date(2020, 2, 3); - el.value = new Date(2020, 1, 3); - await elementUpdated(el); - expect(el.checkValidity()).to.be.false; - ValidityHelpers.isValid(el).to.be.false; - - el.value = new Date(2021, 2, 3); - await elementUpdated(el); - expect(el.checkValidity()).to.be.true; - ValidityHelpers.isValid(el).to.be.true; + element.min = new Date(2020, 2, 3); + element.value = new Date(2020, 1, 3); + await elementUpdated(element); + expect(element.checkValidity()).to.be.false; + ValidityHelpers.isValid(element).to.be.false; + + element.value = new Date(2021, 2, 3); + await elementUpdated(element); + expect(element.checkValidity()).to.be.true; + ValidityHelpers.isValid(element).to.be.true; }); it('should respect max attribute', async () => { - el.max = new Date(2020, 2, 3); - el.value = new Date(2020, 3, 3); - await elementUpdated(el); + element.max = new Date(2020, 2, 3); + element.value = new Date(2020, 3, 3); + await elementUpdated(element); - expect(el.checkValidity()).to.be.false; - ValidityHelpers.isValid(el).to.be.false; + expect(element.checkValidity()).to.be.false; + ValidityHelpers.isValid(element).to.be.false; - el.value = new Date(2020, 1, 3); - expect(el.checkValidity()).to.be.true; - ValidityHelpers.isValid(el).to.be.true; + element.value = new Date(2020, 1, 3); + expect(element.checkValidity()).to.be.true; + ValidityHelpers.isValid(element).to.be.true; }); it('valid/invalid state with required', async () => { - expect(el.reportValidity()).to.be.true; + expect(element.reportValidity()).to.be.true; - el.required = true; - el.disabled = true; - await elementUpdated(el); - expect(el.reportValidity()).to.be.true; + element.required = true; + element.disabled = true; + await elementUpdated(element); + expect(element.reportValidity()).to.be.true; - el.disabled = false; - await elementUpdated(el); - expect(el.reportValidity()).to.be.false; + element.disabled = false; + await elementUpdated(element); + expect(element.reportValidity()).to.be.false; - el.value = new Date(2020, 2, 3); - await elementUpdated(el); - expect(el.reportValidity()).to.be.true; + element.value = new Date(2020, 2, 3); + await elementUpdated(element); + expect(element.reportValidity()).to.be.true; }); it('should emit events correctly', async () => { - const eventSpy = spy(el, 'emitEvent'); + const eventSpy = spy(element, 'emitEvent'); - el.focus(); - await elementUpdated(el); - expect(isFocused(el)).to.be.true; + element.focus(); + await elementUpdated(element); + expect(isFocused(element)).to.be.true; simulateKeyboard(input, arrowUp); - await elementUpdated(el); + await elementUpdated(element); expect(eventSpy).calledWith('igcInput'); eventSpy.resetHistory(); simulateKeyboard(input, arrowDown); - await elementUpdated(el); + await elementUpdated(element); expect(eventSpy).calledWith('igcInput'); eventSpy.resetHistory(); simulateWheel(input, { deltaY: -125 }); - await elementUpdated(el); + await elementUpdated(element); expect(eventSpy).calledWith('igcInput'); eventSpy.resetHistory(); - el.blur(); - await elementUpdated(el); - expect(isFocused(el)).to.be.false; + element.blur(); + await elementUpdated(element); + expect(isFocused(element)).to.be.false; expect(eventSpy).calledWith('igcChange'); - el.clear(); - await elementUpdated(el); + element.clear(); + await elementUpdated(element); //10.10.____ const value = '1010'; input.value = value; simulateInput(input, { value, inputType: 'insertText' }); - await elementUpdated(el); + await elementUpdated(element); - el.blur(); - await elementUpdated(el); - expect(isFocused(el)).to.be.false; + element.blur(); + await elementUpdated(element); + expect(isFocused(element)).to.be.false; expect(eventSpy).calledWith('igcChange'); }); }); @@ -1186,3 +1197,7 @@ describe('Date Time Input component', () => { }); }); }); + +function checkDates(a: Date, b: Date) { + expect(a.toISOString()).to.equal(b.toISOString()); +} diff --git a/src/components/date-time-input/date-time-input.ts b/src/components/date-time-input/date-time-input.ts index 9477e8afd..80a12f932 100644 --- a/src/components/date-time-input/date-time-input.ts +++ b/src/components/date-time-input/date-time-input.ts @@ -1,10 +1,10 @@ import { getDateFormatter } from 'igniteui-i18n-core'; -import { html } from 'lit'; +import { html, type PropertyValues } from 'lit'; import { eventOptions, property } from 'lit/decorators.js'; import { ifDefined } from 'lit/directives/if-defined.js'; import { live } from 'lit/directives/live.js'; import { addThemingController } from '../../theming/theming-controller.js'; -import { convertToDate } from '../calendar/helpers.js'; +import { convertToDate, isValidDate } from '../calendar/helpers.js'; import { addKeybindings, arrowDown, @@ -14,9 +14,12 @@ import { ctrlKey, } from '../common/controllers/key-bindings.js'; import { addSlotController, setSlots } from '../common/controllers/slot.js'; -import { watch } from '../common/decorators/watch.js'; import { registerComponent } from '../common/definitions/register.js'; -import { addI18nController } from '../common/i18n/i18n-controller.js'; +import { + addI18nController, + formatDisplayDate, + getDefaultDateTimeFormat, +} from '../common/i18n/i18n-controller.js'; import type { AbstractConstructor } from '../common/mixins/constructor.js'; import { EventEmitterMixin } from '../common/mixins/event-emitter.js'; import { FormValueDateTimeTransformers } from '../common/mixins/forms/form-transformers.js'; @@ -34,10 +37,13 @@ import IgcValidationContainerComponent from '../validation-container/validation- import { DatePart, type DatePartDeltas, - type DatePartInfo, + DEFAULT_DATE_PARTS_SPIN_DELTAS, +} from './date-part.js'; +import { + createDatePart, DateParts, - DateTimeUtil, -} from './date-util.js'; + DateTimeMaskParser, +} from './datetime-mask-parser.js'; import { dateTimeInputValidators } from './validators.js'; export interface IgcDateTimeInputComponentEventMap extends Omit< @@ -98,9 +104,24 @@ export default class IgcDateTimeInputComponent extends EventEmitterMixin< ); } - protected override get __validators() { - return dateTimeInputValidators; - } + //#region Private state and properties + + protected override readonly _parser = new DateTimeMaskParser(); + + // Format and mask state + private _defaultDisplayFormat = ''; + private _displayFormat?: string; + private _inputFormat?: string; + + // Value tracking + private _oldValue: Date | null = null; + private _min: Date | null = null; + private _max: Date | null = null; + + private readonly _i18nController = addI18nController(this, { + defaultEN: {}, + onResourceChange: this._handleResourceChange, + }); protected override readonly _themes = addThemingController(this, all); @@ -113,52 +134,44 @@ export default class IgcDateTimeInputComponent extends EventEmitterMixin< transformers: FormValueDateTimeTransformers, }); - private readonly _i18nController = addI18nController(this, { - defaultEN: {}, - onResourceChange: () => { - this.setDefaultMask(); - }, - }); + protected override get __validators() { + return dateTimeInputValidators; + } - protected _defaultMask!: string; - private _defaultDisplayFormat!: string; - private _displayFormat?: string; - private _oldValue: Date | null = null; - private _min: Date | null = null; - private _max: Date | null = null; + /** + * Determines which date/time part is currently targeted based on cursor position. + * When focused, returns the part under the cursor. + * When unfocused, returns a default part based on available parts. + */ + private get _targetDatePart(): DatePart | undefined { + return this._focused + ? this._getDatePartAtCursor() + : this._getDefaultDatePart(); + } + + private get _datePartDeltas(): DatePartDeltas { + return { ...DEFAULT_DATE_PARTS_SPIN_DELTAS, ...this.spinDelta }; + } + + //#endregion - private _inputDateParts!: DatePartInfo[]; - private _inputFormat!: string; - private _datePartDeltas: DatePartDeltas = { - date: 1, - month: 1, - year: 1, - hours: 1, - minutes: 1, - seconds: 1, - }; + //#region Public attributes and properties /** * The date format to apply on the input. * @attr input-format */ @property({ attribute: 'input-format' }) - public get inputFormat(): string { - return this._inputFormat || this._defaultMask; - } - public set inputFormat(val: string) { if (val) { - this.setMask(val); + this._applyMask(val); this._inputFormat = val; - if (this.value) { - this.updateMask(); - } + this._updateMaskDisplay(); } } - public get value(): Date | null { - return this._formValue.value; + public get inputFormat(): string { + return this._inputFormat || this._parser.mask; } /* @tsTwoWayProperty(true, "igcChange", "detail", false) */ @@ -169,7 +182,11 @@ export default class IgcDateTimeInputComponent extends EventEmitterMixin< @property({ converter: convertToDate }) public set value(value: Date | string | null | undefined) { this._formValue.setValueAndFormState(value as Date | null); - this.updateMask(); + this._updateMaskDisplay(); + } + + public get value(): Date | null { + return this._formValue.value; } /** @@ -221,7 +238,7 @@ export default class IgcDateTimeInputComponent extends EventEmitterMixin< * All values default to `1`. */ @property({ attribute: false }) - public spinDelta!: DatePartDeltas; + public spinDelta?: DatePartDeltas; /** * Sets whether to loop over the currently spun segment. @@ -243,177 +260,221 @@ export default class IgcDateTimeInputComponent extends EventEmitterMixin< return this._i18nController.locale; } - @watch('locale', { waitUntilFirstUpdate: true }) - protected setDefaultMask(): void { - this.updateDefaultDisplayFormat(); + //#endregion - if (!this._inputFormat) { - this.updateDefaultMask(); - this.setMask(this._defaultMask); + //#region Lifecycle Hooks + + constructor() { + super(); + + addKeybindings(this, { + skip: () => this.readOnly, + bindingDefaults: { triggers: ['keydownRepeat'] }, + }) + .set([ctrlKey, ';'], this._setCurrentDateTime) + .set(arrowUp, this._keyboardSpin.bind(this, 'up')) + .set(arrowDown, this._keyboardSpin.bind(this, 'down')) + .set([ctrlKey, arrowLeft], this._navigateParts.bind(this, 0)) + .set([ctrlKey, arrowRight], this._navigateParts.bind(this, 1)); + } + + protected override update(props: PropertyValues): void { + if (props.has('displayFormat')) { + this._updateDefaultDisplayFormat(); } - if (this.value) { - this.updateMask(); + if (props.has('locale')) { + this._initializeDefaultMask(); } - } - @watch('displayFormat', { waitUntilFirstUpdate: true }) - protected setDisplayFormat(): void { - this.updateDefaultDisplayFormat(); - if (this.value) { - this.updateMask(); + if (props.has('displayFormat') || props.has('locale')) { + this._updateMaskDisplay(); } + + super.update(props); } - protected get hasDateParts(): boolean { - const parts = - this._inputDateParts || - DateTimeUtil.parseDateTimeFormat(this.inputFormat, this.locale); + //#endregion - return parts.some( - (p) => - p.type === DateParts.Date || - p.type === DateParts.Month || - p.type === DateParts.Year - ); + //#region Overrides + + protected override _resolvePartNames(base: string) { + const result = super._resolvePartNames(base); + // Apply `filled` part when the mask is not empty + result.filled = result.filled || !this._isEmptyMask; + return result; } - protected get hasTimeParts(): boolean { - const parts = - this._inputDateParts || - DateTimeUtil.parseDateTimeFormat(this.inputFormat, this.locale); - return parts.some( - (p) => - p.type === DateParts.Hours || - p.type === DateParts.Minutes || - p.type === DateParts.Seconds - ); + protected override _updateSetRangeTextValue(): void { + this._updateValueFromMask(); } - private get targetDatePart(): DatePart | undefined { - let result: DatePart | undefined; + //#endregion - if (this._focused) { - const partType = this._inputDateParts.find( - (p) => - p.start <= this._inputSelection.start && - this._inputSelection.start <= p.end && - p.type !== DateParts.Literal - )?.type as string as DatePart; - - if (partType) { - result = partType; - } - } else if (this._inputDateParts.some((p) => p.type === DateParts.Date)) { - result = DatePart.Date; - } else if (this._inputDateParts.some((p) => p.type === DateParts.Hours)) { - result = DatePart.Hours; - } else { - result = this._inputDateParts[0].type as string as DatePart; - } + //#region Event handlers - return result; + private _emitInputEvent(): void { + this._setTouchedState(); + this.emitEvent('igcInput', { detail: this.value?.toString() }); } - private get datePartDeltas(): DatePartDeltas { - return Object.assign({}, this._datePartDeltas, this.spinDelta); + private _handleResourceChange(): void { + this._initializeDefaultMask(); + this._updateMaskDisplay(); } - constructor() { - super(); - - addKeybindings(this, { - skip: () => this.readOnly, - bindingDefaults: { triggers: ['keydownRepeat'] }, - }) - .set([ctrlKey, ';'], this.setToday) - .set(arrowUp, this.keyboardSpin.bind(this, 'up')) - .set(arrowDown, this.keyboardSpin.bind(this, 'down')) - .set([ctrlKey, arrowLeft], this.navigateParts.bind(this, 0)) - .set([ctrlKey, arrowRight], this.navigateParts.bind(this, 1)); - } - - public override connectedCallback() { - super.connectedCallback(); - this.updateDefaultMask(); - this.updateDefaultDisplayFormat(); - this.setMask(this.inputFormat); - if (this.value) { - this.updateMask(); + protected _handleDragLeave(): void { + if (!this._focused) { + this._updateMaskDisplay(); } } - /** Increments a date/time portion. */ - public stepUp(datePart?: DatePart, delta?: number): void { - const targetPart = datePart || this.targetDatePart; + protected _handleDragEnter(): void { + if (!this._focused) { + this._maskedValue = this._buildMaskedValue(); + } + } - if (!targetPart) { + @eventOptions({ passive: false }) + private async _handleWheel(event: WheelEvent): Promise { + if (!this._focused || this.readOnly) { return; } + event.preventDefault(); + event.stopPropagation(); + const { start, end } = this._inputSelection; - const newValue = this.trySpinValue(targetPart, delta); - this.value = newValue; - this.updateComplete.then(() => this._input?.setSelectionRange(start, end)); + event.deltaY > 0 ? this.stepDown() : this.stepUp(); + this._emitInputEvent(); + + await this.updateComplete; + this.setSelectionRange(start, end); } - /** Decrements a date/time portion. */ - public stepDown(datePart?: DatePart, delta?: number): void { - const targetPart = datePart || this.targetDatePart; + protected async _handleFocus(): Promise { + this._focused = true; - if (!targetPart) { + if (this.readOnly) { return; } - const { start, end } = this._inputSelection; - const newValue = this.trySpinValue(targetPart, delta, true); - this.value = newValue; - this.updateComplete.then(() => this._input?.setSelectionRange(start, end)); + this._oldValue = this.value; + + if (!this.value) { + this._maskedValue = this._parser.emptyMask; + await this.updateComplete; + this.select(); + } else if (this.displayFormat !== this.inputFormat) { + this._updateMaskDisplay(); + } } - /** Clears the input element of user input. */ - public clear(): void { - this._maskedValue = ''; - this.value = null; + protected override _handleBlur(): void { + this._focused = false; + + // Handle incomplete mask input + if (!(this._isMaskComplete() || this._isEmptyMask)) { + const parsedDate = this._parser.parseDate(this._maskedValue); + + if (parsedDate) { + this.value = parsedDate; + } else { + this.clear(); + } + } else { + this._updateMaskDisplay(); + } + + // Emit change event if value changed + if (!this.readOnly && this._oldValue !== this.value) { + this.emitEvent('igcChange', { detail: this.value }); + } + + super._handleBlur(); } - protected setToday() { + //#endregion + + //#region Keybindings + + protected _setCurrentDateTime(): void { this.value = new Date(); - this._fireInputEvent(); + this._emitInputEvent(); } - protected updateMask() { - if (this._focused) { - this._maskedValue = this.getMaskedValue(); - } else { - if (!DateTimeUtil.isValidDate(this.value)) { - this._maskedValue = ''; - return; - } + /** + * Navigates to the previous or next date part. + */ + protected _navigateParts(direction: number): void { + const position = this._calculatePartNavigationPosition( + this._input?.value ?? '', + direction + ); + this.setSelectionRange(position, position); + } - this._maskedValue = DateTimeUtil.formatDisplayDate( - this.value, - this.locale, - this.displayFormat + /** + * Calculates the new cursor position when navigating between date parts. + * direction = 0: navigate to start of previous part + * direction = 1: navigate to start of next part + */ + private _calculatePartNavigationPosition( + inputValue: string, + direction: number + ): number { + const cursorPos = this._maskSelection.start; + const dateParts = this._parser.dateParts; + + if (direction === 0) { + // Navigate backwards: find last literal before cursor + const part = dateParts.findLast( + (part) => part.type === DateParts.Literal && part.end < cursorPos ); + return part?.end ?? 0; } + + // Navigate forwards: find first literal after cursor + const part = dateParts.find( + (part) => part.type === DateParts.Literal && part.start > cursorPos + ); + return part?.start ?? inputValue.length; } - private _fireInputEvent(): void { - this._setTouchedState(); - this.emitEvent('igcInput', { detail: this.value?.toString() }); + /** + * Handles keyboard-triggered spinning (arrow up/down). + */ + protected async _keyboardSpin(direction: 'up' | 'down'): Promise { + direction === 'up' ? this.stepUp() : this.stepDown(); + this._emitInputEvent(); + await this.updateComplete; + this.setSelectionRange(this._maskSelection.start, this._maskSelection.end); } - protected handleDragLeave() { - if (!this._focused) { - this.updateMask(); + //#endregion + + //#region Internal API + + /** + * Updates the displayed mask value based on focus state. + * When focused, shows the editable mask. When unfocused, shows formatted display value. + */ + protected _updateMaskDisplay(): void { + if (this._focused) { + this._maskedValue = this._buildMaskedValue(); + return; } - } - protected handleDragEnter() { - if (!this._focused) { - this._maskedValue = this.getMaskedValue(); + if (!isValidDate(this.value)) { + this._maskedValue = ''; + return; } + + this._maskedValue = formatDisplayDate( + this.value, + this.locale, + this.displayFormat + ); } protected async _updateInput( @@ -424,256 +485,213 @@ export default class IgcDateTimeInputComponent extends EventEmitterMixin< this._maskedValue = result.value; - this.updateValue(); + this._updateValueFromMask(); this.requestUpdate(); if (start !== this.inputFormat.length) { - this._fireInputEvent(); + this._emitInputEvent(); } await this.updateComplete; this._input?.setSelectionRange(result.end, result.end); } - private trySpinValue( + /** + * Common logic for stepping up or down a date part. + */ + private _performStep( + datePart: DatePart | undefined, + delta: number | undefined, + isDecrement: boolean + ): void { + const part = datePart || this._targetDatePart; + const { start, end } = this._inputSelection; + + this.value = this._calculateSpunValue(part!, delta, isDecrement); + this.updateComplete.then(() => this._input?.setSelectionRange(start, end)); + } + + /** + * Calculates the new date value after spinning a date part. + */ + private _calculateSpunValue( datePart: DatePart, - delta?: number, - negative = false + delta: number | undefined, + isDecrement: boolean ): Date { - // default to 1 if a delta is set to 0 or any other falsy value - const _delta = - delta || this.datePartDeltas[datePart as keyof DatePartDeltas] || 1; + // Default to 1 if delta is 0 or undefined + const effectiveDelta = + delta || this._datePartDeltas[datePart as keyof DatePartDeltas] || 1; + + const spinAmount = isDecrement + ? -Math.abs(effectiveDelta) + : Math.abs(effectiveDelta); - const spinValue = negative ? -Math.abs(_delta) : Math.abs(_delta); - return this.spinValue(datePart, spinValue); + return this._spinDatePart(datePart, spinAmount); } - private spinValue(datePart: DatePart, delta: number): Date { - if (!(this.value && DateTimeUtil.isValidDate(this.value))) { + /** + * Spins a specific date part by the given delta. + */ + private _spinDatePart(datePart: DatePart, delta: number): Date { + if (!isValidDate(this.value)) { return new Date(); } const newDate = new Date(this.value.getTime()); - let formatPart: DatePartInfo | undefined; - let amPmFromMask: string; - - 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: - formatPart = this._inputDateParts.find( - (dp) => dp.type === DateParts.AmPm - ); - if (formatPart !== undefined) { - amPmFromMask = this._maskedValue.substring( - formatPart!.start, - formatPart!.end - ); - return DateTimeUtil.spinAmPm(newDate, this.value, amPmFromMask); - } - break; + const partType = datePart as unknown as DateParts; + + // Get the part instance from the parser, or create one for explicit spin operations + let part = this._parser.getPartByType(partType); + if (!part) { + // For explicit spin operations (e.g., stepDown(DatePart.Minutes)), + // create a temporary part even if not in the format + part = createDatePart(partType, { start: 0, end: 0, format: '' }); } - return newDate; - } - - @eventOptions({ passive: false }) - private async onWheel(event: WheelEvent) { - if (!this._focused || this.readOnly) { - return; + // For AM/PM, we need to extract the current AM/PM value from the mask + let amPmValue: string | undefined; + if (datePart === DatePart.AmPm) { + const formatPart = this._parser.getPartByType(DateParts.AmPm); + if (formatPart) { + amPmValue = this._maskedValue.substring( + formatPart.start, + formatPart.end + ); + } } - event.preventDefault(); - event.stopPropagation(); + part.spin(delta, { + date: newDate, + spinLoop: this.spinLoop, + amPmValue, + originalDate: this.value, + }); - const { start, end } = this._inputSelection; - event.deltaY > 0 ? this.stepDown() : this.stepUp(); - this._fireInputEvent(); - - await this.updateComplete; - this.setSelectionRange(start, end); - } - - private updateDefaultMask(): void { - this._defaultMask = DateTimeUtil.getDefaultInputMask(this.locale); + return newDate; } - private updateDefaultDisplayFormat(): void { + /** + * Updates the default display format based on current locale. + */ + private _updateDefaultDisplayFormat(): void { this._defaultDisplayFormat = getDateFormatter().getLocaleDateTimeFormat( this.locale ); } - private setMask(string: string): void { - const oldFormat = this._inputDateParts?.map((p) => p.format).join(''); - this._inputDateParts = DateTimeUtil.parseDateTimeFormat( - string, - this.locale, - true - ); - const value = this._inputDateParts.map((p) => p.format).join(''); - - this._defaultMask = value; - - const newMask = (value || DateTimeUtil.DEFAULT_INPUT_FORMAT).replace( - new RegExp(/(?=[^t])[\w]/, 'g'), - '0' - ); - - this.mask = newMask.includes('tt') ? newMask.replace(/tt/g, 'LL') : newMask; + /** + * Applies a mask pattern to the input, parsing the format string into date parts. + */ + private _applyMask(formatString: string): void { + const previous = this._parser.mask; + this._parser.mask = formatString; - if (!this.placeholder || oldFormat === this.placeholder) { - this.placeholder = value; + // Update placeholder if not set or if it matches the old format + if (!this.placeholder || previous === this.placeholder) { + this.placeholder = this._parser.mask; } } - private parseDate(val: string) { - return val - ? DateTimeUtil.parseValueFromMask(val, this._inputDateParts, this.prompt) - : null; + /** + * Builds the masked value string from the current date value. + * Returns empty mask if no value, or existing masked value if incomplete. + */ + private _buildMaskedValue(): string { + return isValidDate(this.value) + ? this._parser.formatDate(this.value) + : this._maskedValue || this._parser.emptyMask; } - private getMaskedValue(): string { - let mask = this._parser.emptyMask; - - if (DateTimeUtil.isValidDate(this.value)) { - for (const part of this._inputDateParts) { - if (part.type === DateParts.Literal) { - continue; - } + protected _initializeDefaultMask(): void { + this._updateDefaultDisplayFormat(); - const targetValue = DateTimeUtil.getPartValue( - part, - part.format.length, - this.value - ); - - mask = this._parser.replace( - mask, - targetValue, - part.start, - part.end - ).value; - } - return mask; + if (!this._inputFormat) { + this._applyMask(getDefaultDateTimeFormat(this.locale)); } + } - if (this.readOnly) { - return ''; - } + /** + * Gets the date part at the current cursor position. + * Uses inclusive end to handle cursor at the end of the last part. + * Returns undefined if cursor is not within a valid date part. + */ + private _getDatePartAtCursor(): DatePart | undefined { + return this._parser.getDatePartForCursor(this._inputSelection.start) + ?.type as DatePart | undefined; + } - return this._maskedValue === '' ? mask : this._maskedValue; + /** + * Gets the default date part to target when the input is not focused. + * Prioritizes: Date > Hours > First available part + */ + private _getDefaultDatePart(): DatePart | undefined { + return (this._parser.getPartByType(DateParts.Date)?.type ?? + this._parser.getPartByType(DateParts.Hours)?.type ?? + this._parser.getFirstDatePart()?.type) as DatePart | undefined; } - private isComplete(): boolean { + /** + * Checks if all mask positions are filled (no prompt characters remain). + */ + private _isMaskComplete(): boolean { return !this._maskedValue.includes(this.prompt); } - private updateValue(): void { - if (this.isComplete()) { - const parsedDate = this.parseDate(this._maskedValue); - this.value = DateTimeUtil.isValidDate(parsedDate) ? parsedDate : null; - } else { + /** + * Updates the internal value based on the current masked input. + * Only sets a value if the mask is complete and parses to a valid date. + */ + private _updateValueFromMask(): void { + if (!this._isMaskComplete()) { this.value = null; + return; } - } - protected override _updateSetRangeTextValue() { - this.updateValue(); + const parsedDate = this._parser.parseDate(this._maskedValue); + this.value = isValidDate(parsedDate) ? parsedDate : null; } - private getNewPosition(value: string, direction = 0): number { - const cursorPos = this._maskSelection.start; + //#endregion - if (!direction) { - // Last literal before the current cursor position or start of input value - const part = this._inputDateParts.findLast( - (part) => part.type === DateParts.Literal && part.end < cursorPos - ); - return part?.end ?? 0; - } + //#region Public API - // First literal after the current cursor position or end of input value - const part = this._inputDateParts.find( - (part) => part.type === DateParts.Literal && part.start > cursorPos - ); - return part?.start ?? value.length; + /** Increments a date/time portion. */ + public stepUp(datePart?: DatePart, delta?: number): void { + this._performStep(datePart, delta, false); } - protected async handleFocus() { - this._focused = true; - - if (this.readOnly) { - return; - } - - this._oldValue = this.value; - const areFormatsDifferent = this.displayFormat !== this.inputFormat; - - if (!this.value) { - this._maskedValue = this._parser.emptyMask; - await this.updateComplete; - this.select(); - } else if (areFormatsDifferent) { - this.updateMask(); - } + /** Decrements a date/time portion. */ + public stepDown(datePart?: DatePart, delta?: number): void { + this._performStep(datePart, delta, true); } - protected handleBlur() { - const isEmptyMask = this._maskedValue === this._parser.emptyMask; - - this._focused = false; - - if (!(this.isComplete() || isEmptyMask)) { - const parse = this.parseDate(this._maskedValue); - - if (parse) { - this.value = parse; - } else { - this.value = null; - this._maskedValue = ''; - } - } else { - this.updateMask(); - } - - const isSameValue = this._oldValue === this.value; - - if (!(this.readOnly || isSameValue)) { - this.emitEvent('igcChange', { detail: this.value }); - } - - super._handleBlur(); + /** Clears the input element of user input. */ + public clear(): void { + this._maskedValue = ''; + this.value = null; } - protected navigateParts(delta: number) { - const position = this.getNewPosition(this._input?.value ?? '', delta); - this.setSelectionRange(position, position); + /* blazorSuppress */ + /** + * Checks whether the current format includes date parts (day, month, year). + * @internal + */ + public hasDateParts(): boolean { + return this._parser.hasDateParts(); } - protected async keyboardSpin(direction: 'up' | 'down') { - direction === 'up' ? this.stepUp() : this.stepDown(); - this._fireInputEvent(); - await this.updateComplete; - this.setSelectionRange(this._maskSelection.start, this._maskSelection.end); + /* blazorSuppress */ + /** + * Checks whether the current format includes time parts (hours, minutes, seconds). + * @internal + */ + public hasTimeParts(): boolean { + return this._parser.hasTimeParts(); } + //#endregion + protected override _renderInput() { return html` `; diff --git a/src/components/date-time-input/date-util.spec.ts b/src/components/date-time-input/date-util.spec.ts deleted file mode 100644 index c630e4b5b..000000000 --- a/src/components/date-time-input/date-util.spec.ts +++ /dev/null @@ -1,670 +0,0 @@ -import { expect } from '@open-wc/testing'; - -import { DateParts, DateTimeUtil } from './date-util.js'; - -describe('Date Util', () => { - const DEFAULT_LOCALE = 'en'; - const DEFAULT_FORMAT = 'MM/dd/yyyy'; - const DEFAULT_TIME_FORMAT = 'MM/dd/yyyy hh:mm tt'; - const DEFAULT_PROMPT = '_'; - - it('locale default mask', () => { - expect(DateTimeUtil.getDefaultInputMask('')).to.equal('MM/dd/yyyy'); - expect(DateTimeUtil.getDefaultInputMask(DEFAULT_LOCALE)).to.equal( - 'MM/dd/yyyy' - ); - expect(DateTimeUtil.getDefaultInputMask('no')).to.equal('dd.MM.yyyy'); - expect(DateTimeUtil.getDefaultInputMask('bg').normalize('NFKC')).to.equal( - 'dd.MM.yyyy г.' - ); - }); - - it('should correctly parse all date time parts (base)', () => { - const result = DateTimeUtil.parseDateTimeFormat( - 'dd/MM/yyyy HH:mm:ss tt', - DEFAULT_LOCALE - ); - const expected = [ - { start: 0, end: 2, type: DateParts.Date, format: 'dd' }, - { start: 2, end: 3, type: DateParts.Literal, format: '/' }, - { start: 3, end: 5, type: DateParts.Month, format: 'MM' }, - { start: 5, end: 6, type: DateParts.Literal, format: '/' }, - { start: 6, end: 10, type: DateParts.Year, format: 'yyyy' }, - { start: 10, end: 11, type: DateParts.Literal, format: ' ' }, - { start: 11, end: 13, type: DateParts.Hours, format: 'HH' }, - { start: 13, end: 14, type: DateParts.Literal, format: ':' }, - { start: 14, end: 16, type: DateParts.Minutes, format: 'mm' }, - { start: 16, end: 17, type: DateParts.Literal, format: ':' }, - { start: 17, end: 19, type: DateParts.Seconds, format: 'ss' }, - { start: 19, end: 20, type: DateParts.Literal, format: ' ' }, - { start: 20, end: 22, type: DateParts.AmPm, format: 'tt' }, - ]; - expect(JSON.stringify(result)).to.equal(JSON.stringify(expected)); - }); - - it('parseValueFromMask with valid date', () => { - let maskedValue = '03/03/2020'; - let date = new Date(2020, 2, 3); - let parts = DateTimeUtil.parseDateTimeFormat( - DEFAULT_FORMAT, - DEFAULT_LOCALE - ); - let parsedDate = DateTimeUtil.parseValueFromMask( - maskedValue, - parts, - DEFAULT_PROMPT - ); - - expect(parsedDate!.getTime()).to.equal(date.getTime()); - - parts = DateTimeUtil.parseDateTimeFormat( - DEFAULT_TIME_FORMAT, - DEFAULT_LOCALE - ); - - maskedValue = '03/03/2020 10:00 PM'; - date = new Date(2020, 2, 3, 22, 0, 0, 0); - parsedDate = DateTimeUtil.parseValueFromMask( - maskedValue, - parts, - DEFAULT_PROMPT - ); - expect(parsedDate!.getTime()).to.equal(date.getTime()); - - maskedValue = '03/03/2020 10:00 AM'; - date = new Date(2020, 2, 3, 10, 0, 0, 0); - parsedDate = DateTimeUtil.parseValueFromMask( - maskedValue, - parts, - DEFAULT_PROMPT - ); - expect(parsedDate!.getTime()).to.equal(date.getTime()); - }); - - it('parseValueFromMask with invalid dates', () => { - let maskedValue = '13/03/2020'; - let parts = DateTimeUtil.parseDateTimeFormat( - DEFAULT_FORMAT, - DEFAULT_LOCALE - ); - - let parsedDate = DateTimeUtil.parseValueFromMask( - maskedValue, - parts, - DEFAULT_PROMPT - ); - expect(parsedDate).to.be.null; - - maskedValue = '03/32/2020'; - parsedDate = DateTimeUtil.parseValueFromMask( - maskedValue, - parts, - DEFAULT_PROMPT - ); - expect(parsedDate).to.be.null; - - maskedValue = '03/32/2020'; - parsedDate = DateTimeUtil.parseValueFromMask( - maskedValue, - parts, - DEFAULT_PROMPT - ); - expect(parsedDate).to.be.null; - - parts = DateTimeUtil.parseDateTimeFormat( - DEFAULT_TIME_FORMAT, - DEFAULT_LOCALE - ); - - maskedValue = '03/03/2020 25:62'; - parsedDate = DateTimeUtil.parseValueFromMask( - maskedValue, - parts, - DEFAULT_PROMPT - ); - expect(parsedDate).to.be.null; - }); - - it('getPartValue should properly return part values', () => { - const currentDate = new Date(2020, 6, 17, 16, 15, 59); - const parts = DateTimeUtil.parseDateTimeFormat( - 'dd/MM/yy hh:mm:ss tt', - DEFAULT_LOCALE - ); - - const partsMap = new Map([ - [DateParts.Date, currentDate.getDate()], - [DateParts.Month, currentDate.getMonth() + 1], - [DateParts.Hours, 4], // we use 12h format; getHours will return 16 - [DateParts.Minutes, currentDate.getMinutes()], - [DateParts.Seconds, currentDate.getSeconds()], - [ - DateParts.Year, - Number.parseInt(currentDate.getFullYear().toString().slice(-2), 10), - ], - ]); - - for (const part of parts) { - if (part.type === DateParts.Literal) { - continue; - } - - const targetValue = DateTimeUtil.getPartValue( - part, - part.format.length, - currentDate - ); - - if (part.type === DateParts.AmPm) { - const amPm = currentDate.getHours() >= 12 ? 'PM' : 'AM'; - expect(amPm).to.equal(targetValue); - } else { - expect(partsMap.get(part.type)).to.equal( - Number.parseInt(targetValue, 10) - ); - } - } - }); - - it('parseIsoDate should parse dates correctly', () => { - const updateDate = (dateValue: Date, stringValue: string): Date => { - const [datePart] = dateValue.toISOString().split('T'); - const newDate = new Date(`${datePart}T${stringValue}`); - newDate.setMilliseconds(0); - return newDate; - }; - - const date = new Date(2020, 2, 3); - - let parsedDate = DateTimeUtil.parseIsoDate(date.toISOString()); - expect(parsedDate!.getTime()).to.equal(date.getTime()); - - parsedDate = DateTimeUtil.parseIsoDate('2022-12-19'); - expect(parsedDate!.getTime()).to.equal( - new Date('2022-12-19T00:00:00').getTime() - ); - - parsedDate = DateTimeUtil.parseIsoDate('2017'); - expect(parsedDate!.getTime()).to.equal( - new Date('2017-01-01T00:00:00').getTime() - ); - - parsedDate = DateTimeUtil.parseIsoDate('2017-09'); - expect(parsedDate!.getTime()).to.equal( - new Date('2017-09-01T00:00:00').getTime() - ); - - parsedDate = DateTimeUtil.parseIsoDate('11:11'); - expect(parsedDate!.getTime()).to.equal( - updateDate(new Date(), '11:11').getTime() - ); - - expect(DateTimeUtil.parseIsoDate('23:60')).to.be.null; - expect(DateTimeUtil.parseIsoDate('')).to.be.null; - }); - - it('isValidDate should properly determine if a date is valid or not', () => { - expect(DateTimeUtil.isValidDate('')).to.be.false; - expect(DateTimeUtil.isValidDate(new Date())).to.be.true; - expect(DateTimeUtil.isValidDate('10.10.2010')).to.be.false; - expect(DateTimeUtil.isValidDate(new Date(Number.NaN))).to.be.false; - expect(DateTimeUtil.isValidDate(new Date().toISOString())).to.be.false; - }); - - it('should return ValidationErrors for minValue and maxValue', () => { - let minValue = new Date(2020, 2, 1); - let maxValue = new Date(2020, 2, 7); - - expect( - DateTimeUtil.validateMinMax(new Date(2020, 2, 5), minValue, maxValue) - ).to.deep.equal({}); - expect( - DateTimeUtil.validateMinMax(new Date(2020, 1, 7), minValue, maxValue) - ).to.deep.equal({ minValue: true }); - expect( - DateTimeUtil.validateMinMax(new Date(2020, 4, 2), minValue, maxValue) - ).to.deep.equal({ maxValue: true }); - - minValue = new Date(2020, 2, 1, 10, 10, 10); - maxValue = new Date(2020, 2, 1, 15, 15, 15); - - expect( - DateTimeUtil.validateMinMax( - new Date(2020, 2, 1, 12, 0, 0), - minValue, - maxValue - ) - ).to.deep.equal({}); - expect( - DateTimeUtil.validateMinMax( - new Date(2020, 2, 1, 9, 0, 0), - minValue, - maxValue - ) - ).to.deep.equal({ minValue: true }); - expect( - DateTimeUtil.validateMinMax( - new Date(2020, 2, 1, 16, 0, 0), - minValue, - maxValue - ) - ).to.deep.equal({ maxValue: true }); - - //ignore date - expect( - DateTimeUtil.validateMinMax( - new Date(2017, 10, 19, 12, 0, 0), - minValue, - maxValue, - true, - false - ) - ).to.deep.equal({}); - expect( - DateTimeUtil.validateMinMax( - new Date(2017, 6, 4, 9, 0, 0), - minValue, - maxValue, - true, - false - ) - ).to.deep.equal({ minValue: true }); - expect( - DateTimeUtil.validateMinMax( - new Date(2017, 3, 12, 16, 0, 0), - minValue, - maxValue, - true, - false - ) - ).to.deep.equal({ maxValue: true }); - - //ignore time - expect( - DateTimeUtil.validateMinMax( - new Date(2020, 2, 1, 20, 30, 0), - minValue, - maxValue, - false, - true - ) - ).to.deep.equal({}); - expect( - DateTimeUtil.validateMinMax( - new Date(2017, 2, 1, 20, 30, 0), - minValue, - maxValue, - false, - true - ) - ).to.deep.equal({ minValue: true }); - expect( - DateTimeUtil.validateMinMax( - new Date(2021, 2, 1, 20, 30, 0), - minValue, - maxValue, - false, - true - ) - ).to.deep.equal({ maxValue: true }); - }); - - it('should compare dates correctly', () => { - let minValue = new Date(2020, 2, 1); - let maxValue = new Date(2020, 2, 7); - - expect(DateTimeUtil.lessThanMinValue(new Date(2020, 2, 2), minValue)).to.be - .false; - expect(DateTimeUtil.lessThanMinValue(new Date(2020, 1, 1), minValue)).to.be - .true; - - expect(DateTimeUtil.greaterThanMaxValue(new Date(2020, 2, 6), maxValue)).to - .be.false; - expect(DateTimeUtil.greaterThanMaxValue(new Date(2020, 2, 8), maxValue)).to - .be.true; - - minValue = new Date(2020, 2, 3, 11, 11, 11); - maxValue = new Date(2020, 2, 3, 15, 15, 15); - - //years - expect( - DateTimeUtil.lessThanMinValue(new Date(2020, 2, 3, 11, 30, 0), minValue) - ).to.be.false; - expect( - DateTimeUtil.lessThanMinValue(new Date(2019, 2, 3, 11, 30, 0), minValue) - ).to.be.true; - - expect( - DateTimeUtil.greaterThanMaxValue( - new Date(2019, 2, 3, 11, 30, 0), - maxValue - ) - ).to.be.false; - expect( - DateTimeUtil.greaterThanMaxValue( - new Date(2021, 2, 3, 11, 30, 0), - maxValue - ) - ).to.be.true; - - //months - expect( - DateTimeUtil.lessThanMinValue(new Date(2020, 2, 3, 12, 45, 0), minValue) - ).to.be.false; - expect( - DateTimeUtil.lessThanMinValue(new Date(2020, 1, 3, 12, 45, 0), minValue) - ).to.be.true; - - expect( - DateTimeUtil.greaterThanMaxValue( - new Date(2020, 1, 3, 11, 30, 0), - maxValue - ) - ).to.be.false; - expect( - DateTimeUtil.greaterThanMaxValue( - new Date(2020, 3, 3, 11, 30, 0), - maxValue - ) - ).to.be.true; - - //days - expect( - DateTimeUtil.lessThanMinValue(new Date(2020, 2, 3, 12, 45, 0), minValue) - ).to.be.false; - expect( - DateTimeUtil.lessThanMinValue(new Date(2020, 2, 2, 12, 45, 0), minValue) - ).to.be.true; - - expect( - DateTimeUtil.greaterThanMaxValue( - new Date(2020, 2, 1, 11, 30, 0), - maxValue - ) - ).to.be.false; - expect( - DateTimeUtil.greaterThanMaxValue( - new Date(2020, 2, 4, 11, 30, 0), - maxValue - ) - ).to.be.true; - - //hours - expect( - DateTimeUtil.lessThanMinValue(new Date(2020, 2, 3, 14, 0, 0), minValue) - ).to.be.false; - expect( - DateTimeUtil.lessThanMinValue(new Date(2020, 2, 3, 10, 0, 0), minValue) - ).to.be.true; - - expect( - DateTimeUtil.greaterThanMaxValue(new Date(2020, 2, 3, 11, 0, 0), maxValue) - ).to.be.false; - expect( - DateTimeUtil.greaterThanMaxValue(new Date(2020, 2, 3, 16, 0, 0), maxValue) - ).to.be.true; - - //minutes - expect( - DateTimeUtil.lessThanMinValue(new Date(2020, 2, 3, 11, 12, 0), minValue) - ).to.be.false; - expect( - DateTimeUtil.lessThanMinValue(new Date(2020, 2, 3, 11, 10, 0), minValue) - ).to.be.true; - - expect( - DateTimeUtil.greaterThanMaxValue( - new Date(2020, 2, 3, 15, 14, 0), - maxValue - ) - ).to.be.false; - expect( - DateTimeUtil.greaterThanMaxValue( - new Date(2020, 2, 3, 15, 16, 0), - maxValue - ) - ).to.be.true; - - //seconds - expect( - DateTimeUtil.lessThanMinValue(new Date(2020, 2, 3, 11, 11, 12), minValue) - ).to.be.false; - expect( - DateTimeUtil.lessThanMinValue(new Date(2020, 2, 3, 11, 11, 10), minValue) - ).to.be.true; - - expect( - DateTimeUtil.greaterThanMaxValue( - new Date(2020, 2, 3, 15, 15, 14), - maxValue - ) - ).to.be.false; - expect( - DateTimeUtil.greaterThanMaxValue( - new Date(2020, 2, 3, 15, 15, 16), - maxValue - ) - ).to.be.true; - - //exclude date - expect( - DateTimeUtil.lessThanMinValue( - new Date(2010, 2, 3, 11, 15, 0), - minValue, - true, - false - ) - ).to.be.false; - expect( - DateTimeUtil.greaterThanMaxValue( - new Date(2030, 2, 3, 15, 15, 14), - maxValue, - true, - false - ) - ).to.be.false; - - //exclude time - expect( - DateTimeUtil.lessThanMinValue( - new Date(2020, 2, 3, 10, 0, 0), - minValue, - false, - true - ) - ).to.be.false; - expect( - DateTimeUtil.greaterThanMaxValue( - new Date(2020, 2, 3, 16, 0, 0), - maxValue, - false, - true - ) - ).to.be.false; - }); - - it('should spin date portions correctly', () => { - let date = new Date(2015, 4, 20); - DateTimeUtil.spinDate(1, date, false); - expect(date.getTime()).to.equal(new Date(2015, 4, 21).getTime()); - DateTimeUtil.spinDate(-1, date, false); - expect(date.getTime()).to.equal(new Date(2015, 4, 20).getTime()); - - // delta !== 1 - DateTimeUtil.spinDate(5, date, false); - expect(date.getTime()).to.equal(new Date(2015, 4, 25).getTime()); - DateTimeUtil.spinDate(-6, date, false); - expect(date.getTime()).to.equal(new Date(2015, 4, 19).getTime()); - - // spinLoop = false - date = new Date(2015, 4, 31); - DateTimeUtil.spinDate(1, date, false); - expect(date.getTime()).to.equal(new Date(2015, 4, 31).getTime()); - DateTimeUtil.spinDate(-50, date, false); - expect(date.getTime()).to.equal(new Date(2015, 4, 1).getTime()); - - // spinLoop = true - DateTimeUtil.spinDate(31, date, true); - expect(date.getTime()).to.equal(new Date(2015, 4, 1).getTime()); - DateTimeUtil.spinDate(-5, date, true); - expect(date.getTime()).to.equal(new Date(2015, 4, 27).getTime()); - }); - - it('should spin month portions correctly', () => { - let date = new Date(2015, 4, 20); - DateTimeUtil.spinMonth(1, date, false); - expect(date.getTime()).to.equal(new Date(2015, 5, 20).getTime()); - DateTimeUtil.spinMonth(-1, date, false); - expect(date.getTime()).to.equal(new Date(2015, 4, 20).getTime()); - - // delta !== 1 - DateTimeUtil.spinMonth(5, date, false); - expect(date.getTime()).to.equal(new Date(2015, 9, 20).getTime()); - DateTimeUtil.spinMonth(-6, date, false); - expect(date.getTime()).to.equal(new Date(2015, 3, 20).getTime()); - - // spinLoop = false - date = new Date(2015, 11, 31); - DateTimeUtil.spinMonth(1, date, false); - expect(date.getTime()).to.equal(new Date(2015, 11, 31).getTime()); - DateTimeUtil.spinMonth(-50, date, false); - expect(date.getTime()).to.equal(new Date(2015, 0, 31).getTime()); - - // spinLoop = true - date = new Date(2015, 11, 1); - DateTimeUtil.spinMonth(2, date, true); - expect(date.getTime()).to.equal(new Date(2015, 1, 1).getTime()); - date = new Date(2015, 0, 1); - DateTimeUtil.spinMonth(-1, date, true); - expect(date.getTime()).to.equal(new Date(2015, 11, 1).getTime()); - - // coerces date portion to be no greater than max date of current month - date = new Date(2020, 2, 31); - DateTimeUtil.spinMonth(-1, date, false); - expect(date.getTime()).to.equal(new Date(2020, 1, 29).getTime()); - DateTimeUtil.spinMonth(1, date, false); - expect(date.getTime()).to.equal(new Date(2020, 2, 29).getTime()); - date = new Date(2020, 4, 31); - DateTimeUtil.spinMonth(1, date, false); - expect(date.getTime()).to.equal(new Date(2020, 5, 30).getTime()); - }); - - it('should spin year portions correctly', () => { - let date = new Date(2015, 4, 20); - DateTimeUtil.spinYear(1, date); - expect(date.getTime()).to.equal(new Date(2016, 4, 20).getTime()); - DateTimeUtil.spinYear(-1, date); - expect(date.getTime()).to.equal(new Date(2015, 4, 20).getTime()); - - // delta !== 1 - DateTimeUtil.spinYear(5, date); - expect(date.getTime()).to.equal(new Date(2020, 4, 20).getTime()); - DateTimeUtil.spinYear(-6, date); - expect(date.getTime()).to.equal(new Date(2014, 4, 20).getTime()); - - // coerces February to be 29 days on a leap year and 28 on a non leap year - date = new Date(2020, 1, 29); - DateTimeUtil.spinYear(1, date); - expect(date.getTime()).to.equal(new Date(2021, 1, 28).getTime()); - DateTimeUtil.spinYear(-1, date); - expect(date.getTime()).to.equal(new Date(2020, 1, 28).getTime()); - }); - - it('should spin hours portion correctly', () => { - let date = new Date(2015, 4, 20, 6); - DateTimeUtil.spinHours(1, date, false); - expect(date.getTime()).to.equal(new Date(2015, 4, 20, 7).getTime()); - DateTimeUtil.spinHours(-1, date, false); - expect(date.getTime()).to.equal(new Date(2015, 4, 20, 6).getTime()); - - // delta !== 1 - DateTimeUtil.spinHours(5, date, false); - expect(date.getTime()).to.equal(new Date(2015, 4, 20, 11).getTime()); - DateTimeUtil.spinHours(-6, date, false); - expect(date.getTime()).to.equal(new Date(2015, 4, 20, 5).getTime()); - - // spinLoop = false - date = new Date(2015, 4, 20, 23); - DateTimeUtil.spinHours(1, date, false); - expect(date.getTime()).to.equal(new Date(2015, 4, 20, 23).getTime()); - DateTimeUtil.spinHours(-30, date, false); - expect(date.getTime()).to.equal(new Date(2015, 4, 20, 0).getTime()); - - // spinLoop = true (date is not affected) - DateTimeUtil.spinHours(25, date, true); - expect(date.getTime()).to.equal(new Date(2015, 4, 20, 1).getTime()); - DateTimeUtil.spinHours(-2, date, true); - expect(date.getTime()).to.equal(new Date(2015, 4, 20, 23).getTime()); - }); - - it('should spin minutes portion correctly', () => { - let date = new Date(2015, 4, 20, 6, 10); - DateTimeUtil.spinMinutes(1, date, false); - expect(date.getTime()).to.equal(new Date(2015, 4, 20, 6, 11).getTime()); - DateTimeUtil.spinMinutes(-1, date, false); - expect(date.getTime()).to.equal(new Date(2015, 4, 20, 6, 10).getTime()); - - // delta !== 1 - DateTimeUtil.spinMinutes(5, date, false); - expect(date.getTime()).to.equal(new Date(2015, 4, 20, 6, 15).getTime()); - DateTimeUtil.spinMinutes(-6, date, false); - expect(date.getTime()).to.equal(new Date(2015, 4, 20, 6, 9).getTime()); - - // spinLoop = false - date = new Date(2015, 4, 20, 12, 59); - DateTimeUtil.spinMinutes(1, date, false); - expect(date.getTime()).to.equal(new Date(2015, 4, 20, 12, 59).getTime()); - DateTimeUtil.spinMinutes(-70, date, false); - expect(date.getTime()).to.equal(new Date(2015, 4, 20, 12, 0).getTime()); - - // spinLoop = true (hours are not affected) - DateTimeUtil.spinMinutes(61, date, true); - expect(date.getTime()).to.equal(new Date(2015, 4, 20, 12, 1).getTime()); - DateTimeUtil.spinMinutes(-5, date, true); - expect(date.getTime()).to.equal(new Date(2015, 4, 20, 12, 56).getTime()); - }); - - it('should spin seconds portion correctly', () => { - // base - let date = new Date(2015, 4, 20, 6, 10, 5); - DateTimeUtil.spinSeconds(1, date, false); - expect(date.getTime()).to.equal(new Date(2015, 4, 20, 6, 10, 6).getTime()); - DateTimeUtil.spinSeconds(-1, date, false); - expect(date.getTime()).to.equal(new Date(2015, 4, 20, 6, 10, 5).getTime()); - - // delta !== 1 - DateTimeUtil.spinSeconds(5, date, false); - expect(date.getTime()).to.equal(new Date(2015, 4, 20, 6, 10, 10).getTime()); - DateTimeUtil.spinSeconds(-6, date, false); - expect(date.getTime()).to.equal(new Date(2015, 4, 20, 6, 10, 4).getTime()); - - // spinLoop = false - date = new Date(2015, 4, 20, 12, 59, 59); - DateTimeUtil.spinSeconds(1, date, false); - expect(date.getTime()).to.equal( - new Date(2015, 4, 20, 12, 59, 59).getTime() - ); - DateTimeUtil.spinSeconds(-70, date, false); - expect(date.getTime()).to.equal(new Date(2015, 4, 20, 12, 59, 0).getTime()); - - // spinLoop = true (minutes are not affected) - DateTimeUtil.spinSeconds(62, date, true); - expect(date.getTime()).to.equal(new Date(2015, 4, 20, 12, 59, 2).getTime()); - DateTimeUtil.spinSeconds(-5, date, true); - expect(date.getTime()).to.equal( - new Date(2015, 4, 20, 12, 59, 57).getTime() - ); - }); - - it('should spin AM/PM portion correctly', () => { - const currentDate = new Date(2015, 4, 31, 4, 59, 59); - const newDate = new Date(2015, 4, 31, 4, 59, 59); - // spin from AM to PM - DateTimeUtil.spinAmPm(currentDate, newDate, 'PM'); - expect(currentDate.getHours()).to.equal(16); - - // spin from PM to AM - DateTimeUtil.spinAmPm(currentDate, newDate, 'AM'); - expect(currentDate.getHours()).to.equal(4); - }); -}); diff --git a/src/components/date-time-input/date-util.ts b/src/components/date-time-input/date-util.ts deleted file mode 100644 index 9f7f17d92..000000000 --- a/src/components/date-time-input/date-util.ts +++ /dev/null @@ -1,646 +0,0 @@ -import { getDateFormatter } from 'igniteui-i18n-core'; -import { parseISODate } from '../calendar/helpers.js'; -import { clamp } from '../common/util.js'; - -export enum FormatDesc { - Numeric = 'numeric', - TwoDigits = '2-digit', -} - -export enum DateParts { - Day = 'day', - Month = 'month', - Year = 'year', - Date = 'date', - Hours = 'hours', - Minutes = 'minutes', - Seconds = 'seconds', - AmPm = 'amPm', - Literal = 'literal', -} - -export enum DatePart { - Month = 'month', - Year = 'year', - Date = 'date', - Hours = 'hours', - Minutes = 'minutes', - Seconds = 'seconds', - AmPm = 'amPm', -} - -/** @ignore */ -export interface DatePartInfo { - type: DateParts; - start: number; - end: number; - format: string; -} - -export interface DatePartDeltas { - date?: number; - month?: number; - year?: number; - hours?: number; - minutes?: number; - seconds?: number; -} - -function isDate(value: unknown): value is Date { - return value instanceof Date; -} - -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 PREDEFINED_FORMATS = new Set([ - 'short', - 'medium', - 'long', - 'full', - ]); - - public static parseValueFromMask( - inputData: string, - dateTimeParts: DatePartInfo[], - promptChar?: string - ): Date | null { - const parts: { [key in DateParts]: number } = {} as any; - dateTimeParts.forEach((dp) => { - let value = Number.parseInt( - DateTimeUtil.getCleanVal(inputData, dp, promptChar), - 10 - ); - if (!value) { - value = - dp.type === DateParts.Date || dp.type === DateParts.Month ? 1 : 0; - } - parts[dp.type] = value; - }); - parts[DateParts.Month] -= 1; - - if (parts[DateParts.Month] < 0 || 11 < parts[DateParts.Month]) { - return null; - } - - // TODO: Century threshold - if (parts[DateParts.Year] < 50) { - parts[DateParts.Year] += 2000; - } - - if ( - parts[DateParts.Date] > - DateTimeUtil.daysInMonth(parts[DateParts.Year], parts[DateParts.Month]) - ) { - return null; - } - - if ( - parts[DateParts.Hours] > 23 || - parts[DateParts.Minutes] > 59 || - parts[DateParts.Seconds] > 59 - ) { - return null; - } - - const amPm = dateTimeParts.find((p) => p.type === DateParts.AmPm); - if (amPm) { - parts[DateParts.Hours] %= 12; - } - - if ( - amPm && - DateTimeUtil.getCleanVal(inputData, amPm, promptChar).toLowerCase() === - 'pm' - ) { - parts[DateParts.Hours] += 12; - } - - return new Date( - parts[DateParts.Year] || 2000, - parts[DateParts.Month] || 0, - parts[DateParts.Date] || 1, - parts[DateParts.Hours] || 0, - parts[DateParts.Minutes] || 0, - parts[DateParts.Seconds] || 0 - ); - } - - public static getDefaultInputMask(locale: string): string { - return getDateFormatter().getLocaleDateTimeFormat(locale, true); - } - - public static parseDateTimeFormat( - mask: string, - locale: string, - leadingZero = false - ): DatePartInfo[] { - const format = mask || DateTimeUtil.getDefaultInputMask(locale); - const dateTimeParts: DatePartInfo[] = []; - const formatArray = Array.from(format); - let currentPart: DatePartInfo | null = 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, leadingZero); - 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, leadingZero); - } - // formats like "y" or "yyy" are treated like "yyyy" while editing - const yearPart = dateTimeParts.filter((p) => p.type === DateParts.Year)[0]; - if (yearPart && yearPart.format !== 'yy') { - yearPart.end += 4 - yearPart.format.length; - yearPart.format = 'yyyy'; - } - - return dateTimeParts; - } - - public static parseIsoDate(value: string): Date | null { - return parseISODate(value); - } - - public static isValidDate(value: any): value is Date { - if (isDate(value)) { - return !Number.isNaN(value.getTime()); - } - - return false; - } - - /** - * Format date for display. - * @param value Date value - * @param locale Locale of the component - * @param displayFormat Display format specified by the user. Can be undefined. - * @param inputFormat Input format, so it is not calculated again and used for leading zero format. - * @returns - */ - public static formatDisplayDate( - value: Date, - locale: string, - displayFormat: string | undefined - ): string { - const options: Intl.DateTimeFormatOptions = {}; - switch (displayFormat) { - case 'short': - case 'long': - case 'medium': - case 'full': - options.dateStyle = displayFormat; - options.timeStyle = displayFormat; - break; - case 'shortDate': - case 'longDate': - case 'mediumDate': - case 'fullDate': - options.dateStyle = displayFormat.toLowerCase().split('date')[0] as any; - break; - case 'shortTime': - case 'longTime': - case 'mediumTime': - case 'fullTime': - options.timeStyle = displayFormat.toLowerCase().split('time')[0] as any; - break; - default: - if (displayFormat) { - return getDateFormatter().formatDateCustomFormat( - value, - displayFormat, - { locale } - ); - } - } - - return getDateFormatter().formatDateTime(value, locale, options); - } - - public static getPartValue( - datePartInfo: DatePartInfo, - partLength: number, - _dateValue: Date | null - ): string { - let maskedValue: any; - const datePart = datePartInfo.type; - - switch (datePart) { - case DateParts.Date: - maskedValue = _dateValue!.getDate(); - break; - case DateParts.Month: - // months are zero based - maskedValue = _dateValue!.getMonth() + 1; - break; - case DateParts.Year: - if (partLength === 2) { - maskedValue = DateTimeUtil.prependValue( - Number.parseInt(_dateValue!.getFullYear().toString().slice(-2), 10), - partLength, - '0' - ); - } else { - maskedValue = _dateValue!.getFullYear(); - } - break; - case DateParts.Hours: - if (datePartInfo.format.indexOf('h') !== -1) { - maskedValue = DateTimeUtil.prependValue( - DateTimeUtil.toTwelveHourFormat(_dateValue!.getHours()), - partLength, - '0' - ); - } else { - maskedValue = _dateValue!.getHours(); - } - break; - case DateParts.Minutes: - maskedValue = _dateValue!.getMinutes(); - break; - case DateParts.Seconds: - maskedValue = _dateValue!.getSeconds(); - break; - case DateParts.AmPm: - maskedValue = _dateValue!.getHours() >= 12 ? 'PM' : 'AM'; - break; - } - - if (datePartInfo.type !== DateParts.AmPm) { - return DateTimeUtil.prependValue(maskedValue, partLength, '0'); - } - - return maskedValue; - } - - private static _spinTimePart( - newDate: Date, - delta: number, - max: number, - min: number, - setter: (value: number) => number, - getter: () => number, - spinLoop: boolean - ): void { - const range = max - min + 1; - let newValue = getter.call(newDate) + delta; - - if (spinLoop) { - newValue = min + ((((newValue - min) % range) + range) % range); - } else { - newValue = clamp(newValue, min, max); - } - - setter.call(newDate, newValue); - } - - public static spinYear(delta: number, newDate: Date): Date { - 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); - - return newDate; - } - - 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); - } - - 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); - } - - public static spinHours( - delta: number, - newDate: Date, - spinLoop: boolean - ): void { - DateTimeUtil._spinTimePart( - newDate, - delta, - 23, - 0, - newDate.setHours, - newDate.getHours, - spinLoop - ); - } - - public static spinMinutes( - delta: number, - newDate: Date, - spinLoop: boolean - ): void { - DateTimeUtil._spinTimePart( - newDate, - delta, - 59, - 0, - newDate.setMinutes, - newDate.getMinutes, - spinLoop - ); - } - - public static spinSeconds( - delta: number, - newDate: Date, - spinLoop: boolean - ): void { - DateTimeUtil._spinTimePart( - newDate, - delta, - 59, - 0, - newDate.setSeconds, - newDate.getSeconds, - spinLoop - ); - } - - public static spinAmPm( - newDate: Date, - currentDate: Date, - amPmFromMask: string - ): Date { - let date = new Date(newDate); - - switch (amPmFromMask) { - case 'am': - case 'AM': - date = new Date(newDate.setHours(newDate.getHours() + 12)); - break; - case 'pm': - case 'PM': - date = new Date(newDate.setHours(newDate.getHours() - 12)); - break; - } - if (date.getDate() !== currentDate.getDate()) { - return currentDate; - } - - return date; - } - - 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 - ) { - // 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; - } - - /** - * Transforms the predefined format to a display format containing only date parts. - * - * @param format The format to check and transform - */ - public static predefinedToDateDisplayFormat(format?: string) { - return format && DateTimeUtil.PREDEFINED_FORMATS.has(format) - ? `${format}Date` - : format; - } - - private static addCurrentPart( - currentPart: DatePartInfo, - dateTimeParts: DatePartInfo[], - leadingZero = false - ): void { - DateTimeUtil.ensureLeadingZero(currentPart, leadingZero); - currentPart.end = currentPart.start + currentPart.format.length; - dateTimeParts.push(currentPart); - } - - private static ensureLeadingZero(part: DatePartInfo, leadingZero = false) { - switch (part.type) { - case DateParts.Date: - case DateParts.Month: - case DateParts.Hours: - case DateParts.Minutes: - case DateParts.Seconds: - if (part.format.length === 1 && leadingZero) { - part.format = part.format.repeat(2); - } - break; - } - } - - private static determineDatePart(char: string): DateParts { - switch (char) { - case 'd': - case 'D': - return DateParts.Date; - case 'M': - return DateParts.Month; - case 'y': - case 'Y': - return DateParts.Year; - case 'h': - case 'H': - return DateParts.Hours; - case 'm': - return DateParts.Minutes; - case 's': - case 'S': - return DateParts.Seconds; - case 't': - case 'T': - return DateParts.AmPm; - default: - return DateParts.Literal; - } - } - - private static getCleanVal( - inputData: string, - datePart: DatePartInfo, - prompt?: string - ): string { - return DateTimeUtil.trimEmptyPlaceholders( - inputData.substring(datePart.start, datePart.end), - prompt - ); - } - - private static escapeRegExp(string: string) { - return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); - } - - private static trimEmptyPlaceholders(value: string, prompt?: string): string { - const result = value.replace( - new RegExp(DateTimeUtil.escapeRegExp(prompt ?? '_'), 'g'), - '' - ); - return result; - } - - private static daysInMonth(fullYear: number, month: number): number { - return new Date(fullYear, month + 1, 0).getDate(); - } - - private static prependValue( - value: number, - partLength: number, - prependChar: string - ): string { - return (prependChar + value.toString()).slice(-partLength); - } - - private static toTwelveHourFormat(value: number): number { - const hour12 = value % 12; - return hour12 === 0 ? 12 : hour12; - } -} diff --git a/src/components/date-time-input/datetime-mask-parser.spec.ts b/src/components/date-time-input/datetime-mask-parser.spec.ts new file mode 100644 index 000000000..d57bd99d1 --- /dev/null +++ b/src/components/date-time-input/datetime-mask-parser.spec.ts @@ -0,0 +1,226 @@ +import { expect } from '@open-wc/testing'; +import { DateParts, DateTimeMaskParser } from './datetime-mask-parser.js'; + +describe('DateTimeMaskParser', () => { + describe('Format Parsing', () => { + it('parses MM/dd/yyyy format correctly', () => { + const parser = new DateTimeMaskParser({ format: 'MM/dd/yyyy' }); + + expect(parser.dateParts).to.have.lengthOf(5); // MM, /, dd, /, yyyy + + const parts = parser.dateParts.filter( + (p) => p.type !== DateParts.Literal + ); + expect(parts).to.have.lengthOf(3); + expect(parts[0].type).to.equal(DateParts.Month); + expect(parts[1].type).to.equal(DateParts.Date); + expect(parts[2].type).to.equal(DateParts.Year); + }); + + it('parses HH:mm:ss format correctly', () => { + const parser = new DateTimeMaskParser({ format: 'HH:mm:ss' }); + + const parts = parser.dateParts.filter( + (p) => p.type !== DateParts.Literal + ); + expect(parts).to.have.lengthOf(3); + expect(parts[0].type).to.equal(DateParts.Hours); + expect(parts[1].type).to.equal(DateParts.Minutes); + expect(parts[2].type).to.equal(DateParts.Seconds); + }); + + it('parses format with AM/PM correctly', () => { + const parser = new DateTimeMaskParser({ format: 'hh:mm tt' }); + + const parts = parser.dateParts.filter( + (p) => p.type !== DateParts.Literal + ); + expect(parts).to.have.lengthOf(3); + expect(parts[0].type).to.equal(DateParts.Hours); + expect(parts[1].type).to.equal(DateParts.Minutes); + expect(parts[2].type).to.equal(DateParts.AmPm); + }); + + it('identifies date part positions correctly', () => { + const parser = new DateTimeMaskParser({ format: 'MM/dd/yyyy' }); + + const monthPart = parser.dateParts.find( + (p) => p.type === DateParts.Month + ); + expect(monthPart!.start).to.equal(0); + expect(monthPart!.end).to.equal(2); + expect(monthPart!.format).to.equal('MM'); + + const datePart = parser.dateParts.find((p) => p.type === DateParts.Date); + expect(datePart!.start).to.equal(3); + expect(datePart!.end).to.equal(5); + + const yearPart = parser.dateParts.find((p) => p.type === DateParts.Year); + expect(yearPart!.start).to.equal(6); + expect(yearPart!.end).to.equal(10); + }); + }); + + describe('Mask Application', () => { + it('generates empty mask correctly', () => { + const parser = new DateTimeMaskParser({ format: 'MM/dd/yyyy' }); + expect(parser.emptyMask).to.equal('__/__/____'); + }); + + it('applies input to mask', () => { + const parser = new DateTimeMaskParser({ format: 'MM/dd/yyyy' }); + expect(parser.apply('12252023')).to.equal('12/25/2023'); + }); + + it('handles partial input', () => { + const parser = new DateTimeMaskParser({ format: 'MM/dd/yyyy' }); + expect(parser.apply('12')).to.equal('12/__/____'); + }); + + it('applies time format', () => { + const parser = new DateTimeMaskParser({ format: 'HH:mm:ss' }); + expect(parser.apply('143025')).to.equal('14:30:25'); + }); + }); + + describe('Date Formatting', () => { + it('formats date to masked string', () => { + const parser = new DateTimeMaskParser({ format: 'MM/dd/yyyy' }); + const date = new Date(2023, 11, 25); // Dec 25, 2023 + + expect(parser.formatDate(date)).to.equal('12/25/2023'); + }); + + it('formats date with leading zeros', () => { + const parser = new DateTimeMaskParser({ format: 'MM/dd/yyyy' }); + const date = new Date(2023, 0, 5); // Jan 5, 2023 + + expect(parser.formatDate(date)).to.equal('01/05/2023'); + }); + + it('formats time correctly', () => { + const parser = new DateTimeMaskParser({ format: 'HH:mm:ss' }); + const date = new Date(2023, 0, 1, 14, 30, 45); + + expect(parser.formatDate(date)).to.equal('14:30:45'); + }); + + it('formats 12-hour time with AM/PM', () => { + const parser = new DateTimeMaskParser({ format: 'hh:mm tt' }); + const pmDate = new Date(2023, 0, 1, 14, 30, 0); + const amDate = new Date(2023, 0, 1, 9, 30, 0); + + expect(parser.formatDate(pmDate)).to.equal('02:30 PM'); + expect(parser.formatDate(amDate)).to.equal('09:30 AM'); + }); + + it('returns empty mask for null date', () => { + const parser = new DateTimeMaskParser({ format: 'MM/dd/yyyy' }); + expect(parser.formatDate(null)).to.equal('__/__/____'); + }); + }); + + describe('Date Parsing', () => { + it('parses masked string to date', () => { + const parser = new DateTimeMaskParser({ format: 'MM/dd/yyyy' }); + const date = parser.parseDate('12/25/2023'); + + expect(date).to.not.be.null; + expect(date!.getFullYear()).to.equal(2023); + expect(date!.getMonth()).to.equal(11); // December (0-indexed) + expect(date!.getDate()).to.equal(25); + }); + + it('parses time correctly', () => { + const parser = new DateTimeMaskParser({ format: 'HH:mm:ss' }); + const date = parser.parseDate('14:30:45'); + + expect(date).to.not.be.null; + expect(date!.getHours()).to.equal(14); + expect(date!.getMinutes()).to.equal(30); + expect(date!.getSeconds()).to.equal(45); + }); + + it('handles AM/PM parsing', () => { + const parser = new DateTimeMaskParser({ format: 'hh:mm tt' }); + + const pmDate = parser.parseDate('02:30 PM'); + expect(pmDate!.getHours()).to.equal(14); + + const amDate = parser.parseDate('09:30 AM'); + expect(amDate!.getHours()).to.equal(9); + }); + + it('returns null for invalid month', () => { + const parser = new DateTimeMaskParser({ format: 'MM/dd/yyyy' }); + expect(parser.parseDate('13/25/2023')).to.be.null; + }); + + it('returns null for invalid date', () => { + const parser = new DateTimeMaskParser({ format: 'MM/dd/yyyy' }); + expect(parser.parseDate('02/30/2023')).to.be.null; // Feb 30 doesn't exist + }); + + it('handles two-digit year with century threshold', () => { + const parser = new DateTimeMaskParser({ format: 'MM/dd/yy' }); + + const date1 = parser.parseDate('12/25/23'); + expect(date1!.getFullYear()).to.equal(2023); + + const date2 = parser.parseDate('12/25/99'); + expect(date2!.getFullYear()).to.equal(1999); + }); + }); + + describe('Part Queries', () => { + it('gets date part at cursor position', () => { + const parser = new DateTimeMaskParser({ format: 'MM/dd/yyyy' }); + + expect(parser.getDatePartAtPosition(0)?.type).to.equal(DateParts.Month); + expect(parser.getDatePartAtPosition(1)?.type).to.equal(DateParts.Month); + expect(parser.getDatePartAtPosition(2)).to.be.undefined; // Literal / + expect(parser.getDatePartAtPosition(3)?.type).to.equal(DateParts.Date); + expect(parser.getDatePartAtPosition(6)?.type).to.equal(DateParts.Year); + }); + + it('identifies date vs time parts', () => { + const dateParser = new DateTimeMaskParser({ format: 'MM/dd/yyyy' }); + expect(dateParser.hasDateParts()).to.be.true; + expect(dateParser.hasTimeParts()).to.be.false; + + const timeParser = new DateTimeMaskParser({ format: 'HH:mm:ss' }); + expect(timeParser.hasDateParts()).to.be.false; + expect(timeParser.hasTimeParts()).to.be.true; + + const bothParser = new DateTimeMaskParser({ format: 'MM/dd/yyyy HH:mm' }); + expect(bothParser.hasDateParts()).to.be.true; + expect(bothParser.hasTimeParts()).to.be.true; + }); + + it('gets first date part', () => { + const parser = new DateTimeMaskParser({ format: 'MM/dd/yyyy' }); + const first = parser.getFirstDatePart(); + + expect(first).to.not.be.undefined; + expect(first!.type).to.equal(DateParts.Month); + }); + + it('gets part by type', () => { + const parser = new DateTimeMaskParser({ format: 'MM/dd/yyyy' }); + + expect(parser.getPartByType(DateParts.Year)?.format).to.equal('yyyy'); + expect(parser.getPartByType(DateParts.AmPm)).to.be.undefined; + }); + }); + + describe('Mask Change', () => { + it('re-parses date parts when mask changes', () => { + const parser = new DateTimeMaskParser({ format: 'MM/dd/yyyy' }); + expect(parser.hasTimeParts()).to.be.false; + + parser.mask = 'HH:mm:ss'; + expect(parser.hasTimeParts()).to.be.true; + expect(parser.hasDateParts()).to.be.false; + }); + }); +}); diff --git a/src/components/date-time-input/datetime-mask-parser.ts b/src/components/date-time-input/datetime-mask-parser.ts new file mode 100644 index 000000000..debc99264 --- /dev/null +++ b/src/components/date-time-input/datetime-mask-parser.ts @@ -0,0 +1,498 @@ +import { asNumber, clamp } from '../common/util.js'; +import { MaskParser } from '../mask-input/mask-parser.js'; +import { createDatePart, DatePartType, type IDatePart } from './date-part.js'; + +//#region Types and Enums + +/** + * Types of date/time parts that can appear in a format string. + * Re-exported from date-part.ts for backward compatibility. + */ +export { DatePartType as DateParts }; + +/** + * Re-export createDatePart factory for creating standalone parts. + */ +export { createDatePart }; + +/** + * Information about a parsed date part within a format string. + * This is a type alias for IDatePart for backward compatibility. + */ +export type DatePartInfo = IDatePart; + +/** Options for the DateTimeMaskParser */ +export interface DateTimeMaskOptions { + /** The date/time format string (e.g., 'MM/dd/yyyy', 'HH:mm:ss') */ + format?: string; + /** The prompt character for unfilled positions */ + promptCharacter?: string; +} + +//#endregion + +//#region Constants + +/** Maps format characters to their corresponding DatePartType */ +const FORMAT_CHAR_TO_DATE_PART = new Map([ + ['d', DatePartType.Date], + ['D', DatePartType.Date], + ['M', DatePartType.Month], + ['y', DatePartType.Year], + ['Y', DatePartType.Year], + ['h', DatePartType.Hours], + ['H', DatePartType.Hours], + ['m', DatePartType.Minutes], + ['s', DatePartType.Seconds], + ['S', DatePartType.Seconds], + ['t', DatePartType.AmPm], + ['T', DatePartType.AmPm], +]); + +/** Set of valid date/time format characters */ +const DATE_FORMAT_CHARS = new Set(FORMAT_CHAR_TO_DATE_PART.keys()); + +/** Century threshold for two-digit year interpretation */ +const CENTURY_THRESHOLD = 50; +const CENTURY_BASE = 2000; + +/** Default values for missing date parts */ +const DEFAULT_DATE_VALUES = { + year: 2000, + month: 0, + date: 1, + hours: 0, + minutes: 0, + seconds: 0, +} as const; + +/** Default date/time format */ +const DEFAULT_DATETIME_FORMAT = 'MM/dd/yyyy'; + +//#endregion + +/** + * A specialized mask parser for date/time input fields. + * Extends MaskParser to handle date-specific format patterns and validation. + * + * @example + * ```ts + * const parser = new DateTimeMaskParser({ format: 'MM/dd/yyyy' }); + * parser.apply('12252023'); // Returns '12/25/2023' + * parser.parseDate('12/25/2023'); // Returns Date object + * ``` + */ +export class DateTimeMaskParser extends MaskParser { + /** Parsed date parts from the format string */ + private _dateParts!: IDatePart[]; + + /** + * Gets the parsed date parts from the format string. + * Each part contains type, position, and format information. + */ + public get dateParts(): ReadonlyArray { + return this._dateParts; + } + + constructor(options?: DateTimeMaskOptions) { + const format = options?.format || DEFAULT_DATETIME_FORMAT; + + super( + options?.promptCharacter + ? { format, promptCharacter: options.promptCharacter } + : { format } + ); + } + + /** + * Sets a new date/time format and re-parses the date parts. + */ + public override set mask(value: string) { + super.mask = value; + this._parseDateFormat(); + } + + public override get mask(): string { + return super.mask; + } + + //#region Date Format Parsing + + /** + * Parses the format string into IDatePart objects. + * This identifies each date/time component and its position. + */ + private _parseDateFormat(): void { + const format = this.mask; + const builders: Array<{ + type: DatePartType; + start: number; + end: number; + format: string; + }> = []; + const chars = Array.from(format); + const length = chars.length; + + let currentBuilder: (typeof builders)[0] | null = null; + let position = 0; + + for (let i = 0; i < length; i++, position++) { + const char = chars[i]; + const partType = FORMAT_CHAR_TO_DATE_PART.get(char); + + if (partType) { + // Date/time format character + if (currentBuilder?.format.includes(char)) { + // Continue building the same part + currentBuilder.end = position + 1; + currentBuilder.format += char; + } else { + // Start a new part + if (currentBuilder) { + builders.push(currentBuilder); + } + currentBuilder = { + type: partType, + start: position, + end: position + 1, + format: char, + }; + } + } else { + // Literal character + if (currentBuilder) { + builders.push(currentBuilder); + currentBuilder = null; + } + builders.push({ + type: DatePartType.Literal, + start: position, + end: position + 1, + format: char, + }); + } + } + + // Don't forget the last part + if (currentBuilder) { + builders.push(currentBuilder); + } + + // Normalize year format for editing (except 'yy') + this._normalizeYearFormatBuilder(builders); + + // Create immutable date parts from builders using factory + this._dateParts = builders.map((b) => + createDatePart(b.type, { start: b.start, end: b.end, format: b.format }) + ); + } + + /** + * Normalizes year format to 'yyyy' for editing (except for 'yy'). + * Also updates the end position to account for the expanded format. + */ + private _normalizeYearFormatBuilder( + builders: Array<{ type: DatePartType; end: number; format: string }> + ): void { + const yearBuilder = builders.find((b) => b.type === DatePartType.Year); + if (yearBuilder && yearBuilder.format.length !== 2) { + const expansion = 4 - yearBuilder.format.length; + yearBuilder.end += expansion; + yearBuilder.format = 'yyyy'; + } + } + + //#endregion + + //#region Date Parsing + + /** + * Parses a masked string into a Date object. + * Returns null if the string cannot be parsed into a valid date. + * + * @param masked - The masked input string to parse + * @returns A Date object or null if parsing fails + */ + public parseDate(masked: string): Date | null { + const parts = this._extractDateValues(masked); + + // Convert to zero-based month (only if month is in format) + if (parts[DatePartType.Month] !== undefined) { + parts[DatePartType.Month]! -= 1; + } + + // Apply century threshold for two-digit years (only if year is in format) + if ( + parts[DatePartType.Year] !== undefined && + parts[DatePartType.Year]! < CENTURY_THRESHOLD + ) { + parts[DatePartType.Year]! += CENTURY_BASE; + } + + if (!this._validateDateParts(parts)) { + return null; + } + + // Handle AM/PM conversion + this._applyAmPmConversion(parts, masked); + + return this._createDateFromParts(parts); + } + + /** + * Extracts numeric values from the masked string for each date part. + */ + private _extractDateValues( + masked: string + ): Partial> { + const parts: Partial> = {}; + const prompt = this.prompt; + + for (const datePart of this._dateParts) { + if (datePart.type === DatePartType.Literal) continue; + + const isMonthOrDate = + datePart.type === DatePartType.Date || + datePart.type === DatePartType.Month; + + const raw = masked.substring(datePart.start, datePart.end); + const cleaned = raw.replaceAll(prompt, ''); + const value = asNumber(cleaned); + + parts[datePart.type] = clamp( + value, + isMonthOrDate ? 1 : 0, + Number.MAX_SAFE_INTEGER + ); + } + + return parts; + } + + /** + * Validates that parsed date parts are within valid ranges. + * Only validates parts that are present in the format. + * Uses the validate() method on each date part instance. + */ + private _validateDateParts( + parts: Partial> + ): boolean { + // Build validation context for date-dependent validation + const context = { + year: parts[DatePartType.Year], + month: parts[DatePartType.Month], + }; + + // Validate each parsed value using its corresponding part instance + for (const datePart of this._dateParts) { + if (datePart.type === DatePartType.Literal) continue; + + const value = parts[datePart.type]; + if (value === undefined) continue; + + if (!datePart.validate(value, context)) { + return false; + } + } + + // Additional check: validate date against month/year context + // (the part's validate method needs both year and month for proper validation) + if ( + parts[DatePartType.Date] !== undefined && + parts[DatePartType.Month] !== undefined && + parts[DatePartType.Year] !== undefined + ) { + if ( + parts[DatePartType.Date]! > + this._daysInMonth(parts[DatePartType.Year]!, parts[DatePartType.Month]!) + ) { + return false; + } + } + + return true; + } + + /** + * Applies AM/PM conversion to hours if format includes AM/PM. + */ + private _applyAmPmConversion( + parts: Partial>, + masked: string + ): void { + const amPmPart = this._dateParts.find((p) => p.type === DatePartType.AmPm); + if (!amPmPart) return; + + parts[DatePartType.Hours]! %= 12; + + const amPmValue = masked + .substring(amPmPart.start, amPmPart.end) + .replaceAll(this.prompt, ''); + + if (amPmValue.toLowerCase() === 'pm') { + parts[DatePartType.Hours]! += 12; + } + } + + /** + * Creates a Date object from parsed parts with defaults for missing values. + */ + private _createDateFromParts( + parts: Partial> + ): Date { + const d = DEFAULT_DATE_VALUES; + return new Date( + parts[DatePartType.Year] ?? d.year, + parts[DatePartType.Month] ?? d.month, + parts[DatePartType.Date] ?? d.date, + parts[DatePartType.Hours] ?? d.hours, + parts[DatePartType.Minutes] ?? d.minutes, + parts[DatePartType.Seconds] ?? d.seconds + ); + } + + /** + * Gets the number of days in a specific month/year. + */ + private _daysInMonth(year: number, month: number): number { + return new Date(year, month + 1, 0).getDate(); + } + + //#endregion + + //#region Date Formatting + + /** + * Formats a Date object into a masked string according to the current format. + * + * @param date - The date to format + * @returns The formatted masked string + */ + public formatDate(date: Date | null): string { + return date + ? this._dateParts.map((part) => part.getValue(date)).join('') + : this.emptyMask; + } + + //#endregion + + //#region Part Queries + + /** + * Finds the date part at a given cursor position. + * Uses exclusive end (position < end) for precise character targeting. + * + * @param position - The cursor position to check + * @returns The DatePartInfo at that position, or undefined if not found + */ + public getDatePartAtPosition(position: number): DatePartInfo | undefined { + return this._dateParts.find( + (p) => + p.type !== DatePartType.Literal && + position >= p.start && + position < p.end + ); + } + + /** + * Finds the date part for a cursor position, using inclusive end. + * This handles the edge case where cursor is at the end of the mask + * (position equals the end of the last part). + * + * @param position - The cursor position to check + * @returns The DatePartInfo at that position, or undefined if not found + */ + public getDatePartForCursor(position: number): DatePartInfo | undefined { + return this._dateParts.find( + (p) => + p.type !== DatePartType.Literal && + position >= p.start && + position <= p.end + ); + } + + /** + * Checks if the format includes any date parts (day, month, year). + */ + public hasDateParts(): boolean { + return this._dateParts.some( + (p) => + p.type === DatePartType.Date || + p.type === DatePartType.Month || + p.type === DatePartType.Year + ); + } + + /** + * Checks if the format includes any time parts (hours, minutes, seconds). + */ + public hasTimeParts(): boolean { + return this._dateParts.some( + (p) => + p.type === DatePartType.Hours || + p.type === DatePartType.Minutes || + p.type === DatePartType.Seconds + ); + } + + /** + * Gets the first non-literal date part (useful for default selection). + */ + public getFirstDatePart(): DatePartInfo | undefined { + return this._dateParts.find((p) => p.type !== DatePartType.Literal); + } + + /** + * Gets a specific type of date part. + */ + public getPartByType(type: DatePartType): DatePartInfo | undefined { + return this._dateParts.find((p) => p.type === type); + } + + //#endregion + + //#region Override for Date-Specific Mask + + /** + * Builds the internal mask pattern from the date format. + * Converts date format characters to mask pattern characters. + */ + protected override _parseMaskLiterals(): void { + // First, convert date format to mask format + const dateFormat = this._options.format; + const maskFormat = this._convertToMaskFormat(dateFormat); + + // Temporarily set the converted format for the base class parsing + const originalFormat = this._options.format; + this._options.format = maskFormat; + + super._parseMaskLiterals(); + + // Restore the original date format + this._options.format = originalFormat; + + // Parse date-specific format structure + this._parseDateFormat(); + } + + /** + * Converts a date format string to a mask format string. + * Date format chars become '0' (numeric) or 'L' (alpha for AM/PM). + */ + private _convertToMaskFormat(dateFormat: string): string { + let result = ''; + + for (const char of dateFormat) { + if (DATE_FORMAT_CHARS.has(char)) { + // AM/PM markers are alphabetic, others are numeric + result += char === 't' || char === 'T' ? 'L' : '0'; + } else { + result += char; + } + } + + return result; + } + + //#endregion +} diff --git a/src/components/date-time-input/validators.ts b/src/components/date-time-input/validators.ts index f8cc13e05..0524c1987 100644 --- a/src/components/date-time-input/validators.ts +++ b/src/components/date-time-input/validators.ts @@ -1,3 +1,4 @@ +import { isDateExceedingMax, isDateLessThanMin } from '../calendar/helpers.js'; import { maxDateValidator, minDateValidator, @@ -5,7 +6,6 @@ import { type Validator, } from '../common/validators.js'; import type IgcDateTimeInputComponent from './date-time-input.js'; -import { DateTimeUtil } from './date-util.js'; export const dateTimeInputValidators: Validator[] = [ requiredValidator, @@ -13,13 +13,11 @@ export const dateTimeInputValidators: Validator[] = [ ...minDateValidator, isValid: (host) => host.value && host.min - ? !DateTimeUtil.lessThanMinValue( - host.value || new Date(), + ? !isDateLessThanMin( + host.value, host.min, - // @ts-expect-error - private access - host.hasTimeParts, - // @ts-expect-error - private access - host.hasDateParts + host.hasTimeParts(), + host.hasDateParts() ) : true, }, @@ -27,13 +25,11 @@ export const dateTimeInputValidators: Validator[] = [ ...maxDateValidator, isValid: (host) => host.value && host.max - ? !DateTimeUtil.greaterThanMaxValue( - host.value || new Date(), + ? !isDateExceedingMax( + host.value, host.max, - // @ts-expect-error - private access - host.hasTimeParts, - // @ts-expect-error - private access - host.hasDateParts + host.hasTimeParts(), + host.hasDateParts() ) : true, }, diff --git a/src/components/mask-input/mask-input-base.ts b/src/components/mask-input/mask-input-base.ts index bdfb21b4d..c18705788 100644 --- a/src/components/mask-input/mask-input-base.ts +++ b/src/components/mask-input/mask-input-base.ts @@ -31,6 +31,11 @@ export abstract class IgcMaskInputBaseComponent extends IgcInputBaseComponent { }; } + /** Indicates whether the current mask value is empty. */ + protected get _isEmptyMask(): boolean { + return this._maskedValue === this._parser.emptyMask; + } + //#endregion //#region Public attributes and properties diff --git a/src/components/mask-input/mask-input.ts b/src/components/mask-input/mask-input.ts index a870333bf..cbf11561b 100644 --- a/src/components/mask-input/mask-input.ts +++ b/src/components/mask-input/mask-input.ts @@ -242,7 +242,7 @@ export default class IgcMaskInputComponent extends IgcMaskInputBaseComponent { } private _updateMaskedValue(): void { - if (this._maskedValue === this._parser.emptyMask) { + if (this._isEmptyMask) { this._maskedValue = ''; } } diff --git a/src/index.ts b/src/index.ts index e79d5309b..a414d808f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -163,8 +163,8 @@ export type { } from './components/calendar/types.js'; export { DateRangeType } from './components/calendar/types.js'; export type { IgcCheckboxChangeEventArgs } from './components/checkbox/checkbox-base.js'; -export { DatePart } from './components/date-time-input/date-util.js'; -export type { DatePartDeltas } from './components/date-time-input/date-util.js'; +export { DatePart } from './components/date-time-input/date-part.js'; +export type { DatePartDeltas } from './components/date-time-input/date-part.js'; export type { CustomDateRange, DateRangeValue,