mirror of
https://github.com/angular/angular.git
synced 2026-09-14 13:54:52 +08:00
feat(select): basic forms support (#1655)
This commit is contained in:
@@ -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(),
|
||||
],
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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('');
|
||||
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user