Skip to content

Commit 03387f0

Browse files
authored
feat: add unit pipe (#11)
1 parent f01adf0 commit 03387f0

14 files changed

+415
-6
lines changed

Diff for: README.md

+27-6
Original file line numberDiff line numberDiff line change
@@ -170,17 +170,38 @@ their [docs](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/G
170170

171171
With the `INTL_COUNTRY_PIPE_DEFAULT_OPTIONS` injection token you can specify default options.
172172

173+
### Unit pipe
174+
175+
Use the unit pipe like the following:
176+
177+
```
178+
{{1.2 | intlUnit: 'hour': options}}
179+
```
180+
181+
The input can be one of the following:
182+
183+
* number
184+
* string (must be parseable as number)
185+
* null
186+
* undefined
187+
188+
The unit parameter is required, see
189+
the [specification](https://tc39.es/proposal-unified-intl-numberformat/section6/locales-currencies-tz_proposed_out.html#sec-issanctionedsimpleunitidentifier)
190+
for a full list of possible values. If you want to transform a decimal number instead, use the `intlDecimal` pipe.
191+
192+
The options are the same as the options for `new Intl.NumberFormat()`. For a list of the options, see
193+
their [docs](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/NumberFormat/NumberFormat).
194+
195+
With the `INTL_UNIT_PIPE_DEFAULT_OPTIONS` injection token you can specify default options.
196+
173197
## Background
174198

175199
For more context, see the following [GitHub issue](https://github.com/angular/angular/issues/49143)
176200

177201
## Feature Roadmap
178202

179203
* Performance: Prepare Intl.* object with default options, only construct new object when necessary
180-
* Relative time
204+
* Limit options to only what is allowed by Intl API
205+
* List pipe
206+
* Relative time pipe
181207
* Migration Schematics for usages of Angular pipes
182-
183-
## Chore Roadmap
184-
185-
* Automatic npm publishing
186-
* Automatic changelog generation

Diff for: projects/angular-ecmascript-intl/src/lib/intl.module.ts

+3
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {IntlDecimalPipe} from "./decimal/intl-decimal.pipe";
55
import {IntlPercentPipe} from "./percent/intl-percent.pipe";
66
import {IntlCurrencyPipe} from "./currency/intl-currency.pipe";
77
import {IntlCountryPipe} from "./country/intl-country.pipe";
8+
import {IntlUnitPipe} from "./unit/intl-unit.pipe";
89

910
@NgModule({
1011
imports: [
@@ -14,6 +15,7 @@ import {IntlCountryPipe} from "./country/intl-country.pipe";
1415
IntlPercentPipe,
1516
IntlCurrencyPipe,
1617
IntlCountryPipe,
18+
IntlUnitPipe,
1719
],
1820
exports: [
1921
IntlDatePipe,
@@ -22,6 +24,7 @@ import {IntlCountryPipe} from "./country/intl-country.pipe";
2224
IntlPercentPipe,
2325
IntlCurrencyPipe,
2426
IntlCountryPipe,
27+
IntlUnitPipe,
2528
],
2629
})
2730
export class IntlModule {
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
import {InjectionToken} from "@angular/core";
2+
3+
export const INTL_UNIT_PIPE_DEFAULT_OPTIONS = new InjectionToken<Partial<Intl.NumberFormatOptions>>('IntlUnitPipeDefaultOptions');
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
import {IntlUnitPipe} from './intl-unit.pipe';
2+
import {TestBed} from "@angular/core/testing";
3+
import {INTL_LOCALES} from "../locale";
4+
import {INTL_UNIT_PIPE_DEFAULT_OPTIONS} from "./intl-unit-pipe-default-options";
5+
6+
describe('IntlUnitPipe', () => {
7+
let testUnit: IntlUnitPipe;
8+
9+
describe('parsing', () => {
10+
beforeEach(() => {
11+
testUnit = new IntlUnitPipe('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, undefined)).toBeNull();
20+
});
21+
22+
it('should handle undefined values', () => {
23+
expect(testUnit.transform(undefined, undefined)).toBeNull();
24+
});
25+
26+
it('should handle empty strings', () => {
27+
expect(testUnit.transform('', undefined)).toBeNull();
28+
});
29+
30+
it('should transform numbers', () => {
31+
expect(testUnit.transform(1, 'hour')).toEqual('1 hr');
32+
});
33+
34+
it('should transform strings', () => {
35+
expect(testUnit.transform('2', 'hour')).toEqual('2 hr');
36+
});
37+
38+
it('should handle invalid strings', () => {
39+
expect(() => testUnit.transform('invalid number', undefined)).toThrow();
40+
});
41+
42+
it('should handle missing Intl.NumberFormat browser API', () => {
43+
// @ts-expect-error Intl APIs are not expected to be undefined
44+
spyOn(Intl, 'NumberFormat').and.returnValue(undefined);
45+
const consoleError = spyOn(console, 'error');
46+
expect(testUnit.transform('1', 'hour')).toBeNull();
47+
48+
expect(consoleError).toHaveBeenCalledTimes(1);
49+
});
50+
});
51+
52+
describe('internationalization', () => {
53+
it('should respect the set locale', () => {
54+
TestBed.configureTestingModule({
55+
providers: [
56+
IntlUnitPipe,
57+
{
58+
provide: INTL_LOCALES,
59+
useValue: 'de-DE',
60+
},
61+
],
62+
});
63+
testUnit = TestBed.inject(IntlUnitPipe);
64+
65+
expect(testUnit.transform(1, 'hour')).toEqual('1 Std.');
66+
});
67+
68+
it('should fall back to the browser default locale', () => {
69+
TestBed.configureTestingModule({providers: [IntlUnitPipe]});
70+
71+
const result1 = TestBed.inject(IntlUnitPipe).transform(1, 'hour');
72+
const result2 = new IntlUnitPipe(navigator.language).transform(1, 'hour');
73+
74+
expect(result1).toEqual(result2);
75+
});
76+
});
77+
78+
describe('options', () => {
79+
it('should respect the setting from default config', () => {
80+
TestBed.configureTestingModule({
81+
providers: [
82+
IntlUnitPipe,
83+
{
84+
provide: INTL_LOCALES,
85+
useValue: 'en-US',
86+
},
87+
{
88+
provide: INTL_UNIT_PIPE_DEFAULT_OPTIONS,
89+
useValue: {
90+
unitDisplay: 'narrow',
91+
},
92+
},
93+
],
94+
});
95+
testUnit = TestBed.inject(IntlUnitPipe);
96+
97+
expect(testUnit.transform(1, 'liter')).toEqual('1L');
98+
99+
});
100+
101+
it('should give the user options a higher priority', () => {
102+
TestBed.configureTestingModule({
103+
providers: [
104+
IntlUnitPipe,
105+
{
106+
provide: INTL_LOCALES,
107+
useValue: 'en-US',
108+
},
109+
{
110+
provide: INTL_UNIT_PIPE_DEFAULT_OPTIONS,
111+
useValue: {
112+
unitDisplay: 'short',
113+
},
114+
},
115+
],
116+
});
117+
testUnit = TestBed.inject(IntlUnitPipe);
118+
119+
expect(testUnit.transform(1, 'liter', {unitDisplay: 'narrow'})).toEqual('1L');
120+
});
121+
});
122+
123+
it('should respect locale option', () => {
124+
TestBed.configureTestingModule({
125+
providers: [
126+
IntlUnitPipe,
127+
{
128+
provide: INTL_LOCALES,
129+
useValue: 'en-US',
130+
},
131+
],
132+
});
133+
testUnit = TestBed.inject(IntlUnitPipe);
134+
135+
expect(testUnit.transform(1, 'hour', {locale: 'de-DE'})).toEqual('1 Std.');
136+
});
137+
138+
it('should not override the style option', () => {
139+
TestBed.configureTestingModule({
140+
providers: [
141+
IntlUnitPipe,
142+
{
143+
provide: INTL_LOCALES,
144+
useValue: 'en-US',
145+
},
146+
{
147+
provide: INTL_UNIT_PIPE_DEFAULT_OPTIONS,
148+
useValue: {
149+
style: 'decimal',
150+
},
151+
},
152+
],
153+
});
154+
testUnit = TestBed.inject(IntlUnitPipe);
155+
156+
expect(testUnit.transform(1, 'hour', {style: 'decimal'})).toEqual('1 hr');
157+
});
158+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import {Inject, Optional, Pipe, PipeTransform} from '@angular/core';
2+
import {IntlPipeOptions} from "../intl-pipe-options";
3+
import {INTL_LOCALES} from "../locale";
4+
import {getNumericValue} from "../utils/number-utils";
5+
import {INTL_UNIT_PIPE_DEFAULT_OPTIONS} from "./intl-unit-pipe-default-options";
6+
7+
export type IntlUnitPipeOptions = Partial<Intl.NumberFormatOptions> & IntlPipeOptions;
8+
9+
@Pipe({
10+
name: 'intlUnit',
11+
standalone: true,
12+
})
13+
export class IntlUnitPipe implements PipeTransform {
14+
15+
constructor(@Optional() @Inject(INTL_LOCALES) readonly locale?: string | string[] | null,
16+
@Optional() @Inject(INTL_UNIT_PIPE_DEFAULT_OPTIONS) readonly defaultOptions?: Partial<Intl.NumberFormatOptions> | null) {
17+
}
18+
19+
transform(value: number | string | null | undefined, unit: string | undefined, options?: IntlUnitPipeOptions): string | null {
20+
if (typeof value !== 'number' && !value) {
21+
return null;
22+
}
23+
24+
const numericValue = getNumericValue(value);
25+
26+
const {locale, ...intlOptions} = options ?? {};
27+
28+
try {
29+
return new Intl.NumberFormat(
30+
locale ?? this.locale ?? undefined,
31+
{...this.defaultOptions, ...intlOptions, unit, style: 'unit'},
32+
).format(numericValue);
33+
} catch (e) {
34+
console.error('Error while transforming the percent value', e);
35+
return null;
36+
}
37+
}
38+
39+
}

Diff for: projects/angular-ecmascript-intl/src/public-api.ts

+2
Original file line numberDiff line numberDiff line change
@@ -16,3 +16,5 @@ export * from './lib/language/intl-language-pipe-default-options';
1616
export * from './lib/locale';
1717
export * from './lib/percent/intl-percent.pipe';
1818
export * from './lib/percent/intl-percent-pipe-default-options';
19+
export * from './lib/unit/intl-unit.pipe';
20+
export * from './lib/unit/intl-unit-pipe-default-options';

Diff for: projects/angular-intl-demo/src/app/pipes/pipes-routing.module.ts

+5
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {PercentComponent} from "./percent/percent.component";
77
import {CurrencyComponent} from "./currency/currency.component";
88
import {LanguageComponent} from "./language/language.component";
99
import {CountryComponent} from "./country/country.component";
10+
import {UnitComponent} from "./unit/unit.component";
1011

1112
const routes: Routes = [
1213
{
@@ -29,6 +30,10 @@ const routes: Routes = [
2930
path: 'currency',
3031
component: CurrencyComponent,
3132
},
33+
{
34+
path: 'unit',
35+
component: UnitComponent,
36+
},
3237
{
3338
path: 'language',
3439
component: LanguageComponent,

Diff for: projects/angular-intl-demo/src/app/pipes/pipes.component.html

+2
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
77
routerLinkActive>Percent</a>
88
<a #currencyActive="routerLinkActive" [active]="currencyActive.isActive" mat-tab-link routerLink="currency"
99
routerLinkActive>Currency</a>
10+
<a #unitActive="routerLinkActive" [active]="unitActive.isActive" mat-tab-link routerLink="unit"
11+
routerLinkActive>Unit</a>
1012
<a #languageActive="routerLinkActive" [active]="languageActive.isActive" mat-tab-link routerLink="language"
1113
routerLinkActive>Language</a>
1214
<a #countryActive="routerLinkActive" [active]="countryActive.isActive" mat-tab-link routerLink="country"

Diff for: projects/angular-intl-demo/src/app/pipes/pipes.module.ts

+2
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {PipesRoutingModule} from "./pipes-routing.module";
1515
import {CountryComponent} from "./country/country.component";
1616
import {NgxMatDatetimePickerModule, NgxMatNativeDateModule} from "@angular-material-components/datetime-picker";
1717
import {MatDatepickerModule} from "@angular/material/datepicker";
18+
import {UnitComponent} from "./unit/unit.component";
1819

1920
@NgModule({
2021
declarations: [
@@ -25,6 +26,7 @@ import {MatDatepickerModule} from "@angular/material/datepicker";
2526
CurrencyComponent,
2627
PipesComponent,
2728
CountryComponent,
29+
UnitComponent,
2830
],
2931
imports: [
3032
CommonModule,

0 commit comments

Comments
 (0)