feat: P5 UX polish - 404 stranka, ErrorBoundary, focus trap, ARIA

- NotFound.jsx: 404 stranka misto redirectu na / (lazy-loaded)
- ErrorBoundary: CSS tridy misto inline stylu, DEV error stack, odkaz na Dashboard
- useFocusTrap hook: Tab/Shift+Tab cycling, auto-focus, restore focus on close
- ConfirmModal: focus trap integrovan
- admin-error-stack CSS pro DEV chybovy vypis

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-12 18:46:22 +01:00
parent 758be819c3
commit ec44895f3d
6 changed files with 137 additions and 28 deletions

View File

@@ -1,5 +1,5 @@
import { lazy, Suspense } from 'react'
import { Routes, Route, Navigate } from 'react-router-dom'
import { Routes, Route } from 'react-router-dom'
import { AuthProvider } from './context/AuthContext'
import { AlertProvider } from './context/AlertContext'
import ErrorBoundary from './components/ErrorBoundary'
@@ -45,6 +45,7 @@ const Invoices = lazy(() => import('./pages/Invoices'))
const InvoiceCreate = lazy(() => import('./pages/InvoiceCreate'))
const InvoiceDetail = lazy(() => import('./pages/InvoiceDetail'))
const Settings = lazy(() => import('./pages/Settings'))
const NotFound = lazy(() => import('./pages/NotFound'))
export default function AdminApp() {
return (
@@ -86,7 +87,7 @@ export default function AdminApp() {
<Route path="invoices/:id" element={<InvoiceDetail />} />
<Route path="settings" element={<Settings />} />
</Route>
<Route path="*" element={<Navigate to="/" replace />} />
<Route path="*" element={<NotFound />} />
</Routes>
</Suspense>
</ErrorBoundary>

View File

@@ -2419,6 +2419,25 @@ img {
}
}
/* Error stack (DEV only) */
.admin-error-stack {
max-width: 600px;
max-height: 200px;
overflow: auto;
padding: 0.75rem 1rem;
margin: 0;
border-radius: var(--border-radius-sm);
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
color: var(--danger-color);
font-family: var(--font-mono);
font-size: 11px;
line-height: 1.5;
text-align: left;
white-space: pre-wrap;
word-break: break-word;
}
/* Keyboard shortcut badge */
.admin-kbd {
display: inline-block;

View File

@@ -1,6 +1,7 @@
import { useEffect } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import useModalLock from '../hooks/useModalLock'
import useFocusTrap from '../hooks/useFocusTrap'
export default function ConfirmModal({
isOpen,
@@ -14,6 +15,7 @@ export default function ConfirmModal({
loading = false
}) {
useModalLock(isOpen)
const trapRef = useFocusTrap(isOpen)
useEffect(() => {
if (!isOpen) return
@@ -70,6 +72,7 @@ export default function ConfirmModal({
>
<div className="admin-modal-backdrop" onClick={onClose} />
<motion.div
ref={trapRef}
className="admin-modal admin-confirm-modal"
role="alertdialog"
aria-modal="true"

View File

@@ -1,10 +1,10 @@
import { Component } from 'react'
export default class ErrorBoundary extends Component {
state = { hasError: false }
state = { hasError: false, error: null }
static getDerivedStateFromError() {
return { hasError: true }
static getDerivedStateFromError(error) {
return { hasError: true, error }
}
componentDidCatch(error, info) {
@@ -16,30 +16,33 @@ export default class ErrorBoundary extends Component {
render() {
if (this.state.hasError) {
return (
<div style={{
minHeight: '50vh',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: '1rem',
color: 'var(--text-secondary, #888)'
}}>
<p>Něco se pokazilo při načítání stránky.</p>
<div className="admin-empty-state" style={{ minHeight: '60vh', justifyContent: 'center' }}>
<div className="admin-empty-icon" style={{ width: 80, height: 80, marginBottom: '1.5rem' }}>
<svg width="36" height="36" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" />
<line x1="12" y1="9" x2="12" y2="13" />
<line x1="12" y1="17" x2="12.01" y2="17" />
</svg>
</div>
<p style={{ marginBottom: '0.5rem' }}>Něco se pokazilo při načítání stránky.</p>
{import.meta.env.DEV && this.state.error && (
<pre className="admin-error-stack">
{this.state.error.message}
{this.state.error.stack && `\n${this.state.error.stack}`}
</pre>
)}
<div style={{ display: 'flex', gap: '0.5rem', marginTop: '0.5rem' }}>
<a href="/" className="admin-btn admin-btn-secondary">
Zpět na Dashboard
</a>
<button
onClick={() => window.location.reload()}
style={{
padding: '0.5rem 1.5rem',
borderRadius: '8px',
border: '1px solid var(--border-color, #333)',
background: 'var(--bg-secondary, #1a1a1a)',
color: 'var(--text-primary, #fff)',
cursor: 'pointer'
}}
className="admin-btn admin-btn-primary"
>
Načíst znovu
</button>
</div>
</div>
)
}
return this.props.children

View File

@@ -0,0 +1,53 @@
import { useEffect, useRef } from 'react'
const FOCUSABLE = 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
/**
* Focus trap pro modaly - drzi focus uvnitr prvku.
* Vraci ref ktery se pripoji na modal kontejner.
*/
export default function useFocusTrap(isOpen) {
const ref = useRef(null)
useEffect(() => {
if (!isOpen || !ref.current) { return }
const container = ref.current
const previouslyFocused = document.activeElement
// Focus prvni focusable prvek v modalu
const focusable = container.querySelectorAll(FOCUSABLE)
if (focusable.length > 0) {
focusable[0].focus()
}
const handleKeyDown = (e) => {
if (e.key !== 'Tab') { return }
const nodes = container.querySelectorAll(FOCUSABLE)
if (nodes.length === 0) { return }
const first = nodes[0]
const last = nodes[nodes.length - 1]
if (e.shiftKey && document.activeElement === first) {
e.preventDefault()
last.focus()
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault()
first.focus()
}
}
document.addEventListener('keydown', handleKeyDown)
return () => {
document.removeEventListener('keydown', handleKeyDown)
if (previouslyFocused && typeof previouslyFocused.focus === 'function') {
previouslyFocused.focus()
}
}
}, [isOpen])
return ref
}

View File

@@ -0,0 +1,30 @@
import { Link } from 'react-router-dom'
import { motion } from 'framer-motion'
export default function NotFound() {
return (
<motion.div
className="admin-empty-state"
style={{ minHeight: '60vh', justifyContent: 'center' }}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4 }}
>
<div className="admin-empty-icon" style={{ width: 80, height: 80, marginBottom: '1.5rem' }}>
<svg width="36" height="36" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="10" />
<path d="M16 16s-1.5-2-4-2-4 2-4 2" />
<line x1="9" y1="9" x2="9.01" y2="9" />
<line x1="15" y1="9" x2="15.01" y2="9" />
</svg>
</div>
<h2 style={{ fontSize: '1.5rem', fontWeight: 600, marginBottom: '0.5rem', color: 'var(--text-primary)' }}>
404
</h2>
<p>Stránka nebyla nalezena.</p>
<Link to="/" className="admin-btn admin-btn-primary" style={{ marginTop: '0.5rem' }}>
Zpět na Dashboard
</Link>
</motion.div>
)
}