Skip to content

Commit 6e1fe2c

Browse files
crisbetommalerba
authored andcommitted
fix(autocomplete): unable to open panel via click inside shado… (#15616)
Fixes clicking on an input to open its autocomplete not working if the consumer is using `ViewEncapsulation.ShadowDom`. The issue comes from the fact that inside the shadow DOM the `event.target` is set to the shadow root, rather than the element being clicked. Fixes #15606.
1 parent de06e59 commit 6e1fe2c

File tree

8 files changed

+137
-42
lines changed

8 files changed

+137
-42
lines changed
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
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+
let shadowDomIsSupported: boolean;
10+
11+
/** Checks whether the user's browser support Shadow DOM. */
12+
export function _supportsShadowDom(): boolean {
13+
if (shadowDomIsSupported == null) {
14+
const head = typeof document !== 'undefined' ? document.head : null;
15+
shadowDomIsSupported = !!(head && ((head as any).createShadowRoot || head.attachShadow));
16+
}
17+
18+
return shadowDomIsSupported;
19+
}

src/cdk/platform/public-api.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,3 +11,4 @@ export * from './platform-module';
1111
export * from './features/input-types';
1212
export * from './features/passive-listeners';
1313
export * from './features/scrolling';
14+
export * from './features/shadow-dom';

src/dev-app/autocomplete/autocomplete-demo.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
* found in the LICENSE file at https://angular.io/license
77
*/
88

9-
import {Component, ViewChild} from '@angular/core';
9+
import {Component, ViewChild, ViewEncapsulation} from '@angular/core';
1010
import {FormControl, NgModel} from '@angular/forms';
1111
import {Observable} from 'rxjs';
1212
import {map, startWith} from 'rxjs/operators';
@@ -27,6 +27,7 @@ export interface StateGroup {
2727
selector: 'autocomplete-demo',
2828
templateUrl: 'autocomplete-demo.html',
2929
styleUrls: ['autocomplete-demo.css'],
30+
encapsulation: ViewEncapsulation.ShadowDom
3031
})
3132
export class AutocompleteDemo {
3233
stateCtrl: FormControl;

src/material/autocomplete/BUILD.bazel

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ ng_module(
2323
"//src/cdk/coercion",
2424
"//src/cdk/keycodes",
2525
"//src/cdk/overlay",
26+
"//src/cdk/platform",
2627
"//src/cdk/portal",
2728
"//src/cdk/scrolling",
2829
"//src/material/core",
@@ -60,6 +61,7 @@ ng_test_library(
6061
"//src/cdk/bidi",
6162
"//src/cdk/keycodes",
6263
"//src/cdk/overlay",
64+
"//src/cdk/platform",
6365
"//src/cdk/private/testing",
6466
"//src/cdk/scrolling",
6567
"//src/cdk/testing",

src/material/autocomplete/autocomplete-trigger.ts

Lines changed: 37 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
* found in the LICENSE file at https://angular.io/license
77
*/
88
import {Directionality} from '@angular/cdk/bidi';
9+
import {coerceBooleanProperty} from '@angular/cdk/coercion';
910
import {DOWN_ARROW, ENTER, ESCAPE, TAB, UP_ARROW} from '@angular/cdk/keycodes';
1011
import {
1112
FlexibleConnectedPositionStrategy,
@@ -16,10 +17,12 @@ import {
1617
ScrollStrategy,
1718
ConnectedPosition,
1819
} from '@angular/cdk/overlay';
20+
import {_supportsShadowDom} from '@angular/cdk/platform';
1921
import {TemplatePortal} from '@angular/cdk/portal';
22+
import {ViewportRuler} from '@angular/cdk/scrolling';
2023
import {DOCUMENT} from '@angular/common';
21-
import {filter, take, switchMap, delay, tap, map} from 'rxjs/operators';
2224
import {
25+
AfterViewInit,
2326
ChangeDetectorRef,
2427
Directive,
2528
ElementRef,
@@ -35,7 +38,6 @@ import {
3538
OnChanges,
3639
SimpleChanges,
3740
} from '@angular/core';
38-
import {ViewportRuler} from '@angular/cdk/scrolling';
3941
import {ControlValueAccessor, NG_VALUE_ACCESSOR} from '@angular/forms';
4042
import {
4143
_countGroupLabelsBeforeOption,
@@ -44,9 +46,10 @@ import {
4446
MatOptionSelectionChange,
4547
} from '@angular/material/core';
4648
import {MatFormField} from '@angular/material/form-field';
47-
import {Subscription, defer, fromEvent, merge, of as observableOf, Subject, Observable} from 'rxjs';
49+
import {defer, fromEvent, merge, Observable, of as observableOf, Subject, Subscription} from 'rxjs';
50+
import {delay, filter, map, switchMap, take, tap} from 'rxjs/operators';
51+
4852
import {MatAutocomplete} from './autocomplete';
49-
import {coerceBooleanProperty} from '@angular/cdk/coercion';
5053
import {MatAutocompleteOrigin} from './autocomplete-origin';
5154

5255

@@ -119,7 +122,8 @@ export function getMatAutocompleteMissingPanelError(): Error {
119122
exportAs: 'matAutocompleteTrigger',
120123
providers: [MAT_AUTOCOMPLETE_VALUE_ACCESSOR]
121124
})
122-
export class MatAutocompleteTrigger implements ControlValueAccessor, OnChanges, OnDestroy {
125+
export class MatAutocompleteTrigger implements ControlValueAccessor, AfterViewInit, OnChanges,
126+
OnDestroy {
123127
private _overlayRef: OverlayRef | null;
124128
private _portal: TemplatePortal;
125129
private _componentDestroyed = false;
@@ -148,6 +152,9 @@ export class MatAutocompleteTrigger implements ControlValueAccessor, OnChanges,
148152
*/
149153
private _canOpenOnNextFocus = true;
150154

155+
/** Whether the element is inside of a ShadowRoot component. */
156+
private _isInsideShadowRoot: boolean;
157+
151158
/** Stream of keyboard events that can close the panel. */
152159
private readonly _closeKeyEventStream = new Subject<void>();
153160

@@ -213,14 +220,24 @@ export class MatAutocompleteTrigger implements ControlValueAccessor, OnChanges,
213220
@Optional() @Inject(DOCUMENT) private _document: any,
214221
// @breaking-change 8.0.0 Make `_viewportRuler` required.
215222
private _viewportRuler?: ViewportRuler) {
223+
this._scrollStrategy = scrollStrategy;
224+
}
216225

226+
ngAfterViewInit() {
217227
if (typeof window !== 'undefined') {
218-
_zone.runOutsideAngular(() => {
228+
this._zone.runOutsideAngular(() => {
219229
window.addEventListener('blur', this._windowBlurHandler);
220230
});
221-
}
222231

223-
this._scrollStrategy = scrollStrategy;
232+
if (_supportsShadowDom()) {
233+
const element = this._element.nativeElement;
234+
const rootNode = element.getRootNode ? element.getRootNode() : null;
235+
236+
// We need to take the `ShadowRoot` off of `window`, because the built-in types are
237+
// incorrect. See https://github.com/Microsoft/TypeScript/issues/27929.
238+
this._isInsideShadowRoot = rootNode instanceof (window as any).ShadowRoot;
239+
}
240+
}
224241
}
225242

226243
ngOnChanges(changes: SimpleChanges) {
@@ -341,19 +358,20 @@ export class MatAutocompleteTrigger implements ControlValueAccessor, OnChanges,
341358
/** Stream of clicks outside of the autocomplete panel. */
342359
private _getOutsideClickStream(): Observable<any> {
343360
return merge(
344-
fromEvent(this._document, 'click') as Observable<MouseEvent>,
345-
fromEvent(this._document, 'touchend') as Observable<TouchEvent>
346-
)
347-
.pipe(filter(event => {
348-
const clickTarget = event.target as HTMLElement;
349-
const formField = this._formField ?
350-
this._formField._elementRef.nativeElement : null;
351-
352-
return this._overlayAttached &&
353-
clickTarget !== this._element.nativeElement &&
361+
fromEvent(this._document, 'click') as Observable<MouseEvent>,
362+
fromEvent(this._document, 'touchend') as Observable<TouchEvent>)
363+
.pipe(filter(event => {
364+
// If we're in the Shadow DOM the event target will be the shadow root so we have to fall
365+
// back to check the first element in the path of the click event.
366+
const clickTarget =
367+
(this._isInsideShadowRoot && event.composedPath ? event.composedPath()[0] :
368+
event.target) as HTMLElement;
369+
const formField = this._formField ? this._formField._elementRef.nativeElement : null;
370+
371+
return this._overlayAttached && clickTarget !== this._element.nativeElement &&
354372
(!formField || !formField.contains(clickTarget)) &&
355373
(!!this._overlayRef && !this._overlayRef.overlayElement.contains(clickTarget));
356-
}));
374+
}));
357375
}
358376

359377
// Implemented as part of ControlValueAccessor.

src/material/autocomplete/autocomplete.spec.ts

Lines changed: 72 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
import {Directionality} from '@angular/cdk/bidi';
22
import {DOWN_ARROW, ENTER, ESCAPE, SPACE, TAB, UP_ARROW} from '@angular/cdk/keycodes';
33
import {Overlay, OverlayContainer} from '@angular/cdk/overlay';
4-
import {MockNgZone} from '@angular/cdk/private/testing';
4+
import {_supportsShadowDom} from '@angular/cdk/platform';
55
import {ScrollDispatcher} from '@angular/cdk/scrolling';
6+
import {MockNgZone} from '@angular/cdk/private/testing';
67
import {
78
clearElement,
89
createKeyboardEvent,
@@ -22,6 +23,7 @@ import {
2223
Type,
2324
ViewChild,
2425
ViewChildren,
26+
ViewEncapsulation,
2527
} from '@angular/core';
2628
import {
2729
async,
@@ -39,7 +41,9 @@ import {By} from '@angular/platform-browser';
3941
import {NoopAnimationsModule} from '@angular/platform-browser/animations';
4042
import {EMPTY, Observable, Subject, Subscription} from 'rxjs';
4143
import {map, startWith} from 'rxjs/operators';
44+
4245
import {MatInputModule} from '../input/index';
46+
4347
import {
4448
getMatAutocompleteMissingPanelError,
4549
MAT_AUTOCOMPLETE_DEFAULT_OPTIONS,
@@ -514,6 +518,49 @@ describe('MatAutocomplete', () => {
514518

515519
});
516520

521+
it('should not close the panel when clicking on the input', fakeAsync(() => {
522+
const fixture = createComponent(SimpleAutocomplete);
523+
fixture.detectChanges();
524+
const input = fixture.debugElement.query(By.css('input')).nativeElement;
525+
526+
dispatchFakeEvent(input, 'focusin');
527+
fixture.detectChanges();
528+
zone.simulateZoneExit();
529+
530+
expect(fixture.componentInstance.trigger.panelOpen)
531+
.toBe(true, 'Expected panel to be opened on focus.');
532+
533+
input.click();
534+
fixture.detectChanges();
535+
536+
expect(fixture.componentInstance.trigger.panelOpen)
537+
.toBe(true, 'Expected panel to remain opened after clicking on the input.');
538+
}));
539+
540+
it('should not close the panel when clicking on the input inside shadow DOM', fakeAsync(() => {
541+
// This test is only relevant for Shadow DOM-capable browsers.
542+
if (!_supportsShadowDom()) {
543+
return;
544+
}
545+
546+
const fixture = createComponent(SimpleAutocompleteShadowDom);
547+
fixture.detectChanges();
548+
const input = fixture.debugElement.query(By.css('input')).nativeElement;
549+
550+
dispatchFakeEvent(input, 'focusin');
551+
fixture.detectChanges();
552+
zone.simulateZoneExit();
553+
554+
expect(fixture.componentInstance.trigger.panelOpen)
555+
.toBe(true, 'Expected panel to be opened on focus.');
556+
557+
input.click();
558+
fixture.detectChanges();
559+
560+
expect(fixture.componentInstance.trigger.panelOpen)
561+
.toBe(true, 'Expected panel to remain opened after clicking on the input.');
562+
}));
563+
517564
it('should have the correct text direction in RTL', () => {
518565
const rtlFixture = createComponent(SimpleAutocomplete, [
519566
{provide: Directionality, useFactory: () => ({value: 'rtl', change: EMPTY})},
@@ -2437,26 +2484,26 @@ describe('MatAutocomplete', () => {
24372484
}));
24382485
});
24392486

2440-
@Component({
2441-
template: `
2442-
<mat-form-field [floatLabel]="floatLabel" [style.width.px]="width">
2443-
<input
2444-
matInput
2445-
placeholder="State"
2446-
[matAutocomplete]="auto"
2447-
[matAutocompletePosition]="position"
2448-
[matAutocompleteDisabled]="autocompleteDisabled"
2449-
[formControl]="stateCtrl">
2450-
</mat-form-field>
2451-
2452-
<mat-autocomplete [class]="panelClass" #auto="matAutocomplete" [displayWith]="displayFn"
2453-
[disableRipple]="disableRipple" (opened)="openedSpy()" (closed)="closedSpy()">
2454-
<mat-option *ngFor="let state of filteredStates" [value]="state">
2455-
<span>{{ state.code }}: {{ state.name }}</span>
2456-
</mat-option>
2457-
</mat-autocomplete>
2458-
`
2459-
})
2487+
const SIMPLE_AUTOCOMPLETE_TEMPLATE = `
2488+
<mat-form-field [floatLabel]="floatLabel" [style.width.px]="width">
2489+
<input
2490+
matInput
2491+
placeholder="State"
2492+
[matAutocomplete]="auto"
2493+
[matAutocompletePosition]="position"
2494+
[matAutocompleteDisabled]="autocompleteDisabled"
2495+
[formControl]="stateCtrl">
2496+
</mat-form-field>
2497+
2498+
<mat-autocomplete [class]="panelClass" #auto="matAutocomplete" [displayWith]="displayFn"
2499+
[disableRipple]="disableRipple" (opened)="openedSpy()" (closed)="closedSpy()">
2500+
<mat-option *ngFor="let state of filteredStates" [value]="state">
2501+
<span>{{ state.code }}: {{ state.name }}</span>
2502+
</mat-option>
2503+
</mat-autocomplete>
2504+
`;
2505+
2506+
@Component({template: SIMPLE_AUTOCOMPLETE_TEMPLATE})
24602507
class SimpleAutocomplete implements OnDestroy {
24612508
stateCtrl = new FormControl();
24622509
filteredStates: any[];
@@ -2507,6 +2554,10 @@ class SimpleAutocomplete implements OnDestroy {
25072554
}
25082555
}
25092556

2557+
@Component({template: SIMPLE_AUTOCOMPLETE_TEMPLATE, encapsulation: ViewEncapsulation.ShadowDom})
2558+
class SimpleAutocompleteShadowDom extends SimpleAutocomplete {
2559+
}
2560+
25102561
@Component({
25112562
template: `
25122563
<mat-form-field *ngIf="isVisible">

tools/public_api_guard/cdk/platform.d.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
export declare function _supportsShadowDom(): boolean;
2+
13
export declare function getRtlScrollAxisType(): RtlScrollAxisType;
24

35
export declare function getSupportedInputTypes(): Set<string>;

tools/public_api_guard/material/autocomplete.d.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ export declare class MatAutocompleteSelectedEvent {
6969
option: MatOption);
7070
}
7171

72-
export declare class MatAutocompleteTrigger implements ControlValueAccessor, OnChanges, OnDestroy {
72+
export declare class MatAutocompleteTrigger implements ControlValueAccessor, AfterViewInit, OnChanges, OnDestroy {
7373
_onChange: (value: any) => void;
7474
_onTouched: () => void;
7575
readonly activeOption: MatOption | null;
@@ -86,6 +86,7 @@ export declare class MatAutocompleteTrigger implements ControlValueAccessor, OnC
8686
_handleInput(event: KeyboardEvent): void;
8787
_handleKeydown(event: KeyboardEvent): void;
8888
closePanel(): void;
89+
ngAfterViewInit(): void;
8990
ngOnChanges(changes: SimpleChanges): void;
9091
ngOnDestroy(): void;
9192
openPanel(): void;

0 commit comments

Comments
 (0)