diff --git a/src/cdk/stepper/stepper.ts b/src/cdk/stepper/stepper.ts index 826f899f816..c8ad3c16140 100644 --- a/src/cdk/stepper/stepper.ts +++ b/src/cdk/stepper/stepper.ts @@ -31,6 +31,7 @@ import { TemplateRef, ViewChild, ViewEncapsulation, + InjectionToken, } from '@angular/core'; import {DOCUMENT} from '@angular/common'; import {AbstractControl} from '@angular/forms'; @@ -65,6 +66,37 @@ export class StepperSelectionEvent { previouslySelectedStep: CdkStep; } +/** The state of each step. */ +export type StepState = 'number' | 'edit' | 'done' | 'error' | string; + +/** Enum to represent the different states of the steps. */ +export const STEP_STATE = { + NUMBER: 'number', + EDIT: 'edit', + DONE: 'done', + ERROR: 'error' +}; + +/** InjectionToken that can be used to specify the global stepper options. */ +export const MAT_STEPPER_GLOBAL_OPTIONS = + new InjectionToken('mat-stepper-global-options'); + +/** Configurable options for stepper. */ +export interface StepperOptions { + /** + * Whether the stepper should display an error state or not. + * Default behavior is assumed to be false. + */ + showError?: boolean; + + /** + * Whether the stepper should display the default indicator type + * or not. + * Default behavior is assumed to be true. + */ + displayDefaultIndicatorType?: boolean; +} + @Component({ moduleId: module.id, selector: 'cdk-step', @@ -74,6 +106,10 @@ export class StepperSelectionEvent { changeDetection: ChangeDetectionStrategy.OnPush, }) export class CdkStep implements OnChanges { + private _stepperOptions: StepperOptions; + _showError: boolean; + _displayDefaultIndicatorType: boolean; + /** Template for step label if it exists. */ @ContentChild(CdkStepLabel) stepLabel: CdkStepLabel; @@ -89,6 +125,9 @@ export class CdkStep implements OnChanges { /** Plain text label of the step. */ @Input() label: string; + /** Error message to display when there's an error. */ + @Input() errorMessage: string; + /** Aria label for the tab. */ @Input('aria-label') ariaLabel: string; @@ -98,6 +137,9 @@ export class CdkStep implements OnChanges { */ @Input('aria-labelledby') ariaLabelledby: string; + /** State of the step. */ + @Input() state: StepState; + /** Whether the user can return to this step once it has been marked as complted. */ @Input() get editable(): boolean { return this._editable; } @@ -117,18 +159,39 @@ export class CdkStep implements OnChanges { /** Whether step is marked as completed. */ @Input() get completed(): boolean { - return this._customCompleted == null ? this._defaultCompleted() : this._customCompleted; + return this._customCompleted == null ? this._getDefaultCompleted() : this._customCompleted; } set completed(value: boolean) { this._customCompleted = coerceBooleanProperty(value); } private _customCompleted: boolean | null = null; - private _defaultCompleted() { + private _getDefaultCompleted() { return this.stepControl ? this.stepControl.valid && this.interacted : this.interacted; } - constructor(@Inject(forwardRef(() => CdkStepper)) private _stepper: CdkStepper) { } + /** Whether step has an error. */ + @Input() + get hasError(): boolean { + return this._customError || this._getDefaultError(); + } + set hasError(value: boolean) { + this._customError = coerceBooleanProperty(value); + } + private _customError: boolean | null = null; + + private _getDefaultError() { + return this.stepControl && this.stepControl.invalid && this.interacted; + } + + /** @breaking-change 8.0.0 remove the `?` after `stepperOptions` */ + constructor( + @Inject(forwardRef(() => CdkStepper)) private _stepper: CdkStepper, + @Optional() @Inject(MAT_STEPPER_GLOBAL_OPTIONS) stepperOptions?: StepperOptions) { + this._stepperOptions = stepperOptions ? stepperOptions : {}; + this._displayDefaultIndicatorType = this._stepperOptions.displayDefaultIndicatorType !== false; + this._showError = !!this._stepperOptions.showError; + } /** Selects this step component. */ select(): void { @@ -143,6 +206,10 @@ export class CdkStep implements OnChanges { this._customCompleted = false; } + if (this._customError != null) { + this._customError = false; + } + if (this.stepControl) { this.stepControl.reset(); } @@ -301,15 +368,46 @@ export class CdkStepper implements AfterViewInit, OnDestroy { } /** Returns the type of icon to be displayed. */ - _getIndicatorType(index: number): 'number' | 'edit' | 'done' { + _getIndicatorType(index: number, state: StepState = STEP_STATE.NUMBER): StepState { const step = this._steps.toArray()[index]; - if (!step.completed || this._selectedIndex == index) { - return 'number'; + const isCurrentStep = this._isCurrentStep(index); + + return step._displayDefaultIndicatorType + ? this._getDefaultIndicatorLogic(step, isCurrentStep) + : this._getGuidelineLogic(step, isCurrentStep, state); + } + + private _getDefaultIndicatorLogic(step: CdkStep, isCurrentStep: boolean): StepState { + if (step._showError && step.hasError && !isCurrentStep) { + return STEP_STATE.ERROR; + } else if (!step.completed || isCurrentStep) { + return STEP_STATE.NUMBER; } else { - return step.editable ? 'edit' : 'done'; + return step.editable ? STEP_STATE.EDIT : STEP_STATE.DONE; } } + private _getGuidelineLogic( + step: CdkStep, + isCurrentStep: boolean, + state: StepState = STEP_STATE.NUMBER): StepState { + if (step._showError && step.hasError && !isCurrentStep) { + return STEP_STATE.ERROR; + } else if (step.completed && !isCurrentStep) { + return STEP_STATE.DONE; + } else if (step.completed && isCurrentStep) { + return state; + } else if (step.editable && isCurrentStep) { + return STEP_STATE.EDIT; + } else { + return state; + } + } + + private _isCurrentStep(index: number) { + return this._selectedIndex === index; + } + /** Returns the index of the currently-focused step header. */ _getFocusIndex() { return this._keyManager ? this._keyManager.activeItemIndex : this._selectedIndex; diff --git a/src/lib/stepper/_stepper-theme.scss b/src/lib/stepper/_stepper-theme.scss index 47458fb8061..6f33d84c56c 100644 --- a/src/lib/stepper/_stepper-theme.scss +++ b/src/lib/stepper/_stepper-theme.scss @@ -6,6 +6,7 @@ $foreground: map-get($theme, foreground); $background: map-get($theme, background); $primary: map-get($theme, primary); + $warn: map-get($theme, warn); .mat-step-header { &.cdk-keyboard-focused, @@ -20,18 +21,29 @@ } .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-step-icon-selected, + .mat-step-icon-state-done, + .mat-step-icon-state-edit { + background-color: mat-color($primary); + color: mat-color($primary, default-contrast); + } + + .mat-step-icon-state-error { + background-color: transparent; + color: mat-color($warn); + } + .mat-step-label.mat-step-label-active { color: mat-color($foreground, text); } + + .mat-step-label.mat-step-label-error { + color: mat-color($warn); + } } .mat-stepper-horizontal, .mat-stepper-vertical { @@ -59,6 +71,14 @@ }; } + .mat-step-sub-label-error { + font-weight: normal; + } + + .mat-step-label-error { + font-size: mat-font-size($config, body-2); + } + .mat-step-label-selected { font: { size: mat-font-size($config, body-2); diff --git a/src/lib/stepper/step-header.html b/src/lib/stepper/step-header.html index 67a8d381ae2..0d918a39470 100644 --- a/src/lib/stepper/step-header.html +++ b/src/lib/stepper/step-header.html @@ -1,8 +1,6 @@
-
- done + + + + warning + + + + + + {{state}} +
+ [class.mat-step-label-selected]="selected" + [class.mat-step-label-error]="state == 'error'"> - +
{{label}}
-
{{_intl.optionalLabel}}
+
{{_intl.optionalLabel}}
+
{{errorMessage}}
diff --git a/src/lib/stepper/step-header.scss b/src/lib/stepper/step-header.scss index 7dc0481581b..228160732d6 100644 --- a/src/lib/stepper/step-header.scss +++ b/src/lib/stepper/step-header.scss @@ -5,7 +5,7 @@ $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-sub-label-font-size: 12px; $mat-step-header-icon-size: 16px !default; .mat-step-header { @@ -17,12 +17,12 @@ $mat-step-header-icon-size: 16px !default; -webkit-tap-highlight-color: transparent; } -.mat-step-optional { - font-size: $mat-step-optional-font-size; +.mat-step-optional, +.mat-step-sub-label-error { + font-size: $mat-step-sub-label-font-size; } -.mat-step-icon, -.mat-step-icon-not-touched { +.mat-step-icon { border-radius: 50%; height: $mat-stepper-label-header-height; width: $mat-stepper-label-header-height; @@ -44,6 +44,12 @@ $mat-step-header-icon-size: 16px !default; width: $mat-step-header-icon-size; } +.mat-step-icon-state-error .mat-icon { + font-size: $mat-step-header-icon-size + 8; + height: $mat-step-header-icon-size + 8; + width: $mat-step-header-icon-size + 8; +} + .mat-step-label { display: inline-block; white-space: nowrap; diff --git a/src/lib/stepper/step-header.ts b/src/lib/stepper/step-header.ts index e369d9cfc43..b571df051f7 100644 --- a/src/lib/stepper/step-header.ts +++ b/src/lib/stepper/step-header.ts @@ -21,7 +21,7 @@ import {Subscription} from 'rxjs'; import {MatStepLabel} from './step-label'; import {MatStepperIntl} from './stepper-intl'; import {MatStepperIconContext} from './stepper-icon'; - +import {StepState} from '@angular/cdk/stepper'; @Component({ moduleId: module.id, @@ -39,11 +39,14 @@ export class MatStepHeader implements OnDestroy { private _intlSubscription: Subscription; /** State of the given step. */ - @Input() state: string; + @Input() state: StepState; /** Label of the given step. */ @Input() label: MatStepLabel | string; + /** Error message to display when there's an error. */ + @Input() errorMessage: string; + /** Overrides for the header icons, passed in via the stepper. */ @Input() iconOverrides: {[key: string]: TemplateRef}; diff --git a/src/lib/stepper/stepper-horizontal.html b/src/lib/stepper/stepper-horizontal.html index 82947eebff7..8e2b442c0c3 100644 --- a/src/lib/stepper/stepper-horizontal.html +++ b/src/lib/stepper/stepper-horizontal.html @@ -12,11 +12,12 @@ [attr.aria-label]="step.ariaLabel || null" [attr.aria-labelledby]="(!step.ariaLabel && step.ariaLabelledby) ? step.ariaLabelledby : null" [index]="i" - [state]="_getIndicatorType(i)" + [state]="_getIndicatorType(i, step.state)" [label]="step.stepLabel || step.label" [selected]="selectedIndex === i" [active]="step.completed || selectedIndex === i || !linear" [optional]="step.optional" + [errorMessage]="step.errorMessage" [iconOverrides]="_iconOverrides">
diff --git a/src/lib/stepper/stepper-icon.ts b/src/lib/stepper/stepper-icon.ts index c8cc8556072..b44c303b9c0 100644 --- a/src/lib/stepper/stepper-icon.ts +++ b/src/lib/stepper/stepper-icon.ts @@ -7,6 +7,7 @@ */ import {Directive, Input, TemplateRef} from '@angular/core'; +import {StepState} from '@angular/cdk/stepper'; /** Template context available to an attached `matStepperIcon`. */ export interface MatStepperIconContext { @@ -26,7 +27,7 @@ export interface MatStepperIconContext { }) export class MatStepperIcon { /** Name of the icon to be overridden. */ - @Input('matStepperIcon') name: 'edit' | 'done' | 'number'; + @Input('matStepperIcon') name: StepState; constructor(public templateRef: TemplateRef) {} } diff --git a/src/lib/stepper/stepper-vertical.html b/src/lib/stepper/stepper-vertical.html index 5ff402fa1e0..3ad0627b04e 100644 --- a/src/lib/stepper/stepper-vertical.html +++ b/src/lib/stepper/stepper-vertical.html @@ -11,11 +11,12 @@ [attr.aria-label]="step.ariaLabel || null" [attr.aria-labelledby]="(!step.ariaLabel && step.ariaLabelledby) ? step.ariaLabelledby : null" [index]="i" - [state]="_getIndicatorType(i)" + [state]="_getIndicatorType(i, step.state)" [label]="step.stepLabel || step.label" [selected]="selectedIndex === i" [active]="step.completed || selectedIndex === i || !linear" [optional]="step.optional" + [errorMessage]="step.errorMessage" [iconOverrides]="_iconOverrides"> diff --git a/src/lib/stepper/stepper.md b/src/lib/stepper/stepper.md index 5ffda6069ae..b392351a46c 100644 --- a/src/lib/stepper/stepper.md +++ b/src/lib/stepper/stepper.md @@ -156,6 +156,71 @@ by placing a `matStepperIcon` for each of the icons that you want to override. T Note that you aren't limited to using the `mat-icon` component when providing custom icons. +#### Step States +You can set the state of a step to whatever you want. The given state by default maps to an icon. However, it can be overridden the same way as mentioned above. + +```html + + +

Put down your phones.

+
+ +
+
+ +

Socialize with each other.

+
+ + +
+
+ +

You're welcome.

+
+ + + + call_end + + + forum + +
+``` + +In order to use the custom step states, you must add the `displayDefaultIndicatorType` option to the global default stepper options which can be specified by providing a value for +`MAT_STEPPER_GLOBAL_OPTIONS` in your application's root module. + +```ts +@NgModule({ + providers: [ + { + provide: MAT_STEPPER_GLOBAL_OPTIONS, + useValue: { displayDefaultIndicatorType: false } + } + ] +}) +``` + + + +### Error State + +The stepper can now show error states by simply providing the `showError` option to the `MAT_STEPPER_GLOBAL_OPTIONS` in your application's root module as mentioned above. + +```ts +@NgModule({ + providers: [ + { + provide: MAT_STEPPER_GLOBAL_OPTIONS, + useValue: { showError: true } + } + ] +}) +``` + + + ### Keyboard interaction - LEFT_ARROW: Focuses the previous step header - RIGHT_ARROW: Focuses the next step header diff --git a/src/lib/stepper/stepper.scss b/src/lib/stepper/stepper.scss index dd3268395e5..012a25fee59 100644 --- a/src/lib/stepper/stepper.scss +++ b/src/lib/stepper/stepper.scss @@ -34,8 +34,7 @@ $mat-stepper-line-gap: 8px !default; align-items: center; padding: 0 $mat-stepper-side-gap; - .mat-step-icon, - .mat-step-icon-not-touched { + .mat-step-icon { margin-right: $mat-stepper-line-gap; flex: none; @@ -52,8 +51,7 @@ $mat-stepper-line-gap: 8px !default; padding: $mat-stepper-side-gap; max-height: $mat-stepper-label-header-height; - .mat-step-icon, - .mat-step-icon-not-touched { + .mat-step-icon { margin-right: $mat-vertical-stepper-content-margin - $mat-stepper-side-gap; [dir='rtl'] & { diff --git a/src/lib/stepper/stepper.spec.ts b/src/lib/stepper/stepper.spec.ts index 05cfe31be3a..d7fae0567f0 100644 --- a/src/lib/stepper/stepper.spec.ts +++ b/src/lib/stepper/stepper.spec.ts @@ -9,10 +9,10 @@ import { SPACE, UP_ARROW, } from '@angular/cdk/keycodes'; -import {StepperOrientation} from '@angular/cdk/stepper'; +import {StepperOrientation, MAT_STEPPER_GLOBAL_OPTIONS, STEP_STATE} from '@angular/cdk/stepper'; import {dispatchKeyboardEvent} from '@angular/cdk/testing'; -import {Component, DebugElement, EventEmitter, OnInit} from '@angular/core'; -import {async, ComponentFixture, fakeAsync, flush, inject, TestBed} from '@angular/core/testing'; +import {Component, DebugElement, EventEmitter, OnInit, Type, Provider} from '@angular/core'; +import {ComponentFixture, fakeAsync, flush, inject, TestBed} from '@angular/core/testing'; import { AbstractControl, AsyncValidatorFn, @@ -20,7 +20,8 @@ import { FormGroup, ReactiveFormsModule, ValidationErrors, - Validators + Validators, + FormBuilder } from '@angular/forms'; import {By} from '@angular/platform-browser'; import {NoopAnimationsModule} from '@angular/platform-browser/animations'; @@ -30,45 +31,26 @@ import {MatStepperModule} from './index'; import {MatHorizontalStepper, MatStep, MatStepper, MatVerticalStepper} from './stepper'; import {MatStepperNext, MatStepperPrevious} from './stepper-button'; import {MatStepperIntl} from './stepper-intl'; +import {MatFormFieldModule} from '@angular/material/form-field'; +import {MatInputModule} from '../input/input-module'; const VALID_REGEX = /valid/; +let dir: {value: Direction, change: EventEmitter}; describe('MatStepper', () => { - let dir: {value: Direction, change: EventEmitter}; - - beforeEach(async(() => { + beforeEach(() => { dir = { value: 'ltr', change: new EventEmitter() }; - - TestBed.configureTestingModule({ - imports: [MatStepperModule, NoopAnimationsModule, ReactiveFormsModule], - declarations: [ - SimpleMatVerticalStepperApp, - LinearMatVerticalStepperApp, - IconOverridesStepper, - SimplePreselectedMatHorizontalStepperApp, - SimpleStepperWithoutStepControl, - SimpleStepperWithStepControlAndCompletedBinding, - SimpleMatHorizontalStepperApp, - LinearStepperWithValidOptionalStep, - StepperWithAriaInputs, - ], - providers: [ - {provide: Directionality, useFactory: () => dir} - ] - }); - - TestBed.compileComponents(); - })); + }); describe('basic stepper', () => { let fixture: ComponentFixture; beforeEach(() => { - fixture = TestBed.createComponent(SimpleMatVerticalStepperApp); + fixture = createComponent(SimpleMatVerticalStepperApp); fixture.detectChanges(); }); @@ -316,69 +298,27 @@ describe('MatStepper', () => { expect(stepperComponent._getIndicatorType(0)).toBe('done'); }); - it('should re-render when the i18n labels change', inject([MatStepperIntl], - (intl: MatStepperIntl) => { - fixture.destroy(); + it('should emit an event when the enter animation is done', fakeAsync(() => { + let stepper = fixture.debugElement.query(By.directive(MatStepper)).componentInstance; + let selectionChangeSpy = jasmine.createSpy('selectionChange spy'); + let animationDoneSpy = jasmine.createSpy('animationDone spy'); + let selectionChangeSubscription = stepper.selectionChange.subscribe(selectionChangeSpy); + let animationDoneSubscription = stepper.animationDone.subscribe(animationDoneSpy); - const i18nFixture = TestBed.createComponent(SimpleMatHorizontalStepperApp); - i18nFixture.detectChanges(); + stepper.selectedIndex = 1; + fixture.detectChanges(); - const header = - i18nFixture.debugElement.queryAll(By.css('mat-step-header'))[2].nativeElement; - const optionalLabel = header.querySelector('.mat-step-optional'); + expect(selectionChangeSpy).toHaveBeenCalledTimes(1); + expect(animationDoneSpy).not.toHaveBeenCalled(); - expect(optionalLabel).toBeTruthy(); - expect(optionalLabel.textContent).toBe('Optional'); + flush(); - intl.optionalLabel = 'Valgfri'; - intl.changes.next(); - i18nFixture.detectChanges(); + expect(selectionChangeSpy).toHaveBeenCalledTimes(1); + expect(animationDoneSpy).toHaveBeenCalledTimes(1); - expect(optionalLabel.textContent).toBe('Valgfri'); - })); - - it('should emit an event when the enter animation is done', fakeAsync(() => { - let stepper = fixture.debugElement.query(By.directive(MatStepper)).componentInstance; - let selectionChangeSpy = jasmine.createSpy('selectionChange spy'); - let animationDoneSpy = jasmine.createSpy('animationDone spy'); - let selectionChangeSubscription = stepper.selectionChange.subscribe(selectionChangeSpy); - let animationDoneSubscription = stepper.animationDone.subscribe(animationDoneSpy); - - stepper.selectedIndex = 1; - fixture.detectChanges(); - - expect(selectionChangeSpy).toHaveBeenCalledTimes(1); - expect(animationDoneSpy).not.toHaveBeenCalled(); - - flush(); - - expect(selectionChangeSpy).toHaveBeenCalledTimes(1); - expect(animationDoneSpy).toHaveBeenCalledTimes(1); - - selectionChangeSubscription.unsubscribe(); - animationDoneSubscription.unsubscribe(); - })); - - it('should not throw when attempting to get the selected step too early', () => { - fixture.destroy(); - fixture = TestBed.createComponent(SimpleMatVerticalStepperApp); - - const stepperComponent: MatVerticalStepper = fixture.debugElement - .query(By.css('mat-vertical-stepper')).componentInstance; - - expect(() => stepperComponent.selected).not.toThrow(); - }); - - it('should not throw when attempting to set the selected step too early', () => { - fixture.destroy(); - fixture = TestBed.createComponent(SimpleMatVerticalStepperApp); - - const stepperComponent: MatVerticalStepper = fixture.debugElement - .query(By.css('mat-vertical-stepper')).componentInstance; - - expect(() => stepperComponent.selected = null!).not.toThrow(); - expect(stepperComponent.selectedIndex).toBe(-1); - }); + selectionChangeSubscription.unsubscribe(); + animationDoneSubscription.unsubscribe(); + })); it('should set the correct aria-posinset and aria-setsize', () => { const headers = @@ -405,14 +345,59 @@ describe('MatStepper', () => { expect(stepperComponent.selectedIndex).toBe(1); }); + }); + describe('basic stepper when attempting to set the selected step too early', () => { + it('should not throw', () => { + const fixture = createComponent(SimpleMatVerticalStepperApp); + const stepperComponent: MatVerticalStepper = fixture.debugElement + .query(By.css('mat-vertical-stepper')).componentInstance; + + expect(() => stepperComponent.selected).not.toThrow(); + }); + }); + + describe('basic stepper when attempting to set the selected step too early', () => { + it('should not throw', () => { + const fixture = createComponent(SimpleMatVerticalStepperApp); + const stepperComponent: MatVerticalStepper = fixture.debugElement + .query(By.css('mat-vertical-stepper')).componentInstance; + + expect(() => stepperComponent.selected = null!).not.toThrow(); + expect(stepperComponent.selectedIndex).toBe(-1); + }); + }); + + describe('basic stepper with i18n label change', () => { + let i18nFixture; + + beforeEach(() => { + i18nFixture = createComponent(SimpleMatHorizontalStepperApp); + i18nFixture.detectChanges(); + }); + + it('should re-render when the i18n labels change', inject([MatStepperIntl], + (intl: MatStepperIntl) => { + const header = + i18nFixture.debugElement.queryAll(By.css('mat-step-header'))[2].nativeElement; + const optionalLabel = header.querySelector('.mat-step-optional'); + + expect(optionalLabel).toBeTruthy(); + expect(optionalLabel.textContent).toBe('Optional'); + + intl.optionalLabel = 'Valgfri'; + intl.changes.next(); + i18nFixture.detectChanges(); + + expect(optionalLabel.textContent).toBe('Valgfri'); + })); }); describe('icon overrides', () => { let fixture: ComponentFixture; beforeEach(() => { - fixture = TestBed.createComponent(IconOverridesStepper); + fixture = createComponent(IconOverridesStepper); fixture.detectChanges(); }); @@ -455,7 +440,7 @@ describe('MatStepper', () => { beforeEach(() => { dir.value = 'rtl'; - fixture = TestBed.createComponent(SimpleMatVerticalStepperApp); + fixture = createComponent(SimpleMatVerticalStepperApp); fixture.detectChanges(); }); @@ -488,7 +473,7 @@ describe('MatStepper', () => { let stepperComponent: MatVerticalStepper; beforeEach(() => { - fixture = TestBed.createComponent(LinearMatVerticalStepperApp); + fixture = createComponent(LinearMatVerticalStepperApp); fixture.detectChanges(); testComponent = fixture.componentInstance; @@ -700,66 +685,71 @@ describe('MatStepper', () => { expect(steps[2].completed).toBe(true, 'Expected third step to be considered complete when doing a run after a reset.'); }); + }); - it('should not throw when there is a pre-defined selectedIndex', () => { - fixture.destroy(); - - let preselectedFixture = TestBed.createComponent(SimplePreselectedMatHorizontalStepperApp); - expect(() => preselectedFixture.detectChanges()).not.toThrow(); + describe('linear stepper with a pre-defined selectedIndex', () => { + let preselectedFixture; + beforeEach(() => { + preselectedFixture = createComponent(SimplePreselectedMatHorizontalStepperApp); }); - it('should not move to the next step if the current one is not completed ' + - 'and there is no `stepControl`', () => { - fixture.destroy(); + it('should not throw', () => { + expect(() => preselectedFixture.detectChanges()).not.toThrow(); + }); + }); - const noStepControlFixture = TestBed.createComponent(SimpleStepperWithoutStepControl); + describe('linear stepper with no `stepControl`', () => { + let noStepControlFixture; + beforeEach(() => { + noStepControlFixture = createComponent(SimpleStepperWithoutStepControl); + noStepControlFixture.detectChanges(); + }); + it('should not move to the next step if the current one is not completed ', () => { + const stepper: MatHorizontalStepper = noStepControlFixture.debugElement + .query(By.directive(MatHorizontalStepper)).componentInstance; - noStepControlFixture.detectChanges(); + const headers = noStepControlFixture.debugElement + .queryAll(By.css('.mat-horizontal-stepper-header')); - const stepper: MatHorizontalStepper = noStepControlFixture.debugElement + expect(stepper.selectedIndex).toBe(0); + + headers[1].nativeElement.click(); + noStepControlFixture.detectChanges(); + + expect(stepper.selectedIndex).toBe(0); + }); + }); + + describe('linear stepper with `stepControl`', () => { + let controlAndBindingFixture; + beforeEach(() => { + controlAndBindingFixture = + createComponent(SimpleStepperWithStepControlAndCompletedBinding); + controlAndBindingFixture.detectChanges(); + }); + + it('should have the `stepControl` take precedence when `completed` is set', () => { + expect(controlAndBindingFixture.componentInstance.steps[0].control.valid).toBe(true); + expect(controlAndBindingFixture.componentInstance.steps[0].completed).toBe(false); + + const stepper: MatHorizontalStepper = controlAndBindingFixture.debugElement .query(By.directive(MatHorizontalStepper)).componentInstance; - const headers = noStepControlFixture.debugElement + const headers = controlAndBindingFixture.debugElement .queryAll(By.css('.mat-horizontal-stepper-header')); expect(stepper.selectedIndex).toBe(0); headers[1].nativeElement.click(); - noStepControlFixture.detectChanges(); + controlAndBindingFixture.detectChanges(); - expect(stepper.selectedIndex).toBe(0); + expect(stepper.selectedIndex).toBe(1); }); - - it('should have the `stepControl` take precedence when both `completed` and ' + - '`stepControl` are set', () => { - fixture.destroy(); - - const controlAndBindingFixture = - TestBed.createComponent(SimpleStepperWithStepControlAndCompletedBinding); - - controlAndBindingFixture.detectChanges(); - - expect(controlAndBindingFixture.componentInstance.steps[0].control.valid).toBe(true); - expect(controlAndBindingFixture.componentInstance.steps[0].completed).toBe(false); - - const stepper: MatHorizontalStepper = controlAndBindingFixture.debugElement - .query(By.directive(MatHorizontalStepper)).componentInstance; - - const headers = controlAndBindingFixture.debugElement - .queryAll(By.css('.mat-horizontal-stepper-header')); - - expect(stepper.selectedIndex).toBe(0); - - headers[1].nativeElement.click(); - controlAndBindingFixture.detectChanges(); - - expect(stepper.selectedIndex).toBe(1); - }); }); describe('vertical stepper', () => { it('should set the aria-orientation to "vertical"', () => { - let fixture = TestBed.createComponent(SimpleMatVerticalStepperApp); + let fixture = createComponent(SimpleMatVerticalStepperApp); fixture.detectChanges(); let stepperEl = fixture.debugElement.query(By.css('mat-vertical-stepper')).nativeElement; @@ -767,7 +757,7 @@ describe('MatStepper', () => { }); it('should support using the left/right arrows to move focus', () => { - let fixture = TestBed.createComponent(SimpleMatVerticalStepperApp); + let fixture = createComponent(SimpleMatVerticalStepperApp); fixture.detectChanges(); let stepHeaders = fixture.debugElement.queryAll(By.css('.mat-vertical-stepper-header')); @@ -775,7 +765,7 @@ describe('MatStepper', () => { }); it('should support using the up/down arrows to move focus', () => { - let fixture = TestBed.createComponent(SimpleMatVerticalStepperApp); + let fixture = createComponent(SimpleMatVerticalStepperApp); fixture.detectChanges(); let stepHeaders = fixture.debugElement.queryAll(By.css('.mat-vertical-stepper-header')); @@ -784,7 +774,7 @@ describe('MatStepper', () => { it('should reverse arrow key focus in RTL mode', () => { dir.value = 'rtl'; - let fixture = TestBed.createComponent(SimpleMatVerticalStepperApp); + let fixture = createComponent(SimpleMatVerticalStepperApp); fixture.detectChanges(); let stepHeaders = fixture.debugElement.queryAll(By.css('.mat-vertical-stepper-header')); @@ -794,7 +784,7 @@ describe('MatStepper', () => { describe('horizontal stepper', () => { it('should set the aria-orientation to "horizontal"', () => { - let fixture = TestBed.createComponent(SimpleMatHorizontalStepperApp); + let fixture = createComponent(SimpleMatHorizontalStepperApp); fixture.detectChanges(); let stepperEl = fixture.debugElement.query(By.css('mat-horizontal-stepper')).nativeElement; @@ -802,7 +792,7 @@ describe('MatStepper', () => { }); it('should support using the left/right arrows to move focus', () => { - let fixture = TestBed.createComponent(SimpleMatHorizontalStepperApp); + let fixture = createComponent(SimpleMatHorizontalStepperApp); fixture.detectChanges(); let stepHeaders = fixture.debugElement.queryAll(By.css('.mat-horizontal-stepper-header')); @@ -811,7 +801,7 @@ describe('MatStepper', () => { it('should reverse arrow key focus in RTL mode', () => { dir.value = 'rtl'; - let fixture = TestBed.createComponent(SimpleMatHorizontalStepperApp); + let fixture = createComponent(SimpleMatHorizontalStepperApp); fixture.detectChanges(); let stepHeaders = fixture.debugElement.queryAll(By.css('.mat-horizontal-stepper-header')); @@ -819,7 +809,7 @@ describe('MatStepper', () => { }); it('should reverse arrow key focus when switching into RTL after init', () => { - let fixture = TestBed.createComponent(SimpleMatHorizontalStepperApp); + let fixture = createComponent(SimpleMatHorizontalStepperApp); fixture.detectChanges(); let stepHeaders = fixture.debugElement.queryAll(By.css('.mat-horizontal-stepper-header')); @@ -833,13 +823,13 @@ describe('MatStepper', () => { }); }); - describe('valid step in linear stepper', () => { + describe('linear stepper with valid step', () => { let fixture: ComponentFixture; let testComponent: LinearStepperWithValidOptionalStep; let stepper: MatStepper; beforeEach(() => { - fixture = TestBed.createComponent(LinearStepperWithValidOptionalStep); + fixture = createComponent(LinearStepperWithValidOptionalStep); fixture.detectChanges(); testComponent = fixture.componentInstance; @@ -875,7 +865,7 @@ describe('MatStepper', () => { let stepHeader: HTMLElement; beforeEach(() => { - fixture = TestBed.createComponent(StepperWithAriaInputs); + fixture = createComponent(StepperWithAriaInputs); fixture.detectChanges(); stepHeader = fixture.nativeElement.querySelector('.mat-step-header'); }); @@ -909,6 +899,76 @@ describe('MatStepper', () => { }); }); + + describe('stepper with error state', () => { + let fixture: ComponentFixture; + let stepper: MatStepper; + + beforeEach(() => { + fixture = createComponent( + MatHorizontalStepperWithErrorsApp, + [{ + provide: MAT_STEPPER_GLOBAL_OPTIONS, + useValue: {showError: true} + }], + [MatFormFieldModule, MatInputModule] + ); + fixture.detectChanges(); + stepper = fixture.debugElement + .query(By.css('mat-horizontal-stepper')).componentInstance; + }); + + it('should show error state', () => { + let nextButtonNativeEl = fixture.debugElement + .queryAll(By.directive(MatStepperNext))[0].nativeElement; + + stepper.selectedIndex = 1; + stepper._steps.first.hasError = true; + nextButtonNativeEl.click(); + fixture.detectChanges(); + + expect(stepper._getIndicatorType(0)).toBe(STEP_STATE.ERROR); + }); + }); + + describe('stepper using Material UI Guideline logic', () => { + let fixture: ComponentFixture; + let stepper: MatStepper; + + beforeEach(() => { + fixture = createComponent( + MatHorizontalStepperWithErrorsApp, + [{ + provide: MAT_STEPPER_GLOBAL_OPTIONS, + useValue: {displayDefaultIndicatorType: false} + }], + [MatFormFieldModule, MatInputModule] + ); + fixture.detectChanges(); + stepper = fixture.debugElement + .query(By.css('mat-horizontal-stepper')).componentInstance; + }); + + it('should show done state when step is completed and its not the current step', () => { + let nextButtonNativeEl = fixture.debugElement + .queryAll(By.directive(MatStepperNext))[0].nativeElement; + + stepper.selectedIndex = 1; + stepper._steps.first.completed = true; + nextButtonNativeEl.click(); + fixture.detectChanges(); + + expect(stepper._getIndicatorType(0)).toBe(STEP_STATE.DONE); + }); + + it('should show edit state when step is editable and its the current step', () => { + stepper.selectedIndex = 1; + stepper._steps.toArray()[1].editable = true; + fixture.detectChanges(); + + expect(stepper._getIndicatorType(1)).toBe(STEP_STATE.EDIT); + }); + }); }); /** Asserts that keyboard interaction works correctly. */ @@ -1012,6 +1072,67 @@ function asyncValidator(minLength: number, validationTrigger: Subject): As }; } +function createComponent(component: Type, + providers: Provider[] = [], + imports: any[] = []): ComponentFixture { + TestBed.configureTestingModule({ + imports: [ + MatStepperModule, + NoopAnimationsModule, + ReactiveFormsModule, + ...imports + ], + declarations: [component], + providers: [ + {provide: Directionality, useFactory: () => dir}, + ...providers + ], + }).compileComponents(); + + return TestBed.createComponent(component); +} + +@Component({ + template: ` +
+ + + Step 1 + + First name + + This field is required + +
+ + +
+
+ + Step 2 + Content 2 +
+ + +
+
+
+
+ ` +}) +class MatHorizontalStepperWithErrorsApp implements OnInit { + formGroup: FormGroup; + + constructor(private _formBuilder: FormBuilder) { } + + ngOnInit() { + this.formGroup = this._formBuilder.group({ + firstNameCtrl: ['', Validators.required], + lastNameCtrl: ['', Validators.required], + }); + } +} + @Component({ template: ` diff --git a/src/lib/stepper/stepper.ts b/src/lib/stepper/stepper.ts index cd042fcbf9b..21b784e1161 100644 --- a/src/lib/stepper/stepper.ts +++ b/src/lib/stepper/stepper.ts @@ -7,7 +7,13 @@ */ import {Directionality} from '@angular/cdk/bidi'; -import {CdkStep, CdkStepper, StepContentPositionState} from '@angular/cdk/stepper'; +import { + CdkStep, + CdkStepper, + StepContentPositionState, + MAT_STEPPER_GLOBAL_OPTIONS, + StepperOptions +} from '@angular/cdk/stepper'; import {AnimationEvent} from '@angular/animations'; import { AfterContentInit, @@ -54,9 +60,11 @@ export class MatStep extends CdkStep implements ErrorStateMatcher { /** Content for step label given by ``. */ @ContentChild(MatStepLabel) stepLabel: MatStepLabel; + /** @breaking-change 8.0.0 remove the `?` after `stepperOptions` */ constructor(@Inject(forwardRef(() => MatStepper)) stepper: MatStepper, - @SkipSelf() private _errorStateMatcher: ErrorStateMatcher) { - super(stepper); + @SkipSelf() private _errorStateMatcher: ErrorStateMatcher, + @Optional() @Inject(MAT_STEPPER_GLOBAL_OPTIONS) stepperOptions?: StepperOptions) { + super(stepper, stepperOptions); } /** Custom error state matcher that additionally checks for validity of interacted form. */ @@ -94,14 +102,7 @@ export class MatStepper extends _CdkStepper implements AfterContentInit { ngAfterContentInit() { const icons = this._icons.toArray(); - - ['edit', 'done', 'number'].forEach(name => { - const override = icons.find(icon => icon.name === name); - - if (override) { - this._iconOverrides[name] = override.templateRef; - } - }); + icons.forEach(({name, templateRef}) => this._iconOverrides[name] = templateRef); // Mark the component for change detection whenever the content children query changes this._steps.changes.pipe(takeUntil(this._destroyed)).subscribe(() => this._stateChanged()); diff --git a/src/material-examples/stepper-errors/stepper-errors-example.css b/src/material-examples/stepper-errors/stepper-errors-example.css new file mode 100644 index 00000000000..e69de29bb2d diff --git a/src/material-examples/stepper-errors/stepper-errors-example.html b/src/material-examples/stepper-errors/stepper-errors-example.html new file mode 100644 index 00000000000..dbd2eaabf6b --- /dev/null +++ b/src/material-examples/stepper-errors/stepper-errors-example.html @@ -0,0 +1,33 @@ + + +
+ Fill out your name + + + +
+ +
+
+
+ +
+ Fill out your address + + + +
+ + +
+
+
+ + Done + You are now done. +
+ + +
+
+
diff --git a/src/material-examples/stepper-errors/stepper-errors-example.ts b/src/material-examples/stepper-errors/stepper-errors-example.ts new file mode 100644 index 00000000000..22e89d43f54 --- /dev/null +++ b/src/material-examples/stepper-errors/stepper-errors-example.ts @@ -0,0 +1,30 @@ +import {Component, OnInit} from '@angular/core'; +import {FormBuilder, FormGroup, Validators} from '@angular/forms'; +import {MAT_STEPPER_GLOBAL_OPTIONS} from '@angular/cdk/stepper'; + +/** + * @title Stepper that displays errors in the steps + */ +@Component({ + selector: 'stepper-errors-example', + templateUrl: 'stepper-errors-example.html', + styleUrls: ['stepper-errors-example.css'], + providers: [{ + provide: MAT_STEPPER_GLOBAL_OPTIONS, useValue: {showError: true} + }] +}) +export class StepperErrorsExample implements OnInit { + firstFormGroup: FormGroup; + secondFormGroup: FormGroup; + + constructor(private _formBuilder: FormBuilder) {} + + ngOnInit() { + this.firstFormGroup = this._formBuilder.group({ + firstCtrl: ['', Validators.required] + }); + this.secondFormGroup = this._formBuilder.group({ + secondCtrl: ['', Validators.required] + }); + } +} diff --git a/src/material-examples/stepper-states/stepper-states-example.css b/src/material-examples/stepper-states/stepper-states-example.css new file mode 100644 index 00000000000..e69de29bb2d diff --git a/src/material-examples/stepper-states/stepper-states-example.html b/src/material-examples/stepper-states/stepper-states-example.html new file mode 100644 index 00000000000..657fd974f4d --- /dev/null +++ b/src/material-examples/stepper-states/stepper-states-example.html @@ -0,0 +1,60 @@ + + +
+ Fill out your name + + + +
+ +
+
+
+ +
+ Fill out your address + + + +
+ + +
+
+
+ + Done + You are now done. +
+ + +
+
+
+ + + +

Put down your phones.

+
+ +
+
+ +

Socialize with each other.

+
+ + +
+
+ +

You're welcome.

+
+ + + + call_end + + + forum + +
diff --git a/src/material-examples/stepper-states/stepper-states-example.ts b/src/material-examples/stepper-states/stepper-states-example.ts new file mode 100644 index 00000000000..bce0d1f2a32 --- /dev/null +++ b/src/material-examples/stepper-states/stepper-states-example.ts @@ -0,0 +1,30 @@ +import {Component, OnInit} from '@angular/core'; +import {FormBuilder, FormGroup, Validators} from '@angular/forms'; +import {MAT_STEPPER_GLOBAL_OPTIONS} from '@angular/cdk/stepper'; + +/** + * @title Stepper with customized states + */ +@Component({ + selector: 'stepper-states-example', + templateUrl: 'stepper-states-example.html', + styleUrls: ['stepper-states-example.css'], + providers: [{ + provide: MAT_STEPPER_GLOBAL_OPTIONS, useValue: {displayDefaultIndicatorType: false} + }] +}) +export class StepperStatesExample implements OnInit { + firstFormGroup: FormGroup; + secondFormGroup: FormGroup; + + constructor(private _formBuilder: FormBuilder) {} + + ngOnInit() { + this.firstFormGroup = this._formBuilder.group({ + firstCtrl: ['', Validators.required] + }); + this.secondFormGroup = this._formBuilder.group({ + secondCtrl: ['', Validators.required] + }); + } +}