diff --git a/e2e-app/src/app/app.module.ts b/e2e-app/src/app/app.module.ts
index a6b65b7568..2d34155436 100644
--- a/e2e-app/src/app/app.module.ts
+++ b/e2e-app/src/app/app.module.ts
@@ -14,6 +14,7 @@ import {DropdownAutoCloseComponent} from './dropdown/autoclose/dropdown-autoclos
import {DropdownFocusComponent} from './dropdown/focus/dropdown-focus.component';
import {DropdownPositionComponent} from './dropdown/position/dropdown-position.component';
import {ModalFocusComponent} from './modal/focus/modal-focus.component';
+import {ModalNestingComponent} from './modal/nesting/modal-nesting.component';
import {PopoverAutocloseComponent} from './popover/autoclose/popover-autoclose.component';
import {TooltipAutocloseComponent} from './tooltip/autoclose/tooltip-autoclose.component';
import {TooltipFocusComponent} from './tooltip/focus/tooltip-focus.component';
@@ -34,6 +35,7 @@ import {TypeaheadValidationComponent} from './typeahead/validation/typeahead-val
DropdownFocusComponent,
DropdownPositionComponent,
ModalFocusComponent,
+ ModalNestingComponent,
PopoverAutocloseComponent,
TooltipAutocloseComponent,
TooltipFocusComponent,
diff --git a/e2e-app/src/app/app.routing.ts b/e2e-app/src/app/app.routing.ts
index 94f8aed82d..3a4840748d 100644
--- a/e2e-app/src/app/app.routing.ts
+++ b/e2e-app/src/app/app.routing.ts
@@ -15,6 +15,7 @@ import {TypeaheadFocusComponent} from './typeahead/focus/typeahead-focus.compone
import {TimepickerNavigationComponent} from './timepicker/navigation/timepicker-navigation.component';
import {TypeaheadValidationComponent} from './typeahead/validation/typeahead-validation.component';
import {DropdownPositionComponent} from './dropdown/position/dropdown-position.component';
+import {ModalNestingComponent} from './modal/nesting/modal-nesting.component';
export const routes: Routes = [
@@ -25,7 +26,10 @@ export const routes: Routes = [
{path: 'autoclose', component: DatepickerAutoCloseComponent}
]
},
- {path: 'modal', children: [{path: 'focus', component: ModalFocusComponent}]},
+ {
+ path: 'modal',
+ children: [{path: 'focus', component: ModalFocusComponent}, {path: 'nesting', component: ModalNestingComponent}]
+ },
{
path: 'dropdown',
children: [
diff --git a/e2e-app/src/app/datepicker/datepicker.po.ts b/e2e-app/src/app/datepicker/datepicker.po.ts
index efb414503e..e9d48b86f8 100644
--- a/e2e-app/src/app/datepicker/datepicker.po.ts
+++ b/e2e-app/src/app/datepicker/datepicker.po.ts
@@ -1,6 +1,6 @@
import {$} from 'protractor';
-export abstract class DatepickerPage {
+export class DatepickerPage {
getDatepicker(selector = 'ngb-datepicker') { return $(selector); }
getDatepickerInput(selector = 'input[ngbDatepicker]') { return $(selector); }
diff --git a/e2e-app/src/app/modal/nesting/modal-nesting.component.html b/e2e-app/src/app/modal/nesting/modal-nesting.component.html
new file mode 100644
index 0000000000..bc59629fed
--- /dev/null
+++ b/e2e-app/src/app/modal/nesting/modal-nesting.component.html
@@ -0,0 +1,50 @@
+
Modal nesting tests
+
+
+
+
+
+
+
diff --git a/e2e-app/src/app/modal/nesting/modal-nesting.component.ts b/e2e-app/src/app/modal/nesting/modal-nesting.component.ts
new file mode 100644
index 0000000000..cdae189ce9
--- /dev/null
+++ b/e2e-app/src/app/modal/nesting/modal-nesting.component.ts
@@ -0,0 +1,13 @@
+import {Component, TemplateRef} from '@angular/core';
+import {NgbModal} from '@ng-bootstrap/ng-bootstrap';
+import {Observable} from 'rxjs';
+import {map} from 'rxjs/operators';
+
+@Component({templateUrl: './modal-nesting.component.html'})
+export class ModalNestingComponent {
+ constructor(private modalService: NgbModal) {}
+
+ openModal(content: TemplateRef) { this.modalService.open(content); }
+
+ search = (text$: Observable) => text$.pipe(map(() => ['one', 'two', 'three']));
+}
diff --git a/e2e-app/src/app/modal/nesting/modal-nesting.e2e-spec.ts b/e2e-app/src/app/modal/nesting/modal-nesting.e2e-spec.ts
new file mode 100644
index 0000000000..eccc72ea14
--- /dev/null
+++ b/e2e-app/src/app/modal/nesting/modal-nesting.e2e-spec.ts
@@ -0,0 +1,80 @@
+import {Key} from 'protractor';
+import {expectFocused, expectNoOpenModals, openUrl, sendKey} from '../../tools.po';
+import {ModalNestingPage} from './modal-nesting.po';
+import {DatepickerPage} from '../../datepicker/datepicker.po';
+import {DropdownPage} from '../../dropdown/dropdown.po';
+import {TypeaheadPage} from '../../typeahead/typeahead.po';
+
+describe('Modal nested components', () => {
+ let page: ModalNestingPage;
+ let datepickerPage: DatepickerPage;
+ let dropdownPage: DropdownPage;
+ let typeaheadPage: TypeaheadPage;
+
+ beforeAll(() => {
+ page = new ModalNestingPage();
+ datepickerPage = new DatepickerPage();
+ dropdownPage = new DropdownPage();
+ typeaheadPage = new TypeaheadPage();
+ });
+
+ beforeEach(async() => await openUrl('modal/nesting'));
+
+ afterEach(async() => { await expectNoOpenModals(); });
+
+ it('should close only datepicker, then modal on ESC', async() => {
+ await page.openModal();
+
+ // open datepicker
+ await page.getDatepickerButton().click();
+ expect(await datepickerPage.getDatepicker().isPresent()).toBeTruthy(`Datepicker should be opened`);
+ await expectFocused(await datepickerPage.getDayElement(new Date(2018, 0, 1)), `01 JAN 2018 should be focused`);
+
+ // close datepicker
+ await sendKey(Key.ESCAPE);
+ expect(await datepickerPage.getDatepicker().isPresent()).toBeFalsy(`Datepicker should be closed`);
+ await expectFocused(await page.getDatepickerButton(), `Datepicker open button should be focused`);
+ expect(await page.getModal().isPresent()).toBeTruthy(`Modal should stay opened`);
+
+ // close modal
+ await sendKey(Key.ESCAPE);
+ });
+
+ it('should close only dropdown, then modal on ESC', async() => {
+ await page.openModal();
+
+ // open dropdown
+ await page.getDropdownButton().click();
+ const dropdown = dropdownPage.getDropdown();
+ expect(await dropdownPage.isOpened(dropdown)).toBeTruthy(`Dropdown should be opened`);
+ await expectFocused(page.getDropdownButton(), `Dropdown button should be focused`);
+
+ // close dropdown
+ await sendKey(Key.ESCAPE);
+ expect(await dropdownPage.isOpened(dropdown)).toBeFalsy(`Dropdown should be closed`);
+ await expectFocused(await page.getDropdownButton(), `Dropdown open button should be focused`);
+ expect(await page.getModal().isPresent()).toBeTruthy(`Modal should stay opened`);
+
+ // close modal
+ await sendKey(Key.ESCAPE);
+ });
+
+ it('should close only typeahead, then modal on ESC', async() => {
+ await page.openModal();
+
+ // open typeahead
+ await page.getTypeaheadInput().click();
+ await sendKey(Key.SPACE);
+ expect(await typeaheadPage.getDropdown().isPresent()).toBeTruthy(`Typeahead should be opened`);
+ await expectFocused(page.getTypeaheadInput(), `Typeahead input should be focused`);
+
+ // close dropdown
+ await sendKey(Key.ESCAPE);
+ expect(await typeaheadPage.getDropdown().isPresent()).toBeFalsy(`Typeahead should be closed`);
+ await expectFocused(page.getTypeaheadInput(), `Typeahead input should be focused`);
+ expect(await page.getModal().isPresent()).toBeTruthy(`Modal should stay opened`);
+
+ // close modal
+ await sendKey(Key.ESCAPE);
+ });
+});
diff --git a/e2e-app/src/app/modal/nesting/modal-nesting.po.ts b/e2e-app/src/app/modal/nesting/modal-nesting.po.ts
new file mode 100644
index 0000000000..4c95a56e24
--- /dev/null
+++ b/e2e-app/src/app/modal/nesting/modal-nesting.po.ts
@@ -0,0 +1,18 @@
+import {$} from 'protractor';
+
+export class ModalNestingPage {
+ getModal(selector = 'ngb-modal-window') { return $(selector); }
+
+ getDatepickerButton() { return $('#datepicker-button'); }
+
+ getDropdownButton() { return $('#dropdown'); }
+
+ getTypeaheadInput() { return $('#typeahead'); }
+
+ async openModal() {
+ await $(`#open-modal`).click();
+ const modal = this.getModal();
+ expect(await modal.isPresent()).toBeTruthy(`A modal should have been opened`);
+ return modal;
+ }
+}
diff --git a/e2e-app/src/app/tools.po.ts b/e2e-app/src/app/tools.po.ts
index 3aaf1acf91..e038591ade 100644
--- a/e2e-app/src/app/tools.po.ts
+++ b/e2e-app/src/app/tools.po.ts
@@ -1,4 +1,4 @@
-import {browser, ElementFinder, Key, WebElement, $, $$, Button} from 'protractor';
+import {$, browser, Button, ElementFinder, Key, protractor, WebElement} from 'protractor';
/**
* Sends keys to a currently focused element
@@ -41,8 +41,8 @@ export const expectFocused = async(el: ElementFinder, message: string) => {
/**
* Checks that there are no open modal windows in the document
*/
-export const expectNoOpenModals = async() => {
- expect(await $$('ngb-modal-window').count()).toBe(0, `There should be no open modal windows left after a modal test`);
+export const expectNoOpenModals = async(error = `There should be no open modal windows left after a modal test`) => {
+ browser.wait(protractor.ExpectedConditions.invisibilityOf($('ngb-modal-window')), 1000, error);
};
/**
diff --git a/src/modal/modal-window.spec.ts b/src/modal/modal-window.spec.ts
index 2f55d073b7..e859257170 100644
--- a/src/modal/modal-window.spec.ts
+++ b/src/modal/modal-window.spec.ts
@@ -2,6 +2,8 @@ import {TestBed, ComponentFixture} from '@angular/core/testing';
import {NgbModalWindow} from './modal-window';
import {ModalDismissReasons} from './modal-dismiss-reasons';
+import {createKeyEvent} from '../test/common';
+import {Key} from '../util/key';
describe('ngb-modal-dialog', () => {
@@ -105,7 +107,7 @@ describe('ngb-modal-dialog', () => {
done();
});
- fixture.debugElement.triggerEventHandler('keyup.esc', {});
+ fixture.nativeElement.dispatchEvent(createKeyEvent(Key.Escape));
});
});
diff --git a/src/modal/modal-window.ts b/src/modal/modal-window.ts
index 9ea8a5f46d..ef9bf3947f 100644
--- a/src/modal/modal-window.ts
+++ b/src/modal/modal-window.ts
@@ -6,13 +6,17 @@ import {
EventEmitter,
Inject,
Input,
+ NgZone,
OnDestroy,
OnInit,
Output,
- ViewEncapsulation,
+ ViewEncapsulation
} from '@angular/core';
+import {fromEvent} from 'rxjs';
+import {filter, takeUntil} from 'rxjs/operators';
import {getFocusableBoundaryElements} from '../util/focus-trap';
+import {Key} from '../util/key';
import {ModalDismissReasons} from './modal-dismiss-reasons';
@Component({
@@ -21,7 +25,6 @@ import {ModalDismissReasons} from './modal-dismiss-reasons';
'[class]': '"modal fade show d-block" + (windowClass ? " " + windowClass : "")',
'role': 'dialog',
'tabindex': '-1',
- '(keyup.esc)': 'escKey($event)',
'(click)': 'backdropClick($event)',
'[attr.aria-modal]': 'true',
'[attr.aria-labelledby]': 'ariaLabelledBy',
@@ -49,17 +52,24 @@ export class NgbModalWindow implements OnInit,
@Output('dismiss') dismissEvent = new EventEmitter();
- constructor(@Inject(DOCUMENT) private _document: any, private _elRef: ElementRef) {}
-
- backdropClick($event): void {
- if (this.backdrop === true && this._elRef.nativeElement === $event.target) {
- this.dismiss(ModalDismissReasons.BACKDROP_CLICK);
- }
+ constructor(@Inject(DOCUMENT) private _document: any, private _elRef: ElementRef, zone: NgZone) {
+ zone.runOutsideAngular(() => {
+ fromEvent(this._elRef.nativeElement, 'keyup')
+ .pipe(
+ takeUntil(this.dismissEvent),
+ // tslint:disable-next-line:deprecation
+ filter(e => e.which === Key.Escape && this.keyboard))
+ .subscribe(event => requestAnimationFrame(() => {
+ if (!event.defaultPrevented) {
+ zone.run(() => this.dismiss(ModalDismissReasons.ESC));
+ }
+ }));
+ });
}
- escKey($event): void {
- if (this.keyboard && !$event.defaultPrevented) {
- this.dismiss(ModalDismissReasons.ESC);
+ backdropClick(event: MouseEvent): void {
+ if (this.backdrop === true && this._elRef.nativeElement === event.target) {
+ this.dismiss(ModalDismissReasons.BACKDROP_CLICK);
}
}
diff --git a/src/modal/modal.spec.ts b/src/modal/modal.spec.ts
index 80af80cbdc..7c9f0c4388 100644
--- a/src/modal/modal.spec.ts
+++ b/src/modal/modal.spec.ts
@@ -9,9 +9,11 @@ import {
OnDestroy,
ViewChild
} from '@angular/core';
-import {async, ComponentFixture, TestBed} from '@angular/core/testing';
+import {async, ComponentFixture, fakeAsync, TestBed, tick} from '@angular/core/testing';
import {NgbModalConfig} from './modal-config';
import {NgbActiveModal, NgbModal, NgbModalModule, NgbModalRef} from './modal.module';
+import {createKeyEvent} from '../test/common';
+import {Key} from '../util/key';
const NOOP = () => {};
@@ -341,25 +343,27 @@ describe('ngb-modal', () => {
});
}));
- 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);
+ it('should dismiss modals on ESC in correct order', fakeAsync(() => {
+ 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);
- (getDebugNode(document.activeElement)).triggerEventHandler('keyup.esc', {});
- fixture.detectChanges();
- expect(fixture.nativeElement).toHaveModal(['foo']);
- expect(document.activeElement).toBe(ngbModalWindow1);
+ ngbModalWindow2.dispatchEvent(createKeyEvent(Key.Escape));
+ tick(16); // RAF in escape handling
+ fixture.detectChanges();
+ expect(fixture.nativeElement).toHaveModal(['foo']);
+ expect(document.activeElement).toBe(ngbModalWindow1);
- (getDebugNode(document.activeElement)).triggerEventHandler('keyup.esc', {});
- fixture.detectChanges();
- expect(fixture.nativeElement).not.toHaveModal();
- expect(document.activeElement).toBe(document.body);
- });
+ ngbModalWindow1.dispatchEvent(createKeyEvent(Key.Escape));
+ tick(16); // RAF in escape handling
+ fixture.detectChanges();
+ expect(fixture.nativeElement).not.toHaveModal();
+ expect(document.activeElement).toBe(document.body);
+ }));
});
describe('backdrop options', () => {
@@ -570,29 +574,31 @@ describe('ngb-modal', () => {
describe('keyboard options', () => {
- it('should dismiss modals on ESC by default', () => {
- fixture.componentInstance.open('foo').result.catch(NOOP);
- fixture.detectChanges();
- expect(fixture.nativeElement).toHaveModal('foo');
+ it('should dismiss modals on ESC by default', fakeAsync(() => {
+ fixture.componentInstance.open('foo').result.catch(NOOP);
+ fixture.detectChanges();
+ expect(fixture.nativeElement).toHaveModal('foo');
- (getDebugNode(document.querySelector('ngb-modal-window'))).triggerEventHandler('keyup.esc', {});
- fixture.detectChanges();
- expect(fixture.nativeElement).not.toHaveModal();
- });
+ document.querySelector('ngb-modal-window').dispatchEvent(createKeyEvent(Key.Escape));
+ tick(16); // RAF in escape handling
+ fixture.detectChanges();
+ expect(fixture.nativeElement).not.toHaveModal();
+ }));
- it('should not dismiss modals on ESC when keyboard option is false', () => {
- const modalInstance = fixture.componentInstance.open('foo', {keyboard: false});
- fixture.detectChanges();
- expect(fixture.nativeElement).toHaveModal('foo');
+ it('should not dismiss modals on ESC when keyboard option is false', fakeAsync(() => {
+ const modalInstance = fixture.componentInstance.open('foo', {keyboard: false});
+ fixture.detectChanges();
+ expect(fixture.nativeElement).toHaveModal('foo');
- (getDebugNode(document.querySelector('ngb-modal-window'))).triggerEventHandler('keyup.esc', {});
- fixture.detectChanges();
- expect(fixture.nativeElement).toHaveModal();
+ document.querySelector('ngb-modal-window').dispatchEvent(createKeyEvent(Key.Escape));
+ tick(16); // RAF in escape handling
+ fixture.detectChanges();
+ expect(fixture.nativeElement).toHaveModal();
- modalInstance.close();
- fixture.detectChanges();
- expect(fixture.nativeElement).not.toHaveModal();
- });
+ modalInstance.close();
+ fixture.detectChanges();
+ expect(fixture.nativeElement).not.toHaveModal();
+ }));
it('should not dismiss modals on ESC when default is prevented', () => {
const modalInstance = fixture.componentInstance.open('foo', {keyboard: true});
diff --git a/src/util/autoclose.ts b/src/util/autoclose.ts
index 7d72fe5f17..8020fee1cb 100644
--- a/src/util/autoclose.ts
+++ b/src/util/autoclose.ts
@@ -1,6 +1,6 @@
import {NgZone} from '@angular/core';
import {fromEvent, Observable, race} from 'rxjs';
-import {delay, filter, map, takeUntil, withLatestFrom} from 'rxjs/operators';
+import {delay, filter, map, takeUntil, tap, withLatestFrom} from 'rxjs/operators';
import {Key} from './key';
import {closest} from './util';
@@ -39,11 +39,11 @@ export function ngbAutoClose(
}
};
- const escapes$ = fromEvent(document, 'keydown')
+ const escapes$ = fromEvent(document, 'keyup')
.pipe(
takeUntil(closed$),
// tslint:disable-next-line:deprecation
- filter(e => e.which === Key.Escape));
+ filter(e => e.which === Key.Escape), tap(e => e.preventDefault()));
// we have to pre-calculate 'shouldCloseOnClick' on 'mousedown/touchstart',