mirror of
https://dev.azure.com/hugendubel/ISA/_git/ISA-Frontend
synced 2025-12-31 09:37:15 +01:00
✨ 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:
@@ -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;
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
24
libs/shell/common/src/lib/tabs-collabsed.service.ts
Normal file
24
libs/shell/common/src/lib/tabs-collabsed.service.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()"
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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). */
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
}
|
||||||
|
|||||||
@@ -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. */
|
||||||
|
|||||||
Reference in New Issue
Block a user