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.
This commit is contained in:
Alireza Ahmadi
2026-11-01 16:49:19 +01:00
parent cbf596d889
commit 1bdb517b88
73 changed files with 672 additions and 48 deletions
+1 -1
View File
@@ -178,7 +178,7 @@ export default function GeodataSection({ active, onBusy, onClose }: GeodataSecti
<div>
{modalContextHolder}
{messageContextHolder}
<Spin spinning={loading}>
<Spin aria-live="off" spinning={loading}>
<Alert type="info" className="mb-12" title={t('pages.index.geodataHint')} showIcon />
<Form layout="vertical">
<Form.Item
+1
View File
@@ -174,6 +174,7 @@ export default function IndexPage() {
<Layout className="content-shell">
<Layout.Content className="content-area">
<Spin
aria-live="off"
spinning={loading || !fetched}
delay={200}
description={loading ? loadingTip : t('loading')}
+1 -1
View File
@@ -103,7 +103,7 @@ export default function VersionModal({ open, status, onClose, onBusy }: VersionM
return (
<Modal open={open} title={t('pages.index.xrayUpdates')} footer={null} onCancel={onClose}>
{modalContextHolder}
<Spin spinning={loading}>
<Spin aria-live="off" spinning={loading}>
<Collapse
accordion
activeKey={activeKey}
+12 -3
View File
@@ -1,4 +1,4 @@
import { useMemo } from 'react';
import { useId, useMemo } from 'react';
import type { ReactNode } from 'react';
import { Card, theme } from 'antd';
@@ -31,6 +31,7 @@ export default function VitalTile({
isMobile,
}: VitalTileProps) {
const { token } = theme.useToken();
const labelId = useId();
const meanColor = token.colorTextTertiary;
const referenceLines = useMemo(
@@ -39,10 +40,18 @@ export default function VitalTile({
);
return (
<Card hoverable className="ov-tile" styles={{ body: { padding: 0 } }}>
<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">{label}</span>
<span className="ov-kicker" id={labelId}>
{label}
</span>
</div>
<div className="ov-tile-value">