feat(shell): add tabs collapsed state service and navigation indicators

Add TabsCollabsedService to manage tabs bar collapsed/expanded state with
proximity-based switching. Implement activity indicators for navigation items
that bubble up from child routes when menu is collapsed.

- Add TabsCollabsedService for centralized tabs state management
- Add indicator support to navigation types and components
- Implement indicator bubbling from children to parent when collapsed
- Update shell-layout with click-outside-to-close for mobile navigation
- Add dynamic route resolution with factory functions and signals
- Update shell-tabs with compact mode and proximity detection
This commit is contained in:
Lorenz Hilpert
2025-12-10 20:07:35 +01:00
parent 1cc13eebe1
commit 7a86fcf507
17 changed files with 386 additions and 69 deletions

View File

@@ -34,10 +34,14 @@ export function injectTabId() {
* If the current tab ID is a reserved process ID, generates a new ID using Date.now(). * If the current tab ID is a reserved process ID, generates a new ID using Date.now().
*/ */
function getNavigableTabId( function getNavigableTabId(
activeTabId: number | null, activeTabId: number | null | undefined,
reservedIds: Set<number>, reservedIds: Set<number>,
): number { ): number {
if (activeTabId === null || reservedIds.has(activeTabId)) { if (
activeTabId === undefined ||
activeTabId === null ||
reservedIds.has(activeTabId)
) {
return Date.now(); return Date.now();
} }
return activeTabId; return activeTabId;

View File

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

View File

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

View File

@@ -4,24 +4,37 @@
header { header {
@apply fixed flex flex-col gap-2 top-0 inset-x-0 z-fixed bg-isa-white; @apply fixed flex flex-col gap-2 top-0 inset-x-0 z-fixed bg-isa-white;
transition: all 0.3s ease;
}
main {
transition: margin-top 0.3s ease;
}
shell-navigation {
transition: top 0.3s ease;
} }
shell-navigation { shell-navigation {
@apply fixed bottom-4 left-0 z-fixed; @apply fixed bottom-4 left-0 z-fixed;
} }
/* Slide animation for navigation - tablet only */ shell-navigation {
@media (max-width: 1023px) { transform: translateX(-100%);
}
shell-navigation.nav-visible {
animation: slideInFromLeft 0.3s ease-out forwards;
}
shell-navigation.nav-hidden {
animation: slideOutToLeft 0.3s ease-in forwards;
}
@screen desktop-small {
shell-navigation { shell-navigation {
transform: translateX(-100%); transform: translateX(0);
} animation: none;
shell-navigation.nav-visible {
animation: slideInFromLeft 0.3s ease-out forwards;
}
shell-navigation.nav-hidden {
animation: slideOutToLeft 0.3s ease-in forwards;
} }
} }

View File

@@ -1,23 +1,34 @@
<shell-network-status-banner /> <shell-network-status-banner />
<header #headerSizeObserver="uiElementSizeObserver" uiElementSizeObserver> <header
class="bg-isa-white"
[class.h-[6.125rem]]="tabsCollabesd.get()"
[class.h-[8rem]]="!tabsCollabesd.get()"
uiElementSizeObserver
#headerSizeObserver="uiElementSizeObserver"
>
<shell-header /> <shell-header />
<shell-tabs class="px-4" /> <shell-tabs class="px-4" />
</header> </header>
<shell-navigation <shell-navigation
[style.top.px]="headerSizeObserver.size().height + fontSizeService.getPx()"
[class.nav-visible]="renderNavigation() && tablet()" [class.nav-visible]="renderNavigation() && tablet()"
[class.nav-hidden]="!renderNavigation() && tablet()" [class.nav-hidden]="!renderNavigation() && tablet()"
[style.top.px]="headerSizeObserver.size().height + fontSizeService.getPx() * 1.5"
#navigationSizeObserver="uiElementSizeObserver"
uiElementSizeObserver uiElementSizeObserver
#navigationSizeObserver="uiElementSizeObserver"
/> />
<main <main
[style.marginTop.px]=" [style.marginTop.px]="
headerSizeObserver.size().height + fontSizeService.getPx() * 1.5 headerSizeObserver.size().height + fontSizeService.getPx()
" "
[style.marginLeft.px]="mainMarginLeft()" [style.margin-left.px]="
tablet()
? '0'
: navigationSizeObserver.size().width + fontSizeService.getPx()
"
(scroll)="onMainScroll()"
> >
<ng-content></ng-content> <ng-content></ng-content>
</main> </main>

View File

@@ -2,11 +2,16 @@ import {
ChangeDetectionStrategy, ChangeDetectionStrategy,
Component, Component,
computed, computed,
ElementRef,
HostListener,
inject, inject,
viewChild,
} from '@angular/core'; } from '@angular/core';
import { NetworkStatusBannerComponent } from './components/network-status-banner.component'; import { NetworkStatusBannerComponent } from './components/network-status-banner.component';
import { NavigationService, FontSizeService } from '@isa/shell/common'; import {
NavigationService,
FontSizeService,
TabsCollabsedService,
} from '@isa/shell/common';
import { ShellHeaderComponent } from '@isa/shell/header'; import { ShellHeaderComponent } from '@isa/shell/header';
import { ShellNavigationComponent } from '@isa/shell/navigation'; import { ShellNavigationComponent } from '@isa/shell/navigation';
import { ShellTabsComponent } from '@isa/shell/tabs'; import { ShellTabsComponent } from '@isa/shell/tabs';
@@ -16,7 +21,6 @@ import {
UiElementSizeObserverDirective, UiElementSizeObserverDirective,
} from '@isa/ui/layout'; } from '@isa/ui/layout';
@Component({ @Component({
selector: 'shell-layout', selector: 'shell-layout',
standalone: true, standalone: true,
@@ -29,18 +33,18 @@ import {
ShellNavigationComponent, ShellNavigationComponent,
ShellTabsComponent, ShellTabsComponent,
UiElementSizeObserverDirective, UiElementSizeObserverDirective,
] ],
}) })
export class ShellLayoutComponent { export class ShellLayoutComponent {
#navigationService = inject(NavigationService); #navigationService = inject(NavigationService);
#elementRef = inject(ElementRef);
readonly tabsCollabesd = inject(TabsCollabsedService);
readonly fontSizeService = inject(FontSizeService); readonly fontSizeService = inject(FontSizeService);
readonly tablet = breakpoint(Breakpoint.Tablet); readonly tablet = breakpoint(Breakpoint.Tablet);
private readonly navigationSizeObserverRef =
viewChild<UiElementSizeObserverDirective>('navigationSizeObserver');
readonly renderNavigation = computed(() => { readonly renderNavigation = computed(() => {
const tablet = this.tablet(); const tablet = this.tablet();
@@ -51,18 +55,23 @@ export class ShellLayoutComponent {
return this.#navigationService.get(); return this.#navigationService.get();
}); });
readonly mainMarginLeft = computed(() => { @HostListener('document:click', ['$event'])
// On tablet, navigation overlaps content (no margin) onDocumentClick(event: MouseEvent): void {
if (this.tablet()) { if (!this.tablet() || !this.#navigationService.get()) return;
return 0;
}
// On desktop, add margin to account for navigation width const target = event.target as HTMLElement;
const navSize = this.navigationSizeObserverRef()?.size(); const nav = this.#elementRef.nativeElement.querySelector('shell-navigation');
if (navSize) { const toggleBtn = target.closest('[data-which="navigation-toggle"]');
return navSize.width + this.fontSizeService.getPx() * 1.5;
}
return 0; if (toggleBtn) return;
});
if (nav && !nav.contains(target)) {
this.#navigationService.set(false);
}
}
onMainScroll(): void {
if (!this.tablet() || !this.#navigationService.get()) return;
this.#navigationService.set(false);
}
} }

View File

@@ -10,7 +10,7 @@ button {
font-weight 0.15s ease-out; font-weight 0.15s ease-out;
} }
a.active, a.active:not(.hide-active),
a:hover, a:hover,
a:focus, a:focus,
button:hover, button:hover,
@@ -18,7 +18,7 @@ button:focus {
@apply bg-isa-neutral-200; @apply bg-isa-neutral-200;
} }
a.active .label { a.active:not(.hide-active) .label {
@apply isa-text-body-1-semibold; @apply isa-text-body-1-semibold;
} }
@@ -28,7 +28,7 @@ a.active .label {
opacity: 0; opacity: 0;
} }
a.active .active-bar { a.active:not(.hide-active) .active-bar {
animation: barReveal 0.25s ease-out forwards; animation: barReveal 0.25s ease-out forwards;
} }
@@ -70,12 +70,36 @@ a.active .active-bar {
opacity: 0; opacity: 0;
} }
.indicator-dot {
@apply absolute -top-1 -right-1 size-2 rounded-full bg-isa-accent-red;
animation: pulse-in 0.3s ease-out;
}
.indicator-dot-centered {
@apply size-1 rounded-full bg-isa-accent-red;
animation: pulse-in 0.3s ease-out;
}
@keyframes pulse-in {
0% {
transform: scale(0);
opacity: 0;
}
50% {
transform: scale(1.4);
}
100% {
transform: scale(1);
opacity: 1;
}
}
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
.chevron { .chevron {
transition: none; transition: none;
} }
a.active .active-bar { a.active:not(.hide-active) .active-bar {
animation: none; animation: none;
transform: scaleX(1); transform: scaleX(1);
opacity: 1; opacity: 1;
@@ -84,4 +108,9 @@ a.active .active-bar {
.sub-items { .sub-items {
transition: none; transition: none;
} }
.indicator-dot,
.indicator-dot-centered {
animation: none;
}
} }

View File

@@ -2,9 +2,11 @@
<a <a
class="relative flex flex-row h-14 px-6 items-center justify-start gap-2 self-stretch" class="relative flex flex-row h-14 px-6 items-center justify-start gap-2 self-stretch"
routerLinkActive="active" routerLinkActive="active"
[class.hide-active]="expanded() && hasActiveChild()"
[routerLink]="r.route" [routerLink]="r.route"
[queryParams]="r.queryParams" [queryParams]="r.queryParams"
[queryParamsHandling]="r.queryParamsHandling" [queryParamsHandling]="r.queryParamsHandling"
(click)="hasChildren() && expanded.set(true); closeNavigation()"
data-what="navigation-item" data-what="navigation-item"
[attr.data-which]="item().label" [attr.data-which]="item().label"
[attr.aria-label]="'Navigate to ' + item().label" [attr.aria-label]="'Navigate to ' + item().label"
@@ -43,14 +45,21 @@
<div <div
class="active-bar absolute right-0 top-0 bottom-0 w-[0.375rem] bg-isa-neutral-700 rounded-full" class="active-bar absolute right-0 top-0 bottom-0 w-[0.375rem] bg-isa-neutral-700 rounded-full"
></div> ></div>
<div class="size-6 grow-0" [innerHTML]="sanitizedHtml()"></div> <div class="relative size-6 grow-0 flex items-center justify-center">
@if (item().icon) {
<div [innerHTML]="sanitizedHtml()"></div>
}
@if (showIndicator()) {
<span [class]="item().icon ? 'indicator-dot' : 'indicator-dot-centered'"></span>
}
</div>
<div class="label grow"> <div class="label grow">
{{ item().label }} {{ item().label }}
</div> </div>
@if (hasChildren()) { @if (hasChildren()) {
<button <button
type="button" type="button"
(click)="$event.stopPropagation(); toggleExpand()" (click)="$event.stopPropagation(); $event.preventDefault(); toggleExpand()"
data-what="navigation-chevron" data-what="navigation-chevron"
[attr.data-which]="item().label" [attr.data-which]="item().label"
[attr.aria-expanded]="expanded()" [attr.aria-expanded]="expanded()"

View File

@@ -14,6 +14,7 @@ import {
import { NavigationItem } from '../../types'; import { NavigationItem } from '../../types';
import { DomSanitizer } from '@angular/platform-browser'; import { DomSanitizer } from '@angular/platform-browser';
import { isaActionChevronRight } from '@isa/icons'; import { isaActionChevronRight } from '@isa/icons';
import { NavigationService } from '@isa/shell/common';
import { NgIcon, provideIcons } from '@ng-icons/core'; import { NgIcon, provideIcons } from '@ng-icons/core';
import { NgTemplateOutlet } from '@angular/common'; import { NgTemplateOutlet } from '@angular/common';
import { RouterLink, RouterLinkActive } from '@angular/router'; import { RouterLink, RouterLinkActive } from '@angular/router';
@@ -40,6 +41,7 @@ import { ShellNavigationSubItemComponent } from '../navigation-sub-item/navigati
export class ShellNavigationItemComponent { export class ShellNavigationItemComponent {
#injector = inject(Injector); #injector = inject(Injector);
#sanitizer = inject(DomSanitizer); #sanitizer = inject(DomSanitizer);
#navigationService = inject(NavigationService);
readonly subItems = viewChildren(ShellNavigationSubItemComponent); readonly subItems = viewChildren(ShellNavigationSubItemComponent);
@@ -61,10 +63,50 @@ export class ShellNavigationItemComponent {
return (this.item().childRoutes?.length ?? 0) > 0; return (this.item().childRoutes?.length ?? 0) > 0;
}); });
hasActiveChild = computed(() => {
return this.subItems().some((subItem) => subItem.isActive());
});
readonly resolvedIndicator = computed(() => {
const indicator = this.item().indicator;
if (typeof indicator === 'function') {
const result = runInInjectionContext(this.#injector, () => indicator());
return isSignal(result) ? result() : result;
}
return indicator;
});
readonly hasChildIndicator = computed(() => {
return this.subItems().some((subItem) => subItem.resolvedIndicator());
});
readonly showIndicator = computed(() => {
// Show indicator if:
// 1. This item has its own indicator, OR
// 2. A child has an indicator AND the menu is collapsed (bubble up)
return this.resolvedIndicator() || (this.hasChildIndicator() && !this.expanded());
});
toggleExpand(): void { toggleExpand(): void {
this.expanded.update((value) => !value); this.expanded.update((value) => !value);
} }
closeNavigation(): void {
this.#navigationService.set(false);
}
readonly firstChildRoute = computed(() => {
const childRoutes = this.item().childRoutes;
if (!childRoutes?.length) return undefined;
const firstChild = childRoutes[0];
if (typeof firstChild === 'function') {
const result = runInInjectionContext(this.#injector, () => firstChild());
return isSignal(result) ? result() : result;
}
return firstChild;
});
readonly route = computed(() => { readonly route = computed(() => {
const route = this.item().route; const route = this.item().route;
@@ -73,7 +115,8 @@ export class ShellNavigationItemComponent {
return isSignal(result) ? result() : result; return isSignal(result) ? result() : result;
} }
return route; // Fall back to first child route if no direct route
return route ?? this.firstChildRoute();
}); });
constructor() { constructor() {

View File

@@ -55,6 +55,25 @@ a.active .active-bar {
} }
} }
.indicator-dot {
@apply size-2 rounded-full bg-isa-accent-red shrink-0;
animation: pulse-in 0.3s ease-out;
}
@keyframes pulse-in {
0% {
transform: scale(0);
opacity: 0;
}
50% {
transform: scale(1.4);
}
100% {
transform: scale(1);
opacity: 1;
}
}
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
a.slide-in { a.slide-in {
animation: none; animation: none;
@@ -65,4 +84,8 @@ a.active .active-bar {
transform: scaleX(1); transform: scaleX(1);
opacity: 1; opacity: 1;
} }
.indicator-dot {
animation: none;
}
} }

View File

@@ -7,10 +7,14 @@
[routerLink]="r.route" [routerLink]="r.route"
[queryParams]="r.queryParams" [queryParams]="r.queryParams"
[queryParamsHandling]="r.queryParamsHandling" [queryParamsHandling]="r.queryParamsHandling"
(click)="closeNavigation()"
data-what="navigation-sub-item" data-what="navigation-sub-item"
[attr.data-which]="label()" [attr.data-which]="label()"
[attr.aria-label]="'Navigate to ' + label()" [attr.aria-label]="'Navigate to ' + label()"
> >
@if (resolvedIndicator()) {
<span class="indicator-dot mr-2"></span>
}
<div class="grow"> <div class="grow">
{{ label() }} {{ label() }}
</div> </div>

View File

@@ -12,7 +12,9 @@ import {
viewChild, viewChild,
} from '@angular/core'; } from '@angular/core';
import { RouterLink, RouterLinkActive } from '@angular/router'; import { RouterLink, RouterLinkActive } from '@angular/router';
import { NavigationService } from '@isa/shell/common';
import { import {
NavigationItemIndicator,
NavigationRouteWithLabel, NavigationRouteWithLabel,
NavigationRouteWithLabelFn, NavigationRouteWithLabelFn,
} from '../../types'; } from '../../types';
@@ -26,6 +28,7 @@ import {
}) })
export class ShellNavigationSubItemComponent { export class ShellNavigationSubItemComponent {
#injector = inject(Injector); #injector = inject(Injector);
#navigationService = inject(NavigationService);
readonly routerLinkActive = viewChild(RouterLinkActive); readonly routerLinkActive = viewChild(RouterLinkActive);
readonly isActive = signal(false); readonly isActive = signal(false);
@@ -55,4 +58,20 @@ export class ShellNavigationSubItemComponent {
}); });
readonly label = computed(() => this.route()?.label ?? ''); readonly label = computed(() => this.route()?.label ?? '');
readonly resolvedIndicator = computed((): NavigationItemIndicator => {
const route = this.route();
if (!route) return null;
const indicator = route.indicator;
if (typeof indicator === 'function') {
const result = runInInjectionContext(this.#injector, () => indicator());
return isSignal(result) ? result() : result;
}
return indicator;
});
closeNavigation(): void {
this.#navigationService.set(false);
}
} }

View File

@@ -16,9 +16,15 @@ import {
injectLegacyTabRoute, injectLegacyTabRoute,
injectLabeledLegacyTabRoute, injectLabeledLegacyTabRoute,
} from '@isa/core/tabs'; } from '@isa/core/tabs';
import {
SelectedShoppingCartResource,
SelectedRewardShoppingCartResource,
} from '@isa/checkout/data-access';
import { RemissionStore } from '@isa/remission/data-access';
import { NavigationGroup, NavigationItem } from './types'; import { NavigationGroup, NavigationItem } from './types';
import { startOfDay, subDays } from 'date-fns'; import { startOfDay, subDays } from 'date-fns';
import { computed, inject } from '@angular/core';
const START_OF_DAY = startOfDay(new Date()); const START_OF_DAY = startOfDay(new Date());
@@ -41,6 +47,19 @@ export const navigations: Array<NavigationGroup | NavigationItem> = [
{ {
type: 'item', type: 'item',
icon: isaNavigationArtikelsuche, icon: isaNavigationArtikelsuche,
indicator: () => {
const shoppingCartResource = inject(
SelectedShoppingCartResource,
).resource;
return computed(() => {
if (!shoppingCartResource.hasValue()) {
return false;
}
const cart = shoppingCartResource.value();
return cart?.items?.length > 0;
});
},
label: 'Artikelsuche', label: 'Artikelsuche',
route: () => route: () =>
injectLegacyTabRoute([ injectLegacyTabRoute([
@@ -68,7 +87,21 @@ export const navigations: Array<NavigationGroup | NavigationItem> = [
}, },
}, },
]), ]),
() => injectLabeledTabRoute('Prämienshop', ['reward']), () => {
const routeSignal = injectLabeledTabRoute('Prämienshop', [
'reward',
]);
const rewardCartResource = inject(
SelectedRewardShoppingCartResource,
).resource;
return computed(() => {
const route = routeSignal();
const hasItems =
rewardCartResource.hasValue() &&
(rewardCartResource.value()?.items?.length ?? 0) > 0;
return { ...route, indicator: hasItems };
});
},
], ],
}, },
{ {
@@ -150,7 +183,16 @@ export const navigations: Array<NavigationGroup | NavigationItem> = [
icon: isaNavigationRemission1, icon: isaNavigationRemission1,
label: 'Remission', label: 'Remission',
childRoutes: [ childRoutes: [
() => injectLabeledTabRoute('Remission', ['remission']), () => {
const routeSignal = injectLabeledTabRoute('Remission', [
'remission',
]);
const remissionStore = inject(RemissionStore);
return computed(() => ({
...routeSignal(),
indicator: remissionStore.remissionStarted(),
}));
},
() => () =>
injectLabeledTabRoute('Warenbegleitscheine', [ injectLabeledTabRoute('Warenbegleitscheine', [
'remission', 'remission',

View File

@@ -1,6 +1,20 @@
import { Signal } from '@angular/core'; import { Signal } from '@angular/core';
import { Params, QueryParamsHandling, UrlTree } from '@angular/router'; import { Params, QueryParamsHandling, UrlTree } from '@angular/router';
/**
* Indicator value for navigation items.
* Shows a red dot when truthy, hidden when falsy.
*/
export type NavigationItemIndicator = boolean | undefined | null;
/**
* Factory function that returns an indicator value.
* Runs in injection context to access services/stores.
*/
export type NavigationItemIndicatorFn = () =>
| NavigationItemIndicator
| Signal<NavigationItemIndicator>;
/** /**
* Route configuration for navigation items. * Route configuration for navigation items.
* Supports static routes, array-based routes, or UrlTree objects. * Supports static routes, array-based routes, or UrlTree objects.
@@ -21,6 +35,8 @@ export type NavigationRoute = {
export type NavigationRouteWithLabel = NavigationRoute & { export type NavigationRouteWithLabel = NavigationRoute & {
/** Display label for the route in menus. */ /** Display label for the route in menus. */
label: string; label: string;
/** Indicator for displaying unfinished state on this child route. */
indicator?: NavigationItemIndicator | NavigationItemIndicatorFn;
}; };
/** /**
@@ -51,6 +67,13 @@ export type NavigationItem = {
type: 'item'; type: 'item';
/** SVG icon content to display (optional). */ /** SVG icon content to display (optional). */
icon?: string; icon?: string;
/**
* Indicator for displaying what navigation item
* has an unfinised state (e.g., shopping cart has items, remission not completed).
*/
indicator?: NavigationItemIndicator | NavigationItemIndicatorFn;
/** Display label for the item. */ /** Display label for the item. */
label: string; label: string;
/** Primary navigation route (static or factory function). */ /** Primary navigation route (static or factory function). */

View File

@@ -1,3 +1,46 @@
:host { :host {
@apply flex items-start gap-2; @apply flex items-start gap-2;
} }
/* Enter animation - pop in with subtle bounce */
@keyframes popIn {
0% {
opacity: 0;
transform: scale(0.7);
}
80% {
opacity: 1;
transform: scale(1.02);
}
100% {
opacity: 1;
transform: scale(1);
}
}
/* Exit animation - shrink and fade */
@keyframes popOut {
0% {
opacity: 1;
transform: scale(1);
}
100% {
opacity: 0;
transform: scale(0.7);
}
}
.fade-slide-in {
animation: popIn 0.35s cubic-bezier(0.34, 1.56, 0.64, 1);
}
.fade-slide-out {
animation: popOut 0.2s cubic-bezier(0.4, 0, 1, 1) forwards;
}
@media (prefers-reduced-motion: reduce) {
.fade-slide-in,
.fade-slide-out {
animation-duration: 0.01s;
}
}

View File

@@ -1,25 +1,38 @@
@let _tabs = tabs(); @let _tabs = tabs();
<ui-carousel class="flex-1 min-w-0"> <ui-carousel class="flex-1 min-w-0">
@for (tab of _tabs; track tab.id) { @for (tab of _tabs; track tab.id) {
<shell-tab-item [tab]="tab" [compact]="compact()" /> <shell-tab-item
animate.enter="fade-slide-in"
animate.leave="fade-slide-out"
[tab]="tab"
[compact]="compact()"
/>
} }
<button @if (!compact() && _tabs.length > 0) {
uiTextButton <button
[disabled]="_tabs.length === 0" animate.enter="fade-slide-in"
color="strong" animate.leave="fade-slide-out"
[size]="compact() ? 'small' : 'medium'" uiTextButton
class="transition-all duration-200 self-center whitespace-nowrap" [disabled]="_tabs.length === 0"
(click)="closeAll()" color="strong"
[class.h-6]="compact()" [size]="compact() ? 'small' : 'medium'"
> class="self-center whitespace-nowrap"
Alle schließen (click)="closeAll()"
</button> [class.h-6]="compact()"
>
Alle schließen
</button>
}
</ui-carousel> </ui-carousel>
<ui-icon-button @if (!compact()) {
name="isaActionPlus" <ui-icon-button
[size]="compact() ? 'small' : 'large'" animate.enter="fade-slide-in"
class="shrink-0 self-center transition-all duration-200 -mt-2 z-sticky" animate.leave="fade-slide-out"
[class.size-6]="compact()" name="isaActionPlus"
(click)="addTab()" [size]="compact() ? 'small' : 'large'"
></ui-icon-button> class="shrink-0 self-center -mt-2 z-sticky"
[class.size-6]="compact()"
(click)="addTab()"
></ui-icon-button>
}

View File

@@ -6,6 +6,8 @@ import { CarouselComponent } from '@isa/ui/carousel';
import { IconButtonComponent, TextButtonComponent } from '@isa/ui/buttons'; import { IconButtonComponent, TextButtonComponent } from '@isa/ui/buttons';
import { provideIcons } from '@ng-icons/core'; import { provideIcons } from '@ng-icons/core';
import { isaActionPlus } from '@isa/icons'; import { isaActionPlus } from '@isa/icons';
import { TabsCollabsedService } from '@isa/shell/common';
import { Breakpoint, breakpoint } from '@isa/ui/layout';
/** /**
* Distance in pixels from the component edge within which * Distance in pixels from the component edge within which
@@ -47,23 +49,28 @@ const PROXIMITY_THRESHOLD_PX = 50;
}, },
}) })
export class ShellTabsComponent { export class ShellTabsComponent {
#tablet = breakpoint(Breakpoint.Tablet);
#tabService = inject(TabService); #tabService = inject(TabService);
#tabsCollabsedService = inject(TabsCollabsedService);
#elementRef = inject(ElementRef); #elementRef = inject(ElementRef);
#router = inject(Router); #router = inject(Router);
/** All tabs from the TabService. */ /** All tabs from the TabService. */
readonly tabs = this.#tabService.entities; readonly tabs = this.#tabService.entities;
#isNear = signal(false);
/** Whether tabs should display in compact mode (mouse is far from component). */ /** Whether tabs should display in compact mode (mouse is far from component). */
compact = computed(() => !this.#isNear()); compact = this.#tabsCollabsedService.get;
/** /**
* Handles mouse movement to detect proximity to the tabs bar. * Handles mouse movement to detect proximity to the tabs bar.
* Updates compact mode based on whether the mouse is near the component. * Updates compact mode based on whether the mouse is near the component.
*/ */
onMouseMove(event: MouseEvent): void { onMouseMove(event: MouseEvent): void {
if (this.#tablet()) {
return;
}
const rect = this.#elementRef.nativeElement.getBoundingClientRect(); const rect = this.#elementRef.nativeElement.getBoundingClientRect();
const mouseY = event.clientY; const mouseY = event.clientY;
const mouseX = event.clientX; const mouseX = event.clientX;
@@ -77,7 +84,7 @@ export class ShellTabsComponent {
: 0; : 0;
const isNear = isWithinX && distanceY <= PROXIMITY_THRESHOLD_PX; const isNear = isWithinX && distanceY <= PROXIMITY_THRESHOLD_PX;
this.#isNear.set(isNear); this.#tabsCollabsedService.set(!isNear);
} }
/** Closes all open tabs and navigates to the root route. */ /** Closes all open tabs and navigates to the root route. */