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
@@ -9,6 +9,7 @@ import {
SafetyCertificateOutlined,
SettingOutlined,
} from '@ant-design/icons';
import { selectTagRender } from '@/components/form';
import type { AllSetting } from '@/models/setting';
import { isOutboundProtocol } from '@/schemas/primitives';
import { HttpUtil, LanguageManager } from '@/utils';
@@ -637,6 +638,7 @@ export default function GeneralTab({ allSetting, updateSetting }: GeneralTabProp
<>
<Select
mode="multiple"
tagRender={selectTagRender}
value={ldapInboundTagList}
onChange={setLdapInboundTagList}
style={{ width: '100%' }}
+1 -1
View File
@@ -381,7 +381,7 @@ export default function SecurityTab({ allSetting, updateSetting, saveSetting }:
+ {t('pages.settings.security.apiTokenNew') || 'New token'}
</Button>
</div>
<Spin spinning={apiTokensLoading}>
<Spin aria-live="off" spinning={apiTokensLoading}>
{!apiTokens.length && !apiTokensLoading && (
<Empty
description={t('pages.settings.security.apiTokenEmpty') || 'No tokens yet'}
@@ -242,6 +242,7 @@ export default function SettingsPage() {
<Layout className="content-shell">
<Layout.Content id="content-layout" className="content-area">
<Spin
aria-live="off"
spinning={spinning || !fetched}
delay={200}
description={t('loading')}
@@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next';
import { Form, Input, InputNumber, Modal, Select, Switch, message } from 'antd';
import { Controller, FormProvider, useForm, useWatch } from 'react-hook-form';
import { selectTagRender } from '@/components/form';
import { FormField, rhfZodValidate } from '@/components/form/rhf';
import SelectAllClearButtons from '@/components/form/SelectAllClearButtons';
import { useInboundOptions } from '@/api/queries/useInboundOptions';
@@ -166,8 +167,8 @@ export default function SubBalancerFormModal({
>
<Select
mode="multiple"
tagRender={selectTagRender}
options={inboundOptions}
maxTagCount="responsive"
listHeight={220}
showSearch={{ optionFilterProp: 'label' }}
/>
@@ -13,7 +13,7 @@ import {
import type { AllSetting } from '@/models/setting';
import { onNumber } from '@/utils/onNumber';
import { SettingListItem } from '@/components/ui';
import { GoRegexInput } from '@/components/form';
import { GoRegexInput, selectTagRender } from '@/components/form';
import { useMediaQuery } from '@/hooks/useMediaQuery';
import { catTabLabel } from './catTabLabel';
import { sanitizePath, normalizePath } from './uriPath';
@@ -418,6 +418,7 @@ export default function SubscriptionFormatsTab({
<SettingListItem paddings="small" title={<>{t('pages.settings.direct')} IPs</>}>
<Select
mode="tags"
tagRender={selectTagRender}
value={ruleValues('direct', 'ip')}
style={{ width: '100%' }}
onChange={(v) => setRuleValues('direct', 'ip', DEFAULT_DIRECT_RULES[1], v)}
@@ -434,6 +435,7 @@ export default function SubscriptionFormatsTab({
>
<Select
mode="tags"
tagRender={selectTagRender}
value={ruleValues('direct', 'domain')}
style={{ width: '100%' }}
onChange={(v) =>
@@ -467,6 +469,7 @@ export default function SubscriptionFormatsTab({
<SettingListItem paddings="small" title={t('pages.xray.blockdomains')}>
<Select
mode="tags"
tagRender={selectTagRender}
value={ruleValues('block', 'domain')}
style={{ width: '100%' }}
onChange={(v) => setRuleValues('block', 'domain', DEFAULT_BLOCK_RULES[0], v)}
@@ -476,6 +479,7 @@ export default function SubscriptionFormatsTab({
<SettingListItem paddings="small" title={t('pages.xray.blockips')}>
<Select
mode="tags"
tagRender={selectTagRender}
value={ruleValues('block', 'ip')}
style={{ width: '100%' }}
onChange={(v) => setRuleValues('block', 'ip', BLOCK_IP_RULE, v)}