Skip to content

Commit 144e0a5

Browse files
committed
feat(autocomplete): add the ability to set a different panel connection element
Currently the autocomplete panel gets attached to the trigger element, however this can be inflexible in the cases where people have a custom input wrapper. These changes add the ability to have an autocomplete be attached to an element that is different from the trigger. Fixes #11269.
1 parent ce0040d commit 144e0a5

File tree

5 files changed

+127
-18
lines changed

5 files changed

+127
-18
lines changed

src/lib/autocomplete/autocomplete-module.ts

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,13 +11,18 @@ import {CommonModule} from '@angular/common';
1111
import {OverlayModule} from '@angular/cdk/overlay';
1212
import {MatOptionModule, MatCommonModule} from '@angular/material/core';
1313
import {MatAutocomplete} from './autocomplete';
14-
import {
15-
MatAutocompleteTrigger,
16-
} from './autocomplete-trigger';
14+
import {MatAutocompleteTrigger} from './autocomplete-trigger';
15+
import {MatAutocompleteOrigin} from './autocomplete-origin';
1716

1817
@NgModule({
1918
imports: [MatOptionModule, OverlayModule, MatCommonModule, CommonModule],
20-
exports: [MatAutocomplete, MatOptionModule, MatAutocompleteTrigger, MatCommonModule],
21-
declarations: [MatAutocomplete, MatAutocompleteTrigger],
19+
exports: [
20+
MatAutocomplete,
21+
MatOptionModule,
22+
MatAutocompleteTrigger,
23+
MatAutocompleteOrigin,
24+
MatCommonModule
25+
],
26+
declarations: [MatAutocomplete, MatAutocompleteTrigger, MatAutocompleteOrigin],
2227
})
2328
export class MatAutocompleteModule {}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
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 {Directive, ElementRef} from '@angular/core';
10+
11+
/**
12+
* Directive applied to an element to make it usable
13+
* as a connection point for an autocomplete panel.
14+
*/
15+
@Directive({
16+
selector: '[matAutocompleteOrigin]',
17+
exportAs: 'matAutocompleteOrigin',
18+
})
19+
export class MatAutocompleteOrigin {
20+
constructor(
21+
/** Reference to the element on which the directive is applied. */
22+
public elementRef: ElementRef<HTMLElement>) { }
23+
}

src/lib/autocomplete/autocomplete-trigger.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ import {MatFormField} from '@angular/material/form-field';
4545
import {Subscription, defer, fromEvent, merge, of as observableOf, Subject, Observable} from 'rxjs';
4646
import {MatAutocomplete} from './autocomplete';
4747
import {coerceBooleanProperty} from '@angular/cdk/coercion';
48+
import {MatAutocompleteOrigin} from './autocomplete-origin';
4849

4950

5051
/**
@@ -91,6 +92,7 @@ export function getMatAutocompleteMissingPanelError(): Error {
9192
'you\'re attempting to open it after the ngAfterContentInit hook.');
9293
}
9394

95+
9496
@Directive({
9597
selector: `input[matAutocomplete], textarea[matAutocomplete]`,
9698
host: {
@@ -143,6 +145,12 @@ export class MatAutocompleteTrigger implements ControlValueAccessor, OnDestroy {
143145
/** The autocomplete panel to be attached to this trigger. */
144146
@Input('matAutocomplete') autocomplete: MatAutocomplete;
145147

148+
/**
149+
* Reference relative to which to position the autocomplete panel.
150+
* Defaults to the autocomplete trigger element.
151+
*/
152+
@Input('matAutocompleteConnectedTo') connectedTo: MatAutocompleteOrigin;
153+
146154
/**
147155
* Whether the autocomplete is disabled. When disabled, the element will
148156
* act as a regular input and the user won't be able to open the panel.
@@ -562,6 +570,10 @@ export class MatAutocompleteTrigger implements ControlValueAccessor, OnDestroy {
562570
}
563571

564572
private _getConnectedElement(): ElementRef {
573+
if (this.connectedTo) {
574+
return this.connectedTo.elementRef;
575+
}
576+
565577
return this._formField ? this._formField.getConnectedOverlayOrigin() : this._element;
566578
}
567579

src/lib/autocomplete/autocomplete.md

Lines changed: 31 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,10 @@ Start by adding a regular `matInput` to your template. Let's assume you're using
66
directive from `ReactiveFormsModule` to track the value of the input.
77

88
> Note: It is possible to use template-driven forms instead, if you prefer. We use reactive forms
9-
in this example because it makes subscribing to changes in the input's value easy. For this example, be sure to
10-
import `ReactiveFormsModule` from `@angular/forms` into your `NgModule`. If you are unfamiliar with using reactive
11-
forms, you can read more about the subject in the [Angular documentation](https://angular.io/guide/reactive-forms).
9+
in this example because it makes subscribing to changes in the input's value easy. For this
10+
example, be sure to import `ReactiveFormsModule` from `@angular/forms` into your `NgModule`.
11+
If you are unfamiliar with using reactive forms, you can read more about the subject in the
12+
[Angular documentation](https://angular.io/guide/reactive-forms).
1213

1314
*my-comp.html*
1415
```html
@@ -41,9 +42,7 @@ to the input's `matAutocomplete` property.
4142
</mat-form-field>
4243

4344
<mat-autocomplete #auto="matAutocomplete">
44-
<mat-option *ngFor="let option of options" [value]="option">
45-
{{ option }}
46-
</mat-option>
45+
<mat-option *ngFor="let option of options" [value]="option">{{option}}</mat-option>
4746
</mat-autocomplete>
4847
```
4948

@@ -61,8 +60,8 @@ option's first letter. We already have access to the built-in `valueChanges` Obs
6160
them through this filter. The resulting Observable, `filteredOptions`, can be added to the
6261
template in place of the `options` property using the `async` pipe.
6362

64-
Below we are also priming our value change stream with an empty string so that the options are filtered by
65-
that value on init (before there are any value changes).
63+
Below we are also priming our value change stream with an empty string so that the options are
64+
filtered by that value on init (before there are any value changes).
6665

6766
\*For optimal accessibility, you may want to consider adding text guidance on the page to explain
6867
filter criteria. This is especially helpful for screenreader users if you're using a non-standard
@@ -91,6 +90,27 @@ injection token.
9190

9291
<!-- example(autocomplete-auto-active-first-option) -->
9392

93+
### Attaching the autocomplete panel to a different element
94+
95+
By default the autocomplete panel will be attached to your input element, however in some cases you
96+
may want it to attach to a different container element. You can change the element that the
97+
autocomplete is attached to using the `matAutocompleteOrigin` directive together with the
98+
`matAutocompleteConnectedTo` input:
99+
100+
```html
101+
<div class="custom-wrapper-example" matAutocompleteOrigin #origin="matAutocompleteOrigin">
102+
<input
103+
matInput
104+
[formControl]="myControl"
105+
[matAutocomplete]="auto"
106+
[matAutocompleteConnectedTo]="origin">
107+
</div>
108+
109+
<mat-autocomplete #auto="matAutocomplete">
110+
<mat-option *ngFor="let option of options" [value]="option">{{option}}</mat-option>
111+
</mat-autocomplete>
112+
```
113+
94114
### Keyboard interaction
95115
- <kbd>DOWN_ARROW</kbd>: Next option becomes active.
96116
- <kbd>UP_ARROW</kbd>: Previous option becomes active.
@@ -105,7 +125,7 @@ injection token.
105125
<mat-autocomplete #auto="matAutocomplete">
106126
<mat-optgroup *ngFor="let group of filteredGroups | async" [label]="group.name">
107127
<mat-option *ngFor="let option of group.options" [value]="option">
108-
{{ option.name }}
128+
{{option.name}}
109129
</mat-option>
110130
</mat-optgroup>
111131
</mat-autocomplete>
@@ -115,5 +135,5 @@ injection token.
115135
The input for an autocomplete without text or labels should be given a meaningful label via
116136
`aria-label` or `aria-labelledby`.
117137

118-
The autocomplete trigger is given `role="combobox"`. The trigger sets `aria-owns` to the autocomplete's
119-
id, and sets `aria-activedescendant` to the active option's id.
138+
The autocomplete trigger is given `role="combobox"`. The trigger sets `aria-owns` to the
139+
autocomplete's id, and sets `aria-activedescendant` to the active option's id.

src/lib/autocomplete/autocomplete.spec.ts

Lines changed: 51 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import {
1919
QueryList,
2020
ViewChild,
2121
ViewChildren,
22+
Type,
2223
} from '@angular/core';
2324
import {
2425
async,
@@ -54,7 +55,7 @@ describe('MatAutocomplete', () => {
5455
let zone: MockNgZone;
5556

5657
// Creates a test component fixture.
57-
function createComponent(component: any, providers: Provider[] = []): ComponentFixture<any> {
58+
function createComponent<T>(component: Type<T>, providers: Provider[] = []) {
5859
TestBed.configureTestingModule({
5960
imports: [
6061
MatAutocompleteModule,
@@ -78,7 +79,7 @@ describe('MatAutocomplete', () => {
7879
overlayContainerElement = oc.getContainerElement();
7980
})();
8081

81-
return TestBed.createComponent(component);
82+
return TestBed.createComponent<T>(component);
8283
}
8384

8485
afterEach(inject([OverlayContainer], (currentOverlayContainer: OverlayContainer) => {
@@ -1978,6 +1979,22 @@ describe('MatAutocomplete', () => {
19781979
expect(event.source).toBe(fixture.componentInstance.autocomplete);
19791980
expect(event.option.value).toBe('Puerto Rico');
19801981
}));
1982+
1983+
it('should be able to set a custom panel connection element', () => {
1984+
const fixture = createComponent(AutocompleteWithDifferentOrigin);
1985+
1986+
fixture.detectChanges();
1987+
fixture.componentInstance.trigger.openPanel();
1988+
fixture.detectChanges();
1989+
zone.simulateZoneExit();
1990+
1991+
const overlayRect =
1992+
overlayContainerElement.querySelector('.cdk-overlay-pane')!.getBoundingClientRect();
1993+
const originRect = fixture.nativeElement.querySelector('.origin').getBoundingClientRect();
1994+
1995+
expect(Math.floor(overlayRect.top)).toBe(Math.floor(originRect.bottom),
1996+
'Expected autocomplete panel to align with the bottom of the new origin.');
1997+
});
19811998
});
19821999

19832000
@Component({
@@ -2315,3 +2332,35 @@ class AutocompleteWithNumberInputAndNgModel {
23152332
selectedValue: number;
23162333
values = [1, 2, 3];
23172334
}
2335+
2336+
2337+
@Component({
2338+
template: `
2339+
<div>
2340+
<mat-form-field>
2341+
<input
2342+
matInput
2343+
[matAutocomplete]="auto"
2344+
[matAutocompleteConnectedTo]="origin"
2345+
[(ngModel)]="selectedValue">
2346+
</mat-form-field>
2347+
</div>
2348+
2349+
<div
2350+
class="origin"
2351+
matAutocompleteOrigin
2352+
#origin="matAutocompleteOrigin"
2353+
style="margin-top: 50px">
2354+
Connection element
2355+
</div>
2356+
2357+
<mat-autocomplete #auto="matAutocomplete">
2358+
<mat-option *ngFor="let value of values" [value]="value">{{value}}</mat-option>
2359+
</mat-autocomplete>
2360+
`
2361+
})
2362+
class AutocompleteWithDifferentOrigin {
2363+
@ViewChild(MatAutocompleteTrigger) trigger: MatAutocompleteTrigger;
2364+
selectedValue: string;
2365+
values = ['one', 'two', 'three'];
2366+
}

0 commit comments

Comments
 (0)