227 lines
6.2 KiB
TypeScript
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,
|
|
);
|
|
}
|