diff --git a/packages/components/timepicker/e2e.playwright-spec.ts b/packages/components/timepicker/e2e.playwright-spec.ts index f78e94f10..5b7e0464c 100644 --- a/packages/components/timepicker/e2e.playwright-spec.ts +++ b/packages/components/timepicker/e2e.playwright-spec.ts @@ -11,5 +11,22 @@ test.describe('KbqTimepickerModule', () => { await e2eEnableDarkTheme(page); await expect(getComponent(page)).toHaveScreenshot('01-dark.png'); }); + + test('should not let digits grow beyond the mask on incomplete value', async ({ page }) => { + await page.goto('/E2eTimepickerStates'); + + const input = page.getByTestId('e2eTimepickerShort'); + + // an incomplete value: the minutes part has a single digit, so it never parses as time + await input.fill('11:1'); + // place the caret at the very beginning, as a mouse click would + await input.evaluate((element: HTMLInputElement) => element.setSelectionRange(0, 0)); + + for (let index = 0; index < 10; index++) { + await input.press('9'); + } + + await expect(input).toHaveValue(/^\d{1,2}:\d{1,2}$/); + }); }); }); diff --git a/packages/components/timepicker/e2e.ts b/packages/components/timepicker/e2e.ts index 8e6d670ff..f35da9911 100644 --- a/packages/components/timepicker/e2e.ts +++ b/packages/components/timepicker/e2e.ts @@ -19,7 +19,7 @@ import { DateTime } from 'luxon'; - + HH:mm @@ -30,7 +30,7 @@ import { DateTime } from 'luxon'; - + HH:mm:ss diff --git a/packages/components/timepicker/timepicker.directive.ts b/packages/components/timepicker/timepicker.directive.ts index a05f4e0f5..dc970aacf 100644 --- a/packages/components/timepicker/timepicker.directive.ts +++ b/packages/components/timepicker/timepicker.directive.ts @@ -87,6 +87,9 @@ let uniqueComponentIdSuffix: number = 0; const shortFormatSize: number = 5; const fullFormatSize: number = 8; +/** Maximum number of digits in a single time part */ +const timePartLength: number = 2; + @Directive({ selector: 'input[kbqTimepicker]', providers: [ @@ -494,6 +497,25 @@ export class KbqTimepicker this.lastValueValid = !!newTimeObj; + const selectionStart = this.selectionStart; + const selectionEnd = this.selectionEnd; + const nextViewValue = newTimeObj ? this.getTimeStringFromDate(newTimeObj, this.format) : formattedValue; + // A complete time is always rewritten, so that the caret keeps walking between the time parts. + // An incomplete one (e.g. `23:1`) is rewritten only when normalization trimmed it — otherwise + // the extra digits stay in the input and the value grows unbounded. + const shouldUpdateView = !!newTimeObj || nextViewValue !== this.viewValue; + + if (shouldUpdateView) { + this.setViewValue(nextViewValue); + + if (selectionStart !== null) { + this.selectionStart = selectionStart; + this.selectionEnd = newTimeObj ? selectionEnd : selectionStart; + + this.createSelectionOfTimeComponentInInput(selectionStart + 1); + } + } + if (!newTimeObj) { if (!this.viewValue) { this.onChange(null); @@ -502,16 +524,6 @@ export class KbqTimepicker return; } - const selectionStart = this.selectionStart; - const selectionEnd = this.selectionEnd; - - this.setViewValue(this.getTimeStringFromDate(newTimeObj, this.format)); - - this.selectionStart = selectionStart; - this.selectionEnd = selectionEnd; - - this.createSelectionOfTimeComponentInInput((selectionStart as number) + 1); - this.value = newTimeObj; this.onChange(newTimeObj); this.stateChanges.next(); @@ -640,29 +652,36 @@ export class KbqTimepicker private replaceNumbers(value: string): string { let formattedValue: string = value; - const match: RegExpMatchArray | null = value.match( - /^(?\d{0,4}):?(?\d{0,4}):?(?\d{0,4})$/ - ); + const match: RegExpMatchArray | null = value.match(/^(?\d*):?(?\d*):?(?\d*)$/); if (match?.groups) { const { hours, minutes, seconds } = match.groups; - if (hours.length && parseInt(hours) > HOURS_PER_DAY) { - formattedValue = formattedValue.replace(hours, HOURS_PER_DAY.toString()); + if (hours.length) { + formattedValue = formattedValue.replace(hours, this.normalizeTimePart(hours, HOURS_PER_DAY)); } - if (minutes.length && parseInt(minutes) > MINUTES_PER_HOUR) { - formattedValue = formattedValue.replace(minutes, MINUTES_PER_HOUR.toString()); + if (minutes.length) { + formattedValue = formattedValue.replace(minutes, this.normalizeTimePart(minutes, MINUTES_PER_HOUR)); } - if (seconds.length && parseInt(seconds) > SECONDS_PER_MINUTE) { - formattedValue = formattedValue.replace(seconds, SECONDS_PER_MINUTE.toString()); + if (seconds.length) { + formattedValue = formattedValue.replace(seconds, this.normalizeTimePart(seconds, SECONDS_PER_MINUTE)); } } return formattedValue; } + /** Clamps a time part to the allowed maximum and trims it to two digits */ + private normalizeTimePart(part: string, maxValue: number): string { + if (part.length <= timePartLength && parseInt(part) <= maxValue) { + return part; + } + + return `${Math.min(parseInt(part), maxValue)}`.padStart(timePartLength, '0'); + } + /** Checks whether the input is invalid based on the native validation. */ private isBadInput(): boolean { const validity = (this.elementRef.nativeElement).validity; diff --git a/packages/components/timepicker/timepicker.spec.ts b/packages/components/timepicker/timepicker.spec.ts index 4d210e181..37bcc7801 100644 --- a/packages/components/timepicker/timepicker.spec.ts +++ b/packages/components/timepicker/timepicker.spec.ts @@ -580,6 +580,44 @@ describe(KbqTimepicker.name, () => { expect(inputElementDebug.nativeElement.value).toBe('23:00:59'); })); + it('Should normalize incomplete value instead of letting it grow', fakeAsync(() => { + inputElementDebug.nativeElement.value = '911:1'; + dispatchFakeEvent(inputElementDebug.nativeElement, 'keydown'); + tick(1); + + expect(inputElementDebug.nativeElement.value).toBe('23:1'); + })); + + it('Should trim time part longer than two digits', fakeAsync(() => { + inputElementDebug.nativeElement.value = '0001:1'; + dispatchFakeEvent(inputElementDebug.nativeElement, 'keydown'); + tick(1); + + expect(inputElementDebug.nativeElement.value).toBe('01:1'); + })); + + it('Should normalize time part with more than four digits', fakeAsync(() => { + inputElementDebug.nativeElement.value = '123456'; + dispatchFakeEvent(inputElementDebug.nativeElement, 'keydown'); + tick(1); + + expect(inputElementDebug.nativeElement.value).toBe('23:00:00'); + })); + + it('Should keep intermediate value untouched while typing', fakeAsync(() => { + inputElementDebug.nativeElement.value = '12:3'; + dispatchFakeEvent(inputElementDebug.nativeElement, 'keydown'); + tick(1); + + expect(inputElementDebug.nativeElement.value).toBe('12:3'); + + inputElementDebug.nativeElement.value = '1'; + dispatchFakeEvent(inputElementDebug.nativeElement, 'keydown'); + tick(1); + + expect(inputElementDebug.nativeElement.value).toBe('1'); + })); + it('Increase hours by ArrowUp key and cycle from max to min', fakeAsync(() => { expect(inputElementDebug.nativeElement.value).toBe('23:00:00');