Files
redux-scraper/app/javascript/bundles/UI/userMenu.ts
2025-07-30 06:59:34 +00:00

166 lines
4.4 KiB
TypeScript

/**
* User Menu Handler
* Manages the responsive user menu that works for both desktop and mobile layouts
*/
interface UserMenuElements {
button: HTMLButtonElement;
menu: HTMLDivElement;
menuIcon: SVGElement | null;
closeIcon: SVGElement | null;
chevronIcon: HTMLElement | null;
}
class UserMenu {
private elements: UserMenuElements | null = null;
constructor() {
this.initialize();
}
private initialize(): void {
const userMenuButton = document.querySelector(
'#user-menu-button',
) as HTMLButtonElement;
const userMenu = document.querySelector('#user-menu') as HTMLDivElement;
const menuIcon = document.querySelector('.menu-icon') as SVGElement | null;
const closeIcon = document.querySelector(
'.close-icon',
) as SVGElement | null;
const chevronIcon = document.querySelector(
'.chevron-icon',
) as HTMLElement | null;
if (!userMenuButton || !userMenu) {
return; // User menu not present (e.g., guest user)
}
this.elements = {
button: userMenuButton,
menu: userMenu,
menuIcon,
closeIcon,
chevronIcon,
};
this.attachEventListeners();
}
private attachEventListeners(): void {
if (!this.elements) return;
// Toggle menu on button click
this.elements.button.addEventListener(
'click',
this.handleMenuToggle.bind(this),
);
// Close menu when clicking outside
document.addEventListener('click', this.handleClickOutside.bind(this));
// Close menu on escape key
document.addEventListener('keydown', this.handleEscapeKey.bind(this));
}
private handleMenuToggle(): void {
if (!this.elements) return;
const isOpen = !this.elements.menu.classList.contains('hidden');
if (isOpen) {
this.closeMenu();
} else {
this.openMenu();
}
}
private handleClickOutside(event: MouseEvent): void {
if (!this.elements) return;
const target = event.target as Node;
if (
!this.elements.button.contains(target) &&
!this.elements.menu.contains(target)
) {
this.closeMenu();
}
}
private handleEscapeKey(event: KeyboardEvent): void {
if (!this.elements) return;
if (
event.key === 'Escape' &&
!this.elements.menu.classList.contains('hidden')
) {
this.closeMenu();
}
}
private openMenu(): void {
if (!this.elements) return;
// Show menu but keep it in initial animation state
this.elements.menu.classList.remove('hidden');
this.elements.button.setAttribute('aria-expanded', 'true');
// Ensure initial state is set
this.elements.menu.classList.add('opacity-0', '-translate-y-2');
this.elements.menu.classList.remove('opacity-100', 'translate-y-0');
// Force a reflow to ensure the initial state is applied
this.elements.menu.offsetHeight;
// Trigger animation by transitioning to visible state
requestAnimationFrame(() => {
this.elements!.menu.classList.remove('opacity-0', '-translate-y-2');
this.elements!.menu.classList.add('opacity-100', 'translate-y-0');
});
// Update mobile icons (hamburger -> close)
if (this.elements.menuIcon && this.elements.closeIcon) {
this.elements.menuIcon.classList.add('hidden');
this.elements.closeIcon.classList.remove('hidden');
}
// Update desktop chevron (rotate down)
if (this.elements.chevronIcon) {
this.elements.chevronIcon.classList.add('rotate-180');
}
}
private closeMenu(): void {
if (!this.elements) return;
// Animate out: fade and slide up
this.elements.menu.classList.remove('opacity-100', 'translate-y-0');
this.elements.menu.classList.add('opacity-0', '-translate-y-2');
// Hide menu after CSS animation completes
setTimeout(() => {
if (this.elements) {
this.elements.menu.classList.add('hidden');
}
}, 150); // Match the CSS transition duration
this.elements.button.setAttribute('aria-expanded', 'false');
// Update mobile icons (close -> hamburger)
if (this.elements.menuIcon && this.elements.closeIcon) {
this.elements.menuIcon.classList.remove('hidden');
this.elements.closeIcon.classList.add('hidden');
}
// Update desktop chevron (rotate back up)
if (this.elements.chevronIcon) {
this.elements.chevronIcon.classList.remove('rotate-180');
}
}
}
// Initialize user menu when DOM is loaded
export function initUserMenu(): void {
new UserMenu();
}