integratedhub/islands/WelcomeBar/NavigationMenu.tsx
2026-02-14 02:20:19 +00:00

216 lines
6.0 KiB
TypeScript

import { useEffect, useRef, useState } from "preact/hooks";
import { createPortal } from "preact/compat";
import { NavigationItem, NavigationMenuProps } from "./welcome-types.ts";
import { getNavigationGroups } from "./navigationConfig.ts";
import { useFocusTrap } from "../../utils/useFocusTrap.ts";
/**
* NavigationMenu Component
* Dropdown menu with grouped navigation links
* Positioned absolutely relative to the hamburger button
* Closes when clicking outside
*
* @param showMenu - Whether menu is visible
* @param menuButtonRef - Reference to the hamburger button for positioning
* @param onClose - Handler to close the menu
* @param isLoggedIn - User authentication status
* @param currentRoute - Current route path
* @param navigationGroups - Optional custom navigation groups
*/
export default function NavigationMenu(
{
showMenu,
menuButtonRef,
onClose,
isLoggedIn,
currentRoute,
navigationGroups,
}: NavigationMenuProps,
) {
const menuRef = useRef<HTMLDivElement>(null);
// Enable focus trap when menu is open
useFocusTrap(showMenu, menuRef);
// Close menu when clicking outside - simplified to prevent issues
useEffect(() => {
if (!showMenu) return;
const handleClickOutside = (event: MouseEvent) => {
const target = event.target as HTMLElement;
if (!target) return;
// Simple check: if click is outside menu container and button
if (
!target.closest(".navigation-menu") &&
!target.closest(".menu-button")
) {
onClose();
}
};
// Handle escape key
const handleEscape = (event: KeyboardEvent) => {
if (event.key === "Escape") {
onClose();
}
};
// Add listeners immediately without delay
document.addEventListener("click", handleClickOutside, true);
document.addEventListener("keydown", handleEscape);
return () => {
document.removeEventListener("click", handleClickOutside, true);
document.removeEventListener("keydown", handleEscape);
};
}, [showMenu, onClose]);
/**
* Render a single navigation item
*/
function renderNavigationItem(item: NavigationItem) {
const baseClasses =
"block w-full text-left px-4 py-2 transition-all duration-200";
const stateClasses = (() => {
if (item.disabled) {
return "text-gray-500 cursor-not-allowed";
}
if (item.path === "/auth/logout") {
return "text-red-400 hover:bg-red-400/10 hover:text-red-300 hover:shadow-[0_0_15px_rgba(248,113,113,0.3)]";
}
if (item.path === "/auth/login") {
return "text-purple-400 hover:bg-purple-400/10 hover:text-purple-300 hover:shadow-[0_0_15px_rgba(192,132,252,0.3)]";
}
return "text-teal-400 hover:bg-teal-400/10 hover:text-teal-300 hover:shadow-[0_0_15px_rgba(20,184,166,0.3)]";
})();
// Skip current route items (except auth forms)
if (!item.isForm && item.path === currentRoute) {
return null;
}
// Handle form items (login/logout)
if (item.isForm && item.formAction) {
return (
<form method={item.formMethod || "post"} action={item.formAction}>
<button
type="submit"
onClick={item.onClick}
class={`${baseClasses} ${stateClasses} bg-transparent border-0 appearance-none`}
role="menuitem"
disabled={item.disabled}
>
{item.label}
</button>
</form>
);
}
// Handle regular links
return (
<a
href={item.path}
class={`${baseClasses} ${stateClasses}`}
onClick={(e) => {
if (item.disabled) {
e.preventDefault();
return;
}
onClose();
}}
role="menuitem"
aria-disabled={item.disabled}
>
{item.label}
</a>
);
}
/**
* Render a navigation group with separator
*/
function renderNavigationGroup(
group: { id: string; label: string; items: NavigationItem[] },
) {
if (group.items.length === 0) return null;
return (
<div key={group.id}>
{group.id !== "auth" && (
<div class="border-t border-teal-400/20 my-1"></div>
)}
{group.items.map(renderNavigationItem)}
</div>
);
}
// Track if we're in browser for portal rendering
const [isBrowser, setIsBrowser] = useState(false);
const [menuPosition, setMenuPosition] = useState({ top: 0, right: 0 });
useEffect(() => {
setIsBrowser(true);
}, []);
// Calculate menu position based on button location
useEffect(() => {
if (showMenu && menuButtonRef && isBrowser) {
const rect = menuButtonRef.getBoundingClientRect();
setMenuPosition({
top: rect.bottom + 8, // 8px below the button
right: globalThis.innerWidth - rect.right, // Align right edge with button
});
}
}, [showMenu, menuButtonRef, isBrowser]);
if (!showMenu || !menuButtonRef) {
return null;
}
// Position menu as fixed overlay using calculated coordinates
const menuStyle = {
"--menu-top": `${menuPosition.top}px`,
"--menu-right": `${menuPosition.right}px`,
} as React.CSSProperties;
// Backdrop element to be portaled to body
const backdrop = (
<div
class="navigation-backdrop"
onClick={onClose}
aria-hidden="true"
/>
);
// Menu dropdown element to be portaled to body
const menu = (
<div
ref={menuRef}
class="w-48 bg-black rounded-lg shadow-xl border border-teal-400 py-2 navigation-menu transform transition-all duration-150 ease-out origin-top-right"
style={menuStyle}
role="menu"
aria-label="Navigation menu"
onClick={(e) => e.stopPropagation()}
>
{getNavigationGroups(isLoggedIn, currentRoute, navigationGroups).map(
renderNavigationGroup,
)}
</div>
);
if (!isBrowser) {
return null;
}
return createPortal(
<>
{/* Overlay backdrop */}
{backdrop}
{/* Menu dropdown */}
{menu}
</>,
document.body,
);
}