Skip to content

Commit

Permalink
feat(autocomplete): add input to control position (angular#15834)
Browse files Browse the repository at this point in the history
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 angular#15640.
  • Loading branch information
crisbeto authored and RudolfFrederiksen committed Jun 21, 2019
1 parent 22c4f19 commit ef0b733
Show file tree
Hide file tree
Showing 3 changed files with 158 additions and 27 deletions.
88 changes: 62 additions & 26 deletions src/material/autocomplete/autocomplete-trigger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
OverlayRef,
PositionStrategy,
ScrollStrategy,
ConnectedPosition,
} from '@angular/cdk/overlay';
import {TemplatePortal} from '@angular/cdk/portal';
import {DOCUMENT} from '@angular/common';
Expand All @@ -31,6 +32,8 @@ import {
OnDestroy,
Optional,
ViewContainerRef,
OnChanges,
SimpleChanges,
} from '@angular/core';
import {ViewportRuler} from '@angular/cdk/scrolling';
import {ControlValueAccessor, NG_VALUE_ACCESSOR} from '@angular/forms';
Expand Down Expand Up @@ -116,7 +119,7 @@ export function getMatAutocompleteMissingPanelError(): Error {
exportAs: 'matAutocompleteTrigger',
providers: [MAT_AUTOCOMPLETE_VALUE_ACCESSOR]
})
export class MatAutocompleteTrigger implements ControlValueAccessor, OnDestroy {
export class MatAutocompleteTrigger implements ControlValueAccessor, OnChanges, OnDestroy {
private _overlayRef: OverlayRef | null;
private _portal: TemplatePortal;
private _componentDestroyed = false;
Expand Down Expand Up @@ -169,6 +172,15 @@ export class MatAutocompleteTrigger implements ControlValueAccessor, OnDestroy {
/** The autocomplete panel to be attached to this trigger. */
@Input('matAutocomplete') autocomplete: MatAutocomplete;

/**
* Position of the autocomplete panel relative to the trigger element. A position of `auto`
* will render the panel underneath the trigger if there is enough space for it to fit in
* the viewport, otherwise the panel will be shown above it. If the position is set to
* `above` or `below`, the panel will always be shown above or below the trigger. no matter
* whether it fits completely in the viewport.
*/
@Input('matAutocompletePosition') position: 'auto' | 'above' | 'below' = 'auto';

/**
* Reference relative to which to position the autocomplete panel.
* Defaults to the autocomplete trigger element.
Expand Down Expand Up @@ -211,6 +223,16 @@ export class MatAutocompleteTrigger implements ControlValueAccessor, OnDestroy {
this._scrollStrategy = scrollStrategy;
}

ngOnChanges(changes: SimpleChanges) {
if (changes['position'] && this._positionStrategy) {
this._setStrategyPositions(this._positionStrategy);

if (this.panelOpen) {
this._overlayRef!.updatePosition();
}
}
}

ngOnDestroy() {
if (typeof window !== 'undefined') {
window.removeEventListener('blur', this._windowBlurHandler);
Expand Down Expand Up @@ -596,10 +618,8 @@ export class MatAutocompleteTrigger implements ControlValueAccessor, OnDestroy {
});
}
} else {
const position = overlayRef.getConfig().positionStrategy as FlexibleConnectedPositionStrategy;

// Update the trigger, panel width and direction, in case anything has changed.
position.setOrigin(this._getConnectedElement());
this._positionStrategy.setOrigin(this._getConnectedElement());
overlayRef.updateSize({width: this._getPanelWidth()});
}

Expand Down Expand Up @@ -630,31 +650,47 @@ export class MatAutocompleteTrigger implements ControlValueAccessor, OnDestroy {
}

private _getOverlayPosition(): PositionStrategy {
this._positionStrategy = this._overlay.position()
const strategy = this._overlay.position()
.flexibleConnectedTo(this._getConnectedElement())
.withFlexibleDimensions(false)
.withPush(false)
.withPositions([
{
originX: 'start',
originY: 'bottom',
overlayX: 'start',
overlayY: 'top'
},
{
originX: 'start',
originY: 'top',
overlayX: 'start',
overlayY: 'bottom',

// The overlay edge connected to the trigger should have squared corners, while
// the opposite end has rounded corners. We apply a CSS class to swap the
// border-radius based on the overlay position.
panelClass: 'mat-autocomplete-panel-above'
}
]);
.withPush(false);

this._setStrategyPositions(strategy);
this._positionStrategy = strategy;
return strategy;
}

/** Sets the positions on a position strategy based on the directive's input state. */
private _setStrategyPositions(positionStrategy: FlexibleConnectedPositionStrategy) {
const belowPosition: ConnectedPosition = {
originX: 'start',
originY: 'bottom',
overlayX: 'start',
overlayY: 'top'
};
const abovePosition: ConnectedPosition = {
originX: 'start',
originY: 'top',
overlayX: 'start',
overlayY: 'bottom',

// The overlay edge connected to the trigger should have squared corners, while
// the opposite end has rounded corners. We apply a CSS class to swap the
// border-radius based on the overlay position.
panelClass: 'mat-autocomplete-panel-above'
};

let positions: ConnectedPosition[];

if (this.position === 'above') {
positions = [abovePosition];
} else if (this.position === 'below') {
positions = [belowPosition];
} else {
positions = [belowPosition, abovePosition];
}

return this._positionStrategy;
positionStrategy.withPositions(positions);
}

private _getConnectedElement(): ElementRef {
Expand Down
93 changes: 93 additions & 0 deletions src/material/autocomplete/autocomplete.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1581,6 +1581,97 @@ describe('MatAutocomplete', () => {

expect(() => fixture.componentInstance.trigger.updatePosition()).not.toThrow();
});

it('should be able to force below position even if there is not enough space', fakeAsync(() => {
let fixture = createComponent(SimpleAutocomplete);
fixture.componentInstance.position = 'below';
fixture.detectChanges();
let inputReference = fixture.debugElement.query(By.css('.mat-form-field-flex')).nativeElement;

// Push the autocomplete trigger down so it won't have room to open below.
inputReference.style.bottom = '0';
inputReference.style.position = 'fixed';

fixture.componentInstance.trigger.openPanel();
fixture.detectChanges();
zone.simulateZoneExit();
fixture.detectChanges();

const inputBottom = inputReference.getBoundingClientRect().bottom;
const panel = overlayContainerElement.querySelector('.cdk-overlay-pane')!;
const panelTop = panel.getBoundingClientRect().top;

expect(Math.floor(inputBottom))
.toEqual(Math.floor(panelTop), 'Expected panel to be below the input.');

expect(panel.classList).not.toContain('mat-autocomplete-panel-above');
}));

it('should be able to force above position even if there is not enough space', fakeAsync(() => {
let fixture = createComponent(SimpleAutocomplete);
fixture.componentInstance.position = 'above';
fixture.detectChanges();
let inputReference = fixture.debugElement.query(By.css('.mat-form-field-flex')).nativeElement;

// Push the autocomplete trigger up so it won't have room to open above.
inputReference.style.top = '0';
inputReference.style.position = 'fixed';

fixture.componentInstance.trigger.openPanel();
fixture.detectChanges();
zone.simulateZoneExit();
fixture.detectChanges();

const inputTop = inputReference.getBoundingClientRect().top;
const panel = overlayContainerElement.querySelector('.cdk-overlay-pane')!;
const panelBottom = panel.getBoundingClientRect().bottom;

expect(Math.floor(inputTop))
.toEqual(Math.floor(panelBottom), 'Expected panel to be above the input.');

expect(panel.classList).toContain('mat-autocomplete-panel-above');
}));

it('should handle the position being changed after the first open', fakeAsync(() => {
let fixture = createComponent(SimpleAutocomplete);
fixture.detectChanges();
let inputReference = fixture.debugElement.query(By.css('.mat-form-field-flex')).nativeElement;
let openPanel = () => {
fixture.componentInstance.trigger.openPanel();
fixture.detectChanges();
zone.simulateZoneExit();
fixture.detectChanges();
};

// Push the autocomplete trigger down so it won't have room to open below.
inputReference.style.bottom = '0';
inputReference.style.position = 'fixed';
openPanel();

let inputRect = inputReference.getBoundingClientRect();
let panel = overlayContainerElement.querySelector('.cdk-overlay-pane')!;
let panelRect = panel.getBoundingClientRect();

expect(Math.floor(inputRect.top))
.toEqual(Math.floor(panelRect.bottom), 'Expected panel to be above the input.');
expect(panel.classList).toContain('mat-autocomplete-panel-above');

fixture.componentInstance.trigger.closePanel();
fixture.detectChanges();

fixture.componentInstance.position = 'below';
fixture.detectChanges();
openPanel();

inputRect = inputReference.getBoundingClientRect();
panel = overlayContainerElement.querySelector('.cdk-overlay-pane')!;
panelRect = panel.getBoundingClientRect();

expect(Math.floor(inputRect.bottom))
.toEqual(Math.floor(panelRect.top), 'Expected panel to be below the input.');
expect(panel.classList).not.toContain('mat-autocomplete-panel-above');
}));

});

describe('Option selection', () => {
Expand Down Expand Up @@ -2328,6 +2419,7 @@ describe('MatAutocomplete', () => {
matInput
placeholder="State"
[matAutocomplete]="auto"
[matAutocompletePosition]="position"
[matAutocompleteDisabled]="autocompleteDisabled"
[formControl]="stateCtrl">
</mat-form-field>
Expand All @@ -2345,6 +2437,7 @@ class SimpleAutocomplete implements OnDestroy {
filteredStates: any[];
valueSub: Subscription;
floatLabel = 'auto';
position = 'auto';
width: number;
disableRipple = false;
autocompleteDisabled = false;
Expand Down
4 changes: 3 additions & 1 deletion tools/public_api_guard/material/autocomplete.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ export declare class MatAutocompleteSelectedEvent {
option: MatOption);
}

export declare class MatAutocompleteTrigger implements ControlValueAccessor, OnDestroy {
export declare class MatAutocompleteTrigger implements ControlValueAccessor, OnChanges, OnDestroy {
_onChange: (value: any) => void;
_onTouched: () => void;
readonly activeOption: MatOption | null;
Expand All @@ -80,11 +80,13 @@ export declare class MatAutocompleteTrigger implements ControlValueAccessor, OnD
readonly optionSelections: Observable<MatOptionSelectionChange>;
readonly panelClosingActions: Observable<MatOptionSelectionChange | null>;
readonly panelOpen: boolean;
position: 'auto' | 'above' | 'below';
constructor(_element: ElementRef<HTMLInputElement>, _overlay: Overlay, _viewContainerRef: ViewContainerRef, _zone: NgZone, _changeDetectorRef: ChangeDetectorRef, scrollStrategy: any, _dir: Directionality, _formField: MatFormField, _document: any, _viewportRuler?: ViewportRuler | undefined);
_handleFocus(): void;
_handleInput(event: KeyboardEvent): void;
_handleKeydown(event: KeyboardEvent): void;
closePanel(): void;
ngOnChanges(changes: SimpleChanges): void;
ngOnDestroy(): void;
openPanel(): void;
registerOnChange(fn: (value: any) => {}): void;
Expand Down

0 comments on commit ef0b733

Please sign in to comment.