feat(stepper): Create MAT_STEPPER_GLOBAL_OPTIONS InjectionToken (#11457)

This commit is contained in:
Alicia F Rodriguez
2018-09-14 10:01:12 -04:00
committed by Jeremy Elbourn
parent 9d3d95f9e2
commit 9ab2c905e2
18 changed files with 669 additions and 184 deletions
+105 -7
View File
@@ -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<StepperOptions>('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;
+25 -5
View File
@@ -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);
+23 -6
View File
@@ -1,8 +1,6 @@
<div class="mat-step-header-ripple" mat-ripple [matRippleTrigger]="_getHostElement()"></div>
<div [class.mat-step-icon]="state !== 'number' || selected"
[class.mat-step-icon-not-touched]="state == 'number' && !selected"
<div class="mat-step-icon-state-{{state}} mat-step-icon" [class.mat-step-icon-selected]="selected"
[ngSwitch]="state">
<ng-container *ngSwitchCase="'number'" [ngSwitch]="!!(iconOverrides && iconOverrides.number)">
<ng-container
*ngSwitchCase="true"
@@ -26,16 +24,35 @@
[ngTemplateOutletContext]="_getIconContext()"></ng-container>
<mat-icon class="mat-step-icon-content" *ngSwitchDefault>done</mat-icon>
</ng-container>
<ng-container *ngSwitchCase="'error'" [ngSwitch]="!!(iconOverrides && iconOverrides.error)">
<ng-container
*ngSwitchCase="true"
[ngTemplateOutlet]="iconOverrides.error"
[ngTemplateOutletContext]="_getIconContext()"></ng-container>
<mat-icon *ngSwitchDefault>warning</mat-icon>
</ng-container>
<!-- Custom state. -->
<ng-container *ngSwitchDefault [ngSwitch]="!!(iconOverrides && iconOverrides[state])">
<ng-container
*ngSwitchCase="true"
[ngTemplateOutlet]="iconOverrides[state]"
[ngTemplateOutletContext]="_getIconContext()"></ng-container>
<mat-icon *ngSwitchDefault>{{state}}</mat-icon>
</ng-container>
</div>
<div class="mat-step-label"
[class.mat-step-label-active]="active"
[class.mat-step-label-selected]="selected">
[class.mat-step-label-selected]="selected"
[class.mat-step-label-error]="state == 'error'">
<!-- If there is a label template, use it. -->
<ng-container *ngIf="_templateLabel()" [ngTemplateOutlet]="_templateLabel()!.template">
</ng-container>
<!-- It there is no label template, fall back to the text label. -->
<!-- If there is no label template, fall back to the text label. -->
<div class="mat-step-text-label" *ngIf="_stringLabel()">{{label}}</div>
<div class="mat-step-optional" *ngIf="optional">{{_intl.optionalLabel}}</div>
<div class="mat-step-optional" *ngIf="optional && state != 'error'">{{_intl.optionalLabel}}</div>
<div class="mat-step-sub-label-error" *ngIf="state == 'error'">{{errorMessage}}</div>
</div>
+11 -5
View File
@@ -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;
+5 -2
View File
@@ -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<MatStepperIconContext>};
+2 -1
View File
@@ -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">
</mat-step-header>
<div *ngIf="!isLast" class="mat-stepper-horizontal-line"></div>
+2 -1
View File
@@ -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<MatStepperIconContext>) {}
}
+2 -1
View File
@@ -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">
</mat-step-header>
+65
View File
@@ -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
<mat-horizontal-stepper>
<mat-step label="Step 1" state="phone">
<p>Put down your phones.</p>
<div>
<button mat-button matStepperNext>Next</button>
</div>
</mat-step>
<mat-step label="Step 2" state="chat">
<p>Socialize with each other.</p>
<div>
<button mat-button matStepperPrevious>Back</button>
<button mat-button matStepperNext>Next</button>
</div>
</mat-step>
<mat-step label="Step 3">
<p>You're welcome.</p>
</mat-step>
<!-- Icon overrides. -->
<ng-template matStepperIcon="phone">
<mat-icon>call_end</mat-icon>
</ng-template>
<ng-template matStepperIcon="chat">
<mat-icon>forum</mat-icon>
</ng-template>
</mat-horizontal-stepper>
```
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 }
}
]
})
```
<!-- example(stepper-states) -->
### 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 }
}
]
})
```
<!-- example(stepper-errors) -->
### Keyboard interaction
- <kbd>LEFT_ARROW</kbd>: Focuses the previous step header
- <kbd>RIGHT_ARROW</kbd>: Focuses the next step header
+2 -4
View File
@@ -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'] & {
+262 -141
View File
@@ -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<Direction>};
describe('MatStepper', () => {
let dir: {value: Direction, change: EventEmitter<Direction>};
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<SimpleMatVerticalStepperApp>;
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<IconOverridesStepper>;
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<LinearStepperWithValidOptionalStep>;
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<MatHorizontalStepperWithErrorsApp>;
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<MatHorizontalStepperWithErrorsApp>;
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<void>): As
};
}
function createComponent<T>(component: Type<T>,
providers: Provider[] = [],
imports: any[] = []): ComponentFixture<T> {
TestBed.configureTestingModule({
imports: [
MatStepperModule,
NoopAnimationsModule,
ReactiveFormsModule,
...imports
],
declarations: [component],
providers: [
{provide: Directionality, useFactory: () => dir},
...providers
],
}).compileComponents();
return TestBed.createComponent<T>(component);
}
@Component({
template: `
<form [formGroup]="formGroup">
<mat-horizontal-stepper>
<mat-step errorMessage="This field is required" [stepControl]="formArray?.get([0])">
<ng-template matStepLabel>Step 1</ng-template>
<mat-form-field>
<mat-label>First name</mat-label>
<input matInput formControlName="firstNameCtrl" required>
<mat-error>This field is required</mat-error>
</mat-form-field>
<div>
<button mat-button matStepperPrevious>Back</button>
<button mat-button matStepperNext>Next</button>
</div>
</mat-step>
<mat-step>
<ng-template matStepLabel>Step 2</ng-template>
Content 2
<div>
<button mat-button matStepperPrevious>Back</button>
<button mat-button matStepperNext>Next</button>
</div>
</mat-step>
</mat-horizontal-stepper>
</form>
`
})
class MatHorizontalStepperWithErrorsApp implements OnInit {
formGroup: FormGroup;
constructor(private _formBuilder: FormBuilder) { }
ngOnInit() {
this.formGroup = this._formBuilder.group({
firstNameCtrl: ['', Validators.required],
lastNameCtrl: ['', Validators.required],
});
}
}
@Component({
template: `
<mat-horizontal-stepper>
+12 -11
View File
@@ -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 `<ng-template matStepLabel>`. */
@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());
@@ -0,0 +1,33 @@
<mat-horizontal-stepper linear #stepper>
<mat-step [stepControl]="firstFormGroup" errorMessage="Name is required.">
<form [formGroup]="firstFormGroup">
<ng-template matStepLabel>Fill out your name</ng-template>
<mat-form-field>
<input matInput placeholder="Last name, First name" formControlName="firstCtrl" required>
</mat-form-field>
<div>
<button mat-button matStepperNext>Next</button>
</div>
</form>
</mat-step>
<mat-step [stepControl]="secondFormGroup" errorMessage="Address is required.">
<form [formGroup]="secondFormGroup">
<ng-template matStepLabel>Fill out your address</ng-template>
<mat-form-field>
<input matInput placeholder="Address" formControlName="secondCtrl" required>
</mat-form-field>
<div>
<button mat-button matStepperPrevious>Back</button>
<button mat-button matStepperNext>Next</button>
</div>
</form>
</mat-step>
<mat-step>
<ng-template matStepLabel>Done</ng-template>
You are now done.
<div>
<button mat-button matStepperPrevious>Back</button>
<button mat-button (click)="stepper.reset()">Reset</button>
</div>
</mat-step>
</mat-horizontal-stepper>
@@ -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]
});
}
}
@@ -0,0 +1,60 @@
<mat-horizontal-stepper #stepper>
<mat-step [stepControl]="firstFormGroup">
<form [formGroup]="firstFormGroup">
<ng-template matStepLabel>Fill out your name</ng-template>
<mat-form-field>
<input matInput placeholder="Last name, First name" formControlName="firstCtrl" required>
</mat-form-field>
<div>
<button mat-button matStepperNext>Next</button>
</div>
</form>
</mat-step>
<mat-step [stepControl]="secondFormGroup">
<form [formGroup]="secondFormGroup">
<ng-template matStepLabel>Fill out your address</ng-template>
<mat-form-field>
<input matInput placeholder="Address" formControlName="secondCtrl" required>
</mat-form-field>
<div>
<button mat-button matStepperPrevious>Back</button>
<button mat-button matStepperNext>Next</button>
</div>
</form>
</mat-step>
<mat-step>
<ng-template matStepLabel>Done</ng-template>
You are now done.
<div>
<button mat-button matStepperPrevious>Back</button>
<button mat-button (click)="stepper.reset()">Reset</button>
</div>
</mat-step>
</mat-horizontal-stepper>
<mat-horizontal-stepper>
<mat-step label="Step 1" state="phone">
<p>Put down your phones.</p>
<div>
<button mat-button matStepperNext>Next</button>
</div>
</mat-step>
<mat-step label="Step 2" state="chat">
<p>Socialize with each other.</p>
<div>
<button mat-button matStepperPrevious>Back</button>
<button mat-button matStepperNext>Next</button>
</div>
</mat-step>
<mat-step label="Step 3">
<p>You're welcome.</p>
</mat-step>
<!-- Icon overrides. -->
<ng-template matStepperIcon="phone">
<mat-icon>call_end</mat-icon>
</ng-template>
<ng-template matStepperIcon="chat">
<mat-icon>forum</mat-icon>
</ng-template>
</mat-horizontal-stepper>
@@ -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]
});
}
}