diff --git a/README.md b/README.md index 0d77e149..bb6a04df 100644 --- a/README.md +++ b/README.md @@ -170,6 +170,30 @@ their [docs](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/G With the `INTL_COUNTRY_PIPE_DEFAULT_OPTIONS` injection token you can specify default options. +### Unit pipe + +Use the unit pipe like the following: + +``` +{{1.2 | intlUnit: 'hour': options}} +``` + +The input can be one of the following: + +* number +* string (must be parseable as number) +* null +* undefined + +The unit parameter is required, see +the [specification](https://tc39.es/proposal-unified-intl-numberformat/section6/locales-currencies-tz_proposed_out.html#sec-issanctionedsimpleunitidentifier) +for a full list of possible values. If you want to transform a decimal number instead, use the `intlDecimal` pipe. + +The options are the same as the options for `new Intl.NumberFormat()`. For a list of the options, see +their [docs](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/NumberFormat/NumberFormat). + +With the `INTL_UNIT_PIPE_DEFAULT_OPTIONS` injection token you can specify default options. + ## Background For more context, see the following [GitHub issue](https://github.com/angular/angular/issues/49143) @@ -177,10 +201,7 @@ For more context, see the following [GitHub issue](https://github.com/angular/an ## Feature Roadmap * Performance: Prepare Intl.* object with default options, only construct new object when necessary -* Relative time +* Limit options to only what is allowed by Intl API +* List pipe +* Relative time pipe * Migration Schematics for usages of Angular pipes - -## Chore Roadmap - -* Automatic npm publishing -* Automatic changelog generation diff --git a/projects/angular-ecmascript-intl/src/lib/intl.module.ts b/projects/angular-ecmascript-intl/src/lib/intl.module.ts index 4541fd47..a2a8be68 100644 --- a/projects/angular-ecmascript-intl/src/lib/intl.module.ts +++ b/projects/angular-ecmascript-intl/src/lib/intl.module.ts @@ -5,6 +5,7 @@ import {IntlDecimalPipe} from "./decimal/intl-decimal.pipe"; import {IntlPercentPipe} from "./percent/intl-percent.pipe"; import {IntlCurrencyPipe} from "./currency/intl-currency.pipe"; import {IntlCountryPipe} from "./country/intl-country.pipe"; +import {IntlUnitPipe} from "./unit/intl-unit.pipe"; @NgModule({ imports: [ @@ -14,6 +15,7 @@ import {IntlCountryPipe} from "./country/intl-country.pipe"; IntlPercentPipe, IntlCurrencyPipe, IntlCountryPipe, + IntlUnitPipe, ], exports: [ IntlDatePipe, @@ -22,6 +24,7 @@ import {IntlCountryPipe} from "./country/intl-country.pipe"; IntlPercentPipe, IntlCurrencyPipe, IntlCountryPipe, + IntlUnitPipe, ], }) export class IntlModule { diff --git a/projects/angular-ecmascript-intl/src/lib/unit/intl-unit-pipe-default-options.ts b/projects/angular-ecmascript-intl/src/lib/unit/intl-unit-pipe-default-options.ts new file mode 100644 index 00000000..66552f41 --- /dev/null +++ b/projects/angular-ecmascript-intl/src/lib/unit/intl-unit-pipe-default-options.ts @@ -0,0 +1,3 @@ +import {InjectionToken} from "@angular/core"; + +export const INTL_UNIT_PIPE_DEFAULT_OPTIONS = new InjectionToken>('IntlUnitPipeDefaultOptions'); diff --git a/projects/angular-ecmascript-intl/src/lib/unit/intl-unit.pipe.spec.ts b/projects/angular-ecmascript-intl/src/lib/unit/intl-unit.pipe.spec.ts new file mode 100644 index 00000000..a45cffa0 --- /dev/null +++ b/projects/angular-ecmascript-intl/src/lib/unit/intl-unit.pipe.spec.ts @@ -0,0 +1,158 @@ +import {IntlUnitPipe} from './intl-unit.pipe'; +import {TestBed} from "@angular/core/testing"; +import {INTL_LOCALES} from "../locale"; +import {INTL_UNIT_PIPE_DEFAULT_OPTIONS} from "./intl-unit-pipe-default-options"; + +describe('IntlUnitPipe', () => { + let testUnit: IntlUnitPipe; + + describe('parsing', () => { + beforeEach(() => { + testUnit = new IntlUnitPipe('en-US'); + }); + + it('should create an instance', () => { + expect(testUnit).toBeTruthy(); + }); + + it('should handle null values', () => { + expect(testUnit.transform(null, undefined)).toBeNull(); + }); + + it('should handle undefined values', () => { + expect(testUnit.transform(undefined, undefined)).toBeNull(); + }); + + it('should handle empty strings', () => { + expect(testUnit.transform('', undefined)).toBeNull(); + }); + + it('should transform numbers', () => { + expect(testUnit.transform(1, 'hour')).toEqual('1 hr'); + }); + + it('should transform strings', () => { + expect(testUnit.transform('2', 'hour')).toEqual('2 hr'); + }); + + it('should handle invalid strings', () => { + expect(() => testUnit.transform('invalid number', undefined)).toThrow(); + }); + + it('should handle missing Intl.NumberFormat browser API', () => { + // @ts-expect-error Intl APIs are not expected to be undefined + spyOn(Intl, 'NumberFormat').and.returnValue(undefined); + const consoleError = spyOn(console, 'error'); + expect(testUnit.transform('1', 'hour')).toBeNull(); + + expect(consoleError).toHaveBeenCalledTimes(1); + }); + }); + + describe('internationalization', () => { + it('should respect the set locale', () => { + TestBed.configureTestingModule({ + providers: [ + IntlUnitPipe, + { + provide: INTL_LOCALES, + useValue: 'de-DE', + }, + ], + }); + testUnit = TestBed.inject(IntlUnitPipe); + + expect(testUnit.transform(1, 'hour')).toEqual('1 Std.'); + }); + + it('should fall back to the browser default locale', () => { + TestBed.configureTestingModule({providers: [IntlUnitPipe]}); + + const result1 = TestBed.inject(IntlUnitPipe).transform(1, 'hour'); + const result2 = new IntlUnitPipe(navigator.language).transform(1, 'hour'); + + expect(result1).toEqual(result2); + }); + }); + + describe('options', () => { + it('should respect the setting from default config', () => { + TestBed.configureTestingModule({ + providers: [ + IntlUnitPipe, + { + provide: INTL_LOCALES, + useValue: 'en-US', + }, + { + provide: INTL_UNIT_PIPE_DEFAULT_OPTIONS, + useValue: { + unitDisplay: 'narrow', + }, + }, + ], + }); + testUnit = TestBed.inject(IntlUnitPipe); + + expect(testUnit.transform(1, 'liter')).toEqual('1L'); + + }); + + it('should give the user options a higher priority', () => { + TestBed.configureTestingModule({ + providers: [ + IntlUnitPipe, + { + provide: INTL_LOCALES, + useValue: 'en-US', + }, + { + provide: INTL_UNIT_PIPE_DEFAULT_OPTIONS, + useValue: { + unitDisplay: 'short', + }, + }, + ], + }); + testUnit = TestBed.inject(IntlUnitPipe); + + expect(testUnit.transform(1, 'liter', {unitDisplay: 'narrow'})).toEqual('1L'); + }); + }); + + it('should respect locale option', () => { + TestBed.configureTestingModule({ + providers: [ + IntlUnitPipe, + { + provide: INTL_LOCALES, + useValue: 'en-US', + }, + ], + }); + testUnit = TestBed.inject(IntlUnitPipe); + + expect(testUnit.transform(1, 'hour', {locale: 'de-DE'})).toEqual('1 Std.'); + }); + + it('should not override the style option', () => { + TestBed.configureTestingModule({ + providers: [ + IntlUnitPipe, + { + provide: INTL_LOCALES, + useValue: 'en-US', + }, + { + provide: INTL_UNIT_PIPE_DEFAULT_OPTIONS, + useValue: { + style: 'decimal', + }, + }, + ], + }); + testUnit = TestBed.inject(IntlUnitPipe); + + expect(testUnit.transform(1, 'hour', {style: 'decimal'})).toEqual('1 hr'); + }); +}); diff --git a/projects/angular-ecmascript-intl/src/lib/unit/intl-unit.pipe.ts b/projects/angular-ecmascript-intl/src/lib/unit/intl-unit.pipe.ts new file mode 100644 index 00000000..0dec65a5 --- /dev/null +++ b/projects/angular-ecmascript-intl/src/lib/unit/intl-unit.pipe.ts @@ -0,0 +1,39 @@ +import {Inject, Optional, Pipe, PipeTransform} from '@angular/core'; +import {IntlPipeOptions} from "../intl-pipe-options"; +import {INTL_LOCALES} from "../locale"; +import {getNumericValue} from "../utils/number-utils"; +import {INTL_UNIT_PIPE_DEFAULT_OPTIONS} from "./intl-unit-pipe-default-options"; + +export type IntlUnitPipeOptions = Partial & IntlPipeOptions; + +@Pipe({ + name: 'intlUnit', + standalone: true, +}) +export class IntlUnitPipe implements PipeTransform { + + constructor(@Optional() @Inject(INTL_LOCALES) readonly locale?: string | string[] | null, + @Optional() @Inject(INTL_UNIT_PIPE_DEFAULT_OPTIONS) readonly defaultOptions?: Partial | null) { + } + + transform(value: number | string | null | undefined, unit: string | undefined, options?: IntlUnitPipeOptions): string | null { + if (typeof value !== 'number' && !value) { + return null; + } + + const numericValue = getNumericValue(value); + + const {locale, ...intlOptions} = options ?? {}; + + try { + return new Intl.NumberFormat( + locale ?? this.locale ?? undefined, + {...this.defaultOptions, ...intlOptions, unit, style: 'unit'}, + ).format(numericValue); + } catch (e) { + console.error('Error while transforming the percent value', e); + return null; + } + } + +} diff --git a/projects/angular-ecmascript-intl/src/public-api.ts b/projects/angular-ecmascript-intl/src/public-api.ts index bd3c3dad..0e0e644b 100644 --- a/projects/angular-ecmascript-intl/src/public-api.ts +++ b/projects/angular-ecmascript-intl/src/public-api.ts @@ -16,3 +16,5 @@ export * from './lib/language/intl-language-pipe-default-options'; export * from './lib/locale'; export * from './lib/percent/intl-percent.pipe'; export * from './lib/percent/intl-percent-pipe-default-options'; +export * from './lib/unit/intl-unit.pipe'; +export * from './lib/unit/intl-unit-pipe-default-options'; diff --git a/projects/angular-intl-demo/src/app/pipes/pipes-routing.module.ts b/projects/angular-intl-demo/src/app/pipes/pipes-routing.module.ts index 7ed7e3e4..dc31dfeb 100644 --- a/projects/angular-intl-demo/src/app/pipes/pipes-routing.module.ts +++ b/projects/angular-intl-demo/src/app/pipes/pipes-routing.module.ts @@ -7,6 +7,7 @@ import {PercentComponent} from "./percent/percent.component"; import {CurrencyComponent} from "./currency/currency.component"; import {LanguageComponent} from "./language/language.component"; import {CountryComponent} from "./country/country.component"; +import {UnitComponent} from "./unit/unit.component"; const routes: Routes = [ { @@ -29,6 +30,10 @@ const routes: Routes = [ path: 'currency', component: CurrencyComponent, }, + { + path: 'unit', + component: UnitComponent, + }, { path: 'language', component: LanguageComponent, 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 8225086a..0ac5dcd3 100644 --- a/projects/angular-intl-demo/src/app/pipes/pipes.component.html +++ b/projects/angular-intl-demo/src/app/pipes/pipes.component.html @@ -7,6 +7,8 @@ routerLinkActive>Percent Currency + Unit Language + + Number + + + + + Unit + + {{unit}} + + + + + Locale + + Browser default + {{language}} + + + + + Unit display + + Browser default + narrow + short + long + + + + + Notation + + Browser default + standard + scientific + engineering + compact + + + + + Sign display + + Browser default + auto + always + exceptZero + negative + never + + + + + Minimum integer digits + + Please enter a number from 1 to 21 + + + + Minimum fraction digits + + Please enter a number from 0 to 20 + + + + Maximum fraction digits + + Please enter a number from 0 to 20 + + + + Minimum significant digits + + Please enter a number from 1 to 21 + + + + Maximum significant digits + + Please enter a number from 1 to 21 + + + +

{{enteredNumber | intlUnit: selectedUnit: { + locale, unitDisplay: unitDisplay, notation, signDisplay, + minimumIntegerDigits: minimumIntegerDigits ?? undefined, + minimumFractionDigits: minimumFractionDigits ?? undefined, + maximumFractionDigits: maximumFractionDigits ?? undefined, + minimumSignificantDigits: minimumSignificantDigits ?? undefined, + maximumSignificantDigits: maximumSignificantDigits ?? undefined +} }}

diff --git a/projects/angular-intl-demo/src/app/pipes/unit/unit.component.scss b/projects/angular-intl-demo/src/app/pipes/unit/unit.component.scss new file mode 100644 index 00000000..e6d6435c --- /dev/null +++ b/projects/angular-intl-demo/src/app/pipes/unit/unit.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/unit/unit.component.ts b/projects/angular-intl-demo/src/app/pipes/unit/unit.component.ts new file mode 100644 index 00000000..4e6dcc83 --- /dev/null +++ b/projects/angular-intl-demo/src/app/pipes/unit/unit.component.ts @@ -0,0 +1,24 @@ +import {Component} from '@angular/core'; +import {languages} from '../../languages'; +import {units} from "./units"; + +@Component({ + selector: 'app-unit', + templateUrl: './unit.component.html', + styleUrls: ['./unit.component.scss'], +}) +export class UnitComponent { + enteredNumber = '1'; + selectedUnit = 'hour'; + languages = languages; + units = units; + locale?: string; + notation?: Intl.NumberFormatOptions['notation']; + signDisplay?: Intl.NumberFormatOptions['signDisplay']; + unitDisplay?: Intl.NumberFormatOptions['unitDisplay']; + minimumIntegerDigits?: Intl.NumberFormatOptions['minimumIntegerDigits'] | null; + minimumFractionDigits?: Intl.NumberFormatOptions['minimumFractionDigits'] | null; + maximumFractionDigits?: Intl.NumberFormatOptions['maximumFractionDigits'] | null; + minimumSignificantDigits?: Intl.NumberFormatOptions['minimumSignificantDigits'] | null; + maximumSignificantDigits?: Intl.NumberFormatOptions['maximumSignificantDigits'] | null; +} diff --git a/projects/angular-intl-demo/src/app/pipes/unit/units.ts b/projects/angular-intl-demo/src/app/pipes/unit/units.ts new file mode 100644 index 00000000..7696ff27 --- /dev/null +++ b/projects/angular-intl-demo/src/app/pipes/unit/units.ts @@ -0,0 +1,45 @@ +export const units = [ + 'acre', + 'bit', + 'byte', + 'celsius', + 'centimeter', + 'day', + 'degree', + 'fahrenheit', + 'fluid-ounce', + 'foot', + 'gallon', + 'gigabit', + 'gigabyte', + 'gram', + 'hectare', + 'hour', + 'inch', + 'kilobit', + 'kilobyte', + 'kilogram', + 'kilometer', + 'liter', + 'megabit', + 'megabyte', + 'meter', + 'mile', + 'mile-scandinavian', + 'milliliter', + 'millimeter', + 'millisecond', + 'minute', + 'month', + 'ounce', + 'percent', + 'petabyte', + 'pound', + 'second', + 'stone', + 'terabit', + 'terabyte', + 'week', + 'yardv', + 'year', +]; diff --git a/projects/angular-intl-demo/src/styles.scss b/projects/angular-intl-demo/src/styles.scss index 98c76323..cc1b5fa1 100644 --- a/projects/angular-intl-demo/src/styles.scss +++ b/projects/angular-intl-demo/src/styles.scss @@ -18,6 +18,7 @@ $dark-theme: mat.define-dark-theme(( ), )); +@include mat.core-theme($light-theme); @include mat.legacy-button-color($light-theme); @include mat.button-theme($light-theme); @include mat.toolbar-theme($light-theme);