Files
3x-ui/frontend/src/lib/xray/forms/SniffingFields.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

89 lines
3.0 KiB
TypeScript

import { useTranslation } from 'react-i18next';
import { Form, Select, Switch } from 'antd';
import type { FormInstance } from 'antd/es/form';
import { selectTagRender } from '@/components/form/SelectTag';
import { SNIFFING_OPTION } from '@/schemas/primitives';
const DEST_OPTIONS = Object.entries(SNIFFING_OPTION).map(([label, value]) => ({ value, label }));
export interface SniffingFieldsProps {
// Base path to the sniffing object in the form, e.g. ['sniffing'] (inbound),
// ['settings', 'reverseSniffing'] (VLESS reverse), ['settings', 'sniffing']
// (loopback). All sub-fields hang off this path.
name: (string | number)[];
form: FormInstance;
// Label for the enable toggle — Enable / Reverse Sniffing / Sniffing differ
// per host.
enableLabel: string;
}
// Shared sniffing form fragment used everywhere the panel edits an xray
// SniffingConfig: the inbound Sniffing tab, VLESS reverse sniffing, and the
// loopback outbound. Renders the enable toggle plus the destOverride /
// metadataOnly / routeOnly / excluded fields when enabled.
export default function SniffingFields({ name, form, enableLabel }: SniffingFieldsProps) {
const { t } = useTranslation();
const enabled = Form.useWatch([...name, 'enabled'], form) ?? false;
return (
<>
<Form.Item label={enableLabel} name={[...name, 'enabled']} valuePropName="checked">
<Switch />
</Form.Item>
{enabled && (
<>
<Form.Item name={[...name, 'destOverride']} wrapperCol={{ md: { span: 14, offset: 8 } }}>
<Select
mode="multiple"
tagRender={selectTagRender}
className="sniffing-options"
aria-label={t('pages.inbounds.sniffingDestOverride')}
options={DEST_OPTIONS}
/>
</Form.Item>
<Form.Item
label={t('pages.inbounds.sniffingMetadataOnly')}
name={[...name, 'metadataOnly']}
valuePropName="checked"
>
<Switch />
</Form.Item>
<Form.Item
label={t('pages.inbounds.sniffingRouteOnly')}
name={[...name, 'routeOnly']}
valuePropName="checked"
>
<Switch />
</Form.Item>
<Form.Item
label={t('pages.inbounds.sniffingIpsExcluded')}
name={[...name, 'ipsExcluded']}
>
<Select
mode="tags"
tagRender={selectTagRender}
tokenSeparators={[',']}
placeholder="IP/CIDR/geoip:*/ext:*"
style={{ width: '100%' }}
/>
</Form.Item>
<Form.Item
label={t('pages.inbounds.sniffingDomainsExcluded')}
name={[...name, 'domainsExcluded']}
>
<Select
mode="tags"
tagRender={selectTagRender}
tokenSeparators={[',']}
placeholder="domain:*/ext:*"
style={{ width: '100%' }}
/>
</Form.Item>
</>
)}
</>
);
}