diff --git a/angular.json b/angular.json
index 34422ddd..9814260b 100644
--- a/angular.json
+++ b/angular.json
@@ -106,6 +106,7 @@
"port": 6006,
"styles": [
"src/styles.scss",
+ "projects/ppwcode/.storybook/styles.scss",
"node_modules/@fortawesome/fontawesome-free/css/fontawesome.min.css",
"node_modules/@fortawesome/fontawesome-free/css/all.min.css"
]
@@ -120,6 +121,7 @@
"outputDir": "dist/storybook",
"styles": [
"src/styles.scss",
+ "projects/ppwcode/.storybook/styles.scss",
"node_modules/@fortawesome/fontawesome-free/css/fontawesome.min.css",
"node_modules/@fortawesome/fontawesome-free/css/all.min.css"
]
diff --git a/projects/ppwcode/.storybook/_theme-colors.scss b/projects/ppwcode/.storybook/_theme-colors.scss
new file mode 100644
index 00000000..cd4ca399
--- /dev/null
+++ b/projects/ppwcode/.storybook/_theme-colors.scss
@@ -0,0 +1,137 @@
+// This file was generated by running 'ng generate @angular/material:theme-color'.
+// Proceed with caution if making changes to this file.
+
+@use 'sass:map';
+@use '@angular/material' as mat;
+
+// Note: Color palettes are generated from primary: #18428c, secondary: #6eb343
+$_palettes: (
+ primary: (
+ 0: #000000,
+ 10: #001944,
+ 20: #002d6e,
+ 25: #053882,
+ 30: #1a448e,
+ 35: #29509a,
+ 40: #375ca7,
+ 50: #5275c2,
+ 60: #6d8fde,
+ 70: #87aafa,
+ 80: #b0c6ff,
+ 90: #d9e2ff,
+ 95: #edf0ff,
+ 98: #faf8ff,
+ 99: #fefbff,
+ 100: #ffffff
+ ),
+ secondary: (
+ 0: #000000,
+ 10: #092100,
+ 20: #153800,
+ 25: #1b4400,
+ 30: #215100,
+ 35: #285e00,
+ 40: #2f6c00,
+ 50: #458619,
+ 60: #5ea133,
+ 70: #77bd4c,
+ 80: #91d964,
+ 90: #acf67d,
+ 95: #ceffab,
+ 98: #eeffdd,
+ 99: #f8ffec,
+ 100: #ffffff
+ ),
+ tertiary: (
+ 0: #000000,
+ 10: #330043,
+ 20: #4d165e,
+ 25: #5a236a,
+ 30: #662f76,
+ 35: #733c83,
+ 40: #814890,
+ 50: #9c60ab,
+ 60: #b87ac6,
+ 70: #d494e3,
+ 80: #f1afff,
+ 90: #fbd7ff,
+ 95: #ffebfe,
+ 98: #fff7fb,
+ 99: #fffbff,
+ 100: #ffffff
+ ),
+ neutral: (
+ 0: #000000,
+ 10: #1a1b20,
+ 20: #2f3036,
+ 25: #3a3b41,
+ 30: #45464c,
+ 35: #515258,
+ 40: #5d5e64,
+ 50: #76777d,
+ 60: #909097,
+ 70: #ababb1,
+ 80: #c6c6cd,
+ 90: #e2e2e9,
+ 95: #f1f0f7,
+ 98: #faf8ff,
+ 99: #fefbff,
+ 100: #ffffff,
+ 4: #0c0e13,
+ 6: #121318,
+ 12: #1e1f24,
+ 17: #282a2f,
+ 22: #33353a,
+ 24: #38393e,
+ 87: #dad9e0,
+ 92: #e8e7ee,
+ 94: #eeedf4,
+ 96: #f4f3fa
+ ),
+ neutral-variant: (
+ 0: #000000,
+ 10: #181b25,
+ 20: #2d303a,
+ 25: #383b45,
+ 30: #434651,
+ 35: #4f525d,
+ 40: #5b5e69,
+ 50: #747782,
+ 60: #8e909c,
+ 70: #a8abb7,
+ 80: #c4c6d3,
+ 90: #e0e2ef,
+ 95: #eef0fd,
+ 98: #faf8ff,
+ 99: #fefbff,
+ 100: #ffffff
+ ),
+ error: (
+ 0: #000000,
+ 10: #410002,
+ 20: #690005,
+ 25: #7e0007,
+ 30: #93000a,
+ 35: #a80710,
+ 40: #ba1a1a,
+ 50: #de3730,
+ 60: #ff5449,
+ 70: #ff897d,
+ 80: #ffb4ab,
+ 90: #ffdad6,
+ 95: #ffedea,
+ 98: #fff8f7,
+ 99: #fffbff,
+ 100: #ffffff
+ )
+);
+
+$_rest: (
+ secondary: map.get($_palettes, secondary),
+ neutral: map.get($_palettes, neutral),
+ neutral-variant: map.get($_palettes, neutral-variant),
+ error: map.get($_palettes, error)
+);
+
+$primary-palette: map.merge(map.get($_palettes, primary), $_rest);
+$tertiary-palette: map.merge(map.get($_palettes, tertiary), $_rest);
diff --git a/projects/ppwcode/.storybook/styles.scss b/projects/ppwcode/.storybook/styles.scss
new file mode 100644
index 00000000..54b52c15
--- /dev/null
+++ b/projects/ppwcode/.storybook/styles.scss
@@ -0,0 +1,7 @@
+@use '@angular/material' as mat;
+@use 'ppw-ds' as ppwDs;
+@use './theme-colors' as themeColors;
+
+:root {
+ @include ppwDs.ppw-ds-material-color-tokens(themeColors.$primary-palette, themeColors.$tertiary-palette);
+}
diff --git a/projects/ppwcode/ng-common-components/package.json b/projects/ppwcode/ng-common-components/package.json
index 1ff330a4..4fc3bf08 100644
--- a/projects/ppwcode/ng-common-components/package.json
+++ b/projects/ppwcode/ng-common-components/package.json
@@ -11,6 +11,9 @@
"@ppwcode/ng-common": "^22.2.1",
"@ppwcode/ng-utils": "^22.2.1"
},
+ "optionalDependencies": {
+ "@ppwcode/ng-ppw-ds": "0.0.4"
+ },
"dependencies": {
"tslib": "^2.3.0"
},
diff --git a/projects/ppwcode/ng-common-components/src/lib/notifications/notification.ts b/projects/ppwcode/ng-common-components/src/lib/notifications/notification.ts
new file mode 100644
index 00000000..a36e94f8
--- /dev/null
+++ b/projects/ppwcode/ng-common-components/src/lib/notifications/notification.ts
@@ -0,0 +1,33 @@
+import { Subscription } from 'rxjs'
+
+/** The different types of supported notifications. */
+export type NotificationType = 'success' | 'error' | 'warning' | 'info'
+
+/**
+ * Represents a notification instance shown to the user.
+ */
+export interface Notification {
+ /** A random id, so we can distinguish it from other notifications. */
+ id: string
+ /** The body of the notification. */
+ body: NotificationBody
+ /** The type of the notification. */
+ type: NotificationType
+ /** The automatic removal delay in milliseconds. A value of 0 means the notification is indefinite. */
+ timeout: number
+ /**
+ * A subscription to the timer that will automatically close the notification.
+ * This is to be unsubscribed explicitly when closing the notification before the timer has passed.
+ */
+ timerSubscription: Subscription
+}
+
+/**
+ * The body of the notification.
+ */
+export interface NotificationBody {
+ /** A translation key to be translated into a notification text. */
+ messageKey: string
+ /** Optional parameters to be used within the translated text. */
+ messageParams?: object
+}
diff --git a/projects/ppwcode/ng-common-components/src/lib/notifications/notification/notification.component.html b/projects/ppwcode/ng-common-components/src/lib/notifications/notification/notification.component.html
new file mode 100644
index 00000000..4b30fc20
--- /dev/null
+++ b/projects/ppwcode/ng-common-components/src/lib/notifications/notification/notification.component.html
@@ -0,0 +1,34 @@
+
+
+@if (timeout() > 0) {
+
+}
diff --git a/projects/ppwcode/ng-common-components/src/lib/notifications/notification/notification.component.scss b/projects/ppwcode/ng-common-components/src/lib/notifications/notification/notification.component.scss
new file mode 100644
index 00000000..b9e216d4
--- /dev/null
+++ b/projects/ppwcode/ng-common-components/src/lib/notifications/notification/notification.component.scss
@@ -0,0 +1,109 @@
+@use '@angular/material' as mat;
+
+:host {
+ display: flex;
+ flex-direction: column;
+ border-radius: var(--ppw-ds-radius-small);
+ width: 100%;
+ min-height: 49px;
+ background: var(--ppw-ds-neutral-100);
+ @include mat.elevation(1);
+
+ --status-icon-color: var(--ppw-ds-neutral-600);
+ --status-icon-background-color: var(--ppw-ds-neutral-300);
+
+ &.notification--info {
+ --status-icon-color: var(--ppw-ds-neutral-600);
+ --status-icon-background-color: var(--ppw-ds-neutral-300);
+ }
+
+ &.notification--warning {
+ --status-icon-color: var(--ppw-ds-warning-600);
+ --status-icon-background-color: var(--ppw-ds-warning-300);
+ }
+
+ &.notification--success {
+ --status-icon-color: var(--ppw-ds-success-600);
+ --status-icon-background-color: var(--ppw-ds-success-300);
+ }
+
+ &.notification--error {
+ --status-icon-color: var(--ppw-ds-error-600);
+ --status-icon-background-color: var(--ppw-ds-error-300);
+ }
+}
+
+.ppw-notification-content {
+ display: flex;
+ flex-direction: row;
+ width: 100%;
+}
+
+.ppw-notification-content__status-icon-wrapper {
+ padding: var(--ppw-ds-spacing-4);
+ border-top-left-radius: var(--ppw-ds-radius-small);
+ color: var(--status-icon-color);
+ background-color: var(--status-icon-background-color);
+ font-size: 2em;
+
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ align-items: center;
+ align-self: stretch;
+
+ &:not(&--with-timer) {
+ border-bottom-left-radius: var(--ppw-ds-radius-small);
+ }
+}
+
+.ppw-notification-content__message {
+ display: flex;
+ flex: 1;
+ flex-direction: row;
+ align-items: center;
+
+ padding: var(--ppw-ds-spacing-4);
+ word-break: break-word;
+}
+
+.ppw-notification-content__message__content {
+ display: flex;
+ flex-direction: column;
+ gap: var(--ppw-ds-spacing-2);
+ flex: 1;
+}
+
+.ppw-notification-content__message__close-button {
+ cursor: pointer;
+}
+
+.ppw-notification-timer {
+ height: 4px;
+ flex-shrink: 0;
+ overflow: hidden;
+ border-bottom-right-radius: var(--ppw-ds-radius-small);
+ border-bottom-left-radius: var(--ppw-ds-radius-small);
+ background-color: var(--ppw-ds-neutral-200);
+
+ &::after {
+ display: block;
+ width: 100%;
+ height: 100%;
+ background-color: var(--status-icon-background-color);
+ content: '';
+ transform: scaleX(1);
+ transform-origin: left;
+ animation: ppw-notification-timer var(--ppw-notification-timeout) linear forwards;
+ }
+}
+
+:host(:hover) .ppw-notification-timer::after {
+ animation-play-state: paused;
+}
+
+@keyframes ppw-notification-timer {
+ to {
+ transform: scaleX(0);
+ }
+}
diff --git a/projects/ppwcode/ng-common-components/src/lib/notifications/notification/notification.component.spec.ts b/projects/ppwcode/ng-common-components/src/lib/notifications/notification/notification.component.spec.ts
new file mode 100644
index 00000000..ff3be17e
--- /dev/null
+++ b/projects/ppwcode/ng-common-components/src/lib/notifications/notification/notification.component.spec.ts
@@ -0,0 +1,98 @@
+import { ComponentFixture } from '@angular/core/testing'
+import {
+ expectOutputEventEmits,
+ getByTestId,
+ prepareAndInstantiateDefaultTestComponent
+} from '@ppwcode/ng-unit-testing'
+import { NotificationType } from '../notification'
+import { NotificationComponent } from './notification.component'
+
+describe('NotificationComponent', () => {
+ let component: NotificationComponent
+ let fixture: ComponentFixture
+
+ const createComponent = async (type: NotificationType = 'success', timeout = 7500): Promise => {
+ fixture = await prepareAndInstantiateDefaultTestComponent(
+ NotificationComponent,
+ {
+ type,
+ timeout,
+ body: {
+ messageKey: 'notifications.saved',
+ messageParams: { name: 'Lunch' }
+ }
+ },
+ {
+ translations: {
+ notifications: {
+ saved: 'Saved {{ name }}'
+ }
+ },
+ language: 'en'
+ }
+ )
+ component = fixture.componentInstance
+ }
+
+ it('should create', async () => {
+ await createComponent()
+
+ expect(component).toBeTruthy()
+ })
+
+ it.each([
+ ['info', 'info'],
+ ['warning', 'warning'],
+ ['success', 'check_circle'],
+ ['error', 'dangerous']
+ ] satisfies Array<[NotificationType, string]>)('should render the %s icon', async (type, icon) => {
+ await createComponent(type)
+
+ const statusIcon = getByTestId(fixture, 'ppw-notification__icon').textContent
+ expect(statusIcon.trim()).toBe(icon)
+ })
+
+ it('should apply the notification type host class', async () => {
+ await createComponent('warning')
+
+ expect(fixture.nativeElement.classList.contains('notification--warning')).toBe(true)
+ })
+
+ it('should render the translated message with parameters', async () => {
+ await createComponent()
+
+ const message = getByTestId(fixture, 'ppw-notification__content').textContent
+ expect(message.trim()).toBe('Saved Lunch')
+ })
+
+ it('should render the timer with the configured timeout', async () => {
+ await createComponent('success', 1000)
+
+ const timer = getByTestId(fixture, 'ppw-notification__timer')
+ const iconWrapper = fixture.nativeElement.querySelector('.ppw-notification-content__status-icon-wrapper')
+
+ expect(timer.style.getPropertyValue('--ppw-notification-timeout')).toBe('1000ms')
+ expect(iconWrapper.classList.contains('ppw-notification-content__status-icon-wrapper--with-timer')).toBe(true)
+ })
+
+ it('should not render a timer for an indefinite notification', async () => {
+ await createComponent('success', 0)
+
+ expect(fixture.nativeElement.querySelector('[data-testid="ppw-notification__timer"]')).toBeNull()
+ expect(
+ fixture.nativeElement
+ .querySelector('.ppw-notification-content__status-icon-wrapper')
+ .classList.contains('ppw-notification-content__status-icon-wrapper--with-timer')
+ ).toBe(false)
+ })
+
+ it('should emit when the close button is clicked', async () => {
+ await createComponent()
+
+ expectOutputEventEmits({
+ outputEvent: component.closeNotification,
+ when: () => getByTestId(fixture, 'ppw-notification__close-button').click(),
+ expectedValue: undefined
+ })
+ })
+})
diff --git a/projects/ppwcode/ng-common-components/src/lib/notifications/notification/notification.component.ts b/projects/ppwcode/ng-common-components/src/lib/notifications/notification/notification.component.ts
new file mode 100644
index 00000000..f5302cd7
--- /dev/null
+++ b/projects/ppwcode/ng-common-components/src/lib/notifications/notification/notification.component.ts
@@ -0,0 +1,49 @@
+import { Component, computed, input, output } from '@angular/core'
+import { MatIcon } from '@angular/material/icon'
+import { TranslatePipe } from '@ngx-translate/core'
+import { NotificationBody, NotificationType } from '../notification'
+
+/**
+ * Displays a single notification, used within the NotificationsComponent.
+ *
+ * @example
+ * ```
+ * Don't use this component explicitly.
+ * ```
+ */
+@Component({
+ selector: 'ppw-notification',
+ imports: [MatIcon, TranslatePipe],
+ templateUrl: './notification.component.html',
+ styleUrl: './notification.component.scss',
+ host: {
+ '[class]': '"notification--" + type()'
+ }
+})
+export class NotificationComponent {
+ /** The type of the notification, used for styling. */
+ public readonly type = input.required()
+
+ /** The body of the notification, this will be translated into readable text. */
+ public readonly body = input.required()
+
+ /** The automatic removal delay in milliseconds. A value of 0 means the notification is indefinite. */
+ public readonly timeout = input.required()
+
+ /** Emits when the user clicks the close button. */
+ public readonly closeNotification = output()
+
+ /** The icon to render in the notification, based on the type of notification. */
+ protected readonly icon = computed(() => {
+ switch (this.type()) {
+ case 'info':
+ return 'info'
+ case 'warning':
+ return 'warning'
+ case 'success':
+ return 'check_circle'
+ case 'error':
+ return 'dangerous'
+ }
+ })
+}
diff --git a/projects/ppwcode/ng-common-components/src/lib/notifications/notifications.service.spec.ts b/projects/ppwcode/ng-common-components/src/lib/notifications/notifications.service.spec.ts
new file mode 100644
index 00000000..e5bea5c3
--- /dev/null
+++ b/projects/ppwcode/ng-common-components/src/lib/notifications/notifications.service.spec.ts
@@ -0,0 +1,212 @@
+import { Overlay } from '@angular/cdk/overlay'
+import { TestBed } from '@angular/core/testing'
+import { NotificationsService } from './notifications.service'
+
+describe('NotificationsService', () => {
+ let service: NotificationsService
+ let overlayRef: {
+ attach: ReturnType
+ detach: ReturnType
+ hasAttached: ReturnType
+ }
+
+ beforeEach(() => {
+ vi.useFakeTimers()
+ overlayRef = {
+ attach: vi.fn(),
+ detach: vi.fn(),
+ hasAttached: vi.fn(() => false)
+ }
+
+ const globalPositionStrategy = {
+ top: vi.fn(() => globalPositionStrategy),
+ right: vi.fn()
+ }
+ const overlay = {
+ position: vi.fn(() => ({
+ global: vi.fn(() => globalPositionStrategy)
+ })),
+ create: vi.fn(() => overlayRef)
+ }
+
+ TestBed.configureTestingModule({
+ providers: [
+ NotificationsService,
+ {
+ provide: Overlay,
+ useValue: overlay
+ }
+ ]
+ })
+
+ service = TestBed.inject(NotificationsService)
+ })
+
+ afterEach(() => {
+ service.notifications().forEach((notification) => notification.timerSubscription.unsubscribe())
+ vi.useRealTimers()
+ })
+
+ it('should create', () => {
+ expect(service).toBeTruthy()
+ })
+
+ it('should add success notifications from a message key', () => {
+ service.success('notifications.saved')
+
+ expect(service.notifications()).toEqual([
+ expect.objectContaining({
+ type: 'success',
+ body: { messageKey: 'notifications.saved' }
+ })
+ ])
+ expect(overlayRef.attach).toHaveBeenCalledOnce()
+ })
+
+ it('should add notifications with message parameters', () => {
+ service.warning({
+ messageKey: 'notifications.saved',
+ messageParams: { name: 'Lunch' }
+ })
+
+ expect(service.notifications()[0]).toEqual(
+ expect.objectContaining({
+ type: 'warning',
+ body: {
+ messageKey: 'notifications.saved',
+ messageParams: { name: 'Lunch' }
+ }
+ })
+ )
+ })
+
+ it('should support all notification types', () => {
+ const notifications = [
+ service.success('notifications.success'),
+ service.error('notifications.error'),
+ service.info('notifications.info'),
+ service.warning('notifications.warning')
+ ]
+
+ expect(notifications.map((notification) => notification.type)).toEqual(['success', 'error', 'info', 'warning'])
+ expect(service.notifications()).toEqual(notifications)
+ })
+
+ it('should attach the notifications portal only once while notifications are visible', () => {
+ service.success('notifications.first')
+ overlayRef.hasAttached.mockReturnValue(true)
+ service.error('notifications.second')
+
+ expect(overlayRef.attach).toHaveBeenCalledOnce()
+ })
+
+ it('should remove notifications explicitly and detach when the last one is gone', () => {
+ service.success('notifications.first')
+ service.error('notifications.second')
+ const [firstNotification, secondNotification] = service.notifications()
+ const unsubscribe = vi.spyOn(firstNotification.timerSubscription, 'unsubscribe')
+
+ service.remove(firstNotification)
+
+ expect(unsubscribe).toHaveBeenCalledOnce()
+ expect(service.notifications()).toEqual([secondNotification])
+ expect(overlayRef.detach).not.toHaveBeenCalled()
+
+ service.remove(secondNotification)
+
+ expect(service.notifications()).toEqual([])
+ expect(overlayRef.detach).toHaveBeenCalledOnce()
+ })
+
+ it('should remove a notification after the timeout', () => {
+ service.info('notifications.wait')
+
+ vi.runAllTimers()
+
+ expect(service.notifications()).toEqual([])
+ expect(overlayRef.detach).toHaveBeenCalledOnce()
+ })
+
+ it('should keep an indefinite info notification until it is removed explicitly', () => {
+ const notification = service.info('notifications.wait', 0)
+
+ vi.runAllTimers()
+
+ expect(service.notifications()).toEqual([notification])
+
+ service.remove(notification)
+
+ expect(service.notifications()).toEqual([])
+ expect(overlayRef.detach).toHaveBeenCalledOnce()
+ })
+
+ it('should cancel the timeout when a notification is removed explicitly', () => {
+ service.info('notifications.wait')
+ const notification = service.notifications()[0]
+
+ service.remove(notification)
+ vi.runAllTimers()
+
+ expect(service.notifications()).toEqual([])
+ expect(overlayRef.detach).toHaveBeenCalledOnce()
+ })
+
+ it('should pause and resume a notification with its remaining timeout', () => {
+ const notification = service.info('notifications.wait', 1000)
+
+ vi.advanceTimersByTime(400)
+ service.pause(notification)
+ vi.advanceTimersByTime(1000)
+
+ expect(service.notifications()).toEqual([notification])
+
+ service.resume(notification)
+ vi.advanceTimersByTime(599)
+
+ expect(service.notifications()).toEqual([notification])
+
+ vi.advanceTimersByTime(1)
+
+ expect(service.notifications()).toEqual([])
+ })
+
+ it('should make repeated pause and resume calls safe', () => {
+ const notification = service.info('notifications.wait', 1000)
+
+ vi.advanceTimersByTime(400)
+ service.pause(notification)
+ service.pause(notification)
+ service.resume(notification)
+ service.resume(notification)
+ vi.advanceTimersByTime(599)
+
+ expect(service.notifications()).toEqual([notification])
+
+ vi.advanceTimersByTime(1)
+
+ expect(service.notifications()).toEqual([])
+ })
+
+ it('should ignore pause and resume for an indefinite notification', () => {
+ const notification = service.info('notifications.wait', 0)
+
+ service.pause(notification)
+ service.resume(notification)
+ vi.runAllTimers()
+
+ expect(service.notifications()).toEqual([notification])
+ })
+
+ it('should cancel a paused timeout when a notification is removed explicitly', () => {
+ const notification = service.info('notifications.wait', 1000)
+
+ vi.advanceTimersByTime(400)
+ service.pause(notification)
+ service.remove(notification)
+ service.resume(notification)
+ vi.runAllTimers()
+
+ expect(service.notifications()).toEqual([])
+ expect(overlayRef.detach).toHaveBeenCalledOnce()
+ })
+})
diff --git a/projects/ppwcode/ng-common-components/src/lib/notifications/notifications.service.ts b/projects/ppwcode/ng-common-components/src/lib/notifications/notifications.service.ts
new file mode 100644
index 00000000..25e7610f
--- /dev/null
+++ b/projects/ppwcode/ng-common-components/src/lib/notifications/notifications.service.ts
@@ -0,0 +1,222 @@
+import { GlobalPositionStrategy, Overlay } from '@angular/cdk/overlay'
+import { ComponentPortal } from '@angular/cdk/portal'
+import { inject, Service, signal } from '@angular/core'
+import { NEVER, Subscription, timer } from 'rxjs'
+import { Notification, NotificationBody, NotificationType } from './notification'
+import { NotificationsComponent } from './notifications/notifications.component'
+import { PPWCODE_COMMON_COMPONENTS_OPTIONS } from '../providers'
+
+const DEFAULT_NOTIFICATION_TIMEOUT = 7500
+const defaultPositionConfigurator = (strategy: GlobalPositionStrategy): GlobalPositionStrategy =>
+ strategy.top('var(--ppw-ds-spacing-gutter)').right('var(--ppw-ds-spacing-gutter)')
+
+interface NotificationTimerState {
+ remaining: number
+ startedAt: number
+ paused: boolean
+}
+
+/**
+ * This service handles showing notifications to the user and managing the overlay used for it.
+ */
+@Service()
+export class NotificationsService {
+ readonly #overlay = inject(Overlay)
+ readonly #notificationConfiguration = inject(PPWCODE_COMMON_COMPONENTS_OPTIONS).notifications
+
+ readonly #positionConfigurator =
+ this.#notificationConfiguration?.positionConfigurator ?? defaultPositionConfigurator
+ readonly #defaultTimeout = this.#notificationConfiguration?.defaultTimeout ?? DEFAULT_NOTIFICATION_TIMEOUT
+
+ // Position the notifications on the screen.
+ readonly #position = this.#positionConfigurator(this.#overlay.position().global())
+
+ // Overlay that is laid on top of the application.
+ readonly #overlayRef = this.#overlay.create({ positionStrategy: this.#position })
+
+ // Portal for the NotificationsComponent that will render the notifications. This portal will be attached to
+ // and detached from the overlay when necessary to keep the DOM as clean as possible.
+ readonly #notificationsPortal = new ComponentPortal(NotificationsComponent)
+
+ // Private array that keeps track of the list of notifications. Prevents manipulation of the notifications outside
+ // of the service.
+ readonly #notifications = signal>([])
+
+ // Private timer state used to pause and resume notification timers without exposing timer implementation details.
+ readonly #timerStates = new WeakMap()
+
+ // Public readonly signal containing the notifications to display.
+ public notifications = this.#notifications.asReadonly()
+
+ /**
+ * Add a notification of type success with the given body.
+ * @param body The translation key or an object with the body settings.
+ * @param timeout The automatic removal delay in milliseconds. Use 0 for an indefinite notification.
+ * @returns The notification so callers can remove indefinite notifications explicitly.
+ */
+ public success(body: string | NotificationBody, timeout = this.#defaultTimeout): Notification {
+ return this.#addNotification('success', body, timeout)
+ }
+
+ /**
+ * Add a notification of type error with the given body.
+ * @param body The translation key or an object with the body settings.
+ * @param timeout The automatic removal delay in milliseconds. Use 0 for an indefinite notification.
+ * @returns The notification so callers can remove indefinite notifications explicitly.
+ */
+ public error(body: string | NotificationBody, timeout = this.#defaultTimeout): Notification {
+ return this.#addNotification('error', body, timeout)
+ }
+
+ /**
+ * Add a notification of type info with the given body.
+ * @param body The translation key or an object with the body settings.
+ * @param timeout The automatic removal delay in milliseconds. Use 0 for an indefinite notification.
+ * @returns The notification so callers can remove indefinite notifications explicitly.
+ */
+ public info(body: string | NotificationBody, timeout = this.#defaultTimeout): Notification {
+ return this.#addNotification('info', body, timeout)
+ }
+
+ /**
+ * Add a notification of type warning with the given body.
+ * @param body The translation key or an object with the body settings.
+ * @param timeout The automatic removal delay in milliseconds. Use 0 for an indefinite notification.
+ * @returns The notification so callers can remove indefinite notifications explicitly.
+ */
+ public warning(body: string | NotificationBody, timeout = this.#defaultTimeout): Notification {
+ return this.#addNotification('warning', body, timeout)
+ }
+
+ /**
+ * Cancels the automatic removal and removes the notification explicitly
+ * @param notification The notification to remove.
+ */
+ public remove(notification: Notification): void {
+ notification.timerSubscription.unsubscribe()
+ this.#timerStates.delete(notification)
+ this.#removeNotification(notification.id)
+ }
+
+ /**
+ * Pauses the automatic removal timer for a notification.
+ * @param notification The notification whose timer should be paused.
+ */
+ public pause(notification: Notification): void {
+ const timerState = this.#timerStates.get(notification)
+ if (!timerState || timerState.paused) {
+ return
+ }
+
+ timerState.remaining = Math.max(0, timerState.remaining - (Date.now() - timerState.startedAt))
+ timerState.paused = true
+ notification.timerSubscription.unsubscribe()
+ }
+
+ /**
+ * Resumes the automatic removal timer for a notification.
+ * @param notification The notification whose timer should be resumed.
+ */
+ public resume(notification: Notification): void {
+ const timerState = this.#timerStates.get(notification)
+ if (!timerState || !timerState.paused) {
+ return
+ }
+
+ timerState.paused = false
+ this.#startTimer(notification)
+ }
+
+ /**
+ * Add a notification to the list of notifications and remove it after 7500ms.
+ * Tries to attach the portal to the overlay.
+ * @param type The type of the notification.
+ * @param body The body of the notification. When a string is given it will be converted into NotificationBody.
+ * @param timeout The automatic removal delay in milliseconds. Use 0 to disable automatic removal.
+ * @returns The added notification.
+ */
+ #addNotification(
+ type: NotificationType,
+ body: string | NotificationBody,
+ timeout = this.#defaultTimeout
+ ): Notification {
+ const id = window.crypto.randomUUID()
+ const notification: Notification = {
+ id,
+ type,
+ body: this.#toNotificationBody(body),
+ timeout,
+ timerSubscription: timeout === 0 ? NEVER.subscribe() : Subscription.EMPTY
+ }
+
+ if (timeout !== 0) {
+ this.#timerStates.set(notification, {
+ remaining: Math.max(timeout, 0),
+ startedAt: Date.now(),
+ paused: false
+ })
+ this.#startTimer(notification)
+ }
+
+ this.#notifications.update((notifications) => [...notifications, notification])
+
+ this.#tryAttachPortal()
+
+ return notification
+ }
+
+ /**
+ * Remove a notification from the list of notifications.
+ * Tries to detach the portal from the overlay.
+ * @param id The id of the notification to remove.
+ */
+ #removeNotification(id: string): void {
+ const notification = this.#notifications().find((currentNotification) => currentNotification.id === id)
+ if (notification) {
+ this.#timerStates.delete(notification)
+ }
+
+ this.#notifications.update((notifications) => notifications.filter((notification) => notification.id !== id))
+
+ this.#tryDetachPortal()
+ }
+
+ /**
+ * Ensure that the given body is a NotificationBody. When a string is given, an object is created and returned.
+ * @param body The body that should be a NotificationBody.
+ */
+ #toNotificationBody(body: string | NotificationBody): NotificationBody {
+ return typeof body === 'string' ? { messageKey: body } : body
+ }
+
+ /** Starts a timer using the notification's currently remaining duration. */
+ #startTimer(notification: Notification): void {
+ const timerState = this.#timerStates.get(notification)
+ if (!timerState) {
+ return
+ }
+
+ timerState.startedAt = Date.now()
+ notification.timerSubscription = timer(timerState.remaining).subscribe(() =>
+ this.#removeNotification(notification.id)
+ )
+ }
+
+ /**
+ * Attaches the notifications portal to the overlay if it is not already attached.
+ */
+ #tryAttachPortal(): void {
+ if (!this.#overlayRef.hasAttached()) {
+ this.#overlayRef.attach(this.#notificationsPortal)
+ }
+ }
+
+ /**
+ * Detaches the notifications portal from the overlay if there are no notifications left.
+ */
+ #tryDetachPortal(): void {
+ if (this.#notifications().length === 0) {
+ this.#overlayRef.detach()
+ }
+ }
+}
diff --git a/projects/ppwcode/ng-common-components/src/lib/notifications/notifications/notifications.component.html b/projects/ppwcode/ng-common-components/src/lib/notifications/notifications/notifications.component.html
new file mode 100644
index 00000000..26cf969f
--- /dev/null
+++ b/projects/ppwcode/ng-common-components/src/lib/notifications/notifications/notifications.component.html
@@ -0,0 +1,10 @@
+@for (notification of notifications(); track notification.id) {
+
+}
diff --git a/projects/ppwcode/ng-common-components/src/lib/notifications/notifications/notifications.component.scss b/projects/ppwcode/ng-common-components/src/lib/notifications/notifications/notifications.component.scss
new file mode 100644
index 00000000..452a0c06
--- /dev/null
+++ b/projects/ppwcode/ng-common-components/src/lib/notifications/notifications/notifications.component.scss
@@ -0,0 +1,7 @@
+:host {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: var(--ppw-ds-spacing-gutter);
+ width: min(500px, 80vw);
+}
diff --git a/projects/ppwcode/ng-common-components/src/lib/notifications/notifications/notifications.component.spec.ts b/projects/ppwcode/ng-common-components/src/lib/notifications/notifications/notifications.component.spec.ts
new file mode 100644
index 00000000..952016e0
--- /dev/null
+++ b/projects/ppwcode/ng-common-components/src/lib/notifications/notifications/notifications.component.spec.ts
@@ -0,0 +1,98 @@
+import { signal } from '@angular/core'
+import { ComponentFixture, TestBed } from '@angular/core/testing'
+import { By } from '@angular/platform-browser'
+import { getByDirective, provideStaticTranslations } from '@ppwcode/ng-unit-testing'
+import { Subscription } from 'rxjs'
+import { Notification as SpiceNotification } from '../notification'
+import { NotificationComponent } from '../notification/notification.component'
+import { NotificationsService } from '../notifications.service'
+import { NotificationsComponent } from './notifications.component'
+
+describe('NotificationsComponent', () => {
+ let component: NotificationsComponent
+ let fixture: ComponentFixture
+ let notifications: ReturnType>>
+ let notificationsService: {
+ notifications: typeof notifications
+ remove: ReturnType
+ pause: ReturnType
+ resume: ReturnType
+ }
+
+ const createNotification = (id: string): SpiceNotification => ({
+ id,
+ type: 'success',
+ body: { messageKey: `notifications.${id}` },
+ timeout: 7500,
+ timerSubscription: new Subscription()
+ })
+
+ beforeEach(async () => {
+ notifications = signal>([])
+ notificationsService = {
+ notifications,
+ remove: vi.fn(),
+ pause: vi.fn(),
+ resume: vi.fn()
+ }
+
+ await TestBed.configureTestingModule({
+ imports: [NotificationsComponent],
+ providers: [
+ provideStaticTranslations({}),
+ {
+ provide: NotificationsService,
+ useValue: notificationsService
+ }
+ ]
+ }).compileComponents()
+
+ fixture = TestBed.createComponent(NotificationsComponent)
+ component = fixture.componentInstance
+ await fixture.whenStable()
+ })
+
+ it('should create', () => {
+ expect(component).toBeTruthy()
+ })
+
+ it('should render the notifications from the service', async () => {
+ notifications.set([createNotification('1'), createNotification('2')])
+ await fixture.whenStable()
+
+ expect(fixture.debugElement.queryAll(By.directive(NotificationComponent))).toHaveLength(2)
+ })
+
+ it('should remove a notification when the child emits closeNotification', async () => {
+ const notification = createNotification('1')
+ notifications.set([notification])
+ await fixture.whenStable()
+
+ const child = getByDirective(fixture, NotificationComponent)
+ child.closeNotification.emit()
+ await fixture.whenStable()
+
+ expect(notificationsService.remove).toHaveBeenCalledWith(notification)
+ })
+
+ it('should forward close requests to the service', () => {
+ const notification = createNotification('1')
+
+ component.close(notification)
+
+ expect(notificationsService.remove).toHaveBeenCalledWith(notification)
+ })
+
+ it('should pause and resume a notification timer when the pointer enters and leaves', async () => {
+ const notification = createNotification('1')
+ notifications.set([notification])
+ await fixture.whenStable()
+
+ const child = fixture.debugElement.query(By.directive(NotificationComponent))
+ child.triggerEventHandler('pointerenter', new PointerEvent('pointerenter'))
+ child.triggerEventHandler('pointerleave', new PointerEvent('pointerleave'))
+
+ expect(notificationsService.pause).toHaveBeenCalledWith(notification)
+ expect(notificationsService.resume).toHaveBeenCalledWith(notification)
+ })
+})
diff --git a/projects/ppwcode/ng-common-components/src/lib/notifications/notifications/notifications.component.ts b/projects/ppwcode/ng-common-components/src/lib/notifications/notifications/notifications.component.ts
new file mode 100644
index 00000000..a929293a
--- /dev/null
+++ b/projects/ppwcode/ng-common-components/src/lib/notifications/notifications/notifications.component.ts
@@ -0,0 +1,51 @@
+import { Component, inject } from '@angular/core'
+import { Notification } from '../notification'
+import { NotificationComponent } from '../notification/notification.component'
+import { NotificationsService } from '../notifications.service'
+
+/**
+ * Component responsible for displaying the notifications of the application.
+ * This component is attached to an OverlayRef through a ComponentPortal inside NotificationsService.
+ *
+ * It renders a list of notifications and handles clicking the close button of a notification.
+ *
+ * @example
+ * ```
+ * Don't use this component explicitly.
+ * ```
+ */
+@Component({
+ selector: 'ppw-notifications',
+ imports: [NotificationComponent],
+ templateUrl: './notifications.component.html',
+ styleUrl: './notifications.component.scss'
+})
+export class NotificationsComponent {
+ readonly #notificationsService = inject(NotificationsService)
+
+ public readonly notifications = this.#notificationsService.notifications
+
+ /**
+ * Requests closing the notification through the notification service.
+ * @param notification The notification to close.
+ */
+ public close(notification: Notification): void {
+ this.#notificationsService.remove(notification)
+ }
+
+ /**
+ * Pauses the timer while the pointer is over the notification.
+ * @param notification The notification whose timer should be paused.
+ */
+ public pause(notification: Notification): void {
+ this.#notificationsService.pause(notification)
+ }
+
+ /**
+ * Resumes the timer when the pointer leaves the notification.
+ * @param notification The notification whose timer should be resumed.
+ */
+ public resume(notification: Notification): void {
+ this.#notificationsService.resume(notification)
+ }
+}
diff --git a/projects/ppwcode/ng-common-components/src/lib/notifications/notifications/notifications.stories.ts b/projects/ppwcode/ng-common-components/src/lib/notifications/notifications/notifications.stories.ts
new file mode 100644
index 00000000..c15e39d1
--- /dev/null
+++ b/projects/ppwcode/ng-common-components/src/lib/notifications/notifications/notifications.stories.ts
@@ -0,0 +1,113 @@
+import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
+import { GlobalPositionStrategy, OverlayModule } from '@angular/cdk/overlay'
+import { MatButtonModule } from '@angular/material/button'
+import { applicationConfig, Meta, moduleMetadata, StoryObj } from '@storybook/angular'
+import { provideTranslateService } from '@ngx-translate/core'
+import { NotificationType } from '../notification'
+import { NotificationsService } from '../notifications.service'
+import { providePpwcodeCommonComponents } from '../../providers'
+
+const bottomCenterPositionConfigurator = (strategy: GlobalPositionStrategy): GlobalPositionStrategy =>
+ strategy.bottom('var(--ppw-ds-spacing-gutter)').centerHorizontally()
+
+@Component({
+ selector: 'ppw-notifications-example',
+ standalone: true,
+ changeDetection: ChangeDetectionStrategy.OnPush,
+ imports: [MatButtonModule],
+ template: `
+
+
Notifications are displayed using the configured viewport position.
+
+
+
+
+
+
+
+ `
+})
+class NotificationsExampleComponent {
+ readonly #notificationsService = inject(NotificationsService)
+
+ public showNotification(type: NotificationType): void {
+ switch (type) {
+ case 'success':
+ this.#notificationsService.success('Your changes were saved successfully.')
+ break
+ case 'error':
+ this.#notificationsService.error('The changes could not be saved.')
+ break
+ case 'warning':
+ this.#notificationsService.warning('Your session will expire soon.')
+ break
+ case 'info':
+ this.#notificationsService.info('A new version of the application is available.')
+ break
+ }
+ }
+}
+
+const meta: Meta = {
+ title: 'ng-common-components/Notifications',
+ component: NotificationsExampleComponent,
+ decorators: [
+ moduleMetadata({
+ imports: [NotificationsExampleComponent, OverlayModule]
+ }),
+ applicationConfig({
+ providers: [provideTranslateService(), NotificationsService]
+ })
+ ],
+ tags: ['autodocs'],
+ parameters: {
+ docs: {
+ description: {
+ component: `
+### When to use
+Use notifications to provide short-lived, non-blocking feedback about an action or system state. The NotificationsService displays them in the configured viewport position and removes them automatically after a short delay. Notifications can also be closed explicitly. The default position is the top-right corner.
+ `
+ }
+ }
+ }
+}
+
+export default meta
+type Story = StoryObj
+
+export const Service: Story = {}
+
+export const BottomCenter: Story = {
+ decorators: [
+ applicationConfig({
+ providers: [
+ providePpwcodeCommonComponents({
+ notifications: {
+ positionConfigurator: bottomCenterPositionConfigurator
+ }
+ })
+ ]
+ })
+ ]
+}
+
+export const ShortTimeout: Story = {
+ decorators: [
+ applicationConfig({
+ providers: [
+ providePpwcodeCommonComponents({
+ notifications: {
+ defaultTimeout: 2000
+ }
+ })
+ ]
+ })
+ ],
+ parameters: {
+ docs: {
+ description: {
+ story: 'Notifications in this story use a 2-second default timeout.'
+ }
+ }
+ }
+}
diff --git a/projects/ppwcode/ng-common-components/src/lib/providers.ts b/projects/ppwcode/ng-common-components/src/lib/providers.ts
index 4241cc31..6b9110b8 100644
--- a/projects/ppwcode/ng-common-components/src/lib/providers.ts
+++ b/projects/ppwcode/ng-common-components/src/lib/providers.ts
@@ -1,7 +1,12 @@
import { InjectionToken, ValueProvider } from '@angular/core'
+import { GlobalPositionStrategy } from '@angular/cdk/overlay'
export interface PpwcodeCommonComponentsProviderOptions {
translationKeys: PpwcodeCommonComponentsTranslationKeys
+ notifications?: {
+ positionConfigurator?: (strategy: GlobalPositionStrategy) => GlobalPositionStrategy
+ defaultTimeout?: number
+ }
}
export interface PpwcodeCommonComponentsTranslationKeys {
@@ -21,6 +26,7 @@ export type PartialPpwcodeCommonComponentsTranslationKeys = {
export type PartialPpwcodeCommonComponentsProviderOptions = {
translationKeys?: PartialPpwcodeCommonComponentsTranslationKeys
+ notifications?: PpwcodeCommonComponentsProviderOptions['notifications']
}
export const DEFAULT_PPWCODE_COMMON_COMPONENTS_TRANSLATION_KEYS: PpwcodeCommonComponentsTranslationKeys = {
@@ -49,6 +55,7 @@ export const providePpwcodeCommonComponents = (
provide: PPWCODE_COMMON_COMPONENTS_OPTIONS,
useValue: {
...options,
+ notifications: options?.notifications,
translationKeys: {
loader: {
...DEFAULT_PPWCODE_COMMON_COMPONENTS_TRANSLATION_KEYS.loader,
diff --git a/projects/ppwcode/ng-common-components/src/public-api.ts b/projects/ppwcode/ng-common-components/src/public-api.ts
index f28d2bff..cb76c98d 100644
--- a/projects/ppwcode/ng-common-components/src/public-api.ts
+++ b/projects/ppwcode/ng-common-components/src/public-api.ts
@@ -23,3 +23,6 @@ export * from './lib/dashboard-items/models/dashboard-item.model'
export * from './lib/dashboard-items/models/dashboard-item-action.model'
export * from './lib/dashboard-items/models/dashboard-options.model'
export * from './lib/dashboard-items/dashboard-items-table/dashboard-items-table.component'
+export * from './lib/notifications/notification'
+export * from './lib/notifications/notifications.service'
+export * from './lib/notifications/notifications/notifications.component'
diff --git a/projects/ppwcode/ng-ppw-ds/src/lib/scss/_colors.scss b/projects/ppwcode/ng-ppw-ds/src/lib/scss/_colors.scss
index 48b60e33..2ab4856e 100644
--- a/projects/ppwcode/ng-ppw-ds/src/lib/scss/_colors.scss
+++ b/projects/ppwcode/ng-ppw-ds/src/lib/scss/_colors.scss
@@ -43,11 +43,18 @@ color schemes based on the given Material design primary and tertiary palettes.
)
);
- // Material theming provides an error palette, but no success palette, so these are hardcoded for now.
- --ppw-ds-success-100: #b5e7cf;
- --ppw-ds-success-200: #92dcb8;
- --ppw-ds-success-300: #60cc97;
- --ppw-ds-success-400: #41c283;
- --ppw-ds-success-500: #11b364;
- --ppw-ds-success-600: #0fa35b;
+ // Material theming provides an error palette, but no success or warning palette, so these are hardcoded for now.
+ // values copied from: https://tw-elements.com/docs/standard/content-styles/colors/
+ --ppw-ds-success-100: #d6fae4;
+ --ppw-ds-success-200: #acf5c9;
+ --ppw-ds-success-300: #83f0ae;
+ --ppw-ds-success-400: #59ea93;
+ --ppw-ds-success-500: #1ce26b;
+ --ppw-ds-success-600: #118c42;
+ --ppw-ds-warning-100: #fbf2de;
+ --ppw-ds-warning-200: #f7e4be;
+ --ppw-ds-warning-300: #f4d79d;
+ --ppw-ds-warning-400: #f0c97d;
+ --ppw-ds-warning-500: #eab54c;
+ --ppw-ds-warning-600: #c48a17;
}