feat(shell-common): add shared shell services library

Add new util library providing state management services for shell components:
- NavigationService: navigation drawer open/closed state
- FontSizeService: application-wide font size with document sync
- NotificationsService: notification state with read status tracking
This commit is contained in:
Lorenz Hilpert
2025-12-03 21:16:14 +01:00
parent 86b0493591
commit 062a8044f2
12 changed files with 541 additions and 0 deletions

201
libs/shell/common/README.md Normal file
View File

@@ -0,0 +1,201 @@
# shell-common
> **Type:** Util Library
> **Domain:** Shell
> **Path:** `libs/shell/common`
## Overview
Shared services and types for shell-domain components. Provides state management for navigation, font size, and notifications.
## Services
### NavigationService
Controls the navigation drawer open/closed state.
```typescript
import { NavigationService } from '@isa/shell/common';
@Component({...})
export class MyComponent {
navigationService = inject(NavigationService);
// Read state (readonly signal)
isOpen = this.navigationService.get;
// Toggle navigation
toggleNav() {
this.navigationService.toggle();
}
// Set specific state
closeNav() {
this.navigationService.set(false);
}
}
```
**API:**
- `get` - Readonly signal of navigation state (`boolean`)
- `toggle()` - Toggles navigation open/closed
- `set(state: boolean)` - Sets navigation state
### FontSizeService
Manages application-wide font size for accessibility.
```typescript
import { FontSizeService, FontSize } from '@isa/shell/common';
@Component({...})
export class MyComponent {
fontSizeService = inject(FontSizeService);
// Read current size
currentSize = this.fontSizeService.get;
// Get size in pixels
currentPx = this.fontSizeService.getPx;
// Change font size
setLarge() {
this.fontSizeService.set('large');
}
// Convert rem to px
getPixels(rem: number) {
return this.fontSizeService.remToPx(rem);
}
}
```
**API:**
- `get` - Readonly signal of current font size
- `getPx` - Computed signal of font size in pixels
- `set(size: FontSize)` - Sets font size
- `remToPx(rem: number)` - Converts rem to pixels
- `fontSizeEffect` - Effect that syncs font size to document
**Types:**
```typescript
type FontSize = 'small' | 'medium' | 'large';
// Maps to: 14px | 16px | 18px
```
### NotificationsService
Manages application notifications with read status tracking.
```typescript
import { NotificationsService, Notification } from '@isa/shell/common';
@Component({...})
export class MyComponent {
notificationsService = inject(NotificationsService);
// Read all notifications
notifications = this.notificationsService.get;
// Get unread count
unreadCount = this.notificationsService.unreadCount;
// Add notification
notify() {
this.notificationsService.add({
id: 'unique-id',
group: 'Orders',
title: 'New Order',
message: 'Order #123 received',
timestamp: Date.now(),
action: {
type: 'navigate',
label: 'View',
target: 'internal',
route: '/orders/123'
}
});
}
// Mark as read
markRead(id: string) {
this.notificationsService.markAsRead(id);
}
}
```
**API:**
- `get` - Readonly signal of all notifications
- `unreadCount` - Computed signal of unread count
- `add(notification: Notification)` - Adds notification
- `remove(id: NotificationId)` - Removes notification
- `clear()` - Removes all notifications
- `markAsRead(id: NotificationId)` - Marks single notification as read
- `markAllAsRead()` - Marks all notifications as read
## Types
### Notification
```typescript
type Notification = {
id: string | number;
group: string;
title: string;
message: string;
action: NotificationAction;
markedAsRead?: number; // timestamp
timestamp: number;
};
```
### NotificationAction
```typescript
type NotificationAction =
| NotificationActionNavigate
| NotificationActionCallback;
type NotificationActionNavigate = {
type: 'navigate';
label: string;
target: 'internal' | 'external';
route: string;
};
type NotificationActionCallback = {
type: 'callback';
label: string;
callback: () => void;
};
```
## Installation
```typescript
import {
NavigationService,
FontSizeService,
FontSize,
NotificationsService,
Notification
} from '@isa/shell/common';
```
## Dependencies
**Internal:**
- `@isa/core/logging` - Logger factory
## Testing
```bash
npx nx test shell-common
```
## Related Libraries
- [`@isa/shell/header`](../header) - Uses all services
- [`@isa/shell/layout`](../layout) - Uses NavigationService
- [`@isa/shell/notifications`](../notifications) - Uses NotificationsService
- [`@isa/shell/navigation`](../navigation) - Uses NavigationService

View File

@@ -0,0 +1,34 @@
const nx = require('@nx/eslint-plugin');
const baseConfig = require('../../../eslint.config.js');
module.exports = [
...baseConfig,
...nx.configs['flat/angular'],
...nx.configs['flat/angular-template'],
{
files: ['**/*.ts'],
rules: {
'@angular-eslint/directive-selector': [
'error',
{
type: 'attribute',
prefix: 'shell',
style: 'camelCase',
},
],
'@angular-eslint/component-selector': [
'error',
{
type: 'element',
prefix: 'shell',
style: 'kebab-case',
},
],
},
},
{
files: ['**/*.html'],
// Override or add rules here
rules: {},
},
];

View File

@@ -0,0 +1,20 @@
{
"name": "shell-common",
"$schema": "../../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "libs/shell/common/src",
"prefix": "shell",
"projectType": "library",
"tags": [],
"targets": {
"test": {
"executor": "@nx/vite:test",
"outputs": ["{options.reportsDirectory}"],
"options": {
"reportsDirectory": "../../../coverage/libs/shell/common"
}
},
"lint": {
"executor": "@nx/eslint:lint"
}
}
}

View File

@@ -0,0 +1,3 @@
export * from './lib/navigation.service';
export * from './lib/font-size.service';
export * from './lib/notifications.service';

View File

@@ -0,0 +1,48 @@
import {
computed,
DOCUMENT,
effect,
inject,
Injectable,
signal,
RendererFactory2,
} from '@angular/core';
import { logger } from '@isa/core/logging';
export type FontSize = 'small' | 'medium' | 'large';
const FONT_SIZE_PX_MAP: Record<FontSize, number> = {
small: 14,
medium: 16,
large: 18,
};
@Injectable({ providedIn: 'root' })
export class FontSizeService {
#logger = logger({ service: 'FontSizeService' });
#state = signal<FontSize>('medium');
#document = inject(DOCUMENT);
#renderer = inject(RendererFactory2).createRenderer(this.#document, null);
readonly get = this.#state.asReadonly();
readonly getPx = computed(() => FONT_SIZE_PX_MAP[this.#state()]);
set(size: FontSize): void {
this.#logger.debug('Font size changed', () => ({ size }));
this.#state.set(size);
}
readonly remToPx = (rem: number) => rem * this.getPx();
readonly fontSizeEffect = effect(() => {
const fontSize = this.#state();
this.#renderer.setStyle(
this.#document.documentElement,
'font-size',
`${FONT_SIZE_PX_MAP[fontSize]}px`,
);
});
}

View File

@@ -0,0 +1,20 @@
import { Injectable, signal } from '@angular/core';
import { logger } from '@isa/core/logging';
@Injectable({ providedIn: 'root' })
export class NavigationService {
#logger = logger({ service: 'NavigationService' });
#state = signal<boolean>(false);
readonly get = this.#state.asReadonly();
toggle(): void {
this.#state.update((state) => !state);
this.#logger.debug('Navigation toggled', () => ({ isOpen: this.#state() }));
}
set(state: boolean): void {
this.#logger.debug('Navigation state set', () => ({ state }));
this.#state.set(state);
}
}

View File

@@ -0,0 +1,89 @@
import { computed, Injectable, signal } from '@angular/core';
import { logger } from '@isa/core/logging';
type Timestamp = number;
type NotificationId = string | number;
export type Notification = {
id: NotificationId;
group: string;
title: string;
message: string;
action: NotificationAction;
markedAsRead?: Timestamp;
timestamp: Timestamp;
};
export type NotificationActionBase = {
label: string;
type: 'navigate' | 'callback';
};
export type NotificationActionNavigate = NotificationActionBase & {
type: 'navigate';
target: 'internal' | 'external';
route: string;
};
export type NotificationActionCallback = NotificationActionBase & {
type: 'callback';
callback: () => void;
};
export type NotificationAction =
| NotificationActionNavigate
| NotificationActionCallback;
@Injectable({ providedIn: 'root' })
export class NotificationsService {
#logger = logger({ service: 'NotificationsService' });
#state = signal<Notification[]>([]);
readonly get = this.#state.asReadonly();
add(notification: Notification): void {
this.#logger.debug('Notification added', () => ({
id: notification.id,
group: notification.group,
}));
this.#state.update((notifications) => [...notifications, notification]);
}
remove(id: NotificationId): void {
this.#logger.debug('Notification removed', () => ({ id }));
this.#state.update((notifications) =>
notifications.filter((notification) => notification.id !== id),
);
}
clear(): void {
this.#logger.debug('All notifications cleared');
this.#state.set([]);
}
markAsRead(id: NotificationId): void {
this.#logger.debug('Notification marked as read', () => ({ id }));
this.#state.update((notifications) =>
notifications.map((notification) =>
notification.id === id
? { ...notification, markedAsRead: Date.now() }
: notification,
),
);
}
markAllAsRead(): void {
this.#logger.debug('All notifications marked as read');
this.#state.update((notifications) =>
notifications.map((notification) => ({
...notification,
markedAsRead: Date.now(),
})),
);
}
readonly unreadCount = computed(() =>
this.#state().filter((notification) => !notification.markedAsRead).length,
);
}

View File

@@ -0,0 +1,13 @@
import '@angular/compiler';
import '@analogjs/vitest-angular/setup-zone';
import {
BrowserTestingModule,
platformBrowserTesting,
} from '@angular/platform-browser/testing';
import { getTestBed } from '@angular/core/testing';
getTestBed().initTestEnvironment(
BrowserTestingModule,
platformBrowserTesting(),
);

View File

@@ -0,0 +1,30 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"importHelpers": true,
"moduleResolution": "bundler",
"strict": true,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"module": "preserve"
},
"angularCompilerOptions": {
"enableI18nLegacyMessageIdFormat": false,
"strictInjectionParameters": true,
"strictInputAccessModifiers": true,
"typeCheckHostBindings": true,
"strictTemplates": true
},
"files": [],
"include": [],
"references": [
{
"path": "./tsconfig.lib.json"
},
{
"path": "./tsconfig.spec.json"
}
]
}

View File

@@ -0,0 +1,27 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../../dist/out-tsc",
"declaration": true,
"declarationMap": true,
"inlineSources": true,
"types": []
},
"exclude": [
"src/**/*.spec.ts",
"src/test-setup.ts",
"jest.config.ts",
"src/**/*.test.ts",
"vite.config.ts",
"vite.config.mts",
"vitest.config.ts",
"vitest.config.mts",
"src/**/*.test.tsx",
"src/**/*.spec.tsx",
"src/**/*.test.js",
"src/**/*.spec.js",
"src/**/*.test.jsx",
"src/**/*.spec.jsx"
],
"include": ["src/**/*.ts"]
}

View File

@@ -0,0 +1,29 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../../dist/out-tsc",
"types": [
"vitest/globals",
"vitest/importMeta",
"vite/client",
"node",
"vitest"
]
},
"include": [
"vite.config.ts",
"vite.config.mts",
"vitest.config.ts",
"vitest.config.mts",
"src/**/*.test.ts",
"src/**/*.spec.ts",
"src/**/*.test.tsx",
"src/**/*.spec.tsx",
"src/**/*.test.js",
"src/**/*.spec.js",
"src/**/*.test.jsx",
"src/**/*.spec.jsx",
"src/**/*.d.ts"
],
"files": ["src/test-setup.ts"]
}

View File

@@ -0,0 +1,27 @@
/// <reference types='vitest' />
import { defineConfig } from 'vite';
import angular from '@analogjs/vite-plugin-angular';
import { nxViteTsPaths } from '@nx/vite/plugins/nx-tsconfig-paths.plugin';
import { nxCopyAssetsPlugin } from '@nx/vite/plugins/nx-copy-assets.plugin';
export default defineConfig(() => ({
root: __dirname,
cacheDir: '../../../node_modules/.vite/libs/shell/common',
plugins: [angular(), nxViteTsPaths(), nxCopyAssetsPlugin(['*.md'])],
// Uncomment this if you are using workers.
// worker: {
// plugins: [ nxViteTsPaths() ],
// },
test: {
watch: false,
globals: true,
environment: 'jsdom',
include: ['{src,tests}/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'],
setupFiles: ['src/test-setup.ts'],
reporters: ['default'],
coverage: {
reportsDirectory: '../../../coverage/libs/shell/common',
provider: 'v8' as const,
},
},
}));