Files
ace-step-ui/components/MobileDrawer.tsx
T

141 lines
4.4 KiB
TypeScript

import React, { useEffect, useRef, useCallback, useState } from 'react';
import { createPortal } from 'react-dom';
import { X } from 'lucide-react';
interface MobileDrawerProps {
isOpen: boolean;
onClose: () => void;
position: 'left' | 'right';
children: React.ReactNode;
title?: string;
}
export function MobileDrawer({ isOpen, onClose, position, children, title }: MobileDrawerProps): React.ReactElement | null {
const [isClosing, setIsClosing] = useState(false);
const drawerRef = useRef<HTMLDivElement>(null);
const previousActiveElement = useRef<HTMLElement | null>(null);
const handleClose = useCallback(() => {
setIsClosing(true);
setTimeout(() => {
setIsClosing(false);
onClose();
}, 300);
}, [onClose]);
useEffect(() => {
if (isOpen) {
previousActiveElement.current = document.activeElement as HTMLElement;
drawerRef.current?.focus();
} else if (previousActiveElement.current) {
previousActiveElement.current.focus();
}
}, [isOpen]);
useEffect(() => {
const handleEscape = (event: KeyboardEvent) => {
if (event.key === 'Escape' && isOpen && !isClosing) {
handleClose();
}
};
document.addEventListener('keydown', handleEscape);
return () => document.removeEventListener('keydown', handleEscape);
}, [isOpen, isClosing, handleClose]);
useEffect(() => {
if (isOpen) {
document.body.style.overflow = 'hidden';
}
return () => {
document.body.style.overflow = '';
};
}, [isOpen]);
useEffect(() => {
if (!isOpen || !drawerRef.current) return;
const drawer = drawerRef.current;
const focusableElements = drawer.querySelectorAll<HTMLElement>(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
const firstElement = focusableElements[0];
const lastElement = focusableElements[focusableElements.length - 1];
const handleTabKey = (event: KeyboardEvent) => {
if (event.key !== 'Tab') return;
if (event.shiftKey) {
if (document.activeElement === firstElement) {
event.preventDefault();
lastElement?.focus();
}
} else {
if (document.activeElement === lastElement) {
event.preventDefault();
firstElement?.focus();
}
}
};
drawer.addEventListener('keydown', handleTabKey);
return () => drawer.removeEventListener('keydown', handleTabKey);
}, [isOpen]);
const handleBackdropClick = (event: React.MouseEvent<HTMLDivElement>) => {
if (event.target === event.currentTarget && !isClosing) {
handleClose();
}
};
if (!isOpen && !isClosing) return null;
const slideAnimation = isClosing
? position === 'left' ? 'drawer-slide-out-left' : 'drawer-slide-out-right'
: position === 'left' ? 'drawer-slide-in-left' : 'drawer-slide-in-right';
const backdropAnimation = isClosing ? 'drawer-backdrop-out' : 'drawer-backdrop-in';
const positionClasses = position === 'left' ? 'left-0' : 'right-0';
const content = (
<div
className={`fixed inset-0 z-50 ${backdropAnimation}`}
onClick={handleBackdropClick}
role="presentation"
>
<div className="fixed inset-0 bg-black/70 backdrop-blur-mobile" />
<div
ref={drawerRef}
role="dialog"
aria-modal="true"
aria-labelledby={title ? 'drawer-title' : undefined}
tabIndex={-1}
className={`fixed top-0 ${positionClasses} h-full w-80 max-w-[85vw] bg-zinc-900 shadow-2xl border-white/10 ${slideAnimation} safe-area-inset-y safe-area-inset-${position} flex flex-col`}
style={{ borderWidth: position === 'left' ? '0 1px 0 0' : '0 0 0 1px' }}
>
<div className="flex items-center justify-between p-4 border-b border-white/10 shrink-0">
{title && (
<h2 id="drawer-title" className="text-lg font-semibold text-white">
{title}
</h2>
)}
{!title && <div />}
<button
onClick={handleClose}
className="p-2 hover:bg-white/10 rounded-lg transition-colors text-zinc-400 hover:text-white tap-highlight-none"
aria-label="Close drawer"
>
<X size={20} />
</button>
</div>
<div className="flex-1 overflow-y-auto overscroll-contain scroll-touch">
{children}
</div>
</div>
</div>
);
return createPortal(content, document.body);
}