2026-01-14 16:30:39 +01:00

168 lines
3.8 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useState, useEffect } from 'react'
import type { ReactNode } from 'react'
import { Button } from './Button'
interface MobileMenuProps {
children: ReactNode
'aria-label'?: string
}
function HamburgerIcon({ isOpen }: { isOpen: boolean }): React.ReactElement {
return (
<svg
className="w-6 h-6"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
{isOpen ? (
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
) : (
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 6h16M4 12h16M4 18h16"
/>
)}
</svg>
)
}
export function MobileMenu({ children, 'aria-label': ariaLabel }: MobileMenuProps): React.ReactElement {
const [isOpen, setIsOpen] = useState(false)
useEffect(() => {
if (isOpen) {
document.body.style.overflow = 'hidden'
} else {
document.body.style.overflow = ''
}
return () => {
document.body.style.overflow = ''
}
}, [isOpen])
useEffect(() => {
const handleEscape = (e: KeyboardEvent): void => {
if (e.key === 'Escape' && isOpen) {
setIsOpen(false)
}
}
document.addEventListener('keydown', handleEscape)
return () => document.removeEventListener('keydown', handleEscape)
}, [isOpen])
return (
<>
<MobileMenuButton
isOpen={isOpen}
onClick={() => setIsOpen(!isOpen)}
ariaLabel={ariaLabel ?? 'Toggle menu'}
/>
{isOpen && (
<MobileMenuDrawer
onClose={() => setIsOpen(false)}
ariaLabel={ariaLabel ?? 'Mobile menu'}
>
{children}
</MobileMenuDrawer>
)}
</>
)
}
function MobileMenuButton({
isOpen,
onClick,
ariaLabel,
}: {
isOpen: boolean
onClick: () => void
ariaLabel: string
}): React.ReactElement {
return (
<button
onClick={onClick}
className="md:hidden text-cyber-accent hover:text-neon-cyan transition-colors focus:outline-none focus:ring-2 focus:ring-neon-cyan rounded p-2"
aria-label={ariaLabel}
aria-expanded={isOpen}
aria-controls="mobile-menu"
>
<HamburgerIcon isOpen={isOpen} />
</button>
)
}
function MobileMenuOverlay({ onClose }: { onClose: () => void }): React.ReactElement {
return (
<div
className="fixed inset-0 bg-black bg-opacity-50 z-40 md:hidden"
onClick={onClose}
aria-hidden="true"
/>
)
}
function MobileMenuContent({
onClose,
ariaLabel,
children,
}: {
onClose: () => void
ariaLabel: string
children: ReactNode
}): React.ReactElement {
return (
<div
id="mobile-menu"
className="fixed top-0 right-0 h-full w-80 max-w-[85vw] bg-cyber-dark border-l border-neon-cyan/30 shadow-glow-cyan z-50 transform transition-transform md:hidden overflow-y-auto"
role="dialog"
aria-modal="true"
aria-label={ariaLabel}
>
<div className="p-4">
<div className="flex justify-end mb-4">
<Button
type="button"
variant="ghost"
onClick={onClose}
className="text-cyber-accent hover:text-neon-cyan text-2xl p-0"
aria-label="Close menu"
>
×
</Button>
</div>
<div className="space-y-4">{children}</div>
</div>
</div>
)
}
function MobileMenuDrawer({
onClose,
ariaLabel,
children,
}: {
onClose: () => void
ariaLabel: string
children: ReactNode
}): React.ReactElement {
return (
<>
<MobileMenuOverlay onClose={onClose} />
<MobileMenuContent onClose={onClose} ariaLabel={ariaLabel}>
{children}
</MobileMenuContent>
</>
)
}