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
@@ -3,6 +3,7 @@ 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 }));
@@ -46,6 +47,7 @@ export default function CipherSuitesSelect({
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 },
@@ -4,6 +4,7 @@ import { expect } from 'storybook/test';
import { Select } from 'antd';
import SelectAllClearButtons from './SelectAllClearButtons';
import { selectTagRender } from './SelectTag';
const inboundOptions: Array<{ value: number; label: string }> = [
{ value: 1, label: 'VLESS Reality — 443' },
@@ -64,13 +65,13 @@ function InboundPickerDemo() {
<SelectAllClearButtons options={inboundOptions} value={selected} onChange={setSelected} />
<Select
mode="multiple"
tagRender={selectTagRender}
style={{ width: '100%' }}
value={selected}
onChange={setSelected}
options={inboundOptions}
placeholder="Select inbounds"
aria-label="Select inbounds"
maxTagCount="responsive"
/>
</div>
);
@@ -89,13 +90,13 @@ function ClientEmailsDemo() {
/>
<Select
mode="multiple"
tagRender={selectTagRender}
style={{ width: '100%' }}
value={selected}
onChange={setSelected}
options={clientEmailOptions}
placeholder="Select clients"
aria-label="Select clients"
maxTagCount="responsive"
/>
</div>
);
@@ -0,0 +1,14 @@
.select-tag-remove {
border: 0;
background: none;
padding: 0;
font: inherit;
color: inherit;
cursor: pointer;
}
.select-tag-remove:focus-visible {
outline: 2px solid var(--ant-color-primary);
outline-offset: 1px;
border-radius: 2px;
}
@@ -0,0 +1,85 @@
import { useState } from 'react';
import type { Meta, StoryObj } from '@storybook/react-vite';
import { expect, waitFor } from 'storybook/test';
import { Select } from 'antd';
import { selectTagRender } from './SelectTag';
const meta = {
title: 'Form/SelectTag',
tags: ['autodocs'],
parameters: {
layout: 'padded',
docs: {
description: {
component:
'Tag renderer for every `mode="tags"` / `mode="multiple"` Select. Ant Design draws the tag "x" as an aria-hidden, unfocusable span; `tagRender={selectTagRender}` swaps in a real button named "Remove <value>", so keyboard and screen-reader users can remove any tag, and focus moves to the next one.',
},
},
},
} satisfies Meta;
export default meta;
type Story = StoryObj<typeof meta>;
function SniDemo() {
const [value, setValue] = useState(['www.microsoft.com', 'www.apple.com', 'dl.google.com']);
return (
<div style={{ maxWidth: 420 }}>
<Select
aria-label="SNI"
mode="tags"
tagRender={selectTagRender}
tokenSeparators={[',']}
style={{ width: '100%' }}
value={value}
onChange={setValue}
/>
</div>
);
}
function InboundDemo() {
const options = [
{ value: 1, label: 'VLESS Reality — 443' },
{ value: 2, label: 'VMess WS — 8443' },
{ value: 3, label: 'Trojan TCP — 2053' },
];
const [value, setValue] = useState([1, 2, 3]);
return (
<div style={{ maxWidth: 420 }}>
<Select
aria-label="Inbounds"
mode="multiple"
tagRender={selectTagRender}
style={{ width: '100%' }}
options={options}
value={value}
onChange={setValue}
/>
</div>
);
}
export const FreeTextTags: Story = {
render: () => <SniDemo />,
play: async ({ canvas, userEvent }) => {
await userEvent.tab();
await userEvent.tab();
await expect(canvas.getByRole('button', { name: 'Remove www.apple.com' })).toHaveFocus();
await userEvent.keyboard('{Enter}');
await expect(canvas.queryByText('www.apple.com')).toBeNull();
await waitFor(() =>
expect(canvas.getByRole('button', { name: 'Remove dl.google.com' })).toHaveFocus(),
);
await expect(canvas.getByRole('combobox', { name: 'SNI' })).toHaveAttribute(
'aria-expanded',
'false',
);
},
};
export const OptionLabels: Story = {
render: () => <InboundDemo />,
};
@@ -0,0 +1,66 @@
import { useContext } from 'react';
import type { KeyboardEvent, MouseEvent } from 'react';
import { useTranslation } from 'react-i18next';
import { ConfigProvider } from 'antd';
import type { SelectProps } from 'antd';
import { CloseOutlined } from '@ant-design/icons';
import './SelectTag.css';
type SelectTagProps = Parameters<NonNullable<SelectProps['tagRender']>>[0];
const REMOVE_CLASS = 'select-tag-remove';
// Stops rc-select from opening the dropdown or eating Enter/Space before the click fires.
function keepFromSelect(e: KeyboardEvent | MouseEvent) {
if ('key' in e && e.key === 'Tab') return;
e.stopPropagation();
if (e.type === 'mousedown') e.preventDefault();
}
function SelectTag({ label, value, closable, onClose }: SelectTagProps) {
const { t } = useTranslation();
const { getPrefixCls } = useContext(ConfigProvider.ConfigContext);
const selectCls = getPrefixCls('select');
const itemCls = `${selectCls}-selection-item`;
const text =
typeof label === 'string' || typeof label === 'number' ? String(label) : String(value);
const remove = (e: MouseEvent<HTMLButtonElement>) => {
const button = e.currentTarget;
const root = button.closest(`.${selectCls}`);
const hadFocus = document.activeElement === button;
const index = root ? [...root.querySelectorAll(`.${REMOVE_CLASS}`)].indexOf(button) : -1;
onClose(e);
if (!root || !hadFocus) return;
// Keyboard users would otherwise land on <body> once this tag unmounts.
requestAnimationFrame(() => {
const rest = root.querySelectorAll<HTMLElement>(`.${REMOVE_CLASS}`);
const next = rest[Math.min(index, rest.length - 1)] ?? root.querySelector('input');
next?.focus();
});
};
return (
<span className={itemCls} title={text}>
<span className={`${itemCls}-content`}>{label}</span>
{closable && (
<button
type="button"
className={`${itemCls}-remove ${REMOVE_CLASS}`}
aria-label={t('removeItem', { item: text })}
onMouseDown={keepFromSelect}
onKeyDown={keepFromSelect}
onClick={remove}
>
<CloseOutlined aria-hidden />
</button>
)}
</span>
);
}
// Ant Design renders each tag's "x" as an aria-hidden, unfocusable span, so screen-reader
// and keyboard users cannot remove a tag; this swaps in a real, named button.
export function selectTagRender(props: SelectTagProps) {
return <SelectTag {...props} />;
}
+1
View File
@@ -7,3 +7,4 @@ export { default as CipherSuitesSelect } from './CipherSuitesSelect';
export { default as RemarkTemplateField } from './RemarkTemplateField';
export { default as RemarkVarPicker } from './RemarkVarPicker';
export { default as CustomSockoptList } from '../../lib/xray/forms/transport/CustomSockoptList';
export { selectTagRender } from './SelectTag';