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().
|
||||
*/
|
||||
function getNavigableTabId(
|
||||
activeTabId: number | null,
|
||||
activeTabId: number | null | undefined,
|
||||
reservedIds: Set<number>,
|
||||
): number {
|
||||
if (activeTabId === null || reservedIds.has(activeTabId)) {
|
||||
if (
|
||||
activeTabId === undefined ||
|
||||
activeTabId === null ||
|
||||
reservedIds.has(activeTabId)
|
||||
) {
|
||||
return Date.now();
|
||||
}
|
||||
return activeTabId;
|
||||
|
||||
@@ -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';
|
||||
|
||||
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 {
|
||||
@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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,23 +1,34 @@
|
||||
<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-tabs class="px-4" />
|
||||
</header>
|
||||
|
||||
<shell-navigation
|
||||
[style.top.px]="headerSizeObserver.size().height + fontSizeService.getPx()"
|
||||
[class.nav-visible]="renderNavigation() && tablet()"
|
||||
[class.nav-hidden]="!renderNavigation() && tablet()"
|
||||
[style.top.px]="headerSizeObserver.size().height + fontSizeService.getPx() * 1.5"
|
||||
#navigationSizeObserver="uiElementSizeObserver"
|
||||
uiElementSizeObserver
|
||||
#navigationSizeObserver="uiElementSizeObserver"
|
||||
/>
|
||||
|
||||
<main
|
||||
[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>
|
||||
</main>
|
||||
|
||||
@@ -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<UiElementSizeObserverDirective>('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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,9 +2,11 @@
|
||||
<a
|
||||
class="relative flex flex-row h-14 px-6 items-center justify-start gap-2 self-stretch"
|
||||
routerLinkActive="active"
|
||||
[class.hide-active]="expanded() && hasActiveChild()"
|
||||
[routerLink]="r.route"
|
||||
[queryParams]="r.queryParams"
|
||||
[queryParamsHandling]="r.queryParamsHandling"
|
||||
(click)="hasChildren() && expanded.set(true); closeNavigation()"
|
||||
data-what="navigation-item"
|
||||
[attr.data-which]="item().label"
|
||||
[attr.aria-label]="'Navigate to ' + item().label"
|
||||
@@ -43,14 +45,21 @@
|
||||
<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="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">
|
||||
{{ item().label }}
|
||||
</div>
|
||||
@if (hasChildren()) {
|
||||
<button
|
||||
type="button"
|
||||
(click)="$event.stopPropagation(); toggleExpand()"
|
||||
(click)="$event.stopPropagation(); $event.preventDefault(); toggleExpand()"
|
||||
data-what="navigation-chevron"
|
||||
[attr.data-which]="item().label"
|
||||
[attr.aria-expanded]="expanded()"
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
import { NavigationItem } from '../../types';
|
||||
import { DomSanitizer } from '@angular/platform-browser';
|
||||
import { isaActionChevronRight } from '@isa/icons';
|
||||
import { NavigationService } from '@isa/shell/common';
|
||||
import { NgIcon, provideIcons } from '@ng-icons/core';
|
||||
import { NgTemplateOutlet } from '@angular/common';
|
||||
import { RouterLink, RouterLinkActive } from '@angular/router';
|
||||
@@ -40,6 +41,7 @@ import { ShellNavigationSubItemComponent } from '../navigation-sub-item/navigati
|
||||
export class ShellNavigationItemComponent {
|
||||
#injector = inject(Injector);
|
||||
#sanitizer = inject(DomSanitizer);
|
||||
#navigationService = inject(NavigationService);
|
||||
|
||||
readonly subItems = viewChildren(ShellNavigationSubItemComponent);
|
||||
|
||||
@@ -61,10 +63,50 @@ export class ShellNavigationItemComponent {
|
||||
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 {
|
||||
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(() => {
|
||||
const route = this.item().route;
|
||||
|
||||
@@ -73,7 +115,8 @@ export class ShellNavigationItemComponent {
|
||||
return isSignal(result) ? result() : result;
|
||||
}
|
||||
|
||||
return route;
|
||||
// Fall back to first child route if no direct route
|
||||
return route ?? this.firstChildRoute();
|
||||
});
|
||||
|
||||
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) {
|
||||
a.slide-in {
|
||||
animation: none;
|
||||
@@ -65,4 +84,8 @@ a.active .active-bar {
|
||||
transform: scaleX(1);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.indicator-dot {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,10 +7,14 @@
|
||||
[routerLink]="r.route"
|
||||
[queryParams]="r.queryParams"
|
||||
[queryParamsHandling]="r.queryParamsHandling"
|
||||
(click)="closeNavigation()"
|
||||
data-what="navigation-sub-item"
|
||||
[attr.data-which]="label()"
|
||||
[attr.aria-label]="'Navigate to ' + label()"
|
||||
>
|
||||
@if (resolvedIndicator()) {
|
||||
<span class="indicator-dot mr-2"></span>
|
||||
}
|
||||
<div class="grow">
|
||||
{{ label() }}
|
||||
</div>
|
||||
|
||||
@@ -12,7 +12,9 @@ import {
|
||||
viewChild,
|
||||
} from '@angular/core';
|
||||
import { RouterLink, RouterLinkActive } from '@angular/router';
|
||||
import { NavigationService } from '@isa/shell/common';
|
||||
import {
|
||||
NavigationItemIndicator,
|
||||
NavigationRouteWithLabel,
|
||||
NavigationRouteWithLabelFn,
|
||||
} from '../../types';
|
||||
@@ -26,6 +28,7 @@ import {
|
||||
})
|
||||
export class ShellNavigationSubItemComponent {
|
||||
#injector = inject(Injector);
|
||||
#navigationService = inject(NavigationService);
|
||||
|
||||
readonly routerLinkActive = viewChild(RouterLinkActive);
|
||||
readonly isActive = signal(false);
|
||||
@@ -55,4 +58,20 @@ export class ShellNavigationSubItemComponent {
|
||||
});
|
||||
|
||||
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,
|
||||
injectLabeledLegacyTabRoute,
|
||||
} 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 { startOfDay, subDays } from 'date-fns';
|
||||
import { computed, inject } from '@angular/core';
|
||||
|
||||
const START_OF_DAY = startOfDay(new Date());
|
||||
|
||||
@@ -41,6 +47,19 @@ export const navigations: Array<NavigationGroup | NavigationItem> = [
|
||||
{
|
||||
type: 'item',
|
||||
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',
|
||||
route: () =>
|
||||
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,
|
||||
label: 'Remission',
|
||||
childRoutes: [
|
||||
() => injectLabeledTabRoute('Remission', ['remission']),
|
||||
() => {
|
||||
const routeSignal = injectLabeledTabRoute('Remission', [
|
||||
'remission',
|
||||
]);
|
||||
const remissionStore = inject(RemissionStore);
|
||||
return computed(() => ({
|
||||
...routeSignal(),
|
||||
indicator: remissionStore.remissionStarted(),
|
||||
}));
|
||||
},
|
||||
() =>
|
||||
injectLabeledTabRoute('Warenbegleitscheine', [
|
||||
'remission',
|
||||
|
||||
@@ -1,6 +1,20 @@
|
||||
import { Signal } from '@angular/core';
|
||||
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.
|
||||
* Supports static routes, array-based routes, or UrlTree objects.
|
||||
@@ -21,6 +35,8 @@ export type NavigationRoute = {
|
||||
export type NavigationRouteWithLabel = NavigationRoute & {
|
||||
/** Display label for the route in menus. */
|
||||
label: string;
|
||||
/** Indicator for displaying unfinished state on this child route. */
|
||||
indicator?: NavigationItemIndicator | NavigationItemIndicatorFn;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -51,6 +67,13 @@ export type NavigationItem = {
|
||||
type: 'item';
|
||||
/** SVG icon content to display (optional). */
|
||||
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. */
|
||||
label: string;
|
||||
/** Primary navigation route (static or factory function). */
|
||||
|
||||
@@ -1,3 +1,46 @@
|
||||
:host {
|
||||
@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();
|
||||
<ui-carousel class="flex-1 min-w-0">
|
||||
@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
|
||||
uiTextButton
|
||||
[disabled]="_tabs.length === 0"
|
||||
color="strong"
|
||||
[size]="compact() ? 'small' : 'medium'"
|
||||
class="transition-all duration-200 self-center whitespace-nowrap"
|
||||
(click)="closeAll()"
|
||||
[class.h-6]="compact()"
|
||||
>
|
||||
Alle schließen
|
||||
</button>
|
||||
@if (!compact() && _tabs.length > 0) {
|
||||
<button
|
||||
animate.enter="fade-slide-in"
|
||||
animate.leave="fade-slide-out"
|
||||
uiTextButton
|
||||
[disabled]="_tabs.length === 0"
|
||||
color="strong"
|
||||
[size]="compact() ? 'small' : 'medium'"
|
||||
class="self-center whitespace-nowrap"
|
||||
(click)="closeAll()"
|
||||
[class.h-6]="compact()"
|
||||
>
|
||||
Alle schließen
|
||||
</button>
|
||||
}
|
||||
</ui-carousel>
|
||||
<ui-icon-button
|
||||
name="isaActionPlus"
|
||||
[size]="compact() ? 'small' : 'large'"
|
||||
class="shrink-0 self-center transition-all duration-200 -mt-2 z-sticky"
|
||||
[class.size-6]="compact()"
|
||||
(click)="addTab()"
|
||||
></ui-icon-button>
|
||||
@if (!compact()) {
|
||||
<ui-icon-button
|
||||
animate.enter="fade-slide-in"
|
||||
animate.leave="fade-slide-out"
|
||||
name="isaActionPlus"
|
||||
[size]="compact() ? 'small' : 'large'"
|
||||
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 { 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. */
|
||||
|
||||
Reference in New Issue
Block a user