Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(select): integrate with Angular forms #1655

Merged
merged 1 commit into from
Nov 3, 2016
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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>
13 changes: 9 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 @@ -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('');

}
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: '*';
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Add a TODO here to double-check the accessibility of this.

}
}

.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