Skip to content
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.

Commit 6a1f68b

Browse files
committedMar 10, 2025·
feat: add duration pipe
1 parent 7a8f98b commit 6a1f68b

11 files changed

+434
-0
lines changed
 

‎README.md

Lines changed: 23 additions & 0 deletions
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 | 136 | 16.4 |
297320

298321
## Angular compatibility table
299322

Lines changed: 6 additions & 0 deletions
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');
Lines changed: 155 additions & 0 deletions
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+
});
Lines changed: 90 additions & 0 deletions
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

Lines changed: 3 additions & 0 deletions
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

Lines changed: 2 additions & 0 deletions
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';
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
<div class="fields-container">
2+
<mat-form-field>
3+
<mat-label>Years</mat-label>
4+
<input matInput type="number" [(ngModel)]="years" />
5+
</mat-form-field>
6+
7+
<mat-form-field>
8+
<mat-label>Months</mat-label>
9+
<input matInput type="number" [(ngModel)]="months" />
10+
</mat-form-field>
11+
12+
<mat-form-field>
13+
<mat-label>Weeks</mat-label>
14+
<input matInput type="number" [(ngModel)]="weeks" />
15+
</mat-form-field>
16+
17+
<mat-form-field>
18+
<mat-label>Days</mat-label>
19+
<input matInput type="number" [(ngModel)]="days" />
20+
</mat-form-field>
21+
22+
<mat-form-field>
23+
<mat-label>Hours</mat-label>
24+
<input matInput type="number" [(ngModel)]="hours" />
25+
</mat-form-field>
26+
27+
<mat-form-field>
28+
<mat-label>Minutes</mat-label>
29+
<input matInput type="number" [(ngModel)]="minutes" />
30+
</mat-form-field>
31+
32+
<mat-form-field>
33+
<mat-label>Seconds</mat-label>
34+
<input matInput type="number" [(ngModel)]="seconds" />
35+
</mat-form-field>
36+
37+
<mat-form-field>
38+
<mat-label>Milliseconds</mat-label>
39+
<input matInput type="number" [(ngModel)]="milliseconds" />
40+
</mat-form-field>
41+
42+
<mat-form-field>
43+
<mat-label>Microseconds</mat-label>
44+
<input matInput type="number" [(ngModel)]="microseconds" />
45+
</mat-form-field>
46+
47+
<mat-form-field>
48+
<mat-label>Nanoseconds</mat-label>
49+
<input matInput type="number" [(ngModel)]="nanoseconds" />
50+
</mat-form-field>
51+
52+
<mat-form-field>
53+
<mat-label>Locale</mat-label>
54+
<mat-select [(ngModel)]="locale">
55+
<mat-option [value]="undefined">Browser default</mat-option>
56+
@for (language of languages; track $index) {
57+
<mat-option [value]="language">{{ language }}</mat-option>
58+
}
59+
</mat-select>
60+
</mat-form-field>
61+
62+
<mat-form-field>
63+
<mat-label>Style</mat-label>
64+
<mat-select [(ngModel)]="style">
65+
<mat-option [value]="undefined">Browser default</mat-option>
66+
<mat-option [value]="'long'">long</mat-option>
67+
<mat-option [value]="'short'">short</mat-option>
68+
<mat-option [value]="'narrow'">narrow</mat-option>
69+
<mat-option [value]="'digital'">digital</mat-option>
70+
</mat-select>
71+
</mat-form-field>
72+
</div>
73+
74+
<p>
75+
{{ value() | intlDuration: options() }}
76+
</p>
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
.fields-container {
2+
display: flex;
3+
gap: 16px;
4+
flex-wrap: wrap;
5+
align-items: flex-start;
6+
margin-bottom: 16px;
7+
8+
mat-form-field {
9+
min-width: 250px;
10+
}
11+
}
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import { CommonModule } from '@angular/common';
2+
import { Component, computed, signal } from '@angular/core';
3+
import { FormsModule } from '@angular/forms';
4+
import { MatFormFieldModule } from '@angular/material/form-field';
5+
import { MatInputModule } from '@angular/material/input';
6+
import { MatSelectModule } from '@angular/material/select';
7+
import {
8+
IntlDurationPipe,
9+
IntlDurationPipeOptions,
10+
} from 'angular-ecmascript-intl';
11+
import { languages } from '../../languages';
12+
13+
@Component({
14+
selector: 'app-duration',
15+
imports: [
16+
CommonModule,
17+
MatFormFieldModule,
18+
MatSelectModule,
19+
IntlDurationPipe,
20+
FormsModule,
21+
MatInputModule,
22+
],
23+
templateUrl: './duration.component.html',
24+
styleUrls: ['./duration.component.scss'],
25+
})
26+
export class DurationComponent {
27+
languages = languages;
28+
years = signal(5);
29+
months = signal(2);
30+
weeks = signal<number | undefined>(undefined);
31+
days = signal(23);
32+
hours = signal<number | undefined>(undefined);
33+
minutes = signal<number | undefined>(undefined);
34+
seconds = signal<number | undefined>(undefined);
35+
milliseconds = signal<number | undefined>(undefined);
36+
microseconds = signal<number | undefined>(undefined);
37+
nanoseconds = signal<number | undefined>(undefined);
38+
locale = signal<string | undefined>(undefined);
39+
style = signal<IntlDurationPipeOptions['style']>(undefined);
40+
value = computed(() => ({
41+
years: this.years(),
42+
months: this.months(),
43+
weeks: this.weeks(),
44+
days: this.days(),
45+
hours: this.hours(),
46+
minutes: this.minutes(),
47+
seconds: this.seconds(),
48+
milliseconds: this.milliseconds(),
49+
microseconds: this.microseconds(),
50+
}));
51+
options = computed<IntlDurationPipeOptions>(() => ({
52+
locale: this.locale(),
53+
style: this.style(),
54+
}));
55+
}

‎projects/angular-intl-demo/src/app/pipes/pipes.component.html

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,14 @@
7171
routerLinkActive
7272
>Relative Time</a
7373
>
74+
<a
75+
#durationActive="routerLinkActive"
76+
[active]="durationActive.isActive"
77+
mat-tab-link
78+
routerLink="duration"
79+
routerLinkActive
80+
>Duration</a
81+
>
7482
</nav>
7583
<mat-tab-nav-panel #tabPanel></mat-tab-nav-panel>
7684

‎projects/angular-intl-demo/src/app/pipes/pipes.routes.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { CountryComponent } from './country/country.component';
33
import { CurrencyComponent } from './currency/currency.component';
44
import { DateComponent } from './date/date.component';
55
import { DecimalComponent } from './decimal/decimal.component';
6+
import { DurationComponent } from './duration/duration.component';
67
import { LanguageComponent } from './language/language.component';
78
import { ListComponent } from './list/list.component';
89
import { PercentComponent } from './percent/percent.component';
@@ -51,6 +52,10 @@ const routes: Routes = [
5152
path: 'relative-time',
5253
component: RelativeTimeComponent,
5354
},
55+
{
56+
path: 'duration',
57+
component: DurationComponent,
58+
},
5459
{
5560
path: '',
5661
redirectTo: 'date',

0 commit comments

Comments
 (0)
Please sign in to comment.