Skip to content

Commit 5aaca54

Browse files
crisbetojelbourn
authored andcommitted
feat(autocomplete): add input to control position (#15834)
Adds an input that allows the consumer to control the autocomplete panel's position. In some cases our automatic positioning might not be appropriate and currently there's no way for consumers to override it. Fixes #15640.
1 parent 00226f0 commit 5aaca54

File tree

3 files changed

+158
-27
lines changed

3 files changed

+158
-27
lines changed

src/material/autocomplete/autocomplete-trigger.ts

Lines changed: 62 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
OverlayRef,
1515
PositionStrategy,
1616
ScrollStrategy,
17+
ConnectedPosition,
1718
} from '@angular/cdk/overlay';
1819
import {TemplatePortal} from '@angular/cdk/portal';
1920
import {DOCUMENT} from '@angular/common';
@@ -31,6 +32,8 @@ import {
3132
OnDestroy,
3233
Optional,
3334
ViewContainerRef,
35+
OnChanges,
36+
SimpleChanges,
3437
} from '@angular/core';
3538
import {ViewportRuler} from '@angular/cdk/scrolling';
3639
import {ControlValueAccessor, NG_VALUE_ACCESSOR} from '@angular/forms';
@@ -116,7 +119,7 @@ export function getMatAutocompleteMissingPanelError(): Error {
116119
exportAs: 'matAutocompleteTrigger',
117120
providers: [MAT_AUTOCOMPLETE_VALUE_ACCESSOR]
118121
})
119-
export class MatAutocompleteTrigger implements ControlValueAccessor, OnDestroy {
122+
export class MatAutocompleteTrigger implements ControlValueAccessor, OnChanges, OnDestroy {
120123
private _overlayRef: OverlayRef | null;
121124
private _portal: TemplatePortal;
122125
private _componentDestroyed = false;
@@ -169,6 +172,15 @@ export class MatAutocompleteTrigger implements ControlValueAccessor, OnDestroy {
169172
/** The autocomplete panel to be attached to this trigger. */
170173
@Input('matAutocomplete') autocomplete: MatAutocomplete;
171174

175+
/**
176+
* Position of the autocomplete panel relative to the trigger element. A position of `auto`
177+
* will render the panel underneath the trigger if there is enough space for it to fit in
178+
* the viewport, otherwise the panel will be shown above it. If the position is set to
179+
* `above` or `below`, the panel will always be shown above or below the trigger. no matter
180+
* whether it fits completely in the viewport.
181+
*/
182+
@Input('matAutocompletePosition') position: 'auto' | 'above' | 'below' = 'auto';
183+
172184
/**
173185
* Reference relative to which to position the autocomplete panel.
174186
* Defaults to the autocomplete trigger element.
@@ -211,6 +223,16 @@ export class MatAutocompleteTrigger implements ControlValueAccessor, OnDestroy {
211223
this._scrollStrategy = scrollStrategy;
212224
}
213225

226+
ngOnChanges(changes: SimpleChanges) {
227+
if (changes['position'] && this._positionStrategy) {
228+
this._setStrategyPositions(this._positionStrategy);
229+
230+
if (this.panelOpen) {
231+
this._overlayRef!.updatePosition();
232+
}
233+
}
234+
}
235+
214236
ngOnDestroy() {
215237
if (typeof window !== 'undefined') {
216238
window.removeEventListener('blur', this._windowBlurHandler);
@@ -596,10 +618,8 @@ export class MatAutocompleteTrigger implements ControlValueAccessor, OnDestroy {
596618
});
597619
}
598620
} else {
599-
const position = overlayRef.getConfig().positionStrategy as FlexibleConnectedPositionStrategy;
600-
601621
// Update the trigger, panel width and direction, in case anything has changed.
602-
position.setOrigin(this._getConnectedElement());
622+
this._positionStrategy.setOrigin(this._getConnectedElement());
603623
overlayRef.updateSize({width: this._getPanelWidth()});
604624
}
605625

@@ -630,31 +650,47 @@ export class MatAutocompleteTrigger implements ControlValueAccessor, OnDestroy {
630650
}
631651

632652
private _getOverlayPosition(): PositionStrategy {
633-
this._positionStrategy = this._overlay.position()
653+
const strategy = this._overlay.position()
634654
.flexibleConnectedTo(this._getConnectedElement())
635655
.withFlexibleDimensions(false)
636-
.withPush(false)
637-
.withPositions([
638-
{
639-
originX: 'start',
640-
originY: 'bottom',
641-
overlayX: 'start',
642-
overlayY: 'top'
643-
},
644-
{
645-
originX: 'start',
646-
originY: 'top',
647-
overlayX: 'start',
648-
overlayY: 'bottom',
649-
650-
// The overlay edge connected to the trigger should have squared corners, while
651-
// the opposite end has rounded corners. We apply a CSS class to swap the
652-
// border-radius based on the overlay position.
653-
panelClass: 'mat-autocomplete-panel-above'
654-
}
655-
]);
656+
.withPush(false);
657+
658+
this._setStrategyPositions(strategy);
659+
this._positionStrategy = strategy;
660+
return strategy;
661+
}
662+
663+
/** Sets the positions on a position strategy based on the directive's input state. */
664+
private _setStrategyPositions(positionStrategy: FlexibleConnectedPositionStrategy) {
665+
const belowPosition: ConnectedPosition = {
666+
originX: 'start',
667+
originY: 'bottom',
668+
overlayX: 'start',
669+
overlayY: 'top'
670+
};
671+
const abovePosition: ConnectedPosition = {
672+
originX: 'start',
673+
originY: 'top',
674+
overlayX: 'start',
675+
overlayY: 'bottom',
676+
677+
// The overlay edge connected to the trigger should have squared corners, while
678+
// the opposite end has rounded corners. We apply a CSS class to swap the
679+
// border-radius based on the overlay position.
680+
panelClass: 'mat-autocomplete-panel-above'
681+
};
682+
683+
let positions: ConnectedPosition[];
684+
685+
if (this.position === 'above') {
686+
positions = [abovePosition];
687+
} else if (this.position === 'below') {
688+
positions = [belowPosition];
689+
} else {
690+
positions = [belowPosition, abovePosition];
691+
}
656692

657-
return this._positionStrategy;
693+
positionStrategy.withPositions(positions);
658694
}
659695

660696
private _getConnectedElement(): ElementRef {

src/material/autocomplete/autocomplete.spec.ts

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1581,6 +1581,97 @@ describe('MatAutocomplete', () => {
15811581

15821582
expect(() => fixture.componentInstance.trigger.updatePosition()).not.toThrow();
15831583
});
1584+
1585+
it('should be able to force below position even if there is not enough space', fakeAsync(() => {
1586+
let fixture = createComponent(SimpleAutocomplete);
1587+
fixture.componentInstance.position = 'below';
1588+
fixture.detectChanges();
1589+
let inputReference = fixture.debugElement.query(By.css('.mat-form-field-flex')).nativeElement;
1590+
1591+
// Push the autocomplete trigger down so it won't have room to open below.
1592+
inputReference.style.bottom = '0';
1593+
inputReference.style.position = 'fixed';
1594+
1595+
fixture.componentInstance.trigger.openPanel();
1596+
fixture.detectChanges();
1597+
zone.simulateZoneExit();
1598+
fixture.detectChanges();
1599+
1600+
const inputBottom = inputReference.getBoundingClientRect().bottom;
1601+
const panel = overlayContainerElement.querySelector('.cdk-overlay-pane')!;
1602+
const panelTop = panel.getBoundingClientRect().top;
1603+
1604+
expect(Math.floor(inputBottom))
1605+
.toEqual(Math.floor(panelTop), 'Expected panel to be below the input.');
1606+
1607+
expect(panel.classList).not.toContain('mat-autocomplete-panel-above');
1608+
}));
1609+
1610+
it('should be able to force above position even if there is not enough space', fakeAsync(() => {
1611+
let fixture = createComponent(SimpleAutocomplete);
1612+
fixture.componentInstance.position = 'above';
1613+
fixture.detectChanges();
1614+
let inputReference = fixture.debugElement.query(By.css('.mat-form-field-flex')).nativeElement;
1615+
1616+
// Push the autocomplete trigger up so it won't have room to open above.
1617+
inputReference.style.top = '0';
1618+
inputReference.style.position = 'fixed';
1619+
1620+
fixture.componentInstance.trigger.openPanel();
1621+
fixture.detectChanges();
1622+
zone.simulateZoneExit();
1623+
fixture.detectChanges();
1624+
1625+
const inputTop = inputReference.getBoundingClientRect().top;
1626+
const panel = overlayContainerElement.querySelector('.cdk-overlay-pane')!;
1627+
const panelBottom = panel.getBoundingClientRect().bottom;
1628+
1629+
expect(Math.floor(inputTop))
1630+
.toEqual(Math.floor(panelBottom), 'Expected panel to be above the input.');
1631+
1632+
expect(panel.classList).toContain('mat-autocomplete-panel-above');
1633+
}));
1634+
1635+
it('should handle the position being changed after the first open', fakeAsync(() => {
1636+
let fixture = createComponent(SimpleAutocomplete);
1637+
fixture.detectChanges();
1638+
let inputReference = fixture.debugElement.query(By.css('.mat-form-field-flex')).nativeElement;
1639+
let openPanel = () => {
1640+
fixture.componentInstance.trigger.openPanel();
1641+
fixture.detectChanges();
1642+
zone.simulateZoneExit();
1643+
fixture.detectChanges();
1644+
};
1645+
1646+
// Push the autocomplete trigger down so it won't have room to open below.
1647+
inputReference.style.bottom = '0';
1648+
inputReference.style.position = 'fixed';
1649+
openPanel();
1650+
1651+
let inputRect = inputReference.getBoundingClientRect();
1652+
let panel = overlayContainerElement.querySelector('.cdk-overlay-pane')!;
1653+
let panelRect = panel.getBoundingClientRect();
1654+
1655+
expect(Math.floor(inputRect.top))
1656+
.toEqual(Math.floor(panelRect.bottom), 'Expected panel to be above the input.');
1657+
expect(panel.classList).toContain('mat-autocomplete-panel-above');
1658+
1659+
fixture.componentInstance.trigger.closePanel();
1660+
fixture.detectChanges();
1661+
1662+
fixture.componentInstance.position = 'below';
1663+
fixture.detectChanges();
1664+
openPanel();
1665+
1666+
inputRect = inputReference.getBoundingClientRect();
1667+
panel = overlayContainerElement.querySelector('.cdk-overlay-pane')!;
1668+
panelRect = panel.getBoundingClientRect();
1669+
1670+
expect(Math.floor(inputRect.bottom))
1671+
.toEqual(Math.floor(panelRect.top), 'Expected panel to be below the input.');
1672+
expect(panel.classList).not.toContain('mat-autocomplete-panel-above');
1673+
}));
1674+
15841675
});
15851676

15861677
describe('Option selection', () => {
@@ -2328,6 +2419,7 @@ describe('MatAutocomplete', () => {
23282419
matInput
23292420
placeholder="State"
23302421
[matAutocomplete]="auto"
2422+
[matAutocompletePosition]="position"
23312423
[matAutocompleteDisabled]="autocompleteDisabled"
23322424
[formControl]="stateCtrl">
23332425
</mat-form-field>
@@ -2345,6 +2437,7 @@ class SimpleAutocomplete implements OnDestroy {
23452437
filteredStates: any[];
23462438
valueSub: Subscription;
23472439
floatLabel = 'auto';
2440+
position = 'auto';
23482441
width: number;
23492442
disableRipple = false;
23502443
autocompleteDisabled = false;

tools/public_api_guard/material/autocomplete.d.ts

Lines changed: 3 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, OnDestroy {
72+
export declare class MatAutocompleteTrigger implements ControlValueAccessor, OnChanges, OnDestroy {
7373
_onChange: (value: any) => void;
7474
_onTouched: () => void;
7575
readonly activeOption: MatOption | null;
@@ -80,11 +80,13 @@ export declare class MatAutocompleteTrigger implements ControlValueAccessor, OnD
8080
readonly optionSelections: Observable<MatOptionSelectionChange>;
8181
readonly panelClosingActions: Observable<MatOptionSelectionChange | null>;
8282
readonly panelOpen: boolean;
83+
position: 'auto' | 'above' | 'below';
8384
constructor(_element: ElementRef<HTMLInputElement>, _overlay: Overlay, _viewContainerRef: ViewContainerRef, _zone: NgZone, _changeDetectorRef: ChangeDetectorRef, scrollStrategy: any, _dir: Directionality, _formField: MatFormField, _document: any, _viewportRuler?: ViewportRuler | undefined);
8485
_handleFocus(): void;
8586
_handleInput(event: KeyboardEvent): void;
8687
_handleKeydown(event: KeyboardEvent): void;
8788
closePanel(): void;
89+
ngOnChanges(changes: SimpleChanges): void;
8890
ngOnDestroy(): void;
8991
openPanel(): void;
9092
registerOnChange(fn: (value: any) => {}): void;

0 commit comments

Comments
 (0)