Files
3x-ui/frontend/src/components/form/CipherSuitesSelect.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

60 lines
1.8 KiB
TypeScript

import { Select } from 'antd';
import type { SelectProps } from 'antd';
import { useTranslation } from 'react-i18next';
import { TLS_CIPHER_OPTION, TLS_CIPHER_PRESETS } from '@/schemas/primitives';
import { selectTagRender } from './SelectTag';
const CIPHER_SUITE_OPTIONS = Object.values(TLS_CIPHER_OPTION).map((v) => ({ value: v, label: v }));
const PRESET_PREFIX = 'preset:';
const PRESET_OPTIONS = Object.keys(TLS_CIPHER_PRESETS).map((name) => ({
value: PRESET_PREFIX + name,
label: name,
}));
type CipherSuitesSelectProps = Omit<
SelectProps<string[]>,
'value' | 'onChange' | 'mode' | 'options'
> & {
// Injected by FormField:
value?: string;
onChange?: (value: string) => void;
};
function expandPreset(next: string[]): string[] {
const picked = [...next].reverse().find((v) => v.startsWith(PRESET_PREFIX));
if (!picked) return next;
const name = picked.slice(PRESET_PREFIX.length) as keyof typeof TLS_CIPHER_PRESETS;
return TLS_CIPHER_PRESETS[name].split(':');
}
// xray splits cipherSuites on ':' into a list, so the picker edits tags while
// the stored value stays the single colon-joined string xray reads.
export default function CipherSuitesSelect({
value = '',
onChange,
...rest
}: CipherSuitesSelectProps) {
const { t } = useTranslation();
const suites = value
.split(':')
.map((s) => s.trim())
.filter(Boolean);
return (
<Select
allowClear
tokenSeparators={[':', ',']}
{...rest}
mode="tags"
tagRender={selectTagRender}
options={[
{ label: t('pages.inbounds.form.presets'), options: PRESET_OPTIONS },
{ label: t('pages.inbounds.form.cipherSuites'), options: CIPHER_SUITE_OPTIONS },
]}
value={suites}
onChange={(next) => onChange?.(expandPreset(next).join(':'))}
/>
);
}