diff --git a/src/components/calendar/helpers.ts b/src/components/calendar/helpers.ts index ba29cd835..6b70ed583 100644 --- a/src/components/calendar/helpers.ts +++ b/src/components/calendar/helpers.ts @@ -6,7 +6,7 @@ import { last, modulo, } from '../common/util.js'; -import type { DateRangeValue } from '../date-range-picker/date-range-picker.js'; +import type { DateRangeValue } from '../types.js'; import { CalendarDay, type CalendarRangeParams, diff --git a/src/components/common/mixins/forms/form-transformers.ts b/src/components/common/mixins/forms/form-transformers.ts index de0901630..89f096866 100644 --- a/src/components/common/mixins/forms/form-transformers.ts +++ b/src/components/common/mixins/forms/form-transformers.ts @@ -3,7 +3,7 @@ import { convertToDateRange, getDateFormValue, } from '../../../calendar/helpers.js'; -import type { DateRangeValue } from '../../../date-range-picker/date-range-picker.js'; +import type { DateRangeValue } from '../../../types.js'; import { asNumber } from '../../util.js'; import type { FormValueType, IgcFormControl } from './types.js'; diff --git a/src/components/date-range-picker/date-range-input.ts b/src/components/date-range-picker/date-range-input.ts new file mode 100644 index 000000000..90f1aa694 --- /dev/null +++ b/src/components/date-range-picker/date-range-input.ts @@ -0,0 +1,381 @@ +import { property } from 'lit/decorators.js'; +import { addThemingController } from '../../theming/theming-controller.js'; +import { CalendarDay } from '../calendar/model.js'; +import { registerComponent } from '../common/definitions/register.js'; +import { formatDisplayDate } from '../common/i18n/i18n-controller.js'; +import { FormValueDateRangeTransformers } from '../common/mixins/forms/form-transformers.js'; +import { createFormValueState } from '../common/mixins/forms/form-value.js'; +import { equal } from '../common/util.js'; +import { + type DatePart, + type DatePartDeltas, + DatePartType, +} from '../date-time-input/date-part.js'; +import { IgcDateTimeInputBaseComponent } from '../date-time-input/date-time-input.base.js'; +import { DateParts } from '../date-time-input/datetime-mask-parser.js'; +import { styles } from '../input/themes/input.base.css.js'; +import { styles as shared } from '../input/themes/shared/input.common.css.js'; +import { all } from '../input/themes/themes.js'; +import type { DateRangeValue } from '../types.js'; +import { + DateRangeMaskParser, + type DateRangePart, + DateRangePosition, +} from './date-range-mask-parser.js'; + +export default class IgcDateRangeInputComponent extends IgcDateTimeInputBaseComponent< + DateRangeValue | null, + DateRangePart +> { + public static readonly tagName = 'igc-date-range-input'; + public static styles = [styles, shared]; + + /* blazorSuppress */ + public static register() { + registerComponent(IgcDateRangeInputComponent); + } + + //#region Private state and properties + + protected override readonly _formValue = createFormValueState(this, { + initialValue: { start: null, end: null }, + transformers: FormValueDateRangeTransformers, + }); + + protected override readonly _themes = addThemingController(this, all); + protected override readonly _parser = new DateRangeMaskParser(); + + protected override get _datePartDeltas(): DatePartDeltas { + return { + date: 1, + month: 1, + year: 1, + }; + } + + // #endregion + + // #region Public attributes and properties + + /* @tsTwoWayProperty(true, "igcChange", "detail", false, true) */ + /** + * The value of the date range input. + * @attr + */ + @property({ attribute: false }) + public set value(value: DateRangeValue | null) { + this._formValue.setValueAndFormState(value); + this._updateMaskDisplay(); + } + + public get value(): DateRangeValue | null { + return this._formValue.value; + } + + // #endregion + + // #region Lifecycle Hooks + + public override connectedCallback(): void { + super.connectedCallback(); + this._initializeDefaultMask(); + this._updateMaskDisplay(); + } + + // #endregion + + // #region Event Handlers Overrides + + protected override async _handleFocus(): Promise { + this._focused = true; + + if (this.readOnly) { + return; + } + + this._oldValue = this.value; + + if (!this.value || (!this.value.start && !this.value.end)) { + this._maskedValue = this._parser.emptyMask; + await this.updateComplete; + this.select(); + } else if (this.displayFormat !== this.inputFormat) { + this._updateMaskDisplay(); + } + } + + protected override _handleBlur(): void { + const isSameValue = equal(this._oldValue, this.value); + + this._focused = false; + + if (!(this._isMaskComplete() || this._isEmptyMask)) { + const parsed = this._parser.parseDateRange(this._maskedValue); + + if (parsed && (parsed.start || parsed.end)) { + this.value = parsed; + } else { + this.value = null; + this._maskedValue = ''; + } + } else { + this._updateMaskDisplay(); + } + + if (!(this.readOnly || isSameValue)) { + this.emitEvent('igcChange', { detail: this.value }); + } + + super._handleBlur(); + } + + // #endregion + + // #region Keybindings overrides + + protected override _setCurrentDateTime(): void { + const today = CalendarDay.today.native; + this.value = { start: today, end: today }; + this._emitInputEvent(); + } + + protected override _calculatePartNavigationPosition( + inputValue: string, + direction: number + ): number { + const cursorPos = this._maskSelection.start; + const rangeParts = this._parser.rangeParts; + + const currentPart = rangeParts.find( + (p) => + p.type !== DateParts.Literal && + cursorPos >= p.start && + cursorPos <= p.end + ); + + const isStartOrEndPart = + currentPart && + (currentPart.position === DateRangePosition.Start || + currentPart.position === DateRangePosition.End); + + if (direction === 0) { + // Backward: if inside a start/end part, move to its start; else, move to previous part's start + if (isStartOrEndPart && cursorPos !== currentPart.start) { + return currentPart.start; + } + const prevPart = [...rangeParts] + .reverse() + .find((p) => p.type !== DateParts.Literal && p.end < cursorPos); + return prevPart?.start ?? 0; + } + + // Forward: if inside a start/end part, move to its end; else, move to next part's end + if (isStartOrEndPart && cursorPos !== currentPart.end) { + return currentPart.end; + } + const nextPart = rangeParts.find( + (p) => p.type !== DateParts.Literal && p.start > cursorPos + ); + return nextPart?.end ?? inputValue.length; + } + + // #endregion + + // #region Internal API Overrides + + protected override _emitInputEvent(): void { + this._setTouchedState(); + this.emitEvent('igcInput', { detail: this._maskedValue }); + } + + protected override _updateMaskDisplay(): void { + if (this._focused) { + // Only reset mask from value when value is non-null (i.e. after spinning or programmatic set). + // When value is null the user is mid-typing — leave _maskedValue unchanged. + if (this.value?.start || this.value?.end) { + this._maskedValue = this._buildMaskedValue(); + } else if (!this._maskedValue) { + this._maskedValue = this._parser.emptyMask; + } + return; + } + + if (!this.value?.start && !this.value?.end) { + this._maskedValue = ''; + return; + } + + const { start, end } = this.value; + const startStr = start + ? formatDisplayDate(start, this.locale, this.displayFormat) + : ''; + const endStr = end + ? formatDisplayDate(end, this.locale, this.displayFormat) + : ''; + this._maskedValue = + startStr && endStr + ? `${startStr}${this._parser.separator}${endStr}` + : startStr || endStr; + } + + protected override _performStep( + datePart: DateRangePart | undefined, + delta: number | undefined, + isDecrement: boolean + ): void { + // If no value exists, set to today's date first + if (!this.value?.start && !this.value?.end) { + const today = CalendarDay.today.native; + this.value = { start: today, end: today }; + const { start, end } = this._inputSelection; + this.updateComplete.then(() => + this._input?.setSelectionRange(start, end) + ); + return; + } + + super._performStep(datePart, delta, isDecrement); + } + + protected override _calculateSpunValue( + datePart: DateRangePart, + delta: number | undefined, + isDecrement: boolean + ): DateRangeValue { + const part = this._parser.getPartByTypeAndPosition( + datePart.part as DatePartType, + datePart.position + ); + + const today = CalendarDay.today.native; + const defaultValue = { start: today, end: today }; + + if (!part) { + return this.value || defaultValue; + } + + const effectiveDelta = + delta ?? this._datePartDeltas[datePart.part as keyof DatePartDeltas] ?? 1; + const spinAmount = effectiveDelta * (isDecrement ? -1 : 1); + + // For AM/PM spinning, extract the current AM/PM value from the mask + const amPmValue = + part.type === DatePartType.AmPm + ? this._maskedValue.substring(part.start, part.end) + : undefined; + + return this._parser.spinDateRangePart( + part, + spinAmount, + this.value, + this.spinLoop, + amPmValue + ); + } + + /** + * Gets the date range part at the current cursor position. + * If the cursor is at a literal, finds the nearest non-literal part. + * Returns undefined if no valid part is found. + */ + protected override _getDatePartAtCursor(): DateRangePart | undefined { + const cursorPos = this._inputSelection.start; + let part = this._parser.getDateRangePartForCursor(cursorPos); + + // If cursor is at a literal, find the nearest non-literal part + if (part?.type === DatePartType.Literal) { + const nextPart = this._parser.rangeParts.find( + (p) => p.start >= cursorPos && p.type !== DatePartType.Literal + ); + if (nextPart) { + part = nextPart; + } else { + part = this._parser.rangeParts.findLast( + (p) => p.end <= cursorPos && p.type !== DatePartType.Literal + ); + } + } + + if (part && part.type !== DatePartType.Literal) { + return { + part: part.type as DatePart, + position: part.position, + }; + } + + return undefined; + } + + /** + * Gets the default date range part to target when the input is not focused. + * Returns the first date part at the start position. + */ + protected override _getDefaultDatePart(): DateRangePart | undefined { + const firstPart = this._parser.getFirstDatePartForPosition( + DateRangePosition.Start + ); + if (firstPart) { + return { + part: firstPart.type as DatePart, + position: DateRangePosition.Start, + }; + } + + return undefined; + } + + protected override _buildMaskedValue(): string { + return this._parser.formatDateRange(this.value); + } + + protected override _applyMask(string: string): void { + const previous = this._parser.mask; + + this._parser.mask = string; + this._defaultMask = string; + this._parser.prompt = this.prompt; + + // Update placeholder if not set or if it matches the previous format + if (!this.placeholder || previous === this.placeholder) { + this.placeholder = this._parser.mask; + } + } + + protected override _updateValueFromMask(): void { + if (!this._isMaskComplete()) { + this.value = null; + return; + } + + const parsed = this._parser.parseDateRange(this._maskedValue); + this.value = parsed?.start || parsed?.end ? parsed : null; + } + + // #region Public API Overrides + + public override hasDateParts(): boolean { + return this._parser.rangeParts.some( + (p) => + p.type === DatePartType.Date || + p.type === DatePartType.Month || + p.type === DatePartType.Year + ); + } + + public override hasTimeParts(): boolean { + return this._parser.rangeParts.some( + (p) => + p.type === DatePartType.Hours || + p.type === DatePartType.Minutes || + p.type === DatePartType.Seconds + ); + } + + // #endregion +} + +declare global { + interface HTMLElementTagNameMap { + 'igc-date-range-input': IgcDateRangeInputComponent; + } +} diff --git a/src/components/date-range-picker/date-range-mask-parser.spec.ts b/src/components/date-range-picker/date-range-mask-parser.spec.ts new file mode 100644 index 000000000..59f6ce7ab --- /dev/null +++ b/src/components/date-range-picker/date-range-mask-parser.spec.ts @@ -0,0 +1,268 @@ +import { expect } from '@open-wc/testing'; + +import { CalendarDay } from '../calendar/model.js'; +import { DatePartType } from '../date-time-input/date-part.js'; +import { + DateRangeMaskParser, + DateRangePosition, +} from './date-range-mask-parser.js'; + +describe('DateRangeMaskParser', () => { + describe('Initialization', () => { + it('creates parser with default format and separator', () => { + const parser = new DateRangeMaskParser(); + + expect(parser.mask).to.equal('MM/dd/yyyy - MM/dd/yyyy'); + expect(parser.separator).to.equal(' - '); + expect(parser.emptyMask).to.equal('__/__/____ - __/__/____'); + }); + + it('creates parser with custom format', () => { + const parser = new DateRangeMaskParser({ format: 'MM/dd/yyyy' }); + + expect(parser.mask).to.equal('MM/dd/yyyy - MM/dd/yyyy'); + expect(parser.emptyMask).to.equal('__/__/____ - __/__/____'); + }); + + it('creates parser with custom separator', () => { + const parser = new DateRangeMaskParser({ + format: 'MM/dd/yyyy', + separator: ' to ', + }); + + expect(parser.separator).to.equal(' to '); + expect(parser.mask).to.equal('MM/dd/yyyy to MM/dd/yyyy'); + }); + + it('creates parser with custom prompt character', () => { + const parser = new DateRangeMaskParser({ + format: 'MM/dd/yyyy', + promptCharacter: '-', + }); + + expect(parser.emptyMask).to.equal('--/--/---- - --/--/----'); + expect(parser.mask).to.equal('MM/dd/yyyy - MM/dd/yyyy'); + }); + + it('builds range parts with position information', () => { + const parser = new DateRangeMaskParser({ format: 'MM/dd/yyyy' }); + const parts = parser.rangeParts; + + const startParts = parts.filter( + (p) => p.position === DateRangePosition.Start + ); + const endParts = parts.filter( + (p) => p.position === DateRangePosition.End + ); + + expect(startParts.length).to.be.greaterThan(0); + expect(endParts.length).to.be.greaterThan(0); + expect(endParts[0].start).to.be.greaterThan(startParts[0].end); + }); + }); + + describe('Date Range Parsing', () => { + it('parses complete date range', () => { + const parser = new DateRangeMaskParser({ format: 'MM/dd/yyyy' }); + const range = parser.parseDateRange('12/25/2026 - 12/31/2026'); + + expect(range).to.not.be.null; + expect(range!.start).to.not.be.null; + expect(range!.end).to.not.be.null; + expect(range!.start!.getMonth()).to.equal(11); + expect(range!.start!.getDate()).to.equal(25); + expect(range!.end!.getDate()).to.equal(31); + }); + + it('parses range with only start date', () => { + const parser = new DateRangeMaskParser({ format: 'MM/dd/yyyy' }); + const range = parser.parseDateRange('12/25/2026 - __/__/____'); + + expect(range).to.not.be.null; + expect(range!.start).to.not.be.null; + expect(range!.end!.getMonth()).to.equal(0); + expect(range!.end!.getDate()).to.equal(1); + expect(range!.end!.getFullYear()).to.equal(2000); + }); + + it('parses range with only end date', () => { + const parser = new DateRangeMaskParser({ format: 'MM/dd/yyyy' }); + const range = parser.parseDateRange('__/__/____ - 12/31/2026'); + + expect(range).to.not.be.null; + expect(range!.start!.getMonth()).to.equal(0); + expect(range!.start!.getDate()).to.equal(1); + expect(range!.start!.getFullYear()).to.equal(2000); + expect(range!.end).to.not.be.null; + }); + + it('returns null for empty mask', () => { + const parser = new DateRangeMaskParser({ format: 'MM/dd/yyyy' }); + const range = parser.parseDateRange(parser.emptyMask); + + expect(range).to.be.null; + }); + + it('returns null for empty string', () => { + const parser = new DateRangeMaskParser({ format: 'MM/dd/yyyy' }); + expect(parser.parseDateRange('')).to.be.null; + }); + }); + + describe('Date Range Formatting', () => { + it('formats complete date range', () => { + const parser = new DateRangeMaskParser({ format: 'MM/dd/yyyy' }); + const range = { + start: new Date(2026, 11, 25), + end: new Date(2026, 11, 31), + }; + + const formatted = parser.formatDateRange(range); + expect(formatted).to.equal('12/25/2026 - 12/31/2026'); + }); + + it('formats range with only start date', () => { + const parser = new DateRangeMaskParser({ format: 'MM/dd/yyyy' }); + const range = { start: new Date(2026, 11, 25), end: null }; + + const formatted = parser.formatDateRange(range); + expect(formatted).to.equal('12/25/2026 - __/__/____'); + }); + + it('formats range with only end date', () => { + const parser = new DateRangeMaskParser({ format: 'MM/dd/yyyy' }); + const range = { start: null, end: new Date(2026, 11, 31) }; + + const formatted = parser.formatDateRange(range); + expect(formatted).to.equal('__/__/____ - 12/31/2026'); + }); + + it('formats null range', () => { + const parser = new DateRangeMaskParser({ format: 'MM/dd/yyyy' }); + const formatted = parser.formatDateRange(null); + + expect(formatted).to.equal('__/__/____ - __/__/____'); + }); + }); + + describe('Part Queries', () => { + it('gets date range part at position', () => { + const parser = new DateRangeMaskParser({ format: 'MM/dd/yyyy' }); + + const startMonthPart = parser.getDateRangePartAtPosition(0); + expect(startMonthPart?.type).to.equal(DatePartType.Month); + expect(startMonthPart?.position).to.equal(DateRangePosition.Start); + + const endMonthPart = parser.getDateRangePartAtPosition(13); + expect(endMonthPart?.type).to.equal(DatePartType.Month); + expect(endMonthPart?.position).to.equal(DateRangePosition.End); + }); + + it('gets part for cursor position', () => { + const parser = new DateRangeMaskParser({ format: 'MM/dd/yyyy' }); + + const part = parser.getDateRangePartForCursor(2); + expect(part?.type).to.equal(DatePartType.Month); + }); + + it('gets part by type and position', () => { + const parser = new DateRangeMaskParser({ format: 'MM/dd/yyyy' }); + + const startYear = parser.getPartByTypeAndPosition( + DatePartType.Year, + DateRangePosition.Start + ); + expect(startYear).to.not.be.undefined; + expect(startYear!.position).to.equal(DateRangePosition.Start); + + const endYear = parser.getPartByTypeAndPosition( + DatePartType.Year, + DateRangePosition.End + ); + expect(endYear).to.not.be.undefined; + expect(endYear!.position).to.equal(DateRangePosition.End); + }); + + it('gets first date part for position', () => { + const parser = new DateRangeMaskParser({ format: 'MM/dd/yyyy' }); + + const startFirst = parser.getFirstDatePartForPosition( + DateRangePosition.Start + ); + expect(startFirst?.type).to.equal(DatePartType.Month); + + const endFirst = parser.getFirstDatePartForPosition( + DateRangePosition.End + ); + expect(endFirst?.position).to.equal(DateRangePosition.End); + }); + }); + + describe('Spinning', () => { + it('spins start date month', () => { + const parser = new DateRangeMaskParser({ format: 'MM/dd/yyyy' }); + const monthPart = parser.getPartByTypeAndPosition( + DatePartType.Month, + DateRangePosition.Start + )!; + const range = { + start: new Date(2026, 11, 25), + end: new Date(2026, 11, 31), + }; + + const newRange = parser.spinDateRangePart(monthPart, 1, range, true); + + expect(newRange.start!.getMonth()).to.equal(0); + expect(newRange.end).to.equal(range.end); + }); + + it('spins end date day', () => { + const parser = new DateRangeMaskParser({ format: 'MM/dd/yyyy' }); + const dayPart = parser.getPartByTypeAndPosition( + DatePartType.Date, + DateRangePosition.End + )!; + const range = { + start: new Date(2026, 11, 25), + end: new Date(2026, 11, 31), + }; + + const newRange = parser.spinDateRangePart(dayPart, -1, range, false); + + expect(newRange.end!.getDate()).to.equal(30); + expect(newRange.start).to.equal(range.start); + }); + + it('creates date when spinning null value', () => { + const parser = new DateRangeMaskParser({ format: 'MM/dd/yyyy' }); + const monthPart = parser.getPartByTypeAndPosition( + DatePartType.Month, + DateRangePosition.Start + )!; + + const newRange = parser.spinDateRangePart(monthPart, 1, null, true); + + const today = CalendarDay.today.native; + expect(newRange.start).to.not.be.null; + expect(newRange.start!.getFullYear()).to.equal(today.getFullYear()); + expect(newRange.start!.getMonth()).to.equal( + CalendarDay.today.add('month', 1).native.getMonth() + ); + expect(newRange.start!.getDate()).to.equal(today.getDate()); + expect(newRange.end).to.be.null; + }); + }); + + describe('Mask Updates', () => { + it('updates mask and rebuilds parts', () => { + const parser = new DateRangeMaskParser({ format: 'MM/dd/yyyy' }); + const initialParts = parser.rangeParts.length; + + parser.mask = 'M/d/yy'; + + expect(parser.mask).to.equal('M/d/yy - M/d/yy'); + expect(parser.rangeParts.length).to.equal(initialParts); + expect(parser.emptyMask).to.equal('_/_/__ - _/_/__'); + }); + }); +}); diff --git a/src/components/date-range-picker/date-range-mask-parser.ts b/src/components/date-range-picker/date-range-mask-parser.ts new file mode 100644 index 000000000..5c20aadab --- /dev/null +++ b/src/components/date-range-picker/date-range-mask-parser.ts @@ -0,0 +1,327 @@ +import { + type DatePart, + DatePartType, + type IDatePart, + type SpinOptions, +} from '../date-time-input/date-part.js'; +import { + type DateTimeMaskOptions, + DateTimeMaskParser, + DEFAULT_DATETIME_FORMAT, +} from '../date-time-input/datetime-mask-parser.js'; +import { MaskParser } from '../mask-input/mask-parser.js'; +import type { DateRangeValue } from '../types.js'; + +//#region Types and Enums + +export interface DateRangePart { + part: DatePart; + position: DateRangePosition; +} + +/** Position of a date part within the date range */ +export enum DateRangePosition { + Start = 'start', + End = 'end', + Separator = 'separator', +} + +/** Extended date part with range position information */ +export interface IDateRangePart extends IDatePart { + position: DateRangePosition; +} + +/** Options for the DateRangeMaskParser */ +export interface DateRangeMaskOptions extends DateTimeMaskOptions { + /** Separator (defaults to ' - ') */ + separator?: string; +} + +//#endregion + +//#region Constants + +/** Default separator between start and end dates */ +const DEFAULT_SEPARATOR = ' - '; + +//#endregion + +/** + * A specialized mask parser for date range input fields. + * Uses composition with two DateTimeMaskParser instances to handle start and end dates. + * + * Accepts a single date format (e.g., 'MM/dd/yyyy') which creates two parsers + * internally, one for the start date and one for the end date. + * + * @example + * ```ts + * const parser = new DateRangeMaskParser({ format: 'MM/dd/yyyy' }); + * parser.parseDateRange('12/25/2023 - 12/31/2023'); // Returns DateRangeValue + * parser.formatDateRange({ start: date1, end: date2 }); // Returns formatted string + * ``` + */ +export class DateRangeMaskParser extends MaskParser { + private _startParser: DateTimeMaskParser; + private _endParser: DateTimeMaskParser; + + /** Cached date range parts with position information */ + private _rangeParts: IDateRangePart[] = []; + + /** The separator between start and end dates */ + private _separator: string; + + /** Start position of the separator in the mask */ + private _separatorStart: number; + + /** End position of the separator in the mask */ + private _separatorEnd: number; + + /** + * Gets the parsed date range parts with position information. + */ + public get rangeParts(): ReadonlyArray { + return this._rangeParts; + } + + /** + * Gets the separator string used between start and end dates. + */ + public get separator(): string { + return this._separator; + } + + constructor(options?: DateRangeMaskOptions) { + const format = options?.format || DEFAULT_DATETIME_FORMAT; + const separator = options?.separator || DEFAULT_SEPARATOR; + const promptCharacter = options?.promptCharacter; + + // Build the combined range format for the parent MaskParser + const rangeFormat = `${format}${separator}${format}`; + + super( + options?.promptCharacter + ? { format: rangeFormat, promptCharacter: options.promptCharacter } + : { format: rangeFormat } + ); + + // Create two parsers for start and end dates + this._startParser = new DateTimeMaskParser({ format, promptCharacter }); + this._endParser = new DateTimeMaskParser({ format, promptCharacter }); + this._separator = separator; + + this._separatorStart = this._startParser.mask.length; + this._separatorEnd = this._separatorStart + separator.length; + + this._buildRangeParts(); + } + + //#region Mask Format Conversion + + /** + * Overrides base class to convert date format to mask format + * before parsing literals. This ensures date format characters + * (M, d, y, etc.) are properly converted to mask characters (0, L). + */ + protected override _parseMaskLiterals(): void { + // Convert the range format to mask format + // e.g., "M/d/yyyy - M/d/yyyy" → "0/0/0000 - 0/0/0000" + const dateFormat = this._options.format; + const maskFormat = + DateTimeMaskParser.convertDateFormatToMaskFormat(dateFormat); + + // Temporarily set the converted format for base class parsing + const originalFormat = this._options.format; + this._options.format = maskFormat; + + super._parseMaskLiterals(); + + // Restore the original date format + this._options.format = originalFormat; + } + + //#endregion + + /** + * Builds the range parts array by combining parts from start and end parsers + * and adding position information. + */ + private _buildRangeParts(): void { + const startParts = this._startParser.dateParts.map( + (part): IDateRangePart => ({ + ...part, + position: DateRangePosition.Start, + getValue: part.getValue.bind(part), + validate: part.validate.bind(part), + spin: part.spin.bind(part), + }) + ); + + const endParts = this._endParser.dateParts.map( + (part): IDateRangePart => ({ + ...part, + // Adjust positions for end date (offset by separator) + start: part.start + this._separatorEnd, + end: part.end + this._separatorEnd, + position: DateRangePosition.End, + getValue: part.getValue.bind(part), + validate: part.validate.bind(part), + spin: part.spin.bind(part), + }) + ); + + this._rangeParts = [...startParts, ...endParts]; + } + + /** + * Sets a new date format and updates both parsers. + */ + public override set mask(value: string) { + this._startParser.mask = value; + this._endParser.mask = value; + + this._separatorStart = this._startParser.mask.length; + this._separatorEnd = this._separatorStart + this._separator.length; + + const rangeFormat = `${value}${this._separator}${value}`; + super.mask = rangeFormat; + + this._buildRangeParts(); + } + + public override get mask(): string { + return super.mask; + } + + //#region Date Range Parsing + + /** + * Parses a masked string into a DateRangeValue using the two internal parsers. + * Returns null if the string cannot be parsed. + */ + public parseDateRange(masked: string): DateRangeValue | null { + if (!masked || masked === this.emptyMask) { + return null; + } + + const startString = masked.substring(0, this._separatorStart); + const endString = masked.substring(this._separatorEnd); + + const start = this._startParser.parseDate(startString); + const end = this._endParser.parseDate(endString); + + return { start, end }; + } + + //#endregion + + //#region Date Range Formatting + + /** + * Formats a DateRangeValue into a masked string using the two internal parsers. + */ + public formatDateRange(range: DateRangeValue | null): string { + const startString = range?.start + ? this._startParser.formatDate(range.start) + : this._startParser.emptyMask; + + const endString = range?.end + ? this._endParser.formatDate(range.end) + : this._endParser.emptyMask; + + return startString + this._separator + endString; + } + + //#endregion + + //#region Part Queries + + /** + * Gets the date range part at a cursor position. + * Uses exclusive end for precise character targeting. + */ + public getDateRangePartAtPosition( + position: number + ): IDateRangePart | undefined { + return this._rangeParts.find( + (part) => position >= part.start && position < part.end + ); + } + + /** + * Gets the date range part for cursor (inclusive end). + * Handles the edge case where cursor is at the end of the last part. + */ + public getDateRangePartForCursor( + position: number + ): IDateRangePart | undefined { + return this._rangeParts.find( + (part) => position >= part.start && position <= part.end + ); + } + + /** + * Gets a specific part type for a position. + */ + public getPartByTypeAndPosition( + type: DatePartType, + position: DateRangePosition + ): IDateRangePart | undefined { + return this._rangeParts.find( + (p) => p.type === type && p.position === position + ); + } + + /** + * Gets the first non-literal date part for a position. + */ + public getFirstDatePartForPosition( + position: DateRangePosition + ): IDateRangePart | undefined { + return this._rangeParts.find( + (p) => p.position === position && p.type !== DatePartType.Literal + ); + } + + //#endregion + + //#region Spinning Support + + /** + * Spins a date part within the range (for stepUp/stepDown functionality). + * Delegates to the underlying date part's spin method. + */ + public spinDateRangePart( + part: IDateRangePart, + delta: number, + currentValue: DateRangeValue | null, + spinLoop: boolean, + amPmValue?: string + ): DateRangeValue { + const value = currentValue || { start: null, end: null }; + + const targetDate = + part.position === DateRangePosition.Start ? value.start : value.end; + + // If no date exists, create one with today's date + const dateToSpin = targetDate || new Date(); + + // Create a new date instance to spin + const newDate = new Date(dateToSpin.getTime()); + + // Spin using the part's built-in spin method + const spinOptions: SpinOptions = { + date: newDate, + spinLoop, + originalDate: dateToSpin, + amPmValue, + }; + + part.spin(delta, spinOptions); + + return part.position === DateRangePosition.Start + ? { ...value, start: newDate } + : { ...value, end: newDate }; + } + + //#endregion +} diff --git a/src/components/date-range-picker/date-range-picker-single.form.spec.ts b/src/components/date-range-picker/date-range-picker-single.form.spec.ts index 77d592d4a..2e33d6430 100644 --- a/src/components/date-range-picker/date-range-picker-single.form.spec.ts +++ b/src/components/date-range-picker/date-range-picker-single.form.spec.ts @@ -11,7 +11,7 @@ import { type ValidationContainerTestsParams, ValidityHelpers, } from '../common/validity-helpers.spec.js'; -import IgcInputComponent from '../input/input.js'; +import IgcDateRangeInputComponent from './date-range-input.js'; import IgcDateRangePickerComponent, { type DateRangeValue, } from './date-range-picker.js'; @@ -21,7 +21,7 @@ describe('Date Range Picker Single Input - Form integration', () => { before(() => defineComponents(IgcDateRangePickerComponent)); let picker: IgcDateRangePickerComponent; - let input: IgcInputComponent; + let input: IgcDateRangeInputComponent; let startKey = ''; let endKey = ''; @@ -67,8 +67,8 @@ describe('Date Range Picker Single Input - Form integration', () => { await elementUpdated(spec.element); input = picker.renderRoot.querySelector( - IgcInputComponent.tagName - ) as IgcInputComponent; + IgcDateRangeInputComponent.tagName + ) as IgcDateRangeInputComponent; checkSelectedRange(spec.element, value, false); @@ -76,7 +76,7 @@ describe('Date Range Picker Single Input - Form integration', () => { await elementUpdated(spec.element); expect(spec.element.value).to.deep.equal(initial); - expect(input.value).to.equal(''); + expect(input.value).to.deep.equal({ start: null, end: null }); }); it('should not be in invalid state on reset for a required control which previously had value', () => { @@ -84,7 +84,9 @@ describe('Date Range Picker Single Input - Form integration', () => { spec.setProperties({ required: true }); spec.assertSubmitPasses(); - const input = picker.renderRoot.querySelector(IgcInputComponent.tagName)!; + const input = picker.renderRoot.querySelector( + IgcDateRangeInputComponent.tagName + )!; expect(input.invalid).to.be.false; spec.setProperties({ value: null }); @@ -100,7 +102,7 @@ describe('Date Range Picker Single Input - Form integration', () => { spec.assertSubmitFails(); await elementUpdated(spec.element); const input = spec.element.renderRoot.querySelector( - IgcInputComponent.tagName + IgcDateRangeInputComponent.tagName )!; expect(input.invalid).to.be.true; @@ -118,10 +120,10 @@ describe('Date Range Picker Single Input - Form integration', () => { await elementUpdated(spec.element); input = spec.element.renderRoot.querySelector( - IgcInputComponent.tagName - ) as IgcInputComponent; + IgcDateRangeInputComponent.tagName + ) as IgcDateRangeInputComponent; - expect(input.value).to.equal(''); + expect(input.value).to.deep.equal({ start: null, end: null }); spec.reset(); await elementUpdated(spec.element); @@ -146,7 +148,7 @@ describe('Date Range Picker Single Input - Form integration', () => { spec.assertSubmitFails(); await elementUpdated(spec.element); const input = spec.element.renderRoot.querySelector( - IgcInputComponent.tagName + IgcDateRangeInputComponent.tagName )!; expect(input.invalid).to.be.true; @@ -162,7 +164,7 @@ describe('Date Range Picker Single Input - Form integration', () => { spec.assertSubmitPasses(); await elementUpdated(spec.element); const input = spec.element.renderRoot.querySelector( - IgcInputComponent.tagName + IgcDateRangeInputComponent.tagName )!; expect(input.invalid).to.be.false; @@ -205,7 +207,7 @@ describe('Date Range Picker Single Input - Form integration', () => { spec.assertSubmitPasses(); await elementUpdated(spec.element); const input = spec.element.renderRoot.querySelector( - IgcInputComponent.tagName + IgcDateRangeInputComponent.tagName )!; expect(input.invalid).to.be.false; @@ -246,7 +248,7 @@ describe('Date Range Picker Single Input - Form integration', () => { spec.assertSubmitPasses(); await elementUpdated(spec.element); const input = spec.element.renderRoot.querySelector( - IgcInputComponent.tagName + IgcDateRangeInputComponent.tagName )!; expect(input.invalid).to.be.false; @@ -279,7 +281,7 @@ describe('Date Range Picker Single Input - Form integration', () => { spec.assertSubmitPasses(); await elementUpdated(spec.element); const input = spec.element.renderRoot.querySelector( - IgcInputComponent.tagName + IgcDateRangeInputComponent.tagName )!; expect(input.invalid).to.be.false; @@ -323,7 +325,7 @@ describe('Date Range Picker Single Input - Form integration', () => { spec.assertSubmitFails(); await elementUpdated(spec.element); const input = spec.element.renderRoot.querySelector( - IgcInputComponent.tagName + IgcDateRangeInputComponent.tagName )!; expect(input.invalid).to.be.true; @@ -383,7 +385,7 @@ describe('Date Range Picker Single Input - Form integration', () => { spec.assertSubmitFails(); await elementUpdated(spec.element); const input = spec.element.renderRoot.querySelector( - IgcInputComponent.tagName + IgcDateRangeInputComponent.tagName )!; expect(input.invalid).to.be.true; @@ -416,8 +418,8 @@ describe('Date Range Picker Single Input - Form integration', () => { html`` ); const input = picker.renderRoot.querySelector( - IgcInputComponent.tagName - ) as IgcInputComponent; + IgcDateRangeInputComponent.tagName + ) as IgcDateRangeInputComponent; expect(picker.invalid).to.be.false; expect(input.invalid).to.be.false; diff --git a/src/components/date-range-picker/date-range-picker-single.spec.ts b/src/components/date-range-picker/date-range-picker-single.spec.ts index 8d79c7dd8..e95f701b2 100644 --- a/src/components/date-range-picker/date-range-picker-single.spec.ts +++ b/src/components/date-range-picker/date-range-picker-single.spec.ts @@ -5,27 +5,32 @@ import { html, waitUntil, } from '@open-wc/testing'; -import { spy } from 'sinon'; +import sinon, { spy } from 'sinon'; import IgcCalendarComponent from '../calendar/calendar.js'; import { CalendarDay } from '../calendar/model.js'; import { altKey, arrowDown, + arrowLeft, + arrowRight, arrowUp, + ctrlKey, escapeKey, } from '../common/controllers/key-bindings.js'; import { defineComponents } from '../common/definitions/defineComponents.js'; import { isFocused, simulateClick, + simulateInput, simulateKeyboard, } from '../common/utils.spec.js'; import type IgcDialogComponent from '../dialog/dialog.js'; -import IgcInputComponent from '../input/input.js'; +import IgcDateRangeInputComponent from './date-range-input.js'; import IgcDateRangePickerComponent from './date-range-picker.js'; import { checkSelectedRange, getIcon, + getInput, selectDates, } from './date-range-picker.utils.spec.js'; @@ -33,7 +38,8 @@ describe('Date range picker - single input', () => { before(() => defineComponents(IgcDateRangePickerComponent)); let picker: IgcDateRangePickerComponent; - let input: IgcInputComponent; + let rangeInput: IgcDateRangeInputComponent; + let input: HTMLInputElement; let calendar: IgcCalendarComponent; const clearIcon = 'input_clear'; @@ -44,11 +50,18 @@ describe('Date range picker - single input', () => { picker = await fixture( html`` ); - input = picker.renderRoot.querySelector(IgcInputComponent.tagName)!; + rangeInput = picker.renderRoot.querySelector( + IgcDateRangeInputComponent.tagName + )!; + input = rangeInput.renderRoot.querySelector('input')!; calendar = picker.renderRoot.querySelector(IgcCalendarComponent.tagName)!; }); + afterEach(() => { + sinon.restore(); + }); + describe('Rendering and initialization', () => { it('is defined', async () => { expect(picker).is.not.undefined; @@ -135,9 +148,10 @@ describe('Date range picker - single input', () => { const eventSpy = spy(picker, 'emitEvent'); // current implementation of DRP single input is not editable; // to refactor when the input is made editable - //picker.nonEditable = true; + picker.nonEditable = true; await elementUpdated(picker); + input = getInput(picker); input.focus(); simulateKeyboard(input, arrowDown); await elementUpdated(picker); @@ -150,11 +164,11 @@ describe('Date range picker - single input', () => { await picker.show(); await selectDates(today, tomorrow, calendar); - checkSelectedRange( - picker, - { start: today.native, end: tomorrow.native }, - false - ); + + input.blur(); + await elementUpdated(input); + + checkSelectedRange(picker, picker.value, false); expect(eventSpy).calledWith('igcChange'); }); @@ -169,7 +183,9 @@ describe('Date range picker - single input', () => { Object.assign(picker, propsSingle); await elementUpdated(picker); - const input = picker.renderRoot.querySelector(IgcInputComponent.tagName)!; + const input = picker.renderRoot.querySelector( + IgcDateRangeInputComponent.tagName + )!; for (const [prop, value] of Object.entries(propsSingle)) { expect((input as any)[prop], `Fail for ${prop}`).to.equal(value); } @@ -195,9 +211,7 @@ describe('Date range picker - single input', () => { picker.displayFormat = obj.format; await elementUpdated(picker); - const input = picker.renderRoot.querySelector( - IgcInputComponent.tagName - )!; + input = getInput(picker); expect(input.value).to.equal( `${obj.formattedValue} - ${obj.formattedValue}` ); @@ -212,7 +226,10 @@ describe('Date range picker - single input', () => { end: CalendarDay.from(new Date(2025, 3, 10)).native, }; await elementUpdated(picker); - const input = picker.renderRoot.querySelector(IgcInputComponent.tagName)!; + const rangeInput = picker.renderRoot.querySelector( + IgcDateRangeInputComponent.tagName + )!; + const input = rangeInput.renderRoot.querySelector('input')!; expect(input.value).to.equal('4/9/2025 - 4/10/2025'); picker.locale = 'bg'; @@ -225,7 +242,8 @@ describe('Date range picker - single input', () => { it('should set the default placeholder of the single input to the input format (like dd/MM/yyyy - dd/MM/yyyy)', async () => { picker.useTwoInputs = false; await elementUpdated(picker); - const input = picker.renderRoot.querySelector(IgcInputComponent.tagName)!; + + input = getInput(picker); expect(input.placeholder).to.equal( `${picker.inputFormat} - ${picker.inputFormat}` ); @@ -246,7 +264,10 @@ describe('Date range picker - single input', () => { }; await elementUpdated(picker); - const input = picker.renderRoot.querySelector(IgcInputComponent.tagName)!; + const rangeInput = picker.renderRoot.querySelector( + IgcDateRangeInputComponent.tagName + )!; + const input = rangeInput.renderRoot.querySelector('input')!; expect(input.value).to.equal('4/9/2025 - 4/10/2025'); picker.displayFormat = 'yyyy-MM-dd'; @@ -254,6 +275,120 @@ describe('Date range picker - single input', () => { expect(input.value).to.equal('2025-04-09 - 2025-04-10'); }); + + it('should use inputFormat for display when displayFormat is not set', async () => { + picker.useTwoInputs = false; + picker.inputFormat = 'yyyy-MM-dd'; + await elementUpdated(picker); + + input = getInput(picker); + expect(input.placeholder).to.equal('yyyy-MM-dd - yyyy-MM-dd'); + + picker.value = { + start: CalendarDay.from(new Date(2025, 3, 9)).native, + end: CalendarDay.from(new Date(2025, 3, 10)).native, + }; + await elementUpdated(picker); + + expect(input.value).to.equal('2025-04-09 - 2025-04-10'); + + input.focus(); + await elementUpdated(input); + expect(input.value).to.equal('2025-04-09 - 2025-04-10'); + + input.blur(); + await elementUpdated(input); + + expect(input.value).to.equal('2025-04-09 - 2025-04-10'); + }); + + it('should apply inputFormat to empty mask and display value when set initially', async () => { + picker = await fixture( + html`` + ); + picker.useTwoInputs = false; + await elementUpdated(picker); + + const rangeInput = picker.renderRoot.querySelector( + IgcDateRangeInputComponent.tagName + )!; + input = rangeInput.renderRoot.querySelector('input')!; + + // Placeholder uses inputFormat (the default locale format) + expect(input.placeholder).to.equal('MM/dd/yyyy - MM/dd/yyyy'); + + input.focus(); + await elementUpdated(input); + + // When focused, the mask uses inputFormat, not displayFormat + expect(input.value).to.contain('__/__/____'); + + picker.value = { + start: new Date(2025, 3, 9, 14, 30, 0), + end: new Date(2025, 3, 10, 9, 15, 0), + }; + await elementUpdated(picker); + + // When focused with a value, still uses inputFormat for editing + expect(input.value).to.equal('04/09/2025 - 04/10/2025'); + + input.blur(); + await elementUpdated(input); + + // When blurred (not focused), uses displayFormat for display + expect(input.value).to.equal('2025-04-09 02:30 PM - 2025-04-10 09:15 AM'); + }); + + it('should initialize default mask based on locale when no inputFormat is set', async () => { + picker = await fixture( + html`` + ); + picker.useTwoInputs = false; + await elementUpdated(picker); + + const rangeInput = picker.renderRoot.querySelector( + IgcDateRangeInputComponent.tagName + )!; + input = rangeInput.renderRoot.querySelector('input')!; + + expect(input.placeholder).to.equal('MM/dd/yyyy - MM/dd/yyyy'); + + picker.locale = 'de'; + await elementUpdated(picker); + + expect(input.placeholder).to.equal('dd.MM.yyyy - dd.MM.yyyy'); + }); + + it('should target the last end position date part when the input receives focus by default', async () => { + picker.useTwoInputs = false; + await elementUpdated(picker); + + input = getInput(picker); + input.focus(); + await elementUpdated(input); + + // Press arrow up without selecting a specific part + // Should increment the last end position part (year) + const initialDate = new Date(2025, 0, 15); // Jan 15, 2025 + picker.value = { start: initialDate, end: initialDate }; + await elementUpdated(picker); + + expect(input.value).to.equal('01/15/2025 - 01/15/2025'); + + // Blur and refocus to reset cursor + input.blur(); + await elementUpdated(input); + input.focus(); + await elementUpdated(input); + + // Arrow up should increment the year (last date part) + simulateKeyboard(input, arrowUp); + await elementUpdated(input); + + expect(input.value).to.equal('01/15/2025 - 01/15/2026'); + }); }); describe('Methods', () => { it('should clear the input on invoking clear()', async () => { @@ -291,6 +426,35 @@ describe('Date range picker - single input', () => { expect(input.value).to.equal('4/9/2025 - 4/10/2025'); expect(eventSpy).not.called; }); + + it('should stepUp/stepDown when input is not focused', async () => { + picker.useTwoInputs = false; + picker.value = { + start: new Date(2025, 0, 15), // Jan 15, 2025 + end: new Date(2025, 0, 16), // Jan 16, 2025 + }; + await elementUpdated(picker); + + input = getInput(picker); + expect(isFocused(input)).to.be.false; + expect(input.value).to.equal('1/15/2025 - 1/16/2025'); + + // stepUp should increment the default date part (first start position part - Month) + rangeInput.stepUp(); + await elementUpdated(rangeInput); + + expect(rangeInput.value?.start?.getMonth()).to.equal(1); + expect(rangeInput.value?.start?.getDate()).to.equal(15); + expect(input.value).to.equal('2/15/2025 - 1/16/2025'); + + // stepDown should decrement the default date part (Month) + rangeInput.stepDown(); + await elementUpdated(rangeInput); + + expect(rangeInput.value?.start?.getMonth()).to.equal(0); + expect(rangeInput.value?.start?.getDate()).to.equal(15); + expect(input.value).to.equal('1/15/2025 - 1/16/2025'); + }); }); describe('Interactions', () => { describe('Selection via the calendar', () => { @@ -494,7 +658,7 @@ describe('Date range picker - single input', () => { dialog = picker.renderRoot.querySelector('igc-dialog'); expect(dialog?.hasAttribute('open')).to.equal(false); - input = picker.renderRoot.querySelector(IgcInputComponent.tagName)!; + input = getInput(picker); calendar = picker.renderRoot.querySelector( IgcCalendarComponent.tagName )!; @@ -540,6 +704,9 @@ describe('Date range picker - single input', () => { dialog = picker.renderRoot.querySelector('igc-dialog'); expect(dialog?.hasAttribute('open')).to.equal(false); + input.blur(); + await elementUpdated(input); + checkSelectedRange( picker, { @@ -553,6 +720,9 @@ describe('Date range picker - single input', () => { describe('Interactions with the inputs and the open and clear buttons', () => { it('should not open the picker when clicking the input in dropdown mode', async () => { + const input = picker.renderRoot.querySelector( + IgcDateRangeInputComponent.tagName + )!; simulateClick(input); await elementUpdated(picker); @@ -563,9 +733,13 @@ describe('Date range picker - single input', () => { picker.mode = 'dialog'; await elementUpdated(picker); - simulateClick(input.renderRoot.querySelector('input')!); + const rangeInput = picker.renderRoot.querySelector( + IgcDateRangeInputComponent.tagName + )!; + const input = rangeInput.renderRoot.querySelector('input')!; + input.focus(); + simulateClick(input); await elementUpdated(picker); - expect(picker.open).to.be.true; }); @@ -575,30 +749,308 @@ describe('Date range picker - single input', () => { picker.value = { start: today.native, end: tomorrow.native }; await elementUpdated(picker); - const input = picker.renderRoot!.querySelector( - IgcInputComponent.tagName - )!; + input = getInput(picker); input.focus(); await elementUpdated(input); + simulateClick(getIcon(picker, clearIcon)); await elementUpdated(picker); + input.blur(); await elementUpdated(input); expect(isFocused(input)).to.be.false; expect(eventSpy).to.be.calledWith('igcChange', { - detail: null, + detail: { start: null, end: null }, }); expect(picker.open).to.be.false; - expect(picker.value).to.deep.equal(null); + expect(picker.value).to.deep.equal({ start: null, end: null }); expect(input.value).to.equal(''); }); + it('should support date-range typing for single input', async () => { + const eventSpy = spy(picker, 'emitEvent'); + picker.useTwoInputs = false; + + await elementUpdated(picker); + + const expectedStart = CalendarDay.today.set({ + year: 2025, + month: 3, + date: 22, + }); + + const expectedEnd = expectedStart.add('day', 1); + input = getInput(picker); + input.focus(); + input.setSelectionRange(0, 1); + await elementUpdated(picker); + + let value = '04/22/2025 - 04/23/2025'; + simulateInput(input as unknown as HTMLInputElement, { + value, + inputType: 'insertText', + }); + input.setSelectionRange(value.length - 1, value.length); + await elementUpdated(picker); + + expect(eventSpy.lastCall).calledWith('igcInput', { + detail: { start: expectedStart.native, end: expectedEnd.native }, + }); + + eventSpy.resetHistory(); + picker.clear(); + await elementUpdated(picker); + + input.focus(); + input.setSelectionRange(0, 1); + await elementUpdated(picker); + + value = '04/22/202504/23/2025'; //not typing the separator characters + simulateInput(input as unknown as HTMLInputElement, { + value, + inputType: 'insertText', + }); + + input.setSelectionRange(value.length - 1, value.length); + await elementUpdated(picker); + + expect(eventSpy.lastCall).calledWith('igcInput', { + detail: { start: expectedStart.native, end: expectedEnd.native }, + }); + + eventSpy.resetHistory(); + input.blur(); + await elementUpdated(picker); + + expect(eventSpy).calledWith('igcChange', { + detail: { start: expectedStart.native, end: expectedEnd.native }, + }); + }); + + it('should in/decrement the different date parts with arrow up/down', async () => { + const expectedStart = CalendarDay.today.set({ + year: 2025, + month: 3, + date: 22, + }); + + const expectedEnd = expectedStart.add('day', 1); + const value = { start: expectedStart.native, end: expectedEnd.native }; + picker.useTwoInputs = false; + picker.value = value; + await elementUpdated(picker); + + input = getInput(picker); + input.focus(); + await elementUpdated(input); + + input.setSelectionRange(2, 2); + await elementUpdated(input); + await elementUpdated(picker); + + expect(isFocused(input)).to.be.true; + //Move selection to the end of 'day' part. + simulateKeyboard(input, [ctrlKey, arrowRight]); + await elementUpdated(rangeInput); + await elementUpdated(input); + + expect(input.selectionStart).to.equal(5); + expect(input.selectionEnd).to.equal(5); + + simulateKeyboard(input, arrowUp); + await elementUpdated(picker); + await elementUpdated(input); + + expect(input.value).to.equal('04/23/2025 - 04/23/2025'); + + //Move selection to the end of 'year' part. + simulateKeyboard(input, [ctrlKey, arrowRight]); + await elementUpdated(picker); + await elementUpdated(input); + + expect(input.selectionStart).to.equal(10); + expect(input.selectionEnd).to.equal(10); + + simulateKeyboard(input, arrowUp); + await elementUpdated(picker); + await elementUpdated(input); + expect(input.value).to.equal('04/23/2026 - 04/23/2025'); + + //Move selection to the end of 'month' part of the end date. + // skips the separator parts when navigating (right direction) + simulateKeyboard(input, [ctrlKey, arrowRight]); + await elementUpdated(input); + + expect(input.selectionStart).to.equal(15); + expect(input.selectionEnd).to.equal(15); + + simulateKeyboard(input, arrowUp); + await elementUpdated(picker); + + expect(input.value).to.equal('04/23/2026 - 05/23/2025'); + + // skips the separator parts when navigating (left direction) + input.setSelectionRange(13, 13); // set selection to the start of the month part of the end date + + simulateKeyboard(input, [ctrlKey, arrowLeft]); + await elementUpdated(rangeInput); + await elementUpdated(input); + + expect(input.selectionStart).to.equal(6); + expect(input.selectionEnd).to.equal(6); + + simulateKeyboard(input, arrowUp); + await elementUpdated(picker); + + expect(input.value).to.equal('04/23/2027 - 05/23/2025'); + }); + + it('should set the range to the current date (start-end) if no value and arrow up/down pressed', async () => { + picker.useTwoInputs = false; + picker.value = null; + await elementUpdated(picker); + + input = getInput(picker); + input.focus(); + input.setSelectionRange(0, 1); + + expect(isFocused(input)).to.be.true; + + simulateKeyboard(input, arrowUp); + await elementUpdated(picker); + input.blur(); + await elementUpdated(input); + + checkSelectedRange( + picker, + { start: today.native, end: today.native }, + false + ); + }); + + it('should set the range to current date with Ctrl+; keyboard combination', async () => { + const eventSpy = spy(picker, 'emitEvent'); + picker.useTwoInputs = false; + picker.value = { + start: new Date(2025, 0, 1), + end: new Date(2025, 0, 2), + }; + await elementUpdated(picker); + + input = getInput(picker); + input.focus(); + await elementUpdated(input); + + expect(input.value).to.equal('01/01/2025 - 01/02/2025'); + + simulateKeyboard(input, [ctrlKey, ';']); + await elementUpdated(picker); + + expect(eventSpy).calledWith('igcInput', { + detail: { start: today.native, end: today.native }, + }); + + input.blur(); + await elementUpdated(input); + + checkSelectedRange( + picker, + { start: today.native, end: today.native }, + false + ); + }); + + it('should spin AM/PM with arrow keys', async () => { + const startDate = new Date(2025, 3, 14, 9, 30, 0); // 9:30 AM + const endDate = new Date(2025, 3, 15, 10, 45, 0); // 10:45 AM + + picker.useTwoInputs = false; + picker.inputFormat = 'MM/dd/yyyy hh:mm tt'; + picker.value = { start: startDate, end: endDate }; + await elementUpdated(picker); + + input = getInput(picker); + input.focus(); + await elementUpdated(input); + + // hh format uses leading zeros for hours (09, 10), tt is AM/PM + expect(input.value).to.contain('09:30 AM'); + expect(input.value).to.contain('10:45 AM'); + + const amIndex = input.value.indexOf('AM'); + input.focus(); + input.setSelectionRange(amIndex, amIndex); + await elementUpdated(picker); + await elementUpdated(input); + + simulateKeyboard(input, arrowUp); + await elementUpdated(picker); + await elementUpdated(input); + + expect(input.value).to.contain('09:30 PM'); + expect(input.value).to.contain('10:45 AM'); + }); + + it('should delete the value on pressing enter (single input)', async () => { + picker.useTwoInputs = false; + picker.value = null; + await elementUpdated(picker); + + input = getInput(picker); + input.focus(); + input.setSelectionRange(0, input.value.length); + + expect(isFocused(input)).to.be.true; + + simulateInput(input as unknown as HTMLInputElement, { + inputType: 'deleteContentBackward', + }); + await elementUpdated(picker); + + input.blur(); + await elementUpdated(input); + await elementUpdated(picker); + + expect(input.value).to.equal(''); + expect(picker.value).to.deep.equal(null); + }); + + it('should fill in missing date values (single input)', async () => { + const expectedStart = CalendarDay.today.set({ + year: 2025, + month: 3, + date: 22, + }); + + const expectedEnd = expectedStart.add('day', 1); + picker.useTwoInputs = false; + picker.value = { start: expectedStart.native, end: expectedEnd.native }; + + await elementUpdated(picker); + + input = getInput(picker); + input.focus(); + + // select start date + input.setSelectionRange(0, 10); + expect(isFocused(input)).to.be.true; + + // delete the year value + simulateKeyboard(input, 'Backspace'); + simulateInput(input as unknown as HTMLInputElement, { + inputType: 'deleteContentBackward', + }); + await elementUpdated(picker); + + input.blur(); + + await elementUpdated(picker); + expect(input.value).to.equal('1/1/2000 - 4/23/2025'); + }); it('should toggle the calendar with keyboard combinations and keep focus', async () => { const eventSpy = spy(picker, 'emitEvent'); - const input = picker.renderRoot!.querySelector( - IgcInputComponent.tagName - )!; + input = getInput(picker); input.focus(); expect(isFocused(input)).to.be.true; @@ -653,10 +1105,11 @@ describe('Date range picker - single input', () => { expect(eventSpy).not.called; checkSelectedRange(picker, testValue, false); }); + it('should not open the calendar on clicking the input - dropdown mode', async () => { const eventSpy = spy(picker, 'emitEvent'); const igcInput = picker.renderRoot.querySelector( - IgcInputComponent.tagName + IgcDateRangeInputComponent.tagName )!; const nativeInput = igcInput.renderRoot.querySelector('input')!; simulateClick(nativeInput); @@ -670,7 +1123,7 @@ describe('Date range picker - single input', () => { picker.mode = 'dialog'; await elementUpdated(picker); const igcInput = picker.renderRoot.querySelector( - IgcInputComponent.tagName + IgcDateRangeInputComponent.tagName )!; const nativeInput = igcInput.renderRoot.querySelector('input')!; diff --git a/src/components/date-range-picker/date-range-picker-two-inputs.form.spec.ts b/src/components/date-range-picker/date-range-picker-two-inputs.form.spec.ts index 259e56d0f..4c453d28e 100644 --- a/src/components/date-range-picker/date-range-picker-two-inputs.form.spec.ts +++ b/src/components/date-range-picker/date-range-picker-two-inputs.form.spec.ts @@ -13,7 +13,7 @@ import { ValidityHelpers, } from '../common/validity-helpers.spec.js'; import IgcDateTimeInputComponent from '../date-time-input/date-time-input.js'; -import IgcInputComponent from '../input/input.js'; +import IgcDateRangeInputComponent from './date-range-input.js'; import IgcDateRangePickerComponent, { type DateRangeValue, } from './date-range-picker.js'; @@ -24,7 +24,9 @@ import { } from './date-range-picker.utils.spec.js'; describe('Date Range Picker Two Inputs - Form integration', () => { - before(() => defineComponents(IgcDateRangePickerComponent)); + before(() => + defineComponents(IgcDateRangePickerComponent, IgcDateRangeInputComponent) + ); let picker: IgcDateRangePickerComponent; let dateTimeInputs: IgcDateTimeInputComponent[]; @@ -483,7 +485,7 @@ describe('Date Range Picker Two Inputs - Form integration', () => { await elementUpdated(spec.element); let singleInput = spec.element.renderRoot.querySelector( - IgcInputComponent.tagName + IgcDateRangeInputComponent.tagName )!; expect(singleInput.invalid).to.be.true; @@ -523,7 +525,7 @@ describe('Date Range Picker Two Inputs - Form integration', () => { await elementUpdated(spec.element); singleInput = spec.element.renderRoot.querySelector( - IgcInputComponent.tagName + IgcDateRangeInputComponent.tagName )!; expect(singleInput.invalid).to.be.true; }); diff --git a/src/components/date-range-picker/date-range-picker-two-inputs.spec.ts b/src/components/date-range-picker/date-range-picker-two-inputs.spec.ts index 5a5681b3c..d8c0b482a 100644 --- a/src/components/date-range-picker/date-range-picker-two-inputs.spec.ts +++ b/src/components/date-range-picker/date-range-picker-two-inputs.spec.ts @@ -24,6 +24,7 @@ import { } from '../common/utils.spec.js'; import IgcDateTimeInputComponent from '../date-time-input/date-time-input.js'; import type IgcDialogComponent from '../dialog/dialog.js'; +import IgcDateRangeInputComponent from './date-range-input.js'; import IgcDateRangePickerComponent from './date-range-picker.js'; import { checkSelectedRange, @@ -32,7 +33,9 @@ import { } from './date-range-picker.utils.spec.js'; describe('Date range picker - two inputs', () => { - before(() => defineComponents(IgcDateRangePickerComponent)); + before(() => + defineComponents(IgcDateRangePickerComponent, IgcDateRangeInputComponent) + ); let picker: IgcDateRangePickerComponent; let dateTimeInputs: Array; diff --git a/src/components/date-range-picker/date-range-picker.common.spec.ts b/src/components/date-range-picker/date-range-picker.common.spec.ts index 0a917f607..b66b99f39 100644 --- a/src/components/date-range-picker/date-range-picker.common.spec.ts +++ b/src/components/date-range-picker/date-range-picker.common.spec.ts @@ -21,6 +21,7 @@ import { } from '../common/utils.spec.js'; import IgcDialogComponent from '../dialog/dialog.js'; import IgcPopoverComponent from '../popover/popover.js'; +import IgcDateRangeInputComponent from './date-range-input.js'; import IgcDateRangePickerComponent, { type CustomDateRange, type DateRangeValue, @@ -33,7 +34,9 @@ import { import IgcPredefinedRangesAreaComponent from './predefined-ranges-area.js'; describe('Date range picker - common tests for single and two inputs mode', () => { - before(() => defineComponents(IgcDateRangePickerComponent)); + before(() => + defineComponents(IgcDateRangePickerComponent, IgcDateRangeInputComponent) + ); let picker: IgcDateRangePickerComponent; let calendar: IgcCalendarComponent; diff --git a/src/components/date-range-picker/date-range-picker.ts b/src/components/date-range-picker/date-range-picker.ts index 6cb018827..cfb5118bf 100644 --- a/src/components/date-range-picker/date-range-picker.ts +++ b/src/components/date-range-picker/date-range-picker.ts @@ -5,7 +5,6 @@ import { query, queryAll, queryAssignedElements, - state, } from 'lit/decorators.js'; import { cache } from 'lit/directives/cache.js'; import { ifDefined } from 'lit/directives/if-defined.js'; @@ -37,7 +36,6 @@ import { } from '../common/i18n/EN/date-range-picker.resources.js'; import { addI18nController, - formatDisplayDate, getDateTimeFormat, getDefaultDateTimeFormat, } from '../common/i18n/i18n-controller.js'; @@ -60,10 +58,11 @@ import IgcDateTimeInputComponent from '../date-time-input/date-time-input.js'; import IgcDialogComponent from '../dialog/dialog.js'; import IgcFocusTrapComponent from '../focus-trap/focus-trap.js'; import IgcIconComponent from '../icon/icon.js'; -import IgcInputComponent from '../input/input.js'; import IgcPopoverComponent from '../popover/popover.js'; import type { ContentOrientation, PickerMode } from '../types.js'; import IgcValidationContainerComponent from '../validation-container/validation-container.js'; +import IgcDateRangeInputComponent from './date-range-input.js'; +import { DateRangePosition } from './date-range-mask-parser.js'; import { styles } from './date-range-picker.base.css.js'; import IgcPredefinedRangesAreaComponent from './predefined-ranges-area.js'; import { styles as shared } from './themes/shared/date-range-picker.common.css.js'; @@ -203,9 +202,9 @@ export default class IgcDateRangePickerComponent extends FormAssociatedRequiredM public static register(): void { registerComponent( IgcDateRangePickerComponent, + IgcDateRangeInputComponent, IgcCalendarComponent, IgcDateTimeInputComponent, - IgcInputComponent, IgcFocusTrapComponent, IgcIconComponent, IgcPopoverComponent, @@ -270,14 +269,11 @@ export default class IgcDateRangePickerComponent extends FormAssociatedRequiredM return this.value?.start ?? this.value?.end ?? null; } - @state() - private _maskedRangeValue = ''; - @queryAll(IgcDateTimeInputComponent.tagName) private readonly _inputs!: IgcDateTimeInputComponent[]; - @query(IgcInputComponent.tagName) - private readonly _input!: IgcInputComponent; + @query(IgcDateRangeInputComponent.tagName) + private readonly _input!: IgcDateRangeInputComponent; @query(IgcCalendarComponent.tagName) private readonly _calendar!: IgcCalendarComponent; @@ -319,7 +315,6 @@ export default class IgcDateRangePickerComponent extends FormAssociatedRequiredM public set value(value: DateRangeValue | string | null | undefined) { this._formValue.setValueAndFormState(convertToDateRange(value)); this._setCalendarRangeValues(); - this._updateMaskedRangeValue(); } public get value(): DateRangeValue | null { @@ -467,7 +462,6 @@ export default class IgcDateRangePickerComponent extends FormAssociatedRequiredM @property({ attribute: 'display-format' }) public set displayFormat(value: string) { this._displayFormat = value; - this._updateMaskedRangeValue(); } public get displayFormat(): string { @@ -484,7 +478,6 @@ export default class IgcDateRangePickerComponent extends FormAssociatedRequiredM @property({ attribute: 'input-format' }) public set inputFormat(value: string) { this._inputFormat = value; - this._updateMaskedRangeValue(); } public get inputFormat(): string { @@ -640,13 +633,28 @@ export default class IgcDateRangePickerComponent extends FormAssociatedRequiredM protected override formResetCallback() { super.formResetCallback(); this._setCalendarRangeValues(); - this._updateMaskedRangeValue(); } // #endregion // #region Public API methods + /* blazorSuppress */ + /** @internal */ + public hasDateParts(): boolean { + return this.useTwoInputs + ? this._inputs[0].hasDateParts() + : this._input.hasDateParts(); + } + + /* blazorSuppress */ + /** @internal */ + public hasTimeParts(): boolean { + return this.useTwoInputs + ? this._inputs[0].hasTimeParts() + : this._input.hasTimeParts(); + } + /** Clears the input parts of the component of any user input */ public clear() { this._oldValue = this.value; @@ -654,6 +662,9 @@ export default class IgcDateRangePickerComponent extends FormAssociatedRequiredM if (this.useTwoInputs) { this._inputs[0]?.clear(); this._inputs[1]?.clear(); + } else { + this._input.value = null; + this._input?.clear(); } } @@ -681,13 +692,11 @@ export default class IgcDateRangePickerComponent extends FormAssociatedRequiredM this._defaultDisplayFormat = getDateFormatter().getLocaleDateTimeFormat( this.locale ); - this._updateMaskedRangeValue(); } @watch('useTwoInputs') protected async _updateDateRange() { await this._calendar?.updateComplete; - this._updateMaskedRangeValue(); this._setCalendarRangeValues(); this._delegateInputsValidity(); } @@ -735,10 +744,8 @@ export default class IgcDateRangePickerComponent extends FormAssociatedRequiredM this._hide(true); } - protected _handleInputEvent(event: CustomEvent) { + protected _handleInput(event: CustomEvent) { event.stopPropagation(); - this._setTouchedState(); - if (this.nonEditable) { event.preventDefault(); return; @@ -753,15 +760,50 @@ export default class IgcDateRangePickerComponent extends FormAssociatedRequiredM this.emitEvent('igcInput', { detail: this.value }); } - protected _handleInputChangeEvent(event: CustomEvent) { + protected _handleInputChange(event: CustomEvent) { event.stopPropagation(); - this._setTouchedState(); const input = event.target as IgcDateTimeInputComponent; const newValue = input.value ? CalendarDay.from(input.value).native : null; const updatedRange = this._getUpdatedDateRange(input, newValue); - const { start, end } = this._swapDates(updatedRange); + const { start, end } = this._swapDates(updatedRange) ?? { + start: null, + end: null, + }; + + this._setCalendarRangeValues(); + this.value = { start, end }; + this.emitEvent('igcChange', { detail: this.value }); + } + + protected async _handleDateRangeInput(event: CustomEvent) { + event.stopPropagation(); + if (this.nonEditable) { + event.preventDefault(); + return; + } + const input = event.target as IgcDateRangeInputComponent; + const newValue = input.value; + + this.value = newValue; + this._calendar.activeDate = newValue?.start; + + this.emitEvent('igcInput', { detail: this.value }); + } + + protected _handleDateRangeInputChange( + event: CustomEvent + ) { + event.stopPropagation(); + + const input = event.target as IgcDateRangeInputComponent; + const newValue = input.value!; + + const { start, end } = this._swapDates(newValue) ?? { + start: null, + end: null, + }; this._setCalendarRangeValues(); this.value = { start, end }; @@ -771,12 +813,6 @@ export default class IgcDateRangePickerComponent extends FormAssociatedRequiredM protected _handleFocusOut({ relatedTarget }: FocusEvent) { if (!this.contains(relatedTarget as Node)) { this._handleBlur(); - - const isSameValue = equal(this.value, this._oldValue); - if (!(this.useTwoInputs || this.readOnly || isSameValue)) { - this.emitEvent('igcChange', { detail: this.value }); - this._oldValue = this.value; - } } } @@ -883,24 +919,6 @@ export default class IgcDateRangePickerComponent extends FormAssociatedRequiredM createDateConstraints(this.min, this.max, this.disabledDates) ?? []; } - private _updateMaskedRangeValue() { - if (this.useTwoInputs) { - return; - } - - if (!isCompleteDateRange(this.value)) { - this._maskedRangeValue = ''; - return; - } - - const { start, end } = this.value; - const displayFormat = getDateTimeFormat(this.displayFormat); - - const startValue = formatDisplayDate(start, this.locale, displayFormat); - const endValue = formatDisplayDate(end, this.locale, displayFormat); - this._maskedRangeValue = `${startValue} - ${endValue}`; - } - private _setCalendarRangeValues() { if (!this._calendar) { return; @@ -953,7 +971,7 @@ export default class IgcDateRangePickerComponent extends FormAssociatedRequiredM // #region Rendering - private _renderClearIcon(picker: DateRangePickerInput = 'start') { + private _renderClearIcon(picker = DateRangePosition.Start) { const clearIcon = this.useTwoInputs ? `clear-icon-${picker}` : 'clear-icon'; return this._firstDefinedInRange ? html` @@ -974,7 +992,7 @@ export default class IgcDateRangePickerComponent extends FormAssociatedRequiredM : nothing; } - private _renderCalendarIcon(picker: DateRangePickerInput = 'start') { + private _renderCalendarIcon(picker = DateRangePosition.Start) { const defaultIcon = html` `; @@ -1116,7 +1134,7 @@ export default class IgcDateRangePickerComponent extends FormAssociatedRequiredM return IgcValidationContainerComponent.create(this); } - protected _renderInput(id: string, picker: DateRangePickerInput = 'start') { + protected _renderInput(id: string, picker = DateRangePosition.Start) { const readOnly = !this._isDropDown || this.readOnly || this.nonEditable; const placeholder = picker === 'start' ? this.placeholderStart : this.placeholderEnd; @@ -1125,9 +1143,13 @@ export default class IgcDateRangePickerComponent extends FormAssociatedRequiredM const value = picker === 'start' ? this.value?.start : this.value?.end; const prefixes = - picker === 'start' ? this._startPrefixes : this._endPrefixes; + picker === DateRangePosition.Start + ? this._startPrefixes + : this._endPrefixes; const suffixes = - picker === 'start' ? this._startSuffixes : this._endSuffixes; + picker === DateRangePosition.Start + ? this._startSuffixes + : this._endSuffixes; const prefix = isEmpty(prefixes) ? undefined : 'prefix'; const suffix = isEmpty(suffixes) ? undefined : 'suffix'; @@ -1148,8 +1170,8 @@ export default class IgcDateRangePickerComponent extends FormAssociatedRequiredM .max=${this._max} label=${label} ?invalid=${live(this.invalid)} - @igcChange=${this._handleInputChangeEvent} - @igcInput=${this._handleInputEvent} + @igcChange=${this._handleInputChange} + @igcInput=${this._handleInput} @click=${this._isDropDown || this.readOnly ? nothing : this._handleInputClick} @@ -1166,32 +1188,41 @@ export default class IgcDateRangePickerComponent extends FormAssociatedRequiredM private _renderInputs(idStart: string, idEnd: string) { return html`
- ${this._renderInput(idStart, 'start')} + ${this._renderInput(idStart, DateRangePosition.Start)}
${this.resourceStrings.separator}
- ${this._renderInput(idEnd, 'end')} + ${this._renderInput(idEnd, DateRangePosition.End)}
${this._renderPicker(idStart)} ${this._renderHelperText()} `; } private _renderSingleInput(id: string) { + const readOnly = !this._isDropDown || this.readOnly || this.nonEditable; + const format = + getDateTimeFormat(this.displayFormat) ?? this._defaultDisplayFormat; const prefix = isEmpty(this._prefixes) ? undefined : 'prefix'; const suffix = isEmpty(this._suffixes) ? undefined : 'suffix'; return html` - ${this._renderClearIcon()} - + ${this._renderHelperText()} ${this._renderPicker(id)} `; } @@ -1224,5 +1255,3 @@ declare global { 'igc-date-range-picker': IgcDateRangePickerComponent; } } - -type DateRangePickerInput = 'start' | 'end'; 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 825b2f891..b536216f6 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 @@ -1,14 +1,15 @@ 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 IgcInputComponent from '../input/input.js'; +import type { DateRangeValue } from '../types.js'; +import IgcDateRangeInputComponent from './date-range-input.js'; import type IgcDateRangePickerComponent from './date-range-picker.js'; -import type { DateRangeValue } from './date-range-picker.js'; export const selectDates = async ( startDate: CalendarDay | null, @@ -50,7 +51,7 @@ export const checkSelectedRange = ( checkDatesEqual(inputs[1].value!, expectedValue.end); } } else { - const input = picker.renderRoot.querySelector(IgcInputComponent.tagName)!; + const input = getInput(picker); const start = expectedValue?.start ? formatDisplayDate( expectedValue.start, @@ -96,3 +97,13 @@ export const checkInputsInvalidState = async ( expect(inputs[0].invalid).to.equal(first); expect(inputs[1].invalid).to.equal(second); }; + +export const getInput = ( + picker: IgcDateRangePickerComponent +): HTMLInputElement => { + const rangeInput = picker.renderRoot.querySelector( + IgcDateRangeInputComponent.tagName + )! as IgcDateRangeInputComponent; + const input = rangeInput.renderRoot.querySelector('input')!; + return input; +}; diff --git a/src/components/date-range-picker/validators.ts b/src/components/date-range-picker/validators.ts index 8d71b82da..2510858f0 100644 --- a/src/components/date-range-picker/validators.ts +++ b/src/components/date-range-picker/validators.ts @@ -1,88 +1,108 @@ import { ValidationResourceStringsEN } from 'igniteui-i18n-core'; -import { calendarRange, isDateInRanges } from '../calendar/helpers.js'; -import { CalendarDay } from '../calendar/model.js'; -import type { DateRangeDescriptor } from '../calendar/types.js'; +import { + calendarRange, + isDateExceedingMax, + isDateInRanges, + isDateLessThanMin, +} from '../calendar/helpers.js'; import { formatString, isEmpty } from '../common/util.js'; import type { Validator } from '../common/validators.js'; +import type { DateRangeValue } from '../types.js'; import type IgcDateRangePickerComponent from './date-range-picker.js'; -import type { DateRangeValue } from './date-range-picker.js'; -export const minDateRangeValidator: Validator<{ - value?: DateRangeValue | null; - min?: Date | null; -}> = { +export const minDateRangeValidator: Validator = { key: 'rangeUnderflow', - message: ({ min }) => - formatString(ValidationResourceStringsEN.min_validation_error!, min), - isValid: ({ value, min }) => { - if (!min) { + message: (host) => + formatString(ValidationResourceStringsEN.min_validation_error!, host.min), + isValid: (host) => { + if (!host.min) { return true; } const isStartInvalid = - value?.start && CalendarDay.compare(value.start, min) < 0; - const isEndInvalid = value?.end && CalendarDay.compare(value.end, min) < 0; + host.value?.start && + isDateLessThanMin( + host.value.start, + host.min, + host.hasTimeParts(), + host.hasDateParts() + ); + const isEndInvalid = + host.value?.end && + isDateLessThanMin( + host.value.end, + host.min, + host.hasTimeParts(), + host.hasDateParts() + ); return !(isStartInvalid || isEndInvalid); }, }; -export const maxDateRangeValidator: Validator<{ - value?: DateRangeValue | null; - max?: Date | null; -}> = { +export const maxDateRangeValidator: Validator = { key: 'rangeOverflow', - message: ({ max }) => - formatString(ValidationResourceStringsEN.max_validation_error!, max), - isValid: ({ value, max }) => { - if (!max) { + message: (host) => + formatString(ValidationResourceStringsEN.max_validation_error!, host.max), + isValid: (host) => { + if (!host.max) { return true; } const isStartInvalid = - value?.start && CalendarDay.compare(value.start, max) > 0; - const isEndInvalid = value?.end && CalendarDay.compare(value.end, max) > 0; + host.value?.start && + isDateExceedingMax( + host.value.start, + host.max, + host.hasTimeParts(), + host.hasDateParts() + ); + const isEndInvalid = + host.value?.end && + isDateExceedingMax( + host.value.end, + host.max, + host.hasTimeParts(), + host.hasDateParts() + ); return !(isStartInvalid || isEndInvalid); }, }; -export const requiredDateRangeValidator: Validator<{ - required: boolean; - value: DateRangeValue | null; -}> = { - key: 'valueMissing', - message: ValidationResourceStringsEN.required_validation_error!, - isValid: ({ required, value }) => { - return required ? isCompleteDateRange(value) : true; - }, -}; +export const requiredDateRangeValidator: Validator = + { + key: 'valueMissing', + message: ValidationResourceStringsEN.required_validation_error!, + isValid: (host) => { + return host.required ? isCompleteDateRange(host.value) : true; + }, + }; -export const badInputDateRangeValidator: Validator<{ - required: boolean; - value: DateRangeValue | null; - disabledDates?: DateRangeDescriptor[]; -}> = { - key: 'badInput', - message: ({ value }) => - formatString( - ValidationResourceStringsEN.disabled_date_validation_error!, - value - ), - isValid: ({ value, disabledDates }) => { - if ( - !isCompleteDateRange(value) || - !disabledDates || - isEmpty(disabledDates) - ) { - return true; - } +export const badInputDateRangeValidator: Validator = + { + key: 'badInput', + message: (host) => + formatString( + ValidationResourceStringsEN.disabled_date_validation_error!, + host.value + ), + isValid: (host) => { + const { value, disabledDates } = host; - return Array.from( - calendarRange({ start: value.start, end: value.end, inclusive: true }) - ).every((date) => !isDateInRanges(date, disabledDates)); - }, -}; + if ( + !isCompleteDateRange(value) || + !disabledDates || + isEmpty(disabledDates) + ) { + return true; + } + + return Array.from( + calendarRange({ start: value.start, end: value.end, inclusive: true }) + ).every((date) => !isDateInRanges(date, disabledDates)); + }, + }; export const dateRangeValidators: Validator[] = [ requiredDateRangeValidator, diff --git a/src/components/date-time-input/date-time-input.base.ts b/src/components/date-time-input/date-time-input.base.ts new file mode 100644 index 000000000..91fd27c77 --- /dev/null +++ b/src/components/date-time-input/date-time-input.base.ts @@ -0,0 +1,483 @@ +import { getDateFormatter } from 'igniteui-i18n-core'; +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 { convertToDate, isValidDate } from '../calendar/helpers.js'; +import { + addKeybindings, + arrowDown, + arrowLeft, + arrowRight, + arrowUp, + ctrlKey, +} from '../common/controllers/key-bindings.js'; +import { addSlotController, setSlots } from '../common/controllers/slot.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 { partMap } from '../common/part-map.js'; +import type { IgcInputComponentEventMap } from '../input/input-base.js'; +import { + IgcMaskInputBaseComponent, + type MaskSelection, +} from '../mask-input/mask-input-base.js'; +import type { DateRangeValue } from '../types.js'; +import type { DatePartDeltas } from './date-part.js'; +import { dateTimeInputValidators } from './validators.js'; + +const Slots = setSlots( + 'prefix', + 'suffix', + 'helper-text', + 'value-missing', + 'range-overflow', + 'range-underflow', + 'custom-error', + 'invalid' +); + +export interface IgcDateTimeInputComponentEventMap extends Omit< + IgcInputComponentEventMap, + 'igcChange' +> { + igcChange: CustomEvent; +} +export abstract class IgcDateTimeInputBaseComponent< + TValue extends Date | DateRangeValue | string | null, + TPart, +> extends EventEmitterMixin< + IgcDateTimeInputComponentEventMap, + AbstractConstructor +>(IgcMaskInputBaseComponent) { + // #region Private state & properties + + protected override get __validators() { + return dateTimeInputValidators; + } + + protected override readonly _slots = addSlotController(this, { + slots: Slots, + }); + + private readonly _i18nController = addI18nController(this, { + defaultEN: {}, + onResourceChange: this._handleResourceChange, + }); + + // Value tracking + protected _oldValue: TValue | null = null; + protected _min: Date | null = null; + protected _max: Date | null = null; + + protected _defaultMask!: string; + + // Format and mask state + protected _defaultDisplayFormat = ''; + protected _displayFormat?: string; + protected _inputFormat?: string; + + protected get _targetDatePart(): TPart | undefined { + return this._focused + ? this._getDatePartAtCursor() + : this._getDefaultDatePart(); + } + + // #endregion + + // #region Public attributes and properties + + public abstract override value: TValue | null; + + /** + * The date format to apply on the input. + * @attr input-format + */ + @property({ attribute: 'input-format' }) + public get inputFormat(): string { + return this._inputFormat || this._parser.mask; + } + + public set inputFormat(val: string) { + if (val) { + this._applyMask(val); + this._inputFormat = val; + this._updateMaskDisplay(); + } + } + + /** + * The minimum value required for the input to remain valid. + * @attr + */ + @property({ converter: convertToDate }) + public set min(value: Date | string | null | undefined) { + this._min = convertToDate(value); + this._validate(); + } + + public get min(): Date | null { + return this._min; + } + + /** + * The maximum value required for the input to remain valid. + * @attr + */ + @property({ converter: convertToDate }) + public set max(value: Date | string | null | undefined) { + this._max = convertToDate(value); + this._validate(); + } + + public get max(): Date | null { + return this._max; + } + + /** + * Format to display the value in when not editing. + * Defaults to the locale format if not set. + * @attr display-format + */ + @property({ attribute: 'display-format' }) + public set displayFormat(value: string) { + this._displayFormat = value; + } + + public get displayFormat(): string { + return ( + this._displayFormat ?? this._inputFormat ?? this._defaultDisplayFormat + ); + } + + /** + * Delta values used to increment or decrement each date part on step actions. + * All values default to `1`. + */ + @property({ attribute: false }) + public spinDelta?: DatePartDeltas; + + /** + * Sets whether to loop over the currently spun segment. + * @attr spin-loop + */ + @property({ type: Boolean, attribute: 'spin-loop' }) + public spinLoop = true; + + /** + * Gets/Sets the locale used for formatting the display value. + * @attr locale + */ + @property() + public set locale(value: string) { + this._i18nController.locale = value; + } + + public get locale(): string { + return this._i18nController.locale; + } + + // #endregion + + //#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 (props.has('locale')) { + this._initializeDefaultMask(); + } + + if (props.has('displayFormat') || props.has('locale')) { + this._updateMaskDisplay(); + } + + super.update(props); + } + + //#endregion + + //#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 override _updateSetRangeTextValue(): void { + this._updateValueFromMask(); + } + + protected override async _updateInput(string: string, range: MaskSelection) { + const { value, end } = this._parser.replace( + this._maskedValue, + string, + range.start, + range.end + ); + + this._maskedValue = value; + + this._updateValueFromMask(); + this.requestUpdate(); + + if (range.start !== this.inputFormat.length) { + this._emitInputEvent(); + } + await this.updateComplete; + this._input?.setSelectionRange(end, end); + } + + // #endregion + + // #region Event handlers + + /** + * Emits the input event after user interaction. + */ + protected _emitInputEvent(): void { + this._setTouchedState(); + this.emitEvent('igcInput', { detail: this.value?.toString() }); + } + + private _handleResourceChange(): void { + this._initializeDefaultMask(); + this._updateMaskDisplay(); + } + + protected _handleDragLeave(): void { + if (!this._focused) { + this._updateMaskDisplay(); + } + } + + protected _handleDragEnter(): void { + if (!this._focused) { + this._maskedValue = this._buildMaskedValue(); + } + } + + /** + * Handles wheel events for spinning date parts. + */ + @eventOptions({ passive: false }) + protected async _handleWheel(event: WheelEvent): Promise { + if (!this._focused || this.readOnly) return; + + event.preventDefault(); + event.stopPropagation(); + + const { start, end } = this._inputSelection; + event.deltaY > 0 ? this.stepDown() : this.stepUp(); + this._emitInputEvent(); + + await this.updateComplete; + this.setSelectionRange(start, end); + } + + // #endregion + + //#region Keybindings + + /** + * Sets the value to the current date/time. + */ + protected _setCurrentDateTime(): void { + this.value = new Date() as TValue; + this._emitInputEvent(); + } + + /** + * 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); + } + + /** + * 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); + } + + // #endregion + + //#region Internal API + + /** + * Common logic for stepping up or down a date part. + * @internal + */ + protected _performStep( + datePart: TPart | undefined, + delta: number | undefined, + isDecrement: boolean + ): void { + const part = datePart || this._targetDatePart; + if (!part) return; + + const { start, end } = this._inputSelection; + const newValue = this._calculateSpunValue(part, delta, isDecrement); + this.value = newValue as TValue; + this.updateComplete.then(() => this._input?.setSelectionRange(start, end)); + } + + /** + * 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; + } + + if (!isValidDate(this.value)) { + this._maskedValue = ''; + return; + } + + this._maskedValue = formatDisplayDate( + this.value, + this.locale, + this.displayFormat + ); + } + + /** + * Checks if all mask positions are filled (no prompt characters remain). + */ + protected _isMaskComplete(): boolean { + return !this._maskedValue.includes(this.prompt); + } + + /** + * Applies a mask pattern to the input, parsing the format string into date parts. + */ + protected _applyMask(formatString: string): void { + const previous = this._parser.mask; + this._parser.mask = formatString; + + // Update placeholder if not set or if it matches the old format + if (!this.placeholder || previous === this.placeholder) { + this.placeholder = this._parser.mask; + } + } + + /** + * Updates the default display format based on current locale. + */ + private _updateDefaultDisplayFormat(): void { + this._defaultDisplayFormat = getDateFormatter().getLocaleDateTimeFormat( + this.locale + ); + } + + protected _initializeDefaultMask(): void { + this._updateDefaultDisplayFormat(); + + if (!this._inputFormat) { + this._applyMask(getDefaultDateTimeFormat(this.locale)); + } + } + + // #region Public API + + /** Increments a date/time portion. */ + public stepUp(datePart?: TPart, delta?: number): void { + this._performStep(datePart, delta, false); + } + + /** Decrements a date/time portion. */ + public stepDown(datePart?: TPart, delta?: number): void { + this._performStep(datePart, delta, true); + } + + /** Clears the input element of user input. */ + public clear(): void { + this._maskedValue = ''; + this.value = null; + } + + //#endregion + + protected override _renderInput() { + return html` + + `; + } + + // #region Abstract methods and properties + + protected abstract get _datePartDeltas(): DatePartDeltas; + + protected abstract _buildMaskedValue(): string; + protected abstract _updateValueFromMask(): void; + protected abstract _calculatePartNavigationPosition( + value: string, + direction: number + ): number; + protected abstract _calculateSpunValue( + part: TPart, + delta: number | undefined, + isDecrement: boolean + ): TValue; + protected abstract _handleFocus(): Promise; + protected abstract _getDatePartAtCursor(): TPart | undefined; + protected abstract _getDefaultDatePart(): TPart | undefined; + + public abstract hasDateParts(): boolean; + public abstract hasTimeParts(): boolean; + + // #endregion +} diff --git a/src/components/date-time-input/date-time-input.ts b/src/components/date-time-input/date-time-input.ts index 80a12f932..c8e8c296f 100644 --- a/src/components/date-time-input/date-time-input.ts +++ b/src/components/date-time-input/date-time-input.ts @@ -1,44 +1,19 @@ -import { getDateFormatter } from 'igniteui-i18n-core'; -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 { property } from 'lit/decorators.js'; import { addThemingController } from '../../theming/theming-controller.js'; import { convertToDate, isValidDate } from '../calendar/helpers.js'; -import { - addKeybindings, - arrowDown, - arrowLeft, - arrowRight, - arrowUp, - ctrlKey, -} from '../common/controllers/key-bindings.js'; -import { addSlotController, setSlots } from '../common/controllers/slot.js'; import { registerComponent } from '../common/definitions/register.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'; import { createFormValueState } from '../common/mixins/forms/form-value.js'; -import { partMap } from '../common/part-map.js'; -import type { IgcInputComponentEventMap } from '../input/input-base.js'; import { styles } from '../input/themes/input.base.css.js'; import { styles as shared } from '../input/themes/shared/input.common.css.js'; import { all } from '../input/themes/themes.js'; -import { - IgcMaskInputBaseComponent, - type MaskSelection, -} from '../mask-input/mask-input-base.js'; import IgcValidationContainerComponent from '../validation-container/validation-container.js'; import { DatePart, type DatePartDeltas, DEFAULT_DATE_PARTS_SPIN_DELTAS, } from './date-part.js'; +import { IgcDateTimeInputBaseComponent } from './date-time-input.base.js'; import { createDatePart, DateParts, @@ -46,24 +21,6 @@ import { } from './datetime-mask-parser.js'; import { dateTimeInputValidators } from './validators.js'; -export interface IgcDateTimeInputComponentEventMap extends Omit< - IgcInputComponentEventMap, - 'igcChange' -> { - igcChange: CustomEvent; -} - -const Slots = setSlots( - 'prefix', - 'suffix', - 'helper-text', - 'value-missing', - 'range-overflow', - 'range-underflow', - 'custom-error', - 'invalid' -); - /** * A date time input is an input field that lets you set and edit the date and time in a chosen input element * using customizable display and input formats. @@ -89,10 +46,10 @@ const Slots = setSlots( * @csspart suffix - The suffix wrapper. * @csspart helper-text - The helper text wrapper. */ -export default class IgcDateTimeInputComponent extends EventEmitterMixin< - IgcDateTimeInputComponentEventMap, - AbstractConstructor ->(IgcMaskInputBaseComponent) { +export default class IgcDateTimeInputComponent extends IgcDateTimeInputBaseComponent< + Date | null, + DatePart +> { public static readonly tagName = 'igc-date-time-input'; public static styles = [styles, shared]; @@ -106,29 +63,8 @@ export default class IgcDateTimeInputComponent extends EventEmitterMixin< //#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); - - protected override readonly _slots = addSlotController(this, { - slots: Slots, - }); - + protected override readonly _parser = new DateTimeMaskParser(); protected override readonly _formValue = createFormValueState(this, { initialValue: null, transformers: FormValueDateTimeTransformers, @@ -138,18 +74,7 @@ export default class IgcDateTimeInputComponent extends EventEmitterMixin< return dateTimeInputValidators; } - /** - * 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 { + protected override get _datePartDeltas(): DatePartDeltas { return { ...DEFAULT_DATE_PARTS_SPIN_DELTAS, ...this.spinDelta }; } @@ -157,23 +82,6 @@ export default class IgcDateTimeInputComponent extends EventEmitterMixin< //#region Public attributes and properties - /** - * The date format to apply on the input. - * @attr input-format - */ - @property({ attribute: 'input-format' }) - public set inputFormat(val: string) { - if (val) { - this._applyMask(val); - this._inputFormat = val; - this._updateMaskDisplay(); - } - } - - public get inputFormat(): string { - return this._inputFormat || this._parser.mask; - } - /* @tsTwoWayProperty(true, "igcChange", "detail", false) */ /** * The value of the input. @@ -194,12 +102,12 @@ export default class IgcDateTimeInputComponent extends EventEmitterMixin< * @attr */ @property({ converter: convertToDate }) - public set min(value: Date | string | null | undefined) { + public override set min(value: Date | string | null | undefined) { this._min = convertToDate(value); this._validate(); } - public get min(): Date | null { + public override get min(): Date | null { return this._min; } @@ -208,150 +116,19 @@ export default class IgcDateTimeInputComponent extends EventEmitterMixin< * @attr */ @property({ converter: convertToDate }) - public set max(value: Date | string | null | undefined) { + public override set max(value: Date | string | null | undefined) { this._max = convertToDate(value); this._validate(); } - public get max(): Date | null { + public override get max(): Date | null { return this._max; } - /** - * Format to display the value in when not editing. - * Defaults to the locale format if not set. - * @attr display-format - */ - @property({ attribute: 'display-format' }) - public set displayFormat(value: string) { - this._displayFormat = value; - } - - public get displayFormat(): string { - return ( - this._displayFormat ?? this._inputFormat ?? this._defaultDisplayFormat - ); - } - - /** - * Delta values used to increment or decrement each date part on step actions. - * All values default to `1`. - */ - @property({ attribute: false }) - public spinDelta?: DatePartDeltas; - - /** - * Sets whether to loop over the currently spun segment. - * @attr spin-loop - */ - @property({ type: Boolean, attribute: 'spin-loop' }) - public spinLoop = true; - - /** - * Gets/Sets the locale used for formatting the display value. - * @attr locale - */ - @property() - public set locale(value: string) { - this._i18nController.locale = value; - } - - public get locale(): string { - return this._i18nController.locale; - } - - //#endregion - - //#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 (props.has('locale')) { - this._initializeDefaultMask(); - } - - if (props.has('displayFormat') || props.has('locale')) { - this._updateMaskDisplay(); - } - - super.update(props); - } - - //#endregion - - //#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 override _updateSetRangeTextValue(): void { - this._updateValueFromMask(); - } - //#endregion //#region Event handlers - private _emitInputEvent(): void { - this._setTouchedState(); - this.emitEvent('igcInput', { detail: this.value?.toString() }); - } - - private _handleResourceChange(): void { - this._initializeDefaultMask(); - this._updateMaskDisplay(); - } - - protected _handleDragLeave(): void { - if (!this._focused) { - this._updateMaskDisplay(); - } - } - - protected _handleDragEnter(): void { - if (!this._focused) { - this._maskedValue = this._buildMaskedValue(); - } - } - - @eventOptions({ passive: false }) - private async _handleWheel(event: WheelEvent): Promise { - if (!this._focused || this.readOnly) { - return; - } - - event.preventDefault(); - event.stopPropagation(); - - const { start, end } = this._inputSelection; - event.deltaY > 0 ? this.stepDown() : this.stepUp(); - this._emitInputEvent(); - - await this.updateComplete; - this.setSelectionRange(start, end); - } - protected async _handleFocus(): Promise { this._focused = true; @@ -396,30 +173,14 @@ export default class IgcDateTimeInputComponent extends EventEmitterMixin< //#endregion - //#region Keybindings - - protected _setCurrentDateTime(): void { - this.value = new Date(); - this._emitInputEvent(); - } - - /** - * 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); - } + //#region Navigation /** * 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( + protected override _calculatePartNavigationPosition( inputValue: string, direction: number ): number { @@ -441,80 +202,44 @@ export default class IgcDateTimeInputComponent extends EventEmitterMixin< return part?.start ?? inputValue.length; } - /** - * 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); - } - //#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. + * 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. */ - protected _updateMaskDisplay(): void { - if (this._focused) { - this._maskedValue = this._buildMaskedValue(); - return; - } - - if (!isValidDate(this.value)) { - this._maskedValue = ''; - return; - } - - this._maskedValue = formatDisplayDate( - this.value, - this.locale, - this.displayFormat - ); + protected override _getDatePartAtCursor(): DatePart | undefined { + return this._parser.getDatePartForCursor(this._inputSelection.start) + ?.type as DatePart | undefined; } - protected async _updateInput( - text: string, - { start, end }: MaskSelection - ): Promise { - const result = this._parser.replace(this._maskedValue, text, start, end); - - this._maskedValue = result.value; - - this._updateValueFromMask(); - this.requestUpdate(); - - if (start !== this.inputFormat.length) { - this._emitInputEvent(); - } - - await this.updateComplete; - this._input?.setSelectionRange(result.end, result.end); + /** + * Gets the default date part to target when the input is not focused. + * Prioritizes: Date > Hours > First available part + */ + protected override _getDefaultDatePart(): DatePart | undefined { + return (this._parser.getPartByType(DateParts.Date)?.type ?? + this._parser.getPartByType(DateParts.Hours)?.type ?? + this._parser.getFirstDatePart()?.type) as DatePart | undefined; } /** - * Common logic for stepping up or down a date part. + * Builds the masked value string from the current date value. + * Returns empty mask if no value, or existing masked value if incomplete. */ - 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)); + protected override _buildMaskedValue(): string { + return isValidDate(this.value) + ? this._parser.formatDate(this.value) + : this._maskedValue || this._parser.emptyMask; } /** * Calculates the new date value after spinning a date part. */ - private _calculateSpunValue( + protected override _calculateSpunValue( datePart: DatePart, delta: number | undefined, isDecrement: boolean @@ -533,7 +258,7 @@ export default class IgcDateTimeInputComponent extends EventEmitterMixin< /** * Spins a specific date part by the given delta. */ - private _spinDatePart(datePart: DatePart, delta: number): Date { + protected _spinDatePart(datePart: DatePart, delta: number): Date { if (!isValidDate(this.value)) { return new Date(); } @@ -571,78 +296,11 @@ export default class IgcDateTimeInputComponent extends EventEmitterMixin< return newDate; } - /** - * Updates the default display format based on current locale. - */ - private _updateDefaultDisplayFormat(): void { - this._defaultDisplayFormat = getDateFormatter().getLocaleDateTimeFormat( - this.locale - ); - } - - /** - * 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; - - // Update placeholder if not set or if it matches the old format - if (!this.placeholder || previous === this.placeholder) { - this.placeholder = this._parser.mask; - } - } - - /** - * 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; - } - - protected _initializeDefaultMask(): void { - this._updateDefaultDisplayFormat(); - - if (!this._inputFormat) { - this._applyMask(getDefaultDateTimeFormat(this.locale)); - } - } - - /** - * 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; - } - - /** - * 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; - } - - /** - * Checks if all mask positions are filled (no prompt characters remain). - */ - private _isMaskComplete(): boolean { - return !this._maskedValue.includes(this.prompt); - } - /** * 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 { + protected override _updateValueFromMask(): void { if (!this._isMaskComplete()) { this.value = null; return; @@ -656,28 +314,12 @@ export default class IgcDateTimeInputComponent extends EventEmitterMixin< //#region Public API - /** Increments a date/time portion. */ - public stepUp(datePart?: DatePart, delta?: number): void { - this._performStep(datePart, delta, false); - } - - /** Decrements a date/time portion. */ - public stepDown(datePart?: DatePart, delta?: number): void { - this._performStep(datePart, delta, true); - } - - /** Clears the input element of user input. */ - public clear(): void { - this._maskedValue = ''; - this.value = null; - } - /* blazorSuppress */ /** * Checks whether the current format includes date parts (day, month, year). * @internal */ - public hasDateParts(): boolean { + public override hasDateParts(): boolean { return this._parser.hasDateParts(); } @@ -686,37 +328,11 @@ export default class IgcDateTimeInputComponent extends EventEmitterMixin< * Checks whether the current format includes time parts (hours, minutes, seconds). * @internal */ - public hasTimeParts(): boolean { + public override hasTimeParts(): boolean { return this._parser.hasTimeParts(); } //#endregion - - protected override _renderInput() { - return html` - - `; - } } declare global { diff --git a/src/components/date-time-input/datetime-mask-parser.ts b/src/components/date-time-input/datetime-mask-parser.ts index debc99264..853b77db1 100644 --- a/src/components/date-time-input/datetime-mask-parser.ts +++ b/src/components/date-time-input/datetime-mask-parser.ts @@ -34,7 +34,7 @@ export interface DateTimeMaskOptions { //#region Constants /** Maps format characters to their corresponding DatePartType */ -const FORMAT_CHAR_TO_DATE_PART = new Map([ +export const FORMAT_CHAR_TO_DATE_PART = new Map([ ['d', DatePartType.Date], ['D', DatePartType.Date], ['M', DatePartType.Month], @@ -50,7 +50,7 @@ const FORMAT_CHAR_TO_DATE_PART = new Map([ ]); /** Set of valid date/time format characters */ -const DATE_FORMAT_CHARS = new Set(FORMAT_CHAR_TO_DATE_PART.keys()); +export const DATE_FORMAT_CHARS = new Set(FORMAT_CHAR_TO_DATE_PART.keys()); /** Century threshold for two-digit year interpretation */ const CENTURY_THRESHOLD = 50; @@ -67,7 +67,7 @@ const DEFAULT_DATE_VALUES = { } as const; /** Default date/time format */ -const DEFAULT_DATETIME_FORMAT = 'MM/dd/yyyy'; +export const DEFAULT_DATETIME_FORMAT = 'MM/dd/yyyy'; //#endregion @@ -460,7 +460,8 @@ export class DateTimeMaskParser extends MaskParser { protected override _parseMaskLiterals(): void { // First, convert date format to mask format const dateFormat = this._options.format; - const maskFormat = this._convertToMaskFormat(dateFormat); + const maskFormat = + DateTimeMaskParser.convertDateFormatToMaskFormat(dateFormat); // Temporarily set the converted format for the base class parsing const originalFormat = this._options.format; @@ -474,12 +475,19 @@ export class DateTimeMaskParser extends MaskParser { // Parse date-specific format structure this._parseDateFormat(); } + //#endregion + + //#region Static Utilities /** * Converts a date format string to a mask format string. * Date format chars become '0' (numeric) or 'L' (alpha for AM/PM). + * This is a static utility that can be reused by other parsers. + * + * @param dateFormat - The date format string to convert (e.g., 'MM/dd/yyyy') + * @returns The mask format string (e.g., '00/00/0000') */ - private _convertToMaskFormat(dateFormat: string): string { + public static convertDateFormatToMaskFormat(dateFormat: string): string { let result = ''; for (const char of dateFormat) { diff --git a/src/components/input/input-base.ts b/src/components/input/input-base.ts index 6f9a4d1a5..f9649c8eb 100644 --- a/src/components/input/input-base.ts +++ b/src/components/input/input-base.ts @@ -9,6 +9,7 @@ import type { Constructor } from '../common/mixins/constructor.js'; import { EventEmitterMixin } from '../common/mixins/event-emitter.js'; import { FormAssociatedRequiredMixin } from '../common/mixins/forms/associated-required.js'; import { partMap } from '../common/part-map.js'; +import type { DateRangeValue } from '../types.js'; import IgcValidationContainerComponent from '../validation-container/validation-container.js'; export interface IgcInputComponentEventMap { @@ -44,7 +45,7 @@ export abstract class IgcInputBaseComponent extends FormAssociatedRequiredMixin( /** The value attribute of the control. * Type varies based on the input type and can be string, Date or null. */ - public abstract value: string | Date | null; + public abstract value: string | Date | DateRangeValue | null; /** * Whether the control will have outlined appearance. diff --git a/src/components/types.ts b/src/components/types.ts index 8f213462c..02888c873 100644 --- a/src/components/types.ts +++ b/src/components/types.ts @@ -25,6 +25,7 @@ export type BadgeShape = 'rounded' | 'square'; export type ButtonGroupSelection = 'single' | 'single-required' | 'multiple'; export type ButtonVariant = 'contained' | 'flat' | 'outlined' | 'fab'; export type CarouselIndicatorsOrientation = 'end' | 'start'; +export type DateRangeValue = { start: Date | null; end: Date | null }; export type DividerType = 'solid' | 'dashed'; export type ExpansionPanelIndicatorPosition = 'start' | 'end' | 'none'; export type IconButtonVariant = 'contained' | 'flat' | 'outlined'; diff --git a/src/index.ts b/src/index.ts index d0047004b..f42f321c3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -133,7 +133,7 @@ export type { IgcChipComponentEventMap } from './components/chip/chip.js'; export type { IgcComboComponentEventMap } from './components/combo/types.js'; export type { IgcDatePickerComponentEventMap } from './components/date-picker/date-picker.js'; export type { IgcDateRangePickerComponentEventMap } from './components/date-range-picker/date-range-picker.js'; -export type { IgcDateTimeInputComponentEventMap } from './components/date-time-input/date-time-input.js'; +export type { IgcDateTimeInputComponentEventMap } from './components/date-time-input/date-time-input.base.js'; export type { IgcDialogComponentEventMap } from './components/dialog/dialog.js'; export type { IgcDropdownComponentEventMap } from './components/dropdown/dropdown.js'; export type { IgcExpansionPanelComponentEventMap } from './components/expansion-panel/expansion-panel.js'; diff --git a/stories/date-range-picker.stories.ts b/stories/date-range-picker.stories.ts index 179c347e2..4beb56cdc 100644 --- a/stories/date-range-picker.stories.ts +++ b/stories/date-range-picker.stories.ts @@ -459,7 +459,6 @@ export const Default: Story = { render: (args) => html` = { actions: { handles: ['igcInput', 'igcChange'] }, }, argTypes: { - inputFormat: { - type: 'string', - description: 'The date format to apply on the input.', - control: 'text', - }, value: { type: 'string | Date', description: 'The value of the input.', @@ -48,6 +43,11 @@ const metadata: Meta = { description: 'The maximum value required for the input to remain valid.', control: 'date', }, + inputFormat: { + type: 'string', + description: 'The date format to apply on the input.', + control: 'text', + }, displayFormat: { type: 'string', description: @@ -141,14 +141,14 @@ const metadata: Meta = { export default metadata; interface IgcDateTimeInputArgs { - /** The date format to apply on the input. */ - inputFormat: string; /** The value of the input. */ value: string | Date; /** The minimum value required for the input to remain valid. */ min: Date; /** The maximum value required for the input to remain valid. */ max: Date; + /** The date format to apply on the input. */ + inputFormat: string; /** * Format to display the value in when not editing. * Defaults to the locale format if not set. diff --git a/stories/file-input.stories.ts b/stories/file-input.stories.ts index 26639126e..625bd03e7 100644 --- a/stories/file-input.stories.ts +++ b/stories/file-input.stories.ts @@ -1,8 +1,3 @@ -import { github } from '@igniteui/material-icons-extended'; -import type { Meta, StoryObj } from '@storybook/web-components-vite'; -import { html } from 'lit'; -import { ifDefined } from 'lit/directives/if-defined.js'; - import { IgcFileInputComponent, IgcIconComponent, @@ -11,9 +6,9 @@ import { registerIconFromText, } from 'igniteui-webcomponents'; import { + disableStoryControls, formControls, formSubmitHandler, - disableStoryControls, } from './story.js'; defineComponents(IgcFileInputComponent, IgcIconComponent); diff --git a/stories/input.stories.ts b/stories/input.stories.ts index 3bba170f9..24ed4ed0e 100644 --- a/stories/input.stories.ts +++ b/stories/input.stories.ts @@ -1,8 +1,3 @@ -import { github } from '@igniteui/material-icons-extended'; -import type { Meta, StoryObj } from '@storybook/web-components-vite'; -import { html } from 'lit'; -import { ifDefined } from 'lit/directives/if-defined.js'; - import { IgcIconComponent, IgcInputComponent, @@ -10,12 +5,17 @@ import { registerIcon, registerIconFromText, } from 'igniteui-webcomponents'; +import type { Meta, StoryObj } from '@storybook/web-components-vite'; import { disableStoryControls, formControls, formSubmitHandler, } from './story.js'; +import { github } from '@igniteui/material-icons-extended'; +import { html } from 'lit'; +import { ifDefined } from 'lit/directives/if-defined.js'; + defineComponents(IgcInputComponent, IgcIconComponent); registerIconFromText(github.name, github.value); registerIcon( diff --git a/stories/mask-input.stories.ts b/stories/mask-input.stories.ts index 7358a575d..5fe070a79 100644 --- a/stories/mask-input.stories.ts +++ b/stories/mask-input.stories.ts @@ -1,20 +1,20 @@ -import { github } from '@igniteui/material-icons-extended'; -import type { Meta, StoryObj } from '@storybook/web-components-vite'; -import { html } from 'lit'; -import { ifDefined } from 'lit/directives/if-defined.js'; - import { IgcIconComponent, IgcMaskInputComponent, defineComponents, registerIconFromText, } from 'igniteui-webcomponents'; +import type { Meta, StoryObj } from '@storybook/web-components-vite'; import { disableStoryControls, formControls, formSubmitHandler, } from './story.js'; +import { github } from '@igniteui/material-icons-extended'; +import { html } from 'lit'; +import { ifDefined } from 'lit/directives/if-defined.js'; + defineComponents(IgcMaskInputComponent, IgcIconComponent); registerIconFromText(github.name, github.value);