feat(shell-tabs): improve collapse behavior with scroll and delay

- Add 1-second delay before collapsing when mouse moves away (desktop)
- Add scroll-to-top detection: expand when page is near top (<50px)
- Add scroll direction detection for tablet: expand on scroll up, collapse on scroll down
- Scroll direction requires 50px threshold before triggering
- Mouse proximity detection disabled on tablet devices
This commit is contained in:
Lorenz Hilpert
2025-12-11 15:04:41 +01:00
parent e9fc791dea
commit eca1e5b8b1
2 changed files with 184 additions and 21 deletions

View File

@@ -58,12 +58,21 @@ Individual tab item component.
## Compact Mode
The tabs bar uses mouse proximity detection to automatically switch display modes:
The tabs bar automatically switches between expanded and compact display modes based on user interaction.
- **Expanded mode** (mouse near): Full height tabs showing name and subtitle
- **Compact mode** (mouse away): Reduced height tabs with hidden subtitle
### Desktop Behavior
The proximity threshold is 50 pixels from the component edge.
- **Expanded** when mouse is within 50px of the tabs bar
- **Collapsed** with 1-second delay when mouse moves away
- **Expanded** when page is scrolled to top (< 50px from top)
### Tablet Behavior (≤ 1279px viewport)
- **Expanded** when page is scrolled to top (< 50px from top)
- **Expanded** when scrolling up (after 50px of upward scroll)
- **Collapsed** when scrolling down
Mouse proximity detection is disabled on tablet devices.
## Dependencies

View File

@@ -1,14 +1,25 @@
import { Component, inject, ElementRef } from '@angular/core';
import { DOCUMENT } from '@angular/common';
import {
Component,
computed,
effect,
ElementRef,
inject,
OnDestroy,
signal,
} from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { Router } from '@angular/router';
import { logger } from '@isa/core/logging';
import { ShellTabItemComponent } from './components/shell-tab-item.component';
import { TabService } from '@isa/core/tabs';
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 { TabsCollapsedService } from '@isa/shell/common';
import { IconButtonComponent, TextButtonComponent } from '@isa/ui/buttons';
import { CarouselComponent } from '@isa/ui/carousel';
import { Breakpoint, breakpoint } from '@isa/ui/layout';
import { provideIcons } from '@ng-icons/core';
import { fromEvent, map, startWith } from 'rxjs';
import { ShellTabItemComponent } from './components/shell-tab-item.component';
/**
* Distance in pixels from the component edge within which
@@ -16,6 +27,24 @@ import { Breakpoint, breakpoint } from '@isa/ui/layout';
*/
const PROXIMITY_THRESHOLD_PX = 50;
/**
* Scroll position threshold in pixels. Tabs expand when
* scroll position is below this value.
*/
const SCROLL_THRESHOLD_PX = 50;
/**
* Delay in milliseconds before collapsing tabs after
* the mouse moves away from the proximity zone.
*/
const COLLAPSE_DELAY_MS = 1000;
/**
* Minimum scroll distance in pixels required to detect
* a scroll direction change (tablet only).
*/
const SCROLL_DIRECTION_THRESHOLD_PX = 50;
/**
* Shell tabs bar component that displays all open tabs in a horizontal carousel.
*
@@ -49,8 +78,9 @@ const PROXIMITY_THRESHOLD_PX = 50;
'(document:mousemove)': 'onMouseMove($event)',
},
})
export class ShellTabsComponent {
export class ShellTabsComponent implements OnDestroy {
#logger = logger({ component: 'ShellTabsComponent' });
#document = inject(DOCUMENT);
#tablet = breakpoint(Breakpoint.Tablet);
#tabService = inject(TabService);
@@ -58,15 +88,83 @@ export class ShellTabsComponent {
#elementRef = inject(ElementRef);
#router = inject(Router);
/** Internal state: whether mouse is within proximity (desktop only). */
#mouseNear = signal(false);
/** Internal state: whether page is scrolled near top. */
#scrolledToTop = signal(true);
/** Internal state: whether user is scrolling up (tablet only). */
#scrollingUp = signal(false);
/** Last scroll position to detect direction. */
#lastScrollY = 0;
/** Anchor position when scroll direction changes (for threshold detection). */
#scrollAnchorY = 0;
/** Current scroll direction: 'up' | 'down' | null */
#currentDirection: 'up' | 'down' | null = null;
/** Timer ID for delayed collapse. */
#collapseTimer: number | undefined;
/** Tracks the current scroll position of the window. */
#scrollPosition = toSignal(
fromEvent(this.#document.defaultView!, 'scroll', { passive: true }).pipe(
startWith(null),
map(() => this.#document.defaultView?.scrollY ?? 0),
),
{ initialValue: 0 },
);
/**
* Computed collapsed state based on combined conditions.
* Desktop: Expanded when mouse is near OR scrolled to top.
* Tablet: Expanded when scrolled to top OR scrolling up.
*/
#shouldCollapse = computed(() => {
const isTablet = this.#tablet();
const mouseCondition = isTablet ? false : this.#mouseNear();
const scrollTopCondition = this.#scrolledToTop();
// Scroll direction only matters on tablet
const scrollUpCondition = isTablet ? this.#scrollingUp() : false;
return !(mouseCondition || scrollTopCondition || scrollUpCondition);
});
/** All tabs from the TabService. */
readonly tabs = this.#tabService.entities;
/** Whether tabs should display in compact mode (mouse is far from component). */
/** Whether tabs should display in compact mode. */
compact = this.#tabsCollapsedService.get;
constructor() {
// Sync scroll position to scrolledToTop and track direction
effect(() => {
const scrollY = this.#scrollPosition();
// Track scroll direction with threshold (for tablet)
this.#updateScrollDirection(scrollY);
// Track if at top
this.#scrolledToTop.set(scrollY < SCROLL_THRESHOLD_PX);
});
// Sync computed collapsed state to service
effect(() => {
this.#tabsCollapsedService.set(this.#shouldCollapse());
});
}
ngOnDestroy(): void {
this.#clearCollapseTimer();
}
/**
* Handles mouse movement to detect proximity to the tabs bar.
* Updates compact mode based on whether the mouse is near the component.
* Expands immediately when near, collapses with delay when far.
* Skipped on tablet devices (scroll-only behavior).
*/
onMouseMove(event: MouseEvent): void {
if (this.#tablet()) {
@@ -74,19 +172,75 @@ export class ShellTabsComponent {
}
const rect = this.#elementRef.nativeElement.getBoundingClientRect();
const mouseY = event.clientY;
const mouseX = event.clientX;
const isWithinX = mouseX >= rect.left && mouseX <= rect.right;
const isWithinX =
event.clientX >= rect.left && event.clientX <= rect.right;
const distanceY =
mouseY < rect.top
? rect.top - mouseY
: mouseY > rect.bottom
? mouseY - rect.bottom
event.clientY < rect.top
? rect.top - event.clientY
: event.clientY > rect.bottom
? event.clientY - rect.bottom
: 0;
const isNear = isWithinX && distanceY <= PROXIMITY_THRESHOLD_PX;
this.#tabsCollapsedService.set(!isNear);
if (isNear) {
this.#clearCollapseTimer();
this.#mouseNear.set(true);
} else if (this.#mouseNear()) {
this.#scheduleMouseLeave();
}
}
/** Schedules a delayed collapse when mouse leaves proximity zone. */
#scheduleMouseLeave(): void {
if (this.#collapseTimer !== undefined) {
return;
}
this.#collapseTimer = window.setTimeout(() => {
this.#mouseNear.set(false);
this.#collapseTimer = undefined;
}, COLLAPSE_DELAY_MS);
}
/** Cancels any pending collapse timer. */
#clearCollapseTimer(): void {
if (this.#collapseTimer !== undefined) {
clearTimeout(this.#collapseTimer);
this.#collapseTimer = undefined;
}
}
/**
* Updates scroll direction state with threshold detection.
* Only triggers scrollingUp after scrolling up at least SCROLL_DIRECTION_THRESHOLD_PX.
*/
#updateScrollDirection(scrollY: number): void {
// Detect direction based on movement from last position
const movingUp = scrollY < this.#lastScrollY;
const movingDown = scrollY > this.#lastScrollY;
this.#lastScrollY = scrollY;
if (movingUp) {
if (this.#currentDirection !== 'up') {
// Direction changed to up, set new anchor
this.#currentDirection = 'up';
this.#scrollAnchorY = scrollY;
}
// Check if scrolled up enough from anchor
const distanceFromAnchor = this.#scrollAnchorY - scrollY;
if (distanceFromAnchor >= SCROLL_DIRECTION_THRESHOLD_PX) {
this.#scrollingUp.set(true);
}
} else if (movingDown) {
if (this.#currentDirection !== 'down') {
// Direction changed to down, set new anchor
this.#currentDirection = 'down';
this.#scrollAnchorY = scrollY;
}
// Collapse immediately when scrolling down
this.#scrollingUp.set(false);
}
}
/** Closes all open tabs and navigates to the root route. */