feat(shell-navigation): implement navigation components with collapsible menu

Add modular navigation system with three reusable components:
- NavigationGroupComponent for grouped navigation sections
- NavigationItemComponent for main navigation entries with expand/collapse
- NavigationSubItemComponent for nested child routes

Include navigation types, route configuration with dynamic tabId support,
and integrate navigation into shell-layout sidebar.
This commit is contained in:
Lorenz Hilpert
2025-12-04 22:15:35 +01:00
parent 5fe85282e7
commit e3c60f14f7
20 changed files with 707 additions and 37 deletions

View File

@@ -1,5 +1,10 @@
<shell-network-status-banner />
<shell-header />
@if (renderNavigation()) {
<shell-navigation />
}
<main>
<ng-content></ng-content>
</main>

View File

@@ -1,7 +1,14 @@
import { ChangeDetectionStrategy, Component } from '@angular/core';
import {
ChangeDetectionStrategy,
Component,
computed,
inject,
} from '@angular/core';
import { NetworkStatusBannerComponent } from './components/network-status-banner.component';
import { NavigationService } from '@isa/shell/common';
import { ShellHeaderComponent } from '@isa/shell/header';
import { ShellNavigationComponent } from '@isa/shell/navigation';
import { Breakpoint, breakpoint } from '@isa/ui/layout';
@Component({
selector: 'shell-layout',
@@ -9,9 +16,27 @@ import { ShellHeaderComponent } from '@isa/shell/header';
templateUrl: './shell-layout.component.html',
styleUrls: ['./shell-layout.component.css'],
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [NetworkStatusBannerComponent, ShellHeaderComponent],
imports: [
NetworkStatusBannerComponent,
ShellHeaderComponent,
ShellNavigationComponent,
],
host: {
class: 'text-isa-neutral-900',
},
})
export class ShellLayoutComponent {}
export class ShellLayoutComponent {
#navigationService = inject(NavigationService);
readonly tablet = breakpoint(Breakpoint.Tablet);
readonly renderNavigation = computed(() => {
const tablet = this.tablet();
if (!tablet) {
return true;
}
return this.#navigationService.get();
});
}

View File

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

View File

@@ -0,0 +1,3 @@
:host {
@apply block self-stretch;
}

View File

@@ -0,0 +1,10 @@
<div class="flex flex-col h-14 items-start self-stretch px-4">
<div
class="isa-text-caption-caps flex h-6 flex-col justify-center flex-[1_0_0] self-stretch"
>
{{ group().label }}
</div>
</div>
@for (item of group().items; track item.label) {
<shell-navigation-item [item]="item" />
}

View File

@@ -0,0 +1,14 @@
import { ChangeDetectionStrategy, Component, input } from '@angular/core';
import { NavigationGroup } from '../../types';
import { ShellNavigationItemComponent } from '../navigation-item/navigation-item.component';
@Component({
selector: 'shell-navigation-group',
templateUrl: './navigation-group.component.html',
styleUrls: ['./navigation-group.component.css'],
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [ShellNavigationItemComponent],
})
export class ShellNavigationGroupComponent {
readonly group = input.required<NavigationGroup>();
}

View File

@@ -0,0 +1,87 @@
:host {
@apply block;
}
a,
button {
@apply isa-text-body-1-regular;
transition:
background-color 0.15s ease-out,
font-weight 0.15s ease-out;
}
a.active,
a:hover,
a:focus,
button:hover,
button:focus {
@apply bg-isa-neutral-200;
}
a.active .label {
@apply isa-text-body-1-semibold;
}
.active-bar {
transform: scaleX(0);
transform-origin: right;
opacity: 0;
}
a.active .active-bar {
animation: barReveal 0.25s ease-out forwards;
}
@keyframes barReveal {
from {
transform: scaleX(0);
opacity: 0;
}
to {
transform: scaleX(1);
opacity: 1;
}
}
.chevron {
transition: transform 0.2s ease-out;
}
.chevron.expanded {
transform: rotate(90deg);
}
.sub-items {
display: grid;
grid-template-rows: 0fr;
opacity: 0;
transition:
grid-template-rows 0.25s ease-out,
opacity 0.25s ease-out;
}
.sub-items.expanded {
grid-template-rows: 1fr;
opacity: 1;
}
.sub-items.collapsed {
grid-template-rows: 0fr;
opacity: 0;
}
@media (prefers-reduced-motion: reduce) {
.chevron {
transition: none;
}
a.active .active-bar {
animation: none;
transform: scaleX(1);
opacity: 1;
}
.sub-items {
transition: none;
}
}

View File

@@ -0,0 +1,67 @@
@if (route(); as r) {
<a
class="relative flex flex-row h-14 px-6 items-center justify-start gap-2 self-stretch"
routerLinkActive="active"
[routerLink]="r.route"
[queryParams]="r.queryParams"
[queryParamsHandling]="r.queryParamsHandling"
data-what="navigation-item"
[attr.data-which]="item().label"
[attr.aria-label]="'Navigate to ' + item().label"
>
<ng-container *ngTemplateOutlet="content"></ng-container>
</a>
} @else {
<button
type="button"
class="relative flex flex-row h-14 px-6 items-center justify-start gap-2 self-stretch w-full text-left"
(click)="toggleExpand()"
data-what="navigation-toggle"
[attr.data-which]="item().label"
[attr.aria-expanded]="expanded()"
[attr.aria-label]="'Toggle ' + item().label + ' submenu'"
>
<ng-container *ngTemplateOutlet="content"></ng-container>
</button>
}
@if (hasChildren()) {
<div
class="sub-items bg-isa-neutral-100 mx-6 desktop:mx-[0.62rem] rounded-[.25rem] overflow-hidden"
[class.expanded]="expanded()"
[class.collapsed]="!expanded()"
>
<div class="min-h-0">
@for (child of item().childRoutes; track $index) {
<shell-navigation-sub-item [item]="child" [style.--i]="$index" />
}
</div>
</div>
}
<ng-template #content>
<div
class="active-bar absolute right-0 top-0 bottom-0 w-[0.375rem] bg-isa-neutral-700 rounded-full"
></div>
<div class="size-6 grow-0" [innerHTML]="sanitizedHtml()"></div>
<div class="label grow">
{{ item().label }}
</div>
@if (hasChildren()) {
<button
type="button"
(click)="$event.stopPropagation(); toggleExpand()"
data-what="navigation-chevron"
[attr.data-which]="item().label"
[attr.aria-expanded]="expanded()"
[attr.aria-label]="'Toggle ' + item().label + ' submenu'"
>
<ng-icon
name="isaActionChevronRight"
size="1.5rem"
class="chevron"
[class.expanded]="expanded()"
/>
</button>
}
</ng-template>

View File

@@ -0,0 +1,94 @@
import {
ChangeDetectionStrategy,
Component,
computed,
effect,
inject,
Injector,
input,
isSignal,
runInInjectionContext,
signal,
viewChildren,
} from '@angular/core';
import { NavigationItem } from '../../types';
import { DomSanitizer } from '@angular/platform-browser';
import { isaActionChevronRight } from '@isa/icons';
import { NgIcon, provideIcons } from '@ng-icons/core';
import { NgTemplateOutlet } from '@angular/common';
import { RouterLink, RouterLinkActive } from '@angular/router';
import { ShellNavigationSubItemComponent } from '../navigation-sub-item/navigation-sub-item.component';
@Component({
selector: 'shell-navigation-item',
templateUrl: './navigation-item.component.html',
styleUrls: ['./navigation-item.component.css'],
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
NgIcon,
NgTemplateOutlet,
RouterLink,
RouterLinkActive,
ShellNavigationSubItemComponent,
],
providers: [
provideIcons({
isaActionChevronRight,
}),
],
})
export class ShellNavigationItemComponent {
#injector = inject(Injector);
#sanitizer = inject(DomSanitizer);
readonly subItems = viewChildren(ShellNavigationSubItemComponent);
expanded = signal(false);
readonly item = input.required<NavigationItem>();
sanitizedHtml = computed(() => {
const icon = this.item().icon;
if (!icon) {
return '';
}
return this.#sanitizer.bypassSecurityTrustHtml(icon);
});
hasChildren = computed(() => {
return (this.item().childRoutes?.length ?? 0) > 0;
});
toggleExpand(): void {
this.expanded.update((value) => !value);
}
readonly route = computed(() => {
const route = this.item().route;
if (typeof route === 'function') {
const result = runInInjectionContext(this.#injector, () => route());
return isSignal(result) ? result() : result;
}
return route;
});
constructor() {
// Auto-expand when a child route becomes active.
// This is a valid effect use case: one-way sync of UI state based on router state.
// It only expands (never auto-collapses), preserving user's manual toggle preference.
effect(() => {
const subItems = this.subItems();
if (!subItems.length) return;
const hasActiveChild = subItems.some((subItem) => subItem.isActive());
if (hasActiveChild) {
this.expanded.set(true);
}
});
}
}

View File

@@ -0,0 +1,68 @@
:host {
display: contents;
}
a {
@apply isa-text-body-2-regular;
transition:
background-color 0.15s ease-out,
font-weight 0.15s ease-out;
}
a.slide-in {
animation: slideIn 0.2s ease-out backwards;
animation-delay: calc(var(--i, 0) * 50ms);
}
a.active,
a:hover,
a:focus,
a:active {
@apply bg-isa-neutral-200 isa-text-body-2-semibold;
}
a .active-bar {
@apply hidden;
transform: scaleX(0);
transform-origin: right;
opacity: 0;
}
a.active .active-bar {
@apply block;
animation: barReveal 0.25s ease-out forwards;
}
@keyframes slideIn {
from {
opacity: 0;
transform: translateX(-8px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
@keyframes barReveal {
from {
transform: scaleX(0);
opacity: 0;
}
to {
transform: scaleX(1);
opacity: 1;
}
}
@media (prefers-reduced-motion: reduce) {
a.slide-in {
animation: none;
}
a.active .active-bar {
animation: none;
transform: scaleX(1);
opacity: 1;
}
}

View File

@@ -0,0 +1,21 @@
@if (route(); as r) {
<a
class="flex flex-row h-14 px-4 items-center self-stretch rounded-[.25rem]"
animate.enter="slide-in"
routerLinkActive="active"
(isActiveChange)="isActive.set($event)"
[routerLink]="r.route"
[queryParams]="r.queryParams"
[queryParamsHandling]="r.queryParamsHandling"
data-what="navigation-sub-item"
[attr.data-which]="label()"
[attr.aria-label]="'Navigate to ' + label()"
>
<div class="grow">
{{ label() }}
</div>
<div
class="active-bar grow-0 w-[0.375rem] bg-isa-neutral-700 rounded-full self-stretch -mr-4"
></div>
</a>
}

View File

@@ -0,0 +1,58 @@
import {
afterNextRender,
ChangeDetectionStrategy,
Component,
computed,
inject,
Injector,
input,
isSignal,
runInInjectionContext,
signal,
viewChild,
} from '@angular/core';
import { RouterLink, RouterLinkActive } from '@angular/router';
import {
NavigationRouteWithLabel,
NavigationRouteWithLabelFn,
} from '../../types';
@Component({
selector: 'shell-navigation-sub-item',
templateUrl: './navigation-sub-item.component.html',
styleUrls: ['./navigation-sub-item.component.css'],
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [RouterLink, RouterLinkActive],
})
export class ShellNavigationSubItemComponent {
#injector = inject(Injector);
readonly routerLinkActive = viewChild(RouterLinkActive);
readonly isActive = signal(false);
constructor() {
afterNextRender(() => {
const rla = this.routerLinkActive();
if (rla?.isActive) {
this.isActive.set(true);
}
});
}
readonly item = input.required<
NavigationRouteWithLabel | NavigationRouteWithLabelFn
>();
readonly route = computed(() => {
const item = this.item();
if (typeof item === 'function') {
const result = runInInjectionContext(this.#injector, () => item());
return isSignal(result) ? result() : result;
}
return item;
});
readonly label = computed(() => this.route()?.label ?? '');
}

View File

@@ -0,0 +1,187 @@
import {
isaNavigationAbholfach,
isaNavigationArtikelsuche,
isaNavigationCalender,
isaNavigationDashboard,
isaNavigationKunden,
isaNavigationRemission1,
isaNavigationReturn,
isaNavigationSortiment,
isaNavigationWarenausgabe,
isaNavigationWareneingang1,
} from '@isa/icons';
import { injectTabId } from '@isa/core/tabs';
import { NavigationGroup, NavigationItem } from './types';
import { computed } from '@angular/core';
export const navigations: Array<NavigationGroup | NavigationItem> = [
// Standalone item with dynamic tabId route
{
type: 'item',
icon: isaNavigationDashboard,
label: 'Dashboard',
route: {
route: '/dashboard',
},
},
// KUNDEN group
{
type: 'group',
label: 'Kunden',
items: [
{
type: 'item',
icon: isaNavigationArtikelsuche,
label: 'Artikelsuche',
route: () => {
const activeTabId = injectTabId();
return computed(() => {
const tabId = activeTabId() ?? Date.now();
return {
route: ['/kunde', tabId, 'product'],
};
});
},
},
{
type: 'item',
icon: isaNavigationKunden,
label: 'Kunden',
route: () => {
const activeTabId = injectTabId();
return computed(() => {
const tabId = activeTabId() ?? Date.now();
return {
route: ['/kunde', tabId, 'customer'],
};
});
},
},
{
type: 'item',
icon: isaNavigationWarenausgabe,
label: 'Warenausgabe',
route: () => {
const activeTabId = injectTabId();
return computed(() => {
const tabId = activeTabId() ?? Date.now();
return {
route: ['/kunde', tabId, 'pickup-shelf'],
};
});
},
},
{
type: 'item',
icon: isaNavigationReturn,
label: 'Rückgabe',
route: () => {
const activeTabId = injectTabId();
return computed(() => {
const tabId = activeTabId() ?? Date.now();
return {
route: ['/', tabId, 'return'],
};
});
},
},
],
},
// FILIALE group
{
type: 'group',
label: 'Filiale',
items: [
{
type: 'item',
icon: isaNavigationCalender,
label: 'Kalender',
route: {
route: '/filiale/task-calendar',
},
},
{
type: 'item',
icon: isaNavigationSortiment,
label: 'Sortiment',
route: {
route: '/filiale/assortment',
},
},
{
type: 'item',
icon: isaNavigationAbholfach,
label: 'Abholfach',
childRoutes: [
{
label: 'Einbuchen',
route: '/filiale/pickup-shelf',
},
{
label: 'Reservierung',
route: '/filiale/goods/in/reservation',
queryParams: { view: 'reservation' },
},
{
label: 'Ausräumen',
route: '/filiale/goods/in/cleanup',
queryParams: { view: 'cleanup' },
},
{
label: 'Remi Vorschau',
route: '/filiale/goods/in/preview',
queryParams: { view: 'remission' },
},
{
label: 'Fehlende',
route: '/filiale/goods/in/list',
queryParams: { view: 'wareneingangsliste' },
},
],
},
{
type: 'item',
icon: isaNavigationRemission1,
label: 'Remission',
childRoutes: [
() => {
const activeTabId = injectTabId();
return computed(() => {
const tabId = activeTabId() ?? Date.now();
return {
label: 'Remission',
route: ['/', tabId, 'remission'],
};
});
},
() => {
const activeTabId = injectTabId();
return computed(() => {
const tabId = activeTabId() ?? Date.now();
return {
label: 'Warenbegleitscheine',
route: ['/', tabId, 'remission', 'return-receipt'],
};
});
},
],
},
{
type: 'item',
icon: isaNavigationWareneingang1,
label: 'Wareneingang',
route: {
route: '/filiale/package-inspection/packages',
queryParams: {
filter_status: '0;8',
filter_zeitraum: '"2025-11-26T23:00:00Z"-"2025-12-04T23:00:00Z"',
filter_lieferant: 'libri;knv%7Cknvbs',
},
},
},
],
},
];

View File

@@ -0,0 +1,16 @@
<nav
class="bg-isa-white rounded-r-2xl flex w-[13.75rem] py-8 gap-8 flex-col"
aria-label="Main navigation"
data-what="main-navigation"
>
@for (navigation of navigations; track navigation.label) {
@switch (navigation.type) {
@case ('group') {
<shell-navigation-group [group]="navigation" />
}
@case ('item') {
<shell-navigation-item [item]="navigation" />
}
}
}
</nav>

View File

@@ -0,0 +1,14 @@
import { Component } from '@angular/core';
import { navigations } from './navigations';
import { ShellNavigationGroupComponent } from './components/navigation-group/navigation-group.component';
import { ShellNavigationItemComponent } from './components/navigation-item/navigation-item.component';
@Component({
selector: 'shell-navigation',
imports: [ShellNavigationGroupComponent, ShellNavigationItemComponent],
templateUrl: './shell-navigation.component.html',
styleUrl: './shell-navigation.component.css',
})
export class ShellNavigationComponent {
readonly navigations = navigations;
}

View File

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

View File

@@ -1,21 +0,0 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ShellNavigationComponent } from './shell-navigation.component';
describe('ShellNavigationComponent', () => {
let component: ShellNavigationComponent;
let fixture: ComponentFixture<ShellNavigationComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [ShellNavigationComponent],
}).compileComponents();
fixture = TestBed.createComponent(ShellNavigationComponent);
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-navigation',
imports: [CommonModule],
templateUrl: './shell-navigation.component.html',
styleUrl: './shell-navigation.component.css',
})
export class ShellNavigationComponent {}

View File

@@ -0,0 +1,33 @@
import { Signal } from '@angular/core';
import { Params, QueryParamsHandling, UrlTree } from '@angular/router';
export type NavigationRoute = {
route: string | unknown[] | UrlTree;
queryParams?: Params;
queryParamsHandling?: QueryParamsHandling;
};
export type NavigationRouteWithLabel = NavigationRoute & {
label: string;
};
export type NavigationRouteFn = () => NavigationRoute | Signal<NavigationRoute>;
export type NavigationRouteWithLabelFn = () =>
| NavigationRouteWithLabel
| Signal<NavigationRouteWithLabel>;
export type NavigationItem = {
type: 'item';
icon?: string;
label: string;
route?: NavigationRoute | NavigationRouteFn;
childRoutes?: Array<NavigationRouteWithLabel | NavigationRouteWithLabelFn>;
};
export type NavigationGroup = {
type: 'group';
label: string;
items: Array<NavigationItem>;
};