From ce9abdc3252537b6c20d7ee9633fceaa59dd678b Mon Sep 17 00:00:00 2001 From: m2rt Date: Wed, 15 Apr 2026 14:20:37 +0300 Subject: [PATCH 1/6] feat(horizontal-stepper): new TEDI-ready component #405 --- .../horizontal-stepper-item.component.html | 32 +++ .../horizontal-stepper-item.component.scss | 250 ++++++++++++++++++ .../horizontal-stepper-item.component.spec.ts | 196 ++++++++++++++ .../horizontal-stepper-item.component.ts | 39 +++ .../horizontal-stepper-item/index.ts | 1 + .../horizontal-stepper.component.html | 1 + .../horizontal-stepper.component.scss | 8 + .../horizontal-stepper.component.spec.ts | 127 +++++++++ .../horizontal-stepper.component.ts | 38 +++ .../horizontal-stepper.stories.ts | 167 ++++++++++++ .../navigation/horizontal-stepper/index.ts | 2 + tedi/components/navigation/index.ts | 1 + tedi/services/translation/translations.ts | 8 + 13 files changed, 870 insertions(+) create mode 100644 tedi/components/navigation/horizontal-stepper/horizontal-stepper-item/horizontal-stepper-item.component.html create mode 100644 tedi/components/navigation/horizontal-stepper/horizontal-stepper-item/horizontal-stepper-item.component.scss create mode 100644 tedi/components/navigation/horizontal-stepper/horizontal-stepper-item/horizontal-stepper-item.component.spec.ts create mode 100644 tedi/components/navigation/horizontal-stepper/horizontal-stepper-item/horizontal-stepper-item.component.ts create mode 100644 tedi/components/navigation/horizontal-stepper/horizontal-stepper-item/index.ts create mode 100644 tedi/components/navigation/horizontal-stepper/horizontal-stepper.component.html create mode 100644 tedi/components/navigation/horizontal-stepper/horizontal-stepper.component.scss create mode 100644 tedi/components/navigation/horizontal-stepper/horizontal-stepper.component.spec.ts create mode 100644 tedi/components/navigation/horizontal-stepper/horizontal-stepper.component.ts create mode 100644 tedi/components/navigation/horizontal-stepper/horizontal-stepper.stories.ts create mode 100644 tedi/components/navigation/horizontal-stepper/index.ts diff --git a/tedi/components/navigation/horizontal-stepper/horizontal-stepper-item/horizontal-stepper-item.component.html b/tedi/components/navigation/horizontal-stepper/horizontal-stepper-item/horizontal-stepper-item.component.html new file mode 100644 index 000000000..7b2b413ff --- /dev/null +++ b/tedi/components/navigation/horizontal-stepper/horizontal-stepper-item/horizontal-stepper-item.component.html @@ -0,0 +1,32 @@ + diff --git a/tedi/components/navigation/horizontal-stepper/horizontal-stepper-item/horizontal-stepper-item.component.scss b/tedi/components/navigation/horizontal-stepper/horizontal-stepper-item/horizontal-stepper-item.component.scss new file mode 100644 index 000000000..42dc4a195 --- /dev/null +++ b/tedi/components/navigation/horizontal-stepper/horizontal-stepper-item/horizontal-stepper-item.component.scss @@ -0,0 +1,250 @@ +@use "@tedi-design-system/core/bootstrap-utility/breakpoints"; + +.tedi-horizontal-stepper-item { + $host: &; + + position: relative; + display: flex; + + @include breakpoints.media-breakpoint-down(sm) { + flex: 1 0 0; + + &--selected { + flex: 0 0 auto; + } + } + + &__step { + position: relative; + display: flex; + gap: var(--stepper-item-horizontal-inner-spacing); + align-items: flex-start; + width: auto; + min-height: 40px; + padding: var(--stepper-item-horizontal-padding-y) + var(--stepper-item-horizontal-padding-x); + font-family: var(--family-default); + text-align: left; + cursor: pointer; + background: none; + border: none; + border-radius: 0; + + @include breakpoints.media-breakpoint-down(sm) { + align-items: center; + justify-content: center; + width: 100%; + } + + &:focus-visible { + z-index: 2; + outline: 2px solid var(--general-focus-outline); + outline-offset: -2px; + } + } + + &__indicator { + display: flex; + flex-shrink: 0; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + background-color: var(--stepper-step-default-bg); + border: 1px solid var(--stepper-step-default-border); + border-radius: 9999px; + } + + &__number { + font-size: var(--body-small-bold-size); + font-weight: var(--body-bold-weight); + line-height: var(--body-small-bold-line-height); + color: var(--general-text-secondary); + } + + &__content { + display: flex; + flex-direction: column; + justify-content: center; + white-space: nowrap; + + @include breakpoints.media-breakpoint-down(sm) { + display: none; + + #{$host}--selected & { + display: flex; + } + } + } + + &__label { + font-size: var(--body-regular-size); + font-weight: var(--body-regular-weight); + line-height: var(--body-regular-line-height); + color: var(--stepper-item-horizontal-text-default); + } + + &__description { + font-size: var(--body-small-regular-size); + font-weight: var(--body-small-regular-weight); + line-height: var(--body-small-regular-line-height); + color: var(--general-text-tertiary); + } + + // ------------------------------------------------------- + // Default type — hover / active + // ------------------------------------------------------- + &:not(#{$host}--selected):not(#{$host}--completed):not(#{$host}--error) { + #{$host}__step:hover { + #{$host}__indicator { + background-color: var(--stepper-step-default-bg-hover); + border-color: transparent; + } + + #{$host}__number { + color: var(--general-text-white); + } + + #{$host}__label { + color: var(--stepper-item-horizontal-text-hover); + } + } + + #{$host}__step:active { + background-color: var(--stepper-item-horizontal-type-default-bg-active); + + #{$host}__indicator { + background-color: var(--stepper-step-default-bg); + border-color: transparent; + } + + #{$host}__number { + color: var(--general-text-primary); + } + + #{$host}__label { + color: var(--stepper-item-horizontal-text-hover); + } + } + } + + // ------------------------------------------------------- + // Selected + // ------------------------------------------------------- + &--selected { + z-index: 1; + + #{$host}__step { + padding-right: calc(var(--stepper-item-horizontal-padding-x) + 16px); + cursor: default; + background-color: var( + --stepper-item-horizontal-type-selected-bg-default + ); + clip-path: polygon(0 0, calc(100% - 16px) 0, 100% 50%, calc(100% - 16px) 100%, 0 100%); + } + + #{$host}__indicator { + background-color: var(--stepper-step-default-bg); + border-color: transparent; + } + + #{$host}__number { + font-weight: var(--body-bold-weight); + color: var(--general-text-brand); + } + + #{$host}__label { + font-weight: var(--body-bold-weight); + line-height: var(--body-bold-line-height); + color: var(--stepper-item-horizontal-text-selected); + } + + #{$host}__description { + color: var(--general-text-white); + } + } + + // ------------------------------------------------------- + // Completed + // ------------------------------------------------------- + &--completed { + #{$host}__step { + background-color: var( + --stepper-item-horizontal-type-completed-bg-default + ); + } + + #{$host}__indicator { + background-color: var(--stepper-step-completed-bg); + border-color: transparent; + } + + #{$host}__label { + color: var(--stepper-item-horizontal-text-completed-default); + } + + #{$host}__step:hover { + #{$host}__indicator { + background-color: var(--stepper-step-completed-bg-hover); + } + + #{$host}__label { + color: var(--stepper-item-horizontal-text-completed-hover); + } + } + + #{$host}__step:active { + background-color: var( + --stepper-item-horizontal-type-completed-bg-active + ); + + #{$host}__indicator { + background-color: var(--stepper-step-completed-bg-active); + } + } + } + + // ------------------------------------------------------- + // Error + // ------------------------------------------------------- + &--error { + #{$host}__step { + background-color: var( + --stepper-item-horizontal-type-danger-bg-default + ); + } + + #{$host}__indicator { + background-color: var(--stepper-step-danger-bg); + border-color: transparent; + } + + #{$host}__label { + color: var(--stepper-item-horizontal-text-danger-default); + } + + #{$host}__description { + color: var(--stepper-item-horizontal-text-danger-default); + } + + #{$host}__step:hover { + #{$host}__indicator { + background-color: var(--stepper-step-danger-bg-hover); + } + + #{$host}__label { + color: var(--stepper-item-horizontal-text-danger-hover); + } + } + + #{$host}__step:active { + background-color: var( + --stepper-item-horizontal-type-danger-bg-active + ); + + #{$host}__indicator { + background-color: var(--stepper-step-danger-bg-active); + } + } + } +} diff --git a/tedi/components/navigation/horizontal-stepper/horizontal-stepper-item/horizontal-stepper-item.component.spec.ts b/tedi/components/navigation/horizontal-stepper/horizontal-stepper-item/horizontal-stepper-item.component.spec.ts new file mode 100644 index 000000000..6672d9e8e --- /dev/null +++ b/tedi/components/navigation/horizontal-stepper/horizontal-stepper-item/horizontal-stepper-item.component.spec.ts @@ -0,0 +1,196 @@ +import { Component } from "@angular/core"; +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { TediTranslationService } from "../../../../services/translation/translation.service"; +import { TEDI_TRANSLATION_DEFAULT_TOKEN } from "../../../../tokens/translation.token"; +import { HorizontalStepperItemComponent } from "./horizontal-stepper-item.component"; + +class TranslationMock { + translate(key: string) { + return key; + } + + track(key: string) { + return () => key; + } +} + +@Component({ + standalone: true, + imports: [HorizontalStepperItemComponent], + template: ` + + `, +}) +class TestHostComponent { + label = "Step"; + description?: string; + completed = false; + error = false; + selected = false; + onStepSelect = jest.fn(); +} + +describe("HorizontalStepperItemComponent", () => { + let fixture: ComponentFixture; + let host: TestHostComponent; + + function getItem(): HTMLElement { + return fixture.nativeElement.querySelector("tedi-horizontal-stepper-item"); + } + + function getButton(): HTMLButtonElement { + return fixture.nativeElement.querySelector( + ".tedi-horizontal-stepper-item__step", + ); + } + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [TestHostComponent], + providers: [ + { provide: TediTranslationService, useClass: TranslationMock }, + { provide: TEDI_TRANSLATION_DEFAULT_TOKEN, useValue: "et" }, + ], + }); + + fixture = TestBed.createComponent(TestHostComponent); + host = fixture.componentInstance; + fixture.detectChanges(); + }); + + it("should create", () => { + expect(getItem()).toBeTruthy(); + }); + + it("should have tedi-horizontal-stepper-item class", () => { + expect(getItem().classList).toContain("tedi-horizontal-stepper-item"); + }); + + it("should render label text", () => { + const label = fixture.nativeElement.querySelector( + ".tedi-horizontal-stepper-item__label", + ); + expect(label.textContent.trim()).toBe("Step"); + }); + + it("should render step number when set", () => { + const itemComponent = fixture.debugElement.children[0].componentInstance; + itemComponent._stepNumber.set(3); + fixture.detectChanges(); + + const number = fixture.nativeElement.querySelector( + ".tedi-horizontal-stepper-item__number", + ); + expect(number.textContent.trim()).toBe("3"); + }); + + it("should render description when provided", () => { + host.description = "Some description"; + fixture.detectChanges(); + + const desc = fixture.nativeElement.querySelector( + ".tedi-horizontal-stepper-item__description", + ); + expect(desc).toBeTruthy(); + expect(desc.textContent.trim()).toBe("Some description"); + }); + + it("should not render description when not provided", () => { + const desc = fixture.nativeElement.querySelector( + ".tedi-horizontal-stepper-item__description", + ); + expect(desc).toBeFalsy(); + }); + + it("should apply selected class and aria-current", () => { + host.selected = true; + fixture.detectChanges(); + + expect(getItem().classList).toContain( + "tedi-horizontal-stepper-item--selected", + ); + expect(getButton().getAttribute("aria-current")).toBe("step"); + }); + + it("should not set aria-current when not selected", () => { + expect(getButton().getAttribute("aria-current")).toBeNull(); + }); + + it("should apply completed class and show check icon", () => { + host.completed = true; + fixture.detectChanges(); + + expect(getItem().classList).toContain( + "tedi-horizontal-stepper-item--completed", + ); + + const icon = fixture.nativeElement.querySelector("tedi-icon"); + expect(icon).toBeTruthy(); + expect(icon.textContent).toContain("check"); + }); + + it("should apply error class and show exclamation icon", () => { + host.error = true; + fixture.detectChanges(); + + expect(getItem().classList).toContain( + "tedi-horizontal-stepper-item--error", + ); + + const icon = fixture.nativeElement.querySelector("tedi-icon"); + expect(icon).toBeTruthy(); + expect(icon.textContent).toContain("exclamation"); + }); + + it("should prioritize error over completed", () => { + host.completed = true; + host.error = true; + fixture.detectChanges(); + + expect(getItem().classList).toContain( + "tedi-horizontal-stepper-item--error", + ); + expect(getItem().classList).not.toContain( + "tedi-horizontal-stepper-item--completed", + ); + + const icon = fixture.nativeElement.querySelector("tedi-icon"); + expect(icon.textContent).toContain("exclamation"); + }); + + it("should show step number for default type", () => { + const number = fixture.nativeElement.querySelector( + ".tedi-horizontal-stepper-item__number", + ); + expect(number).toBeTruthy(); + }); + + it("should not show step number for completed type", () => { + host.completed = true; + fixture.detectChanges(); + + const number = fixture.nativeElement.querySelector( + ".tedi-horizontal-stepper-item__number", + ); + expect(number).toBeFalsy(); + }); + + it("should emit stepSelect on click", () => { + getButton().click(); + fixture.detectChanges(); + + expect(host.onStepSelect).toHaveBeenCalled(); + }); + + it("should render a button element", () => { + expect(getButton().tagName).toBe("BUTTON"); + expect(getButton().getAttribute("type")).toBe("button"); + }); +}); diff --git a/tedi/components/navigation/horizontal-stepper/horizontal-stepper-item/horizontal-stepper-item.component.ts b/tedi/components/navigation/horizontal-stepper/horizontal-stepper-item/horizontal-stepper-item.component.ts new file mode 100644 index 000000000..2f4b2e804 --- /dev/null +++ b/tedi/components/navigation/horizontal-stepper/horizontal-stepper-item/horizontal-stepper-item.component.ts @@ -0,0 +1,39 @@ +import { + booleanAttribute, + ChangeDetectionStrategy, + Component, + input, + output, + signal, + ViewEncapsulation, +} from "@angular/core"; +import { IconComponent } from "../../../base/icon/icon.component"; +import { TediTranslationPipe } from "../../../../services/translation/translation.pipe"; + +@Component({ + selector: "tedi-horizontal-stepper-item", + imports: [IconComponent, TediTranslationPipe], + templateUrl: "./horizontal-stepper-item.component.html", + styleUrl: "./horizontal-stepper-item.component.scss", + encapsulation: ViewEncapsulation.None, + changeDetection: ChangeDetectionStrategy.OnPush, + host: { + "[class.tedi-horizontal-stepper-item]": "true", + "[class.tedi-horizontal-stepper-item--selected]": "selected()", + "[class.tedi-horizontal-stepper-item--completed]": + "completed() && !error()", + "[class.tedi-horizontal-stepper-item--error]": "error()", + }, +}) +export class HorizontalStepperItemComponent { + label = input.required(); + description = input(); + completed = input(false, { transform: booleanAttribute }); + error = input(false, { transform: booleanAttribute }); + selected = input(false, { transform: booleanAttribute }); + + stepSelect = output(); + + /** @internal Set by parent HorizontalStepperComponent */ + _stepNumber = signal(0); +} diff --git a/tedi/components/navigation/horizontal-stepper/horizontal-stepper-item/index.ts b/tedi/components/navigation/horizontal-stepper/horizontal-stepper-item/index.ts new file mode 100644 index 000000000..cb50d2187 --- /dev/null +++ b/tedi/components/navigation/horizontal-stepper/horizontal-stepper-item/index.ts @@ -0,0 +1 @@ +export * from "./horizontal-stepper-item.component"; diff --git a/tedi/components/navigation/horizontal-stepper/horizontal-stepper.component.html b/tedi/components/navigation/horizontal-stepper/horizontal-stepper.component.html new file mode 100644 index 000000000..40b372640 --- /dev/null +++ b/tedi/components/navigation/horizontal-stepper/horizontal-stepper.component.html @@ -0,0 +1 @@ + diff --git a/tedi/components/navigation/horizontal-stepper/horizontal-stepper.component.scss b/tedi/components/navigation/horizontal-stepper/horizontal-stepper.component.scss new file mode 100644 index 000000000..db7418399 --- /dev/null +++ b/tedi/components/navigation/horizontal-stepper/horizontal-stepper.component.scss @@ -0,0 +1,8 @@ +.tedi-horizontal-stepper { + display: flex; + background-color: var(--stepper-background); + + &--transparent { + background-color: transparent; + } +} diff --git a/tedi/components/navigation/horizontal-stepper/horizontal-stepper.component.spec.ts b/tedi/components/navigation/horizontal-stepper/horizontal-stepper.component.spec.ts new file mode 100644 index 000000000..f956ef88e --- /dev/null +++ b/tedi/components/navigation/horizontal-stepper/horizontal-stepper.component.spec.ts @@ -0,0 +1,127 @@ +import { Component } from "@angular/core"; +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { TediTranslationService } from "../../../services/translation/translation.service"; +import { TEDI_TRANSLATION_DEFAULT_TOKEN } from "../../../tokens/translation.token"; +import { HorizontalStepperComponent } from "./horizontal-stepper.component"; +import { HorizontalStepperItemComponent } from "./horizontal-stepper-item/horizontal-stepper-item.component"; + +class TranslationMock { + translate(key: string) { + return key; + } + + track(key: string) { + return () => key; + } +} + +@Component({ + standalone: true, + imports: [HorizontalStepperComponent, HorizontalStepperItemComponent], + template: ` + + + + + + + `, +}) +class TestHostComponent { + ariaLabel = "Form progress"; + background: "default" | "transparent" = "default"; +} + +describe("HorizontalStepperComponent", () => { + let fixture: ComponentFixture; + let host: TestHostComponent; + + function getStepper(): HTMLElement { + return fixture.nativeElement.querySelector("tedi-horizontal-stepper"); + } + + function getItems(): NodeListOf { + return fixture.nativeElement.querySelectorAll( + "tedi-horizontal-stepper-item", + ); + } + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [TestHostComponent], + providers: [ + { provide: TediTranslationService, useClass: TranslationMock }, + { provide: TEDI_TRANSLATION_DEFAULT_TOKEN, useValue: "et" }, + ], + }); + + fixture = TestBed.createComponent(TestHostComponent); + host = fixture.componentInstance; + fixture.detectChanges(); + }); + + it("should create", () => { + expect(getStepper()).toBeTruthy(); + }); + + it("should have tedi-horizontal-stepper class", () => { + expect(getStepper().classList).toContain("tedi-horizontal-stepper"); + }); + + it("should have navigation role", () => { + expect(getStepper().getAttribute("role")).toBe("navigation"); + }); + + it("should set aria-label", () => { + expect(getStepper().getAttribute("aria-label")).toBe("Form progress"); + }); + + it("should render all step items", () => { + expect(getItems().length).toBe(4); + }); + + it("should assign step numbers to items", () => { + const numbers = fixture.nativeElement.querySelectorAll( + ".tedi-horizontal-stepper-item__number", + ); + // Item 1 is completed (no number), item 2 is selected (shows number) + // Items 3 and 4 are default (show numbers) + // Step 2 = selected, step 3 and 4 = default + expect(numbers.length).toBe(3); + expect(numbers[0].textContent.trim()).toBe("2"); + expect(numbers[1].textContent.trim()).toBe("3"); + expect(numbers[2].textContent.trim()).toBe("4"); + }); + + it("should apply transparent background class", () => { + host.background = "transparent"; + fixture.detectChanges(); + + expect(getStepper().classList).toContain( + "tedi-horizontal-stepper--transparent", + ); + }); + + it("should not apply transparent class by default", () => { + expect(getStepper().classList).not.toContain( + "tedi-horizontal-stepper--transparent", + ); + }); + + it("should apply correct states to items", () => { + const items = getItems(); + + expect(items[0].classList).toContain( + "tedi-horizontal-stepper-item--completed", + ); + expect(items[1].classList).toContain( + "tedi-horizontal-stepper-item--selected", + ); + expect(items[2].classList).not.toContain( + "tedi-horizontal-stepper-item--selected", + ); + expect(items[3].classList).not.toContain( + "tedi-horizontal-stepper-item--completed", + ); + }); +}); diff --git a/tedi/components/navigation/horizontal-stepper/horizontal-stepper.component.ts b/tedi/components/navigation/horizontal-stepper/horizontal-stepper.component.ts new file mode 100644 index 000000000..73ebb72d4 --- /dev/null +++ b/tedi/components/navigation/horizontal-stepper/horizontal-stepper.component.ts @@ -0,0 +1,38 @@ +import { + ChangeDetectionStrategy, + Component, + contentChildren, + effect, + input, + ViewEncapsulation, +} from "@angular/core"; +import { HorizontalStepperItemComponent } from "./horizontal-stepper-item/horizontal-stepper-item.component"; + +export type HorizontalStepperBackground = "default" | "transparent"; + +@Component({ + selector: "tedi-horizontal-stepper", + templateUrl: "./horizontal-stepper.component.html", + styleUrl: "./horizontal-stepper.component.scss", + encapsulation: ViewEncapsulation.None, + changeDetection: ChangeDetectionStrategy.OnPush, + host: { + "[class.tedi-horizontal-stepper]": "true", + "[class.tedi-horizontal-stepper--transparent]": + "background() === 'transparent'", + role: "navigation", + "[attr.aria-label]": "ariaLabel()", + }, +}) +export class HorizontalStepperComponent { + ariaLabel = input(); + background = input("default"); + + items = contentChildren(HorizontalStepperItemComponent); + + private assignStepNumbers = effect(() => { + this.items().forEach((item, index) => { + item._stepNumber.set(index + 1); + }); + }); +} diff --git a/tedi/components/navigation/horizontal-stepper/horizontal-stepper.stories.ts b/tedi/components/navigation/horizontal-stepper/horizontal-stepper.stories.ts new file mode 100644 index 000000000..894e664b4 --- /dev/null +++ b/tedi/components/navigation/horizontal-stepper/horizontal-stepper.stories.ts @@ -0,0 +1,167 @@ +import { Meta, StoryObj, moduleMetadata } from "@storybook/angular"; +import { HorizontalStepperComponent } from "./horizontal-stepper.component"; +import { HorizontalStepperItemComponent } from "./horizontal-stepper-item/horizontal-stepper-item.component"; + +/** + * Figma ↗
+ * A horizontal stepper component for displaying multi-step progress flows. + * Each step can be in default, selected, completed, or error state. + */ +export default { + title: "TEDI-Ready/Components/Navigation/HorizontalStepper", + component: HorizontalStepperComponent, + decorators: [ + moduleMetadata({ + imports: [HorizontalStepperComponent, HorizontalStepperItemComponent], + }), + ], + argTypes: { + ariaLabel: { + control: "text", + description: "Accessible label for the navigation landmark.", + table: { + type: { summary: "string" }, + category: "inputs", + }, + }, + background: { + control: "select", + options: ["default", "transparent"], + description: "Background style of the stepper container.", + table: { + defaultValue: { summary: "default" }, + type: { summary: "'default' | 'transparent'" }, + category: "inputs", + }, + }, + }, +} as Meta; + +type Story = StoryObj; + +export const Default: Story = { + render: (props) => ({ + props, + template: ` + + + + + + + `, + }), + args: { + background: "default", + }, +}; + +export const SecondStep: Story = { + render: (props) => ({ + props, + template: ` + + + + + + + `, + }), + args: { + background: "default", + }, +}; + +export const ThirdStep: Story = { + render: (props) => ({ + props, + template: ` + + + + + + + `, + }), + args: { + background: "default", + }, +}; + +export const WithErrors: Story = { + render: (props) => ({ + props, + template: ` +
+ + + + + + + + + + + + +
+ `, + }), + args: { + background: "default", + }, +}; + +export const WithDescriptions: Story = { + render: (props) => ({ + props, + template: ` +
+ + + + + + + + + + + + + + + + + + + + + + + + +
+ `, + }), + args: { + background: "default", + }, +}; + +export const TransparentBackground: Story = { + render: (props) => ({ + props, + template: ` + + + + + + + `, + }), +}; diff --git a/tedi/components/navigation/horizontal-stepper/index.ts b/tedi/components/navigation/horizontal-stepper/index.ts new file mode 100644 index 000000000..17a879dd9 --- /dev/null +++ b/tedi/components/navigation/horizontal-stepper/index.ts @@ -0,0 +1,2 @@ +export * from "./horizontal-stepper.component"; +export * from "./horizontal-stepper-item"; diff --git a/tedi/components/navigation/index.ts b/tedi/components/navigation/index.ts index b7336cf96..4cae8b5eb 100644 --- a/tedi/components/navigation/index.ts +++ b/tedi/components/navigation/index.ts @@ -1 +1,2 @@ +export * from "./horizontal-stepper"; export * from "./link/link.component"; \ No newline at end of file diff --git a/tedi/services/translation/translations.ts b/tedi/services/translation/translations.ts index 7665e5351..c42513236 100644 --- a/tedi/services/translation/translations.ts +++ b/tedi/services/translation/translations.ts @@ -289,6 +289,14 @@ export const translationsMap = { en: "Not completed", ru: "Не завершено", }, + "stepper.error": { + description: + "Label for screen-reader that this step has errors (visually hidden)", + components: ["HorizontalStepper"], + et: "Viga", + en: "Error", + ru: "Ошибка", + }, "skeleton.loading": { description: "Announced by screen-readers when skeleton is loading", components: ["Skeleton"], From c174dedf15876776b0fc299763da2cfe8986a963 Mon Sep 17 00:00:00 2001 From: m2rt Date: Mon, 27 Apr 2026 14:31:32 +0300 Subject: [PATCH 2/6] feat(horizontal-stepper): fixes from review #405 --- skills/tedi-angular/references/components.md | 20 ++++ .../horizontal-stepper-item.component.scss | 92 +++++++------------ .../horizontal-stepper.component.scss | 38 ++++++++ .../horizontal-stepper.component.spec.ts | 54 ++++++++++- .../horizontal-stepper.component.ts | 12 +++ .../horizontal-stepper.stories.ts | 47 ++++++++++ 6 files changed, 202 insertions(+), 61 deletions(-) diff --git a/skills/tedi-angular/references/components.md b/skills/tedi-angular/references/components.md index 842c2dcfe..a967265f4 100644 --- a/skills/tedi-angular/references/components.md +++ b/skills/tedi-angular/references/components.md @@ -637,6 +637,26 @@ Implements `ControlValueAccessor`. Value type is `T` (single) or `T[]` (multisel Go to page ``` +### HorizontalStepper +**Selector:** `tedi-horizontal-stepper` +**Inputs:** +- `ariaLabel: string` +- `background: "default" | "transparent" = "default"` +- `compact: boolean | "sm" | "md" | "lg" | "xl" = "sm"` — collapse labels to show only indicators plus the selected step's label. `true` = always collapsed; a breakpoint = collapsed below that breakpoint. + +**Sub-component:** `tedi-horizontal-stepper-item` +- `label: string` (required), `description: string` +- `completed`, `error`, `selected` (booleanAttribute inputs) +- `(stepSelect)` — emitted on click + +```html + + + + + +``` + ## Notifications ### Alert diff --git a/tedi/components/navigation/horizontal-stepper/horizontal-stepper-item/horizontal-stepper-item.component.scss b/tedi/components/navigation/horizontal-stepper/horizontal-stepper-item/horizontal-stepper-item.component.scss index 42dc4a195..3678daee4 100644 --- a/tedi/components/navigation/horizontal-stepper/horizontal-stepper-item/horizontal-stepper-item.component.scss +++ b/tedi/components/navigation/horizontal-stepper/horizontal-stepper-item/horizontal-stepper-item.component.scss @@ -1,19 +1,9 @@ -@use "@tedi-design-system/core/bootstrap-utility/breakpoints"; - .tedi-horizontal-stepper-item { $host: &; position: relative; display: flex; - @include breakpoints.media-breakpoint-down(sm) { - flex: 1 0 0; - - &--selected { - flex: 0 0 auto; - } - } - &__step { position: relative; display: flex; @@ -21,8 +11,7 @@ align-items: flex-start; width: auto; min-height: 40px; - padding: var(--stepper-item-horizontal-padding-y) - var(--stepper-item-horizontal-padding-x); + padding: var(--stepper-item-horizontal-padding-y) var(--stepper-item-horizontal-padding-x); font-family: var(--family-default); text-align: left; cursor: pointer; @@ -30,12 +19,6 @@ border: none; border-radius: 0; - @include breakpoints.media-breakpoint-down(sm) { - align-items: center; - justify-content: center; - width: 100%; - } - &:focus-visible { z-index: 2; outline: 2px solid var(--general-focus-outline); @@ -48,11 +31,11 @@ flex-shrink: 0; align-items: center; justify-content: center; - width: 24px; - height: 24px; + width: var(--stepper-item-vertical-step-size-lg); + height: var(--stepper-item-vertical-step-size-lg); background-color: var(--stepper-step-default-bg); border: 1px solid var(--stepper-step-default-border); - border-radius: 9999px; + border-radius: 100%; } &__number { @@ -67,14 +50,6 @@ flex-direction: column; justify-content: center; white-space: nowrap; - - @include breakpoints.media-breakpoint-down(sm) { - display: none; - - #{$host}--selected & { - display: flex; - } - } } &__label { @@ -91,9 +66,6 @@ color: var(--general-text-tertiary); } - // ------------------------------------------------------- - // Default type — hover / active - // ------------------------------------------------------- &:not(#{$host}--selected):not(#{$host}--completed):not(#{$host}--error) { #{$host}__step:hover { #{$host}__indicator { @@ -132,15 +104,15 @@ // Selected // ------------------------------------------------------- &--selected { + --_step-arrow-width: 1rem; + z-index: 1; #{$host}__step { - padding-right: calc(var(--stepper-item-horizontal-padding-x) + 16px); + padding-right: calc(var(--stepper-item-horizontal-padding-x) + var(--_step-arrow-width)); cursor: default; - background-color: var( - --stepper-item-horizontal-type-selected-bg-default - ); - clip-path: polygon(0 0, calc(100% - 16px) 0, 100% 50%, calc(100% - 16px) 100%, 0 100%); + background-color: var(--stepper-item-horizontal-type-selected-bg-default); + clip-path: polygon(0 0, calc(100% - var(--_step-arrow-width)) 0, 100% 50%, calc(100% - var(--_step-arrow-width)) 100%, 0 100%); } #{$host}__indicator { @@ -164,14 +136,9 @@ } } - // ------------------------------------------------------- - // Completed - // ------------------------------------------------------- &--completed { #{$host}__step { - background-color: var( - --stepper-item-horizontal-type-completed-bg-default - ); + background-color: var(--stepper-item-horizontal-type-completed-bg-default); } #{$host}__indicator { @@ -183,35 +150,38 @@ color: var(--stepper-item-horizontal-text-completed-default); } + #{$host}__description { + color: var(--stepper-item-horizontal-text-completed-default); + } + #{$host}__step:hover { #{$host}__indicator { background-color: var(--stepper-step-completed-bg-hover); } - #{$host}__label { + #{$host}__label, + #{$host}__description { color: var(--stepper-item-horizontal-text-completed-hover); } } #{$host}__step:active { - background-color: var( - --stepper-item-horizontal-type-completed-bg-active - ); + background-color: var(--stepper-item-horizontal-type-completed-bg-active); #{$host}__indicator { - background-color: var(--stepper-step-completed-bg-active); + background-color: var(--stepper-step-completed-bg-hover); + } + + #{$host}__label, + #{$host}__description { + color: var(--stepper-item-horizontal-text-completed-hover); } } } - // ------------------------------------------------------- - // Error - // ------------------------------------------------------- &--error { #{$host}__step { - background-color: var( - --stepper-item-horizontal-type-danger-bg-default - ); + background-color: var(--stepper-item-horizontal-type-danger-bg-default); } #{$host}__indicator { @@ -232,18 +202,22 @@ background-color: var(--stepper-step-danger-bg-hover); } - #{$host}__label { + #{$host}__label, + #{$host}__description { color: var(--stepper-item-horizontal-text-danger-hover); } } #{$host}__step:active { - background-color: var( - --stepper-item-horizontal-type-danger-bg-active - ); + background-color: var(--stepper-item-horizontal-type-danger-bg-active); #{$host}__indicator { - background-color: var(--stepper-step-danger-bg-active); + background-color: var(--stepper-step-danger-bg-hover); + } + + #{$host}__label, + #{$host}__description { + color: var(--stepper-item-horizontal-text-danger-hover); } } } diff --git a/tedi/components/navigation/horizontal-stepper/horizontal-stepper.component.scss b/tedi/components/navigation/horizontal-stepper/horizontal-stepper.component.scss index db7418399..727d4bf32 100644 --- a/tedi/components/navigation/horizontal-stepper/horizontal-stepper.component.scss +++ b/tedi/components/navigation/horizontal-stepper/horizontal-stepper.component.scss @@ -1,3 +1,29 @@ +@use "@tedi-design-system/core/bootstrap-utility/breakpoints"; + +@mixin compact-styles { + .tedi-horizontal-stepper-item { + flex: 1 0 0; + + &--selected { + flex: 0 0 auto; + } + + .tedi-horizontal-stepper-item__step { + align-items: center; + justify-content: center; + width: 100%; + } + + .tedi-horizontal-stepper-item__content { + display: none; + } + + &--selected .tedi-horizontal-stepper-item__content { + display: flex; + } + } +} + .tedi-horizontal-stepper { display: flex; background-color: var(--stepper-background); @@ -5,4 +31,16 @@ &--transparent { background-color: transparent; } + + &--compact { + @include compact-styles; + } + + @each $bp in (sm, md, lg, xl) { + &--compact-#{$bp} { + @include breakpoints.media-breakpoint-down($bp) { + @include compact-styles; + } + } + } } diff --git a/tedi/components/navigation/horizontal-stepper/horizontal-stepper.component.spec.ts b/tedi/components/navigation/horizontal-stepper/horizontal-stepper.component.spec.ts index f956ef88e..746189c28 100644 --- a/tedi/components/navigation/horizontal-stepper/horizontal-stepper.component.spec.ts +++ b/tedi/components/navigation/horizontal-stepper/horizontal-stepper.component.spec.ts @@ -2,7 +2,10 @@ import { Component } from "@angular/core"; import { ComponentFixture, TestBed } from "@angular/core/testing"; import { TediTranslationService } from "../../../services/translation/translation.service"; import { TEDI_TRANSLATION_DEFAULT_TOKEN } from "../../../tokens/translation.token"; -import { HorizontalStepperComponent } from "./horizontal-stepper.component"; +import { + HorizontalStepperComponent, + HorizontalStepperCompact, +} from "./horizontal-stepper.component"; import { HorizontalStepperItemComponent } from "./horizontal-stepper-item/horizontal-stepper-item.component"; class TranslationMock { @@ -19,7 +22,11 @@ class TranslationMock { standalone: true, imports: [HorizontalStepperComponent, HorizontalStepperItemComponent], template: ` - + @@ -30,6 +37,7 @@ class TranslationMock { class TestHostComponent { ariaLabel = "Form progress"; background: "default" | "transparent" = "default"; + compact: HorizontalStepperCompact = "sm"; } describe("HorizontalStepperComponent", () => { @@ -124,4 +132,46 @@ describe("HorizontalStepperComponent", () => { "tedi-horizontal-stepper-item--completed", ); }); + + describe("compact input", () => { + it("should apply compact-sm class by default", () => { + expect(getStepper().classList).toContain( + "tedi-horizontal-stepper--compact-sm", + ); + expect(getStepper().classList).not.toContain( + "tedi-horizontal-stepper--compact", + ); + }); + + it("should apply compact class when compact is true", () => { + host.compact = true; + fixture.detectChanges(); + + expect(getStepper().classList).toContain( + "tedi-horizontal-stepper--compact", + ); + expect(getStepper().classList).not.toContain( + "tedi-horizontal-stepper--compact-sm", + ); + }); + + it("should not apply any compact class when compact is false", () => { + host.compact = false; + fixture.detectChanges(); + + const classes = Array.from(getStepper().classList); + expect(classes.some((c) => c.startsWith("tedi-horizontal-stepper--compact"))).toBe(false); + }); + + it("should apply the matching breakpoint class", () => { + for (const bp of ["sm", "md", "lg", "xl"] as const) { + host.compact = bp; + fixture.detectChanges(); + + expect(getStepper().classList).toContain( + `tedi-horizontal-stepper--compact-${bp}`, + ); + } + }); + }); }); diff --git a/tedi/components/navigation/horizontal-stepper/horizontal-stepper.component.ts b/tedi/components/navigation/horizontal-stepper/horizontal-stepper.component.ts index 73ebb72d4..c4af1150d 100644 --- a/tedi/components/navigation/horizontal-stepper/horizontal-stepper.component.ts +++ b/tedi/components/navigation/horizontal-stepper/horizontal-stepper.component.ts @@ -9,6 +9,8 @@ import { import { HorizontalStepperItemComponent } from "./horizontal-stepper-item/horizontal-stepper-item.component"; export type HorizontalStepperBackground = "default" | "transparent"; +export type HorizontalStepperBreakpoint = "sm" | "md" | "lg" | "xl"; +export type HorizontalStepperCompact = boolean | HorizontalStepperBreakpoint; @Component({ selector: "tedi-horizontal-stepper", @@ -20,6 +22,11 @@ export type HorizontalStepperBackground = "default" | "transparent"; "[class.tedi-horizontal-stepper]": "true", "[class.tedi-horizontal-stepper--transparent]": "background() === 'transparent'", + "[class.tedi-horizontal-stepper--compact]": "compact() === true", + "[class.tedi-horizontal-stepper--compact-sm]": "compact() === 'sm'", + "[class.tedi-horizontal-stepper--compact-md]": "compact() === 'md'", + "[class.tedi-horizontal-stepper--compact-lg]": "compact() === 'lg'", + "[class.tedi-horizontal-stepper--compact-xl]": "compact() === 'xl'", role: "navigation", "[attr.aria-label]": "ariaLabel()", }, @@ -27,6 +34,11 @@ export type HorizontalStepperBackground = "default" | "transparent"; export class HorizontalStepperComponent { ariaLabel = input(); background = input("default"); + /** + * Collapse labels so only indicators plus the selected step's label are visible. + * `true` — always collapsed. A breakpoint (`'sm'`, `'md'`, `'lg'`, `'xl'`) — collapsed below that breakpoint. @default 'sm' + */ + compact = input("sm"); items = contentChildren(HorizontalStepperItemComponent); diff --git a/tedi/components/navigation/horizontal-stepper/horizontal-stepper.stories.ts b/tedi/components/navigation/horizontal-stepper/horizontal-stepper.stories.ts index 894e664b4..1f3096b67 100644 --- a/tedi/components/navigation/horizontal-stepper/horizontal-stepper.stories.ts +++ b/tedi/components/navigation/horizontal-stepper/horizontal-stepper.stories.ts @@ -34,6 +34,17 @@ export default { category: "inputs", }, }, + compact: { + control: "select", + options: [true, false, "sm", "md", "lg", "xl"], + description: + "Collapse labels (show only indicators + selected step's label). `true` = always; a breakpoint string = collapse below that breakpoint.", + table: { + defaultValue: { summary: "'sm'" }, + type: { summary: "boolean | 'sm' | 'md' | 'lg' | 'xl'" }, + category: "inputs", + }, + }, }, } as Meta; @@ -165,3 +176,39 @@ export const TransparentBackground: Story = { `, }), }; + +/** + * Always collapsed — only indicators plus the selected step's label are shown, + * regardless of viewport width. + */ +export const AlwaysCompact: Story = { + render: (props) => ({ + props, + template: ` + + + + + + + `, + }), +}; + +/** + * Collapses at the `md` breakpoint — useful when labels with descriptions + * would overflow on narrower desktops. + */ +export const CompactAtMdBreakpoint: Story = { + render: (props) => ({ + props, + template: ` + + + + + + + `, + }), +}; From 1bafb7152049d0c797d228f86047fb6a3aa6d15f Mon Sep 17 00:00:00 2001 From: m2rt Date: Mon, 11 May 2026 15:34:48 +0300 Subject: [PATCH 3/6] feat(horizontal-stepper): changes from design review #405 --- package-lock.json | 8 +- package.json | 2 +- .../horizontal-stepper-item.component.html | 3 +- .../horizontal-stepper-item.component.scss | 26 +-- .../horizontal-stepper-item.component.spec.ts | 32 ++++ .../horizontal-stepper-item.component.ts | 16 ++ .../horizontal-stepper.stories.ts | 164 ++++++++++++++++++ 7 files changed, 233 insertions(+), 18 deletions(-) diff --git a/package-lock.json b/package-lock.json index 2dc33f150..0b4a9ee59 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,7 +8,7 @@ "name": "@tedi-design-system/angular", "version": "0.0.0-semantic-version", "dependencies": { - "@tedi-design-system/core": "^6.0.1" + "@tedi-design-system/core": "^6.1.0" }, "devDependencies": { "@angular-devkit/core": "19.2.15", @@ -10205,9 +10205,9 @@ } }, "node_modules/@tedi-design-system/core": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/@tedi-design-system/core/-/core-6.0.1.tgz", - "integrity": "sha512-SgWbcIofn/LSzGbHYPYZD7i3PPdeL/qTaq99QT+RY6i9ISYViHQcrtNDiLZogx5KrREl/mQcrl3sLZNwmU6bDg==", + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@tedi-design-system/core/-/core-6.1.0.tgz", + "integrity": "sha512-esBlv4lhEi6MBM6x/3CBDiU8M+WGNRBbCEznV3DFbLAg7CJjtdjjyhqRvCwo6pN0mC8g4AcWrwsQ1th3GQueng==", "engines": { "node": ">=24.0.0", "npm": ">=11.0.0" diff --git a/package.json b/package.json index 8b1585494..c0ede82e1 100644 --- a/package.json +++ b/package.json @@ -32,7 +32,7 @@ "ngx-float-ui": "^19.0.1 || ^20.0.0 || ^21.0.0" }, "dependencies": { - "@tedi-design-system/core": "^6.0.1" + "@tedi-design-system/core": "^6.1.0" }, "devDependencies": { "@angular-devkit/core": "19.2.15", diff --git a/tedi/components/navigation/horizontal-stepper/horizontal-stepper-item/horizontal-stepper-item.component.html b/tedi/components/navigation/horizontal-stepper/horizontal-stepper-item/horizontal-stepper-item.component.html index 7b2b413ff..ec0632c2a 100644 --- a/tedi/components/navigation/horizontal-stepper/horizontal-stepper-item/horizontal-stepper-item.component.html +++ b/tedi/components/navigation/horizontal-stepper/horizontal-stepper-item/horizontal-stepper-item.component.html @@ -1,8 +1,9 @@ + + + + `, +}) +class ExternalNavigationDemoComponent { + steps = STEPS; + current = signal(0); + + back(): void { + this.current.update((s) => Math.max(0, s - 1)); + } + + next(): void { + this.current.update((s) => Math.min(this.steps.length - 1, s + 1)); + } +} + /** * Figma ↗
* A horizontal stepper component for displaying multi-step progress flows. @@ -212,3 +279,100 @@ export const CompactAtMdBreakpoint: Story = { `, }), }; + +/** + * Validation runs at the end of the form — every step is reachable via the + * header. Each item listens to `stepSelect` and updates the active step. + * Past steps render as `completed`, future steps stay default. + */ +export const ClickToNavigate: Story = { + render: () => ({ + moduleMetadata: { imports: [StepClickNavigationDemoComponent] }, + template: ``, + }), + parameters: { + docs: { + source: { + type: "code", + language: "ts", + code: `@Component({ + imports: [HorizontalStepperComponent, HorizontalStepperItemComponent], + template: \` + + @for (label of steps; track label; let i = $index) { + + } + + \`, +}) +export class FormWizardComponent { + steps = ["Kutse", "Tahteavaldus", "Geenianalüüs", "Vastus"]; + current = signal(1); +}`, + }, + }, + }, +}; + +/** + * Step-by-step validation — the user advances with `Edasi`/`Tagasi`. + * Past steps render as `completed` and are clickable for back-navigation; + * future steps are `disabled` so the user can't skip ahead from the header. + */ +export const ExternalNavigation: Story = { + render: () => ({ + moduleMetadata: { imports: [ExternalNavigationDemoComponent] }, + template: ``, + }), + parameters: { + docs: { + source: { + type: "code", + language: "ts", + code: `@Component({ + imports: [ + HorizontalStepperComponent, + HorizontalStepperItemComponent, + ButtonComponent, + ], + template: \` + + @for (label of steps; track label; let i = $index) { + + } + + + + \`, +}) +export class FormWizardComponent { + steps = ["Kutse", "Tahteavaldus", "Geenianalüüs", "Vastus"]; + current = signal(0); + + back(): void { + this.current.update((s) => Math.max(0, s - 1)); + } + + next(): void { + this.current.update((s) => Math.min(this.steps.length - 1, s + 1)); + } +}`, + }, + }, + }, +}; From be383363c27afd63639c8464d8919534c7505184 Mon Sep 17 00:00:00 2001 From: m2rt Date: Thu, 14 May 2026 10:17:48 +0300 Subject: [PATCH 4/6] feat(horizontal-stepper): updated stories, breakpoint improvement, modal bp refacto #405 --- .../horizontal-stepper.component.scss | 2 +- .../horizontal-stepper.component.spec.ts | 10 +- .../horizontal-stepper.component.ts | 8 +- .../horizontal-stepper.stories.ts | 100 ++++++++++++------ .../overlay/modal/modal.component.scss | 2 +- .../components/overlay/modal/modal.stories.ts | 8 +- tedi/components/overlay/modal/modal.types.ts | 5 +- .../services/breakpoint/breakpoint.service.ts | 9 ++ 8 files changed, 92 insertions(+), 52 deletions(-) diff --git a/tedi/components/navigation/horizontal-stepper/horizontal-stepper.component.scss b/tedi/components/navigation/horizontal-stepper/horizontal-stepper.component.scss index 727d4bf32..4ad8b11b6 100644 --- a/tedi/components/navigation/horizontal-stepper/horizontal-stepper.component.scss +++ b/tedi/components/navigation/horizontal-stepper/horizontal-stepper.component.scss @@ -36,7 +36,7 @@ @include compact-styles; } - @each $bp in (sm, md, lg, xl) { + @each $bp in (sm, md, lg, xl, xxl) { &--compact-#{$bp} { @include breakpoints.media-breakpoint-down($bp) { @include compact-styles; diff --git a/tedi/components/navigation/horizontal-stepper/horizontal-stepper.component.spec.ts b/tedi/components/navigation/horizontal-stepper/horizontal-stepper.component.spec.ts index 746189c28..28835ad7e 100644 --- a/tedi/components/navigation/horizontal-stepper/horizontal-stepper.component.spec.ts +++ b/tedi/components/navigation/horizontal-stepper/horizontal-stepper.component.spec.ts @@ -2,11 +2,9 @@ import { Component } from "@angular/core"; import { ComponentFixture, TestBed } from "@angular/core/testing"; import { TediTranslationService } from "../../../services/translation/translation.service"; import { TEDI_TRANSLATION_DEFAULT_TOKEN } from "../../../tokens/translation.token"; -import { - HorizontalStepperComponent, - HorizontalStepperCompact, -} from "./horizontal-stepper.component"; +import { HorizontalStepperComponent } from "./horizontal-stepper.component"; import { HorizontalStepperItemComponent } from "./horizontal-stepper-item/horizontal-stepper-item.component"; +import { BreakpointFlag } from "../../../services/breakpoint/breakpoint.service"; class TranslationMock { translate(key: string) { @@ -37,7 +35,7 @@ class TranslationMock { class TestHostComponent { ariaLabel = "Form progress"; background: "default" | "transparent" = "default"; - compact: HorizontalStepperCompact = "sm"; + compact: BreakpointFlag = "sm"; } describe("HorizontalStepperComponent", () => { @@ -164,7 +162,7 @@ describe("HorizontalStepperComponent", () => { }); it("should apply the matching breakpoint class", () => { - for (const bp of ["sm", "md", "lg", "xl"] as const) { + for (const bp of ["sm", "md", "lg", "xl", "xxl"] as const) { host.compact = bp; fixture.detectChanges(); diff --git a/tedi/components/navigation/horizontal-stepper/horizontal-stepper.component.ts b/tedi/components/navigation/horizontal-stepper/horizontal-stepper.component.ts index c4af1150d..2194c02ea 100644 --- a/tedi/components/navigation/horizontal-stepper/horizontal-stepper.component.ts +++ b/tedi/components/navigation/horizontal-stepper/horizontal-stepper.component.ts @@ -7,10 +7,9 @@ import { ViewEncapsulation, } from "@angular/core"; import { HorizontalStepperItemComponent } from "./horizontal-stepper-item/horizontal-stepper-item.component"; +import { BreakpointFlag } from "../../../services/breakpoint/breakpoint.service"; export type HorizontalStepperBackground = "default" | "transparent"; -export type HorizontalStepperBreakpoint = "sm" | "md" | "lg" | "xl"; -export type HorizontalStepperCompact = boolean | HorizontalStepperBreakpoint; @Component({ selector: "tedi-horizontal-stepper", @@ -27,6 +26,7 @@ export type HorizontalStepperCompact = boolean | HorizontalStepperBreakpoint; "[class.tedi-horizontal-stepper--compact-md]": "compact() === 'md'", "[class.tedi-horizontal-stepper--compact-lg]": "compact() === 'lg'", "[class.tedi-horizontal-stepper--compact-xl]": "compact() === 'xl'", + "[class.tedi-horizontal-stepper--compact-xxl]": "compact() === 'xxl'", role: "navigation", "[attr.aria-label]": "ariaLabel()", }, @@ -36,9 +36,9 @@ export class HorizontalStepperComponent { background = input("default"); /** * Collapse labels so only indicators plus the selected step's label are visible. - * `true` — always collapsed. A breakpoint (`'sm'`, `'md'`, `'lg'`, `'xl'`) — collapsed below that breakpoint. @default 'sm' + * `true` — always collapsed. A breakpoint (`'sm'`, `'md'`, `'lg'`, `'xl'`, `'xxl'`) — collapsed below that breakpoint. @default 'sm' */ - compact = input("sm"); + compact = input("sm"); items = contentChildren(HorizontalStepperItemComponent); diff --git a/tedi/components/navigation/horizontal-stepper/horizontal-stepper.stories.ts b/tedi/components/navigation/horizontal-stepper/horizontal-stepper.stories.ts index c8d051657..e3eb26d70 100644 --- a/tedi/components/navigation/horizontal-stepper/horizontal-stepper.stories.ts +++ b/tedi/components/navigation/horizontal-stepper/horizontal-stepper.stories.ts @@ -28,6 +28,31 @@ class StepClickNavigationDemoComponent { current = signal(1); } +@Component({ + selector: "story-compact-navigation", + standalone: true, + imports: [HorizontalStepperComponent, HorizontalStepperItemComponent], + template: ` +
+ + @for (label of steps; track label; let i = $index) { + + } + +
+ `, +}) +class CompactNavigationDemoComponent { + steps = STEPS; + current = signal(1); +} + @Component({ selector: "story-external-navigation", standalone: true, @@ -103,12 +128,12 @@ export default { }, compact: { control: "select", - options: [true, false, "sm", "md", "lg", "xl"], + options: [true, false, "sm", "md", "lg", "xl", "xxl"], description: "Collapse labels (show only indicators + selected step's label). `true` = always; a breakpoint string = collapse below that breakpoint.", table: { defaultValue: { summary: "'sm'" }, - type: { summary: "boolean | 'sm' | 'md' | 'lg' | 'xl'" }, + type: { summary: "boolean | 'sm' | 'md' | 'lg' | 'xl' | 'xxl'" }, category: "inputs", }, }, @@ -121,7 +146,7 @@ export const Default: Story = { render: (props) => ({ props, template: ` - + @@ -130,7 +155,9 @@ export const Default: Story = { `, }), args: { + ariaLabel: "Form progress", background: "default", + compact: "sm", }, }; @@ -245,39 +272,44 @@ export const TransparentBackground: Story = { }; /** - * Always collapsed — only indicators plus the selected step's label are shown, - * regardless of viewport width. - */ -export const AlwaysCompact: Story = { - render: (props) => ({ - props, - template: ` - - - - - - - `, - }), -}; - -/** - * Collapses at the `md` breakpoint — useful when labels with descriptions - * would overflow on narrower desktops. + * Collapsed — only indicators plus the selected step's label are shown. + * Use `[compact]="true"` for always-on, or pass a breakpoint (e.g. `compact="md"`) + * to collapse only below that viewport width. Each indicator is clickable so the + * user can jump between steps. */ -export const CompactAtMdBreakpoint: Story = { - render: (props) => ({ - props, - template: ` - - - - - - - `, +export const Compact: Story = { + render: () => ({ + moduleMetadata: { imports: [CompactNavigationDemoComponent] }, + template: ``, }), + parameters: { + docs: { + source: { + type: "code", + language: "ts", + code: `@Component({ + imports: [HorizontalStepperComponent, HorizontalStepperItemComponent], + template: \` + + @for (label of steps; track label; let i = $index) { + + } + + \`, +}) +export class FormWizardComponent { + steps = ["Kutse", "Tahteavaldus", "Geenianalüüs", "Vastus"]; + current = signal(1); +}`, + }, + }, + }, }; /** diff --git a/tedi/components/overlay/modal/modal.component.scss b/tedi/components/overlay/modal/modal.component.scss index 3111f2877..6888a9aee 100644 --- a/tedi/components/overlay/modal/modal.component.scss +++ b/tedi/components/overlay/modal/modal.component.scss @@ -323,7 +323,7 @@ $modal-widths: (xs, sm, md, lg, xl); } } - @each $bp in (sm, md, lg, xl) { + @each $bp in (sm, md, lg, xl, xxl) { &--fullscreen-#{$bp} { @include breakpoints.media-breakpoint-down($bp) { --_tedi-modal-max-width: 100vw; diff --git a/tedi/components/overlay/modal/modal.stories.ts b/tedi/components/overlay/modal/modal.stories.ts index 2123b5358..76b55296d 100644 --- a/tedi/components/overlay/modal/modal.stories.ts +++ b/tedi/components/overlay/modal/modal.stories.ts @@ -6,7 +6,7 @@ import { ModalContentComponent } from "./modal-content/modal-content.component"; import { ModalFooterComponent } from "./modal-footer/modal-footer.component"; import { ModalService } from "./modal.service"; import { ModalRef } from "./modal-ref"; -import { MODAL_DATA } from "./modal.types"; +import { MODAL_DATA, ModalFullscreen } from "./modal.types"; import { ButtonComponent } from "../../buttons/button/button.component"; import { LabelComponent } from "../../form/label/label.component"; import { IconComponent } from "../../base/icon/icon.component"; @@ -496,7 +496,7 @@ export default { description: "Scroll behavior when content overflows. `'content'` scrolls inside the modal, `'page'` scrolls the full overlay.", }, fullscreen: { - table: { type: { summary: "boolean | 'sm' | 'md' | 'lg' | 'xl'" }, defaultValue: { summary: "false" }, category: "ModalConfig" }, + table: { type: { summary: "boolean | 'sm' | 'md' | 'lg' | 'xl' | 'xxl'" }, defaultValue: { summary: "false" }, category: "ModalConfig" }, description: "Fullscreen mode. `true` = always fullscreen. A breakpoint string (e.g. `'md'`) makes the modal fullscreen below that breakpoint.", }, closeOnBackdropClick: { @@ -544,7 +544,7 @@ export const Default: StoryObj = { maxWidth: { control: "text" }, position: { control: "select", options: ["center", "top", "left", "right"] }, scrollBehavior: { control: "select", options: ["content", "page"] }, - fullscreen: { control: "text", description: "Set `true` for always fullscreen or a breakpoint string (`sm`, `md`, `lg`, `xl`)." }, + fullscreen: { control: "text", description: "Set `true` for always fullscreen or a breakpoint string (`sm`, `md`, `lg`, `xl`, `xxl`)." }, closeOnBackdropClick: { control: "boolean" }, closeOnEscape: { control: "boolean" }, showClose: { control: "boolean" }, @@ -635,7 +635,7 @@ class MyModalContent { maxWidth: this.maxWidth || undefined, position: this.position as "center" | "top" | "left" | "right", scrollBehavior: this.scrollBehavior as "content" | "page", - fullscreen: this.parseFullscreen() as boolean | "sm" | "md" | "lg" | "xl", + fullscreen: this.parseFullscreen() as ModalFullscreen, closeOnBackdropClick: this.closeOnBackdropClick, closeOnEscape: this.closeOnEscape, }); diff --git a/tedi/components/overlay/modal/modal.types.ts b/tedi/components/overlay/modal/modal.types.ts index 69906b8f1..e790b54e3 100644 --- a/tedi/components/overlay/modal/modal.types.ts +++ b/tedi/components/overlay/modal/modal.types.ts @@ -1,11 +1,12 @@ import { InjectionToken } from "@angular/core"; +import { BreakpointFlag } from "../../../services/breakpoint/breakpoint.service"; export type ModalSize = "default" | "small"; export type ModalWidthPreset = "xs" | "sm" | "md" | "lg" | "xl"; export type ModalWidth = ModalWidthPreset | (string & {}); export type ModalPosition = "center" | "top" | "left" | "right"; export type ModalScrollBehavior = "content" | "page"; -export type ModalFullscreen = boolean | "sm" | "md" | "lg" | "xl"; +export type ModalFullscreen = BreakpointFlag; export interface ModalConfig { /** Data to pass to the modal content component. Accessible via `inject(MODAL_DATA)`. */ @@ -24,7 +25,7 @@ export interface ModalConfig { closeOnEscape?: boolean; /** Whether to show a close button in the header. @default true */ showClose?: boolean; - /** Fullscreen mode. `true` = always fullscreen, `'sm'`/`'md'`/etc = fullscreen below that breakpoint. @default false */ + /** Fullscreen mode. `true` = always fullscreen, a breakpoint (`'sm'`, `'md'`, `'lg'`, `'xl'`, `'xxl'`) = fullscreen below that breakpoint. @default false */ fullscreen?: ModalFullscreen; /** Max-width cap (e.g. '75%', '60vw'). Overrides the default 95vw limit. */ maxWidth?: string; diff --git a/tedi/services/breakpoint/breakpoint.service.ts b/tedi/services/breakpoint/breakpoint.service.ts index 5abdf65a5..23624df08 100644 --- a/tedi/services/breakpoint/breakpoint.service.ts +++ b/tedi/services/breakpoint/breakpoint.service.ts @@ -32,6 +32,15 @@ export type BreakpointObject = { xs: T } & Partial< >; export type BreakpointInput = T | BreakpointObject; +/** + * Flag that toggles a feature on/off, optionally only below a breakpoint. + * `true` — always on. `false` — always off. A breakpoint name — on below that breakpoint. + * + * `'xs'` is intentionally excluded: "below xs" has no meaningful viewport and would + * be a confusing duplicate of `true`. Use `true` for always-on. + */ +export type BreakpointFlag = boolean | Exclude; + @Injectable({ providedIn: "root", }) From 88cd48659dab3244433d2e2d9d1bec2d4828583b Mon Sep 17 00:00:00 2001 From: m2rt Date: Thu, 14 May 2026 10:21:38 +0300 Subject: [PATCH 5/6] feat(horizontal-stepper): code review fixes #405 --- .../horizontal-stepper-item.component.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tedi/components/navigation/horizontal-stepper/horizontal-stepper-item/horizontal-stepper-item.component.ts b/tedi/components/navigation/horizontal-stepper/horizontal-stepper-item/horizontal-stepper-item.component.ts index cf0e9d2aa..5ec83a714 100644 --- a/tedi/components/navigation/horizontal-stepper/horizontal-stepper-item/horizontal-stepper-item.component.ts +++ b/tedi/components/navigation/horizontal-stepper/horizontal-stepper-item/horizontal-stepper-item.component.ts @@ -49,7 +49,7 @@ export class HorizontalStepperItemComponent { _stepNumber = signal(0); protected onClick(): void { - if (this.selected()) return; + if (this.selected() || this.disabled()) return; this.stepSelect.emit(); } } From 44eaf536518394f4870a370749c93a1c8fa4a03b Mon Sep 17 00:00:00 2001 From: m2rt Date: Thu, 14 May 2026 10:30:09 +0300 Subject: [PATCH 6/6] chore: updated modal and horizontal-stepper docs #405 --- skills/tedi-angular/references/components.md | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/skills/tedi-angular/references/components.md b/skills/tedi-angular/references/components.md index a967265f4..2b533573c 100644 --- a/skills/tedi-angular/references/components.md +++ b/skills/tedi-angular/references/components.md @@ -642,7 +642,7 @@ Implements `ControlValueAccessor`. Value type is `T` (single) or `T[]` (multisel **Inputs:** - `ariaLabel: string` - `background: "default" | "transparent" = "default"` -- `compact: boolean | "sm" | "md" | "lg" | "xl" = "sm"` — collapse labels to show only indicators plus the selected step's label. `true` = always collapsed; a breakpoint = collapsed below that breakpoint. +- `compact: boolean | "sm" | "md" | "lg" | "xl" | "xxl" = "sm"` — collapse labels to show only indicators plus the selected step's label. `true` = always collapsed; a breakpoint = collapsed below that breakpoint. **Sub-component:** `tedi-horizontal-stepper-item` - `label: string` (required), `description: string` @@ -720,8 +720,9 @@ openModal() { size: 'default', // 'default' | 'small' position: 'center', // 'center' | 'top' | 'left' | 'right' closeOnBackdropClick: true, + closeOnEscape: true, scrollBehavior: 'content', // 'content' | 'page' - mobileFullscreen: false, + fullscreen: false, // true | 'sm' | 'md' | 'lg' | 'xl' | 'xxl' | false }); ref.closed.subscribe(result => console.log(result)); @@ -731,11 +732,15 @@ openModal() { **ModalConfig inputs:** - `data: unknown` — injected via `MODAL_DATA` token - `width: ModalWidth = "sm"` — preset (`xs`-`xl`) or custom CSS value (`"80%"`, `"600px"`) +- `maxWidth: string` — max-width cap (e.g. `"75%"`, `"60vw"`). Overrides the default 95vw limit. - `size: ModalSize = "default"` — `"default"` or `"small"` - `position: ModalPosition = "center"` — `"center"`, `"top"`, `"left"`, `"right"` - `closeOnBackdropClick: boolean = true` +- `closeOnEscape: boolean = true` - `scrollBehavior: "content" | "page" = "content"` -- `mobileFullscreen: boolean = false` +- `fullscreen: boolean | "sm" | "md" | "lg" | "xl" | "xxl" = false` — `true` = always fullscreen; a breakpoint = fullscreen below that breakpoint. +- `ariaLabel: string` — ARIA label for the dialog. +- `ariaLabelledBy: string` — ID of the element that labels the dialog. **ModalRef methods/properties:** - `close(result?: R)` — close with optional result