Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,11 @@
<button
mat-button
#menuTrigger="matMenuTrigger"
#menuTriggerButton
[ngClass]="cls"
[matMenuTriggerFor]="actionMenu"
(click)="navigateToFirst($event)"
(mouseenter)="onMouseEnter()"
(mouseleave)="onMouseLeave($event)"
(mouseleave)="onMouseLeave()"
(focus)="preventFocus($event)"
[disabled]="disabled"
[class.active]="isActive"
Expand All @@ -21,13 +20,7 @@
}
</button>
</span>
<mat-menu
#actionMenu="matMenu"
[hasBackdrop]="false"
[class]="panelClass"
(mouseenter)="onMenuMouseEnter()"
(mouseleave)="onMenuMouseLeave($event)"
>
<mat-menu #actionMenu="matMenu" [hasBackdrop]="false" [class]="panelClass">
@for (section of actionMenuItems; track $index; let last = $last) {
@for (item of section; track item.label) {
<button mat-menu-item (click)="onMenuItemClick(item)" [class.text-critical]="item.red">
Expand Down
151 changes: 41 additions & 110 deletions src/app/core/_components/menus/action-menu/action-menu.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ import { LocalStorageService } from 'src/app/core/_services/storage/local-storag
import {
AfterViewInit,
Component,
ElementRef,
EventEmitter,
Input,
OnDestroy,
Expand Down Expand Up @@ -41,30 +40,21 @@ import { UISettingsUtilityClass } from '@src/app/shared/utils/config';
* - opening on hover or click (`openOnMouseEnter`)
* - optional hover-to-close behavior with a short grace period (`enableHoverClose`)
* - routing, external links, and custom menu item click events
*
* The component uses a global `mousemove` listener while the menu is open to detect
* pointer exits and applies a short deferred close to avoid flicker when moving
* between trigger and menu.
*/
export class ActionMenuComponent implements OnInit, AfterViewInit, OnDestroy {
/** Delay in milliseconds to prevent hover flickering */
static readonly hoverDelayMs = 30;
/** Grace period (ms) before a hover-close fires, long enough to cross the gap
* between trigger and panel. */
static readonly hoverCloseDelayMs = 150;

private subscriptions: Subscription[] = [];

/** Handle to remove the global mousemove listener */
private globalMouseMoveListener: (() => void) | null = null;

/** Handle for the deferred hover-close timeout */
hoverTimeout: ReturnType<typeof setTimeout> | null = null;
menuPanelId: string | undefined;

// track last known mouse coords for deferred checks
private lastMouseX = 0;
private lastMouseY = 0;
/** Cleanup handles for the overlay-panel hover listeners. */
private panelListeners: (() => void)[] = [];

@ViewChild(MatMenuTrigger) trigger!: MatMenuTrigger;
@ViewChild('menuTriggerButton', { read: ElementRef }) triggerButton!: ElementRef<HTMLButtonElement>;

currentUrl: string[] = [];
isActive = false;
Expand Down Expand Up @@ -169,66 +159,58 @@ export class ActionMenuComponent implements OnInit, AfterViewInit, OnDestroy {
);
}

/**
* Angular lifecycle hook: called after view init. Captures the menu panel id
* and subscribes to menu open/close events to manage the global mouse listener.
*/
ngAfterViewInit(): void {
this.menuPanelId = this.trigger.menu?.panelId;
this.subscriptions.push(
this.trigger.menuOpened.subscribe(() => {
this.attachPanelHoverListeners();
})
);

this.subscriptions.push(
this.trigger.menuClosed.subscribe(() => {
if (ActionMenuComponent.openMenuTrigger === this.trigger) {
ActionMenuComponent.openMenuTrigger = null;
}
this.removeGlobalMouseListener();
})
);

this.subscriptions.push(
this.trigger.menuOpened.subscribe(() => {
// Slight delay to ensure the menu DOM is initialized before checking pointer
setTimeout(() => {
this.addGlobalMouseListener();
}, ActionMenuComponent.hoverDelayMs);
this.detachPanelHoverListeners();
this.hoveringMenu = false;
})
);
}

/**
* Add a single global mousemove listener to track pointer position while menu is open.
* The listener updates last known coordinates and delegates to `checkPointerOutside`.
* Bind mouseenter/mouseleave to the rendered overlay. Targets the
* `.cdk-overlay-pane` wrapper, not the panel itself — Material sets
* `pointer-events: none` on the panel during its enter animation, so attaching
* to the pane (which stays interactive) detects hover even mid-animation.
*/
addGlobalMouseListener() {
private attachPanelHoverListeners(): void {
if (!this.supportsHover) return;
if (this.globalMouseMoveListener) return;

this.globalMouseMoveListener = this.renderer.listen('document', 'mousemove', (event: MouseEvent) => {
// always keep last coords up to date
this.lastMouseX = event.clientX;
this.lastMouseY = event.clientY;
this.checkPointerOutside(event);
});
this.detachPanelHoverListeners();

const panelId = this.trigger.menu?.panelId;
const panel = panelId ? document.getElementById(panelId) : null;
const target = panel?.closest('.cdk-overlay-pane') ?? panel;
if (!target) return;

this.panelListeners.push(
this.renderer.listen(target, 'mouseenter', () => this.onMenuMouseEnter()),
this.renderer.listen(target, 'mouseleave', () => this.onMenuMouseLeave())
);
}

/**
* Remove the global mousemove listener if present.
*/
removeGlobalMouseListener() {
if (this.globalMouseMoveListener) {
this.globalMouseMoveListener();
this.globalMouseMoveListener = null;
private detachPanelHoverListeners(): void {
for (const unlisten of this.panelListeners) {
unlisten();
}
this.panelListeners = [];
}

/**
* Angular lifecycle hook: unsubscribe and clean up global listener.
*/
ngOnDestroy(): void {
for (const sub of this.subscriptions) {
sub.unsubscribe();
}
this.removeGlobalMouseListener();
this.detachPanelHoverListeners();
this.clearHoverTimeout();
}

/**
Expand Down Expand Up @@ -316,15 +298,9 @@ export class ActionMenuComponent implements OnInit, AfterViewInit, OnDestroy {
}
}

/**
* Mouse left the trigger button: record coordinates and start deferred close.
*/
onMouseLeave(event: MouseEvent): void {
onMouseLeave(): void {
if (!this.supportsHover) return;
this.hoveringButton = false;
// capture last coords immediately so timeout checks current pointer
this.lastMouseX = event.clientX;
this.lastMouseY = event.clientY;
this.startHoverTimeout();
}

Expand All @@ -337,37 +313,25 @@ export class ActionMenuComponent implements OnInit, AfterViewInit, OnDestroy {
this.clearHoverTimeout();
}

/**
* Mouse left the menu panel: record coordinates and start deferred close.
*/
onMenuMouseLeave(event: MouseEvent): void {
onMenuMouseLeave(): void {
if (!this.supportsHover) return;
this.hoveringMenu = false;
this.lastMouseX = event.clientX;
this.lastMouseY = event.clientY;
this.startHoverTimeout();
}

/**
* Start a single deferred timeout to check pointer position after a short grace period.
* If the pointer is still outside the trigger and menu, close the menu.
*/
/** Close after a grace period, but only if the pointer left both the trigger and the panel. */
startHoverTimeout() {
if (!this.openOnMouseEnter || !this.enableHoverClose) return;

// prevent multiple timers
this.clearHoverTimeout();

this.hoverTimeout = setTimeout(() => {
try {
if (this.isPointerOutsideAt(this.lastMouseX, this.lastMouseY)) {
this.closeMenuIfOpen();
}
} finally {
// ensure we clear the stored timeout handle
this.hoverTimeout = null;
this.hoverTimeout = null;
if (!this.hoveringButton && !this.hoveringMenu) {
this.closeMenuIfOpen();
}
}, ActionMenuComponent.hoverDelayMs);
}, ActionMenuComponent.hoverCloseDelayMs);
}

/**
Expand Down Expand Up @@ -413,39 +377,6 @@ export class ActionMenuComponent implements OnInit, AfterViewInit, OnDestroy {
}
}

/**
* Determine whether a given screen coordinate is outside the trigger button and menu panel.
* Returns `true` when pointer is outside both.
*/
isPointerOutsideAt(x: number, y: number): boolean {
const hoveredElement = document.elementFromPoint(x, y);

const insideTrigger = this.triggerButton?.nativeElement.contains(hoveredElement);
const insideMenu = hoveredElement?.closest(`#${this.menuPanelId ?? ''}`);

return !(insideTrigger || insideMenu);
}

/**
* Called by the global mousemove handler to decide whether to schedule a deferred close
* or cancel one when the pointer returns inside.
*/
checkPointerOutside(event: MouseEvent) {
if (!this.openOnMouseEnter || !this.enableHoverClose) return;

// update last coords (already done in global listener, but keep safe)
this.lastMouseX = event.clientX;
this.lastMouseY = event.clientY;

if (this.isPointerOutsideAt(this.lastMouseX, this.lastMouseY)) {
// schedule closure after grace period
this.startHoverTimeout();
} else {
// pointer is inside — cancel any pending close
this.clearHoverTimeout();
}
}

/**
* Icon helper checks: return true when the provided name matches a specific icon key.
* These helpers are intentionally defensive (handle undefined).
Expand Down
11 changes: 11 additions & 0 deletions src/styles/components/_material.scss
Original file line number Diff line number Diff line change
Expand Up @@ -433,6 +433,17 @@ textarea.mat-mdc-input-element.custom-textarea {
.mat-mdc-menu-item {
font-size: var(--text-md);
}

.mat-mdc-menu-item {
transition: background-color var(--duration-fast) var(--easing-standard);

&:hover:not([disabled]),
&.cdk-keyboard-focused:not([disabled]) {
background: var(--surface-soft-hover);
--mat-menu-item-label-text-color: var(--heading-foreground);
--mat-menu-item-icon-color: var(--heading-foreground);
}
}
}

.mat-mdc-dialog-container {
Expand Down
Loading