Skip to content

Commit

Permalink
feat(select): integrate with angular forms
Browse files Browse the repository at this point in the history
  • Loading branch information
kara committed Oct 31, 2016
1 parent 5a781a0 commit 053408a
Show file tree
Hide file tree
Showing 8 changed files with 250 additions and 33 deletions.
3 changes: 2 additions & 1 deletion src/demo-app/demo-app-module.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -39,6 +39,7 @@ import {TabsDemo, SunnyTabContent, RainyTabContent, FoggyTabContent} from './tab
BrowserModule,
FormsModule,
HttpModule,
ReactiveFormsModule,
RouterModule.forRoot(DEMO_APP_ROUTES),
MaterialModule.forRoot(),
],
Expand Down
10 changes: 8 additions & 2 deletions src/demo-app/select/select-demo.html
Original file line number Diff line number Diff line change
@@ -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>
11 changes: 7 additions & 4 deletions src/demo-app/select/select-demo.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import {Component} from '@angular/core';

import {FormControl} from '@angular/forms';

@Component({
moduleId: module.id,
Expand All @@ -9,8 +9,11 @@ import {Component} from '@angular/core';
})
export class SelectDemo {
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('');

}
13 changes: 9 additions & 4 deletions src/lib/select/_select-theme.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}

Expand All @@ -27,6 +28,10 @@
md-select:focus & {
color: md-color($primary);
}

.ng-invalid.ng-touched & {
color: md-color($warn);
}
}

.md-select-content {
Expand Down
12 changes: 8 additions & 4 deletions src/lib/select/option.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {
Component,
ElementRef,
EventEmitter,
Input,
Output,
Renderer,
ViewEncapsulation
Expand All @@ -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)': 'select(true)',
'(keydown)': '_handleKeydown($event)'
},
templateUrl: 'option.html',
Expand All @@ -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();

Expand All @@ -46,9 +50,9 @@ export class MdOption {
}

/** Selects the option. */
select(): void {
select(isUserInput = false): void {
this._selected = true;
this.onSelect.emit();
this.onSelect.emit(isUserInput);
}

/** Deselects the option. */
Expand All @@ -64,7 +68,7 @@ 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.select(true);
}
}

Expand Down
4 changes: 4 additions & 0 deletions src/lib/select/select.scss
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,10 @@ md-select {
[dir='rtl'] & {
transform-origin: right top;
}

[aria-required=true] &::after {
content: '*';
}
}

.md-select-value {
Expand Down
148 changes: 135 additions & 13 deletions src/lib/select/select.spec.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,20 @@
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;
let dir: {value: string};

beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [MdSelectModule.forRoot()],
imports: [MdSelectModule.forRoot(), ReactiveFormsModule],
declarations: [BasicSelect],
providers: [
{provide: OverlayContainer, useFactory: () => {
Expand Down Expand Up @@ -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();

Expand All @@ -206,6 +207,94 @@ 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();

fixture.componentInstance.control.setValue('pizza-1');
fixture.detectChanges();

value = fixture.debugElement.query(By.css('.md-select-value'));
expect(value.nativeElement.textContent).toContain('Pizza');
});

it('should update the form value when the view changes', () => {
expect(fixture.componentInstance.control.value).toEqual(null);

trigger.click();
fixture.detectChanges();

const option = overlayContainerElement.querySelector('md-option') as HTMLElement;
option.click();
fixture.detectChanges();

expect(fixture.componentInstance.control.value).toEqual('steak-0');
});

it('should set the control to touched when the select is touched', () => {
expect(fixture.componentInstance.control.touched).toEqual(false);

trigger.click();
fixture.detectChanges();
expect(fixture.componentInstance.control.touched).toEqual(false);

const backdrop =
overlayContainerElement.querySelector('.md-overlay-backdrop') as HTMLElement;
backdrop.click();
dispatchEvent('blur', trigger);
fixture.detectChanges();

expect(fixture.componentInstance.control.touched).toEqual(true);
});

it('should set the control to dirty when the select\'s value changes in the DOM', () => {
expect(fixture.componentInstance.control.dirty).toEqual(false);

trigger.click();
fixture.detectChanges();

const option = overlayContainerElement.querySelector('md-option') as HTMLElement;
option.click();
fixture.detectChanges();

expect(fixture.componentInstance.control.dirty).toEqual(true);
});

it('should not set the control to dirty when the value changes programmatically', () => {
expect(fixture.componentInstance.control.dirty).toEqual(false);

fixture.componentInstance.control.setValue('pizza-1');

expect(fixture.componentInstance.control.dirty).toEqual(false);
});


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);

fixture.componentInstance.isRequired = true;
fixture.detectChanges();
expect(getComputedStyle(placeholder, '::after').getPropertyValue('content')).toContain('*');
});

});

describe('animations', () => {
let fixture: ComponentFixture<BasicSelect>;
let trigger: HTMLElement;
Expand Down Expand Up @@ -278,22 +367,40 @@ 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');

fixture.componentInstance.isRequired = true;
fixture.detectChanges();

expect(select.getAttribute('aria-required')).toEqual('true');
});

it('should set aria-invalid for selects that are invalid', () => {
expect(select.getAttribute('aria-invalid')).toEqual('false');

fixture.componentInstance.isRequired = true;
fixture.detectChanges();

expect(select.getAttribute('aria-invalid')).toEqual('true');
});

});
Expand Down Expand Up @@ -347,19 +454,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);
}
Loading

0 comments on commit 053408a

Please sign in to comment.