168 lines
3.8 KiB
TypeScript
168 lines
3.8 KiB
TypeScript
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>
|
||
</>
|
||
)
|
||
}
|