Files
3x-ui/frontend/src/pages/xray/routing/RoutingBasic.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

203 lines
5.8 KiB
TypeScript

import { useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Alert, Select, Switch } from 'antd';
import { selectTagRender } from '@/components/form';
import { SettingListItem } from '@/components/ui';
import type { XraySettingsValue, SetTemplate } from '@/hooks/useXraySetting';
import {
BITTORRENT_PROTOCOLS,
BLOCK_DOMAINS_OPTIONS,
DOMAINS_OPTIONS,
IPS_OPTIONS,
SERVICES_OPTIONS,
directSettings,
ipv4Settings,
} from '../basics/constants';
import {
getDefaultOutboundTag,
ruleGetter,
ruleSetter,
setDefaultOutboundTag,
syncOutbound,
} from '../basics/helpers';
interface RoutingBasicProps {
templateSettings: XraySettingsValue | null;
setTemplateSettings: SetTemplate;
}
export default function RoutingBasic({ templateSettings, setTemplateSettings }: RoutingBasicProps) {
const { t } = useTranslation();
const mutate = useCallback(
(mutator: (next: XraySettingsValue) => void) => {
setTemplateSettings((prev) => {
if (!prev) return prev;
const clone = JSON.parse(JSON.stringify(prev)) as XraySettingsValue;
mutator(clone);
return clone;
});
},
[setTemplateSettings],
);
const blockedIPs = ruleGetter(templateSettings, 'blocked', 'ip');
const blockedDomains = ruleGetter(templateSettings, 'blocked', 'domain');
const blockedProtocols = ruleGetter(templateSettings, 'blocked', 'protocol');
const directIPs = ruleGetter(templateSettings, 'direct', 'ip');
const directDomains = ruleGetter(templateSettings, 'direct', 'domain');
const ipv4Domains = ruleGetter(templateSettings, 'IPv4', 'domain');
const torrentActive = BITTORRENT_PROTOCOLS.every((p) => blockedProtocols.includes(p));
const defaultOutboundTag = getDefaultOutboundTag(templateSettings);
const defaultOutboundOptions = useMemo(() => {
const tags = new Set<string>(['direct', 'blocked']);
for (const o of templateSettings?.outbounds ?? []) {
if (o?.tag) tags.add(o.tag);
}
return [...tags].map((value) => ({ label: value, value }));
}, [templateSettings?.outbounds]);
return (
<>
<Alert
type="warning"
showIcon
className="mb-12 hint-alert"
title={t('pages.xray.blockConnectionsConfigsDesc')}
/>
<SettingListItem
title={t('pages.xray.defaultOutbound')}
description={t('pages.xray.defaultOutboundDesc')}
paddings="small"
control={
<Select
value={defaultOutboundTag}
style={{ width: '100%' }}
options={defaultOutboundOptions}
onChange={(tag) => mutate((tt) => setDefaultOutboundTag(tt, tag))}
/>
}
/>
<SettingListItem
title={t('pages.xray.Torrent')}
paddings="small"
control={
<Switch
checked={torrentActive}
onChange={(checked) =>
mutate((tt) => {
const next = checked
? [...blockedProtocols, ...BITTORRENT_PROTOCOLS]
: blockedProtocols.filter((d) => !BITTORRENT_PROTOCOLS.includes(d));
ruleSetter(tt, 'blocked', 'protocol', next);
})
}
/>
}
/>
<SettingListItem
title={t('pages.xray.blockips')}
paddings="small"
control={
<Select
mode="tags"
tagRender={selectTagRender}
value={blockedIPs}
style={{ width: '100%' }}
options={IPS_OPTIONS}
onChange={(v) => mutate((tt) => ruleSetter(tt, 'blocked', 'ip', v))}
/>
}
/>
<SettingListItem
title={t('pages.xray.blockdomains')}
paddings="small"
control={
<Select
mode="tags"
tagRender={selectTagRender}
value={blockedDomains}
style={{ width: '100%' }}
options={BLOCK_DOMAINS_OPTIONS}
onChange={(v) => mutate((tt) => ruleSetter(tt, 'blocked', 'domain', v))}
/>
}
/>
<Alert
type="warning"
showIcon
className="mb-12 hint-alert"
title={t('pages.xray.directConnectionsConfigsDesc')}
/>
<SettingListItem
title={t('pages.xray.directips')}
paddings="small"
control={
<Select
mode="tags"
tagRender={selectTagRender}
value={directIPs}
style={{ width: '100%' }}
options={IPS_OPTIONS}
onChange={(v) =>
mutate((tt) => {
ruleSetter(tt, 'direct', 'ip', v);
syncOutbound(tt, 'direct', directSettings);
})
}
/>
}
/>
<SettingListItem
title={t('pages.xray.directdomains')}
paddings="small"
control={
<Select
mode="tags"
tagRender={selectTagRender}
value={directDomains}
style={{ width: '100%' }}
options={DOMAINS_OPTIONS}
onChange={(v) =>
mutate((tt) => {
ruleSetter(tt, 'direct', 'domain', v);
syncOutbound(tt, 'direct', directSettings);
})
}
/>
}
/>
<SettingListItem
title={t('pages.xray.ipv4Routing')}
description={t('pages.xray.ipv4RoutingDesc')}
paddings="small"
control={
<Select
mode="tags"
tagRender={selectTagRender}
value={ipv4Domains}
style={{ width: '100%' }}
options={SERVICES_OPTIONS}
onChange={(v) =>
mutate((tt) => {
ruleSetter(tt, 'IPv4', 'domain', v);
syncOutbound(tt, 'IPv4', ipv4Settings);
})
}
/>
}
/>
</>
);
}