feat: make energy chart drilldowns keyboard accessible
This commit is contained in:
@@ -161,6 +161,7 @@ SUM(fee) / NULLIF(SUM(kwh), 0)
|
|||||||
- 已完成里程考核归因、车辆详情和每日汇报跳转。
|
- 已完成里程考核归因、车辆详情和每日汇报跳转。
|
||||||
- 已完成氢能站点、客户下钻代码。
|
- 已完成氢能站点、客户下钻代码。
|
||||||
- 已完成氢能每日趋势到加氢订单的 URL 下钻、分页和结构化故障状态;待数据源恢复后验收真实明细。
|
- 已完成氢能每日趋势到加氢订单的 URL 下钻、分页和结构化故障状态;待数据源恢复后验收真实明细。
|
||||||
|
- 已为电能总览与氢能每日趋势柱图统一 Enter/Space 键盘下钻;氢能有效日期柱提供可读名称、焦点和选中反馈。
|
||||||
- 已完成电能全部车辆、日期和订单下钻。
|
- 已完成电能全部车辆、日期和订单下钻。
|
||||||
- 已完成 ETC 通行记录与结算账单下钻代码、筛选、分页和未同步空状态;待首次真实同步后验收真实数据。
|
- 已完成 ETC 通行记录与结算账单下钻代码、筛选、分页和未同步空状态;待首次真实同步后验收真实数据。
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user