a11y: add ARIA live regions for status announcements and errors
This commit is contained in:
23
src/App.tsx
23
src/App.tsx
@@ -90,6 +90,7 @@ const App: React.FC = () => {
|
|||||||
const [generatedHtml, setGeneratedHtml] = useState<string>('');
|
const [generatedHtml, setGeneratedHtml] = useState<string>('');
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [showShortcuts, setShowShortcuts] = useState(false);
|
const [showShortcuts, setShowShortcuts] = useState(false);
|
||||||
|
const [statusMessage, setStatusMessage] = useState('');
|
||||||
|
|
||||||
const { uiZoom, setUiZoom, isLoaded } = useSettings();
|
const { uiZoom, setUiZoom, isLoaded } = useSettings();
|
||||||
const { templates, categories, isLoading: templatesLoading, error: templatesError, refresh, openFolder } = useTemplates();
|
const { templates, categories, isLoading: templatesLoading, error: templatesError, refresh, openFolder } = useTemplates();
|
||||||
@@ -103,6 +104,17 @@ const App: React.FC = () => {
|
|||||||
// The Tauri file drop is disabled to allow the webview to handle drag events
|
// The Tauri file drop is disabled to allow the webview to handle drag events
|
||||||
// This preserves the drag hover animations in the FileUpload component
|
// This preserves the drag hover animations in the FileUpload component
|
||||||
|
|
||||||
|
// Announce state changes to screen readers
|
||||||
|
useEffect(() => {
|
||||||
|
const messages: Record<string, string> = {
|
||||||
|
[AppState.UPLOAD]: 'Upload screen',
|
||||||
|
[AppState.CONFIG]: 'Style configuration',
|
||||||
|
[AppState.GENERATING]: 'Generating document',
|
||||||
|
[AppState.PREVIEW]: 'Document preview',
|
||||||
|
};
|
||||||
|
setStatusMessage(messages[appState] || '');
|
||||||
|
}, [appState]);
|
||||||
|
|
||||||
// Global keydown listener for shortcuts help
|
// Global keydown listener for shortcuts help
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
@@ -168,7 +180,11 @@ const App: React.FC = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (!isLoaded) {
|
if (!isLoaded) {
|
||||||
return null;
|
return (
|
||||||
|
<div className="h-screen w-screen flex items-center justify-center bg-zinc-950" role="status" aria-live="polite">
|
||||||
|
<span className="sr-only">Loading TypoGenie</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (appState === AppState.PREVIEW) {
|
if (appState === AppState.PREVIEW) {
|
||||||
@@ -177,6 +193,7 @@ const App: React.FC = () => {
|
|||||||
className="h-screen w-screen overflow-hidden"
|
className="h-screen w-screen overflow-hidden"
|
||||||
style={{ fontSize: `${uiZoom}%` }}
|
style={{ fontSize: `${uiZoom}%` }}
|
||||||
>
|
>
|
||||||
|
<div role="status" aria-live="polite" className="sr-only">{statusMessage}</div>
|
||||||
<div className="h-full w-full flex flex-col">
|
<div className="h-full w-full flex flex-col">
|
||||||
<Preview
|
<Preview
|
||||||
htmlContent={generatedHtml}
|
htmlContent={generatedHtml}
|
||||||
@@ -198,6 +215,7 @@ const App: React.FC = () => {
|
|||||||
className="h-screen bg-zinc-950 text-zinc-100 flex flex-col font-sans selection:bg-indigo-500/30 overflow-hidden"
|
className="h-screen bg-zinc-950 text-zinc-100 flex flex-col font-sans selection:bg-indigo-500/30 overflow-hidden"
|
||||||
style={{ fontSize: `${uiZoom}%` }}
|
style={{ fontSize: `${uiZoom}%` }}
|
||||||
>
|
>
|
||||||
|
<div role="status" aria-live="polite" className="sr-only">{statusMessage}</div>
|
||||||
|
|
||||||
{/* Keyboard Shortcuts Modal */}
|
{/* Keyboard Shortcuts Modal */}
|
||||||
<KeyboardShortcutsHelp isOpen={showShortcuts} onClose={() => setShowShortcuts(false)} />
|
<KeyboardShortcutsHelp isOpen={showShortcuts} onClose={() => setShowShortcuts(false)} />
|
||||||
@@ -380,7 +398,6 @@ const App: React.FC = () => {
|
|||||||
exit={{ opacity: 0, y: -10, scale: 0.95 }}
|
exit={{ opacity: 0, y: -10, scale: 0.95 }}
|
||||||
className="mt-4 p-4 bg-red-900/20 border border-red-800 rounded-xl text-center text-red-300"
|
className="mt-4 p-4 bg-red-900/20 border border-red-800 rounded-xl text-center text-red-300"
|
||||||
role="alert"
|
role="alert"
|
||||||
aria-live="polite"
|
|
||||||
>
|
>
|
||||||
{error}
|
{error}
|
||||||
</motion.div>
|
</motion.div>
|
||||||
@@ -392,6 +409,8 @@ const App: React.FC = () => {
|
|||||||
{appState === AppState.GENERATING && (
|
{appState === AppState.GENERATING && (
|
||||||
<motion.div
|
<motion.div
|
||||||
key="generating"
|
key="generating"
|
||||||
|
role="status"
|
||||||
|
aria-busy="true"
|
||||||
initial={{ opacity: 0, scale: 0.9 }}
|
initial={{ opacity: 0, scale: 0.9 }}
|
||||||
animate={{ opacity: 1, scale: 1 }}
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
exit={{ opacity: 0, scale: 1.1 }}
|
exit={{ opacity: 0, scale: 1.1 }}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useCallback, useState, useRef, useEffect } from 'react';
|
import React, { useCallback, useState, useRef } from 'react';
|
||||||
import { motion } from 'motion/react';
|
import { motion } from 'motion/react';
|
||||||
import { Upload, FileText, AlertCircle } from 'lucide-react';
|
import { Upload, FileText, AlertCircle } from 'lucide-react';
|
||||||
import { useKeyboardNavigation } from '../hooks/useKeyboardNavigation';
|
import { useKeyboardNavigation } from '../hooks/useKeyboardNavigation';
|
||||||
@@ -87,14 +87,6 @@ export const FileUpload: React.FC<FileUploadProps> = ({ onFileLoaded }) => {
|
|||||||
},
|
},
|
||||||
}, [isFocused]);
|
}, [isFocused]);
|
||||||
|
|
||||||
// Clear error after 5 seconds
|
|
||||||
useEffect(() => {
|
|
||||||
if (error) {
|
|
||||||
const timer = setTimeout(() => setError(null), 5000);
|
|
||||||
return () => clearTimeout(timer);
|
|
||||||
}
|
|
||||||
}, [error]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<motion.div
|
||||||
className="w-full max-w-xl mx-auto"
|
className="w-full max-w-xl mx-auto"
|
||||||
@@ -109,6 +101,7 @@ export const FileUpload: React.FC<FileUploadProps> = ({ onFileLoaded }) => {
|
|||||||
role="button"
|
role="button"
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
aria-label="Drop zone. Click or press Enter to select a file."
|
aria-label="Drop zone. Click or press Enter to select a file."
|
||||||
|
aria-describedby={error ? "upload-error" : undefined}
|
||||||
className={`relative group flex flex-col items-center justify-center w-full h-64 border-2 border-dashed rounded-2xl cursor-pointer overflow-hidden outline-none transition-all
|
className={`relative group flex flex-col items-center justify-center w-full h-64 border-2 border-dashed rounded-2xl cursor-pointer overflow-hidden outline-none transition-all
|
||||||
${dragActive ? 'border-indigo-500 bg-indigo-500/10' : 'border-zinc-700 bg-zinc-900/50'}
|
${dragActive ? 'border-indigo-500 bg-indigo-500/10' : 'border-zinc-700 bg-zinc-900/50'}
|
||||||
${isFocused ? 'ring-2 ring-indigo-500 ring-offset-2 ring-offset-zinc-950 border-indigo-400' : ''}
|
${isFocused ? 'ring-2 ring-indigo-500 ring-offset-2 ring-offset-zinc-950 border-indigo-400' : ''}
|
||||||
@@ -208,9 +201,9 @@ export const FileUpload: React.FC<FileUploadProps> = ({ onFileLoaded }) => {
|
|||||||
initial={{ opacity: 0, y: -10, height: 0 }}
|
initial={{ opacity: 0, y: -10, height: 0 }}
|
||||||
animate={{ opacity: 1, y: 0, height: 'auto' }}
|
animate={{ opacity: 1, y: 0, height: 'auto' }}
|
||||||
exit={{ opacity: 0, y: -10, height: 0 }}
|
exit={{ opacity: 0, y: -10, height: 0 }}
|
||||||
|
id="upload-error"
|
||||||
className="mt-4 p-4 bg-red-900/20 border border-red-800 rounded-lg flex items-center gap-3 text-red-200"
|
className="mt-4 p-4 bg-red-900/20 border border-red-800 rounded-lg flex items-center gap-3 text-red-200"
|
||||||
role="alert"
|
role="alert"
|
||||||
aria-live="polite"
|
|
||||||
>
|
>
|
||||||
<motion.div
|
<motion.div
|
||||||
animate={{ rotate: [0, 10, -10, 0] }}
|
animate={{ rotate: [0, 10, -10, 0] }}
|
||||||
|
|||||||
@@ -141,6 +141,7 @@ export const Preview: React.FC<PreviewProps> = ({
|
|||||||
const [successMsg, setSuccessMsg] = useState(false);
|
const [successMsg, setSuccessMsg] = useState(false);
|
||||||
const [showExportModal, setShowExportModal] = useState(false);
|
const [showExportModal, setShowExportModal] = useState(false);
|
||||||
const [focusedElement, setFocusedElement] = useState<'back' | 'fonts' | 'save'>('save');
|
const [focusedElement, setFocusedElement] = useState<'back' | 'fonts' | 'save'>('save');
|
||||||
|
const [exportError, setExportError] = useState<string | null>(null);
|
||||||
|
|
||||||
// Get current style from templates
|
// Get current style from templates
|
||||||
const style = templates.find(s => s.id === selectedStyleId) || templates[0] || null;
|
const style = templates.find(s => s.id === selectedStyleId) || templates[0] || null;
|
||||||
@@ -242,7 +243,7 @@ export const Preview: React.FC<PreviewProps> = ({
|
|||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Docx Gen Error", e);
|
console.error("Docx Gen Error", e);
|
||||||
alert("Failed to generate DOCX: " + e);
|
setExportError("Failed to generate DOCX: " + e);
|
||||||
} finally {
|
} finally {
|
||||||
setIsExporting(false);
|
setIsExporting(false);
|
||||||
}
|
}
|
||||||
@@ -375,7 +376,7 @@ export const Preview: React.FC<PreviewProps> = ({
|
|||||||
}, [htmlContent, paperSize, selectedStyleId, templates, style]);
|
}, [htmlContent, paperSize, selectedStyleId, templates, style]);
|
||||||
|
|
||||||
if (!style) {
|
if (!style) {
|
||||||
return <div className="h-screen flex items-center justify-center text-white">Loading...</div>;
|
return <div className="h-screen flex items-center justify-center text-white" role="status">Loading...</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -399,6 +400,7 @@ export const Preview: React.FC<PreviewProps> = ({
|
|||||||
onClick={handleSave}
|
onClick={handleSave}
|
||||||
onFocus={() => setFocusedElement('save')}
|
onFocus={() => setFocusedElement('save')}
|
||||||
disabled={isExporting}
|
disabled={isExporting}
|
||||||
|
aria-live="polite"
|
||||||
whileHover={{ scale: 1.05, boxShadow: '0 0 20px rgba(59, 130, 246, 0.4)' }}
|
whileHover={{ scale: 1.05, boxShadow: '0 0 20px rgba(59, 130, 246, 0.4)' }}
|
||||||
whileTap={{ scale: 0.95 }}
|
whileTap={{ scale: 0.95 }}
|
||||||
className={`flex items-center gap-2 px-5 py-2.5 rounded-lg font-semibold transition-colors shadow-lg ${successMsg ? 'bg-emerald-600 text-white' : 'bg-blue-600 text-white hover:bg-blue-500'} ${isExporting ? 'opacity-50 cursor-wait' : ''}`}
|
className={`flex items-center gap-2 px-5 py-2.5 rounded-lg font-semibold transition-colors shadow-lg ${successMsg ? 'bg-emerald-600 text-white' : 'bg-blue-600 text-white hover:bg-blue-500'} ${isExporting ? 'opacity-50 cursor-wait' : ''}`}
|
||||||
@@ -431,6 +433,12 @@ export const Preview: React.FC<PreviewProps> = ({
|
|||||||
onClose={() => setShowExportModal(false)}
|
onClose={() => setShowExportModal(false)}
|
||||||
onExport={handleExportConfirm}
|
onExport={handleExportConfirm}
|
||||||
/>
|
/>
|
||||||
|
{exportError && (
|
||||||
|
<div role="alert" className="fixed bottom-4 right-4 z-50 p-4 bg-red-900/90 border border-red-800 rounded-xl text-red-200 max-w-md">
|
||||||
|
{exportError}
|
||||||
|
<button onClick={() => setExportError(null)} className="ml-3 text-red-400 hover:text-white" aria-label="Dismiss error">✕</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</motion.div>
|
</motion.div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -250,7 +250,7 @@ export const StyleSelector: React.FC<StyleSelectorProps> = ({
|
|||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="w-full h-full flex items-center justify-center">
|
<div className="w-full h-full flex items-center justify-center" role="status">
|
||||||
<motion.div className="text-center" initial={{ opacity: 0 }} animate={{ opacity: 1 }}>
|
<motion.div className="text-center" initial={{ opacity: 0 }} animate={{ opacity: 1 }}>
|
||||||
<motion.div className="w-12 h-12 border-4 border-indigo-500/30 border-t-indigo-500 rounded-full mx-auto mb-4" animate={{ rotate: 360 }} transition={{ duration: 1, repeat: Infinity, ease: "linear" }} />
|
<motion.div className="w-12 h-12 border-4 border-indigo-500/30 border-t-indigo-500 rounded-full mx-auto mb-4" animate={{ rotate: 360 }} transition={{ duration: 1, repeat: Infinity, ease: "linear" }} />
|
||||||
<p className="text-zinc-400">Loading templates...</p>
|
<p className="text-zinc-400">Loading templates...</p>
|
||||||
@@ -261,10 +261,10 @@ export const StyleSelector: React.FC<StyleSelectorProps> = ({
|
|||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
<div className="w-full h-full flex flex-col items-center justify-center p-8 text-center">
|
<div className="w-full h-full flex flex-col items-center justify-center p-8 text-center" role="alert">
|
||||||
<motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} className="max-w-md">
|
<motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} className="max-w-md">
|
||||||
<div className="w-16 h-16 bg-red-500/10 rounded-full flex items-center justify-center mx-auto mb-4">
|
<div className="w-16 h-16 bg-red-500/10 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||||
<span className="text-2xl">⚠️</span>
|
<span className="text-2xl" aria-hidden="true">⚠️</span>
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-xl font-semibold text-white mb-2">Template Error</h3>
|
<h3 className="text-xl font-semibold text-white mb-2">Template Error</h3>
|
||||||
<p className="text-zinc-400 mb-6">{error}</p>
|
<p className="text-zinc-400 mb-6">{error}</p>
|
||||||
@@ -372,6 +372,9 @@ export const StyleSelector: React.FC<StyleSelectorProps> = ({
|
|||||||
className="w-full bg-zinc-900 border border-zinc-700 rounded-xl py-2 pl-9 pr-4 text-sm text-zinc-200 placeholder:text-zinc-500 focus:outline-none focus:border-indigo-500/50 focus:ring-1 focus:ring-indigo-500/50 transition-all"
|
className="w-full bg-zinc-900 border border-zinc-700 rounded-xl py-2 pl-9 pr-4 text-sm text-zinc-200 placeholder:text-zinc-500 focus:outline-none focus:border-indigo-500/50 focus:ring-1 focus:ring-indigo-500/50 transition-all"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div role="status" aria-live="polite" className="sr-only">
|
||||||
|
{searchQuery.trim() ? `${filteredStyles.length} template${filteredStyles.length !== 1 ? 's' : ''} found` : ''}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user