♻️ refactor(shell-header): restructure with modular sub-components

Reorganize header into focused sub-components:
- ShellNavigationToggleComponent: drawer toggle with dynamic icon
- ShellFontSizeSelectorComponent: accessibility font size control
- ShellLogoutButtonComponent: logout with AuthService integration
- ShellNotificationsToggleComponent: notification panel overlay

Add E2E testing attributes and ARIA accessibility support
This commit is contained in:
Lorenz Hilpert
2025-12-03 21:16:57 +01:00
parent daf79d55a5
commit 3ed3d0b466
15 changed files with 346 additions and 33 deletions

View File

@@ -1 +1 @@
export * from './lib/shell-header/shell-header.component';
export * from './lib/shell-header.component';

View File

@@ -0,0 +1,15 @@
.selector {
@apply relative flex rounded-full bg-isa-neutral-300;
}
.indicator {
@apply absolute top-0 size-12 rounded-full bg-isa-neutral-700 transition-transform duration-200 ease-out;
}
.option {
@apply relative flex h-12 w-12 cursor-pointer items-center justify-center transition-colors duration-200;
}
.option.selected {
@apply text-isa-neutral-300;
}

View File

@@ -0,0 +1,33 @@
<fieldset
class="selector"
data-what="fieldset"
data-which="font-size-selector"
role="radiogroup"
aria-label="Schriftgröße auswählen"
>
<div class="indicator" [style.transform]="'translateX(' + indicatorOffset() + 'rem)'"></div>
@for (option of fontSizeOptions; track option.value) {
<input
type="radio"
class="sr-only"
[id]="'font-size-' + option.value"
[value]="option.value"
name="font-size"
[ngModel]="fontSizeService.get()"
(ngModelChange)="onFontSizeChange($event)"
[attr.data-what]="'radio'"
[attr.data-which]="'font-size-' + option.value"
/>
<label
class="option"
[for]="'font-size-' + option.value"
[class.selected]="fontSizeService.get() === option.value"
[attr.aria-label]="option.label"
[attr.data-what]="'label'"
[attr.data-which]="'font-size-label-' + option.value"
>
<ng-icon name="isaNavigationFontsize" [size]="option.iconSize" />
</label>
}
</fieldset>

View File

@@ -0,0 +1,56 @@
import {
ChangeDetectionStrategy,
Component,
computed,
inject,
} from '@angular/core';
import { FormsModule } from '@angular/forms';
import { logger } from '@isa/core/logging';
import { NgIcon, provideIcons } from '@ng-icons/core';
import { isaNavigationFontsize } from '@isa/icons';
import { FontSize, FontSizeService } from '@isa/shell/common';
interface FontSizeOption {
value: FontSize;
label: string;
iconSize: string;
}
@Component({
selector: 'shell-font-size-selector',
templateUrl: './font-size-selector.component.html',
styleUrl: './font-size-selector.component.css',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [FormsModule, NgIcon],
providers: [
provideIcons({
isaNavigationFontsize,
}),
],
})
export class ShellFontSizeSelectorComponent {
#logger = logger({ component: 'ShellFontSizeSelectorComponent' });
readonly fontSizeService = inject(FontSizeService);
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' },
];
readonly #offsetMap: Record<FontSize, number> = {
small: 0,
medium: 3,
large: 6,
};
readonly indicatorOffset = computed(
() => this.#offsetMap[this.fontSizeService.get()],
);
onFontSizeChange(size: FontSize): void {
this.#logger.debug('Font size changed', () => ({ size }));
this.fontSizeService.set(size);
}
}

View File

@@ -0,0 +1,31 @@
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import { logger } from '@isa/core/logging';
import { InfoButtonComponent } from '@isa/ui/buttons';
import { AuthService } from '@isa/core/auth';
import { NgIcon, provideIcons } from '@ng-icons/core';
import { isaNavigationLogout } from '@isa/icons';
@Component({
selector: 'shell-logout-button',
template: `<ui-info-button
(click)="logout()"
data-what="button"
data-which="logout-button"
aria-label="Abmelden"
>
<ng-icon uiInfoButtonIcon name="isaNavigationLogout" />
<span class="isa-text-body-2-bold" uiInfoButtonLabel>NEU</span>
</ui-info-button>`,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [InfoButtonComponent, NgIcon],
providers: [provideIcons({ isaNavigationLogout })],
})
export class ShellLogoutButtonComponent {
#logger = logger({ component: 'ShellLogoutButtonComponent' });
#authService = inject(AuthService);
logout(): void {
this.#logger.info('User logging out');
this.#authService.logout();
}
}

View File

@@ -0,0 +1,56 @@
import {
ChangeDetectionStrategy,
Component,
computed,
inject,
} from '@angular/core';
import { logger } from '@isa/core/logging';
import { isaActionClose, isaNavigationSidemenu } from '@isa/icons';
import { NavigationService } from '@isa/shell/common';
import {
IconButtonComponent,
IconButtonColor,
IconButtonSize,
} from '@isa/ui/buttons';
import { provideIcons } from '@ng-icons/core';
@Component({
selector: 'shell-navigation-toggle',
template: `<ui-icon-button
[name]="iconName()"
[color]="IconButtonColor.Primary"
[size]="IconButtonSize.Large"
(click)="toggle()"
data-what="button"
data-which="navigation-toggle"
[attr.aria-label]="ariaLabel()"
[attr.aria-expanded]="navigationService.get()"
/>`,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [IconButtonComponent],
providers: [provideIcons({ isaNavigationSidemenu, isaActionClose })],
})
export class ShellNavigationToggleComponent {
#logger = logger({ component: 'ShellNavigationToggleComponent' });
readonly navigationService = inject(NavigationService);
readonly IconButtonColor = IconButtonColor;
readonly IconButtonSize = IconButtonSize;
iconName = computed(() => {
const open = this.navigationService.get();
return open ? 'isaActionClose' : 'isaNavigationSidemenu';
});
ariaLabel = computed(() =>
this.navigationService.get() ? 'Menü schließen' : 'Menü öffnen',
);
toggle(): void {
this.navigationService.toggle();
this.#logger.debug('Navigation toggled', () => ({
isOpen: this.navigationService.get(),
}));
}
}

View File

@@ -0,0 +1,3 @@
:host {
display: block;
}

View File

@@ -0,0 +1,36 @@
<ui-icon-button
cdkOverlayOrigin
#trigger="cdkOverlayOrigin"
[name]="icon()"
[color]="IconButtonColor.Tertiary"
[size]="IconButtonSize.Large"
[disabled]="!hasNotifications()"
(click)="toggle()"
data-what="button"
data-which="notifications-toggle"
[attr.aria-label]="ariaLabel()"
[attr.aria-expanded]="isOpen()"
aria-haspopup="true"
/>
<ng-template
cdkConnectedOverlay
[cdkConnectedOverlayOrigin]="trigger"
[cdkConnectedOverlayOpen]="isOpen()"
[cdkConnectedOverlayPositions]="positions"
[cdkConnectedOverlayOffsetY]="12"
[cdkConnectedOverlayHasBackdrop]="true"
cdkConnectedOverlayBackdropClass="cdk-overlay-transparent-backdrop"
(backdropClick)="close()"
(detach)="close()"
>
<div
class="p-4 bg-isa-white rounded-2xl max-h-96 overflow-y-auto shadow-lg"
data-what="panel"
data-which="notifications-panel"
role="dialog"
aria-label="Benachrichtigungen"
>
<shell-notifications />
</div>
</ng-template>

View File

@@ -0,0 +1,79 @@
import {
ChangeDetectionStrategy,
Component,
computed,
inject,
signal,
} from '@angular/core';
import { CdkConnectedOverlay, CdkOverlayOrigin, ConnectedPosition } from '@angular/cdk/overlay';
import { logger } from '@isa/core/logging';
import { isaNavigationMessage, isaNavigationMessageUnread } from '@isa/icons';
import { provideIcons } from '@ng-icons/core';
import {
IconButtonComponent,
IconButtonColor,
IconButtonSize,
} from '@isa/ui/buttons';
import { NotificationsService } from '@isa/shell/common';
import { ShellNotificationsComponent } from '@isa/shell/notifications';
@Component({
selector: 'shell-notifications-toggle',
templateUrl: './notifications-toggle.component.html',
styleUrl: './notifications-toggle.component.css',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [IconButtonComponent, ShellNotificationsComponent, CdkOverlayOrigin, CdkConnectedOverlay],
providers: [
provideIcons({ isaNavigationMessage, isaNavigationMessageUnread }),
],
})
export class ShellNotificationsToggleComponent {
#logger = logger({ component: 'ShellNotificationsToggleComponent' });
readonly IconButtonColor = IconButtonColor;
readonly IconButtonSize = IconButtonSize;
readonly notificationsService = inject(NotificationsService);
isOpen = signal(false);
readonly positions: ConnectedPosition[] = [
// Bottom right
{ originX: 'end', originY: 'bottom', overlayX: 'end', overlayY: 'top' },
// Bottom left
{ originX: 'start', originY: 'bottom', overlayX: 'start', overlayY: 'top' },
// Left top
{ originX: 'start', originY: 'top', overlayX: 'end', overlayY: 'top' },
// Left bottom
{ originX: 'start', originY: 'bottom', overlayX: 'end', overlayY: 'bottom' },
];
toggle(): void {
this.isOpen.update((open) => !open);
this.#logger.debug('Notifications panel toggled', () => ({
isOpen: this.isOpen(),
}));
}
close(): void {
this.isOpen.set(false);
this.#logger.debug('Notifications panel closed');
}
hasNotifications = computed(() => this.notificationsService.get().length > 0);
unreadNotifications = computed(
() => this.notificationsService.unreadCount() > 0,
);
icon = computed(() =>
this.unreadNotifications()
? 'isaNavigationMessageUnread'
: 'isaNavigationMessage',
);
ariaLabel = computed(() => {
if (!this.hasNotifications()) return 'Keine Benachrichtigungen';
return this.isOpen() ? 'Benachrichtigungen schließen' : 'Benachrichtigungen öffnen';
});
}

View File

@@ -0,0 +1,14 @@
<header
class="flex items-center gap-2 px-4 desktop:px-6 py-2 bg-isa-white shadow-[0_2px_6px_0_rgba(0,0,0,0.1)]"
data-what="header"
data-which="shell-header"
role="banner"
>
@if (isTablet()) {
<shell-navigation-toggle />
}
<div class="grow"></div>
<shell-font-size-selector />
<shell-logout-button />
<shell-notifications-toggle />
</header>

View File

@@ -0,0 +1,22 @@
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { ShellNavigationToggleComponent } from './components/navigation-toggle.component';
import { Breakpoint, breakpoint } from '@isa/ui/layout';
import { ShellFontSizeSelectorComponent } from './components/font-size-selector/font-size-selector.component';
import { ShellLogoutButtonComponent } from './components/logout-button.component';
import { ShellNotificationsToggleComponent } from './components/notifications-toggle/notifications-toggle.component';
@Component({
selector: 'shell-header',
templateUrl: './shell-header.component.html',
styleUrls: ['./shell-header.component.css'],
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
ShellNavigationToggleComponent,
ShellFontSizeSelectorComponent,
ShellLogoutButtonComponent,
ShellNotificationsToggleComponent,
],
})
export class ShellHeaderComponent {
readonly isTablet = breakpoint(Breakpoint.Tablet);
}

View File

@@ -1 +0,0 @@
<p>ShellHeader works!</p>

View File

@@ -1,21 +0,0 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ShellHeaderComponent } from './shell-header.component';
describe('ShellHeaderComponent', () => {
let component: ShellHeaderComponent;
let fixture: ComponentFixture<ShellHeaderComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [ShellHeaderComponent],
}).compileComponents();
fixture = TestBed.createComponent(ShellHeaderComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@@ -1,10 +0,0 @@
import { Component } from '@angular/core';
import { CommonModule } from '@angular/common';
@Component({
selector: 'shell-shell-header',
imports: [CommonModule],
templateUrl: './shell-header.component.html',
styleUrl: './shell-header.component.css',
})
export class ShellHeaderComponent {}