mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-11-05 20:55:29 +03:00
1bdb517b88
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.
87 lines
2.1 KiB
TypeScript
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>
|
|
);
|
|
}
|