diff --git a/libs/components/src/index.ts b/libs/components/src/index.ts index 319b60e6435..284dc639746 100644 --- a/libs/components/src/index.ts +++ b/libs/components/src/index.ts @@ -41,3 +41,4 @@ export * from "./toast"; export * from "./toggle-group"; export * from "./typography"; export * from "./utils"; +export * from "./stepper"; diff --git a/libs/components/src/resize-observer/index.ts b/libs/components/src/resize-observer/index.ts new file mode 100644 index 00000000000..c0c0912562f --- /dev/null +++ b/libs/components/src/resize-observer/index.ts @@ -0,0 +1 @@ +export * from "./resize-observer.directive"; diff --git a/libs/components/src/resize-observer/resize-observer.directive.ts b/libs/components/src/resize-observer/resize-observer.directive.ts new file mode 100644 index 00000000000..5943636f450 --- /dev/null +++ b/libs/components/src/resize-observer/resize-observer.directive.ts @@ -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); + } +} diff --git a/libs/components/src/stepper/index.ts b/libs/components/src/stepper/index.ts new file mode 100644 index 00000000000..0408a424672 --- /dev/null +++ b/libs/components/src/stepper/index.ts @@ -0,0 +1 @@ +export * from "./stepper.module"; diff --git a/libs/components/src/stepper/step.component.html b/libs/components/src/stepper/step.component.html new file mode 100644 index 00000000000..a4bd3d8f63e --- /dev/null +++ b/libs/components/src/stepper/step.component.html @@ -0,0 +1,3 @@ + + + diff --git a/libs/components/src/stepper/step.component.ts b/libs/components/src/stepper/step.component.ts new file mode 100644 index 00000000000..6d558964d89 --- /dev/null +++ b/libs/components/src/stepper/step.component.ts @@ -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); + } +} diff --git a/libs/components/src/stepper/step.stories.ts b/libs/components/src/stepper/step.stories.ts new file mode 100644 index 00000000000..c8005e26de3 --- /dev/null +++ b/libs/components/src/stepper/step.stories.ts @@ -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 = { + render: (args) => ({ + props: args, + template: /*html*/ ` + + + Your custom step content appears in here. You can add whatever content you'd like + + + `, + }), +}; diff --git a/libs/components/src/stepper/stepper.component.html b/libs/components/src/stepper/stepper.component.html new file mode 100644 index 00000000000..cc4753b8d72 --- /dev/null +++ b/libs/components/src/stepper/stepper.component.html @@ -0,0 +1,126 @@ + + @if (orientation === "horizontal") { + + + @for (step of steps; track $index; let isLast = $last) { + @let isCurrentStepDisabled = isStepDisabled($index); + + @if (step.completed) { + + + + } @else { + + {{ $index + 1 }} + + } + + {{ step.label }} + + @if (step.subLabel()) { + + {{ step.subLabel() }} + + } + + + @if (!isLast) { + + } + } + + + @for (step of steps; track $index; let isLast = $last) { + + @if (selectedIndex === $index) { + + } + + } + } @else { + @for (step of steps; track $index; let isLast = $last) { + @let isCurrentStepDisabled = isStepDisabled($index); + + @if (step.completed) { + + + + } @else { + + {{ $index + 1 }} + + } + + {{ step.label }} + + @if (step.subLabel()) { + + {{ step.subLabel() }} + + } + + + + + @if (selectedIndex === $index) { + + + + } + + + } + } + diff --git a/libs/components/src/stepper/stepper.component.ts b/libs/components/src/stepper/stepper.component.ts new file mode 100644 index 00000000000..59c12b4371e --- /dev/null +++ b/libs/components/src/stepper/stepper.component.ts @@ -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 `` 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, + ) { + super(_dir, _changeDetectorRef, _elementRef); + } + + private resizeWidthsMap = new Map([ + [2, 600], + [3, 768], + [4, 900], + ]); + + override readonly steps!: QueryList; + + 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); +} diff --git a/libs/components/src/stepper/stepper.mdx b/libs/components/src/stepper/stepper.mdx new file mode 100644 index 00000000000..ca4efd97aef --- /dev/null +++ b/libs/components/src/stepper/stepper.mdx @@ -0,0 +1,35 @@ +import { Meta, Story, Source, Primary, Controls, Title, Description } from "@storybook/addon-docs"; + +import * as stories from "./stepper.stories"; + + + + + + + + + +## Step Component + +The `` 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 +``. + +```html + + + Your content here + + Your content here + Your content here + +``` diff --git a/libs/components/src/stepper/stepper.module.ts b/libs/components/src/stepper/stepper.module.ts new file mode 100644 index 00000000000..da66f2c6a9c --- /dev/null +++ b/libs/components/src/stepper/stepper.module.ts @@ -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 {} diff --git a/libs/components/src/stepper/stepper.stories.ts b/libs/components/src/stepper/stepper.stories.ts new file mode 100644 index 00000000000..a2593588599 --- /dev/null +++ b/libs/components/src/stepper/stepper.stories.ts @@ -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 = { + render: (args) => ({ + props: args, + template: /*html*/ ` + + + Your custom step content appears in here. You can add whatever content you'd like + + Some button label + + + + Another step + + Some button label + + + + The last step + + Some button label + + + + `, + }), +}; + +export const Horizontal: StoryObj = { + ...Default, + args: { + orientation: "horizontal", + }, +};
Your custom step content appears in here. You can add whatever content you'd like
{{ step.label }}
+ {{ step.subLabel() }} +
Another step
The last step