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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user