Skip to content

Commit

Permalink
feat(modal): stacked modals
Browse files Browse the repository at this point in the history
* feat(modal): stacked modals

* fix(modal): correctly trap focus for multiple modal windows

Closes #2640
Closes #643
  • Loading branch information
Maxkorz authored and Benoit Charbonnier committed Aug 29, 2018
1 parent dcdb5fc commit 2409572
Show file tree
Hide file tree
Showing 7 changed files with 172 additions and 27 deletions.
@@ -0,0 +1 @@
<button class="btn btn-lg btn-outline-primary" (click)="open()">Launch demo modal</button>
61 changes: 61 additions & 0 deletions demo/src/app/components/modal/demos/stacked/modal-stacked.ts
@@ -0,0 +1,61 @@
import { Component } from '@angular/core';
import { NgbActiveModal, NgbModal } from '@ng-bootstrap/ng-bootstrap';

@Component({
template: `
<div class="modal-header">
<h4 class="modal-title">Hi there!</h4>
<button type="button" class="close" aria-label="Close" (click)="activeModal.dismiss('Cross click')">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body">
<p>Hello, World!</p>
<p><button class="btn btn-lg btn-outline-primary" (click)="open()">Launch demo modal</button></p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-dark" (click)="activeModal.close('Close click')">Close</button>
</div>
`
})
export class NgbdModal1Content {
constructor(private modalService: NgbModal, public activeModal: NgbActiveModal) {}

open() {
this.modalService.open(NgbdModal2Content, {
size: 'lg'
});
}
}

@Component({
template: `
<div class="modal-header">
<h4 class="modal-title">Hi there!</h4>
<button type="button" class="close" aria-label="Close" (click)="activeModal.dismiss('Cross click')">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body">
<p>Hello, World!</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-dark" (click)="activeModal.close('Close click')">Close</button>
</div>
`
})
export class NgbdModal2Content {
constructor(public activeModal: NgbActiveModal) {}
}

@Component({
selector: 'ngbd-modal-stacked',
templateUrl: './modal-stacked.html'
})
export class NgbdModalStacked {
constructor(private modalService: NgbModal) {}

open() {
this.modalService.open(NgbdModal1Content);
}
}
13 changes: 10 additions & 3 deletions demo/src/app/components/modal/modal.module.ts
Expand Up @@ -8,8 +8,9 @@ import { NgbdExamplesPage } from '../shared/examples-page/examples.component';
import { NgbdModalBasic } from './demos/basic/modal-basic';
import { NgbdModalComponent, NgbdModalContent } from './demos/component/modal-component';
import { NgbdModalOptions } from './demos/options/modal-options';
import { NgbdModal1Content, NgbdModal2Content, NgbdModalStacked } from './demos/stacked/modal-stacked';

const DEMO_DIRECTIVES = [NgbdModalBasic, NgbdModalComponent, NgbdModalOptions];
const DEMO_DIRECTIVES = [NgbdModalBasic, NgbdModalComponent, NgbdModalOptions, NgbdModalStacked];

const DEMOS = {
basic: {
Expand All @@ -29,6 +30,12 @@ const DEMOS = {
type: NgbdModalOptions,
code: require('!!raw-loader!./demos/options/modal-options'),
markup: require('!!raw-loader!./demos/options/modal-options.html')
},
stacked: {
title: 'Stacked modals',
type: NgbdModalStacked,
code: require('!!raw-loader!./demos/stacked/modal-stacked'),
markup: require('!!raw-loader!./demos/stacked/modal-stacked.html')
}
};

Expand All @@ -49,8 +56,8 @@ export const ROUTES = [
NgbdSharedModule,
NgbdComponentsSharedModule
],
declarations: [NgbdModalContent, ...DEMO_DIRECTIVES],
entryComponents: [NgbdModalContent, ...DEMO_DIRECTIVES]
declarations: [NgbdModalContent, NgbdModal1Content, NgbdModal2Content, ...DEMO_DIRECTIVES],
entryComponents: [NgbdModalContent, NgbdModal1Content, NgbdModal2Content, ...DEMO_DIRECTIVES]
})
export class NgbdModalModule {
constructor(demoList: NgbdDemoList) {
Expand Down
3 changes: 2 additions & 1 deletion src/modal/modal-backdrop.ts
Expand Up @@ -3,7 +3,8 @@ import {Component, Input} from '@angular/core';
@Component({
selector: 'ngb-modal-backdrop',
template: '',
host: {'[class]': '"modal-backdrop fade show" + (backdropClass ? " " + backdropClass : "")'}
host:
{'[class]': '"modal-backdrop fade show" + (backdropClass ? " " + backdropClass : "")', 'style': 'z-index: 1050'}
})
export class NgbModalBackdrop {
@Input() backdropClass: string;
Expand Down
41 changes: 39 additions & 2 deletions src/modal/modal-stack.ts
Expand Up @@ -6,9 +6,12 @@ import {
Inject,
ComponentFactoryResolver,
ComponentRef,
TemplateRef
TemplateRef,
RendererFactory2
} from '@angular/core';
import {Subject} from 'rxjs';

import {ngbFocusTrap} from '../util/focus-trap';
import {ContentRef} from '../util/popup';
import {isDefined, isString} from '../util/util';
import {ScrollBar} from '../util/scrollbar';
Expand All @@ -22,16 +25,32 @@ export class NgbModalStack {
private _windowAttributes = ['ariaLabelledBy', 'backdrop', 'centered', 'keyboard', 'size', 'windowClass'];
private _backdropAttributes = ['backdropClass'];
private _modalRefs: NgbModalRef[] = [];
private _windowCmpts: ComponentRef<NgbModalWindow>[] = [];
private _activeWindowCmptHasChanged = new Subject();

constructor(
private _applicationRef: ApplicationRef, private _injector: Injector, @Inject(DOCUMENT) private _document,
private _scrollBar: ScrollBar) {}
private _scrollBar: ScrollBar, private _rendererFactory: RendererFactory2) {
// Trap focus on active WindowCmpt
this._activeWindowCmptHasChanged.subscribe(() => {
if (this._windowCmpts.length) {
const activeWindowCmpt = this._windowCmpts[this._windowCmpts.length - 1];
ngbFocusTrap(activeWindowCmpt.location.nativeElement, this._activeWindowCmptHasChanged);
}
});
}

open(moduleCFR: ComponentFactoryResolver, contentInjector: Injector, content: any, options): NgbModalRef {
const containerEl =
isDefined(options.container) ? this._document.querySelector(options.container) : this._document.body;
const renderer = this._rendererFactory.createRenderer(null, null);

const revertPaddingForScrollBar = this._scrollBar.compensate();
const removeBodyClass = () => {
if (!this._modalRefs.length) {
renderer.removeClass(this._document.body, 'modal-open');
}
};

if (!containerEl) {
throw new Error(`The specified modal container "${options.container || 'body'}" was not found in the DOM.`);
Expand All @@ -46,11 +65,16 @@ export class NgbModalStack {
let ngbModalRef: NgbModalRef = new NgbModalRef(windowCmptRef, contentRef, backdropCmptRef, options.beforeDismiss);

this._registerModalRef(ngbModalRef);
this._registerWindowCmpt(windowCmptRef);
ngbModalRef.result.then(revertPaddingForScrollBar, revertPaddingForScrollBar);
ngbModalRef.result.then(removeBodyClass, removeBodyClass);
activeModal.close = (result: any) => { ngbModalRef.close(result); };
activeModal.dismiss = (reason: any) => { ngbModalRef.dismiss(reason); };

this._applyWindowOptions(windowCmptRef.instance, options);
if (this._modalRefs.length === 1) {
renderer.addClass(this._document.body, 'modal-open');
}

if (backdropCmptRef && backdropCmptRef.instance) {
this._applyBackdropOptions(backdropCmptRef.instance, options);
Expand Down Expand Up @@ -139,4 +163,17 @@ export class NgbModalStack {
this._modalRefs.push(ngbModalRef);
ngbModalRef.result.then(unregisterModalRef, unregisterModalRef);
}

private _registerWindowCmpt(ngbWindowCmpt: ComponentRef<NgbModalWindow>) {
this._windowCmpts.push(ngbWindowCmpt);
this._activeWindowCmptHasChanged.next();

ngbWindowCmpt.onDestroy(() => {
const index = this._windowCmpts.indexOf(ngbWindowCmpt);
if (index > -1) {
this._windowCmpts.splice(index, 1);
this._activeWindowCmptHasChanged.next();
}
});
}
}
13 changes: 2 additions & 11 deletions src/modal/modal-window.ts
Expand Up @@ -6,14 +6,12 @@ import {
Input,
Inject,
ElementRef,
Renderer2,
OnInit,
AfterViewInit,
OnDestroy
} from '@angular/core';

import {ModalDismissReasons} from './modal-dismiss-reasons';
import {ngbFocusTrap} from '../util/focus-trap';

@Component({
selector: 'ngb-modal-window',
Expand Down Expand Up @@ -45,10 +43,7 @@ export class NgbModalWindow implements OnInit,

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

constructor(@Inject(DOCUMENT) document, private _elRef: ElementRef<HTMLElement>, private _renderer: Renderer2) {
this._document = document;
ngbFocusTrap(this._elRef.nativeElement, this.dismissEvent);
}
constructor(@Inject(DOCUMENT) document, private _elRef: ElementRef<HTMLElement>) { this._document = document; }

backdropClick($event): void {
if (this.backdrop === true && this._elRef.nativeElement === $event.target) {
Expand All @@ -64,10 +59,7 @@ export class NgbModalWindow implements OnInit,

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

ngOnInit() {
this._elWithFocus = this._document.activeElement;
this._renderer.addClass(this._document.body, 'modal-open');
}
ngOnInit() { this._elWithFocus = this._document.activeElement; }

ngAfterViewInit() {
if (!this._elRef.nativeElement.contains(document.activeElement)) {
Expand All @@ -88,6 +80,5 @@ export class NgbModalWindow implements OnInit,
elementToFocus['focus'].apply(elementToFocus, []);

this._elWithFocus = null;
this._renderer.removeClass(body, 'modal-open');
}
}
67 changes: 57 additions & 10 deletions src/modal/modal.spec.ts
Expand Up @@ -222,17 +222,19 @@ describe('ngb-modal', () => {
fixture.whenStable().then(() => { expect(rejectReason).toBe('myReason'); });
});

it('should add / remove "modal-open" class to body when modal is open', () => {
const modalRef = fixture.componentInstance.open('bar');
fixture.detectChanges();
expect(fixture.nativeElement).toHaveModal();
expect(document.body).toHaveCssClass('modal-open');
it('should add / remove "modal-open" class to body when modal is open', async(() => {
const modalRef = fixture.componentInstance.open('bar');
fixture.detectChanges();
expect(fixture.nativeElement).toHaveModal();
expect(document.body).toHaveCssClass('modal-open');

modalRef.close('bar result');
fixture.detectChanges();
expect(fixture.nativeElement).not.toHaveModal();
expect(document.body).not.toHaveCssClass('modal-open');
});
modalRef.close('bar result');
fixture.detectChanges();
fixture.whenStable().then(() => {
expect(fixture.nativeElement).not.toHaveModal();
expect(document.body).not.toHaveCssClass('modal-open');
});
}));

it('should not throw when close called multiple times', () => {
const modalInstance = fixture.componentInstance.open('foo');
Expand Down Expand Up @@ -283,6 +285,51 @@ describe('ngb-modal', () => {
});
});

describe('stacked modals', () => {

it('should not remove "modal-open" class on body when closed modal is not last', async(() => {
const modalRef1 = fixture.componentInstance.open('foo');
const modalRef2 = fixture.componentInstance.open('bar');
fixture.detectChanges();
expect(fixture.nativeElement).toHaveModal();
expect(document.body).toHaveCssClass('modal-open');

modalRef1.close('foo result');
fixture.detectChanges();
fixture.whenStable().then(() => {
expect(fixture.nativeElement).toHaveModal();
expect(document.body).toHaveCssClass('modal-open');

modalRef2.close('bar result');
fixture.detectChanges();
fixture.whenStable().then(() => {
expect(fixture.nativeElement).not.toHaveModal();
expect(document.body).not.toHaveCssClass('modal-open');
});
});
}));

it('should dismiss modals on ESC in correct order', () => {
fixture.componentInstance.open('foo').result.catch(NOOP);
fixture.componentInstance.open('bar').result.catch(NOOP);
const ngbModalWindow1 = document.querySelectorAll('ngb-modal-window')[0];
const ngbModalWindow2 = document.querySelectorAll('ngb-modal-window')[1];
fixture.detectChanges();
expect(fixture.nativeElement).toHaveModal(['foo', 'bar']);
expect(document.activeElement).toBe(ngbModalWindow2);

(<DebugElement>getDebugNode(document.activeElement)).triggerEventHandler('keyup.esc', {});
fixture.detectChanges();
expect(fixture.nativeElement).toHaveModal(['foo']);
expect(document.activeElement).toBe(ngbModalWindow1);

(<DebugElement>getDebugNode(document.activeElement)).triggerEventHandler('keyup.esc', {});
fixture.detectChanges();
expect(fixture.nativeElement).not.toHaveModal();
expect(document.activeElement).toBe(document.body);
});
});

describe('backdrop options', () => {

it('should have backdrop by default', () => {
Expand Down

0 comments on commit 2409572

Please sign in to comment.