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:
35
src/App.css
35
src/App.css
@@ -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
|
||||||
======================================== */
|
======================================== */
|
||||||
|
|||||||
50
src/App.tsx
50
src/App.tsx
@@ -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"/>
|
||||||
|
|||||||
Reference in New Issue
Block a user