From 144e0a58c0c7c140e238ae223fb926b77f2ea7ab Mon Sep 17 00:00:00 2001 From: crisbeto Date: Sat, 12 May 2018 09:44:37 +0200 Subject: [PATCH] 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. --- src/lib/autocomplete/autocomplete-module.ts | 15 ++++-- src/lib/autocomplete/autocomplete-origin.ts | 23 +++++++++ src/lib/autocomplete/autocomplete-trigger.ts | 12 +++++ src/lib/autocomplete/autocomplete.md | 42 ++++++++++++---- src/lib/autocomplete/autocomplete.spec.ts | 53 +++++++++++++++++++- 5 files changed, 127 insertions(+), 18 deletions(-) create mode 100644 src/lib/autocomplete/autocomplete-origin.ts diff --git a/src/lib/autocomplete/autocomplete-module.ts b/src/lib/autocomplete/autocomplete-module.ts index 9a773cfc812e..9e92a246a041 100644 --- a/src/lib/autocomplete/autocomplete-module.ts +++ b/src/lib/autocomplete/autocomplete-module.ts @@ -11,13 +11,18 @@ import {CommonModule} from '@angular/common'; import {OverlayModule} from '@angular/cdk/overlay'; import {MatOptionModule, MatCommonModule} from '@angular/material/core'; import {MatAutocomplete} from './autocomplete'; -import { - MatAutocompleteTrigger, -} from './autocomplete-trigger'; +import {MatAutocompleteTrigger} from './autocomplete-trigger'; +import {MatAutocompleteOrigin} from './autocomplete-origin'; @NgModule({ imports: [MatOptionModule, OverlayModule, MatCommonModule, CommonModule], - exports: [MatAutocomplete, MatOptionModule, MatAutocompleteTrigger, MatCommonModule], - declarations: [MatAutocomplete, MatAutocompleteTrigger], + exports: [ + MatAutocomplete, + MatOptionModule, + MatAutocompleteTrigger, + MatAutocompleteOrigin, + MatCommonModule + ], + declarations: [MatAutocomplete, MatAutocompleteTrigger, MatAutocompleteOrigin], }) export class MatAutocompleteModule {} diff --git a/src/lib/autocomplete/autocomplete-origin.ts b/src/lib/autocomplete/autocomplete-origin.ts new file mode 100644 index 000000000000..abb23e1887a2 --- /dev/null +++ b/src/lib/autocomplete/autocomplete-origin.ts @@ -0,0 +1,23 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {Directive, ElementRef} from '@angular/core'; + +/** + * Directive applied to an element to make it usable + * as a connection point for an autocomplete panel. + */ +@Directive({ + selector: '[matAutocompleteOrigin]', + exportAs: 'matAutocompleteOrigin', +}) +export class MatAutocompleteOrigin { + constructor( + /** Reference to the element on which the directive is applied. */ + public elementRef: ElementRef) { } +} diff --git a/src/lib/autocomplete/autocomplete-trigger.ts b/src/lib/autocomplete/autocomplete-trigger.ts index ca254dd6abfc..9ca2210b4c16 100644 --- a/src/lib/autocomplete/autocomplete-trigger.ts +++ b/src/lib/autocomplete/autocomplete-trigger.ts @@ -45,6 +45,7 @@ import {MatFormField} from '@angular/material/form-field'; import {Subscription, defer, fromEvent, merge, of as observableOf, Subject, Observable} from 'rxjs'; import {MatAutocomplete} from './autocomplete'; import {coerceBooleanProperty} from '@angular/cdk/coercion'; +import {MatAutocompleteOrigin} from './autocomplete-origin'; /** @@ -91,6 +92,7 @@ export function getMatAutocompleteMissingPanelError(): Error { 'you\'re attempting to open it after the ngAfterContentInit hook.'); } + @Directive({ selector: `input[matAutocomplete], textarea[matAutocomplete]`, host: { @@ -143,6 +145,12 @@ export class MatAutocompleteTrigger implements ControlValueAccessor, OnDestroy { /** The autocomplete panel to be attached to this trigger. */ @Input('matAutocomplete') autocomplete: MatAutocomplete; + /** + * Reference relative to which to position the autocomplete panel. + * Defaults to the autocomplete trigger element. + */ + @Input('matAutocompleteConnectedTo') connectedTo: MatAutocompleteOrigin; + /** * Whether the autocomplete is disabled. When disabled, the element will * 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 { } private _getConnectedElement(): ElementRef { + if (this.connectedTo) { + return this.connectedTo.elementRef; + } + return this._formField ? this._formField.getConnectedOverlayOrigin() : this._element; } diff --git a/src/lib/autocomplete/autocomplete.md b/src/lib/autocomplete/autocomplete.md index 68924e40b80b..73d618ae7222 100644 --- a/src/lib/autocomplete/autocomplete.md +++ b/src/lib/autocomplete/autocomplete.md @@ -6,9 +6,10 @@ Start by adding a regular `matInput` to your template. Let's assume you're using directive from `ReactiveFormsModule` to track the value of the input. > Note: It is possible to use template-driven forms instead, if you prefer. We use reactive forms -in this example because it makes subscribing to changes in the input's value easy. For this example, be sure to -import `ReactiveFormsModule` from `@angular/forms` into your `NgModule`. If you are unfamiliar with using reactive -forms, you can read more about the subject in the [Angular documentation](https://angular.io/guide/reactive-forms). +in this example because it makes subscribing to changes in the input's value easy. For this +example, be sure to import `ReactiveFormsModule` from `@angular/forms` into your `NgModule`. +If you are unfamiliar with using reactive forms, you can read more about the subject in the +[Angular documentation](https://angular.io/guide/reactive-forms). *my-comp.html* ```html @@ -41,9 +42,7 @@ to the input's `matAutocomplete` property. - - {{ option }} - + {{option}} ``` @@ -61,8 +60,8 @@ option's first letter. We already have access to the built-in `valueChanges` Obs them through this filter. The resulting Observable, `filteredOptions`, can be added to the template in place of the `options` property using the `async` pipe. -Below we are also priming our value change stream with an empty string so that the options are filtered by -that value on init (before there are any value changes). +Below we are also priming our value change stream with an empty string so that the options are +filtered by that value on init (before there are any value changes). \*For optimal accessibility, you may want to consider adding text guidance on the page to explain filter criteria. This is especially helpful for screenreader users if you're using a non-standard @@ -91,6 +90,27 @@ injection token. +### Attaching the autocomplete panel to a different element + +By default the autocomplete panel will be attached to your input element, however in some cases you +may want it to attach to a different container element. You can change the element that the +autocomplete is attached to using the `matAutocompleteOrigin` directive together with the +`matAutocompleteConnectedTo` input: + +```html +
+ +
+ + + {{option}} + +``` + ### Keyboard interaction - DOWN_ARROW: Next option becomes active. - UP_ARROW: Previous option becomes active. @@ -105,7 +125,7 @@ injection token. - {{ option.name }} + {{option.name}} @@ -115,5 +135,5 @@ injection token. The input for an autocomplete without text or labels should be given a meaningful label via `aria-label` or `aria-labelledby`. -The autocomplete trigger is given `role="combobox"`. The trigger sets `aria-owns` to the autocomplete's -id, and sets `aria-activedescendant` to the active option's id. +The autocomplete trigger is given `role="combobox"`. The trigger sets `aria-owns` to the +autocomplete's id, and sets `aria-activedescendant` to the active option's id. diff --git a/src/lib/autocomplete/autocomplete.spec.ts b/src/lib/autocomplete/autocomplete.spec.ts index a9b6331fcf03..c580014d7215 100644 --- a/src/lib/autocomplete/autocomplete.spec.ts +++ b/src/lib/autocomplete/autocomplete.spec.ts @@ -19,6 +19,7 @@ import { QueryList, ViewChild, ViewChildren, + Type, } from '@angular/core'; import { async, @@ -54,7 +55,7 @@ describe('MatAutocomplete', () => { let zone: MockNgZone; // Creates a test component fixture. - function createComponent(component: any, providers: Provider[] = []): ComponentFixture { + function createComponent(component: Type, providers: Provider[] = []) { TestBed.configureTestingModule({ imports: [ MatAutocompleteModule, @@ -78,7 +79,7 @@ describe('MatAutocomplete', () => { overlayContainerElement = oc.getContainerElement(); })(); - return TestBed.createComponent(component); + return TestBed.createComponent(component); } afterEach(inject([OverlayContainer], (currentOverlayContainer: OverlayContainer) => { @@ -1978,6 +1979,22 @@ describe('MatAutocomplete', () => { expect(event.source).toBe(fixture.componentInstance.autocomplete); expect(event.option.value).toBe('Puerto Rico'); })); + + it('should be able to set a custom panel connection element', () => { + const fixture = createComponent(AutocompleteWithDifferentOrigin); + + fixture.detectChanges(); + fixture.componentInstance.trigger.openPanel(); + fixture.detectChanges(); + zone.simulateZoneExit(); + + const overlayRect = + overlayContainerElement.querySelector('.cdk-overlay-pane')!.getBoundingClientRect(); + const originRect = fixture.nativeElement.querySelector('.origin').getBoundingClientRect(); + + expect(Math.floor(overlayRect.top)).toBe(Math.floor(originRect.bottom), + 'Expected autocomplete panel to align with the bottom of the new origin.'); + }); }); @Component({ @@ -2315,3 +2332,35 @@ class AutocompleteWithNumberInputAndNgModel { selectedValue: number; values = [1, 2, 3]; } + + +@Component({ + template: ` +
+ + + +
+ +
+ Connection element +
+ + + {{value}} + + ` +}) +class AutocompleteWithDifferentOrigin { + @ViewChild(MatAutocompleteTrigger) trigger: MatAutocompleteTrigger; + selectedValue: string; + values = ['one', 'two', 'three']; +}