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 { 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 { AuthProvider } from './context/AuthContext'
|
||||||
import { AlertProvider } from './context/AlertContext'
|
import { AlertProvider } from './context/AlertContext'
|
||||||
import ErrorBoundary from './components/ErrorBoundary'
|
import ErrorBoundary from './components/ErrorBoundary'
|
||||||
@@ -45,6 +45,7 @@ const Invoices = lazy(() => import('./pages/Invoices'))
|
|||||||
const InvoiceCreate = lazy(() => import('./pages/InvoiceCreate'))
|
const InvoiceCreate = lazy(() => import('./pages/InvoiceCreate'))
|
||||||
const InvoiceDetail = lazy(() => import('./pages/InvoiceDetail'))
|
const InvoiceDetail = lazy(() => import('./pages/InvoiceDetail'))
|
||||||
const Settings = lazy(() => import('./pages/Settings'))
|
const Settings = lazy(() => import('./pages/Settings'))
|
||||||
|
const NotFound = lazy(() => import('./pages/NotFound'))
|
||||||
|
|
||||||
export default function AdminApp() {
|
export default function AdminApp() {
|
||||||
return (
|
return (
|
||||||
@@ -86,7 +87,7 @@ export default function AdminApp() {
|
|||||||
<Route path="invoices/:id" element={<InvoiceDetail />} />
|
<Route path="invoices/:id" element={<InvoiceDetail />} />
|
||||||
<Route path="settings" element={<Settings />} />
|
<Route path="settings" element={<Settings />} />
|
||||||
</Route>
|
</Route>
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<NotFound />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</ErrorBoundary>
|
</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 */
|
/* Keyboard shortcut badge */
|
||||||
.admin-kbd {
|
.admin-kbd {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useEffect } from 'react'
|
import { useEffect } from 'react'
|
||||||
import { motion, AnimatePresence } from 'framer-motion'
|
import { motion, AnimatePresence } from 'framer-motion'
|
||||||
import useModalLock from '../hooks/useModalLock'
|
import useModalLock from '../hooks/useModalLock'
|
||||||
|
import useFocusTrap from '../hooks/useFocusTrap'
|
||||||
|
|
||||||
export default function ConfirmModal({
|
export default function ConfirmModal({
|
||||||
isOpen,
|
isOpen,
|
||||||
@@ -14,6 +15,7 @@ export default function ConfirmModal({
|
|||||||
loading = false
|
loading = false
|
||||||
}) {
|
}) {
|
||||||
useModalLock(isOpen)
|
useModalLock(isOpen)
|
||||||
|
const trapRef = useFocusTrap(isOpen)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isOpen) return
|
if (!isOpen) return
|
||||||
@@ -70,6 +72,7 @@ export default function ConfirmModal({
|
|||||||
>
|
>
|
||||||
<div className="admin-modal-backdrop" onClick={onClose} />
|
<div className="admin-modal-backdrop" onClick={onClose} />
|
||||||
<motion.div
|
<motion.div
|
||||||
|
ref={trapRef}
|
||||||
className="admin-modal admin-confirm-modal"
|
className="admin-modal admin-confirm-modal"
|
||||||
role="alertdialog"
|
role="alertdialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { Component } from 'react'
|
import { Component } from 'react'
|
||||||
|
|
||||||
export default class ErrorBoundary extends Component {
|
export default class ErrorBoundary extends Component {
|
||||||
state = { hasError: false }
|
state = { hasError: false, error: null }
|
||||||
|
|
||||||
static getDerivedStateFromError() {
|
static getDerivedStateFromError(error) {
|
||||||
return { hasError: true }
|
return { hasError: true, error }
|
||||||
}
|
}
|
||||||
|
|
||||||
componentDidCatch(error, info) {
|
componentDidCatch(error, info) {
|
||||||
@@ -16,30 +16,33 @@ export default class ErrorBoundary extends Component {
|
|||||||
render() {
|
render() {
|
||||||
if (this.state.hasError) {
|
if (this.state.hasError) {
|
||||||
return (
|
return (
|
||||||
<div style={{
|
<div className="admin-empty-state" style={{ minHeight: '60vh', justifyContent: 'center' }}>
|
||||||
minHeight: '50vh',
|
<div className="admin-empty-icon" style={{ width: 80, height: 80, marginBottom: '1.5rem' }}>
|
||||||
display: 'flex',
|
<svg width="36" height="36" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||||
flexDirection: 'column',
|
<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" />
|
||||||
alignItems: 'center',
|
<line x1="12" y1="9" x2="12" y2="13" />
|
||||||
justifyContent: 'center',
|
<line x1="12" y1="17" x2="12.01" y2="17" />
|
||||||
gap: '1rem',
|
</svg>
|
||||||
color: 'var(--text-secondary, #888)'
|
</div>
|
||||||
}}>
|
<p style={{ marginBottom: '0.5rem' }}>Něco se pokazilo při načítání stránky.</p>
|
||||||
<p>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
|
<button
|
||||||
onClick={() => window.location.reload()}
|
onClick={() => window.location.reload()}
|
||||||
style={{
|
className="admin-btn admin-btn-primary"
|
||||||
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
|
Načíst znovu
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
return this.props.children
|
return this.props.children
|
||||||
|
|||||||
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