mirror of
https://github.com/angular/angular.git
synced 2026-09-14 13:54:52 +08:00
feat(autocomplete): add fallback positions (#2726)
This commit is contained in:
@@ -1,3 +1,5 @@
|
||||
Space above cards: <input type="number" [formControl]="topHeightCtrl">
|
||||
<div [style.height.px]="topHeightCtrl.value"></div>
|
||||
<div class="demo-autocomplete">
|
||||
<md-card>
|
||||
<div>Reactive value: {{ stateCtrl.value }}</div>
|
||||
|
||||
@@ -12,6 +12,7 @@ import {Subscription} from 'rxjs/Subscription';
|
||||
export class AutocompleteDemo implements OnDestroy {
|
||||
stateCtrl = new FormControl();
|
||||
currentState = '';
|
||||
topHeightCtrl = new FormControl(0);
|
||||
|
||||
reactiveStates: any[];
|
||||
tdStates: any[];
|
||||
|
||||
@@ -5,19 +5,17 @@ import {NgControl} from '@angular/forms';
|
||||
import {Overlay, OverlayRef, OverlayState, TemplatePortal} from '../core';
|
||||
import {MdAutocomplete} from './autocomplete';
|
||||
import {PositionStrategy} from '../core/overlay/position/position-strategy';
|
||||
import {ConnectedPositionStrategy} from '../core/overlay/position/connected-position-strategy';
|
||||
import {Observable} from 'rxjs/Observable';
|
||||
import {MdOptionSelectEvent, MdOption} from '../core/option/option';
|
||||
import {ActiveDescendantKeyManager} from '../core/a11y/activedescendant-key-manager';
|
||||
import {ENTER} from '../core/keyboard/keycodes';
|
||||
import {Subscription} from 'rxjs/Subscription';
|
||||
import 'rxjs/add/observable/merge';
|
||||
import {Dir} from '../core/rtl/dir';
|
||||
import 'rxjs/add/operator/startWith';
|
||||
import 'rxjs/add/operator/switchMap';
|
||||
|
||||
|
||||
/** The panel needs a slight y-offset to ensure the input underline displays. */
|
||||
export const MD_AUTOCOMPLETE_PANEL_OFFSET = 6;
|
||||
|
||||
@Directive({
|
||||
selector: 'input[mdAutocomplete], input[matAutocomplete]',
|
||||
host: {
|
||||
@@ -37,8 +35,12 @@ export class MdAutocompleteTrigger implements AfterContentInit, OnDestroy {
|
||||
private _portal: TemplatePortal;
|
||||
private _panelOpen: boolean = false;
|
||||
|
||||
/** The subscription to positioning changes in the autocomplete panel. */
|
||||
private _panelPositionSub: Subscription;
|
||||
|
||||
/** Manages active item in option list based on key events. */
|
||||
private _keyManager: ActiveDescendantKeyManager;
|
||||
private _positionStrategy: ConnectedPositionStrategy;
|
||||
|
||||
/* The autocomplete panel to be attached to this trigger. */
|
||||
@Input('mdAutocomplete') autocomplete: MdAutocomplete;
|
||||
@@ -51,7 +53,13 @@ export class MdAutocompleteTrigger implements AfterContentInit, OnDestroy {
|
||||
this._keyManager = new ActiveDescendantKeyManager(this.autocomplete.options);
|
||||
}
|
||||
|
||||
ngOnDestroy() { this._destroyPanel(); }
|
||||
ngOnDestroy() {
|
||||
if (this._panelPositionSub) {
|
||||
this._panelPositionSub.unsubscribe();
|
||||
}
|
||||
|
||||
this._destroyPanel();
|
||||
}
|
||||
|
||||
/* Whether or not the autocomplete panel is open. */
|
||||
get panelOpen(): boolean {
|
||||
@@ -124,7 +132,7 @@ export class MdAutocompleteTrigger implements AfterContentInit, OnDestroy {
|
||||
// create a new stream of panelClosingActions, replacing any previous streams
|
||||
// that were created, and flatten it so our stream only emits closing events...
|
||||
.switchMap(() => {
|
||||
this._resetActiveItem();
|
||||
this._resetPanel();
|
||||
return this.panelClosingActions;
|
||||
})
|
||||
// when the first closing event occurs...
|
||||
@@ -174,10 +182,24 @@ export class MdAutocompleteTrigger implements AfterContentInit, OnDestroy {
|
||||
}
|
||||
|
||||
private _getOverlayPosition(): PositionStrategy {
|
||||
return this._overlay.position().connectedTo(
|
||||
this._positionStrategy = this._overlay.position().connectedTo(
|
||||
this._element,
|
||||
{originX: 'start', originY: 'bottom'}, {overlayX: 'start', overlayY: 'top'})
|
||||
.withOffsetY(MD_AUTOCOMPLETE_PANEL_OFFSET);
|
||||
.withFallbackPosition(
|
||||
{originX: 'start', originY: 'top'}, {overlayX: 'start', overlayY: 'bottom'}
|
||||
);
|
||||
this._subscribeToPositionChanges(this._positionStrategy);
|
||||
return this._positionStrategy;
|
||||
}
|
||||
|
||||
/**
|
||||
* This method subscribes to position changes in the autocomplete panel, so the panel's
|
||||
* y-offset can be adjusted to match the new position.
|
||||
*/
|
||||
private _subscribeToPositionChanges(strategy: ConnectedPositionStrategy) {
|
||||
this._panelPositionSub = strategy.onPositionChange.subscribe(change => {
|
||||
this.autocomplete.positionY = change.connectionPair.originY === 'top' ? 'above' : 'below';
|
||||
});
|
||||
}
|
||||
|
||||
/** Returns the width of the input element, so the panel width can match it. */
|
||||
@@ -190,5 +212,14 @@ export class MdAutocompleteTrigger implements AfterContentInit, OnDestroy {
|
||||
this._keyManager.setActiveItem(-1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resets the active item and re-calculates alignment of the panel in case its size
|
||||
* has changed due to fewer or greater number of options.
|
||||
*/
|
||||
private _resetPanel() {
|
||||
this._resetActiveItem();
|
||||
this._positionStrategy.recalculateLastPosition();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="md-autocomplete-panel" role="listbox" [id]="id">
|
||||
<div class="md-autocomplete-panel" role="listbox" [id]="id" [ngClass]="_getPositionClass()">
|
||||
<ng-content></ng-content>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,5 +1,28 @@
|
||||
@import '../core/style/menu-common';
|
||||
|
||||
/**
|
||||
* The max-height of the panel, currently matching md-select value.
|
||||
* TODO: Check value with MD team.
|
||||
*/
|
||||
$md-autocomplete-panel-max-height: 256px !default;
|
||||
|
||||
/** When in "below" position, the panel needs a slight y-offset to ensure the input underline displays. */
|
||||
$md-autocomplete-panel-below-offset: 6px !default;
|
||||
|
||||
/** When in "above" position, the panel needs a larger y-offset to ensure the label has room to display. */
|
||||
$md-autocomplete-panel-above-offset: -24px !default;
|
||||
|
||||
.md-autocomplete-panel {
|
||||
@include md-menu-base();
|
||||
|
||||
max-height: $md-autocomplete-panel-max-height;
|
||||
position: relative;
|
||||
|
||||
&.md-autocomplete-panel-below {
|
||||
top: $md-autocomplete-panel-below-offset;
|
||||
}
|
||||
|
||||
&.md-autocomplete-panel-above {
|
||||
top: $md-autocomplete-panel-above-offset;
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import {FormControl, ReactiveFormsModule} from '@angular/forms';
|
||||
import {Subscription} from 'rxjs/Subscription';
|
||||
import {ENTER, DOWN_ARROW, SPACE} from '../core/keyboard/keycodes';
|
||||
import {MdOption} from '../core/option/option';
|
||||
import {ViewportRuler} from '../core/overlay/position/viewport-ruler';
|
||||
|
||||
describe('MdAutocomplete', () => {
|
||||
let overlayContainerElement: HTMLElement;
|
||||
@@ -35,6 +36,7 @@ describe('MdAutocomplete', () => {
|
||||
{provide: Dir, useFactory: () => {
|
||||
return {value: dir};
|
||||
}},
|
||||
{provide: ViewportRuler, useClass: FakeViewportRuler}
|
||||
]
|
||||
});
|
||||
|
||||
@@ -392,8 +394,8 @@ describe('MdAutocomplete', () => {
|
||||
});
|
||||
|
||||
describe('aria', () => {
|
||||
let fixture: ComponentFixture<SimpleAutocomplete>;
|
||||
let input: HTMLInputElement;
|
||||
let fixture: ComponentFixture<SimpleAutocomplete>;
|
||||
let input: HTMLInputElement;
|
||||
|
||||
beforeEach(() => {
|
||||
fixture = TestBed.createComponent(SimpleAutocomplete);
|
||||
@@ -477,6 +479,77 @@ describe('MdAutocomplete', () => {
|
||||
|
||||
expect(input.getAttribute('aria-owns'))
|
||||
.toEqual(panel.getAttribute('id'), 'Expected aria-owns to match attached autocomplete.');
|
||||
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
describe('Fallback positions', () => {
|
||||
let fixture: ComponentFixture<SimpleAutocomplete>;
|
||||
let input: HTMLInputElement;
|
||||
|
||||
beforeEach(() => {
|
||||
fixture = TestBed.createComponent(SimpleAutocomplete);
|
||||
fixture.detectChanges();
|
||||
|
||||
input = fixture.debugElement.query(By.css('input')).nativeElement;
|
||||
});
|
||||
|
||||
it('should use below positioning by default', () => {
|
||||
fixture.componentInstance.trigger.openPanel();
|
||||
fixture.detectChanges();
|
||||
|
||||
const inputBottom = input.getBoundingClientRect().bottom;
|
||||
const panel = overlayContainerElement.querySelector('.md-autocomplete-panel');
|
||||
const panelTop = panel.getBoundingClientRect().top;
|
||||
|
||||
// Panel is offset by 6px in styles so that the underline has room to display.
|
||||
expect((inputBottom + 6).toFixed(2))
|
||||
.toEqual(panelTop.toFixed(2), `Expected panel top to match input bottom by default.`);
|
||||
expect(fixture.componentInstance.trigger.autocomplete.positionY)
|
||||
.toEqual('below', `Expected autocomplete positionY to default to below.`);
|
||||
});
|
||||
|
||||
it('should fall back to above position if panel cannot fit below', () => {
|
||||
// Push the autocomplete trigger down so it won't have room to open "below"
|
||||
input.style.top = '400px';
|
||||
input.style.position = 'relative';
|
||||
|
||||
fixture.componentInstance.trigger.openPanel();
|
||||
fixture.detectChanges();
|
||||
|
||||
const inputTop = input.getBoundingClientRect().top;
|
||||
const panel = overlayContainerElement.querySelector('.md-autocomplete-panel');
|
||||
const panelBottom = panel.getBoundingClientRect().bottom;
|
||||
|
||||
// Panel is offset by 24px in styles so that the label has room to display.
|
||||
expect((inputTop - 24).toFixed(2))
|
||||
.toEqual(panelBottom.toFixed(2), `Expected panel to fall back to above position.`);
|
||||
expect(fixture.componentInstance.trigger.autocomplete.positionY)
|
||||
.toEqual('above', `Expected autocomplete positionY to be "above" if panel won't fit.`);
|
||||
});
|
||||
|
||||
it('should align panel properly when filtering in "above" position', () => {
|
||||
// Push the autocomplete trigger down so it won't have room to open "below"
|
||||
input.style.top = '400px';
|
||||
input.style.position = 'relative';
|
||||
|
||||
fixture.componentInstance.trigger.openPanel();
|
||||
fixture.detectChanges();
|
||||
|
||||
input.value = 'f';
|
||||
dispatchEvent('input', input);
|
||||
fixture.detectChanges();
|
||||
|
||||
const inputTop = input.getBoundingClientRect().top;
|
||||
const panel = overlayContainerElement.querySelector('.md-autocomplete-panel');
|
||||
const panelBottom = panel.getBoundingClientRect().bottom;
|
||||
|
||||
// Panel is offset by 24px in styles so that the label has room to display.
|
||||
expect((inputTop - 24).toFixed(2))
|
||||
.toEqual(panelBottom.toFixed(2), `Expected panel to stay aligned after filtering.`);
|
||||
expect(fixture.componentInstance.trigger.autocomplete.positionY)
|
||||
.toEqual('above', `Expected autocomplete positionY to be "above" if panel won't fit.`);
|
||||
});
|
||||
|
||||
});
|
||||
@@ -553,5 +626,15 @@ class FakeKeyboardEvent {
|
||||
preventDefault() {}
|
||||
}
|
||||
|
||||
class FakeViewportRuler {
|
||||
getViewportRect() {
|
||||
return {
|
||||
left: 0, top: 0, width: 500, height: 500, bottom: 500, right: 500
|
||||
};
|
||||
}
|
||||
|
||||
getViewportScrollPosition() {
|
||||
return {top: 0, left: 0};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
ViewEncapsulation
|
||||
} from '@angular/core';
|
||||
import {MdOption} from '../core';
|
||||
import {MenuPositionY} from '../menu/menu-positions';
|
||||
|
||||
/**
|
||||
* Autocomplete IDs need to be unique across components, so this counter exists outside of
|
||||
@@ -24,10 +25,22 @@ let _uniqueAutocompleteIdCounter = 0;
|
||||
})
|
||||
export class MdAutocomplete {
|
||||
|
||||
/** Whether the autocomplete panel displays above or below its trigger. */
|
||||
positionY: MenuPositionY = 'below';
|
||||
|
||||
@ViewChild(TemplateRef) template: TemplateRef<any>;
|
||||
@ContentChildren(MdOption) options: QueryList<MdOption>;
|
||||
|
||||
/** Unique ID to be used by autocomplete trigger's "aria-owns" property. */
|
||||
id: string = `md-autocomplete-${_uniqueAutocompleteIdCounter++}`;
|
||||
|
||||
/** Sets a class on the panel based on its position (used to set y-offset). */
|
||||
_getPositionClass() {
|
||||
return {
|
||||
'md-autocomplete-panel-below': this.positionY === 'below',
|
||||
'md-autocomplete-panel-above': this.positionY === 'above'
|
||||
};
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import {ModuleWithProviders, NgModule} from '@angular/core';
|
||||
|
||||
import {MdOptionModule, OverlayModule, OVERLAY_PROVIDERS, CompatibilityModule} from '../core';
|
||||
import {CommonModule} from '@angular/common';
|
||||
import {MdAutocomplete} from './autocomplete';
|
||||
import {MdAutocompleteTrigger} from './autocomplete-trigger';
|
||||
export * from './autocomplete';
|
||||
export * from './autocomplete-trigger';
|
||||
|
||||
@NgModule({
|
||||
imports: [MdOptionModule, OverlayModule, CompatibilityModule],
|
||||
imports: [MdOptionModule, OverlayModule, CompatibilityModule, CommonModule],
|
||||
exports: [MdAutocomplete, MdOptionModule, MdAutocompleteTrigger, CompatibilityModule],
|
||||
declarations: [MdAutocomplete, MdAutocompleteTrigger],
|
||||
})
|
||||
|
||||
@@ -228,6 +228,39 @@ describe('ConnectedPositionStrategy', () => {
|
||||
expect(overlayRect.right).toBe(originRect.left);
|
||||
});
|
||||
|
||||
it('should recalculate and set the last position with recalculateLastPosition()', () => {
|
||||
// Use the fake viewport ruler because we don't know *exactly* how big the viewport is.
|
||||
fakeViewportRuler.fakeRect = {
|
||||
top: 0, left: 0, width: 500, height: 500, right: 500, bottom: 500
|
||||
};
|
||||
positionBuilder = new OverlayPositionBuilder(fakeViewportRuler);
|
||||
|
||||
// Push the trigger down so the overlay doesn't have room to open on the bottom.
|
||||
originElement.style.top = '475px';
|
||||
originRect = originElement.getBoundingClientRect();
|
||||
|
||||
strategy = positionBuilder.connectedTo(
|
||||
fakeElementRef,
|
||||
{originX: 'start', originY: 'bottom'},
|
||||
{overlayX: 'start', overlayY: 'top'})
|
||||
.withFallbackPosition(
|
||||
{originX: 'start', originY: 'top'},
|
||||
{overlayX: 'start', overlayY: 'bottom'});
|
||||
|
||||
// This should apply the fallback position, as the original position won't fit.
|
||||
strategy.apply(overlayElement);
|
||||
|
||||
// Now make the overlay small enough to fit in the first preferred position.
|
||||
overlayElement.style.height = '15px';
|
||||
|
||||
// This should only re-align in the last position, even though the first would fit.
|
||||
strategy.recalculateLastPosition();
|
||||
|
||||
let overlayRect = overlayElement.getBoundingClientRect();
|
||||
expect(overlayRect.bottom).toBe(originRect.top,
|
||||
'Expected overlay to be re-aligned to the trigger in the previous position.');
|
||||
});
|
||||
|
||||
it('should position a panel properly when rtl', () => {
|
||||
// must make the overlay longer than the origin to properly test attachment
|
||||
overlayElement.style.width = `500px`;
|
||||
|
||||
@@ -53,6 +53,12 @@ export class ConnectedPositionStrategy implements PositionStrategy {
|
||||
/** The origin element against which the overlay will be positioned. */
|
||||
private _origin: HTMLElement;
|
||||
|
||||
/** The overlay pane element. */
|
||||
private _pane: HTMLElement;
|
||||
|
||||
/** The last position to have been calculated as the best fit position. */
|
||||
private _lastConnectedPosition: ConnectionPositionPair;
|
||||
|
||||
_onPositionChange:
|
||||
Subject<ConnectedOverlayPositionChange> = new Subject<ConnectedOverlayPositionChange>();
|
||||
|
||||
@@ -89,6 +95,9 @@ export class ConnectedPositionStrategy implements PositionStrategy {
|
||||
* @returns Resolves when the styles have been applied.
|
||||
*/
|
||||
apply(element: HTMLElement): Promise<void> {
|
||||
// Cache the overlay pane element in case re-calculating position is necessary
|
||||
this._pane = element;
|
||||
|
||||
// We need the bounding rects for the origin and the overlay to determine how to position
|
||||
// the overlay relative to the origin.
|
||||
const originRect = this._origin.getBoundingClientRect();
|
||||
@@ -112,6 +121,9 @@ export class ConnectedPositionStrategy implements PositionStrategy {
|
||||
if (overlayPoint.fitsInViewport) {
|
||||
this._setElementPosition(element, overlayPoint);
|
||||
|
||||
// Save the last connected position in case the position needs to be re-calculated.
|
||||
this._lastConnectedPosition = pos;
|
||||
|
||||
// Notify that the position has been changed along with its change properties.
|
||||
const scrollableViewProperties = this.getScrollableViewProperties(element);
|
||||
const positionChange = new ConnectedOverlayPositionChange(pos, scrollableViewProperties);
|
||||
@@ -130,6 +142,22 @@ export class ConnectedPositionStrategy implements PositionStrategy {
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
|
||||
/**
|
||||
* This re-aligns the overlay element with the trigger in its last calculated position,
|
||||
* even if a position higher in the "preferred positions" list would now fit. This
|
||||
* allows one to re-align the panel without changing the orientation of the panel.
|
||||
*/
|
||||
recalculateLastPosition(): void {
|
||||
const originRect = this._origin.getBoundingClientRect();
|
||||
const overlayRect = this._pane.getBoundingClientRect();
|
||||
const viewportRect = this._viewportRuler.getViewportRect();
|
||||
|
||||
let originPoint = this._getOriginConnectionPoint(originRect, this._lastConnectedPosition);
|
||||
let overlayPoint =
|
||||
this._getOverlayPoint(originPoint, overlayRect, viewportRect, this._lastConnectedPosition);
|
||||
this._setElementPosition(this._pane, overlayPoint);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the list of Scrollable containers that host the origin element so that
|
||||
* on reposition we can evaluate if it or the overlay has been clipped or outside view. Every
|
||||
|
||||
Reference in New Issue
Block a user