Files
3x-ui/frontend/src/pages/index/VitalTile.tsx
T
Alireza Ahmadi 1bdb517b88 fix(a11y): silence live stats and make Select tags removable for screen readers
Ant Design's Spin wraps its content in aria-live="polite", so every
WebSocket tick on the dashboard, inbounds, clients and nodes pages was
announced, and only the changed number was read, with no label.
Pass aria-live="off" on every Spin that wraps content; aria-busy still
signals loading. Each vital tile is now a group named by its metric,
so "47.2 %" is read as CPU.

rc-select renders each tag's remove "x" as an aria-hidden span with no
tabIndex, so screen-reader users could only Backspace the last value
away, never remove one SNI or inbound from the middle.
selectTagRender swaps in a real button named "Remove <value>" and
moves focus to the next tag after removal. It is applied to every
tags/multiple Select. maxTagCount="responsive" is dropped because it
hid selected inbounds behind "+N", where no screen reader could reach
them.

Two oxlint rules (antd-a11y/spin-content-not-live and
antd-a11y/select-tag-remove) keep new code from regressing either fix.
2026-11-01 16:49:19 +01:00

87 lines
2.1 KiB
TypeScript

import { useId, useMemo } from 'react';
import type { ReactNode } from 'react';
import { Card, theme } from 'antd';
import { Sparkline } from '@/components/viz';
import { mean, peak } from './useOverviewHistory';
interface VitalTileProps {
available?: boolean;
icon: ReactNode;
label: string;
percent: number;
statusColor: string;
detail: string;
footLeft: string;
footRight: string;
data: number[];
isMobile: boolean;
}
export default function VitalTile({
available = true,
icon,
label,
percent,
statusColor,
detail,
footLeft,
footRight,
data,
isMobile,
}: VitalTileProps) {
const { token } = theme.useToken();
const labelId = useId();
const meanColor = token.colorTextTertiary;
const referenceLines = useMemo(
() => (data.length > 1 ? [{ y: mean(data), dash: '3 4', color: meanColor }] : []),
[data, meanColor],
);
return (
<Card
hoverable
className="ov-tile"
styles={{ body: { padding: 0 } }}
role="group"
aria-labelledby={labelId}
>
<div className="ov-tile-head">
<span className="ov-tile-icon">{icon}</span>
<span className="ov-kicker" id={labelId}>
{label}
</span>
</div>
<div className="ov-tile-value">
<span className="ov-tile-number">{available ? percent.toFixed(1) : '—'}</span>
{available && <span className="ov-tile-unit">%</span>}
</div>
<div className="ov-tile-detail">{available ? detail : '—'}</div>
<div className="ov-tile-foot">
<span>{available ? footLeft : '—'}</span>
<span>{available ? footRight : '—'}</span>
</div>
<div className="ov-tile-chart">
<Sparkline
data={data}
height={isMobile ? 48 : 62}
strokeWidth={1.5}
fillOpacity={0.3}
showGrid={false}
showMarker={false}
valueMax={peak(data) > 0 ? null : 100}
stroke={statusColor}
referenceLines={referenceLines}
yFormatter={(v) => `${v.toFixed(0)}%`}
name1={label}
/>
</div>
</Card>
);
}