feat: iPad portrait responsive layout — min-[820px] breakpoints across all blocks + tablet slide-in nav

This commit is contained in:
Jeffrey 2026-02-24 12:18:36 +01:00
parent 7010e6519b
commit 313cfd31fb
13 changed files with 59 additions and 54 deletions

View File

@ -56,7 +56,6 @@ function groupChildren(children: NavChild[]): { heading: string | null; links: N
return result
}
/* ── Priority #4: Focus trap hook for mobile menu ──────────────────── */
function useFocusTrap(containerRef: React.RefObject<HTMLElement | null>, active: boolean) {
useEffect(() => {
if (!active || !containerRef.current) return
@ -68,10 +67,8 @@ function useFocusTrap(containerRef: React.RefObject<HTMLElement | null>, active:
const getFocusableElements = () =>
Array.from(container.querySelectorAll<HTMLElement>(focusableSelector))
// Focus the first focusable element when trap activates
const elements = getFocusableElements()
if (elements.length > 0) {
// Small delay to ensure the menu transition has started
setTimeout(() => elements[0]?.focus(), 100)
}
@ -102,16 +99,13 @@ function useFocusTrap(containerRef: React.RefObject<HTMLElement | null>, active:
}, [active, containerRef])
}
/* ── Priority #10: Check if a nav item matches current path ────────── */
function isActiveSection(item: NavItem, pathname: string | null): boolean {
if (!pathname || !item.children?.length) {
// For top-level links without children, check direct href match
const href = resolveHref(item)
if (href === '/') return pathname === '/'
return pathname?.startsWith(href) || false
}
// For dropdown parents, check if any child href matches
return item.children.some((child) => {
const childHref = resolveHref(child)
if (childHref === '/' || childHref === '#') return false
@ -119,7 +113,6 @@ function isActiveSection(item: NavItem, pathname: string | null): boolean {
})
}
const MegaMenuPanel: React.FC<{
item: NavItem
headerRef: React.RefObject<HTMLElement | null>
@ -155,7 +148,7 @@ const MegaMenuPanel: React.FC<{
onClick={onClose}
/>
{/* Panel — white in light, navy in dark */}
{/* 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"
@ -222,7 +215,6 @@ export const HeaderNav: React.FC<{ data: HeaderType; socialLinks?: SocialLinkDat
const megaMenuRef = useRef<HTMLDivElement | null>(null)
const mobileMenuRef = useRef<HTMLDivElement | null>(null)
// Priority #4: Focus trap
useFocusTrap(mobileMenuRef, mobileOpen)
useEffect(() => {
@ -275,13 +267,12 @@ export const HeaderNav: React.FC<{ data: HeaderType; socialLinks?: SocialLinkDat
return (
<>
{/* ── Desktop nav ── */}
<nav ref={navRef} className="hidden md:flex gap-6 items-center">
{/* ── 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
// Priority #10: Active section detection
const isActive = isActiveSection(item, pathname)
return hasChildren ? (
@ -302,7 +293,6 @@ export const HeaderNav: React.FC<{ data: HeaderType; socialLinks?: SocialLinkDat
<ChevronDownIcon
className={`w-4 h-4 transition-transform duration-200 ${isOpen ? 'rotate-180' : ''}`}
/>
{/* Active indicator dot */}
{isActive && !isOpen && (
<span className="absolute -bottom-1 left-1/2 -translate-x-1/2 w-1 h-1 rounded-full bg-fd-yellow" />
)}
@ -361,27 +351,42 @@ export const HeaderNav: React.FC<{ data: HeaderType; socialLinks?: SocialLinkDat
/>
)}
{/* ── Mobile hamburger ── */}
{/* ── Hamburger — shows below lg (mobile + tablet) ── */}
<button
className="md:hidden text-fd-navy dark:text-white p-2"
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>
{/* ── Mobile full-screen overlay (Priority #4: has focus trap via ref) ── */}
{/* ── 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-0 z-50 bg-fd-navy flex flex-col transition-transform duration-300 md:hidden ${
mobileOpen ? 'translate-x-0' : 'translate-x-full'
}`}
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 py-5 border-b border-white/10">
<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>
@ -395,7 +400,7 @@ export const HeaderNav: React.FC<{ data: HeaderType; socialLinks?: SocialLinkDat
</div>
{/* Nav links */}
<nav className="flex-1 overflow-y-auto px-6 py-6 flex flex-col">
<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
@ -404,7 +409,7 @@ export const HeaderNav: React.FC<{ data: HeaderType; socialLinks?: SocialLinkDat
<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 text-white font-joey-medium text-xl hover:text-fd-yellow transition-colors"
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
@ -421,7 +426,7 @@ export const HeaderNav: React.FC<{ data: HeaderType; socialLinks?: SocialLinkDat
key={j}
href={resolveHref(child)}
onClick={closeMobile}
className="block pl-4 py-3 text-fd-yellow font-joey text-lg border-b border-white/5 hover:text-white transition-colors"
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>
@ -433,7 +438,7 @@ export const HeaderNav: React.FC<{ data: HeaderType; socialLinks?: SocialLinkDat
key={i}
href={resolveHref(item)}
onClick={closeMobile}
className="py-4 text-white font-joey-medium text-xl border-b border-white/10 hover:text-fd-yellow transition-colors block"
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>
@ -441,8 +446,8 @@ export const HeaderNav: React.FC<{ data: HeaderType; socialLinks?: SocialLinkDat
})}
</nav>
{/* ── Mobile menu footer: flag left, social icons right ── */}
<div className="px-6 py-5 border-t border-white/10 flex items-center justify-between">
{/* 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

View File

@ -50,10 +50,10 @@ const sectionBgMap: Record<string, string> = {
}
const layoutGridMap: Record<string, string> = {
'1-2': 'lg:grid-cols-[1fr_2fr]',
'2-1': 'lg:grid-cols-[2fr_1fr]',
'1-1-1': 'lg:grid-cols-3',
'1-1': 'lg:grid-cols-2',
'1-2': 'min-[820px]:grid-cols-[1fr_2fr]',
'2-1': 'min-[820px]:grid-cols-[2fr_1fr]',
'1-1-1': 'min-[820px]:grid-cols-3',
'1-1': 'min-[820px]:grid-cols-2',
}
const styleClassMap: Record<string, string> = {
@ -80,7 +80,7 @@ export const FDCardGridBlockComponent: React.FC<FDCardGridBlockProps> = ({
return (
<section id={anchorId || undefined} className={`w-full py-16 md:py-20 lg:py-[99px] ${sectionBg}`}>
<div className="max-w-[1200px] mx-auto px-6 md:px-8">
<div className={`grid grid-cols-1 md:grid-cols-2 ${gridCols} gap-4 md:gap-6`}>
<div className={`grid grid-cols-1 ${gridCols} gap-4 md:gap-6`}>
{cards?.map((card, index) => {
const mode = card.displayMode || 'content'

View File

@ -48,7 +48,7 @@ export const FDCtaSideImageBlockComponent: React.FC<FDCtaSideImageBlockProps> =
: 'text-fd-navy dark:text-white'
const textContent = (
<div className={`flex flex-col flex-1 items-start gap-8 lg:gap-[41px] ${!hasImage ? 'max-w-2xl' : ''}`}>
<div className={`flex flex-col flex-1 items-start gap-8 min-[820px]:gap-[41px] ${!hasImage ? 'max-w-2xl' : ''}`}>
<div className="flex flex-col items-start gap-4 w-full">
<h2 className={`w-full font-joey-heavy text-fd-h1 leading-tight ${headingClass}`}>
{heading}
@ -67,7 +67,7 @@ export const FDCtaSideImageBlockComponent: React.FC<FDCtaSideImageBlockProps> =
)
const imageContent = hasImage ? (
<div className={`relative w-full lg:w-[575px] lg:h-[479px] flex-shrink-0 overflow-hidden ${imageRadius}`}>
<div className={`relative w-full min-[820px]:w-[45%] lg:w-[575px] lg:h-[479px] flex-shrink-0 overflow-hidden ${imageRadius}`}>
<FDImage
media={image as Media}
size="large"
@ -81,7 +81,7 @@ export const FDCtaSideImageBlockComponent: React.FC<FDCtaSideImageBlockProps> =
return (
<section id={anchorId || undefined} className={`w-full py-16 md:py-20 lg:py-[99px] ${sectionBg}`}>
<div className="max-w-[1200px] mx-auto px-6 md:px-8 flex flex-col lg:flex-row items-center gap-10 lg:gap-16">
<div className="max-w-[1200px] mx-auto px-6 md:px-8 flex flex-col min-[820px]:flex-row items-center gap-10 min-[820px]:gap-16">
{imagePosition === 'left' ? (
<>{imageContent}{textContent}</>
) : (

View File

@ -56,7 +56,7 @@ export const FDLocationsGridBlockComponent: React.FC<Props> = ({
</div>
)}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 md:gap-6">
<div className="grid grid-cols-1 sm:grid-cols-2 min-[820px]:grid-cols-3 gap-4 md:gap-6">
{(cards ?? []).map((card, i) => {
const media = card.image as Media | undefined
const isLink = Boolean(card.link)

View File

@ -64,9 +64,9 @@ const buttonVariantMap: Record<string, { variant: 'primary' | 'outline' }> = {
}
const gridColsMap: Record<number, string> = {
1: 'lg:grid-cols-1 max-w-[500px] mx-auto',
2: 'lg:grid-cols-2',
3: 'lg:grid-cols-3',
1: 'min-[820px]:grid-cols-1 max-w-[500px] mx-auto',
2: 'min-[820px]:grid-cols-2',
3: 'min-[820px]:grid-cols-3',
}
const cardRadius = 'rounded-[32px] md:rounded-[50px] lg:rounded-[70px]'
@ -96,11 +96,11 @@ export const FDPricingCardBlockComponent: React.FC<FDPricingCardBlockProps> = ({
{sectionTitle}
</h2>
)}
<div className={`grid grid-cols-1 ${gridCols} gap-6 md:gap-8`}>
<div className={`grid grid-cols-1 ${gridCols} gap-4 min-[820px]:gap-5 lg:gap-8`}>
{cards?.map((card, index) => (
<div
key={index}
className={`${style.bg} ${style.border} ${cardRadius} px-8 md:px-12 py-10 md:py-14 flex flex-col gap-5`}
className={`${style.bg} ${style.border} ${cardRadius} px-6 min-[820px]:px-8 lg:px-12 py-8 min-[820px]:py-10 lg:py-14 flex flex-col gap-5`}
>
<h3 className={`font-joey-heavy text-fd-h2 leading-tight ${style.title}`}>
{card.title}

View File

@ -219,10 +219,10 @@ export const FDServiceCalculatorBlockComponent: React.FC<Props> = ({
</div>
)}
<div className="grid grid-cols-1 lg:grid-cols-5 gap-6 lg:gap-8">
<div className="grid grid-cols-1 min-[820px]:grid-cols-5 gap-6 min-[820px]:gap-8">
{/* ── Left: Configuration ─────────────────────────────────── */}
<div className={`lg:col-span-3 p-7 md:p-9 ${cardClass}`}>
<div className={`min-[820px]:col-span-3 p-7 md:p-9 ${cardClass}`}>
{/* Option groups */}
{(optionGroups ?? []).map((group, gi) => (
@ -312,7 +312,7 @@ export const FDServiceCalculatorBlockComponent: React.FC<Props> = ({
</div>
{/* ── Right: Summary ──────────────────────────────────────── */}
<div className={`lg:col-span-2 p-7 md:p-9 flex flex-col ${cardClass}`}>
<div className={`min-[820px]:col-span-2 p-7 md:p-9 flex flex-col ${cardClass}`}>
<h3 className={`font-joey-heavy text-fd-h2 mb-6 ${headingColor}`}>{summaryHeading}</h3>
<div className="flex-1 space-y-0">

View File

@ -100,7 +100,7 @@ export const FDServiceChooserBlockComponent: React.FC<Props> = ({
</p>
)}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 md:gap-6">
<div className="grid grid-cols-1 sm:grid-cols-2 min-[820px]:grid-cols-3 gap-4 md:gap-6">
{activeCategory?.services?.map((service, i) => (
<div
key={i}

View File

@ -4,8 +4,8 @@ import { FDImage } from '@/components/FDImage'
const columnClasses: Record<string, string> = {
'2': 'grid-cols-1 md:grid-cols-2',
'3': 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3',
'4': 'grid-cols-2 lg:grid-cols-4',
'3': 'grid-cols-1 md:grid-cols-2 min-[820px]:grid-cols-3',
'4': 'grid-cols-2 min-[820px]:grid-cols-4',
}
/* Priority #5: Responsive radius for service images */

View File

@ -34,8 +34,8 @@ const cardMap: Record<string, { bg: string; name: string; role: string; bio: str
const colsMap: Record<string, string> = {
'2': 'sm:grid-cols-2',
'3': 'sm:grid-cols-2 lg:grid-cols-3',
'4': 'sm:grid-cols-2 lg:grid-cols-4',
'3': 'sm:grid-cols-2 min-[820px]:grid-cols-3',
'4': 'sm:grid-cols-2 min-[820px]:grid-cols-4',
}
/* Priority #5: Responsive radius for team member cards */

View File

@ -121,7 +121,7 @@ export const FDTestimonialBlockComponent: React.FC<FDTestimonialBlockProps> = ({
)}
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<div className="grid grid-cols-1 md:grid-cols-2 min-[820px]:grid-cols-3 gap-6">
{testimonials?.map((t, i) => {
const avatar = t.avatar as Media | undefined
return (

View File

@ -78,7 +78,7 @@ export const FDUspChecklistBlockComponent: React.FC<FDUspChecklistBlockProps> =
return (
<section id={anchorId || undefined} className={`w-full py-16 md:py-20 lg:py-[99px] ${bg}`}>
<div className="max-w-[1200px] mx-auto px-6 md:px-8 flex flex-col lg:flex-row items-center gap-10 lg:gap-16">
<div className="max-w-[1200px] mx-auto px-6 md:px-8 flex flex-col min-[820px]:flex-row items-center gap-10 min-[820px]:gap-16">
{imagePosition === 'left' ? <>{imageContent}{textContent}</> : <>{textContent}{imageContent}</>}
</div>
</section>

View File

@ -169,10 +169,10 @@ export const FDVpsCalculatorBlockComponent: React.FC<FDVpsCalculatorBlockProps>
</div>
)}
<div className="grid grid-cols-1 lg:grid-cols-5 gap-6 lg:gap-8">
<div className="grid grid-cols-1 min-[820px]:grid-cols-5 gap-6 min-[820px]:gap-8">
{/* Left: Config */}
<div className={`lg:col-span-3 p-7 md:p-9 ${cardClass}`}>
<div className={`min-[820px]:col-span-3 p-7 md:p-9 ${cardClass}`}>
<p className={`font-joey-medium text-fd-body tracking-widest uppercase mb-3 ${sectionLabel}`}>
Operativsystem
@ -225,7 +225,7 @@ export const FDVpsCalculatorBlockComponent: React.FC<FDVpsCalculatorBlockProps>
</div>
{/* Right: Summary */}
<div className={`lg:col-span-2 p-7 md:p-9 flex flex-col ${cardClass}`}>
<div className={`min-[820px]:col-span-2 p-7 md:p-9 flex flex-col ${cardClass}`}>
<h3 className={`font-joey-heavy text-fd-h2 mb-6 ${headingColor}`}>Kostnadsöversikt</h3>
<div className="flex-1 space-y-0">

View File

@ -44,7 +44,7 @@ export const FDWideCardBlockComponent: React.FC<FDWideCardBlockProps> = ({
return (
<section id={anchorId || undefined} className={`w-full py-16 md:py-20 lg:py-[99px] ${sectionBg}`}>
<div className="max-w-[1200px] mx-auto px-6 md:px-8">
<div className={`${card.bg} ${cardRadius} overflow-hidden flex flex-col lg:flex-row`}>
<div className={`${card.bg} ${cardRadius} overflow-hidden flex flex-col min-[820px]:flex-row`}>
<div className="flex-1 flex flex-col justify-center gap-5 md:gap-6 px-8 md:px-14 lg:px-16 py-12 md:py-16">
<h2 className={`font-joey-heavy text-fd-h1 leading-tight ${card.heading}`}>
{heading}