Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions packages/components/timepicker/e2e.playwright-spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}$/);
});
});
});
4 changes: 2 additions & 2 deletions packages/components/timepicker/e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import { DateTime } from 'luxon';
<!-- empty state -->
<kbq-form-field>
<i kbq-icon="kbq-clock_16" kbqPrefix></i>
<input kbqTimepicker [format]="timeFormats.HHmm" />
<input data-testid="e2eTimepickerShort" kbqTimepicker [format]="timeFormats.HHmm" />
<kbq-hint>HH:mm</kbq-hint>
</kbq-form-field>

Expand All @@ -30,7 +30,7 @@ import { DateTime } from 'luxon';
</kbq-form-field>
<kbq-form-field>
<i kbq-icon="kbq-clock_16" kbqPrefix></i>
<input kbqTimepicker [format]="timeFormats.HHmmss" [(ngModel)]="value" />
<input data-testid="e2eTimepickerFull" kbqTimepicker [format]="timeFormats.HHmmss" [(ngModel)]="value" />
<kbq-hint>HH:mm:ss</kbq-hint>
</kbq-form-field>

Expand Down
57 changes: 38 additions & 19 deletions packages/components/timepicker/timepicker.directive.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
Expand Down Expand Up @@ -494,6 +497,25 @@ export class KbqTimepicker<D>

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);
Expand All @@ -502,16 +524,6 @@ export class KbqTimepicker<D>
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();
Expand Down Expand Up @@ -640,29 +652,36 @@ export class KbqTimepicker<D>
private replaceNumbers(value: string): string {
let formattedValue: string = value;

const match: RegExpMatchArray | null = value.match(
/^(?<hours>\d{0,4}):?(?<minutes>\d{0,4}):?(?<seconds>\d{0,4})$/
);
const match: RegExpMatchArray | null = value.match(/^(?<hours>\d*):?(?<minutes>\d*):?(?<seconds>\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 = (<HTMLInputElement>this.elementRef.nativeElement).validity;
Expand Down
38 changes: 38 additions & 0 deletions packages/components/timepicker/timepicker.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');

Expand Down