Add flyout hover-to-expand for collapsed sidebar

When the sidebar is collapsed to a 56px icon rail, hovering expands it
as a translucent overlay (240px) on top of the page content for quick
sub-menu access. Left 56px stays solid, the overlapping portion uses
subtle backdrop-filter blur. Auto-closes on navigation or mouse leave.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
master
2026-03-07 16:53:27 +02:00
parent 01dae8d402
commit 407318d81e

View File

@@ -72,6 +72,9 @@ interface NavSectionGroup {
<aside
class="sidebar"
[class.sidebar--collapsed]="collapsed"
[class.sidebar--flyout]="flyoutOpen()"
(mouseenter)="onSidebarMouseEnter()"
(mouseleave)="onSidebarMouseLeave()"
role="navigation"
aria-label="Main navigation"
>
@@ -93,11 +96,11 @@ interface NavSectionGroup {
@for (group of displaySectionGroups(); track group.id) {
<div
class="sb-group"
[class.sb-group--collapsed]="!collapsed && sidebarPrefs.collapsedGroups().has(group.id)"
[class.sb-group--collapsed]="!effectiveCollapsed && sidebarPrefs.collapsedGroups().has(group.id)"
role="group"
[attr.aria-label]="group.label"
>
@if (!collapsed) {
@if (!effectiveCollapsed) {
<button
type="button"
class="sb-group__header"
@@ -115,10 +118,10 @@ interface NavSectionGroup {
<div class="sb-group__body" [id]="'nav-grp-' + group.id">
<div class="sb-group__body-inner">
@for (section of group.sections; track section.id; let sectionFirst = $first) {
@if (!collapsed && !sectionFirst) {
@if (!effectiveCollapsed && !sectionFirst) {
<div class="sb-divider"></div>
}
@if (!collapsed && section.displayChildren.length > 0) {
@if (!effectiveCollapsed && section.displayChildren.length > 0) {
<!-- Section with foldable children: link + chevron toggle -->
<div class="sb-section" [class.sb-section--folded]="sidebarPrefs.collapsedSections().has(section.id)">
<div class="sb-section__head">
@@ -127,7 +130,7 @@ interface NavSectionGroup {
[icon]="section.icon"
[route]="section.route"
[badge]="section.sectionBadge"
[collapsed]="collapsed"
[collapsed]="effectiveCollapsed"
></app-sidebar-nav-item>
<button
type="button"
@@ -151,7 +154,7 @@ interface NavSectionGroup {
[route]="child.route"
[badge]="child.badge ?? null"
[isChild]="true"
[collapsed]="collapsed"
[collapsed]="effectiveCollapsed"
></app-sidebar-nav-item>
}
</div>
@@ -164,7 +167,7 @@ interface NavSectionGroup {
[icon]="section.icon"
[route]="section.route"
[badge]="section.sectionBadge"
[collapsed]="collapsed"
[collapsed]="effectiveCollapsed"
></app-sidebar-nav-item>
}
}
@@ -215,13 +218,45 @@ interface NavSectionGroup {
color: var(--color-sidebar-text);
overflow: hidden;
position: relative;
transition: width 0.3s cubic-bezier(0.22, 1, 0.36, 1);
transition: width 0.3s cubic-bezier(0.22, 1, 0.36, 1),
box-shadow 0.3s ease;
}
.sidebar--collapsed {
width: 56px;
}
/* ---- Flyout overlay (collapsed + mouse hover) ---- */
.sidebar--collapsed.sidebar--flyout {
width: 240px;
/* Left 56px stays solid (the existing rail), right portion is subtly translucent */
background:
linear-gradient(
90deg,
var(--color-sidebar-bg) 0px,
var(--color-sidebar-bg) 56px,
color-mix(in srgb, var(--color-sidebar-bg) 96%, transparent) 57px,
color-mix(in srgb, var(--color-sidebar-bg) 94%, transparent) 100%
);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
box-shadow:
4px 0 24px rgba(0, 0, 0, 0.3),
1px 0 0 rgba(245, 166, 35, 0.08);
}
.sidebar--collapsed.sidebar--flyout .sb-group__header,
.sidebar--collapsed.sidebar--flyout .sb-divider,
.sidebar--collapsed.sidebar--flyout .sb-section__head,
.sidebar--collapsed.sidebar--flyout .sb-section__body {
animation: flyoutFadeIn 0.2s ease-out both;
}
@keyframes flyoutFadeIn {
from { opacity: 0; transform: translateX(-6px); }
to { opacity: 1; transform: translateX(0); }
}
.sidebar::after {
content: '';
position: absolute;
@@ -307,6 +342,10 @@ interface NavSectionGroup {
padding: 0.5rem 0.25rem;
}
.sidebar--collapsed.sidebar--flyout .sidebar__nav {
padding: 0.5rem 0.5rem;
}
/* ================================================================
Nav group (collapsible section cluster)
================================================================ */
@@ -604,11 +643,31 @@ interface NavSectionGroup {
padding: 0.375rem 0.25rem 0.5rem;
}
.sidebar--collapsed.sidebar--flyout .sidebar__footer {
padding: 0.5rem 0.625rem 0.625rem;
}
.sidebar--collapsed .sidebar__version {
font-size: 0;
opacity: 0;
overflow: hidden;
}
.sidebar--collapsed.sidebar--flyout .sidebar__version {
font-size: 0.5625rem;
opacity: 1;
overflow: visible;
}
/* ---- Flyout: disable on mobile ---- */
@media (max-width: 991px) {
.sidebar.sidebar--collapsed.sidebar--flyout {
width: 280px;
backdrop-filter: none;
-webkit-backdrop-filter: none;
box-shadow: none;
}
}
`],
changeDetection: ChangeDetectionStrategy.OnPush,
})
@@ -622,11 +681,29 @@ export class AppSidebarComponent implements AfterViewInit {
readonly sidebarPrefs = inject(SidebarPreferenceService);
@Input() collapsed = false;
private _collapsed = false;
@Input()
get collapsed(): boolean { return this._collapsed; }
set collapsed(value: boolean) {
this._collapsed = value;
if (!value) this.closeFlyout();
}
/** Flyout open state (collapsed rail + mouse hovering) */
readonly flyoutOpen = signal(false);
/** Effective collapsed state: collapsed AND not in flyout mode */
get effectiveCollapsed(): boolean {
return this._collapsed && !this.flyoutOpen();
}
@Output() mobileClose = new EventEmitter<void>();
@Output() collapseToggle = new EventEmitter<void>();
@ViewChild('sidebarNav', { static: false }) sidebarNavRef!: ElementRef<HTMLElement>;
private flyoutEnterTimer: ReturnType<typeof setTimeout> | null = null;
private flyoutLeaveTimer: ReturnType<typeof setTimeout> | null = null;
private readonly pendingApprovalsCount = signal(0);
/**
@@ -834,12 +911,14 @@ export class AppSidebarComponent implements AfterViewInit {
constructor() {
this.loadPendingApprovalsBadge();
this.destroyRef.onDestroy(() => this.clearFlyoutTimers());
this.router.events
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((event) => {
if (event instanceof NavigationEnd) {
this.loadPendingApprovalsBadge();
this.doctorTrendService.refresh();
this.closeFlyout();
}
});
}
@@ -921,6 +1000,34 @@ export class AppSidebarComponent implements AfterViewInit {
return this.authService.hasAnyScope(scopes);
}
/** Flyout: expand sidebar as translucent overlay when hovering the collapsed rail */
onSidebarMouseEnter(): void {
if (!this._collapsed || window.innerWidth <= 991) return;
this.clearFlyoutTimers();
this.flyoutEnterTimer = setTimeout(() => this.flyoutOpen.set(true), 150);
}
onSidebarMouseLeave(): void {
this.clearFlyoutTimers();
this.flyoutLeaveTimer = setTimeout(() => this.flyoutOpen.set(false), 300);
}
private closeFlyout(): void {
this.clearFlyoutTimers();
this.flyoutOpen.set(false);
}
private clearFlyoutTimers(): void {
if (this.flyoutEnterTimer) {
clearTimeout(this.flyoutEnterTimer);
this.flyoutEnterTimer = null;
}
if (this.flyoutLeaveTimer) {
clearTimeout(this.flyoutLeaveTimer);
this.flyoutLeaveTimer = null;
}
}
private loadPendingApprovalsBadge(): void {
if (!this.approvalApi) {
return;