feat(shell): add logging and JSDoc documentation across shell components

- Add @isa/core/logging to services and components with actions
- Add comprehensive JSDoc documentation with @example blocks
- Fix typo: TabsCollabsedService → TabsCollapsedService
- Add error handling with try/catch for async operations
- Add tap operator for logging in NetworkStatusService
This commit is contained in:
Lorenz Hilpert
2025-12-10 20:36:58 +01:00
parent b7e69dacf7
commit 5bebd3de4d
19 changed files with 392 additions and 24 deletions

View File

@@ -1,5 +1,6 @@
import { inject, Injectable } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { logger } from '@isa/core/logging';
import {
map,
Observable,
@@ -7,22 +8,56 @@ import {
merge,
startWith,
shareReplay,
tap,
} from 'rxjs';
/** Network connectivity status values. */
export type NetworkStatus = 'online' | 'offline';
/**
* Service for monitoring browser network connectivity status.
*
* Listens to browser online/offline events and provides a reactive
* observable that emits the current network status. The observable
* is shared and replays the latest value to new subscribers.
*
* @example
* ```typescript
* readonly networkService = inject(NetworkStatusService);
*
* // Subscribe to status changes
* this.networkService.status$.subscribe(status => {
* console.log('Network status:', status);
* });
* ```
*/
@Injectable({ providedIn: 'root' })
export class NetworkStatusService {
#logger = logger({ service: 'NetworkStatusService' });
/**
* Observable that emits current network status ('online' | 'offline').
* Emits immediately on subscription with current state.
*/
readonly status$: Observable<NetworkStatus> = merge(
fromEvent(window, 'online'),
fromEvent(window, 'offline'),
).pipe(
startWith(null), // emit immediately
map((): NetworkStatus => (navigator.onLine ? 'online' : 'offline')),
tap((status) => this.#logger.debug('Network status changed', () => ({ status }))),
shareReplay({ bufferSize: 1, refCount: true }),
);
}
/**
* Injection function to get the network status observable.
* @returns Observable of network status
*/
export const injectNetworkStatus$ = () => inject(NetworkStatusService).status$;
/**
* Injection function to get network status as a signal.
* @returns Signal of network status (undefined initially until first emission)
*/
export const injectNetworkStatus = () => toSignal(injectNetworkStatus$());

View File

@@ -1,4 +1,4 @@
export * from './lib/navigation.service';
export * from './lib/font-size.service';
export * from './lib/notifications.service';
export * from './lib/tabs-collabsed.service';
export * from './lib/tabs-collapsed.service';

View File

@@ -9,14 +9,40 @@ import {
} from '@angular/core';
import { logger } from '@isa/core/logging';
/** Available font size options for the application. */
export type FontSize = 'small' | 'medium' | 'large';
/** Mapping of font size names to pixel values. */
const FONT_SIZE_PX_MAP: Record<FontSize, number> = {
small: 14,
medium: 16,
large: 18,
};
/**
* Service for managing application-wide font size.
*
* Provides reactive font size state with automatic DOM updates.
* The font size is applied to the document root element, enabling
* rem-based scaling throughout the application.
*
* @example
* ```typescript
* readonly fontSizeService = inject(FontSizeService);
*
* // Get current font size
* const size = this.fontSizeService.get(); // 'small' | 'medium' | 'large'
*
* // Get pixel value
* const px = this.fontSizeService.getPx(); // 14 | 16 | 18
*
* // Set font size
* this.fontSizeService.set('large');
*
* // Convert rem to pixels
* const pixels = this.fontSizeService.remToPx(2); // 32 (when medium)
* ```
*/
@Injectable({ providedIn: 'root' })
export class FontSizeService {
#logger = logger({ service: 'FontSizeService' });
@@ -26,17 +52,29 @@ export class FontSizeService {
#renderer = inject(RendererFactory2).createRenderer(this.#document, null);
/** Readonly signal exposing the current font size name. */
readonly get = this.#state.asReadonly();
/** Computed signal returning the current font size in pixels. */
readonly getPx = computed(() => FONT_SIZE_PX_MAP[this.#state()]);
/**
* Sets the application font size.
* @param size - The font size to apply ('small', 'medium', or 'large')
*/
set(size: FontSize): void {
this.#logger.debug('Font size changed', () => ({ size }));
this.#state.set(size);
}
/**
* Converts rem units to pixels based on current font size.
* @param rem - The value in rem units
* @returns The equivalent value in pixels
*/
readonly remToPx = (rem: number) => rem * this.getPx();
/** Effect that applies the font size to the document root element. */
readonly fontSizeEffect = effect(() => {
const fontSize = this.#state();
this.#renderer.setStyle(

View File

@@ -1,18 +1,45 @@
import { Injectable, signal } from '@angular/core';
import { logger } from '@isa/core/logging';
/**
* Service for managing the navigation sidebar visibility state.
*
* Controls whether the sidebar navigation panel is open or closed,
* primarily used for responsive mobile/tablet layouts where the
* navigation can be toggled via a hamburger menu.
*
* @example
* ```typescript
* readonly navigationService = inject(NavigationService);
*
* // Check if navigation is open
* const isOpen = this.navigationService.get();
*
* // Toggle navigation
* this.navigationService.toggle();
*
* // Explicitly set state
* this.navigationService.set(false); // Close navigation
* ```
*/
@Injectable({ providedIn: 'root' })
export class NavigationService {
#logger = logger({ service: 'NavigationService' });
#state = signal<boolean>(false);
/** Readonly signal exposing the current navigation open/closed state. */
readonly get = this.#state.asReadonly();
/** Toggles the navigation state between open and closed. */
toggle(): void {
this.#state.update((state) => !state);
this.#logger.debug('Navigation toggled', () => ({ isOpen: this.#state() }));
}
/**
* Sets the navigation visibility state.
* @param state - True to open navigation, false to close
*/
set(state: boolean): void {
this.#logger.debug('Navigation state set', () => ({ state }));
this.#state.set(state);

View File

@@ -1,47 +1,103 @@
import { computed, Injectable, signal } from '@angular/core';
import { logger } from '@isa/core/logging';
/** Unix timestamp in milliseconds. */
type Timestamp = number;
/** Unique identifier for a notification. */
type NotificationId = string | number;
/**
* Represents an in-app notification.
*
* Notifications are displayed in the notifications panel and can be
* grouped, marked as read, and trigger actions when clicked.
*/
export type Notification = {
/** Unique identifier for this notification. */
id: NotificationId;
/** Group name for categorizing notifications (e.g., 'orders', 'system'). */
group: string;
/** Short title displayed prominently. */
title: string;
/** Detailed message content. */
message: string;
/** Action to perform when notification is clicked. */
action: NotificationAction;
/** Timestamp when marked as read (undefined if unread). */
markedAsRead?: Timestamp;
/** Creation timestamp for sorting. */
timestamp: Timestamp;
};
/** Base properties for notification actions. */
export type NotificationActionBase = {
/** Button label text. */
label: string;
/** Action type discriminator. */
type: 'navigate' | 'callback';
};
/** Navigation action that routes to an internal or external URL. */
export type NotificationActionNavigate = NotificationActionBase & {
type: 'navigate';
/** Whether the route is internal (Angular router) or external (new tab). */
target: 'internal' | 'external';
/** The route path or URL to navigate to. */
route: string;
};
/** Callback action that executes a function. */
export type NotificationActionCallback = NotificationActionBase & {
type: 'callback';
/** Function to execute when action is triggered. */
callback: () => void;
};
/** Union type for all notification action types. */
export type NotificationAction =
| NotificationActionNavigate
| NotificationActionCallback;
/**
* Service for managing in-app notifications.
*
* Provides a centralized store for notifications with support for
* adding, removing, grouping, and tracking read/unread state.
*
* @example
* ```typescript
* readonly notifications = inject(NotificationsService);
*
* // Add a notification
* this.notifications.add({
* id: 'order-123',
* group: 'orders',
* title: 'New Order',
* message: 'Order #123 has been placed',
* timestamp: Date.now(),
* action: { type: 'navigate', target: 'internal', route: '/orders/123', label: 'View' }
* });
*
* // Get unread count
* const unread = this.notifications.unreadCount();
*
* // Mark as read
* this.notifications.markAsRead('order-123');
* ```
*/
@Injectable({ providedIn: 'root' })
export class NotificationsService {
#logger = logger({ service: 'NotificationsService' });
#state = signal<Notification[]>([]);
/** Readonly signal exposing all current notifications. */
readonly get = this.#state.asReadonly();
/**
* Adds a new notification to the store.
* @param notification - The notification to add
*/
add(notification: Notification): void {
this.#logger.debug('Notification added', () => ({
id: notification.id,
@@ -50,6 +106,10 @@ export class NotificationsService {
this.#state.update((notifications) => [...notifications, notification]);
}
/**
* Removes a notification by its ID.
* @param id - The notification ID to remove
*/
remove(id: NotificationId): void {
this.#logger.debug('Notification removed', () => ({ id }));
this.#state.update((notifications) =>
@@ -57,11 +117,16 @@ export class NotificationsService {
);
}
/** Removes all notifications from the store. */
clear(): void {
this.#logger.debug('All notifications cleared');
this.#state.set([]);
}
/**
* Marks a specific notification as read.
* @param id - The notification ID to mark as read
*/
markAsRead(id: NotificationId): void {
this.#logger.debug('Notification marked as read', () => ({ id }));
this.#state.update((notifications) =>
@@ -73,6 +138,7 @@ export class NotificationsService {
);
}
/** Marks all notifications as read. */
markAllAsRead(): void {
this.#logger.debug('All notifications marked as read');
this.#state.update((notifications) =>
@@ -83,6 +149,7 @@ export class NotificationsService {
);
}
/** Computed signal returning the count of unread notifications. */
readonly unreadCount = computed(() =>
this.#state().filter((notification) => !notification.markedAsRead).length,
);

View File

@@ -10,7 +10,7 @@ import { logger } from '@isa/core/logging';
*
* @example
* ```typescript
* readonly tabsCollapsed = inject(TabsCollabsedService);
* readonly tabsCollapsed = inject(TabsCollapsedService);
*
* // Read current state
* const isCollapsed = this.tabsCollapsed.get();
@@ -23,7 +23,7 @@ import { logger } from '@isa/core/logging';
* ```
*/
@Injectable({ providedIn: 'root' })
export class TabsCollabsedService {
export class TabsCollapsedService {
#logger = logger({ service: 'TabsService' });
#state = signal<boolean>(false);

View File

@@ -10,12 +10,28 @@ import { NgIcon, provideIcons } from '@ng-icons/core';
import { isaNavigationFontsize } from '@isa/icons';
import { FontSize, FontSizeService } from '@isa/shell/common';
/** Configuration for a font size option in the selector. */
interface FontSizeOption {
/** The font size value ('small', 'medium', 'large'). */
value: FontSize;
/** Accessible label for screen readers. */
label: string;
/** Icon size for visual representation. */
iconSize: string;
}
/**
* Font size selector component for accessibility settings.
*
* Displays three font size options (small, medium, large) with visual
* icons indicating the relative size. Includes an animated indicator
* that slides to show the currently selected option.
*
* @example
* ```html
* <shell-font-size-selector />
* ```
*/
@Component({
selector: 'shell-font-size-selector',
templateUrl: './font-size-selector.component.html',
@@ -31,24 +47,32 @@ interface FontSizeOption {
export class ShellFontSizeSelectorComponent {
#logger = logger({ component: 'ShellFontSizeSelectorComponent' });
/** Font size service for reading and updating the current size. */
readonly fontSizeService = inject(FontSizeService);
/** Available font size options with labels and icon sizes. */
readonly fontSizeOptions: FontSizeOption[] = [
{ value: 'small', label: 'Kleine Schriftgröße', iconSize: '0.63rem' },
{ value: 'medium', label: 'Mittlere Schriftgröße', iconSize: '1rem' },
{ value: 'large', label: 'Große Schriftgröße', iconSize: '1.3rem' },
];
/** Pixel offsets for the sliding indicator animation. */
readonly #offsetMap: Record<FontSize, number> = {
small: 0,
medium: 3,
large: 6,
};
/** Computed offset position for the sliding indicator. */
readonly indicatorOffset = computed(
() => this.#offsetMap[this.fontSizeService.get()],
);
/**
* Handles font size selection changes.
* @param size - The newly selected font size
*/
onFontSizeChange(size: FontSize): void {
this.#logger.debug('Font size changed', () => ({ size }));
this.fontSizeService.set(size);

View File

@@ -5,6 +5,17 @@ import { AuthService } from '@isa/core/auth';
import { NgIcon, provideIcons } from '@ng-icons/core';
import { isaNavigationLogout } from '@isa/icons';
/**
* Logout button component for the header.
*
* Displays a logout icon button that triggers user logout when clicked.
* Includes "NEU" label to indicate the new shell interface.
*
* @example
* ```html
* <shell-logout-button />
* ```
*/
@Component({
selector: 'shell-logout-button',
template: `<ui-info-button
@@ -24,6 +35,7 @@ export class ShellLogoutButtonComponent {
#logger = logger({ component: 'ShellLogoutButtonComponent' });
#authService = inject(AuthService);
/** Logs out the current user via the AuthService. */
logout(): void {
this.#logger.info('User logging out');
this.#authService.logout();

View File

@@ -14,6 +14,17 @@ import {
} from '@isa/ui/buttons';
import { provideIcons } from '@ng-icons/core';
/**
* Navigation toggle button (hamburger menu) for tablet/mobile layouts.
*
* Toggles the sidebar navigation visibility. The icon changes between
* a hamburger menu icon (closed) and close icon (open) based on state.
*
* @example
* ```html
* <shell-navigation-toggle />
* ```
*/
@Component({
selector: 'shell-navigation-toggle',
template: `<ui-icon-button
@@ -33,20 +44,24 @@ import { provideIcons } from '@ng-icons/core';
export class ShellNavigationToggleComponent {
#logger = logger({ component: 'ShellNavigationToggleComponent' });
/** Navigation service for controlling sidebar visibility. */
readonly navigationService = inject(NavigationService);
readonly IconButtonColor = IconButtonColor;
readonly IconButtonSize = IconButtonSize;
/** Computed icon name based on navigation state. */
iconName = computed(() => {
const open = this.navigationService.get();
return open ? 'isaActionClose' : 'isaNavigationSidemenu';
});
/** Computed ARIA label for accessibility. */
ariaLabel = computed(() =>
this.navigationService.get() ? 'Menü schließen' : 'Menü öffnen',
);
/** Toggles the navigation sidebar visibility. */
toggle(): void {
this.navigationService.toggle();
this.#logger.debug('Navigation toggled', () => ({

View File

@@ -17,6 +17,18 @@ import {
import { NotificationsService } from '@isa/shell/common';
import { ShellNotificationsComponent } from '@isa/shell/notifications';
/**
* Notifications toggle button with dropdown panel.
*
* Displays a bell icon that shows unread indicator when there are
* unread notifications. Clicking opens an overlay panel with the
* full notifications list.
*
* @example
* ```html
* <shell-notifications-toggle />
* ```
*/
@Component({
selector: 'shell-notifications-toggle',
templateUrl: './notifications-toggle.component.html',
@@ -33,10 +45,13 @@ export class ShellNotificationsToggleComponent {
readonly IconButtonColor = IconButtonColor;
readonly IconButtonSize = IconButtonSize;
/** Notifications service for accessing notification state. */
readonly notificationsService = inject(NotificationsService);
/** Whether the notifications panel overlay is currently open. */
isOpen = signal(false);
/** CDK overlay positions for the notifications panel. */
readonly positions: ConnectedPosition[] = [
// Bottom right
{ originX: 'end', originY: 'bottom', overlayX: 'end', overlayY: 'top' },
@@ -48,6 +63,7 @@ export class ShellNotificationsToggleComponent {
{ originX: 'start', originY: 'bottom', overlayX: 'end', overlayY: 'bottom' },
];
/** Toggles the notifications panel visibility. */
toggle(): void {
this.isOpen.update((open) => !open);
this.#logger.debug('Notifications panel toggled', () => ({
@@ -55,23 +71,28 @@ export class ShellNotificationsToggleComponent {
}));
}
/** Closes the notifications panel. */
close(): void {
this.isOpen.set(false);
this.#logger.debug('Notifications panel closed');
}
/** Whether there are any notifications. */
hasNotifications = computed(() => this.notificationsService.get().length > 0);
/** Whether there are unread notifications. */
unreadNotifications = computed(
() => this.notificationsService.unreadCount() > 0,
);
/** Icon name based on unread notification state. */
icon = computed(() =>
this.unreadNotifications()
? 'isaNavigationMessageUnread'
: 'isaNavigationMessage',
);
/** ARIA label for accessibility based on current state. */
ariaLabel = computed(() => {
if (!this.hasNotifications()) return 'Keine Benachrichtigungen';
return this.isOpen() ? 'Benachrichtigungen schließen' : 'Benachrichtigungen öffnen';

View File

@@ -5,6 +5,22 @@ import { ShellFontSizeSelectorComponent } from './components/font-size-selector/
import { ShellLogoutButtonComponent } from './components/logout-button.component';
import { ShellNotificationsToggleComponent } from './components/notifications-toggle/notifications-toggle.component';
/**
* Application header component for the shell layout.
*
* Displays the main header bar with:
* - Navigation toggle (hamburger menu on tablet/mobile)
* - Font size selector for accessibility
* - Notifications toggle with unread indicator
* - Logout button
*
* The navigation toggle is only visible on tablet breakpoint and below.
*
* @example
* ```html
* <shell-header />
* ```
*/
@Component({
selector: 'shell-header',
templateUrl: './shell-header.component.html',
@@ -18,5 +34,6 @@ import { ShellNotificationsToggleComponent } from './components/notifications-to
],
})
export class ShellHeaderComponent {
/** Signal indicating if the viewport is at or below tablet breakpoint. */
readonly isTablet = breakpoint(Breakpoint.Tablet);
}

View File

@@ -6,11 +6,12 @@ import {
HostListener,
inject,
} from '@angular/core';
import { logger } from '@isa/core/logging';
import { NetworkStatusBannerComponent } from './components/network-status-banner.component';
import {
NavigationService,
FontSizeService,
TabsCollabsedService,
TabsCollapsedService,
} from '@isa/shell/common';
import { ShellHeaderComponent } from '@isa/shell/header';
import { ShellNavigationComponent } from '@isa/shell/navigation';
@@ -53,11 +54,12 @@ import {
],
})
export class ShellLayoutComponent {
#logger = logger({ component: 'ShellLayoutComponent' });
#navigationService = inject(NavigationService);
#elementRef = inject(ElementRef);
/** Service for managing tabs collapsed state. */
readonly tabsCollabesd = inject(TabsCollabsedService);
readonly tabsCollapsed = inject(TabsCollapsedService);
/** Service for managing font size scaling. */
readonly fontSizeService = inject(FontSizeService);
@@ -101,6 +103,7 @@ export class ShellLayoutComponent {
/** Closes navigation on main content scroll (tablet/mobile only). */
onMainScroll(): void {
if (!this.tablet() || !this.#navigationService.get()) return;
this.#logger.debug('Closing navigation on scroll');
this.#navigationService.set(false);
}
}

View File

@@ -2,6 +2,17 @@ import { ChangeDetectionStrategy, Component, input } from '@angular/core';
import { NavigationGroup } from '../../types';
import { ShellNavigationItemComponent } from '../navigation-item/navigation-item.component';
/**
* Navigation group component that renders a labeled section with navigation items.
*
* Groups navigation items under a common label (e.g., "Kunden", "Filiale").
* Each group displays its label as a header followed by its child items.
*
* @example
* ```html
* <shell-navigation-group [group]="navigationGroup" />
* ```
*/
@Component({
selector: 'shell-navigation-group',
templateUrl: './navigation-group.component.html',
@@ -10,5 +21,6 @@ import { ShellNavigationItemComponent } from '../navigation-item/navigation-item
imports: [ShellNavigationItemComponent],
})
export class ShellNavigationGroupComponent {
/** The navigation group configuration with label and items. */
readonly group = input.required<NavigationGroup>();
}

View File

@@ -3,6 +3,18 @@ import { navigations } from './navigations';
import { ShellNavigationGroupComponent } from './components/navigation-group/navigation-group.component';
import { ShellNavigationItemComponent } from './components/navigation-item/navigation-item.component';
/**
* Sidebar navigation component for the shell layout.
*
* Renders the main navigation sidebar with groups and items.
* The navigation structure is defined in the `navigations` configuration,
* which supports both standalone items and grouped items.
*
* @example
* ```html
* <shell-navigation />
* ```
*/
@Component({
selector: 'shell-navigation',
imports: [ShellNavigationGroupComponent, ShellNavigationItemComponent],
@@ -10,5 +22,6 @@ import { ShellNavigationItemComponent } from './components/navigation-item/navig
styleUrl: './shell-navigation.component.css',
})
export class ShellNavigationComponent {
/** The navigation configuration array containing groups and items. */
readonly navigations = navigations;
}

View File

@@ -14,6 +14,20 @@ import { isaActionCheck } from '@isa/icons';
import { logger } from '@isa/core/logging';
import { Notification } from '@isa/shell/common';
/**
* Single notification component displaying a notification item.
*
* Shows the notification title, message, relative time, and action button.
* Handles navigation (internal/external) and callback actions when clicked.
*
* @example
* ```html
* <shell-notification
* [notification]="notification"
* (actionTriggered)="onNotificationAction($event)"
* />
* ```
*/
@Component({
selector: 'shell-notification',
templateUrl: './notification.component.html',
@@ -26,13 +40,21 @@ export class ShellNotificationComponent {
readonly #logger = logger({ component: 'ShellNotificationComponent' });
readonly #router = inject(Router);
/** The notification data to display. */
notification = input.required<Notification>();
/** Emits when the notification action is triggered. */
actionTriggered = output<Notification>();
/** Human-readable relative time (e.g., "2 hours ago"). */
relativeTime = computed(() =>
formatDistanceToNow(this.notification().timestamp, { addSuffix: true }),
);
/**
* Handles the notification action button click.
* Navigates to route or executes callback based on action type.
*/
onAction(): void {
const notification = this.notification();
const action = notification.action;

View File

@@ -9,6 +9,18 @@ import { ShellNotificationComponent } from './components/notification/notificati
import { Notification } from '@isa/shell/common';
import { KeyValuePipe } from '@angular/common';
/**
* Notifications panel component that displays grouped notifications.
*
* Shows all notifications organized by group (e.g., "orders", "system").
* Within each group, notifications are sorted with unread items first,
* then by timestamp (newest first).
*
* @example
* ```html
* <shell-notifications />
* ```
*/
@Component({
selector: 'shell-notifications',
templateUrl: './shell-notifications.component.html',
@@ -17,8 +29,14 @@ import { KeyValuePipe } from '@angular/common';
imports: [ShellNotificationComponent, KeyValuePipe],
})
export class ShellNotificationsComponent {
/** Notifications service for accessing the notification store. */
readonly notificationsService = inject(NotificationsService);
/**
* Computed signal that groups and sorts notifications.
* Groups by notification.group, sorts unread first then by timestamp.
*/
groupedNotifications = computed(() => {
const notifications = this.notificationsService.get();

View File

@@ -5,6 +5,7 @@ import {
inject,
computed,
} from '@angular/core';
import { logger } from '@isa/core/logging';
import { Tab } from '@isa/core/tabs';
import { NgIcon, provideIcons } from '@ng-icons/core';
import { isaActionClose } from '@isa/icons';
@@ -38,6 +39,7 @@ import { Router } from '@angular/router';
},
})
export class ShellTabItemComponent {
#logger = logger({ component: 'ShellTabItemComponent' });
#router = inject(Router);
#tabService = inject(TabService);
@@ -71,20 +73,27 @@ export class ShellTabItemComponent {
* Closes this tab and navigates to the previously active tab.
* If no previous tab exists, navigates to the root route.
*/
async close(event: MouseEvent) {
async close(event: MouseEvent): Promise<void> {
event.stopPropagation();
event.preventDefault();
const previousTab = this.#tabService.removeTab(this.tab().id);
const tabId = this.tab().id;
this.#logger.debug('Closing tab', () => ({ tabId }));
if (previousTab) {
const location = this.#tabService.getCurrentLocation(previousTab.id);
if (location?.url) {
await this.#router.navigateByUrl(location.url);
return;
try {
const previousTab = this.#tabService.removeTab(tabId);
if (previousTab) {
const location = this.#tabService.getCurrentLocation(previousTab.id);
if (location?.url) {
await this.#router.navigateByUrl(location.url);
return;
}
}
}
await this.#router.navigateByUrl('/');
await this.#router.navigateByUrl('/');
} catch (error) {
this.#logger.error('Failed to close tab', error as Error, () => ({ tabId }));
}
}
}

View File

@@ -1,12 +1,13 @@
import { Component, inject, computed, signal, ElementRef } from '@angular/core';
import { Component, inject, ElementRef } from '@angular/core';
import { Router } from '@angular/router';
import { logger } from '@isa/core/logging';
import { ShellTabItemComponent } from './components/shell-tab-item.component';
import { TabService } from '@isa/core/tabs';
import { CarouselComponent } from '@isa/ui/carousel';
import { IconButtonComponent, TextButtonComponent } from '@isa/ui/buttons';
import { provideIcons } from '@ng-icons/core';
import { isaActionPlus } from '@isa/icons';
import { TabsCollabsedService } from '@isa/shell/common';
import { TabsCollapsedService } from '@isa/shell/common';
import { Breakpoint, breakpoint } from '@isa/ui/layout';
/**
@@ -49,10 +50,11 @@ const PROXIMITY_THRESHOLD_PX = 50;
},
})
export class ShellTabsComponent {
#logger = logger({ component: 'ShellTabsComponent' });
#tablet = breakpoint(Breakpoint.Tablet);
#tabService = inject(TabService);
#tabsCollabsedService = inject(TabsCollabsedService);
#tabsCollapsedService = inject(TabsCollapsedService);
#elementRef = inject(ElementRef);
#router = inject(Router);
@@ -60,7 +62,7 @@ export class ShellTabsComponent {
readonly tabs = this.#tabService.entities;
/** Whether tabs should display in compact mode (mouse is far from component). */
compact = this.#tabsCollabsedService.get;
compact = this.#tabsCollapsedService.get;
/**
* Handles mouse movement to detect proximity to the tabs bar.
@@ -84,19 +86,30 @@ export class ShellTabsComponent {
: 0;
const isNear = isWithinX && distanceY <= PROXIMITY_THRESHOLD_PX;
this.#tabsCollabsedService.set(!isNear);
this.#tabsCollapsedService.set(!isNear);
}
/** Closes all open tabs and navigates to the root route. */
async closeAll(): Promise<void> {
this.#tabService.removeAllTabs();
await this.#router.navigateByUrl('/');
this.#logger.info('Closing all tabs');
try {
this.#tabService.removeAllTabs();
await this.#router.navigateByUrl('/');
} catch (error) {
this.#logger.error('Failed to close all tabs', error as Error);
}
}
/** Creates a new tab and navigates to the product search view. */
async addTab(): Promise<void> {
await this.#router.navigateByUrl(
`/kunde/${Date.now()}/product/(filter//side:search)`,
);
const tabId = Date.now();
this.#logger.debug('Creating new tab', () => ({ tabId }));
try {
await this.#router.navigateByUrl(
`/kunde/${tabId}/product/(filter//side:search)`,
);
} catch (error) {
this.#logger.error('Failed to create new tab', error as Error, () => ({ tabId }));
}
}
}

View File

@@ -9,6 +9,25 @@ import {
signal,
} from '@angular/core';
/**
* Directive that observes and reports element size changes.
*
* Uses ResizeObserver to track the host element's dimensions and
* provides both a signal and output event for reactive updates.
*
* @example
* ```html
* <!-- As output event -->
* <div uiElementSizeObserver (uiElementSizeObserver)="onSizeChange($event)">
* Content
* </div>
*
* <!-- As template reference -->
* <div uiElementSizeObserver #sizeRef="uiElementSizeObserver">
* Width: {{ sizeRef.size().width }}px
* </div>
* ```
*/
@Directive({
selector: '[uiElementSizeObserver]',
exportAs: 'uiElementSizeObserver',
@@ -32,6 +51,7 @@ export class UiElementSizeObserverDirective
this.#resizeObserver?.disconnect();
}
/** Emits the current element size to both signal and output. */
#emitSize() {
const size = this.#element.getBoundingClientRect();
this.#size.set(size);
@@ -40,8 +60,10 @@ export class UiElementSizeObserverDirective
#size = signal<{ width: number; height: number }>({ height: 0, width: 0 });
/** Readonly signal exposing the current element dimensions. */
readonly size: Signal<{ width: number; height: number }> =
this.#size.asReadonly();
/** Output event emitting size changes. */
readonly uiElementSizeObserver = output<{ width: number; height: number }>();
}