diff --git a/modules/angular2/src/common/directives.ts b/modules/angular2/src/common/directives.ts index c777bae2dd..ceb662d168 100644 --- a/modules/angular2/src/common/directives.ts +++ b/modules/angular2/src/common/directives.ts @@ -8,5 +8,6 @@ export {NgFor} from './directives/ng_for'; export {NgIf} from './directives/ng_if'; export {NgStyle} from './directives/ng_style'; export {NgSwitch, NgSwitchWhen, NgSwitchDefault} from './directives/ng_switch'; +export {NgPlural, NgPluralCase, NgLocalization} from './directives/ng_plural'; export * from './directives/observable_list_diff'; -export {CORE_DIRECTIVES} from './directives/core_directives'; \ No newline at end of file +export {CORE_DIRECTIVES} from './directives/core_directives'; diff --git a/modules/angular2/src/common/directives/core_directives.ts b/modules/angular2/src/common/directives/core_directives.ts index 61f9e77612..e9dbd2464c 100644 --- a/modules/angular2/src/common/directives/core_directives.ts +++ b/modules/angular2/src/common/directives/core_directives.ts @@ -4,6 +4,7 @@ import {NgFor} from './ng_for'; import {NgIf} from './ng_if'; import {NgStyle} from './ng_style'; import {NgSwitch, NgSwitchWhen, NgSwitchDefault} from './ng_switch'; +import {NgPlural, NgPluralCase} from './ng_plural'; /** * A collection of Angular core directives that are likely to be used in each and every Angular @@ -45,5 +46,14 @@ import {NgSwitch, NgSwitchWhen, NgSwitchDefault} from './ng_switch'; * } * ``` */ -export const CORE_DIRECTIVES: Type[] = - CONST_EXPR([NgClass, NgFor, NgIf, NgStyle, NgSwitch, NgSwitchWhen, NgSwitchDefault]); +export const CORE_DIRECTIVES: Type[] = CONST_EXPR([ + NgClass, + NgFor, + NgIf, + NgStyle, + NgSwitch, + NgSwitchWhen, + NgSwitchDefault, + NgPlural, + NgPluralCase +]); diff --git a/modules/angular2/src/common/directives/ng_plural.ts b/modules/angular2/src/common/directives/ng_plural.ts new file mode 100644 index 0000000000..243d0052b7 --- /dev/null +++ b/modules/angular2/src/common/directives/ng_plural.ts @@ -0,0 +1,146 @@ +import { + Directive, + ViewContainerRef, + TemplateRef, + ContentChildren, + QueryList, + Attribute, + AfterContentInit, + Input +} from 'angular2/core'; +import {isPresent, NumberWrapper} from 'angular2/src/facade/lang'; +import {Map} from 'angular2/src/facade/collection'; +import {SwitchView} from './ng_switch'; + +const _CATEGORY_DEFAULT = 'other'; + +export abstract class NgLocalization { abstract getPluralCategory(value: any): string; } + +/** + * `ngPlural` is an i18n directive that displays DOM sub-trees that match the switch expression + * value, or failing that, DOM sub-trees that match the switch expression's pluralization category. + * + * To use this directive, you must provide an extension of `NgLocalization` that maps values to + * category names. You then define a container element that sets the `[ngPlural]` attribute to a + * switch expression. + * - Inner elements defined with an `[ngPluralCase]` attribute will display based on their + * expression. + * - If `[ngPluralCase]` is set to a value starting with `=`, it will only display if the value + * matches the switch expression exactly. + * - Otherwise, the view will be treated as a "category match", and will only display if exact + * value matches aren't found and the value maps to its category using the `getPluralCategory` + * function provided. + * + * If no matching views are found for a switch expression, inner elements marked + * `[ngPluralCase]="other"` will be displayed. + * + * ```typescript + * class MyLocalization extends NgLocalization { + * getPluralCategory(value: any) { + * if(value < 5) { + * return 'few'; + * } + * } + * } + * + * @Component({ + * selector: 'app', + * providers: [provide(NgLocalization, {useClass: MyLocalization})] + * }) + * @View({ + * template: ` + *

Value = {{value}}

+ * + * + *
+ * + * + * + * + *
+ * `, + * directives: [NgPlural, NgPluralCase] + * }) + * export class App { + * value = 'init'; + * + * inc() { + * this.value = this.value === 'init' ? 0 : this.value + 1; + * } + * } + * + * ``` + */ + +@Directive({selector: '[ngPluralCase]'}) +export class NgPluralCase { + _view: SwitchView; + constructor(@Attribute('ngPluralCase') public value: string, template: TemplateRef, + viewContainer: ViewContainerRef) { + this._view = new SwitchView(viewContainer, template); + } +} + + +@Directive({selector: '[ngPlural]'}) +export class NgPlural implements AfterContentInit { + private _switchValue: number; + private _activeView: SwitchView; + private _caseViews = new Map(); + @ContentChildren(NgPluralCase) cases: QueryList = null; + + constructor(private _localization: NgLocalization) {} + + @Input() + set ngPlural(value: number) { + this._switchValue = value; + this._updateView(); + } + + ngAfterContentInit() { + this.cases.forEach((pluralCase: NgPluralCase): void => { + this._caseViews.set(this._formatValue(pluralCase), pluralCase._view); + }); + this._updateView(); + } + + /** @internal */ + _updateView(): void { + this._clearViews(); + + var view: SwitchView = this._caseViews.get(this._switchValue); + if (!isPresent(view)) view = this._getCategoryView(this._switchValue); + + this._activateView(view); + } + + /** @internal */ + _clearViews() { + if (isPresent(this._activeView)) this._activeView.destroy(); + } + + /** @internal */ + _activateView(view: SwitchView) { + if (!isPresent(view)) return; + this._activeView = view; + this._activeView.create(); + } + + /** @internal */ + _getCategoryView(value: number): SwitchView { + var category: string = this._localization.getPluralCategory(value); + var categoryView: SwitchView = this._caseViews.get(category); + return isPresent(categoryView) ? categoryView : this._caseViews.get(_CATEGORY_DEFAULT); + } + + /** @internal */ + _isValueView(pluralCase: NgPluralCase): boolean { return pluralCase.value[0] === "="; } + + /** @internal */ + _formatValue(pluralCase: NgPluralCase): any { + return this._isValueView(pluralCase) ? this._stripValue(pluralCase.value) : pluralCase.value; + } + + /** @internal */ + _stripValue(value: string): number { return NumberWrapper.parseInt(value.substring(1), 10); } +} diff --git a/modules/angular2/test/common/directives/ng_plural_spec.ts b/modules/angular2/test/common/directives/ng_plural_spec.ts new file mode 100644 index 0000000000..0253696e4c --- /dev/null +++ b/modules/angular2/test/common/directives/ng_plural_spec.ts @@ -0,0 +1,137 @@ +import { + AsyncTestCompleter, + TestComponentBuilder, + beforeEachProviders, + beforeEach, + ddescribe, + describe, + el, + expect, + iit, + inject, + it, + xit, +} from 'angular2/testing_internal'; + +import {Component, View, Injectable, provide} from 'angular2/core'; +import {NgPlural, NgPluralCase, NgLocalization} from 'angular2/common'; + +export function main() { + describe('switch', () => { + beforeEachProviders(() => [provide(NgLocalization, {useClass: TestLocalizationMap})]); + + it('should display the template according to the exact value', + inject([TestComponentBuilder, AsyncTestCompleter], (tcb: TestComponentBuilder, async) => { + var template = '
' + + '
    ' + + '' + + '' + + '
'; + + tcb.overrideTemplate(TestComponent, template) + .createAsync(TestComponent) + .then((fixture) => { + fixture.debugElement.componentInstance.switchValue = 0; + fixture.detectChanges(); + expect(fixture.debugElement.nativeElement).toHaveText('you have no messages.'); + + fixture.debugElement.componentInstance.switchValue = 1; + fixture.detectChanges(); + expect(fixture.debugElement.nativeElement).toHaveText('you have one message.'); + + async.done(); + }); + })); + + it('should display the template according to the category', + inject([TestComponentBuilder, AsyncTestCompleter], (tcb: TestComponentBuilder, async) => { + var template = + '
' + + '
    ' + + '' + + '' + + '
'; + + tcb.overrideTemplate(TestComponent, template) + .createAsync(TestComponent) + .then((fixture) => { + fixture.debugElement.componentInstance.switchValue = 2; + fixture.detectChanges(); + expect(fixture.debugElement.nativeElement).toHaveText('you have a few messages.'); + + fixture.debugElement.componentInstance.switchValue = 8; + fixture.detectChanges(); + expect(fixture.debugElement.nativeElement).toHaveText('you have many messages.'); + + async.done(); + }); + })); + + it('should default to other when no matches are found', + inject([TestComponentBuilder, AsyncTestCompleter], (tcb: TestComponentBuilder, async) => { + var template = + '
' + + '
    ' + + '' + + '' + + '
'; + + tcb.overrideTemplate(TestComponent, template) + .createAsync(TestComponent) + .then((fixture) => { + fixture.debugElement.componentInstance.switchValue = 100; + fixture.detectChanges(); + expect(fixture.debugElement.nativeElement).toHaveText('default message.'); + + async.done(); + }); + })); + + it('should prioritize value matches over category matches', + inject([TestComponentBuilder, AsyncTestCompleter], (tcb: TestComponentBuilder, async) => { + var template = + '
' + + '
    ' + + '' + + '' + + '
'; + + tcb.overrideTemplate(TestComponent, template) + .createAsync(TestComponent) + .then((fixture) => { + fixture.debugElement.componentInstance.switchValue = 2; + fixture.detectChanges(); + expect(fixture.debugElement.nativeElement).toHaveText('you have two messages.'); + + fixture.debugElement.componentInstance.switchValue = 3; + fixture.detectChanges(); + expect(fixture.debugElement.nativeElement).toHaveText('you have a few messages.'); + + async.done(); + }); + })); + }); +} + + +@Injectable() +export class TestLocalizationMap extends NgLocalization { + getPluralCategory(value: number): string { + if (value > 1 && value < 4) { + return 'few'; + } else if (value >= 4 && value < 10) { + return 'many'; + } else { + return 'other'; + } + } +} + + +@Component({selector: 'test-cmp'}) +@View({directives: [NgPlural, NgPluralCase]}) +class TestComponent { + switchValue: number; + + constructor() { this.switchValue = null; } +} diff --git a/modules/angular2/test/public_api_spec.ts b/modules/angular2/test/public_api_spec.ts index fc2d60962c..7752520c70 100644 --- a/modules/angular2/test/public_api_spec.ts +++ b/modules/angular2/test/public_api_spec.ts @@ -381,6 +381,7 @@ var NG_COMMON = [ 'NgFormModel.value', 'NgIf', 'NgIf.ngIf=', + 'NgLocalization', 'NgModel', 'NgModel.asyncValidator', 'NgModel.control', @@ -405,6 +406,14 @@ var NG_COMMON = [ 'NgModel.viewModel', 'NgModel.viewModel=', 'NgModel.viewToModelUpdate()', + 'NgPlural', + 'NgPlural.cases', + 'NgPlural.cases=', + 'NgPlural.ngAfterContentInit()', + 'NgPlural.ngPlural=', + 'NgPluralCase', + 'NgPluralCase.value', + 'NgPluralCase.value=', 'NgSelectOption', 'NgStyle', 'NgStyle.ngDoCheck()', diff --git a/tools/public_api_guard/public_api_spec.ts b/tools/public_api_guard/public_api_spec.ts index ed1ba08b48..7a68565cf7 100644 --- a/tools/public_api_guard/public_api_spec.ts +++ b/tools/public_api_guard/public_api_spec.ts @@ -760,6 +760,8 @@ const COMMON = [ 'NgIf', 'NgIf.constructor(_viewContainer:ViewContainerRef, _templateRef:TemplateRef)', 'NgIf.ngIf=(newCondition:any)', + 'NgLocalization', + 'NgLocalization.getPluralCategory(value:any):string', 'NgModel', 'NgModel.asyncValidator:AsyncValidatorFn', 'NgModel.constructor(_validators:any[], _asyncValidators:any[], valueAccessors:ControlValueAccessor[])', @@ -771,6 +773,13 @@ const COMMON = [ 'NgModel.validator:ValidatorFn', 'NgModel.viewModel:any', 'NgModel.viewToModelUpdate(newValue:any):void', + 'NgPlural', + 'NgPlural.cases:QueryList', + 'NgPlural.constructor(_localization:NgLocalization)', + 'NgPlural.ngAfterContentInit():any', + 'NgPluralCase.constructor(value:string, template:TemplateRef, viewContainer:ViewContainerRef)', + 'NgPlural.ngPlural=(value:number)', + 'NgPluralCase', 'NgSelectOption', 'NgStyle', 'NgStyle.constructor(_differs:KeyValueDiffers, _ngEl:ElementRef, _renderer:Renderer)',