Services horizontal mega-menu

Task #11: Rework the EHSAN header "خدماتنا" (Services) menu from a narrow
vertical radix dropdown into a full-width horizontal mega-menu, matching the
real ehsan.sa layout where services span the width as icon-above-label columns.

Changes (artifacts/ehsan-poc/src/components/layout/Header.tsx):
- Removed the radix DropdownMenu for services; replaced with a custom
  state-controlled panel (servicesOpen) absolutely positioned full-width
  below the sticky header (header set to relative; panel inset-x-0 top-full).
- Eight services render in a single horizontal flex row, each as an icon
  stacked above its label, with direction-aware vertical dividers (border-s
  on non-first items) so RTL/LTR both read correctly.
- "طلب دعم" kept as a distinct trailing column (subtle bg) routing to /request.
- Dismissal: outside-click (document mousedown + ref containment), Escape key,
  selecting an item, and mouse-leaving the panel. Trigger is click-to-toggle.
- Accessibility: trigger has aria-haspopup/aria-controls/aria-expanded; panel
  has id="services-megamenu" and role="menu".

Deviation: dropped trigger hover-to-open (kept click-toggle) because a
mouseenter+click race closed the panel immediately under Playwright; click
toggle is predictable. Mobile services list (vertical) intentionally unchanged.

Verified: tsc --noEmit clean; e2e UI test passed (open, horizontal layout with
all 8 services + request, Escape closes, click navigates to /opportunities and
closes); console clean; architect review Pass. No emojis.
This commit is contained in:
Replit Agent
2026-06-05 18:45:04 +00:00
parent 0e7b85d88e
commit 0884652f97
2 changed files with 90 additions and 42 deletions
Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

After

Width:  |  Height:  |  Size: 64 KiB

@@ -1,15 +1,8 @@
import { useState } from "react";
import { useEffect, useRef, useState } from "react";
import { Link, useLocation } from "wouter";
import { useLanguage } from "../../contexts/LanguageContext";
import { useAuth } from "../../contexts/AuthContext";
import { Button } from "../ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "../ui/dropdown-menu";
import {
Search,
ShoppingCart,
@@ -36,11 +29,42 @@ export function Header() {
const { isAuthenticated, logout } = useAuth();
const [location, setLocation] = useLocation();
const [mobileOpen, setMobileOpen] = useState(false);
const [servicesOpen, setServicesOpen] = useState(false);
const servicesTriggerRef = useRef<HTMLButtonElement>(null);
const servicesPanelRef = useRef<HTMLDivElement>(null);
const toggleLanguage = () => setLanguage(language === "ar" ? "en" : "ar");
const isActive = (path: string) => location === path;
const handleServiceClick = (path: string) => {
setServicesOpen(false);
setLocation(path);
};
useEffect(() => {
if (!servicesOpen) return;
const onPointerDown = (e: MouseEvent) => {
const target = e.target as Node;
if (
servicesPanelRef.current?.contains(target) ||
servicesTriggerRef.current?.contains(target)
) {
return;
}
setServicesOpen(false);
};
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") setServicesOpen(false);
};
document.addEventListener("mousedown", onPointerDown);
document.addEventListener("keydown", onKeyDown);
return () => {
document.removeEventListener("mousedown", onPointerDown);
document.removeEventListener("keydown", onKeyDown);
};
}, [servicesOpen]);
const services = [
{ key: "ghiras", label: t.serviceItems.ghiras, icon: Sprout },
{ key: "zakat", label: t.serviceItems.zakat, icon: HandCoins },
@@ -53,7 +77,7 @@ export function Header() {
];
return (
<header className="bg-white sticky top-0 z-50 shadow-sm">
<header className="bg-white sticky top-0 z-50 shadow-sm relative">
<div className="container mx-auto px-4 h-16 flex items-center justify-between gap-4">
{/* Logo */}
<Link href="/" className="flex items-center shrink-0">
@@ -89,39 +113,23 @@ export function Header() {
<ChevronDown className="w-3.5 h-3.5" />
</Link>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
className="px-3 py-2 text-sm font-medium text-foreground hover:text-primary inline-flex items-center gap-1 transition-colors"
data-testid="nav-services"
>
{t.nav.services}
<ChevronDown className="w-3.5 h-3.5" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="center" className="w-56">
{services.map((s) => (
<DropdownMenuItem
key={s.key}
onClick={() => setLocation("/opportunities")}
data-testid={`nav-service-${s.key}`}
className="gap-2 cursor-pointer"
>
<s.icon className="w-4 h-4 text-primary" />
<span>{s.label}</span>
</DropdownMenuItem>
))}
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => setLocation("/request")}
data-testid="nav-requestSupport"
className="gap-2 cursor-pointer"
>
<HeartHandshake className="w-4 h-4 text-primary" />
<span>{t.nav.requestSupport}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<button
ref={servicesTriggerRef}
type="button"
onClick={() => setServicesOpen((v) => !v)}
className={`px-3 py-2 text-sm font-medium inline-flex items-center gap-1 transition-colors ${
servicesOpen ? "text-primary" : "text-foreground hover:text-primary"
}`}
data-testid="nav-services"
aria-haspopup="menu"
aria-controls="services-megamenu"
aria-expanded={servicesOpen}
>
{t.nav.services}
<ChevronDown
className={`w-3.5 h-3.5 transition-transform ${servicesOpen ? "rotate-180" : ""}`}
/>
</button>
<span className="px-3 py-2 text-sm font-medium text-foreground/70 cursor-default select-none">
{t.nav.about}
@@ -200,6 +208,46 @@ export function Header() {
</div>
</div>
{/* Services mega-menu (desktop) */}
{servicesOpen && (
<div
ref={servicesPanelRef}
id="services-megamenu"
role="menu"
className="hidden lg:block absolute inset-x-0 top-full border-t bg-white shadow-lg"
data-testid="services-megamenu"
onMouseLeave={() => setServicesOpen(false)}
>
<div className="container mx-auto px-4 py-4">
<div className="flex items-stretch">
{services.map((s, i) => (
<button
key={s.key}
type="button"
onClick={() => handleServiceClick("/opportunities")}
data-testid={`nav-service-${s.key}`}
className={`flex-1 flex flex-col items-center justify-center gap-2 px-2 py-3 rounded-md text-center hover:bg-muted transition-colors ${
i > 0 ? "border-s border-border" : ""
}`}
>
<s.icon className="w-7 h-7 text-primary" />
<span className="text-sm font-medium text-foreground">{s.label}</span>
</button>
))}
<button
type="button"
onClick={() => handleServiceClick("/request")}
data-testid="nav-requestSupport"
className="flex-1 flex flex-col items-center justify-center gap-2 px-2 py-3 rounded-md text-center bg-muted/40 hover:bg-muted transition-colors border-s border-border"
>
<HeartHandshake className="w-7 h-7 text-primary" />
<span className="text-sm font-semibold text-primary">{t.nav.requestSupport}</span>
</button>
</div>
</div>
</div>
)}
{/* Mobile nav panel */}
{mobileOpen && (
<div className="lg:hidden border-t bg-white px-4 py-3 space-y-1">