From a47156c29a591ee5bd586aafc06a5723f651a324 Mon Sep 17 00:00:00 2001 From: "pearu.sarv" Date: Thu, 22 Jan 2026 13:53:05 +0200 Subject: [PATCH 1/7] feat(vertical-stepper): add community vertical stepper component #254 --- .../vertical-stepper-item.html | 56 ++++ .../vertical-stepper-item.scss | 264 ++++++++++++++++++ .../vertical-stepper-item.ts | 70 +++++ .../vertical-stepper/vertical-stepper.html | 3 + .../vertical-stepper/vertical-stepper.scss | 3 + .../vertical-stepper/vertical-stepper.ts | 20 ++ 6 files changed, 416 insertions(+) create mode 100644 community/components/navigation/vertical-stepper/vertical-stepper-item/vertical-stepper-item.html create mode 100644 community/components/navigation/vertical-stepper/vertical-stepper-item/vertical-stepper-item.scss create mode 100644 community/components/navigation/vertical-stepper/vertical-stepper-item/vertical-stepper-item.ts create mode 100644 community/components/navigation/vertical-stepper/vertical-stepper.html create mode 100644 community/components/navigation/vertical-stepper/vertical-stepper.scss create mode 100644 community/components/navigation/vertical-stepper/vertical-stepper.ts diff --git a/community/components/navigation/vertical-stepper/vertical-stepper-item/vertical-stepper-item.html b/community/components/navigation/vertical-stepper/vertical-stepper-item/vertical-stepper-item.html new file mode 100644 index 00000000..52ac3b83 --- /dev/null +++ b/community/components/navigation/vertical-stepper/vertical-stepper-item/vertical-stepper-item.html @@ -0,0 +1,56 @@ + + @if(subItem() || !compact()) { @if (completed()) { + + } @else if (error()) { + + } } + + +
+ @if(!selected() && compact()) { @if (completed()) { + + } @else if (error()) { + + } } +
+
+
+ @if (hasSubItems()) { + + } @else { + + @if (link()) { + {{ title() }} + } @else { + + } + + + } +
+
+ +
+@if(opened()) { + +} diff --git a/community/components/navigation/vertical-stepper/vertical-stepper-item/vertical-stepper-item.scss b/community/components/navigation/vertical-stepper/vertical-stepper-item/vertical-stepper-item.scss new file mode 100644 index 00000000..d363b2b6 --- /dev/null +++ b/community/components/navigation/vertical-stepper/vertical-stepper-item/vertical-stepper-item.scss @@ -0,0 +1,264 @@ +.tedi-vertical-stepper-item { + $host-class: &; + display: grid; + position: relative; + + --_step-indicator-size: 24px; + --_step-indicator-color: var(--general-text-secondary, #4b4e62); + --_step-indicator-border: var(--stepper-step-default-border, #9293a4); + --_step-indicator-border-hover: var( + --stepper-step-default-border-hover, + #005aa3 + ); + --_step-indicator-border-width: 1px; + --_step-indicator-background: var(--stepper-step-default-bg, #fff); + --_step-indicator-background-hover: var(--stepper-step-default-bg, #fff); + + --_step-min-height: var(--stepper-item-vertical-lg-min-height, 40px); + --_step-padding: var(--stepper-item-vertical-lg-padding-top, 8px); + --_step-inner-spacing: var(--stepper-item-vertical-lg-inner-spacing, 8px); + --_step-line-color: var(--stepper-item-vertical-line, #9293a4); + + --_step-title-color: var(--stepper-item-vertical-text-default, #151926); + --_step-title-color-hover: var(--stepper-item-vertical-text-hover, #004277); + --_step-title-size: var(--body-regular-size, 16px); + --_step-title-weight: var(--body-regular-weight, 400); + --_step-title-line-height: var(--body-regular-line-height, 24px); + --_step-title-spacing: var(--layout-grid-gutters-08, 8px); + + grid-template-areas: + "indicator title" + ". description"; + grid-template-columns: auto 1fr; + grid-auto-columns: auto; + column-gap: var(--_step-inner-spacing); + z-index: 0; + + &__indicator { + grid-area: indicator; + display: flex; + align-items: center; + justify-content: center; + height: var(--_step-indicator-size); + width: var(--_step-indicator-size); + border: var(--_step-indicator-border) solid + var(--_step-indicator-border-width); + background-color: var(--_step-indicator-background); + color: var(--_step-indicator-color); + border-radius: 9999px; + font-size: var(--body-small-bold-size, 14px); + font-weight: var(--body-bold-weight, 700); + align-self: center; + justify-self: center; + + &::before { + content: counter(step-number); + } + } + + &:has(> &__title :is(button, a):hover) > &__indicator { + background-color: var(--_step-indicator-background-hover); + border-color: var(--_step-indicator-border-hover); + } + + &:not(&--sub-item) { + counter-increment: step-number; + } + + &__title { + grid-area: title; + // min-height: var(--_step-min-height); + padding: var(--_step-padding) 0; + + a, + button { + padding: 0; + background: none; + border: none; + text-decoration: none; + font-family: var(--family-default, Roboto); + font-size: var(--_step-title-size); + font-weight: var(--_step-title-weight); + line-height: var(--_step-title-line-height); + cursor: pointer; + color: var(--_step-title-color); + + &:hover { + color: var(--_step-title-color-hover); + &:not(#{$host-class}__toggle) { + text-decoration: underline; + } + } + } + } + + &__toggle { + display: flex; + width: 100%; + align-items: center; + + &:hover > span { + text-decoration: underline; + } + } + + &__toggle-icon { + margin-left: auto; + transition: transform 0.3s; + &--opened { + transform: rotateX(180deg); + } + } + + &__status-icon { + margin-inline: var(--_step-title-spacing); + } + + &__description { + grid-area: description; + + &:empty { + display: none; + } + } + + &__line { + &::before, + &::after { + content: ""; + grid-row: 1 / span 1; + grid-column: 1 / span 1; + left: 50%; + transform: translateX(-50%); + position: absolute; + width: 1px; + background-color: var(--_step-line-color); + top: 0; + bottom: 0; + z-index: -1; + } + + &::before { + grid-row: 1 / span 1; + } + + &::after { + grid-row: 2 / span 1; + } + } + + &:not(&--sub-item):first-child > &__line { + &::before { + top: 50%; + } + } + + &:not(&--sub-item):last-child { + &:not(:has(#{$host-class}--sub-item)) > #{$host-class}__line::before { + bottom: 50%; + } + & > #{$host-class}__line::after { + content: none; + } + + #{$host-class}--sub-item:last-child #{$host-class}__line { + &::before { + bottom: 50%; + } + &::after { + content: none; + } + } + } + + &--compact { + --_step-indicator-size: var(--stepper-item-vertical-step-size-md, 16px); + --_step-min-height: var(--stepper-item-vertical-compact-min-height, 32px); + --_step-padding: var(--stepper-item-vertical-compact-padding-top, 3px); + --_step-inner-spacing: var( + --stepper-item-vertical-compact-inner-spacing, + 6px + ); + --_step-title-spacing: var(--layout-grid-gutters-04, 4px); + #{$host-class}__indicator::before { + content: none; + } + + &#{$host-class}--enumerated #{$host-class}__title :is(a, button)::before { + content: counter(step-number)"."; + display: inline-block; + } + } + + &--sub-item { + grid-column: 1 / span 2; + grid-template-columns: subgrid; + --_step-indicator-size: var(--stepper-item-vertical-step-size-sm, 9px); + --_step-min-height: var(--stepper-item-vertical-compact-min-height, 32px); + --_step-padding: var(--stepper-item-vertical-padding-y-sm, 2px); + + #{$host-class}__indicator::before { + content: none; + } + } + + &--completed { + --_step-indicator-background: var(--stepper-step-completed-bg, #266b42); + --_step-indicator-border: var(--stepper-step-completed-bg, #266b42); + --_step-indicator-color: var(--general-text-white, #fff); + --_step-indicator-border-hover: var( + --stepper-step-completed-bg-hover, + #1d5032 + ); + --_step-indicator-background-hover: var( + --stepper-step-completed-bg-hover, + #1d5032 + ); + } + + &--error { + --_step-indicator-background: var(--stepper-step-danger-bg, #ac3232); + --_step-indicator-border: var(--stepper-step-danger-bg, #ac3232); + --_step-indicator-color: var(--general-text-white, #fff); + --_step-indicator-border-hover: var( + --stepper-step-danger-bg-hover, + #812525 + ); + --_step-indicator-background-hover: var( + --stepper-step-danger-bg-hover, + #812525 + ); + } + + &--selected { + --_step-indicator-background: var(--stepper-step-selected-bg, #fff); + --_step-indicator-color: var( + --stepper-item-vertical-text-selected, + #004277 + ); + --_step-indicator-border: var(--stepper-step-selected-border, #004277); + --_step-indicator-border-width: 2px; + --_step-indicator-border-hover: var( + --stepper-step-selected-border-hover, + #003662 + ); + --_step-title-color: var(--stepper-item-vertical-text-selected, #004277); + --_step-title-weight: var(--body-bold-weight, 700); + &#{$host-class}--compact:not(#{$host-class}--sub-item) { + --_step-indicator-border-width: 4px; + } + } + + &--disabled { + --_step-indicator-background: var(--stepper-step-disabled-bg, #d2d3d8); + --_step-indicator-color: var( + --stepper-item-vertical-text-disabled, + #9293a4 + ); + --_step-indicator-border: var(--stepper-step-disabled-border, #9293a4); + --_step-title-color: var(--stepper-item-vertical-text-disabled, #9293A4); + #{$host-class}__title { + pointer-events: none; + } + } +} diff --git a/community/components/navigation/vertical-stepper/vertical-stepper-item/vertical-stepper-item.ts b/community/components/navigation/vertical-stepper/vertical-stepper-item/vertical-stepper-item.ts new file mode 100644 index 00000000..a0a9fbd6 --- /dev/null +++ b/community/components/navigation/vertical-stepper/vertical-stepper-item/vertical-stepper-item.ts @@ -0,0 +1,70 @@ +import { + booleanAttribute, + ChangeDetectionStrategy, + Component, + computed, + contentChildren, + effect, + inject, + input, + model, + output, + signal, + ViewEncapsulation, +} from "@angular/core"; +import { VerticalStepperComponent } from "../vertical-stepper"; +import { RouterLink } from "@angular/router"; +import { NgTemplateOutlet } from "@angular/common"; +import { IconComponent } from "@tedi-design-system/angular/tedi"; + +@Component({ + selector: "tedi-vertical-stepper-item", + imports: [IconComponent, RouterLink, NgTemplateOutlet], + templateUrl: "./vertical-stepper-item.html", + styleUrl: "./vertical-stepper-item.scss", + changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.None, + host: { + "[class.tedi-vertical-stepper-item]": "true", + "[class.tedi-vertical-stepper-item--completed]": "completed()", + "[class.tedi-vertical-stepper-item--error]": "error()", + "[class.tedi-vertical-stepper-item--selected]": "selected()", + "[class.tedi-vertical-stepper-item--disabled]": "disabled()", + "[class.tedi-vertical-stepper-item--sub-item]": "subItem()", + "[class.tedi-vertical-stepper-item--compact]": "compact()", + "[class.tedi-vertical-stepper-item--enumerated]": "enumerated()", + }, +}) +export class VerticalStepperItemComponent { + completed = input(false, { transform: booleanAttribute }); + error = input(false, { transform: booleanAttribute }); + selected = input(false, { transform: booleanAttribute }); + disabled = input(false, { transform: booleanAttribute }); + title = input.required(); + link = input(undefined); + opened = model(false); // for items with children + subItem = signal(false); + + private stepperContext = inject(VerticalStepperComponent, { optional: true }); + subItems = contentChildren(VerticalStepperItemComponent); + compact = computed(() => this.stepperContext?.compact()); + enumerated = computed(() => this.stepperContext?.compact()); + hasSubItems = computed(() => !!this.subItems().length); + + itemSelect = output(); + + onSubItemSelect = effect(() => { + const subItemSelected = this.subItems().some((item) => item.selected()); + if (subItemSelected) { + this.opened.set(true); + } + }); + + onSubItemChanges = effect(() => { + this.subItems().forEach((item) => item.subItem.set(true)); + }); + + toggleOpen() { + this.opened.update((previouslyOpened) => !previouslyOpened); + } +} diff --git a/community/components/navigation/vertical-stepper/vertical-stepper.html b/community/components/navigation/vertical-stepper/vertical-stepper.html new file mode 100644 index 00000000..74b1ac3b --- /dev/null +++ b/community/components/navigation/vertical-stepper/vertical-stepper.html @@ -0,0 +1,3 @@ + diff --git a/community/components/navigation/vertical-stepper/vertical-stepper.scss b/community/components/navigation/vertical-stepper/vertical-stepper.scss new file mode 100644 index 00000000..d757cebf --- /dev/null +++ b/community/components/navigation/vertical-stepper/vertical-stepper.scss @@ -0,0 +1,3 @@ +.tedi-vertical-stepper { + counter-reset: step-number; +} diff --git a/community/components/navigation/vertical-stepper/vertical-stepper.ts b/community/components/navigation/vertical-stepper/vertical-stepper.ts new file mode 100644 index 00000000..be6d1a7b --- /dev/null +++ b/community/components/navigation/vertical-stepper/vertical-stepper.ts @@ -0,0 +1,20 @@ +import { booleanAttribute, ChangeDetectionStrategy, Component, input, ViewEncapsulation } from "@angular/core"; + +@Component({ + selector: "tedi-vertical-stepper", + imports: [], + templateUrl: "./vertical-stepper.html", + styleUrl: "./vertical-stepper.scss", + changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.None, + host: { + "[class.tedi-vertical-stepper]": "true", + "[class.tedi-vertical-stepper--compact]": "compact()", + }, +}) +export class VerticalStepperComponent { + + compact = input(false, { transform: booleanAttribute }); + enumerated = input(false, { transform: booleanAttribute }); + +} From 21d784b9e52e8106154913296882417b39d3f4dc Mon Sep 17 00:00:00 2001 From: "pearu.sarv" Date: Thu, 22 Jan 2026 16:01:59 +0200 Subject: [PATCH 2/7] feat(vertical-stepper): emit item select event on router link active change, minor style fixes #254 --- .../vertical-stepper-item/vertical-stepper-item.html | 9 +++++++-- .../vertical-stepper-item/vertical-stepper-item.scss | 10 ++++++---- .../vertical-stepper-item/vertical-stepper-item.ts | 12 +++++++++--- 3 files changed, 22 insertions(+), 9 deletions(-) diff --git a/community/components/navigation/vertical-stepper/vertical-stepper-item/vertical-stepper-item.html b/community/components/navigation/vertical-stepper/vertical-stepper-item/vertical-stepper-item.html index 52ac3b83..e9315a74 100644 --- a/community/components/navigation/vertical-stepper/vertical-stepper-item/vertical-stepper-item.html +++ b/community/components/navigation/vertical-stepper/vertical-stepper-item/vertical-stepper-item.html @@ -17,7 +17,7 @@
- @if(!selected() && compact()) { @if (completed()) { + @if(!selected() && compact() && !subItem()) { @if (completed()) { } @else if (error()) { @@ -40,7 +40,12 @@ } @else { @if (link()) { - {{ title() }} + {{ title() }} } @else { } diff --git a/community/components/navigation/vertical-stepper/vertical-stepper-item/vertical-stepper-item.scss b/community/components/navigation/vertical-stepper/vertical-stepper-item/vertical-stepper-item.scss index d363b2b6..34c39c73 100644 --- a/community/components/navigation/vertical-stepper/vertical-stepper-item/vertical-stepper-item.scss +++ b/community/components/navigation/vertical-stepper/vertical-stepper-item/vertical-stepper-item.scss @@ -107,7 +107,7 @@ transition: transform 0.3s; &--opened { transform: rotateX(180deg); - } + } } &__status-icon { @@ -184,8 +184,10 @@ content: none; } - &#{$host-class}--enumerated #{$host-class}__title :is(a, button)::before { - content: counter(step-number)"."; + &#{$host-class}--enumerated:not(#{$host-class}--sub-item) + > #{$host-class}__title + :is(a, button)::before { + content: counter(step-number) "."; display: inline-block; } } @@ -256,7 +258,7 @@ #9293a4 ); --_step-indicator-border: var(--stepper-step-disabled-border, #9293a4); - --_step-title-color: var(--stepper-item-vertical-text-disabled, #9293A4); + --_step-title-color: var(--stepper-item-vertical-text-disabled, #9293a4); #{$host-class}__title { pointer-events: none; } diff --git a/community/components/navigation/vertical-stepper/vertical-stepper-item/vertical-stepper-item.ts b/community/components/navigation/vertical-stepper/vertical-stepper-item/vertical-stepper-item.ts index a0a9fbd6..cfd68ef6 100644 --- a/community/components/navigation/vertical-stepper/vertical-stepper-item/vertical-stepper-item.ts +++ b/community/components/navigation/vertical-stepper/vertical-stepper-item/vertical-stepper-item.ts @@ -13,13 +13,13 @@ import { ViewEncapsulation, } from "@angular/core"; import { VerticalStepperComponent } from "../vertical-stepper"; -import { RouterLink } from "@angular/router"; +import { RouterLink, RouterLinkActive } from "@angular/router"; import { NgTemplateOutlet } from "@angular/common"; import { IconComponent } from "@tedi-design-system/angular/tedi"; @Component({ selector: "tedi-vertical-stepper-item", - imports: [IconComponent, RouterLink, NgTemplateOutlet], + imports: [IconComponent, RouterLink, RouterLinkActive, NgTemplateOutlet], templateUrl: "./vertical-stepper-item.html", styleUrl: "./vertical-stepper-item.scss", changeDetection: ChangeDetectionStrategy.OnPush, @@ -48,7 +48,7 @@ export class VerticalStepperItemComponent { private stepperContext = inject(VerticalStepperComponent, { optional: true }); subItems = contentChildren(VerticalStepperItemComponent); compact = computed(() => this.stepperContext?.compact()); - enumerated = computed(() => this.stepperContext?.compact()); + enumerated = computed(() => this.stepperContext?.enumerated()); hasSubItems = computed(() => !!this.subItems().length); itemSelect = output(); @@ -67,4 +67,10 @@ export class VerticalStepperItemComponent { toggleOpen() { this.opened.update((previouslyOpened) => !previouslyOpened); } + + routerLinkActiveChange(isActive: boolean) { + if (isActive) { + this.itemSelect.emit(); + } + } } From 95986c3512bb929a699ba59e106c0315356bd35f Mon Sep 17 00:00:00 2001 From: "pearu.sarv" Date: Fri, 23 Jan 2026 13:56:13 +0200 Subject: [PATCH 3/7] feat(vertical-stepper): add vertical stepper a11y attributes #254 --- .../vertical-stepper-item.html | 15 ++++++++++++--- .../vertical-stepper-item.scss | 9 +++++++++ .../vertical-stepper-item.ts | 17 +++++++++++++++-- .../vertical-stepper/vertical-stepper.html | 6 ++++-- .../vertical-stepper/vertical-stepper.ts | 11 ++++++++--- tedi/services/translation/translations.ts | 16 ++++++++++++++++ 6 files changed, 64 insertions(+), 10 deletions(-) diff --git a/community/components/navigation/vertical-stepper/vertical-stepper-item/vertical-stepper-item.html b/community/components/navigation/vertical-stepper/vertical-stepper-item/vertical-stepper-item.html index e9315a74..68692401 100644 --- a/community/components/navigation/vertical-stepper/vertical-stepper-item/vertical-stepper-item.html +++ b/community/components/navigation/vertical-stepper/vertical-stepper-item/vertical-stepper-item.html @@ -1,6 +1,7 @@ @if(subItem() || !compact()) { @if (completed()) { } @else if (error()) {
@if (hasSubItems()) { - + } diff --git a/community/components/navigation/vertical-stepper/vertical-stepper-item/vertical-stepper-item.scss b/community/components/navigation/vertical-stepper/vertical-stepper-item/vertical-stepper-item.scss index 34c39c73..4adc1032 100644 --- a/community/components/navigation/vertical-stepper/vertical-stepper-item/vertical-stepper-item.scss +++ b/community/components/navigation/vertical-stepper/vertical-stepper-item/vertical-stepper-item.scss @@ -263,4 +263,13 @@ pointer-events: none; } } + + &--informative { + --_step-indicator-background: var(--stepper-step-disabled-bg, #d2d3d8); + --_step-indicator-border: var(--stepper-step-disabled-border, #9293a4); + --_step-title-color: var(--general-text-tertiary, #5d6071); + #{$host-class}__title { + pointer-events: none; + } + } } diff --git a/community/components/navigation/vertical-stepper/vertical-stepper-item/vertical-stepper-item.ts b/community/components/navigation/vertical-stepper/vertical-stepper-item/vertical-stepper-item.ts index cfd68ef6..e3eff70c 100644 --- a/community/components/navigation/vertical-stepper/vertical-stepper-item/vertical-stepper-item.ts +++ b/community/components/navigation/vertical-stepper/vertical-stepper-item/vertical-stepper-item.ts @@ -15,11 +15,20 @@ import { import { VerticalStepperComponent } from "../vertical-stepper"; import { RouterLink, RouterLinkActive } from "@angular/router"; import { NgTemplateOutlet } from "@angular/common"; -import { IconComponent } from "@tedi-design-system/angular/tedi"; +import { + IconComponent, + TediTranslationPipe, +} from "@tedi-design-system/angular/tedi"; @Component({ selector: "tedi-vertical-stepper-item", - imports: [IconComponent, RouterLink, RouterLinkActive, NgTemplateOutlet], + imports: [ + IconComponent, + RouterLink, + RouterLinkActive, + NgTemplateOutlet, + TediTranslationPipe, + ], templateUrl: "./vertical-stepper-item.html", styleUrl: "./vertical-stepper-item.scss", changeDetection: ChangeDetectionStrategy.OnPush, @@ -30,9 +39,12 @@ import { IconComponent } from "@tedi-design-system/angular/tedi"; "[class.tedi-vertical-stepper-item--error]": "error()", "[class.tedi-vertical-stepper-item--selected]": "selected()", "[class.tedi-vertical-stepper-item--disabled]": "disabled()", + "[class.tedi-vertical-stepper-item--informative]": "informative()", "[class.tedi-vertical-stepper-item--sub-item]": "subItem()", "[class.tedi-vertical-stepper-item--compact]": "compact()", "[class.tedi-vertical-stepper-item--enumerated]": "enumerated()", + "[attr.role]": "'treeitem'", + "[attr.aria-selected]": "selected()", }, }) export class VerticalStepperItemComponent { @@ -40,6 +52,7 @@ export class VerticalStepperItemComponent { error = input(false, { transform: booleanAttribute }); selected = input(false, { transform: booleanAttribute }); disabled = input(false, { transform: booleanAttribute }); + informative = input(false, { transform: booleanAttribute }); title = input.required(); link = input(undefined); opened = model(false); // for items with children diff --git a/community/components/navigation/vertical-stepper/vertical-stepper.html b/community/components/navigation/vertical-stepper/vertical-stepper.html index 74b1ac3b..9312e665 100644 --- a/community/components/navigation/vertical-stepper/vertical-stepper.html +++ b/community/components/navigation/vertical-stepper/vertical-stepper.html @@ -1,3 +1,5 @@ -