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:
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user