diff --git a/libs/core/tabs/src/lib/tab.injector.ts b/libs/core/tabs/src/lib/tab.injector.ts index 6ecb0e8ec..b7744fb35 100644 --- a/libs/core/tabs/src/lib/tab.injector.ts +++ b/libs/core/tabs/src/lib/tab.injector.ts @@ -34,10 +34,14 @@ export function injectTabId() { * If the current tab ID is a reserved process ID, generates a new ID using Date.now(). */ function getNavigableTabId( - activeTabId: number | null, + activeTabId: number | null | undefined, reservedIds: Set, ): number { - if (activeTabId === null || reservedIds.has(activeTabId)) { + if ( + activeTabId === undefined || + activeTabId === null || + reservedIds.has(activeTabId) + ) { return Date.now(); } return activeTabId; diff --git a/libs/shell/common/src/index.ts b/libs/shell/common/src/index.ts index 923ce55ce..6314eee2f 100644 --- a/libs/shell/common/src/index.ts +++ b/libs/shell/common/src/index.ts @@ -1,3 +1,4 @@ export * from './lib/navigation.service'; export * from './lib/font-size.service'; export * from './lib/notifications.service'; +export * from './lib/tabs-collabsed.service'; diff --git a/libs/shell/common/src/lib/tabs-collabsed.service.ts b/libs/shell/common/src/lib/tabs-collabsed.service.ts new file mode 100644 index 000000000..97c79eead --- /dev/null +++ b/libs/shell/common/src/lib/tabs-collabsed.service.ts @@ -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(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); + } +} diff --git a/libs/shell/layout/src/lib/shell-layout.component.css b/libs/shell/layout/src/lib/shell-layout.component.css index 8000ba3b9..77399764b 100644 --- a/libs/shell/layout/src/lib/shell-layout.component.css +++ b/libs/shell/layout/src/lib/shell-layout.component.css @@ -4,24 +4,37 @@ header { @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 { @apply fixed bottom-4 left-0 z-fixed; } -/* Slide animation for navigation - tablet only */ -@media (max-width: 1023px) { +shell-navigation { + 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 { - 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; + transform: translateX(0); + animation: none; } } diff --git a/libs/shell/layout/src/lib/shell-layout.component.html b/libs/shell/layout/src/lib/shell-layout.component.html index f6d00e673..6883245ac 100644 --- a/libs/shell/layout/src/lib/shell-layout.component.html +++ b/libs/shell/layout/src/lib/shell-layout.component.html @@ -1,23 +1,34 @@ -
+
diff --git a/libs/shell/layout/src/lib/shell-layout.component.ts b/libs/shell/layout/src/lib/shell-layout.component.ts index a9000f6ce..627ad8d1a 100644 --- a/libs/shell/layout/src/lib/shell-layout.component.ts +++ b/libs/shell/layout/src/lib/shell-layout.component.ts @@ -2,11 +2,16 @@ import { ChangeDetectionStrategy, Component, computed, + ElementRef, + HostListener, inject, - viewChild, } from '@angular/core'; 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 { ShellNavigationComponent } from '@isa/shell/navigation'; import { ShellTabsComponent } from '@isa/shell/tabs'; @@ -16,7 +21,6 @@ import { UiElementSizeObserverDirective, } from '@isa/ui/layout'; - @Component({ selector: 'shell-layout', standalone: true, @@ -29,18 +33,18 @@ import { ShellNavigationComponent, ShellTabsComponent, UiElementSizeObserverDirective, - ] + ], }) export class ShellLayoutComponent { #navigationService = inject(NavigationService); + #elementRef = inject(ElementRef); + + readonly tabsCollabesd = inject(TabsCollabsedService); readonly fontSizeService = inject(FontSizeService); readonly tablet = breakpoint(Breakpoint.Tablet); - private readonly navigationSizeObserverRef = - viewChild('navigationSizeObserver'); - readonly renderNavigation = computed(() => { const tablet = this.tablet(); @@ -51,18 +55,23 @@ export class ShellLayoutComponent { return this.#navigationService.get(); }); - readonly mainMarginLeft = computed(() => { - // On tablet, navigation overlaps content (no margin) - if (this.tablet()) { - return 0; - } + @HostListener('document:click', ['$event']) + onDocumentClick(event: MouseEvent): void { + if (!this.tablet() || !this.#navigationService.get()) return; - // On desktop, add margin to account for navigation width - const navSize = this.navigationSizeObserverRef()?.size(); - if (navSize) { - return navSize.width + this.fontSizeService.getPx() * 1.5; - } + const target = event.target as HTMLElement; + const nav = this.#elementRef.nativeElement.querySelector('shell-navigation'); + const toggleBtn = target.closest('[data-which="navigation-toggle"]'); - 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); + } } diff --git a/libs/shell/navigation/src/lib/components/navigation-item/navigation-item.component.css b/libs/shell/navigation/src/lib/components/navigation-item/navigation-item.component.css index e1574133c..6787274dd 100644 --- a/libs/shell/navigation/src/lib/components/navigation-item/navigation-item.component.css +++ b/libs/shell/navigation/src/lib/components/navigation-item/navigation-item.component.css @@ -10,7 +10,7 @@ button { font-weight 0.15s ease-out; } -a.active, +a.active:not(.hide-active), a:hover, a:focus, button:hover, @@ -18,7 +18,7 @@ button:focus { @apply bg-isa-neutral-200; } -a.active .label { +a.active:not(.hide-active) .label { @apply isa-text-body-1-semibold; } @@ -28,7 +28,7 @@ a.active .label { opacity: 0; } -a.active .active-bar { +a.active:not(.hide-active) .active-bar { animation: barReveal 0.25s ease-out forwards; } @@ -70,12 +70,36 @@ a.active .active-bar { 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) { .chevron { transition: none; } - a.active .active-bar { + a.active:not(.hide-active) .active-bar { animation: none; transform: scaleX(1); opacity: 1; @@ -84,4 +108,9 @@ a.active .active-bar { .sub-items { transition: none; } + + .indicator-dot, + .indicator-dot-centered { + animation: none; + } } diff --git a/libs/shell/navigation/src/lib/components/navigation-item/navigation-item.component.html b/libs/shell/navigation/src/lib/components/navigation-item/navigation-item.component.html index 81b87beae..6d4996ad1 100644 --- a/libs/shell/navigation/src/lib/components/navigation-item/navigation-item.component.html +++ b/libs/shell/navigation/src/lib/components/navigation-item/navigation-item.component.html @@ -2,9 +2,11 @@ -
+
+ @if (item().icon) { +
+ } + @if (showIndicator()) { + + } +
{{ item().label }}
@if (hasChildren()) { + @if (!compact() && _tabs.length > 0) { + + } - +@if (!compact()) { + +} diff --git a/libs/shell/tabs/src/lib/shell-tabs.component.ts b/libs/shell/tabs/src/lib/shell-tabs.component.ts index ed0f27d0f..a67fc90de 100644 --- a/libs/shell/tabs/src/lib/shell-tabs.component.ts +++ b/libs/shell/tabs/src/lib/shell-tabs.component.ts @@ -6,6 +6,8 @@ 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 { Breakpoint, breakpoint } from '@isa/ui/layout'; /** * Distance in pixels from the component edge within which @@ -47,23 +49,28 @@ const PROXIMITY_THRESHOLD_PX = 50; }, }) export class ShellTabsComponent { + #tablet = breakpoint(Breakpoint.Tablet); + #tabService = inject(TabService); + #tabsCollabsedService = inject(TabsCollabsedService); #elementRef = inject(ElementRef); #router = inject(Router); /** All tabs from the TabService. */ readonly tabs = this.#tabService.entities; - #isNear = signal(false); - /** 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. * Updates compact mode based on whether the mouse is near the component. */ onMouseMove(event: MouseEvent): void { + if (this.#tablet()) { + return; + } + const rect = this.#elementRef.nativeElement.getBoundingClientRect(); const mouseY = event.clientY; const mouseX = event.clientX; @@ -77,7 +84,7 @@ export class ShellTabsComponent { : 0; 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. */