From 18aef58663483347ea9ba6b3c49bc944edb7d180 Mon Sep 17 00:00:00 2001 From: ddaribo Date: Thu, 17 Apr 2025 17:33:27 +0300 Subject: [PATCH 01/14] poc editable date range input --- .../date-range-picker/date-range-input.ts | 442 ++++++++++++++++++ .../date-range-picker-single.form.spec.ts | 41 +- .../date-range-picker-single.spec.ts | 277 ++++++++++- .../date-range-picker-two-inputs.form.spec.ts | 10 +- .../date-range-picker-two-inputs.spec.ts | 5 +- .../date-range-picker.common.spec.ts | 5 +- .../date-range-picker/date-range-picker.ts | 139 +++--- .../date-range-picker.utils.spec.ts | 14 +- .../date-time-input/date-time-input.base.ts | 400 ++++++++++++++++ .../date-time-input/date-time-input.ts | 408 ++-------------- src/components/date-time-input/date-util.ts | 18 + src/components/input/input-base.ts | 3 +- src/index.ts | 2 +- stories/date-range-picker.stories.ts | 1 - stories/date-time-input.stories.ts | 17 +- stories/file-input.stories.ts | 7 +- stories/input.stories.ts | 7 +- stories/mask-input.stories.ts | 7 +- 18 files changed, 1295 insertions(+), 508 deletions(-) create mode 100644 src/components/date-range-picker/date-range-input.ts create mode 100644 src/components/date-time-input/date-time-input.base.ts 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..867bb75e3 --- /dev/null +++ b/src/components/date-range-picker/date-range-input.ts @@ -0,0 +1,442 @@ +import { LitElement } from 'lit'; +import { property } from 'lit/decorators.js'; +import { CalendarDay } from '../calendar/model.js'; +import { watch } from '../common/decorators/watch.js'; +import { registerComponent } from '../common/definitions/register.js'; +import { + type FormValue, + createFormValueState, + defaultDateRangeTransformers, +} from '../common/mixins/forms/form-value.js'; +import { createCounter, equal } from '../common/util.js'; +import { IgcDateTimeInputBaseComponent } from '../date-time-input/date-time-input.base.js'; +import { + DatePart, + type DatePartDeltas, + DateParts, + type DateRangePart, + type DateRangePartInfo, + DateRangePosition, + DateTimeUtil, +} from '../date-time-input/date-util.js'; +import type { DateRangeValue } from './date-range-picker.js'; +import { isCompleteDateRange } from './validators.js'; + +const SINGLE_INPUT_SEPARATOR = ' - '; + +export default class IgcDateRangeInputComponent extends IgcDateTimeInputBaseComponent< + DateRangeValue | null, + DateRangePart, + DateRangePartInfo +> { + public static readonly tagName = 'igc-date-range-input'; + + protected static shadowRootOptions = { + ...LitElement.shadowRootOptions, + delegatesFocus: true, + }; + + /* blazorSuppress */ + public static register() { + registerComponent(IgcDateRangeInputComponent); + } + + // #region Properties + + private static readonly increment = createCounter(); + + protected override inputId = `date-range-input-${IgcDateRangeInputComponent.increment()}`; + protected override _datePartDeltas: DatePartDeltas = { + date: 1, + month: 1, + year: 1, + }; + + private _oldRangeValue: DateRangeValue | null = null; + + protected override _inputFormat!: string; + protected override _formValue: FormValue; + + protected override get targetDatePart(): DateRangePart | undefined { + let result: DateRangePart | undefined; + + if (this.focused) { + const part = this._inputDateParts.find( + (p) => + p.start <= this.inputSelection.start && + this.inputSelection.start <= p.end && + p.type !== DateParts.Literal + ); + const partType = part?.type as string as DatePart; + + if (partType) { + result = { part: partType, position: part?.position! }; + } + } else { + result = { + part: this._inputDateParts[0].type as string as DatePart, + position: DateRangePosition.Start, + }; + } + + return result; + } + + public get value(): DateRangeValue | null { + return this._formValue.value; + } + + public set value(value: DateRangeValue | null) { + this._formValue.setValueAndFormState(value as DateRangeValue | null); + this.updateMask(); + } + + /** + * The date format to apply on the input. + * @attr input-format + */ + @property({ attribute: 'input-format' }) + public override get inputFormat(): string { + return ( + this._inputFormat || this._defaultMask?.split(SINGLE_INPUT_SEPARATOR)[0] + ); + } + + public override set inputFormat(value: string) { + if (value) { + this._inputFormat = value; + this.setMask(value); + if (this.value) { + this.updateMask(); + } + } + } + + // #endregion + + // #region Methods + + constructor() { + super(); + + this._formValue = createFormValueState(this, { + initialValue: null, + transformers: defaultDateRangeTransformers, + }); + } + + @watch('displayFormat') + protected _onDisplayFormatChange() { + this.updateMask(); + } + + protected override setMask(string: string) { + const oldFormat = this._inputDateParts?.map((p) => p.format).join(''); + this._inputDateParts = DateTimeUtil.parseDateTimeFormat(string); + const startParts = this._inputDateParts.map((part) => ({ + ...part, + position: DateRangePosition.Start, + })) as DateRangePartInfo[]; + + const separatorStart = startParts[startParts.length - 1].end; + const separatorParts: DateRangePartInfo[] = []; + + for (let i = 0; i < SINGLE_INPUT_SEPARATOR.length; i++) { + const element = SINGLE_INPUT_SEPARATOR.charAt(i); + + separatorParts.push({ + type: DateParts.Literal, + format: element, + start: separatorStart + i, + end: separatorStart + i + 1, + position: DateRangePosition.Separator, + }); + } + + let currentPosition = separatorStart + SINGLE_INPUT_SEPARATOR.length; + + // Clone original parts, adjusting positions + const endParts: DateRangePartInfo[] = startParts.map((part) => { + const length = part.end - part.start; + const newPart: DateRangePartInfo = { + type: part.type, + format: part.format, + start: currentPosition, + end: currentPosition + length, + position: DateRangePosition.End, + }; + currentPosition += length; + return newPart; + }); + + this._inputDateParts = [...startParts, ...separatorParts, ...endParts]; + + this._defaultMask = this._inputDateParts.map((p) => p.format).join(''); + + const value = this._defaultMask; + this._mask = (value || DateTimeUtil.DEFAULT_INPUT_FORMAT).replace( + new RegExp(/(?=[^t])[\w]/, 'g'), + '0' + ); + + this.parser.mask = this._mask; + this.parser.prompt = this.prompt; + + if (!this.placeholder || oldFormat === this.placeholder) { + this.placeholder = value; + } + } + + protected override getMaskedValue() { + let mask = this.emptyMask; + + if (DateTimeUtil.isValidDate(this.value?.start)) { + const startParts = this._inputDateParts.filter( + (p) => p.position === DateRangePosition.Start + ); + mask = this._setDatePartInMask(mask, startParts, this.value.start); + } + if (DateTimeUtil.isValidDate(this.value?.end)) { + const endParts = this._inputDateParts.filter( + (p) => p.position === DateRangePosition.End + ); + mask = this._setDatePartInMask(mask, endParts, this.value.end); + return mask; + } + + return this.maskedValue === '' ? mask : this.maskedValue; + } + + protected override getNewPosition(value: string, direction = 0): number { + let cursorPos = this.selection.start; + + const separatorPart = this._inputDateParts.find( + (part) => part.position === DateRangePosition.Separator + ); + + if (!direction) { + const firstSeparator = + this._inputDateParts.find( + (p) => p.position === DateRangePosition.Separator + )?.start ?? 0; + const lastSeparator = + this._inputDateParts.findLast( + (p) => p.position === DateRangePosition.Separator + )?.end ?? 0; + // Last literal before the current cursor position or start of input value + let part = this._inputDateParts.findLast( + (part) => part.type === DateParts.Literal && part.end < cursorPos + ); + // skip over the separator parts + if ( + part?.position === DateRangePosition.Separator && + cursorPos === lastSeparator + ) { + cursorPos = firstSeparator; + part = this._inputDateParts.findLast( + (part) => part.type === DateParts.Literal && part.end < cursorPos + ); + } + return part?.end ?? 0; + } + + if ( + separatorPart && + cursorPos >= separatorPart.start && + cursorPos <= separatorPart.end + ) { + // Cursor is inside the separator; skip over it + cursorPos = separatorPart.end + 1; + } + // First literal after the current cursor position or end of input value + const part = this._inputDateParts.find( + (part) => part.type === DateParts.Literal && part.start > cursorPos + ); + return part?.start ?? value.length; + } + + protected override updateValue(): void { + if (this.isComplete()) { + const parsedRange = this._parseRangeValue(this.maskedValue); + this.value = parsedRange; + } else { + this.value = null; + } + } + + protected override async handleFocus() { + this.focused = true; + + if (this.readOnly) { + return; + } + this._oldRangeValue = this.value; + const areFormatsDifferent = this.displayFormat !== this.inputFormat; + + if (!this.value || !this.value.start || !this.value.end) { + this.maskedValue = this.emptyMask; + await this.updateComplete; + this.select(); + } else if (areFormatsDifferent) { + this.updateMask(); + } + } + + protected override async handleBlur() { + const isEmptyMask = this.maskedValue === this.emptyMask; + const isSameValue = equal(this._oldRangeValue, this.value); + + this.focused = false; + + if (!(this.isComplete() || isEmptyMask)) { + const parse = this._parseRangeValue(this.maskedValue); + + if (parse) { + this.value = parse; + } else { + this.value = null; + this.maskedValue = ''; + } + } else { + this.updateMask(); + } + + if (!(this.readOnly || isSameValue)) { + this.emitEvent('igcChange', { detail: this.value }); + } + + this.checkValidity(); + } + + protected override spinValue( + datePart: DateRangePart, + delta: number + ): DateRangeValue { + if (!isCompleteDateRange(this.value)) { + return { start: CalendarDay.today.native, end: CalendarDay.today.native }; + } + + let newDate = this.value?.start + ? CalendarDay.from(this.value.start).native + : CalendarDay.today.native; + if (datePart.position === DateRangePosition.End) { + newDate = this.value?.end + ? CalendarDay.from(this.value.end).native + : CalendarDay.today.native; + } + + switch (datePart.part) { + case DatePart.Date: + DateTimeUtil.spinDate(delta, newDate, this.spinLoop); + break; + case DatePart.Month: + DateTimeUtil.spinMonth(delta, newDate, this.spinLoop); + break; + case DatePart.Year: + DateTimeUtil.spinYear(delta, newDate); + break; + } + const value = { + ...this.value, + [datePart.position]: newDate, + } as DateRangeValue; + return value; + } + + protected override updateMask() { + if (this.focused) { + this.maskedValue = this.getMaskedValue(); + } else { + if (!isCompleteDateRange(this.value)) { + this.maskedValue = ''; + return; + } + + const { formatDate, predefinedToDateDisplayFormat } = DateTimeUtil; + + const { start, end } = this.value; + const format = + predefinedToDateDisplayFormat(this.displayFormat) ?? + this.displayFormat ?? + this.inputFormat; + + this.maskedValue = format + ? `${formatDate(start, this.locale, format)} - ${formatDate(end, this.locale, format)}` + : `${start.toLocaleDateString()} - ${end.toLocaleDateString()}`; + } + } + + protected override handleInput() { + this.emitEvent('igcInput', { detail: JSON.stringify(this.value) }); + } + + private _setDatePartInMask( + mask: string, + parts: DateRangePartInfo[], + value: Date | null + ): string { + let resultMask = mask; + for (const part of parts) { + if (part.type === DateParts.Literal) { + continue; + } + + const targetValue = DateTimeUtil.getPartValue( + part, + part.format.length, + value + ); + + resultMask = this.parser.replace( + resultMask, + targetValue, + part.start, + part.end + ).value; + } + return resultMask; + } + + private _parseRangeValue(value: string): DateRangeValue | null { + const dates = value.split(SINGLE_INPUT_SEPARATOR); + if (dates.length !== 2) { + return null; + } + + const startParts = this._inputDateParts.filter( + (p) => p.position === DateRangePosition.Start + ); + + const endPartsOriginal = this._inputDateParts.filter( + (p) => p.position === DateRangePosition.End + ); + + // Rebase endParts to start from 0, so they can be parsed on their own + const offset = endPartsOriginal.length > 0 ? endPartsOriginal[0].start : 0; + const endParts = endPartsOriginal.map((p) => ({ + ...p, + start: p.start - offset, + end: p.end - offset, + })); + + const start = DateTimeUtil.parseValueFromMask( + dates[0], + startParts, + this.prompt + ); + + const end = DateTimeUtil.parseValueFromMask( + dates[1], + endParts, + this.prompt + ); + + return { start: start ?? null, end: end ?? null }; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'igc-date-range-input': IgcDateRangeInputComponent; + } +} 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 39a805e73..0b656d48f 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 @@ -8,7 +8,8 @@ import { runValidationContainerTests, simulateClick, } from '../common/utils.spec.js'; -import IgcInputComponent from '../input/input.js'; +import type IgcDateRangeInputComponentComponent from './date-range-input.js'; +import IgcDateRangeInputComponent from './date-range-input.js'; import IgcDateRangePickerComponent, { type DateRangeValue, } from './date-range-picker.js'; @@ -18,7 +19,7 @@ describe('Date Range Picker Single Input - Form integration', () => { before(() => defineComponents(IgcDateRangePickerComponent)); let picker: IgcDateRangePickerComponent; - let input: IgcInputComponent; + let input: IgcDateRangeInputComponentComponent; let startKey = ''; let endKey = ''; @@ -64,8 +65,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); @@ -73,7 +74,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', () => { @@ -81,7 +82,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 }); @@ -97,7 +100,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; @@ -115,10 +118,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); @@ -143,7 +146,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; @@ -159,7 +162,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; @@ -202,7 +205,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; @@ -243,7 +246,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; @@ -276,7 +279,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; @@ -318,7 +321,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; @@ -378,7 +381,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; @@ -411,8 +414,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 ab8da4527..5bb04a0c7 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 @@ -8,19 +8,27 @@ import { import { spy } from 'sinon'; import IgcCalendarComponent from '../calendar/calendar.js'; import { CalendarDay } from '../calendar/model.js'; -import { escapeKey } from '../common/controllers/key-bindings.js'; +import { + 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'; @@ -28,7 +36,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'; @@ -39,7 +48,10 @@ describe('Date range picker - single input', () => { picker = await fixture( html`` ); - input = picker.renderRoot.querySelector(IgcInputComponent.tagName)!; + rangeInput = picker.renderRoot.querySelector( + IgcDateRangeInputComponent.tagName + )!; + rangeInput.renderRoot.querySelector('input')!; calendar = picker.renderRoot.querySelector(IgcCalendarComponent.tagName)!; }); @@ -130,9 +142,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); @@ -164,7 +177,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); } @@ -190,9 +205,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}` ); @@ -207,7 +220,8 @@ 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)!; + + input = getInput(picker); expect(input.value).to.equal('04/09/2025 - 04/10/2025'); picker.locale = 'bg'; @@ -220,7 +234,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}` ); @@ -241,7 +256,7 @@ describe('Date range picker - single input', () => { }; await elementUpdated(picker); - const input = picker.renderRoot.querySelector(IgcInputComponent.tagName)!; + input = getInput(picker); expect(input.value).to.equal('04/09/2025 - 04/10/2025'); picker.displayFormat = 'yyyy-MM-dd'; @@ -259,6 +274,7 @@ describe('Date range picker - single input', () => { }; await elementUpdated(picker); + input = getInput(picker); expect(input.value).to.equal('04/09/2025 - 04/10/2025'); picker.clear(); @@ -283,6 +299,7 @@ describe('Date range picker - single input', () => { start, end, }); + input = getInput(picker); expect(input.value).to.equal('04/09/2025 - 04/10/2025'); expect(eventSpy).not.called; }); @@ -489,7 +506,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 )!; @@ -548,6 +565,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); @@ -558,9 +578,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; }); @@ -570,24 +594,232 @@ 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(); + input.setSelectionRange(2, 2); + + expect(isFocused(input)).to.be.true; + //Move selection to the end of 'day' part. + simulateKeyboard(input, [ctrlKey, arrowRight]); + await elementUpdated(rangeInput); + + expect(input.selectionStart).to.equal(5); + expect(input.selectionEnd).to.equal(5); + + simulateKeyboard(input, arrowUp); + await elementUpdated(picker); + + 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(rangeInput); + + expect(input.selectionStart).to.equal(10); + expect(input.selectionEnd).to.equal(10); + + simulateKeyboard(input, arrowUp); + await elementUpdated(picker); + + 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(rangeInput); + + 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); + + 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); + checkSelectedRange( + picker, + + { start: today.native, end: today.native }, + + false + ); + }); + + 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(rangeInput); + 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('01/01/2000 - 04/23/2025'); + }); }); describe('Readonly state', () => { @@ -607,10 +839,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); @@ -624,7 +857,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 22abb9a1c..7ad32eb39 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 @@ -10,7 +10,7 @@ import { simulateInput, } from '../common/utils.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'; @@ -21,7 +21,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[]; @@ -477,7 +479,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; @@ -517,7 +519,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 e74ce937d..a05c7b519 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 @@ -23,6 +23,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, @@ -31,7 +32,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 3d8c9bf67..2e60eb178 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 DateRangeValue, type CustomDateRange, @@ -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 f10fd294e..23e791c04 100644 --- a/src/components/date-range-picker/date-range-picker.ts +++ b/src/components/date-range-picker/date-range-picker.ts @@ -4,7 +4,6 @@ import { query, queryAll, queryAssignedElements, - state, } from 'lit/decorators.js'; import { ifDefined } from 'lit/directives/if-defined.js'; import { live } from 'lit/directives/live.js'; @@ -46,14 +45,17 @@ import { isEmpty, } from '../common/util.js'; import IgcDateTimeInputComponent from '../date-time-input/date-time-input.js'; -import { DateTimeUtil } from '../date-time-input/date-util.js'; +import { + DateRangePosition, + DateTimeUtil, +} from '../date-time-input/date-util.js'; import IgcDialogComponent from '../dialog/dialog.js'; import IgcFocusTrapComponent from '../focus-trap/focus-trap.js'; import IgcIconComponent from '../icon/icon.js'; -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 { 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'; @@ -196,9 +198,9 @@ export default class IgcDateRangePickerComponent extends FormAssociatedRequiredM public static register(): void { registerComponent( IgcDateRangePickerComponent, + IgcDateRangeInputComponent, IgcCalendarComponent, IgcDateTimeInputComponent, - IgcInputComponent, IgcFocusTrapComponent, IgcIconComponent, IgcPopoverComponent, @@ -239,14 +241,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; @@ -291,7 +290,6 @@ export default class IgcDateRangePickerComponent extends FormAssociatedRequiredM this._validate(); this._setCalendarRangeValues(); - this._updateMaskedRangeValue(); } public get value(): DateRangeValue | null { @@ -427,7 +425,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 { @@ -442,7 +439,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 { @@ -608,7 +604,6 @@ export default class IgcDateRangePickerComponent extends FormAssociatedRequiredM protected override formResetCallback() { super.formResetCallback(); this._setCalendarRangeValues(); - this._updateMaskedRangeValue(); } // #endregion @@ -622,6 +617,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(); } } @@ -646,13 +644,11 @@ export default class IgcDateRangePickerComponent extends FormAssociatedRequiredM @watch('locale') protected _updateDefaultMask(): void { this._defaultMask = DateTimeUtil.getDefaultMask(this.locale); - this._updateMaskedRangeValue(); } @watch('useTwoInputs') protected async _updateDateRange() { await this._calendar?.updateComplete; - this._updateMaskedRangeValue(); this._setCalendarRangeValues(); this._delegateInputsValidity(); } @@ -723,7 +719,41 @@ export default class IgcDateRangePickerComponent extends FormAssociatedRequiredM 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 _handleDateRangeInputEvent(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 _handleDateRangeInputChangeEvent(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 }; @@ -737,12 +767,6 @@ export default class IgcDateRangePickerComponent extends FormAssociatedRequiredM protected _handleFocusOut({ relatedTarget }: FocusEvent) { if (!this.contains(relatedTarget as Node)) { this.checkValidity(); - - const isSameValue = equal(this.value, this._oldValue); - if (!(this.useTwoInputs || this.readOnly || isSameValue)) { - this.emitEvent('igcChange', { detail: this.value }); - this._oldValue = this.value; - } } } @@ -855,29 +879,6 @@ export default class IgcDateRangePickerComponent extends FormAssociatedRequiredM this._dateConstraints = isEmpty(dates) ? [] : dates; } - private _updateMaskedRangeValue() { - if (this.useTwoInputs) { - return; - } - - if (!isCompleteDateRange(this.value)) { - this._maskedRangeValue = ''; - return; - } - - const { formatDate, predefinedToDateDisplayFormat } = DateTimeUtil; - - const { start, end } = this.value; - const format = - predefinedToDateDisplayFormat(this._displayFormat) ?? - this._displayFormat ?? - this.inputFormat; - - this._maskedRangeValue = format - ? `${formatDate(start, this.locale, format)} - ${formatDate(end, this.locale, format)}` - : `${start.toLocaleDateString()} - ${end.toLocaleDateString()}`; - } - private _setCalendarRangeValues() { if (!this._calendar) { return; @@ -930,7 +931,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` @@ -951,7 +952,7 @@ export default class IgcDateRangePickerComponent extends FormAssociatedRequiredM : nothing; } - private _renderCalendarIcon(picker: DateRangePickerInput = 'start') { + private _renderCalendarIcon(picker = DateRangePosition.Start) { const defaultIcon = html` `; @@ -1091,20 +1092,28 @@ 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; - const label = picker === 'start' ? this.labelStart : this.labelEnd; + picker === DateRangePosition.Start + ? this.placeholderStart + : this.placeholderEnd; + const label = + picker === DateRangePosition.Start ? this.labelStart : this.labelEnd; const format = DateTimeUtil.predefinedToDateDisplayFormat( this._displayFormat! ); - const value = picker === 'start' ? this.value?.start : this.value?.end; + const value = + picker === DateRangePosition.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'; @@ -1143,32 +1152,40 @@ 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 = DateTimeUtil.predefinedToDateDisplayFormat( + this._displayFormat! + )!; const prefix = isEmpty(this._prefixes) ? undefined : 'prefix'; const suffix = isEmpty(this._suffixes) ? undefined : 'suffix'; return html` - ${this._renderClearIcon()} - + ${this._renderHelperText()} ${this._renderPicker(id)} `; } @@ -1201,5 +1218,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 e0dbed229..de3c79d29 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 @@ -6,7 +6,7 @@ import { equal } from '../common/util.js'; import { checkDatesEqual, simulateClick } from '../common/utils.spec.js'; import IgcDateTimeInputComponent from '../date-time-input/date-time-input.js'; import { DateTimeUtil } from '../date-time-input/date-util.js'; -import IgcInputComponent from '../input/input.js'; +import IgcDateRangeInputComponent from './date-range-input.js'; import type IgcDateRangePickerComponent from './date-range-picker.js'; import type { DateRangeValue } from './date-range-picker.js'; @@ -50,7 +50,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 ? DateTimeUtil.formatDate( expectedValue.start, @@ -96,3 +96,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-time-input/date-time-input.base.ts b/src/components/date-time-input/date-time-input.base.ts new file mode 100644 index 000000000..ed8df7b65 --- /dev/null +++ b/src/components/date-time-input/date-time-input.base.ts @@ -0,0 +1,400 @@ +import { html } from 'lit'; +import { eventOptions, property } from 'lit/decorators.js'; +import { live } from 'lit/directives/live.js'; +import { + addKeybindings, + altKey, + arrowDown, + arrowLeft, + arrowRight, + arrowUp, + ctrlKey, +} from '../common/controllers/key-bindings.js'; +import { partMap } from '../common/part-map.js'; +import { noop } from '../common/util.js'; +import { + IgcMaskInputBaseComponent, + type MaskRange, +} from '../mask-input/mask-input-base.js'; + +import { ifDefined } from 'lit/directives/if-defined.js'; +import { convertToDate } from '../calendar/helpers.js'; +import { watch } from '../common/decorators/watch.js'; +import type { AbstractConstructor } from '../common/mixins/constructor.js'; +import { EventEmitterMixin } from '../common/mixins/event-emitter.js'; +import type { DateRangeValue } from '../date-range-picker/date-range-picker.js'; +import type { IgcInputComponentEventMap } from '../input/input-base.js'; +import { type DatePartDeltas, DateParts, DateTimeUtil } from './date-util.js'; +import type { + DatePart, + DatePartInfo, + DateRangePart, + DateRangePartInfo, +} from './date-util.js'; +import { dateTimeInputValidators } from './validators.js'; + +export interface IgcDateTimeInputComponentEventMap + extends Omit { + igcChange: CustomEvent; +} +export abstract class IgcDateTimeInputBaseComponent< + TValue extends Date | DateRangeValue | string | null, + TPart extends DatePart | DateRangePart, + TPartInfo extends DatePartInfo | DateRangePartInfo, +> extends EventEmitterMixin< + IgcDateTimeInputComponentEventMap, + AbstractConstructor +>(IgcMaskInputBaseComponent) { + // #region Internal state & properties + + protected override get __validators() { + return dateTimeInputValidators; + } + private _min: Date | null = null; + private _max: Date | null = null; + protected _defaultMask!: string; + protected _oldValue: TValue | null = null; + protected _inputDateParts!: TPartInfo[]; + protected _inputFormat!: string; + + protected abstract _datePartDeltas: DatePartDeltas; + protected abstract get targetDatePart(): TPart | undefined; + + protected get hasDateParts(): boolean { + const parts = + this._inputDateParts || + DateTimeUtil.parseDateTimeFormat(this.inputFormat); + + return parts.some( + (p) => + p.type === DateParts.Date || + p.type === DateParts.Month || + p.type === DateParts.Year + ); + } + + protected get hasTimeParts(): boolean { + const parts = + this._inputDateParts || + DateTimeUtil.parseDateTimeFormat(this.inputFormat); + return parts.some( + (p) => + p.type === DateParts.Hours || + p.type === DateParts.Minutes || + p.type === DateParts.Seconds + ); + } + + protected get datePartDeltas(): DatePartDeltas { + return Object.assign({}, this._datePartDeltas, this.spinDelta); + } + + // #endregion + + // #region Public 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._defaultMask; + } + + public set inputFormat(val: string) { + if (val) { + this.setMask(val); + this._inputFormat = val; + if (this.value) { + this.updateMask(); + } + } + } + + /** + * 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._updateValidity(); + } + + 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._updateValidity(); + } + + public get max(): Date | null { + return this._max; + } + + /** + * Format to display the value in when not editing. + * Defaults to the input format if not set. + * @attr display-format + */ + @property({ attribute: 'display-format' }) + public displayFormat!: string; + + /** + * 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; + + /** + * The locale settings used to display the value. + * @attr + */ + @property() + public locale = 'en'; + + // #endregion + + // #region Lifecycle & observers + + constructor() { + super(); + + addKeybindings(this, { + skip: () => this.readOnly, + bindingDefaults: { preventDefault: true, triggers: ['keydownRepeat'] }, + }) + // Skip default spin when in the context of a date picker + .set([altKey, arrowUp], noop) + .set([altKey, arrowDown], noop) + + .set([ctrlKey, ';'], this.setToday) + .set(arrowUp, this.keyboardSpin.bind(this, 'up')) + .set(arrowDown, this.keyboardSpin.bind(this, 'down')) + .set([ctrlKey, arrowLeft], this.navigateParts.bind(this, 0)) + .set([ctrlKey, arrowRight], this.navigateParts.bind(this, 1)); + } + + public override connectedCallback() { + super.connectedCallback(); + this.updateDefaultMask(); + this.setMask(this.inputFormat); + this._updateValidity(); + if (this.value) { + this.updateMask(); + } + } + + @watch('locale', { waitUntilFirstUpdate: true }) + protected _setDefaultMask(): void { + if (!this._inputFormat) { + this.updateDefaultMask(); + this.setMask(this._defaultMask); + } + + if (this.value) { + this.updateMask(); + } + } + + @watch('displayFormat', { waitUntilFirstUpdate: true }) + protected _setDisplayFormat(): void { + if (this.value) { + this.updateMask(); + } + } + + @watch('prompt', { waitUntilFirstUpdate: true }) + protected _promptChange(): void { + if (!this.prompt) { + this.prompt = this.parser.prompt; + } else { + this.parser.prompt = this.prompt; + } + } + + // #endregion + + // #region Methods + + /** Increments a date/time portion. */ + public stepUp(datePart?: TPart, delta?: number): void { + const targetPart = datePart || this.targetDatePart; + + if (!targetPart) { + return; + } + + const { start, end } = this.inputSelection; + const newValue = this.trySpinValue(targetPart, delta); + this.value = newValue as TValue; + this.updateComplete.then(() => this.input.setSelectionRange(start, end)); + } + + /** Decrements a date/time portion. */ + public stepDown(datePart?: TPart, delta?: number): void { + const targetPart = datePart || this.targetDatePart; + + if (!targetPart) { + return; + } + + const { start, end } = this.inputSelection; + const newValue = this.trySpinValue(targetPart, delta, true); + this.value = newValue; + this.updateComplete.then(() => this.input.setSelectionRange(start, end)); + } + + /** Clears the input element of user input. */ + public clear(): void { + this.maskedValue = ''; + this.value = null; + } + + protected setToday() { + this.value = new Date() as TValue; + this.handleInput(); + } + + protected handleDragLeave() { + if (!this.focused) { + this.updateMask(); + } + } + + protected handleDragEnter() { + if (!this.focused) { + this.maskedValue = this.getMaskedValue(); + } + } + + protected async updateInput(string: string, range: MaskRange) { + const { value, end } = this.parser.replace( + this.maskedValue, + string, + range.start, + range.end + ); + + this.maskedValue = value; + + this.updateValue(); + this.requestUpdate(); + + if (range.start !== this.inputFormat.length) { + this.handleInput(); + } + await this.updateComplete; + this.input.setSelectionRange(end, end); + } + + protected trySpinValue( + datePart: TPart, + delta?: number, + negative = false + ): TValue { + // default to 1 if a delta is set to 0 or any other falsy value + const _delta = + delta || this.datePartDeltas[datePart as keyof DatePartDeltas] || 1; + + const spinValue = negative ? -Math.abs(_delta) : Math.abs(_delta); + return this.spinValue(datePart, spinValue); + } + + protected isComplete(): boolean { + return !this.maskedValue.includes(this.prompt); + } + + protected override _updateSetRangeTextValue() { + this.updateValue(); + } + + protected navigateParts(delta: number) { + const position = this.getNewPosition(this.input.value, delta); + this.setSelectionRange(position, position); + } + + protected async keyboardSpin(direction: 'up' | 'down') { + direction === 'up' ? this.stepUp() : this.stepDown(); + this.handleInput(); + await this.updateComplete; + this.setSelectionRange(this.selection.start, this.selection.end); + } + + @eventOptions({ passive: false }) + private async onWheel(event: WheelEvent) { + if (!this.focused || this.readOnly) { + return; + } + + event.preventDefault(); + event.stopPropagation(); + + const { start, end } = this.inputSelection; + event.deltaY > 0 ? this.stepDown() : this.stepUp(); + this.handleInput(); + + await this.updateComplete; + this.setSelectionRange(start, end); + } + + protected updateDefaultMask(): void { + this._defaultMask = DateTimeUtil.getDefaultMask(this.locale); + } + + protected override renderInput() { + return html` + + `; + } + + protected abstract override handleInput(): void; + protected abstract updateMask(): void; + protected abstract updateValue(): void; + protected abstract getNewPosition(value: string, direction: number): number; + protected abstract spinValue(datePart: TPart, delta: number): TValue; + protected abstract setMask(string: string): void; + protected abstract getMaskedValue(): string; + protected abstract handleBlur(): void; + protected abstract handleFocus(): Promise; + + // #endregion +} diff --git a/src/components/date-time-input/date-time-input.ts b/src/components/date-time-input/date-time-input.ts index a6c222f7c..184a43f94 100644 --- a/src/components/date-time-input/date-time-input.ts +++ b/src/components/date-time-input/date-time-input.ts @@ -1,35 +1,13 @@ -import { html } 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 { convertToDate } from '../calendar/helpers.js'; -import { - addKeybindings, - altKey, - arrowDown, - arrowLeft, - arrowRight, - arrowUp, - ctrlKey, -} from '../common/controllers/key-bindings.js'; -import { watch } from '../common/decorators/watch.js'; import { registerComponent } from '../common/definitions/register.js'; -import type { AbstractConstructor } from '../common/mixins/constructor.js'; -import { EventEmitterMixin } from '../common/mixins/event-emitter.js'; import { type FormValue, createFormValueState, defaultDateTimeTransformers, } from '../common/mixins/forms/form-value.js'; -import { partMap } from '../common/part-map.js'; -import { noop } from '../common/util.js'; -import type { IgcInputComponentEventMap } from '../input/input-base.js'; -import { - IgcMaskInputBaseComponent, - type MaskRange, -} from '../mask-input/mask-input-base.js'; import IgcValidationContainerComponent from '../validation-container/validation-container.js'; +import { IgcDateTimeInputBaseComponent } from './date-time-input.base.js'; import { DatePart, type DatePartDeltas, @@ -37,12 +15,6 @@ import { DateParts, DateTimeUtil, } from './date-util.js'; -import { dateTimeInputValidators } from './validators.js'; - -export interface IgcDateTimeInputComponentEventMap - extends Omit { - igcChange: CustomEvent; -} /** * A date time input is an input field that lets you set and edit the date and time in a chosen input element @@ -69,10 +41,11 @@ export interface IgcDateTimeInputComponentEventMap * @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, + DatePartInfo +> { public static readonly tagName = 'igc-date-time-input'; /* blazorSuppress */ @@ -83,20 +56,9 @@ export default class IgcDateTimeInputComponent extends EventEmitterMixin< ); } - protected override get __validators() { - return dateTimeInputValidators; - } - protected override _formValue: FormValue; - protected _defaultMask!: string; - private _oldValue: Date | null = null; - private _min: Date | null = null; - private _max: Date | null = null; - - private _inputDateParts!: DatePartInfo[]; - private _inputFormat!: string; - private _datePartDeltas: DatePartDeltas = { + protected override _datePartDeltas: DatePartDeltas = { date: 1, month: 1, year: 1, @@ -105,25 +67,6 @@ export default class IgcDateTimeInputComponent extends EventEmitterMixin< seconds: 1, }; - /** - * The date format to apply on the input. - * @attr input-format - */ - @property({ attribute: 'input-format' }) - public get inputFormat(): string { - return this._inputFormat || this._defaultMask; - } - - public set inputFormat(val: string) { - if (val) { - this.setMask(val); - this._inputFormat = val; - if (this.value) { - this.updateMask(); - } - } - } - public get value(): Date | null { return this._formValue.value; } @@ -140,117 +83,7 @@ export default class IgcDateTimeInputComponent extends EventEmitterMixin< this._validate(); } - /** - * 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._updateValidity(); - } - - 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._updateValidity(); - } - - public get max(): Date | null { - return this._max; - } - - /** - * Format to display the value in when not editing. - * Defaults to the input format if not set. - * @attr display-format - */ - @property({ attribute: 'display-format' }) - public displayFormat!: string; - - /** - * 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; - - /** - * The locale settings used to display the value. - * @attr - */ - @property() - public locale = 'en'; - - @watch('locale', { waitUntilFirstUpdate: true }) - protected setDefaultMask(): void { - if (!this._inputFormat) { - this.updateDefaultMask(); - this.setMask(this._defaultMask); - } - - if (this.value) { - this.updateMask(); - } - } - - @watch('displayFormat', { waitUntilFirstUpdate: true }) - protected setDisplayFormat(): void { - if (this.value) { - this.updateMask(); - } - } - - @watch('prompt', { waitUntilFirstUpdate: true }) - protected promptChange(): void { - if (!this.prompt) { - this.prompt = this.parser.prompt; - } else { - this.parser.prompt = this.prompt; - } - } - - protected get hasDateParts(): boolean { - const parts = - this._inputDateParts || - DateTimeUtil.parseDateTimeFormat(this.inputFormat); - - return parts.some( - (p) => - p.type === DateParts.Date || - p.type === DateParts.Month || - p.type === DateParts.Year - ); - } - - protected get hasTimeParts(): boolean { - const parts = - this._inputDateParts || - DateTimeUtil.parseDateTimeFormat(this.inputFormat); - return parts.some( - (p) => - p.type === DateParts.Hours || - p.type === DateParts.Minutes || - p.type === DateParts.Seconds - ); - } - - private get targetDatePart(): DatePart | undefined { + protected override get targetDatePart(): DatePart | undefined { let result: DatePart | undefined; if (this.focused) { @@ -275,10 +108,6 @@ export default class IgcDateTimeInputComponent extends EventEmitterMixin< return result; } - private get datePartDeltas(): DatePartDeltas { - return Object.assign({}, this._datePartDeltas, this.spinDelta); - } - constructor() { super(); @@ -286,69 +115,6 @@ export default class IgcDateTimeInputComponent extends EventEmitterMixin< initialValue: null, transformers: defaultDateTimeTransformers, }); - - addKeybindings(this, { - skip: () => this.readOnly, - bindingDefaults: { preventDefault: true, triggers: ['keydownRepeat'] }, - }) - // Skip default spin when in the context of a date picker - .set([altKey, arrowUp], noop) - .set([altKey, arrowDown], noop) - - .set([ctrlKey, ';'], this.setToday) - .set(arrowUp, this.keyboardSpin.bind(this, 'up')) - .set(arrowDown, this.keyboardSpin.bind(this, 'down')) - .set([ctrlKey, arrowLeft], this.navigateParts.bind(this, 0)) - .set([ctrlKey, arrowRight], this.navigateParts.bind(this, 1)); - } - - public override connectedCallback() { - super.connectedCallback(); - this.updateDefaultMask(); - this.setMask(this.inputFormat); - this._updateValidity(); - if (this.value) { - this.updateMask(); - } - } - - /** Increments a date/time portion. */ - public stepUp(datePart?: DatePart, delta?: number): void { - const targetPart = datePart || this.targetDatePart; - - if (!targetPart) { - return; - } - - const { start, end } = this.inputSelection; - const newValue = this.trySpinValue(targetPart, delta); - this.value = newValue; - this.updateComplete.then(() => this.input.setSelectionRange(start, end)); - } - - /** Decrements a date/time portion. */ - public stepDown(datePart?: DatePart, delta?: number): void { - const targetPart = datePart || this.targetDatePart; - - if (!targetPart) { - return; - } - - const { start, end } = this.inputSelection; - const newValue = this.trySpinValue(targetPart, delta, true); - this.value = newValue; - this.updateComplete.then(() => this.input.setSelectionRange(start, end)); - } - - /** Clears the input element of user input. */ - public clear(): void { - this.maskedValue = ''; - this.value = null; - } - - protected setToday() { - this.value = new Date(); - this.handleInput(); } protected updateMask() { @@ -385,52 +151,7 @@ export default class IgcDateTimeInputComponent extends EventEmitterMixin< this.emitEvent('igcInput', { detail: this.value?.toString() }); } - protected handleDragLeave() { - if (!this.focused) { - this.updateMask(); - } - } - - protected handleDragEnter() { - if (!this.focused) { - this.maskedValue = this.getMaskedValue(); - } - } - - protected async updateInput(string: string, range: MaskRange) { - const { value, end } = this.parser.replace( - this.maskedValue, - string, - range.start, - range.end - ); - - this.maskedValue = value; - - this.updateValue(); - this.requestUpdate(); - - if (range.start !== this.inputFormat.length) { - this.handleInput(); - } - await this.updateComplete; - this.input.setSelectionRange(end, end); - } - - private trySpinValue( - datePart: DatePart, - delta?: number, - negative = false - ): Date { - // default to 1 if a delta is set to 0 or any other falsy value - const _delta = - delta || this.datePartDeltas[datePart as keyof DatePartDeltas] || 1; - - const spinValue = negative ? -Math.abs(_delta) : Math.abs(_delta); - return this.spinValue(datePart, spinValue); - } - - private spinValue(datePart: DatePart, delta: number): Date { + protected override spinValue(datePart: DatePart, delta: number): Date { if (!(this.value && DateTimeUtil.isValidDate(this.value))) { return new Date(); } @@ -475,28 +196,26 @@ export default class IgcDateTimeInputComponent extends EventEmitterMixin< return newDate; } - @eventOptions({ passive: false }) - private async onWheel(event: WheelEvent) { - if (!this.focused || this.readOnly) { + protected override async handleFocus() { + this.focused = true; + + if (this.readOnly) { return; } - event.preventDefault(); - event.stopPropagation(); - - const { start, end } = this.inputSelection; - event.deltaY > 0 ? this.stepDown() : this.stepUp(); - this.handleInput(); - - await this.updateComplete; - this.setSelectionRange(start, end); - } + this._oldValue = this.value; + const areFormatsDifferent = this.displayFormat !== this.inputFormat; - private updateDefaultMask(): void { - this._defaultMask = DateTimeUtil.getDefaultMask(this.locale); + if (!this.value) { + this.maskedValue = this.emptyMask; + await this.updateComplete; + this.select(); + } else if (areFormatsDifferent) { + this.updateMask(); + } } - private setMask(string: string): void { + protected setMask(string: string): void { const oldFormat = this._inputDateParts?.map((p) => p.format).join(''); this._inputDateParts = DateTimeUtil.parseDateTimeFormat(string); const value = this._inputDateParts.map((p) => p.format).join(''); @@ -520,13 +239,13 @@ export default class IgcDateTimeInputComponent extends EventEmitterMixin< } } - private parseDate(val: string) { + private _parseDate(val: string) { return val ? DateTimeUtil.parseValueFromMask(val, this._inputDateParts, this.prompt) : null; } - private getMaskedValue(): string { + protected getMaskedValue(): string { let mask = this.emptyMask; if (DateTimeUtil.isValidDate(this.value)) { @@ -554,24 +273,16 @@ export default class IgcDateTimeInputComponent extends EventEmitterMixin< return this.maskedValue === '' ? mask : this.maskedValue; } - private isComplete(): boolean { - return !this.maskedValue.includes(this.prompt); - } - - private updateValue(): void { + protected updateValue(): void { if (this.isComplete()) { - const parsedDate = this.parseDate(this.maskedValue); + const parsedDate = this._parseDate(this.maskedValue); this.value = DateTimeUtil.isValidDate(parsedDate) ? parsedDate : null; } else { this.value = null; } } - protected override _updateSetRangeTextValue() { - this.updateValue(); - } - - private getNewPosition(value: string, direction = 0): number { + protected getNewPosition(value: string, direction = 0): number { const cursorPos = this.selection.start; if (!direction) { @@ -589,32 +300,13 @@ export default class IgcDateTimeInputComponent extends EventEmitterMixin< return part?.start ?? value.length; } - protected async handleFocus() { - this.focused = true; - - if (this.readOnly) { - return; - } - - this._oldValue = this.value; - const areFormatsDifferent = this.displayFormat !== this.inputFormat; - - if (!this.value) { - this.maskedValue = this.emptyMask; - await this.updateComplete; - this.select(); - } else if (areFormatsDifferent) { - this.updateMask(); - } - } - - protected handleBlur() { + protected override handleBlur() { const isEmptyMask = this.maskedValue === this.emptyMask; this.focused = false; if (!(this.isComplete() || isEmptyMask)) { - const parse = this.parseDate(this.maskedValue); + const parse = this._parseDate(this.maskedValue); if (parse) { this.value = parse; @@ -634,44 +326,6 @@ export default class IgcDateTimeInputComponent extends EventEmitterMixin< this.checkValidity(); } - - protected navigateParts(delta: number) { - const position = this.getNewPosition(this.input.value, delta); - this.setSelectionRange(position, position); - } - - protected async keyboardSpin(direction: 'up' | 'down') { - direction === 'up' ? this.stepUp() : this.stepDown(); - this.handleInput(); - await this.updateComplete; - this.setSelectionRange(this.selection.start, this.selection.end); - } - - protected override renderInput() { - return html` - - `; - } } declare global { diff --git a/src/components/date-time-input/date-util.ts b/src/components/date-time-input/date-util.ts index 53181570e..2e107eb31 100644 --- a/src/components/date-time-input/date-util.ts +++ b/src/components/date-time-input/date-util.ts @@ -36,6 +36,24 @@ export interface DatePartInfo { format: string; } +/** @ignore */ +export enum DateRangePosition { + Start = 'start', + End = 'end', + Separator = 'separator', +} + +/** @ignore */ +export interface DateRangePart { + part: DatePart; + position: DateRangePosition; +} + +/** @ignore */ +export interface DateRangePartInfo extends DatePartInfo { + position?: DateRangePosition; +} + export interface DatePartDeltas { date?: number; month?: number; diff --git a/src/components/input/input-base.ts b/src/components/input/input-base.ts index 257ddc300..b71b4f159 100644 --- a/src/components/input/input-base.ts +++ b/src/components/input/input-base.ts @@ -8,6 +8,7 @@ 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 { createCounter } from '../common/util.js'; +import type { DateRangeValue } from '../date-range-picker/date-range-picker.js'; import type { RangeTextSelectMode, SelectionRangeDirection } from '../types.js'; import IgcValidationContainerComponent from '../validation-container/validation-container.js'; import { styles } from './themes/input.base.css.js'; @@ -47,7 +48,7 @@ export abstract class IgcInputBaseComponent extends FormAssociatedRequiredMixin( /* blazorSuppress */ /** The value attribute of the control. */ - public abstract value: string | Date | null; + public abstract value: string | Date | DateRangeValue | null; @query('input') protected input!: HTMLInputElement; diff --git a/src/index.ts b/src/index.ts index 3c750698b..26303d23b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -100,7 +100,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 adc89be89..0fb9c562e 100644 --- a/stories/date-range-picker.stories.ts +++ b/stories/date-range-picker.stories.ts @@ -448,7 +448,6 @@ export const Default: Story = { render: (args) => html` = { actions: { handles: ['igcInput', 'igcChange'] }, }, argTypes: { + value: { + type: 'string | Date | DateRangeValue', + description: 'The value of the input.', + options: ['string', 'Date', 'DateRangeValue'], + control: 'text', + }, inputFormat: { type: 'string', description: 'The date format to apply on the input.', control: 'text', }, - value: { - type: 'string | Date', - description: 'The value of the input.', - options: ['string', 'Date'], - control: 'text', - }, min: { type: 'Date', description: 'The minimum value required for the input to remain valid.', @@ -132,10 +133,10 @@ const metadata: Meta = { export default metadata; interface IgcDateTimeInputArgs { + /** The value of the input. */ + value: string | Date | DateRangeValue; /** 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. */ diff --git a/stories/file-input.stories.ts b/stories/file-input.stories.ts index f6c2f1a83..ac2da9f3a 100644 --- a/stories/file-input.stories.ts +++ b/stories/file-input.stories.ts @@ -10,6 +10,7 @@ import { registerIcon, registerIconFromText, } from 'igniteui-webcomponents'; +import type { DateRangeValue } from '../src/components/date-range-picker/date-range-picker.js'; import { formControls, formSubmitHandler } from './story.js'; defineComponents(IgcFileInputComponent, IgcIconComponent); @@ -29,9 +30,9 @@ const metadata: Meta = { }, argTypes: { value: { - type: 'string | Date', + type: 'string | Date | DateRangeValue', description: 'The value of the control.', - options: ['string', 'Date'], + options: ['string', 'Date', 'DateRangeValue'], control: 'text', }, multiple: { @@ -110,7 +111,7 @@ export default metadata; interface IgcFileInputArgs { /** The value of the control. */ - value: string | Date; + value: string | Date | DateRangeValue; /** * The multiple attribute of the control. * Used to indicate that a file input allows the user to select more than one file. diff --git a/stories/input.stories.ts b/stories/input.stories.ts index 7513c9b90..395922c01 100644 --- a/stories/input.stories.ts +++ b/stories/input.stories.ts @@ -10,6 +10,7 @@ import { registerIcon, registerIconFromText, } from 'igniteui-webcomponents'; +import type { DateRangeValue } from '../src/components/date-range-picker/date-range-picker.js'; import { disableStoryControls, formControls, @@ -33,9 +34,9 @@ const metadata: Meta = { }, argTypes: { value: { - type: 'string | Date', + type: 'string | Date | DateRangeValue', description: 'The value of the control.', - options: ['string', 'Date'], + options: ['string', 'Date', 'DateRangeValue'], control: 'text', }, type: { @@ -162,7 +163,7 @@ export default metadata; interface IgcInputArgs { /** The value of the control. */ - value: string | Date; + value: string | Date | DateRangeValue; /** The type attribute of the control. */ type: 'email' | 'number' | 'password' | 'search' | 'tel' | 'text' | 'url'; /** diff --git a/stories/mask-input.stories.ts b/stories/mask-input.stories.ts index 9687bf60f..72e053f82 100644 --- a/stories/mask-input.stories.ts +++ b/stories/mask-input.stories.ts @@ -9,6 +9,7 @@ import { defineComponents, registerIconFromText, } from 'igniteui-webcomponents'; +import type { DateRangeValue } from '../src/components/date-range-picker/date-range-picker.js'; import { disableStoryControls, formControls, @@ -41,10 +42,10 @@ const metadata: Meta = { table: { defaultValue: { summary: 'raw' } }, }, value: { - type: 'string | Date', + type: 'string | Date | DateRangeValue', description: 'The value of the input.\n\nRegardless of the currently set `value-mode`, an empty value will return an empty string.', - options: ['string', 'Date'], + options: ['string', 'Date', 'DateRangeValue'], control: 'text', }, mask: { @@ -129,7 +130,7 @@ interface IgcMaskInputArgs { * * Regardless of the currently set `value-mode`, an empty value will return an empty string. */ - value: string | Date; + value: string | Date | DateRangeValue; /** The mask pattern to apply on the input. */ mask: string; /** The prompt symbol to use for unfilled parts of the mask. */ From 773dc529e96c278c760ea154bf926bd8d9182418 Mon Sep 17 00:00:00 2001 From: ddaribo Date: Tue, 17 Jun 2025 11:36:12 +0300 Subject: [PATCH 02/14] feat(drp): handle the alt + arrow down/up focus properly for single input; add test for both input modes --- .../date-range-picker-single.spec.ts | 27 +++++++++++++++++++ .../date-range-picker-two-inputs.spec.ts | 24 +++++++++++++++++ .../date-range-picker/date-range-picker.ts | 2 +- 3 files changed, 52 insertions(+), 1 deletion(-) 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 5bb04a0c7..563ff62d1 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 @@ -9,6 +9,8 @@ import { spy } from 'sinon'; import IgcCalendarComponent from '../calendar/calendar.js'; import { CalendarDay } from '../calendar/model.js'; import { + altKey, + arrowDown, arrowLeft, arrowRight, arrowUp, @@ -820,6 +822,31 @@ describe('Date range picker - single input', () => { await elementUpdated(picker); expect(input.value).to.equal('01/01/2000 - 04/23/2025'); }); + + it('should toggle the calendar dropdown with alt + arrow down/up and keep it focused', async () => { + const eventSpy = spy(picker, 'emitEvent'); + input = getInput(picker); + input.focus(); + + expect(isFocused(input)).to.be.true; + + simulateKeyboard(input, [altKey, arrowDown]); + await elementUpdated(picker); + + expect(picker.open).to.be.true; + expect(isFocused(input)).to.be.false; + expect(eventSpy.firstCall).calledWith('igcOpening'); + expect(eventSpy.lastCall).calledWith('igcOpened'); + eventSpy.resetHistory(); + + simulateKeyboard(input, [altKey, arrowUp]); + await elementUpdated(picker); + + expect(picker.open).to.be.false; + expect(isFocused(input)).to.be.true; + expect(eventSpy.firstCall).calledWith('igcClosing'); + expect(eventSpy.lastCall).calledWith('igcClosed'); + }); }); describe('Readonly state', () => { 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 a05c7b519..d4d786af4 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 @@ -9,6 +9,7 @@ import { spy } from 'sinon'; import IgcCalendarComponent from '../calendar/calendar.js'; import { CalendarDay } from '../calendar/model.js'; import { + altKey, arrowDown, arrowUp, escapeKey, @@ -802,6 +803,29 @@ describe('Date range picker - two inputs', () => { checkDatesEqual(calendar.activeDate, june3rd2025); }); + it('should toggle the calendar dropdown with alt + arrow down/up and keep it focused', async () => { + const eventSpy = spy(picker, 'emitEvent'); + dateTimeInputs[0].focus(); + + expect(isFocused(dateTimeInputs[0])).to.be.true; + + simulateKeyboard(dateTimeInputs[0], [altKey, arrowDown]); + await elementUpdated(picker); + + expect(picker.open).to.be.true; + expect(isFocused(dateTimeInputs[0])).to.be.false; + expect(eventSpy.firstCall).calledWith('igcOpening'); + expect(eventSpy.lastCall).calledWith('igcOpened'); + eventSpy.resetHistory(); + + simulateKeyboard(dateTimeInputs[0], [altKey, arrowUp]); + await elementUpdated(picker); + + expect(picker.open).to.be.false; + expect(isFocused(dateTimeInputs[0])).to.be.true; + expect(eventSpy.firstCall).calledWith('igcClosing'); + expect(eventSpy.lastCall).calledWith('igcClosed'); + }); }); describe('Readonly state', () => { beforeEach(async () => { diff --git a/src/components/date-range-picker/date-range-picker.ts b/src/components/date-range-picker/date-range-picker.ts index b1dc738d4..3b9e59fc2 100644 --- a/src/components/date-range-picker/date-range-picker.ts +++ b/src/components/date-range-picker/date-range-picker.ts @@ -782,7 +782,7 @@ export default class IgcDateRangePickerComponent extends FormAssociatedRequiredM if (!this._isDropDown) { this._revertValue(); } - this._inputs[0]?.focus(); + this.useTwoInputs ? this._inputs[0].focus() : this._input.focus(); } } From 52b457143a8becd435f9792048dca6dd5e0734c9 Mon Sep 17 00:00:00 2001 From: Bozhidara Pachilova Date: Fri, 20 Feb 2026 17:59:24 +0200 Subject: [PATCH 03/14] refactor(date-time): single input working, wip --- .../date-range-picker/date-range-input.ts | 66 +-- .../date-time-input/date-time-input.base.ts | 106 ++-- .../date-time-input/date-time-input.ts | 474 +++++++++++------- stories/date-time-input.stories.ts | 53 +- 4 files changed, 422 insertions(+), 277 deletions(-) diff --git a/src/components/date-range-picker/date-range-input.ts b/src/components/date-range-picker/date-range-input.ts index 0258ac7f8..dbbac0639 100644 --- a/src/components/date-range-picker/date-range-input.ts +++ b/src/components/date-range-picker/date-range-input.ts @@ -87,6 +87,7 @@ export default class IgcDateRangeInputComponent extends IgcDateTimeInputBaseComp transformers: FormValueDateRangeTransformers, }); + protected override readonly _parser = new DateTimeMaskParser(); private _startParser: DateTimeMaskParser = new DateTimeMaskParser(); private _endParser: DateTimeMaskParser = new DateTimeMaskParser(); @@ -96,9 +97,7 @@ export default class IgcDateRangeInputComponent extends IgcDateTimeInputBaseComp year: 1, }; - private _oldRangeValue: DateRangeValue | null = null; - - protected override _inputFormat!: string; + protected override _inputFormat = ''; /** * Format to display the value in when not editing. @@ -123,11 +122,11 @@ export default class IgcDateRangeInputComponent extends IgcDateTimeInputBaseComp return this._startParser.hasTimeParts(); } - protected override get targetDatePart(): DateRangePart | undefined { + protected override get _targetDatePart(): DateRangePart | undefined { let result: DateRangePart | undefined; if (this._focused) { - const part = this._inputDateParts.find( + const part = this._inputDateParts?.find( (p) => p.start <= this._inputSelection.start && this._inputSelection.start <= p.end && @@ -139,11 +138,13 @@ export default class IgcDateRangeInputComponent extends IgcDateTimeInputBaseComp result = { part: partType, position: part!.position! }; } } else { - const firstPart = this._inputDateParts[0]; - result = { - part: firstPart?.type as string as DatePart, - position: DateRangePosition.Start, - }; + const firstPart = this._inputDateParts?.[0]; + if (firstPart) { + result = { + part: firstPart.type as string as DatePart, + position: DateRangePosition.Start, + }; + } } return result; @@ -188,6 +189,14 @@ export default class IgcDateRangeInputComponent extends IgcDateTimeInputBaseComp this.updateMask(); } + protected override updateDefaultMask(): void { + if (!this._inputFormat) { + // Use a default date format from the start parser + const defaultFormat = 'MM/dd/yyyy'; + this.setMask(defaultFormat); + } + } + protected override setMask(string: string): void { const oldFormat = this._inputDateParts?.map((p) => p.format).join(''); @@ -321,7 +330,7 @@ export default class IgcDateRangeInputComponent extends IgcDateTimeInputBaseComp } protected override updateValue(): void { - if (this.isComplete()) { + if (this._isMaskComplete()) { const parsedRange = this._parseRangeValue(this._maskedValue); this.value = parsedRange; } else { @@ -335,7 +344,7 @@ export default class IgcDateRangeInputComponent extends IgcDateTimeInputBaseComp if (this.readOnly) { return; } - this._oldRangeValue = this.value; + this._oldValue = this.value; const areFormatsDifferent = this.displayFormat !== this.inputFormat; if (!this.value || !this.value.start || !this.value.end) { @@ -349,11 +358,11 @@ export default class IgcDateRangeInputComponent extends IgcDateTimeInputBaseComp public override handleBlur(): void { const isEmptyMask = this._maskedValue === this._parser.emptyMask; - const isSameValue = equal(this._oldRangeValue, this.value); + const isSameValue = equal(this._oldValue, this.value); this._focused = false; - if (!(this.isComplete() || isEmptyMask)) { + if (!(this._isMaskComplete() || isEmptyMask)) { const parse = this._parseRangeValue(this._maskedValue); if (parse) { @@ -373,6 +382,18 @@ export default class IgcDateRangeInputComponent extends IgcDateTimeInputBaseComp super._handleBlur(); } + private _parseRangeValue(value: string): DateRangeValue | null { + const dates = value.split(SINGLE_INPUT_SEPARATOR); + if (dates.length !== 2) { + return null; + } + + const start = this._startParser.parseDate(dates[0]); + const end = this._endParser.parseDate(dates[1]); + + return { start: start ?? null, end: end ?? null }; + } + protected override spinValue( datePart: DateRangePart, delta: number @@ -431,11 +452,6 @@ export default class IgcDateRangeInputComponent extends IgcDateTimeInputBaseComp } } - protected override handleInput() { - this._setTouchedState(); - this.emitEvent('igcInput', { detail: JSON.stringify(this.value) }); - } - private _setDatePartInMask( mask: string, parts: DateRangePartInfo[], @@ -473,18 +489,6 @@ export default class IgcDateRangeInputComponent extends IgcDateTimeInputBaseComp } return resultMask; } - - private _parseRangeValue(value: string): DateRangeValue | null { - const dates = value.split(SINGLE_INPUT_SEPARATOR); - if (dates.length !== 2) { - return null; - } - - const start = this._startParser.parseDate(dates[0]); - const end = this._endParser.parseDate(dates[1]); - - return { start: start ?? null, end: end ?? null }; - } } declare global { diff --git a/src/components/date-time-input/date-time-input.base.ts b/src/components/date-time-input/date-time-input.base.ts index c9d0cf944..bb505cb84 100644 --- a/src/components/date-time-input/date-time-input.base.ts +++ b/src/components/date-time-input/date-time-input.base.ts @@ -43,15 +43,17 @@ export abstract class IgcDateTimeInputBaseComponent< protected override get __validators() { return dateTimeInputValidators; } + protected _min: Date | null = null; protected _max: Date | null = null; protected _defaultMask!: string; protected _oldValue: TValue | null = null; protected _inputDateParts!: TPartInfo[]; protected _inputFormat = ''; + protected _defaultDisplayFormat = ''; - protected abstract _datePartDeltas: any; - protected abstract get targetDatePart(): TPart | undefined; + protected abstract get _datePartDeltas(): any; + protected abstract get _targetDatePart(): TPart | undefined; protected get hasDateParts(): boolean { // Override in subclass with specific implementation @@ -63,10 +65,6 @@ export abstract class IgcDateTimeInputBaseComponent< return false; } - protected get datePartDeltas(): any { - return Object.assign({}, this._datePartDeltas, this.spinDelta); - } - // #endregion // #region Public properties @@ -134,7 +132,7 @@ export abstract class IgcDateTimeInputBaseComponent< * All values default to `1`. */ @property({ attribute: false }) - public spinDelta: any = {}; + public spinDelta?: any; /** * Sets whether to loop over the currently spun segment. @@ -161,11 +159,11 @@ export abstract class IgcDateTimeInputBaseComponent< skip: () => this.readOnly, bindingDefaults: { triggers: ['keydownRepeat'] }, }) - .set([ctrlKey, ';'], this.setToday) - .set(arrowUp, this.keyboardSpin.bind(this, 'up')) - .set(arrowDown, this.keyboardSpin.bind(this, 'down')) - .set([ctrlKey, arrowLeft], this.navigateParts.bind(this, 0)) - .set([ctrlKey, arrowRight], this.navigateParts.bind(this, 1)); + .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)); } public override connectedCallback() { @@ -229,33 +227,59 @@ export abstract class IgcDateTimeInputBaseComponent< delta: number | undefined, isDecrement: boolean ): void { - const targetPart = datePart || this.targetDatePart; - if (!targetPart) return; + const part = datePart || this._targetDatePart; + if (!part) return; const { start, end } = this._inputSelection; - const newValue = this.trySpinValue(targetPart, delta, isDecrement); + const newValue = this._calculateSpunValue(part, delta, isDecrement); this.value = newValue as TValue; this.updateComplete.then(() => this._input?.setSelectionRange(start, end)); } + /** + * Calculates the new value after spinning a date part. + */ + protected _calculateSpunValue( + datePart: TPart, + delta: number | undefined, + isDecrement: boolean + ): TValue { + // Default to 1 if delta is 0 or undefined + const effectiveDelta = + delta || (this._datePartDeltas as any)[datePart as any] || 1; + const spinAmount = isDecrement + ? -Math.abs(effectiveDelta) + : Math.abs(effectiveDelta); + return this.spinValue(datePart, spinAmount); + } + /** Clears the input element of user input. */ public clear(): void { this._maskedValue = ''; this.value = null; } - protected setToday() { + /** + * Sets the value to the current date/time. + */ + protected _setCurrentDateTime(): void { this.value = new Date() as TValue; - this.handleInput(); + this._emitInputEvent(); } - protected handleDragLeave() { + /** + * Handles drag leave events. + */ + protected _handleDragLeave(): void { if (!this._focused) { this.updateMask(); } } - protected handleDragEnter() { + /** + * Handles drag enter events. + */ + protected _handleDragEnter(): void { if (!this._focused) { this._maskedValue = this.getMaskedValue(); } @@ -275,25 +299,16 @@ export abstract class IgcDateTimeInputBaseComponent< this.requestUpdate(); if (range.start !== this.inputFormat.length) { - this.handleInput(); + this._emitInputEvent(); } await this.updateComplete; this._input?.setSelectionRange(end, end); } - protected trySpinValue( - datePart: TPart, - delta?: number, - negative = false - ): TValue { - // default to 1 if a delta is set to 0 or any other falsy value - const _delta = delta || this.datePartDeltas[datePart as any] || 1; - - const spinValue = negative ? -Math.abs(_delta) : Math.abs(_delta); - return this.spinValue(datePart, spinValue); - } - - protected isComplete(): boolean { + /** + * Checks if all mask positions are filled (no prompt characters remain). + */ + protected _isMaskComplete(): boolean { return !this._maskedValue.includes(this.prompt); } @@ -303,39 +318,35 @@ export abstract class IgcDateTimeInputBaseComponent< /** * Navigates to the previous or next date part. - * @internal */ - protected navigateParts(delta: number): void { - const position = this.getNewPosition(this._input?.value ?? '', delta); + protected _navigateParts(direction: number): void { + const position = this.getNewPosition(this._input?.value ?? '', direction); this.setSelectionRange(position, position); } /** * Emits the input event after user interaction. - * @internal */ - protected emitInputEvent(): void { + protected _emitInputEvent(): void { this._setTouchedState(); this.emitEvent('igcInput', { detail: this.value?.toString() }); } /** * Handles keyboard-triggered spinning (arrow up/down). - * @internal */ - protected async keyboardSpin(direction: 'up' | 'down'): Promise { + protected async _keyboardSpin(direction: 'up' | 'down'): Promise { direction === 'up' ? this.stepUp() : this.stepDown(); - this.handleInput(); + this._emitInputEvent(); await this.updateComplete; this.setSelectionRange(this._maskSelection.start, this._maskSelection.end); } /** * Handles wheel events for spinning date parts. - * @internal */ @eventOptions({ passive: false }) - protected async handleWheel(event: WheelEvent): Promise { + protected async _handleWheel(event: WheelEvent): Promise { if (!this._focused || this.readOnly) return; event.preventDefault(); @@ -343,7 +354,7 @@ export abstract class IgcDateTimeInputBaseComponent< const { start, end } = this._inputSelection; event.deltaY > 0 ? this.stepDown() : this.stepUp(); - this.handleInput(); + this._emitInputEvent(); await this.updateComplete; this.setSelectionRange(start, end); @@ -366,20 +377,19 @@ export abstract class IgcDateTimeInputBaseComponent< @blur=${this.handleBlur} @focus=${this.handleFocus} @input=${super._handleInput} - @wheel=${this.handleWheel} + @wheel=${this._handleWheel} @keydown=${super._setMaskSelection} @click=${super._handleClick} @cut=${super._setMaskSelection} @compositionstart=${super._handleCompositionStart} @compositionend=${super._handleCompositionEnd} - @dragenter=${this.handleDragEnter} - @dragleave=${this.handleDragLeave} + @dragenter=${this._handleDragEnter} + @dragleave=${this._handleDragLeave} @dragstart=${super._setMaskSelection} /> `; } - protected abstract handleInput(): void; protected abstract updateMask(): void; protected abstract updateValue(): void; protected abstract getNewPosition(value: string, direction: number): number; diff --git a/src/components/date-time-input/date-time-input.ts b/src/components/date-time-input/date-time-input.ts index 0db979edf..2a13a4b5a 100644 --- a/src/components/date-time-input/date-time-input.ts +++ b/src/components/date-time-input/date-time-input.ts @@ -1,32 +1,37 @@ +import { getDateFormatter } from 'igniteui-i18n-core'; +import { html, type PropertyValues } from 'lit'; import { property } from 'lit/decorators.js'; +import { ifDefined } from 'lit/directives/if-defined.js'; +import { live } from 'lit/directives/live.js'; import { addThemingController } from '../../theming/theming-controller.js'; import { convertToDate, isValidDate } from '../calendar/helpers.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 { 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 { 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 IgcValidationContainerComponent from '../validation-container/validation-container.js'; -import { DatePart, type DatePartDeltas, DatePartType } from './date-part.js'; +import { + DatePart, + type DatePartDeltas, + DEFAULT_DATE_PARTS_SPIN_DELTAS, +} from './date-part.js'; import { IgcDateTimeInputBaseComponent } from './date-time-input.base.js'; import { + createDatePart, type DatePartInfo, + DateParts, DateTimeMaskParser, } from './datetime-mask-parser.js'; -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. @@ -61,152 +66,279 @@ export default class IgcDateTimeInputComponent extends IgcDateTimeInputBaseCompo public static styles = [styles, shared]; /* blazorSuppress */ - public static register() { + public static register(): void { registerComponent( IgcDateTimeInputComponent, IgcValidationContainerComponent ); } + //#region Private state and properties + + protected override readonly _parser = new DateTimeMaskParser(); + + private _displayFormat?: string; + protected override _inputFormat = ''; + + private readonly _i18nController = addI18nController(this, { + defaultEN: {}, + onResourceChange: this._handleResourceChange, + }); + + protected override readonly _themes = addThemingController(this, all); + + protected override readonly _slots = addSlotController(this, { + slots: setSlots( + 'prefix', + 'suffix', + 'helper-text', + 'value-missing', + 'range-overflow', + 'range-underflow', + 'custom-error', + 'invalid' + ), + }); + protected override readonly _formValue = createFormValueState(this, { initialValue: null, transformers: FormValueDateTimeTransformers, }); - protected override readonly _parser: DateTimeMaskParser = - new DateTimeMaskParser(); + protected get _targetDatePart(): DatePart | undefined { + return this._focused + ? this._getDatePartAtCursor() + : this._getDefaultDatePart(); + } - protected override readonly _themes = addThemingController(this, all); + protected get _datePartDeltas(): DatePartDeltas { + return { ...DEFAULT_DATE_PARTS_SPIN_DELTAS, ...this.spinDelta }; + } - protected override readonly _slots = addSlotController(this, { - slots: Slots, - }); + //#endregion + + //#region Public attributes and properties + + /** + * The date format to apply on the input. + * @attr input-format + */ + @property({ attribute: 'input-format' }) + public override set inputFormat(val: string) { + if (val) { + this.setMask(val); + this._inputFormat = val; + this.updateMask(); + } + } + + public override get inputFormat(): string { + return this._inputFormat || this._parser.mask; + } + + /** + * The value of the input. + * @attr + */ + @property({ converter: convertToDate }) + public override set value(value: Date | string | null | undefined) { + this._formValue.setValueAndFormState(value as Date | null); + this.updateMask(); + } - protected override _datePartDeltas: DatePartDeltas = { - date: 1, - month: 1, - year: 1, - hours: 1, - minutes: 1, - seconds: 1, - }; + public override get value(): Date | null { + return this._formValue.value; + } /** * Format to display the value in when not editing. - * Defaults to the input format if not set. + * Defaults to the locale format if not set. * @attr display-format */ @property({ attribute: 'display-format' }) - public displayFormat = ''; + public override set displayFormat(value: string) { + this._displayFormat = value; + } + + public override get displayFormat(): string { + return ( + this._displayFormat ?? this._inputFormat ?? this._defaultDisplayFormat + ); + } /** - * The locale settings used to display the value. - * @attr + * Delta values used to increment or decrement each date part on step actions. + * All values default to `1`. + */ + @property({ attribute: false }) + public override spinDelta?: DatePartDeltas; + + /** + * Sets whether to loop over the currently spun segment. + * @attr spin-loop + */ + @property({ type: Boolean, attribute: 'spin-loop' }) + public override spinLoop = true; + + /** + * Gets/Sets the locale used for formatting the display value. + * @attr locale */ @property() - public locale = 'en'; + public override set locale(value: string) { + this._i18nController.locale = value; + } - public override get hasDateParts(): boolean { - return this._parser.hasDateParts(); + public override get locale(): string { + return this._i18nController.locale; } - public override get hasTimeParts(): boolean { - return this._parser.hasTimeParts(); + //#endregion + + //#region Lifecycle Hooks + + protected override update(props: PropertyValues): void { + if (props.has('displayFormat')) { + this._updateDefaultDisplayFormat(); + } + + if (props.has('locale')) { + this.updateDefaultMask(); + } + + if (props.has('displayFormat') || props.has('locale')) { + this.updateMask(); + } + + super.update(props); } - public get value(): Date | null { - return this._formValue.value; + //#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; } - /* @tsTwoWayProperty(true, "igcChange", "detail", false) */ - /** - * The value of the input. - * @attr - */ - @property({ converter: convertToDate }) - public set value(value: Date | string | null | undefined) { - this._formValue.setValueAndFormState(value as Date | null); + protected override _updateSetRangeTextValue(): void { + this.updateValue(); + } + + //#endregion + + //#region Event handlers + + private _handleResourceChange(): void { + this.updateDefaultMask(); this.updateMask(); } - protected override get targetDatePart(): DatePart | undefined { - let result: DatePart | undefined; + public override async handleFocus(): Promise { + this._focused = true; - if (this._focused) { - const part = this._parser.getDatePartAtPosition( - this._inputSelection.start - ); - if (part) { - result = part.type as DatePart; + if (this.readOnly) { + return; + } + + this._oldValue = this.value; + + if (!this.value) { + this._maskedValue = this._parser.emptyMask; + await this.updateComplete; + this.select(); + } else if (this.displayFormat !== this.inputFormat) { + this.updateMask(); + } + } + + public override handleBlur(): void { + this._focused = false; + + // Handle incomplete mask input + if (!(this._isMaskComplete() || this._isEmptyMask)) { + const parsedDate = this._parser.parseDate(this._maskedValue); + + if (parsedDate) { + this.value = parsedDate; + } else { + this.clear(); } } else { - // Default to date if available, otherwise hours, otherwise first part - const datePart = this._parser.getPartByType(DatePartType.Date); - const hoursPart = this._parser.getPartByType(DatePartType.Hours); - const firstPart = this._parser.getFirstDatePart(); - - if (datePart) { - result = DatePart.Date; - } else if (hoursPart) { - result = DatePart.Hours; - } else if (firstPart) { - result = firstPart.type as DatePart; - } + this.updateMask(); } - return result; + // Emit change event if value changed + if (!this.readOnly && this._oldValue !== this.value) { + this.emitEvent('igcChange', { detail: this.value }); + } + + super._handleBlur(); } - protected override updateMask(): void { + //#endregion + + //#region Internal API + + public override updateMask(): void { if (this._focused) { this._maskedValue = this.getMaskedValue(); - } else { - if (!isValidDate(this.value)) { - this._maskedValue = ''; - return; - } + return; + } - if (this.displayFormat) { - // Use locale-based formatting for display format - this._maskedValue = this.value!.toLocaleDateString(this.locale, { - year: 'numeric', - month: '2-digit', - day: '2-digit', - }); - } else { - // Use parser formatting for input format - this._maskedValue = this._parser.formatDate(this.value); - } + if (!isValidDate(this.value)) { + this._maskedValue = ''; + return; } + + this._maskedValue = formatDisplayDate( + this.value, + this.locale, + this.displayFormat + ); } - protected override handleInput(): void { - this._setTouchedState(); - this.emitEvent('igcInput', { detail: this._maskedValue }); + public override updateValue(): void { + if (!this._isMaskComplete()) { + this.value = null; + return; + } + + const parsedDate = this._parser.parseDate(this._maskedValue); + this.value = isValidDate(parsedDate) ? parsedDate : null; } - protected override spinValue(datePart: DatePart, delta: number): Date { + public override spinValue(datePart: DatePart, delta: number): Date { if (!isValidDate(this.value)) { return new Date(); } const newDate = new Date(this.value.getTime()); - const part = this._parser.getPartByType(datePart as DatePartType); + const partType = datePart as unknown as DateParts; + // Get the part instance from the parser, or create one for explicit spin operations + let part = this._parser.getPartByType(partType); if (!part) { - return newDate; + // For explicit spin operations (e.g., stepDown(DatePart.Minutes)), + // create a temporary part even if not in the format + part = createDatePart(partType, { start: 0, end: 0, format: '' }); } - // Extract AM/PM value if spinning AM/PM part + // For AM/PM, we need to extract the current AM/PM value from the mask let amPmValue: string | undefined; if (datePart === DatePart.AmPm) { - const amPmPart = this._parser.getPartByType(DatePartType.AmPm); - if (amPmPart) { - amPmValue = this._maskedValue.substring(amPmPart.start, amPmPart.end); + const formatPart = this._parser.getPartByType(DateParts.AmPm); + if (formatPart) { + amPmValue = this._maskedValue.substring( + formatPart.start, + formatPart.end + ); } } - // Spin the value using the part's spin method part.spin(delta, { date: newDate, spinLoop: this.spinLoop, @@ -217,109 +349,111 @@ export default class IgcDateTimeInputComponent extends IgcDateTimeInputBaseCompo return newDate; } - protected override setMask(string: string): void { - const oldFormat = this._parser.mask; - - // Set the parser's mask which will parse the date format - this._parser.mask = string; - - // Store the formatted mask as default - this._defaultMask = string; + public override setMask(formatString: string): void { + const previous = this._parser.mask; + this._parser.mask = formatString; - // Update mask and placeholder - this.mask = this._parser.mask; - - if (!this.placeholder || oldFormat === this.placeholder) { - this.placeholder = string; + if (!this.placeholder || previous === this.placeholder) { + this.placeholder = this._parser.mask; } } - protected override getMaskedValue(): string { - if (isValidDate(this.value)) { - return this._parser.formatDate(this.value); - } - - if (this.readOnly) { - return ''; - } - - return this._maskedValue === '' - ? this._parser.emptyMask - : this._maskedValue; + public override getMaskedValue(): string { + return isValidDate(this.value) + ? this._parser.formatDate(this.value) + : this._maskedValue || this._parser.emptyMask; } - protected override updateValue(): void { - if (this.isComplete()) { - const parsedDate = this._parser.parseDate(this._maskedValue); - this.value = isValidDate(parsedDate) ? parsedDate : null; - } else { - this.value = null; + protected override updateDefaultMask(): void { + this._updateDefaultDisplayFormat(); + + if (!this._inputFormat) { + this.setMask(getDefaultDateTimeFormat(this.locale)); } } - protected override getNewPosition(value: string, direction = 0): number { - const cursorPos = this._inputSelection.start; + public override getNewPosition( + inputValue: string, + direction: number + ): number { + const cursorPos = this._maskSelection.start; const dateParts = this._parser.dateParts; - if (!direction) { - // Last literal before the current cursor position or start of input value + if (direction === 0) { const part = dateParts.findLast( - (part) => part.type === DatePartType.Literal && part.end < cursorPos + (part) => part.type === DateParts.Literal && part.end < cursorPos ); return part?.end ?? 0; } - // First literal after the current cursor position or end of input value const part = dateParts.find( - (part) => part.type === DatePartType.Literal && part.start > cursorPos + (part) => part.type === DateParts.Literal && part.start > cursorPos ); - return part?.start ?? value.length; + return part?.start ?? inputValue.length; } - public override async handleFocus(): Promise { - this._focused = true; - - if (this.readOnly) { - return; - } - - this._oldValue = this.value; - const areFormatsDifferent = this.displayFormat !== this.inputFormat; - - if (!this.value) { - this._maskedValue = this._parser.emptyMask; - await this.updateComplete; - this.select(); - } else if (areFormatsDifferent) { - this.updateMask(); - } + private _updateDefaultDisplayFormat(): void { + this._defaultDisplayFormat = getDateFormatter().getLocaleDateTimeFormat( + this.locale + ); } - public override handleBlur(): void { - const isEmptyMask = this._maskedValue === this._parser.emptyMask; + /** + * 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; + } - this._focused = false; + private _getDefaultDatePart(): DatePart | undefined { + return (this._parser.getPartByType(DateParts.Date)?.type ?? + this._parser.getPartByType(DateParts.Hours)?.type ?? + this._parser.getFirstDatePart()?.type) as DatePart | undefined; + } - if (!(this.isComplete() || isEmptyMask)) { - const parse = this._parser.parseDate(this._maskedValue); + //#endregion - if (isValidDate(parse)) { - this.value = parse; - } else { - this.value = null; - this._maskedValue = ''; - } - } else { - this.updateMask(); - } + //#region Public API - const isSameValue = this._oldValue === this.value; + /* blazorSuppress */ + public override get hasDateParts(): boolean { + return this._parser.hasDateParts(); + } - if (!(this.readOnly || isSameValue)) { - this.emitEvent('igcChange', { detail: this.value }); - } + /* blazorSuppress */ + public override get hasTimeParts(): boolean { + return this._parser.hasTimeParts(); + } - super._handleBlur(); + //#endregion + + protected override _renderInput() { + return html` + + `; } } diff --git a/stories/date-time-input.stories.ts b/stories/date-time-input.stories.ts index 43a07e78f..f9a3ea144 100644 --- a/stories/date-time-input.stories.ts +++ b/stories/date-time-input.stories.ts @@ -27,18 +27,10 @@ const metadata: Meta = { actions: { handles: ['igcInput', 'igcChange'] }, }, argTypes: { - displayFormat: { - type: 'string', - description: - 'Format to display the value in when not editing.\nDefaults to the input format if not set.', - control: 'text', - table: { defaultValue: { summary: '' } }, - }, - locale: { + inputFormat: { type: 'string', - description: 'The locale settings used to display the value.', + description: 'The date format to apply on the input.', control: 'text', - table: { defaultValue: { summary: 'en' } }, }, value: { type: 'string | Date', @@ -46,9 +38,22 @@ const metadata: Meta = { options: ['string', 'Date'], control: 'text', }, - inputFormat: { + displayFormat: { type: 'string', - description: 'The date format to apply on the input.', + description: + 'Format to display the value in when not editing.\nDefaults to the locale format if not set.', + control: 'text', + }, + spinLoop: { + type: 'boolean', + description: 'Sets whether to loop over the currently spun segment.', + control: 'boolean', + table: { defaultValue: { summary: 'true' } }, + }, + locale: { + type: 'string', + description: + 'Gets/Sets the locale used for formatting the display value.', control: 'text', }, min: { @@ -61,12 +66,6 @@ const metadata: Meta = { description: 'The maximum value required for the input to remain valid.', control: 'date', }, - spinLoop: { - type: 'boolean', - description: 'Sets whether to loop over the currently spun segment.', - control: 'boolean', - table: { defaultValue: { summary: 'true' } }, - }, readOnly: { type: 'boolean', description: 'Makes the control a readonly field.', @@ -128,8 +127,6 @@ const metadata: Meta = { }, }, args: { - displayFormat: '', - locale: 'en', spinLoop: true, readOnly: false, mask: 'CCCCCCCCCC', @@ -144,23 +141,23 @@ 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; /** * Format to display the value in when not editing. - * Defaults to the input format if not set. + * Defaults to the locale format if not set. */ displayFormat: string; - /** The locale settings used to display the value. */ + /** Sets whether to loop over the currently spun segment. */ + spinLoop: boolean; + /** Gets/Sets the locale used for formatting the display value. */ locale: string; - /** The value of the input. */ - value: string | Date; - /** The date format to apply on the input. */ - inputFormat: string; /** The minimum value required for the input to remain valid. */ min: Date; /** The maximum value required for the input to remain valid. */ max: Date; - /** Sets whether to loop over the currently spun segment. */ - spinLoop: boolean; /** Makes the control a readonly field. */ readOnly: boolean; /** The masked pattern of the component. */ From 7d1f073078b6e7d6e28194047cbb208df88b6b70 Mon Sep 17 00:00:00 2001 From: Bozhidara Pachilova Date: Tue, 24 Feb 2026 15:38:49 +0200 Subject: [PATCH 04/14] chore(drp): checkpoint --- .../date-range-picker/date-range-input.ts | 509 +++++++----------- .../date-range-mask-parser.ts | 370 +++++++++++++ .../date-range-picker/date-range-picker.ts | 10 +- .../date-time-input/date-time-input.base.ts | 246 ++++++--- .../date-time-input/date-time-input.ts | 384 +++++-------- .../date-time-input/datetime-mask-parser.ts | 2 +- stories/date-time-input.stories.ts | 42 +- 7 files changed, 897 insertions(+), 666 deletions(-) create mode 100644 src/components/date-range-picker/date-range-mask-parser.ts diff --git a/src/components/date-range-picker/date-range-input.ts b/src/components/date-range-picker/date-range-input.ts index dbbac0639..7e2bc783e 100644 --- a/src/components/date-range-picker/date-range-input.ts +++ b/src/components/date-range-picker/date-range-input.ts @@ -6,6 +6,10 @@ import { CalendarDay } from '../calendar/model.js'; import { addSlotController, setSlots } from '../common/controllers/slot.js'; import { watch } from '../common/decorators/watch.js'; import { registerComponent } from '../common/definitions/register.js'; +import { + formatDisplayDate, + getDefaultDateTimeFormat, +} 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'; @@ -15,18 +19,17 @@ import { DatePartType, } from '../date-time-input/date-part.js'; import { IgcDateTimeInputBaseComponent } from '../date-time-input/date-time-input.base.js'; -import { - type DatePartInfo, - DateTimeMaskParser, -} from '../date-time-input/datetime-mask-parser.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 { + DateRangeMaskParser, + DateRangePosition, +} from './date-range-mask-parser.js'; import type { DateRangeValue } from './date-range-picker.js'; import { isCompleteDateRange } from './validators.js'; -const SINGLE_INPUT_SEPARATOR = ' - '; - const Slots = setSlots( 'prefix', 'suffix', @@ -37,28 +40,15 @@ const Slots = setSlots( 'invalid' ); -/** @ignore */ -export enum DateRangePosition { - Start = 'start', - End = 'end', - Separator = 'separator', -} - /** @ignore */ export interface DateRangePart { part: DatePart; position: DateRangePosition; } -/** @ignore */ -export interface DateRangePartInfo extends DatePartInfo { - position?: DateRangePosition; -} - export default class IgcDateRangeInputComponent extends IgcDateTimeInputBaseComponent< DateRangeValue | null, - DateRangePart, - DateRangePartInfo + DateRangePart > { public static readonly tagName = 'igc-date-range-input'; @@ -87,9 +77,7 @@ export default class IgcDateRangeInputComponent extends IgcDateTimeInputBaseComp transformers: FormValueDateRangeTransformers, }); - protected override readonly _parser = new DateTimeMaskParser(); - private _startParser: DateTimeMaskParser = new DateTimeMaskParser(); - private _endParser: DateTimeMaskParser = new DateTimeMaskParser(); + protected override readonly _parser = new DateRangeMaskParser(); protected override _datePartDeltas: DatePartDeltas = { date: 1, @@ -97,242 +85,235 @@ export default class IgcDateRangeInputComponent extends IgcDateTimeInputBaseComp year: 1, }; - protected override _inputFormat = ''; - - /** - * Format to display the value in when not editing. - * Defaults to the input format if not set. - * @attr display-format - */ - @property({ attribute: 'display-format' }) - public displayFormat = ''; - - /** - * The locale settings used to display the value. - * @attr - */ - @property() - public locale = 'en'; - - protected override get hasDateParts(): boolean { - return this._startParser.hasDateParts(); - } - - protected override get hasTimeParts(): boolean { - return this._startParser.hasTimeParts(); - } - protected override get _targetDatePart(): DateRangePart | undefined { - let result: DateRangePart | undefined; - if (this._focused) { - const part = this._inputDateParts?.find( - (p) => - p.start <= this._inputSelection.start && - this._inputSelection.start <= p.end && - p.type !== DatePartType.Literal + const part = this._parser.getDateRangePartForCursor( + this._inputSelection.start ); - const partType = part?.type as string as DatePart; - if (partType) { - result = { part: partType, position: part!.position! }; + if (part && part.type !== DatePartType.Literal) { + return { + part: part.type as string as DatePart, + position: part.position, + }; } } else { - const firstPart = this._inputDateParts?.[0]; + const firstPart = this._parser.getFirstDatePartForPosition( + DateRangePosition.Start + ); if (firstPart) { - result = { + return { part: firstPart.type as string as DatePart, position: DateRangePosition.Start, }; } } - return result; + return undefined; } - public get value(): DateRangeValue | null { - return this._formValue.value; - } + // #endregion - public set value(value: DateRangeValue | null) { - this._formValue.setValueAndFormState(value as DateRangeValue | null); - this.updateMask(); - } + // #region Public properties + /* @tsTwoWayProperty(true, "igcChange", "detail", false, true) */ /** - * The date format to apply on the input. - * @attr input-format + * The value of the date range input. + * @attr */ - @property({ attribute: 'input-format' }) - public override get inputFormat(): string { - return ( - this._inputFormat || this._defaultMask?.split(SINGLE_INPUT_SEPARATOR)[0] - ); + @property({ attribute: false }) + public set value(value: DateRangeValue | null) { + this._formValue.setValueAndFormState(value); + this._updateMaskDisplay(); } - public override set inputFormat(value: string) { - if (value) { - this._inputFormat = value; - this.setMask(value); - if (this.value) { - this.updateMask(); - } - } + public get value(): DateRangeValue | null { + return this._formValue.value; } // #endregion - // #region Methods - - @watch('displayFormat') - protected _onDisplayFormatChange() { - this.updateMask(); - } + // #region Lifecycle - protected override updateDefaultMask(): void { - if (!this._inputFormat) { - // Use a default date format from the start parser - const defaultFormat = 'MM/dd/yyyy'; - this.setMask(defaultFormat); - } + public override connectedCallback(): void { + super.connectedCallback(); + // Initialize with locale-aware format on first connection + this._initializeDefaultMask(); } - protected override setMask(string: string): void { - const oldFormat = this._inputDateParts?.map((p) => p.format).join(''); - - // Set both parsers to the same format - this._startParser.mask = string; - this._endParser.mask = string; - - // Get date parts from the start parser - const baseParts = Array.from(this._startParser.dateParts); + // #endregion - // Create start parts with position - const startParts = baseParts.map((part) => ({ - ...part, - position: DateRangePosition.Start, - })) as DateRangePartInfo[]; + // #region Methods - const separatorStart = startParts[startParts.length - 1].end; - const separatorParts: DateRangePartInfo[] = []; + @watch('locale', { waitUntilFirstUpdate: true }) + protected _localeChanged(): void { + this.updateDefaultMask(); + this._updateMaskDisplay(); + } - // Add separator parts - for (let i = 0; i < SINGLE_INPUT_SEPARATOR.length; i++) { - const element = SINGLE_INPUT_SEPARATOR.charAt(i); + protected override _initializeDefaultMask(): void { + // Call base to update _defaultDisplayFormat + super._initializeDefaultMask(); - separatorParts.push({ - type: DatePartType.Literal, - format: element, - start: separatorStart + i, - end: separatorStart + i + 1, - position: DateRangePosition.Separator, - } as DateRangePartInfo); + // Apply range-specific mask if no custom input format + if (!this._inputFormat) { + const singleFormat = getDefaultDateTimeFormat(this.locale); + this._parser.mask = singleFormat; + this._defaultMask = singleFormat; + this.placeholder = `${singleFormat}${this._parser.separator}${singleFormat}`; } + } - let currentPosition = separatorStart + SINGLE_INPUT_SEPARATOR.length; + protected override hasDateParts(): boolean { + return this._parser.rangeParts.some( + (p) => + p.type === DatePartType.Date || + p.type === DatePartType.Month || + p.type === DatePartType.Year + ); + } - // Clone parts for end date, adjusting positions - const endParts: DateRangePartInfo[] = baseParts.map((part) => { - const length = part.end - part.start; - const newPart: DateRangePartInfo = { - type: part.type, - format: part.format, - start: currentPosition, - end: currentPosition + length, - position: DateRangePosition.End, - } as DateRangePartInfo; - currentPosition += length; - return newPart; - }); + protected override hasTimeParts(): boolean { + return this._parser.rangeParts.some( + (p) => + p.type === DatePartType.Hours || + p.type === DatePartType.Minutes || + p.type === DatePartType.Seconds + ); + } - this._inputDateParts = [...startParts, ...separatorParts, ...endParts]; + protected override buildMaskedValue(): string { + return isCompleteDateRange(this.value) && + isValidDate(this.value.start) && + isValidDate(this.value.end) + ? this._parser.formatDateRange(this.value) + : this._maskedValue || this._parser.emptyMask; + } - this._defaultMask = this._inputDateParts.map((p) => p.format).join(''); + protected override updateDefaultMask(): void { + if (!this._inputFormat) { + const singleFormat = getDefaultDateTimeFormat(this.locale); + this._parser.mask = singleFormat; + this._defaultMask = singleFormat; + this.placeholder = `${singleFormat}${this._parser.separator}${singleFormat}`; + } + } - const value = this._defaultMask; + protected override _applyMask(string: string): void { + const oldPlaceholder = this.placeholder; + const oldFormat = this._defaultMask; - // Build compound mask pattern - this.mask = - this._startParser.mask + SINGLE_INPUT_SEPARATOR + this._endParser.mask; + // string is the single date format + this._parser.mask = string; + this._defaultMask = string; this._parser.prompt = this.prompt; - if (!this.placeholder || oldFormat === this.placeholder) { - this.placeholder = value; + // Update placeholder if it was using the old format + if (!this.placeholder || oldFormat === oldPlaceholder) { + this.placeholder = `${string}${this._parser.separator}${string}`; } } - protected override getMaskedValue(): string { - let mask = this._parser.emptyMask; - - if (isValidDate(this.value?.start)) { - const startParts = this._inputDateParts.filter( - (p) => p.position === DateRangePosition.Start - ); - mask = this._setDatePartInMask(mask, startParts, this.value.start); + protected override _updateMaskDisplay(): void { + if (this._focused) { + this._maskedValue = this.buildMaskedValue(); + return; } - if (isValidDate(this.value?.end)) { - const endParts = this._inputDateParts.filter( - (p) => p.position === DateRangePosition.End - ); - mask = this._setDatePartInMask(mask, endParts, this.value.end); - return mask; + + if (!this.value || (!this.value.start && !this.value.end)) { + this._maskedValue = ''; + return; } - return this._maskedValue === '' ? mask : this._maskedValue; + // If custom display format is set (different from input format) + if (this._displayFormat) { + 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}` + : ''; + } else { + // Use input format (from parser) + this._maskedValue = this._parser.formatDateRange(this.value); + } } - protected override getNewPosition(value: string, direction = 0): number { - let cursorPos = this._maskSelection.start; + protected override calculatePartNavigationPosition( + inputValue: string, + direction: number + ): number { + const cursorPos = this._maskSelection.start; + const rangeParts = this._parser.rangeParts; + + if (direction === 0) { + // Navigate backwards: find last literal before cursor + const part = [...rangeParts] + .reverse() + .find((p) => p.type === DateParts.Literal && p.end < cursorPos); + return part?.end ?? 0; + } + + // Navigate forwards: find first literal after cursor + const part = rangeParts.find( + (p) => p.type === DateParts.Literal && p.start > cursorPos + ); + return part?.start ?? inputValue.length; + } - const separatorPart = this._inputDateParts.find( - (part) => part.position === DateRangePosition.Separator + protected override calculateSpunValue( + datePart: DateRangePart, + delta: number | undefined, + isDecrement: boolean + ): DateRangeValue { + // Get the part from the parser + const part = this._parser.getPartByTypeAndPosition( + datePart.part as DatePartType, + datePart.position ); - if (!direction) { - const firstSeparator = - this._inputDateParts.find( - (p) => p.position === DateRangePosition.Separator - )?.start ?? 0; - const lastSeparator = - this._inputDateParts.findLast( - (p) => p.position === DateRangePosition.Separator - )?.end ?? 0; - // Last literal before the current cursor position or start of input value - let part = this._inputDateParts.findLast( - (part) => part.type === DatePartType.Literal && part.end < cursorPos + if (!part) { + // Fallback if part not found + return ( + this.value || { + start: CalendarDay.today.native, + end: CalendarDay.today.native, + } ); - // skip over the separator parts - if ( - part?.position === DateRangePosition.Separator && - cursorPos === lastSeparator - ) { - cursorPos = firstSeparator; - part = this._inputDateParts.findLast( - (part) => part.type === DatePartType.Literal && part.end < cursorPos - ); - } - return part?.end ?? 0; } - if ( - separatorPart && - cursorPos >= separatorPart.start && - cursorPos <= separatorPart.end - ) { - // Cursor is inside the separator; skip over it - cursorPos = separatorPart.end + 1; - } - // First literal after the current cursor position or end of input value - const part = this._inputDateParts.find( - (part) => part.type === DatePartType.Literal && part.start > cursorPos + // Default to 1 if delta is 0 or undefined + const effectiveDelta = + delta || this._datePartDeltas[datePart.part as keyof DatePartDeltas] || 1; + + const spinAmount = isDecrement + ? -Math.abs(effectiveDelta) + : Math.abs(effectiveDelta); + + // Use the parser's spinDateRangePart method + return this._parser.spinDateRangePart( + part, + spinAmount, + this.value, + this.spinLoop ); - return part?.start ?? value.length; } - protected override updateValue(): void { - if (this._isMaskComplete()) { - const parsedRange = this._parseRangeValue(this._maskedValue); - this.value = parsedRange; + protected override updateValueFromMask(): void { + if (!this._isMaskComplete()) { + // Don't update value if mask is incomplete + this._updateMaskDisplay(); + return; + } + + const parsed = this._parser.parseDateRange(this._maskedValue); + if (parsed && (parsed.start || parsed.end)) { + this.value = parsed; } else { this.value = null; } @@ -344,6 +325,7 @@ export default class IgcDateRangeInputComponent extends IgcDateTimeInputBaseComp if (this.readOnly) { return; } + this._oldValue = this.value; const areFormatsDifferent = this.displayFormat !== this.inputFormat; @@ -352,27 +334,27 @@ export default class IgcDateRangeInputComponent extends IgcDateTimeInputBaseComp await this.updateComplete; this.select(); } else if (areFormatsDifferent) { - this.updateMask(); + this._updateMaskDisplay(); } } - public override handleBlur(): void { + public override _handleBlur(): void { const isEmptyMask = this._maskedValue === this._parser.emptyMask; const isSameValue = equal(this._oldValue, this.value); this._focused = false; if (!(this._isMaskComplete() || isEmptyMask)) { - const parse = this._parseRangeValue(this._maskedValue); + const parsed = this._parser.parseDateRange(this._maskedValue); - if (parse) { - this.value = parse; + if (parsed && (parsed.start || parsed.end)) { + this.value = parsed; } else { this.value = null; this._maskedValue = ''; } } else { - this.updateMask(); + this._updateMaskDisplay(); } if (!(this.readOnly || isSameValue)) { @@ -382,113 +364,16 @@ export default class IgcDateRangeInputComponent extends IgcDateTimeInputBaseComp super._handleBlur(); } - private _parseRangeValue(value: string): DateRangeValue | null { - const dates = value.split(SINGLE_INPUT_SEPARATOR); - if (dates.length !== 2) { - return null; - } - - const start = this._startParser.parseDate(dates[0]); - const end = this._endParser.parseDate(dates[1]); - - return { start: start ?? null, end: end ?? null }; - } - - protected override spinValue( - datePart: DateRangePart, - delta: number - ): DateRangeValue { - if (!isCompleteDateRange(this.value)) { - return { start: CalendarDay.today.native, end: CalendarDay.today.native }; - } - - let newDate = this.value?.start - ? CalendarDay.from(this.value.start).native - : CalendarDay.today.native; - if (datePart.position === DateRangePosition.End) { - newDate = this.value?.end - ? CalendarDay.from(this.value.end).native - : CalendarDay.today.native; - } - - const parser = - datePart.position === DateRangePosition.End - ? this._endParser - : this._startParser; - const part = parser.getPartByType(datePart.part as DatePartType); - - if (part) { - part.spin(delta, { - date: newDate, - spinLoop: this.spinLoop, - }); - } - - const value = { - ...this.value, - [datePart.position]: newDate, - } as DateRangeValue; - return value; - } - - protected override updateMask(): void { - if (this._focused) { - this._maskedValue = this.getMaskedValue(); - } else { - if (!isCompleteDateRange(this.value)) { - this._maskedValue = ''; - return; - } - - const { start, end } = this.value; - - if (this.displayFormat) { - // Use locale-based formatting for display format - this._maskedValue = `${start.toLocaleDateString(this.locale)} - ${end.toLocaleDateString(this.locale)}`; - } else { - // Use parser formatting for input format - this._maskedValue = `${this._startParser.formatDate(start)} - ${this._endParser.formatDate(end)}`; - } - } + /** + * Sets the value to a range of the current date/time as start and end. + */ + protected override _setCurrentDateTime(): void { + const today = CalendarDay.today.native; + this.value = { start: today, end: today }; + this._emitInputEvent(); } - private _setDatePartInMask( - mask: string, - parts: DateRangePartInfo[], - value: Date | null - ): string { - if (!isValidDate(value)) { - return mask; - } - - let resultMask = mask; - const parser = - parts[0]?.position === DateRangePosition.End - ? this._endParser - : this._startParser; - - for (const part of parts) { - if (part.type === DatePartType.Literal) { - continue; - } - - // Get the corresponding part from the parser - const datePart = parser.getPartByType(part.type); - if (!datePart) { - continue; - } - - const targetValue = datePart.getValue(value); - - resultMask = this._parser.replace( - resultMask, - targetValue, - part.start, - part.end - ).value; - } - return resultMask; - } + // #endregion } declare global { 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..c5fb8710e --- /dev/null +++ b/src/components/date-range-picker/date-range-mask-parser.ts @@ -0,0 +1,370 @@ +import { + DatePartType, + type IDatePart, + type SpinOptions, +} from '../date-time-input/date-part.js'; +import { + DateTimeMaskParser, + DEFAULT_DATETIME_FORMAT, +} from '../date-time-input/datetime-mask-parser.js'; +import { MaskParser } from '../mask-input/mask-parser.js'; +import type { DateRangeValue } from './date-range-picker.js'; + +//#region Types and Enums + +/** 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 { + /** + * The date format string (e.g., 'MM/dd/yyyy'). + */ + format?: string; + /** The prompt character for unfilled positions */ + promptCharacter?: string; + /** Custom 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 { + /** Parser for the start date */ + private _startParser: DateTimeMaskParser; + + /** Parser for the end date */ + 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; + + /** Flag to prevent recursive mask setting */ + private _isUpdatingMask = false; + + /** + * 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( + promptCharacter + ? { format: rangeFormat, promptCharacter } + : { format: rangeFormat } + ); + + // Create two parsers for start and end dates + const parserOptions = promptCharacter + ? { format, promptCharacter } + : { format }; + this._startParser = new DateTimeMaskParser(parserOptions); + this._endParser = new DateTimeMaskParser(parserOptions); + this._separator = separator; + + // Calculate separator positions + this._separatorStart = this._startParser.mask.length; + this._separatorEnd = this._separatorStart + separator.length; + + // Build range parts from the two parsers + this._buildRangeParts(); + } + + /** + * 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, + // Explicitly bind methods to preserve functionality + 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, + // Delegate methods to the original part + 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) { + // Prevent recursive calls when we set the parent mask + if (this._isUpdatingMask) { + return; + } + + this._isUpdatingMask = true; + + try { + // Update both parsers with the new format + this._startParser.mask = value; + this._endParser.mask = value; + + // Recalculate separator positions + this._separatorStart = this._startParser.mask.length; + this._separatorEnd = this._separatorStart + this._separator.length; + + // Update parent mask with combined format + const rangeFormat = `${value}${this._separator}${value}`; + super.mask = rangeFormat; + + // Rebuild range parts + this._buildRangeParts(); + } finally { + this._isUpdatingMask = false; + } + } + + 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; + } + + // Extract start and end date strings + const startString = masked.substring(0, this._separatorStart); + const endString = masked.substring(this._separatorEnd); + + // Delegate parsing to the individual parsers + const start = this._startParser.parseDate(startString); + const end = this._endParser.parseDate(endString); + + if (!start && !end) { + return null; + } + + 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 { + if (!range) { + return ''; + } + + // Delegate formatting to the individual parsers + 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 all parts for a specific position (start or end). + */ + public getPartsForPosition( + position: DateRangePosition + ): ReadonlyArray { + return this._rangeParts.filter((p) => p.position === position); + } + + /** + * 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 Override for Date Range Mask + + /** + * Builds the internal mask pattern from the date range format. + * Delegates to the start parser's conversion logic. + */ + protected override _parseMaskLiterals(): void { + // The parent MaskParser will handle the mask literals + // We just need to rebuild range parts after parsing + super._parseMaskLiterals(); + + // Rebuild range parts if parsers are initialized + if (this._startParser && this._endParser) { + this._buildRangeParts(); + } + } + + //#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 + ): DateRangeValue { + // Ensure we have a value to work with + const value = currentValue || { start: null, end: null }; + + // Determine which date to spin + 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, + }; + + part.spin(delta, spinOptions); + + // Return updated range + return { + ...value, + start: part.position === DateRangePosition.Start ? newDate : value.start, + end: part.position === DateRangePosition.End ? newDate : value.end, + }; + } + + //#endregion +} diff --git a/src/components/date-range-picker/date-range-picker.ts b/src/components/date-range-picker/date-range-picker.ts index 735f4df3a..507c09f09 100644 --- a/src/components/date-range-picker/date-range-picker.ts +++ b/src/components/date-range-picker/date-range-picker.ts @@ -61,9 +61,8 @@ import IgcIconComponent from '../icon/icon.js'; import IgcPopoverComponent from '../popover/popover.js'; import type { ContentOrientation, PickerMode } from '../types.js'; import IgcValidationContainerComponent from '../validation-container/validation-container.js'; -import IgcDateRangeInputComponent, { - DateRangePosition, -} from './date-range-input.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'; @@ -1183,7 +1182,8 @@ export default class IgcDateRangePickerComponent extends FormAssociatedRequiredM private _renderSingleInput(id: string) { const readOnly = !this._isDropDown || this.readOnly || this.nonEditable; - const format = getDateTimeFormat(this._displayFormat); + const format = + getDateTimeFormat(this._displayFormat) ?? this._defaultDisplayFormat; const prefix = isEmpty(this._prefixes) ? undefined : 'prefix'; const suffix = isEmpty(this._suffixes) ? undefined : 'suffix'; @@ -1200,7 +1200,7 @@ export default class IgcDateRangePickerComponent extends FormAssociatedRequiredM ?invalid=${live(this.invalid)} .disabled=${this.disabled} .inputFormat=${live(this.inputFormat)} - .displayFormat=${live(format ?? '')} + .displayFormat=${live(format)} .locale=${live(this.locale)} .prompt=${this.prompt} @igcInput=${this._handleDateRangeInputEvent} diff --git a/src/components/date-time-input/date-time-input.base.ts b/src/components/date-time-input/date-time-input.base.ts index bb505cb84..b8165c3f1 100644 --- a/src/components/date-time-input/date-time-input.base.ts +++ b/src/components/date-time-input/date-time-input.base.ts @@ -1,8 +1,9 @@ -import { html } from 'lit'; +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 } from '../calendar/helpers.js'; +import { convertToDate, isValidDate } from '../calendar/helpers.js'; import { addKeybindings, arrowDown, @@ -12,6 +13,11 @@ import { ctrlKey, } from '../common/controllers/key-bindings.js'; import { watch } from '../common/decorators/watch.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'; @@ -33,7 +39,6 @@ export interface IgcDateTimeInputComponentEventMap extends Omit< export abstract class IgcDateTimeInputBaseComponent< TValue extends Date | DateRangeValue | string | null, TPart, - TPartInfo, > extends EventEmitterMixin< IgcDateTimeInputComponentEventMap, AbstractConstructor @@ -44,23 +49,33 @@ export abstract class IgcDateTimeInputBaseComponent< return dateTimeInputValidators; } + 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; - protected _oldValue: TValue | null = null; - protected _inputDateParts!: TPartInfo[]; - protected _inputFormat = ''; + + // Format and mask state + protected _displayFormat?: string; + protected _inputFormat?: string; + protected _defaultDisplayFormat = ''; protected abstract get _datePartDeltas(): any; protected abstract get _targetDatePart(): TPart | undefined; - protected get hasDateParts(): boolean { + protected hasDateParts(): boolean { // Override in subclass with specific implementation return false; } - protected get hasTimeParts(): boolean { + protected hasTimeParts(): boolean { // Override in subclass with specific implementation return false; } @@ -78,16 +93,14 @@ export abstract class IgcDateTimeInputBaseComponent< */ @property({ attribute: 'input-format' }) public get inputFormat(): string { - return this._inputFormat || this._defaultMask; + return this._inputFormat || this._parser.mask; } public set inputFormat(val: string) { if (val) { - this.setMask(val); + this._applyMask(val); this._inputFormat = val; - if (this.value) { - this.updateMask(); - } + this._updateMaskDisplay(); } } @@ -121,11 +134,19 @@ export abstract class IgcDateTimeInputBaseComponent< /** * Format to display the value in when not editing. - * Defaults to the input format if not set. + * Defaults to the locale format if not set. * @attr display-format */ @property({ attribute: 'display-format' }) - public abstract displayFormat: string; + 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. @@ -142,11 +163,17 @@ export abstract class IgcDateTimeInputBaseComponent< public spinLoop = true; /** - * The locale settings used to display the value. - * @attr + * Gets/Sets the locale used for formatting the display value. + * @attr locale */ @property() - public abstract locale: string; + public set locale(value: string) { + this._i18nController.locale = value; + } + + public get locale(): string { + return this._i18nController.locale; + } // #endregion @@ -166,35 +193,41 @@ export abstract class IgcDateTimeInputBaseComponent< .set([ctrlKey, arrowRight], this._navigateParts.bind(this, 1)); } - public override connectedCallback() { - super.connectedCallback(); - this.updateDefaultMask(); - this.setMask(this.inputFormat); - this._validate(); - if (this.value) { - this.updateMask(); + //#region Lifecycle Hooks + + protected override update(props: PropertyValues): void { + if (props.has('displayFormat')) { + this._updateDefaultDisplayFormat(); } - } - @watch('locale', { waitUntilFirstUpdate: true }) - protected _setDefaultMask(): void { - if (!this._inputFormat) { - this.updateDefaultMask(); - this.setMask(this._defaultMask); + if (props.has('locale')) { + this._initializeDefaultMask(); } - if (this.value) { - this.updateMask(); + if (props.has('displayFormat') || props.has('locale')) { + this._updateMaskDisplay(); } + + super.update(props); } - @watch('displayFormat', { waitUntilFirstUpdate: true }) - protected _setDisplayFormat(): void { - if (this.value) { - this.updateMask(); - } + //#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 + @watch('prompt', { waitUntilFirstUpdate: true }) protected _promptChange(): void { if (!this.prompt) { @@ -231,28 +264,11 @@ export abstract class IgcDateTimeInputBaseComponent< if (!part) return; const { start, end } = this._inputSelection; - const newValue = this._calculateSpunValue(part, delta, isDecrement); + const newValue = this.calculateSpunValue(part, delta, isDecrement); this.value = newValue as TValue; this.updateComplete.then(() => this._input?.setSelectionRange(start, end)); } - /** - * Calculates the new value after spinning a date part. - */ - protected _calculateSpunValue( - datePart: TPart, - delta: number | undefined, - isDecrement: boolean - ): TValue { - // Default to 1 if delta is 0 or undefined - const effectiveDelta = - delta || (this._datePartDeltas as any)[datePart as any] || 1; - const spinAmount = isDecrement - ? -Math.abs(effectiveDelta) - : Math.abs(effectiveDelta); - return this.spinValue(datePart, spinAmount); - } - /** Clears the input element of user input. */ public clear(): void { this._maskedValue = ''; @@ -267,21 +283,15 @@ export abstract class IgcDateTimeInputBaseComponent< this._emitInputEvent(); } - /** - * Handles drag leave events. - */ protected _handleDragLeave(): void { if (!this._focused) { - this.updateMask(); + this._updateMaskDisplay(); } } - /** - * Handles drag enter events. - */ protected _handleDragEnter(): void { if (!this._focused) { - this._maskedValue = this.getMaskedValue(); + this._maskedValue = this.buildMaskedValue(); } } @@ -295,7 +305,7 @@ export abstract class IgcDateTimeInputBaseComponent< this._maskedValue = value; - this.updateValue(); + this.updateValueFromMask(); this.requestUpdate(); if (range.start !== this.inputFormat.length) { @@ -305,6 +315,28 @@ export abstract class IgcDateTimeInputBaseComponent< this._input?.setSelectionRange(end, 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). */ @@ -312,15 +344,14 @@ export abstract class IgcDateTimeInputBaseComponent< return !this._maskedValue.includes(this.prompt); } - protected override _updateSetRangeTextValue() { - this.updateValue(); - } - /** * Navigates to the previous or next date part. */ protected _navigateParts(direction: number): void { - const position = this.getNewPosition(this._input?.value ?? '', direction); + const position = this.calculatePartNavigationPosition( + this._input?.value ?? '', + direction + ); this.setSelectionRange(position, position); } @@ -364,6 +395,41 @@ export abstract class IgcDateTimeInputBaseComponent< // Override in subclass with specific implementation } + private _handleResourceChange(): void { + this._initializeDefaultMask(); + this._updateMaskDisplay(); + } + + /** + * 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)); + } + } + protected override _renderInput() { return html` `; } - protected abstract updateMask(): void; - protected abstract updateValue(): void; - protected abstract getNewPosition(value: string, direction: number): number; - protected abstract spinValue(datePart: TPart, delta: number): TValue; - protected abstract setMask(string: string): void; - protected abstract getMaskedValue(): string; - public abstract handleBlur(): void; - public abstract handleFocus(): Promise; - + 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 spinDatePart(datePart: TPart, delta: number): TValue; + protected abstract handleFocus(): Promise; // #endregion } diff --git a/src/components/date-time-input/date-time-input.ts b/src/components/date-time-input/date-time-input.ts index 2a13a4b5a..8d6396c7c 100644 --- a/src/components/date-time-input/date-time-input.ts +++ b/src/components/date-time-input/date-time-input.ts @@ -1,20 +1,11 @@ -import { getDateFormatter } from 'igniteui-i18n-core'; -import { html, type PropertyValues } from 'lit'; import { property } from 'lit/decorators.js'; -import { ifDefined } from 'lit/directives/if-defined.js'; -import { live } from 'lit/directives/live.js'; + import { addThemingController } from '../../theming/theming-controller.js'; import { convertToDate, isValidDate } from '../calendar/helpers.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 { 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 { 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'; @@ -27,10 +18,21 @@ import { import { IgcDateTimeInputBaseComponent } from './date-time-input.base.js'; import { createDatePart, - type DatePartInfo, DateParts, DateTimeMaskParser, } from './datetime-mask-parser.js'; +import { dateTimeInputValidators } from './validators.js'; + +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 @@ -59,8 +61,7 @@ import { */ export default class IgcDateTimeInputComponent extends IgcDateTimeInputBaseComponent< Date | null, - DatePart, - DatePartInfo + DatePart > { public static readonly tagName = 'igc-date-time-input'; public static styles = [styles, shared]; @@ -76,28 +77,9 @@ export default class IgcDateTimeInputComponent extends IgcDateTimeInputBaseCompo //#region Private state and properties protected override readonly _parser = new DateTimeMaskParser(); - - private _displayFormat?: string; - protected override _inputFormat = ''; - - private readonly _i18nController = addI18nController(this, { - defaultEN: {}, - onResourceChange: this._handleResourceChange, - }); - protected override readonly _themes = addThemingController(this, all); - protected override readonly _slots = addSlotController(this, { - slots: setSlots( - 'prefix', - 'suffix', - 'helper-text', - 'value-missing', - 'range-overflow', - 'range-underflow', - 'custom-error', - 'invalid' - ), + slots: Slots, }); protected override readonly _formValue = createFormValueState(this, { @@ -105,13 +87,22 @@ export default class IgcDateTimeInputComponent extends IgcDateTimeInputBaseCompo transformers: FormValueDateTimeTransformers, }); - protected get _targetDatePart(): DatePart | undefined { + protected override get __validators() { + 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. + */ + protected override get _targetDatePart(): DatePart | undefined { return this._focused ? this._getDatePartAtCursor() : this._getDefaultDatePart(); } - protected get _datePartDeltas(): DatePartDeltas { + protected override get _datePartDeltas(): DatePartDeltas { return { ...DEFAULT_DATE_PARTS_SPIN_DELTAS, ...this.spinDelta }; } @@ -119,125 +110,54 @@ export default class IgcDateTimeInputComponent extends IgcDateTimeInputBaseCompo //#region Public attributes and properties - /** - * The date format to apply on the input. - * @attr input-format - */ - @property({ attribute: 'input-format' }) - public override set inputFormat(val: string) { - if (val) { - this.setMask(val); - this._inputFormat = val; - this.updateMask(); - } - } - - public override get inputFormat(): string { - return this._inputFormat || this._parser.mask; - } - + /* @tsTwoWayProperty(true, "igcChange", "detail", false) */ /** * The value of the input. * @attr */ @property({ converter: convertToDate }) - public override set value(value: Date | string | null | undefined) { + public set value(value: Date | string | null | undefined) { this._formValue.setValueAndFormState(value as Date | null); - this.updateMask(); + this._updateMaskDisplay(); } - public override get value(): Date | null { + public get value(): Date | null { return this._formValue.value; } /** - * Format to display the value in when not editing. - * Defaults to the locale format if not set. - * @attr display-format + * The minimum value required for the input to remain valid. + * @attr */ - @property({ attribute: 'display-format' }) - public override set displayFormat(value: string) { - this._displayFormat = value; + @property({ converter: convertToDate }) + public override set min(value: Date | string | null | undefined) { + this._min = convertToDate(value); + this._validate(); } - public override get displayFormat(): string { - return ( - this._displayFormat ?? this._inputFormat ?? this._defaultDisplayFormat - ); + public override get min(): Date | null { + return this._min; } /** - * Delta values used to increment or decrement each date part on step actions. - * All values default to `1`. - */ - @property({ attribute: false }) - public override spinDelta?: DatePartDeltas; - - /** - * Sets whether to loop over the currently spun segment. - * @attr spin-loop - */ - @property({ type: Boolean, attribute: 'spin-loop' }) - public override spinLoop = true; - - /** - * Gets/Sets the locale used for formatting the display value. - * @attr locale + * The maximum value required for the input to remain valid. + * @attr */ - @property() - public override set locale(value: string) { - this._i18nController.locale = value; - } - - public override get locale(): string { - return this._i18nController.locale; - } - - //#endregion - - //#region Lifecycle Hooks - - protected override update(props: PropertyValues): void { - if (props.has('displayFormat')) { - this._updateDefaultDisplayFormat(); - } - - if (props.has('locale')) { - this.updateDefaultMask(); - } - - if (props.has('displayFormat') || props.has('locale')) { - this.updateMask(); - } - - 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; + @property({ converter: convertToDate }) + public override set max(value: Date | string | null | undefined) { + this._max = convertToDate(value); + this._validate(); } - protected override _updateSetRangeTextValue(): void { - this.updateValue(); + public override get max(): Date | null { + return this._max; } //#endregion //#region Event handlers - private _handleResourceChange(): void { - this.updateDefaultMask(); - this.updateMask(); - } - - public override async handleFocus(): Promise { + protected async handleFocus(): Promise { this._focused = true; if (this.readOnly) { @@ -251,11 +171,11 @@ export default class IgcDateTimeInputComponent extends IgcDateTimeInputBaseCompo await this.updateComplete; this.select(); } else if (this.displayFormat !== this.inputFormat) { - this.updateMask(); + this._updateMaskDisplay(); } } - public override handleBlur(): void { + protected override _handleBlur(): void { this._focused = false; // Handle incomplete mask input @@ -268,7 +188,7 @@ export default class IgcDateTimeInputComponent extends IgcDateTimeInputBaseCompo this.clear(); } } else { - this.updateMask(); + this._updateMaskDisplay(); } // Emit change event if value changed @@ -281,37 +201,92 @@ export default class IgcDateTimeInputComponent extends IgcDateTimeInputBaseCompo //#endregion - //#region Internal API + //#region Navigation - public override updateMask(): void { - if (this._focused) { - this._maskedValue = this.getMaskedValue(); - return; - } + /** + * 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 + */ + protected override calculatePartNavigationPosition( + inputValue: string, + direction: number + ): number { + const cursorPos = this._maskSelection.start; + const dateParts = this._parser.dateParts; - if (!isValidDate(this.value)) { - this._maskedValue = ''; - return; + if (direction === 0) { + // Navigate backwards: find last literal before cursor + const part = dateParts.findLast( + (part) => part.type === DateParts.Literal && part.end < cursorPos + ); + return part?.end ?? 0; } - this._maskedValue = formatDisplayDate( - this.value, - this.locale, - this.displayFormat + // Navigate forwards: find first literal after cursor + const part = dateParts.find( + (part) => part.type === DateParts.Literal && part.start > cursorPos ); + return part?.start ?? inputValue.length; } - public override updateValue(): void { - if (!this._isMaskComplete()) { - this.value = null; - return; - } + //#endregion - const parsedDate = this._parser.parseDate(this._maskedValue); - this.value = isValidDate(parsedDate) ? parsedDate : null; + //#region Internal API + + /** + * Builds the masked value string from the current date value. + * Returns empty mask if no value, or existing masked value if incomplete. + */ + protected override buildMaskedValue(): string { + return isValidDate(this.value) + ? this._parser.formatDate(this.value) + : this._maskedValue || this._parser.emptyMask; + } + + /** + * 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; + } + + /** + * Calculates the new date value after spinning a date part. + */ + protected calculateSpunValue( + datePart: DatePart, + delta: number | undefined, + isDecrement: boolean + ): Date { + // Default to 1 if delta is 0 or undefined + const effectiveDelta = + delta || this._datePartDeltas[datePart as keyof DatePartDeltas] || 1; + + const spinAmount = isDecrement + ? -Math.abs(effectiveDelta) + : Math.abs(effectiveDelta); + + return this.spinDatePart(datePart, spinAmount); } - public override spinValue(datePart: DatePart, delta: number): Date { + /** + * Spins a specific date part by the given delta. + */ + protected spinDatePart(datePart: DatePart, delta: number): Date { if (!isValidDate(this.value)) { return new Date(); } @@ -349,69 +324,18 @@ export default class IgcDateTimeInputComponent extends IgcDateTimeInputBaseCompo return newDate; } - public override setMask(formatString: string): void { - const previous = this._parser.mask; - this._parser.mask = formatString; - - if (!this.placeholder || previous === this.placeholder) { - this.placeholder = this._parser.mask; - } - } - - public override getMaskedValue(): string { - return isValidDate(this.value) - ? this._parser.formatDate(this.value) - : this._maskedValue || this._parser.emptyMask; - } - - protected override updateDefaultMask(): void { - this._updateDefaultDisplayFormat(); - - if (!this._inputFormat) { - this.setMask(getDefaultDateTimeFormat(this.locale)); - } - } - - public override getNewPosition( - inputValue: string, - direction: number - ): number { - const cursorPos = this._maskSelection.start; - const dateParts = this._parser.dateParts; - - if (direction === 0) { - const part = dateParts.findLast( - (part) => part.type === DateParts.Literal && part.end < cursorPos - ); - return part?.end ?? 0; - } - - const part = dateParts.find( - (part) => part.type === DateParts.Literal && part.start > cursorPos - ); - return part?.start ?? inputValue.length; - } - - private _updateDefaultDisplayFormat(): void { - this._defaultDisplayFormat = getDateFormatter().getLocaleDateTimeFormat( - 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. + * 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 _getDatePartAtCursor(): DatePart | undefined { - return this._parser.getDatePartForCursor(this._inputSelection.start) - ?.type as DatePart | undefined; - } + protected override updateValueFromMask(): void { + if (!this._isMaskComplete()) { + this.value = null; + return; + } - private _getDefaultDatePart(): DatePart | undefined { - return (this._parser.getPartByType(DateParts.Date)?.type ?? - this._parser.getPartByType(DateParts.Hours)?.type ?? - this._parser.getFirstDatePart()?.type) as DatePart | undefined; + const parsedDate = this._parser.parseDate(this._maskedValue); + this.value = isValidDate(parsedDate) ? parsedDate : null; } //#endregion @@ -419,42 +343,24 @@ export default class IgcDateTimeInputComponent extends IgcDateTimeInputBaseCompo //#region Public API /* blazorSuppress */ - public override get hasDateParts(): boolean { + /** + * Checks whether the current format includes date parts (day, month, year). + * @internal + */ + public override hasDateParts(): boolean { return this._parser.hasDateParts(); } /* blazorSuppress */ - public override get hasTimeParts(): boolean { + /** + * Checks whether the current format includes time parts (hours, minutes, seconds). + * @internal + */ + 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..8096f250d 100644 --- a/src/components/date-time-input/datetime-mask-parser.ts +++ b/src/components/date-time-input/datetime-mask-parser.ts @@ -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 diff --git a/stories/date-time-input.stories.ts b/stories/date-time-input.stories.ts index f9a3ea144..879d1b61c 100644 --- a/stories/date-time-input.stories.ts +++ b/stories/date-time-input.stories.ts @@ -27,17 +27,27 @@ const metadata: Meta = { 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.', options: ['string', 'Date'], control: 'text', }, + min: { + type: 'Date', + description: 'The minimum value required for the input to remain valid.', + control: 'date', + }, + max: { + type: 'Date', + 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: @@ -56,16 +66,6 @@ const metadata: Meta = { 'Gets/Sets the locale used for formatting the display value.', control: 'text', }, - min: { - type: 'Date', - description: 'The minimum value required for the input to remain valid.', - control: 'date', - }, - max: { - type: 'Date', - description: 'The maximum value required for the input to remain valid.', - control: 'date', - }, readOnly: { type: 'boolean', description: 'Makes the control a readonly field.', @@ -141,10 +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. @@ -154,10 +158,6 @@ interface IgcDateTimeInputArgs { spinLoop: boolean; /** Gets/Sets the locale used for formatting the display value. */ locale: string; - /** The minimum value required for the input to remain valid. */ - min: Date; - /** The maximum value required for the input to remain valid. */ - max: Date; /** Makes the control a readonly field. */ readOnly: boolean; /** The masked pattern of the component. */ From ae41f03f2fbe735fca9c3c51ccfd0e04ec34e62d Mon Sep 17 00:00:00 2001 From: Bozhidara Pachilova Date: Thu, 26 Feb 2026 17:25:12 +0200 Subject: [PATCH 05/14] chore(drp): working checkpoint --- .../date-range-picker/date-range-input.ts | 171 +++++++++++++----- .../date-range-mask-parser.ts | 80 ++++++-- .../date-range-picker-single.spec.ts | 55 ++++-- 3 files changed, 230 insertions(+), 76 deletions(-) diff --git a/src/components/date-range-picker/date-range-input.ts b/src/components/date-range-picker/date-range-input.ts index 7e2bc783e..6fbc5b771 100644 --- a/src/components/date-range-picker/date-range-input.ts +++ b/src/components/date-range-picker/date-range-input.ts @@ -1,7 +1,6 @@ import { LitElement } from 'lit'; import { property } from 'lit/decorators.js'; import { addThemingController } from '../../theming/theming-controller.js'; -import { isValidDate } from '../calendar/helpers.js'; import { CalendarDay } from '../calendar/model.js'; import { addSlotController, setSlots } from '../common/controllers/slot.js'; import { watch } from '../common/decorators/watch.js'; @@ -28,7 +27,6 @@ import { DateRangePosition, } from './date-range-mask-parser.js'; import type { DateRangeValue } from './date-range-picker.js'; -import { isCompleteDateRange } from './validators.js'; const Slots = setSlots( 'prefix', @@ -87,9 +85,26 @@ export default class IgcDateRangeInputComponent extends IgcDateTimeInputBaseComp protected override get _targetDatePart(): DateRangePart | undefined { if (this._focused) { - const part = this._parser.getDateRangePartForCursor( - this._inputSelection.start - ); + 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 && part.type === DatePartType.Literal) { + // Try to find the next non-literal part after the cursor + const nextPart = this._parser.rangeParts.find( + (p) => p.start >= cursorPos && p.type !== DatePartType.Literal + ); + + if (nextPart) { + part = nextPart; + } else { + // If no next part, find the previous non-literal part + const prevPart = [...this._parser.rangeParts] + .reverse() + .find((p) => p.end <= cursorPos && p.type !== DatePartType.Literal); + part = prevPart; + } + } if (part && part.type !== DatePartType.Literal) { return { @@ -139,6 +154,8 @@ export default class IgcDateRangeInputComponent extends IgcDateTimeInputBaseComp super.connectedCallback(); // Initialize with locale-aware format on first connection this._initializeDefaultMask(); + // Set initial display value + this._updateMaskDisplay(); } // #endregion @@ -183,11 +200,9 @@ export default class IgcDateRangeInputComponent extends IgcDateTimeInputBaseComp } protected override buildMaskedValue(): string { - return isCompleteDateRange(this.value) && - isValidDate(this.value.start) && - isValidDate(this.value.end) - ? this._parser.formatDateRange(this.value) - : this._maskedValue || this._parser.emptyMask; + // Format the range - parser handles null/undefined values gracefully + // by returning empty masks for missing start/end dates + return this._parser.formatDateRange(this.value); } protected override updateDefaultMask(): void { @@ -216,7 +231,14 @@ export default class IgcDateRangeInputComponent extends IgcDateTimeInputBaseComp protected override _updateMaskDisplay(): void { if (this._focused) { - this._maskedValue = this.buildMaskedValue(); + // Only reset mask from value when value is non-null (e.g. after spinning or programmatic set). + // When value is null the user is mid-typing — leave _maskedValue unchanged. + if (this.value && (this.value.start || this.value.end)) { + this._maskedValue = this.buildMaskedValue(); + } else if (!this._maskedValue) { + // Not yet initialised — show the empty mask so prompts are visible. + this._maskedValue = this._parser.emptyMask; + } return; } @@ -225,23 +247,19 @@ export default class IgcDateRangeInputComponent extends IgcDateTimeInputBaseComp return; } - // If custom display format is set (different from input format) - if (this._displayFormat) { - 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}` - : ''; - } else { - // Use input format (from parser) - this._maskedValue = this._parser.formatDateRange(this.value); - } + // When unfocused always use formatDisplayDate (locale-aware, un-padded). + // displayFormat can be undefined — formatDisplayDate handles that by using locale defaults. + 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 calculatePartNavigationPosition( @@ -251,19 +269,59 @@ export default class IgcDateRangeInputComponent extends IgcDateTimeInputBaseComp const cursorPos = this._maskSelection.start; const rangeParts = this._parser.rangeParts; + // Find the current non-literal part containing the cursor + const currentPart = rangeParts.find( + (p) => + p.type !== DateParts.Literal && + cursorPos >= p.start && + cursorPos <= p.end + ); + + // Only apply start/end jump for start/end parts + const isStartOrEndPart = + currentPart && + (currentPart.position === DateRangePosition.Start || + currentPart.position === DateRangePosition.End); + if (direction === 0) { - // Navigate backwards: find last literal before cursor - const part = [...rangeParts] + // 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 part?.end ?? 0; + .find((p) => p.type !== DateParts.Literal && p.end < cursorPos); + return prevPart?.start ?? 0; } - // Navigate forwards: find first literal after cursor - const part = rangeParts.find( - (p) => p.type === DateParts.Literal && p.start > cursorPos + // 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 part?.start ?? inputValue.length; + return nextPart?.end ?? inputValue.length; + } + + 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 || (!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; + } + + // Otherwise, use the base class implementation + super._performStep(datePart, delta, isDecrement); } protected override calculateSpunValue( @@ -304,14 +362,31 @@ export default class IgcDateRangeInputComponent extends IgcDateTimeInputBaseComp ); } + /** + * Checks if the mask has actual user input (not just prompts or empty). + */ + protected override _isMaskComplete(): boolean { + // Check if ALL non-literal positions are filled (no prompts remaining) + return !this._maskedValue.split('').some((char, index) => { + if (char === this.prompt && !this._parser.literalPositions.has(index)) { + return true; // Found a prompt in a non-literal position = incomplete + } + return false; + }); + } + protected override updateValueFromMask(): void { + // Only parse and update value if the mask is complete + // This prevents filling in default dates (01/01/2000) for incomplete masks if (!this._isMaskComplete()) { - // Don't update value if mask is incomplete - this._updateMaskDisplay(); + // Don't parse incomplete masks - just emit igcInput with null + this.value = null; return; } + // Parse the date range from the current masked value const parsed = this._parser.parseDateRange(this._maskedValue); + if (parsed && (parsed.start || parsed.end)) { this.value = parsed; } else { @@ -319,6 +394,16 @@ export default class IgcDateRangeInputComponent extends IgcDateTimeInputBaseComp } } + /** + * Emits the input event with the DateRangeValue detail. + */ + protected override _emitInputEvent(): void { + this._setTouchedState(); + // Cast to any to bypass type checking - the event map doesn't define igcInput for date range + // but runtime emits the DateRangeValue as detail + this.emitEvent('igcInput' as any, { detail: this.value }); + } + public override async handleFocus(): Promise { this._focused = true; @@ -327,14 +412,14 @@ export default class IgcDateRangeInputComponent extends IgcDateTimeInputBaseComp } this._oldValue = this.value; - const areFormatsDifferent = this.displayFormat !== this.inputFormat; - if (!this.value || !this.value.start || !this.value.end) { - this._maskedValue = this._parser.emptyMask; + // Always update mask display when focused to show the editable format + this._updateMaskDisplay(); + + // If no value, select all to allow immediate typing + if (!this.value || (!this.value.start && !this.value.end)) { await this.updateComplete; this.select(); - } else if (areFormatsDifferent) { - this._updateMaskDisplay(); } } diff --git a/src/components/date-range-picker/date-range-mask-parser.ts b/src/components/date-range-picker/date-range-mask-parser.ts index c5fb8710e..e687cd925 100644 --- a/src/components/date-range-picker/date-range-mask-parser.ts +++ b/src/components/date-range-picker/date-range-mask-parser.ts @@ -125,6 +125,64 @@ export class DateRangeMaskParser extends MaskParser { 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 = this._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; + } + + /** + * Converts date format characters to mask format characters. + * Date parts become '0' (numeric) and time markers become 'L' (alpha). + */ + private _convertDateFormatToMaskFormat(dateFormat: string): string { + // Set of date/time format characters + const dateChars = new Set([ + 'M', + 'd', + 'y', + 'Y', + 'D', + 'h', + 'H', + 'm', + 's', + 'S', + 't', + 'T', + ]); + + let result = ''; + for (const char of dateFormat) { + if (dateChars.has(char)) { + // AM/PM markers are alphabetic, others are numeric + result += char === 't' || char === 'T' ? 'L' : '0'; + } else { + result += char; + } + } + return result; + } + + //#endregion + /** * Builds the range parts array by combining parts from start and end parsers * and adding position information. @@ -227,8 +285,13 @@ export class DateRangeMaskParser extends MaskParser { * Formats a DateRangeValue into a masked string using the two internal parsers. */ public formatDateRange(range: DateRangeValue | null): string { + // If range is completely null/undefined, return the empty masks if (!range) { - return ''; + return ( + this._startParser.emptyMask + + this._separator + + this._endParser.emptyMask + ); } // Delegate formatting to the individual parsers @@ -307,21 +370,6 @@ export class DateRangeMaskParser extends MaskParser { //#region Override for Date Range Mask - /** - * Builds the internal mask pattern from the date range format. - * Delegates to the start parser's conversion logic. - */ - protected override _parseMaskLiterals(): void { - // The parent MaskParser will handle the mask literals - // We just need to rebuild range parts after parsing - super._parseMaskLiterals(); - - // Rebuild range parts if parsers are initialized - if (this._startParser && this._endParser) { - this._buildRangeParts(); - } - } - //#endregion //#region Spinning Support 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 b87602e71..87d29e1da 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,7 +5,7 @@ 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 { @@ -25,7 +25,6 @@ import { 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 { @@ -54,11 +53,15 @@ describe('Date range picker - single input', () => { rangeInput = picker.renderRoot.querySelector( IgcDateRangeInputComponent.tagName )!; - rangeInput.renderRoot.querySelector('input')!; + 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; @@ -161,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'); }); @@ -223,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'; @@ -258,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'; @@ -552,6 +561,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, { @@ -687,37 +699,44 @@ describe('Date range picker - single input', () => { 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(rangeInput); + 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(rangeInput); + await elementUpdated(input); expect(input.selectionStart).to.equal(15); expect(input.selectionEnd).to.equal(15); @@ -732,6 +751,7 @@ describe('Date range picker - single input', () => { simulateKeyboard(input, [ctrlKey, arrowLeft]); await elementUpdated(rangeInput); + await elementUpdated(input); expect(input.selectionStart).to.equal(6); expect(input.selectionEnd).to.equal(6); @@ -755,11 +775,12 @@ describe('Date range picker - single input', () => { simulateKeyboard(input, arrowUp); await elementUpdated(picker); + input.blur(); + await elementUpdated(input); + checkSelectedRange( picker, - { start: today.native, end: today.native }, - false ); }); @@ -781,7 +802,7 @@ describe('Date range picker - single input', () => { await elementUpdated(picker); input.blur(); - await elementUpdated(rangeInput); + await elementUpdated(input); await elementUpdated(picker); expect(input.value).to.equal(''); @@ -818,7 +839,7 @@ describe('Date range picker - single input', () => { input.blur(); await elementUpdated(picker); - expect(input.value).to.equal('01/01/2000 - 04/23/2025'); + expect(input.value).to.equal('1/1/2000 - 4/23/2025'); }); it('should toggle the calendar with keyboard combinations and keep focus', async () => { From 6c7e112ac6d3e724c8b58acb225d019979e2f8fe Mon Sep 17 00:00:00 2001 From: Bozhidara Pachilova Date: Wed, 4 Mar 2026 18:49:53 +0200 Subject: [PATCH 06/14] refactor(date-time-inputs): organize regions, remove redundancies --- .../date-range-picker/date-range-input.ts | 330 +++++++----------- .../date-range-mask-parser.ts | 9 +- .../date-time-input/date-time-input.base.ts | 283 +++++++-------- .../date-time-input/date-time-input.ts | 47 +-- src/components/input/input-base.ts | 3 +- stories/date-time-input.stories.ts | 6 +- stories/file-input.stories.ts | 6 +- stories/input.stories.ts | 6 +- stories/mask-input.stories.ts | 6 +- 9 files changed, 301 insertions(+), 395 deletions(-) diff --git a/src/components/date-range-picker/date-range-input.ts b/src/components/date-range-picker/date-range-input.ts index 6fbc5b771..b9097cceb 100644 --- a/src/components/date-range-picker/date-range-input.ts +++ b/src/components/date-range-picker/date-range-input.ts @@ -1,9 +1,6 @@ -import { LitElement } from 'lit'; import { property } from 'lit/decorators.js'; import { addThemingController } from '../../theming/theming-controller.js'; import { CalendarDay } from '../calendar/model.js'; -import { addSlotController, setSlots } from '../common/controllers/slot.js'; -import { watch } from '../common/decorators/watch.js'; import { registerComponent } from '../common/definitions/register.js'; import { formatDisplayDate, @@ -24,37 +21,16 @@ import { styles as shared } from '../input/themes/shared/input.common.css.js'; import { all } from '../input/themes/themes.js'; import { DateRangeMaskParser, + type DateRangePart, DateRangePosition, } from './date-range-mask-parser.js'; import type { DateRangeValue } from './date-range-picker.js'; -const Slots = setSlots( - 'prefix', - 'suffix', - 'helper-text', - 'value-missing', - 'bad-input', - 'custom-error', - 'invalid' -); - -/** @ignore */ -export interface DateRangePart { - part: DatePart; - position: DateRangePosition; -} - export default class IgcDateRangeInputComponent extends IgcDateTimeInputBaseComponent< DateRangeValue | null, DateRangePart > { public static readonly tagName = 'igc-date-range-input'; - - protected static shadowRootOptions = { - ...LitElement.shadowRootOptions, - delegatesFocus: true, - }; - public static styles = [styles, shared]; /* blazorSuppress */ @@ -62,19 +38,14 @@ export default class IgcDateRangeInputComponent extends IgcDateTimeInputBaseComp registerComponent(IgcDateRangeInputComponent); } - // #region Properties - - protected override readonly _themes = addThemingController(this, all); - - protected override readonly _slots = addSlotController(this, { - slots: Slots, - }); + //#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 _datePartDeltas: DatePartDeltas = { @@ -90,7 +61,6 @@ export default class IgcDateRangeInputComponent extends IgcDateTimeInputBaseComp // If cursor is at a literal, find the nearest non-literal part if (part && part.type === DatePartType.Literal) { - // Try to find the next non-literal part after the cursor const nextPart = this._parser.rangeParts.find( (p) => p.start >= cursorPos && p.type !== DatePartType.Literal ); @@ -108,7 +78,7 @@ export default class IgcDateRangeInputComponent extends IgcDateTimeInputBaseComp if (part && part.type !== DatePartType.Literal) { return { - part: part.type as string as DatePart, + part: part.type as DatePart, position: part.position, }; } @@ -118,7 +88,7 @@ export default class IgcDateRangeInputComponent extends IgcDateTimeInputBaseComp ); if (firstPart) { return { - part: firstPart.type as string as DatePart, + part: firstPart.type as DatePart, position: DateRangePosition.Start, }; } @@ -129,7 +99,7 @@ export default class IgcDateRangeInputComponent extends IgcDateTimeInputBaseComp // #endregion - // #region Public properties + // #region Public attributes and properties /* @tsTwoWayProperty(true, "igcChange", "detail", false, true) */ /** @@ -148,128 +118,78 @@ export default class IgcDateRangeInputComponent extends IgcDateTimeInputBaseComp // #endregion - // #region Lifecycle + // #region Lifecycle Hooks public override connectedCallback(): void { super.connectedCallback(); - // Initialize with locale-aware format on first connection this._initializeDefaultMask(); - // Set initial display value this._updateMaskDisplay(); } // #endregion - // #region Methods + // #region Event Handlers Overrides - @watch('locale', { waitUntilFirstUpdate: true }) - protected _localeChanged(): void { - this.updateDefaultMask(); - this._updateMaskDisplay(); - } - - protected override _initializeDefaultMask(): void { - // Call base to update _defaultDisplayFormat - super._initializeDefaultMask(); + protected override async _handleFocus(): Promise { + this._focused = true; - // Apply range-specific mask if no custom input format - if (!this._inputFormat) { - const singleFormat = getDefaultDateTimeFormat(this.locale); - this._parser.mask = singleFormat; - this._defaultMask = singleFormat; - this.placeholder = `${singleFormat}${this._parser.separator}${singleFormat}`; + if (this.readOnly) { + return; } - } - protected override hasDateParts(): boolean { - return this._parser.rangeParts.some( - (p) => - p.type === DatePartType.Date || - p.type === DatePartType.Month || - p.type === DatePartType.Year - ); - } - - protected override hasTimeParts(): boolean { - return this._parser.rangeParts.some( - (p) => - p.type === DatePartType.Hours || - p.type === DatePartType.Minutes || - p.type === DatePartType.Seconds - ); - } - - protected override buildMaskedValue(): string { - // Format the range - parser handles null/undefined values gracefully - // by returning empty masks for missing start/end dates - return this._parser.formatDateRange(this.value); - } + this._oldValue = this.value; - protected override updateDefaultMask(): void { - if (!this._inputFormat) { - const singleFormat = getDefaultDateTimeFormat(this.locale); - this._parser.mask = singleFormat; - this._defaultMask = singleFormat; - this.placeholder = `${singleFormat}${this._parser.separator}${singleFormat}`; + 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 _applyMask(string: string): void { - const oldPlaceholder = this.placeholder; - const oldFormat = this._defaultMask; + public override _handleBlur(): void { + const isSameValue = equal(this._oldValue, this.value); - // string is the single date format - this._parser.mask = string; - this._defaultMask = string; - this._parser.prompt = this.prompt; + this._focused = false; - // Update placeholder if it was using the old format - if (!this.placeholder || oldFormat === oldPlaceholder) { - this.placeholder = `${string}${this._parser.separator}${string}`; - } - } + if (!(this._isMaskComplete() || this._isEmptyMask)) { + const parsed = this._parser.parseDateRange(this._maskedValue); - protected override _updateMaskDisplay(): void { - if (this._focused) { - // Only reset mask from value when value is non-null (e.g. after spinning or programmatic set). - // When value is null the user is mid-typing — leave _maskedValue unchanged. - if (this.value && (this.value.start || this.value.end)) { - this._maskedValue = this.buildMaskedValue(); - } else if (!this._maskedValue) { - // Not yet initialised — show the empty mask so prompts are visible. - this._maskedValue = this._parser.emptyMask; + if (parsed && (parsed.start || parsed.end)) { + this.value = parsed; + } else { + this.value = null; + this._maskedValue = ''; } - return; + } else { + this._updateMaskDisplay(); } - if (!this.value || (!this.value.start && !this.value.end)) { - this._maskedValue = ''; - return; + if (!(this.readOnly || isSameValue)) { + this.emitEvent('igcChange', { detail: this.value }); } - // When unfocused always use formatDisplayDate (locale-aware, un-padded). - // displayFormat can be undefined — formatDisplayDate handles that by using locale defaults. - 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; + 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( + protected override _calculatePartNavigationPosition( inputValue: string, direction: number ): number { const cursorPos = this._maskSelection.start; const rangeParts = this._parser.rangeParts; - // Find the current non-literal part containing the cursor const currentPart = rangeParts.find( (p) => p.type !== DateParts.Literal && @@ -277,7 +197,6 @@ export default class IgcDateRangeInputComponent extends IgcDateTimeInputBaseComp cursorPos <= p.end ); - // Only apply start/end jump for start/end parts const isStartOrEndPart = currentPart && (currentPart.position === DateRangePosition.Start || @@ -304,6 +223,40 @@ export default class IgcDateRangeInputComponent extends IgcDateTimeInputBaseComp return nextPart?.end ?? inputValue.length; } + // #endregion + + // #region Internal API Overrides + + 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 && (this.value.start || this.value.end)) { + this._maskedValue = this._buildMaskedValue(); + } else if (!this._maskedValue) { + this._maskedValue = this._parser.emptyMask; + } + return; + } + + if (!this.value || (!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, @@ -320,23 +273,20 @@ export default class IgcDateRangeInputComponent extends IgcDateTimeInputBaseComp return; } - // Otherwise, use the base class implementation super._performStep(datePart, delta, isDecrement); } - protected override calculateSpunValue( + protected override _calculateSpunValue( datePart: DateRangePart, delta: number | undefined, isDecrement: boolean ): DateRangeValue { - // Get the part from the parser const part = this._parser.getPartByTypeAndPosition( datePart.part as DatePartType, datePart.position ); if (!part) { - // Fallback if part not found return ( this.value || { start: CalendarDay.today.native, @@ -345,7 +295,6 @@ export default class IgcDateRangeInputComponent extends IgcDateTimeInputBaseComp ); } - // Default to 1 if delta is 0 or undefined const effectiveDelta = delta || this._datePartDeltas[datePart.part as keyof DatePartDeltas] || 1; @@ -353,7 +302,6 @@ export default class IgcDateRangeInputComponent extends IgcDateTimeInputBaseComp ? -Math.abs(effectiveDelta) : Math.abs(effectiveDelta); - // Use the parser's spinDateRangePart method return this._parser.spinDateRangePart( part, spinAmount, @@ -362,100 +310,68 @@ export default class IgcDateRangeInputComponent extends IgcDateTimeInputBaseComp ); } - /** - * Checks if the mask has actual user input (not just prompts or empty). - */ - protected override _isMaskComplete(): boolean { - // Check if ALL non-literal positions are filled (no prompts remaining) - return !this._maskedValue.split('').some((char, index) => { - if (char === this.prompt && !this._parser.literalPositions.has(index)) { - return true; // Found a prompt in a non-literal position = incomplete - } - return false; - }); - } - - protected override updateValueFromMask(): void { - // Only parse and update value if the mask is complete - // This prevents filling in default dates (01/01/2000) for incomplete masks - if (!this._isMaskComplete()) { - // Don't parse incomplete masks - just emit igcInput with null - this.value = null; - return; - } - - // Parse the date range from the current masked value - const parsed = this._parser.parseDateRange(this._maskedValue); + protected override _initializeDefaultMask(): void { + super._initializeDefaultMask(); - if (parsed && (parsed.start || parsed.end)) { - this.value = parsed; - } else { - this.value = null; + if (!this._inputFormat) { + const singleFormat = getDefaultDateTimeFormat(this.locale); + this._parser.mask = singleFormat; + this._defaultMask = singleFormat; + this.placeholder = `${singleFormat}${this._parser.separator}${singleFormat}`; } } - /** - * Emits the input event with the DateRangeValue detail. - */ - protected override _emitInputEvent(): void { - this._setTouchedState(); - // Cast to any to bypass type checking - the event map doesn't define igcInput for date range - // but runtime emits the DateRangeValue as detail - this.emitEvent('igcInput' as any, { detail: this.value }); + protected override _buildMaskedValue(): string { + return this._parser.formatDateRange(this.value); } - public override async handleFocus(): Promise { - this._focused = true; - - if (this.readOnly) { - return; - } - - this._oldValue = this.value; + protected override _applyMask(string: string): void { + const oldPlaceholder = this.placeholder; + const oldFormat = this._defaultMask; - // Always update mask display when focused to show the editable format - this._updateMaskDisplay(); + // string is the single date format + this._parser.mask = string; + this._defaultMask = string; + this._parser.prompt = this.prompt; - // If no value, select all to allow immediate typing - if (!this.value || (!this.value.start && !this.value.end)) { - await this.updateComplete; - this.select(); + if (!this.placeholder || oldFormat === oldPlaceholder) { + this.placeholder = `${string}${this._parser.separator}${string}`; } } - public override _handleBlur(): void { - const isEmptyMask = this._maskedValue === this._parser.emptyMask; - const isSameValue = equal(this._oldValue, this.value); - - this._focused = false; + protected override _updateValueFromMask(): void { + if (!this._isMaskComplete()) { + this.value = null; + return; + } - if (!(this._isMaskComplete() || isEmptyMask)) { - const parsed = this._parser.parseDateRange(this._maskedValue); + const parsed = this._parser.parseDateRange(this._maskedValue); - if (parsed && (parsed.start || parsed.end)) { - this.value = parsed; - } else { - this.value = null; - this._maskedValue = ''; - } + if (parsed && (parsed.start || parsed.end)) { + this.value = parsed; } else { - this._updateMaskDisplay(); + this.value = null; } + } - if (!(this.readOnly || isSameValue)) { - this.emitEvent('igcChange', { detail: this.value }); - } + // #region Public API Overrides - super._handleBlur(); + public override hasDateParts(): boolean { + return this._parser.rangeParts.some( + (p) => + p.type === DatePartType.Date || + p.type === DatePartType.Month || + p.type === DatePartType.Year + ); } - /** - * Sets the value to a range of the current date/time as start and end. - */ - protected override _setCurrentDateTime(): void { - const today = CalendarDay.today.native; - this.value = { start: today, end: today }; - this._emitInputEvent(); + public override hasTimeParts(): boolean { + return this._parser.rangeParts.some( + (p) => + p.type === DatePartType.Hours || + p.type === DatePartType.Minutes || + p.type === DatePartType.Seconds + ); } // #endregion diff --git a/src/components/date-range-picker/date-range-mask-parser.ts b/src/components/date-range-picker/date-range-mask-parser.ts index e687cd925..167341b0e 100644 --- a/src/components/date-range-picker/date-range-mask-parser.ts +++ b/src/components/date-range-picker/date-range-mask-parser.ts @@ -1,4 +1,5 @@ import { + type DatePart, DatePartType, type IDatePart, type SpinOptions, @@ -12,6 +13,11 @@ import type { DateRangeValue } from './date-range-picker.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', @@ -60,10 +66,7 @@ const DEFAULT_SEPARATOR = ' - '; * ``` */ export class DateRangeMaskParser extends MaskParser { - /** Parser for the start date */ private _startParser: DateTimeMaskParser; - - /** Parser for the end date */ private _endParser: DateTimeMaskParser; /** Cached date range parts with position information */ diff --git a/src/components/date-time-input/date-time-input.base.ts b/src/components/date-time-input/date-time-input.base.ts index b8165c3f1..1e9d72fd5 100644 --- a/src/components/date-time-input/date-time-input.base.ts +++ b/src/components/date-time-input/date-time-input.base.ts @@ -12,7 +12,7 @@ import { arrowUp, ctrlKey, } from '../common/controllers/key-bindings.js'; -import { watch } from '../common/decorators/watch.js'; +import { addSlotController, setSlots } from '../common/controllers/slot.js'; import { addI18nController, formatDisplayDate, @@ -27,9 +27,20 @@ import { IgcMaskInputBaseComponent, type MaskSelection, } from '../mask-input/mask-input-base.js'; -// Note: Concrete implementations should define their own date part types +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' @@ -43,12 +54,16 @@ export abstract class IgcDateTimeInputBaseComponent< IgcDateTimeInputComponentEventMap, AbstractConstructor >(IgcMaskInputBaseComponent) { - // #region Internal state & properties + // #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, @@ -62,29 +77,14 @@ export abstract class IgcDateTimeInputBaseComponent< protected _defaultMask!: string; // Format and mask state + protected _defaultDisplayFormat = ''; protected _displayFormat?: string; protected _inputFormat?: string; - protected _defaultDisplayFormat = ''; - - protected abstract get _datePartDeltas(): any; - protected abstract get _targetDatePart(): TPart | undefined; - - protected hasDateParts(): boolean { - // Override in subclass with specific implementation - return false; - } - - protected hasTimeParts(): boolean { - // Override in subclass with specific implementation - return false; - } - // #endregion - // #region Public properties + // #region Public attributes and properties - // @ts-expect-error - TValue can include DateRangeValue which is not in base type public abstract override value: TValue | null; /** @@ -153,7 +153,7 @@ export abstract class IgcDateTimeInputBaseComponent< * All values default to `1`. */ @property({ attribute: false }) - public spinDelta?: any; + public spinDelta?: DatePartDeltas; /** * Sets whether to loop over the currently spun segment. @@ -177,7 +177,7 @@ export abstract class IgcDateTimeInputBaseComponent< // #endregion - // #region Lifecycle & observers + //#region Lifecycle Hooks constructor() { super(); @@ -193,8 +193,6 @@ export abstract class IgcDateTimeInputBaseComponent< .set([ctrlKey, arrowRight], this._navigateParts.bind(this, 1)); } - //#region Lifecycle Hooks - protected override update(props: PropertyValues): void { if (props.has('displayFormat')) { this._updateDefaultDisplayFormat(); @@ -223,64 +221,44 @@ export abstract class IgcDateTimeInputBaseComponent< } protected override _updateSetRangeTextValue(): void { - this.updateValueFromMask(); + this._updateValueFromMask(); } - //#endregion + protected override async _updateInput(string: string, range: MaskSelection) { + const { value, end } = this._parser.replace( + this._maskedValue, + string, + range.start, + range.end + ); - @watch('prompt', { waitUntilFirstUpdate: true }) - protected _promptChange(): void { - if (!this.prompt) { - this.prompt = this._parser.prompt; - } else { - this._parser.prompt = this.prompt; + 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 Methods - - /** 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); - } + // #region Event handlers /** - * Common logic for stepping up or down a date part. - * @internal + * Emits the input event after user interaction. */ - 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)); - } - - /** Clears the input element of user input. */ - public clear(): void { - this._maskedValue = ''; - this.value = null; + protected _emitInputEvent(): void { + this._setTouchedState(); + this.emitEvent('igcInput', { detail: this.value?.toString() }); } - /** - * Sets the value to the current date/time. - */ - protected _setCurrentDateTime(): void { - this.value = new Date() as TValue; - this._emitInputEvent(); + private _handleResourceChange(): void { + this._initializeDefaultMask(); + this._updateMaskDisplay(); } protected _handleDragLeave(): void { @@ -291,78 +269,51 @@ export abstract class IgcDateTimeInputBaseComponent< protected _handleDragEnter(): void { if (!this._focused) { - this._maskedValue = this.buildMaskedValue(); + this._maskedValue = this._buildMaskedValue(); } } - protected override async _updateInput(string: string, range: MaskSelection) { - const { value, end } = this._parser.replace( - this._maskedValue, - string, - range.start, - range.end - ); + /** + * Handles wheel events for spinning date parts. + */ + @eventOptions({ passive: false }) + protected async _handleWheel(event: WheelEvent): Promise { + if (!this._focused || this.readOnly) return; - this._maskedValue = value; + event.preventDefault(); + event.stopPropagation(); - this.updateValueFromMask(); - this.requestUpdate(); + const { start, end } = this._inputSelection; + event.deltaY > 0 ? this.stepDown() : this.stepUp(); + this._emitInputEvent(); - if (range.start !== this.inputFormat.length) { - this._emitInputEvent(); - } await this.updateComplete; - this._input?.setSelectionRange(end, end); + this.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; - } + // #endregion - this._maskedValue = formatDisplayDate( - this.value, - this.locale, - this.displayFormat - ); - } + //#region Keybindings /** - * Checks if all mask positions are filled (no prompt characters remain). + * Sets the value to the current date/time. */ - protected _isMaskComplete(): boolean { - return !this._maskedValue.includes(this.prompt); + 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( + const position = this._calculatePartNavigationPosition( this._input?.value ?? '', direction ); this.setSelectionRange(position, position); } - /** - * Emits the input event after user interaction. - */ - protected _emitInputEvent(): void { - this._setTouchedState(); - this.emitEvent('igcInput', { detail: this.value?.toString() }); - } - /** * Handles keyboard-triggered spinning (arrow up/down). */ @@ -373,31 +324,55 @@ export abstract class IgcDateTimeInputBaseComponent< this.setSelectionRange(this._maskSelection.start, this._maskSelection.end); } + // #endregion + + //#region Internal API + /** - * Handles wheel events for spinning date parts. + * Common logic for stepping up or down a date part. + * @internal */ - @eventOptions({ passive: false }) - protected async _handleWheel(event: WheelEvent): Promise { - if (!this._focused || this.readOnly) return; - - event.preventDefault(); - event.stopPropagation(); + protected _performStep( + datePart: TPart | undefined, + delta: number | undefined, + isDecrement: boolean + ): void { + const part = datePart || this._targetDatePart; + if (!part) return; const { start, end } = this._inputSelection; - event.deltaY > 0 ? this.stepDown() : this.stepUp(); - this._emitInputEvent(); - - await this.updateComplete; - this.setSelectionRange(start, end); + const newValue = this._calculateSpunValue(part, delta, isDecrement); + this.value = newValue as TValue; + this.updateComplete.then(() => this._input?.setSelectionRange(start, end)); } - protected updateDefaultMask(): void { - // Override in subclass with specific implementation + /** + * 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 + ); } - private _handleResourceChange(): void { - this._initializeDefaultMask(); - this._updateMaskDisplay(); + /** + * Checks if all mask positions are filled (no prompt characters remain). + */ + protected _isMaskComplete(): boolean { + return !this._maskedValue.includes(this.prompt); } /** @@ -430,6 +405,26 @@ export abstract class IgcDateTimeInputBaseComponent< } } + // #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` ; + protected abstract _handleFocus(): Promise; + + 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 8d6396c7c..28ca9e791 100644 --- a/src/components/date-time-input/date-time-input.ts +++ b/src/components/date-time-input/date-time-input.ts @@ -1,8 +1,6 @@ import { property } from 'lit/decorators.js'; - import { addThemingController } from '../../theming/theming-controller.js'; import { convertToDate, isValidDate } from '../calendar/helpers.js'; -import { addSlotController, setSlots } from '../common/controllers/slot.js'; import { registerComponent } from '../common/definitions/register.js'; import { FormValueDateTimeTransformers } from '../common/mixins/forms/form-transformers.js'; import { createFormValueState } from '../common/mixins/forms/form-value.js'; @@ -23,17 +21,6 @@ import { } from './datetime-mask-parser.js'; import { dateTimeInputValidators } from './validators.js'; -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. @@ -76,12 +63,8 @@ export default class IgcDateTimeInputComponent extends IgcDateTimeInputBaseCompo //#region Private state and properties - protected override readonly _parser = new DateTimeMaskParser(); 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, @@ -157,7 +140,7 @@ export default class IgcDateTimeInputComponent extends IgcDateTimeInputBaseCompo //#region Event handlers - protected async handleFocus(): Promise { + protected async _handleFocus(): Promise { this._focused = true; if (this.readOnly) { @@ -208,7 +191,7 @@ export default class IgcDateTimeInputComponent extends IgcDateTimeInputBaseCompo * direction = 0: navigate to start of previous part * direction = 1: navigate to start of next part */ - protected override calculatePartNavigationPosition( + protected override _calculatePartNavigationPosition( inputValue: string, direction: number ): number { @@ -234,16 +217,6 @@ export default class IgcDateTimeInputComponent extends IgcDateTimeInputBaseCompo //#region Internal API - /** - * Builds the masked value string from the current date value. - * Returns empty mask if no value, or existing masked value if incomplete. - */ - protected override buildMaskedValue(): string { - return isValidDate(this.value) - ? this._parser.formatDate(this.value) - : this._maskedValue || this._parser.emptyMask; - } - /** * Gets the date part at the current cursor position. * Uses inclusive end to handle cursor at the end of the last part. @@ -264,10 +237,20 @@ export default class IgcDateTimeInputComponent extends IgcDateTimeInputBaseCompo this._parser.getFirstDatePart()?.type) as DatePart | undefined; } + /** + * Builds the masked value string from the current date value. + * Returns empty mask if no value, or existing masked value if incomplete. + */ + 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. */ - protected calculateSpunValue( + protected override _calculateSpunValue( datePart: DatePart, delta: number | undefined, isDecrement: boolean @@ -328,7 +311,7 @@ export default class IgcDateTimeInputComponent extends IgcDateTimeInputBaseCompo * 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. */ - protected override updateValueFromMask(): void { + protected override _updateValueFromMask(): void { if (!this._isMaskComplete()) { this.value = null; return; diff --git a/src/components/input/input-base.ts b/src/components/input/input-base.ts index 6f9a4d1a5..456084c21 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 '../date-range-picker/date-range-picker.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/stories/date-time-input.stories.ts b/stories/date-time-input.stories.ts index 879d1b61c..0e580c1a0 100644 --- a/stories/date-time-input.stories.ts +++ b/stories/date-time-input.stories.ts @@ -28,9 +28,9 @@ const metadata: Meta = { }, argTypes: { value: { - type: 'string | Date', + type: 'string | Date | DateRangeValue', description: 'The value of the input.', - options: ['string', 'Date'], + options: ['string', 'Date', 'DateRangeValue'], control: 'text', }, min: { @@ -142,7 +142,7 @@ export default metadata; interface IgcDateTimeInputArgs { /** The value of the input. */ - value: string | Date; + value: string | Date | DateRangeValue; /** The minimum value required for the input to remain valid. */ min: Date; /** The maximum value required for the input to remain valid. */ diff --git a/stories/file-input.stories.ts b/stories/file-input.stories.ts index f4819284d..4faea6371 100644 --- a/stories/file-input.stories.ts +++ b/stories/file-input.stories.ts @@ -29,10 +29,10 @@ const metadata: Meta = { }, argTypes: { value: { - type: 'string | Date', + type: 'string | Date | DateRangeValue', description: 'The value of the control.\nSimilar to native file input, this property is read-only and cannot be set programmatically.', - options: ['string', 'Date'], + options: ['string', 'Date', 'DateRangeValue'], control: 'text', }, locale: { @@ -120,7 +120,7 @@ interface IgcFileInputArgs { * The value of the control. * Similar to native file input, this property is read-only and cannot be set programmatically. */ - value: string | Date; + value: string | Date | DateRangeValue; /** Gets/Sets the locale used for getting language, affecting resource strings. */ locale: string; /** diff --git a/stories/input.stories.ts b/stories/input.stories.ts index 3bba170f9..8dc816cc7 100644 --- a/stories/input.stories.ts +++ b/stories/input.stories.ts @@ -33,9 +33,9 @@ const metadata: Meta = { }, argTypes: { value: { - type: 'string | Date', + type: 'string | Date | DateRangeValue', description: 'The value of the control.', - options: ['string', 'Date'], + options: ['string', 'Date', 'DateRangeValue'], control: 'text', }, type: { @@ -162,7 +162,7 @@ export default metadata; interface IgcInputArgs { /** The value of the control. */ - value: string | Date; + value: string | Date | DateRangeValue; /** The type attribute of the control. */ type: 'text' | 'email' | 'number' | 'password' | 'search' | 'tel' | 'url'; /** Makes the control a readonly field. */ diff --git a/stories/mask-input.stories.ts b/stories/mask-input.stories.ts index 4246d7d34..1aa10cb70 100644 --- a/stories/mask-input.stories.ts +++ b/stories/mask-input.stories.ts @@ -41,10 +41,10 @@ const metadata: Meta = { table: { defaultValue: { summary: 'raw' } }, }, value: { - type: 'string | Date', + type: 'string | Date | DateRangeValue', description: 'The value of the input.\n\nRegardless of the currently set `value-mode`, an empty value will return an empty string.', - options: ['string', 'Date'], + options: ['string', 'Date', 'DateRangeValue'], control: 'text', }, mask: { @@ -136,7 +136,7 @@ interface IgcMaskInputArgs { * * Regardless of the currently set `value-mode`, an empty value will return an empty string. */ - value: string | Date; + value: string | Date | DateRangeValue; /** The masked pattern of the component. */ mask: string; /** The prompt symbol to use for unfilled parts of the mask pattern. */ From e4163792d739a8e7b5f30f6060ff9cbaf614b6aa Mon Sep 17 00:00:00 2001 From: Bozhidara Pachilova Date: Thu, 5 Mar 2026 15:49:48 +0200 Subject: [PATCH 07/14] refactor(date-time-inputs): polishing and fixes --- .../date-range-picker/date-range-input.ts | 144 +++++++++--------- .../date-range-mask-parser.ts | 131 +++------------- .../date-range-picker-single.spec.ts | 96 ++++++++++++ .../date-range-picker/date-range-picker.ts | 2 +- .../date-time-input/date-time-input.base.ts | 9 +- .../date-time-input/date-time-input.ts | 15 +- .../date-time-input/datetime-mask-parser.ts | 16 +- 7 files changed, 217 insertions(+), 196 deletions(-) diff --git a/src/components/date-range-picker/date-range-input.ts b/src/components/date-range-picker/date-range-input.ts index b9097cceb..9f6fa96c1 100644 --- a/src/components/date-range-picker/date-range-input.ts +++ b/src/components/date-range-picker/date-range-input.ts @@ -54,48 +54,7 @@ export default class IgcDateRangeInputComponent extends IgcDateTimeInputBaseComp year: 1, }; - protected override get _targetDatePart(): DateRangePart | undefined { - if (this._focused) { - 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 && part.type === DatePartType.Literal) { - const nextPart = this._parser.rangeParts.find( - (p) => p.start >= cursorPos && p.type !== DatePartType.Literal - ); - - if (nextPart) { - part = nextPart; - } else { - // If no next part, find the previous non-literal part - const prevPart = [...this._parser.rangeParts] - .reverse() - .find((p) => p.end <= cursorPos && p.type !== DatePartType.Literal); - part = prevPart; - } - } - - if (part && part.type !== DatePartType.Literal) { - return { - part: part.type as DatePart, - position: part.position, - }; - } - } else { - const firstPart = this._parser.getFirstDatePartForPosition( - DateRangePosition.Start - ); - if (firstPart) { - return { - part: firstPart.type as DatePart, - position: DateRangePosition.Start, - }; - } - } - - return undefined; - } + // #endregion // #endregion @@ -231,7 +190,7 @@ export default class IgcDateRangeInputComponent extends IgcDateTimeInputBaseComp 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 && (this.value.start || this.value.end)) { + if (this.value?.start || this.value?.end) { this._maskedValue = this._buildMaskedValue(); } else if (!this._maskedValue) { this._maskedValue = this._parser.emptyMask; @@ -239,17 +198,17 @@ export default class IgcDateRangeInputComponent extends IgcDateTimeInputBaseComp return; } - if (!this.value || (!this.value.start && !this.value.end)) { + if (!this.value?.start && !this.value?.end) { this._maskedValue = ''; return; } const { start, end } = this.value; const startStr = start - ? formatDisplayDate(start, this.locale, this._displayFormat) + ? formatDisplayDate(start, this.locale, this.displayFormat) : ''; const endStr = end - ? formatDisplayDate(end, this.locale, this._displayFormat) + ? formatDisplayDate(end, this.locale, this.displayFormat) : ''; this._maskedValue = startStr && endStr @@ -263,7 +222,7 @@ export default class IgcDateRangeInputComponent extends IgcDateTimeInputBaseComp isDecrement: boolean ): void { // If no value exists, set to today's date first - if (!this.value || (!this.value.start && !this.value.end)) { + if (!this.value?.start && !this.value?.end) { const today = CalendarDay.today.native; this.value = { start: today, end: today }; const { start, end } = this._inputSelection; @@ -286,27 +245,29 @@ export default class IgcDateRangeInputComponent extends IgcDateTimeInputBaseComp datePart.position ); + const today = CalendarDay.today.native; + const defaultValue = { start: today, end: today }; + if (!part) { - return ( - this.value || { - start: CalendarDay.today.native, - end: CalendarDay.today.native, - } - ); + return this.value || defaultValue; } const effectiveDelta = - delta || this._datePartDeltas[datePart.part as keyof DatePartDeltas] || 1; + delta ?? this._datePartDeltas[datePart.part as keyof DatePartDeltas] ?? 1; + const spinAmount = effectiveDelta * (isDecrement ? -1 : 1); - const spinAmount = isDecrement - ? -Math.abs(effectiveDelta) - : Math.abs(effectiveDelta); + // 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 + this.spinLoop, + amPmValue ); } @@ -321,21 +282,71 @@ export default class IgcDateRangeInputComponent extends IgcDateTimeInputBaseComp } } + /** + * 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 oldPlaceholder = this.placeholder; - const oldFormat = this._defaultMask; + const previous = this._parser.mask; - // string is the single date format this._parser.mask = string; this._defaultMask = string; this._parser.prompt = this.prompt; - if (!this.placeholder || oldFormat === oldPlaceholder) { - this.placeholder = `${string}${this._parser.separator}${string}`; + // Update placeholder if not set or if it matches the previous format + if (!this.placeholder || previous === this.placeholder) { + this.placeholder = this._parser.mask; } } @@ -346,12 +357,7 @@ export default class IgcDateRangeInputComponent extends IgcDateTimeInputBaseComp } const parsed = this._parser.parseDateRange(this._maskedValue); - - if (parsed && (parsed.start || parsed.end)) { - this.value = parsed; - } else { - this.value = null; - } + this.value = parsed?.start || parsed?.end ? parsed : null; } // #region Public API Overrides diff --git a/src/components/date-range-picker/date-range-mask-parser.ts b/src/components/date-range-picker/date-range-mask-parser.ts index 167341b0e..6706fa786 100644 --- a/src/components/date-range-picker/date-range-mask-parser.ts +++ b/src/components/date-range-picker/date-range-mask-parser.ts @@ -5,6 +5,7 @@ import { type SpinOptions, } from '../date-time-input/date-part.js'; import { + type DateTimeMaskOptions, DateTimeMaskParser, DEFAULT_DATETIME_FORMAT, } from '../date-time-input/datetime-mask-parser.js'; @@ -31,14 +32,8 @@ export interface IDateRangePart extends IDatePart { } /** Options for the DateRangeMaskParser */ -export interface DateRangeMaskOptions { - /** - * The date format string (e.g., 'MM/dd/yyyy'). - */ - format?: string; - /** The prompt character for unfilled positions */ - promptCharacter?: string; - /** Custom separator (defaults to ' - ') */ +export interface DateRangeMaskOptions extends DateTimeMaskOptions { + /** Separator (defaults to ' - ') */ separator?: string; } @@ -81,9 +76,6 @@ export class DateRangeMaskParser extends MaskParser { /** End position of the separator in the mask */ private _separatorEnd: number; - /** Flag to prevent recursive mask setting */ - private _isUpdatingMask = false; - /** * Gets the parsed date range parts with position information. */ @@ -106,25 +98,16 @@ export class DateRangeMaskParser extends MaskParser { // Build the combined range format for the parent MaskParser const rangeFormat = `${format}${separator}${format}`; - super( - promptCharacter - ? { format: rangeFormat, promptCharacter } - : { format: rangeFormat } - ); + super({ format: rangeFormat, promptCharacter }); // Create two parsers for start and end dates - const parserOptions = promptCharacter - ? { format, promptCharacter } - : { format }; - this._startParser = new DateTimeMaskParser(parserOptions); - this._endParser = new DateTimeMaskParser(parserOptions); + this._startParser = new DateTimeMaskParser({ format, promptCharacter }); + this._endParser = new DateTimeMaskParser({ format, promptCharacter }); this._separator = separator; - // Calculate separator positions this._separatorStart = this._startParser.mask.length; this._separatorEnd = this._separatorStart + separator.length; - // Build range parts from the two parsers this._buildRangeParts(); } @@ -139,7 +122,8 @@ export class DateRangeMaskParser extends MaskParser { // 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 = this._convertDateFormatToMaskFormat(dateFormat); + const maskFormat = + DateTimeMaskParser.convertDateFormatToMaskFormat(dateFormat); // Temporarily set the converted format for base class parsing const originalFormat = this._options.format; @@ -151,39 +135,6 @@ export class DateRangeMaskParser extends MaskParser { this._options.format = originalFormat; } - /** - * Converts date format characters to mask format characters. - * Date parts become '0' (numeric) and time markers become 'L' (alpha). - */ - private _convertDateFormatToMaskFormat(dateFormat: string): string { - // Set of date/time format characters - const dateChars = new Set([ - 'M', - 'd', - 'y', - 'Y', - 'D', - 'h', - 'H', - 'm', - 's', - 'S', - 't', - 'T', - ]); - - let result = ''; - for (const char of dateFormat) { - if (dateChars.has(char)) { - // AM/PM markers are alphabetic, others are numeric - result += char === 't' || char === 'T' ? 'L' : '0'; - } else { - result += char; - } - } - return result; - } - //#endregion /** @@ -195,7 +146,6 @@ export class DateRangeMaskParser extends MaskParser { (part): IDateRangePart => ({ ...part, position: DateRangePosition.Start, - // Explicitly bind methods to preserve functionality getValue: part.getValue.bind(part), validate: part.validate.bind(part), spin: part.spin.bind(part), @@ -209,7 +159,6 @@ export class DateRangeMaskParser extends MaskParser { start: part.start + this._separatorEnd, end: part.end + this._separatorEnd, position: DateRangePosition.End, - // Delegate methods to the original part getValue: part.getValue.bind(part), validate: part.validate.bind(part), spin: part.spin.bind(part), @@ -223,31 +172,16 @@ export class DateRangeMaskParser extends MaskParser { * Sets a new date format and updates both parsers. */ public override set mask(value: string) { - // Prevent recursive calls when we set the parent mask - if (this._isUpdatingMask) { - return; - } - - this._isUpdatingMask = true; + this._startParser.mask = value; + this._endParser.mask = value; - try { - // Update both parsers with the new format - this._startParser.mask = value; - this._endParser.mask = value; - - // Recalculate separator positions - this._separatorStart = this._startParser.mask.length; - this._separatorEnd = this._separatorStart + this._separator.length; + this._separatorStart = this._startParser.mask.length; + this._separatorEnd = this._separatorStart + this._separator.length; - // Update parent mask with combined format - const rangeFormat = `${value}${this._separator}${value}`; - super.mask = rangeFormat; + const rangeFormat = `${value}${this._separator}${value}`; + super.mask = rangeFormat; - // Rebuild range parts - this._buildRangeParts(); - } finally { - this._isUpdatingMask = false; - } + this._buildRangeParts(); } public override get mask(): string { @@ -265,11 +199,9 @@ export class DateRangeMaskParser extends MaskParser { return null; } - // Extract start and end date strings const startString = masked.substring(0, this._separatorStart); const endString = masked.substring(this._separatorEnd); - // Delegate parsing to the individual parsers const start = this._startParser.parseDate(startString); const end = this._endParser.parseDate(endString); @@ -288,21 +220,11 @@ export class DateRangeMaskParser extends MaskParser { * Formats a DateRangeValue into a masked string using the two internal parsers. */ public formatDateRange(range: DateRangeValue | null): string { - // If range is completely null/undefined, return the empty masks - if (!range) { - return ( - this._startParser.emptyMask + - this._separator + - this._endParser.emptyMask - ); - } - - // Delegate formatting to the individual parsers - const startString = range.start + const startString = range?.start ? this._startParser.formatDate(range.start) : this._startParser.emptyMask; - const endString = range.end + const endString = range?.end ? this._endParser.formatDate(range.end) : this._endParser.emptyMask; @@ -371,10 +293,6 @@ export class DateRangeMaskParser extends MaskParser { //#endregion - //#region Override for Date Range Mask - - //#endregion - //#region Spinning Support /** @@ -385,12 +303,11 @@ export class DateRangeMaskParser extends MaskParser { part: IDateRangePart, delta: number, currentValue: DateRangeValue | null, - spinLoop: boolean + spinLoop: boolean, + amPmValue?: string ): DateRangeValue { - // Ensure we have a value to work with const value = currentValue || { start: null, end: null }; - // Determine which date to spin const targetDate = part.position === DateRangePosition.Start ? value.start : value.end; @@ -405,16 +322,14 @@ export class DateRangeMaskParser extends MaskParser { date: newDate, spinLoop, originalDate: dateToSpin, + amPmValue, }; part.spin(delta, spinOptions); - // Return updated range - return { - ...value, - start: part.position === DateRangePosition.Start ? newDate : value.start, - end: part.position === DateRangePosition.End ? newDate : value.end, - }; + return part.position === DateRangePosition.Start + ? { ...value, start: newDate } + : { ...value, end: newDate }; } //#endregion 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 87d29e1da..7be17677d 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 @@ -275,6 +275,71 @@ 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'); + }); }); describe('Methods', () => { it('should clear the input on invoking clear()', async () => { @@ -785,6 +850,37 @@ describe('Date range picker - single input', () => { ); }); + 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; diff --git a/src/components/date-range-picker/date-range-picker.ts b/src/components/date-range-picker/date-range-picker.ts index 507c09f09..d813d3885 100644 --- a/src/components/date-range-picker/date-range-picker.ts +++ b/src/components/date-range-picker/date-range-picker.ts @@ -1183,7 +1183,7 @@ export default class IgcDateRangePickerComponent extends FormAssociatedRequiredM private _renderSingleInput(id: string) { const readOnly = !this._isDropDown || this.readOnly || this.nonEditable; const format = - getDateTimeFormat(this._displayFormat) ?? this._defaultDisplayFormat; + getDateTimeFormat(this.displayFormat) ?? this._defaultDisplayFormat; const prefix = isEmpty(this._prefixes) ? undefined : 'prefix'; const suffix = isEmpty(this._suffixes) ? undefined : 'suffix'; diff --git a/src/components/date-time-input/date-time-input.base.ts b/src/components/date-time-input/date-time-input.base.ts index 1e9d72fd5..d4e62e2d1 100644 --- a/src/components/date-time-input/date-time-input.base.ts +++ b/src/components/date-time-input/date-time-input.base.ts @@ -81,6 +81,12 @@ export abstract class IgcDateTimeInputBaseComponent< protected _displayFormat?: string; protected _inputFormat?: string; + protected get _targetDatePart(): TPart | undefined { + return this._focused + ? this._getDatePartAtCursor() + : this._getDefaultDatePart(); + } + // #endregion // #region Public attributes and properties @@ -454,7 +460,6 @@ export abstract class IgcDateTimeInputBaseComponent< // #region Abstract methods and properties protected abstract get _datePartDeltas(): any; - protected abstract get _targetDatePart(): TPart | undefined; protected abstract _buildMaskedValue(): string; protected abstract _updateValueFromMask(): void; @@ -468,6 +473,8 @@ export abstract class IgcDateTimeInputBaseComponent< 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; diff --git a/src/components/date-time-input/date-time-input.ts b/src/components/date-time-input/date-time-input.ts index 28ca9e791..9dd81d910 100644 --- a/src/components/date-time-input/date-time-input.ts +++ b/src/components/date-time-input/date-time-input.ts @@ -74,17 +74,6 @@ export default class IgcDateTimeInputComponent extends IgcDateTimeInputBaseCompo 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. - */ - protected override get _targetDatePart(): DatePart | undefined { - return this._focused - ? this._getDatePartAtCursor() - : this._getDefaultDatePart(); - } - protected override get _datePartDeltas(): DatePartDeltas { return { ...DEFAULT_DATE_PARTS_SPIN_DELTAS, ...this.spinDelta }; } @@ -222,7 +211,7 @@ export default class IgcDateTimeInputComponent extends IgcDateTimeInputBaseCompo * 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 { + protected override _getDatePartAtCursor(): DatePart | undefined { return this._parser.getDatePartForCursor(this._inputSelection.start) ?.type as DatePart | undefined; } @@ -231,7 +220,7 @@ export default class IgcDateTimeInputComponent extends IgcDateTimeInputBaseCompo * Gets the default date part to target when the input is not focused. * Prioritizes: Date > Hours > First available part */ - private _getDefaultDatePart(): DatePart | undefined { + 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; diff --git a/src/components/date-time-input/datetime-mask-parser.ts b/src/components/date-time-input/datetime-mask-parser.ts index 8096f250d..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; @@ -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) { From bb4ad221b9f5d93ea14e048560dd8870f1a915f2 Mon Sep 17 00:00:00 2001 From: Bozhidara Pachilova Date: Thu, 5 Mar 2026 17:12:35 +0200 Subject: [PATCH 08/14] chore(file-input): fix leftover circular import --- src/components/file-input/file-input.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/components/file-input/file-input.ts b/src/components/file-input/file-input.ts index fe8f1578d..94d5c8b60 100644 --- a/src/components/file-input/file-input.ts +++ b/src/components/file-input/file-input.ts @@ -1,9 +1,9 @@ -import { html } from 'lit'; -import { property, state } from 'lit/decorators.js'; import { FileInputResourceStringsEN, type IFileInputResourceStrings, -} from '../../index.js'; +} from 'igniteui-i18n-core'; +import { html } from 'lit'; +import { property, state } from 'lit/decorators.js'; import { addThemingController } from '../../theming/theming-controller.js'; import IgcButtonComponent from '../button/button.js'; import { addSlotController, setSlots } from '../common/controllers/slot.js'; From e5a9c67d26480455c4a701dc9894b54299d5760e Mon Sep 17 00:00:00 2001 From: Bozhidara Pachilova Date: Thu, 5 Mar 2026 17:20:47 +0200 Subject: [PATCH 09/14] chore(*): fix has time/date-parts getter invocation --- src/components/date-time-input/validators.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/components/date-time-input/validators.ts b/src/components/date-time-input/validators.ts index 59a7810de..0524c1987 100644 --- a/src/components/date-time-input/validators.ts +++ b/src/components/date-time-input/validators.ts @@ -16,8 +16,8 @@ export const dateTimeInputValidators: Validator[] = [ ? !isDateLessThanMin( host.value, host.min, - host.hasTimeParts, - host.hasDateParts + host.hasTimeParts(), + host.hasDateParts() ) : true, }, @@ -28,8 +28,8 @@ export const dateTimeInputValidators: Validator[] = [ ? !isDateExceedingMax( host.value, host.max, - host.hasTimeParts, - host.hasDateParts + host.hasTimeParts(), + host.hasDateParts() ) : true, }, From 801fb1de34829c665f1b3f22480add81e6a38b89 Mon Sep 17 00:00:00 2001 From: Bozhidara Pachilova Date: Fri, 6 Mar 2026 11:30:04 +0200 Subject: [PATCH 10/14] test(drp): increase coverage --- .../date-range-picker/date-range-input.ts | 16 +- .../date-range-mask-parser.spec.ts | 268 ++++++++++++++++++ .../date-range-mask-parser.ts | 15 +- .../date-range-picker-single.spec.ts | 110 +++++++ 4 files changed, 384 insertions(+), 25 deletions(-) create mode 100644 src/components/date-range-picker/date-range-mask-parser.spec.ts diff --git a/src/components/date-range-picker/date-range-input.ts b/src/components/date-range-picker/date-range-input.ts index 9f6fa96c1..f8a2de454 100644 --- a/src/components/date-range-picker/date-range-input.ts +++ b/src/components/date-range-picker/date-range-input.ts @@ -2,10 +2,7 @@ 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, - getDefaultDateTimeFormat, -} from '../common/i18n/i18n-controller.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'; @@ -271,17 +268,6 @@ export default class IgcDateRangeInputComponent extends IgcDateTimeInputBaseComp ); } - protected override _initializeDefaultMask(): void { - super._initializeDefaultMask(); - - if (!this._inputFormat) { - const singleFormat = getDefaultDateTimeFormat(this.locale); - this._parser.mask = singleFormat; - this._defaultMask = singleFormat; - this.placeholder = `${singleFormat}${this._parser.separator}${singleFormat}`; - } - } - /** * Gets the date range part at the current cursor position. * If the cursor is at a literal, finds the nearest non-literal part. 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 index 6706fa786..45270dd44 100644 --- a/src/components/date-range-picker/date-range-mask-parser.ts +++ b/src/components/date-range-picker/date-range-mask-parser.ts @@ -98,7 +98,11 @@ export class DateRangeMaskParser extends MaskParser { // Build the combined range format for the parent MaskParser const rangeFormat = `${format}${separator}${format}`; - super({ format: rangeFormat, promptCharacter }); + super( + options?.promptCharacter + ? { format: rangeFormat, promptCharacter: options.promptCharacter } + : { format: rangeFormat } + ); // Create two parsers for start and end dates this._startParser = new DateTimeMaskParser({ format, promptCharacter }); @@ -259,15 +263,6 @@ export class DateRangeMaskParser extends MaskParser { ); } - /** - * Gets all parts for a specific position (start or end). - */ - public getPartsForPosition( - position: DateRangePosition - ): ReadonlyArray { - return this._rangeParts.filter((p) => p.position === position); - } - /** * Gets a specific part type for a position. */ 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 7be17677d..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 @@ -340,6 +340,55 @@ describe('Date range picker - single 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 () => { @@ -377,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', () => { @@ -850,6 +928,38 @@ describe('Date range picker - single input', () => { ); }); + 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 From 6cef110a88fd93558447033ed246ca3371bf7a9e Mon Sep 17 00:00:00 2001 From: Bozhidara Pachilova Date: Fri, 6 Mar 2026 14:25:01 +0200 Subject: [PATCH 11/14] refactor(drp): validator to use new utility methods --- .../date-range-mask-parser.ts | 4 - .../date-range-picker/date-range-picker.ts | 16 +++ .../date-range-picker/validators.ts | 134 ++++++++++-------- 3 files changed, 93 insertions(+), 61 deletions(-) diff --git a/src/components/date-range-picker/date-range-mask-parser.ts b/src/components/date-range-picker/date-range-mask-parser.ts index 45270dd44..38dbe11f1 100644 --- a/src/components/date-range-picker/date-range-mask-parser.ts +++ b/src/components/date-range-picker/date-range-mask-parser.ts @@ -209,10 +209,6 @@ export class DateRangeMaskParser extends MaskParser { const start = this._startParser.parseDate(startString); const end = this._endParser.parseDate(endString); - if (!start && !end) { - return null; - } - return { start, end }; } diff --git a/src/components/date-range-picker/date-range-picker.ts b/src/components/date-range-picker/date-range-picker.ts index d813d3885..46c0282b1 100644 --- a/src/components/date-range-picker/date-range-picker.ts +++ b/src/components/date-range-picker/date-range-picker.ts @@ -639,6 +639,22 @@ export default class IgcDateRangePickerComponent extends FormAssociatedRequiredM // #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; diff --git a/src/components/date-range-picker/validators.ts b/src/components/date-range-picker/validators.ts index 8d71b82da..54f0ab428 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 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, From ad2ed386e83ca6ac33147a77522d9231d4bd584c Mon Sep 17 00:00:00 2001 From: Bozhidara Pachilova Date: Fri, 13 Mar 2026 16:26:30 +0200 Subject: [PATCH 12/14] refactor(*): apply suggestions --- .../date-range-picker/date-range-input.ts | 17 ++++++++++++----- .../date-time-input/date-time-input.base.ts | 2 +- stories/date-time-input.stories.ts | 12 ++++++------ stories/file-input.stories.ts | 16 ++++++++-------- stories/input.stories.ts | 16 ++++++++-------- stories/mask-input.stories.ts | 16 ++++++++-------- 6 files changed, 43 insertions(+), 36 deletions(-) diff --git a/src/components/date-range-picker/date-range-input.ts b/src/components/date-range-picker/date-range-input.ts index f8a2de454..5cb1dc52a 100644 --- a/src/components/date-range-picker/date-range-input.ts +++ b/src/components/date-range-picker/date-range-input.ts @@ -45,11 +45,13 @@ export default class IgcDateRangeInputComponent extends IgcDateTimeInputBaseComp protected override readonly _themes = addThemingController(this, all); protected override readonly _parser = new DateRangeMaskParser(); - protected override _datePartDeltas: DatePartDeltas = { - date: 1, - month: 1, - year: 1, - }; + protected override get _datePartDeltas(): DatePartDeltas { + return { + date: 1, + month: 1, + year: 1, + }; + } // #endregion @@ -183,6 +185,11 @@ export default class IgcDateRangeInputComponent extends IgcDateTimeInputBaseComp // #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). diff --git a/src/components/date-time-input/date-time-input.base.ts b/src/components/date-time-input/date-time-input.base.ts index d4e62e2d1..15adcd877 100644 --- a/src/components/date-time-input/date-time-input.base.ts +++ b/src/components/date-time-input/date-time-input.base.ts @@ -459,7 +459,7 @@ export abstract class IgcDateTimeInputBaseComponent< // #region Abstract methods and properties - protected abstract get _datePartDeltas(): any; + protected abstract get _datePartDeltas(): DatePartDeltas; protected abstract _buildMaskedValue(): string; protected abstract _updateValueFromMask(): void; diff --git a/stories/date-time-input.stories.ts b/stories/date-time-input.stories.ts index 0e580c1a0..72e9732d4 100644 --- a/stories/date-time-input.stories.ts +++ b/stories/date-time-input.stories.ts @@ -1,16 +1,16 @@ -import type { Meta, StoryObj } from '@storybook/web-components-vite'; -import { html } from 'lit'; - import { IgcDateTimeInputComponent, defineComponents, } from 'igniteui-webcomponents'; +import type { Meta, StoryObj } from '@storybook/web-components-vite'; import { disableStoryControls, formControls, formSubmitHandler, } from './story.js'; +import { html } from 'lit'; + defineComponents(IgcDateTimeInputComponent); // region default @@ -28,9 +28,9 @@ const metadata: Meta = { }, argTypes: { value: { - type: 'string | Date | DateRangeValue', + type: 'string | Date', description: 'The value of the input.', - options: ['string', 'Date', 'DateRangeValue'], + options: ['string', 'Date'], control: 'text', }, min: { @@ -142,7 +142,7 @@ export default metadata; interface IgcDateTimeInputArgs { /** The value of the input. */ - value: string | Date | DateRangeValue; + 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. */ diff --git a/stories/file-input.stories.ts b/stories/file-input.stories.ts index 4faea6371..494752de5 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, @@ -10,8 +5,13 @@ import { registerIcon, registerIconFromText, } from 'igniteui-webcomponents'; +import type { Meta, StoryObj } from '@storybook/web-components-vite'; import { 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(IgcFileInputComponent, IgcIconComponent); registerIconFromText(github.name, github.value); registerIcon( @@ -29,10 +29,10 @@ const metadata: Meta = { }, argTypes: { value: { - type: 'string | Date | DateRangeValue', + type: 'string | Date', description: 'The value of the control.\nSimilar to native file input, this property is read-only and cannot be set programmatically.', - options: ['string', 'Date', 'DateRangeValue'], + options: ['string', 'Date'], control: 'text', }, locale: { @@ -120,7 +120,7 @@ interface IgcFileInputArgs { * The value of the control. * Similar to native file input, this property is read-only and cannot be set programmatically. */ - value: string | Date | DateRangeValue; + value: string | Date; /** Gets/Sets the locale used for getting language, affecting resource strings. */ locale: string; /** diff --git a/stories/input.stories.ts b/stories/input.stories.ts index 8dc816cc7..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( @@ -33,9 +33,9 @@ const metadata: Meta = { }, argTypes: { value: { - type: 'string | Date | DateRangeValue', + type: 'string | Date', description: 'The value of the control.', - options: ['string', 'Date', 'DateRangeValue'], + options: ['string', 'Date'], control: 'text', }, type: { @@ -162,7 +162,7 @@ export default metadata; interface IgcInputArgs { /** The value of the control. */ - value: string | Date | DateRangeValue; + value: string | Date; /** The type attribute of the control. */ type: 'text' | 'email' | 'number' | 'password' | 'search' | 'tel' | 'url'; /** Makes the control a readonly field. */ diff --git a/stories/mask-input.stories.ts b/stories/mask-input.stories.ts index 1aa10cb70..f7ad323ee 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); @@ -41,10 +41,10 @@ const metadata: Meta = { table: { defaultValue: { summary: 'raw' } }, }, value: { - type: 'string | Date | DateRangeValue', + type: 'string | Date', description: 'The value of the input.\n\nRegardless of the currently set `value-mode`, an empty value will return an empty string.', - options: ['string', 'Date', 'DateRangeValue'], + options: ['string', 'Date'], control: 'text', }, mask: { @@ -136,7 +136,7 @@ interface IgcMaskInputArgs { * * Regardless of the currently set `value-mode`, an empty value will return an empty string. */ - value: string | Date | DateRangeValue; + value: string | Date; /** The masked pattern of the component. */ mask: string; /** The prompt symbol to use for unfilled parts of the mask pattern. */ From d4184cea11abaa315cf13c85c80eb9e80f5a5709 Mon Sep 17 00:00:00 2001 From: Bozhidara Pachilova Date: Fri, 13 Mar 2026 16:30:21 +0200 Subject: [PATCH 13/14] chore(drp): fix typo --- .../date-range-picker/date-range-picker-single.form.spec.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) 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 943fbb117..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,6 @@ import { type ValidationContainerTestsParams, ValidityHelpers, } from '../common/validity-helpers.spec.js'; -import type IgcDateRangeInputComponentComponent from './date-range-input.js'; import IgcDateRangeInputComponent from './date-range-input.js'; import IgcDateRangePickerComponent, { type DateRangeValue, @@ -22,7 +21,7 @@ describe('Date Range Picker Single Input - Form integration', () => { before(() => defineComponents(IgcDateRangePickerComponent)); let picker: IgcDateRangePickerComponent; - let input: IgcDateRangeInputComponentComponent; + let input: IgcDateRangeInputComponent; let startKey = ''; let endKey = ''; From 2bccfe1b195c0bb5ac79bd1ffade3867aae216fa Mon Sep 17 00:00:00 2001 From: Bozhidara Pachilova Date: Fri, 13 Mar 2026 16:57:51 +0200 Subject: [PATCH 14/14] chore(*): address comments --- src/components/calendar/helpers.ts | 2 +- .../common/mixins/forms/form-transformers.ts | 2 +- .../date-range-picker/date-range-input.ts | 6 ++---- .../date-range-mask-parser.ts | 2 +- .../date-range-picker/date-range-picker.ts | 18 ++++++++++-------- .../date-range-picker.utils.spec.ts | 3 ++- src/components/date-range-picker/validators.ts | 2 +- .../date-time-input/date-time-input.base.ts | 2 +- .../date-time-input/date-time-input.ts | 4 ++-- src/components/input/input-base.ts | 2 +- src/components/types.ts | 1 + 11 files changed, 23 insertions(+), 21 deletions(-) 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 index 5cb1dc52a..90f1aa694 100644 --- a/src/components/date-range-picker/date-range-input.ts +++ b/src/components/date-range-picker/date-range-input.ts @@ -16,12 +16,12 @@ 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'; -import type { DateRangeValue } from './date-range-picker.js'; export default class IgcDateRangeInputComponent extends IgcDateTimeInputBaseComponent< DateRangeValue | null, @@ -55,8 +55,6 @@ export default class IgcDateRangeInputComponent extends IgcDateTimeInputBaseComp // #endregion - // #endregion - // #region Public attributes and properties /* @tsTwoWayProperty(true, "igcChange", "detail", false, true) */ @@ -106,7 +104,7 @@ export default class IgcDateRangeInputComponent extends IgcDateTimeInputBaseComp } } - public override _handleBlur(): void { + protected override _handleBlur(): void { const isSameValue = equal(this._oldValue, this.value); this._focused = false; diff --git a/src/components/date-range-picker/date-range-mask-parser.ts b/src/components/date-range-picker/date-range-mask-parser.ts index 38dbe11f1..5c20aadab 100644 --- a/src/components/date-range-picker/date-range-mask-parser.ts +++ b/src/components/date-range-picker/date-range-mask-parser.ts @@ -10,7 +10,7 @@ import { DEFAULT_DATETIME_FORMAT, } from '../date-time-input/datetime-mask-parser.js'; import { MaskParser } from '../mask-input/mask-parser.js'; -import type { DateRangeValue } from './date-range-picker.js'; +import type { DateRangeValue } from '../types.js'; //#region Types and Enums diff --git a/src/components/date-range-picker/date-range-picker.ts b/src/components/date-range-picker/date-range-picker.ts index 46c0282b1..cfb5118bf 100644 --- a/src/components/date-range-picker/date-range-picker.ts +++ b/src/components/date-range-picker/date-range-picker.ts @@ -744,7 +744,7 @@ export default class IgcDateRangePickerComponent extends FormAssociatedRequiredM this._hide(true); } - protected _handleInputEvent(event: CustomEvent) { + protected _handleInput(event: CustomEvent) { event.stopPropagation(); if (this.nonEditable) { event.preventDefault(); @@ -760,7 +760,7 @@ export default class IgcDateRangePickerComponent extends FormAssociatedRequiredM this.emitEvent('igcInput', { detail: this.value }); } - protected _handleInputChangeEvent(event: CustomEvent) { + protected _handleInputChange(event: CustomEvent) { event.stopPropagation(); const input = event.target as IgcDateTimeInputComponent; @@ -777,7 +777,7 @@ export default class IgcDateRangePickerComponent extends FormAssociatedRequiredM this.emitEvent('igcChange', { detail: this.value }); } - protected async _handleDateRangeInputEvent(event: CustomEvent) { + protected async _handleDateRangeInput(event: CustomEvent) { event.stopPropagation(); if (this.nonEditable) { event.preventDefault(); @@ -792,7 +792,9 @@ export default class IgcDateRangePickerComponent extends FormAssociatedRequiredM this.emitEvent('igcInput', { detail: this.value }); } - protected _handleDateRangeInputChangeEvent(event: CustomEvent) { + protected _handleDateRangeInputChange( + event: CustomEvent + ) { event.stopPropagation(); const input = event.target as IgcDateRangeInputComponent; @@ -1168,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} @@ -1219,8 +1221,8 @@ export default class IgcDateRangePickerComponent extends FormAssociatedRequiredM .displayFormat=${live(format)} .locale=${live(this.locale)} .prompt=${this.prompt} - @igcInput=${this._handleDateRangeInputEvent} - @igcChange=${this._handleDateRangeInputChangeEvent} + @igcInput=${this._handleDateRangeInput} + @igcChange=${this._handleDateRangeInputChange} @click=${this._isDropDown || this.readOnly ? nothing : this._handleInputClick} 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 d8f0f4151..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 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, diff --git a/src/components/date-range-picker/validators.ts b/src/components/date-range-picker/validators.ts index 54f0ab428..2510858f0 100644 --- a/src/components/date-range-picker/validators.ts +++ b/src/components/date-range-picker/validators.ts @@ -7,8 +7,8 @@ import { } 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 = { key: 'rangeUnderflow', diff --git a/src/components/date-time-input/date-time-input.base.ts b/src/components/date-time-input/date-time-input.base.ts index 15adcd877..91fd27c77 100644 --- a/src/components/date-time-input/date-time-input.base.ts +++ b/src/components/date-time-input/date-time-input.base.ts @@ -21,12 +21,12 @@ import { 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 { DateRangeValue } from '../date-range-picker/date-range-picker.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'; diff --git a/src/components/date-time-input/date-time-input.ts b/src/components/date-time-input/date-time-input.ts index 9dd81d910..c8e8c296f 100644 --- a/src/components/date-time-input/date-time-input.ts +++ b/src/components/date-time-input/date-time-input.ts @@ -252,13 +252,13 @@ export default class IgcDateTimeInputComponent extends IgcDateTimeInputBaseCompo ? -Math.abs(effectiveDelta) : Math.abs(effectiveDelta); - return this.spinDatePart(datePart, spinAmount); + return this._spinDatePart(datePart, spinAmount); } /** * Spins a specific date part by the given delta. */ - protected spinDatePart(datePart: DatePart, delta: number): Date { + protected _spinDatePart(datePart: DatePart, delta: number): Date { if (!isValidDate(this.value)) { return new Date(); } diff --git a/src/components/input/input-base.ts b/src/components/input/input-base.ts index 456084c21..f9649c8eb 100644 --- a/src/components/input/input-base.ts +++ b/src/components/input/input-base.ts @@ -9,7 +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 '../date-range-picker/date-range-picker.js'; +import type { DateRangeValue } from '../types.js'; import IgcValidationContainerComponent from '../validation-container/validation-container.js'; export interface IgcInputComponentEventMap { 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';