diff --git a/src/cdk/stepper/stepper.ts b/src/cdk/stepper/stepper.ts index 428095a806a0..e80ff26fbf81 100644 --- a/src/cdk/stepper/stepper.ts +++ b/src/cdk/stepper/stepper.ts @@ -24,6 +24,8 @@ import { } from '@angular/core'; import {LEFT_ARROW, RIGHT_ARROW, ENTER, SPACE} from '@angular/cdk/keyboard'; import {CdkStepLabel} from './step-label'; +import {coerceBooleanProperty} from '@angular/cdk/coercion'; +import {AbstractControl} from '@angular/forms'; /** Used to generate unique ID for each stepper component. */ let nextId = 0; @@ -45,7 +47,7 @@ export class CdkStepperSelectionEvent { @Component({ selector: 'cdk-step', - templateUrl: 'step.html', + templateUrl: 'step.html' }) export class CdkStep { /** Template for step label if it exists. */ @@ -54,6 +56,17 @@ export class CdkStep { /** Template for step content. */ @ViewChild(TemplateRef) content: TemplateRef; + /** The top level abstract control of the step. */ + @Input() + get stepControl() { return this._stepControl; } + set stepControl(control: AbstractControl) { + this._stepControl = control; + } + private _stepControl: AbstractControl; + + /** Whether user has seen the expanded step content or not . */ + interacted = false; + /** Label of the step. */ @Input() label: string; @@ -70,7 +83,7 @@ export class CdkStep { selector: 'cdk-stepper', host: { '(focus)': '_focusStep()', - '(keydown)': '_onKeydown($event)', + '(keydown)': '_onKeydown($event)' }, }) export class CdkStepper { @@ -80,11 +93,17 @@ export class CdkStepper { /** The list of step headers of the steps in the stepper. */ _stepHeader: QueryList; + /** Whether the validity of previous steps should be checked or not. */ + @Input() + get linear() { return this._linear; } + set linear(value: any) { this._linear = coerceBooleanProperty(value); } + private _linear = false; + /** The index of the selected step. */ @Input() get selectedIndex() { return this._selectedIndex; } set selectedIndex(index: number) { - if (this._selectedIndex != index) { + if (this._selectedIndex != index && !this._anyControlsInvalid(index)) { this._emitStepperSelectionEvent(index); this._focusStep(this._selectedIndex); } @@ -153,7 +172,7 @@ export class CdkStepper { break; case SPACE: case ENTER: - this._emitStepperSelectionEvent(this._focusIndex); + this.selectedIndex = this._focusIndex; break; default: // Return to avoid calling preventDefault on keys that are not explicitly handled. @@ -166,4 +185,17 @@ export class CdkStepper { this._focusIndex = index; this._stepHeader.toArray()[this._focusIndex].nativeElement.focus(); } + + private _anyControlsInvalid(index: number): boolean { + const stepsArray = this._steps.toArray(); + stepsArray[this._selectedIndex].interacted = true; + if (this._linear) { + for (let i = 0; i < index; i++) { + if (!stepsArray[i].stepControl.valid) { + return true; + } + } + } + return false; + } } diff --git a/src/cdk/stepper/tsconfig-build.json b/src/cdk/stepper/tsconfig-build.json index d00cedda93ea..ea3a295b1e25 100644 --- a/src/cdk/stepper/tsconfig-build.json +++ b/src/cdk/stepper/tsconfig-build.json @@ -4,7 +4,8 @@ "outDir": "../../../dist/packages/cdk", "baseUrl": ".", "paths": { - "@angular/cdk/keyboard": ["../../../dist/packages/cdk/keyboard/public_api"] + "@angular/cdk/keyboard": ["../../../dist/packages/cdk/keyboard/public_api"], + "@angular/cdk/coercion": ["../../../dist/packages/cdk/coercion/public_api"] } }, "files": [ diff --git a/src/demo-app/stepper/stepper-demo.html b/src/demo-app/stepper/stepper-demo.html index e1205208640e..f394b0de8a24 100644 --- a/src/demo-app/stepper/stepper-demo.html +++ b/src/demo-app/stepper/stepper-demo.html @@ -1,3 +1,47 @@ +

Linear Vertical Stepper Demo

+Disable linear mode +
+ + + Fill out your name + + + This field is required + + + + + This field is required + +
+ +
+
+ + + +
Fill out your phone number
+
+ + + This field is required + +
+ + +
+
+ + + Confirm your information + Everything seems correct. +
+ +
+
+
+
+

Vertical Stepper Demo

@@ -134,16 +178,3 @@

Horizontal Stepper Demo with Templated Label

- -

Vertical Stepper Demo

- - - - - -
- - -
-
-
diff --git a/src/demo-app/stepper/stepper-demo.ts b/src/demo-app/stepper/stepper-demo.ts index 7df83cde23f5..cc0bc6673215 100644 --- a/src/demo-app/stepper/stepper-demo.ts +++ b/src/demo-app/stepper/stepper-demo.ts @@ -1,16 +1,39 @@ import {Component} from '@angular/core'; +import {Validators, FormBuilder, FormGroup} from '@angular/forms'; @Component({ moduleId: module.id, selector: 'stepper-demo', templateUrl: 'stepper-demo.html', - styleUrls: ['stepper-demo.scss'], + styleUrls: ['stepper-demo.scss'] }) export class StepperDemo { + formGroup: FormGroup; + isNonLinear = false; + steps = [ {label: 'Confirm your name', content: 'Last name, First name.'}, {label: 'Confirm your contact information', content: '123-456-7890'}, {label: 'Confirm your address', content: '1600 Amphitheater Pkwy MTV'}, {label: 'You are now done', content: 'Finished!'} ]; + + /** Returns a FormArray with the name 'formArray'. */ + get formArray() { return this.formGroup.get('formArray'); } + + constructor(private _formBuilder: FormBuilder) { } + + ngOnInit() { + this.formGroup = this._formBuilder.group({ + formArray: this._formBuilder.array([ + this._formBuilder.group({ + firstNameFormCtrl: ['', Validators.required], + lastNameFormCtrl: ['', Validators.required], + }), + this._formBuilder.group({ + phoneFormCtrl: [''], + }) + ]) + }); + } } diff --git a/src/lib/stepper/stepper.ts b/src/lib/stepper/stepper.ts index f76c63bdd6c0..5a0dd890fa89 100644 --- a/src/lib/stepper/stepper.ts +++ b/src/lib/stepper/stepper.ts @@ -15,26 +15,56 @@ import { // considers such imports as unused (https://github.com/Microsoft/TypeScript/issues/14953) // tslint:disable-next-line:no-unused-variable ElementRef, + Inject, + Optional, QueryList, + SkipSelf, ViewChildren }from '@angular/core'; import {MdStepLabel} from './step-label'; +import { + defaultErrorStateMatcher, + ErrorOptions, + MD_ERROR_GLOBAL_OPTIONS, + ErrorStateMatcher +} from '../core/error/error-options'; +import {FormControl, FormGroupDirective, NgForm} from '@angular/forms'; @Component({ moduleId: module.id, selector: 'md-step, mat-step', - templateUrl: 'step.html' + templateUrl: 'step.html', + providers: [{provide: MD_ERROR_GLOBAL_OPTIONS, useExisting: MdStep}] }) -export class MdStep extends CdkStep { +export class MdStep extends CdkStep implements ErrorOptions { /** Content for step label given by or . */ @ContentChild(MdStepLabel) stepLabel: MdStepLabel; - constructor(mdStepper: MdStepper) { + /** Original ErrorStateMatcher that checks the validity of form control. */ + private _originalErrorStateMatcher: ErrorStateMatcher; + + constructor(mdStepper: MdStepper, + @Optional() @SkipSelf() @Inject(MD_ERROR_GLOBAL_OPTIONS) errorOptions: ErrorOptions) { super(mdStepper); + this._originalErrorStateMatcher = + errorOptions ? errorOptions.errorStateMatcher || defaultErrorStateMatcher + : defaultErrorStateMatcher; + } + + /** Custom error state matcher that additionally checks for validity of interacted form. */ + errorStateMatcher = (control: FormControl, form: FormGroupDirective | NgForm) => { + let originalErrorState = this._originalErrorStateMatcher(control, form); + + // Custom error state checks for the validity of form that is not submitted or touched + // since user can trigger a form change by calling for another step without directly + // interacting with the current form. + let customErrorState = control.invalid && this.interacted; + + return originalErrorState || customErrorState; } } -export class MdStepper extends CdkStepper { +export class MdStepper extends CdkStepper implements ErrorOptions { /** The list of step headers of the steps in the stepper. */ @ViewChildren('stepHeader') _stepHeader: QueryList;