mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-11-12 16:15:02 +03:00
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:
@@ -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} />;
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user