Skip to content

Commit 6ffe468

Browse files
committed
feat: add duration pipe
1 parent 066077b commit 6ffe468

11 files changed

+426
-0
lines changed

README.md

+23
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ Now you can use the pipes, see below.
5555
- [Unit pipe](#unit-pipe)
5656
- [List pipe](#list-pipe)
5757
- [Relative Time (timeago) pipe](#relative-time-timeago-pipe)
58+
- [Duration pipe](#duration-pipe)
5859

5960
### Date pipe
6061

@@ -279,6 +280,27 @@ The following options are supported:
279280

280281
With the `INTL_RELATIVE_TIME_PIPE_DEFAULT_OPTIONS` injection token you can specify default options.
281282

283+
## Duration pipe
284+
285+
Use the duration pipe like the following:
286+
287+
```
288+
{{ { hours: 2, minutes: 53 } | intlDuration: options }}
289+
```
290+
291+
The input can be one of the following:
292+
293+
- [duration object](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/DurationFormat/format#parameters)
294+
- null
295+
- undefined
296+
297+
The following options are supported:
298+
299+
- [`style`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/DurationFormat/DurationFormat#style)
300+
- [`fractionalDigits`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/DurationFormat/DurationFormat#fractionalDigits)
301+
302+
For each duration unit, there is a style and display option.
303+
282304
## Browser compatibility
283305

284306
This library supports the latest major version of the following browsers:
@@ -302,6 +324,7 @@ In case you need to support older versions of that browsers, see the below table
302324
| Unit | 77 | 78 | 14.1 |
303325
| List | 72 | 78 | 14.1 |
304326
| Relative Time | 71 | 65 | 14 |
327+
| Duration | n/a | n/a | 16.4 |
305328

306329
## Angular compatibility table
307330

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import { InjectionToken } from '@angular/core';
2+
import { IntlDurationPipeOptions } from './intl-duration.pipe';
3+
4+
export const INTL_DURATION_PIPE_DEFAULT_OPTIONS = new InjectionToken<
5+
Omit<IntlDurationPipeOptions, 'locale'>
6+
>('IntlDurationPipeDefaultOptions');
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
import { TestBed } from '@angular/core/testing';
2+
import { INTL_LOCALES } from '../locale';
3+
import { INTL_DURATION_PIPE_DEFAULT_OPTIONS } from './intl-duration-pipe-default-options';
4+
import { IntlDurationPipe } from './intl-duration.pipe';
5+
6+
describe('IntlDurationPipe', () => {
7+
let testUnit: IntlDurationPipe;
8+
9+
describe('parsing', () => {
10+
beforeEach(() => {
11+
testUnit = new IntlDurationPipe('en-US');
12+
});
13+
14+
it('should create an instance', () => {
15+
expect(testUnit).toBeTruthy();
16+
});
17+
18+
it('should handle null values', () => {
19+
expect(testUnit.transform(null)).toBeNull();
20+
});
21+
22+
it('should handle undefined values', () => {
23+
expect(testUnit.transform(undefined)).toBeNull();
24+
});
25+
26+
it('should transform durations', () => {
27+
expect(
28+
testUnit.transform({
29+
years: 2,
30+
months: 11,
31+
weeks: 2,
32+
days: 1,
33+
hours: 0,
34+
minutes: 55,
35+
seconds: 19,
36+
milliseconds: 940,
37+
microseconds: 10,
38+
nanoseconds: 3,
39+
}),
40+
).toEqual(
41+
'2 yrs, 11 mths, 2 wks, 1 day, 55 min, 19 sec, 940 ms, 10 μs, 3 ns',
42+
);
43+
});
44+
45+
it('should handle missing Intl.NumberFormat browser API', () => {
46+
// @ts-expect-error Intl APIs are not expected to be undefined
47+
spyOn(Intl, 'DurationFormat').and.returnValue(undefined);
48+
const consoleError = spyOn(console, 'error');
49+
expect(testUnit.transform({ years: 1 })).toBeNull();
50+
51+
expect(consoleError).toHaveBeenCalledTimes(1);
52+
});
53+
});
54+
55+
describe('internationalization', () => {
56+
it('should respect the set locale', () => {
57+
TestBed.configureTestingModule({
58+
providers: [
59+
IntlDurationPipe,
60+
{
61+
provide: INTL_LOCALES,
62+
useValue: 'de-DE',
63+
},
64+
],
65+
});
66+
testUnit = TestBed.inject(IntlDurationPipe);
67+
68+
expect(testUnit.transform({ years: 1 })).toEqual('1 J');
69+
});
70+
71+
it('should fall back to the browser default locale', () => {
72+
TestBed.configureTestingModule({ providers: [IntlDurationPipe] });
73+
74+
const result1 = TestBed.inject(IntlDurationPipe).transform({ years: 1 });
75+
const result2 = new IntlDurationPipe(navigator.language).transform({
76+
years: 1,
77+
});
78+
79+
expect(result1).toEqual(result2);
80+
});
81+
});
82+
83+
describe('options', () => {
84+
it('should respect the setting from default config', () => {
85+
TestBed.configureTestingModule({
86+
providers: [
87+
IntlDurationPipe,
88+
{
89+
provide: INTL_LOCALES,
90+
useValue: 'en-US',
91+
},
92+
{
93+
provide: INTL_DURATION_PIPE_DEFAULT_OPTIONS,
94+
useValue: {
95+
style: 'long',
96+
},
97+
},
98+
],
99+
});
100+
testUnit = TestBed.inject(IntlDurationPipe);
101+
102+
expect(testUnit.transform({ years: 1 })).toEqual('1 year');
103+
});
104+
105+
it('should give the user options a higher priority', () => {
106+
TestBed.configureTestingModule({
107+
providers: [
108+
IntlDurationPipe,
109+
{
110+
provide: INTL_LOCALES,
111+
useValue: 'en-US',
112+
},
113+
{
114+
provide: INTL_DURATION_PIPE_DEFAULT_OPTIONS,
115+
useValue: {
116+
style: 'long',
117+
},
118+
},
119+
],
120+
});
121+
testUnit = TestBed.inject(IntlDurationPipe);
122+
123+
expect(testUnit.transform({ years: 1 }, { style: 'narrow' })).toEqual(
124+
'1y',
125+
);
126+
});
127+
});
128+
129+
it('should respect locale option', () => {
130+
TestBed.configureTestingModule({
131+
providers: [
132+
IntlDurationPipe,
133+
{
134+
provide: INTL_LOCALES,
135+
useValue: 'en-US',
136+
},
137+
],
138+
});
139+
testUnit = TestBed.inject(IntlDurationPipe);
140+
141+
expect(testUnit.transform({ years: 1 }, { locale: 'de-DE' })).toEqual(
142+
'1 J',
143+
);
144+
});
145+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import { Inject, Optional, Pipe, PipeTransform } from '@angular/core';
2+
import { IntlPipeOptions } from '../intl-pipe-options';
3+
import { INTL_LOCALES } from '../locale';
4+
import { INTL_DURATION_PIPE_DEFAULT_OPTIONS } from './intl-duration-pipe-default-options';
5+
6+
// ToDo: remove once TypeScript includes official typings
7+
// eslint-disable-next-line @typescript-eslint/no-namespace
8+
declare namespace Intl {
9+
export class DurationFormat {
10+
constructor(locale?: string[] | string, options?: DurationFormatOptions);
11+
12+
format(duration: Duration): string;
13+
}
14+
15+
export interface DurationFormatOptions {
16+
style?: DurationItemStyle | 'digital';
17+
years?: DurationItemStyle;
18+
yearsDisplay?: DurationItemDisplay;
19+
months?: DurationItemStyle;
20+
monthsDisplay?: DurationItemDisplay;
21+
weeks?: DurationItemStyle;
22+
weeksDisplay?: DurationItemDisplay;
23+
days?: DurationItemStyle;
24+
daysDisplay?: DurationItemDisplay;
25+
hours?: DurationItemStyle | 'numeric' | '2-digit';
26+
hoursDisplay?: DurationItemDisplay;
27+
minutes?: DurationItemStyle | 'numeric' | '2-digit';
28+
minutesDisplay?: DurationItemDisplay;
29+
seconds?: DurationItemStyle | 'numeric' | '2-digit';
30+
secondsDisplay?: DurationItemDisplay;
31+
milliseconds?: DurationItemStyle | 'numeric' | '2-digit';
32+
millisecondsDisplay?: DurationItemDisplay;
33+
microseconds?: DurationItemStyle | 'numeric';
34+
microsecondsDisplay?: DurationItemDisplay;
35+
nanoseconds?: DurationItemStyle | 'numeric';
36+
nanosecondsDisplay?: DurationItemDisplay;
37+
fractionalDigits?: number;
38+
}
39+
40+
export type DurationItemStyle = 'long' | 'short' | 'narrow';
41+
export type DurationItemDisplay = 'always' | 'auto';
42+
43+
export interface Duration {
44+
years?: number;
45+
months?: number;
46+
weeks?: number;
47+
days?: number;
48+
hours?: number;
49+
minutes?: number;
50+
seconds?: number;
51+
milliseconds?: number;
52+
microseconds?: number;
53+
nanoseconds?: number;
54+
}
55+
}
56+
57+
export type IntlDurationPipeOptions = Partial<Intl.DurationFormatOptions> &
58+
IntlPipeOptions;
59+
60+
@Pipe({
61+
name: 'intlDuration',
62+
standalone: true,
63+
})
64+
export class IntlDurationPipe implements PipeTransform {
65+
constructor(
66+
@Optional()
67+
@Inject(INTL_LOCALES)
68+
readonly locale?: string | string[] | null,
69+
@Optional()
70+
@Inject(INTL_DURATION_PIPE_DEFAULT_OPTIONS)
71+
readonly defaultOptions?: Omit<IntlDurationPipeOptions, 'locale'> | null,
72+
) {}
73+
transform(
74+
value: Intl.Duration | null | undefined,
75+
options?: IntlDurationPipeOptions,
76+
): string | null {
77+
if (!value) {
78+
return null;
79+
}
80+
81+
const { locale, ...intlOptions } = options ?? {};
82+
83+
try {
84+
return new Intl.DurationFormat(locale ?? this.locale ?? undefined, {
85+
...this.defaultOptions,
86+
...intlOptions,
87+
}).format(value);
88+
} catch (e) {
89+
console.error('Error while transforming the duration value', e);
90+
return null;
91+
}
92+
}
93+
}

projects/angular-ecmascript-intl/src/lib/intl.module.ts

+3
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { IntlCountryPipe } from './country/intl-country.pipe';
33
import { IntlCurrencyPipe } from './currency/intl-currency.pipe';
44
import { IntlDatePipe } from './date/intl-date.pipe';
55
import { IntlDecimalPipe } from './decimal/intl-decimal.pipe';
6+
import { IntlDurationPipe } from './duration/intl-duration.pipe';
67
import { IntlLanguagePipe } from './language/intl-language.pipe';
78
import { IntlListPipe } from './list/intl-list.pipe';
89
import { IntlPercentPipe } from './percent/intl-percent.pipe';
@@ -20,6 +21,7 @@ import { IntlUnitPipe } from './unit/intl-unit.pipe';
2021
IntlUnitPipe,
2122
IntlListPipe,
2223
IntlRelativeTimePipe,
24+
IntlDurationPipe,
2325
],
2426
exports: [
2527
IntlDatePipe,
@@ -31,6 +33,7 @@ import { IntlUnitPipe } from './unit/intl-unit.pipe';
3133
IntlUnitPipe,
3234
IntlListPipe,
3335
IntlRelativeTimePipe,
36+
IntlDurationPipe,
3437
],
3538
})
3639
export class IntlModule {}

projects/angular-ecmascript-intl/src/public-api.ts

+2
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ export * from './lib/date/intl-date-pipe-default-options';
1010
export * from './lib/date/intl-date.pipe';
1111
export * from './lib/decimal/intl-decimal-pipe-default-options';
1212
export * from './lib/decimal/intl-decimal.pipe';
13+
export * from './lib/duration/intl-duration-pipe-default-options';
14+
export * from './lib/duration/intl-duration.pipe';
1315
export * from './lib/intl.module';
1416
export * from './lib/language/intl-language-pipe-default-options';
1517
export * from './lib/language/intl-language.pipe';

0 commit comments

Comments
 (0)