Harden: Fix critical accessibility issues

- Add aria-labels to icon-only buttons (refresh, language toggle)
- Add aria-hidden to decorative SVGs
- Add aria-label to data source select
- Replace outline:none with visible focus rings on all inputs/selects
- Add <main> landmark for screen reader navigation
- Add prefers-reduced-motion: disable all animations for vestibular safety
- Move error message inline style to CSS class
- Add aria-label to both nav landmarks

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
fahed
2026-03-25 18:23:04 +03:00
parent c8567da75f
commit 25066af67c
2 changed files with 60 additions and 25 deletions

View File

@@ -84,6 +84,17 @@ html[dir="rtl"] {
background: var(--text-secondary); background: var(--text-secondary);
} }
.error-container button:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.error-message {
max-width: 400px;
text-align: center;
color: var(--text-muted);
}
/* Empty State */ /* Empty State */
.empty-state { .empty-state {
display: flex; display: flex;
@@ -216,7 +227,8 @@ html[dir="rtl"] {
} }
.data-source-select:focus { .data-source-select:focus {
outline: none; outline: 2px solid var(--accent);
outline-offset: 1px;
background-color: rgba(59, 130, 246, 0.12); background-color: rgba(59, 130, 246, 0.12);
} }
@@ -673,7 +685,8 @@ table tbody tr:hover {
.control-group select:focus, .control-group select:focus,
.control-group input[type="date"]:focus { .control-group input[type="date"]:focus {
outline: none; outline: 2px solid var(--accent);
outline-offset: -1px;
border-color: var(--accent); border-color: var(--accent);
} }
@@ -1679,7 +1692,8 @@ table tbody tr:hover {
.editor-section input:focus, .editor-section input:focus,
.editor-section select:focus { .editor-section select:focus {
outline: none; outline: 2px solid var(--accent);
outline-offset: -1px;
border-color: var(--accent); border-color: var(--accent);
} }
@@ -1988,6 +2002,21 @@ html[dir="rtl"] .chart-export-btn.visible {
text-align: left !important; text-align: left !important;
} }
/* ========================================
Reduced Motion
======================================== */
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
.carousel-track {
transition: none;
}
}
/* ======================================== /* ========================================
Loading Skeleton Loading Skeleton
======================================== */ ======================================== */

View File

@@ -95,7 +95,7 @@ function App() {
return ( return (
<div className="error-container" dir={dir}> <div className="error-container" dir={dir}>
<h2>{t('app.error')}</h2> <h2>{t('app.error')}</h2>
<p style={{maxWidth: '400px', textAlign: 'center', color: '#64748b'}}> <p className="error-message">
{t(`errors.${error.type}`)} {t(`errors.${error.type}`)}
</p> </p>
<button onClick={() => loadData()}>{t('app.retry')}</button> <button onClick={() => loadData()}>{t('app.retry')}</button>
@@ -106,10 +106,10 @@ function App() {
return ( return (
<Router> <Router>
<div className="app" dir={dir}> <div className="app" dir={dir}>
<nav className="nav-bar"> <nav className="nav-bar" aria-label={t('nav.dashboard')}>
<div className="nav-content"> <div className="nav-content">
<div className="nav-brand"> <div className="nav-brand">
<svg className="nav-brand-icon" width="20" height="20" viewBox="0 0 24 24" fill="currentColor"> <svg className="nav-brand-icon" width="20" height="20" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<rect x="3" y="3" width="7" height="7" rx="1"/> <rect x="3" y="3" width="7" height="7" rx="1"/>
<rect x="14" y="3" width="7" height="4" rx="1"/> <rect x="14" y="3" width="7" height="4" rx="1"/>
<rect x="3" y="14" width="7" height="7" rx="1"/> <rect x="3" y="14" width="7" height="7" rx="1"/>
@@ -117,10 +117,11 @@ function App() {
</svg> </svg>
<span className="nav-brand-text"> <span className="nav-brand-text">
HiHala Data HiHala Data
<select <select
className="data-source-select" className="data-source-select"
value={dataSource} value={dataSource}
onChange={e => setDataSource(e.target.value)} onChange={e => setDataSource(e.target.value)}
aria-label={t('dataSources.museums')}
> >
{dataSources.map(src => ( {dataSources.map(src => (
<option key={src.id} value={src.id} disabled={!src.enabled}> <option key={src.id} value={src.id} disabled={!src.enabled}>
@@ -132,7 +133,7 @@ function App() {
</div> </div>
<div className="nav-links"> <div className="nav-links">
<NavLink to="/"> <NavLink to="/">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<rect x="3" y="3" width="7" height="9" rx="1"/> <rect x="3" y="3" width="7" height="9" rx="1"/>
<rect x="14" y="3" width="7" height="5" rx="1"/> <rect x="14" y="3" width="7" height="5" rx="1"/>
<rect x="14" y="12" width="7" height="9" rx="1"/> <rect x="14" y="12" width="7" height="9" rx="1"/>
@@ -141,7 +142,7 @@ function App() {
{t('nav.dashboard')} {t('nav.dashboard')}
</NavLink> </NavLink>
<NavLink to="/comparison"> <NavLink to="/comparison">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<line x1="18" y1="20" x2="18" y2="10"/> <line x1="18" y1="20" x2="18" y2="10"/>
<line x1="12" y1="20" x2="12" y2="4"/> <line x1="12" y1="20" x2="12" y2="4"/>
<line x1="6" y1="20" x2="6" y2="14"/> <line x1="6" y1="20" x2="6" y2="14"/>
@@ -164,24 +165,26 @@ function App() {
{t('app.offline') || 'Offline'} {t('app.offline') || 'Offline'}
</span> </span>
)} )}
<button <button
className={`nav-refresh-btn ${refreshing ? 'refreshing' : ''}`} className={`nav-refresh-btn ${refreshing ? 'refreshing' : ''}`}
onClick={handleRefresh} onClick={handleRefresh}
disabled={refreshing} disabled={refreshing}
aria-label={t('app.refresh') || 'Refresh data'}
title={t('app.refresh') || 'Refresh data'} title={t('app.refresh') || 'Refresh data'}
> >
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<polyline points="23 4 23 10 17 10"/> <polyline points="23 4 23 10 17 10"/>
<polyline points="1 20 1 14 7 14"/> <polyline points="1 20 1 14 7 14"/>
<path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/> <path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/>
</svg> </svg>
</button> </button>
<button <button
className="nav-lang-toggle" className="nav-lang-toggle"
onClick={switchLanguage} onClick={switchLanguage}
aria-label={t('language.switch')}
title="Switch language" title="Switch language"
> >
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<circle cx="12" cy="12" r="10"/> <circle cx="12" cy="12" r="10"/>
<line x1="2" y1="12" x2="22" y2="12"/> <line x1="2" y1="12" x2="22" y2="12"/>
<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/> <path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/>
@@ -192,18 +195,20 @@ function App() {
</div> </div>
</nav> </nav>
<Suspense fallback={<LoadingSkeleton />}> <main>
<Routes> <Suspense fallback={<LoadingSkeleton />}>
<Route path="/" element={<Dashboard data={data} showDataLabels={showDataLabels} setShowDataLabels={setShowDataLabels} includeVAT={includeVAT} setIncludeVAT={setIncludeVAT} />} /> <Routes>
<Route path="/comparison" element={<Comparison data={data} showDataLabels={showDataLabels} setShowDataLabels={setShowDataLabels} includeVAT={includeVAT} setIncludeVAT={setIncludeVAT} />} /> <Route path="/" element={<Dashboard data={data} showDataLabels={showDataLabels} setShowDataLabels={setShowDataLabels} includeVAT={includeVAT} setIncludeVAT={setIncludeVAT} />} />
<Route path="/slides" element={<Slides data={data} />} /> <Route path="/comparison" element={<Comparison data={data} showDataLabels={showDataLabels} setShowDataLabels={setShowDataLabels} includeVAT={includeVAT} setIncludeVAT={setIncludeVAT} />} />
</Routes> <Route path="/slides" element={<Slides data={data} />} />
</Suspense> </Routes>
</Suspense>
</main>
{/* Mobile Bottom Navigation */} {/* Mobile Bottom Navigation */}
<nav className="mobile-nav"> <nav className="mobile-nav" aria-label="Mobile navigation">
<NavLink to="/" className="mobile-nav-item"> <NavLink to="/" className="mobile-nav-item">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<rect x="3" y="3" width="7" height="9" rx="1"/> <rect x="3" y="3" width="7" height="9" rx="1"/>
<rect x="14" y="3" width="7" height="5" rx="1"/> <rect x="14" y="3" width="7" height="5" rx="1"/>
<rect x="14" y="12" width="7" height="9" rx="1"/> <rect x="14" y="12" width="7" height="9" rx="1"/>
@@ -212,18 +217,19 @@ function App() {
<span>{t('nav.dashboard')}</span> <span>{t('nav.dashboard')}</span>
</NavLink> </NavLink>
<NavLink to="/comparison" className="mobile-nav-item"> <NavLink to="/comparison" className="mobile-nav-item">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<line x1="18" y1="20" x2="18" y2="10"/> <line x1="18" y1="20" x2="18" y2="10"/>
<line x1="12" y1="20" x2="12" y2="4"/> <line x1="12" y1="20" x2="12" y2="4"/>
<line x1="6" y1="20" x2="6" y2="14"/> <line x1="6" y1="20" x2="6" y2="14"/>
</svg> </svg>
<span>{t('nav.compare')}</span> <span>{t('nav.compare')}</span>
</NavLink> </NavLink>
<button <button
className="mobile-nav-item" className="mobile-nav-item"
onClick={switchLanguage} onClick={switchLanguage}
aria-label={t('language.switch')}
> >
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<circle cx="12" cy="12" r="10"/> <circle cx="12" cy="12" r="10"/>
<line x1="2" y1="12" x2="22" y2="12"/> <line x1="2" y1="12" x2="22" y2="12"/>
<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/> <path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/>