Skip to content

Commit

Permalink
feat(toast): add animations
Browse files Browse the repository at this point in the history
BREAKING CHANGE:

Toast events API has changed to stick with specs provided by Bootstrap

Before:

```
<ngb-toast (hide)="..."></ngb-toast>
```

After:

```
// template
ngb-toast (hidden)="..."></ngb-toast>
```

The `hidden` event is emitted after the 'fade out' animation is
  • Loading branch information
fbasso authored and maxokorokov committed Jun 3, 2020
1 parent 7b8569c commit f8df46c
Show file tree
Hide file tree
Showing 6 changed files with 183 additions and 28 deletions.
3 changes: 2 additions & 1 deletion src/toast/toast-config.spec.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import {NgbToastConfig} from './toast-config';
import {NgbConfig} from '../ngb-config';

describe('NgbToastConfig', () => {
it('should have sensible default values', () => {
const config = new NgbToastConfig();
const config = new NgbToastConfig(new NgbConfig());

expect(config.delay).toBe(500);
expect(config.autohide).toBe(true);
Expand Down
4 changes: 4 additions & 0 deletions src/toast/toast-config.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {Injectable} from '@angular/core';
import {NgbConfig} from '../ngb-config';

/**
* Interface used to type all toast config options. See `NgbToastConfig`.
Expand Down Expand Up @@ -36,7 +37,10 @@ export interface NgbToastOptions {
*/
@Injectable({providedIn: 'root'})
export class NgbToastConfig implements NgbToastOptions {
animation: boolean;
autohide = true;
delay = 500;
ariaLive: 'polite' | 'alert' = 'polite';

constructor(ngbConfig: NgbConfig) { this.animation = ngbConfig.animation; }
}
2 changes: 2 additions & 0 deletions src/toast/toast.scss
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
}

ngb-toast {
display: block;

.toast-header .close {
margin-left: auto;
margin-bottom: 0.25rem;
Expand Down
120 changes: 99 additions & 21 deletions src/toast/toast.spec.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,21 @@
import {Component} from '@angular/core';
import {ComponentFixture, fakeAsync, TestBed, tick} from '@angular/core/testing';
import {async, ComponentFixture, fakeAsync, TestBed, tick} from '@angular/core/testing';

import {createGenericTestComponent} from '../test/common';
import {createGenericTestComponent, isBrowserVisible} from '../test/common';
import {NgbToastModule} from './toast.module';

import {NgbConfig} from '../ngb-config';
import {NgbConfigAnimation} from '../test/ngb-config-animation';

const createTestComponent = (html: string) =>
createGenericTestComponent(html, TestComponent) as ComponentFixture<TestComponent>;

const getElementWithSelector = (fixture: ComponentFixture<TestComponent>, className) =>
fixture.nativeElement.querySelector(className);
const getElementWithSelector = (element: HTMLElement, className) => element.querySelector(className);

const getToastElement = (fixture: ComponentFixture<TestComponent>): Element =>
getElementWithSelector(fixture, 'ngb-toast');
const getToastHeaderElement = (fixture: ComponentFixture<TestComponent>): Element =>
getElementWithSelector(fixture, 'ngb-toast .toast-header');
const getToastBodyElement = (fixture: ComponentFixture<TestComponent>): Element =>
getElementWithSelector(fixture, 'ngb-toast .toast-body');
const getToastElement = (element: HTMLElement): Element => getElementWithSelector(element, 'ngb-toast');
const getToastHeaderElement = (element: HTMLElement): Element =>
getElementWithSelector(element, 'ngb-toast .toast-header');
const getToastBodyElement = (element: HTMLElement): Element => getElementWithSelector(element, 'ngb-toast .toast-body');

describe('ngb-toast', () => {

Expand All @@ -30,26 +30,27 @@ describe('ngb-toast', () => {
it('should have default classnames', () => {
const fixture = createTestComponent(`<ngb-toast header="header">body</ngb-toast>`);
// Below getter are using Bootstrap classnames
const toast = getToastElement(fixture);
const header = getToastHeaderElement(fixture);
const body = getToastBodyElement(fixture);
const toast = getToastElement(fixture.nativeElement);
const header = getToastHeaderElement(fixture.nativeElement);
const body = getToastBodyElement(fixture.nativeElement);

expect(toast).toBeDefined();
expect(toast).toHaveCssClass('toast');
expect(toast).not.toHaveCssClass('fade');
expect(toast).toHaveCssClass('show');
expect(header).toBeDefined();
expect(body).toBeDefined();
});

it('should not generate a header element when header input is not specified', () => {
const fixture = createTestComponent(`<ngb-toast>body</ngb-toast>`);
const toastHeader = getToastHeaderElement(fixture);
const toastHeader = getToastHeaderElement(fixture.nativeElement);
expect(toastHeader).toBeNull();
});

it('should contain a close button when header is specified', () => {
const fixture = createTestComponent(`<ngb-toast header="header">body</ngb-toast>`);
const toastHeader = getToastHeaderElement(fixture);
const toastHeader = getToastHeaderElement(fixture.nativeElement);
expect(toastHeader.querySelector('button.close')).toBeDefined();
});

Expand All @@ -58,23 +59,23 @@ describe('ngb-toast', () => {
<ng-template ngbToastHeader>{{header}}</ng-template>
body
</ngb-toast>`);
const toastHeader = getToastHeaderElement(fixture);
const toastHeader = getToastHeaderElement(fixture.nativeElement);
expect(toastHeader.querySelector('button.close')).toBeDefined();
});

it('should emit hide output when close is clicked', () => {
const fixture =
createTestComponent(`<ngb-toast header="header" [autohide]="false" (hide)="hide()">body</ngb-toast>`);
createTestComponent(`<ngb-toast header="header" [autohide]="false" (hidden)="hide()">body</ngb-toast>`);

const toast = getToastElement(fixture);
const toast = getToastElement(fixture.nativeElement);
const closeButton = toast.querySelector('button.close') as HTMLElement;
closeButton.click();
fixture.detectChanges();
expect(fixture.componentInstance.hide).toHaveBeenCalled();
});

it('should emit hide output after default delay (500ms)', fakeAsync(() => {
const fixture = createTestComponent(`<ngb-toast header="header" (hide)="hide()">body</ngb-toast>`);
const fixture = createTestComponent(`<ngb-toast header="header" (hidden)="hide()">body</ngb-toast>`);
tick(499);
fixture.detectChanges();
expect(fixture.componentInstance.hide).not.toHaveBeenCalled();
Expand All @@ -85,7 +86,7 @@ describe('ngb-toast', () => {

it('should emit hide output after a custom delay in ms', fakeAsync(() => {
const fixture =
createTestComponent(`<ngb-toast header="header" [delay]="10000" (hide)="hide()">body</ngb-toast>`);
createTestComponent(`<ngb-toast header="header" [delay]="10000" (hidden)="hide()">body</ngb-toast>`);
tick(9999);
fixture.detectChanges();
expect(fixture.componentInstance.hide).not.toHaveBeenCalled();
Expand All @@ -96,7 +97,7 @@ describe('ngb-toast', () => {

it('should emit hide only one time regardless of autohide toggling', fakeAsync(() => {
const fixture =
createTestComponent(`<ngb-toast header="header" [autohide]="autohide" (hide)="hide()">body</ngb-toast>`);
createTestComponent(`<ngb-toast header="header" [autohide]="autohide" (hidden)="hide()">body</ngb-toast>`);
tick(250);
fixture.componentInstance.autohide = false;
fixture.detectChanges();
Expand All @@ -112,6 +113,83 @@ describe('ngb-toast', () => {
});
});

if (isBrowserVisible('ngb-toast animations')) {
describe('ngb-toast animations', () => {

@Component({
template: `
<ngb-toast header="Hello" [autohide]="false" (shown)="onShown()" (hidden)="onHidden()">Cool!</ngb-toast>`,
host: {'[class.ngb-reduce-motion]': 'reduceMotion'}
})
class TestAnimationComponent {
reduceMotion = true;
onShown = () => {};
onHidden = () => {};
}

beforeEach(() => {
TestBed.configureTestingModule({
declarations: [TestAnimationComponent],
imports: [NgbToastModule],
providers: [{provide: NgbConfig, useClass: NgbConfigAnimation}]
});
});

[true, false].forEach(reduceMotion => {

it(`should run the transition when creating a toast (force-reduced-motion = ${reduceMotion})`, async(() => {
const fixture = TestBed.createComponent(TestAnimationComponent);
fixture.componentInstance.reduceMotion = reduceMotion;
fixture.detectChanges();

const toastEl = getToastElement(fixture.nativeElement);

spyOn(fixture.componentInstance, 'onShown').and.callFake(() => {
expect(window.getComputedStyle(toastEl).opacity).toBe('1');
expect(toastEl).not.toHaveCssClass('showing');
expect(toastEl).toHaveCssClass('show');
expect(toastEl).toHaveCssClass('fade');
});

expect(toastEl).toHaveCssClass('fade');
if (reduceMotion) {
expect(window.getComputedStyle(toastEl).opacity).toBe('1');
expect(toastEl).toHaveCssClass('show');
} else {
expect(window.getComputedStyle(toastEl).opacity).toBe('0');
expect(toastEl).not.toHaveCssClass('show');
expect(toastEl).toHaveCssClass('showing');
}
}));

it(`should run the transition when closing a toast (force-reduced-motion = ${reduceMotion})`, async(() => {
const fixture = TestBed.createComponent(TestAnimationComponent);
fixture.componentInstance.reduceMotion = reduceMotion;
fixture.detectChanges();

const toastEl = getToastElement(fixture.nativeElement);
const buttonEl = fixture.nativeElement.querySelector('button');

spyOn(fixture.componentInstance, 'onShown').and.callFake(() => {
expect(window.getComputedStyle(toastEl).opacity).toBe('1');
expect(toastEl).toHaveCssClass('show');
expect(toastEl).toHaveCssClass('fade');

buttonEl.click();
});

spyOn(fixture.componentInstance, 'onHidden').and.callFake(() => {
expect(window.getComputedStyle(toastEl).opacity).toBe('0');
expect(toastEl).not.toHaveCssClass('show');
expect(toastEl).toHaveCssClass('fade');
expect(toastEl).toHaveCssClass('hide');
});
}));
});
});
}



@Component({selector: 'test-cmp', template: ''})
export class TestComponent {
Expand Down
66 changes: 60 additions & 6 deletions src/toast/toast.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,15 @@ import {
SimpleChanges,
TemplateRef,
ViewEncapsulation,
ElementRef,
NgZone,
} from '@angular/core';

import {NgbToastConfig} from './toast-config';
import {ngbRunTransition} from '../util/transition/ngbTransition';
import {ngbToastFadeInTransition, ngbToastFadeOutTransition} from '../util/transition/ngbFadingTransition';
import {take} from 'rxjs/operators';


/**
* This directive allows the usage of HTML markup or other directives
Expand All @@ -39,8 +45,8 @@ export class NgbToastHeader {
'role': 'alert',
'[attr.aria-live]': 'ariaLive',
'aria-atomic': 'true',
'[class.toast]': 'true',
'[class.show]': 'true',
'class': 'toast',
'[class.fade]': 'animation',
},
template: `
<ng-template #headerTpl>
Expand All @@ -62,6 +68,13 @@ export class NgbToastHeader {
})
export class NgbToast implements AfterContentInit,
OnChanges {
/**
* If `true`, toast opening and closing will be animated.
*
* Animation is triggered only when the `.hide()` or `.hide()` functions are called
*/
@Input() animation: boolean;

private _timeoutID;

/**
Expand All @@ -88,6 +101,11 @@ export class NgbToast implements AfterContentInit,
*/
@ContentChild(NgbToastHeader, {read: TemplateRef, static: true}) contentHeaderTpl: TemplateRef<any>| null = null;

/**
* An event fired when the toast fade in animation
*/
@Output() shown = new EventEmitter<void>();

/**
* An event fired immediately when toast's `hide()` method has been called.
* It can only occur in 2 different scenarios:
Expand All @@ -97,17 +115,25 @@ export class NgbToast implements AfterContentInit,
* Additionally this output is purely informative. The toast won't disappear. It's up to the user to take care of
* that.
*/
@Output('hide') hideOutput = new EventEmitter<void>();
@Output() hidden = new EventEmitter<void>();

constructor(@Attribute('aria-live') public ariaLive: string, config: NgbToastConfig) {
constructor(
@Attribute('aria-live') public ariaLive: string, config: NgbToastConfig, private _zone: NgZone,
private _element: ElementRef) {
if (this.ariaLive == null) {
this.ariaLive = config.ariaLive;
}
this.delay = config.delay;
this.autohide = config.autohide;
this.animation = config.animation;
}

ngAfterContentInit() { this._init(); }
ngAfterContentInit() {
this._zone.onStable.asObservable().pipe(take(1)).subscribe(() => {
this._init();
this.show();
});
}

ngOnChanges(changes: SimpleChanges) {
if ('autohide' in changes) {
Expand All @@ -116,9 +142,37 @@ export class NgbToast implements AfterContentInit,
}
}

/**
* Triggers toast closing programmatically.
*
* The returned observable will emit and be completed once the closing transition has finished.
* If the animations are turned off this happens synchronously.
*
* Alternatively you could listen or subscribe to the `(hidden)` output
*/
hide() {
this._clearTimeout();
this.hideOutput.emit();
const transition = ngbRunTransition(
this._element.nativeElement, ngbToastFadeOutTransition, {animation: this.animation, runningTransition: 'stop'});
transition.subscribe(() => { this.hidden.emit(); });
return transition;
}

/**
* Triggers toast opening programmatically.
*
* The returned observable will emit and be completed once the opening transition has finished.
* If the animations are turned off this happens synchronously.
*
* Alternatively you could listen or subscribe to the `(shown)` output
*/
show() {
const transition = ngbRunTransition(this._element.nativeElement, ngbToastFadeInTransition, {
animation: this.animation,
runningTransition: 'continue',
});
transition.subscribe(() => { this.shown.emit(); });
return transition;
}

private _init() {
Expand Down
16 changes: 16 additions & 0 deletions src/util/transition/ngbFadingTransition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,19 @@ import {NgbTransitionStartFn} from './ngbTransition';
export const ngbAlertFadingTransition: NgbTransitionStartFn = ({classList}: HTMLElement) => {
classList.remove('show');
};

export const ngbToastFadeInTransition: NgbTransitionStartFn = ({classList}: HTMLElement) => {
classList.remove('hide');
classList.add('showing');

return () => {
classList.remove('showing');
classList.add('show');
};
};

export const ngbToastFadeOutTransition: NgbTransitionStartFn = ({classList}: HTMLElement) => {
classList.remove('show');

return () => { classList.add('hide'); };
};

0 comments on commit f8df46c

Please sign in to comment.