mirror of
https://github.com/bitwarden/browser
synced 2025-12-10 21:33:27 +00:00
[CL-194] add vertical stepper to CL (#14528)
* Copy Vertical stepper into CL * remove unused input * add docs around vertical step usage * use signal inputs * add vertical step story * enhance documentation * WIP * Rename to Stepper * adds horizontal stepper * updated view logic * add resizeobserver directive * add basic responsizeness to stepper * add comment about stateChanged method * update responsive logic * reformat with prettier * remove obsolete applyBorder input * fix step type mismatch * fix incorrect step import * fix borken disabled logic * fix class logic * move tabpanel out of tablist. correctly increment ids * make map private * use accordion attributes for vertical stepper * barrel export directive * fixing types * remove now obsolete step-content * reimplement constructors to fix storybook not rendering * move padding to different container * move map and observer into directive * remove useless test for now * add comment about constructor implementation * add template variable for disabled state * fix typo * simplify resize observer directive logic * add jsdoc description * use typography directive * use the variable for step disabled * Update libs/components/src/stepper/stepper.mdx Co-authored-by: Vicki League <vleague@bitwarden.com> --------- Co-authored-by: Will Martin <contact@willmartian.com> Co-authored-by: Vicki League <vleague@bitwarden.com>
This commit is contained in:
@@ -41,3 +41,4 @@ export * from "./toast";
|
|||||||
export * from "./toggle-group";
|
export * from "./toggle-group";
|
||||||
export * from "./typography";
|
export * from "./typography";
|
||||||
export * from "./utils";
|
export * from "./utils";
|
||||||
|
export * from "./stepper";
|
||||||
|
|||||||
1
libs/components/src/resize-observer/index.ts
Normal file
1
libs/components/src/resize-observer/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from "./resize-observer.directive";
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { Directive, ElementRef, EventEmitter, Output, OnDestroy } from "@angular/core";
|
||||||
|
|
||||||
|
@Directive({
|
||||||
|
selector: "[resizeObserver]",
|
||||||
|
standalone: true,
|
||||||
|
})
|
||||||
|
export class ResizeObserverDirective implements OnDestroy {
|
||||||
|
private observer = new ResizeObserver((entries) => {
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (entry.target === this.el.nativeElement) {
|
||||||
|
this._resizeCallback(entry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
@Output()
|
||||||
|
resize = new EventEmitter();
|
||||||
|
|
||||||
|
constructor(private el: ElementRef) {
|
||||||
|
this.observer.observe(this.el.nativeElement);
|
||||||
|
}
|
||||||
|
|
||||||
|
_resizeCallback(entry: ResizeObserverEntry) {
|
||||||
|
this.resize.emit(entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnDestroy() {
|
||||||
|
this.observer.unobserve(this.el.nativeElement);
|
||||||
|
}
|
||||||
|
}
|
||||||
1
libs/components/src/stepper/index.ts
Normal file
1
libs/components/src/stepper/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from "./stepper.module";
|
||||||
3
libs/components/src/stepper/step.component.html
Normal file
3
libs/components/src/stepper/step.component.html
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
<ng-template>
|
||||||
|
<ng-content></ng-content>
|
||||||
|
</ng-template>
|
||||||
16
libs/components/src/stepper/step.component.ts
Normal file
16
libs/components/src/stepper/step.component.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { CdkStep, CdkStepper } from "@angular/cdk/stepper";
|
||||||
|
import { Component, input } from "@angular/core";
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: "bit-step",
|
||||||
|
templateUrl: "step.component.html",
|
||||||
|
providers: [{ provide: CdkStep, useExisting: StepComponent }],
|
||||||
|
standalone: true,
|
||||||
|
})
|
||||||
|
export class StepComponent extends CdkStep {
|
||||||
|
subLabel = input();
|
||||||
|
|
||||||
|
constructor(stepper: CdkStepper) {
|
||||||
|
super(stepper);
|
||||||
|
}
|
||||||
|
}
|
||||||
30
libs/components/src/stepper/step.stories.ts
Normal file
30
libs/components/src/stepper/step.stories.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { Meta, moduleMetadata, StoryObj } from "@storybook/angular";
|
||||||
|
|
||||||
|
import { StepComponent } from "./step.component";
|
||||||
|
import { StepperComponent } from "./stepper.component";
|
||||||
|
|
||||||
|
export default {
|
||||||
|
title: "Component Library/Stepper/Step",
|
||||||
|
component: StepComponent,
|
||||||
|
decorators: [
|
||||||
|
moduleMetadata({
|
||||||
|
imports: [StepperComponent],
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
} as Meta;
|
||||||
|
|
||||||
|
export const Default: StoryObj<StepComponent> = {
|
||||||
|
render: (args) => ({
|
||||||
|
props: args,
|
||||||
|
template: /*html*/ `
|
||||||
|
<bit-stepper>
|
||||||
|
<bit-step
|
||||||
|
label="This is the label"
|
||||||
|
subLabel="This is the sub label"
|
||||||
|
>
|
||||||
|
<p>Your custom step content appears in here. You can add whatever content you'd like</p>
|
||||||
|
</bit-step>
|
||||||
|
</bit-stepper>
|
||||||
|
`,
|
||||||
|
}),
|
||||||
|
};
|
||||||
126
libs/components/src/stepper/stepper.component.html
Normal file
126
libs/components/src/stepper/stepper.component.html
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
<div resizeObserver (resize)="handleResize($event)">
|
||||||
|
@if (orientation === "horizontal") {
|
||||||
|
<div role="tablist">
|
||||||
|
<div class="tw-flex tw-gap-8 tw-justify-between">
|
||||||
|
@for (step of steps; track $index; let isLast = $last) {
|
||||||
|
@let isCurrentStepDisabled = isStepDisabled($index);
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
[disabled]="isCurrentStepDisabled"
|
||||||
|
(click)="selectStepByIndex($index)"
|
||||||
|
class="tw-flex tw-p-3 tw-items-center tw-border-none tw-bg-transparent tw-shrink-0"
|
||||||
|
[ngClass]="{
|
||||||
|
'hover:tw-bg-secondary-100': !isCurrentStepDisabled && step.editable,
|
||||||
|
}"
|
||||||
|
[attr.aria-selected]="selectedIndex === $index"
|
||||||
|
[attr.aria-controls]="contentId + $index"
|
||||||
|
role="tab"
|
||||||
|
>
|
||||||
|
@if (step.completed) {
|
||||||
|
<span
|
||||||
|
class="tw-me-3.5 tw-size-9 tw-rounded-full tw-bg-primary-600 tw-font-bold tw-leading-9 tw-text-contrast"
|
||||||
|
>
|
||||||
|
<i class="bwi bwi-fw bwi-check" aria-hidden="true"></i>
|
||||||
|
</span>
|
||||||
|
} @else {
|
||||||
|
<span
|
||||||
|
class="tw-me-3.5 tw-size-9 tw-rounded-full tw-font-bold tw-leading-9"
|
||||||
|
[ngClass]="{
|
||||||
|
'tw-bg-primary-600 tw-text-contrast': selectedIndex === $index,
|
||||||
|
'tw-bg-secondary-300 tw-text-main':
|
||||||
|
selectedIndex !== $index && !isCurrentStepDisabled && step.editable,
|
||||||
|
'tw-bg-transparent tw-text-muted': isCurrentStepDisabled,
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
{{ $index + 1 }}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
<div class="tw-leading-snug tw-text-left">
|
||||||
|
<p bitTypography="body1" class="tw-m-0">{{ step.label }}</p>
|
||||||
|
|
||||||
|
@if (step.subLabel()) {
|
||||||
|
<p bitTypography="body2" class="tw-m-0 tw-mt-1 tw-text-muted">
|
||||||
|
{{ step.subLabel() }}
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
@if (!isLast) {
|
||||||
|
<div
|
||||||
|
class="after:tw-left-0 after:tw-top-[50%] after:-tw-translate-y-[50%] after:tw-h-[2px] after:tw-w-full after:tw-absolute after:tw-bg-secondary-300 after:tw-content-[''] tw-relative tw-w-full"
|
||||||
|
></div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@for (step of steps; track $index; let isLast = $last) {
|
||||||
|
<div role="tabpanel" [attr.id]="contentId + $index" [hidden]="!selected">
|
||||||
|
@if (selectedIndex === $index) {
|
||||||
|
<div [ngTemplateOutlet]="selected.content"></div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
} @else {
|
||||||
|
@for (step of steps; track $index; let isLast = $last) {
|
||||||
|
@let isCurrentStepDisabled = isStepDisabled($index);
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
[disabled]="isCurrentStepDisabled"
|
||||||
|
(click)="selectStepByIndex($index)"
|
||||||
|
class="tw-flex tw-p-3 tw-w-full tw-items-center tw-border-none tw-bg-transparent"
|
||||||
|
[ngClass]="{
|
||||||
|
'hover:tw-bg-secondary-100': !isCurrentStepDisabled && step.editable,
|
||||||
|
}"
|
||||||
|
[attr.id]="contentId + 'accordion' + $index"
|
||||||
|
[attr.aria-expanded]="selectedIndex === $index"
|
||||||
|
[attr.aria-controls]="contentId + $index"
|
||||||
|
>
|
||||||
|
@if (step.completed) {
|
||||||
|
<span
|
||||||
|
class="tw-me-3.5 tw-size-9 tw-rounded-full tw-bg-primary-600 tw-font-bold tw-leading-9 tw-text-contrast"
|
||||||
|
>
|
||||||
|
<i class="bwi bwi-fw bwi-check" aria-hidden="true"></i>
|
||||||
|
</span>
|
||||||
|
} @else {
|
||||||
|
<span
|
||||||
|
class="tw-me-3.5 tw-size-9 tw-rounded-full tw-font-bold tw-leading-9"
|
||||||
|
[ngClass]="{
|
||||||
|
'tw-bg-primary-600 tw-text-contrast': selectedIndex === $index,
|
||||||
|
'tw-bg-secondary-300 tw-text-main':
|
||||||
|
selectedIndex !== $index && !isCurrentStepDisabled && step.editable,
|
||||||
|
'tw-bg-transparent tw-text-muted': isCurrentStepDisabled,
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
{{ $index + 1 }}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
<div class="tw-leading-snug tw-text-left">
|
||||||
|
<p bitTypography="body1" class="tw-m-0">{{ step.label }}</p>
|
||||||
|
|
||||||
|
@if (step.subLabel()) {
|
||||||
|
<div bitTypography="body2" class="tw-m-0 tw-mt-1 tw-text-muted">
|
||||||
|
{{ step.subLabel() }}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
<div
|
||||||
|
[attr.id]="contentId + $index"
|
||||||
|
[hidden]="!selected"
|
||||||
|
[attr.aria-labelledby]="contentId + 'accordion' + $index"
|
||||||
|
role="region"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="tw-ms-7 tw-border-solid tw-border-0 tw-border-s tw-border-secondary-300"
|
||||||
|
[ngClass]="{ 'tw-min-h-6': !isLast }"
|
||||||
|
>
|
||||||
|
@if (selectedIndex === $index) {
|
||||||
|
<div class="tw-ps-8 tw-py-2">
|
||||||
|
<div [ngTemplateOutlet]="selected.content"></div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</div>
|
||||||
88
libs/components/src/stepper/stepper.component.ts
Normal file
88
libs/components/src/stepper/stepper.component.ts
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
import { Directionality } from "@angular/cdk/bidi";
|
||||||
|
import { CdkStepper, StepperOrientation } from "@angular/cdk/stepper";
|
||||||
|
import { CommonModule } from "@angular/common";
|
||||||
|
import { ChangeDetectorRef, Component, ElementRef, Input, QueryList } from "@angular/core";
|
||||||
|
|
||||||
|
import { ResizeObserverDirective } from "../resize-observer";
|
||||||
|
import { TypographyModule } from "../typography";
|
||||||
|
|
||||||
|
import { StepComponent } from "./step.component";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The `<bit-stepper>` component extends the
|
||||||
|
* [Angular CdkStepper](https://material.angular.io/cdk/stepper/api#CdkStepper) component
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: "bit-stepper",
|
||||||
|
templateUrl: "stepper.component.html",
|
||||||
|
providers: [{ provide: CdkStepper, useExisting: StepperComponent }],
|
||||||
|
imports: [CommonModule, ResizeObserverDirective, TypographyModule],
|
||||||
|
standalone: true,
|
||||||
|
})
|
||||||
|
export class StepperComponent extends CdkStepper {
|
||||||
|
// Need to reimplement the constructor to fix an invalidFactoryDep error in Storybook
|
||||||
|
// @see https://github.com/storybookjs/storybook/issues/23534#issuecomment-2042888436
|
||||||
|
constructor(
|
||||||
|
_dir: Directionality,
|
||||||
|
_changeDetectorRef: ChangeDetectorRef,
|
||||||
|
_elementRef: ElementRef<HTMLElement>,
|
||||||
|
) {
|
||||||
|
super(_dir, _changeDetectorRef, _elementRef);
|
||||||
|
}
|
||||||
|
|
||||||
|
private resizeWidthsMap = new Map([
|
||||||
|
[2, 600],
|
||||||
|
[3, 768],
|
||||||
|
[4, 900],
|
||||||
|
]);
|
||||||
|
|
||||||
|
override readonly steps!: QueryList<StepComponent>;
|
||||||
|
|
||||||
|
private internalOrientation: StepperOrientation | undefined = undefined;
|
||||||
|
private initialOrientation: StepperOrientation | undefined = undefined;
|
||||||
|
|
||||||
|
// overriding CdkStepper orientation input so we can default to vertical
|
||||||
|
@Input()
|
||||||
|
override get orientation() {
|
||||||
|
return this.internalOrientation || "vertical";
|
||||||
|
}
|
||||||
|
override set orientation(value: StepperOrientation) {
|
||||||
|
if (!this.internalOrientation) {
|
||||||
|
// tracking the first value of orientation. We want to handle resize events if it's 'horizontal'.
|
||||||
|
// If it's 'vertical' don't change the orientation to 'horizontal' when resizing
|
||||||
|
this.initialOrientation = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.internalOrientation = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
handleResize(entry: ResizeObserverEntry) {
|
||||||
|
if (this.initialOrientation === "horizontal") {
|
||||||
|
const stepperContainerWidth = entry.contentRect.width;
|
||||||
|
const numberOfSteps = this.steps.length;
|
||||||
|
const breakpoint = this.resizeWidthsMap.get(numberOfSteps) || 450;
|
||||||
|
|
||||||
|
this.orientation = stepperContainerWidth < breakpoint ? "vertical" : "horizontal";
|
||||||
|
// This is a method of CdkStepper. Their docs define it as: 'Marks the component to be change detected'
|
||||||
|
this._stateChanged();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
isStepDisabled(index: number) {
|
||||||
|
if (this.selectedIndex !== index) {
|
||||||
|
return this.selectedIndex === index - 1
|
||||||
|
? !this.steps.find((_, i) => i == index - 1)?.completed
|
||||||
|
: true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
selectStepByIndex(index: number): void {
|
||||||
|
this.selectedIndex = index;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* UID for `[attr.aria-controls]`
|
||||||
|
*/
|
||||||
|
protected contentId = Math.random().toString(36).substring(2);
|
||||||
|
}
|
||||||
35
libs/components/src/stepper/stepper.mdx
Normal file
35
libs/components/src/stepper/stepper.mdx
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { Meta, Story, Source, Primary, Controls, Title, Description } from "@storybook/addon-docs";
|
||||||
|
|
||||||
|
import * as stories from "./stepper.stories";
|
||||||
|
|
||||||
|
<Meta of={stories} />
|
||||||
|
|
||||||
|
<Title />
|
||||||
|
<Description />
|
||||||
|
|
||||||
|
<Primary />
|
||||||
|
<Controls />
|
||||||
|
|
||||||
|
## Step Component
|
||||||
|
|
||||||
|
The `<bit-step>` component extends the
|
||||||
|
[Angular CdkStep](https://material.angular.io/cdk/stepper/api#CdkStep) component
|
||||||
|
|
||||||
|
The following additional Inputs are accepted:
|
||||||
|
|
||||||
|
| Input | Type | Description |
|
||||||
|
| ---------- | ------ | -------------------------------------------------------------- |
|
||||||
|
| `subLabel` | string | An optional supplemental label to display below the main label |
|
||||||
|
|
||||||
|
In order for the stepper component to work as intended, its children must be instances of
|
||||||
|
`<bit-step>`.
|
||||||
|
|
||||||
|
```html
|
||||||
|
<bit-stepper>
|
||||||
|
<bit-step label="This is the label" subLabel="This is the sub label">
|
||||||
|
Your content here
|
||||||
|
</bit-step>
|
||||||
|
<bit-step label="Another label"> Your content here </bit-step>
|
||||||
|
<bit-step label="The last label"> Your content here </bit-step>
|
||||||
|
</bit-stepper>
|
||||||
|
```
|
||||||
10
libs/components/src/stepper/stepper.module.ts
Normal file
10
libs/components/src/stepper/stepper.module.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { NgModule } from "@angular/core";
|
||||||
|
|
||||||
|
import { StepComponent } from "./step.component";
|
||||||
|
import { StepperComponent } from "./stepper.component";
|
||||||
|
|
||||||
|
@NgModule({
|
||||||
|
imports: [StepperComponent, StepComponent],
|
||||||
|
exports: [StepperComponent, StepComponent],
|
||||||
|
})
|
||||||
|
export class StepperModule {}
|
||||||
70
libs/components/src/stepper/stepper.stories.ts
Normal file
70
libs/components/src/stepper/stepper.stories.ts
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
import { Meta, moduleMetadata, StoryObj } from "@storybook/angular";
|
||||||
|
|
||||||
|
import { ButtonComponent } from "../button";
|
||||||
|
|
||||||
|
import { StepComponent } from "./step.component";
|
||||||
|
import { StepperComponent } from "./stepper.component";
|
||||||
|
|
||||||
|
export default {
|
||||||
|
title: "Component Library/Stepper",
|
||||||
|
component: StepperComponent,
|
||||||
|
decorators: [
|
||||||
|
moduleMetadata({
|
||||||
|
imports: [ButtonComponent, StepComponent],
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
} as Meta;
|
||||||
|
|
||||||
|
export const Default: StoryObj<StepperComponent> = {
|
||||||
|
render: (args) => ({
|
||||||
|
props: args,
|
||||||
|
template: /*html*/ `
|
||||||
|
<bit-stepper [orientation]="orientation">
|
||||||
|
<bit-step
|
||||||
|
label="This is the label"
|
||||||
|
subLabel="This is the sub label"
|
||||||
|
>
|
||||||
|
<p>Your custom step content appears in here. You can add whatever content you'd like</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
bitButton
|
||||||
|
buttonType="primary"
|
||||||
|
>
|
||||||
|
Some button label
|
||||||
|
</button>
|
||||||
|
</bit-step>
|
||||||
|
<bit-step
|
||||||
|
label="Another label"
|
||||||
|
>
|
||||||
|
<p>Another step</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
bitButton
|
||||||
|
buttonType="primary"
|
||||||
|
>
|
||||||
|
Some button label
|
||||||
|
</button>
|
||||||
|
</bit-step>
|
||||||
|
<bit-step
|
||||||
|
label="The last label"
|
||||||
|
>
|
||||||
|
<p>The last step</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
bitButton
|
||||||
|
buttonType="primary"
|
||||||
|
>
|
||||||
|
Some button label
|
||||||
|
</button>
|
||||||
|
</bit-step>
|
||||||
|
</bit-stepper>
|
||||||
|
`,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Horizontal: StoryObj<StepperComponent> = {
|
||||||
|
...Default,
|
||||||
|
args: {
|
||||||
|
orientation: "horizontal",
|
||||||
|
},
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user