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
@@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next';
import { Modal, Select, Typography, message } from 'antd';
import { HttpUtil } from '@/utils';
import { SelectAllClearButtons } from '@/components/form';
import { SelectAllClearButtons, selectTagRender } from '@/components/form';
import { buildClonePayload, pickClonePort } from '@/lib/xray/inbound-clone';
import type { NodeRecord } from '@/api/queries/useNodesQuery';
import type { DBInbound } from '@/models/dbinbound';
@@ -132,6 +132,7 @@ export default function CloneInboundModal({
<Select
aria-label={t('pages.inbounds.deployTo')}
mode="multiple"
tagRender={selectTagRender}
style={{ width: '100%' }}
value={targets}
onChange={setTargets}
@@ -720,6 +720,7 @@ export default function InboundsPage() {
<Layout className="content-shell">
<Layout.Content id="content-layout" className="content-area">
<Spin
aria-live="off"
spinning={!fetched || !hostsFetched}
delay={200}
description={t('loading')}
@@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next';
import { Alert, Input, Modal, Select, Space, Table, Tag, Typography, message } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { selectTagRender } from '@/components/form';
import { HttpUtil } from '@/utils';
import { formatInboundLabel } from '@/lib/inbounds/label';
import { coerceInboundJsonField, type DBInbound } from '@/models/dbinbound';
@@ -207,6 +208,7 @@ export default function AttachClientsModal({
) : (
<Select
mode="multiple"
tagRender={selectTagRender}
aria-label={t('pages.inbounds.attachClientsTargets')}
style={{ width: '100%' }}
value={targetIds}
@@ -200,7 +200,7 @@ export default function AttachExistingClientsModal({
{noClients ? (
<Alert type="info" showIcon title={t('pages.inbounds.attachExistingNoClients')} />
) : (
<Spin spinning={loading}>
<Spin aria-live="off" spinning={loading}>
<Space orientation="vertical" size="small" style={{ width: '100%' }}>
<Space style={{ width: '100%', justifyContent: 'space-between' }} wrap>
<Space wrap>
@@ -1,6 +1,7 @@
import { useTranslation } from 'react-i18next';
import { Input, InputNumber, Select } from 'antd';
import { selectTagRender } from '@/components/form';
import { FormField } from '@/components/form/rhf';
import { MASQUE_DEFAULT_PATH } from '@/schemas/protocols/stream/masque';
@@ -14,7 +15,7 @@ export function MasqueSettingsFields() {
name={['settings', 'address']}
required
>
<Select mode="tags" tokenSeparators={[',', ' ']} open={false} />
<Select mode="tags" tagRender={selectTagRender} tokenSeparators={[',', ' ']} open={false} />
</FormField>
<FormField label={t('pages.inbounds.info.mtu')} name={['settings', 'mtu']}>
<InputNumber min={1280} max={65535} placeholder="1280" style={{ width: '100%' }} />
@@ -16,6 +16,7 @@ import {
import { CloudDownloadOutlined, SyncOutlined } from '@ant-design/icons';
import { useFormContext, useWatch } from 'react-hook-form';
import { selectTagRender } from '@/components/form';
import { FormField } from '@/components/form/rhf';
import { HttpUtil } from '@/utils';
@@ -210,6 +211,7 @@ export default function TuicFields() {
<FormField name={['settings', 'server', 'alpn']} label={t('pages.xray.tuic.alpn')}>
<Select
mode="tags"
tagRender={selectTagRender}
tokenSeparators={[',']}
options={[
{ label: 'h3', value: 'h3' },
@@ -4,6 +4,7 @@ import { Button, Form, Input, InputNumber, Select, Space, Switch } from 'antd';
import { MinusOutlined, PlusOutlined } from '@ant-design/icons';
import { useFieldArray, useFormContext } from 'react-hook-form';
import { selectTagRender } from '@/components/form';
import { FormField } from '@/components/form/rhf';
interface StringListProps {
@@ -112,6 +113,7 @@ export default function TunFields() {
<Select
id="tun-wfp-block-leak"
mode="tags"
tagRender={selectTagRender}
tokenSeparators={[',', ' ']}
allowClear
options={WFP_BLOCK_LEAK_OPTIONS}
@@ -17,6 +17,7 @@ import {
import { RadarChartOutlined, ReloadOutlined, SearchOutlined } from '@ant-design/icons';
import dayjs from 'dayjs';
import { selectTagRender } from '@/components/form';
import { FormField } from '@/components/form/rhf';
import { UTLS_FINGERPRINT } from '@/schemas/primitives';
import {
@@ -208,7 +209,12 @@ export default function RealityForm({
</Form.Item>
)}
<FormField label="SNI" name={['streamSettings', 'realitySettings', 'serverNames']}>
<Select mode="tags" tokenSeparators={[',']} style={{ width: '100%' }} />
<Select
mode="tags"
tagRender={selectTagRender}
tokenSeparators={[',']}
style={{ width: '100%' }}
/>
</FormField>
<FormField
name={['streamSettings', 'realitySettings', 'maxTimediff']}
@@ -251,6 +257,7 @@ export default function RealityForm({
<Select
id={`${uid}-shortIds`}
mode="tags"
tagRender={selectTagRender}
tokenSeparators={[',']}
style={{ flex: 1 }}
/>
@@ -20,7 +20,7 @@ import {
} from '@ant-design/icons';
import { useFieldArray, useFormContext, useWatch } from 'react-hook-form';
import { CipherSuitesSelect } from '@/components/form';
import { CipherSuitesSelect, selectTagRender } from '@/components/form';
import DynamicEchHelper from '@/components/form/DynamicEchHelper';
import { FormField } from '@/components/form/rhf';
import {
@@ -380,6 +380,7 @@ export default function TlsForm({
<FormField name={['streamSettings', 'tlsSettings', 'alpn']} label="ALPN">
<Select
mode="multiple"
tagRender={selectTagRender}
tokenSeparators={[',']}
style={{ width: '100%' }}
options={Object.values(ALPN_OPTION).map((a) => ({ value: a, label: a }))}
@@ -392,6 +393,7 @@ export default function TlsForm({
>
<Select
mode="tags"
tagRender={selectTagRender}
tokenSeparators={[',', ' ']}
style={{ width: '100%' }}
options={['X25519MLKEM768', 'X25519', 'P-256', 'P-384', 'P-521'].map((c) => ({
@@ -478,6 +480,7 @@ export default function TlsForm({
id={`${uid}-pinnedPeerCertSha256`}
aria-describedby={`${uid}-pinnedPeerCertSha256-hint`}
mode="tags"
tagRender={selectTagRender}
tokenSeparators={[',', ' ']}
placeholder={t('pages.inbounds.form.pinnedPeerCertSha256Placeholder')}
style={{ width: 'calc(100% - 64px)' }}
@@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next';
import { Alert, Form, InputNumber, Segmented, Select, Switch } from 'antd';
import { Controller, useFormContext, useWatch } from 'react-hook-form';
import { selectTagRender } from '@/components/form';
import { FormField } from '@/components/form/rhf';
import { SockoptCustomField } from '@/lib/xray/forms/fields';
import { TCP_CONGESTION_OPTION } from '@/schemas/primitives';
@@ -228,6 +229,7 @@ export default function SockoptForm({
>
<Select
mode="tags"
tagRender={selectTagRender}
style={{ width: '100%' }}
tokenSeparators={[',']}
options={[