integratedhub/islands/WelcomeBar/NavigationMenu.tsx
2026-01-20 09:02:26 -06:00

227 lines
6.2 KiB
TypeScript

import { useEffect, useState } from "preact/hooks";
import { createPortal } from "preact/compat";
import { NavigationItem, NavigationMenuProps } from "./welcome-types.ts";
import { getNavigationGroups } from "./navigationConfig.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,
) {
// 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}`}
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: window.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 = {
position: "fixed" as const,
top: `${menuPosition.top}px`,
right: `${menuPosition.right}px`,
zIndex: 9999,
minWidth: "192px",
boxShadow:
"0 10px 15px -3px rgba(20, 184, 166, 0.3), 0 4px 6px -2px rgba(20, 184, 166, 0.2)",
borderRadius: "0.5rem",
border: "1px solid rgb(20, 184, 166)",
backgroundColor: "black",
};
// Backdrop element to be portaled to body
const backdrop = (
<div
style={{
position: "fixed",
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: "rgba(0, 0, 0, 0.5)",
backdropFilter: "blur(4px)",
WebkitBackdropFilter: "blur(4px)",
zIndex: 9998,
}}
onClick={onClose}
aria-hidden="true"
/>
);
// Menu dropdown element to be portaled to body
const menu = (
<div
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,
);
}