feat(select): basic forms support (#1655)

This commit is contained in:
Kara
2016-11-03 16:01:03 -07:00
committed by Jeremy Elbourn
parent 780a6549d0
commit a4ab10f207
8 changed files with 287 additions and 33 deletions
+2 -1
View File
@@ -1,7 +1,7 @@
import {NgModule, ApplicationRef} from '@angular/core';
import {BrowserModule} from '@angular/platform-browser';
import {HttpModule} from '@angular/http';
import {FormsModule} from '@angular/forms';
import {FormsModule, ReactiveFormsModule} from '@angular/forms';
import {DemoApp, Home} from './demo-app/demo-app';
import {RouterModule} from '@angular/router';
import {MaterialModule} from '@angular/material';
@@ -39,6 +39,7 @@ import {TabsDemo, SunnyTabContent, RainyTabContent, FoggyTabContent} from './tab
BrowserModule,
FormsModule,
HttpModule,
ReactiveFormsModule,
RouterModule.forRoot(DEMO_APP_ROUTES),
MaterialModule.forRoot(),
],
+8 -2
View File
@@ -1,5 +1,11 @@
<div class="demo-select">
<md-select placeholder="Food">
<md-option *ngFor="let food of foods"> {{ food.viewValue }} </md-option>
<md-select placeholder="Food" [formControl]="control" [required]="isRequired">
<md-option *ngFor="let food of foods" [value]="food.value"> {{ food.viewValue }} </md-option>
</md-select>
<p> Value: {{ control.value }} </p>
<p> Touched: {{ control.touched }} </p>
<p> Dirty: {{ control.dirty }} </p>
<p> Status: {{ control.status }} </p>
<button md-button (click)="control.setValue('pizza-1')">SET VALUE</button>
<button md-button (click)="isRequired=!isRequired">TOGGLE REQUIRED</button>
</div>
+9 -4
View File
@@ -1,5 +1,5 @@
import {Component} from '@angular/core';
import {FormControl} from '@angular/forms';
@Component({
moduleId: module.id,
@@ -8,9 +8,14 @@ import {Component} from '@angular/core';
styleUrls: ['select-demo.css'],
})
export class SelectDemo {
isRequired = false;
foods = [
{value: 'steak', viewValue: 'Steak'},
{value: 'pizza', viewValue: 'Pizza'},
{value: 'tacos', viewValue: 'Tacos'}
{value: 'steak-0', viewValue: 'Steak'},
{value: 'pizza-1', viewValue: 'Pizza'},
{value: 'tacos-2', viewValue: 'Tacos'}
];
control = new FormControl('');
}
+9 -4
View File
@@ -5,19 +5,20 @@
$foreground: map-get($theme, foreground);
$background: map-get($theme, background);
$primary: map-get($theme, primary);
$warn: map-get($theme, warn);
.md-select-trigger {
color: md-color($foreground, hint-text);
border-bottom: 1px solid md-color($foreground, divider);
md-select:focus & {
color: md-color($primary);
border-bottom: 1px solid md-color($primary);
}
}
.md-select-placeholder {
md-select:focus & {
color: md-color($primary);
.ng-invalid.ng-touched & {
color: md-color($warn);
border-bottom: 1px solid md-color($warn);
}
}
@@ -27,6 +28,10 @@
md-select:focus & {
color: md-color($primary);
}
.ng-invalid.ng-touched & {
color: md-color($warn);
}
}
.md-select-content {
+15 -2
View File
@@ -2,6 +2,7 @@ import {
Component,
ElementRef,
EventEmitter,
Input,
Output,
Renderer,
ViewEncapsulation
@@ -16,7 +17,7 @@ import {ENTER, SPACE} from '../core/keyboard/keycodes';
'tabindex': '0',
'[class.md-selected]': 'selected',
'[attr.aria-selected]': 'selected.toString()',
'(click)': 'select()',
'(click)': '_selectViaInteraction()',
'(keydown)': '_handleKeydown($event)'
},
templateUrl: 'option.html',
@@ -26,6 +27,9 @@ import {ENTER, SPACE} from '../core/keyboard/keycodes';
export class MdOption {
private _selected = false;
/** The form value of the option. */
@Input() value: any;
/** Event emitted when the option is selected. */
@Output() onSelect = new EventEmitter();
@@ -64,10 +68,19 @@ export class MdOption {
/** Ensures the option is selected when activated from the keyboard. */
_handleKeydown(event: KeyboardEvent): void {
if (event.keyCode === ENTER || event.keyCode === SPACE) {
this.select();
this._selectViaInteraction();
}
}
/**
* Selects the option while indicating the selection came from the user. Used to
* determine if the select's view -> model callback should be invoked.
*/
_selectViaInteraction() {
this._selected = true;
this.onSelect.emit(true);
}
_getHostElement(): HTMLElement {
return this._element.nativeElement;
}
+5
View File
@@ -25,6 +25,11 @@ md-select {
[dir='rtl'] & {
transform-origin: right top;
}
// TODO: Double-check accessibility of this style
[aria-required=true] &::after {
content: '*';
}
}
.md-select-value {
+160 -14
View File
@@ -1,11 +1,12 @@
import {TestBed, async, ComponentFixture} from '@angular/core/testing';
import {By} from '@angular/platform-browser';
import {Component, DebugElement, QueryList, ViewChild, ViewChildren} from '@angular/core';
import {Component, QueryList, ViewChild, ViewChildren} from '@angular/core';
import {MdSelectModule} from './index';
import {OverlayContainer} from '../core/overlay/overlay-container';
import {MdSelect} from './select';
import {MdOption} from './option';
import {Dir} from '../core/rtl/dir';
import {FormControl, ReactiveFormsModule} from '@angular/forms';
describe('MdSelect', () => {
let overlayContainerElement: HTMLElement;
@@ -13,7 +14,7 @@ describe('MdSelect', () => {
beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [MdSelectModule.forRoot()],
imports: [MdSelectModule.forRoot(), ReactiveFormsModule],
declarations: [BasicSelect],
providers: [
{provide: OverlayContainer, useFactory: () => {
@@ -190,7 +191,7 @@ describe('MdSelect', () => {
}));
it('should select an option that was added after initialization', () => {
fixture.componentInstance.foods.push({viewValue: 'Pasta'});
fixture.componentInstance.foods.push({viewValue: 'Pasta', value: 'pasta-3'});
trigger.click();
fixture.detectChanges();
@@ -206,6 +207,114 @@ describe('MdSelect', () => {
});
describe('forms integration', () => {
let fixture: ComponentFixture<BasicSelect>;
let trigger: HTMLElement;
beforeEach(() => {
fixture = TestBed.createComponent(BasicSelect);
fixture.detectChanges();
trigger = fixture.debugElement.query(By.css('.md-select-trigger')).nativeElement;
});
it('should set the view value from the form', () => {
let value = fixture.debugElement.query(By.css('.md-select-value'));
expect(value).toBeNull('Expected trigger to start with empty value.');
fixture.componentInstance.control.setValue('pizza-1');
fixture.detectChanges();
value = fixture.debugElement.query(By.css('.md-select-value'));
expect(value.nativeElement.textContent)
.toContain('Pizza', `Expected trigger to be populated by the control's new value.`);
trigger.click();
fixture.detectChanges();
const options =
overlayContainerElement.querySelectorAll('md-option') as NodeListOf<HTMLElement>;
expect(options[1].classList)
.toContain('md-selected', `Expected option with the control's new value to be selected.`);
});
it('should update the form value when the view changes', () => {
expect(fixture.componentInstance.control.value)
.toEqual(null, `Expected the control's value to be null initially.`);
trigger.click();
fixture.detectChanges();
const option = overlayContainerElement.querySelector('md-option') as HTMLElement;
option.click();
fixture.detectChanges();
expect(fixture.componentInstance.control.value)
.toEqual('steak-0', `Expected control's value to be set to the new option.`);
});
it('should set the control to touched when the select is touched', () => {
expect(fixture.componentInstance.control.touched)
.toEqual(false, `Expected the control to start off as untouched.`);
trigger.click();
dispatchEvent('blur', trigger);
fixture.detectChanges();
expect(fixture.componentInstance.control.touched)
.toEqual(false, `Expected the control to stay untouched when menu opened.`);
const backdrop =
overlayContainerElement.querySelector('.md-overlay-backdrop') as HTMLElement;
backdrop.click();
dispatchEvent('blur', trigger);
fixture.detectChanges();
expect(fixture.componentInstance.control.touched)
.toEqual(true, `Expected the control to be touched as soon as focus left the select.`);
});
it('should set the control to dirty when the select\'s value changes in the DOM', () => {
expect(fixture.componentInstance.control.dirty)
.toEqual(false, `Expected control to start out pristine.`);
trigger.click();
fixture.detectChanges();
const option = overlayContainerElement.querySelector('md-option') as HTMLElement;
option.click();
fixture.detectChanges();
expect(fixture.componentInstance.control.dirty)
.toEqual(true, `Expected control to be dirty after value was changed by user.`);
});
it('should not set the control to dirty when the value changes programmatically', () => {
expect(fixture.componentInstance.control.dirty)
.toEqual(false, `Expected control to start out pristine.`);
fixture.componentInstance.control.setValue('pizza-1');
expect(fixture.componentInstance.control.dirty)
.toEqual(false, `Expected control to stay pristine after programmatic change.`);
});
it('should set an asterisk after the placeholder if the control is required', () => {
const placeholder =
fixture.debugElement.query(By.css('.md-select-placeholder')).nativeElement;
const initialContent = getComputedStyle(placeholder, '::after').getPropertyValue('content');
// must support both default cases to work in all browsers in Saucelabs
expect(initialContent === 'none' || initialContent === '')
.toBe(true, `Expected placeholder not to have an asterisk, as control was not required.`);
fixture.componentInstance.isRequired = true;
fixture.detectChanges();
expect(getComputedStyle(placeholder, '::after').getPropertyValue('content'))
.toContain('*', `Expected placeholder to have an asterisk, as control was required.`);
});
});
describe('animations', () => {
let fixture: ComponentFixture<BasicSelect>;
let trigger: HTMLElement;
@@ -278,22 +387,44 @@ describe('MdSelect', () => {
});
describe('for select', () => {
let select: DebugElement;
let select: HTMLElement;
beforeEach(() => {
select = fixture.debugElement.query(By.css('md-select'));
select = fixture.debugElement.query(By.css('md-select')).nativeElement;
});
it('should set the role of the select to listbox', () => {
expect(select.nativeElement.getAttribute('role')).toEqual('listbox');
expect(select.getAttribute('role')).toEqual('listbox');
});
it('should set the aria label of the select to the placeholder', () => {
expect(select.nativeElement.getAttribute('aria-label')).toEqual('Food');
expect(select.getAttribute('aria-label')).toEqual('Food');
});
it('should set the tabindex of the select to 0', () => {
expect(select.nativeElement.getAttribute('tabindex')).toEqual('0');
expect(select.getAttribute('tabindex')).toEqual('0');
});
it('should set aria-required for required selects', () => {
expect(select.getAttribute('aria-required'))
.toEqual('false', `Expected aria-required attr to be false for normal selects.`);
fixture.componentInstance.isRequired = true;
fixture.detectChanges();
expect(select.getAttribute('aria-required'))
.toEqual('true', `Expected aria-required attr to be true for required selects.`);
});
it('should set aria-invalid for selects that are invalid', () => {
expect(select.getAttribute('aria-invalid'))
.toEqual('false', `Expected aria-invalid attr to be false for valid selects.`);
fixture.componentInstance.isRequired = true;
fixture.detectChanges();
expect(select.getAttribute('aria-invalid'))
.toEqual('true', `Expected aria-invalid attr to be true for invalid selects.`);
});
});
@@ -347,19 +478,34 @@ describe('MdSelect', () => {
@Component({
selector: 'basic-select',
template: `
<md-select placeholder="Food">
<md-option *ngFor="let food of foods">{{ food.viewValue }}</md-option>
<md-select placeholder="Food" [formControl]="control" [required]="isRequired">
<md-option *ngFor="let food of foods" [value]="food.value">{{ food.viewValue }}</md-option>
</md-select>
`
})
class BasicSelect {
foods = [
{ viewValue: 'Steak' },
{ viewValue: 'Pizza' },
{ viewValue: 'Tacos' },
{ value: 'steak-0', viewValue: 'Steak' },
{ value: 'pizza-1', viewValue: 'Pizza' },
{ value: 'tacos-2', viewValue: 'Tacos' },
];
control = new FormControl();
isRequired: boolean;
@ViewChild(MdSelect) select: MdSelect;
@ViewChildren(MdOption) options: QueryList<MdOption>;
}
/**
* 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);
}
+79 -6
View File
@@ -19,6 +19,8 @@ import {ListKeyManager} from '../core/a11y/list-key-manager';
import {Dir} from '../core/rtl/dir';
import {Subscription} from 'rxjs/Subscription';
import {transformPlaceholder, transformPanel, fadeInContent} from './select-animations';
import {ControlValueAccessor, NgControl} from '@angular/forms';
import {coerceBooleanProperty} from '../core/coersion/boolean-property';
@Component({
moduleId: module.id,
@@ -30,16 +32,19 @@ import {transformPlaceholder, transformPanel, fadeInContent} from './select-anim
'role': 'listbox',
'tabindex': '0',
'[attr.aria-label]': 'placeholder',
'(keydown)': '_openOnActivate($event)'
'[attr.aria-required]': 'required.toString()',
'[attr.aria-invalid]': '_control?.invalid || "false"',
'(keydown)': '_handleKeydown($event)',
'(blur)': '_onBlur()'
},
animations: [
transformPlaceholder,
transformPanel,
fadeInContent
],
exportAs: 'mdSelect'
exportAs: 'mdSelect',
})
export class MdSelect implements AfterContentInit, OnDestroy {
export class MdSelect implements AfterContentInit, ControlValueAccessor, OnDestroy {
/** Whether or not the overlay panel is open. */
private _panelOpen = false;
@@ -55,9 +60,18 @@ export class MdSelect implements AfterContentInit, OnDestroy {
/** Subscription to tab events while overlay is focused. */
private _tabSubscription: Subscription;
/** Whether filling out the select is required in the form. */
private _required: boolean = false;
/** Manages keyboard events for options in the panel. */
_keyManager: ListKeyManager;
/** View -> model callback called when value changes */
_onChange: (value: any) => void;
/** View -> model callback called when select has been touched */
_onTouched: Function;
/** This position config ensures that the top left corner of the overlay
* is aligned with with the top left of the origin (overlapping the trigger
* completely). In RTL mode, the top right corners are aligned instead.
@@ -73,11 +87,23 @@ export class MdSelect implements AfterContentInit, OnDestroy {
@ContentChildren(MdOption) options: QueryList<MdOption>;
@Input() placeholder: string;
@Input()
get required() {
return this._required;
}
set required(value: any) {
this._required = coerceBooleanProperty(value);
}
@Output() onOpen = new EventEmitter();
@Output() onClose = new EventEmitter();
constructor(private _element: ElementRef, private _renderer: Renderer,
@Optional() private _dir: Dir) {}
@Optional() private _dir: Dir, @Optional() public _control: NgControl) {
this._control.valueAccessor = this;
}
ngAfterContentInit() {
this._initKeyManager();
@@ -111,6 +137,38 @@ export class MdSelect implements AfterContentInit, OnDestroy {
this._focusHost();
}
/**
* Sets the select's value. Part of the ControlValueAccessor interface
* required to integrate with Angular's core forms API.
*/
writeValue(value: any): void {
if (!this.options) { return; }
this.options.forEach((option: MdOption) => {
if (option.value === value) {
option.select();
}
});
}
/**
* Saves a callback function to be invoked when the select's value
* changes from user input. Part of the ControlValueAccessor interface
* required to integrate with Angular's core forms API.
*/
registerOnChange(fn: (value: any) => void): void {
this._onChange = fn;
}
/**
* Saves a callback function to be invoked when the select is blurred
* by the user. Part of the ControlValueAccessor interface required
* to integrate with Angular's core forms API.
*/
registerOnTouched(fn: Function): void {
this._onTouched = fn;
}
/** Whether or not the overlay panel is open. */
get panelOpen(): boolean {
return this._panelOpen;
@@ -147,7 +205,7 @@ export class MdSelect implements AfterContentInit, OnDestroy {
}
/** Ensures the panel opens if activated by the keyboard. */
_openOnActivate(event: KeyboardEvent): void {
_handleKeydown(event: KeyboardEvent): void {
if (event.keyCode === ENTER || event.keyCode === SPACE) {
this.open();
}
@@ -166,6 +224,16 @@ export class MdSelect implements AfterContentInit, OnDestroy {
}
}
/**
* Calls the touched callback only if the panel is closed. Otherwise, the trigger will
* "blur" to the panel when it opens, causing a false positive.
*/
_onBlur() {
if (!this.panelOpen) {
this._onTouched();
}
}
/** Sets up a key manager to listen to keyboard events on the overlay panel. */
private _initKeyManager() {
this._keyManager = new ListKeyManager(this.options);
@@ -177,7 +245,12 @@ export class MdSelect implements AfterContentInit, OnDestroy {
/** Listens to selection events on each option. */
private _listenToOptions(): void {
this.options.forEach((option: MdOption) => {
const sub = option.onSelect.subscribe(() => this._onSelect(option));
const sub = option.onSelect.subscribe((isUserInput: boolean) => {
if (isUserInput) {
this._onChange(option.value);
}
this._onSelect(option);
});
this._subscriptions.push(sub);
});
}