From 18aef58663483347ea9ba6b3c49bc944edb7d180 Mon Sep 17 00:00:00 2001 From: ddaribo Date: Thu, 17 Apr 2025 17:33:27 +0300 Subject: [PATCH 1/2] 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 2/2] 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(); } }