docs(cdk-stepper): add guide to create stepper (#14710)

* docs(cdk-stepper): add guide to create stepper

* Added guide how to create a custom stepper using the CDK
* Added a material example to demonstrate the custom CDK stepper
* Made steps from CdkStepper public as it is necessary to build
a custom stepper

* Review comments
This commit is contained in:
Michael Hoffmann
2019-01-16 19:37:01 +01:00
committed by vivian-hu
parent 3d3179f2f8
commit f6d0ea80ca
12 changed files with 312 additions and 32 deletions
@@ -0,0 +1,167 @@
# Creating a custom stepper using the CDK stepper
The [CDK stepper](https://material.angular.io/cdk/stepper/overview) allows to build a custom stepper which you can completely style yourself without any specific Material Design styling.
In this guide, we'll learn how we can build our own custom stepper using the CDK stepper. Here is what we'll build by the end of this guide:
<!-- example(cdk-custom-stepper-without-form) -->
## Create our custom stepper component
Now we are ready to create our custom stepper component. Therefore, we need to create a new Angular component which extends `CdkStepper`:
**custom-stepper.component.ts**
```ts
@Component({
selector: "app-custom-stepper",
templateUrl: "./custom-stepper.component.html",
styleUrls: ["./custom-stepper.component.css"],
// This custom stepper provides itself as CdkStepper so that it can be recognized
// by other components.
providers: [{ provide: CdkStepper, useExisting: CustomStepperComponent }]
})
export class CustomStepperComponent extends CdkStepper {
/** Whether the validity of previous steps should be checked or not */
linear: boolean;
/** The index of the selected step. */
selectedIndex: number;
/** The list of step components that the stepper is holding. */
steps: QueryList<CdkStep>;
onClick(index: number): void {
this.selectedIndex = index;
}
}
```
After we've extended our component class from `CdkStepper` we can now access different properties from this class like `linear`, `selectedIndex` and `steps` which are defined in the [API documentation](https://material.angular.io/cdk/stepper/api#CdkStepper).
This is the HTML template of our custom stepper component:
**custom-stepper.component.html**
```html
<section class="container">
<header><h2>Step {{selectedIndex + 1}}/{{steps.length}}</h2></header>
<section *ngFor="let step of steps; let i = index;">
<div [style.display]="selectedIndex === i ? 'block' : 'none'">
<!-- Content from the CdkStep is projected here -->
<ng-container [ngTemplateOutlet]="step.content"></ng-container>
</div>
</section>
<footer class="step-navigation-bar">
<button class="nav-button" cdkStepperPrevious>&larr;</button>
<button
class="step"
*ngFor="let step of steps; let i = index;"
[ngClass]="{'active': selectedIndex === i}"
(click)="onClick(i)"
>
Step {{i + 1}}
</button>
<button class="nav-button" cdkStepperNext>&rarr;</button>
</footer>
</section>
```
In the `app.component.css` file we can now style the stepper however we want:
**custom-stepper.component.css**
```css
.example-container {
border: 1px solid black;
padding: 10px;
margin: 10px;
}
.example-step-navigation-bar {
display: flex;
justify-content: flex-start;
margin-top: 10px;
}
.example-active {
color: blue;
}
.example-step {
background: transparent;
border: 0;
margin: 0 10px;
padding: 10px;
color: black;
}
.example-step.example-active {
color: blue;
border-bottom: 1px solid blue;
}
.example-nav-button {
background: transparent;
border: 0;
}
```
## Using our new custom stepper component
Now we are ready to use our new custom stepper component and fill it with steps. Therefore we can, for example, add it to our `app.component.html` and define some steps:
**app.component.html**
```html
<app-custom-stepper>
<cdk-step><p>This is any content of "Step 1"</p></cdk-step>
<cdk-step><p>This is any content of "Step 2"</p></cdk-step>
</app-custom-stepper>
```
As you can see in this example, each step needs to be wrapped inside a `<cdk-step>` tag.
If you want to iterate over your steps and use your own custom component you can do it, for example, this way:
```html
<app-custom-stepper>
<cdk-step *ngFor="let step of mySteps; let stepIndex = index">
<my-step-component [step]="step"></my-step-component>
</cdk-step>
</app-custom-stepper>
```
## Linear mode
The above example allows the user to freely navigate between all steps. The `CdkStepper` additionally provides the linear mode which requires the user to complete previous steps before proceeding.
A simple example without using forms could look this way:
**app.component.html**
```html
<app-custom-stepper linear>
<cdk-step editable="false" [completed]="completed">
<input type="text" name="a" value="Cannot proceed to next step" />
<button (click)="completeStep()">Complete step</button>
</cdk-step>
<cdk-step editable="false">
<input type="text" name="b" value="b" />
</cdk-step>
</app-custom-stepper>
```
**app.component.ts**
```ts
export class AppComponent {
completed = false;
completeStep(): void {
this.completed = true;
}
}
```
+21 -12
View File
@@ -247,9 +247,18 @@ export class CdkStepper implements AfterViewInit, OnDestroy {
*/
private _document: Document | undefined;
/** The list of step components that the stepper is holding. */
/**
* The list of step components that the stepper is holding.
* @deprecated use `steps` instead
* @breaking-change 9.0.0 remove this property
*/
@ContentChildren(CdkStep) _steps: QueryList<CdkStep>;
/** The list of step components that the stepper is holding. */
get steps(): QueryList<CdkStep> {
return this._steps;
}
/**
* The list of step headers of the steps in the stepper.
* @deprecated Type to be changed to `QueryList<CdkStepHeader>`.
@@ -267,15 +276,15 @@ export class CdkStepper implements AfterViewInit, OnDestroy {
@Input()
get selectedIndex() { return this._selectedIndex; }
set selectedIndex(index: number) {
if (this._steps) {
if (this.steps) {
// Ensure that the index can't be out of bounds.
if (index < 0 || index > this._steps.length - 1) {
if (index < 0 || index > this.steps.length - 1) {
throw Error('cdkStepper: Cannot assign out-of-bounds value to `selectedIndex`.');
}
if (this._selectedIndex != index &&
!this._anyControlsInvalidOrPending(index) &&
(index >= this._selectedIndex || this._steps.toArray()[index].editable)) {
(index >= this._selectedIndex || this.steps.toArray()[index].editable)) {
this._updateSelectedItemIndex(index);
}
} else {
@@ -288,10 +297,10 @@ export class CdkStepper implements AfterViewInit, OnDestroy {
@Input()
get selected(): CdkStep {
// @breaking-change 8.0.0 Change return type to `CdkStep | undefined`.
return this._steps ? this._steps.toArray()[this.selectedIndex] : undefined!;
return this.steps ? this.steps.toArray()[this.selectedIndex] : undefined!;
}
set selected(step: CdkStep) {
this.selectedIndex = this._steps ? this._steps.toArray().indexOf(step) : -1;
this.selectedIndex = this.steps ? this.steps.toArray().indexOf(step) : -1;
}
/** Event emitted when the selected step has changed. */
@@ -327,7 +336,7 @@ export class CdkStepper implements AfterViewInit, OnDestroy {
this._keyManager.updateActiveItemIndex(this._selectedIndex);
this._steps.changes.pipe(takeUntil(this._destroyed)).subscribe(() => {
this.steps.changes.pipe(takeUntil(this._destroyed)).subscribe(() => {
if (!this.selected) {
this._selectedIndex = Math.max(this._selectedIndex - 1, 0);
}
@@ -341,7 +350,7 @@ export class CdkStepper implements AfterViewInit, OnDestroy {
/** Selects and focuses the next step in list. */
next(): void {
this.selectedIndex = Math.min(this._selectedIndex + 1, this._steps.length - 1);
this.selectedIndex = Math.min(this._selectedIndex + 1, this.steps.length - 1);
}
/** Selects and focuses the previous step in list. */
@@ -352,7 +361,7 @@ export class CdkStepper implements AfterViewInit, OnDestroy {
/** Resets the stepper to its initial state. Note that this includes clearing form data. */
reset(): void {
this._updateSelectedItemIndex(0);
this._steps.forEach(step => step.reset());
this.steps.forEach(step => step.reset());
this._stateChanged();
}
@@ -384,7 +393,7 @@ export class CdkStepper implements AfterViewInit, OnDestroy {
/** Returns the type of icon to be displayed. */
_getIndicatorType(index: number, state: StepState = STEP_STATE.NUMBER): StepState {
const step = this._steps.toArray()[index];
const step = this.steps.toArray()[index];
const isCurrentStep = this._isCurrentStep(index);
return step._displayDefaultIndicatorType
@@ -429,7 +438,7 @@ export class CdkStepper implements AfterViewInit, OnDestroy {
}
private _updateSelectedItemIndex(newIndex: number): void {
const stepsArray = this._steps.toArray();
const stepsArray = this.steps.toArray();
this.selectionChange.emit({
selectedIndex: newIndex,
previouslySelectedIndex: this._selectedIndex,
@@ -469,7 +478,7 @@ export class CdkStepper implements AfterViewInit, OnDestroy {
}
private _anyControlsInvalidOrPending(index: number): boolean {
const steps = this._steps.toArray();
const steps = this.steps.toArray();
steps[this._selectedIndex].interacted = true;
+3 -3
View File
@@ -1,12 +1,12 @@
<div class="mat-horizontal-stepper-header-container">
<ng-container *ngFor="let step of _steps; let i = index; let isLast = last">
<ng-container *ngFor="let step of steps; let i = index; let isLast = last">
<mat-step-header class="mat-horizontal-stepper-header"
(click)="step.select()"
(keydown)="_onKeydown($event)"
[tabIndex]="_getFocusIndex() === i ? 0 : -1"
[id]="_getStepLabelId(i)"
[attr.aria-posinset]="i + 1"
[attr.aria-setsize]="_steps.length"
[attr.aria-setsize]="steps.length"
[attr.aria-controls]="_getStepContentId(i)"
[attr.aria-selected]="selectedIndex == i"
[attr.aria-label]="step.ariaLabel || null"
@@ -25,7 +25,7 @@
</div>
<div class="mat-horizontal-content-container">
<div *ngFor="let step of _steps; let i = index"
<div *ngFor="let step of steps; let i = index"
class="mat-horizontal-stepper-content" role="tabpanel"
[@stepTransition]="_getAnimationDirection(i)"
(@stepTransition.done)="_animationDone.next($event)"
+2 -2
View File
@@ -1,11 +1,11 @@
<div class="mat-step" *ngFor="let step of _steps; let i = index; let isLast = last">
<div class="mat-step" *ngFor="let step of steps; let i = index; let isLast = last">
<mat-step-header class="mat-vertical-stepper-header"
(click)="step.select()"
(keydown)="_onKeydown($event)"
[tabIndex]="_getFocusIndex() == i ? 0 : -1"
[id]="_getStepLabelId(i)"
[attr.aria-posinset]="i + 1"
[attr.aria-setsize]="_steps.length"
[attr.aria-setsize]="steps.length"
[attr.aria-controls]="_getStepContentId(i)"
[attr.aria-selected]="selectedIndex === i"
[attr.aria-label]="step.ariaLabel || null"
+20 -15
View File
@@ -9,7 +9,12 @@ import {
SPACE,
UP_ARROW,
} from '@angular/cdk/keycodes';
import {StepperOrientation, STEPPER_GLOBAL_OPTIONS, STEP_STATE} from '@angular/cdk/stepper';
import {
StepperOrientation,
STEPPER_GLOBAL_OPTIONS,
STEP_STATE,
CdkStep
} from '@angular/cdk/stepper';
import {dispatchKeyboardEvent, createKeyboardEvent, dispatchEvent} from '@angular/cdk/testing';
import {Component, DebugElement, EventEmitter, OnInit, Type, Provider} from '@angular/core';
import {ComponentFixture, fakeAsync, flush, inject, TestBed} from '@angular/core/testing';
@@ -259,7 +264,7 @@ describe('MatStepper', () => {
let stepperComponent = fixture.debugElement.query(By.directive(MatStepper)).componentInstance;
stepperComponent.selectedIndex = 1;
stepperComponent._steps.toArray()[0].editable = false;
stepperComponent.steps.toArray()[0].editable = false;
let previousButtonNativeEl = fixture.debugElement
.queryAll(By.directive(MatStepperPrevious))[1].nativeElement;
previousButtonNativeEl.click();
@@ -267,7 +272,7 @@ describe('MatStepper', () => {
expect(stepperComponent.selectedIndex).toBe(1);
stepperComponent._steps.toArray()[0].editable = true;
stepperComponent.steps.toArray()[0].editable = true;
previousButtonNativeEl.click();
fixture.detectChanges();
@@ -279,7 +284,7 @@ describe('MatStepper', () => {
let nextButtonNativeEl = fixture.debugElement
.queryAll(By.directive(MatStepperNext))[0].nativeElement;
expect(stepperComponent._getIndicatorType(0)).toBe('number');
stepperComponent._steps.toArray()[0].editable = true;
stepperComponent.steps.toArray()[0].editable = true;
nextButtonNativeEl.click();
fixture.detectChanges();
@@ -291,7 +296,7 @@ describe('MatStepper', () => {
let nextButtonNativeEl = fixture.debugElement
.queryAll(By.directive(MatStepperNext))[0].nativeElement;
expect(stepperComponent._getIndicatorType(0)).toBe('number');
stepperComponent._steps.toArray()[0].editable = false;
stepperComponent.steps.toArray()[0].editable = false;
nextButtonNativeEl.click();
fixture.detectChanges();
@@ -415,7 +420,7 @@ describe('MatStepper', () => {
const stepperDebugElement = fixture.debugElement.query(By.directive(MatStepper));
const stepperComponent: MatStepper = stepperDebugElement.componentInstance;
stepperComponent._steps.toArray()[0].editable = true;
stepperComponent.steps.toArray()[0].editable = true;
stepperComponent.next();
fixture.detectChanges();
@@ -428,7 +433,7 @@ describe('MatStepper', () => {
const stepperDebugElement = fixture.debugElement.query(By.directive(MatStepper));
const stepperComponent: MatStepper = stepperDebugElement.componentInstance;
stepperComponent._steps.toArray()[0].editable = false;
stepperComponent.steps.toArray()[0].editable = false;
stepperComponent.next();
fixture.detectChanges();
@@ -591,7 +596,7 @@ describe('MatStepper', () => {
stepperComponent.selectedIndex = 2;
fixture.detectChanges();
expect(stepperComponent._steps.toArray()[2].optional).toBe(true);
expect(stepperComponent.steps.toArray()[2].optional).toBe(true);
expect(stepperComponent.selectedIndex).toBe(2);
expect(testComponent.threeGroup.get('threeCtrl')!.valid).toBe(true);
@@ -614,7 +619,7 @@ describe('MatStepper', () => {
});
it('should be able to reset the stepper to its initial state', () => {
const steps = stepperComponent._steps.toArray();
const steps = stepperComponent.steps.toArray();
testComponent.oneGroup.get('oneCtrl')!.setValue('value');
fixture.detectChanges();
@@ -650,7 +655,7 @@ describe('MatStepper', () => {
});
it('should reset back to the first step when some of the steps are not editable', () => {
const steps = stepperComponent._steps.toArray();
const steps = stepperComponent.steps.toArray();
steps[0].editable = false;
@@ -669,7 +674,7 @@ describe('MatStepper', () => {
});
it('should not clobber the `complete` binding when resetting', () => {
const steps: MatStep[] = stepperComponent._steps.toArray();
const steps: CdkStep[] = stepperComponent.steps.toArray();
const fillOutStepper = () => {
testComponent.oneGroup.get('oneCtrl')!.setValue('input');
testComponent.twoGroup.get('twoCtrl')!.setValue('input');
@@ -933,7 +938,7 @@ describe('MatStepper', () => {
.queryAll(By.directive(MatStepperNext))[0].nativeElement;
stepper.selectedIndex = 1;
stepper._steps.first.hasError = true;
stepper.steps.first.hasError = true;
nextButtonNativeEl.click();
fixture.detectChanges();
@@ -950,7 +955,7 @@ describe('MatStepper', () => {
expect(stepper._getIndicatorType(0)).toBe(STEP_STATE.ERROR);
stepper._steps.first.hasError = false;
stepper.steps.first.hasError = false;
fixture.detectChanges();
expect(stepper._getIndicatorType(0)).not.toBe(STEP_STATE.ERROR);
@@ -981,7 +986,7 @@ describe('MatStepper', () => {
.queryAll(By.directive(MatStepperNext))[0].nativeElement;
stepper.selectedIndex = 1;
stepper._steps.first.completed = true;
stepper.steps.first.completed = true;
nextButtonNativeEl.click();
fixture.detectChanges();
@@ -990,7 +995,7 @@ describe('MatStepper', () => {
it('should show edit state when step is editable and its the current step', () => {
stepper.selectedIndex = 1;
stepper._steps.toArray()[1].editable = true;
stepper.steps.toArray()[1].editable = true;
fixture.detectChanges();
expect(stepper._getIndicatorType(1)).toBe(STEP_STATE.EDIT);
@@ -0,0 +1,8 @@
<example-custom-stepper>
<cdk-step>
<p>This is any content of "Step 1"</p>
</cdk-step>
<cdk-step>
<p>This is any content of "Step 2"</p>
</cdk-step>
</example-custom-stepper>
@@ -0,0 +1,37 @@
import {Component, QueryList, ChangeDetectorRef} from '@angular/core';
import {CdkStepper, CdkStep} from '@angular/cdk/stepper';
import {Directionality} from '@angular/cdk/bidi';
/** @title A custom CDK stepper without a form */
@Component({
selector: 'cdk-custom-stepper-without-form-example',
templateUrl: './cdk-custom-stepper-without-form-example.html',
styleUrls: ['./cdk-custom-stepper-without-form-example.css']
})
export class CdkCustomStepperWithoutFormExample {}
/** Custom CDK stepper component */
@Component({
selector: 'example-custom-stepper',
templateUrl: './example-custom-stepper.html',
styleUrls: ['./example-custom-stepper.css'],
providers: [{ provide: CdkStepper, useExisting: CustomStepper }],
})
export class CustomStepper extends CdkStepper {
/** Whether the validity of previous steps should be checked or not */
linear: boolean;
/** The index of the selected step. */
selectedIndex: number;
/** The list of step components that the stepper is holding. */
steps: QueryList<CdkStep>;
constructor(dir: Directionality, changeDetectorRef: ChangeDetectorRef) {
super(dir, changeDetectorRef);
}
onClick(index: number): void {
this.selectedIndex = index;
}
}
@@ -0,0 +1,33 @@
.example-container {
border: 1px solid black;
padding: 10px;
margin: 10px;
}
.example-step-navigation-bar {
display: flex;
justify-content: flex-start;
margin-top: 10px;
}
.example-active {
color: blue;
}
.example-step {
background: transparent;
border: 0;
margin: 0 10px;
padding: 10px;
color: black;
}
.example-step.example-active {
color: blue;
border-bottom: 1px solid blue;
}
.example-nav-button {
background: transparent;
border: 0;
}
@@ -0,0 +1,17 @@
<section class="example-container">
<header>
<h2>Step {{selectedIndex + 1}}/{{steps.length}}</h2>
</header>
<section *ngFor="let step of steps; let i = index; let isLast = last">
<div [style.display]="selectedIndex === i ? 'block' : 'none'">
<ng-container [ngTemplateOutlet]="step.content"></ng-container>
</div>
</section>
<footer class="example-step-navigation-bar">
<button class="example-nav-button" cdkStepperPrevious>&larr;</button>
<button class="example-step" *ngFor="let step of steps; let i = index;" [ngClass]="{'example-active': selectedIndex === i}" (click)="onClick(i)">Step {{i + 1}}</button>
<button class="example-nav-button" cdkStepperNext>&rarr;</button>
</footer>
</section>
+3
View File
@@ -5,6 +5,7 @@ import {A11yModule} from '@angular/cdk/a11y';
import {CdkTableModule} from '@angular/cdk/table';
import {CdkTreeModule} from '@angular/cdk/tree';
import {DragDropModule} from '@angular/cdk/drag-drop';
import {CdkStepperModule} from '@angular/cdk/stepper';
import {PortalModule} from '@angular/cdk/portal';
import {
MatAutocompleteModule, MatBadgeModule, MatBottomSheetModule, MatButtonModule,
@@ -21,6 +22,7 @@ import {
A11yModule,
CdkTableModule,
CdkTreeModule,
CdkStepperModule,
DragDropModule,
MatAutocompleteModule,
MatBadgeModule,
@@ -64,6 +66,7 @@ import {
A11yModule,
CdkTableModule,
CdkTreeModule,
CdkStepperModule,
DragDropModule,
MatAutocompleteModule,
MatBadgeModule,
+1
View File
@@ -41,6 +41,7 @@ export declare class CdkStepper implements AfterViewInit, OnDestroy {
selected: CdkStep;
selectedIndex: number;
selectionChange: EventEmitter<StepperSelectionEvent>;
readonly steps: QueryList<CdkStep>;
constructor(_dir: Directionality, _changeDetectorRef: ChangeDetectorRef, _elementRef?: ElementRef<HTMLElement> | undefined, _document?: any);
_getAnimationDirection(index: number): StepContentPositionState;
_getFocusIndex(): number | null;