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 { ZoomControl } from './components/ZoomControl';
|
||||||
import { useSettings } from './hooks/useSettings';
|
import { useSettings } from './hooks/useSettings';
|
||||||
import { useTemplates } from './hooks/useTemplates';
|
import { useTemplates } from './hooks/useTemplates';
|
||||||
|
import { useDialog } from './hooks/useDialog';
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
import { parse } from 'marked';
|
import { parse } from 'marked';
|
||||||
import { Sparkles, Loader2, FileType, Keyboard, X, RefreshCw } from 'lucide-react';
|
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';
|
import { useKeyboardNavigation } from './hooks/useKeyboardNavigation';
|
||||||
|
|
||||||
// Keyboard shortcuts help component
|
// 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 = [
|
const shortcuts = [
|
||||||
{ key: '↑ / ↓', description: 'Navigate styles' },
|
{ key: '↑ / ↓', description: 'Navigate styles' },
|
||||||
{ key: '← / →', description: 'Navigate categories (when focused)' },
|
{ key: '← / →', description: 'Navigate categories (when focused)' },
|
||||||
@@ -26,56 +28,56 @@ const KeyboardShortcutsHelp: React.FC<{ onClose: () => void }> = ({ onClose }) =
|
|||||||
{ key: 'Escape', description: 'Go back / Close' },
|
{ key: 'Escape', description: 'Go back / Close' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<dialog
|
||||||
initial={{ opacity: 0 }}
|
ref={dialogRef}
|
||||||
animate={{ opacity: 1 }}
|
onClick={handleBackdropClick}
|
||||||
exit={{ opacity: 0 }}
|
aria-labelledby="shortcuts-title"
|
||||||
className="fixed inset-0 bg-black/50 backdrop-blur-sm z-50 flex items-center justify-center p-4"
|
className="fixed inset-0 z-50 p-4"
|
||||||
onClick={onClose}
|
|
||||||
>
|
>
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ scale: 0.9, opacity: 0 }}
|
initial={{ scale: 0.9, opacity: 0 }}
|
||||||
animate={{ scale: 1, opacity: 1 }}
|
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"
|
className="bg-zinc-900 border border-zinc-700 rounded-2xl p-6 max-w-md w-full shadow-2xl"
|
||||||
onClick={e => e.stopPropagation()}
|
onClick={e => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<div className="flex justify-between items-center mb-6">
|
<div className="flex justify-between items-center mb-6">
|
||||||
<div className="flex items-center gap-3">
|
<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} />
|
<Keyboard size={20} />
|
||||||
</div>
|
</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>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
onClick={close}
|
||||||
className="p-1 hover:bg-zinc-800 rounded-lg text-zinc-400 hover:text-white transition-colors"
|
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} />
|
<X size={20} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-3">
|
<dl className="space-y-3">
|
||||||
{shortcuts.map((shortcut, index) => (
|
{shortcuts.map((shortcut) => (
|
||||||
<motion.div
|
<div
|
||||||
key={shortcut.key}
|
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"
|
className="flex justify-between items-center py-2 border-b border-zinc-800 last:border-0"
|
||||||
>
|
>
|
||||||
<span className="text-zinc-400">{shortcut.description}</span>
|
<dt className="text-zinc-400">{shortcut.description}</dt>
|
||||||
<kbd className="px-2 py-1 bg-zinc-800 rounded text-sm font-mono text-zinc-300 border border-zinc-700">
|
<dd className="ml-4">
|
||||||
{shortcut.key}
|
<kbd className="px-2 py-1 bg-zinc-800 rounded text-sm font-mono text-zinc-300 border border-zinc-700">
|
||||||
</kbd>
|
{shortcut.key}
|
||||||
</motion.div>
|
</kbd>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</dl>
|
||||||
<p className="mt-6 text-xs text-zinc-400 text-center">
|
<p className="mt-6 text-xs text-zinc-400 text-center">
|
||||||
Press Escape or click outside to close
|
Press Escape or click outside to close
|
||||||
</p>
|
</p>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</motion.div>
|
</dialog>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -194,11 +196,7 @@ const App: React.FC = () => {
|
|||||||
>
|
>
|
||||||
|
|
||||||
{/* Keyboard Shortcuts Modal */}
|
{/* Keyboard Shortcuts Modal */}
|
||||||
<AnimatePresence>
|
<KeyboardShortcutsHelp isOpen={showShortcuts} onClose={() => setShowShortcuts(false)} />
|
||||||
{showShortcuts && (
|
|
||||||
<KeyboardShortcutsHelp onClose={() => setShowShortcuts(false)} />
|
|
||||||
)}
|
|
||||||
</AnimatePresence>
|
|
||||||
|
|
||||||
{/* Header - Fixed height */}
|
{/* Header - Fixed height */}
|
||||||
<motion.header
|
<motion.header
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { X } from 'lucide-react';
|
import { X } from 'lucide-react';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
import { useDialog } from '../hooks/useDialog';
|
||||||
|
|
||||||
interface ExportOptionsModalProps {
|
interface ExportOptionsModalProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
@@ -8,6 +9,7 @@ interface ExportOptionsModalProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function ExportOptionsModal({ isOpen, onClose, onExport }: ExportOptionsModalProps) {
|
export default function ExportOptionsModal({ isOpen, onClose, onExport }: ExportOptionsModalProps) {
|
||||||
|
const { dialogRef, handleBackdropClick, close } = useDialog(isOpen, { onClose });
|
||||||
const [selectedMode, setSelectedMode] = useState<'table' | 'semantic'>('semantic');
|
const [selectedMode, setSelectedMode] = useState<'table' | 'semantic'>('semantic');
|
||||||
|
|
||||||
if (!isOpen) return null;
|
if (!isOpen) return null;
|
||||||
@@ -17,15 +19,23 @@ export default function ExportOptionsModal({ isOpen, onClose, onExport }: Export
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-zinc-950/80 backdrop-blur-sm">
|
<dialog
|
||||||
<div className="relative w-full max-w-2xl bg-zinc-900 rounded-2xl shadow-2xl m-4 overflow-hidden border border-zinc-700">
|
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 */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between px-6 py-4 border-b border-zinc-800 bg-zinc-900/50">
|
<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
|
<button
|
||||||
onClick={onClose}
|
onClick={close}
|
||||||
className="p-1 text-zinc-400 hover:text-white transition-colors rounded-md hover:bg-zinc-800"
|
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"
|
aria-label="Close export options"
|
||||||
>
|
>
|
||||||
<X size={20} />
|
<X size={20} />
|
||||||
</button>
|
</button>
|
||||||
@@ -37,109 +47,113 @@ export default function ExportOptionsModal({ isOpen, onClose, onExport }: Export
|
|||||||
Choose how headers should be rendered in your Word document:
|
Choose how headers should be rendered in your Word document:
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* Option 1: High-Fidelity */}
|
<fieldset>
|
||||||
<label
|
<legend className="sr-only">Header rendering mode</legend>
|
||||||
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>
|
|
||||||
|
|
||||||
{/* Option 2: Semantic */}
|
{/* Option 1: High-Fidelity */}
|
||||||
<label
|
<label
|
||||||
className={`block p-4 border-2 rounded-lg cursor-pointer transition-all ${selectedMode === 'semantic'
|
className={`block p-4 border-2 rounded-lg cursor-pointer transition-all ${selectedMode === 'table'
|
||||||
? 'border-indigo-500 bg-indigo-500/10'
|
? 'border-indigo-500 bg-indigo-500/10'
|
||||||
: 'border-zinc-700 hover:border-zinc-600 bg-zinc-800/50'
|
: 'border-zinc-700 hover:border-zinc-600 bg-zinc-800/50'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="flex items-start">
|
<div className="flex items-start">
|
||||||
<input
|
<input
|
||||||
type="radio"
|
type="radio"
|
||||||
name="exportMode"
|
name="exportMode"
|
||||||
value="semantic"
|
value="table"
|
||||||
checked={selectedMode === 'semantic'}
|
checked={selectedMode === 'table'}
|
||||||
onChange={() => setSelectedMode('semantic')}
|
onChange={() => setSelectedMode('table')}
|
||||||
className="mt-1 mr-3 text-indigo-600 focus:ring-indigo-500 bg-zinc-700 border-zinc-600"
|
className="mt-1 mr-3 text-indigo-600 focus:ring-indigo-500 bg-zinc-700 border-zinc-600"
|
||||||
/>
|
/>
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<div className="font-semibold text-white mb-2">Semantic Structure</div>
|
<div className="font-semibold text-white mb-2">High-Fidelity Layout</div>
|
||||||
<div className="space-y-1 text-sm">
|
<div className="space-y-1 text-sm">
|
||||||
<div className="flex items-start">
|
<div className="flex items-start">
|
||||||
<span className="text-green-500 mr-2">✓</span>
|
<span className="text-green-500 mr-2" aria-hidden="true">✓</span>
|
||||||
<span className="text-zinc-300">Auto-generated Table of Contents</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>
|
||||||
<div className="flex items-start">
|
<div className="mt-3 text-xs text-zinc-400 italic">
|
||||||
<span className="text-green-500 mr-2">✓</span>
|
Best for: Portfolios, brochures, print-ready designs
|
||||||
<span className="text-zinc-300">Document navigation panel works</span>
|
|
||||||
</div>
|
</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>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
<div className="flex items-center justify-end gap-3 px-6 py-4 border-t border-zinc-800 bg-zinc-900">
|
<div className="flex items-center justify-end gap-3 px-6 py-4 border-t border-zinc-800 bg-zinc-900">
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
onClick={close}
|
||||||
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"
|
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
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={handleExport}
|
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
|
Export to Word
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</dialog>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import React, { useEffect, useRef } from 'react';
|
import React, { useEffect, useRef } from 'react';
|
||||||
import { X } from 'lucide-react';
|
import { X } from 'lucide-react';
|
||||||
import { StyleOption } from '../types';
|
import { StyleOption } from '../types';
|
||||||
|
import { useDialog } from '../hooks/useDialog';
|
||||||
|
|
||||||
interface StylePreviewModalProps {
|
interface StylePreviewModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
style: StyleOption;
|
style: StyleOption;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
@@ -28,24 +30,17 @@ const SAMPLE_CONTENT = `
|
|||||||
<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>
|
<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);
|
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleEscape = (e: KeyboardEvent) => {
|
if (!isOpen || !iframeRef.current) return;
|
||||||
if (e.key === 'Escape') onClose();
|
|
||||||
};
|
|
||||||
window.addEventListener('keydown', handleEscape);
|
|
||||||
return () => window.removeEventListener('keydown', handleEscape);
|
|
||||||
}, [onClose]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!iframeRef.current) return;
|
|
||||||
const doc = iframeRef.current.contentDocument;
|
const doc = iframeRef.current.contentDocument;
|
||||||
if (doc) {
|
if (doc) {
|
||||||
const html = `
|
const html = `
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html>
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
<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.write(html);
|
||||||
doc.close();
|
doc.close();
|
||||||
}
|
}
|
||||||
}, [style]);
|
}, [isOpen, style]);
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<dialog
|
||||||
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"
|
ref={dialogRef}
|
||||||
onClick={(e) => {
|
onClick={handleBackdropClick}
|
||||||
// Close if clicking the background overlay directly
|
aria-labelledby="preview-title"
|
||||||
if (e.target === e.currentTarget) {
|
className="fixed inset-0 z-[100] p-4"
|
||||||
onClose();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<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 */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between p-4 border-b border-zinc-800 bg-zinc-900/50">
|
<div className="flex items-center justify-between p-4 border-b border-zinc-800 bg-zinc-900/50">
|
||||||
<div>
|
<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>
|
<p className="text-sm text-zinc-400">{style.description}</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
onClick={close}
|
||||||
className="p-2 text-zinc-400 hover:text-white hover:bg-zinc-800 rounded-full transition-colors"
|
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} />
|
<X size={24} />
|
||||||
</button>
|
</button>
|
||||||
@@ -116,13 +113,13 @@ export const StylePreviewModal: React.FC<StylePreviewModalProps> = ({ style, onC
|
|||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
<div className="p-4 border-t border-zinc-800 bg-zinc-900 flex justify-end">
|
<div className="p-4 border-t border-zinc-800 bg-zinc-900 flex justify-end">
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
onClick={close}
|
||||||
className="px-4 py-2 bg-indigo-600 hover:bg-indigo-500 text-white font-medium rounded-lg transition-colors"
|
className="px-5 py-3 bg-indigo-600 hover:bg-indigo-500 text-white font-medium rounded-lg transition-colors"
|
||||||
>
|
>
|
||||||
Close Preview
|
Close Preview
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</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;
|
white-space: nowrap;
|
||||||
border-width: 0;
|
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