Skip to content

Instantly share code, notes, and snippets.

@davideas
Last active December 12, 2022 15:57
Show Gist options
  • Save davideas/3534a5c2567d6668013426ab961990f6 to your computer and use it in GitHub Desktop.
Save davideas/3534a5c2567d6668013426ab961990f6 to your computer and use it in GitHub Desktop.
Angular 8.x Responsive Stepper with headers disable feature
import { Directionality } from '@angular/cdk/bidi';
import { CdkStep, CdkStepper, StepperSelectionEvent } from '@angular/cdk/stepper';
import {
AfterViewChecked,
AfterViewInit,
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
ContentChildren,
ElementRef,
EventEmitter,
forwardRef,
Inject,
Input,
Optional,
Output,
QueryList,
ViewChildren
} from '@angular/core';
import { MatStep, MatStepper } from '@angular/material';
import { DOCUMENT } from '@angular/common';
const MAT_STEPPER_PROXY_FACTORY_PROVIDER = {
provide: MatStepper,
deps: [
forwardRef(() => ResponsiveStepperComponent),
[new Optional(), Directionality],
ChangeDetectorRef,
[new Inject(DOCUMENT)]
],
useFactory: MAT_STEPPER_PROXY_FACTORY
};
const CDK_STEPPER_PROXY_FACTORY_PROVIDER = { ...MAT_STEPPER_PROXY_FACTORY_PROVIDER, provide: CdkStepper };
export function MAT_STEPPER_PROXY_FACTORY(component: ResponsiveStepperComponent, directionality: Directionality,
changeDetectorRef: ChangeDetectorRef, document: Document) {
// We create a fake stepper primarily so we can generate a proxy from it. The fake one, however, is used until
// our view is initialized. The reason we need a proxy is so we can toggle between our 2 steppers
// (vertical and horizontal) depending on our "orientation" property. Probably a good idea to include a polyfill
// for the Proxy class: https://github.com/GoogleChrome/proxy-polyfill.
const elementRef = new ElementRef(document.createElement('mat-horizontal-stepper'));
const stepper = new MatStepper(directionality, changeDetectorRef, elementRef, document);
return new Proxy(stepper, {
get: (target, property) => Reflect.get(component.stepper || target, property),
set: (target, property, value) => Reflect.set(component.stepper || target, property, value)
});
}
/**
* Configurable vertical/horizontal layout.<br>
* Keeps input fields state.<br>
* Allow to make headers un-clickable (disabled) with normal cursor: see updateStepState().
*
* Authors: @grant77, @davideas
*/
@Component({
selector: 'responsive-stepper',
// templateUrl: './stepper.component.html',
// styleUrls: ['./stepper.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
providers: [
MAT_STEPPER_PROXY_FACTORY_PROVIDER,
CDK_STEPPER_PROXY_FACTORY_PROVIDER
],
template: `
<ng-container [ngSwitch]="orientation">
<mat-horizontal-stepper *ngSwitchDefault
[labelPosition]="labelPosition"
[linear]="linear"
[selected]="selected"
[selectedIndex]="selectedIndex"
(animationDone)="animationDone.emit($event)"
(selectionChange)="selectionChange.emit($event)">
</mat-horizontal-stepper>
<mat-vertical-stepper *ngSwitchCase="'vertical'"
[linear]="linear"
[selected]="selected"
[selectedIndex]="selectedIndex"
(animationDone)="animationDone.emit($event)"
(selectionChange)="selectionChange.emit($event)">
</mat-vertical-stepper>
</ng-container>`
})
export class ResponsiveStepperComponent implements AfterViewInit, AfterViewChecked {
// public properties
@Input() labelPosition?: 'bottom' | 'end';
@Input() linear?: boolean;
@Input() orientation?: 'horizontal' | 'vertical';
@Input() selected?: CdkStep;
@Input() selectedIndex?: number;
// public events
@Output() animationDone = new EventEmitter<void>();
@Output() selectionChange = new EventEmitter<StepperSelectionEvent>();
@Output() orientationChange = new EventEmitter<string>();
// internal properties
@ViewChildren(MatStepper) stepperList!: QueryList<MatStepper>;
@ContentChildren(MatStep) steps!: QueryList<MatStep>;
get stepper(): MatStepper {
return this.stepperList && this.stepperList.first;
}
// private properties
private lastSelectedIndex?: number;
private needsFocus = false;
private htmlSteps: Array<HTMLElement> = [];
constructor(private changeDetectorRef: ChangeDetectorRef) {
}
ngAfterViewInit() {
this.reset();
this.stepperList.changes.subscribe(() => this.reset());
// Emitted from (animationDone) event
this.selectionChange.subscribe((e: StepperSelectionEvent) => this.lastSelectedIndex = e.selectedIndex);
this.syncHTMLSteps();
// Initial step selection with enter animation if initial step > 1
setTimeout(() => this.stepper.selectedIndex = this.selectedIndex, 400);
}
ngAfterViewChecked() {
if (this.needsFocus) {
this.needsFocus = false;
const { _elementRef, _keyManager, selectedIndex } = this.stepper as any;
_elementRef.nativeElement.focus();
_keyManager.setActiveItem(selectedIndex);
}
}
get isHorizontal(): boolean {
return this.orientation === 'horizontal';
}
get isVertical(): boolean {
return this.orientation === 'vertical';
}
next() {
this.stepper.next();
}
previous() {
this.stepper.previous();
}
/**
* Enable/Disable the click on the step header.
*
* @param step The step number
* @param enabled The new state
*/
updateStepState(step: number, enabled: boolean) {
if (this.htmlSteps.length > 0) {
this.htmlSteps[step - 1].style.pointerEvents = enabled ? '' : 'none';
}
}
/**
* Sync from the dom the list of HTML elements for the steps.
*/
private syncHTMLSteps() {
this.htmlSteps = [];
let increment = 1;
let stepper: HTMLElement = document.querySelector('.mat-stepper-vertical');
if (!stepper) {
increment = 2; // 2, because Angular adds 2 elements for each horizontal step
stepper = document.querySelector('.mat-horizontal-stepper-header-container');
}
for (let i = 0; i < stepper.children.length; i += increment) {
this.htmlSteps.push(stepper.children[i] as HTMLElement);
}
}
private reset() {
// Delay is necessary (Too early in AfterViewInit: HTMLElements not loaded)
setTimeout(() => this.syncHTMLSteps(), 100);
const { stepper, steps, changeDetectorRef, lastSelectedIndex } = this;
stepper.steps.reset(steps.toArray());
stepper.steps.notifyOnChanges();
if (lastSelectedIndex) {
stepper.selectedIndex = lastSelectedIndex;
// After htmlSteps have been synced
setTimeout(() => this.orientationChange.emit(this.orientation), 101);
}
Promise.resolve().then(() => {
this.needsFocus = true;
changeDetectorRef.markForCheck();
});
}
}
@chlegou
Copy link

chlegou commented Apr 2, 2020

@davideas , nice work from the flexible adapter to the flexible Angular :p
is there any online example? we're really looking for it. please provide one.

@davideas
Copy link
Author

davideas commented Apr 2, 2020

@chlegou, thank you!

  • The FlexibleAdapter project among others here are now suspended because I am focusing on others priorities and I do not have time to dedicate, even if I would like.
  • For this gist of ResponsiveStepper, I do not have live example, I know, it needs also the html and ts part of the
    caller component, but I can give you the files "as is" by email if you want.

@chlegou
Copy link

chlegou commented Apr 8, 2020

@davideas, that works super.
Please send files to this email: Nicolastsue@gmail.com
Thank you so much for your time. And I appreciate the efforts you made in flexible adapter.

Good luck on your newest projects.

@munozdaniel
Copy link

Excellent, is there any online example?

@cmjunior
Copy link

cmjunior commented Jul 8, 2020

Hi guys, this is awesome, I just putted an online version of it this: https://stackblitz.com/edit/angular-stepper-responsive

In my project, it was returning an error:

core.js:6014 ERROR TypeError: Cannot read property 'children' of null
at ResponsiveStepperComponent.syncHTMLSteps (responsive-stepper.component.ts:229)
at responsive-stepper.component.ts:236
at ZoneDelegate.invokeTask (zone-evergreen.js:391)
at Object.onInvokeTask (core.js:39679)
at ZoneDelegate.invokeTask (zone-evergreen.js:390)
at Zone.runTask (zone-evergreen.js:168)
at invokeTask (zone-evergreen.js:465)
at ZoneTask.invoke (zone-evergreen.js:454)
at timer (zone-evergreen.js:2650)

I just corrected it as follow, line: 175:

for (let i = 0; stepper && i < stepper.children.length; i += increment) {
      this.htmlSteps.push(stepper.children[i] as HTMLElement);
}

I needed, in my project, to replace the default icons, I did get it done putting the icons inside the proxies, the component receive a list of iconSet to show on steps. It can be checked on the stackblitz. The documentation way didn´t worked for me, even on the regular mat-stepper.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment