From ba0da4cba97bc90e960e51ae6785f796c7a12b95 Mon Sep 17 00:00:00 2001 From: m2rt Date: Fri, 16 Jan 2026 17:17:10 +0200 Subject: [PATCH 1/4] feat(toast): toast tedi-ready component #270 Created component, service, stories, tests. Added missing alert tests --- .../alert/alert.component.spec.ts | 223 +++++++- .../notifications/alert/alert.component.ts | 20 +- tedi/components/notifications/index.ts | 1 + .../toast/toast-container.component.spec.ts | 263 +++++++++ .../toast/toast-container.component.ts | 116 ++++ .../notifications/toast/toast.component.html | 24 + .../notifications/toast/toast.component.scss | 235 ++++++++ .../toast/toast.component.spec.ts | 167 ++++++ .../notifications/toast/toast.component.ts | 119 ++++ .../notifications/toast/toast.stories.ts | 512 ++++++++++++++++++ tedi/services/index.ts | 1 + .../toast/toast-announcer.service.spec.ts | 251 +++++++++ .../services/toast/toast-announcer.service.ts | 86 +++ tedi/services/toast/toast.service.spec.ts | 418 ++++++++++++++ tedi/services/toast/toast.service.ts | 294 ++++++++++ 15 files changed, 2727 insertions(+), 3 deletions(-) create mode 100644 tedi/components/notifications/toast/toast-container.component.spec.ts create mode 100644 tedi/components/notifications/toast/toast-container.component.ts create mode 100644 tedi/components/notifications/toast/toast.component.html create mode 100644 tedi/components/notifications/toast/toast.component.scss create mode 100644 tedi/components/notifications/toast/toast.component.spec.ts create mode 100644 tedi/components/notifications/toast/toast.component.ts create mode 100644 tedi/components/notifications/toast/toast.stories.ts create mode 100644 tedi/services/toast/toast-announcer.service.spec.ts create mode 100644 tedi/services/toast/toast-announcer.service.ts create mode 100644 tedi/services/toast/toast.service.spec.ts create mode 100644 tedi/services/toast/toast.service.ts diff --git a/tedi/components/notifications/alert/alert.component.spec.ts b/tedi/components/notifications/alert/alert.component.spec.ts index abb54051a..ba642b3aa 100644 --- a/tedi/components/notifications/alert/alert.component.spec.ts +++ b/tedi/components/notifications/alert/alert.component.spec.ts @@ -1,6 +1,12 @@ -import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { ComponentFixture, TestBed, fakeAsync, tick } from "@angular/core/testing"; import { By } from "@angular/platform-browser"; -import { AlertComponent, AlertRole, AlertType } from "./alert.component"; +import { + AlertComponent, + AlertRole, + AlertType, + AlertTitleType, + AlertVariant, +} from "./alert.component"; import { TEDI_TRANSLATION_DEFAULT_TOKEN } from "../../../tokens/translation.token"; describe("AlertComponent", () => { @@ -109,4 +115,217 @@ describe("AlertComponent", () => { expect((fixture.nativeElement as HTMLElement).style.display).toBe("none"); }); + + describe("title", () => { + it("should display title when provided", () => { + fixture.componentRef.setInput("title", "Test Alert Title"); + fixture.detectChanges(); + + const titleElement = fixture.debugElement.query( + By.css(".tedi-alert__title") + ); + expect(titleElement).toBeTruthy(); + expect(titleElement.nativeElement.textContent).toContain( + "Test Alert Title" + ); + }); + + it("should not display title element when title is not provided", () => { + fixture.componentRef.setInput("title", undefined); + fixture.detectChanges(); + + const titleElement = fixture.debugElement.query( + By.css(".tedi-alert__title") + ); + expect(titleElement).toBeNull(); + }); + }); + + describe("titleElement", () => { + it("should use h2 as default title element", () => { + fixture.componentRef.setInput("title", "Test Title"); + fixture.detectChanges(); + + const h2Element = fixture.debugElement.query( + By.css("h2.tedi-alert__title") + ); + expect(h2Element).toBeTruthy(); + }); + + it("should use specified title element tag", () => { + const titleElements: AlertTitleType[] = [ + "h1", + "h2", + "h3", + "h4", + "h5", + "h6", + "div", + ]; + + for (const tag of titleElements) { + fixture.componentRef.setInput("title", "Test Title"); + fixture.componentRef.setInput("titleElement", tag); + fixture.detectChanges(); + + const titleTag = fixture.debugElement.query( + By.css(`${tag}.tedi-alert__title`) + ); + expect(titleTag).toBeTruthy(); + } + }); + }); + + describe("icon", () => { + it("should display icon in head when title and icon are provided", () => { + fixture.componentRef.setInput("title", "Test Title"); + fixture.componentRef.setInput("icon", "info"); + fixture.detectChanges(); + + const iconElement = fixture.debugElement.query( + By.css(".tedi-alert__head > tedi-icon") + ); + expect(iconElement).toBeTruthy(); + expect(iconElement.nativeElement.textContent).toBe("info"); + }); + + it("should display icon in content when no title but icon is provided", () => { + fixture.componentRef.setInput("icon", "warning"); + fixture.detectChanges(); + + const iconElement = fixture.debugElement.query( + By.css(".tedi-alert__content-icon") + ); + expect(iconElement).toBeTruthy(); + expect(iconElement.nativeElement.textContent).toBe("warning"); + }); + + it("should not display icon when not provided", () => { + fixture.componentRef.setInput("icon", ""); + fixture.detectChanges(); + + const iconElement = fixture.debugElement.query(By.css("tedi-icon")); + expect(iconElement).toBeNull(); + }); + }); + + describe("open", () => { + it("should be visible when open is true", () => { + fixture.componentRef.setInput("open", true); + fixture.detectChanges(); + + expect(element.style.display).toBe("flex"); + }); + + it("should be hidden when open is false", () => { + fixture.componentRef.setInput("open", false); + fixture.detectChanges(); + + expect(element.style.display).toBe("none"); + }); + }); + + describe("closeDelay", () => { + it("should close immediately when closeDelay is 0", () => { + fixture.componentRef.setInput("showClose", true); + fixture.componentRef.setInput("closeDelay", 0); + fixture.detectChanges(); + + const closeButton = fixture.debugElement.query( + By.css(".tedi-alert__close") + ).nativeElement as HTMLButtonElement; + + closeButton.click(); + fixture.detectChanges(); + + expect(element.style.display).toBe("none"); + }); + + it("should delay close when closeDelay is set", fakeAsync(() => { + fixture.componentRef.setInput("showClose", true); + fixture.componentRef.setInput("closeDelay", 300); + fixture.detectChanges(); + + const closeButton = fixture.debugElement.query( + By.css(".tedi-alert__close") + ).nativeElement as HTMLButtonElement; + + closeButton.click(); + fixture.detectChanges(); + + expect(element.style.display).toBe("flex"); + tick(300); + fixture.detectChanges(); + expect(element.style.display).toBe("none"); + })); + }); + + describe("closeClick", () => { + it("should emit closeClick event when close button is clicked", () => { + fixture.componentRef.setInput("showClose", true); + fixture.detectChanges(); + + const closeClickSpy = jest.fn(); + component.closeClick.subscribe(closeClickSpy); + + const closeButton = fixture.debugElement.query( + By.css(".tedi-alert__close") + ).nativeElement as HTMLButtonElement; + + closeButton.click(); + fixture.detectChanges(); + + expect(closeClickSpy).toHaveBeenCalled(); + }); + }); + + describe("aria-label", () => { + it("should set aria-label with type only when no title", () => { + fixture.componentRef.setInput("type", "warning"); + fixture.componentRef.setInput("title", undefined); + fixture.detectChanges(); + + expect(element.getAttribute("aria-label")).toBe("warning alert"); + }); + + it("should set aria-label with type and title when title is provided", () => { + fixture.componentRef.setInput("type", "danger"); + fixture.componentRef.setInput("title", "Error occurred"); + fixture.detectChanges(); + + expect(element.getAttribute("aria-label")).toBe( + "danger alert: Error occurred" + ); + }); + }); + + describe("variant", () => { + it("should apply default variant without extra classes", () => { + fixture.componentRef.setInput("variant", "default"); + fixture.detectChanges(); + + expect(element.classList.contains("tedi-alert")).toBe(true); + expect(element.classList.contains("tedi-alert--global")).toBe(false); + expect(element.classList.contains("tedi-alert--no-side-borders")).toBe( + false + ); + }); + + it("should apply all variant classes correctly", () => { + const variants: AlertVariant[] = ["default", "global", "noSideBorders"]; + + for (const variant of variants) { + fixture.componentRef.setInput("variant", variant); + fixture.detectChanges(); + + if (variant === "global") { + expect(element.classList.contains("tedi-alert--global")).toBe(true); + } else if (variant === "noSideBorders") { + expect( + element.classList.contains("tedi-alert--no-side-borders") + ).toBe(true); + } + } + }); + }); }); diff --git a/tedi/components/notifications/alert/alert.component.ts b/tedi/components/notifications/alert/alert.component.ts index 81843cdc3..e2f497230 100644 --- a/tedi/components/notifications/alert/alert.component.ts +++ b/tedi/components/notifications/alert/alert.component.ts @@ -3,6 +3,7 @@ import { computed, input, model, + output, ChangeDetectionStrategy, ViewEncapsulation, } from "@angular/core"; @@ -78,6 +79,17 @@ export class AlertComponent { */ open = model(true); + /** + * Delay in milliseconds before setting "open" to false when close is triggered. + * @default 0 + */ + closeDelay = input(0); + + /** + * Close click output + */ + readonly closeClick = output(); + getAriaLive = computed(() => { switch (this.role()) { case "alert": @@ -106,6 +118,12 @@ export class AlertComponent { }); handleClose() { - this.open.set(false); + this.closeClick.emit(); + const delay = this.closeDelay(); + if (delay > 0) { + setTimeout(() => this.open.set(false), delay); + } else { + this.open.set(false); + } } } diff --git a/tedi/components/notifications/index.ts b/tedi/components/notifications/index.ts index 5ade4eac2..54eb4d1e4 100644 --- a/tedi/components/notifications/index.ts +++ b/tedi/components/notifications/index.ts @@ -1 +1,2 @@ export * from "./alert/alert.component"; +export * from "./toast/toast.component"; diff --git a/tedi/components/notifications/toast/toast-container.component.spec.ts b/tedi/components/notifications/toast/toast-container.component.spec.ts new file mode 100644 index 000000000..181f585c5 --- /dev/null +++ b/tedi/components/notifications/toast/toast-container.component.spec.ts @@ -0,0 +1,263 @@ +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { signal, Signal, WritableSignal } from "@angular/core"; +import { ToastContainerComponent, ToastItem } from "./toast-container.component"; +import { ToastService } from "../../../services/toast/toast.service"; +import { TEDI_TRANSLATION_DEFAULT_TOKEN } from "../../../tokens/translation.token"; + +describe("ToastContainerComponent", () => { + let component: ToastContainerComponent; + let fixture: ComponentFixture; + let toastsSignal: WritableSignal; + let mockToastService: { + toasts$: Signal; + getToasts: jest.Mock; + close: jest.Mock; + pause: jest.Mock; + resume: jest.Mock; + }; + + const createMockToast = (overrides: Partial = {}): ToastItem => ({ + id: "test-toast-1", + title: "Test Toast", + content: "Test content", + type: "info", + role: "status", + position: "bottom-right", + ...overrides, + }); + + beforeEach(async () => { + toastsSignal = signal([]); + mockToastService = { + toasts$: toastsSignal.asReadonly(), + getToasts: jest.fn().mockImplementation(() => toastsSignal()), + close: jest.fn(), + pause: jest.fn(), + resume: jest.fn(), + }; + + await TestBed.configureTestingModule({ + imports: [ToastContainerComponent], + providers: [ + { provide: ToastService, useValue: mockToastService }, + { provide: TEDI_TRANSLATION_DEFAULT_TOKEN, useValue: "et" }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(ToastContainerComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it("should create", () => { + expect(component).toBeTruthy(); + }); + + it("should have all positions defined", () => { + expect(component.positions).toEqual([ + "top-left", + "top-right", + "bottom-left", + "bottom-right", + ]); + }); + + describe("hasToastsForPosition", () => { + it("should return true when there are toasts for position", () => { + toastsSignal.set([createMockToast({ position: "top-left" })]); + + expect(component.hasToastsForPosition("top-left")).toBe(true); + }); + + it("should return false when there are no toasts for position", () => { + toastsSignal.set([createMockToast({ position: "top-left" })]); + + expect(component.hasToastsForPosition("bottom-right")).toBe(false); + }); + + it("should return false when there are no toasts at all", () => { + toastsSignal.set([]); + + expect(component.hasToastsForPosition("top-left")).toBe(false); + }); + }); + + describe("getToastsForPosition", () => { + it("should return toasts for specific position", () => { + const topLeftToast = createMockToast({ + id: "toast-1", + position: "top-left", + }); + const bottomRightToast = createMockToast({ + id: "toast-2", + position: "bottom-right", + }); + + toastsSignal.set([topLeftToast, bottomRightToast]); + + const result = component.getToastsForPosition("top-left"); + + expect(result).toHaveLength(1); + expect(result[0]).toEqual(topLeftToast); + }); + + it("should return empty array when no toasts for position", () => { + toastsSignal.set([createMockToast({ position: "top-left" })]); + + const result = component.getToastsForPosition("bottom-right"); + + expect(result).toHaveLength(0); + }); + + it("should return multiple toasts for same position", () => { + toastsSignal.set([ + createMockToast({ id: "toast-1", position: "top-left" }), + createMockToast({ id: "toast-2", position: "top-left" }), + createMockToast({ id: "toast-3", position: "top-left" }), + ]); + + const result = component.getToastsForPosition("top-left"); + + expect(result).toHaveLength(3); + }); + }); + + describe("event handlers", () => { + it("should call toastService.close when onClosed is called", () => { + const toastId = "test-toast-id"; + + component.onClosed(toastId); + + expect(mockToastService.close).toHaveBeenCalledWith(toastId); + }); + + it("should call toastService.pause when onMouseEnter is called", () => { + const toastId = "test-toast-id"; + + component.onMouseEnter(toastId); + + expect(mockToastService.pause).toHaveBeenCalledWith(toastId); + }); + + it("should call toastService.resume when onMouseLeave is called", () => { + const toastId = "test-toast-id"; + + component.onMouseLeave(toastId); + + expect(mockToastService.resume).toHaveBeenCalledWith(toastId); + }); + }); + + describe("rendering", () => { + it("should render toast container", () => { + const container = fixture.nativeElement; + expect(container.classList.contains("tedi-toast-container")).toBe(true); + }); + + it("should render position container when toasts exist", () => { + toastsSignal.set([createMockToast({ position: "top-right" })]); + + fixture.detectChanges(); + + const positionContainer = fixture.nativeElement.querySelector( + ".tedi-toast-container__position--top-right" + ); + expect(positionContainer).toBeTruthy(); + }); + + it("should not render position container when no toasts", () => { + toastsSignal.set([]); + + fixture.detectChanges(); + + const positionContainer = fixture.nativeElement.querySelector( + ".tedi-toast-container__position" + ); + expect(positionContainer).toBeFalsy(); + }); + + it("should render toast component for each toast", () => { + toastsSignal.set([ + createMockToast({ id: "toast-1", position: "bottom-right" }), + createMockToast({ id: "toast-2", position: "bottom-right" }), + ]); + + fixture.detectChanges(); + + const toasts = fixture.nativeElement.querySelectorAll("tedi-toast"); + expect(toasts.length).toBe(2); + }); + + it("should pass correct props to toast component", () => { + toastsSignal.set([ + createMockToast({ + title: "Test Title", + type: "success", + icon: "check", + role: "alert", + duration: 5000, + showProgressBar: true, + paused: true, + position: "top-right", + }), + ]); + + fixture.detectChanges(); + + const toast = fixture.nativeElement.querySelector("tedi-toast"); + expect(toast).toBeTruthy(); + }); + + it("should apply exiting class when toast is exiting", () => { + toastsSignal.set([ + createMockToast({ exiting: true, position: "bottom-right" }), + ]); + + fixture.detectChanges(); + + const toast = fixture.nativeElement.querySelector("tedi-toast"); + expect(toast.classList.contains("tedi-toast--exiting")).toBe(true); + }); + + it("should render toast content when provided", () => { + toastsSignal.set([ + createMockToast({ content: "Toast content text", position: "bottom-right" }), + ]); + + fixture.detectChanges(); + + const container = fixture.nativeElement.querySelector( + ".tedi-toast-container__position" + ); + expect(container.textContent).toContain("Toast content text"); + }); + }); + + describe("multiple positions", () => { + it("should render toasts in multiple positions", () => { + toastsSignal.set([ + createMockToast({ id: "toast-1", position: "top-left" }), + createMockToast({ id: "toast-2", position: "top-right" }), + createMockToast({ id: "toast-3", position: "bottom-right" }), + ]); + + fixture.detectChanges(); + + expect( + fixture.nativeElement.querySelector( + ".tedi-toast-container__position--top-left" + ) + ).toBeTruthy(); + expect( + fixture.nativeElement.querySelector( + ".tedi-toast-container__position--top-right" + ) + ).toBeTruthy(); + expect( + fixture.nativeElement.querySelector( + ".tedi-toast-container__position--bottom-right" + ) + ).toBeTruthy(); + }); + }); +}); diff --git a/tedi/components/notifications/toast/toast-container.component.ts b/tedi/components/notifications/toast/toast-container.component.ts new file mode 100644 index 000000000..e1a72bebd --- /dev/null +++ b/tedi/components/notifications/toast/toast-container.component.ts @@ -0,0 +1,116 @@ +import { + Component, + ChangeDetectionStrategy, + ViewEncapsulation, + inject, + computed, +} from "@angular/core"; +import { ToastComponent, ToastPosition, ToastRole } from "./toast.component"; +import { ToastService } from "../../../services/toast/toast.service"; + +export interface ToastItem { + id: string; + title: string; + content?: string; + type?: "info" | "success" | "warning" | "danger"; + icon?: string; + role?: ToastRole; + duration?: number; + showProgressBar?: boolean; + pauseOnHover?: boolean; + paused?: boolean; + exiting?: boolean; + position: ToastPosition; +} + +const POSITIONS: ToastPosition[] = [ + "top-left", + "top-right", + "bottom-left", + "bottom-right", +]; + +/** + * Internal toast container component that renders toast notifications. + * This component is automatically created by ToastService using CDK Overlay. + * + * @internal + */ +@Component({ + selector: "tedi-toast-container", + standalone: true, + imports: [ToastComponent], + template: ` + @for (position of positions; track position) { + @if (toastsByPosition()[position].length > 0) { +
+ @for (toast of toastsByPosition()[position]; track toast.id) { + + @if (toast.content) { + {{ toast.content }} + } + + } +
+ } + } + `, + encapsulation: ViewEncapsulation.None, + changeDetection: ChangeDetectionStrategy.OnPush, + host: { + class: "tedi-toast-container", + }, +}) +export class ToastContainerComponent { + private readonly toastService = inject(ToastService); + + readonly positions = POSITIONS; + + readonly toastsByPosition = computed(() => { + const toasts = this.toastService.toasts$(); + const grouped: Record = { + "top-left": [], + "top-right": [], + "bottom-left": [], + "bottom-right": [], + }; + + for (const toast of toasts) { + grouped[toast.position].push(toast); + } + + return grouped; + }); + + hasToastsForPosition(position: ToastPosition): boolean { + return this.toastService.getToasts().some((t) => t.position === position); + } + + getToastsForPosition(position: ToastPosition): ToastItem[] { + return this.toastService.getToasts().filter((t) => t.position === position); + } + + onClosed(id: string): void { + this.toastService.close(id); + } + + onMouseEnter(id: string): void { + this.toastService.pause(id); + } + + onMouseLeave(id: string): void { + this.toastService.resume(id); + } +} diff --git a/tedi/components/notifications/toast/toast.component.html b/tedi/components/notifications/toast/toast.component.html new file mode 100644 index 000000000..403fb3ba5 --- /dev/null +++ b/tedi/components/notifications/toast/toast.component.html @@ -0,0 +1,24 @@ +
+ + + + @if (showProgressBar() && duration() > 0) { +
+ } +
diff --git a/tedi/components/notifications/toast/toast.component.scss b/tedi/components/notifications/toast/toast.component.scss new file mode 100644 index 000000000..b371d1dda --- /dev/null +++ b/tedi/components/notifications/toast/toast.component.scss @@ -0,0 +1,235 @@ +@use "@tedi-design-system/core/bootstrap-utility/breakpoints"; + +tedi-toast { + display: block; + width: var(--toast-width); + max-width: 100%; +} + +.tedi-toast__wrapper { + position: relative; + box-shadow: 0px 1px 5px 0px var(--tedi-alpha-20, rgba(0, 0, 0, 0.2)); +} + +.tedi-toast__progress { + position: absolute; + bottom: 0; + left: 0; + width: 100%; + height: 4px; + background: var(--toast-progress-color); + transform-origin: left; + animation: toast-progress linear forwards; + + &--success { + --toast-progress-color: var(--alert-default-border-success); + } + + &--info { + --toast-progress-color: var(--alert-default-border-info); + } + + &--danger { + --toast-progress-color: var(--alert-default-border-danger); + } + + &--warning { + --toast-progress-color: var(--alert-default-border-warning); + } + + &--paused { + animation-play-state: paused; + } +} + +.tedi-toast-container { + position: fixed; + inset: 0; + pointer-events: none; + z-index: var(--z-index-toast, 1050); + + &__position { + position: absolute; + display: flex; + flex-direction: column; + gap: var(--dimensions-05); + max-height: calc(100vh - calc(var(--toast-margin-bottom) * 2)); + overflow: hidden; + + >* { + pointer-events: auto; + } + + &--top-left { + top: var(--toast-margin-bottom); + left: var(--toast-margin-left); + align-items: flex-start; + + tedi-toast { + animation: toast-slide-in-left 0.3s ease-out forwards; + } + + tedi-toast.tedi-toast--exiting { + animation: toast-slide-out-left 0.3s ease-out forwards; + } + } + + &--top-right { + top: var(--toast-margin-bottom); + right: var(--toast-margin-right); + align-items: flex-end; + + tedi-toast { + animation: toast-slide-in-right 0.3s ease-out forwards; + } + + tedi-toast.tedi-toast--exiting { + animation: toast-slide-out-right 0.3s ease-out forwards; + } + } + + &--bottom-left { + bottom: var(--toast-margin-bottom); + left: var(--toast-margin-left); + align-items: flex-start; + flex-direction: column-reverse; + + tedi-toast { + animation: toast-slide-in-left 0.3s ease-out forwards; + } + + tedi-toast.tedi-toast--exiting { + animation: toast-slide-out-left 0.3s ease-out forwards; + } + } + + &--bottom-right { + bottom: var(--toast-margin-bottom); + right: var(--toast-margin-right); + align-items: flex-end; + flex-direction: column-reverse; + + tedi-toast { + animation: toast-slide-in-right 0.3s ease-out forwards; + } + + tedi-toast.tedi-toast--exiting { + animation: toast-slide-out-right 0.3s ease-out forwards; + } + } + + // Mobile: center all positions and use full width with padding + @include breakpoints.media-breakpoint-down(sm) { + + &--top-left, + &--top-right, + &--bottom-left, + &--bottom-right { + left: var(--toast-margin-left); + right: var(--toast-margin-right); + transform: none; + align-items: stretch; + + tedi-toast { + width: 100%; + animation: toast-slide-in-right 0.3s ease-out forwards; + } + + tedi-toast.tedi-toast--exiting { + animation: toast-slide-out-right 0.3s ease-out forwards; + } + } + } + } +} + + +@keyframes toast-progress { + from { + transform: scaleX(1); + } + + to { + transform: scaleX(0); + } +} + +@keyframes toast-slide-in-right { + from { + transform: translateX(100%); + } + + to { + transform: translateX(0); + } +} + +@keyframes toast-slide-out-right { + from { + transform: translateX(0); + } + + to { + transform: translateX(100%); + } +} + +@keyframes toast-slide-in-left { + from { + transform: translateX(-100%); + } + + to { + transform: translateX(0); + } +} + +@keyframes toast-slide-out-left { + from { + transform: translateX(0); + } + + to { + transform: translateX(-100%); + } +} + +@keyframes toast-slide-in-down { + from { + transform: translateY(-100%); + } + + to { + transform: translateY(0); + } +} + +@keyframes toast-slide-in-up { + from { + transform: translateY(100%); + } + + to { + transform: translateY(0); + } +} + +@keyframes toast-slide-out-up { + from { + transform: translateY(0); + } + + to { + transform: translateY(-100%); + } +} + +@keyframes toast-slide-out-down { + from { + transform: translateY(0); + } + + to { + transform: translateY(100%); + } +} diff --git a/tedi/components/notifications/toast/toast.component.spec.ts b/tedi/components/notifications/toast/toast.component.spec.ts new file mode 100644 index 000000000..4587e0684 --- /dev/null +++ b/tedi/components/notifications/toast/toast.component.spec.ts @@ -0,0 +1,167 @@ +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { ToastComponent, ToastType } from "./toast.component"; +import { TEDI_TRANSLATION_DEFAULT_TOKEN } from "../../../tokens/translation.token"; + +describe("ToastComponent", () => { + let component: ToastComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ToastComponent], + providers: [{ provide: TEDI_TRANSLATION_DEFAULT_TOKEN, useValue: "et" }], + }).compileComponents(); + + fixture = TestBed.createComponent(ToastComponent); + component = fixture.componentInstance; + fixture.componentRef.setInput("title", "Test Title"); + fixture.detectChanges(); + }); + + it("should create", () => { + expect(component).toBeTruthy(); + }); + + it("should render alert component", () => { + const alertElement = fixture.nativeElement.querySelector("tedi-alert"); + expect(alertElement).toBeTruthy(); + }); + + it("should display title in the alert", () => { + const titleElement = fixture.nativeElement.querySelector(".tedi-alert__title"); + expect(titleElement.textContent).toContain("Test Title"); + }); + + it("should apply correct type class", () => { + const types: ToastType[] = ["info", "success", "warning", "danger"]; + + for (const type of types) { + fixture.componentRef.setInput("type", type); + fixture.detectChanges(); + + const alertElement = fixture.nativeElement.querySelector("tedi-alert"); + expect(alertElement.classList.contains(`tedi-alert--${type}`)).toBe(true); + } + }); + + it("should pass icon to alert when provided", () => { + fixture.componentRef.setInput("icon", "info"); + fixture.detectChanges(); + + // Use direct child selector to get the alert icon (not close button icon) + const iconElement = fixture.nativeElement.querySelector(".tedi-alert__head > tedi-icon"); + expect(iconElement).toBeTruthy(); + expect(iconElement.textContent).toBe("info"); + }); + + it("should not show icon when not provided", () => { + fixture.componentRef.setInput("icon", undefined); + fixture.detectChanges(); + + // Use direct child selector to exclude the close button's icon + const iconElement = fixture.nativeElement.querySelector(".tedi-alert__head > tedi-icon"); + expect(iconElement).toBeFalsy(); + }); + + it("should always show close button", () => { + const closeButton = fixture.nativeElement.querySelector(".tedi-alert__close"); + expect(closeButton).toBeTruthy(); + }); + + it("should emit closed event when close button is clicked", () => { + const closedSpy = jest.fn(); + component.closed.subscribe(closedSpy); + + const closeButton = fixture.nativeElement.querySelector(".tedi-alert__close"); + closeButton.click(); + fixture.detectChanges(); + + expect(closedSpy).toHaveBeenCalled(); + }); + + it("should have status role by default", () => { + const alertElement = fixture.nativeElement.querySelector("tedi-alert"); + expect(alertElement.getAttribute("role")).toBe("status"); + }); + + it("should apply custom role when provided", () => { + fixture.componentRef.setInput("role", "alert"); + fixture.detectChanges(); + + const alertElement = fixture.nativeElement.querySelector("tedi-alert"); + expect(alertElement.getAttribute("role")).toBe("alert"); + }); + + it("should show progress bar when showProgressBar is true and duration > 0", () => { + fixture.componentRef.setInput("duration", 5000); + fixture.componentRef.setInput("showProgressBar", true); + fixture.detectChanges(); + + const progressBar = fixture.nativeElement.querySelector(".tedi-toast__progress"); + expect(progressBar).toBeTruthy(); + }); + + it("should not show progress bar when showProgressBar is false", () => { + fixture.componentRef.setInput("duration", 5000); + fixture.componentRef.setInput("showProgressBar", false); + fixture.detectChanges(); + + const progressBar = fixture.nativeElement.querySelector(".tedi-toast__progress"); + expect(progressBar).toBeFalsy(); + }); + + it("should not show progress bar when duration is 0", () => { + fixture.componentRef.setInput("duration", 0); + fixture.componentRef.setInput("showProgressBar", true); + fixture.detectChanges(); + + const progressBar = fixture.nativeElement.querySelector(".tedi-toast__progress"); + expect(progressBar).toBeFalsy(); + }); + + it("should apply correct type class to progress bar", () => { + const types: ToastType[] = ["info", "success", "warning", "danger"]; + + for (const type of types) { + fixture.componentRef.setInput("type", type); + fixture.componentRef.setInput("duration", 5000); + fixture.componentRef.setInput("showProgressBar", true); + fixture.detectChanges(); + + const progressBar = fixture.nativeElement.querySelector(".tedi-toast__progress"); + expect(progressBar.classList.contains(`tedi-toast__progress--${type}`)).toBe(true); + } + }); + + it("should pause progress bar animation when paused is true", () => { + fixture.componentRef.setInput("duration", 5000); + fixture.componentRef.setInput("showProgressBar", true); + fixture.componentRef.setInput("paused", true); + fixture.detectChanges(); + + const progressBar = fixture.nativeElement.querySelector(".tedi-toast__progress"); + expect(progressBar.classList.contains("tedi-toast__progress--paused")).toBe(true); + }); + + it("should emit mouseEnter event on mouse enter", () => { + const mouseEnterSpy = jest.fn(); + component.mouseEnter.subscribe(mouseEnterSpy); + + const wrapper = fixture.nativeElement.querySelector(".tedi-toast__wrapper"); + wrapper.dispatchEvent(new MouseEvent("mouseenter")); + fixture.detectChanges(); + + expect(mouseEnterSpy).toHaveBeenCalled(); + }); + + it("should emit mouseLeave event on mouse leave", () => { + const mouseLeaveSpy = jest.fn(); + component.mouseLeave.subscribe(mouseLeaveSpy); + + const wrapper = fixture.nativeElement.querySelector(".tedi-toast__wrapper"); + wrapper.dispatchEvent(new MouseEvent("mouseleave")); + fixture.detectChanges(); + + expect(mouseLeaveSpy).toHaveBeenCalled(); + }); +}); diff --git a/tedi/components/notifications/toast/toast.component.ts b/tedi/components/notifications/toast/toast.component.ts new file mode 100644 index 000000000..cf4486a78 --- /dev/null +++ b/tedi/components/notifications/toast/toast.component.ts @@ -0,0 +1,119 @@ +import { + Component, + ChangeDetectionStrategy, + ViewEncapsulation, + input, + output, +} from "@angular/core"; +import { AlertComponent, AlertType, AlertRole } from "../alert/alert.component"; + +export type ToastType = AlertType; +export type ToastRole = AlertRole; +export type ToastPosition = + | "top-left" + | "top-right" + | "bottom-left" + | "bottom-right"; + +export interface ToastConfig { + title: string; + content?: string; + type?: ToastType; + icon?: string; + /** + * Toast duration in milliseconds. Set to 0 for persistent toast. + */ + duration?: number; + /** + * Whether to show the progress bar for timed toasts. + * @default false + */ + showProgressBar?: boolean; + /** + * Whether to pause the auto-close timer when hovering over the toast. + * @default true + */ + pauseOnHover?: boolean; + /** + * The ARIA role of the toast, informing screen readers about the notification's priority. + * - 'status': For non-critical notifications. + * - 'alert': For critical errors. + * - 'none': Used when no ARIA role is needed. + * @default status + */ + role?: ToastRole; +} + +@Component({ + selector: "tedi-toast", + standalone: true, + imports: [AlertComponent], + templateUrl: "./toast.component.html", + styleUrl: "./toast.component.scss", + encapsulation: ViewEncapsulation.None, + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ToastComponent { + /** + * Title of the toast notification. + */ + readonly title = input(); + + /** + * Type of the toast notification determining its color scheme. + * @default info + */ + readonly type = input("info"); + + /** + * Icon name. Only shown when provided. + */ + readonly icon = input(); + + /** + * The ARIA role of the toast, informing screen readers about the notification's priority. + * - 'status': For non-critical notifications (screen readers announce politely). + * - 'alert': For critical errors (screen readers announce immediately). + * - 'none': Used when no ARIA role is needed. + * @default status + */ + readonly role = input("status"); + + /** + * Duration in milliseconds for auto-close. + * @default 0 + */ + readonly duration = input(0); + + /** + * Whether to show the progress bar. + * @default false + */ + readonly showProgressBar = input(false); + + /** + * Whether the toast timer is currently paused. + * @default false + */ + readonly paused = input(false); + + /** + * Emits when the toast is closed by the user. + */ + readonly closed = output(); + + readonly mouseEnter = output(); + readonly mouseLeave = output(); + + handleClose(): void { + this.closed.emit(); + } + + onMouseEnter(): void { + this.mouseEnter.emit(); + } + + onMouseLeave(): void { + this.mouseLeave.emit(); + } +} diff --git a/tedi/components/notifications/toast/toast.stories.ts b/tedi/components/notifications/toast/toast.stories.ts new file mode 100644 index 000000000..a9b3db84a --- /dev/null +++ b/tedi/components/notifications/toast/toast.stories.ts @@ -0,0 +1,512 @@ +import { + type Meta, + type StoryObj, + moduleMetadata, + applicationConfig, +} from "@storybook/angular"; +import { Component, inject } from "@angular/core"; +import { provideAnimations } from "@angular/platform-browser/animations"; + +import { ToastComponent } from "./toast.component"; +import { ToastService } from "../../../services/toast/toast.service"; +import { RowComponent } from "../../helpers/grid/row/row.component"; +import { ColComponent } from "../../helpers/grid/col/col.component"; +import { ButtonComponent } from "../../buttons/button/button.component"; +import { VerticalSpacingDirective } from "../../../directives/vertical-spacing/vertical-spacing.directive"; + +/** + * Figma ↗
+ * Zeroheight ↗
+ * + * ## Usage + * + * Inject `ToastService` and call one of the convenience methods: + * + * ```typescript + * import { ToastService } from '@tedi-design-system/angular/tedi'; + * + * export class MyComponent { + * private toastService = inject(ToastService); + * + * showNotification() { + * this.toastService.success('Title', 'Content text', { icon: 'icon_name' }); + * } + * } + * ``` + * + * ## Accessibility + * + * Toasts use CDK live announcer for accessibility: + * - `role="status"` (default): For non-critical notifications. Screen readers announce politely. + * - `role="alert"` (default for danger): For critical errors. Screen readers announce immediately. + * - `role="none"`: When no screen reader announcement is needed. + */ +export default { + title: "TEDI-Ready/Components/Notifications/Toast", + component: ToastComponent, + decorators: [ + moduleMetadata({ + imports: [ + ToastComponent, + RowComponent, + ColComponent, + ButtonComponent, + VerticalSpacingDirective, + ], + }), + applicationConfig({ + providers: [ + provideAnimations(), + ], + }), + ], +} as Meta; + +type Story = StoryObj; + +@Component({ + selector: "toast-default-demo", + standalone: true, + imports: [ButtonComponent, RowComponent, ColComponent, VerticalSpacingDirective], + template: ` +
+ + + + + + + + + + + + + + + + + + + + +
+ `, +}) +class ToastDefaultDemoComponent { + private readonly toastService = inject(ToastService); + + showSuccess() { + this.toastService.success("Notice", "Something was successful!"); + } + + showWarning() { + this.toastService.warning("Notice", "Warning!"); + } + + showDanger() { + this.toastService.danger("Notice", "Something went wrong!"); + } + + showInfo() { + this.toastService.info("Notice", "Some info text that can usually be very long!"); + } +} + +/** + * Default toast notifications with different types. + */ +export const Default: Story = { + decorators: [ + moduleMetadata({ + imports: [ToastDefaultDemoComponent], + }), + ], + parameters: { + docs: { + source: { + code: ` +this.toastService.success("Notice", "Something was successful!"); +this.toastService.warning("Notice", "Warning!"); +this.toastService.danger("Notice", "Something went wrong!"); +this.toastService.info("Notice", "Some info text!"); + `, + language: "typescript", + type: "code", + }, + }, + }, + render: () => ({ + template: ``, + }), +}; + +@Component({ + selector: "toast-icon-demo", + standalone: true, + imports: [ButtonComponent, RowComponent, ColComponent, VerticalSpacingDirective], + template: ` + + `, +}) +class ToastIconDemoComponent { + private readonly toastService = inject(ToastService); + + showWithCustomIcon() { + this.toastService.info("With Icon", "Using a custom icon", { icon: "info" }); + } +} + +/** + * Toasts with or without icons. Icons are only shown when explicitly provided. + */ +export const WithIcon: Story = { + decorators: [ + moduleMetadata({ + imports: [ToastIconDemoComponent], + }), + ], + parameters: { + docs: { + source: { + code: `this.toastService.info("With Icon", "Using a custom icon", { icon: "info" });`, + language: "typescript", + type: "code", + }, + }, + }, + render: () => ({ + template: ``, + }), +}; + +@Component({ + selector: "toast-timer-demo", + standalone: true, + imports: [ButtonComponent, RowComponent, ColComponent, VerticalSpacingDirective], + template: ` +
+ + + + + + + + +
+ `, +}) +class ToastTimerDemoComponent { + private readonly toastService = inject(ToastService); + + show(delay: number) { + this.toastService.info(`${delay}s delay`, `Closes after ${delay} seconds`, { + duration: delay * 1000, + showProgressBar: true, + }); + } +} + +/** + * Toasts with custom auto-close durations and progress bar. + */ +export const CustomTimerForAutoclose: Story = { + decorators: [ + moduleMetadata({ + imports: [ToastTimerDemoComponent], + }), + ], + parameters: { + docs: { + source: { + code: ` +this.toastService.info("2s delay", "Closes after 2 seconds", { + duration: 2000, + showProgressBar: true +}); + +this.toastService.info("10s delay", "Closes after 10 seconds", { + duration: 10000, + showProgressBar: true +}); + `, + language: "typescript", + type: "code", + }, + }, + }, + render: () => ({ + template: ``, + }), +}; + +@Component({ + selector: "toast-persistent-demo", + standalone: true, + imports: [ButtonComponent], + template: ` + + `, +}) +class ToastPersistentDemoComponent { + private readonly toastService = inject(ToastService); + + showPersistent() { + this.toastService.warning("Persistent", "Stays until closed", { + duration: 0, + }); + } +} + +/** + * Persistent toast that stays visible until manually closed. + */ +export const PersistentToast: Story = { + decorators: [ + moduleMetadata({ + imports: [ToastPersistentDemoComponent], + }), + ], + parameters: { + docs: { + source: { + code: `this.toastService.warning("Persistent", "Stays until closed", { duration: 0 });`, + language: "typescript", + type: "code", + }, + }, + }, + render: () => ({ + template: ``, + }), +}; + +@Component({ + selector: "toast-position-demo", + standalone: true, + imports: [ButtonComponent, RowComponent, ColComponent, VerticalSpacingDirective], + template: ` +
+ + + + + + + + + + + + + + + + +
+ `, +}) +class ToastPositionDemoComponent { + private readonly toastService = inject(ToastService); + + showTopLeft() { + this.toastService.info("Top Left", "Positioned at top-left corner.", { + position: "top-left", + }); + } + + showTopRight() { + this.toastService.info("Top Right", "Positioned at top-right corner.", { + position: "top-right", + }); + } + + showBottomLeft() { + this.toastService.info("Bottom Left", "Positioned at bottom-left corner.", { + position: "bottom-left", + }); + } + + showBottomRight() { + this.toastService.info("Bottom Right", "Positioned at bottom-right corner.", { + position: "bottom-right", + }); + } +} + +/** + * Toast notifications at different screen positions. + */ +export const Positions: Story = { + decorators: [ + moduleMetadata({ + imports: [ToastPositionDemoComponent], + }), + ], + parameters: { + docs: { + source: { + code: ` +this.toastService.info("Top Left", "Message", { position: "top-left" }); +this.toastService.info("Top Right", "Message", { position: "top-right" }); +this.toastService.info("Bottom Left", "Message", { position: "bottom-left" }); +this.toastService.info("Bottom Right", "Message", { position: "bottom-right" }); + `, + language: "typescript", + type: "code", + }, + }, + }, + render: () => ({ + template: ``, + }), +}; + +@Component({ + selector: "toast-hover-demo", + standalone: true, + imports: [ButtonComponent, RowComponent, ColComponent], + template: ` + + + + + + + + + `, +}) +class ToastHoverDemoComponent { + private readonly toastService = inject(ToastService); + + showPauseOnHover() { + this.toastService.info("Pauses", "Timer stops when hovered", { + showProgressBar: true, + }); + } + + showNoPause() { + this.toastService.danger("No Pause", "Closes even if hovered", { + showProgressBar: true, + pauseOnHover: false, + }); + } +} + +/** + * Toasts with hover behavior control. By default, hovering pauses the auto-close timer. + */ +export const HoverBehavior: Story = { + decorators: [ + moduleMetadata({ + imports: [ToastHoverDemoComponent], + }), + ], + parameters: { + docs: { + source: { + code: ` +this.toastService.info("Pauses", "Timer stops when hovered", { + showProgressBar: true +}); + +this.toastService.danger("No Pause", "Closes even if hovered", { + showProgressBar: true, + pauseOnHover: false +}); + `, + language: "typescript", + type: "code", + }, + }, + }, + render: () => ({ + template: ``, + }), +}; + +@Component({ + selector: "toast-wcag-demo", + standalone: true, + imports: [ButtonComponent, RowComponent, ColComponent], + template: ` + + + + + + + + + + + + `, +}) +class ToastWcagDemoComponent { + private readonly toastService = inject(ToastService); + + showStatus() { + this.toastService.success("Success", "Screen reader announces politely"); + } + + showAlert() { + this.toastService.danger("Error", "Screen reader announces immediately"); + } + + showNone() { + this.toastService.info("Info", "No screen reader announcement", { + role: "none" + }); + } +} + +/** + * Toasts with different ARIA roles for screen reader accessibility. + */ +export const WCAGCompliance: Story = { + decorators: [ + moduleMetadata({ + imports: [ToastWcagDemoComponent], + }), + ], + parameters: { + docs: { + source: { + code: ` +// Polite announcement (default for success, info, warning) +this.toastService.success("Success", "Screen reader announces politely"); + +// Assertive announcement - danger() defaults to role="alert" +this.toastService.danger("Error", "Screen reader announces immediately"); +this.toastService.info("Info", "No screen reader announcement", { role: "none" }); + `, + language: "typescript", + type: "code", + }, + }, + }, + render: () => ({ + template: ``, + }), +}; diff --git a/tedi/services/index.ts b/tedi/services/index.ts index 549e7a9f5..80b532e2b 100644 --- a/tedi/services/index.ts +++ b/tedi/services/index.ts @@ -2,3 +2,4 @@ export * from "./breakpoint/breakpoint.service"; export * from "./translation/translation.service"; export * from "./theme/theme.service"; export * from "./translation/translation.pipe"; +export * from "./toast/toast.service"; diff --git a/tedi/services/toast/toast-announcer.service.spec.ts b/tedi/services/toast/toast-announcer.service.spec.ts new file mode 100644 index 000000000..d8b82406d --- /dev/null +++ b/tedi/services/toast/toast-announcer.service.spec.ts @@ -0,0 +1,251 @@ +import { TestBed, fakeAsync, tick } from "@angular/core/testing"; +import { DOCUMENT } from "@angular/common"; +import { ToastAnnouncerService } from "./toast-announcer.service"; + +describe("ToastAnnouncerService", () => { + let service: ToastAnnouncerService; + let document: Document; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ToastAnnouncerService], + }); + + service = TestBed.inject(ToastAnnouncerService); + document = TestBed.inject(DOCUMENT); + }); + + afterEach(() => { + service.destroy(); + }); + + describe("announce", () => { + it("should create polite announcer element", fakeAsync(() => { + service.announce("Test message", "polite"); + + tick(100); + + const element = document.getElementById("tedi-toast-announcer-polite"); + expect(element).toBeTruthy(); + expect(element?.getAttribute("aria-live")).toBe("polite"); + expect(element?.getAttribute("aria-atomic")).toBe("true"); + expect(element?.getAttribute("role")).toBe("status"); + expect(element?.classList.contains("sr-only")).toBe(true); + })); + + it("should create assertive announcer element", fakeAsync(() => { + service.announce("Test message", "assertive"); + + tick(100); + + const element = document.getElementById("tedi-toast-announcer-assertive"); + expect(element).toBeTruthy(); + expect(element?.getAttribute("aria-live")).toBe("assertive"); + expect(element?.getAttribute("aria-atomic")).toBe("true"); + expect(element?.getAttribute("role")).toBe("alert"); + expect(element?.classList.contains("sr-only")).toBe(true); + })); + + it("should set message content after delay", fakeAsync(() => { + service.announce("Test message", "polite"); + + const element = document.getElementById("tedi-toast-announcer-polite"); + expect(element?.textContent).toBe(""); + + tick(100); + + expect(element?.textContent).toBe("Test message"); + })); + + it("should clear message after clearAfterMs", fakeAsync(() => { + service.announce("Test message", "polite", 500); + + tick(100); + expect( + document.getElementById("tedi-toast-announcer-polite")?.textContent + ).toBe("Test message"); + + tick(500); + expect( + document.getElementById("tedi-toast-announcer-polite")?.textContent + ).toBe(""); + })); + + it("should use default polite politeness", fakeAsync(() => { + service.announce("Test message"); + + tick(100); + + const politeElement = document.getElementById( + "tedi-toast-announcer-polite" + ); + expect(politeElement?.textContent).toBe("Test message"); + })); + + it("should reuse existing element for same politeness", fakeAsync(() => { + service.announce("First message", "polite"); + tick(100); + + const firstElement = document.getElementById( + "tedi-toast-announcer-polite" + ); + + service.announce("Second message", "polite"); + tick(100); + + const secondElement = document.getElementById( + "tedi-toast-announcer-polite" + ); + expect(firstElement).toBe(secondElement); + expect(secondElement?.textContent).toBe("Second message"); + })); + + it("should create separate elements for different politeness levels", fakeAsync(() => { + service.announce("Polite message", "polite"); + service.announce("Assertive message", "assertive"); + + tick(100); + + const politeElement = document.getElementById( + "tedi-toast-announcer-polite" + ); + const assertiveElement = document.getElementById( + "tedi-toast-announcer-assertive" + ); + + expect(politeElement).toBeTruthy(); + expect(assertiveElement).toBeTruthy(); + expect(politeElement).not.toBe(assertiveElement); + })); + + it("should clear content before setting new message for re-announcement", fakeAsync(() => { + service.announce("First message", "polite"); + tick(100); + + const element = document.getElementById("tedi-toast-announcer-polite"); + expect(element?.textContent).toBe("First message"); + + // Announce same message again + service.announce("First message", "polite"); + + expect(element?.textContent).toBe(""); + + tick(100); + expect(element?.textContent).toBe("First message"); + })); + }); + + describe("clear", () => { + it("should clear polite element content", fakeAsync(() => { + service.announce("Test message", "polite"); + tick(100); + + service.clear(); + + const element = document.getElementById("tedi-toast-announcer-polite"); + expect(element?.textContent).toBe(""); + })); + + it("should clear assertive element content", fakeAsync(() => { + service.announce("Test message", "assertive"); + tick(100); + + service.clear(); + + const element = document.getElementById("tedi-toast-announcer-assertive"); + expect(element?.textContent).toBe(""); + })); + + it("should clear both elements", fakeAsync(() => { + service.announce("Polite message", "polite"); + service.announce("Assertive message", "assertive"); + tick(100); + + service.clear(); + + expect( + document.getElementById("tedi-toast-announcer-polite")?.textContent + ).toBe(""); + expect( + document.getElementById("tedi-toast-announcer-assertive")?.textContent + ).toBe(""); + })); + + it("should not throw when no elements exist", () => { + expect(() => service.clear()).not.toThrow(); + }); + }); + + describe("destroy", () => { + it("should remove polite element from DOM", fakeAsync(() => { + service.announce("Test message", "polite"); + tick(100); + + expect( + document.getElementById("tedi-toast-announcer-polite") + ).toBeTruthy(); + + service.destroy(); + + expect(document.getElementById("tedi-toast-announcer-polite")).toBeNull(); + })); + + it("should remove assertive element from DOM", fakeAsync(() => { + service.announce("Test message", "assertive"); + tick(100); + + expect( + document.getElementById("tedi-toast-announcer-assertive") + ).toBeTruthy(); + + service.destroy(); + + expect( + document.getElementById("tedi-toast-announcer-assertive") + ).toBeNull(); + })); + + it("should remove both elements", fakeAsync(() => { + service.announce("Polite", "polite"); + service.announce("Assertive", "assertive"); + tick(100); + + service.destroy(); + + expect(document.getElementById("tedi-toast-announcer-polite")).toBeNull(); + expect( + document.getElementById("tedi-toast-announcer-assertive") + ).toBeNull(); + })); + + it("should not throw when no elements exist", () => { + expect(() => service.destroy()).not.toThrow(); + }); + + it("should allow creating new elements after destroy", fakeAsync(() => { + service.announce("First", "polite"); + tick(100); + service.destroy(); + + service.announce("Second", "polite"); + tick(100); + + const element = document.getElementById("tedi-toast-announcer-polite"); + expect(element).toBeTruthy(); + expect(element?.textContent).toBe("Second"); + })); + }); + + describe("ngOnDestroy", () => { + it("should call destroy on ngOnDestroy", fakeAsync(() => { + const destroySpy = jest.spyOn(service, "destroy"); + + service.announce("Test", "polite"); + tick(100); + + service.ngOnDestroy(); + + expect(destroySpy).toHaveBeenCalled(); + })); + }); +}); diff --git a/tedi/services/toast/toast-announcer.service.ts b/tedi/services/toast/toast-announcer.service.ts new file mode 100644 index 000000000..5f155319a --- /dev/null +++ b/tedi/services/toast/toast-announcer.service.ts @@ -0,0 +1,86 @@ +import { Injectable, inject, OnDestroy } from "@angular/core"; +import { DOCUMENT } from "@angular/common"; + +/** + * Custom announcer service for toast notifications that uses the `sr-only` class + * instead of CDK's LiveAnnouncer which requires CDK styles. + * + * Creates a visually hidden element that screen readers can access to announce + * toast messages with appropriate politeness levels. + * + * @internal + */ +@Injectable({ providedIn: "root" }) +export class ToastAnnouncerService implements OnDestroy { + private readonly document = inject(DOCUMENT); + + private politeElement: HTMLElement | null = null; + private assertiveElement: HTMLElement | null = null; + + /** + * Announce a message to screen readers. + * @param message The message to announce + * @param politeness The politeness level: 'polite' (default) or 'assertive' + * @param clearAfterMs Time in ms after which to clear the message (default: 1000ms) + */ + announce(message: string, politeness: "polite" | "assertive" = "polite", clearAfterMs: number = 1000): void { + const element = this.getOrCreateElement(politeness); + element.textContent = ""; + + // Use a small timeout to ensure screen readers detect the change + setTimeout(() => { + element.textContent = message; + setTimeout(() => { + element.textContent = ""; + }, clearAfterMs); + }, 100); + } + + /** + * Clear all announcements text content. + */ + clear(): void { + if (this.politeElement) { + this.politeElement.textContent = ""; + } + if (this.assertiveElement) { + this.assertiveElement.textContent = ""; + } + } + + destroy(): void { + this.politeElement?.remove(); + this.assertiveElement?.remove(); + this.politeElement = null; + this.assertiveElement = null; + } + + ngOnDestroy(): void { + this.destroy(); + } + + private getOrCreateElement(politeness: "polite" | "assertive"): HTMLElement { + if (politeness === "assertive") { + if (!this.assertiveElement) { + this.assertiveElement = this.createAnnouncerElement("assertive"); + } + return this.assertiveElement; + } else { + if (!this.politeElement) { + this.politeElement = this.createAnnouncerElement("polite"); + } + return this.politeElement; + } + } + + private createAnnouncerElement(politeness: "polite" | "assertive"): HTMLElement { + const element = this.document.createElement("span"); + element.setAttribute("aria-live", politeness); + element.setAttribute("aria-atomic", "true"); + element.setAttribute("role", politeness === "assertive" ? "alert" : "status"); + element.classList.add("sr-only"); + element.id = `tedi-toast-announcer-${politeness}`; + this.document.body.appendChild(element); + return element; + } +} diff --git a/tedi/services/toast/toast.service.spec.ts b/tedi/services/toast/toast.service.spec.ts new file mode 100644 index 000000000..55e9682b0 --- /dev/null +++ b/tedi/services/toast/toast.service.spec.ts @@ -0,0 +1,418 @@ +import { TestBed, fakeAsync, tick } from "@angular/core/testing"; +import { ToastService } from "./toast.service"; +import { ToastAnnouncerService } from "./toast-announcer.service"; +import { Overlay, OverlayRef } from "@angular/cdk/overlay"; + +describe("ToastService", () => { + let service: ToastService; + let announcerSpy: jest.SpyInstance; + let mockOverlayRef: Partial; + let mockOverlay: Partial; + + beforeEach(() => { + // Reset static state before each test + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (ToastService as any).sharedToasts.set([]); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (ToastService as any).sharedTimerMap.clear(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (ToastService as any).sharedOverlayRef = null; + + mockOverlayRef = { + attach: jest.fn(), + dispose: jest.fn(), + hasAttached: jest.fn().mockReturnValue(true), + overlayElement: { isConnected: true } as HTMLElement, + }; + + mockOverlay = { + create: jest.fn().mockReturnValue(mockOverlayRef), + scrollStrategies: { + noop: jest.fn().mockReturnValue({}), + } as unknown as Overlay["scrollStrategies"], + position: jest.fn().mockReturnValue({ + global: jest.fn().mockReturnValue({}), + }), + }; + + TestBed.configureTestingModule({ + providers: [ + ToastService, + ToastAnnouncerService, + { provide: Overlay, useValue: mockOverlay }, + ], + }); + + service = TestBed.inject(ToastService); + const announcer = TestBed.inject(ToastAnnouncerService); + announcerSpy = jest.spyOn(announcer, "announce"); + }); + + afterEach(fakeAsync(() => { + const toasts = service.getToasts(); + toasts.forEach((toast) => { + service.close(toast.id); + }); + tick(300); + })); + + describe("show methods", () => { + it("should show info toast", fakeAsync(() => { + const id = service.info("Info Title", "Info content"); + + expect(id).toBeDefined(); + expect(service.getToasts().length).toBe(1); + expect(service.getToasts()[0].type).toBe("info"); + expect(service.getToasts()[0].title).toBe("Info Title"); + expect(service.getToasts()[0].content).toBe("Info content"); + + service.close(id); + tick(300); + })); + + it("should show success toast", fakeAsync(() => { + const id = service.success("Success Title", "Success content"); + + expect(service.getToasts()[0].type).toBe("success"); + + service.close(id); + tick(300); + })); + + it("should show warning toast", fakeAsync(() => { + const id = service.warning("Warning Title", "Warning content"); + + expect(service.getToasts()[0].type).toBe("warning"); + + service.close(id); + tick(300); + })); + + it("should show danger toast with alert role by default", fakeAsync(() => { + const id = service.danger("Danger Title", "Danger content"); + + expect(service.getToasts()[0].type).toBe("danger"); + expect(service.getToasts()[0].role).toBe("alert"); + + service.close(id); + tick(300); + })); + + it("should show toast with custom options", fakeAsync(() => { + const id = service.show({ + title: "Custom Toast", + content: "Custom content", + type: "success", + icon: "check", + position: "top-left", + duration: 5000, + showProgressBar: true, + pauseOnHover: false, + role: "alert", + }); + + const toast = service.getToasts()[0]; + expect(toast.title).toBe("Custom Toast"); + expect(toast.content).toBe("Custom content"); + expect(toast.type).toBe("success"); + expect(toast.icon).toBe("check"); + expect(toast.position).toBe("top-left"); + expect(toast.duration).toBe(5000); + expect(toast.showProgressBar).toBe(true); + expect(toast.pauseOnHover).toBe(false); + expect(toast.role).toBe("alert"); + + service.close(id); + tick(300); + })); + + it("should use custom id when provided", fakeAsync(() => { + const customId = "my-custom-toast-id"; + const id = service.info("Title", "Content", { id: customId }); + + expect(id).toBe(customId); + expect(service.getToasts()[0].id).toBe(customId); + + service.close(id); + tick(300); + })); + + it("should use default values when not provided", fakeAsync(() => { + const id = service.show({ title: "Minimal Toast" }); + + const toast = service.getToasts()[0]; + expect(toast.type).toBe("info"); + expect(toast.position).toBe("bottom-right"); + expect(toast.role).toBe("status"); + expect(toast.showProgressBar).toBe(false); + expect(toast.pauseOnHover).toBe(true); + + service.close(id); + tick(300); + })); + }); + + describe("auto-close", () => { + it("should auto-close toast after duration", fakeAsync(() => { + service.info("Auto close", "Content", { duration: 1000 }); + + expect(service.getToasts().length).toBe(1); + + tick(1000); // Duration + tick(300); // Animation + + expect(service.getToasts().length).toBe(0); + })); + + it("should not auto-close when duration is 0", fakeAsync(() => { + const id = service.info("Persistent", "Content", { duration: 0 }); + + expect(service.getToasts().length).toBe(1); + + tick(10000); + + expect(service.getToasts().length).toBe(1); + + service.close(id); + tick(300); + })); + }); + + describe("close", () => { + it("should close toast by id", fakeAsync(() => { + const id = service.info("Title", "Content"); + + expect(service.getToasts().length).toBe(1); + + service.close(id); + + expect(service.getToasts()[0].exiting).toBe(true); + tick(300); + + expect(service.getToasts().length).toBe(0); + })); + + it("should not throw when closing non-existent toast", fakeAsync(() => { + expect(() => service.close("non-existent-id")).not.toThrow(); + })); + + it("should not close already exiting toast", fakeAsync(() => { + const id = service.info("Title", "Content"); + service.close(id); + + // Try to close again while exiting + service.close(id); + + tick(300); + expect(service.getToasts().length).toBe(0); + })); + }); + + describe("pause and resume", () => { + it("should pause toast timer", fakeAsync(() => { + const id = service.info("Title", "Content", { + duration: 2000, + pauseOnHover: true, + }); + + tick(500); + service.pause(id); + + expect(service.getToasts()[0].paused).toBe(true); + + tick(5000); // Wait longer than original duration + + expect(service.getToasts().length).toBe(1); + + service.close(id); + tick(300); + })); + + it("should resume toast timer", fakeAsync(() => { + const id = service.info("Title", "Content", { + duration: 2000, + pauseOnHover: true, + }); + + tick(500); + service.pause(id); + + tick(1000); + service.resume(id); + + expect(service.getToasts()[0].paused).toBe(false); + + tick(1500); + tick(300); // Animation + + expect(service.getToasts().length).toBe(0); + })); + + it("should not pause when pauseOnHover is false", fakeAsync(() => { + const id = service.info("Title", "Content", { + duration: 2000, + pauseOnHover: false, + }); + + service.pause(id); + + expect(service.getToasts()[0].paused).toBeFalsy(); + + tick(2000); + tick(300); + })); + + it("should not pause exiting toast", fakeAsync(() => { + const id = service.info("Title", "Content", { + duration: 2000, + pauseOnHover: true, + }); + + service.close(id); + service.pause(id); + + tick(300); + })); + + it("should not resume when not paused", fakeAsync(() => { + const id = service.info("Title", "Content", { + duration: 2000, + pauseOnHover: true, + }); + + service.resume(id); + + service.close(id); + tick(300); + })); + + it("should not resume exiting toast", fakeAsync(() => { + const id = service.info("Title", "Content", { + duration: 2000, + pauseOnHover: true, + }); + + service.pause(id); + service.close(id); + service.resume(id); + + tick(300); + })); + + it("should not pause non-existent toast", fakeAsync(() => { + expect(() => service.pause("non-existent")).not.toThrow(); + })); + + it("should not resume non-existent toast", fakeAsync(() => { + expect(() => service.resume("non-existent")).not.toThrow(); + })); + }); + + describe("screen reader announcements", () => { + it("should announce with polite politeness for status role", fakeAsync(() => { + const id = service.info("Title", "Content", { role: "status" }); + + expect(announcerSpy).toHaveBeenCalledWith("Title: Content", "polite"); + + service.close(id); + tick(300); + })); + + it("should announce with assertive politeness for alert role", fakeAsync(() => { + const id = service.danger("Error", "Something went wrong"); + + expect(announcerSpy).toHaveBeenCalledWith( + "Error: Something went wrong", + "assertive" + ); + + service.close(id); + tick(300); + })); + + it("should not announce when role is none", fakeAsync(() => { + const id = service.info("Title", "Content", { role: "none" }); + + expect(announcerSpy).not.toHaveBeenCalled(); + + service.close(id); + tick(300); + })); + + it("should announce only title when no content", fakeAsync(() => { + const id = service.info("Title Only"); + + expect(announcerSpy).toHaveBeenCalledWith("Title Only", "polite"); + + service.close(id); + tick(300); + })); + }); + + describe("multiple toasts", () => { + it("should manage multiple toasts", fakeAsync(() => { + const id1 = service.info("Toast 1"); + const id2 = service.success("Toast 2"); + const id3 = service.warning("Toast 3"); + + expect(service.getToasts().length).toBe(3); + + service.close(id2); + tick(300); + + expect(service.getToasts().length).toBe(2); + expect(service.getToasts().find((t) => t.id === id2)).toBeUndefined(); + + service.close(id1); + service.close(id3); + tick(300); + })); + + it("should handle toasts in different positions", fakeAsync(() => { + const id1 = service.info("Top Left", undefined, { position: "top-left" }); + const id2 = service.info("Bottom Right", undefined, { + position: "bottom-right", + }); + + const toasts = service.getToasts(); + expect(toasts.find((t) => t.id === id1)?.position).toBe("top-left"); + expect(toasts.find((t) => t.id === id2)?.position).toBe("bottom-right"); + + service.close(id1); + service.close(id2); + tick(300); + })); + }); + + describe("overlay management", () => { + it("should create overlay on first toast", fakeAsync(() => { + const id = service.info("Title"); + + expect(mockOverlay.create).toHaveBeenCalled(); + expect(mockOverlayRef.attach).toHaveBeenCalled(); + + service.close(id); + tick(300); + })); + + it("should reuse existing overlay for subsequent toasts", fakeAsync(() => { + const id1 = service.info("Toast 1"); + const id2 = service.info("Toast 2"); + + expect(mockOverlay.create).toHaveBeenCalledTimes(1); + + service.close(id1); + service.close(id2); + tick(300); + })); + + it("should dispose overlay when all toasts are closed", fakeAsync(() => { + const id = service.info("Title"); + + service.close(id); + tick(300); + + expect(mockOverlayRef.dispose).toHaveBeenCalled(); + })); + }); +}); diff --git a/tedi/services/toast/toast.service.ts b/tedi/services/toast/toast.service.ts new file mode 100644 index 000000000..c3737bb6a --- /dev/null +++ b/tedi/services/toast/toast.service.ts @@ -0,0 +1,294 @@ +import { + Injectable, + Injector, + inject, + signal, +} from "@angular/core"; +import { Overlay, OverlayRef, OverlayConfig } from "@angular/cdk/overlay"; +import { ComponentPortal } from "@angular/cdk/portal"; +import { ToastContainerComponent, ToastItem } from "../../components/notifications/toast/toast-container.component"; +import { ToastPosition, ToastConfig, ToastRole } from "../../components/notifications/toast/toast.component"; +import { ToastAnnouncerService } from "./toast-announcer.service"; + +export interface ToastOptions extends ToastConfig { + position?: ToastPosition; + id?: string; +} + +type ToastMethodOptions = Partial>; + +const DEFAULT_DURATION = 6000; +const ANIMATION_DURATION = 300; + +let toastId = 0; + +interface ToastTimerState { + timeout: ReturnType | null; + startTime: number; + remainingTime: number; +} + +@Injectable({ providedIn: "root" }) +export class ToastService { + private readonly overlay = inject(Overlay); + private readonly injector = inject(Injector); + private readonly announcer = inject(ToastAnnouncerService); + + // Static shared state across all service instances + private static readonly sharedToasts = signal([]); + private static readonly sharedTimerMap = new Map(); + private static sharedOverlayRef: OverlayRef | null = null; + + // Instance accessors for static state + private get toasts() { + return ToastService.sharedToasts; + } + + private get timerMap() { + return ToastService.sharedTimerMap; + } + + private get overlayRef() { + return ToastService.sharedOverlayRef; + } + + private set overlayRef(value: OverlayRef | null) { + ToastService.sharedOverlayRef = value; + } + + /** + * Readonly signal of all current toasts. + * @internal + */ + readonly toasts$ = ToastService.sharedToasts.asReadonly(); + + /** + * Get all current toasts + * @internal + */ + getToasts(): ToastItem[] { + return this.toasts(); + } + + /** + * Show an info toast notification. + * @param title The toast title + * @param content Toast content + * @param options Additional toast options + */ + info(title: string, content?: string, options?: ToastMethodOptions): string { + return this.show({ ...options, title, content, type: "info" }); + } + + /** + * Show a success toast notification. + * @param title The toast title + * @param content Toast content + * @param options Additional toast options + */ + success(title: string, content?: string, options?: ToastMethodOptions): string { + return this.show({ ...options, title, content, type: "success" }); + } + + /** + * Show a warning toast notification. + * @param title The toast title + * @param content Toast content + * @param options Additional toast options + */ + warning(title: string, content?: string, options?: ToastMethodOptions): string { + return this.show({ ...options, title, content, type: "warning" }); + } + + /** + * Show a danger toast notification. + * Defaults to role="alert" for immediate screen reader announcement. + * @param title The toast title + * @param content Toast content + * @param options Additional toast options + */ + danger(title: string, content?: string, options?: ToastMethodOptions): string { + return this.show({ role: "alert", ...options, title, content, type: "danger" }); + } + + /** + * Show a toast notification with full configuration. + */ + show(options: ToastOptions): string { + this.assertContainerExists(); + + const id = options.id || this.generateId(); + const position = options.position || "bottom-right"; + const duration = options.duration ?? DEFAULT_DURATION; + const role = options.role ?? "status"; + const showProgressBar = options.showProgressBar ?? false; + const pauseOnHover = options.pauseOnHover ?? true; + + const toast: ToastItem = { + id, + title: options.title, + content: options.content, + type: options.type || "info", + icon: options.icon, + role, + duration, + showProgressBar, + pauseOnHover, + position, + }; + + this.toasts.update((toasts) => [...toasts, toast]); + + this.announceToScreenReader(toast, role); + + if (duration > 0) { + this.startTimer(id, duration); + } + + return id; + } + + /** + * Close a specific toast by ID. + */ + close(id: string): void { + this.clearTimer(id); + + const toast = this.toasts().find((t) => t.id === id); + if (!toast || toast.exiting) return; + + this.toasts.update((toasts) => + toasts.map((t) => (t.id === id ? { ...t, exiting: true } : t)) + ); + + setTimeout(() => { + this.toasts.update((toasts) => toasts.filter((t) => t.id !== id)); + this.cleanupIfEmpty(); + }, ANIMATION_DURATION); + } + + /** + * Pause a toast's auto-close timer. + * @internal + */ + pause(id: string): void { + const toast = this.toasts().find((t) => t.id === id); + if (!toast || !toast.pauseOnHover || toast.exiting) return; + + const timerState = this.timerMap.get(id); + if (!timerState || !timerState.timeout) return; + + const elapsed = Date.now() - timerState.startTime; + const remainingTime = Math.max(0, timerState.remainingTime - elapsed); + + clearTimeout(timerState.timeout); + timerState.timeout = null; + timerState.remainingTime = remainingTime; + + this.toasts.update((toasts) => + toasts.map((t) => (t.id === id ? { ...t, paused: true } : t)) + ); + } + + /** + * Resume a toast's auto-close timer. + * @internal + */ + resume(id: string): void { + const toast = this.toasts().find((t) => t.id === id); + if (!toast || !toast.pauseOnHover || toast.exiting) return; + + const timerState = this.timerMap.get(id); + if (!timerState || timerState.timeout) return; + + this.toasts.update((toasts) => + toasts.map((t) => (t.id === id ? { ...t, paused: false } : t)) + ); + + if (timerState.remainingTime > 0) { + timerState.startTime = Date.now(); + timerState.timeout = setTimeout(() => this.close(id), timerState.remainingTime); + } + } + + private startTimer(id: string, duration: number): void { + const timeout = setTimeout(() => this.close(id), duration); + this.timerMap.set(id, { + timeout, + startTime: Date.now(), + remainingTime: duration, + }); + } + + private clearTimer(id: string): void { + const timerState = this.timerMap.get(id); + if (timerState?.timeout) { + clearTimeout(timerState.timeout); + } + this.timerMap.delete(id); + } + + private assertContainerExists(): void { + if (this.overlayRef) { + // Check if the portal is attached and the overlay element is in the DOM + const isAttached = this.overlayRef.hasAttached(); + const isInDom = this.overlayRef.overlayElement?.isConnected ?? false; + + if (isAttached && isInDom) { + return; + } + + // Portal detached or element removed from DOM, clean up stale overlay + try { + this.overlayRef.dispose(); + } catch { + // Ignore disposal errors + } + this.overlayRef = null; + this.toasts.set([]); + this.timerMap.forEach((state) => { + if (state.timeout) clearTimeout(state.timeout); + }); + this.timerMap.clear(); + } + + const overlayConfig = new OverlayConfig({ + hasBackdrop: false, + scrollStrategy: this.overlay.scrollStrategies.noop(), + positionStrategy: this.overlay.position().global(), + }); + + this.overlayRef = this.overlay.create(overlayConfig); + + const portal = new ComponentPortal( + ToastContainerComponent, + null, + this.injector + ); + + this.overlayRef.attach(portal); + } + + private cleanupIfEmpty(): void { + if (!this.toasts().length && this.overlayRef) { + this.overlayRef.dispose(); + this.overlayRef = null; + this.announcer.destroy(); + } + } + + private announceToScreenReader(toast: ToastItem, role: ToastRole): void { + if (role === "none") return; + + const message = toast.content + ? `${toast.title}: ${toast.content}` + : toast.title; + + const politeness = role === "alert" ? "assertive" : "polite"; + this.announcer.announce(message, politeness); + } + + private generateId(): string { + return `toast-${++toastId}`; + } +} From a3df3dedf03d913d2e989d7f99e0857fbef4d103 Mon Sep 17 00:00:00 2001 From: m2rt Date: Mon, 19 Jan 2026 15:35:25 +0200 Subject: [PATCH 2/4] feat(toast): added descriptions for toast inputs #270 --- .../notifications/toast/toast.component.ts | 33 +++++++++++- .../notifications/toast/toast.stories.ts | 50 +++++++++++++++++++ tedi/services/toast/toast.service.ts | 14 ++---- 3 files changed, 85 insertions(+), 12 deletions(-) diff --git a/tedi/components/notifications/toast/toast.component.ts b/tedi/components/notifications/toast/toast.component.ts index cf4486a78..22d8af6fd 100644 --- a/tedi/components/notifications/toast/toast.component.ts +++ b/tedi/components/notifications/toast/toast.component.ts @@ -7,6 +7,8 @@ import { } from "@angular/core"; import { AlertComponent, AlertType, AlertRole } from "../alert/alert.component"; +export const TOAST_DEFAULT_DURATION = 6000; + export type ToastType = AlertType; export type ToastRole = AlertRole; export type ToastPosition = @@ -16,9 +18,22 @@ export type ToastPosition = | "bottom-right"; export interface ToastConfig { + /** + * Title of the toast notification. + */ title: string; + /** + * Toast text content. + */ content?: string; + /** + * Type of the toast notification determining its color scheme. + * @default info + */ type?: ToastType; + /** + * Specifies an optional icon to display in the toast notification, providing quick visual context. + */ icon?: string; /** * Toast duration in milliseconds. Set to 0 for persistent toast. @@ -42,6 +57,20 @@ export interface ToastConfig { * @default status */ role?: ToastRole; + /** + * The position of toast container. + * Possible values: + * - 'top-left' + * - 'top-right' + * - 'bottom-left' + * - 'bottom-right' + * @default bottom-right + */ + position?: ToastPosition; + /** + * Unique identifier of given toast. Id is automatically generated if not provided by client + */ + id?: string; } @Component({ @@ -81,9 +110,9 @@ export class ToastComponent { /** * Duration in milliseconds for auto-close. - * @default 0 + * @default 6000 */ - readonly duration = input(0); + readonly duration = input(TOAST_DEFAULT_DURATION); /** * Whether to show the progress bar. diff --git a/tedi/components/notifications/toast/toast.stories.ts b/tedi/components/notifications/toast/toast.stories.ts index a9b3db84a..5ab911bb1 100644 --- a/tedi/components/notifications/toast/toast.stories.ts +++ b/tedi/components/notifications/toast/toast.stories.ts @@ -60,6 +60,56 @@ export default { ], }), ], + argTypes: { + title: { + control: "text", + description: + "Title of the toast notification.", + }, + content: { + control: "text", + description: + "Toast text content.", + }, + type: { + control: "radio", + options: ["info", "success", "warning", "error"], + description: + "Type of the toast notification determining its color scheme.", + defaultValue: { + summary: "info", + }, + }, + icon: { + control: "text", + description: + "Specifies an optional icon to display in the toast notification. See the icon component for more details.", + }, + duration: { + control: "number", + description: "Toast duration in milliseconds. Set to 0 for persistent toast.", + defaultValue: { summary: 6000 } + }, + showProgressBar: { + control: "boolean", + description: "Whether to show the progress bar for timed toasts.", + defaultValue: { summary: false } + }, + pauseOnHover: { + control: "boolean", + description: "Whether to pause the auto-close timer when hovering over the toast.", + defaultValue: { summary: true } + }, + role: { + control: "select", + options: ["alert", "status", "none"], + description: + "The ARIA role of the toast, informing screen readers about the notification's priority. Options: \n - alert for high-priority messages that demand immediate attention. \n - status for less urgent messages providing feedback or updates.\n - none used when no ARIA role is needed.", + defaultValue: { + summary: "alert", + }, + }, + }, } as Meta; type Story = StoryObj; diff --git a/tedi/services/toast/toast.service.ts b/tedi/services/toast/toast.service.ts index c3737bb6a..751a74e92 100644 --- a/tedi/services/toast/toast.service.ts +++ b/tedi/services/toast/toast.service.ts @@ -7,17 +7,11 @@ import { import { Overlay, OverlayRef, OverlayConfig } from "@angular/cdk/overlay"; import { ComponentPortal } from "@angular/cdk/portal"; import { ToastContainerComponent, ToastItem } from "../../components/notifications/toast/toast-container.component"; -import { ToastPosition, ToastConfig, ToastRole } from "../../components/notifications/toast/toast.component"; +import { ToastConfig, ToastRole, TOAST_DEFAULT_DURATION } from "../../components/notifications/toast/toast.component"; import { ToastAnnouncerService } from "./toast-announcer.service"; -export interface ToastOptions extends ToastConfig { - position?: ToastPosition; - id?: string; -} - -type ToastMethodOptions = Partial>; +type ToastMethodOptions = Partial>; -const DEFAULT_DURATION = 6000; const ANIMATION_DURATION = 300; let toastId = 0; @@ -114,12 +108,12 @@ export class ToastService { /** * Show a toast notification with full configuration. */ - show(options: ToastOptions): string { + show(options: ToastConfig): string { this.assertContainerExists(); const id = options.id || this.generateId(); const position = options.position || "bottom-right"; - const duration = options.duration ?? DEFAULT_DURATION; + const duration = options.duration ?? TOAST_DEFAULT_DURATION; const role = options.role ?? "status"; const showProgressBar = options.showProgressBar ?? false; const pauseOnHover = options.pauseOnHover ?? true; From 8c25a1e3ad7112c7447e0d7fd0da8c00ea56ccf9 Mon Sep 17 00:00:00 2001 From: m2rt Date: Mon, 19 Jan 2026 15:43:24 +0200 Subject: [PATCH 3/4] feat(toast): fixed missing icon value issue #270 --- .../notifications/toast/toast-container.component.ts | 2 +- tedi/components/notifications/toast/toast.component.ts | 2 +- tedi/services/toast/toast.service.ts | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/tedi/components/notifications/toast/toast-container.component.ts b/tedi/components/notifications/toast/toast-container.component.ts index e1a72bebd..c0ed4d3cc 100644 --- a/tedi/components/notifications/toast/toast-container.component.ts +++ b/tedi/components/notifications/toast/toast-container.component.ts @@ -48,7 +48,7 @@ const POSITIONS: ToastPosition[] = [ (); + readonly icon = input(""); /** * The ARIA role of the toast, informing screen readers about the notification's priority. diff --git a/tedi/services/toast/toast.service.ts b/tedi/services/toast/toast.service.ts index 751a74e92..cbebea318 100644 --- a/tedi/services/toast/toast.service.ts +++ b/tedi/services/toast/toast.service.ts @@ -122,8 +122,8 @@ export class ToastService { id, title: options.title, content: options.content, - type: options.type || "info", - icon: options.icon, + type: options.type ?? "info", + icon: options.icon ?? "", role, duration, showProgressBar, From 2f74586d05565a2e32fb3313fc154823a9102691 Mon Sep 17 00:00:00 2001 From: m2rt Date: Mon, 26 Jan 2026 08:52:24 +0200 Subject: [PATCH 4/4] feat(toast): changes/improvements from design review #270 --- .../notifications/toast/toast.component.scss | 19 +++++++----- .../notifications/toast/toast.stories.ts | 31 +++++++++---------- 2 files changed, 27 insertions(+), 23 deletions(-) diff --git a/tedi/components/notifications/toast/toast.component.scss b/tedi/components/notifications/toast/toast.component.scss index b371d1dda..34e6bff1a 100644 --- a/tedi/components/notifications/toast/toast.component.scss +++ b/tedi/components/notifications/toast/toast.component.scss @@ -8,18 +8,23 @@ tedi-toast { .tedi-toast__wrapper { position: relative; - box-shadow: 0px 1px 5px 0px var(--tedi-alpha-20, rgba(0, 0, 0, 0.2)); + padding: var(--toast-outer-spacing, 4px); + + tedi-alert { + box-shadow: 0 4px 10px 0 var(--tedi-alpha-14, rgba(0, 0, 0, 0.14)); + } } .tedi-toast__progress { position: absolute; - bottom: 0; - left: 0; - width: 100%; + bottom: var(--toast-outer-spacing); + left: var(--toast-outer-spacing); + width: calc(100% - var(--toast-outer-spacing) * 2); height: 4px; background: var(--toast-progress-color); transform-origin: left; animation: toast-progress linear forwards; + border-radius: 0 var(--alert-radius) 0 var(--alert-radius); &--success { --toast-progress-color: var(--alert-default-border-success); @@ -54,7 +59,7 @@ tedi-toast { flex-direction: column; gap: var(--dimensions-05); max-height: calc(100vh - calc(var(--toast-margin-bottom) * 2)); - overflow: hidden; + overflow: visible; >* { pointer-events: auto; @@ -62,7 +67,7 @@ tedi-toast { &--top-left { top: var(--toast-margin-bottom); - left: var(--toast-margin-left); + left: var(--toast-margin-right); align-items: flex-start; tedi-toast { @@ -90,7 +95,7 @@ tedi-toast { &--bottom-left { bottom: var(--toast-margin-bottom); - left: var(--toast-margin-left); + left: var(--toast-margin-right); align-items: flex-start; flex-direction: column-reverse; diff --git a/tedi/components/notifications/toast/toast.stories.ts b/tedi/components/notifications/toast/toast.stories.ts index 5ab911bb1..45c6c7ef9 100644 --- a/tedi/components/notifications/toast/toast.stories.ts +++ b/tedi/components/notifications/toast/toast.stories.ts @@ -119,7 +119,7 @@ type Story = StoryObj; standalone: true, imports: [ButtonComponent, RowComponent, ColComponent, VerticalSpacingDirective], template: ` -
+
@@ -235,16 +235,14 @@ export const WithIcon: Story = { standalone: true, imports: [ButtonComponent, RowComponent, ColComponent, VerticalSpacingDirective], template: ` -
- - - - - - - - -
+ + + + + + + + `, }) class ToastTimerDemoComponent { @@ -337,8 +335,8 @@ export const PersistentToast: Story = { standalone: true, imports: [ButtonComponent, RowComponent, ColComponent, VerticalSpacingDirective], template: ` -
- +
+ - +