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:
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,29 +16,32 @@ 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>
|
||||
<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'
|
||||
}}
|
||||
>
|
||||
Načíst znovu
|
||||
</button>
|
||||
<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()}
|
||||
className="admin-btn admin-btn-primary"
|
||||
>
|
||||
Načíst znovu
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
53
src/admin/hooks/useFocusTrap.js
Normal file
53
src/admin/hooks/useFocusTrap.js
Normal 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
|
||||
}
|
||||
30
src/admin/pages/NotFound.jsx
Normal file
30
src/admin/pages/NotFound.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user