Skip to content

Commit

Permalink
feat: add change emitters to the Intl providers (#5867)
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
crisbeto authored and andrewseguin committed Jul 27, 2017
1 parent 8edbe47 commit 0a5489f
Show file tree
Hide file tree
Showing 11 changed files with 132 additions and 25 deletions.
14 changes: 13 additions & 1 deletion 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';
Expand Down Expand Up @@ -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;
Expand Down
20 changes: 17 additions & 3 deletions src/lib/datepicker/calendar.ts
Expand Up @@ -17,7 +17,9 @@ import {
NgZone,
Optional,
Output,
ViewEncapsulation
ViewEncapsulation,
ChangeDetectorRef,
OnDestroy,
} from '@angular/core';
import {
DOWN_ARROW,
Expand All @@ -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';


/**
Expand All @@ -53,7 +56,9 @@ import {first} from '../core/rxjs/index';
encapsulation: ViewEncapsulation.None,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class MdCalendar<D> implements AfterContentInit {
export class MdCalendar<D> implements AfterContentInit, OnDestroy {
private _intlChanges: Subscription;

/** A date representing the period (month or year) to start the calendar in. */
@Input() startAt: D;

Expand Down Expand Up @@ -123,13 +128,18 @@ export class MdCalendar<D> implements AfterContentInit {
private _ngZone: NgZone,
@Optional() @Inject(MATERIAL_COMPATIBILITY_MODE) public _isCompatibilityMode: boolean,
@Optional() private _dateAdapter: DateAdapter<D>,
@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() {
Expand All @@ -138,6 +148,10 @@ export class MdCalendar<D> 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)) {
Expand Down
8 changes: 7 additions & 1 deletion src/lib/datepicker/datepicker-intl.ts
Expand Up @@ -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<void> = new EventEmitter<void>();

/** A label for the calendar popup (used by screen readers). */
calendarLabel = 'Calendar';

Expand Down
22 changes: 19 additions & 3 deletions src/lib/datepicker/datepicker-toggle.ts
Expand Up @@ -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({
Expand All @@ -27,7 +35,9 @@ import {coerceBooleanProperty} from '@angular/cdk/coercion';
encapsulation: ViewEncapsulation.None,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class MdDatepickerToggle<D> {
export class MdDatepickerToggle<D> implements OnDestroy {
private _intlChanges: Subscription;

/** Datepicker instance that the button will toggle. */
@Input('mdDatepickerToggle') datepicker: MdDatepicker<D>;

Expand All @@ -45,7 +55,13 @@ export class MdDatepickerToggle<D> {
}
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) {
Expand Down
13 changes: 12 additions & 1 deletion src/lib/datepicker/datepicker.spec.ts
Expand Up @@ -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';
Expand Down Expand Up @@ -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', () => {
Expand Down
8 changes: 7 additions & 1 deletion src/lib/paginator/paginator-intl.ts
Expand Up @@ -6,14 +6,20 @@
* 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
* include it in a custom provider
*/
@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<void> = new EventEmitter<void>();

/** A label for the page size selector. */
itemsPerPageLabel = 'Items per page:';

Expand Down
13 changes: 12 additions & 1 deletion 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';
Expand Down Expand Up @@ -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', () => {
Expand Down
18 changes: 14 additions & 4 deletions src/lib/paginator/paginator.ts
Expand Up @@ -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;
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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; }
Expand Down
9 changes: 8 additions & 1 deletion src/lib/sort/sort-header-intl.ts
Expand Up @@ -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';

/**
Expand All @@ -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<void> = new EventEmitter<void>();

/** ARIA label for the sorting button. */
sortButtonLabel = (id: string) => {
return `Change sorting for ${id}`;
}
Expand Down
12 changes: 7 additions & 5 deletions src/lib/sort/sort-header.ts
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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() {
Expand All @@ -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. */
Expand Down
20 changes: 16 additions & 4 deletions 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';

Expand Down Expand Up @@ -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');
}));
});

/**
Expand Down

0 comments on commit 0a5489f

Please sign in to comment.