diff --git a/README.md b/README.md index 7d34549e..418e7cdd 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,7 @@ Now you can use the pipes, see below. - [Unit pipe](#unit-pipe) - [List pipe](#list-pipe) - [Relative Time (timeago) pipe](#relative-time-timeago-pipe) +- [Duration pipe](#duration-pipe) ### Date pipe @@ -271,6 +272,27 @@ The following options are supported: With the `INTL_RELATIVE_TIME_PIPE_DEFAULT_OPTIONS` injection token you can specify default options. +## Duration pipe + +Use the duration pipe like the following: + +``` +{{ { hours: 2, minutes: 53 } | intlDuration: options }} +``` + +The input can be one of the following: + +- [duration object](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/DurationFormat/format#parameters) +- null +- undefined + +The following options are supported: + +- [`style`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/DurationFormat/DurationFormat#style) +- [`fractionalDigits`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/DurationFormat/DurationFormat#fractionalDigits) + +For each duration unit, there is a style and display option. + ## Browser compatibility 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 | Unit | 77 | 78 | 14.1 | | List | 72 | 78 | 14.1 | | Relative Time | 71 | 65 | 14 | +| Duration | 129 | 136 | 16.4 | ## Angular compatibility table diff --git a/projects/angular-ecmascript-intl/src/lib/duration/intl-duration-pipe-default-options.ts b/projects/angular-ecmascript-intl/src/lib/duration/intl-duration-pipe-default-options.ts new file mode 100644 index 00000000..751960d3 --- /dev/null +++ b/projects/angular-ecmascript-intl/src/lib/duration/intl-duration-pipe-default-options.ts @@ -0,0 +1,6 @@ +import { InjectionToken } from '@angular/core'; +import { IntlDurationPipeOptions } from './intl-duration.pipe'; + +export const INTL_DURATION_PIPE_DEFAULT_OPTIONS = new InjectionToken< + Omit<IntlDurationPipeOptions, 'locale'> +>('IntlDurationPipeDefaultOptions'); diff --git a/projects/angular-ecmascript-intl/src/lib/duration/intl-duration.pipe.spec.ts b/projects/angular-ecmascript-intl/src/lib/duration/intl-duration.pipe.spec.ts new file mode 100644 index 00000000..a21135f4 --- /dev/null +++ b/projects/angular-ecmascript-intl/src/lib/duration/intl-duration.pipe.spec.ts @@ -0,0 +1,155 @@ +import { TestBed } from '@angular/core/testing'; +import { INTL_LOCALES } from '../locale'; +import { INTL_DURATION_PIPE_DEFAULT_OPTIONS } from './intl-duration-pipe-default-options'; +import { IntlDurationPipe } from './intl-duration.pipe'; + +describe('IntlDurationPipe', () => { + let testUnit: IntlDurationPipe; + + describe('parsing', () => { + beforeEach(() => { + TestBed.runInInjectionContext(() => { + testUnit = new IntlDurationPipe(); + Object.defineProperty(testUnit, 'locale', { value: 'en-US' }); + }); + }); + + it('should create an instance', () => { + expect(testUnit).toBeTruthy(); + }); + + it('should handle null values', () => { + expect(testUnit.transform(null)).toBeNull(); + }); + + it('should handle undefined values', () => { + expect(testUnit.transform(undefined)).toBeNull(); + }); + + it('should transform durations', () => { + expect( + testUnit.transform({ + years: 2, + months: 11, + weeks: 2, + days: 1, + hours: 0, + minutes: 55, + seconds: 19, + milliseconds: 940, + microseconds: 10, + nanoseconds: 3, + }), + ).toEqual( + '2 yrs, 11 mths, 2 wks, 1 day, 55 min, 19 sec, 940 ms, 10 μs, 3 ns', + ); + }); + + it('should handle missing Intl.NumberFormat browser API', () => { + // @ts-expect-error Intl APIs are not expected to be undefined + spyOn(Intl, 'DurationFormat').and.returnValue(undefined); + const consoleError = spyOn(console, 'error'); + expect(testUnit.transform({ years: 1 })).toBeNull(); + + expect(consoleError).toHaveBeenCalledTimes(1); + }); + }); + + describe('internationalization', () => { + it('should respect the set locale', () => { + TestBed.configureTestingModule({ + providers: [ + { + provide: INTL_LOCALES, + useValue: 'de-DE', + }, + ], + }); + TestBed.runInInjectionContext(() => (testUnit = new IntlDurationPipe())); + + expect(testUnit.transform({ years: 1 })).toEqual('1 J'); + }); + + it('should fall back to the browser default locale', () => { + let defaultLanguageTestUnit!: IntlDurationPipe; + let browserLanguageTestUnit!: IntlDurationPipe; + + TestBed.runInInjectionContext(() => { + defaultLanguageTestUnit = new IntlDurationPipe(); + browserLanguageTestUnit = new IntlDurationPipe(); + Object.defineProperty(browserLanguageTestUnit, 'locale', { + value: undefined, + }); + Object.defineProperty(defaultLanguageTestUnit, 'locale', { + value: navigator.language, + }); + }); + + const result1 = browserLanguageTestUnit.transform({ years: 1 }); + const result2 = defaultLanguageTestUnit.transform({ years: 1 }); + + expect(result1).toEqual(result2); + }); + }); + + describe('options', () => { + it('should respect the setting from default config', () => { + TestBed.configureTestingModule({ + providers: [ + { + provide: INTL_LOCALES, + useValue: 'en-US', + }, + { + provide: INTL_DURATION_PIPE_DEFAULT_OPTIONS, + useValue: { + style: 'long', + }, + }, + ], + }); + TestBed.runInInjectionContext(() => (testUnit = new IntlDurationPipe())); + + expect(testUnit.transform({ years: 1 })).toEqual('1 year'); + }); + + it('should give the user options a higher priority', () => { + TestBed.configureTestingModule({ + providers: [ + IntlDurationPipe, + { + provide: INTL_LOCALES, + useValue: 'en-US', + }, + { + provide: INTL_DURATION_PIPE_DEFAULT_OPTIONS, + useValue: { + style: 'long', + }, + }, + ], + }); + TestBed.runInInjectionContext(() => (testUnit = new IntlDurationPipe())); + + expect(testUnit.transform({ years: 1 }, { style: 'narrow' })).toEqual( + '1y', + ); + }); + }); + + it('should respect locale option', () => { + TestBed.configureTestingModule({ + providers: [ + { + provide: INTL_LOCALES, + useValue: 'en-US', + }, + ], + }); + TestBed.runInInjectionContext(() => (testUnit = new IntlDurationPipe())); + + expect(testUnit.transform({ years: 1 }, { locale: 'de-DE' })).toEqual( + '1 J', + ); + }); +}); diff --git a/projects/angular-ecmascript-intl/src/lib/duration/intl-duration.pipe.ts b/projects/angular-ecmascript-intl/src/lib/duration/intl-duration.pipe.ts new file mode 100644 index 00000000..e50f0e95 --- /dev/null +++ b/projects/angular-ecmascript-intl/src/lib/duration/intl-duration.pipe.ts @@ -0,0 +1,90 @@ +import { inject, Pipe, PipeTransform } from '@angular/core'; +import { IntlPipeOptions } from '../intl-pipe-options'; +import { INTL_LOCALES } from '../locale'; +import { INTL_DURATION_PIPE_DEFAULT_OPTIONS } from './intl-duration-pipe-default-options'; + +// ToDo: remove once TypeScript includes official typings +// eslint-disable-next-line @typescript-eslint/no-namespace +declare namespace Intl { + export class DurationFormat { + constructor(locale?: string[] | string, options?: DurationFormatOptions); + + format(duration: Duration): string; + } + + export interface DurationFormatOptions { + style?: DurationItemStyle | 'digital'; + years?: DurationItemStyle; + yearsDisplay?: DurationItemDisplay; + months?: DurationItemStyle; + monthsDisplay?: DurationItemDisplay; + weeks?: DurationItemStyle; + weeksDisplay?: DurationItemDisplay; + days?: DurationItemStyle; + daysDisplay?: DurationItemDisplay; + hours?: DurationItemStyle | 'numeric' | '2-digit'; + hoursDisplay?: DurationItemDisplay; + minutes?: DurationItemStyle | 'numeric' | '2-digit'; + minutesDisplay?: DurationItemDisplay; + seconds?: DurationItemStyle | 'numeric' | '2-digit'; + secondsDisplay?: DurationItemDisplay; + milliseconds?: DurationItemStyle | 'numeric' | '2-digit'; + millisecondsDisplay?: DurationItemDisplay; + microseconds?: DurationItemStyle | 'numeric'; + microsecondsDisplay?: DurationItemDisplay; + nanoseconds?: DurationItemStyle | 'numeric'; + nanosecondsDisplay?: DurationItemDisplay; + fractionalDigits?: number; + } + + export type DurationItemStyle = 'long' | 'short' | 'narrow'; + export type DurationItemDisplay = 'always' | 'auto'; + + export interface Duration { + years?: number; + months?: number; + weeks?: number; + days?: number; + hours?: number; + minutes?: number; + seconds?: number; + milliseconds?: number; + microseconds?: number; + nanoseconds?: number; + } +} + +export type IntlDurationPipeOptions = Partial<Intl.DurationFormatOptions> & + IntlPipeOptions; + +@Pipe({ + name: 'intlDuration', + standalone: true, +}) +export class IntlDurationPipe implements PipeTransform { + readonly locale = inject(INTL_LOCALES, { optional: true }); + readonly defaultOptions = inject(INTL_DURATION_PIPE_DEFAULT_OPTIONS, { + optional: true, + }); + + transform( + value: Intl.Duration | null | undefined, + options?: IntlDurationPipeOptions, + ): string | null { + if (!value) { + return null; + } + + const { locale, ...intlOptions } = options ?? {}; + + try { + return new Intl.DurationFormat(locale ?? this.locale ?? undefined, { + ...this.defaultOptions, + ...intlOptions, + }).format(value); + } catch (e) { + console.error('Error while transforming the duration value', e); + return null; + } + } +} diff --git a/projects/angular-ecmascript-intl/src/lib/intl.module.ts b/projects/angular-ecmascript-intl/src/lib/intl.module.ts index 56cb9147..17df0cb7 100644 --- a/projects/angular-ecmascript-intl/src/lib/intl.module.ts +++ b/projects/angular-ecmascript-intl/src/lib/intl.module.ts @@ -3,6 +3,7 @@ import { IntlCountryPipe } from './country/intl-country.pipe'; import { IntlCurrencyPipe } from './currency/intl-currency.pipe'; import { IntlDatePipe } from './date/intl-date.pipe'; import { IntlDecimalPipe } from './decimal/intl-decimal.pipe'; +import { IntlDurationPipe } from './duration/intl-duration.pipe'; import { IntlLanguagePipe } from './language/intl-language.pipe'; import { IntlListPipe } from './list/intl-list.pipe'; import { IntlPercentPipe } from './percent/intl-percent.pipe'; @@ -20,6 +21,7 @@ import { IntlUnitPipe } from './unit/intl-unit.pipe'; IntlUnitPipe, IntlListPipe, IntlRelativeTimePipe, + IntlDurationPipe, ], exports: [ IntlDatePipe, @@ -31,6 +33,7 @@ import { IntlUnitPipe } from './unit/intl-unit.pipe'; IntlUnitPipe, IntlListPipe, IntlRelativeTimePipe, + IntlDurationPipe, ], }) export class IntlModule {} diff --git a/projects/angular-ecmascript-intl/src/public-api.ts b/projects/angular-ecmascript-intl/src/public-api.ts index 6d520d86..5be1eaf9 100644 --- a/projects/angular-ecmascript-intl/src/public-api.ts +++ b/projects/angular-ecmascript-intl/src/public-api.ts @@ -10,6 +10,8 @@ export * from './lib/date/intl-date-pipe-default-options'; export * from './lib/date/intl-date.pipe'; export * from './lib/decimal/intl-decimal-pipe-default-options'; export * from './lib/decimal/intl-decimal.pipe'; +export * from './lib/duration/intl-duration-pipe-default-options'; +export * from './lib/duration/intl-duration.pipe'; export * from './lib/intl.module'; export * from './lib/language/intl-language-pipe-default-options'; export * from './lib/language/intl-language.pipe'; diff --git a/projects/angular-intl-demo/src/app/pipes/duration/duration.component.html b/projects/angular-intl-demo/src/app/pipes/duration/duration.component.html new file mode 100644 index 00000000..bbd846c5 --- /dev/null +++ b/projects/angular-intl-demo/src/app/pipes/duration/duration.component.html @@ -0,0 +1,76 @@ +<div class="fields-container"> + <mat-form-field> + <mat-label>Years</mat-label> + <input matInput type="number" [(ngModel)]="years" /> + </mat-form-field> + + <mat-form-field> + <mat-label>Months</mat-label> + <input matInput type="number" [(ngModel)]="months" /> + </mat-form-field> + + <mat-form-field> + <mat-label>Weeks</mat-label> + <input matInput type="number" [(ngModel)]="weeks" /> + </mat-form-field> + + <mat-form-field> + <mat-label>Days</mat-label> + <input matInput type="number" [(ngModel)]="days" /> + </mat-form-field> + + <mat-form-field> + <mat-label>Hours</mat-label> + <input matInput type="number" [(ngModel)]="hours" /> + </mat-form-field> + + <mat-form-field> + <mat-label>Minutes</mat-label> + <input matInput type="number" [(ngModel)]="minutes" /> + </mat-form-field> + + <mat-form-field> + <mat-label>Seconds</mat-label> + <input matInput type="number" [(ngModel)]="seconds" /> + </mat-form-field> + + <mat-form-field> + <mat-label>Milliseconds</mat-label> + <input matInput type="number" [(ngModel)]="milliseconds" /> + </mat-form-field> + + <mat-form-field> + <mat-label>Microseconds</mat-label> + <input matInput type="number" [(ngModel)]="microseconds" /> + </mat-form-field> + + <mat-form-field> + <mat-label>Nanoseconds</mat-label> + <input matInput type="number" [(ngModel)]="nanoseconds" /> + </mat-form-field> + + <mat-form-field> + <mat-label>Locale</mat-label> + <mat-select [(ngModel)]="locale"> + <mat-option [value]="undefined">Browser default</mat-option> + @for (language of languages; track $index) { + <mat-option [value]="language">{{ language }}</mat-option> + } + </mat-select> + </mat-form-field> + + <mat-form-field> + <mat-label>Style</mat-label> + <mat-select [(ngModel)]="style"> + <mat-option [value]="undefined">Browser default</mat-option> + <mat-option [value]="'long'">long</mat-option> + <mat-option [value]="'short'">short</mat-option> + <mat-option [value]="'narrow'">narrow</mat-option> + <mat-option [value]="'digital'">digital</mat-option> + </mat-select> + </mat-form-field> +</div> + +<p> + {{ value() | intlDuration: options() }} +</p> diff --git a/projects/angular-intl-demo/src/app/pipes/duration/duration.component.scss b/projects/angular-intl-demo/src/app/pipes/duration/duration.component.scss new file mode 100644 index 00000000..e6d6435c --- /dev/null +++ b/projects/angular-intl-demo/src/app/pipes/duration/duration.component.scss @@ -0,0 +1,11 @@ +.fields-container { + display: flex; + gap: 16px; + flex-wrap: wrap; + align-items: flex-start; + margin-bottom: 16px; + + mat-form-field { + min-width: 250px; + } +} diff --git a/projects/angular-intl-demo/src/app/pipes/duration/duration.component.ts b/projects/angular-intl-demo/src/app/pipes/duration/duration.component.ts new file mode 100644 index 00000000..714ee71a --- /dev/null +++ b/projects/angular-intl-demo/src/app/pipes/duration/duration.component.ts @@ -0,0 +1,55 @@ +import { CommonModule } from '@angular/common'; +import { Component, computed, signal } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatInputModule } from '@angular/material/input'; +import { MatSelectModule } from '@angular/material/select'; +import { + IntlDurationPipe, + IntlDurationPipeOptions, +} from 'angular-ecmascript-intl'; +import { languages } from '../../languages'; + +@Component({ + selector: 'app-duration', + imports: [ + CommonModule, + MatFormFieldModule, + MatSelectModule, + IntlDurationPipe, + FormsModule, + MatInputModule, + ], + templateUrl: './duration.component.html', + styleUrls: ['./duration.component.scss'], +}) +export class DurationComponent { + languages = languages; + years = signal(5); + months = signal(2); + weeks = signal<number | undefined>(undefined); + days = signal(23); + hours = signal<number | undefined>(undefined); + minutes = signal<number | undefined>(undefined); + seconds = signal<number | undefined>(undefined); + milliseconds = signal<number | undefined>(undefined); + microseconds = signal<number | undefined>(undefined); + nanoseconds = signal<number | undefined>(undefined); + locale = signal<string | undefined>(undefined); + style = signal<IntlDurationPipeOptions['style']>(undefined); + value = computed(() => ({ + years: this.years(), + months: this.months(), + weeks: this.weeks(), + days: this.days(), + hours: this.hours(), + minutes: this.minutes(), + seconds: this.seconds(), + milliseconds: this.milliseconds(), + microseconds: this.microseconds(), + })); + options = computed<IntlDurationPipeOptions>(() => ({ + locale: this.locale(), + style: this.style(), + })); +} diff --git a/projects/angular-intl-demo/src/app/pipes/pipes.component.html b/projects/angular-intl-demo/src/app/pipes/pipes.component.html index e4ae04cd..771ccec1 100644 --- a/projects/angular-intl-demo/src/app/pipes/pipes.component.html +++ b/projects/angular-intl-demo/src/app/pipes/pipes.component.html @@ -71,6 +71,14 @@ routerLinkActive >Relative Time</a > + <a + #durationActive="routerLinkActive" + [active]="durationActive.isActive" + mat-tab-link + routerLink="duration" + routerLinkActive + >Duration</a + > </nav> <mat-tab-nav-panel #tabPanel></mat-tab-nav-panel> diff --git a/projects/angular-intl-demo/src/app/pipes/pipes.routes.ts b/projects/angular-intl-demo/src/app/pipes/pipes.routes.ts index 7c2eb2fe..593d09bb 100644 --- a/projects/angular-intl-demo/src/app/pipes/pipes.routes.ts +++ b/projects/angular-intl-demo/src/app/pipes/pipes.routes.ts @@ -3,6 +3,7 @@ import { CountryComponent } from './country/country.component'; import { CurrencyComponent } from './currency/currency.component'; import { DateComponent } from './date/date.component'; import { DecimalComponent } from './decimal/decimal.component'; +import { DurationComponent } from './duration/duration.component'; import { LanguageComponent } from './language/language.component'; import { ListComponent } from './list/list.component'; import { PercentComponent } from './percent/percent.component'; @@ -51,6 +52,10 @@ const routes: Routes = [ path: 'relative-time', component: RelativeTimeComponent, }, + { + path: 'duration', + component: DurationComponent, + }, { path: '', redirectTo: 'date',