Skip to content

Commit

Permalink
fix(modal): don't close popup on ESC when closing nested components (#…
Browse files Browse the repository at this point in the history
…3384)

Fixes an issue for datepicker, typeahead, dropdown inside the modal.
When these components are closed on ESC, now modal stays open.

Fixes #3358
  • Loading branch information
maxokorokov committed Sep 27, 2019
1 parent d8812b9 commit 75f0966
Show file tree
Hide file tree
Showing 12 changed files with 242 additions and 57 deletions.
2 changes: 2 additions & 0 deletions e2e-app/src/app/app.module.ts
Expand Up @@ -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';
Expand All @@ -34,6 +35,7 @@ import {TypeaheadValidationComponent} from './typeahead/validation/typeahead-val
DropdownFocusComponent,
DropdownPositionComponent,
ModalFocusComponent,
ModalNestingComponent,
PopoverAutocloseComponent,
TooltipAutocloseComponent,
TooltipFocusComponent,
Expand Down
6 changes: 5 additions & 1 deletion e2e-app/src/app/app.routing.ts
Expand Up @@ -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 = [
Expand All @@ -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: [
Expand Down
2 changes: 1 addition & 1 deletion 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); }
Expand Down
50 changes: 50 additions & 0 deletions e2e-app/src/app/modal/nesting/modal-nesting.component.html
@@ -0,0 +1,50 @@
<h3>Modal nesting tests</h3>

<ng-template #t let-modal>
<div class="modal-header">
<h4 class="modal-title">Modal with nested popups</h4>
<button type="button" class="close" aria-label="Close" (click)="modal.dismiss()">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body">
<form>
<!-- datepicker -->
<div class="form-group">
<label for="datepicker">Datepicker</label>
<div class="input-group">
<input id="datepicker" class="form-control" placeholder="yyyy-mm-dd" name="dp"
ngbDatepicker #dp="ngbDatepicker" [startDate]="{year: 2018, month: 1}">
<div class="input-group-append">
<button class="btn btn-outline-secondary" id="datepicker-button" (click)="dp.toggle()" type="button">Open</button>
</div>
</div>
</div>

<!-- dropdown -->
<div class="form-group">
<label for="dropdown">Typeahead</label>
<div class="input-group">
<div ngbDropdown class="d-inline-block">
<button class="btn btn-outline-primary" id="dropdown" ngbDropdownToggle>Toggle dropdown</button>
<div ngbDropdownMenu aria-labelledby="dropdownBasic1">
<button ngbDropdownItem>Action - 1</button>
<button ngbDropdownItem>Another Action</button>
<button ngbDropdownItem>Something else is here</button>
</div>
</div>
</div>
</div>

<!-- typeahead -->
<div class="form-group">
<label for="typeahead">Typeahead</label>
<div class="input-group">
<input id="typeahead" type="text" name="tphd" class="form-control" [ngbTypeahead]="search"/>
</div>
</div>
</form>
</div>
</ng-template>

<button class="btn btn-outline-secondary ml-2" type="button" id="open-modal" (click)="openModal(t)">Open Modal</button>
13 changes: 13 additions & 0 deletions 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<any>) { this.modalService.open(content); }

search = (text$: Observable<string>) => text$.pipe(map(() => ['one', 'two', 'three']));
}
80 changes: 80 additions & 0 deletions 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);
});
});
18 changes: 18 additions & 0 deletions 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;
}
}
6 changes: 3 additions & 3 deletions 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
Expand Down Expand Up @@ -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);
};

/**
Expand Down
4 changes: 3 additions & 1 deletion src/modal/modal-window.spec.ts
Expand Up @@ -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', () => {

Expand Down Expand Up @@ -105,7 +107,7 @@ describe('ngb-modal-dialog', () => {
done();
});

fixture.debugElement.triggerEventHandler('keyup.esc', {});
fixture.nativeElement.dispatchEvent(createKeyEvent(Key.Escape));
});
});

Expand Down
32 changes: 21 additions & 11 deletions src/modal/modal-window.ts
Expand Up @@ -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({
Expand All @@ -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',
Expand Down Expand Up @@ -49,17 +52,24 @@ export class NgbModalWindow implements OnInit,

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

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

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<HTMLElement>, zone: NgZone) {
zone.runOutsideAngular(() => {
fromEvent<KeyboardEvent>(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);
}
}

Expand Down

0 comments on commit 75f0966

Please sign in to comment.