Files
ace-step-ui/components/MobileDrawer.tsx
T
fspecii 325046eaf2 Apply i18n to all components, add collapsible sidebar, playback speed control, inline title editing, and ConfirmDialog for deletions
Phase 4: i18n all 15 components from PR #19. Sidebar gains collapse/expand
toggle. Player gets playback speed selector (0.25x-2.0x) fixing bug B1
(hardcoded Chinese '正常'). SongList adds inline title editing and model
version badge. Delete actions now use ConfirmDialog instead of
window.confirm. Volume persisted to localStorage. Training nav item
deferred to Phase 7.
2026-02-08 18:35:09 +02:00

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-[60] ${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);
}