Skip to content

Commit 5c862c4

Browse files
committed
feat: add duration pipe
1 parent 02da643 commit 5c862c4

11 files changed

+434
-0
lines changed

README.md

+23
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ Now you can use the pipes, see below.
4747
- [Unit pipe](#unit-pipe)
4848
- [List pipe](#list-pipe)
4949
- [Relative Time (timeago) pipe](#relative-time-timeago-pipe)
50+
- [Duration pipe](#duration-pipe)
5051

5152
### Date pipe
5253

@@ -271,6 +272,27 @@ The following options are supported:
271272

272273
With the `INTL_RELATIVE_TIME_PIPE_DEFAULT_OPTIONS` injection token you can specify default options.
273274

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

276298
This library supports the latest major version of the following browsers:
@@ -294,6 +316,7 @@ In case you need to support older versions of that browsers, see the below table
294316
| Unit | 77 | 78 | 14.1 |
295317
| List | 72 | 78 | 14.1 |
296318
| Relative Time | 71 | 65 | 14 |
319+
| Duration | 129 | n/a | 16.4 |
297320

298321
## Angular compatibility table
299322

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,155 @@
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+
TestBed.runInInjectionContext(() => {
12+
testUnit = new IntlDurationPipe();
13+
Object.defineProperty(testUnit, 'locale', { value: 'en-US' });
14+
});
15+
});
16+
17+
it('should create an instance', () => {
18+
expect(testUnit).toBeTruthy();
19+
});
20+
21+
it('should handle null values', () => {
22+
expect(testUnit.transform(null)).toBeNull();
23+
});
24+
25+
it('should handle undefined values', () => {
26+
expect(testUnit.transform(undefined)).toBeNull();
27+
});
28+
29+
it('should transform durations', () => {
30+
expect(
31+
testUnit.transform({
32+
years: 2,
33+
months: 11,
34+
weeks: 2,
35+
days: 1,
36+
hours: 0,
37+
minutes: 55,
38+
seconds: 19,
39+
milliseconds: 940,
40+
microseconds: 10,
41+
nanoseconds: 3,
42+
}),
43+
).toEqual(
44+
'2 yrs, 11 mths, 2 wks, 1 day, 55 min, 19 sec, 940 ms, 10 μs, 3 ns',
45+
);
46+
});
47+
48+
it('should handle missing Intl.NumberFormat browser API', () => {
49+
// @ts-expect-error Intl APIs are not expected to be undefined
50+
spyOn(Intl, 'DurationFormat').and.returnValue(undefined);
51+
const consoleError = spyOn(console, 'error');
52+
expect(testUnit.transform({ years: 1 })).toBeNull();
53+
54+
expect(consoleError).toHaveBeenCalledTimes(1);
55+
});
56+
});
57+
58+
describe('internationalization', () => {
59+
it('should respect the set locale', () => {
60+
TestBed.configureTestingModule({
61+
providers: [
62+
{
63+
provide: INTL_LOCALES,
64+
useValue: 'de-DE',
65+
},
66+
],
67+
});
68+
TestBed.runInInjectionContext(() => (testUnit = new IntlDurationPipe()));
69+
70+
expect(testUnit.transform({ years: 1 })).toEqual('1 J');
71+
});
72+
73+
it('should fall back to the browser default locale', () => {
74+
let defaultLanguageTestUnit!: IntlDurationPipe;
75+
let browserLanguageTestUnit!: IntlDurationPipe;
76+
77+
TestBed.runInInjectionContext(() => {
78+
defaultLanguageTestUnit = new IntlDurationPipe();
79+
browserLanguageTestUnit = new IntlDurationPipe();
80+
Object.defineProperty(browserLanguageTestUnit, 'locale', {
81+
value: undefined,
82+
});
83+
Object.defineProperty(defaultLanguageTestUnit, 'locale', {
84+
value: navigator.language,
85+
});
86+
});
87+
88+
const result1 = browserLanguageTestUnit.transform({ years: 1 });
89+
const result2 = defaultLanguageTestUnit.transform({ years: 1 });
90+
91+
expect(result1).toEqual(result2);
92+
});
93+
});
94+
95+
describe('options', () => {
96+
it('should respect the setting from default config', () => {
97+
TestBed.configureTestingModule({
98+
providers: [
99+
{
100+
provide: INTL_LOCALES,
101+
useValue: 'en-US',
102+
},
103+
{
104+
provide: INTL_DURATION_PIPE_DEFAULT_OPTIONS,
105+
useValue: {
106+
style: 'long',
107+
},
108+
},
109+
],
110+
});
111+
TestBed.runInInjectionContext(() => (testUnit = new IntlDurationPipe()));
112+
113+
expect(testUnit.transform({ years: 1 })).toEqual('1 year');
114+
});
115+
116+
it('should give the user options a higher priority', () => {
117+
TestBed.configureTestingModule({
118+
providers: [
119+
IntlDurationPipe,
120+
{
121+
provide: INTL_LOCALES,
122+
useValue: 'en-US',
123+
},
124+
{
125+
provide: INTL_DURATION_PIPE_DEFAULT_OPTIONS,
126+
useValue: {
127+
style: 'long',
128+
},
129+
},
130+
],
131+
});
132+
TestBed.runInInjectionContext(() => (testUnit = new IntlDurationPipe()));
133+
134+
expect(testUnit.transform({ years: 1 }, { style: 'narrow' })).toEqual(
135+
'1y',
136+
);
137+
});
138+
});
139+
140+
it('should respect locale option', () => {
141+
TestBed.configureTestingModule({
142+
providers: [
143+
{
144+
provide: INTL_LOCALES,
145+
useValue: 'en-US',
146+
},
147+
],
148+
});
149+
TestBed.runInInjectionContext(() => (testUnit = new IntlDurationPipe()));
150+
151+
expect(testUnit.transform({ years: 1 }, { locale: 'de-DE' })).toEqual(
152+
'1 J',
153+
);
154+
});
155+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import { inject, 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+
readonly locale = inject(INTL_LOCALES, { optional: true });
66+
readonly defaultOptions = inject(INTL_DURATION_PIPE_DEFAULT_OPTIONS, {
67+
optional: true,
68+
});
69+
70+
transform(
71+
value: Intl.Duration | null | undefined,
72+
options?: IntlDurationPipeOptions,
73+
): string | null {
74+
if (!value) {
75+
return null;
76+
}
77+
78+
const { locale, ...intlOptions } = options ?? {};
79+
80+
try {
81+
return new Intl.DurationFormat(locale ?? this.locale ?? undefined, {
82+
...this.defaultOptions,
83+
...intlOptions,
84+
}).format(value);
85+
} catch (e) {
86+
console.error('Error while transforming the duration value', e);
87+
return null;
88+
}
89+
}
90+
}

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)