Skip to content

Commit

Permalink
Date/time editors: Add support for same input formats as Angular Date…
Browse files Browse the repository at this point in the history
… Pipe - AmPm (Period), Fractional Seconds parts (#14065)

---------

Co-authored-by: Stamen Stoychev <[email protected]>
  • Loading branch information
ddaribo and ChronosSF authored May 21, 2024
1 parent 37578ef commit 0a65630
Show file tree
Hide file tree
Showing 18 changed files with 373 additions and 133 deletions.
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ All notable changes for each version of this project will be documented in this
### New Features
- `IgxCombo`, `IgxSimpleCombo`:
- Introduced abillity for hiding the clear icon button when the custom clear icon template is empty.
- `IgxDateTimeEditor`, `IgxTimePicker`:
- Now accept the following custom `inputFormat` options, as Angular's DatePipe:
- Fractional seconds: S, SS, SSS.
- Period (Am/Pm): a, aa, aaa, aaaa, aaaaa

## 17.2.0
### New Features
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ const reduceToDictionary = (parts: DatePartInfo[]) => parts.reduce((obj, x) => {
describe(`DateTimeUtil Unit tests`, () => {
describe('Date Time Parsing', () => {
it('should correctly parse all date time parts (base)', () => {
const result = DateTimeUtil.parseDateTimeFormat('dd/MM/yyyy HH:mm:ss tt');
let result = DateTimeUtil.parseDateTimeFormat('dd/MM/yyyy HH:mm:ss:SS a');
const expected = [
{ start: 0, end: 2, type: DatePart.Date, format: 'dd' },
{ start: 2, end: 3, type: DatePart.Literal, format: '/' },
Expand All @@ -22,10 +22,16 @@ describe(`DateTimeUtil Unit tests`, () => {
{ start: 14, end: 16, type: DatePart.Minutes, format: 'mm' },
{ start: 16, end: 17, type: DatePart.Literal, format: ':' },
{ start: 17, end: 19, type: DatePart.Seconds, format: 'ss' },
{ start: 19, end: 20, type: DatePart.Literal, format: ' ' },
{ start: 20, end: 22, type: DatePart.AmPm, format: 'tt' }
{ start: 19, end: 20, type: DatePart.Literal, format: ':' },
{ start: 20, end: 23, type: DatePart.FractionalSeconds, format: 'SSS' },
{ start: 23, end: 24, type: DatePart.Literal, format: ' ' },
{ start: 24, end: 26, type: DatePart.AmPm, format: 'aa' }
];
expect(JSON.stringify(result)).toEqual(JSON.stringify(expected));

result = DateTimeUtil.parseDateTimeFormat('dd/MM/yyyy HH:mm:ss:SS tt');
expected[expected.length - 1] = { start: 24, end: 26, type: DatePart.AmPm, format: 'tt' }
expect(JSON.stringify(result)).toEqual(JSON.stringify(expected));
});

it('should correctly parse date parts of with short formats', () => {
Expand Down Expand Up @@ -112,27 +118,37 @@ describe(`DateTimeUtil Unit tests`, () => {
expect(result).toEqual(new Date(2020, 9, 31));
});

it('should correctly parse values in h:m:s tt format', () => {
it('should correctly parse values in h:m:s a, aa,.. or h:m:s tt format', () => {
const verifyTime = (val: Date, hours = 0, minutes = 0, seconds = 0, milliseconds = 0) => {
expect(val.getHours()).toEqual(hours);
expect(val.getMinutes()).toEqual(minutes);
expect(val.getSeconds()).toEqual(seconds);
expect(val.getMilliseconds()).toEqual(milliseconds);
};

const parts = DateTimeUtil.parseDateTimeFormat('h:m:s tt');
let result = DateTimeUtil.parseValueFromMask('11:34:12 AM', parts);
verifyTime(result, 11, 34, 12);
result = DateTimeUtil.parseValueFromMask('04:12:15 PM', parts);
verifyTime(result, 16, 12, 15);
result = DateTimeUtil.parseValueFromMask('11:00:00 AM', parts);
verifyTime(result, 11, 0, 0);
result = DateTimeUtil.parseValueFromMask('10:00:00 PM', parts);
verifyTime(result, 22, 0, 0);
result = DateTimeUtil.parseValueFromMask('12:00:00 PM', parts);
verifyTime(result, 12, 0, 0);
result = DateTimeUtil.parseValueFromMask('12:00:00 AM', parts);
verifyTime(result, 0, 0, 0);
const runTestsForParts = (parts: DatePartInfo[]) => {
let result = DateTimeUtil.parseValueFromMask('11:34:12 AM', parts);
verifyTime(result, 11, 34, 12);
result = DateTimeUtil.parseValueFromMask('04:12:15 PM', parts);
verifyTime(result, 16, 12, 15);
result = DateTimeUtil.parseValueFromMask('11:00:00 AM', parts);
verifyTime(result, 11, 0, 0);
result = DateTimeUtil.parseValueFromMask('10:00:00 PM', parts);
verifyTime(result, 22, 0, 0);
result = DateTimeUtil.parseValueFromMask('12:00:00 PM', parts);
verifyTime(result, 12, 0, 0);
result = DateTimeUtil.parseValueFromMask('12:00:00 AM', parts);
verifyTime(result, 0, 0, 0);
}

const inputFormat = 'h:m:s';
let parts = DateTimeUtil.parseDateTimeFormat(`${inputFormat} tt`);
runTestsForParts(parts);

for (let i = 0; i < 5; i++) {
parts = DateTimeUtil.parseDateTimeFormat(`${inputFormat} ${'a'.repeat(i + 1)}`);
runTestsForParts(parts);
}
});
});

Expand All @@ -159,7 +175,7 @@ describe(`DateTimeUtil Unit tests`, () => {
{ start: 5, end: 6, type: DatePart.Literal, format: ':' },
{ start: 6, end: 8, type: DatePart.Seconds, format: 'ss' },
{ start: 8, end: 9, type: DatePart.Literal, format: ' ' },
{ start: 9, end: 11, type: DatePart.AmPm, format: 'tt' }
{ start: 9, end: 11, type: DatePart.AmPm, format: 'a' }
];

result = DateTimeUtil.parseValueFromMask(input, dateParts);
Expand Down Expand Up @@ -225,6 +241,7 @@ describe(`DateTimeUtil Unit tests`, () => {
expect(DateTimeUtil.isDateOrTimeChar('h')).toBeTrue();
expect(DateTimeUtil.isDateOrTimeChar('m')).toBeTrue();
expect(DateTimeUtil.isDateOrTimeChar('s')).toBeTrue();
expect(DateTimeUtil.isDateOrTimeChar('S')).toBeTrue();
expect(DateTimeUtil.isDateOrTimeChar(':')).toBeFalse();
expect(DateTimeUtil.isDateOrTimeChar('/')).toBeFalse();
expect(DateTimeUtil.isDateOrTimeChar('.')).toBeFalse();
Expand Down Expand Up @@ -404,7 +421,35 @@ describe(`DateTimeUtil Unit tests`, () => {
expect(date.getTime()).toEqual(new Date(2015, 4, 20, 12, 59, 57).getTime());
});

it('should spin AM/PM portion correctly', () => {
it('should spin fractional seconds portion correctly', () => {
// base
let date = new Date(2024, 3, 10, 6, 10, 5, 555);
DateTimeUtil.spinFractionalSeconds(1, date, false);
expect(date.getTime()).toEqual(new Date(2024, 3, 10, 6, 10, 5, 556).getTime());
DateTimeUtil.spinFractionalSeconds(-1, date, false);
expect(date.getTime()).toEqual(new Date(2024, 3, 10, 6, 10, 5, 555).getTime());

// delta !== 1
DateTimeUtil.spinFractionalSeconds(5, date, false);
expect(date.getTime()).toEqual(new Date(2024, 3, 10, 6, 10, 5, 560).getTime());
DateTimeUtil.spinFractionalSeconds(-6, date, false);
expect(date.getTime()).toEqual(new Date(2024, 3, 10, 6, 10, 5, 554).getTime());

// without looping over
date = new Date(2024, 3, 10, 6, 10, 5, 999);
DateTimeUtil.spinFractionalSeconds(1, date, false);
expect(date.getTime()).toEqual(new Date(2024, 3, 10, 6, 10, 5, 999).getTime());
DateTimeUtil.spinFractionalSeconds(-1000, date, false);
expect(date.getTime()).toEqual(new Date(2024, 3, 10, 6, 10, 5, 0).getTime());

// with looping over (seconds are not affected)
DateTimeUtil.spinFractionalSeconds(1001, date, true);
expect(date.getTime()).toEqual(new Date(2024, 3, 10, 6, 10, 5, 1).getTime());
DateTimeUtil.spinFractionalSeconds(-5, date, true);
expect(date.getTime()).toEqual(new Date(2024, 3, 10, 6, 10, 5, 996).getTime());
});

it('should spin AM/PM and a/p portion correctly', () => {
const currentDate = new Date(2015, 4, 31, 4, 59, 59);
const newDate = new Date(2015, 4, 31, 4, 59, 59);
// spin from AM to PM
Expand All @@ -414,6 +459,12 @@ describe(`DateTimeUtil Unit tests`, () => {
// spin from PM to AM
DateTimeUtil.spinAmPm(currentDate, newDate, 'AM');
expect(currentDate.getHours()).toEqual(4);

DateTimeUtil.spinAmPm(currentDate, newDate, 'p');
expect(currentDate.getHours()).toEqual(16);

DateTimeUtil.spinAmPm(currentDate, newDate, 'a');
expect(currentDate.getHours()).toEqual(4);
});

it('should compare dates correctly', () => {
Expand Down
107 changes: 90 additions & 17 deletions projects/igniteui-angular/src/lib/date-common/util/date-time.util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,16 @@ const enum FormatDesc {
TwoDigits = '2-digit'
}

const DATE_CHARS = ['h', 'H', 'm', 's', 'S', 't', 'T'];
const TIME_CHARS = ['d', 'D', 'M', 'y', 'Y'];
const TIME_CHARS = ['h', 'H', 'm', 's', 'S', 't', 'T', 'a'];
const DATE_CHARS = ['d', 'D', 'M', 'y', 'Y'];

/** @hidden */
const enum AmPmValues {
AM = 'AM',
A = 'a',
PM = 'PM',
P = 'p'
}

/** @hidden */
const enum DateParts {
Expand Down Expand Up @@ -56,7 +64,8 @@ export abstract class DateTimeUtil {
return null;
}

if (parts[DatePart.Hours] > 23 || parts[DatePart.Minutes] > 59 || parts[DatePart.Seconds] > 59) {
if (parts[DatePart.Hours] > 23 || parts[DatePart.Minutes] > 59
|| parts[DatePart.Seconds] > 59 || parts[DatePart.FractionalSeconds] > 999) {
return null;
}

Expand All @@ -65,8 +74,11 @@ export abstract class DateTimeUtil {
parts[DatePart.Hours] %= 12;
}

if (amPm && DateTimeUtil.getCleanVal(inputData, amPm, promptChar).toLowerCase() === 'pm') {
parts[DatePart.Hours] += 12;
if (amPm) {
const cleanVal = DateTimeUtil.getCleanVal(inputData, amPm, promptChar);
if (DateTimeUtil.isPm(cleanVal)) {
parts[DatePart.Hours] += 12;
}
}

return new Date(
Expand All @@ -75,7 +87,8 @@ export abstract class DateTimeUtil {
parts[DatePart.Date] || 1,
parts[DatePart.Hours] || 0,
parts[DatePart.Minutes] || 0,
parts[DatePart.Seconds] || 0
parts[DatePart.Seconds] || 0,
parts[DatePart.FractionalSeconds] || 0
);
}

Expand All @@ -86,7 +99,7 @@ export abstract class DateTimeUtil {
const formatArray = Array.from(format);
let currentPart: DatePartInfo = null;
let position = 0;

let lastPartAdded = false;
for (let i = 0; i < formatArray.length; i++, position++) {
const type = DateTimeUtil.determineDatePart(formatArray[i]);
if (currentPart) {
Expand All @@ -97,8 +110,15 @@ export abstract class DateTimeUtil {
}
}

if (currentPart.type === DatePart.AmPm && currentPart.format.indexOf('a') !== -1) {
currentPart = DateTimeUtil.simplifyAmPmFormat(currentPart);
}
DateTimeUtil.addCurrentPart(currentPart, dateTimeParts);
lastPartAdded = true;
position = currentPart.end;
if(i === formatArray.length - 1 && currentPart.type !== type) {
lastPartAdded = false;
}
}

currentPart = {
Expand All @@ -110,7 +130,10 @@ export abstract class DateTimeUtil {
}

// make sure the last member of a format like H:m:s is not omitted
if (!dateTimeParts.filter(p => p.format.includes(currentPart.format)).length) {
if (!lastPartAdded) {
if (currentPart.type === DatePart.AmPm) {
currentPart = DateTimeUtil.simplifyAmPmFormat(currentPart);
}
DateTimeUtil.addCurrentPart(currentPart, dateTimeParts);
}
// formats like "y" or "yyy" are treated like "yyyy" while editing
Expand All @@ -123,6 +146,13 @@ export abstract class DateTimeUtil {
return dateTimeParts;
}

/** Simplifies the AmPm part to as many chars as will be displayed */
private static simplifyAmPmFormat(currentPart: DatePartInfo){
currentPart.format = currentPart.format.length === 5 ? 'a' : 'aa';
currentPart.end = currentPart.start + currentPart.format.length;
return { ...currentPart };
}

public static getPartValue(value: Date, datePartInfo: DatePartInfo, partLength: number): string {
let maskedValue;
const datePart = datePartInfo.type;
Expand Down Expand Up @@ -156,8 +186,11 @@ export abstract class DateTimeUtil {
case DatePart.Seconds:
maskedValue = value.getSeconds();
break;
case DatePart.FractionalSeconds:
maskedValue = value.getMilliseconds();
break;
case DatePart.AmPm:
maskedValue = value.getHours() >= 12 ? 'PM' : 'AM';
maskedValue = DateTimeUtil.getAmPmValue(partLength, value.getHours() < 12);
break;
}

Expand All @@ -168,6 +201,29 @@ export abstract class DateTimeUtil {
return maskedValue;
}

/** Returns the AmPm part value depending on the part length and a
* conditional expression indicating whether the value is AM or PM.
*/
public static getAmPmValue(partLength: number, isAm: boolean) {
if (isAm) {
return partLength === 1 ? AmPmValues.A : AmPmValues.AM;
} else {
return partLength === 1 ? AmPmValues.P : AmPmValues.PM;
}
}

/** Returns true if a string value indicates an AM period */
public static isAm(value: string) {
value = value.toLowerCase();
return (value === AmPmValues.AM.toLowerCase() || value === AmPmValues.A.toLowerCase());
}

/** Returns true if a string value indicates a PM period */
public static isPm(value: string) {
value = value.toLowerCase();
return (value === AmPmValues.PM.toLowerCase() || value === AmPmValues.P.toLowerCase());
}

/** Builds a date-time editor's default input format based on provided locale settings. */
public static getDefaultInputFormat(locale: string): string {
locale = locale || DateTimeUtil.DEFAULT_LOCALE;
Expand Down Expand Up @@ -311,16 +367,28 @@ export abstract class DateTimeUtil {
newDate.setSeconds(seconds);
}

/** Spins the fractional seconds (milliseconds) portion in a date-time editor. */
public static spinFractionalSeconds(delta: number, newDate: Date, spinLoop: boolean) {
const maxMs = 999;
const minMs = 0;
let ms = newDate.getMilliseconds() + delta;
if (ms > maxMs) {
ms = spinLoop ? ms % maxMs - 1 : maxMs;
} else if (ms < minMs) {
ms = spinLoop ? maxMs + (ms % maxMs) + 1 : minMs;
}

newDate.setMilliseconds(ms);
}

/** Spins the AM/PM portion in a date-time editor. */
public static spinAmPm(newDate: Date, currentDate: Date, amPmFromMask: string): Date {
switch (amPmFromMask) {
case 'AM':
newDate = new Date(newDate.setHours(newDate.getHours() + 12));
break;
case 'PM':
newDate = new Date(newDate.setHours(newDate.getHours() - 12));
break;
if(DateTimeUtil.isAm(amPmFromMask)) {
newDate = new Date(newDate.setHours(newDate.getHours() + 12));
} else if(DateTimeUtil.isPm(amPmFromMask)) {
newDate = new Date(newDate.setHours(newDate.getHours() - 12));
}

if (newDate.getDate() !== currentDate.getDate()) {
return currentDate;
}
Expand Down Expand Up @@ -517,6 +585,9 @@ export abstract class DateTimeUtil {
part.format = part.format.repeat(2);
}
break;
case DatePart.FractionalSeconds:
part.format = part.format[0].repeat(3);
break;
}
}

Expand All @@ -540,8 +611,10 @@ export abstract class DateTimeUtil {
case 'm':
return DatePart.Minutes;
case 's':
case 'S':
return DatePart.Seconds;
case 'S':
return DatePart.FractionalSeconds;
case 'a':
case 't':
case 'T':
return DatePart.AmPm;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ public datePart: typeof DatePart = DatePart;
```
```html
<igx-input-group>
<input igxInput #timeEditor="igxDateTimeEditor" type="text" [igxDateTimeEditor]="'HH:mm tt'" [(ngModel)]="date">
<input igxInput #timeEditor="igxDateTimeEditor" type="text" [igxDateTimeEditor]="'HH:mm a'" [(ngModel)]="date">
<igx-suffix>
<igx-icon (click)="timeEditor.increment(datePart.Minutes)">keyboard_arrow_up</igx-icon>
<igx-icon (click)="timeEditor.decrement(datePart.Minutes)">keyboard_arrow_down</igx-icon>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export enum DatePart {
Hours = 'hours',
Minutes = 'minutes',
Seconds = 'seconds',
FractionalSeconds = 'fractionalSeconds',
AmPm = 'ampm',
Literal = 'literal'
}
Expand All @@ -34,4 +35,5 @@ export interface DatePartDeltas {
hours?: number;
minutes?: number;
seconds?: number;
fractionalSeconds?: number;
}
Loading

0 comments on commit 0a65630

Please sign in to comment.