From 0c6f3432bdc10afba9a57496bf6d9ab4225d4dc5 Mon Sep 17 00:00:00 2001 From: Kara Erickson Date: Mon, 31 Oct 2016 14:48:50 -0700 Subject: [PATCH] feat(select): integrate with angular forms --- src/demo-app/demo-app-module.ts | 3 +- src/demo-app/select/select-demo.html | 10 +- src/demo-app/select/select-demo.ts | 11 +- src/lib/select/_select-theme.scss | 13 ++- src/lib/select/option.ts | 12 ++- src/lib/select/select.scss | 4 + src/lib/select/select.spec.ts | 149 ++++++++++++++++++++++++--- src/lib/select/select.ts | 82 ++++++++++++++- 8 files changed, 251 insertions(+), 33 deletions(-) diff --git a/src/demo-app/demo-app-module.ts b/src/demo-app/demo-app-module.ts index 9dcd619629de..048530e046fe 100644 --- a/src/demo-app/demo-app-module.ts +++ b/src/demo-app/demo-app-module.ts @@ -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(), ], diff --git a/src/demo-app/select/select-demo.html b/src/demo-app/select/select-demo.html index 67390a5987cd..bb6476d0f200 100644 --- a/src/demo-app/select/select-demo.html +++ b/src/demo-app/select/select-demo.html @@ -1,5 +1,11 @@
- - {{ food.viewValue }} + + {{ food.viewValue }} +

Value: {{ control.value }}

+

Touched: {{ control.touched }}

+

Dirty: {{ control.dirty }}

+

Status: {{ control.status }}

+ +
diff --git a/src/demo-app/select/select-demo.ts b/src/demo-app/select/select-demo.ts index d8d9ec1615be..9865c640173a 100644 --- a/src/demo-app/select/select-demo.ts +++ b/src/demo-app/select/select-demo.ts @@ -1,5 +1,5 @@ import {Component} from '@angular/core'; - +import {FormControl} from '@angular/forms'; @Component({ moduleId: module.id, @@ -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(''); + } diff --git a/src/lib/select/_select-theme.scss b/src/lib/select/_select-theme.scss index cdb52334dab4..7e692683d7ff 100644 --- a/src/lib/select/_select-theme.scss +++ b/src/lib/select/_select-theme.scss @@ -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 { diff --git a/src/lib/select/option.ts b/src/lib/select/option.ts index 791e683a50ee..f1f615ee1611 100644 --- a/src/lib/select/option.ts +++ b/src/lib/select/option.ts @@ -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)': 'select(true)', '(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(); @@ -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. */ @@ -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); } } diff --git a/src/lib/select/select.scss b/src/lib/select/select.scss index 243f8e4e29e3..ad43ed196867 100644 --- a/src/lib/select/select.scss +++ b/src/lib/select/select.scss @@ -25,6 +25,10 @@ md-select { [dir='rtl'] & { transform-origin: right top; } + + [aria-required=true] &::after { + content: '*'; + } } .md-select-value { diff --git a/src/lib/select/select.spec.ts b/src/lib/select/select.spec.ts index fb2c5c6e67eb..13cc90145bb2 100644 --- a/src/lib/select/select.spec.ts +++ b/src/lib/select/select.spec.ts @@ -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,94 @@ describe('MdSelect', () => { }); + describe('forms integration', () => { + let fixture: ComponentFixture; + 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; let trigger: HTMLElement; @@ -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'); }); }); @@ -347,19 +454,35 @@ describe('MdSelect', () => { @Component({ selector: 'basic-select', template: ` - - {{ food.viewValue }} + + {{ food.viewValue }} + ` }) 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; +} +/** + * 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); } diff --git a/src/lib/select/select.ts b/src/lib/select/select.ts index 3d647f271583..80fe9dcafbf6 100644 --- a/src/lib/select/select.ts +++ b/src/lib/select/select.ts @@ -19,6 +19,7 @@ import {ListKeyManager} from '../core/a11y/list-key-manager'; import {Dir} from '../core/rtl/dir'; import {Subscription} from 'rxjs/Subscription'; import {SelectAnimations} from './select-animations'; +import {ControlValueAccessor, NgControl} from '@angular/forms'; @Component({ moduleId: module.id, @@ -30,16 +31,19 @@ import {SelectAnimations} from './select-animations'; 'role': 'listbox', 'tabindex': '0', '[attr.aria-label]': 'placeholder', - '(keydown)': '_openOnActivate($event)' + '[attr.aria-required]': '_required.toString()', + '[attr.aria-invalid]': '_control?.invalid || "false"', + '(keydown)': '_openOnActivate($event)', + '(blur)': '_onBlur()' }, animations: [ SelectAnimations.transformPlaceholder(), SelectAnimations.transformPanel(), SelectAnimations.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 +59,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 +86,23 @@ export class MdSelect implements AfterContentInit, OnDestroy { @ContentChildren(MdOption) options: QueryList; @Input() placeholder: string; + + @Input() + get required() { + return this._required; + } + + set required(value: any) { + this._required = value != null && `${value}` !== 'false'; + } + @Output() onOpen = new EventEmitter(); @Output() onClose = new EventEmitter(); constructor(private _element: ElementRef, private _renderer: Renderer, - @Optional() private _dir: Dir) {} + @Optional() private _dir: Dir, @Optional() private _control: NgControl) { + this._control.valueAccessor = this; + } ngAfterContentInit() { this._initKeyManager(); @@ -110,6 +135,38 @@ export class MdSelect implements AfterContentInit, OnDestroy { this._panelOpen = false; } + /** + * 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; @@ -163,6 +220,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); @@ -174,7 +241,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); }); }