From a4ab10f207bbc89de1fe34671e7895258b149a04 Mon Sep 17 00:00:00 2001 From: Kara Date: Thu, 3 Nov 2016 16:01:03 -0700 Subject: [PATCH] feat(select): basic forms support (#1655) --- src/demo-app/demo-app-module.ts | 3 +- src/demo-app/select/select-demo.html | 10 +- src/demo-app/select/select-demo.ts | 13 +- src/lib/select/_select-theme.scss | 13 +- src/lib/select/option.ts | 17 ++- src/lib/select/select.scss | 5 + src/lib/select/select.spec.ts | 174 ++++++++++++++++++++++++--- src/lib/select/select.ts | 85 ++++++++++++- 8 files changed, 287 insertions(+), 33 deletions(-) diff --git a/src/demo-app/demo-app-module.ts b/src/demo-app/demo-app-module.ts index 9dcd619629d..048530e046f 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 67390a5987c..bb6476d0f20 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 d8d9ec1615b..e3cd9f1978b 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, @@ -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(''); + } diff --git a/src/lib/select/_select-theme.scss b/src/lib/select/_select-theme.scss index cdb52334dab..7e692683d7f 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 791e683a50e..fa2d41c9063 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)': '_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; } diff --git a/src/lib/select/select.scss b/src/lib/select/select.scss index 243f8e4e29e..850bed0c3fe 100644 --- a/src/lib/select/select.scss +++ b/src/lib/select/select.scss @@ -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 { diff --git a/src/lib/select/select.spec.ts b/src/lib/select/select.spec.ts index fb2c5c6e67e..ee1d936039e 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,114 @@ 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('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; + 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; 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: ` - - {{ 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 b00d76d6249..09eefc69f60 100644 --- a/src/lib/select/select.ts +++ b/src/lib/select/select.ts @@ -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; @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); }); }