a11y: convert all modals to native dialog with focus management
This commit is contained in:
58
src/App.tsx
58
src/App.tsx
@@ -7,6 +7,7 @@ import { Preview } from './components/Preview';
|
||||
import { ZoomControl } from './components/ZoomControl';
|
||||
import { useSettings } from './hooks/useSettings';
|
||||
import { useTemplates } from './hooks/useTemplates';
|
||||
import { useDialog } from './hooks/useDialog';
|
||||
// @ts-ignore
|
||||
import { parse } from 'marked';
|
||||
import { Sparkles, Loader2, FileType, Keyboard, X, RefreshCw } from 'lucide-react';
|
||||
@@ -14,7 +15,8 @@ import { Sparkles, Loader2, FileType, Keyboard, X, RefreshCw } from 'lucide-reac
|
||||
import { useKeyboardNavigation } from './hooks/useKeyboardNavigation';
|
||||
|
||||
// Keyboard shortcuts help component
|
||||
const KeyboardShortcutsHelp: React.FC<{ onClose: () => void }> = ({ onClose }) => {
|
||||
const KeyboardShortcutsHelp: React.FC<{ isOpen: boolean; onClose: () => void }> = ({ isOpen, onClose }) => {
|
||||
const { dialogRef, handleBackdropClick, close } = useDialog(isOpen, { onClose });
|
||||
const shortcuts = [
|
||||
{ key: '↑ / ↓', description: 'Navigate styles' },
|
||||
{ key: '← / →', description: 'Navigate categories (when focused)' },
|
||||
@@ -26,56 +28,56 @@ const KeyboardShortcutsHelp: React.FC<{ onClose: () => void }> = ({ onClose }) =
|
||||
{ key: 'Escape', description: 'Go back / Close' },
|
||||
];
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 bg-black/50 backdrop-blur-sm z-50 flex items-center justify-center p-4"
|
||||
onClick={onClose}
|
||||
<dialog
|
||||
ref={dialogRef}
|
||||
onClick={handleBackdropClick}
|
||||
aria-labelledby="shortcuts-title"
|
||||
className="fixed inset-0 z-50 p-4"
|
||||
>
|
||||
<motion.div
|
||||
initial={{ scale: 0.9, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
exit={{ scale: 0.9, opacity: 0 }}
|
||||
className="bg-zinc-900 border border-zinc-700 rounded-2xl p-6 max-w-md w-full shadow-2xl"
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-indigo-500/10 rounded-lg text-indigo-400">
|
||||
<div className="p-2 bg-indigo-500/10 rounded-lg text-indigo-400" aria-hidden="true">
|
||||
<Keyboard size={20} />
|
||||
</div>
|
||||
<h2 className="text-xl font-bold text-white">Keyboard Shortcuts</h2>
|
||||
<h2 id="shortcuts-title" className="text-xl font-bold text-white">Keyboard Shortcuts</h2>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1 hover:bg-zinc-800 rounded-lg text-zinc-400 hover:text-white transition-colors"
|
||||
onClick={close}
|
||||
className="p-2 min-w-[44px] min-h-[44px] flex items-center justify-center hover:bg-zinc-800 rounded-lg text-zinc-400 hover:text-white transition-colors"
|
||||
aria-label="Close shortcuts"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{shortcuts.map((shortcut, index) => (
|
||||
<motion.div
|
||||
<dl className="space-y-3">
|
||||
{shortcuts.map((shortcut) => (
|
||||
<div
|
||||
key={shortcut.key}
|
||||
initial={{ opacity: 0, x: -10 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay: index * 0.05 }}
|
||||
className="flex justify-between items-center py-2 border-b border-zinc-800 last:border-0"
|
||||
>
|
||||
<span className="text-zinc-400">{shortcut.description}</span>
|
||||
<kbd className="px-2 py-1 bg-zinc-800 rounded text-sm font-mono text-zinc-300 border border-zinc-700">
|
||||
{shortcut.key}
|
||||
</kbd>
|
||||
</motion.div>
|
||||
<dt className="text-zinc-400">{shortcut.description}</dt>
|
||||
<dd className="ml-4">
|
||||
<kbd className="px-2 py-1 bg-zinc-800 rounded text-sm font-mono text-zinc-300 border border-zinc-700">
|
||||
{shortcut.key}
|
||||
</kbd>
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</dl>
|
||||
<p className="mt-6 text-xs text-zinc-400 text-center">
|
||||
Press Escape or click outside to close
|
||||
</p>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</dialog>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -194,11 +196,7 @@ const App: React.FC = () => {
|
||||
>
|
||||
|
||||
{/* Keyboard Shortcuts Modal */}
|
||||
<AnimatePresence>
|
||||
{showShortcuts && (
|
||||
<KeyboardShortcutsHelp onClose={() => setShowShortcuts(false)} />
|
||||
)}
|
||||
</AnimatePresence>
|
||||
<KeyboardShortcutsHelp isOpen={showShortcuts} onClose={() => setShowShortcuts(false)} />
|
||||
|
||||
{/* Header - Fixed height */}
|
||||
<motion.header
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { X } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { useDialog } from '../hooks/useDialog';
|
||||
|
||||
interface ExportOptionsModalProps {
|
||||
isOpen: boolean;
|
||||
@@ -8,6 +9,7 @@ interface ExportOptionsModalProps {
|
||||
}
|
||||
|
||||
export default function ExportOptionsModal({ isOpen, onClose, onExport }: ExportOptionsModalProps) {
|
||||
const { dialogRef, handleBackdropClick, close } = useDialog(isOpen, { onClose });
|
||||
const [selectedMode, setSelectedMode] = useState<'table' | 'semantic'>('semantic');
|
||||
|
||||
if (!isOpen) return null;
|
||||
@@ -17,15 +19,23 @@ export default function ExportOptionsModal({ isOpen, onClose, onExport }: Export
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-zinc-950/80 backdrop-blur-sm">
|
||||
<div className="relative w-full max-w-2xl bg-zinc-900 rounded-2xl shadow-2xl m-4 overflow-hidden border border-zinc-700">
|
||||
<dialog
|
||||
ref={dialogRef}
|
||||
onClick={handleBackdropClick}
|
||||
aria-labelledby="export-title"
|
||||
className="fixed inset-0 z-50 p-4"
|
||||
>
|
||||
<div
|
||||
className="relative w-full max-w-2xl bg-zinc-900 rounded-2xl shadow-2xl overflow-hidden border border-zinc-700"
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-zinc-800 bg-zinc-900/50">
|
||||
<h2 className="text-xl font-semibold text-white">Export Options</h2>
|
||||
<h2 id="export-title" className="text-xl font-semibold text-white">Export Options</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1 text-zinc-400 hover:text-white transition-colors rounded-md hover:bg-zinc-800"
|
||||
aria-label="Close"
|
||||
onClick={close}
|
||||
className="p-2 min-w-[44px] min-h-[44px] flex items-center justify-center text-zinc-400 hover:text-white transition-colors rounded-md hover:bg-zinc-800"
|
||||
aria-label="Close export options"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
@@ -37,109 +47,113 @@ export default function ExportOptionsModal({ isOpen, onClose, onExport }: Export
|
||||
Choose how headers should be rendered in your Word document:
|
||||
</p>
|
||||
|
||||
{/* Option 1: High-Fidelity */}
|
||||
<label
|
||||
className={`block p-4 border-2 rounded-lg cursor-pointer transition-all ${selectedMode === 'table'
|
||||
? 'border-indigo-500 bg-indigo-500/10'
|
||||
: 'border-zinc-700 hover:border-zinc-600 bg-zinc-800/50'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start">
|
||||
<input
|
||||
type="radio"
|
||||
name="exportMode"
|
||||
value="table"
|
||||
checked={selectedMode === 'table'}
|
||||
onChange={() => setSelectedMode('table')}
|
||||
className="mt-1 mr-3 text-indigo-600 focus:ring-indigo-500 bg-zinc-700 border-zinc-600"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<div className="font-semibold text-white mb-2">High-Fidelity Layout</div>
|
||||
<div className="space-y-1 text-sm">
|
||||
<div className="flex items-start">
|
||||
<span className="text-green-500 mr-2">✓</span>
|
||||
<span className="text-zinc-300">Perfect padding and border alignment</span>
|
||||
</div>
|
||||
<div className="flex items-start">
|
||||
<span className="text-green-500 mr-2">✓</span>
|
||||
<span className="text-zinc-300">Backgrounds contained precisely</span>
|
||||
</div>
|
||||
<div className="flex items-start">
|
||||
<span className="text-red-500 mr-2">✗</span>
|
||||
<span className="text-zinc-300">No automatic Table of Contents</span>
|
||||
</div>
|
||||
<div className="flex items-start">
|
||||
<span className="text-red-500 mr-2">✗</span>
|
||||
<span className="text-zinc-300">Document outline/navigation disabled</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 text-xs text-zinc-400 italic">
|
||||
Best for: Portfolios, brochures, print-ready designs
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
<fieldset>
|
||||
<legend className="sr-only">Header rendering mode</legend>
|
||||
|
||||
{/* Option 2: Semantic */}
|
||||
<label
|
||||
className={`block p-4 border-2 rounded-lg cursor-pointer transition-all ${selectedMode === 'semantic'
|
||||
? 'border-indigo-500 bg-indigo-500/10'
|
||||
: 'border-zinc-700 hover:border-zinc-600 bg-zinc-800/50'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start">
|
||||
<input
|
||||
type="radio"
|
||||
name="exportMode"
|
||||
value="semantic"
|
||||
checked={selectedMode === 'semantic'}
|
||||
onChange={() => setSelectedMode('semantic')}
|
||||
className="mt-1 mr-3 text-indigo-600 focus:ring-indigo-500 bg-zinc-700 border-zinc-600"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<div className="font-semibold text-white mb-2">Semantic Structure</div>
|
||||
<div className="space-y-1 text-sm">
|
||||
<div className="flex items-start">
|
||||
<span className="text-green-500 mr-2">✓</span>
|
||||
<span className="text-zinc-300">Auto-generated Table of Contents</span>
|
||||
{/* Option 1: High-Fidelity */}
|
||||
<label
|
||||
className={`block p-4 border-2 rounded-lg cursor-pointer transition-all ${selectedMode === 'table'
|
||||
? 'border-indigo-500 bg-indigo-500/10'
|
||||
: 'border-zinc-700 hover:border-zinc-600 bg-zinc-800/50'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start">
|
||||
<input
|
||||
type="radio"
|
||||
name="exportMode"
|
||||
value="table"
|
||||
checked={selectedMode === 'table'}
|
||||
onChange={() => setSelectedMode('table')}
|
||||
className="mt-1 mr-3 text-indigo-600 focus:ring-indigo-500 bg-zinc-700 border-zinc-600"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<div className="font-semibold text-white mb-2">High-Fidelity Layout</div>
|
||||
<div className="space-y-1 text-sm">
|
||||
<div className="flex items-start">
|
||||
<span className="text-green-500 mr-2" aria-hidden="true">✓</span>
|
||||
<span className="text-zinc-300">Perfect padding and border alignment</span>
|
||||
</div>
|
||||
<div className="flex items-start">
|
||||
<span className="text-green-500 mr-2" aria-hidden="true">✓</span>
|
||||
<span className="text-zinc-300">Backgrounds contained precisely</span>
|
||||
</div>
|
||||
<div className="flex items-start">
|
||||
<span className="text-red-500 mr-2" aria-hidden="true">✗</span>
|
||||
<span className="text-zinc-300">No automatic Table of Contents</span>
|
||||
</div>
|
||||
<div className="flex items-start">
|
||||
<span className="text-red-500 mr-2" aria-hidden="true">✗</span>
|
||||
<span className="text-zinc-300">Document outline/navigation disabled</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start">
|
||||
<span className="text-green-500 mr-2">✓</span>
|
||||
<span className="text-zinc-300">Document navigation panel works</span>
|
||||
<div className="mt-3 text-xs text-zinc-400 italic">
|
||||
Best for: Portfolios, brochures, print-ready designs
|
||||
</div>
|
||||
<div className="flex items-start">
|
||||
<span className="text-green-500 mr-2">✓</span>
|
||||
<span className="text-zinc-300">Screen reader accessible</span>
|
||||
</div>
|
||||
<div className="flex items-start">
|
||||
<span className="text-yellow-500 mr-2">⚠</span>
|
||||
<span className="text-zinc-300">Minor padding/border alignment issues</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 text-xs text-zinc-400 italic">
|
||||
Best for: Academic papers, reports, accessible documents
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
</label>
|
||||
|
||||
{/* Option 2: Semantic */}
|
||||
<label
|
||||
className={`block p-4 mt-4 border-2 rounded-lg cursor-pointer transition-all ${selectedMode === 'semantic'
|
||||
? 'border-indigo-500 bg-indigo-500/10'
|
||||
: 'border-zinc-700 hover:border-zinc-600 bg-zinc-800/50'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start">
|
||||
<input
|
||||
type="radio"
|
||||
name="exportMode"
|
||||
value="semantic"
|
||||
checked={selectedMode === 'semantic'}
|
||||
onChange={() => setSelectedMode('semantic')}
|
||||
className="mt-1 mr-3 text-indigo-600 focus:ring-indigo-500 bg-zinc-700 border-zinc-600"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<div className="font-semibold text-white mb-2">Semantic Structure</div>
|
||||
<div className="space-y-1 text-sm">
|
||||
<div className="flex items-start">
|
||||
<span className="text-green-500 mr-2" aria-hidden="true">✓</span>
|
||||
<span className="text-zinc-300">Auto-generated Table of Contents</span>
|
||||
</div>
|
||||
<div className="flex items-start">
|
||||
<span className="text-green-500 mr-2" aria-hidden="true">✓</span>
|
||||
<span className="text-zinc-300">Document navigation panel works</span>
|
||||
</div>
|
||||
<div className="flex items-start">
|
||||
<span className="text-green-500 mr-2" aria-hidden="true">✓</span>
|
||||
<span className="text-zinc-300">Screen reader accessible</span>
|
||||
</div>
|
||||
<div className="flex items-start">
|
||||
<span className="text-yellow-500 mr-2" aria-hidden="true">⚠</span>
|
||||
<span className="text-zinc-300">Minor padding/border alignment issues</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 text-xs text-zinc-400 italic">
|
||||
Best for: Academic papers, reports, accessible documents
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
</fieldset>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-end gap-3 px-6 py-4 border-t border-zinc-800 bg-zinc-900">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-sm font-medium text-zinc-300 bg-zinc-800 border border-zinc-700 rounded-md hover:bg-zinc-700 transition-colors"
|
||||
onClick={close}
|
||||
className="px-5 py-3 text-sm font-medium text-zinc-300 bg-zinc-800 border border-zinc-700 rounded-md hover:bg-zinc-700 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleExport}
|
||||
className="px-4 py-2 text-sm font-medium text-white bg-indigo-600 rounded-md hover:bg-indigo-500 transition-colors"
|
||||
className="px-5 py-3 text-sm font-medium text-white bg-indigo-600 rounded-md hover:bg-indigo-500 transition-colors"
|
||||
>
|
||||
Export to Word
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { X } from 'lucide-react';
|
||||
import { StyleOption } from '../types';
|
||||
import { useDialog } from '../hooks/useDialog';
|
||||
|
||||
interface StylePreviewModalProps {
|
||||
isOpen: boolean;
|
||||
style: StyleOption;
|
||||
onClose: () => void;
|
||||
}
|
||||
@@ -10,42 +12,35 @@ interface StylePreviewModalProps {
|
||||
const SAMPLE_CONTENT = `
|
||||
<h1>The Art of Typography</h1>
|
||||
<p>Typography is the art and technique of arranging type to make written language legible, readable, and appealing when displayed. The arrangement of type involves selecting typefaces, point sizes, line lengths, line-spacing, and letter-spacing.</p>
|
||||
|
||||
|
||||
<h2>1. Visual Hierarchy</h2>
|
||||
<p>Visual hierarchy enables the reader to understand the importance of different sections. By using size, weight, and color, we guide the eye through the document in a logical flow.</p>
|
||||
|
||||
|
||||
<blockquote>
|
||||
"Design is not just what it looks like and feels like. Design is how it works."
|
||||
</blockquote>
|
||||
|
||||
|
||||
<h2>2. Key Elements</h2>
|
||||
<ul>
|
||||
<li><strong>Typeface:</strong> The design of the letters.</li>
|
||||
<li><strong>Contrast:</strong> Distinguishing elements effectively.</li>
|
||||
<li><strong>Consistency:</strong> Maintaining a coherent structure.</li>
|
||||
</ul>
|
||||
|
||||
|
||||
<p>Good typography is invisible. It should not distract the reader but rather enhance the reading experience, ensuring the message is delivered clearly and effectively.</p>
|
||||
`;
|
||||
|
||||
export const StylePreviewModal: React.FC<StylePreviewModalProps> = ({ style, onClose }) => {
|
||||
export const StylePreviewModal: React.FC<StylePreviewModalProps> = ({ isOpen, style, onClose }) => {
|
||||
const { dialogRef, handleBackdropClick, close } = useDialog(isOpen, { onClose });
|
||||
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handleEscape = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
};
|
||||
window.addEventListener('keydown', handleEscape);
|
||||
return () => window.removeEventListener('keydown', handleEscape);
|
||||
}, [onClose]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!iframeRef.current) return;
|
||||
if (!isOpen || !iframeRef.current) return;
|
||||
const doc = iframeRef.current.contentDocument;
|
||||
if (doc) {
|
||||
const html = `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
@@ -74,29 +69,31 @@ export const StylePreviewModal: React.FC<StylePreviewModalProps> = ({ style, onC
|
||||
doc.write(html);
|
||||
doc.close();
|
||||
}
|
||||
}, [style]);
|
||||
}, [isOpen, style]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-zinc-950/80 backdrop-blur-sm animate-in fade-in duration-200"
|
||||
onClick={(e) => {
|
||||
// Close if clicking the background overlay directly
|
||||
if (e.target === e.currentTarget) {
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
<dialog
|
||||
ref={dialogRef}
|
||||
onClick={handleBackdropClick}
|
||||
aria-labelledby="preview-title"
|
||||
className="fixed inset-0 z-[100] p-4"
|
||||
>
|
||||
<div className="relative w-full max-w-3xl bg-zinc-900 rounded-2xl border border-zinc-700 shadow-2xl overflow-hidden flex flex-col h-[85vh]">
|
||||
|
||||
<div
|
||||
className="relative w-full max-w-3xl bg-zinc-900 rounded-2xl border border-zinc-700 shadow-2xl overflow-hidden flex flex-col h-[85vh]"
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-4 border-b border-zinc-800 bg-zinc-900/50">
|
||||
<div>
|
||||
<h3 className="text-xl font-bold text-white">{style.name}</h3>
|
||||
<h3 id="preview-title" className="text-xl font-bold text-white">{style.name}</h3>
|
||||
<p className="text-sm text-zinc-400">{style.description}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 text-zinc-400 hover:text-white hover:bg-zinc-800 rounded-full transition-colors"
|
||||
<button
|
||||
onClick={close}
|
||||
className="p-2 min-w-[44px] min-h-[44px] flex items-center justify-center text-zinc-400 hover:text-white hover:bg-zinc-800 rounded-full transition-colors"
|
||||
aria-label="Close preview"
|
||||
>
|
||||
<X size={24} />
|
||||
</button>
|
||||
@@ -105,24 +102,24 @@ export const StylePreviewModal: React.FC<StylePreviewModalProps> = ({ style, onC
|
||||
{/* Content */}
|
||||
<div className="flex-grow bg-zinc-800 p-1 overflow-hidden relative">
|
||||
<div className="w-full h-full bg-white rounded-lg overflow-hidden shadow-inner">
|
||||
<iframe
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
title="Style Preview"
|
||||
className="w-full h-full border-0"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
{/* Footer */}
|
||||
<div className="p-4 border-t border-zinc-800 bg-zinc-900 flex justify-end">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 bg-indigo-600 hover:bg-indigo-500 text-white font-medium rounded-lg transition-colors"
|
||||
onClick={close}
|
||||
className="px-5 py-3 bg-indigo-600 hover:bg-indigo-500 text-white font-medium rounded-lg transition-colors"
|
||||
>
|
||||
Close Preview
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
58
src/hooks/useDialog.ts
Normal file
58
src/hooks/useDialog.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { useRef, useEffect, useCallback } from 'react';
|
||||
|
||||
interface UseDialogOptions {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function useDialog(isOpen: boolean, options: UseDialogOptions) {
|
||||
const dialogRef = useRef<HTMLDialogElement>(null);
|
||||
const triggerRef = useRef<Element | null>(null);
|
||||
|
||||
const close = useCallback(() => {
|
||||
dialogRef.current?.close();
|
||||
options.onClose();
|
||||
}, [options.onClose]);
|
||||
|
||||
useEffect(() => {
|
||||
const dialog = dialogRef.current;
|
||||
if (!dialog) return;
|
||||
|
||||
if (isOpen) {
|
||||
triggerRef.current = document.activeElement;
|
||||
if (!dialog.open) {
|
||||
dialog.showModal();
|
||||
}
|
||||
} else {
|
||||
if (dialog.open) {
|
||||
dialog.close();
|
||||
}
|
||||
// Restore focus to trigger
|
||||
if (triggerRef.current instanceof HTMLElement) {
|
||||
triggerRef.current.focus();
|
||||
}
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
// Handle native cancel event (Escape key)
|
||||
useEffect(() => {
|
||||
const dialog = dialogRef.current;
|
||||
if (!dialog) return;
|
||||
|
||||
const handleCancel = (e: Event) => {
|
||||
e.preventDefault();
|
||||
close();
|
||||
};
|
||||
|
||||
dialog.addEventListener('cancel', handleCancel);
|
||||
return () => dialog.removeEventListener('cancel', handleCancel);
|
||||
}, [close]);
|
||||
|
||||
// Handle backdrop click
|
||||
const handleBackdropClick = useCallback((e: React.MouseEvent<HTMLDialogElement>) => {
|
||||
if (e.target === e.currentTarget) {
|
||||
close();
|
||||
}
|
||||
}, [close]);
|
||||
|
||||
return { dialogRef, handleBackdropClick, close };
|
||||
}
|
||||
@@ -138,3 +138,24 @@ body ::-webkit-scrollbar-thumb:hover,
|
||||
white-space: nowrap;
|
||||
border-width: 0;
|
||||
}
|
||||
|
||||
/* Native dialog styles */
|
||||
dialog {
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: 0;
|
||||
max-width: 100vw;
|
||||
max-height: 100vh;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
dialog::backdrop {
|
||||
background: rgba(9, 9, 11, 0.8);
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
dialog[open] {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user