Skip to content

Commit

Permalink
feat(select): basic forms support
Browse files Browse the repository at this point in the history
  • Loading branch information
kara committed Nov 3, 2016
1 parent b14bb72 commit d7b44af
Show file tree
Hide file tree
Showing 8 changed files with 284 additions and 32 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
17 changes: 15 additions & 2 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)': '_selectViaInteraction()',
'(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 Down Expand Up @@ -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;
}
Expand Down
5 changes: 5 additions & 0 deletions src/lib/select/select.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
172 changes: 159 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,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;
Expand Down Expand Up @@ -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.`);
});

});
Expand Down Expand Up @@ -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);
}
Loading

0 comments on commit d7b44af

Please sign in to comment.