Skip to content

Commit d60bc3f

Browse files
chrisjwalk-botCopilotchrisjwalk
authored
feat: replace snackbar with persistent notification centre (#63)
* feat: replace snackbar with notification centre (closes #62) - Add NotificationStore (NgRx Signal Store) with add/dismiss/markRead/ markAllRead methods, unreadCount computed signal, and autoDismissMs support for transient notifications - Add NotificationList component — shared panel content used by both CdkOverlay (desktop) and MatBottomSheet (mobile) - Add NotificationBell component — bell icon with matBadge count badge, opens CdkOverlay dropdown on desktop and MatBottomSheet on mobile, marks all read on open - Update SwUpdateStore to use NotificationStore instead of MatSnackBar; remove snackbar-specific state fields (message/action/snackbarConfig) - Add lib-notification-bell to MainToolbar - Provide NotificationStore alongside SwUpdateStore in app.ts Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * test(shared): add unit tests for NotificationBell and NotificationList Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(notification-bell): anchor overlay to button element; add min-height to empty panel Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(notification-bell): use click event target as overlay anchor Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(main-toolbar): move notification bell to far right, after nav links Bell is now rightmost item (logged out) or second from right next to logout (logged in), matching Facebook/LinkedIn pattern. This also fixes overlay positioning since the bell is now at the viewport edge. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(notification-bell): use global position strategy anchored to top-right Drops the flexibleConnectedTo approach entirely. Panel is now fixed at right:8px top:72px (below the toolbar), matching Facebook/LinkedIn style. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(notification-bell): widen panel to 380px and fix overlay top offset for 56px toolbar Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(notification-list): make host display:block so w-full fills overlay pane Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(notification-bell): flush top to 56px toolbar, right to 16px to clear scrollbar Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(styles): add scrollbar-gutter:stable to prevent layout shift on scroll Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(styles): move scrollbar-gutter:stable to main scroll container, not html Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * chore: remove scrollbar-gutter:stable, causes unwanted gap on non-scrolling pages Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat(debug): add secret /debug page with notification test buttons Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(notification-list): fix unread border and min-height - Use [class.border-l-4] + [class.border-l-blue-500] so border fully disappears on mark-read (border-neutral-100 was overriding border-l-transparent) - Move min-h off container onto empty state only Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(notification-list): restore unread dot indicator next to title Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(dx): add reload action to debug sw-update notification Mirrors the real sw-update store action so the debug page accurately represents the notification UX — shows the Reload button and triggers window.location.reload() when clicked. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: remove NavigationEnd polling from SwUpdateStore Polling checkForUpdate() on every NavigationEnd risked adding a new sw-update notification on each navigation while an update was pending. The Angular SW checks for updates automatically on load and via its own internal schedule — manual polling is unnecessary. Removes: checkForUpdate rxMethod, Router dependency, related tests. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: chrisjwalk <8442112+chrisjwalk@users.noreply.github.com>
1 parent 754a359 commit d60bc3f

16 files changed

Lines changed: 746 additions & 127 deletions

apps/web-app/src/app/app.routes.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,5 +18,10 @@ export const routes: Route[] = [
1818
path: 'login',
1919
loadChildren: () => import('@myorg/login').then((m) => m.loginRoutes),
2020
},
21+
{
22+
path: 'debug',
23+
loadChildren: () =>
24+
import('./debug/debug.routes').then((m) => m.debugRoutes),
25+
},
2126
{ path: '**', redirectTo: '' },
2227
];

apps/web-app/src/app/app.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { AuthStore } from '@myorg/auth';
66
import {
77
LayoutStore,
88
MainToolbar,
9+
NotificationStore,
910
Sidenav,
1011
SwUpdateStore,
1112
} from '@myorg/shared';
@@ -64,7 +65,7 @@ import { filter, pipe, tap } from 'rxjs';
6465
'data-testid': 'app-root',
6566
},
6667
changeDetection: ChangeDetectionStrategy.OnPush,
67-
providers: [SwUpdateStore],
68+
providers: [NotificationStore, SwUpdateStore],
6869
})
6970
export class App {
7071
readonly swUpdateStore = inject(SwUpdateStore);
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
import { Route } from '@angular/router';
2+
import { Debug } from './debug';
3+
4+
export const debugRoutes: Route[] = [{ path: '', component: Debug }];
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
2+
import { MatButton } from '@angular/material/button';
3+
import { MatDivider } from '@angular/material/divider';
4+
import { NotificationStore } from '@myorg/shared';
5+
6+
@Component({
7+
imports: [MatButton, MatDivider],
8+
template: `
9+
<div class="p-8 max-w-lg flex flex-col gap-4">
10+
<h1 class="text-2xl font-bold m-0">🛠 Debug Tools</h1>
11+
<p class="text-sm text-neutral-500 m-0">
12+
Not linked from the nav. Use to test features during development.
13+
</p>
14+
15+
<mat-divider />
16+
17+
<h2 class="text-base font-semibold m-0">Notifications</h2>
18+
19+
<div class="flex flex-wrap gap-2">
20+
<button mat-stroked-button (click)="addInfo()">Add info</button>
21+
<button mat-stroked-button (click)="addError()">Add error</button>
22+
<button mat-stroked-button (click)="addAuth()">Add auth</button>
23+
<button mat-stroked-button (click)="addSwUpdate()">
24+
Add sw-update
25+
</button>
26+
<button mat-stroked-button (click)="addWithAction()">
27+
Add with action
28+
</button>
29+
<button mat-stroked-button (click)="addAutoDismiss()">
30+
Add auto-dismiss (3s)
31+
</button>
32+
</div>
33+
34+
<div class="flex gap-2">
35+
<button mat-stroked-button color="warn" (click)="store.markAllRead()">
36+
Mark all read
37+
</button>
38+
<button mat-flat-button color="warn" (click)="clearAll()">
39+
Clear all
40+
</button>
41+
</div>
42+
43+
<p class="text-sm text-neutral-500 m-0">
44+
Unread: {{ store.unreadCount() }} / Total:
45+
{{ store.notifications().length }}
46+
</p>
47+
</div>
48+
`,
49+
changeDetection: ChangeDetectionStrategy.OnPush,
50+
})
51+
export class Debug {
52+
readonly store = inject(NotificationStore);
53+
54+
addInfo() {
55+
this.store.add({
56+
kind: 'info',
57+
title: 'Info notification',
58+
detail: 'This is a sample informational message.',
59+
});
60+
}
61+
62+
addError() {
63+
this.store.add({
64+
kind: 'error',
65+
title: 'Something went wrong',
66+
detail: 'An unexpected error occurred. Please try again.',
67+
});
68+
}
69+
70+
addAuth() {
71+
this.store.add({
72+
kind: 'auth',
73+
title: 'Session expiring soon',
74+
detail: 'You will be logged out in 5 minutes.',
75+
});
76+
}
77+
78+
addSwUpdate() {
79+
this.store.add({
80+
kind: 'sw-update',
81+
title: 'App update available',
82+
detail: 'A new version is ready. Reload to update.',
83+
action: {
84+
label: 'Reload',
85+
handler: () => window.location.reload(),
86+
},
87+
});
88+
}
89+
90+
addWithAction() {
91+
this.store.add({
92+
kind: 'info',
93+
title: 'Action required',
94+
detail: 'Click the button to perform an action.',
95+
action: {
96+
label: 'Do it',
97+
handler: () => alert('Action triggered!'),
98+
},
99+
});
100+
}
101+
102+
addAutoDismiss() {
103+
this.store.add({
104+
kind: 'info',
105+
title: 'Auto-dismissing notification',
106+
detail: 'This will disappear after 3 seconds.',
107+
autoDismissMs: 3000,
108+
});
109+
}
110+
111+
clearAll() {
112+
this.store.notifications().forEach((n) => this.store.dismiss(n.id));
113+
}
114+
}

libs/shared/src/lib/components/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
export * from './main-toolbar';
22
export * from './nav-links';
3+
export * from './notification-bell';
4+
export * from './notification-list';
35
export * from './page-container';
46
export * from './page-toolbar-button';
57
export * from './page-toolbar';

libs/shared/src/lib/components/main-toolbar.spec.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,17 @@
11
import { provideHttpClient } from '@angular/common/http';
22
import { provideHttpClientTesting } from '@angular/common/http/testing';
33
import { render, screen } from '@testing-library/angular';
4+
import { NotificationStore } from '../state/notification.store';
45
import { MainToolbar } from './main-toolbar';
56

67
describe('MainToolbar', () => {
78
it('should create', async () => {
89
await render(MainToolbar, {
9-
providers: [provideHttpClient(), provideHttpClientTesting()],
10+
providers: [
11+
provideHttpClient(),
12+
provideHttpClientTesting(),
13+
NotificationStore,
14+
],
1015
});
1116

1217
expect(screen.getByTestId('lib-main-toolbar')).toBeTruthy();

libs/shared/src/lib/components/main-toolbar.ts

Lines changed: 22 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,17 @@ import { MatTooltip } from '@angular/material/tooltip';
1111
import { RouterLink } from '@angular/router';
1212

1313
import { NAV_LINKS } from './nav-links';
14+
import { NotificationBell } from './notification-bell';
1415

1516
@Component({
16-
imports: [MatIcon, MatToolbar, MatTooltip, RouterLink, MatIconButton],
17+
imports: [
18+
MatIcon,
19+
MatToolbar,
20+
MatTooltip,
21+
RouterLink,
22+
MatIconButton,
23+
NotificationBell,
24+
],
1725
selector: 'lib-main-toolbar',
1826
template: `
1927
<mat-toolbar class="app-main-toolbar fixed top-0 w-full z-50 flex gap-2">
@@ -87,7 +95,7 @@ import { NAV_LINKS } from './nav-links';
8795
Demo App
8896
</a>
8997
<span class="flex-1"></span>
90-
<div class="hidden md:flex gap-2">
98+
<div class="hidden md:flex gap-2 items-center">
9199
@for (link of navLinks; track link.routerLink) {
92100
<button
93101
mat-icon-button
@@ -98,17 +106,19 @@ import { NAV_LINKS } from './nav-links';
98106
<mat-icon>{{ link.icon }}</mat-icon>
99107
</button>
100108
}
101-
@if (loggedIn()) {
102-
<button
103-
mat-icon-button
104-
(click)="logout.emit()"
105-
matTooltip="Log out"
106-
aria-label="Log out"
107-
>
108-
<mat-icon>logout</mat-icon>
109-
</button>
110-
}
111109
</div>
110+
<lib-notification-bell />
111+
@if (loggedIn()) {
112+
<button
113+
mat-icon-button
114+
class="hidden md:inline-flex"
115+
(click)="logout.emit()"
116+
matTooltip="Log out"
117+
aria-label="Log out"
118+
>
119+
<mat-icon>logout</mat-icon>
120+
</button>
121+
}
112122
</mat-toolbar>
113123
`,
114124
styles: [
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import { provideNoopAnimations } from '@angular/platform-browser/animations';
2+
import { fireEvent, render, screen } from '@testing-library/angular';
3+
import { NotificationStore } from '../state/notification.store';
4+
import { NotificationBell } from './notification-bell';
5+
6+
async function setup() {
7+
const { fixture } = await render(NotificationBell, {
8+
providers: [provideNoopAnimations(), NotificationStore],
9+
});
10+
const component = fixture.debugElement.componentInstance as NotificationBell;
11+
return { fixture, component };
12+
}
13+
14+
describe('NotificationBell', () => {
15+
it('should render the bell button', async () => {
16+
await setup();
17+
expect(screen.getByRole('button')).toBeTruthy();
18+
});
19+
20+
it('should hide badge when there are no unread notifications', async () => {
21+
await setup();
22+
const badgeHost = document.querySelector('.mat-badge-hidden');
23+
expect(badgeHost).toBeTruthy();
24+
});
25+
26+
it('should show badge count when there are unread notifications', async () => {
27+
const { fixture } = await setup();
28+
const store = fixture.debugElement.injector.get(NotificationStore);
29+
store.add({ kind: 'info', title: 'Test' });
30+
fixture.detectChanges();
31+
const badge = document.querySelector('.mat-badge-content');
32+
expect(badge?.textContent?.trim()).toBe('1');
33+
});
34+
35+
it('should call open() when bell is clicked', async () => {
36+
const { component } = await setup();
37+
vi.spyOn(component, 'open');
38+
await fireEvent.click(screen.getByRole('button'));
39+
expect(component.open).toHaveBeenCalled();
40+
});
41+
42+
it('should have aria-label describing unread count', async () => {
43+
const { fixture } = await setup();
44+
const store = fixture.debugElement.injector.get(NotificationStore);
45+
store.add({ kind: 'info', title: 'A' });
46+
store.add({ kind: 'info', title: 'B' });
47+
fixture.detectChanges();
48+
const button = screen.getByRole('button');
49+
expect(button.getAttribute('aria-label')).toContain('2 unread');
50+
});
51+
});
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import {
2+
ChangeDetectionStrategy,
3+
Component,
4+
ComponentRef,
5+
DestroyRef,
6+
Injector,
7+
computed,
8+
inject,
9+
} from '@angular/core';
10+
import { Overlay, OverlayRef } from '@angular/cdk/overlay';
11+
import { ComponentPortal } from '@angular/cdk/portal';
12+
import { Breakpoints, BreakpointObserver } from '@angular/cdk/layout';
13+
import { MatBottomSheet } from '@angular/material/bottom-sheet';
14+
import { MatBadgeModule } from '@angular/material/badge';
15+
import { MatIconButton } from '@angular/material/button';
16+
import { MatIcon } from '@angular/material/icon';
17+
import { NotificationStore } from '../state/notification.store';
18+
import { NotificationList } from './notification-list';
19+
20+
@Component({
21+
imports: [MatBadgeModule, MatIcon, MatIconButton],
22+
selector: 'lib-notification-bell',
23+
template: `
24+
<button
25+
mat-icon-button
26+
[matBadge]="store.unreadCount()"
27+
[matBadgeHidden]="store.unreadCount() === 0"
28+
matBadgeColor="warn"
29+
matBadgeSize="small"
30+
[attr.aria-label]="ariaLabel()"
31+
(click)="open()"
32+
>
33+
<mat-icon>notifications</mat-icon>
34+
</button>
35+
`,
36+
changeDetection: ChangeDetectionStrategy.OnPush,
37+
})
38+
export class NotificationBell {
39+
readonly store = inject(NotificationStore);
40+
41+
private readonly injector = inject(Injector);
42+
private readonly overlay = inject(Overlay);
43+
private readonly bottomSheet = inject(MatBottomSheet);
44+
private readonly breakpointObserver = inject(BreakpointObserver);
45+
private readonly destroyRef = inject(DestroyRef);
46+
47+
private overlayRef: OverlayRef | null = null;
48+
private panelRef: ComponentRef<NotificationList> | null = null;
49+
50+
readonly ariaLabel = computed(() => {
51+
const count = this.store.unreadCount();
52+
return count > 0
53+
? `${count} unread notification${count === 1 ? '' : 's'}`
54+
: 'Notifications';
55+
});
56+
57+
constructor() {
58+
this.destroyRef.onDestroy(() => this.overlayRef?.dispose());
59+
}
60+
61+
open(): void {
62+
if (this.breakpointObserver.isMatched(Breakpoints.Handset)) {
63+
this.bottomSheet.open(NotificationList, { injector: this.injector });
64+
} else {
65+
this.toggleOverlay();
66+
}
67+
}
68+
69+
private toggleOverlay(): void {
70+
if (this.overlayRef?.hasAttached()) {
71+
this.store.markAllRead();
72+
this.overlayRef.detach();
73+
return;
74+
}
75+
76+
if (!this.overlayRef) {
77+
this.overlayRef = this.overlay.create({
78+
hasBackdrop: true,
79+
backdropClass: 'cdk-overlay-transparent-backdrop',
80+
width: '380px',
81+
positionStrategy: this.overlay
82+
.position()
83+
.global()
84+
.right('16px')
85+
.top('56px'),
86+
scrollStrategy: this.overlay.scrollStrategies.reposition(),
87+
});
88+
89+
this.overlayRef.backdropClick().subscribe(() => {
90+
this.store.markAllRead();
91+
this.overlayRef?.detach();
92+
});
93+
}
94+
95+
this.panelRef = this.overlayRef.attach(
96+
new ComponentPortal(NotificationList, null, this.injector),
97+
);
98+
}
99+
}

0 commit comments

Comments
 (0)