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/skills/tedi-angular/references/components.md b/skills/tedi-angular/references/components.md index 842c2dcfe..2b533573c 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" | "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` +- `completed`, `error`, `selected` (booleanAttribute inputs) +- `(stepSelect)` — emitted on click + +```html + + + + + +``` + ## Notifications ### Alert @@ -700,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)); @@ -711,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 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..ec0632c2a --- /dev/null +++ b/tedi/components/navigation/horizontal-stepper/horizontal-stepper-item/horizontal-stepper-item.component.html @@ -0,0 +1,33 @@ + 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..ff58a5671 --- /dev/null +++ b/tedi/components/navigation/horizontal-stepper/horizontal-stepper-item/horizontal-stepper-item.component.scss @@ -0,0 +1,230 @@ +.tedi-horizontal-stepper-item { + $host: &; + + position: relative; + display: flex; + + &__step { + position: relative; + display: flex; + gap: var(--stepper-item-horizontal-inner-spacing); + align-items: flex-start; + width: auto; + min-height: var(--stepper-item-horizontal-min-height); + 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; + + &:focus-visible { + z-index: 2; + outline: none; + box-shadow: 0 0 0 1px var(--tedi-neutral-100), 0 0 0 3px var(--tedi-primary-400); + } + + &:disabled { + cursor: default; + } + } + + &__indicator { + display: flex; + flex-shrink: 0; + align-items: center; + justify-content: center; + 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: 100%; + } + + &__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; + } + + &__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); + } + + &:not(#{$host}--selected):not(#{$host}--completed):not(#{$host}--error) { + #{$host}__step:hover:not(:disabled) { + #{$host}__indicator { + background-color: var(--stepper-step-default-bg-hover); + border-color: var(--stepper-step-default-border-hover); + } + + #{$host}__number { + color: var(--general-text-white); + } + + #{$host}__label { + color: var(--stepper-item-horizontal-text-hover); + } + } + + #{$host}__step:active:not(:disabled) { + #{$host}__indicator { + background-color: var(--stepper-step-default-bg-active); + border-color: var(--stepper-step-default-border-active); + } + + #{$host}__number { + color: var(--general-text-white); + } + + #{$host}__label { + color: var(--stepper-item-horizontal-text-hover); + } + } + } + + // ------------------------------------------------------- + // Selected + // ------------------------------------------------------- + &--selected { + --_step-arrow-width: 1rem; + + z-index: 1; + + #{$host}__step { + 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% - var(--_step-arrow-width)) 0, 100% 50%, calc(100% - var(--_step-arrow-width)) 100%, 0 100%); + + &:focus-visible { + clip-path: none; + } + } + + #{$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 { + #{$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}__description { + color: var(--stepper-item-horizontal-text-completed-default); + } + + #{$host}__step:hover:not(:disabled) { + #{$host}__indicator { + background-color: var(--stepper-step-completed-bg-hover); + } + + #{$host}__label, + #{$host}__description { + color: var(--stepper-item-horizontal-text-completed-hover); + } + } + + #{$host}__step:active:not(:disabled) { + background-color: var(--stepper-item-horizontal-type-completed-bg-active); + + #{$host}__indicator { + background-color: var(--stepper-step-completed-bg-hover); + } + + #{$host}__label, + #{$host}__description { + color: var(--stepper-item-horizontal-text-completed-hover); + } + } + } + + &--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:not(:disabled) { + #{$host}__indicator { + background-color: var(--stepper-step-danger-bg-hover); + } + + #{$host}__label, + #{$host}__description { + color: var(--stepper-item-horizontal-text-danger-hover); + } + } + + #{$host}__step:active:not(:disabled) { + background-color: var(--stepper-item-horizontal-type-danger-bg-active); + + #{$host}__indicator { + 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-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..17aa82b28 --- /dev/null +++ b/tedi/components/navigation/horizontal-stepper/horizontal-stepper-item/horizontal-stepper-item.component.spec.ts @@ -0,0 +1,228 @@ +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; + disabled = 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 not emit stepSelect when selected", () => { + host.selected = true; + fixture.detectChanges(); + + getButton().click(); + fixture.detectChanges(); + + expect(host.onStepSelect).not.toHaveBeenCalled(); + }); + + it("should apply disabled class and disable the button when disabled", () => { + host.disabled = true; + fixture.detectChanges(); + + expect(getItem().classList).toContain( + "tedi-horizontal-stepper-item--disabled", + ); + expect(getButton().disabled).toBe(true); + }); + + it("should not emit stepSelect when disabled", () => { + host.disabled = true; + fixture.detectChanges(); + + getButton().click(); + fixture.detectChanges(); + + expect(host.onStepSelect).not.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..5ec83a714 --- /dev/null +++ b/tedi/components/navigation/horizontal-stepper/horizontal-stepper-item/horizontal-stepper-item.component.ts @@ -0,0 +1,55 @@ +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()", + "[class.tedi-horizontal-stepper-item--disabled]": "disabled()", + }, +}) +export class HorizontalStepperItemComponent { + label = input.required(); + description = input(); + completed = input(false, { transform: booleanAttribute }); + error = input(false, { transform: booleanAttribute }); + selected = input(false, { transform: booleanAttribute }); + /** + * Prevents the step from being clicked or focused. Use for future steps + * the user shouldn't reach yet (e.g. when validation runs step-by-step). + * Leave completed steps enabled so users can navigate back to them. + */ + disabled = input(false, { transform: booleanAttribute }); + + /** + * Emits when the user clicks the step. Does not emit when the step is + * selected (it's already the current step) or disabled. + */ + stepSelect = output(); + + /** @internal Set by parent HorizontalStepperComponent */ + _stepNumber = signal(0); + + protected onClick(): void { + if (this.selected() || this.disabled()) return; + this.stepSelect.emit(); + } +} 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..4ad8b11b6 --- /dev/null +++ b/tedi/components/navigation/horizontal-stepper/horizontal-stepper.component.scss @@ -0,0 +1,46 @@ +@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); + + &--transparent { + background-color: transparent; + } + + &--compact { + @include compact-styles; + } + + @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 new file mode 100644 index 000000000..28835ad7e --- /dev/null +++ b/tedi/components/navigation/horizontal-stepper/horizontal-stepper.component.spec.ts @@ -0,0 +1,175 @@ +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"; +import { BreakpointFlag } from "../../../services/breakpoint/breakpoint.service"; + +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"; + compact: BreakpointFlag = "sm"; +} + +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", + ); + }); + + 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", "xxl"] 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 new file mode 100644 index 000000000..2194c02ea --- /dev/null +++ b/tedi/components/navigation/horizontal-stepper/horizontal-stepper.component.ts @@ -0,0 +1,50 @@ +import { + ChangeDetectionStrategy, + Component, + contentChildren, + effect, + input, + 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"; + +@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'", + "[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'", + "[class.tedi-horizontal-stepper--compact-xxl]": "compact() === 'xxl'", + role: "navigation", + "[attr.aria-label]": "ariaLabel()", + }, +}) +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'`, `'xxl'`) — collapsed below that breakpoint. @default 'sm' + */ + compact = input("sm"); + + 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..333873af9 --- /dev/null +++ b/tedi/components/navigation/horizontal-stepper/horizontal-stepper.stories.ts @@ -0,0 +1,411 @@ +import { Component, signal } from "@angular/core"; +import { Meta, StoryObj, moduleMetadata } from "@storybook/angular"; +import { ButtonComponent } from "../../buttons/button/button.component"; +import { HorizontalStepperComponent } from "./horizontal-stepper.component"; +import { HorizontalStepperItemComponent } from "./horizontal-stepper-item/horizontal-stepper-item.component"; + +const STEPS = ["Kutse", "Tahteavaldus", "Geenianalüüs", "Vastus"]; + +@Component({ + selector: "story-step-click-navigation", + standalone: true, + imports: [HorizontalStepperComponent, HorizontalStepperItemComponent], + template: ` + + @for (label of steps; track label; let i = $index) { + + } + + `, +}) +class StepClickNavigationDemoComponent { + steps = STEPS; + 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, + imports: [ + HorizontalStepperComponent, + HorizontalStepperItemComponent, + ButtonComponent, + ], + template: ` +
+ + @for (label of steps; track label; let i = $index) { + + } + +
+ + +
+
+ `, +}) +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 ↗
+ * Zeroheight ↗
+ * 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", + }, + }, + compact: { + control: "select", + 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' | 'xxl'" }, + category: "inputs", + }, + }, + }, +} as Meta; + +type Story = StoryObj; + +export const Default: Story = { + render: (props) => ({ + props, + template: ` + + + + + + + `, + }), + args: { + ariaLabel: "Form progress", + background: "default", + compact: "sm", + }, +}; + +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: ` + + + + + + + `, + }), +}; + +/** + * 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 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); +}`, + }, + }, + }, +}; + +/** + * 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)); + } +}`, + }, + }, + }, +}; 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/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", }) 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"],