Files
3x-ui/frontend/src/pages/inbounds/form/protocols/tun.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

125 lines
4.3 KiB
TypeScript

import { useId, type ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
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 {
name: string[];
label: ReactNode;
tooltip?: string;
placeholder: (index: number) => string;
}
function StringList({ name, label, tooltip, placeholder }: StringListProps) {
const { t } = useTranslation();
const labelId = useId();
const { control } = useFormContext();
const { fields, append, remove } = useFieldArray({ control, name: name.join('.') });
return (
<Form.Item label={<span id={labelId}>{label}</span>} tooltip={tooltip}>
<Button aria-label={t('add')} size="small" onClick={() => append('')}>
<PlusOutlined aria-hidden />
</Button>
{fields.map((field, j) => (
<Space.Compact key={field.id} block className="mt-4">
<FormField name={[...name, j]} noStyle>
<Input
aria-labelledby={labelId}
aria-describedby={tooltip ? `${labelId}-hint` : undefined}
placeholder={placeholder(j)}
/>
</FormField>
<Button aria-label={t('remove')} size="small" onClick={() => remove(j)}>
<MinusOutlined aria-hidden />
</Button>
</Space.Compact>
))}
{tooltip && (
<span id={`${labelId}-hint`} hidden>
{tooltip}
</span>
)}
</Form.Item>
);
}
// Named autoSystemWfpBlockLeak values; xray-core 26.10.31 also takes typed "tcp/<port>" or "udp/<port>".
const WFP_BLOCK_LEAK_OPTIONS = [
{ value: 'dns', label: 'dns' },
{ value: 'windnsclient', label: 'windnsclient' },
{ value: 'misconfigtun', label: 'misconfigtun' },
];
export default function TunFields() {
const { t } = useTranslation();
return (
<>
<FormField name={['settings', 'name']} label={t('pages.inbounds.info.interfaceName')}>
<Input placeholder="xray0" />
</FormField>
<FormField
name={['settings', 'desc']}
label={t('pages.inbounds.form.tunDesc')}
tooltip={t('pages.inbounds.form.tunDescTooltip')}
>
<Input id="tun-desc" placeholder="Wintun" />
</FormField>
<FormField name={['settings', 'mtu']} label="MTU">
<InputNumber min={0} />
</FormField>
<StringList
name={['settings', 'gateway']}
label={t('pages.inbounds.info.gateway')}
placeholder={(j) => (j === 0 ? '10.0.0.1/16' : 'fc00::1/64')}
/>
<StringList
name={['settings', 'dns']}
label="DNS"
placeholder={(j) => (j === 0 ? '1.1.1.1' : '8.8.8.8')}
/>
<FormField name={['settings', 'userLevel']} label={t('pages.xray.tun.userLevel')}>
<InputNumber min={0} />
</FormField>
<StringList
name={['settings', 'autoSystemRoutingTable']}
label={t('pages.inbounds.info.autoSystemRoutes')}
tooltip={t('pages.inbounds.form.autoSystemRoutesTooltip')}
placeholder={(j) => (j === 0 ? '0.0.0.0/0' : '::/0')}
/>
<FormField
name={['settings', 'autoOutboundsInterface']}
label={t('pages.inbounds.form.autoOutboundsInterface')}
tooltip={t('pages.inbounds.form.autoOutboundsInterfaceTooltip')}
>
<Input placeholder="auto" />
</FormField>
<FormField
name={['settings', 'autoSystemDnsToGateway']}
label={t('pages.inbounds.form.autoSystemDnsToGateway')}
tooltip={t('pages.inbounds.form.autoSystemDnsToGatewayTooltip')}
valueProp="checked"
>
<Switch id="tun-auto-dns-gateway" />
</FormField>
<FormField
name={['settings', 'autoSystemWfpBlockLeak']}
label={t('pages.inbounds.form.autoSystemWfpBlockLeak')}
tooltip={t('pages.inbounds.form.autoSystemWfpBlockLeakTooltip')}
>
<Select
id="tun-wfp-block-leak"
mode="tags"
tagRender={selectTagRender}
tokenSeparators={[',', ' ']}
allowClear
options={WFP_BLOCK_LEAK_OPTIONS}
/>
</FormField>
</>
);
}