464 lines
16 KiB
TypeScript
464 lines
16 KiB
TypeScript
'use client'
|
|
import React, { useState, useEffect, useRef, useCallback } from 'react'
|
|
import type { Header as HeaderType, Page } from '@/payload-types'
|
|
import Link from 'next/link'
|
|
import { usePathname } from 'next/navigation'
|
|
import { MenuIcon, XIcon, ChevronDownIcon, ChevronRightIcon } from 'lucide-react'
|
|
import { SocialIconsRow } from '@/components/SocialIcons'
|
|
import type { SocialLinkData } from '@/components/SocialIcons'
|
|
|
|
type NavChild = NonNullable<NonNullable<NonNullable<HeaderType['navItems']>[number]['children']>[number]>
|
|
type NavItem = NonNullable<HeaderType['navItems']>[number]
|
|
|
|
function resolveHref(item: {
|
|
type?: string | null
|
|
url?: string | null
|
|
reference?: { relationTo?: string; value?: number | Page | null } | null
|
|
}): string {
|
|
if (item.type === 'reference' && item.reference?.value) {
|
|
const page = item.reference.value
|
|
if (typeof page === 'object' && page !== null) {
|
|
return page.slug === 'home' || page.slug === 'startsida' ? '/' : `/${(page as Page).slug}`
|
|
}
|
|
return '#'
|
|
}
|
|
return item.url || '#'
|
|
}
|
|
|
|
const SwedishFlag = () => (
|
|
<svg width="28" height="20" viewBox="0 0 28 20" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true" className="rounded-[3px]">
|
|
<rect width="28" height="20" fill="#006AA7" />
|
|
<rect x="8" width="4" height="20" fill="#FECC02" />
|
|
<rect y="8" width="28" height="4" fill="#FECC02" />
|
|
</svg>
|
|
)
|
|
|
|
function groupChildren(children: NavChild[]): { heading: string | null; links: NavChild[] }[] {
|
|
const main: NavChild[] = []
|
|
const groups: Record<string, NavChild[]> = {}
|
|
const groupOrder: string[] = []
|
|
|
|
for (const child of children) {
|
|
if (!child.group) {
|
|
main.push(child)
|
|
} else {
|
|
if (!groups[child.group]) {
|
|
groups[child.group] = []
|
|
groupOrder.push(child.group)
|
|
}
|
|
groups[child.group].push(child)
|
|
}
|
|
}
|
|
|
|
const result: { heading: string | null; links: NavChild[] }[] = []
|
|
if (main.length > 0) result.push({ heading: null, links: main })
|
|
for (const g of groupOrder) result.push({ heading: g, links: groups[g] })
|
|
return result
|
|
}
|
|
|
|
function useFocusTrap(containerRef: React.RefObject<HTMLElement | null>, active: boolean) {
|
|
useEffect(() => {
|
|
if (!active || !containerRef.current) return
|
|
|
|
const container = containerRef.current
|
|
const focusableSelector =
|
|
'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
|
|
|
|
const getFocusableElements = () =>
|
|
Array.from(container.querySelectorAll<HTMLElement>(focusableSelector))
|
|
|
|
const elements = getFocusableElements()
|
|
if (elements.length > 0) {
|
|
setTimeout(() => elements[0]?.focus(), 100)
|
|
}
|
|
|
|
const handleKeyDown = (e: KeyboardEvent) => {
|
|
if (e.key !== 'Tab') return
|
|
|
|
const focusable = getFocusableElements()
|
|
if (focusable.length === 0) return
|
|
|
|
const first = focusable[0]
|
|
const last = focusable[focusable.length - 1]
|
|
|
|
if (e.shiftKey) {
|
|
if (document.activeElement === first) {
|
|
e.preventDefault()
|
|
last.focus()
|
|
}
|
|
} else {
|
|
if (document.activeElement === last) {
|
|
e.preventDefault()
|
|
first.focus()
|
|
}
|
|
}
|
|
}
|
|
|
|
document.addEventListener('keydown', handleKeyDown)
|
|
return () => document.removeEventListener('keydown', handleKeyDown)
|
|
}, [active, containerRef])
|
|
}
|
|
|
|
function isActiveSection(item: NavItem, pathname: string | null): boolean {
|
|
if (!pathname || !item.children?.length) {
|
|
const href = resolveHref(item)
|
|
if (href === '/') return pathname === '/'
|
|
return pathname?.startsWith(href) || false
|
|
}
|
|
|
|
return item.children.some((child) => {
|
|
const childHref = resolveHref(child)
|
|
if (childHref === '/' || childHref === '#') return false
|
|
return pathname?.startsWith(childHref) || false
|
|
})
|
|
}
|
|
|
|
const MegaMenuPanel: React.FC<{
|
|
item: NavItem
|
|
headerRef: React.RefObject<HTMLElement | null>
|
|
panelRef: React.RefObject<HTMLDivElement | null>
|
|
onClose: () => void
|
|
}> = ({ item, headerRef, panelRef, onClose }) => {
|
|
const [top, setTop] = useState(72)
|
|
|
|
useEffect(() => {
|
|
const update = () => {
|
|
if (headerRef.current) {
|
|
const rect = headerRef.current.getBoundingClientRect()
|
|
setTop(rect.bottom)
|
|
}
|
|
}
|
|
update()
|
|
window.addEventListener('scroll', update, { passive: true })
|
|
window.addEventListener('resize', update)
|
|
return () => {
|
|
window.removeEventListener('scroll', update)
|
|
window.removeEventListener('resize', update)
|
|
}
|
|
}, [headerRef])
|
|
|
|
const columns = groupChildren(item.children || [])
|
|
|
|
return (
|
|
<>
|
|
{/* Blur backdrop */}
|
|
<div
|
|
className="fixed inset-0 z-30"
|
|
style={{ top, backdropFilter: 'blur(6px)', WebkitBackdropFilter: 'blur(6px)', backgroundColor: 'rgba(14, 35, 56, 0.08)' }}
|
|
onClick={onClose}
|
|
/>
|
|
|
|
{/* Panel */}
|
|
<div
|
|
ref={panelRef}
|
|
className="fixed left-0 right-0 z-40 bg-white dark:bg-fd-navy border-b border-fd-navy/10 dark:border-white/10"
|
|
style={{ top }}
|
|
>
|
|
<div className="max-w-[1200px] mx-auto px-6 md:px-8 py-10 md:py-14">
|
|
<div className="grid grid-cols-1 md:grid-cols-[200px_1fr] gap-8 md:gap-12">
|
|
|
|
{/* Left: category title */}
|
|
<div>
|
|
<h2 className="font-joey-heavy text-fd-h1 text-fd-navy dark:text-fd-yellow leading-tight">
|
|
{item.label}
|
|
</h2>
|
|
</div>
|
|
|
|
{/* Right: columns */}
|
|
<div className={`grid gap-6 ${columns.length === 1 ? 'grid-cols-1' : columns.length === 2 ? 'grid-cols-2' : 'grid-cols-3'}`}>
|
|
{columns.map((col, ci) => (
|
|
<div key={ci}>
|
|
{col.heading && (
|
|
<p className="font-joey-medium text-fd-small text-fd-navy/40 dark:text-white/40 uppercase tracking-widest mb-2">
|
|
{col.heading}
|
|
</p>
|
|
)}
|
|
<ul className="flex flex-col gap-0">
|
|
{col.links.map((link, li) => (
|
|
<li key={li}>
|
|
<Link
|
|
href={resolveHref(link)}
|
|
onClick={onClose}
|
|
className={`block py-1 transition-colors duration-150 ${
|
|
col.heading
|
|
? 'font-joey-medium text-lg text-fd-navy/80 dark:text-white/75 hover:text-fd-navy dark:hover:text-fd-yellow'
|
|
: 'font-joey-bold text-fd-h3 text-fd-navy dark:text-white hover:text-fd-yellow dark:hover:text-fd-yellow'
|
|
}`}
|
|
>
|
|
{link.label}
|
|
</Link>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</>
|
|
)
|
|
}
|
|
|
|
export const HeaderNav: React.FC<{ data: HeaderType; socialLinks?: SocialLinkData[] }> = ({
|
|
data,
|
|
socialLinks = [],
|
|
}) => {
|
|
const navItems = (data?.navItems || []) as NavItem[]
|
|
const [mobileOpen, setMobileOpen] = useState(false)
|
|
const [openDropdown, setOpenDropdown] = useState<string | null>(null)
|
|
const [mobileOpenSection, setMobileOpenSection] = useState<string | null>(null)
|
|
const pathname = usePathname()
|
|
const navRef = useRef<HTMLDivElement>(null)
|
|
const headerRef = useRef<HTMLElement | null>(null)
|
|
const megaMenuRef = useRef<HTMLDivElement | null>(null)
|
|
const mobileMenuRef = useRef<HTMLDivElement | null>(null)
|
|
|
|
useFocusTrap(mobileMenuRef, mobileOpen)
|
|
|
|
useEffect(() => {
|
|
if (navRef.current) {
|
|
headerRef.current = navRef.current.closest('header')
|
|
}
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
setMobileOpen(false)
|
|
setMobileOpenSection(null)
|
|
setOpenDropdown(null)
|
|
}, [pathname])
|
|
|
|
useEffect(() => {
|
|
document.body.style.overflow = mobileOpen ? 'hidden' : ''
|
|
return () => { document.body.style.overflow = '' }
|
|
}, [mobileOpen])
|
|
|
|
useEffect(() => {
|
|
const handler = (e: MouseEvent) => {
|
|
if (
|
|
navRef.current && !navRef.current.contains(e.target as Node) &&
|
|
megaMenuRef.current && !megaMenuRef.current.contains(e.target as Node)
|
|
) {
|
|
setOpenDropdown(null)
|
|
}
|
|
}
|
|
document.addEventListener('mousedown', handler)
|
|
return () => document.removeEventListener('mousedown', handler)
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
const handler = (e: KeyboardEvent) => {
|
|
if (e.key === 'Escape') {
|
|
setOpenDropdown(null)
|
|
setMobileOpen(false)
|
|
}
|
|
}
|
|
document.addEventListener('keydown', handler)
|
|
return () => document.removeEventListener('keydown', handler)
|
|
}, [])
|
|
|
|
const closeMobile = () => {
|
|
setMobileOpen(false)
|
|
setMobileOpenSection(null)
|
|
}
|
|
|
|
const activeItem = navItems.find((item) => item.label === openDropdown)
|
|
|
|
return (
|
|
<>
|
|
{/* ── Desktop nav — shows at lg (1024px+) ── */}
|
|
<nav ref={navRef} className="hidden lg:flex gap-6 items-center">
|
|
{navItems.map((item, i) => {
|
|
const hasChildren = item.children && item.children.length > 0
|
|
const isOpen = openDropdown === item.label
|
|
const isMega = item.megaMenu && hasChildren
|
|
const isActive = isActiveSection(item, pathname)
|
|
|
|
return hasChildren ? (
|
|
<div key={i} className="relative">
|
|
<button
|
|
onClick={() => setOpenDropdown(isOpen ? null : item.label ?? null)}
|
|
className={`flex items-center gap-1 font-joey-bold transition-colors text-base ${
|
|
isOpen
|
|
? 'text-fd-yellow'
|
|
: isActive
|
|
? 'text-fd-yellow'
|
|
: 'text-fd-navy dark:text-white hover:text-fd-yellow dark:hover:text-fd-yellow'
|
|
}`}
|
|
aria-haspopup="true"
|
|
aria-expanded={isOpen}
|
|
>
|
|
{item.label}
|
|
<ChevronDownIcon
|
|
className={`w-4 h-4 transition-transform duration-200 ${isOpen ? 'rotate-180' : ''}`}
|
|
/>
|
|
{isActive && !isOpen && (
|
|
<span className="absolute -bottom-1 left-1/2 -translate-x-1/2 w-1 h-1 rounded-full bg-fd-yellow" />
|
|
)}
|
|
</button>
|
|
|
|
{/* Small card dropdown */}
|
|
{!isMega && (
|
|
<div
|
|
className={`absolute top-full left-0 mt-3 min-w-[220px] bg-white dark:bg-fd-navy rounded-[16px] shadow-lg border border-fd-navy/10 dark:border-white/10 py-2 z-50 transition-all duration-200 origin-top ${
|
|
isOpen
|
|
? 'opacity-100 scale-y-100 pointer-events-auto'
|
|
: 'opacity-0 scale-y-95 pointer-events-none'
|
|
}`}
|
|
>
|
|
{item.children!.map((child, j) => (
|
|
<Link
|
|
key={j}
|
|
href={resolveHref(child)}
|
|
onClick={() => setOpenDropdown(null)}
|
|
className="block px-5 py-2.5 font-joey text-fd-navy dark:text-white hover:bg-fd-yellow/20 dark:hover:bg-white/10 transition-colors text-sm"
|
|
>
|
|
{child.label}
|
|
</Link>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<Link
|
|
key={i}
|
|
href={resolveHref(item)}
|
|
className={`relative font-joey-bold transition-colors text-base ${
|
|
isActive
|
|
? 'text-fd-yellow'
|
|
: 'text-fd-navy dark:text-white hover:text-fd-yellow dark:hover:text-fd-yellow'
|
|
}`}
|
|
>
|
|
{item.label}
|
|
{isActive && (
|
|
<span className="absolute -bottom-1 left-1/2 -translate-x-1/2 w-1 h-1 rounded-full bg-fd-yellow" />
|
|
)}
|
|
</Link>
|
|
)
|
|
})}
|
|
|
|
<SwedishFlag />
|
|
</nav>
|
|
|
|
{/* ── Mega menu panel ── */}
|
|
{activeItem?.megaMenu && activeItem.children?.length && (
|
|
<MegaMenuPanel
|
|
item={activeItem}
|
|
headerRef={headerRef}
|
|
panelRef={megaMenuRef}
|
|
onClose={() => setOpenDropdown(null)}
|
|
/>
|
|
)}
|
|
|
|
{/* ── Hamburger — shows below lg (mobile + tablet) ── */}
|
|
<button
|
|
className="lg:hidden text-fd-navy dark:text-white p-2"
|
|
onClick={() => setMobileOpen(!mobileOpen)}
|
|
aria-label={mobileOpen ? 'Stäng meny' : 'Öppna meny'}
|
|
>
|
|
{mobileOpen ? <XIcon className="w-6 h-6" /> : <MenuIcon className="w-6 h-6" />}
|
|
</button>
|
|
|
|
{/* ── Tablet backdrop — blur overlay left of panel on md+ ── */}
|
|
<div
|
|
className={`fixed inset-0 z-40 lg:hidden transition-opacity duration-300 ${
|
|
mobileOpen ? 'opacity-100 pointer-events-auto' : 'opacity-0 pointer-events-none'
|
|
}`}
|
|
style={{ backdropFilter: 'blur(4px)', WebkitBackdropFilter: 'blur(4px)', backgroundColor: 'rgba(14, 35, 56, 0.45)' }}
|
|
onClick={closeMobile}
|
|
aria-hidden="true"
|
|
/>
|
|
|
|
{/* ── Mobile/tablet slide-in panel ──
|
|
Mobile (<768px): full-screen overlay
|
|
Tablet (768-1023px): right-side panel, 420px wide, with backdrop above
|
|
── */}
|
|
<div
|
|
ref={mobileMenuRef}
|
|
className={`fixed inset-y-0 right-0 z-50 flex flex-col bg-fd-navy lg:hidden
|
|
w-full md:w-[420px]
|
|
transition-transform duration-300
|
|
${mobileOpen ? 'translate-x-0 md:shadow-[-24px_0_60px_rgba(0,0,0,0.25)]' : 'translate-x-full'}
|
|
`}
|
|
role="dialog"
|
|
aria-modal="true"
|
|
aria-label="Navigeringsmeny"
|
|
>
|
|
{/* Header bar */}
|
|
<div className="flex items-center justify-between px-6 md:px-8 py-5 border-b border-white/10">
|
|
<Link href="/" onClick={closeMobile}>
|
|
<span className="font-joey-heavy text-fd-yellow text-xl">Fiber Direkt</span>
|
|
</Link>
|
|
<button
|
|
onClick={closeMobile}
|
|
className="text-white p-2 hover:text-fd-yellow transition-colors"
|
|
aria-label="Stäng meny"
|
|
>
|
|
<XIcon className="w-6 h-6" />
|
|
</button>
|
|
</div>
|
|
|
|
{/* Nav links */}
|
|
<nav className="flex-1 overflow-y-auto px-6 md:px-8 py-6 flex flex-col">
|
|
{navItems.map((item, i) => {
|
|
const hasChildren = item.children && item.children.length > 0
|
|
const isExpanded = mobileOpenSection === item.label
|
|
|
|
return hasChildren ? (
|
|
<div key={i} className="border-b border-white/10">
|
|
<button
|
|
onClick={() => setMobileOpenSection(isExpanded ? null : item.label ?? null)}
|
|
className="w-full flex justify-between items-center py-4 md:py-5 text-white font-joey-medium text-xl md:text-2xl hover:text-fd-yellow transition-colors"
|
|
>
|
|
{item.label}
|
|
<ChevronRightIcon
|
|
className={`w-5 h-5 transition-transform duration-200 ${isExpanded ? 'rotate-90' : ''}`}
|
|
/>
|
|
</button>
|
|
<div
|
|
className={`overflow-hidden transition-all duration-300 ${
|
|
isExpanded ? 'max-h-[500px] pb-3' : 'max-h-0'
|
|
}`}
|
|
>
|
|
{item.children!.map((child, j) => (
|
|
<Link
|
|
key={j}
|
|
href={resolveHref(child)}
|
|
onClick={closeMobile}
|
|
className="block pl-4 py-3 md:py-3.5 text-fd-yellow font-joey text-lg md:text-xl border-b border-white/5 hover:text-white transition-colors"
|
|
>
|
|
{child.label}
|
|
</Link>
|
|
))}
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<Link
|
|
key={i}
|
|
href={resolveHref(item)}
|
|
onClick={closeMobile}
|
|
className="py-4 md:py-5 text-white font-joey-medium text-xl md:text-2xl border-b border-white/10 hover:text-fd-yellow transition-colors block"
|
|
>
|
|
{item.label}
|
|
</Link>
|
|
)
|
|
})}
|
|
</nav>
|
|
|
|
{/* Footer: Swedish flag left, social icons right */}
|
|
<div className="px-6 md:px-8 py-5 border-t border-white/10 flex items-center justify-between">
|
|
<SwedishFlag />
|
|
{socialLinks.length > 0 && (
|
|
<SocialIconsRow
|
|
links={socialLinks}
|
|
linkClassName="text-white/50 hover:text-white"
|
|
iconSize="w-6 h-6"
|
|
/>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</>
|
|
)
|
|
}
|