feat: make energy chart drilldowns keyboard accessible

This commit is contained in:
kkfluous
2026-08-07 16:56:01 +08:00
parent 86a23ad343
commit 0adc2c80f7
5 changed files with 62 additions and 17 deletions
+1
View File
@@ -161,6 +161,7 @@ SUM(fee) / NULLIF(SUM(kwh), 0)
- 已完成里程考核归因、车辆详情和每日汇报跳转。 - 已完成里程考核归因、车辆详情和每日汇报跳转。
- 已完成氢能站点、客户下钻代码。 - 已完成氢能站点、客户下钻代码。
- 已完成氢能每日趋势到加氢订单的 URL 下钻、分页和结构化故障状态;待数据源恢复后验收真实明细。 - 已完成氢能每日趋势到加氢订单的 URL 下钻、分页和结构化故障状态;待数据源恢复后验收真实明细。
- 已为电能总览与氢能每日趋势柱图统一 Enter/Space 键盘下钻;氢能有效日期柱提供可读名称、焦点和选中反馈。
- 已完成电能全部车辆、日期和订单下钻。 - 已完成电能全部车辆、日期和订单下钻。
- 已完成 ETC 通行记录与结算账单下钻代码、筛选、分页和未同步空状态;待首次真实同步后验收真实数据。 - 已完成 ETC 通行记录与结算账单下钻代码、筛选、分页和未同步空状态;待首次真实同步后验收真实数据。
+2 -6
View File
@@ -5,6 +5,7 @@ import { fetchElectricOverview, type ElectricOverviewResponse } from './api';
import RotatingFooterHint from '../../components/RotatingFooterHint'; import RotatingFooterHint from '../../components/RotatingFooterHint';
import { ErrorState, LoadingState, MetricTile, SurfaceCard } from '../../components/ui/surface'; import { ErrorState, LoadingState, MetricTile, SurfaceCard } from '../../components/ui/surface';
import { buildElectricDrillUrl } from './electric-drill-context'; import { buildElectricDrillUrl } from './electric-drill-context';
import { activateDrillOnKeyDown } from './drill-interaction';
function fmtYuan(yuan: number) { function fmtYuan(yuan: number) {
return `¥${yuan.toLocaleString('zh-CN', { maximumFractionDigits: 2 })}`; return `¥${yuan.toLocaleString('zh-CN', { maximumFractionDigits: 2 })}`;
@@ -141,12 +142,7 @@ export default function ElectricOverview() {
tabIndex={0} tabIndex={0}
aria-label={`${item.date},充电费用 ${fmtYuan(item.fee)},查看全部车辆订单`} aria-label={`${item.date},充电费用 ${fmtYuan(item.fee)},查看全部车辆订单`}
onClick={() => openDay(item.date)} onClick={() => openDay(item.date)}
onKeyDown={event => { onKeyDown={event => activateDrillOnKeyDown(event, () => openDay(item.date))}
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
openDay(item.date);
}
}}
/> />
))} ))}
</Bar> </Bar>
+23 -11
View File
@@ -15,6 +15,7 @@ import {
import HydrogenOrders from './HydrogenOrders'; import HydrogenOrders from './HydrogenOrders';
import AnalysisDateFilters from './AnalysisDateFilters'; import AnalysisDateFilters from './AnalysisDateFilters';
import AnalysisScopeSwitch, { type AnalysisScopeOption } from './AnalysisScopeSwitch'; import AnalysisScopeSwitch, { type AnalysisScopeOption } from './AnalysisScopeSwitch';
import { activateDrillOnKeyDown } from './drill-interaction';
import { import {
dateRangeModeLabel, dateRangeModeLabel,
getQuickDateRange, getQuickDateRange,
@@ -88,6 +89,11 @@ export default function HydrogenDaily() {
const avgKg = activeDays > 0 ? totalKg / activeDays : 0; const avgKg = activeDays > 0 ? totalKg / activeDays : 0;
const scopeLabel = dateRangeModeLabel(pick); const scopeLabel = dateRangeModeLabel(pick);
const rangeText = `${effectiveRange.start}${effectiveRange.end}`; const rangeText = `${effectiveRange.start}${effectiveRange.end}`;
const orderScopeLabel = selectedCustomerName
? `客户 ${selectedCustomerName}`
: selectedStationName
? `站点 ${selectedStationName}`
: vehicleScope === 'external' ? '外部车辆' : '羚牛车辆';
const peakDay = trendData.reduce<HydrogenDailyRow | null>((best, item) => (!best || item.totalKg > best.totalKg ? item : best), null); const peakDay = trendData.reduce<HydrogenDailyRow | null>((best, item) => (!best || item.totalKg > best.totalKg ? item : best), null);
const lowDay = trendData const lowDay = trendData
.filter(item => item.totalKg > 0) .filter(item => item.totalKg > 0)
@@ -346,17 +352,23 @@ export default function HydrogenDaily() {
label={{ value: '均值', position: 'right', fill: '#d97706', fontSize: 10, fontWeight: 700 }} label={{ value: '均值', position: 'right', fill: '#d97706', fontSize: 10, fontWeight: 700 }}
/> />
)} )}
<Bar <Bar dataKey="totalKg" radius={[4, 4, 0, 0]}>
dataKey="totalKg" {trendData.map(item => (
radius={[4, 4, 0, 0]} <Cell
className="cursor-pointer" key={item.date}
onClick={entry => { fill="url(#hydrogenBarGrad)"
const date = String(entry.payload?.date ?? ''); stroke={selectedDate === item.date ? '#1d4ed8' : undefined}
if (date && Number(entry.payload?.totalKg) > 0) openOrders(date); strokeWidth={selectedDate === item.date ? 2 : undefined}
}} className={item.totalKg > 0 ? 'cursor-pointer outline-none focus:stroke-blue-700 focus:stroke-2' : undefined}
> role={item.totalKg > 0 ? 'button' : undefined}
{trendData.map((_, i) => ( tabIndex={item.totalKg > 0 ? 0 : -1}
<Cell key={i} fill="url(#hydrogenBarGrad)" /> aria-pressed={item.totalKg > 0 ? selectedDate === item.date : undefined}
aria-label={`${item.date},加氢量 ${item.totalKg.toLocaleString('zh-CN', { maximumFractionDigits: 2 })} Kg${item.totalKg > 0 ? `,查看${orderScopeLabel}加氢订单` : ',无可下钻订单'}`}
onClick={item.totalKg > 0 ? () => openOrders(item.date) : undefined}
onKeyDown={item.totalKg > 0
? event => activateDrillOnKeyDown(event, () => openOrders(item.date))
: undefined}
/>
))} ))}
</Bar> </Bar>
<defs> <defs>
@@ -0,0 +1,25 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { activateDrillOnKeyDown } from './drill-interaction.js';
test('activates drilldown with Enter and Space only', () => {
for (const key of ['Enter', ' ']) {
let prevented = false;
let activations = 0;
const handled = activateDrillOnKeyDown(
{ key, preventDefault: () => { prevented = true; } },
() => { activations += 1; },
);
assert.equal(handled, true);
assert.equal(prevented, true);
assert.equal(activations, 1);
}
let activations = 0;
const handled = activateDrillOnKeyDown(
{ key: 'ArrowRight', preventDefault: () => assert.fail('must not prevent navigation keys') },
() => { activations += 1; },
);
assert.equal(handled, false);
assert.equal(activations, 0);
});
+11
View File
@@ -0,0 +1,11 @@
export interface DrillKeyEvent {
key: string;
preventDefault: () => void;
}
export function activateDrillOnKeyDown(event: DrillKeyEvent, activate: () => void): boolean {
if (event.key !== 'Enter' && event.key !== ' ') return false;
event.preventDefault();
activate();
return true;
}