Skip to content

Commit

Permalink
feat(modal): add animations
Browse files Browse the repository at this point in the history
  • Loading branch information
maxokorokov committed Jun 3, 2020
1 parent 30da3f1 commit 86cbf06
Show file tree
Hide file tree
Showing 9 changed files with 309 additions and 32 deletions.
11 changes: 11 additions & 0 deletions src/modal/modal-backdrop.spec.ts
Expand Up @@ -11,5 +11,16 @@ describe('ngb-modal-backdrop', () => {

fixture.detectChanges();
expect(fixture.nativeElement).toHaveCssClass('modal-backdrop');
expect(fixture.nativeElement).toHaveCssClass('show');
expect(fixture.nativeElement).not.toHaveCssClass('fade');
});

it('should render correct CSS classes for animations', () => {
const fixture = TestBed.createComponent(NgbModalBackdrop);
fixture.componentInstance.animation = true;

fixture.detectChanges();
expect(fixture.nativeElement).toHaveCssClass('show');
expect(fixture.nativeElement).toHaveCssClass('fade');
});
});
34 changes: 30 additions & 4 deletions src/modal/modal-backdrop.ts
@@ -1,12 +1,38 @@
import {Component, Input, ViewEncapsulation} from '@angular/core';
import {Component, ElementRef, Input, NgZone, OnInit, ViewEncapsulation} from '@angular/core';

import {Observable} from 'rxjs';
import {take} from 'rxjs/operators';

import {ngbRunTransition} from '../util/transition/ngbTransition';

@Component({
selector: 'ngb-modal-backdrop',
encapsulation: ViewEncapsulation.None,
template: '',
host:
{'[class]': '"modal-backdrop fade show" + (backdropClass ? " " + backdropClass : "")', 'style': 'z-index: 1050'}
host: {
'[class]': '"modal-backdrop" + (backdropClass ? " " + backdropClass : "")',
'[class.show]': '!animation',
'[class.fade]': 'animation',
'style': 'z-index: 1050'
}
})
export class NgbModalBackdrop {
export class NgbModalBackdrop implements OnInit {
@Input() animation: boolean;
@Input() backdropClass: string;

constructor(private _el: ElementRef<HTMLElement>, private _zone: NgZone) {}

ngOnInit() {
this._zone.onStable.asObservable().pipe(take(1)).subscribe(() => {
ngbRunTransition(
this._el.nativeElement, ({classList}) => classList.add('show'),
{animation: this.animation, runningTransition: 'continue'});
});
}

hide(): Observable<void> {
return ngbRunTransition(
this._el.nativeElement, ({classList}) => classList.remove('show'),
{animation: this.animation, runningTransition: 'stop'});
}
}
6 changes: 5 additions & 1 deletion src/modal/modal-config.spec.ts
@@ -1,11 +1,15 @@
import {inject} from '@angular/core/testing';

import {NgbModalConfig} from './modal-config';
import {NgbConfig} from '../ngb-config';

describe('NgbModalConfig', () => {

it('should have sensible default values', inject([NgbModalConfig], (config: NgbModalConfig) => {
it('should have sensible default values',
inject([NgbModalConfig, NgbConfig], (config: NgbModalConfig, ngbConfig: NgbConfig) => {

expect(config.animation).toBe(ngbConfig.animation);
expect(config.ariaLabelledBy).toBeUndefined();
expect(config.ariaLabelledBy).toBeUndefined();
expect(config.ariaDescribedBy).toBeUndefined();
expect(config.backdrop).toBe(true);
Expand Down
10 changes: 9 additions & 1 deletion src/modal/modal-config.ts
@@ -1,9 +1,15 @@
import {Injectable, Injector} from '@angular/core';
import {NgbConfig} from '../ngb-config';

/**
* Options available when opening new modal windows with `NgbModal.open()` method.
*/
export interface NgbModalOptions {
/**
* If `true`, modal opening and closing will be animated.
*/
animation?: boolean;

/**
* `aria-labelledby` attribute value to set on the modal window.
*
Expand All @@ -18,7 +24,6 @@ export interface NgbModalOptions {
*/
ariaDescribedBy?: string;


/**
* If `true`, the backdrop element will be created for a given modal.
*
Expand Down Expand Up @@ -104,6 +109,7 @@ export interface NgbModalOptions {
*/
@Injectable({providedIn: 'root'})
export class NgbModalConfig implements Required<NgbModalOptions> {
animation: boolean;
ariaLabelledBy: string;
ariaDescribedBy: string;
backdrop: boolean | 'static' = true;
Expand All @@ -116,4 +122,6 @@ export class NgbModalConfig implements Required<NgbModalOptions> {
size: 'sm' | 'lg' | 'xl' | string;
windowClass: string;
backdropClass: string;

constructor(ngbConfig: NgbConfig) { this.animation = ngbConfig.animation; }
}
85 changes: 70 additions & 15 deletions src/modal/modal-ref.ts
@@ -1,5 +1,8 @@
import {ComponentRef} from '@angular/core';

import {Observable, of, Subject, zip} from 'rxjs';
import {takeUntil} from 'rxjs/operators';

import {NgbModalBackdrop} from './modal-backdrop';
import {NgbModalWindow} from './modal-window';

Expand Down Expand Up @@ -31,6 +34,9 @@ export class NgbActiveModal {
* A reference to the newly opened modal returned by the `NgbModal.open()` method.
*/
export class NgbModalRef {
private _closed = new Subject<any>();
private _dismissed = new Subject<any>();
private _hidden = new Subject<void>();
private _resolve: (result?: any) => void;
private _reject: (reason?: any) => void;

Expand All @@ -50,6 +56,38 @@ export class NgbModalRef {
*/
result: Promise<any>;

/**
* The observable that emits when the modal is closed via the `.close()` method.
*
* It will emit the result passed to the `.close()` method.
*/
get closed(): Observable<any> { return this._closed.asObservable().pipe(takeUntil(this._hidden)); }

/**
* The observable that emits when the modal is dismissed via the `.dismiss()` method.
*
* It will emit the reason passed to the `.dismissed()` method by the user, or one of the internal
* reasons like backdrop click or ESC key press.
*/
get dismissed(): Observable<any> { return this._dismissed.asObservable().pipe(takeUntil(this._hidden)); }

/**
* The observable that emits when both modal window and backdrop are closed and animations were finished.
* At this point modal and backdrop elements will be removed from the DOM tree.
*
* This observable will be completed after emitting.
*/
get hidden(): Observable<void> { return this._hidden.asObservable(); }

/**
* The observable that emits when modal is fully visible and animation was finished.
* Modal DOM element is always available synchronously after calling 'modal.open()' service.
*
* This observable will be completed after emitting.
* It will not emit, if modal is closed before open animation is finished.
*/
get shown() { return this._windowCmptRef.instance.shown.asObservable(); }

constructor(
private _windowCmptRef: ComponentRef<NgbModalWindow>, private _contentRef: ContentRef,
private _backdropCmptRef?: ComponentRef<NgbModalBackdrop>, private _beforeDismiss?: Function) {
Expand All @@ -69,12 +107,14 @@ export class NgbModalRef {
*/
close(result?: any): void {
if (this._windowCmptRef) {
this._closed.next(result);
this._resolve(result);
this._removeModalElements();
}
}

private _dismiss(reason?: any) {
this._dismissed.next(reason);
this._reject(reason);
this._removeModalElements();
}
Expand Down Expand Up @@ -106,22 +146,37 @@ export class NgbModalRef {
}

private _removeModalElements() {
const windowNativeEl = this._windowCmptRef.location.nativeElement;
windowNativeEl.parentNode.removeChild(windowNativeEl);
this._windowCmptRef.destroy();

if (this._backdropCmptRef) {
const backdropNativeEl = this._backdropCmptRef.location.nativeElement;
backdropNativeEl.parentNode.removeChild(backdropNativeEl);
this._backdropCmptRef.destroy();
}
const windowTransition$ = this._windowCmptRef.instance.hide();
const backdropTransition$ = this._backdropCmptRef ? this._backdropCmptRef.instance.hide() : of(undefined);

if (this._contentRef && this._contentRef.viewRef) {
this._contentRef.viewRef.destroy();
}
// hiding window
windowTransition$.subscribe(() => {
const {nativeElement} = this._windowCmptRef.location;
nativeElement.parentNode.removeChild(nativeElement);
this._windowCmptRef.destroy();

if (this._contentRef && this._contentRef.viewRef) {
this._contentRef.viewRef.destroy();
}

this._windowCmptRef = <any>null;
this._backdropCmptRef = <any>null;
this._contentRef = <any>null;
this._windowCmptRef = <any>null;
this._contentRef = <any>null;
});

// hiding backdrop
backdropTransition$.subscribe(() => {
if (this._backdropCmptRef) {
const {nativeElement} = this._backdropCmptRef.location;
nativeElement.parentNode.removeChild(nativeElement);
this._backdropCmptRef.destroy();
this._backdropCmptRef = <any>null;
}
});

// all done
zip(windowTransition$, backdropTransition$).subscribe(() => {
this._hidden.next();
this._hidden.complete();
});
}
}
8 changes: 5 additions & 3 deletions src/modal/modal-stack.ts
Expand Up @@ -25,10 +25,12 @@ import {NgbModalWindow} from './modal-window';
export class NgbModalStack {
private _activeWindowCmptHasChanged = new Subject();
private _ariaHiddenValues: Map<Element, string | null> = new Map();
private _backdropAttributes = ['backdropClass'];
private _backdropAttributes = ['animation', 'backdropClass'];
private _modalRefs: NgbModalRef[] = [];
private _windowAttributes =
['ariaLabelledBy', 'ariaDescribedBy', 'backdrop', 'centered', 'keyboard', 'scrollable', 'size', 'windowClass'];
private _windowAttributes = [
'animation', 'ariaLabelledBy', 'ariaDescribedBy', 'backdrop', 'centered', 'keyboard', 'scrollable', 'size',
'windowClass'
];
private _windowCmpts: ComponentRef<NgbModalWindow>[] = [];

constructor(
Expand Down
62 changes: 55 additions & 7 deletions src/modal/modal-window.ts
Expand Up @@ -13,17 +13,20 @@ import {
ViewChild,
ViewEncapsulation
} from '@angular/core';
import {fromEvent, Subject} from 'rxjs';

import {fromEvent, Observable, Subject, zip} from 'rxjs';
import {filter, switchMap, take, takeUntil, tap} from 'rxjs/operators';

import {getFocusableBoundaryElements} from '../util/focus-trap';
import {Key} from '../util/key';
import {ModalDismissReasons} from './modal-dismiss-reasons';
import {ngbRunTransition, NgbTransitionOptions} from '../util/transition/ngbTransition';

@Component({
selector: 'ngb-modal-window',
host: {
'[class]': '"modal fade show d-block" + (windowClass ? " " + windowClass : "")',
'[class]': '"modal d-block" + (windowClass ? " " + windowClass : "")',
'[class.fade]': 'animation',
'role': 'dialog',
'tabindex': '-1',
'[attr.aria-modal]': 'true',
Expand All @@ -46,6 +49,7 @@ export class NgbModalWindow implements OnInit,

@ViewChild('dialog', {static: true}) private _dialogEl: ElementRef<HTMLElement>;

@Input() animation: boolean;
@Input() ariaLabelledBy: string;
@Input() ariaDescribedBy: string;
@Input() backdrop: boolean | string = true;
Expand All @@ -57,17 +61,58 @@ export class NgbModalWindow implements OnInit,

@Output('dismiss') dismissEvent = new EventEmitter();

shown = new Subject<void>();
hidden = new Subject<void>();

constructor(
@Inject(DOCUMENT) private _document: any, private _elRef: ElementRef<HTMLElement>, private _zone: NgZone) {}

dismiss(reason): void { this.dismissEvent.emit(reason); }

ngOnInit() { this._elWithFocus = this._document.activeElement; }

ngAfterViewInit() {
ngAfterViewInit() { this._show(); }

ngOnDestroy() { this._disableEventHandling(); }

hide(): Observable<any> {
const {nativeElement} = this._elRef;
this._zone.runOutsideAngular(() => {
const context: NgbTransitionOptions<any> = {animation: this.animation, runningTransition: 'stop'};

const windowTransition$ = ngbRunTransition(nativeElement, () => nativeElement.classList.remove('show'), context);
const dialogTransition$ = ngbRunTransition(this._dialogEl.nativeElement, () => {}, context);

const transitions$ = zip(windowTransition$, dialogTransition$);
transitions$.subscribe(() => {
this.hidden.next();
this.hidden.complete();
});

this._disableEventHandling();
this._restoreFocus();

return transitions$;
}

private _show() {
const {nativeElement} = this._elRef;
const context: NgbTransitionOptions<any> = {animation: this.animation, runningTransition: 'continue'};

const windowTransition$ = ngbRunTransition(nativeElement, () => nativeElement.classList.add('show'), context);
const dialogTransition$ = ngbRunTransition(this._dialogEl.nativeElement, () => {}, context);

zip(windowTransition$, dialogTransition$).subscribe(() => {
this.shown.next();
this.shown.complete();
});

this._enableEventHandling();
this._setFocus();
}

private _enableEventHandling() {
const {nativeElement} = this._elRef;
this._zone.runOutsideAngular(() => {
fromEvent<KeyboardEvent>(nativeElement, 'keydown')
.pipe(
takeUntil(this._closed$),
Expand Down Expand Up @@ -100,7 +145,12 @@ export class NgbModalWindow implements OnInit,
preventClose = false;
});
});
}

private _disableEventHandling() { this._closed$.next(); }

private _setFocus() {
const {nativeElement} = this._elRef;
if (!nativeElement.contains(document.activeElement)) {
const autoFocusable = nativeElement.querySelector(`[ngbAutofocus]`) as HTMLElement;
const firstFocusable = getFocusableBoundaryElements(nativeElement)[0];
Expand All @@ -110,7 +160,7 @@ export class NgbModalWindow implements OnInit,
}
}

ngOnDestroy() {
private _restoreFocus() {
const body = this._document.body;
const elWithFocus = this._elWithFocus;

Expand All @@ -124,7 +174,5 @@ export class NgbModalWindow implements OnInit,
setTimeout(() => elementToFocus.focus());
this._elWithFocus = null;
});

this._closed$.next();
}
}

0 comments on commit 86cbf06

Please sign in to comment.