From 87318bc7c83d249036837376609ea099e5aea2d9 Mon Sep 17 00:00:00 2001 From: Ji Won Shin Date: Thu, 31 Aug 2017 15:26:05 -0700 Subject: [PATCH] feat(stepper): initial version of stepper (#6594) --- src/cdk/stepper/index.ts | 9 + src/cdk/stepper/public_api.ts | 25 + src/cdk/stepper/step-label.ts | 16 + src/cdk/stepper/step.html | 1 + src/cdk/stepper/stepper-button.ts | 28 + src/cdk/stepper/stepper.ts | 263 +++++++ src/cdk/stepper/tsconfig-build.json | 13 + src/demo-app/demo-app/demo-app.ts | 1 + src/demo-app/demo-app/demo-module.ts | 2 + src/demo-app/demo-app/routes.ts | 2 + src/demo-app/demo-material-module.ts | 4 +- src/demo-app/stepper/stepper-demo.html | 197 +++++ src/demo-app/stepper/stepper-demo.ts | 53 ++ src/demo-app/system-config.ts | 1 + src/e2e-app/system-config.ts | 1 + src/lib/core/compatibility/compatibility.ts | 16 +- src/lib/core/theming/_all-theme.scss | 2 + src/lib/core/typography/_all-typography.scss | 2 + src/lib/module.ts | 4 +- src/lib/public_api.ts | 1 + src/lib/stepper/_stepper-theme.scss | 53 ++ src/lib/stepper/index.ts | 40 + src/lib/stepper/step-header.html | 17 + src/lib/stepper/step-header.scss | 42 + src/lib/stepper/step-header.ts | 73 ++ src/lib/stepper/step-label.ts | 22 + src/lib/stepper/step.html | 1 + src/lib/stepper/stepper-button.ts | 31 + src/lib/stepper/stepper-horizontal.html | 30 + src/lib/stepper/stepper-vertical.html | 28 + src/lib/stepper/stepper.md | 132 ++++ src/lib/stepper/stepper.scss | 122 +++ src/lib/stepper/stepper.spec.ts | 765 +++++++++++++++++++ src/lib/stepper/stepper.ts | 132 ++++ test/karma-test-shim.js | 1 + 35 files changed, 2126 insertions(+), 4 deletions(-) create mode 100644 src/cdk/stepper/index.ts create mode 100644 src/cdk/stepper/public_api.ts create mode 100644 src/cdk/stepper/step-label.ts create mode 100644 src/cdk/stepper/step.html create mode 100644 src/cdk/stepper/stepper-button.ts create mode 100644 src/cdk/stepper/stepper.ts create mode 100644 src/cdk/stepper/tsconfig-build.json create mode 100644 src/demo-app/stepper/stepper-demo.html create mode 100644 src/demo-app/stepper/stepper-demo.ts create mode 100644 src/lib/stepper/_stepper-theme.scss create mode 100644 src/lib/stepper/index.ts create mode 100644 src/lib/stepper/step-header.html create mode 100644 src/lib/stepper/step-header.scss create mode 100644 src/lib/stepper/step-header.ts create mode 100644 src/lib/stepper/step-label.ts create mode 100644 src/lib/stepper/step.html create mode 100644 src/lib/stepper/stepper-button.ts create mode 100644 src/lib/stepper/stepper-horizontal.html create mode 100644 src/lib/stepper/stepper-vertical.html create mode 100644 src/lib/stepper/stepper.md create mode 100644 src/lib/stepper/stepper.scss create mode 100644 src/lib/stepper/stepper.spec.ts create mode 100644 src/lib/stepper/stepper.ts diff --git a/src/cdk/stepper/index.ts b/src/cdk/stepper/index.ts new file mode 100644 index 00000000000..f93e7c31d56 --- /dev/null +++ b/src/cdk/stepper/index.ts @@ -0,0 +1,9 @@ +/** + * @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 + */ + +export * from './public_api'; diff --git a/src/cdk/stepper/public_api.ts b/src/cdk/stepper/public_api.ts new file mode 100644 index 00000000000..2ef3326db21 --- /dev/null +++ b/src/cdk/stepper/public_api.ts @@ -0,0 +1,25 @@ +/** + * @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 {NgModule} from '@angular/core'; +import {CdkStepper, CdkStep} from './stepper'; +import {CommonModule} from '@angular/common'; +import {CdkStepLabel} from './step-label'; +import {CdkStepperNext, CdkStepperPrevious} from './stepper-button'; +import {BidiModule} from '@angular/cdk/bidi'; + +@NgModule({ + imports: [BidiModule, CommonModule], + exports: [CdkStep, CdkStepper, CdkStepLabel, CdkStepperNext, CdkStepperPrevious], + declarations: [CdkStep, CdkStepper, CdkStepLabel, CdkStepperNext, CdkStepperPrevious] +}) +export class CdkStepperModule {} + +export * from './stepper'; +export * from './step-label'; +export * from './stepper-button'; diff --git a/src/cdk/stepper/step-label.ts b/src/cdk/stepper/step-label.ts new file mode 100644 index 00000000000..e253b4bfcab --- /dev/null +++ b/src/cdk/stepper/step-label.ts @@ -0,0 +1,16 @@ +/** + * @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 {Directive, TemplateRef} from '@angular/core'; + +@Directive({ + selector: '[cdkStepLabel]', +}) +export class CdkStepLabel { + constructor(public template: TemplateRef) { } +} diff --git a/src/cdk/stepper/step.html b/src/cdk/stepper/step.html new file mode 100644 index 00000000000..cd48c06b991 --- /dev/null +++ b/src/cdk/stepper/step.html @@ -0,0 +1 @@ + diff --git a/src/cdk/stepper/stepper-button.ts b/src/cdk/stepper/stepper-button.ts new file mode 100644 index 00000000000..c63d8c822ee --- /dev/null +++ b/src/cdk/stepper/stepper-button.ts @@ -0,0 +1,28 @@ +/** + * @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 {Directive} from '@angular/core'; +import {CdkStepper} from './stepper'; + +/** Button that moves to the next step in a stepper workflow. */ +@Directive({ + selector: 'button[cdkStepperNext]', + host: {'(click)': '_stepper.next()'} +}) +export class CdkStepperNext { + constructor(public _stepper: CdkStepper) { } +} + +/** Button that moves to the previous step in a stepper workflow. */ +@Directive({ + selector: 'button[cdkStepperPrevious]', + host: {'(click)': '_stepper.previous()'} +}) +export class CdkStepperPrevious { + constructor(public _stepper: CdkStepper) { } +} diff --git a/src/cdk/stepper/stepper.ts b/src/cdk/stepper/stepper.ts new file mode 100644 index 00000000000..24a02dc6545 --- /dev/null +++ b/src/cdk/stepper/stepper.ts @@ -0,0 +1,263 @@ +/** + * @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 { + ContentChildren, + EventEmitter, + Input, + Output, + QueryList, + Directive, + // This import is only used to define a generic type. The current TypeScript version incorrectly + // considers such imports as unused (https://github.com/Microsoft/TypeScript/issues/14953) + // tslint:disable-next-line:no-unused-variable + ElementRef, + Component, + ContentChild, + ViewChild, + TemplateRef, + ViewEncapsulation, + Optional, + Inject, + forwardRef +} from '@angular/core'; +import {LEFT_ARROW, RIGHT_ARROW, ENTER, SPACE} from '@angular/cdk/keycodes'; +import {CdkStepLabel} from './step-label'; +import {coerceBooleanProperty} from '@angular/cdk/coercion'; +import {AbstractControl} from '@angular/forms'; +import {Directionality} from '@angular/cdk/bidi'; + +/** Used to generate unique ID for each stepper component. */ +let nextId = 0; + +/** + * Position state of the content of each step in stepper that is used for transitioning + * the content into correct position upon step selection change. + */ +export type StepContentPositionState = 'previous' | 'current' | 'next'; + +/** Change event emitted on selection changes. */ +export class StepperSelectionEvent { + /** Index of the step now selected. */ + selectedIndex: number; + + /** Index of the step previously selected. */ + previouslySelectedIndex: number; + + /** The step instance now selected. */ + selectedStep: CdkStep; + + /** The step instance previously selected. */ + previouslySelectedStep: CdkStep; +} + +@Component({ + selector: 'cdk-step', + templateUrl: 'step.html', + encapsulation: ViewEncapsulation.None +}) +export class CdkStep { + /** Template for step label if it exists. */ + @ContentChild(CdkStepLabel) stepLabel: CdkStepLabel; + + /** Template for step content. */ + @ViewChild(TemplateRef) content: TemplateRef; + + /** The top level abstract control of the step. */ + @Input() stepControl: AbstractControl; + + /** Whether user has seen the expanded step content or not . */ + interacted = false; + + /** Label of the step. */ + @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(@Inject(forwardRef(() => CdkStepper)) private _stepper: CdkStepper) { } + + /** Selects this step component. */ + select(): void { + this._stepper.selected = this; + } +} + +@Directive({ + selector: '[cdkStepper]', +}) +export class CdkStepper { + /** The list of step components that the stepper is holding. */ + @ContentChildren(CdkStep) _steps: QueryList; + + /** 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._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) { + this._emitStepperSelectionEvent(index); + this._focusIndex = this._selectedIndex; + } + } + private _selectedIndex: number = 0; + + /** The step that is selected. */ + @Input() + get selected() { return this._steps[this.selectedIndex]; } + set selected(step: CdkStep) { + let index = this._steps.toArray().indexOf(step); + this.selectedIndex = index; + } + + /** Event emitted when the selected step has changed. */ + @Output() selectionChange = new EventEmitter(); + + /** The index of the step that the focus can be set. */ + _focusIndex: number = 0; + + /** Used to track unique ID for each stepper component. */ + _groupId: number; + + constructor(@Optional() private _dir: Directionality) { + this._groupId = nextId++; + } + + /** Selects and focuses the next step in list. */ + next(): void { + this.selectedIndex = Math.min(this._selectedIndex + 1, this._steps.length - 1); + } + + /** Selects and focuses the previous step in list. */ + previous(): void { + this.selectedIndex = Math.max(this._selectedIndex - 1, 0); + } + + /** Returns a unique id for each step label element. */ + _getStepLabelId(i: number): string { + return `mat-step-label-${this._groupId}-${i}`; + } + + /** Returns unique id for each step content element. */ + _getStepContentId(i: number): string { + return `mat-step-content-${this._groupId}-${i}`; + } + + /** Returns position state of the step with the given index. */ + _getAnimationDirection(index: number): StepContentPositionState { + const position = index - this._selectedIndex; + if (position < 0) { + return 'previous'; + } else if (position > 0) { + return 'next'; + } else { + return 'current'; + } + } + + /** 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({ + selectedIndex: newIndex, + previouslySelectedIndex: this._selectedIndex, + selectedStep: stepsArray[newIndex], + previouslySelectedStep: stepsArray[this._selectedIndex], + }); + this._selectedIndex = newIndex; + } + + _onKeydown(event: KeyboardEvent) { + switch (event.keyCode) { + case RIGHT_ARROW: + if (this._dir && this._dir.value === 'rtl') { + this._focusStep((this._focusIndex + this._steps.length - 1) % this._steps.length); + } else { + this._focusStep((this._focusIndex + 1) % this._steps.length); + } + break; + case LEFT_ARROW: + if (this._dir && this._dir.value === 'rtl') { + this._focusStep((this._focusIndex + 1) % this._steps.length); + } else { + this._focusStep((this._focusIndex + this._steps.length - 1) % this._steps.length); + } + break; + case SPACE: + case ENTER: + this.selectedIndex = this._focusIndex; + break; + default: + // Return to avoid calling preventDefault on keys that are not explicitly handled. + return; + } + event.preventDefault(); + } + + private _focusStep(index: number) { + 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) { + return stepsArray.slice(0, index).some(step => step.stepControl.invalid); + } + return false; + } +} diff --git a/src/cdk/stepper/tsconfig-build.json b/src/cdk/stepper/tsconfig-build.json new file mode 100644 index 00000000000..4231d46ba70 --- /dev/null +++ b/src/cdk/stepper/tsconfig-build.json @@ -0,0 +1,13 @@ +{ + "extends": "../tsconfig-build", + "files": [ + "public_api.ts" + ], + "angularCompilerOptions": { + "annotateForClosureCompiler": true, + "strictMetadataEmit": true, + "flatModuleOutFile": "index.js", + "flatModuleId": "@angular/cdk/stepper", + "skipTemplateCodegen": true + } +} diff --git a/src/demo-app/demo-app/demo-app.ts b/src/demo-app/demo-app/demo-app.ts index 2938c6fe540..8d35ac9bb93 100644 --- a/src/demo-app/demo-app/demo-app.ts +++ b/src/demo-app/demo-app/demo-app.ts @@ -72,6 +72,7 @@ export class DemoApp { {name: 'Slider', route: '/slider'}, {name: 'Slide Toggle', route: '/slide-toggle'}, {name: 'Snack Bar', route: '/snack-bar'}, + {name: 'Stepper', route: 'stepper'}, {name: 'Table', route: '/table'}, {name: 'Tabs', route: '/tabs'}, {name: 'Toolbar', route: '/toolbar'}, diff --git a/src/demo-app/demo-app/demo-module.ts b/src/demo-app/demo-app/demo-module.ts index 7b5165b3fa3..79de0624a05 100644 --- a/src/demo-app/demo-app/demo-module.ts +++ b/src/demo-app/demo-app/demo-module.ts @@ -40,6 +40,7 @@ import {PeopleDatabase} from '../table/people-database'; import {DatepickerDemo} from '../datepicker/datepicker-demo'; import {TypographyDemo} from '../typography/typography-demo'; import {ExpansionDemo} from '../expansion/expansion-demo'; +import {StepperDemo} from '../stepper/stepper-demo'; import {DemoMaterialModule} from '../demo-material-module'; import { FullscreenOverlayContainer, @@ -92,6 +93,7 @@ import {TableHeaderDemo} from '../table/table-header-demo'; SliderDemo, SlideToggleDemo, SpagettiPanel, + StepperDemo, StyleDemo, TableHeaderDemo, ToolbarDemo, diff --git a/src/demo-app/demo-app/routes.ts b/src/demo-app/demo-app/routes.ts index ac5d752b0f5..1a0b5eb5a38 100644 --- a/src/demo-app/demo-app/routes.ts +++ b/src/demo-app/demo-app/routes.ts @@ -36,6 +36,7 @@ import {DatepickerDemo} from '../datepicker/datepicker-demo'; import {TableDemo} from '../table/table-demo'; import {TypographyDemo} from '../typography/typography-demo'; import {ExpansionDemo} from '../expansion/expansion-demo'; +import {StepperDemo} from '../stepper/stepper-demo'; import {DemoApp} from './demo-app'; import {AccessibilityDemo} from '../a11y/a11y'; import {ACCESSIBILITY_DEMO_ROUTES} from '../a11y/routes'; @@ -78,6 +79,7 @@ export const DEMO_APP_ROUTES: Routes = [ {path: 'style', component: StyleDemo}, {path: 'typography', component: TypographyDemo}, {path: 'expansion', component: ExpansionDemo}, + {path: 'stepper', component: StepperDemo} ]} ]; diff --git a/src/demo-app/demo-material-module.ts b/src/demo-app/demo-material-module.ts index 74b1b5ce9e1..bf2ffc8cc2d 100644 --- a/src/demo-app/demo-material-module.ts +++ b/src/demo-app/demo-material-module.ts @@ -31,7 +31,8 @@ import { MdTabsModule, MdToolbarModule, MdTooltipModule, - StyleModule + StyleModule, + MdStepperModule, } from '@angular/material'; import {CdkTableModule} from '@angular/cdk/table'; import {A11yModule} from '@angular/cdk/a11y'; @@ -73,6 +74,7 @@ import {PortalModule} from '@angular/cdk/portal'; MdSliderModule, MdSnackBarModule, MdSortModule, + MdStepperModule, MdTabsModule, MdToolbarModule, MdTooltipModule, diff --git a/src/demo-app/stepper/stepper-demo.html b/src/demo-app/stepper/stepper-demo.html new file mode 100644 index 00000000000..11cda6eaea1 --- /dev/null +++ b/src/demo-app/stepper/stepper-demo.html @@ -0,0 +1,197 @@ +Disable linear mode + +

Linear Vertical Stepper Demo using a single form

+
+ + + Fill out your name + + + This field is required + + + + + This field is required + +
+ +
+
+ + + +
Fill out your email address
+
+ + + The input is invalid. + +
+ + +
+
+ + + Confirm your information + Everything seems correct. +
+ +
+
+
+
+ +

Linear Horizontal Stepper Demo using a different form for each step

+ + +
+ Fill out your name + + + This field is required + + + + This field is required + +
+ +
+
+
+ + +
+ Fill out your email address + + + The input is invalid + +
+ + +
+
+
+ + +
+ Confirm your information + Everything seems correct. +
+ +
+
+
+
+ +

Vertical Stepper Demo

+Make steps non-editable + + + Fill out your name + + + + + + + +
+ +
+
+ + + +
Fill out your phone number
+
+ + + +
+ + +
+
+ + + +
Fill out your address
+
+ + + +
+ + +
+
+ + + Confirm your information + Everything seems correct. +
+ +
+
+
+ +

Horizontal Stepper Demo with Text Label

+ + + + + + + + + +
+ +
+
+ + + + + +
+ + +
+
+ + + + + +
+ + +
+
+ + + Everything seems correct. +
+ +
+
+
+ +

Horizontal Stepper Demo with Templated Label

+ + + {{step.label}} + + + +
+ + +
+
+
diff --git a/src/demo-app/stepper/stepper-demo.ts b/src/demo-app/stepper/stepper-demo.ts new file mode 100644 index 00000000000..77cfb353591 --- /dev/null +++ b/src/demo-app/stepper/stepper-demo.ts @@ -0,0 +1,53 @@ +import {Component} from '@angular/core'; +import {AbstractControl, 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', + templateUrl: 'stepper-demo.html' +}) +export class StepperDemo { + formGroup: FormGroup; + isNonLinear = false; + isNonEditable = false; + + nameFormGroup: FormGroup; + emailFormGroup: FormGroup; + + 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(): AbstractControl | null { 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({ + emailFormCtrl: ['', Validators.pattern(EMAIL_REGEX)] + }), + ]) + }); + + this.nameFormGroup = this._formBuilder.group({ + firstNameCtrl: ['', Validators.required], + lastNameCtrl: ['', Validators.required], + }); + + this.emailFormGroup = this._formBuilder.group({ + emailCtrl: ['', Validators.pattern(EMAIL_REGEX)] + }); + } +} diff --git a/src/demo-app/system-config.ts b/src/demo-app/system-config.ts index 6965450053b..38f8de2c228 100644 --- a/src/demo-app/system-config.ts +++ b/src/demo-app/system-config.ts @@ -38,6 +38,7 @@ System.config({ '@angular/cdk/portal': 'dist/bundles/cdk-portal.umd.js', '@angular/cdk/rxjs': 'dist/bundles/cdk-rxjs.umd.js', '@angular/cdk/scrolling': 'dist/bundles/cdk-scrolling.umd.js', + '@angular/cdk/stepper': 'dist/bundles/cdk-stepper.umd.js', '@angular/cdk/table': 'dist/bundles/cdk-table.umd.js', '@angular/cdk/testing': 'dist/bundles/cdk-testing.umd.js', }, diff --git a/src/e2e-app/system-config.ts b/src/e2e-app/system-config.ts index 2360a995fe4..75700c38fcf 100644 --- a/src/e2e-app/system-config.ts +++ b/src/e2e-app/system-config.ts @@ -38,6 +38,7 @@ System.config({ '@angular/cdk/portal': 'dist/bundles/cdk-portal.umd.js', '@angular/cdk/rxjs': 'dist/bundles/cdk-rxjs.umd.js', '@angular/cdk/scrolling': 'dist/bundles/cdk-scrolling.umd.js', + '@angular/cdk/stepper': 'dist/bundles/cdk-stepper.umd.js', '@angular/cdk/table': 'dist/bundles/cdk-table.umd.js', '@angular/cdk/testing': 'dist/bundles/cdk-testing.umd.js', '@angular/material-examples': 'dist/bundles/material-examples.umd.js', diff --git a/src/lib/core/compatibility/compatibility.ts b/src/lib/core/compatibility/compatibility.ts index 53ed876ff3d..769e23fcb54 100644 --- a/src/lib/core/compatibility/compatibility.ts +++ b/src/lib/core/compatibility/compatibility.ts @@ -39,6 +39,9 @@ export const MAT_ELEMENTS_SELECTOR = ` [matHeaderRowDef], [matLine], [matRowDef], + [matStepLabel], + [matStepperNext], + [matStepperPrevious], [matTabLabel], [matTabLink], [matTabNav], @@ -73,6 +76,7 @@ export const MAT_ELEMENTS_SELECTOR = ` mat-header-cell, mat-header-row, mat-hint, + mat-horizontal-stepper, mat-icon, mat-input-container, mat-form-field, @@ -92,10 +96,12 @@ export const MAT_ELEMENTS_SELECTOR = ` mat-sidenav-container, mat-slider, mat-spinner, + mat-step, mat-tab, mat-table, mat-tab-group, - mat-toolbar`; + mat-toolbar, + mat-vertical-stepper`; /** Selector that matches all elements that may have style collisions with AngularJS Material. */ export const MD_ELEMENTS_SELECTOR = ` @@ -116,6 +122,9 @@ export const MD_ELEMENTS_SELECTOR = ` [mdHeaderRowDef], [mdLine], [mdRowDef], + [mdStepLabel], + [mdStepperNext], + [mdStepperPrevious], [mdTabLabel], [mdTabLink], [mdTabNav], @@ -150,6 +159,7 @@ export const MD_ELEMENTS_SELECTOR = ` md-header-cell, md-header-row, md-hint, + md-horizontal-stepper, md-icon, md-input-container, md-form-field, @@ -169,10 +179,12 @@ export const MD_ELEMENTS_SELECTOR = ` md-sidenav-container, md-slider, md-spinner, + md-step, md-tab, md-table, md-tab-group, - md-toolbar`; + md-toolbar, + md-vertical-stepper`; /** Directive that enforces that the `mat-` prefix cannot be used. */ @Directive({selector: MAT_ELEMENTS_SELECTOR}) diff --git a/src/lib/core/theming/_all-theme.scss b/src/lib/core/theming/_all-theme.scss index 4f808fca5ef..a2ed3f9459b 100644 --- a/src/lib/core/theming/_all-theme.scss +++ b/src/lib/core/theming/_all-theme.scss @@ -23,6 +23,7 @@ @import '../../sidenav/sidenav-theme'; @import '../../slide-toggle/slide-toggle-theme'; @import '../../slider/slider-theme'; +@import '../../stepper/stepper-theme'; @import '../../tabs/tabs-theme'; @import '../../toolbar/toolbar-theme'; @import '../../tooltip/tooltip-theme'; @@ -57,6 +58,7 @@ @include mat-sidenav-theme($theme); @include mat-slide-toggle-theme($theme); @include mat-slider-theme($theme); + @include mat-stepper-theme($theme); @include mat-tabs-theme($theme); @include mat-toolbar-theme($theme); @include mat-tooltip-theme($theme); diff --git a/src/lib/core/typography/_all-typography.scss b/src/lib/core/typography/_all-typography.scss index 4935de608bd..a0766383a78 100644 --- a/src/lib/core/typography/_all-typography.scss +++ b/src/lib/core/typography/_all-typography.scss @@ -22,6 +22,7 @@ @import '../../sidenav/sidenav-theme'; @import '../../slide-toggle/slide-toggle-theme'; @import '../../slider/slider-theme'; +@import '../../stepper/stepper-theme'; @import '../../tabs/tabs-theme'; @import '../../toolbar/toolbar-theme'; @import '../../tooltip/tooltip-theme'; @@ -61,6 +62,7 @@ @include mat-sidenav-typography($config); @include mat-slide-toggle-typography($config); @include mat-slider-typography($config); + @include mat-stepper-typography($config); @include mat-tabs-typography($config); @include mat-toolbar-typography($config); @include mat-tooltip-typography($config); diff --git a/src/lib/module.ts b/src/lib/module.ts index 4cec7ca1de0..66d460334b0 100644 --- a/src/lib/module.ts +++ b/src/lib/module.ts @@ -46,6 +46,7 @@ import {MdTableModule} from './table/index'; import {MdSortModule} from './sort/index'; import {MdPaginatorModule} from './paginator/index'; import {MdFormFieldModule} from './form-field/index'; +import {MdStepperModule} from './stepper/index'; const MATERIAL_MODULES = [ MdAutocompleteModule, @@ -75,6 +76,7 @@ const MATERIAL_MODULES = [ MdSlideToggleModule, MdSnackBarModule, MdSortModule, + MdStepperModule, MdTabsModule, MdToolbarModule, MdTooltipModule, @@ -85,7 +87,7 @@ const MATERIAL_MODULES = [ A11yModule, PlatformModule, MdCommonModule, - ObserversModule + ObserversModule, ]; /** @deprecated */ diff --git a/src/lib/public_api.ts b/src/lib/public_api.ts index 7c4bdffb425..4156ab5191d 100644 --- a/src/lib/public_api.ts +++ b/src/lib/public_api.ts @@ -46,3 +46,4 @@ export * from './tabs/index'; export * from './tabs/tab-nav-bar/index'; export * from './toolbar/index'; export * from './tooltip/index'; +export * from './stepper/index'; diff --git a/src/lib/stepper/_stepper-theme.scss b/src/lib/stepper/_stepper-theme.scss new file mode 100644 index 00000000000..fc4e339a49c --- /dev/null +++ b/src/lib/stepper/_stepper-theme.scss @@ -0,0 +1,53 @@ +@import '../core/theming/palette'; +@import '../core/theming/theming'; +@import '../core/typography/typography-utils'; + +@mixin mat-stepper-theme($theme) { + $foreground: map-get($theme, foreground); + $background: map-get($theme, background); + $primary: map-get($theme, primary); + + .mat-step-header { + &:focus, + &:hover { + background-color: mat-color($background, hover); + } + + .mat-step-label-active { + color: mat-color($foreground, text); + } + + .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); + } + + .mat-step-icon-not-touched { + background-color: mat-color($foreground, disabled-text); + color: mat-color($primary, default-contrast); + } + } + + .mat-stepper-horizontal, .mat-stepper-vertical { + background-color: mat-color($background, card); + } + + .mat-stepper-vertical-line::before { + border-left-color: mat-color($foreground, divider); + } + + .mat-stepper-horizontal-line { + border-top-color: mat-color($foreground, divider); + } +} + +@mixin mat-stepper-typography($config) { + .mat-stepper-vertical, .mat-stepper-horizontal { + font-family: mat-font-family($config); + } +} diff --git a/src/lib/stepper/index.ts b/src/lib/stepper/index.ts new file mode 100644 index 00000000000..a574fbc5399 --- /dev/null +++ b/src/lib/stepper/index.ts @@ -0,0 +1,40 @@ +/** + * @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 {NgModule} from '@angular/core'; +import {CommonModule} from '@angular/common'; +import {PortalModule} from '@angular/cdk/portal'; +import {MdButtonModule} from '../button/index'; +import {MdStep, MdStepper, MdHorizontalStepper, MdVerticalStepper} from './stepper'; +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, + MdIconModule + ], + exports: [MdCommonModule, MdHorizontalStepper, MdVerticalStepper, MdStep, MdStepLabel, MdStepper, + MdStepperNext, MdStepperPrevious, MdStepHeader], + declarations: [MdHorizontalStepper, MdVerticalStepper, MdStep, MdStepLabel, MdStepper, + MdStepperNext, MdStepperPrevious, MdStepHeader], +}) +export class MdStepperModule {} + +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 00000000000..3f2310862f6 --- /dev/null +++ b/src/lib/stepper/step-header.html @@ -0,0 +1,17 @@ +
+ {{index + 1}} + create + done +
+
+ + + + +
{{label}}
+ +
Optional
+
+ diff --git a/src/lib/stepper/step-header.scss b/src/lib/stepper/step-header.scss new file mode 100644 index 00000000000..9313a57e4a4 --- /dev/null +++ b/src/lib/stepper/step-header.scss @@ -0,0 +1,42 @@ +$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; + +.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 00000000000..14901227f69 --- /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/step-label.ts b/src/lib/stepper/step-label.ts new file mode 100644 index 00000000000..62e1bc7f60e --- /dev/null +++ b/src/lib/stepper/step-label.ts @@ -0,0 +1,22 @@ +/** + * @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 {Directive, TemplateRef} from '@angular/core'; +import {CdkStepLabel} from '@angular/cdk/stepper'; + +/** Workaround for https://github.com/angular/angular/issues/17849 */ +export const _MdStepLabel = CdkStepLabel; + +@Directive({ + selector: '[mdStepLabel], [matStepLabel]', +}) +export class MdStepLabel extends _MdStepLabel { + constructor(template: TemplateRef) { + super(template); + } +} diff --git a/src/lib/stepper/step.html b/src/lib/stepper/step.html new file mode 100644 index 00000000000..cd48c06b991 --- /dev/null +++ b/src/lib/stepper/step.html @@ -0,0 +1 @@ + diff --git a/src/lib/stepper/stepper-button.ts b/src/lib/stepper/stepper-button.ts new file mode 100644 index 00000000000..e1d5bb488be --- /dev/null +++ b/src/lib/stepper/stepper-button.ts @@ -0,0 +1,31 @@ +/** + * @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 {Directive} from '@angular/core'; +import {CdkStepper, CdkStepperNext, CdkStepperPrevious} from '@angular/cdk/stepper'; +import {MdStepper} from './stepper'; + +/** Workaround for https://github.com/angular/angular/issues/17849 */ +export const _MdStepperNext = CdkStepperNext; +export const _MdStepperPrevious = CdkStepperPrevious; + +/** Button that moves to the next step in a stepper workflow. */ +@Directive({ + selector: 'button[mdStepperNext], button[matStepperNext]', + host: {'(click)': '_stepper.next()'}, + providers: [{provide: CdkStepper, useExisting: MdStepper}] +}) +export class MdStepperNext extends _MdStepperNext { } + +/** Button that moves to the previous step in a stepper workflow. */ +@Directive({ + selector: 'button[mdStepperPrevious], button[matStepperPrevious]', + host: {'(click)': '_stepper.previous()'}, + providers: [{provide: CdkStepper, useExisting: MdStepper}] +}) +export class MdStepperPrevious extends _MdStepperPrevious { } diff --git a/src/lib/stepper/stepper-horizontal.html b/src/lib/stepper/stepper-horizontal.html new file mode 100644 index 00000000000..5e2d2ea80fa --- /dev/null +++ b/src/lib/stepper/stepper-horizontal.html @@ -0,0 +1,30 @@ +
+ + + +
+
+
+ +
+
+ +
+
diff --git a/src/lib/stepper/stepper-vertical.html b/src/lib/stepper/stepper-vertical.html new file mode 100644 index 00000000000..b73bed61929 --- /dev/null +++ b/src/lib/stepper/stepper-vertical.html @@ -0,0 +1,28 @@ +
+ + + +
+
+
+ +
+
+
+
diff --git a/src/lib/stepper/stepper.md b/src/lib/stepper/stepper.md new file mode 100644 index 00000000000..54e273068cf --- /dev/null +++ b/src/lib/stepper/stepper.md @@ -0,0 +1,132 @@ +Angular Material's stepper provides a wizard-like workflow by dividing content into logical steps. + +Material stepper builds on the foundation of the CDK stepper that is responsible for the logic +that drives a stepped workflow. Material stepper extends the CDK stepper and has Material Design +styling. + +### Stepper variants +There are two stepper components: `md-horizontal-stepper` and `md-vertical-stepper`. They +can be used the same way. The only difference is the orientation of stepper. +`md-horizontal-stepper` selector can be used to create a horizontal stepper, and +`md-vertical-stepper` can be used to create a vertical stepper. `md-step` components need to be +placed inside either one of the two stepper components. + +### Labels +If a step's label is only text, then the `label` attribute can be used. +```html + + + Content 1 + + + Content 2 + + +``` + +For more complex labels, add a template with the `mdStepLabel` directive inside the +`md-step`. +```html + + + ... + ... + + +``` + +### Stepper buttons +There are two button directives to support navigation between different steps: +`mdStepperPrevious` and `mdStepperNext`. +```html + + + ... +
+ + +
+
+
+``` + +### Linear stepper +The `linear` attribute can be set on `md-horizontal-stepper` and `md-vertical-stepper` to create +a linear stepper that requires the user to complete previous steps before proceeding +to following steps. For each `md-step`, the `stepControl` attribute can be set to the top level +`AbstractControl` that is used to check the validity of the step. + +There are two possible approaches. One is using a single form for stepper, and the other is +using a different form for each step. + +#### Using a single form +When using a single form for the stepper, `mdStepperPrevious` and `mdStepperNext` have to be +set to `type="button"` in order to prevent submission of the form before all steps +are completed. + +```html +
+ + + ... +
+ +
+
+ + ... +
+ + +
+
+ ... +
+
+``` + +#### Using a different form for each step +```html + + +
+ ... +
+
+ +
+ ... +
+
+
+``` +### Types of steps + +#### Optional step +If completion of a step in linear stepper is not required, then the `optional` attribute can be set +on `md-step`. + +#### Editable step +By default, steps are editable, which means users can return to previously completed steps and +edit their responses. `editable="true"` can be set on `md-step` to change the default. + +#### Completed step +By default, the `completed` attribute of a step returns `true` if the step is valid (in case of +linear stepper) and the user has interacted with the step. The user, however, can also override +this default `completed` behavior by setting the `completed` attribute as needed. + +### Keyboard interaction +- LEFT_ARROW: Focuses the previous step header +- RIGHT_ARROW: Focuses the next step header +- ENTER, SPACE: Selects the step that the focus is currently on +- TAB: Focuses the next tabbable element +- TAB+SHIFT: Focuses the previous tabbable element + +### Accessibility +The stepper is treated as a tabbed view for accessibility purposes, so it is given +`role="tablist"` by default. The header of step that can be clicked to select the step +is given `role="tab"`, and the content that can be expanded upon selection is given +`role="tabpanel"`. `aria-selected` attribute of step header and `aria-expanded` attribute of +step content is automatically set based on step selection change. + +The stepper and each step should be given a meaningful label via `aria-label` or `aria-labelledby`. \ No newline at end of file diff --git a/src/lib/stepper/stepper.scss b/src/lib/stepper/stepper.scss new file mode 100644 index 00000000000..b05c8ef8672 --- /dev/null +++ b/src/lib/stepper/stepper.scss @@ -0,0 +1,122 @@ +@import '../core/style/variables'; + +$mat-horizontal-stepper-header-height: 72px !default; +$mat-stepper-label-header-height: 24px !default; +$mat-stepper-side-gap: 24px !default; +$mat-vertical-stepper-content-margin: 36px !default; +$mat-stepper-line-width: 1px !default; +$mat-stepper-line-gap: 8px !default; + +.mat-stepper-vertical, +.mat-stepper-horizontal { + display: block; +} + +.mat-step-header { + overflow: hidden; + outline: none; +} + +.mat-horizontal-stepper-header-container { + white-space: nowrap; + display: flex; + 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: flex; + height: $mat-horizontal-stepper-header-height; + overflow: hidden; + align-items: center; + padding: 0 $mat-stepper-side-gap; + + .mat-step-icon, + .mat-step-icon-not-touched { + margin-right: $mat-stepper-line-gap; + flex: none; + + [dir='rtl'] & { + margin-right: 0; + margin-left: $mat-stepper-line-gap; + } + } +} + +.mat-vertical-stepper-header { + display: flex; + align-items: center; + padding: $mat-stepper-side-gap; + max-height: $mat-stepper-label-header-height; + + .mat-step-icon, + .mat-step-icon-not-touched { + margin-right: $mat-vertical-stepper-content-margin - $mat-stepper-side-gap; + + [dir='rtl'] & { + margin-right: 0; + margin-left: $mat-vertical-stepper-content-margin - $mat-stepper-side-gap; + } + } +} + +.mat-horizontal-stepper-content { + overflow: hidden; + + &[aria-expanded='false'] { + height: 0; + } +} + +.mat-horizontal-content-container { + overflow: hidden; + padding: 0 $mat-stepper-side-gap $mat-stepper-side-gap $mat-stepper-side-gap; +} + +.mat-vertical-content-container { + margin-left: $mat-vertical-stepper-content-margin; + border: 0; + position: relative; + + [dir='rtl'] & { + margin-left: 0; + margin-right: $mat-vertical-stepper-content-margin; + } +} + +.mat-stepper-vertical-line::before { + content: ''; + position: absolute; + top: $mat-stepper-line-gap - $mat-stepper-side-gap; + bottom: $mat-stepper-line-gap - $mat-stepper-side-gap; + left: 0; + border-left-width: $mat-stepper-line-width; + border-left-style: solid; + + [dir='rtl'] & { + left: auto; + right: 0; + } +} + +.mat-vertical-stepper-content { + overflow: hidden; +} + +.mat-vertical-content { + padding: 0 $mat-stepper-side-gap $mat-stepper-side-gap $mat-stepper-side-gap; +} + +.mat-step:last-child { + .mat-vertical-content-container { + border: none; + } +} diff --git a/src/lib/stepper/stepper.spec.ts b/src/lib/stepper/stepper.spec.ts new file mode 100644 index 00000000000..b5965f9d475 --- /dev/null +++ b/src/lib/stepper/stepper.spec.ts @@ -0,0 +1,765 @@ +import {async, ComponentFixture, TestBed} from '@angular/core/testing'; +import {Component, DebugElement} from '@angular/core'; +import {MdStepperModule} from './index'; +import {By} from '@angular/platform-browser'; +import {NoopAnimationsModule} from '@angular/platform-browser/animations'; +import {FormControl, FormGroup, ReactiveFormsModule, Validators} from '@angular/forms'; +import {MdStepperNext, MdStepperPrevious} from './stepper-button'; +import {dispatchKeyboardEvent} from '@angular/cdk/testing'; +import {ENTER, LEFT_ARROW, RIGHT_ARROW, SPACE} from '@angular/cdk/keycodes'; +import {MdStepper, MdHorizontalStepper, MdVerticalStepper} from './stepper'; + +const EMAIL_REGEX = /^[a-zA-Z0-9.!#$%&’*+/=?^_`{|}~-]+@[a-zA-Z0-9-]+(?:\.[a-zA-Z0-9-]+)*$/; + +describe('MdHorizontalStepper', () => { + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [MdStepperModule, NoopAnimationsModule, ReactiveFormsModule], + declarations: [ + SimpleMdHorizontalStepperApp, + LinearMdHorizontalStepperApp + ] + }); + + TestBed.compileComponents(); + })); + + describe('basic horizontal stepper', () => { + let fixture: ComponentFixture; + let stepperComponent: MdHorizontalStepper; + + beforeEach(() => { + fixture = TestBed.createComponent(SimpleMdHorizontalStepperApp); + fixture.detectChanges(); + + stepperComponent = fixture.debugElement + .query(By.css('md-horizontal-stepper')).componentInstance; + }); + + it('should default to the first step', () => { + expect(stepperComponent.selectedIndex).toBe(0); + }); + + it('should change selected index on header click', () => { + let stepHeaders = fixture.debugElement.queryAll(By.css('.mat-horizontal-stepper-header')); + assertSelectionChangeOnHeaderClick(stepperComponent, fixture, stepHeaders); + }); + + it('should set the "tablist" role on stepper', () => { + let stepperEl = fixture.debugElement.query(By.css('md-horizontal-stepper')).nativeElement; + expect(stepperEl.getAttribute('role')).toBe('tablist'); + }); + + it('should set aria-expanded of content correctly', () => { + let stepContents = fixture.debugElement.queryAll(By.css(`.mat-horizontal-stepper-content`)); + assertCorrectAriaExpandedAttribute(stepperComponent, fixture, stepContents); + }); + + it('should display the correct label', () => { + assertCorrectStepLabel(stepperComponent, fixture); + }); + + it('should go to next available step when the next button is clicked', () => { + assertNextStepperButtonClick(stepperComponent, fixture); + }); + + it('should go to previous available step when the previous button is clicked', () => { + assertPreviousStepperButtonClick(stepperComponent, fixture); + }); + + it('should set the correct step position for animation', () => { + assertCorrectStepPosition(stepperComponent, fixture); + }); + + it('should support keyboard events to move and select focus', () => { + let stepHeaders = fixture.debugElement.queryAll(By.css('.mat-horizontal-stepper-header')); + assertCorrectKeyboardInteraction(stepperComponent, fixture, stepHeaders); + }); + + it('should not set focus on header of selected step if header is not clicked', () => { + 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'); + }); + }); + + describe('linear horizontal stepper', () => { + let fixture: ComponentFixture; + let testComponent: LinearMdHorizontalStepperApp; + let stepperComponent: MdHorizontalStepper; + + beforeEach(() => { + fixture = TestBed.createComponent(LinearMdHorizontalStepperApp); + fixture.detectChanges(); + + testComponent = fixture.componentInstance; + stepperComponent = fixture.debugElement + .query(By.css('md-horizontal-stepper')).componentInstance; + }); + + it('should have true linear attribute', () => { + expect(stepperComponent.linear).toBe(true); + }); + + it('should not move to next step if current step is not valid', () => { + expect(testComponent.oneGroup.get('oneCtrl')!.value).toBe(''); + expect(testComponent.oneGroup.get('oneCtrl')!.valid).toBe(false); + expect(testComponent.oneGroup.valid).toBe(false); + expect(stepperComponent.selectedIndex).toBe(0); + + let stepHeaderEl = fixture.debugElement + .queryAll(By.css('.mat-horizontal-stepper-header'))[1].nativeElement; + assertLinearStepperValidity(stepHeaderEl, stepperComponent, testComponent, fixture); + }); + + it('should not focus step header upon click if it is not able to be selected', () => { + assertStepHeaderBlurred(fixture); + }); + + it('should be able to move to next step even when invalid if current step is optional', () => { + assertOptionalStepValidity(stepperComponent, testComponent, fixture); + }); + }); +}); + +describe('MdVerticalStepper', () => { + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [MdStepperModule, NoopAnimationsModule, ReactiveFormsModule], + declarations: [ + SimpleMdVerticalStepperApp, + LinearMdVerticalStepperApp + ] + }); + + TestBed.compileComponents(); + })); + + describe('basic vertical stepper', () => { + let fixture: ComponentFixture; + let stepperComponent: MdVerticalStepper; + + beforeEach(() => { + fixture = TestBed.createComponent(SimpleMdVerticalStepperApp); + fixture.detectChanges(); + + stepperComponent = fixture.debugElement + .query(By.css('md-vertical-stepper')).componentInstance; + }); + + it('should default to the first step', () => { + expect(stepperComponent.selectedIndex).toBe(0); + }); + + it('should change selected index on header click', () => { + let stepHeaders = fixture.debugElement.queryAll(By.css('.mat-vertical-stepper-header')); + assertSelectionChangeOnHeaderClick(stepperComponent, fixture, stepHeaders); + + }); + + it('should set the "tablist" role on stepper', () => { + let stepperEl = fixture.debugElement.query(By.css('md-vertical-stepper')).nativeElement; + expect(stepperEl.getAttribute('role')).toBe('tablist'); + }); + + it('should set aria-expanded of content correctly', () => { + let stepContents = fixture.debugElement.queryAll(By.css(`.mat-vertical-stepper-content`)); + assertCorrectAriaExpandedAttribute(stepperComponent, fixture, stepContents); + }); + + it('should display the correct label', () => { + assertCorrectStepLabel(stepperComponent, fixture); + }); + + it('should go to next available step when the next button is clicked', () => { + assertNextStepperButtonClick(stepperComponent, fixture); + }); + + it('should go to previous available step when the previous button is clicked', () => { + assertPreviousStepperButtonClick(stepperComponent, fixture); + }); + + it('should set the correct step position for animation', () => { + assertCorrectStepPosition(stepperComponent, fixture); + }); + + it('should support keyboard events to move and select focus', () => { + let stepHeaders = fixture.debugElement.queryAll(By.css('.mat-vertical-stepper-header')); + assertCorrectKeyboardInteraction(stepperComponent, fixture, stepHeaders); + }); + + it('should not set focus on header of selected step if header is not clicked', () => { + 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'); + }); + }); + + describe('linear vertical stepper', () => { + let fixture: ComponentFixture; + let testComponent: LinearMdVerticalStepperApp; + let stepperComponent: MdVerticalStepper; + + beforeEach(() => { + fixture = TestBed.createComponent(LinearMdVerticalStepperApp); + fixture.detectChanges(); + + testComponent = fixture.componentInstance; + stepperComponent = fixture.debugElement + .query(By.css('md-vertical-stepper')).componentInstance; + }); + + it('should have true linear attribute', () => { + expect(stepperComponent.linear).toBe(true); + }); + + it('should not move to next step if current step is not valid', () => { + expect(testComponent.oneGroup.get('oneCtrl')!.value).toBe(''); + expect(testComponent.oneGroup.get('oneCtrl')!.valid).toBe(false); + expect(testComponent.oneGroup.valid).toBe(false); + expect(stepperComponent.selectedIndex).toBe(0); + + let stepHeaderEl = fixture.debugElement + .queryAll(By.css('.mat-vertical-stepper-header'))[1].nativeElement; + + assertLinearStepperValidity(stepHeaderEl, stepperComponent, testComponent, fixture); + }); + + it('should not focus step header upon click if it is not able to be selected', () => { + assertStepHeaderBlurred(fixture); + }); + + it('should be able to move to next step even when invalid if current step is optional', () => { + assertOptionalStepValidity(stepperComponent, testComponent, fixture); + }); + }); +}); + +/** Asserts that `selectedIndex` updates correctly when header of another step is clicked. */ +function assertSelectionChangeOnHeaderClick(stepperComponent: MdStepper, + fixture: ComponentFixture, + stepHeaders: DebugElement[]) { + expect(stepperComponent.selectedIndex).toBe(0); + + // select the second step + let stepHeaderEl = stepHeaders[1].nativeElement; + stepHeaderEl.click(); + fixture.detectChanges(); + + expect(stepperComponent.selectedIndex).toBe(1); + + // select the third step + stepHeaderEl = stepHeaders[2].nativeElement; + stepHeaderEl.click(); + fixture.detectChanges(); + + expect(stepperComponent.selectedIndex).toBe(2); +} + +/** Asserts that 'aria-expanded' attribute is correct for expanded content of step. */ +function assertCorrectAriaExpandedAttribute(stepperComponent: MdStepper, + fixture: ComponentFixture, + stepContents: DebugElement[]) { + let firstStepContentEl = stepContents[0].nativeElement; + expect(firstStepContentEl.getAttribute('aria-expanded')).toBe('true'); + + stepperComponent.selectedIndex = 1; + fixture.detectChanges(); + + expect(firstStepContentEl.getAttribute('aria-expanded')).toBe('false'); + let secondStepContentEl = stepContents[1].nativeElement; + expect(secondStepContentEl.getAttribute('aria-expanded')).toBe('true'); +} + +/** Asserts that step has correct label. */ +function assertCorrectStepLabel(stepperComponent: MdStepper, fixture: ComponentFixture) { + let selectedLabel = fixture.nativeElement.querySelector('[aria-selected="true"]'); + expect(selectedLabel.textContent).toMatch('Step 1'); + + stepperComponent.selectedIndex = 2; + fixture.detectChanges(); + + selectedLabel = fixture.nativeElement.querySelector('[aria-selected="true"]'); + expect(selectedLabel.textContent).toMatch('Step 3'); + + fixture.componentInstance.inputLabel = 'New Label'; + fixture.detectChanges(); + + selectedLabel = fixture.nativeElement.querySelector('[aria-selected="true"]'); + expect(selectedLabel.textContent).toMatch('New Label'); +} + +/** Asserts that clicking on MdStepperNext button updates `selectedIndex` correctly. */ +function assertNextStepperButtonClick(stepperComponent: MdStepper, fixture: ComponentFixture) { + expect(stepperComponent.selectedIndex).toBe(0); + + let nextButtonNativeEl = fixture.debugElement + .queryAll(By.directive(MdStepperNext))[0].nativeElement; + nextButtonNativeEl.click(); + fixture.detectChanges(); + + expect(stepperComponent.selectedIndex).toBe(1); + + nextButtonNativeEl = fixture.debugElement + .queryAll(By.directive(MdStepperNext))[1].nativeElement; + nextButtonNativeEl.click(); + fixture.detectChanges(); + + expect(stepperComponent.selectedIndex).toBe(2); + + nextButtonNativeEl = fixture.debugElement + .queryAll(By.directive(MdStepperNext))[2].nativeElement; + nextButtonNativeEl.click(); + fixture.detectChanges(); + + expect(stepperComponent.selectedIndex).toBe(2); +} + +/** Asserts that clicking on MdStepperPrevious button updates `selectedIndex` correctly. */ +function assertPreviousStepperButtonClick(stepperComponent: MdStepper, + fixture: ComponentFixture) { + expect(stepperComponent.selectedIndex).toBe(0); + + stepperComponent.selectedIndex = 2; + let previousButtonNativeEl = fixture.debugElement + .queryAll(By.directive(MdStepperPrevious))[2].nativeElement; + previousButtonNativeEl.click(); + fixture.detectChanges(); + + expect(stepperComponent.selectedIndex).toBe(1); + + previousButtonNativeEl = fixture.debugElement + .queryAll(By.directive(MdStepperPrevious))[1].nativeElement; + previousButtonNativeEl.click(); + fixture.detectChanges(); + + expect(stepperComponent.selectedIndex).toBe(0); + + previousButtonNativeEl = fixture.debugElement + .queryAll(By.directive(MdStepperPrevious))[0].nativeElement; + previousButtonNativeEl.click(); + fixture.detectChanges(); + + expect(stepperComponent.selectedIndex).toBe(0); +} + +/** Asserts that step position is correct for animation. */ +function assertCorrectStepPosition(stepperComponent: MdStepper, fixture: ComponentFixture) { + expect(stepperComponent._getAnimationDirection(0)).toBe('current'); + expect(stepperComponent._getAnimationDirection(1)).toBe('next'); + expect(stepperComponent._getAnimationDirection(2)).toBe('next'); + + stepperComponent.selectedIndex = 1; + fixture.detectChanges(); + + expect(stepperComponent._getAnimationDirection(0)).toBe('previous'); + expect(stepperComponent._getAnimationDirection(1)).toBe('current'); + expect(stepperComponent._getAnimationDirection(2)).toBe('next'); + + stepperComponent.selectedIndex = 2; + fixture.detectChanges(); + + expect(stepperComponent._getAnimationDirection(0)).toBe('previous'); + expect(stepperComponent._getAnimationDirection(1)).toBe('previous'); + expect(stepperComponent._getAnimationDirection(2)).toBe('current'); +} + +/** Asserts that keyboard interaction works correctly. */ +function assertCorrectKeyboardInteraction(stepperComponent: MdStepper, + fixture: ComponentFixture, + stepHeaders: DebugElement[]) { + expect(stepperComponent._focusIndex).toBe(0); + expect(stepperComponent.selectedIndex).toBe(0); + + let stepHeaderEl = stepHeaders[0].nativeElement; + dispatchKeyboardEvent(stepHeaderEl, 'keydown', RIGHT_ARROW); + fixture.detectChanges(); + + expect(stepperComponent._focusIndex) + .toBe(1, 'Expected index of focused step to be increased by 1 after RIGHT_ARROW event.'); + expect(stepperComponent.selectedIndex) + .toBe(0, 'Expected index of selected step to remain unchanged after RIGHT_ARROW event.'); + + stepHeaderEl = stepHeaders[1].nativeElement; + dispatchKeyboardEvent(stepHeaderEl, 'keydown', ENTER); + fixture.detectChanges(); + + expect(stepperComponent._focusIndex) + .toBe(1, 'Expected index of focused step to remain unchanged after ENTER event.'); + expect(stepperComponent.selectedIndex) + .toBe(1, + 'Expected index of selected step to change to index of focused step after EVENT event.'); + + stepHeaderEl = stepHeaders[1].nativeElement; + dispatchKeyboardEvent(stepHeaderEl, 'keydown', LEFT_ARROW); + fixture.detectChanges(); + + expect(stepperComponent._focusIndex) + .toBe(0, 'Expected index of focused step to decrease by 1 after LEFT_ARROW event.'); + expect(stepperComponent.selectedIndex) + .toBe(1, 'Expected index of selected step to remain unchanged after LEFT_ARROW event.'); + + // When the focus is on the last step and right arrow key is pressed, the focus should cycle + // through to the first step. + stepperComponent._focusIndex = 2; + stepHeaderEl = stepHeaders[2].nativeElement; + dispatchKeyboardEvent(stepHeaderEl, 'keydown', RIGHT_ARROW); + fixture.detectChanges(); + + expect(stepperComponent._focusIndex) + .toBe(0, + 'Expected index of focused step to cycle through to index 0 after RIGHT_ARROW event.'); + expect(stepperComponent.selectedIndex) + .toBe(1, 'Expected index of selected step to remain unchanged after RIGHT_ARROW event.'); + + stepHeaderEl = stepHeaders[0].nativeElement; + dispatchKeyboardEvent(stepHeaderEl, 'keydown', SPACE); + fixture.detectChanges(); + + expect(stepperComponent._focusIndex) + .toBe(0, 'Expected index of focused to remain unchanged after SPACE event.'); + expect(stepperComponent.selectedIndex) + .toBe(0, + 'Expected index of selected step to change to index of focused step after SPACE event.'); +} + +/** Asserts that step selection change using stepper buttons does not focus step header. */ +function assertStepHeaderFocusNotCalled(stepperComponent: MdStepper, + fixture: ComponentFixture) { + 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'); + nextButtonNativeEl.click(); + fixture.detectChanges(); + + expect(stepperComponent.selectedIndex).toBe(1); + expect(stepHeaderEl.focus).not.toHaveBeenCalled(); +} + +/** + * 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, + fixture: ComponentFixture) { + stepHeaderEl.click(); + fixture.detectChanges(); + + expect(stepperComponent.selectedIndex).toBe(0); + + let nextButtonNativeEl = fixture.debugElement + .queryAll(By.directive(MdStepperNext))[0].nativeElement; + nextButtonNativeEl.click(); + fixture.detectChanges(); + + expect(stepperComponent.selectedIndex).toBe(0); + + testComponent.oneGroup.get('oneCtrl')!.setValue('answer'); + stepHeaderEl.click(); + fixture.detectChanges(); + + expect(testComponent.oneGroup.valid).toBe(true); + expect(stepperComponent.selectedIndex).toBe(1); +} + +/** Asserts that step header focus is blurred if the step cannot be selected upon header click. */ +function assertStepHeaderBlurred(fixture: ComponentFixture) { + let stepHeaderEl = fixture.debugElement + .queryAll(By.css('md-step-header'))[1].nativeElement; + spyOn(stepHeaderEl, 'blur'); + stepHeaderEl.click(); + fixture.detectChanges(); + + 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) { + 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) { + 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, + 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: ` + + + Step 1 + Content 1 +
+ + +
+
+ + Step 2 + Content 2 +
+ + +
+
+ + Content 3 +
+ + +
+
+
+ ` +}) +class SimpleMdHorizontalStepperApp { + inputLabel = 'Step 3'; +} + +@Component({ + template: ` + + +
+ Step one + + + +
+ + +
+
+
+ +
+ Step two + + + +
+ + +
+
+
+ +
+ Step two + + + +
+ + +
+
+
+ + Done + +
+ ` +}) +class LinearMdHorizontalStepperApp { + oneGroup: FormGroup; + twoGroup: FormGroup; + threeGroup: FormGroup; + + ngOnInit() { + this.oneGroup = new FormGroup({ + oneCtrl: new FormControl('', Validators.required) + }); + this.twoGroup = new FormGroup({ + twoCtrl: new FormControl('', Validators.required) + }); + this.threeGroup = new FormGroup({ + threeCtrl: new FormControl('', Validators.pattern(EMAIL_REGEX)) + }); + } +} + +@Component({ + template: ` + + + Step 1 + Content 1 +
+ + +
+
+ + Step 2 + Content 2 +
+ + +
+
+ + Content 3 +
+ + +
+
+
+ ` +}) +class SimpleMdVerticalStepperApp { + inputLabel = 'Step 3'; +} + +@Component({ + template: ` + + +
+ Step one + + + +
+ + +
+
+
+ +
+ Step two + + + +
+ + +
+
+
+ +
+ Step two + + + +
+ + +
+
+
+ + Done + +
+ ` +}) +class LinearMdVerticalStepperApp { + oneGroup: FormGroup; + twoGroup: FormGroup; + threeGroup: FormGroup; + + ngOnInit() { + this.oneGroup = new FormGroup({ + oneCtrl: new FormControl('', Validators.required) + }); + 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 new file mode 100644 index 00000000000..4ec7fd207d2 --- /dev/null +++ b/src/lib/stepper/stepper.ts @@ -0,0 +1,132 @@ +/** + * @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 {CdkStep, CdkStepper} from '@angular/cdk/stepper'; +import { + Component, + ContentChild, + ContentChildren, Directive, + // This import is only used to define a generic type. The current TypeScript version incorrectly + // considers such imports as unused (https://github.com/Microsoft/TypeScript/issues/14953) + // tslint:disable-next-line:no-unused-variable + ElementRef, + forwardRef, + Inject, + Optional, + QueryList, + SkipSelf, + ViewChildren, + ViewEncapsulation +} 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'; +import {MdStepHeader} from './step-header'; +import {state, style, transition, trigger, animate} from '@angular/animations'; + +/** Workaround for https://github.com/angular/angular/issues/17849 */ +export const _MdStep = CdkStep; +export const _MdStepper = CdkStepper; + +@Component({ + moduleId: module.id, + selector: 'md-step, mat-step', + templateUrl: 'step.html', + providers: [{provide: MD_ERROR_GLOBAL_OPTIONS, useExisting: MdStep}], + encapsulation: ViewEncapsulation.None +}) +export class MdStep extends _MdStep implements ErrorOptions { + /** Content for step label given by or . */ + @ContentChild(MdStepLabel) stepLabel: MdStepLabel; + + /** Original ErrorStateMatcher that checks the validity of form control. */ + private _originalErrorStateMatcher: ErrorStateMatcher; + + constructor(@Inject(forwardRef(() => MdStepper)) 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; + } +} + +@Directive({ + selector: '[mdStepper]' +}) +export class MdStepper extends _MdStepper { + /** The list of step headers of the steps in the stepper. */ + @ViewChildren(MdStepHeader, {read: ElementRef}) _stepHeader: QueryList; + + /** Steps that the stepper holds. */ + @ContentChildren(MdStep) _steps: QueryList; +} + +@Component({ + moduleId: module.id, + selector: 'md-horizontal-stepper, mat-horizontal-stepper', + templateUrl: 'stepper-horizontal.html', + styleUrls: ['stepper.css'], + inputs: ['selectedIndex'], + host: { + 'class': 'mat-stepper-horizontal', + 'role': 'tablist', + }, + animations: [ + trigger('stepTransition', [ + state('previous', style({transform: 'translate3d(-100%, 0, 0)', visibility: 'hidden'})), + state('current', style({transform: 'translate3d(0%, 0, 0)', visibility: 'visible'})), + state('next', style({transform: 'translate3d(100%, 0, 0)', visibility: 'hidden'})), + transition('* => *', + animate('500ms cubic-bezier(0.35, 0, 0.25, 1)')) + ]) + ], + providers: [{provide: MdStepper, useExisting: MdHorizontalStepper}], + encapsulation: ViewEncapsulation.None +}) +export class MdHorizontalStepper extends MdStepper { } + +@Component({ + moduleId: module.id, + selector: 'md-vertical-stepper, mat-vertical-stepper', + templateUrl: 'stepper-vertical.html', + styleUrls: ['stepper.css'], + inputs: ['selectedIndex'], + host: { + 'class': 'mat-stepper-vertical', + 'role': 'tablist', + }, + animations: [ + trigger('stepTransition', [ + state('previous', style({height: '0px', visibility: 'hidden'})), + state('next', style({height: '0px', visibility: 'hidden'})), + state('current', style({height: '*', visibility: 'visible'})), + transition('* <=> current', animate('225ms cubic-bezier(0.4, 0.0, 0.2, 1)')) + ]) + ], + providers: [{provide: MdStepper, useExisting: MdVerticalStepper}], + encapsulation: ViewEncapsulation.None +}) +export class MdVerticalStepper extends MdStepper { } diff --git a/test/karma-test-shim.js b/test/karma-test-shim.js index 7790a25cd57..3dfeae04fac 100644 --- a/test/karma-test-shim.js +++ b/test/karma-test-shim.js @@ -60,6 +60,7 @@ System.config({ '@angular/cdk/portal': 'dist/packages/cdk/portal/index.js', '@angular/cdk/rxjs': 'dist/packages/cdk/rxjs/index.js', '@angular/cdk/scrolling': 'dist/packages/cdk/scrolling/index.js', + '@angular/cdk/stepper': 'dist/packages/cdk/stepper/index.js', '@angular/cdk/table': 'dist/packages/cdk/table/index.js', '@angular/cdk/testing': 'dist/packages/cdk/testing/index.js', },