diff --git a/src/cdk/stepper/stepper.ts b/src/cdk/stepper/stepper.ts index 31242d557228..b765d6c2f262 100644 --- a/src/cdk/stepper/stepper.ts +++ b/src/cdk/stepper/stepper.ts @@ -20,7 +20,8 @@ import { Component, ContentChild, ViewChild, - TemplateRef + TemplateRef, + ViewEncapsulation } from '@angular/core'; import {LEFT_ARROW, RIGHT_ARROW, ENTER, SPACE} from '@angular/cdk/keycodes'; import {CdkStepLabel} from './step-label'; @@ -53,7 +54,8 @@ export class StepperSelectionEvent { @Component({ selector: 'cdk-step', - templateUrl: 'step.html' + templateUrl: 'step.html', + encapsulation: ViewEncapsulation.None }) export class CdkStep { /** Template for step label if it exists. */ @@ -77,6 +79,35 @@ export class CdkStep { @Input() label: string; + @Input() + get editable() { return this._editable; } + set editable(value: any) { + this._editable = coerceBooleanProperty(value); + } + private _editable = true; + + /** Whether the completion of step is optional or not. */ + @Input() + get optional() { return this._optional; } + set optional(value: any) { + this._optional = coerceBooleanProperty(value); + } + private _optional = false; + + /** Return whether step is completed or not. */ + @Input() + get completed() { + return this._customCompleted == null ? this._defaultCompleted : this._customCompleted; + } + set completed(value: any) { + this._customCompleted = coerceBooleanProperty(value); + } + private _customCompleted: boolean | null = null; + + private get _defaultCompleted() { + return this._stepControl ? this._stepControl.valid && this.interacted : this.interacted; + } + constructor(private _stepper: CdkStepper) { } /** Selects this step component. */ @@ -109,7 +140,8 @@ export class CdkStepper { @Input() get selectedIndex() { return this._selectedIndex; } set selectedIndex(index: number) { - if (this._anyControlsInvalid(index)) { + if (this._anyControlsInvalid(index) + || index < this._selectedIndex && !this._steps.toArray()[index].editable) { // remove focus from clicked step header if the step is not able to be selected this._stepHeader.toArray()[index].nativeElement.blur(); } else if (this._selectedIndex != index) { @@ -134,7 +166,7 @@ export class CdkStepper { _focusIndex: number = 0; /** Used to track unique ID for each stepper component. */ - private _groupId: number; + _groupId: number; constructor() { this._groupId = nextId++; @@ -172,6 +204,16 @@ export class CdkStepper { } } + /** Returns the type of icon to be displayed. */ + _getIndicatorType(index: number): 'number' | 'edit' | 'done' { + const step = this._steps.toArray()[index]; + if (!step.completed || this._selectedIndex == index) { + return 'number'; + } else { + return step.editable ? 'edit' : 'done'; + } + } + private _emitStepperSelectionEvent(newIndex: number): void { const stepsArray = this._steps.toArray(); this.selectionChange.emit({ diff --git a/src/demo-app/stepper/stepper-demo.html b/src/demo-app/stepper/stepper-demo.html index 70dd6ab6b81b..dbbc6b501ed1 100644 --- a/src/demo-app/stepper/stepper-demo.html +++ b/src/demo-app/stepper/stepper-demo.html @@ -19,13 +19,13 @@ <h3>Linear Vertical Stepper Demo using a single form</h3> </div> </md-step> - <md-step formGroupName="1" [stepControl]="formArray.get([1])"> + <md-step formGroupName="1" [stepControl]="formArray.get([1])" optional> <ng-template mdStepLabel> - <div>Fill out your phone number</div> + <div>Fill out your email address</div> </ng-template> <md-input-container> - <input mdInput placeholder="Phone number" formControlName="phoneFormCtrl"> - <md-error>This field is required</md-error> + <input mdInput placeholder="Email address" formControlName="emailFormCtrl"> + <md-error>The input is invalid.</md-error> </md-input-container> <div> <button md-button mdStepperPrevious type="button">Back</button> @@ -62,12 +62,12 @@ <h3>Linear Horizontal Stepper Demo using a different form for each step</h3> </form> </md-step> - <md-step [stepControl]="phoneFormGroup"> - <form [formGroup]="phoneFormGroup"> + <md-step [stepControl]="emailFormGroup" optional> + <form [formGroup]="emailFormGroup"> <ng-template mdStepLabel>Fill out your phone number</ng-template> <md-form-field> - <input mdInput placeholder="Phone number" formControlName="phoneCtrl" required> - <md-error>This field is required</md-error> + <input mdInput placeholder="Email address" formControlName="emailCtrl"> + <md-error>The input is invalid</md-error> </md-form-field> <div> <button md-button mdStepperPrevious>Back</button> @@ -88,30 +88,28 @@ <h3>Linear Horizontal Stepper Demo using a different form for each step</h3> </md-horizontal-stepper> <h3>Vertical Stepper Demo</h3> +<md-checkbox [(ngModel)]="isNonEditable">Make steps non-editable</md-checkbox> <md-vertical-stepper> - <md-step> + <md-step [editable]="!isNonEditable"> <ng-template mdStepLabel>Fill out your name</ng-template> <md-form-field> <input mdInput placeholder="First Name"> - <md-error>This field is required</md-error> </md-form-field> <md-form-field> <input mdInput placeholder="Last Name"> - <md-error>This field is required</md-error> </md-form-field> <div> <button md-button mdStepperNext type="button">Next</button> </div> </md-step> - <md-step> + <md-step [editable]="!isNonEditable"> <ng-template mdStepLabel> <div>Fill out your phone number</div> </ng-template> <md-form-field> <input mdInput placeholder="Phone number"> - <md-error>This field is required</md-error> </md-form-field> <div> <button md-button mdStepperPrevious type="button">Back</button> @@ -119,13 +117,12 @@ <h3>Vertical Stepper Demo</h3> </div> </md-step> - <md-step> + <md-step [editable]="!isNonEditable"> <ng-template mdStepLabel> <div>Fill out your address</div> </ng-template> <md-form-field> <input mdInput placeholder="Address"> - <md-error>This field is required</md-error> </md-form-field> <div> <button md-button mdStepperPrevious type="button">Back</button> @@ -148,12 +145,10 @@ <h3>Horizontal Stepper Demo</h3> <ng-template mdStepLabel>Fill out your name</ng-template> <md-form-field> <input mdInput placeholder="First Name"> - <md-error>This field is required</md-error> </md-form-field> <md-form-field> <input mdInput placeholder="Last Name"> - <md-error>This field is required</md-error> </md-form-field> <div> <button md-button mdStepperNext type="button">Next</button> @@ -161,12 +156,9 @@ <h3>Horizontal Stepper Demo</h3> </md-step> <md-step> - <ng-template mdStepLabel> - <div>Fill out your phone number</div> - </ng-template> + <ng-template mdStepLabel>Fill out your phone number</ng-template> <md-form-field> <input mdInput placeholder="Phone number"> - <md-error>This field is required</md-error> </md-form-field> <div> <button md-button mdStepperPrevious type="button">Back</button> @@ -175,12 +167,9 @@ <h3>Horizontal Stepper Demo</h3> </md-step> <md-step> - <ng-template mdStepLabel> - <div>Fill out your address</div> - </ng-template> + <ng-template mdStepLabel>Fill out your address</ng-template> <md-form-field> <input mdInput placeholder="Address"> - <md-error>This field is required</md-error> </md-form-field> <div> <button md-button mdStepperPrevious type="button">Back</button> diff --git a/src/demo-app/stepper/stepper-demo.ts b/src/demo-app/stepper/stepper-demo.ts index c4f546f0dfed..2c7ed6f7ce92 100644 --- a/src/demo-app/stepper/stepper-demo.ts +++ b/src/demo-app/stepper/stepper-demo.ts @@ -1,6 +1,8 @@ import {Component} from '@angular/core'; import {FormBuilder, FormGroup, Validators} from '@angular/forms'; +const EMAIL_REGEX = /^[a-zA-Z0-9.!#$%&’*+/=?^_`{|}~-]+@[a-zA-Z0-9-]+(?:\.[a-zA-Z0-9-]+)*$/; + @Component({ moduleId: module.id, selector: 'stepper-demo', @@ -10,9 +12,10 @@ import {FormBuilder, FormGroup, Validators} from '@angular/forms'; export class StepperDemo { formGroup: FormGroup; isNonLinear = false; + isNonEditable = false; nameFormGroup: FormGroup; - phoneFormGroup: FormGroup; + emailFormGroup: FormGroup; steps = [ {label: 'Confirm your name', content: 'Last name, First name.'}, @@ -34,8 +37,8 @@ export class StepperDemo { lastNameFormCtrl: ['', Validators.required], }), this._formBuilder.group({ - phoneFormCtrl: [''], - }) + emailFormCtrl: ['', Validators.pattern(EMAIL_REGEX)] + }), ]) }); @@ -44,8 +47,8 @@ export class StepperDemo { lastNameCtrl: ['', Validators.required], }); - this.phoneFormGroup = this._formBuilder.group({ - phoneCtrl: ['', Validators.required] + this.emailFormGroup = this._formBuilder.group({ + emailCtrl: ['', Validators.pattern(EMAIL_REGEX)] }); } } diff --git a/src/lib/stepper/_stepper-theme.scss b/src/lib/stepper/_stepper-theme.scss index d61eb4715996..ed5042d3b031 100644 --- a/src/lib/stepper/_stepper-theme.scss +++ b/src/lib/stepper/_stepper-theme.scss @@ -7,30 +7,29 @@ $background: map-get($theme, background); $primary: map-get($theme, primary); - .mat-horizontal-stepper-header, .mat-vertical-stepper-header { - + .mat-step-header { &:focus, &:hover { background-color: mat-color($background, hover); } - .mat-stepper-label { + .mat-step-label-active { color: mat-color($foreground, text); } - .mat-stepper-index { + .mat-step-label-inactive, + .mat-step-optional { + color: mat-color($foreground, disabled-text); + } + + .mat-step-icon { background-color: mat-color($primary); color: mat-color($primary, default-contrast); } - &[aria-selected='false'] { - .mat-stepper-label { - color: mat-color($foreground, disabled-text); - } - - .mat-stepper-index { - background-color: mat-color($foreground, disabled-text); - } + .mat-step-icon-not-touched { + background-color: mat-color($foreground, disabled-text); + color: mat-color($primary, default-contrast); } } diff --git a/src/lib/stepper/index.ts b/src/lib/stepper/index.ts index de1802347449..416ee5d2295a 100644 --- a/src/lib/stepper/index.ts +++ b/src/lib/stepper/index.ts @@ -17,13 +17,22 @@ import {CdkStepperModule} from '@angular/cdk/stepper'; import {MdCommonModule} from '../core'; import {MdStepLabel} from './step-label'; import {MdStepperNext, MdStepperPrevious} from './stepper-button'; +import {MdIconModule} from '../icon/index'; +import {MdStepHeader} from './step-header'; @NgModule({ - imports: [MdCommonModule, CommonModule, PortalModule, MdButtonModule, CdkStepperModule], + imports: [ + MdCommonModule, + CommonModule, + PortalModule, + MdButtonModule, + CdkStepperModule, + MdIconModule + ], exports: [MdCommonModule, MdHorizontalStepper, MdVerticalStepper, MdStep, MdStepLabel, MdStepper, - MdStepperNext, MdStepperPrevious], + MdStepperNext, MdStepperPrevious, MdStepHeader], declarations: [MdHorizontalStepper, MdVerticalStepper, MdStep, MdStepLabel, MdStepper, - MdStepperNext, MdStepperPrevious], + MdStepperNext, MdStepperPrevious, MdStepHeader], }) export class MdStepperModule {} @@ -32,3 +41,4 @@ export * from './stepper-vertical'; export * from './step-label'; export * from './stepper'; export * from './stepper-button'; +export * from './step-header'; diff --git a/src/lib/stepper/step-header.html b/src/lib/stepper/step-header.html new file mode 100644 index 000000000000..3f2310862f6f --- /dev/null +++ b/src/lib/stepper/step-header.html @@ -0,0 +1,17 @@ +<div [class.mat-step-icon]="icon != 'number' || selected" + [class.mat-step-icon-not-touched]="icon == 'number' && !selected"> + <span *ngIf="icon == 'number'">{{index + 1}}</span> + <md-icon *ngIf="icon == 'edit'">create</md-icon> + <md-icon *ngIf="icon == 'done'">done</md-icon> +</div> +<div [class.mat-step-label-active]="active" + [class.mat-step-label-inactive]="!active"> + <!-- If there is a label template, use it. --> + <ng-container *ngIf="_templateLabel" [ngTemplateOutlet]="label.template"> + </ng-container> + <!-- It there is no label template, fall back to the text label. --> + <div class="mat-step-text-label" *ngIf="_stringLabel">{{label}}</div> + + <div class="mat-step-optional" *ngIf="optional">Optional</div> +</div> + diff --git a/src/lib/stepper/step-header.scss b/src/lib/stepper/step-header.scss new file mode 100644 index 000000000000..94496b4adcfc --- /dev/null +++ b/src/lib/stepper/step-header.scss @@ -0,0 +1,46 @@ +$mat-stepper-label-header-height: 24px !default; +$mat-stepper-label-min-width: 50px !default; +$mat-stepper-side-gap: 24px !default; +$mat-vertical-stepper-content-margin: 36px !default; +$mat-stepper-line-gap: 8px !default; +$mat-step-optional-font-size: 12px; +$mat-step-header-icon-size: 16px !default; + +:host { + display: flex; +} + +.mat-step-optional { + font-size: $mat-step-optional-font-size; +} + +.mat-step-icon, +.mat-step-icon-not-touched { + border-radius: 50%; + height: $mat-stepper-label-header-height; + width: $mat-stepper-label-header-height; + align-items: center; + justify-content: center; + display: flex; +} + +.mat-step-icon .mat-icon { + font-size: $mat-step-header-icon-size; + height: $mat-step-header-icon-size; + width: $mat-step-header-icon-size; +} + +.mat-step-label-active, +.mat-step-label-inactive { + display: inline-block; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + min-width: $mat-stepper-label-min-width; + vertical-align: middle; +} + +.mat-step-text-label { + text-overflow: ellipsis; + overflow: hidden; +} diff --git a/src/lib/stepper/step-header.ts b/src/lib/stepper/step-header.ts new file mode 100644 index 000000000000..14901227f698 --- /dev/null +++ b/src/lib/stepper/step-header.ts @@ -0,0 +1,73 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {Component, Input, ViewEncapsulation} from '@angular/core'; +import {coerceBooleanProperty, coerceNumberProperty} from '@angular/cdk/coercion'; +import {MdStepLabel} from './step-label'; + +@Component({ + selector: 'md-step-header, mat-step-header', + templateUrl: 'step-header.html', + styleUrls: ['step-header.css'], + host: { + 'class': 'mat-step-header', + 'role': 'tab', + }, + encapsulation: ViewEncapsulation.None +}) +export class MdStepHeader { + /** Icon for the given step. */ + @Input() + icon: string; + + /** Label of the given step. */ + @Input() + label: MdStepLabel | string; + + /** Index of the given step. */ + @Input() + get index() { return this._index; } + set index(value: any) { + this._index = coerceNumberProperty(value); + } + private _index: number; + + /** Whether the given step is selected. */ + @Input() + get selected() { return this._selected; } + set selected(value: any) { + this._selected = coerceBooleanProperty(value); + } + private _selected: boolean; + + /** Whether the given step label is active. */ + @Input() + get active() { return this._active; } + set active(value: any) { + this._active = coerceBooleanProperty(value); + } + private _active: boolean; + + /** Whether the given step is optional. */ + @Input() + get optional() { return this._optional; } + set optional(value: any) { + this._optional = coerceBooleanProperty(value); + } + private _optional: boolean; + + /** Returns string label of given step if it is a text label. */ + get _stringLabel(): string | null { + return this.label instanceof MdStepLabel ? null : this.label; + } + + /** Returns MdStepLabel if the label of given step is a template label. */ + get _templateLabel(): MdStepLabel | null { + return this.label instanceof MdStepLabel ? this.label : null; + } +} diff --git a/src/lib/stepper/stepper-horizontal.html b/src/lib/stepper/stepper-horizontal.html index 6472a981ff9b..5e2d2ea80fa7 100644 --- a/src/lib/stepper/stepper-horizontal.html +++ b/src/lib/stepper/stepper-horizontal.html @@ -1,29 +1,23 @@ <div class="mat-horizontal-stepper-header-container"> <ng-container *ngFor="let step of _steps; let i = index; let isLast = last"> - <div #stepHeader class="mat-horizontal-stepper-header" - role="tab" - [id]="_getStepLabelId(i)" - [attr.aria-controls]="_getStepContentId(i)" - [attr.aria-selected]="selectedIndex == i" - [tabIndex]="_focusIndex == i ? 0 : -1" - (click)="step.select()" - (keydown)="_onKeydown($event)"> - <div class="mat-stepper-index"> - {{i + 1}} - </div> - - <div class="mat-stepper-label"> - <!-- If there is a label template, use it. --> - <ng-container *ngIf="step.stepLabel" [ngTemplateOutlet]="step.stepLabel.template"> - </ng-container> - <!-- It there is no label template, fall back to the text label. --> - <div *ngIf="!step.stepLabel">{{step.label}}</div> - </div> - </div> - + <md-step-header class="mat-horizontal-stepper-header" + (click)="step.select()" + (keydown)="_onKeydown($event)" + [tabIndex]="_focusIndex == i ? 0 : -1" + [id]="_getStepLabelId(i)" + [attr.aria-controls]="_getStepContentId(i)" + [attr.aria-selected]="selectedIndex == i" + [index]="i" + [icon]="_getIndicatorType(i)" + [label]="step.stepLabel || step.label" + [selected]="selectedIndex == i" + [active]="step.completed || selectedIndex == i" + [optional]="step.optional"> + </md-step-header> <div *ngIf="!isLast" class="mat-stepper-horizontal-line"></div> </ng-container> </div> + <div class="mat-horizontal-content-container"> <div *ngFor="let step of _steps; let i = index" class="mat-horizontal-stepper-content" role="tabpanel" diff --git a/src/lib/stepper/stepper-horizontal.ts b/src/lib/stepper/stepper-horizontal.ts index dbccf06a60c8..776337a8aa5f 100644 --- a/src/lib/stepper/stepper-horizontal.ts +++ b/src/lib/stepper/stepper-horizontal.ts @@ -6,7 +6,7 @@ * found in the LICENSE file at https://angular.io/license */ -import {Component} from '@angular/core'; +import {Component, ViewEncapsulation} from '@angular/core'; import {MdStepper} from './stepper'; import {animate, state, style, transition, trigger} from '@angular/animations'; @@ -29,6 +29,7 @@ import {animate, state, style, transition, trigger} from '@angular/animations'; animate('500ms cubic-bezier(0.35, 0, 0.25, 1)')) ]) ], - providers: [{provide: MdStepper, useExisting: MdHorizontalStepper}] + providers: [{provide: MdStepper, useExisting: MdHorizontalStepper}], + encapsulation: ViewEncapsulation.None }) export class MdHorizontalStepper extends MdStepper { } diff --git a/src/lib/stepper/stepper-vertical.html b/src/lib/stepper/stepper-vertical.html index 99dc3a6ac1c1..e32bf82b4f23 100644 --- a/src/lib/stepper/stepper-vertical.html +++ b/src/lib/stepper/stepper-vertical.html @@ -1,24 +1,19 @@ <div class="mat-step" *ngFor="let step of _steps; let i = index; let isLast = last"> - <div #stepHeader class="mat-vertical-stepper-header" role="tab" - [id]="_getStepLabelId(i)" - [attr.aria-controls]="_getStepContentId(i)" - [attr.aria-selected]="selectedIndex == i" - [tabIndex]="_focusIndex == i ? 0 : -1" - (click)="step.select()" - (keydown)="_onKeydown($event)"> - <div class="mat-stepper-index"> - {{i + 1}} - </div> - - <div class="mat-stepper-label"> - <!-- If there is a label template, use it. --> - <ng-container *ngIf="step.stepLabel"[ngTemplateOutlet]="step.stepLabel.template"> - </ng-container> - <!-- It there is no label template, fall back to the text label. --> - <div *ngIf="!step.stepLabel">{{step.label}}</div> - </div> + <md-step-header class="mat-vertical-stepper-header" + (click)="step.select()" + (keydown)="_onKeydown($event)" + [tabIndex]="_focusIndex == i ? 0 : -1" + [id]="_getStepLabelId(i)" + [attr.aria-controls]="_getStepContentId(i)" + [attr.aria-selected]="selectedIndex == i" + [index]="i" + [icon]="_getIndicatorType(i)" + [label]="step.stepLabel || step.label" + [selected]="selectedIndex == i" + [active]="step.completed || selectedIndex == i" + [optional]="step.optional"> + </md-step-header> - </div> <div class="mat-vertical-content-container" [class.mat-stepper-vertical-line]="!isLast"> <div class="mat-vertical-stepper-content" role="tabpanel" [@stepTransition]="_getAnimationDirection(i)" diff --git a/src/lib/stepper/stepper-vertical.ts b/src/lib/stepper/stepper-vertical.ts index b72349db888c..9f85984e74ef 100644 --- a/src/lib/stepper/stepper-vertical.ts +++ b/src/lib/stepper/stepper-vertical.ts @@ -6,7 +6,7 @@ * found in the LICENSE file at https://angular.io/license */ -import {Component} from '@angular/core'; +import {Component, ViewEncapsulation} from '@angular/core'; import {MdStepper} from './stepper'; import {animate, state, style, transition, trigger} from '@angular/animations'; @@ -28,6 +28,7 @@ import {animate, state, style, transition, trigger} from '@angular/animations'; transition('* <=> current', animate('225ms cubic-bezier(0.4, 0.0, 0.2, 1)')) ]) ], - providers: [{provide: MdStepper, useExisting: MdVerticalStepper}] + providers: [{provide: MdStepper, useExisting: MdVerticalStepper}], + encapsulation: ViewEncapsulation.None }) export class MdVerticalStepper extends MdStepper { } diff --git a/src/lib/stepper/stepper.scss b/src/lib/stepper/stepper.scss index c91bf3dc4c88..8f443c1f5d63 100644 --- a/src/lib/stepper/stepper.scss +++ b/src/lib/stepper/stepper.scss @@ -2,7 +2,6 @@ $mat-horizontal-stepper-header-height: 72px !default; $mat-stepper-label-header-height: 24px !default; -$mat-stepper-label-min-width: 50px !default; $mat-stepper-side-gap: 24px !default; $mat-vertical-stepper-content-margin: 36px !default; $mat-stepper-line-width: 1px !default; @@ -12,21 +11,9 @@ $mat-stepper-line-gap: 8px !default; display: block; } -.mat-stepper-label { - display: inline-flex; - white-space: nowrap; +.mat-step-header { overflow: hidden; - // TODO(jwshin): text-overflow does not work as expected. - text-overflow: ellipsis; - min-width: $mat-stepper-label-min-width; -} - -.mat-stepper-index { - border-radius: 50%; - height: $mat-stepper-label-header-height; - width: $mat-stepper-label-header-height; - text-align: center; - line-height: $mat-stepper-label-header-height; + outline: none; } .mat-horizontal-stepper-header-container { @@ -35,17 +22,25 @@ $mat-stepper-line-gap: 8px !default; align-items: center; } +.mat-stepper-horizontal-line { + border-top-width: $mat-stepper-line-width; + border-top-style: solid; + flex: auto; + height: 0; + margin: 0 $mat-stepper-line-gap - $mat-stepper-side-gap; + min-width: $mat-stepper-line-gap + $mat-stepper-side-gap; +} + .mat-horizontal-stepper-header { - display: inline-flex; - line-height: $mat-horizontal-stepper-header-height; + display: flex; + height: $mat-horizontal-stepper-header-height; overflow: hidden; align-items: center; - outline: none; padding: 0 $mat-stepper-side-gap; - .mat-stepper-index { + .mat-step-icon, + .mat-step-icon-not-touched { margin-right: $mat-stepper-line-gap; - display: inline-block; flex: none; } } @@ -54,22 +49,14 @@ $mat-stepper-line-gap: 8px !default; display: flex; align-items: center; padding: $mat-stepper-side-gap; - outline: none; + max-height: $mat-stepper-label-header-height; - .mat-stepper-index { + .mat-step-icon, + .mat-step-icon-not-touched { margin-right: $mat-vertical-stepper-content-margin - $mat-stepper-side-gap; } } -.mat-stepper-horizontal-line { - border-top-width: $mat-stepper-line-width; - border-top-style: solid; - flex: auto; - height: 0; - margin: 0 $mat-stepper-line-gap - $mat-stepper-side-gap; - min-width: $mat-stepper-line-gap + $mat-stepper-side-gap; -} - .mat-horizontal-stepper-content { overflow: hidden; diff --git a/src/lib/stepper/stepper.spec.ts b/src/lib/stepper/stepper.spec.ts index 7e266674638a..ca3025b606fb 100644 --- a/src/lib/stepper/stepper.spec.ts +++ b/src/lib/stepper/stepper.spec.ts @@ -11,6 +11,8 @@ import {dispatchKeyboardEvent} from '@angular/cdk/testing'; import {ENTER, LEFT_ARROW, RIGHT_ARROW, SPACE} from '@angular/cdk/keycodes'; import {MdStepper} from './stepper'; +const EMAIL_REGEX = /^[a-zA-Z0-9.!#$%&’*+/=?^_`{|}~-]+@[a-zA-Z0-9-]+(?:\.[a-zA-Z0-9-]+)*$/; + describe('MdHorizontalStepper', () => { beforeEach(async(() => { TestBed.configureTestingModule({ @@ -42,7 +44,7 @@ describe('MdHorizontalStepper', () => { it('should change selected index on header click', () => { let stepHeaders = fixture.debugElement.queryAll(By.css('.mat-horizontal-stepper-header')); - checkSelectionChangeOnHeaderClick(stepperComponent, fixture, stepHeaders); + assertSelectionChangeOnHeaderClick(stepperComponent, fixture, stepHeaders); }); it('should set the "tablist" role on stepper', () => { @@ -52,34 +54,44 @@ describe('MdHorizontalStepper', () => { it('should set aria-expanded of content correctly', () => { let stepContents = fixture.debugElement.queryAll(By.css(`.mat-horizontal-stepper-content`)); - checkExpandedContent(stepperComponent, fixture, stepContents); + assertCorrectAriaExpandedAttribute(stepperComponent, fixture, stepContents); }); it('should display the correct label', () => { - checkCorrectLabel(stepperComponent, fixture); + assertCorrectStepLabel(stepperComponent, fixture); }); it('should go to next available step when the next button is clicked', () => { - checkNextStepperButton(stepperComponent, fixture); + assertNextStepperButtonClick(stepperComponent, fixture); }); it('should go to previous available step when the previous button is clicked', () => { - checkPreviousStepperButton(stepperComponent, fixture); + assertPreviousStepperButtonClick(stepperComponent, fixture); }); it('should set the correct step position for animation', () => { - checkStepPosition(stepperComponent, fixture); + assertCorrectStepPosition(stepperComponent, fixture); }); it('should support keyboard events to move and select focus', () => { let stepHeaders = fixture.debugElement.queryAll(By.css('.mat-horizontal-stepper-header')); - checkKeyboardEvent(stepperComponent, fixture, stepHeaders); + assertCorrectKeyboardInteraction(stepperComponent, fixture, stepHeaders); }); it('should not set focus on header of selected step if header is not clicked', () => { - let stepHeaderEl = fixture.debugElement - .queryAll(By.css('.mat-horizontal-stepper-header'))[1].nativeElement; - checkStepHeaderFocusNotCalled(stepHeaderEl, stepperComponent, fixture); + assertStepHeaderFocusNotCalled(stepperComponent, fixture); + }); + + it('should only be able to return to a previous step if it is editable', () => { + assertEditableStepChange(stepperComponent, fixture); + }); + + it('should set create icon if step is editable and completed', () => { + assertCorrectStepIcon(stepperComponent, fixture, true, 'edit'); + }); + + it('should set done icon if step is not editable and is completed', () => { + assertCorrectStepIcon(stepperComponent, fixture, false, 'done'); }); }); @@ -109,13 +121,15 @@ describe('MdHorizontalStepper', () => { let stepHeaderEl = fixture.debugElement .queryAll(By.css('.mat-horizontal-stepper-header'))[1].nativeElement; - checkLinearStepperValidity(stepHeaderEl, stepperComponent, testComponent, fixture); + assertLinearStepperValidity(stepHeaderEl, stepperComponent, testComponent, fixture); }); it('should not focus step header upon click if it is not able to be selected', () => { - let stepHeaderEl = fixture.debugElement - .queryAll(By.css('.mat-horizontal-stepper-header'))[1].nativeElement; - checkStepHeaderBlur(stepHeaderEl, fixture); + assertStepHeaderBlurred(fixture); + }); + + it('should be able to move to next step even when invalid if current step is optional', () => { + assertOptionalStepValidity(stepperComponent, testComponent, fixture); }); }); }); @@ -151,7 +165,7 @@ describe('MdVerticalStepper', () => { it('should change selected index on header click', () => { let stepHeaders = fixture.debugElement.queryAll(By.css('.mat-vertical-stepper-header')); - checkSelectionChangeOnHeaderClick(stepperComponent, fixture, stepHeaders); + assertSelectionChangeOnHeaderClick(stepperComponent, fixture, stepHeaders); }); @@ -162,34 +176,44 @@ describe('MdVerticalStepper', () => { it('should set aria-expanded of content correctly', () => { let stepContents = fixture.debugElement.queryAll(By.css(`.mat-vertical-stepper-content`)); - checkExpandedContent(stepperComponent, fixture, stepContents); + assertCorrectAriaExpandedAttribute(stepperComponent, fixture, stepContents); }); it('should display the correct label', () => { - checkCorrectLabel(stepperComponent, fixture); + assertCorrectStepLabel(stepperComponent, fixture); }); it('should go to next available step when the next button is clicked', () => { - checkNextStepperButton(stepperComponent, fixture); + assertNextStepperButtonClick(stepperComponent, fixture); }); it('should go to previous available step when the previous button is clicked', () => { - checkPreviousStepperButton(stepperComponent, fixture); + assertPreviousStepperButtonClick(stepperComponent, fixture); }); it('should set the correct step position for animation', () => { - checkStepPosition(stepperComponent, fixture); + assertCorrectStepPosition(stepperComponent, fixture); }); it('should support keyboard events to move and select focus', () => { let stepHeaders = fixture.debugElement.queryAll(By.css('.mat-vertical-stepper-header')); - checkKeyboardEvent(stepperComponent, fixture, stepHeaders); + assertCorrectKeyboardInteraction(stepperComponent, fixture, stepHeaders); }); it('should not set focus on header of selected step if header is not clicked', () => { - let stepHeaderEl = fixture.debugElement - .queryAll(By.css('.mat-vertical-stepper-header'))[1].nativeElement; - checkStepHeaderFocusNotCalled(stepHeaderEl, stepperComponent, fixture); + assertStepHeaderFocusNotCalled(stepperComponent, fixture); + }); + + it('should only be able to return to a previous step if it is editable', () => { + assertEditableStepChange(stepperComponent, fixture); + }); + + it('should set create icon if step is editable and completed', () => { + assertCorrectStepIcon(stepperComponent, fixture, true, 'edit'); + }); + + it('should set done icon if step is not editable and is completed', () => { + assertCorrectStepIcon(stepperComponent, fixture, false, 'done'); }); }); @@ -220,18 +244,21 @@ describe('MdVerticalStepper', () => { let stepHeaderEl = fixture.debugElement .queryAll(By.css('.mat-vertical-stepper-header'))[1].nativeElement; - checkLinearStepperValidity(stepHeaderEl, stepperComponent, testComponent, fixture); + assertLinearStepperValidity(stepHeaderEl, stepperComponent, testComponent, fixture); }); it('should not focus step header upon click if it is not able to be selected', () => { - let stepHeaderEl = fixture.debugElement - .queryAll(By.css('.mat-vertical-stepper-header'))[1].nativeElement; - checkStepHeaderBlur(stepHeaderEl, fixture); + assertStepHeaderBlurred(fixture); + }); + + it('should be able to move to next step even when invalid if current step is optional', () => { + assertOptionalStepValidity(stepperComponent, testComponent, fixture); }); }); }); -function checkSelectionChangeOnHeaderClick(stepperComponent: MdStepper, +/** Asserts that `selectedIndex` updates correctly when header of another step is clicked. */ +function assertSelectionChangeOnHeaderClick(stepperComponent: MdStepper, fixture: ComponentFixture<any>, stepHeaders: DebugElement[]) { expect(stepperComponent.selectedIndex).toBe(0); @@ -251,7 +278,8 @@ function checkSelectionChangeOnHeaderClick(stepperComponent: MdStepper, expect(stepperComponent.selectedIndex).toBe(2); } -function checkExpandedContent(stepperComponent: MdStepper, +/** Asserts that 'aria-expanded' attribute is correct for expanded content of step. */ +function assertCorrectAriaExpandedAttribute(stepperComponent: MdStepper, fixture: ComponentFixture<any>, stepContents: DebugElement[]) { let firstStepContentEl = stepContents[0].nativeElement; @@ -265,7 +293,8 @@ function checkExpandedContent(stepperComponent: MdStepper, expect(secondStepContentEl.getAttribute('aria-expanded')).toBe('true'); } -function checkCorrectLabel(stepperComponent: MdStepper, fixture: ComponentFixture<any>) { +/** Asserts that step has correct label. */ +function assertCorrectStepLabel(stepperComponent: MdStepper, fixture: ComponentFixture<any>) { let selectedLabel = fixture.nativeElement.querySelector('[aria-selected="true"]'); expect(selectedLabel.textContent).toMatch('Step 1'); @@ -282,7 +311,8 @@ function checkCorrectLabel(stepperComponent: MdStepper, fixture: ComponentFixtur expect(selectedLabel.textContent).toMatch('New Label'); } -function checkNextStepperButton(stepperComponent: MdStepper, fixture: ComponentFixture<any>) { +/** Asserts that clicking on MdStepperNext button updates `selectedIndex` correctly. */ +function assertNextStepperButtonClick(stepperComponent: MdStepper, fixture: ComponentFixture<any>) { expect(stepperComponent.selectedIndex).toBe(0); let nextButtonNativeEl = fixture.debugElement @@ -307,7 +337,9 @@ function checkNextStepperButton(stepperComponent: MdStepper, fixture: ComponentF expect(stepperComponent.selectedIndex).toBe(2); } -function checkPreviousStepperButton(stepperComponent: MdStepper, fixture: ComponentFixture<any>) { +/** Asserts that clicking on MdStepperPrevious button updates `selectedIndex` correctly. */ +function assertPreviousStepperButtonClick(stepperComponent: MdStepper, + fixture: ComponentFixture<any>) { expect(stepperComponent.selectedIndex).toBe(0); stepperComponent.selectedIndex = 2; @@ -333,7 +365,8 @@ function checkPreviousStepperButton(stepperComponent: MdStepper, fixture: Compon expect(stepperComponent.selectedIndex).toBe(0); } -function checkStepPosition(stepperComponent: MdStepper, fixture: ComponentFixture<any>) { +/** Asserts that step position is correct for animation. */ +function assertCorrectStepPosition(stepperComponent: MdStepper, fixture: ComponentFixture<any>) { expect(stepperComponent._getAnimationDirection(0)).toBe('current'); expect(stepperComponent._getAnimationDirection(1)).toBe('next'); expect(stepperComponent._getAnimationDirection(2)).toBe('next'); @@ -353,7 +386,8 @@ function checkStepPosition(stepperComponent: MdStepper, fixture: ComponentFixtur expect(stepperComponent._getAnimationDirection(2)).toBe('current'); } -function checkKeyboardEvent(stepperComponent: MdStepper, +/** Asserts that keyboard interaction works correctly. */ +function assertCorrectKeyboardInteraction(stepperComponent: MdStepper, fixture: ComponentFixture<any>, stepHeaders: DebugElement[]) { expect(stepperComponent._focusIndex).toBe(0); @@ -411,9 +445,11 @@ function checkKeyboardEvent(stepperComponent: MdStepper, 'Expected index of selected step to change to index of focused step after SPACE event.'); } -function checkStepHeaderFocusNotCalled(stepHeaderEl: HTMLElement, - stepperComponent: MdStepper, - fixture: ComponentFixture<any>) { +/** Asserts that step selection change using stepper buttons does not focus step header. */ +function assertStepHeaderFocusNotCalled(stepperComponent: MdStepper, + fixture: ComponentFixture<any>) { + let stepHeaderEl = fixture.debugElement + .queryAll(By.css('md-step-header'))[1].nativeElement; let nextButtonNativeEl = fixture.debugElement .queryAll(By.directive(MdStepperNext))[0].nativeElement; spyOn(stepHeaderEl, 'focus'); @@ -424,7 +460,10 @@ function checkStepHeaderFocusNotCalled(stepHeaderEl: HTMLElement, expect(stepHeaderEl.focus).not.toHaveBeenCalled(); } -function checkLinearStepperValidity(stepHeaderEl: HTMLElement, +/** + * Asserts that linear stepper does not allow step selection change if current step is not valid. + */ +function assertLinearStepperValidity(stepHeaderEl: HTMLElement, stepperComponent: MdStepper, testComponent: LinearMdHorizontalStepperApp | LinearMdVerticalStepperApp, @@ -449,7 +488,10 @@ function checkLinearStepperValidity(stepHeaderEl: HTMLElement, expect(stepperComponent.selectedIndex).toBe(1); } -function checkStepHeaderBlur(stepHeaderEl: HTMLElement, fixture: ComponentFixture<any>) { +/** Asserts that step header focus is blurred if the step cannot be selected upon header click. */ +function assertStepHeaderBlurred(fixture: ComponentFixture<any>) { + let stepHeaderEl = fixture.debugElement + .queryAll(By.css('md-step-header'))[1].nativeElement; spyOn(stepHeaderEl, 'blur'); stepHeaderEl.click(); fixture.detectChanges(); @@ -457,6 +499,81 @@ function checkStepHeaderBlur(stepHeaderEl: HTMLElement, fixture: ComponentFixtur expect(stepHeaderEl.blur).toHaveBeenCalled(); } +/** Asserts that it is only possible to go back to a previous step if the step is editable. */ +function assertEditableStepChange(stepperComponent: MdStepper, + fixture: ComponentFixture<any>) { + stepperComponent.selectedIndex = 1; + stepperComponent._steps.toArray()[0].editable = false; + let previousButtonNativeEl = fixture.debugElement + .queryAll(By.directive(MdStepperPrevious))[1].nativeElement; + previousButtonNativeEl.click(); + fixture.detectChanges(); + + expect(stepperComponent.selectedIndex).toBe(1); + + stepperComponent._steps.toArray()[0].editable = true; + previousButtonNativeEl.click(); + fixture.detectChanges(); + + expect(stepperComponent.selectedIndex).toBe(0); +} + +/** + * Asserts that it is possible to skip an optional step in linear stepper if there is no input + * or the input is valid. + */ +function assertOptionalStepValidity(stepperComponent: MdStepper, + testComponent: LinearMdHorizontalStepperApp | LinearMdVerticalStepperApp, + fixture: ComponentFixture<any>) { + testComponent.oneGroup.get('oneCtrl')!.setValue('input'); + testComponent.twoGroup.get('twoCtrl')!.setValue('input'); + stepperComponent.selectedIndex = 2; + fixture.detectChanges(); + + expect(stepperComponent.selectedIndex).toBe(2); + expect(testComponent.threeGroup.get('threeCtrl')!.valid).toBe(true); + + let nextButtonNativeEl = fixture.debugElement + .queryAll(By.directive(MdStepperNext))[2].nativeElement; + nextButtonNativeEl.click(); + fixture.detectChanges(); + + expect(stepperComponent.selectedIndex) + .toBe(3, 'Expected selectedIndex to change when optional step input is empty.'); + + stepperComponent.selectedIndex = 2; + testComponent.threeGroup.get('threeCtrl')!.setValue('input'); + nextButtonNativeEl.click(); + fixture.detectChanges(); + + expect(testComponent.threeGroup.get('threeCtrl')!.valid).toBe(false); + expect(stepperComponent.selectedIndex) + .toBe(2, 'Expected selectedIndex to remain unchanged when optional step input is invalid.'); + + testComponent.threeGroup.get('threeCtrl')!.setValue('123@gmail.com'); + nextButtonNativeEl.click(); + fixture.detectChanges(); + + expect(testComponent.threeGroup.get('threeCtrl')!.valid).toBe(true); + expect(stepperComponent.selectedIndex) + .toBe(3, 'Expected selectedIndex to change when optional step input is valid.'); +} + +/** Asserts that step header set the correct icon depending on the state of step. */ +function assertCorrectStepIcon(stepperComponent: MdStepper, + fixture: ComponentFixture<any>, + isEditable: boolean, + icon: String) { + let nextButtonNativeEl = fixture.debugElement + .queryAll(By.directive(MdStepperNext))[0].nativeElement; + expect(stepperComponent._getIndicatorType(0)).toBe('number'); + stepperComponent._steps.toArray()[0].editable = isEditable; + nextButtonNativeEl.click(); + fixture.detectChanges(); + + expect(stepperComponent._getIndicatorType(0)).toBe(icon); +} + @Component({ template: ` <md-horizontal-stepper> @@ -517,12 +634,28 @@ class SimpleMdHorizontalStepperApp { </div> </form> </md-step> + <md-step [stepControl]="threeGroup" optional> + <form [formGroup]="threeGroup"> + <ng-template mdStepLabel>Step two</ng-template> + <md-form-field> + <input mdInput formControlName="threeCtrl"> + </md-form-field> + <div> + <button md-button mdStepperPrevious>Back</button> + <button md-button mdStepperNext>Next</button> + </div> + </form> + </md-step> + <md-step> + Done + </md-step> </md-horizontal-stepper> ` }) class LinearMdHorizontalStepperApp { oneGroup: FormGroup; twoGroup: FormGroup; + threeGroup: FormGroup; ngOnInit() { this.oneGroup = new FormGroup({ @@ -531,6 +664,9 @@ class LinearMdHorizontalStepperApp { this.twoGroup = new FormGroup({ twoCtrl: new FormControl('', Validators.required) }); + this.threeGroup = new FormGroup({ + threeCtrl: new FormControl('', Validators.pattern(EMAIL_REGEX)) + }); } } @@ -594,12 +730,28 @@ class SimpleMdVerticalStepperApp { </div> </form> </md-step> + <md-step [stepControl]="threeGroup" optional> + <form [formGroup]="threeGroup"> + <ng-template mdStepLabel>Step two</ng-template> + <md-form-field> + <input mdInput formControlName="threeCtrl"> + </md-form-field> + <div> + <button md-button mdStepperPrevious>Back</button> + <button md-button mdStepperNext>Next</button> + </div> + </form> + </md-step> + <md-step> + Done + </md-step> </md-vertical-stepper> ` }) class LinearMdVerticalStepperApp { oneGroup: FormGroup; twoGroup: FormGroup; + threeGroup: FormGroup; ngOnInit() { this.oneGroup = new FormGroup({ @@ -608,5 +760,8 @@ class LinearMdVerticalStepperApp { this.twoGroup = new FormGroup({ twoCtrl: new FormControl('', Validators.required) }); + this.threeGroup = new FormGroup({ + threeCtrl: new FormControl('', Validators.pattern(EMAIL_REGEX)) + }); } } diff --git a/src/lib/stepper/stepper.ts b/src/lib/stepper/stepper.ts index 5a0dd890fa89..9492a4a32a8d 100644 --- a/src/lib/stepper/stepper.ts +++ b/src/lib/stepper/stepper.ts @@ -19,8 +19,9 @@ import { Optional, QueryList, SkipSelf, - ViewChildren -}from '@angular/core'; + ViewChildren, + ViewEncapsulation +} from '@angular/core'; import {MdStepLabel} from './step-label'; import { defaultErrorStateMatcher, @@ -29,12 +30,14 @@ import { ErrorStateMatcher } from '../core/error/error-options'; import {FormControl, FormGroupDirective, NgForm} from '@angular/forms'; +import {MdStepHeader} from './step-header'; @Component({ moduleId: module.id, selector: 'md-step, mat-step', templateUrl: 'step.html', - providers: [{provide: MD_ERROR_GLOBAL_OPTIONS, useExisting: MdStep}] + providers: [{provide: MD_ERROR_GLOBAL_OPTIONS, useExisting: MdStep}], + encapsulation: ViewEncapsulation.None }) export class MdStep extends CdkStep implements ErrorOptions { /** Content for step label given by <ng-template matStepLabel> or <ng-template mdStepLabel>. */ @@ -66,7 +69,7 @@ export class MdStep extends CdkStep implements ErrorOptions { export class MdStepper extends CdkStepper implements ErrorOptions { /** The list of step headers of the steps in the stepper. */ - @ViewChildren('stepHeader') _stepHeader: QueryList<ElementRef>; + @ViewChildren(MdStepHeader, {read: ElementRef}) _stepHeader: QueryList<ElementRef>; /** Steps that the stepper holds. */ @ContentChildren(MdStep) _steps: QueryList<MdStep>;