From 0a5489f9c4fda997982b90edd19f2ffff7193a5a Mon Sep 17 00:00:00 2001 From: Kristiyan Kostadinov Date: Thu, 27 Jul 2017 19:46:05 +0300 Subject: [PATCH] feat: add change emitters to the Intl providers (#5867) Since we're switching all components to OnPush change detection, they won't necessarily react to dynamic changes in the i18n labels. These changes add an `EventEmitter` per provider that allow for components to react to those types of changes. Note that it's up to the consumer to call `provider.changes.emit()` in order to trigger the re-render. Fixes #5738. --- src/lib/datepicker/calendar.spec.ts | 14 +++++++++++++- src/lib/datepicker/calendar.ts | 20 +++++++++++++++++--- src/lib/datepicker/datepicker-intl.ts | 8 +++++++- src/lib/datepicker/datepicker-toggle.ts | 22 +++++++++++++++++++--- src/lib/datepicker/datepicker.spec.ts | 13 ++++++++++++- src/lib/paginator/paginator-intl.ts | 8 +++++++- src/lib/paginator/paginator.spec.ts | 13 ++++++++++++- src/lib/paginator/paginator.ts | 18 ++++++++++++++---- src/lib/sort/sort-header-intl.ts | 9 ++++++++- src/lib/sort/sort-header.ts | 12 +++++++----- src/lib/sort/sort.spec.ts | 20 ++++++++++++++++---- 11 files changed, 132 insertions(+), 25 deletions(-) diff --git a/src/lib/datepicker/calendar.spec.ts b/src/lib/datepicker/calendar.spec.ts index c7e82c13d274..78be7540dca8 100644 --- a/src/lib/datepicker/calendar.spec.ts +++ b/src/lib/datepicker/calendar.spec.ts @@ -1,4 +1,4 @@ -import {async, ComponentFixture, TestBed} from '@angular/core/testing'; +import {async, ComponentFixture, inject, TestBed} from '@angular/core/testing'; import {Component} from '@angular/core'; import {MdCalendar} from './calendar'; import {By} from '@angular/platform-browser'; @@ -148,6 +148,18 @@ describe('MdCalendar', () => { expect(testComponent.selected).toEqual(new Date(2017, JAN, 31)); }); + it('should re-render when the i18n labels have changed', + inject([MdDatepickerIntl], (intl: MdDatepickerIntl) => { + const button = fixture.debugElement.nativeElement + .querySelector('.mat-calendar-period-button'); + + intl.switchToYearViewLabel = 'Go to year view?'; + intl.changes.emit(); + fixture.detectChanges(); + + expect(button.getAttribute('aria-label')).toBe('Go to year view?'); + })); + describe('a11y', () => { describe('calendar body', () => { let calendarBodyEl: HTMLElement; diff --git a/src/lib/datepicker/calendar.ts b/src/lib/datepicker/calendar.ts index 7bfccbbb1042..2b21bbc884f2 100644 --- a/src/lib/datepicker/calendar.ts +++ b/src/lib/datepicker/calendar.ts @@ -17,7 +17,9 @@ import { NgZone, Optional, Output, - ViewEncapsulation + ViewEncapsulation, + ChangeDetectorRef, + OnDestroy, } from '@angular/core'; import { DOWN_ARROW, @@ -36,6 +38,7 @@ import {createMissingDateImplError} from './datepicker-errors'; import {MD_DATE_FORMATS, MdDateFormats} from '../core/datetime/date-formats'; import {MATERIAL_COMPATIBILITY_MODE} from '../core'; import {first} from '../core/rxjs/index'; +import {Subscription} from 'rxjs/Subscription'; /** @@ -53,7 +56,9 @@ import {first} from '../core/rxjs/index'; encapsulation: ViewEncapsulation.None, changeDetection: ChangeDetectionStrategy.OnPush, }) -export class MdCalendar implements AfterContentInit { +export class MdCalendar implements AfterContentInit, OnDestroy { + private _intlChanges: Subscription; + /** A date representing the period (month or year) to start the calendar in. */ @Input() startAt: D; @@ -123,13 +128,18 @@ export class MdCalendar implements AfterContentInit { private _ngZone: NgZone, @Optional() @Inject(MATERIAL_COMPATIBILITY_MODE) public _isCompatibilityMode: boolean, @Optional() private _dateAdapter: DateAdapter, - @Optional() @Inject(MD_DATE_FORMATS) private _dateFormats: MdDateFormats) { + @Optional() @Inject(MD_DATE_FORMATS) private _dateFormats: MdDateFormats, + changeDetectorRef: ChangeDetectorRef) { + if (!this._dateAdapter) { throw createMissingDateImplError('DateAdapter'); } + if (!this._dateFormats) { throw createMissingDateImplError('MD_DATE_FORMATS'); } + + this._intlChanges = _intl.changes.subscribe(() => changeDetectorRef.markForCheck()); } ngAfterContentInit() { @@ -138,6 +148,10 @@ export class MdCalendar implements AfterContentInit { this._monthView = this.startView != 'year'; } + ngOnDestroy() { + this._intlChanges.unsubscribe(); + } + /** Handles date selection in the month view. */ _dateSelected(date: D): void { if (!this._dateAdapter.sameDate(date, this.selected)) { diff --git a/src/lib/datepicker/datepicker-intl.ts b/src/lib/datepicker/datepicker-intl.ts index 7b7f12ede359..a7e4634878bb 100644 --- a/src/lib/datepicker/datepicker-intl.ts +++ b/src/lib/datepicker/datepicker-intl.ts @@ -6,12 +6,18 @@ * found in the LICENSE file at https://angular.io/license */ -import {Injectable} from '@angular/core'; +import {Injectable, EventEmitter} from '@angular/core'; /** Datepicker data that requires internationalization. */ @Injectable() export class MdDatepickerIntl { + /** + * Stream that emits whenever the labels here are changed. Use this to notify + * components if the labels have changed after initialization. + */ + changes: EventEmitter = new EventEmitter(); + /** A label for the calendar popup (used by screen readers). */ calendarLabel = 'Calendar'; diff --git a/src/lib/datepicker/datepicker-toggle.ts b/src/lib/datepicker/datepicker-toggle.ts index cc30fb8efc97..fce2affa9070 100644 --- a/src/lib/datepicker/datepicker-toggle.ts +++ b/src/lib/datepicker/datepicker-toggle.ts @@ -6,10 +6,18 @@ * found in the LICENSE file at https://angular.io/license */ -import {ChangeDetectionStrategy, Component, Input, ViewEncapsulation} from '@angular/core'; +import { + ChangeDetectionStrategy, + Component, + Input, + ViewEncapsulation, + OnDestroy, + ChangeDetectorRef, +} from '@angular/core'; import {MdDatepicker} from './datepicker'; import {MdDatepickerIntl} from './datepicker-intl'; import {coerceBooleanProperty} from '@angular/cdk/coercion'; +import {Subscription} from 'rxjs/Subscription'; @Component({ @@ -27,7 +35,9 @@ import {coerceBooleanProperty} from '@angular/cdk/coercion'; encapsulation: ViewEncapsulation.None, changeDetection: ChangeDetectionStrategy.OnPush, }) -export class MdDatepickerToggle { +export class MdDatepickerToggle implements OnDestroy { + private _intlChanges: Subscription; + /** Datepicker instance that the button will toggle. */ @Input('mdDatepickerToggle') datepicker: MdDatepicker; @@ -45,7 +55,13 @@ export class MdDatepickerToggle { } private _disabled: boolean; - constructor(public _intl: MdDatepickerIntl) {} + constructor(public _intl: MdDatepickerIntl, changeDetectorRef: ChangeDetectorRef) { + this._intlChanges = _intl.changes.subscribe(() => changeDetectorRef.markForCheck()); + } + + ngOnDestroy() { + this._intlChanges.unsubscribe(); + } _open(event: Event): void { if (this.datepicker && !this.disabled) { diff --git a/src/lib/datepicker/datepicker.spec.ts b/src/lib/datepicker/datepicker.spec.ts index a2e9c90e6aed..091296119fdf 100644 --- a/src/lib/datepicker/datepicker.spec.ts +++ b/src/lib/datepicker/datepicker.spec.ts @@ -3,7 +3,7 @@ import {async, ComponentFixture, TestBed, inject} from '@angular/core/testing'; import {NoopAnimationsModule} from '@angular/platform-browser/animations'; import {FormControl, FormsModule, ReactiveFormsModule} from '@angular/forms'; import {By} from '@angular/platform-browser'; -import {MdDatepickerModule} from './index'; +import {MdDatepickerModule, MdDatepickerIntl} from './index'; import {MdDatepicker} from './datepicker'; import {MdDatepickerInput} from './datepicker-input'; import {MdInputModule} from '../input/index'; @@ -532,6 +532,17 @@ describe('MdDatepicker', () => { expect(document.activeElement).toBe(toggle, 'Expected focus to be restored to toggle.'); }); + + it('should re-render when the i18n labels change', + inject([MdDatepickerIntl], (intl: MdDatepickerIntl) => { + const toggle = fixture.debugElement.query(By.css('button')).nativeElement; + + intl.openCalendarLabel = 'Open the calendar, perhaps?'; + intl.changes.emit(); + fixture.detectChanges(); + + expect(toggle.getAttribute('aria-label')).toBe('Open the calendar, perhaps?'); + })); }); describe('datepicker inside input-container', () => { diff --git a/src/lib/paginator/paginator-intl.ts b/src/lib/paginator/paginator-intl.ts index 02e188f3d6d5..d908f61467d9 100644 --- a/src/lib/paginator/paginator-intl.ts +++ b/src/lib/paginator/paginator-intl.ts @@ -6,7 +6,7 @@ * found in the LICENSE file at https://angular.io/license */ -import {Injectable} from '@angular/core'; +import {Injectable, EventEmitter} from '@angular/core'; /** * To modify the labels and text displayed, create a new instance of MdPaginatorIntl and @@ -14,6 +14,12 @@ import {Injectable} from '@angular/core'; */ @Injectable() export class MdPaginatorIntl { + /** + * Stream that emits whenever the labels here are changed. Use this to notify + * components if the labels have changed after initialization. + */ + changes: EventEmitter = new EventEmitter(); + /** A label for the page size selector. */ itemsPerPageLabel = 'Items per page:'; diff --git a/src/lib/paginator/paginator.spec.ts b/src/lib/paginator/paginator.spec.ts index eb4c693922e4..939176dd5446 100644 --- a/src/lib/paginator/paginator.spec.ts +++ b/src/lib/paginator/paginator.spec.ts @@ -1,4 +1,4 @@ -import {async, ComponentFixture, TestBed} from '@angular/core/testing'; +import {async, ComponentFixture, TestBed, inject} from '@angular/core/testing'; import {MdPaginatorModule} from './index'; import {MdPaginator, PageEvent} from './paginator'; import {Component, ElementRef, ViewChild} from '@angular/core'; @@ -90,6 +90,17 @@ describe('MdPaginator', () => { expect(getPreviousButton(fixture).getAttribute('aria-label')).toBe('Previous page'); expect(getNextButton(fixture).getAttribute('aria-label')).toBe('Next page'); }); + + it('should re-render when the i18n labels change', + inject([MdPaginatorIntl], (intl: MdPaginatorIntl) => { + const label = fixture.nativeElement.querySelector('.mat-paginator-page-size-label'); + + intl.itemsPerPageLabel = '1337 items per page'; + intl.changes.emit(); + fixture.detectChanges(); + + expect(label.textContent).toBe('1337 items per page'); + })); }); describe('when navigating with the navigation buttons', () => { diff --git a/src/lib/paginator/paginator.ts b/src/lib/paginator/paginator.ts index f97fbc55eb0d..10996698cfe5 100644 --- a/src/lib/paginator/paginator.ts +++ b/src/lib/paginator/paginator.ts @@ -7,16 +7,19 @@ */ import { - ChangeDetectionStrategy, ChangeDetectorRef, + ChangeDetectionStrategy, + ChangeDetectorRef, Component, EventEmitter, Input, OnInit, Output, - ViewEncapsulation + ViewEncapsulation, + OnDestroy, } from '@angular/core'; import {MdPaginatorIntl} from './paginator-intl'; import {MATERIAL_COMPATIBILITY_MODE} from '../core'; +import {Subscription} from 'rxjs/Subscription'; /** The default page size if there is no page size and there are no provided page size options. */ const DEFAULT_PAGE_SIZE = 50; @@ -55,8 +58,9 @@ export class PageEvent { changeDetection: ChangeDetectionStrategy.OnPush, encapsulation: ViewEncapsulation.None, }) -export class MdPaginator implements OnInit { +export class MdPaginator implements OnInit, OnDestroy { private _initialized: boolean; + private _intlChanges: Subscription; /** The zero-based page index of the displayed list of items. Defaulted to 0. */ @Input() @@ -101,13 +105,19 @@ export class MdPaginator implements OnInit { _displayedPageSizeOptions: number[]; constructor(public _intl: MdPaginatorIntl, - private _changeDetectorRef: ChangeDetectorRef) { } + private _changeDetectorRef: ChangeDetectorRef) { + this._intlChanges = _intl.changes.subscribe(() => this._changeDetectorRef.markForCheck()); + } ngOnInit() { this._initialized = true; this._updateDisplayedPageSizeOptions(); } + ngOnDestroy() { + this._intlChanges.unsubscribe(); + } + /** Advances to the next page if it exists. */ nextPage() { if (!this.hasNextPage()) { return; } diff --git a/src/lib/sort/sort-header-intl.ts b/src/lib/sort/sort-header-intl.ts index a15734ecb94b..ee9727c382e3 100644 --- a/src/lib/sort/sort-header-intl.ts +++ b/src/lib/sort/sort-header-intl.ts @@ -6,7 +6,7 @@ * found in the LICENSE file at https://angular.io/license */ -import {Injectable} from '@angular/core'; +import {Injectable, EventEmitter} from '@angular/core'; import {SortDirection} from './sort-direction'; /** @@ -15,6 +15,13 @@ import {SortDirection} from './sort-direction'; */ @Injectable() export class MdSortHeaderIntl { + /** + * Stream that emits whenever the labels here are changed. Use this to notify + * components if the labels have changed after initialization. + */ + changes: EventEmitter = new EventEmitter(); + + /** ARIA label for the sorting button. */ sortButtonLabel = (id: string) => { return `Change sorting for ${id}`; } diff --git a/src/lib/sort/sort-header.ts b/src/lib/sort/sort-header.ts index 583da859dd5a..db59feacf20c 100644 --- a/src/lib/sort/sort-header.ts +++ b/src/lib/sort/sort-header.ts @@ -20,6 +20,7 @@ import {CdkColumnDef} from '@angular/cdk/table'; import {coerceBooleanProperty} from '../core'; import {getMdSortHeaderNotContainedWithinMdSortError} from './sort-errors'; import {Subscription} from 'rxjs/Subscription'; +import {merge} from 'rxjs/observable/merge'; /** * Applies sorting behavior (click to change sort) and styles to an element, including an @@ -43,8 +44,7 @@ import {Subscription} from 'rxjs/Subscription'; changeDetection: ChangeDetectionStrategy.OnPush, }) export class MdSortHeader implements MdSortable { - /** @docs-private */ - sortSubscription: Subscription; + private _rerenderSubscription: Subscription; /** * ID of this sort header. If used within the context of a CdkColumnDef, this will default to @@ -69,14 +69,16 @@ export class MdSortHeader implements MdSortable { set _id(v: string) { this.id = v; } constructor(public _intl: MdSortHeaderIntl, - private _changeDetectorRef: ChangeDetectorRef, + changeDetectorRef: ChangeDetectorRef, @Optional() public _sort: MdSort, @Optional() public _cdkColumnDef: CdkColumnDef) { if (!_sort) { throw getMdSortHeaderNotContainedWithinMdSortError(); } - this.sortSubscription = _sort.mdSortChange.subscribe(() => _changeDetectorRef.markForCheck()); + this._rerenderSubscription = merge(_sort.mdSortChange, _intl.changes).subscribe(() => { + changeDetectorRef.markForCheck(); + }); } ngOnInit() { @@ -89,7 +91,7 @@ export class MdSortHeader implements MdSortable { ngOnDestroy() { this._sort.deregister(this); - this.sortSubscription.unsubscribe(); + this._rerenderSubscription.unsubscribe(); } /** Whether this MdSortHeader is currently sorted in either ascending or descending order. */ diff --git a/src/lib/sort/sort.spec.ts b/src/lib/sort/sort.spec.ts index eeac41c62fff..8f4d54c6327c 100644 --- a/src/lib/sort/sort.spec.ts +++ b/src/lib/sort/sort.spec.ts @@ -1,15 +1,15 @@ -import {async, ComponentFixture, TestBed} from '@angular/core/testing'; +import {async, ComponentFixture, TestBed, inject} from '@angular/core/testing'; import {Component, ElementRef, ViewChild} from '@angular/core'; -import {MdSort, MdSortHeader, Sort, SortDirection, MdSortModule} from './index'; +import {By} from '@angular/platform-browser'; +import {MdSort, MdSortHeader, Sort, SortDirection, MdSortModule, MdSortHeaderIntl} from './index'; import {CdkTableModule, DataSource, CollectionViewer} from '@angular/cdk/table'; import {Observable} from 'rxjs/Observable'; -import {dispatchMouseEvent} from '@angular/cdk/testing'; import { getMdSortDuplicateMdSortableIdError, getMdSortHeaderMissingIdError, getMdSortHeaderNotContainedWithinMdSortError } from './sort-errors'; -import {wrappedErrorMessage} from '@angular/cdk/testing'; +import {wrappedErrorMessage, dispatchMouseEvent} from '@angular/cdk/testing'; import {map} from '../core/rxjs/index'; import {MdTableModule} from '../table/index'; @@ -141,6 +141,18 @@ describe('MdSort', () => { const button = fixture.nativeElement.querySelector('#defaultSortHeaderA button'); expect(button.getAttribute('aria-label')).toBe('Change sorting for defaultSortHeaderA'); }); + + it('should re-render when the i18n labels have changed', + inject([MdSortHeaderIntl], (intl: MdSortHeaderIntl) => { + const header = fixture.debugElement.query(By.directive(MdSortHeader)).nativeElement; + const button = header.querySelector('.mat-sort-header-button'); + + intl.sortButtonLabel = () => 'Sort all of the things'; + intl.changes.emit(); + fixture.detectChanges(); + + expect(button.getAttribute('aria-label')).toBe('Sort all of the things'); + })); }); /**