feat(autocomplete): add autocomplete panel toggling (#2452)

This commit is contained in:
Kara
2017-01-06 16:15:32 -08:00
committed by GitHub
parent 55e56865e1
commit d4ab3d3fea
21 changed files with 461 additions and 76 deletions
@@ -1,3 +1,9 @@
<div class="demo-autocomplete">
<md-autocomplete></md-autocomplete>
<md-input-container>
<input mdInput placeholder="State" [mdAutocomplete]="auto">
</md-input-container>
<md-autocomplete #auto="mdAutocomplete">
<md-option *ngFor="let state of states" [value]="state.code"> {{ state.name }} </md-option>
</md-autocomplete>
</div>
+30 -1
View File
@@ -6,4 +6,33 @@ import {Component} from '@angular/core';
templateUrl: 'autocomplete-demo.html',
styleUrls: ['autocomplete-demo.css'],
})
export class AutocompleteDemo {}
export class AutocompleteDemo {
states = [
{code: 'AL', name: 'Alabama'},
{code: 'AZ', name: 'Arizona'},
{code: 'CA', name: 'California'},
{code: 'CO', name: 'Colorado'},
{code: 'CT', name: 'Connecticut'},
{code: 'FL', name: 'Florida'},
{code: 'GA', name: 'Georgia'},
{code: 'ID', name: 'Idaho'},
{code: 'KS', name: 'Kansas'},
{code: 'LA', name: 'Louisiana'},
{code: 'MA', name: 'Massachusetts'},
{code: 'MN', name: 'Minnesota'},
{code: 'MI', name: 'Mississippi'},
{code: 'NY', name: 'New York'},
{code: 'NC', name: 'North Carolina'},
{code: 'OK', name: 'Oklahoma'},
{code: 'OH', name: 'Ohio'},
{code: 'OR', name: 'Oregon'},
{code: 'PA', name: 'Pennsylvania'},
{code: 'SC', name: 'South Carolina'},
{code: 'TN', name: 'Tennessee'},
{code: 'TX', name: 'Texas'},
{code: 'VA', name: 'Virginia'},
{code: 'WA', name: 'Washington'},
{code: 'WI', name: 'Wisconsin'},
{code: 'WY', name: 'Wyoming'},
];
}
@@ -1,5 +1,16 @@
@import '../core/theming/theming';
@mixin md-autocomplete-theme($theme) {
$foreground: map-get($theme, foreground);
$background: map-get($theme, background);
md-option {
background: md-color($background, card);
color: md-color($foreground, text);
&.md-selected {
background: md-color($background, card);
color: md-color($foreground, text);
}
}
}
@@ -0,0 +1,114 @@
import {Directive, ElementRef, Input, ViewContainerRef, OnDestroy} from '@angular/core';
import {Overlay, OverlayRef, OverlayState, TemplatePortal} from '../core';
import {MdAutocomplete} from './autocomplete';
import {PositionStrategy} from '../core/overlay/position/position-strategy';
import {Observable} from 'rxjs/Observable';
import {Subscription} from 'rxjs/Subscription';
import 'rxjs/add/observable/merge';
/** 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: {
'(focus)': 'openPanel()'
}
})
export class MdAutocompleteTrigger implements OnDestroy {
private _overlayRef: OverlayRef;
private _portal: TemplatePortal;
private _panelOpen: boolean = false;
/** The subscription to events that close the autocomplete panel. */
private _closingActionsSubscription: Subscription;
/* The autocomplete panel to be attached to this trigger. */
@Input('mdAutocomplete') autocomplete: MdAutocomplete;
constructor(private _element: ElementRef, private _overlay: Overlay,
private _viewContainerRef: ViewContainerRef) {}
ngOnDestroy() { this._destroyPanel(); }
/* Whether or not the autocomplete panel is open. */
get panelOpen(): boolean {
return this._panelOpen;
}
/** Opens the autocomplete suggestion panel. */
openPanel(): void {
if (!this._overlayRef) {
this._createOverlay();
}
if (!this._overlayRef.hasAttached()) {
this._overlayRef.attach(this._portal);
this._closingActionsSubscription =
this.panelClosingActions.subscribe(() => this.closePanel());
}
this._panelOpen = true;
}
/** Closes the autocomplete suggestion panel. */
closePanel(): void {
if (this._overlayRef && this._overlayRef.hasAttached()) {
this._overlayRef.detach();
}
this._closingActionsSubscription.unsubscribe();
this._panelOpen = false;
}
/**
* A stream of actions that should close the autocomplete panel, including
* when an option is selected and when the backdrop is clicked.
*/
get panelClosingActions(): Observable<any> {
// TODO(kara): add tab event observable with keyboard event PR
return Observable.merge(...this.optionSelections, this._overlayRef.backdropClick());
}
/** Stream of autocomplete option selections. */
get optionSelections(): Observable<any>[] {
return this.autocomplete.options.map(option => option.onSelect);
}
/** Destroys the autocomplete suggestion panel. */
private _destroyPanel(): void {
if (this._overlayRef) {
this.closePanel();
this._overlayRef.dispose();
this._overlayRef = null;
}
}
private _createOverlay(): void {
this._portal = new TemplatePortal(this.autocomplete.template, this._viewContainerRef);
this._overlayRef = this._overlay.create(this._getOverlayConfig());
}
private _getOverlayConfig(): OverlayState {
const overlayState = new OverlayState();
overlayState.positionStrategy = this._getOverlayPosition();
overlayState.width = this._getHostWidth();
overlayState.hasBackdrop = true;
overlayState.backdropClass = 'md-overlay-transparent-backdrop';
return overlayState;
}
private _getOverlayPosition(): PositionStrategy {
return this._overlay.position().connectedTo(
this._element,
{originX: 'start', originY: 'bottom'}, {overlayX: 'start', overlayY: 'top'})
.withOffsetY(MD_AUTOCOMPLETE_PANEL_OFFSET);
}
/** Returns the width of the input element, so the panel width can match it. */
private _getHostWidth(): number {
return this._element.nativeElement.getBoundingClientRect().width;
}
}
+5 -1
View File
@@ -1 +1,5 @@
I'm an autocomplete!
<template>
<div class="md-autocomplete-panel">
<ng-content></ng-content>
</div>
</template>
+5
View File
@@ -0,0 +1,5 @@
@import '../core/style/menu-common';
.md-autocomplete-panel {
@include md-menu-base();
}
+164 -9
View File
@@ -1,29 +1,184 @@
import {TestBed, async} from '@angular/core/testing';
import {Component} from '@angular/core';
import {MdAutocompleteModule} from './index';
import {TestBed, async, ComponentFixture} from '@angular/core/testing';
import {Component, ViewChild} from '@angular/core';
import {By} from '@angular/platform-browser';
import {MdAutocompleteModule, MdAutocompleteTrigger} from './index';
import {OverlayContainer} from '../core/overlay/overlay-container';
import {MdInputModule} from '../input/index';
describe('MdAutocomplete', () => {
let overlayContainerElement: HTMLElement;
beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [MdAutocompleteModule.forRoot()],
imports: [MdAutocompleteModule.forRoot(), MdInputModule.forRoot()],
declarations: [SimpleAutocomplete],
providers: []
providers: [
{provide: OverlayContainer, useFactory: () => {
overlayContainerElement = document.createElement('div');
document.body.appendChild(overlayContainerElement);
// remove body padding to keep consistent cross-browser
document.body.style.padding = '0';
document.body.style.margin = '0';
return {getContainerElement: () => overlayContainerElement};
}},
]
});
TestBed.compileComponents();
}));
it('should have a test', () => {
expect(true).toBe(true);
describe('panel toggling', () => {
let fixture: ComponentFixture<SimpleAutocomplete>;
let trigger: HTMLElement;
beforeEach(() => {
fixture = TestBed.createComponent(SimpleAutocomplete);
fixture.detectChanges();
trigger = fixture.debugElement.query(By.css('input')).nativeElement;
});
it('should open the panel when the input is focused', () => {
expect(fixture.componentInstance.trigger.panelOpen).toBe(false);
dispatchEvent('focus', trigger);
fixture.detectChanges();
expect(fixture.componentInstance.trigger.panelOpen)
.toBe(true, `Expected panel state to read open when input is focused.`);
expect(overlayContainerElement.textContent)
.toContain('Alabama', `Expected panel to display when input is focused.`);
expect(overlayContainerElement.textContent)
.toContain('California', `Expected panel to display when input is focused.`);
});
it('should open the panel programmatically', () => {
expect(fixture.componentInstance.trigger.panelOpen).toBe(false);
fixture.componentInstance.trigger.openPanel();
fixture.detectChanges();
expect(fixture.componentInstance.trigger.panelOpen)
.toBe(true, `Expected panel state to read open when opened programmatically.`);
expect(overlayContainerElement.textContent)
.toContain('Alabama', `Expected panel to display when opened programmatically.`);
expect(overlayContainerElement.textContent)
.toContain('California', `Expected panel to display when opened programmatically.`);
});
it('should close the panel when a click occurs outside it', async(() => {
dispatchEvent('focus', trigger);
fixture.detectChanges();
const backdrop =
overlayContainerElement.querySelector('.cdk-overlay-backdrop') as HTMLElement;
backdrop.click();
fixture.detectChanges();
fixture.whenStable().then(() => {
expect(fixture.componentInstance.trigger.panelOpen)
.toBe(false, `Expected clicking outside the panel to set its state to closed.`);
expect(overlayContainerElement.textContent)
.toEqual('', `Expected clicking outside the panel to close the panel.`);
});
}));
it('should close the panel when an option is clicked', async(() => {
dispatchEvent('focus', trigger);
fixture.detectChanges();
const option = overlayContainerElement.querySelector('md-option') as HTMLElement;
option.click();
fixture.detectChanges();
fixture.whenStable().then(() => {
expect(fixture.componentInstance.trigger.panelOpen)
.toBe(false, `Expected clicking an option to set the panel state to closed.`);
expect(overlayContainerElement.textContent)
.toEqual('', `Expected clicking an option to close the panel.`);
});
}));
it('should close the panel when a newly created option is clicked', async(() => {
fixture.componentInstance.states.unshift({code: 'TEST', name: 'test'});
fixture.detectChanges();
dispatchEvent('focus', trigger);
fixture.detectChanges();
const option = overlayContainerElement.querySelector('md-option') as HTMLElement;
option.click();
fixture.detectChanges();
fixture.whenStable().then(() => {
expect(fixture.componentInstance.trigger.panelOpen)
.toBe(false, `Expected clicking a new option to set the panel state to closed.`);
expect(overlayContainerElement.textContent)
.toEqual('', `Expected clicking a new option to close the panel.`);
});
}));
it('should close the panel programmatically', async(() => {
fixture.componentInstance.trigger.openPanel();
fixture.detectChanges();
fixture.componentInstance.trigger.closePanel();
fixture.detectChanges();
fixture.whenStable().then(() => {
expect(fixture.componentInstance.trigger.panelOpen)
.toBe(false, `Expected closing programmatically to set the panel state to closed.`);
expect(overlayContainerElement.textContent)
.toEqual('', `Expected closing programmatically to close the panel.`);
});
}));
});
});
@Component({
template: `
<md-autocomplete></md-autocomplete>
<md-input-container>
<input mdInput placeholder="State" [mdAutocomplete]="auto">
</md-input-container>
<md-autocomplete #auto="mdAutocomplete">
<md-option *ngFor="let state of states" [value]="state.code"> {{ state.name }} </md-option>
</md-autocomplete>
`
})
class SimpleAutocomplete {}
class SimpleAutocomplete {
@ViewChild(MdAutocompleteTrigger) trigger: MdAutocompleteTrigger;
states = [
{code: 'AL', name: 'Alabama'},
{code: 'CA', name: 'California'},
{code: 'FL', name: 'Florida'},
{code: 'KS', name: 'Kansas'},
{code: 'MA', name: 'Massachusetts'},
{code: 'NY', name: 'New York'},
{code: 'OR', name: 'Oregon'},
{code: 'PA', name: 'Pennsylvania'},
{code: 'TN', name: 'Tennessee'},
{code: 'VA', name: 'Virginia'},
{code: 'WY', name: 'Wyoming'},
];
}
/**
* TODO: Move this to core testing utility until Angular has event faking
* support.
*
* Dispatches an event from an element.
* @param eventName Name of the event
* @param element The element from which the event will be dispatched.
*/
function dispatchEvent(eventName: string, element: HTMLElement): void {
let event = document.createEvent('Event');
event.initEvent(eventName, true, true);
element.dispatchEvent(event);
}
+15 -2
View File
@@ -1,4 +1,12 @@
import {Component, ViewEncapsulation} from '@angular/core';
import {
Component,
ContentChildren,
QueryList,
TemplateRef,
ViewChild,
ViewEncapsulation
} from '@angular/core';
import {MdOption} from '../core';
@Component({
moduleId: module.id,
@@ -6,6 +14,11 @@ import {Component, ViewEncapsulation} from '@angular/core';
templateUrl: 'autocomplete.html',
styleUrls: ['autocomplete.css'],
encapsulation: ViewEncapsulation.None,
exportAs: 'mdAutocomplete'
})
export class MdAutocomplete {}
export class MdAutocomplete {
@ViewChild(TemplateRef) template: TemplateRef<any>;
@ContentChildren(MdOption) options: QueryList<MdOption>;
}
+11 -5
View File
@@ -1,18 +1,24 @@
import {ModuleWithProviders, NgModule} from '@angular/core';
import {DefaultStyleCompatibilityModeModule} from '../core';
import {
MdOptionModule, OverlayModule, OVERLAY_PROVIDERS, DefaultStyleCompatibilityModeModule
} from '../core';
import {MdAutocomplete} from './autocomplete';
import {MdAutocompleteTrigger} from './autocomplete-trigger';
export * from './autocomplete';
export * from './autocomplete-trigger';
@NgModule({
imports: [DefaultStyleCompatibilityModeModule],
exports: [MdAutocomplete, DefaultStyleCompatibilityModeModule],
declarations: [MdAutocomplete],
imports: [MdOptionModule, OverlayModule, DefaultStyleCompatibilityModeModule],
exports: [
MdAutocomplete, MdOptionModule, MdAutocompleteTrigger, DefaultStyleCompatibilityModeModule
],
declarations: [MdAutocomplete, MdAutocompleteTrigger],
})
export class MdAutocompleteModule {
static forRoot(): ModuleWithProviders {
return {
ngModule: MdAutocompleteModule,
providers: []
providers: [OVERLAY_PROVIDERS]
};
}
}
+4 -1
View File
@@ -3,7 +3,8 @@
@import 'style/elevation';
@import 'overlay/overlay';
@import 'ripple/ripple';
@import 'option/option';
@import 'option/option-theme';
// Mixin that renders all of the core styles that are not theme-dependent.
@mixin md-core() {
@@ -17,6 +18,7 @@
}
@include md-ripple();
@include md-option();
@include cdk-a11y();
@include cdk-overlay();
}
@@ -24,4 +26,5 @@
// Mixin that renders all of the core styles that depend on the theme.
@mixin md-core-theme($theme) {
@include md-ripple-theme($theme);
@include md-option-theme($theme);
}
+5
View File
@@ -2,6 +2,7 @@ import {NgModule, ModuleWithProviders} from '@angular/core';
import {MdLineModule} from './line/line';
import {RtlModule} from './rtl/dir';
import {ObserveContentModule} from './observe-content/observe-content';
import {MdOptionModule} from './option/option';
import {MdRippleModule} from './ripple/ripple';
import {PortalModule} from './portal/portal-directives';
import {OverlayModule} from './overlay/overlay-directives';
@@ -15,6 +16,8 @@ export {Dir, LayoutDirection, RtlModule} from './rtl/dir';
// Mutation Observer
export {ObserveContentModule, ObserveContent} from './observe-content/observe-content';
export {MdOptionModule, MdOption} from './option/option';
// Portals
export {
Portal,
@@ -125,6 +128,7 @@ export {NoConflictStyleCompatibilityMode} from './compatibility/no-conflict-mode
PortalModule,
OverlayModule,
A11yModule,
MdOptionModule
],
exports: [
MdLineModule,
@@ -134,6 +138,7 @@ export {NoConflictStyleCompatibilityMode} from './compatibility/no-conflict-mode
PortalModule,
OverlayModule,
A11yModule,
MdOptionModule
],
})
export class MdCoreModule {
+24
View File
@@ -0,0 +1,24 @@
@import '../theming/palette';
@import '../theming/theming';
@mixin md-option-theme($theme) {
$foreground: map-get($theme, foreground);
$background: map-get($theme, background);
$primary: map-get($theme, primary);
md-option {
&:hover:not(.md-option-disabled), &:focus:not(.md-option-disabled) {
background: md-color($background, hover);
}
&.md-selected {
background: md-color($background, hover);
color: md-color($primary);
}
&.md-option-disabled {
color: md-color($foreground, hint-text);
}
}
}
+34
View File
@@ -0,0 +1,34 @@
@import '../style/menu-common';
@import '../a11y/a11y';
/**
* This mixin contains shared option styles between the select and
* autocomplete components.
*/
@mixin md-option() {
md-option {
@include md-menu-item-base();
position: relative;
cursor: pointer;
outline: none;
&[aria-disabled='true'] {
cursor: default;
user-select: none;
}
}
.md-option-ripple {
position: absolute;
top: 0;
left: 0;
bottom: 0;
right: 0;
// In high contrast mode this completely covers the text.
@include cdk-high-contrast {
opacity: 0.5;
}
}
}
@@ -4,11 +4,15 @@ import {
EventEmitter,
Input,
Output,
NgModule,
ModuleWithProviders,
Renderer,
ViewEncapsulation
} from '@angular/core';
import {ENTER, SPACE} from '../core/keyboard/keycodes';
import {coerceBooleanProperty} from '../core/coercion/boolean-property';
import {CommonModule} from '@angular/common';
import {ENTER, SPACE} from '../keyboard/keycodes';
import {coerceBooleanProperty} from '../coercion/boolean-property';
import {MdRippleModule} from '../ripple/ripple';
/**
* Option IDs need to be unique across components, so this counter exists outside of
@@ -34,7 +38,6 @@ let _uniqueIdCounter = 0;
'(keydown)': '_handleKeydown($event)'
},
templateUrl: 'option.html',
styleUrls: ['select.css'],
encapsulation: ViewEncapsulation.None
})
export class MdOption {
@@ -43,7 +46,7 @@ export class MdOption {
/** Whether the option is disabled. */
private _disabled: boolean = false;
private _id: string = `md-select-option-${_uniqueIdCounter++}`;
private _id: string = `md-option-${_uniqueIdCounter++}`;
/** The unique ID of the option. */
get id() { return this._id; }
@@ -98,7 +101,6 @@ export class MdOption {
}
}
/**
* Selects the option while indicating the selection came from the user. Used to
* determine if the select's view -> model callback should be invoked.
@@ -120,3 +122,17 @@ export class MdOption {
}
}
@NgModule({
imports: [MdRippleModule, CommonModule],
exports: [MdOption],
declarations: [MdOption]
})
export class MdOptionModule {
static forRoot(): ModuleWithProviders {
return {
ngModule: MdOptionModule,
providers: []
};
}
}
-16
View File
@@ -45,20 +45,4 @@
color: md-color($foreground, hint-text);
}
}
md-option {
&:hover:not(.md-option-disabled), &:focus:not(.md-option-disabled) {
background: md-color($background, hover);
}
&.md-selected {
background: md-color($background, hover);
color: md-color($primary);
}
&.md-option-disabled {
color: md-color($foreground, hint-text);
}
}
}
+4 -6
View File
@@ -1,22 +1,20 @@
import {NgModule, ModuleWithProviders} from '@angular/core';
import {CommonModule} from '@angular/common';
import {MdSelect} from './select';
import {MdOption} from './option';
import {MdOptionModule} from '../core/option/option';
import {
DefaultStyleCompatibilityModeModule,
OVERLAY_PROVIDERS,
MdRippleModule,
OverlayModule,
} from '../core';
export * from './select';
export {MdOption} from './option';
export {fadeInContent, transformPanel, transformPlaceholder} from './select-animations';
@NgModule({
imports: [CommonModule, OverlayModule, MdRippleModule, DefaultStyleCompatibilityModeModule],
exports: [MdSelect, MdOption, DefaultStyleCompatibilityModeModule],
declarations: [MdSelect, MdOption],
imports: [CommonModule, OverlayModule, MdOptionModule, DefaultStyleCompatibilityModeModule],
exports: [MdSelect, MdOptionModule, DefaultStyleCompatibilityModeModule],
declarations: [MdSelect],
})
export class MdSelectModule {
static forRoot(): ModuleWithProviders {
-24
View File
@@ -105,27 +105,3 @@ md-select {
}
}
md-option {
@include md-menu-item-base();
position: relative;
cursor: pointer;
outline: none;
&[aria-disabled='true'] {
cursor: default;
user-select: none;
}
}
.md-option-ripple {
position: absolute;
top: 0;
left: 0;
bottom: 0;
right: 0;
// In high contrast mode this completely covers the text.
@include cdk-high-contrast {
opacity: 0.5;
}
}
+5 -4
View File
@@ -4,7 +4,7 @@ import {Component, DebugElement, QueryList, ViewChild, ViewChildren} from '@angu
import {MdSelectModule} from './index';
import {OverlayContainer} from '../core/overlay/overlay-container';
import {MdSelect} from './select';
import {MdOption} from './option';
import {MdOption} from '../core/option/option';
import {Dir} from '../core/rtl/dir';
import {FormControl, FormsModule, ReactiveFormsModule} from '@angular/forms';
import {ViewportRuler} from '../core/overlay/position/viewport-ruler';
@@ -587,6 +587,7 @@ describe('MdSelect', () => {
select.style.marginRight = '20px';
});
it('should align the first option with the trigger text if no option is selected', () => {
trigger.click();
fixture.detectChanges();
@@ -1081,7 +1082,7 @@ describe('MdSelect', () => {
let firstOptionID = options[0].id;
expect(options[0].id)
.toContain('md-select-option', `Expected option ID to have the correct prefix.`);
.toContain('md-option', `Expected option ID to have the correct prefix.`);
expect(options[0].id).not.toEqual(options[1].id, `Expected option IDs to be unique.`);
const backdrop =
@@ -1096,7 +1097,7 @@ describe('MdSelect', () => {
options =
overlayContainerElement.querySelectorAll('md-option') as NodeListOf<HTMLElement>;
expect(options[0].id)
.toContain('md-select-option', `Expected option ID to have the correct prefix.`);
.toContain('md-option', `Expected option ID to have the correct prefix.`);
expect(options[0].id).not.toEqual(firstOptionID, `Expected option IDs to be unique.`);
expect(options[0].id).not.toEqual(options[1].id, `Expected option IDs to be unique.`);
});
@@ -1219,7 +1220,7 @@ class ManySelects {}
</md-option>
</md-select>
</div>
`
`,
})
class NgIfSelect {
+1 -1
View File
@@ -13,7 +13,7 @@ import {
ViewEncapsulation,
ViewChild,
} from '@angular/core';
import {MdOption} from './option';
import {MdOption} from '../core/option/option';
import {ENTER, SPACE} from '../core/keyboard/keycodes';
import {ListKeyManager} from '../core/a11y/list-key-manager';
import {Dir} from '../core/rtl/dir';
+1
View File
@@ -75,6 +75,7 @@ task(':build:components:rollup', () => {
'rxjs/add/observable/fromEvent': 'Rx.Observable',
'rxjs/add/observable/forkJoin': 'Rx.Observable',
'rxjs/add/observable/of': 'Rx.Observable',
'rxjs/add/observable/merge': 'Rx.Observable',
'rxjs/add/observable/throw': 'Rx.Observable',
'rxjs/add/operator/toPromise': 'Rx.Observable.prototype',
'rxjs/add/operator/map': 'Rx.Observable.prototype',