Skip to content

Commit 210d054

Browse files
authored
feat(overlay): support closing when clicking outside the overlay (#16611)
Previously the overlay relied on creating a backdrop element to capture clicks to then close the overlay. This change adds support for detecting clicks outside of the overlay without creating a separate backdrop element.
1 parent f61a6d9 commit 210d054

14 files changed

+403
-47
lines changed
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.io/license
7+
*/
8+
9+
import {DOCUMENT} from '@angular/common';
10+
import {Inject, Injectable, OnDestroy} from '@angular/core';
11+
import {OverlayReference} from '../overlay-reference';
12+
13+
14+
/**
15+
* Service for dispatching events that land on the body to appropriate overlay ref,
16+
* if any. It maintains a list of attached overlays to determine best suited overlay based
17+
* on event target and order of overlay opens.
18+
*/
19+
@Injectable({providedIn: 'root'})
20+
export abstract class BaseOverlayDispatcher implements OnDestroy {
21+
22+
/** Currently attached overlays in the order they were attached. */
23+
_attachedOverlays: OverlayReference[] = [];
24+
25+
protected _document: Document;
26+
protected _isAttached: boolean;
27+
28+
constructor(@Inject(DOCUMENT) document: any) {
29+
this._document = document;
30+
}
31+
32+
ngOnDestroy(): void {
33+
this.detach();
34+
}
35+
36+
/** Add a new overlay to the list of attached overlay refs. */
37+
add(overlayRef: OverlayReference): void {
38+
// Ensure that we don't get the same overlay multiple times.
39+
this.remove(overlayRef);
40+
this._attachedOverlays.push(overlayRef);
41+
}
42+
43+
/** Remove an overlay from the list of attached overlay refs. */
44+
remove(overlayRef: OverlayReference): void {
45+
const index = this._attachedOverlays.indexOf(overlayRef);
46+
47+
if (index > -1) {
48+
this._attachedOverlays.splice(index, 1);
49+
}
50+
51+
// Remove the global listener once there are no more overlays.
52+
if (this._attachedOverlays.length === 0) {
53+
this.detach();
54+
}
55+
}
56+
57+
/** Detaches the global event listener. */
58+
protected abstract detach(): void;
59+
}

src/cdk/overlay/dispatchers/index.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.io/license
7+
*/
8+
9+
export {OverlayOutsideClickDispatcher} from './overlay-outside-click-dispatcher';
10+
export {OverlayKeyboardDispatcher} from './overlay-keyboard-dispatcher';

src/cdk/overlay/keyboard/overlay-keyboard-dispatcher.ts renamed to src/cdk/overlay/dispatchers/overlay-keyboard-dispatcher.ts

Lines changed: 5 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,11 @@ import {
1111
Inject,
1212
Injectable,
1313
InjectionToken,
14-
OnDestroy,
1514
Optional,
1615
SkipSelf,
1716
} from '@angular/core';
1817
import {OverlayReference} from '../overlay-reference';
18+
import {BaseOverlayDispatcher} from './base-overlay-dispatcher';
1919

2020

2121
/**
@@ -24,52 +24,25 @@ import {OverlayReference} from '../overlay-reference';
2424
* on event target and order of overlay opens.
2525
*/
2626
@Injectable({providedIn: 'root'})
27-
export class OverlayKeyboardDispatcher implements OnDestroy {
28-
29-
/** Currently attached overlays in the order they were attached. */
30-
_attachedOverlays: OverlayReference[] = [];
31-
32-
private _document: Document;
33-
private _isAttached: boolean;
27+
export class OverlayKeyboardDispatcher extends BaseOverlayDispatcher {
3428

3529
constructor(@Inject(DOCUMENT) document: any) {
36-
this._document = document;
37-
}
38-
39-
ngOnDestroy() {
40-
this._detach();
30+
super(document);
4131
}
4232

4333
/** Add a new overlay to the list of attached overlay refs. */
4434
add(overlayRef: OverlayReference): void {
45-
// Ensure that we don't get the same overlay multiple times.
46-
this.remove(overlayRef);
35+
super.add(overlayRef);
4736

4837
// Lazily start dispatcher once first overlay is added
4938
if (!this._isAttached) {
5039
this._document.body.addEventListener('keydown', this._keydownListener);
5140
this._isAttached = true;
5241
}
53-
54-
this._attachedOverlays.push(overlayRef);
55-
}
56-
57-
/** Remove an overlay from the list of attached overlay refs. */
58-
remove(overlayRef: OverlayReference): void {
59-
const index = this._attachedOverlays.indexOf(overlayRef);
60-
61-
if (index > -1) {
62-
this._attachedOverlays.splice(index, 1);
63-
}
64-
65-
// Remove the global listener once there are no more overlays.
66-
if (this._attachedOverlays.length === 0) {
67-
this._detach();
68-
}
6942
}
7043

7144
/** Detaches the global keyboard event listener. */
72-
private _detach() {
45+
protected detach() {
7346
if (this._isAttached) {
7447
this._document.body.removeEventListener('keydown', this._keydownListener);
7548
this._isAttached = false;
Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
import {TestBed, inject} from '@angular/core/testing';
2+
import {Component, NgModule} from '@angular/core';
3+
import {OverlayModule, OverlayContainer, Overlay} from '../index';
4+
import {OverlayOutsideClickDispatcher} from './overlay-outside-click-dispatcher';
5+
import {ComponentPortal} from '@angular/cdk/portal';
6+
7+
8+
describe('OverlayOutsideClickDispatcher', () => {
9+
let outsideClickDispatcher: OverlayOutsideClickDispatcher;
10+
let overlay: Overlay;
11+
12+
beforeEach(() => {
13+
TestBed.configureTestingModule({
14+
imports: [OverlayModule, TestComponentModule],
15+
});
16+
17+
inject([OverlayOutsideClickDispatcher, Overlay],
18+
(ocd: OverlayOutsideClickDispatcher, o: Overlay) => {
19+
outsideClickDispatcher = ocd;
20+
overlay = o;
21+
})();
22+
});
23+
24+
afterEach(inject([OverlayContainer], (overlayContainer: OverlayContainer) => {
25+
overlayContainer.ngOnDestroy();
26+
}));
27+
28+
it('should track overlays in order as they are attached and detached', () => {
29+
const overlayOne = overlay.create();
30+
const overlayTwo = overlay.create();
31+
32+
outsideClickDispatcher.add(overlayOne);
33+
outsideClickDispatcher.add(overlayTwo);
34+
35+
expect(outsideClickDispatcher._attachedOverlays.length)
36+
.toBe(2, 'Expected both overlays to be tracked.');
37+
expect(outsideClickDispatcher._attachedOverlays[0])
38+
.toBe(overlayOne, 'Expected one to be first.');
39+
expect(outsideClickDispatcher._attachedOverlays[1])
40+
.toBe(overlayTwo, 'Expected two to be last.');
41+
42+
outsideClickDispatcher.remove(overlayOne);
43+
outsideClickDispatcher.add(overlayOne);
44+
45+
expect(outsideClickDispatcher._attachedOverlays[0])
46+
.toBe(overlayTwo, 'Expected two to now be first.');
47+
expect(outsideClickDispatcher._attachedOverlays[1])
48+
.toBe(overlayOne, 'Expected one to now be last.');
49+
50+
overlayOne.dispose();
51+
overlayTwo.dispose();
52+
});
53+
54+
it(
55+
'should dispatch mouse click events to the attached overlays',
56+
() => {
57+
const overlayOne = overlay.create();
58+
const overlayTwo = overlay.create();
59+
const overlayOneSpy = jasmine.createSpy('overlayOne mouse click event spy');
60+
const overlayTwoSpy = jasmine.createSpy('overlayTwo mouse click event spy');
61+
62+
overlayOne.outsidePointerEvents().subscribe(overlayOneSpy);
63+
overlayTwo.outsidePointerEvents().subscribe(overlayTwoSpy);
64+
65+
outsideClickDispatcher.add(overlayOne);
66+
outsideClickDispatcher.add(overlayTwo);
67+
68+
const button = document.createElement('button');
69+
document.body.appendChild(button);
70+
button.click();
71+
72+
expect(overlayOneSpy).toHaveBeenCalled();
73+
expect(overlayTwoSpy).toHaveBeenCalled();
74+
75+
button.parentNode!.removeChild(button);
76+
overlayOne.dispose();
77+
overlayTwo.dispose();
78+
});
79+
80+
it(
81+
'should dispatch mouse click events to the attached overlays even when propagation is stopped',
82+
() => {
83+
const overlayRef = overlay.create();
84+
const spy = jasmine.createSpy('overlay mouse click event spy');
85+
overlayRef.outsidePointerEvents().subscribe(spy);
86+
87+
outsideClickDispatcher.add(overlayRef);
88+
89+
const button = document.createElement('button');
90+
document.body.appendChild(button);
91+
button.addEventListener('click', event => event.stopPropagation());
92+
button.click();
93+
94+
expect(spy).toHaveBeenCalled();
95+
96+
button.parentNode!.removeChild(button);
97+
overlayRef.dispose();
98+
});
99+
100+
it('should dispose of the global click event handler correctly', () => {
101+
const overlayRef = overlay.create();
102+
const body = document.body;
103+
104+
spyOn(body, 'addEventListener');
105+
spyOn(body, 'removeEventListener');
106+
107+
outsideClickDispatcher.add(overlayRef);
108+
expect(body.addEventListener).toHaveBeenCalledWith('click', jasmine.any(Function), true);
109+
110+
overlayRef.dispose();
111+
expect(body.removeEventListener).toHaveBeenCalledWith('click', jasmine.any(Function), true);
112+
});
113+
114+
it('should not add the same overlay to the stack multiple times', () => {
115+
const overlayOne = overlay.create();
116+
const overlayTwo = overlay.create();
117+
118+
outsideClickDispatcher.add(overlayOne);
119+
outsideClickDispatcher.add(overlayTwo);
120+
outsideClickDispatcher.add(overlayOne);
121+
122+
expect(outsideClickDispatcher._attachedOverlays).toEqual([overlayTwo, overlayOne]);
123+
124+
overlayOne.dispose();
125+
overlayTwo.dispose();
126+
});
127+
128+
it(`should not dispatch click event when click on element
129+
included in excludeFromOutsideClick array`, () => {
130+
const overlayRef = overlay.create();
131+
const spy = jasmine.createSpy('overlay mouse click event spy');
132+
overlayRef.outsidePointerEvents().subscribe(spy);
133+
134+
const overlayConfig = overlayRef.getConfig();
135+
expect(overlayConfig.excludeFromOutsideClick).toBeDefined();
136+
expect(overlayConfig.excludeFromOutsideClick!.length).toBe(0);
137+
138+
overlayRef.attach(new ComponentPortal(TestComponent));
139+
140+
const buttonShouldNotDetach = document.createElement('button');
141+
document.body.appendChild(buttonShouldNotDetach);
142+
overlayConfig.excludeFromOutsideClick!.push(buttonShouldNotDetach);
143+
buttonShouldNotDetach.click();
144+
145+
expect(spy).not.toHaveBeenCalled();
146+
147+
buttonShouldNotDetach.parentNode!.removeChild(buttonShouldNotDetach);
148+
overlayRef.dispose();
149+
});
150+
});
151+
152+
153+
@Component({
154+
template: 'Hello'
155+
})
156+
class TestComponent { }
157+
158+
159+
// Create a real (non-test) NgModule as a workaround for
160+
// https://github.com/angular/angular/issues/10760
161+
@NgModule({
162+
exports: [TestComponent],
163+
declarations: [TestComponent],
164+
entryComponents: [TestComponent],
165+
})
166+
class TestComponentModule { }

0 commit comments

Comments
 (0)