From 9d9dcfee94a8123575467278d1912b7692656b06 Mon Sep 17 00:00:00 2001 From: Glen Staes Date: Tue, 18 Aug 2026 15:31:00 +0200 Subject: [PATCH 1/3] Set up design system tokens to be available throughout Storybook --- angular.json | 2 + .../ppwcode/.storybook/_theme-colors.scss | 137 ++++++++++++++++++ projects/ppwcode/.storybook/styles.scss | 7 + 3 files changed, 146 insertions(+) create mode 100644 projects/ppwcode/.storybook/_theme-colors.scss create mode 100644 projects/ppwcode/.storybook/styles.scss 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); +} From 468b54199c705051105d158ba6e45b3c7f66a138 Mon Sep 17 00:00:00 2001 From: Glen Staes Date: Tue, 18 Aug 2026 15:31:13 +0200 Subject: [PATCH 2/3] Add notifications --- .../src/lib/notifications/notification.ts | 33 +++ .../notification/notification.component.html | 34 +++ .../notification/notification.component.scss | 109 +++++++++ .../notification.component.spec.ts | 98 ++++++++ .../notification/notification.component.ts | 49 ++++ .../notifications.service.spec.ts | 212 +++++++++++++++++ .../notifications/notifications.service.ts | 222 ++++++++++++++++++ .../notifications.component.html | 10 + .../notifications.component.scss | 7 + .../notifications.component.spec.ts | 98 ++++++++ .../notifications/notifications.component.ts | 51 ++++ .../notifications/notifications.stories.ts | 113 +++++++++ .../ng-common-components/src/lib/providers.ts | 7 + .../ng-common-components/src/public-api.ts | 3 + .../ng-ppw-ds/src/lib/scss/_colors.scss | 21 +- 15 files changed, 1060 insertions(+), 7 deletions(-) create mode 100644 projects/ppwcode/ng-common-components/src/lib/notifications/notification.ts create mode 100644 projects/ppwcode/ng-common-components/src/lib/notifications/notification/notification.component.html create mode 100644 projects/ppwcode/ng-common-components/src/lib/notifications/notification/notification.component.scss create mode 100644 projects/ppwcode/ng-common-components/src/lib/notifications/notification/notification.component.spec.ts create mode 100644 projects/ppwcode/ng-common-components/src/lib/notifications/notification/notification.component.ts create mode 100644 projects/ppwcode/ng-common-components/src/lib/notifications/notifications.service.spec.ts create mode 100644 projects/ppwcode/ng-common-components/src/lib/notifications/notifications.service.ts create mode 100644 projects/ppwcode/ng-common-components/src/lib/notifications/notifications/notifications.component.html create mode 100644 projects/ppwcode/ng-common-components/src/lib/notifications/notifications/notifications.component.scss create mode 100644 projects/ppwcode/ng-common-components/src/lib/notifications/notifications/notifications.component.spec.ts create mode 100644 projects/ppwcode/ng-common-components/src/lib/notifications/notifications/notifications.component.ts create mode 100644 projects/ppwcode/ng-common-components/src/lib/notifications/notifications/notifications.stories.ts 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 @@ +
+ + +
+
+ +
+ + close +
+
+ +@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; } From bac7f16fcf42a8a191b8da459ad1d2665764b73c Mon Sep 17 00:00:00 2001 From: Glen Staes Date: Tue, 18 Aug 2026 17:16:12 +0200 Subject: [PATCH 3/3] Add design system as dependency to common-components --- projects/ppwcode/ng-common-components/package.json | 3 +++ 1 file changed, 3 insertions(+) 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" },