Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix(modal): prevent modal from closing when using scrollbar #3531

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
35 changes: 34 additions & 1 deletion e2e-app/src/app/modal/autoclose/modal-autoclose.e2e-spec.ts
@@ -1,6 +1,6 @@
import {ModalAutoClosePage} from './modal-autoclose.po';
import {expectNoOpenModals, openUrl, sendKey} from '../../tools.po';
import {Key} from 'protractor';
import {browser, Key} from 'protractor';

describe('Modal', () => {
let page: ModalAutoClosePage;
Expand Down Expand Up @@ -44,15 +44,44 @@ describe('Modal', () => {
it('should close modal on backdrop click', async() => {
const modal = await page.openModal();

// dialog click
await page.getModalDialog().click();
expect(await modal.isPresent()).toBeTruthy('The modal should stay opened on dialog click');

// close
await modal.click();
expect(await modal.isPresent()).toBeFalsy('The modal should be closed on backdrop click');
expect(await page.getDismissReason()).toBe('Click', `Modal should have been dismissed with 'Click' reason`);
});

it('should close modal when dragging from backdrop -> dialog', async() => {
const modal = await page.openModal();

// close
const dialog = await page.getModalDialog();
await browser.actions().dragAndDrop(modal, dialog).mouseUp().perform();
expect(await modal.isPresent()).toBeFalsy('The modal should be closed on drag from backdrop -> dialog');
expect(await page.getDismissReason()).toBe('Click', `Modal should have been dismissed with 'Click' reason`);
});

it('should NOT close modal when dragging from dialog -> backdrop', async() => {
const modal = await page.openModal();

// close
const dialog = await page.getModalDialog();
await browser.actions().dragAndDrop(dialog, modal).mouseUp().perform();
expect(await modal.isPresent()).toBeTruthy('The modal should stay opened on drag from dialog -> backdrop');
await page.getModalCloseButton().click();
});


it(`should NOT close modal on 'static' backdrop click`, async() => {
const modal = await page.openModal('backdrop-static');

// dialog click
await page.getModalDialog().click();
expect(await modal.isPresent()).toBeTruthy('The modal should stay opened on dialog click');

// close
await modal.click();
expect(await modal.isPresent()).toBeTruthy('The modal should stay opened on backdrop click');
Expand All @@ -62,6 +91,10 @@ describe('Modal', () => {
it(`should NOT close modal on click with no backdrop`, async() => {
const modal = await page.openModal('backdrop-false');

// dialog click
await page.getModalDialog().click();
expect(await modal.isPresent()).toBeTruthy('The modal should stay opened on dialog click');

// close
await modal.click();
expect(await modal.isPresent()).toBeTruthy('The modal should stay opened on backdrop click');
Expand Down
2 changes: 2 additions & 0 deletions e2e-app/src/app/modal/autoclose/modal-autoclose.po.ts
Expand Up @@ -3,6 +3,8 @@ import {$} from 'protractor';
export class ModalAutoClosePage {
getModal(selector = 'ngb-modal-window') { return $(selector); }

getModalDialog() { return $('.modal-dialog'); }

getModalCloseButton() { return $('#modal-close-button'); }

getDismissReason() { return $('#dismiss-reason').getText(); }
Expand Down
64 changes: 41 additions & 23 deletions src/modal/modal-window.ts
Expand Up @@ -10,10 +10,11 @@ import {
OnDestroy,
OnInit,
Output,
ViewChild,
ViewEncapsulation
} from '@angular/core';
import {fromEvent} from 'rxjs';
import {filter, map, takeUntil, withLatestFrom} from 'rxjs/operators';
import {filter, switchMap, take, takeUntil, tap} from 'rxjs/operators';

import {getFocusableBoundaryElements} from '../util/focus-trap';
import {Key} from '../util/key';
Expand All @@ -29,7 +30,7 @@ import {ModalDismissReasons} from './modal-dismiss-reasons';
'[attr.aria-labelledby]': 'ariaLabelledBy',
},
template: `
<div [class]="'modal-dialog' + (size ? ' modal-' + size : '') + (centered ? ' modal-dialog-centered' : '') +
<div #dialog [class]="'modal-dialog' + (size ? ' modal-' + size : '') + (centered ? ' modal-dialog-centered' : '') +
(scrollable ? ' modal-dialog-scrollable' : '')" role="document">
<div class="modal-content"><ng-content></ng-content></div>
</div>
Expand All @@ -41,6 +42,8 @@ export class NgbModalWindow implements OnInit,
AfterViewInit, OnDestroy {
private _elWithFocus: Element; // element that is focused prior to modal opening

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

@Input() ariaLabelledBy: string;
@Input() backdrop: boolean | string = true;
@Input() centered: string;
Expand All @@ -52,40 +55,55 @@ export class NgbModalWindow implements OnInit,
@Output('dismiss') dismissEvent = new EventEmitter();

constructor(
@Inject(DOCUMENT) private _document: any, private _elRef: ElementRef<HTMLElement>, private _zone: NgZone) {
_zone.runOutsideAngular(() => {
fromEvent<KeyboardEvent>(this._elRef.nativeElement, 'keydown')
@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() {
const {nativeElement} = this._elRef;
this._zone.runOutsideAngular(() => {

fromEvent<KeyboardEvent>(nativeElement, 'keydown')
.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));
this._zone.run(() => this.dismiss(ModalDismissReasons.ESC));
}
}));

const mouseDowns$ = fromEvent<MouseEvent>(this._elRef.nativeElement, 'mousedown')
.pipe(
takeUntil(this.dismissEvent),
map(e => this.backdrop === true && this._elRef.nativeElement === e.target));
// We're listening to 'mousedown' and 'mouseup' to prevent modal from closing when pressing the mouse
// inside the modal dialog and releasing it outside
let preventClose = false;
fromEvent<MouseEvent>(this._dialogEl.nativeElement, 'mousedown')
.pipe(
takeUntil(this.dismissEvent), tap(() => preventClose = false),
switchMap(
() => fromEvent<MouseEvent>(nativeElement, 'mouseup').pipe(takeUntil(this.dismissEvent), take(1))),
filter(({target}) => nativeElement === target))
.subscribe(() => { preventClose = true; });

fromEvent<MouseEvent>(this._elRef.nativeElement, 'mouseup')
.pipe(takeUntil(this.dismissEvent), withLatestFrom(mouseDowns$), filter(([_, shouldClose]) => shouldClose))
.subscribe(() => this._zone.run(() => this.dismiss(ModalDismissReasons.BACKDROP_CLICK)));
// We're listening to 'click' to dismiss modal on modal window click, except when:
// 1. clicking on modal dialog itself
// 2. closing was prevented by mousedown/up handlers
// 3. clicking on scrollbar when the viewport is too small and modal doesn't fit (click is not triggered at all)
fromEvent<MouseEvent>(nativeElement, 'click').pipe(takeUntil(this.dismissEvent)).subscribe(({target}) => {
if (this.backdrop === true && nativeElement === target && !preventClose) {
this._zone.run(() => this.dismiss(ModalDismissReasons.BACKDROP_CLICK));
}
preventClose = false;
});
});
}

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

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

ngAfterViewInit() {
if (!this._elRef.nativeElement.contains(document.activeElement)) {
const autoFocusable = this._elRef.nativeElement.querySelector(`[ngbAutofocus]`) as HTMLElement;
const firstFocusable = getFocusableBoundaryElements(this._elRef.nativeElement)[0];
if (!nativeElement.contains(document.activeElement)) {
const autoFocusable = nativeElement.querySelector(`[ngbAutofocus]`) as HTMLElement;
const firstFocusable = getFocusableBoundaryElements(nativeElement)[0];

const elementToFocus = autoFocusable || firstFocusable || this._elRef.nativeElement;
const elementToFocus = autoFocusable || firstFocusable || nativeElement;
elementToFocus.focus();
}
}
Expand Down