fix: accessibility, theming, and focus-visibility improvements

Addresses critical and high-severity findings from UI audit:

- C1: Define missing CSS tokens (--hover, --bg-primary/secondary/tertiary)
  fixing broken hover states and Slides Builder backgrounds
- C2: Chart colors now read CSS custom properties at render-time via
  getChartTheme(), adapting tooltip, ticks, and grid to dark mode
- C3: Multi-select ARIA fixed — label elements now carry role="option"
  and aria-selected for valid listbox semantics
- H1/M1: Remove unused --gold and duplicate --primary tokens;
  replace all var(--primary) with var(--accent) throughout App.css
- H3/H4: Focus-visible outlines added to all custom interactive elements
  (chips, controls, year buttons, hero button, multi-select trigger)
- H5: access-badge--full hardcoded colors replaced with design tokens
- H7: aria-pressed added to all chart toggle buttons
- L1: Hardcoded #fff/white replaced with var(--text-inverse)
- M4: index.html now preloads DM Serif Display, Outfit, and IBM Plex
  Sans Arabic — all fonts actually used in the app

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
fahed
2026-04-26 15:46:54 +03:00
parent d3f9a6cd43
commit 9138ac1098
5 changed files with 136 additions and 79 deletions
+1 -1
View File
@@ -8,7 +8,7 @@
<meta name="description" content="HiHala Data Dashboard — Event analytics, visitor tracking, and revenue insights" /> <meta name="description" content="HiHala Data Dashboard — Event analytics, visitor tracking, and revenue insights" />
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600&family=IBM+Plex+Sans+Arabic:wght@400;500;600;700&display=swap" rel="stylesheet"> <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=DM+Serif+Display:ital@0;1&family=Outfit:wght@300;400;500;600;700&family=IBM+Plex+Sans+Arabic:wght@300;400;500;600;700&display=swap">
<title>HiHala Data</title> <title>HiHala Data</title>
</head> </head>
<body> <body>
+35 -18
View File
@@ -23,13 +23,11 @@
--text-secondary: #334155; --text-secondary: #334155;
--text-muted: #64748b; --text-muted: #64748b;
--accent: #2563eb; --accent: #2563eb;
--primary: #2563eb;
--accent-light: #dbeafe; --accent-light: #dbeafe;
--success: #059669; --success: #059669;
--success-light: #d1fae5; --success-light: #d1fae5;
--danger: #dc2626; --danger: #dc2626;
--danger-light: #fee2e2; --danger-light: #fee2e2;
--gold: #b8860b;
--brand-icon: #3b82f6; --brand-icon: #3b82f6;
--brand-text: #1e3a5f; --brand-text: #1e3a5f;
--text-inverse: #ffffff; --text-inverse: #ffffff;
@@ -44,6 +42,10 @@
--shadow-sm: 0 1px 2px rgba(0,0,0,0.05); --shadow-sm: 0 1px 2px rgba(0,0,0,0.05);
--shadow: 0 4px 12px rgba(0,0,0,0.08); --shadow: 0 4px 12px rgba(0,0,0,0.08);
--radius: 12px; --radius: 12px;
--hover: var(--muted-light);
--bg-primary: var(--surface);
--bg-secondary: var(--bg);
--bg-tertiary: var(--border);
} }
/* Dark mode */ /* Dark mode */
@@ -56,7 +58,6 @@
--text-secondary: #cbd5e1; --text-secondary: #cbd5e1;
--text-muted: #94a3b8; --text-muted: #94a3b8;
--accent: #3b82f6; --accent: #3b82f6;
--primary: #3b82f6;
--accent-light: #1e3a5f; --accent-light: #1e3a5f;
--success: #34d399; --success: #34d399;
--success-light: #064e3b; --success-light: #064e3b;
@@ -88,7 +89,6 @@
--text-secondary: #cbd5e1; --text-secondary: #cbd5e1;
--text-muted: #94a3b8; --text-muted: #94a3b8;
--accent: #3b82f6; --accent: #3b82f6;
--primary: #3b82f6;
--accent-light: #1e3a5f; --accent-light: #1e3a5f;
--success: #34d399; --success: #34d399;
--success-light: #064e3b; --success-light: #064e3b;
@@ -226,7 +226,7 @@ html[dir="rtl"] {
.empty-state-action { .empty-state-action {
padding: 10px 20px; padding: 10px 20px;
background: var(--primary); background: var(--accent);
color: var(--text-inverse); color: var(--text-inverse);
border: none; border: none;
border-radius: 8px; border-radius: 8px;
@@ -1089,7 +1089,7 @@ table tbody tr:hover {
.drp-custom-field input[type="date"]:focus { .drp-custom-field input[type="date"]:focus {
outline: none; outline: none;
border-color: var(--accent); border-color: var(--accent);
box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.12); box-shadow: 0 0 0 2px var(--accent);
} }
.drp-custom-sep { .drp-custom-sep {
@@ -1127,7 +1127,7 @@ table tbody tr:hover {
.drp-apply { .drp-apply {
background: var(--accent); background: var(--accent);
border: 1px solid transparent; border: 1px solid transparent;
color: #fff; color: var(--text-inverse);
} }
.drp-apply:hover { .drp-apply:hover {
@@ -1301,7 +1301,7 @@ table tbody tr:hover {
font-size: 1rem; font-size: 1rem;
font-weight: 600; font-weight: 600;
background: var(--accent); background: var(--accent);
color: white; color: var(--text-inverse);
cursor: pointer; cursor: pointer;
transition: opacity 150ms ease; transition: opacity 150ms ease;
} }
@@ -1398,8 +1398,8 @@ table tbody tr:hover {
} }
.access-badge--full { .access-badge--full {
background: #d1fae5; background: var(--success-light);
color: #065f46; color: var(--success);
} }
.btn-small { .btn-small {
@@ -1418,7 +1418,7 @@ table tbody tr:hover {
.btn-small.btn-primary { .btn-small.btn-primary {
background: var(--accent); background: var(--accent);
color: white; color: var(--text-inverse);
border-color: var(--accent); border-color: var(--accent);
} }
@@ -1758,7 +1758,7 @@ tr.editing td {
.chart-metric-selector button.active { .chart-metric-selector button.active {
background: var(--surface); background: var(--surface);
color: var(--primary); color: var(--accent);
box-shadow: 0 1px 3px rgba(0,0,0,0.1); box-shadow: 0 1px 3px rgba(0,0,0,0.1);
font-weight: 600; font-weight: 600;
} }
@@ -1792,7 +1792,7 @@ tr.editing td {
} }
.carousel:focus-visible { .carousel:focus-visible {
outline: 2px solid var(--primary); outline: 2px solid var(--accent);
outline-offset: 4px; outline-offset: 4px;
border-radius: 8px; border-radius: 8px;
} }
@@ -1877,7 +1877,7 @@ tr.editing td {
} }
.carousel-dot.active { .carousel-dot.active {
background: var(--primary); background: var(--accent);
width: 24px; width: 24px;
} }
@@ -1903,14 +1903,14 @@ tr.editing td {
} }
.carousel-dots.labeled .carousel-dot:hover { .carousel-dots.labeled .carousel-dot:hover {
border-color: var(--primary); border-color: var(--accent);
background: var(--bg); background: var(--bg);
} }
.carousel-dots.labeled .carousel-dot.active { .carousel-dots.labeled .carousel-dot.active {
width: auto; width: auto;
background: var(--primary); background: var(--accent);
border-color: var(--primary); border-color: var(--accent);
} }
.carousel-dots.labeled .carousel-dot.active .dot-label { .carousel-dots.labeled .carousel-dot.active .dot-label {
@@ -2082,7 +2082,7 @@ tr.editing td {
.mobile-nav-item:hover, .mobile-nav-item:hover,
.mobile-nav-item.active { .mobile-nav-item.active {
color: var(--primary); color: var(--accent);
background: rgba(37, 99, 235, 0.08); background: rgba(37, 99, 235, 0.08);
} }
@@ -2833,6 +2833,23 @@ html[dir="rtl"] .exportable-chart-wrapper .chart-export-btn.visible {
text-align: left !important; text-align: left !important;
} }
/* ========================================
Focus Visible — Keyboard accessibility
======================================== */
.drp-chip:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.drp-year-btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.drp-cancel:focus-visible,
.drp-apply:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.nav-refresh-btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.nav-lang-toggle:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.multi-select-trigger:focus-visible { outline: 2px solid var(--accent); outline-offset: -1px; }
.toggle-switch button:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.chart-metric-selector button:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.nav-link:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.controls-reset:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.alt-filter-reset:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
/* ======================================== /* ========================================
Reduced Motion Reduced Motion
======================================== */ ======================================== */
+13 -4
View File
@@ -303,7 +303,7 @@ function AltMultiSelect({ value, options, onChange, allLabel, countLabel, clearL
<div className="altms-dropdown" role="listbox" aria-multiselectable="true"> <div className="altms-dropdown" role="listbox" aria-multiselectable="true">
<div className="altms-list"> <div className="altms-list">
{options.map(opt => ( {options.map(opt => (
<label key={opt} className={`altms-option${value.includes(opt)?' altms-option--checked':''}`}> <label key={opt} role="option" aria-selected={value.includes(opt)} className={`altms-option${value.includes(opt)?' altms-option--checked':''}`}>
<input type="checkbox" className="altms-check" checked={value.includes(opt)} onChange={() => toggle(opt)} aria-label={opt} /> <input type="checkbox" className="altms-check" checked={value.includes(opt)} onChange={() => toggle(opt)} aria-label={opt} />
<span className="altms-check-box">{value.includes(opt) && <svg width="10" height="8" viewBox="0 0 10 8" fill="none"><path d="M1 4L3.5 6.5L9 1" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/></svg>}</span> <span className="altms-check-box">{value.includes(opt) && <svg width="10" height="8" viewBox="0 0 10 8" fill="none"><path d="M1 4L3.5 6.5L9 1" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/></svg>}</span>
<span className="altms-opt-label">{opt}</span> <span className="altms-opt-label">{opt}</span>
@@ -611,6 +611,15 @@ export default function PeriodSelectorDemo({ data, seasons, includeVAT, allowedM
.alt-page-title { font-size:1.75rem; } .alt-page-title { font-size:1.75rem; }
.alt-chart-header { flex-direction:column; } .alt-chart-header { flex-direction:column; }
} }
/* ── focus-visible ── */
.alt-chip:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.alt-ctrl:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.alt-yr-btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.dalt-hero-btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.altms-trigger:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.altms-clear:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.alt-cancel:focus-visible, .alt-apply:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
`}</style> `}</style>
<Link to={L.backTo} className="alt-back"> <Link to={L.backTo} className="alt-back">
@@ -659,9 +668,9 @@ export default function PeriodSelectorDemo({ data, seasons, includeVAT, allowedM
<div className="alt-chart-header"> <div className="alt-chart-header">
<h3 className="alt-chart-title">{L.trendTitle}</h3> <h3 className="alt-chart-title">{L.trendTitle}</h3>
<div className="alt-chart-controls"> <div className="alt-chart-controls">
{metricOpts.map(o => <button key={o.value} type="button" className={`alt-ctrl${metric===o.value?' alt-ctrl-on':''}`} onClick={() => setMetric(o.value)}>{o.label}</button>)} {metricOpts.map(o => <button key={o.value} type="button" aria-pressed={metric===o.value} className={`alt-ctrl${metric===o.value?' alt-ctrl-on':''}`} onClick={() => setMetric(o.value)}>{o.label}</button>)}
<div className="alt-ctrl-sep" /> <div className="alt-ctrl-sep" />
{granOpts.map(o => <button key={o.value} type="button" className={`alt-ctrl${gran===o.value?' alt-ctrl-on':''}`} onClick={() => setGran(o.value)}>{o.label}</button>)} {granOpts.map(o => <button key={o.value} type="button" aria-pressed={gran===o.value} className={`alt-ctrl${gran===o.value?' alt-ctrl-on':''}`} onClick={() => setGran(o.value)}>{o.label}</button>)}
</div> </div>
</div> </div>
<div className="alt-chart-wrap"><Line data={trendData} options={chartOpts} /></div> <div className="alt-chart-wrap"><Line data={trendData} options={chartOpts} /></div>
@@ -670,7 +679,7 @@ export default function PeriodSelectorDemo({ data, seasons, includeVAT, allowedM
<div className="alt-chart-header"> <div className="alt-chart-header">
<h3 className="alt-chart-title">{L.museumTitle}</h3> <h3 className="alt-chart-title">{L.museumTitle}</h3>
<div className="alt-chart-controls"> <div className="alt-chart-controls">
{metricOpts.map(o => <button key={o.value} type="button" className={`alt-ctrl${metric===o.value?' alt-ctrl-on':''}`} onClick={() => setMetric(o.value)}>{o.label}</button>)} {metricOpts.map(o => <button key={o.value} type="button" aria-pressed={metric===o.value} className={`alt-ctrl${metric===o.value?' alt-ctrl-on':''}`} onClick={() => setMetric(o.value)}>{o.label}</button>)}
</div> </div>
</div> </div>
<div className="alt-chart-wrap"><Bar data={museumData} options={chartOpts} /></div> <div className="alt-chart-wrap"><Bar data={museumData} options={chartOpts} /></div>
+27 -18
View File
@@ -305,7 +305,7 @@ function AltMultiSelect({ value, options, onChange, allLabel, countLabel, clearL
<div className="altms-dropdown" role="listbox" aria-multiselectable="true"> <div className="altms-dropdown" role="listbox" aria-multiselectable="true">
<div className="altms-list"> <div className="altms-list">
{options.map(opt => ( {options.map(opt => (
<label key={opt} className={`altms-option${value.includes(opt)?' altms-option--checked':''}`}> <label key={opt} role="option" aria-selected={value.includes(opt)} className={`altms-option${value.includes(opt)?' altms-option--checked':''}`}>
<input type="checkbox" className="altms-check" checked={value.includes(opt)} onChange={() => toggle(opt)} aria-label={opt} /> <input type="checkbox" className="altms-check" checked={value.includes(opt)} onChange={() => toggle(opt)} aria-label={opt} />
<span className="altms-check-box">{value.includes(opt) && <svg width="10" height="8" viewBox="0 0 10 8" fill="none"><path d="M1 4L3.5 6.5L9 1" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/></svg>}</span> <span className="altms-check-box">{value.includes(opt) && <svg width="10" height="8" viewBox="0 0 10 8" fill="none"><path d="M1 4L3.5 6.5L9 1" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/></svg>}</span>
<span className="altms-opt-label">{opt}</span> <span className="altms-opt-label">{opt}</span>
@@ -617,6 +617,15 @@ export default function DashboardDemo({ data, seasons: _seasons, includeVAT, set
.alt-page-title { font-size:1.75rem; } .alt-page-title { font-size:1.75rem; }
.altms-label { max-width:100px; } .altms-label { max-width:100px; }
} }
/* ── focus-visible ── */
.alt-chip:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.alt-ctrl:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.alt-yr-btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.dalt-hero-btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.altms-trigger:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.altms-clear:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.alt-cancel:focus-visible, .alt-apply:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
`}</style> `}</style>
<h1 className="alt-page-title">{L.pageTitle}</h1> <h1 className="alt-page-title">{L.pageTitle}</h1>
@@ -656,9 +665,9 @@ export default function DashboardDemo({ data, seasons: _seasons, includeVAT, set
<div className="alt-chart-header"> <div className="alt-chart-header">
<h3 className="alt-chart-title">{L.trendTitle}</h3> <h3 className="alt-chart-title">{L.trendTitle}</h3>
<div className="alt-chart-controls"> <div className="alt-chart-controls">
{metricOpts.map(o => <button key={o.value} type="button" className={`alt-ctrl${metric===o.value?' alt-ctrl-on':''}`} onClick={() => setMetric(o.value)}>{o.label}</button>)} {metricOpts.map(o => <button key={o.value} type="button" aria-pressed={metric===o.value} className={`alt-ctrl${metric===o.value?' alt-ctrl-on':''}`} onClick={() => setMetric(o.value)}>{o.label}</button>)}
<div className="alt-ctrl-sep" /> <div className="alt-ctrl-sep" />
{granOpts.map(o => <button key={o.value} type="button" className={`alt-ctrl${gran===o.value?' alt-ctrl-on':''}`} onClick={() => setGran(o.value)}>{o.label}</button>)} {granOpts.map(o => <button key={o.value} type="button" aria-pressed={gran===o.value} className={`alt-ctrl${gran===o.value?' alt-ctrl-on':''}`} onClick={() => setGran(o.value)}>{o.label}</button>)}
</div> </div>
</div> </div>
<div className="alt-chart-wrap alt-chart-wrap--tall"><Line data={trendData} options={chartOpts} /></div> <div className="alt-chart-wrap alt-chart-wrap--tall"><Line data={trendData} options={chartOpts} /></div>
@@ -668,13 +677,13 @@ export default function DashboardDemo({ data, seasons: _seasons, includeVAT, set
<div className="alt-chart-header"> <div className="alt-chart-header">
<h3 className="alt-chart-title">{L.museumTitle}</h3> <h3 className="alt-chart-title">{L.museumTitle}</h3>
<div className="alt-chart-controls"> <div className="alt-chart-controls">
{metricOpts.map(o => <button key={o.value} type="button" className={`alt-ctrl${metric===o.value?' alt-ctrl-on':''}`} onClick={() => setMetric(o.value)}>{o.label}</button>)} {metricOpts.map(o => <button key={o.value} type="button" aria-pressed={metric===o.value} className={`alt-ctrl${metric===o.value?' alt-ctrl-on':''}`} onClick={() => setMetric(o.value)}>{o.label}</button>)}
<div className="alt-ctrl-sep" /> <div className="alt-ctrl-sep" />
<button type="button" className={`alt-ctrl${museumChartType==='bar'?' alt-ctrl-on':''}`} onClick={() => setMuseumChartType('bar')}>{L.barLabel}</button> <button type="button" aria-pressed={museumChartType==='bar'} className={`alt-ctrl${museumChartType==='bar'?' alt-ctrl-on':''}`} onClick={() => setMuseumChartType('bar')}>{L.barLabel}</button>
<button type="button" className={`alt-ctrl${museumChartType==='pie'?' alt-ctrl-on':''}`} onClick={() => setMuseumChartType('pie')}>{L.pieLabel}</button> <button type="button" aria-pressed={museumChartType==='pie'} className={`alt-ctrl${museumChartType==='pie'?' alt-ctrl-on':''}`} onClick={() => setMuseumChartType('pie')}>{L.pieLabel}</button>
<div className="alt-ctrl-sep" /> <div className="alt-ctrl-sep" />
<button type="button" className={`alt-ctrl${museumDisplayMode==='absolute'?' alt-ctrl-on':''}`} onClick={() => setMuseumDisplayMode('absolute')}>{L.absLabel}</button> <button type="button" aria-pressed={museumDisplayMode==='absolute'} className={`alt-ctrl${museumDisplayMode==='absolute'?' alt-ctrl-on':''}`} onClick={() => setMuseumDisplayMode('absolute')}>{L.absLabel}</button>
<button type="button" className={`alt-ctrl${museumDisplayMode==='percent'?' alt-ctrl-on':''}`} onClick={() => setMuseumDisplayMode('percent')}>{L.pctLabel}</button> <button type="button" aria-pressed={museumDisplayMode==='percent'} className={`alt-ctrl${museumDisplayMode==='percent'?' alt-ctrl-on':''}`} onClick={() => setMuseumDisplayMode('percent')}>{L.pctLabel}</button>
</div> </div>
</div> </div>
<div className="alt-chart-wrap alt-chart-wrap--tall"> <div className="alt-chart-wrap alt-chart-wrap--tall">
@@ -686,13 +695,13 @@ export default function DashboardDemo({ data, seasons: _seasons, includeVAT, set
<div className="alt-chart-header"> <div className="alt-chart-header">
<h3 className="alt-chart-title">{L.channelTitle}</h3> <h3 className="alt-chart-title">{L.channelTitle}</h3>
<div className="alt-chart-controls"> <div className="alt-chart-controls">
{metricOpts.map(o => <button key={o.value} type="button" className={`alt-ctrl${metric===o.value?' alt-ctrl-on':''}`} onClick={() => setMetric(o.value)}>{o.label}</button>)} {metricOpts.map(o => <button key={o.value} type="button" aria-pressed={metric===o.value} className={`alt-ctrl${metric===o.value?' alt-ctrl-on':''}`} onClick={() => setMetric(o.value)}>{o.label}</button>)}
<div className="alt-ctrl-sep" /> <div className="alt-ctrl-sep" />
<button type="button" className={`alt-ctrl${channelChartType==='bar'?' alt-ctrl-on':''}`} onClick={() => setChannelChartType('bar')}>{L.barLabel}</button> <button type="button" aria-pressed={channelChartType==='bar'} className={`alt-ctrl${channelChartType==='bar'?' alt-ctrl-on':''}`} onClick={() => setChannelChartType('bar')}>{L.barLabel}</button>
<button type="button" className={`alt-ctrl${channelChartType==='pie'?' alt-ctrl-on':''}`} onClick={() => setChannelChartType('pie')}>{L.pieLabel}</button> <button type="button" aria-pressed={channelChartType==='pie'} className={`alt-ctrl${channelChartType==='pie'?' alt-ctrl-on':''}`} onClick={() => setChannelChartType('pie')}>{L.pieLabel}</button>
<div className="alt-ctrl-sep" /> <div className="alt-ctrl-sep" />
<button type="button" className={`alt-ctrl${channelDisplayMode==='absolute'?' alt-ctrl-on':''}`} onClick={() => setChannelDisplayMode('absolute')}>{L.absLabel}</button> <button type="button" aria-pressed={channelDisplayMode==='absolute'} className={`alt-ctrl${channelDisplayMode==='absolute'?' alt-ctrl-on':''}`} onClick={() => setChannelDisplayMode('absolute')}>{L.absLabel}</button>
<button type="button" className={`alt-ctrl${channelDisplayMode==='percent'?' alt-ctrl-on':''}`} onClick={() => setChannelDisplayMode('percent')}>{L.pctLabel}</button> <button type="button" aria-pressed={channelDisplayMode==='percent'} className={`alt-ctrl${channelDisplayMode==='percent'?' alt-ctrl-on':''}`} onClick={() => setChannelDisplayMode('percent')}>{L.pctLabel}</button>
</div> </div>
</div> </div>
<div className="alt-chart-wrap"> <div className="alt-chart-wrap">
@@ -704,13 +713,13 @@ export default function DashboardDemo({ data, seasons: _seasons, includeVAT, set
<div className="alt-chart-header"> <div className="alt-chart-header">
<h3 className="alt-chart-title">{L.districtTitle}</h3> <h3 className="alt-chart-title">{L.districtTitle}</h3>
<div className="alt-chart-controls"> <div className="alt-chart-controls">
{metricOpts.map(o => <button key={o.value} type="button" className={`alt-ctrl${metric===o.value?' alt-ctrl-on':''}`} onClick={() => setMetric(o.value)}>{o.label}</button>)} {metricOpts.map(o => <button key={o.value} type="button" aria-pressed={metric===o.value} className={`alt-ctrl${metric===o.value?' alt-ctrl-on':''}`} onClick={() => setMetric(o.value)}>{o.label}</button>)}
<div className="alt-ctrl-sep" /> <div className="alt-ctrl-sep" />
<button type="button" className={`alt-ctrl${districtChartType==='bar'?' alt-ctrl-on':''}`} onClick={() => setDistrictChartType('bar')}>{L.barLabel}</button> <button type="button" aria-pressed={districtChartType==='bar'} className={`alt-ctrl${districtChartType==='bar'?' alt-ctrl-on':''}`} onClick={() => setDistrictChartType('bar')}>{L.barLabel}</button>
<button type="button" className={`alt-ctrl${districtChartType==='pie'?' alt-ctrl-on':''}`} onClick={() => setDistrictChartType('pie')}>{L.pieLabel}</button> <button type="button" aria-pressed={districtChartType==='pie'} className={`alt-ctrl${districtChartType==='pie'?' alt-ctrl-on':''}`} onClick={() => setDistrictChartType('pie')}>{L.pieLabel}</button>
<div className="alt-ctrl-sep" /> <div className="alt-ctrl-sep" />
<button type="button" className={`alt-ctrl${districtDisplayMode==='absolute'?' alt-ctrl-on':''}`} onClick={() => setDistrictDisplayMode('absolute')}>{L.absLabel}</button> <button type="button" aria-pressed={districtDisplayMode==='absolute'} className={`alt-ctrl${districtDisplayMode==='absolute'?' alt-ctrl-on':''}`} onClick={() => setDistrictDisplayMode('absolute')}>{L.absLabel}</button>
<button type="button" className={`alt-ctrl${districtDisplayMode==='percent'?' alt-ctrl-on':''}`} onClick={() => setDistrictDisplayMode('percent')}>{L.pctLabel}</button> <button type="button" aria-pressed={districtDisplayMode==='percent'} className={`alt-ctrl${districtDisplayMode==='percent'?' alt-ctrl-on':''}`} onClick={() => setDistrictDisplayMode('percent')}>{L.pctLabel}</button>
</div> </div>
</div> </div>
<div className="alt-chart-wrap"> <div className="alt-chart-wrap">
+60 -38
View File
@@ -37,9 +37,21 @@ export const chartColors = {
success: '#059669', success: '#059669',
danger: '#dc2626', danger: '#dc2626',
muted: '#94a3b8', muted: '#94a3b8',
grid: '#f1f5f9' grid: '#e2e8f0' // fallback only; use getChartTheme().border at runtime
}; };
export function getChartTheme() {
const style = getComputedStyle(document.documentElement);
const get = (v: string) => style.getPropertyValue(v).trim();
return {
surface: get('--surface') || '#ffffff',
textPrimary: get('--text-primary') || '#0f172a',
textMuted: get('--text-muted') || '#64748b',
border: get('--border') || '#e2e8f0',
textInverse: get('--text-inverse') || '#ffffff',
};
}
// Extended palette for charts with many categories (events, channels) // Extended palette for charts with many categories (events, channels)
export const chartPalette = [ export const chartPalette = [
'#2563eb', // blue '#2563eb', // blue
@@ -54,15 +66,15 @@ export const chartPalette = [
'#ea580c', // orange '#ea580c', // orange
]; ];
export const createDataLabelConfig = (showDataLabels: boolean): any => ({ export const createDataLabelConfig = (showDataLabels: boolean, overrides?: { color?: string; backgroundColor?: string }): any => ({
display: showDataLabels, display: showDataLabels,
color: '#1e293b', color: overrides?.color ?? '#1e293b',
font: { size: 10, weight: 600 }, font: { size: 10, weight: 600 },
anchor: 'end', anchor: 'end',
align: 'end', align: 'end',
offset: 4, offset: 4,
padding: 4, padding: 4,
backgroundColor: 'rgba(255, 255, 255, 0.85)', backgroundColor: overrides?.backgroundColor ?? 'rgba(255, 255, 255, 0.85)',
borderRadius: 3, borderRadius: 3,
textDirection: 'ltr', // Force LTR for numbers - fixes RTL misalignment textDirection: 'ltr', // Force LTR for numbers - fixes RTL misalignment
formatter: (value: number | null) => { formatter: (value: number | null) => {
@@ -74,43 +86,53 @@ export const createDataLabelConfig = (showDataLabels: boolean): any => ({
} }
}); });
export const createBaseOptions = (showDataLabels: boolean): any => ({ export const createBaseOptions = (showDataLabels: boolean): any => {
responsive: true, const theme = getChartTheme();
maintainAspectRatio: false, return {
locale: 'en-US', // Force LTR number formatting responsive: true,
layout: { maintainAspectRatio: false,
padding: { locale: 'en-US', // Force LTR number formatting
top: showDataLabels ? 25 : 5, layout: {
right: 5, padding: {
bottom: 5, top: showDataLabels ? 25 : 5,
left: 5 right: 5,
} bottom: 5,
}, left: 5
plugins: { }
legend: { display: false },
tooltip: {
backgroundColor: '#1e293b',
padding: 12,
cornerRadius: 8,
titleFont: { size: 12 },
bodyFont: { size: 11 },
rtl: false,
textDirection: 'ltr'
}, },
datalabels: createDataLabelConfig(showDataLabels) plugins: {
}, legend: { display: false },
scales: { tooltip: {
x: { backgroundColor: theme.surface,
grid: { display: false }, titleColor: theme.textPrimary,
ticks: { font: { size: 10 }, color: '#94a3b8' } bodyColor: theme.textMuted,
borderColor: theme.border,
borderWidth: 1,
padding: 12,
cornerRadius: 8,
titleFont: { size: 12 },
bodyFont: { size: 11 },
rtl: false,
textDirection: 'ltr'
},
datalabels: createDataLabelConfig(showDataLabels, {
color: theme.textPrimary,
backgroundColor: theme.surface + 'dd',
})
}, },
y: { scales: {
grid: { color: chartColors.grid }, x: {
ticks: { font: { size: 10 }, color: '#94a3b8' }, grid: { display: false },
border: { display: false } ticks: { font: { size: 10 }, color: theme.textMuted }
},
y: {
grid: { color: theme.border },
ticks: { font: { size: 10 }, color: theme.textMuted },
border: { display: false }
}
} }
} };
}); };
export const lineDatasetDefaults = { export const lineDatasetDefaults = {
borderWidth: 2, borderWidth: 2,