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
|
<aside
|
||||||
class="sidebar"
|
class="sidebar"
|
||||||
[class.sidebar--collapsed]="collapsed"
|
[class.sidebar--collapsed]="collapsed"
|
||||||
|
[class.sidebar--flyout]="flyoutOpen()"
|
||||||
|
(mouseenter)="onSidebarMouseEnter()"
|
||||||
|
(mouseleave)="onSidebarMouseLeave()"
|
||||||
role="navigation"
|
role="navigation"
|
||||||
aria-label="Main navigation"
|
aria-label="Main navigation"
|
||||||
>
|
>
|
||||||
@@ -93,11 +96,11 @@ interface NavSectionGroup {
|
|||||||
@for (group of displaySectionGroups(); track group.id) {
|
@for (group of displaySectionGroups(); track group.id) {
|
||||||
<div
|
<div
|
||||||
class="sb-group"
|
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"
|
role="group"
|
||||||
[attr.aria-label]="group.label"
|
[attr.aria-label]="group.label"
|
||||||
>
|
>
|
||||||
@if (!collapsed) {
|
@if (!effectiveCollapsed) {
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="sb-group__header"
|
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" [id]="'nav-grp-' + group.id">
|
||||||
<div class="sb-group__body-inner">
|
<div class="sb-group__body-inner">
|
||||||
@for (section of group.sections; track section.id; let sectionFirst = $first) {
|
@for (section of group.sections; track section.id; let sectionFirst = $first) {
|
||||||
@if (!collapsed && !sectionFirst) {
|
@if (!effectiveCollapsed && !sectionFirst) {
|
||||||
<div class="sb-divider"></div>
|
<div class="sb-divider"></div>
|
||||||
}
|
}
|
||||||
@if (!collapsed && section.displayChildren.length > 0) {
|
@if (!effectiveCollapsed && section.displayChildren.length > 0) {
|
||||||
<!-- Section with foldable children: link + chevron toggle -->
|
<!-- Section with foldable children: link + chevron toggle -->
|
||||||
<div class="sb-section" [class.sb-section--folded]="sidebarPrefs.collapsedSections().has(section.id)">
|
<div class="sb-section" [class.sb-section--folded]="sidebarPrefs.collapsedSections().has(section.id)">
|
||||||
<div class="sb-section__head">
|
<div class="sb-section__head">
|
||||||
@@ -127,7 +130,7 @@ interface NavSectionGroup {
|
|||||||
[icon]="section.icon"
|
[icon]="section.icon"
|
||||||
[route]="section.route"
|
[route]="section.route"
|
||||||
[badge]="section.sectionBadge"
|
[badge]="section.sectionBadge"
|
||||||
[collapsed]="collapsed"
|
[collapsed]="effectiveCollapsed"
|
||||||
></app-sidebar-nav-item>
|
></app-sidebar-nav-item>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -151,7 +154,7 @@ interface NavSectionGroup {
|
|||||||
[route]="child.route"
|
[route]="child.route"
|
||||||
[badge]="child.badge ?? null"
|
[badge]="child.badge ?? null"
|
||||||
[isChild]="true"
|
[isChild]="true"
|
||||||
[collapsed]="collapsed"
|
[collapsed]="effectiveCollapsed"
|
||||||
></app-sidebar-nav-item>
|
></app-sidebar-nav-item>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
@@ -164,7 +167,7 @@ interface NavSectionGroup {
|
|||||||
[icon]="section.icon"
|
[icon]="section.icon"
|
||||||
[route]="section.route"
|
[route]="section.route"
|
||||||
[badge]="section.sectionBadge"
|
[badge]="section.sectionBadge"
|
||||||
[collapsed]="collapsed"
|
[collapsed]="effectiveCollapsed"
|
||||||
></app-sidebar-nav-item>
|
></app-sidebar-nav-item>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -215,13 +218,45 @@ interface NavSectionGroup {
|
|||||||
color: var(--color-sidebar-text);
|
color: var(--color-sidebar-text);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
position: relative;
|
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 {
|
.sidebar--collapsed {
|
||||||
width: 56px;
|
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 {
|
.sidebar::after {
|
||||||
content: '';
|
content: '';
|
||||||
position: absolute;
|
position: absolute;
|
||||||
@@ -307,6 +342,10 @@ interface NavSectionGroup {
|
|||||||
padding: 0.5rem 0.25rem;
|
padding: 0.5rem 0.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sidebar--collapsed.sidebar--flyout .sidebar__nav {
|
||||||
|
padding: 0.5rem 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
/* ================================================================
|
/* ================================================================
|
||||||
Nav group (collapsible section cluster)
|
Nav group (collapsible section cluster)
|
||||||
================================================================ */
|
================================================================ */
|
||||||
@@ -604,11 +643,31 @@ interface NavSectionGroup {
|
|||||||
padding: 0.375rem 0.25rem 0.5rem;
|
padding: 0.375rem 0.25rem 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sidebar--collapsed.sidebar--flyout .sidebar__footer {
|
||||||
|
padding: 0.5rem 0.625rem 0.625rem;
|
||||||
|
}
|
||||||
|
|
||||||
.sidebar--collapsed .sidebar__version {
|
.sidebar--collapsed .sidebar__version {
|
||||||
font-size: 0;
|
font-size: 0;
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
overflow: hidden;
|
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,
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
})
|
})
|
||||||
@@ -622,11 +681,29 @@ export class AppSidebarComponent implements AfterViewInit {
|
|||||||
|
|
||||||
readonly sidebarPrefs = inject(SidebarPreferenceService);
|
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() mobileClose = new EventEmitter<void>();
|
||||||
@Output() collapseToggle = new EventEmitter<void>();
|
@Output() collapseToggle = new EventEmitter<void>();
|
||||||
@ViewChild('sidebarNav', { static: false }) sidebarNavRef!: ElementRef<HTMLElement>;
|
@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);
|
private readonly pendingApprovalsCount = signal(0);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -834,12 +911,14 @@ export class AppSidebarComponent implements AfterViewInit {
|
|||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.loadPendingApprovalsBadge();
|
this.loadPendingApprovalsBadge();
|
||||||
|
this.destroyRef.onDestroy(() => this.clearFlyoutTimers());
|
||||||
this.router.events
|
this.router.events
|
||||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||||
.subscribe((event) => {
|
.subscribe((event) => {
|
||||||
if (event instanceof NavigationEnd) {
|
if (event instanceof NavigationEnd) {
|
||||||
this.loadPendingApprovalsBadge();
|
this.loadPendingApprovalsBadge();
|
||||||
this.doctorTrendService.refresh();
|
this.doctorTrendService.refresh();
|
||||||
|
this.closeFlyout();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -921,6 +1000,34 @@ export class AppSidebarComponent implements AfterViewInit {
|
|||||||
return this.authService.hasAnyScope(scopes);
|
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 {
|
private loadPendingApprovalsBadge(): void {
|
||||||
if (!this.approvalApi) {
|
if (!this.approvalApi) {
|
||||||
return;
|
return;
|
||||||
|
|||||||
Reference in New Issue
Block a user