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:
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 { Link, useLocation } from "wouter";
|
||||||
import { useLanguage } from "../../contexts/LanguageContext";
|
import { useLanguage } from "../../contexts/LanguageContext";
|
||||||
import { useAuth } from "../../contexts/AuthContext";
|
import { useAuth } from "../../contexts/AuthContext";
|
||||||
import { Button } from "../ui/button";
|
import { Button } from "../ui/button";
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuSeparator,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
} from "../ui/dropdown-menu";
|
|
||||||
import {
|
import {
|
||||||
Search,
|
Search,
|
||||||
ShoppingCart,
|
ShoppingCart,
|
||||||
@@ -36,11 +29,42 @@ export function Header() {
|
|||||||
const { isAuthenticated, logout } = useAuth();
|
const { isAuthenticated, logout } = useAuth();
|
||||||
const [location, setLocation] = useLocation();
|
const [location, setLocation] = useLocation();
|
||||||
const [mobileOpen, setMobileOpen] = useState(false);
|
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 toggleLanguage = () => setLanguage(language === "ar" ? "en" : "ar");
|
||||||
|
|
||||||
const isActive = (path: string) => location === path;
|
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 = [
|
const services = [
|
||||||
{ key: "ghiras", label: t.serviceItems.ghiras, icon: Sprout },
|
{ key: "ghiras", label: t.serviceItems.ghiras, icon: Sprout },
|
||||||
{ key: "zakat", label: t.serviceItems.zakat, icon: HandCoins },
|
{ key: "zakat", label: t.serviceItems.zakat, icon: HandCoins },
|
||||||
@@ -53,7 +77,7 @@ export function Header() {
|
|||||||
];
|
];
|
||||||
|
|
||||||
return (
|
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">
|
<div className="container mx-auto px-4 h-16 flex items-center justify-between gap-4">
|
||||||
{/* Logo */}
|
{/* Logo */}
|
||||||
<Link href="/" className="flex items-center shrink-0">
|
<Link href="/" className="flex items-center shrink-0">
|
||||||
@@ -89,39 +113,23 @@ export function Header() {
|
|||||||
<ChevronDown className="w-3.5 h-3.5" />
|
<ChevronDown className="w-3.5 h-3.5" />
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<DropdownMenu>
|
<button
|
||||||
<DropdownMenuTrigger asChild>
|
ref={servicesTriggerRef}
|
||||||
<button
|
type="button"
|
||||||
className="px-3 py-2 text-sm font-medium text-foreground hover:text-primary inline-flex items-center gap-1 transition-colors"
|
onClick={() => setServicesOpen((v) => !v)}
|
||||||
data-testid="nav-services"
|
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"
|
||||||
{t.nav.services}
|
}`}
|
||||||
<ChevronDown className="w-3.5 h-3.5" />
|
data-testid="nav-services"
|
||||||
</button>
|
aria-haspopup="menu"
|
||||||
</DropdownMenuTrigger>
|
aria-controls="services-megamenu"
|
||||||
<DropdownMenuContent align="center" className="w-56">
|
aria-expanded={servicesOpen}
|
||||||
{services.map((s) => (
|
>
|
||||||
<DropdownMenuItem
|
{t.nav.services}
|
||||||
key={s.key}
|
<ChevronDown
|
||||||
onClick={() => setLocation("/opportunities")}
|
className={`w-3.5 h-3.5 transition-transform ${servicesOpen ? "rotate-180" : ""}`}
|
||||||
data-testid={`nav-service-${s.key}`}
|
/>
|
||||||
className="gap-2 cursor-pointer"
|
</button>
|
||||||
>
|
|
||||||
<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>
|
|
||||||
|
|
||||||
<span className="px-3 py-2 text-sm font-medium text-foreground/70 cursor-default select-none">
|
<span className="px-3 py-2 text-sm font-medium text-foreground/70 cursor-default select-none">
|
||||||
{t.nav.about}
|
{t.nav.about}
|
||||||
@@ -200,6 +208,46 @@ export function Header() {
|
|||||||
</div>
|
</div>
|
||||||
</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 */}
|
{/* Mobile nav panel */}
|
||||||
{mobileOpen && (
|
{mobileOpen && (
|
||||||
<div className="lg:hidden border-t bg-white px-4 py-3 space-y-1">
|
<div className="lg:hidden border-t bg-white px-4 py-3 space-y-1">
|
||||||
|
|||||||
Reference in New Issue
Block a user