mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-11-02 03:05: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:
+11
-4
@@ -11,7 +11,8 @@
|
||||
"jsPlugins": [
|
||||
"./tools/oxlint/input-number-guard.mjs",
|
||||
"./tools/oxlint/control-label-guard.mjs",
|
||||
"./tools/oxlint/icon-a11y-guard.mjs"
|
||||
"./tools/oxlint/icon-a11y-guard.mjs",
|
||||
"./tools/oxlint/antd-a11y-guard.mjs"
|
||||
],
|
||||
"categories": {
|
||||
"correctness": "error"
|
||||
@@ -52,7 +53,9 @@
|
||||
"input-number/no-synthetic-clear": "off",
|
||||
"jsx-a11y/prefer-tag-over-role": "off",
|
||||
"control-label/has-name": "off",
|
||||
"antd-icon/explicit-a11y": "off"
|
||||
"antd-icon/explicit-a11y": "off",
|
||||
"antd-a11y/spin-content-not-live": "off",
|
||||
"antd-a11y/select-tag-remove": "off"
|
||||
},
|
||||
"overrides": [
|
||||
{
|
||||
@@ -78,7 +81,9 @@
|
||||
],
|
||||
"rules": {
|
||||
"control-label/has-name": "error",
|
||||
"antd-icon/explicit-a11y": "error"
|
||||
"antd-icon/explicit-a11y": "error",
|
||||
"antd-a11y/spin-content-not-live": "error",
|
||||
"antd-a11y/select-tag-remove": "error"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -88,7 +93,9 @@
|
||||
],
|
||||
"rules": {
|
||||
"control-label/has-name": "off",
|
||||
"antd-icon/explicit-a11y": "off"
|
||||
"antd-icon/explicit-a11y": "off",
|
||||
"antd-a11y/spin-content-not-live": "off",
|
||||
"antd-a11y/select-tag-remove": "off"
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -2,6 +2,7 @@ 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 }));
|
||||
@@ -36,6 +37,7 @@ export default function SniffingFields({ name, form, enableLabel }: SniffingFiel
|
||||
<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}
|
||||
@@ -61,6 +63,7 @@ export default function SniffingFields({ name, form, enableLabel }: SniffingFiel
|
||||
>
|
||||
<Select
|
||||
mode="tags"
|
||||
tagRender={selectTagRender}
|
||||
tokenSeparators={[',']}
|
||||
placeholder="IP/CIDR/geoip:*/ext:*"
|
||||
style={{ width: '100%' }}
|
||||
@@ -72,6 +75,7 @@ export default function SniffingFields({ name, form, enableLabel }: SniffingFiel
|
||||
>
|
||||
<Select
|
||||
mode="tags"
|
||||
tagRender={selectTagRender}
|
||||
tokenSeparators={[',']}
|
||||
placeholder="domain:*/ext:*"
|
||||
style={{ width: '100%' }}
|
||||
|
||||
@@ -16,6 +16,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import type { FormInstance } from 'antd/es/form';
|
||||
import type { NamePath } from 'antd/es/form/interface';
|
||||
|
||||
import { selectTagRender } from '@/components/form/SelectTag';
|
||||
import { RandomUtil } from '@/utils';
|
||||
import { activateOnKey } from '@/utils/a11y';
|
||||
import { OutboundProtocols, UTLS_FINGERPRINT } from '@/schemas/primitives';
|
||||
@@ -608,6 +609,7 @@ function FilterMaskSettings({ fieldName }: { fieldName: number }) {
|
||||
<Select
|
||||
aria-describedby={`${uid}-allowed-hint`}
|
||||
mode="tags"
|
||||
tagRender={selectTagRender}
|
||||
style={{ width: '100%' }}
|
||||
tokenSeparators={[',', ' ']}
|
||||
placeholder="10.0.0.0/8, geoip:ir"
|
||||
@@ -624,6 +626,7 @@ function FilterMaskSettings({ fieldName }: { fieldName: number }) {
|
||||
<Select
|
||||
aria-describedby={`${uid}-blocked-hint`}
|
||||
mode="tags"
|
||||
tagRender={selectTagRender}
|
||||
style={{ width: '100%' }}
|
||||
tokenSeparators={[',', ' ']}
|
||||
placeholder="203.0.113.7, geoip:cn"
|
||||
@@ -649,7 +652,12 @@ function SudokuSettings({ fieldName }: { fieldName: number }) {
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item label="Custom Tables" name={[fieldName, 'settings', 'customTables']}>
|
||||
<Select mode="tags" style={{ width: '100%' }} tokenSeparators={[',']} />
|
||||
<Select
|
||||
mode="tags"
|
||||
tagRender={selectTagRender}
|
||||
style={{ width: '100%' }}
|
||||
tokenSeparators={[',']}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label="Padding Min" name={[fieldName, 'settings', 'paddingMin']}>
|
||||
<InputNumber min={0} />
|
||||
@@ -1175,7 +1183,12 @@ function UdpMaskItem({
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item label="IPs" name={[fieldName, 'settings', 'ips']}>
|
||||
<Select mode="tags" style={{ width: '100%' }} tokenSeparators={[',']} />
|
||||
<Select
|
||||
mode="tags"
|
||||
tagRender={selectTagRender}
|
||||
style={{ width: '100%' }}
|
||||
tokenSeparators={[',']}
|
||||
/>
|
||||
</Form.Item>
|
||||
</>
|
||||
);
|
||||
@@ -1189,6 +1202,7 @@ function UdpMaskItem({
|
||||
<Form.Item label="STUN Servers" name={[fieldName, 'settings', 'stunServers']}>
|
||||
<Select
|
||||
mode="tags"
|
||||
tagRender={selectTagRender}
|
||||
style={{ width: '100%' }}
|
||||
tokenSeparators={[',']}
|
||||
placeholder="host:port"
|
||||
@@ -1236,6 +1250,7 @@ function UdpMaskItem({
|
||||
<Form.Item label="ALPN" name={[fieldName, 'settings', 'tlsConfig', 'alpn']}>
|
||||
<Select
|
||||
mode="multiple"
|
||||
tagRender={selectTagRender}
|
||||
style={{ width: '100%' }}
|
||||
options={[
|
||||
{ value: 'h3', label: 'h3' },
|
||||
@@ -1529,6 +1544,7 @@ function XdnsSettings({ udpFieldName }: { udpFieldName: number }) {
|
||||
<Form.Item label="Names" name={[domain.name, 'names']}>
|
||||
<Select
|
||||
mode="tags"
|
||||
tagRender={selectTagRender}
|
||||
style={{ width: '100%' }}
|
||||
tokenSeparators={[',']}
|
||||
placeholder="t.example.com"
|
||||
@@ -1537,6 +1553,7 @@ function XdnsSettings({ udpFieldName }: { udpFieldName: number }) {
|
||||
<Form.Item label="Record Types" name={[domain.name, 'types']}>
|
||||
<Select
|
||||
mode="multiple"
|
||||
tagRender={selectTagRender}
|
||||
options={XDNS_RECORD_TYPE_OPTIONS}
|
||||
placeholder="TXT (client), all (server)"
|
||||
/>
|
||||
@@ -1583,6 +1600,7 @@ function XdnsSettings({ udpFieldName }: { udpFieldName: number }) {
|
||||
<Form.Item label="Addresses" name={[resolver.name, 'addrs']}>
|
||||
<Select
|
||||
mode="tags"
|
||||
tagRender={selectTagRender}
|
||||
style={{ width: '100%' }}
|
||||
tokenSeparators={[',', ' ']}
|
||||
placeholder="udp://8.8.8.8:53, https://dns.google/dns-query"
|
||||
@@ -1670,7 +1688,7 @@ function UdpHopSettings({ udpFieldName }: { udpFieldName: number }) {
|
||||
normalize={(value: string[]) => value.join(',')}
|
||||
rules={[{ required: true, message: 'pick at least one mode' }]}
|
||||
>
|
||||
<Select mode="multiple" options={UDP_HOP_MODES} />
|
||||
<Select mode="multiple" tagRender={selectTagRender} options={UDP_HOP_MODES} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="Interval (s)"
|
||||
@@ -1683,7 +1701,12 @@ function UdpHopSettings({ udpFieldName }: { udpFieldName: number }) {
|
||||
<Input placeholder="20000-50000" />
|
||||
</Form.Item>
|
||||
<Form.Item label="Remote IPs" name={[udpFieldName, 'settings', 'remoteIPs']}>
|
||||
<Select mode="tags" style={{ width: '100%' }} tokenSeparators={[',', ' ']} />
|
||||
<Select
|
||||
mode="tags"
|
||||
tagRender={selectTagRender}
|
||||
style={{ width: '100%' }}
|
||||
tokenSeparators={[',', ' ']}
|
||||
/>
|
||||
</Form.Item>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Input, InputNumber, Select, Switch, Typography } from 'antd';
|
||||
import { useFormContext, useWatch } from 'react-hook-form';
|
||||
|
||||
import { JsonEditor } from '@/components/form';
|
||||
import { selectTagRender } from '@/components/form/SelectTag';
|
||||
import { FormField } from '@/components/form/rhf';
|
||||
import { XDriveServiceSchema } from '@/schemas/protocols/stream/xdrive';
|
||||
|
||||
@@ -161,7 +162,7 @@ export default function XDriveForm() {
|
||||
tooltip={t('pages.inbounds.form.xdrive.secretsDesc')}
|
||||
name={[...BASE, 'secrets']}
|
||||
>
|
||||
<Select mode="tags" tokenSeparators={[',']} open={false} />
|
||||
<Select mode="tags" tagRender={selectTagRender} tokenSeparators={[',']} open={false} />
|
||||
</FormField>
|
||||
<FormField
|
||||
label={t('pages.inbounds.form.xdrive.template')}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Alert, Modal, Select, Typography, message } from 'antd';
|
||||
|
||||
import { SelectAllClearButtons } from '@/components/form';
|
||||
import { SelectAllClearButtons, selectTagRender } from '@/components/form';
|
||||
import type { InboundOption } from '@/hooks/useClients';
|
||||
import { formatInboundLabel } from '@/lib/inbounds/label';
|
||||
import type { BulkAttachResult } from '@/schemas/client';
|
||||
@@ -106,6 +106,7 @@ export default function BulkAttachInboundsModal({
|
||||
/>
|
||||
<Select
|
||||
mode="multiple"
|
||||
tagRender={selectTagRender}
|
||||
style={{ width: '100%' }}
|
||||
value={targetIds}
|
||||
onChange={setTargetIds}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Alert, Modal, Select, Typography, message } from 'antd';
|
||||
|
||||
import { SelectAllClearButtons } from '@/components/form';
|
||||
import { SelectAllClearButtons, selectTagRender } from '@/components/form';
|
||||
import type { InboundOption } from '@/hooks/useClients';
|
||||
import { formatInboundLabel } from '@/lib/inbounds/label';
|
||||
import type { BulkDetachResult } from '@/schemas/client';
|
||||
@@ -106,6 +106,7 @@ export default function BulkDetachInboundsModal({
|
||||
/>
|
||||
<Select
|
||||
mode="multiple"
|
||||
tagRender={selectTagRender}
|
||||
style={{ width: '100%' }}
|
||||
value={targetIds}
|
||||
onChange={setTargetIds}
|
||||
|
||||
@@ -21,7 +21,7 @@ import { FormProvider, useForm, useWatch } from 'react-hook-form';
|
||||
import { RandomUtil, SizeFormatter } from '@/utils';
|
||||
import { formatInboundLabel } from '@/lib/inbounds/label';
|
||||
import { TLS_FLOW_CONTROL, TRAFFIC_RESETS } from '@/schemas/primitives';
|
||||
import { DateTimePicker, SelectAllClearButtons } from '@/components/form';
|
||||
import { DateTimePicker, SelectAllClearButtons, selectTagRender } from '@/components/form';
|
||||
import { FormField } from '@/components/form/rhf';
|
||||
import { useClients, type InboundOption } from '@/hooks/useClients';
|
||||
import { useFail2banStatusQuery, getLimitIpNotice } from '@/api/queries/useFail2banStatusQuery';
|
||||
@@ -268,6 +268,7 @@ export default function ClientBulkAddModal({
|
||||
<Select
|
||||
id={`${uid}-inbounds`}
|
||||
mode="multiple"
|
||||
tagRender={selectTagRender}
|
||||
value={inboundIds}
|
||||
onChange={(v) => methods.setValue('inboundIds', v)}
|
||||
options={inboundOptions}
|
||||
|
||||
@@ -38,7 +38,7 @@ import { resolveExternalLinkExpiry } from '@/lib/clients/external-link';
|
||||
import { useDatepicker } from '@/hooks/useDatepicker';
|
||||
import { useClientHwids } from '@/hooks/useClientHwids';
|
||||
import { useRawHostsQuery } from '@/api/queries/useRawHostsQuery';
|
||||
import { DateTimePicker, SelectAllClearButtons } from '@/components/form';
|
||||
import { DateTimePicker, SelectAllClearButtons, selectTagRender } from '@/components/form';
|
||||
import { FormField, rhfZodValidate } from '@/components/form/rhf';
|
||||
import ClientHwidListModal from '@/components/clients/ClientHwidList';
|
||||
import { TLS_FLOW_CONTROL, TRAFFIC_RESETS } from '@/schemas/primitives';
|
||||
@@ -1168,11 +1168,11 @@ export default function ClientFormModal({
|
||||
<Select
|
||||
id={`${uid}-host-rule-override`}
|
||||
mode="multiple"
|
||||
tagRender={selectTagRender}
|
||||
value={clientHostRuleIds || []}
|
||||
onChange={(v) => methods.setValue('clientHostRuleIds', v)}
|
||||
options={hostRuleOptions}
|
||||
placeholder={t('pages.clients.hostRuleOverridePlaceholder')}
|
||||
maxTagCount="responsive"
|
||||
placement="topLeft"
|
||||
listHeight={220}
|
||||
allowClear
|
||||
@@ -1226,11 +1226,11 @@ export default function ClientFormModal({
|
||||
<Select
|
||||
id={`${uid}-inbounds`}
|
||||
mode="multiple"
|
||||
tagRender={selectTagRender}
|
||||
value={inboundIds}
|
||||
onChange={handleInboundChange}
|
||||
options={inboundOptions}
|
||||
placeholder={t('pages.clients.selectInbound')}
|
||||
maxTagCount="responsive"
|
||||
placement="topLeft"
|
||||
listHeight={220}
|
||||
showSearch={{
|
||||
|
||||
@@ -163,7 +163,7 @@ function SubscriptionQrPresentation({
|
||||
showIcon
|
||||
title={t('pages.clients.happLinkDisclosure')}
|
||||
/>
|
||||
<Spin spinning={happLoading}>
|
||||
<Spin aria-live="off" spinning={happLoading}>
|
||||
<div style={{ minHeight: happLoading ? 48 : undefined }}>
|
||||
{happLink ? (
|
||||
<>
|
||||
@@ -563,7 +563,7 @@ function ClientQrModalContent({
|
||||
centered
|
||||
onCancel={() => onOpenChange(false)}
|
||||
>
|
||||
<Spin spinning={loading}>
|
||||
<Spin aria-live="off" spinning={loading}>
|
||||
{!client?.subId && !loading && (
|
||||
<div style={{ padding: 24, textAlign: 'center', opacity: 0.6 }}>
|
||||
{t('pages.clients.noSubId')}
|
||||
|
||||
@@ -1305,6 +1305,7 @@ export default function ClientsPage() {
|
||||
<Layout className="content-shell">
|
||||
<Layout.Content id="content-layout" className="content-area">
|
||||
<Spin
|
||||
aria-live="off"
|
||||
spinning={!fetched || !hostsFetched}
|
||||
delay={200}
|
||||
description={t('loading')}
|
||||
@@ -1724,7 +1725,7 @@ export default function ClientsPage() {
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Spin spinning={transitioning}>
|
||||
<Spin aria-live="off" spinning={transitioning}>
|
||||
<div className="client-cards">
|
||||
{filteredClients.length > 0 && (
|
||||
<div className="card-bulk-bar">
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
import dayjs from 'dayjs';
|
||||
import type { Dayjs } from 'dayjs';
|
||||
|
||||
import { selectTagRender } from '@/components/form';
|
||||
import type { InboundOption } from '@/hooks/useClients';
|
||||
import type { NodeRecord } from '@/schemas/node';
|
||||
import { formatInboundLabel } from '@/lib/inbounds/label';
|
||||
@@ -118,11 +119,11 @@ export default function FilterDrawer({
|
||||
<Select
|
||||
id={`${uid}-protocols`}
|
||||
mode="multiple"
|
||||
tagRender={selectTagRender}
|
||||
value={filters.protocols}
|
||||
onChange={(v) => patch('protocols', v as string[])}
|
||||
options={protocolOptions}
|
||||
placeholder={t('pages.inbounds.protocol')}
|
||||
maxTagCount="responsive"
|
||||
allowClear
|
||||
/>
|
||||
</Form.Item>
|
||||
@@ -131,11 +132,11 @@ export default function FilterDrawer({
|
||||
<Select
|
||||
id={`${uid}-inbounds`}
|
||||
mode="multiple"
|
||||
tagRender={selectTagRender}
|
||||
value={filters.inboundIds}
|
||||
onChange={(v) => patch('inboundIds', v as number[])}
|
||||
options={inboundOptions}
|
||||
placeholder={t('inbounds')}
|
||||
maxTagCount="responsive"
|
||||
allowClear
|
||||
showSearch={{ optionFilterProp: 'label' }}
|
||||
listHeight={220}
|
||||
@@ -147,11 +148,11 @@ export default function FilterDrawer({
|
||||
<Select
|
||||
id={`${uid}-nodes`}
|
||||
mode="multiple"
|
||||
tagRender={selectTagRender}
|
||||
value={filters.nodeIds}
|
||||
onChange={(v) => patch('nodeIds', v as number[])}
|
||||
options={nodeOptions}
|
||||
placeholder={t('pages.clients.filters.nodes')}
|
||||
maxTagCount="responsive"
|
||||
allowClear
|
||||
showSearch={{ optionFilterProp: 'label' }}
|
||||
listHeight={220}
|
||||
@@ -163,11 +164,11 @@ export default function FilterDrawer({
|
||||
<Select
|
||||
id={`${uid}-groups`}
|
||||
mode="multiple"
|
||||
tagRender={selectTagRender}
|
||||
value={filters.groups}
|
||||
onChange={(v) => patch('groups', v as string[])}
|
||||
options={groupOptions}
|
||||
placeholder={t('pages.clients.groupPlaceholder')}
|
||||
maxTagCount="responsive"
|
||||
allowClear
|
||||
showSearch={{ optionFilterProp: 'label' }}
|
||||
listHeight={220}
|
||||
|
||||
@@ -496,7 +496,13 @@ export default function GroupsPage() {
|
||||
<AppSidebar />
|
||||
<Layout className="content-shell">
|
||||
<Layout.Content id="content-layout" className="content-area">
|
||||
<Spin spinning={!fetched} delay={200} description={t('loading')} size="large">
|
||||
<Spin
|
||||
aria-live="off"
|
||||
spinning={!fetched}
|
||||
delay={200}
|
||||
description={t('loading')}
|
||||
size="large"
|
||||
>
|
||||
{!fetched ? (
|
||||
<div className="loading-spacer" />
|
||||
) : fetchError ? (
|
||||
|
||||
@@ -28,7 +28,7 @@ import type { HostRecord } from '@/api/queries/useHostsQuery';
|
||||
import { BulkAddHostSchema, type BulkAddHostValues } from '@/schemas/api/host';
|
||||
import type { InboundOption } from '@/schemas/client';
|
||||
import { ALPN_OPTION, UTLS_FINGERPRINT } from '@/schemas/primitives';
|
||||
import { CipherSuitesSelect } from '@/components/form';
|
||||
import { CipherSuitesSelect, selectTagRender } from '@/components/form';
|
||||
import DynamicEchHelper from '@/components/form/DynamicEchHelper';
|
||||
import { FormField, rhfZodValidate } from '@/components/form/rhf';
|
||||
import { useNodesQuery } from '@/api/queries/useNodesQuery';
|
||||
@@ -291,6 +291,7 @@ export default function HostFormModal({
|
||||
>
|
||||
<Select
|
||||
mode="multiple"
|
||||
tagRender={selectTagRender}
|
||||
options={inboundSelectOptions}
|
||||
showSearch={{ optionFilterProp: 'label' }}
|
||||
placeholder={t('pages.hosts.selectInbound')}
|
||||
@@ -304,6 +305,7 @@ export default function HostFormModal({
|
||||
>
|
||||
<Select
|
||||
mode="tags"
|
||||
tagRender={selectTagRender}
|
||||
options={hostOptions}
|
||||
tokenSeparators={[',', ';', ' ']}
|
||||
placeholder="cdn.example.com, cdn2.example.com:443"
|
||||
@@ -326,7 +328,12 @@ export default function HostFormModal({
|
||||
label={t('pages.hosts.fields.tags')}
|
||||
tooltip={t('pages.hosts.hints.tags')}
|
||||
>
|
||||
<Select mode="tags" allowClear tokenSeparators={[',']} />
|
||||
<Select
|
||||
mode="tags"
|
||||
tagRender={selectTagRender}
|
||||
allowClear
|
||||
tokenSeparators={[',']}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField
|
||||
name="nodeGuids"
|
||||
@@ -335,6 +342,7 @@ export default function HostFormModal({
|
||||
>
|
||||
<Select
|
||||
mode="multiple"
|
||||
tagRender={selectTagRender}
|
||||
allowClear
|
||||
options={nodeSelectOptions}
|
||||
showSearch={{ optionFilterProp: 'label' }}
|
||||
@@ -377,6 +385,7 @@ export default function HostFormModal({
|
||||
>
|
||||
<Select
|
||||
mode="tags"
|
||||
tagRender={selectTagRender}
|
||||
options={sniOptions}
|
||||
tokenSeparators={[',', ';', ' ']}
|
||||
placeholder="sni.example.com, sni2.example.com"
|
||||
@@ -413,7 +422,12 @@ export default function HostFormModal({
|
||||
{showTlsExtras && (
|
||||
<>
|
||||
<FormField name="alpn" label={t('pages.hosts.fields.alpn')}>
|
||||
<Select mode="multiple" allowClear options={alpnOptions} />
|
||||
<Select
|
||||
mode="multiple"
|
||||
tagRender={selectTagRender}
|
||||
allowClear
|
||||
options={alpnOptions}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField
|
||||
name="cipherSuites"
|
||||
@@ -425,7 +439,12 @@ export default function HostFormModal({
|
||||
/>
|
||||
</FormField>
|
||||
<FormField name="pinnedPeerCertSha256" label={t('pages.hosts.fields.pins')}>
|
||||
<Select mode="tags" allowClear tokenSeparators={[',']} />
|
||||
<Select
|
||||
mode="tags"
|
||||
tagRender={selectTagRender}
|
||||
allowClear
|
||||
tokenSeparators={[',']}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField
|
||||
name="verifyPeerCertByName"
|
||||
@@ -519,6 +538,7 @@ export default function HostFormModal({
|
||||
>
|
||||
<Select
|
||||
mode="multiple"
|
||||
tagRender={selectTagRender}
|
||||
allowClear
|
||||
options={['raw', 'json', 'clash'].map((v) => ({
|
||||
value: v,
|
||||
|
||||
@@ -180,7 +180,7 @@ export default function HostsPage() {
|
||||
<AppSidebar />
|
||||
<Layout className="content-shell">
|
||||
<Layout.Content id="content-layout" className="content-area">
|
||||
<Spin spinning={!fetched} delay={200} size="large">
|
||||
<Spin aria-live="off" spinning={!fetched} delay={200} size="large">
|
||||
{!fetched ? (
|
||||
<div className="loading-spacer" />
|
||||
) : fetchError ? (
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import { Modal, Select, Typography, message } from 'antd';
|
||||
|
||||
import { HttpUtil } from '@/utils';
|
||||
import { SelectAllClearButtons } from '@/components/form';
|
||||
import { SelectAllClearButtons, selectTagRender } from '@/components/form';
|
||||
import { buildClonePayload, pickClonePort } from '@/lib/xray/inbound-clone';
|
||||
import type { NodeRecord } from '@/api/queries/useNodesQuery';
|
||||
import type { DBInbound } from '@/models/dbinbound';
|
||||
@@ -132,6 +132,7 @@ export default function CloneInboundModal({
|
||||
<Select
|
||||
aria-label={t('pages.inbounds.deployTo')}
|
||||
mode="multiple"
|
||||
tagRender={selectTagRender}
|
||||
style={{ width: '100%' }}
|
||||
value={targets}
|
||||
onChange={setTargets}
|
||||
|
||||
@@ -720,6 +720,7 @@ export default function InboundsPage() {
|
||||
<Layout className="content-shell">
|
||||
<Layout.Content id="content-layout" className="content-area">
|
||||
<Spin
|
||||
aria-live="off"
|
||||
spinning={!fetched || !hostsFetched}
|
||||
delay={200}
|
||||
description={t('loading')}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import { Alert, Input, Modal, Select, Space, Table, Tag, Typography, message } from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
|
||||
import { selectTagRender } from '@/components/form';
|
||||
import { HttpUtil } from '@/utils';
|
||||
import { formatInboundLabel } from '@/lib/inbounds/label';
|
||||
import { coerceInboundJsonField, type DBInbound } from '@/models/dbinbound';
|
||||
@@ -207,6 +208,7 @@ export default function AttachClientsModal({
|
||||
) : (
|
||||
<Select
|
||||
mode="multiple"
|
||||
tagRender={selectTagRender}
|
||||
aria-label={t('pages.inbounds.attachClientsTargets')}
|
||||
style={{ width: '100%' }}
|
||||
value={targetIds}
|
||||
|
||||
@@ -200,7 +200,7 @@ export default function AttachExistingClientsModal({
|
||||
{noClients ? (
|
||||
<Alert type="info" showIcon title={t('pages.inbounds.attachExistingNoClients')} />
|
||||
) : (
|
||||
<Spin spinning={loading}>
|
||||
<Spin aria-live="off" spinning={loading}>
|
||||
<Space orientation="vertical" size="small" style={{ width: '100%' }}>
|
||||
<Space style={{ width: '100%', justifyContent: 'space-between' }} wrap>
|
||||
<Space wrap>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Input, InputNumber, Select } from 'antd';
|
||||
|
||||
import { selectTagRender } from '@/components/form';
|
||||
import { FormField } from '@/components/form/rhf';
|
||||
import { MASQUE_DEFAULT_PATH } from '@/schemas/protocols/stream/masque';
|
||||
|
||||
@@ -14,7 +15,7 @@ export function MasqueSettingsFields() {
|
||||
name={['settings', 'address']}
|
||||
required
|
||||
>
|
||||
<Select mode="tags" tokenSeparators={[',', ' ']} open={false} />
|
||||
<Select mode="tags" tagRender={selectTagRender} tokenSeparators={[',', ' ']} open={false} />
|
||||
</FormField>
|
||||
<FormField label={t('pages.inbounds.info.mtu')} name={['settings', 'mtu']}>
|
||||
<InputNumber min={1280} max={65535} placeholder="1280" style={{ width: '100%' }} />
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
import { CloudDownloadOutlined, SyncOutlined } from '@ant-design/icons';
|
||||
import { useFormContext, useWatch } from 'react-hook-form';
|
||||
|
||||
import { selectTagRender } from '@/components/form';
|
||||
import { FormField } from '@/components/form/rhf';
|
||||
import { HttpUtil } from '@/utils';
|
||||
|
||||
@@ -210,6 +211,7 @@ export default function TuicFields() {
|
||||
<FormField name={['settings', 'server', 'alpn']} label={t('pages.xray.tuic.alpn')}>
|
||||
<Select
|
||||
mode="tags"
|
||||
tagRender={selectTagRender}
|
||||
tokenSeparators={[',']}
|
||||
options={[
|
||||
{ label: 'h3', value: 'h3' },
|
||||
|
||||
@@ -4,6 +4,7 @@ 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 {
|
||||
@@ -112,6 +113,7 @@ export default function TunFields() {
|
||||
<Select
|
||||
id="tun-wfp-block-leak"
|
||||
mode="tags"
|
||||
tagRender={selectTagRender}
|
||||
tokenSeparators={[',', ' ']}
|
||||
allowClear
|
||||
options={WFP_BLOCK_LEAK_OPTIONS}
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
import { RadarChartOutlined, ReloadOutlined, SearchOutlined } from '@ant-design/icons';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
import { selectTagRender } from '@/components/form';
|
||||
import { FormField } from '@/components/form/rhf';
|
||||
import { UTLS_FINGERPRINT } from '@/schemas/primitives';
|
||||
import {
|
||||
@@ -208,7 +209,12 @@ export default function RealityForm({
|
||||
</Form.Item>
|
||||
)}
|
||||
<FormField label="SNI" name={['streamSettings', 'realitySettings', 'serverNames']}>
|
||||
<Select mode="tags" tokenSeparators={[',']} style={{ width: '100%' }} />
|
||||
<Select
|
||||
mode="tags"
|
||||
tagRender={selectTagRender}
|
||||
tokenSeparators={[',']}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField
|
||||
name={['streamSettings', 'realitySettings', 'maxTimediff']}
|
||||
@@ -251,6 +257,7 @@ export default function RealityForm({
|
||||
<Select
|
||||
id={`${uid}-shortIds`}
|
||||
mode="tags"
|
||||
tagRender={selectTagRender}
|
||||
tokenSeparators={[',']}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
} from '@ant-design/icons';
|
||||
import { useFieldArray, useFormContext, useWatch } from 'react-hook-form';
|
||||
|
||||
import { CipherSuitesSelect } from '@/components/form';
|
||||
import { CipherSuitesSelect, selectTagRender } from '@/components/form';
|
||||
import DynamicEchHelper from '@/components/form/DynamicEchHelper';
|
||||
import { FormField } from '@/components/form/rhf';
|
||||
import {
|
||||
@@ -380,6 +380,7 @@ export default function TlsForm({
|
||||
<FormField name={['streamSettings', 'tlsSettings', 'alpn']} label="ALPN">
|
||||
<Select
|
||||
mode="multiple"
|
||||
tagRender={selectTagRender}
|
||||
tokenSeparators={[',']}
|
||||
style={{ width: '100%' }}
|
||||
options={Object.values(ALPN_OPTION).map((a) => ({ value: a, label: a }))}
|
||||
@@ -392,6 +393,7 @@ export default function TlsForm({
|
||||
>
|
||||
<Select
|
||||
mode="tags"
|
||||
tagRender={selectTagRender}
|
||||
tokenSeparators={[',', ' ']}
|
||||
style={{ width: '100%' }}
|
||||
options={['X25519MLKEM768', 'X25519', 'P-256', 'P-384', 'P-521'].map((c) => ({
|
||||
@@ -478,6 +480,7 @@ export default function TlsForm({
|
||||
id={`${uid}-pinnedPeerCertSha256`}
|
||||
aria-describedby={`${uid}-pinnedPeerCertSha256-hint`}
|
||||
mode="tags"
|
||||
tagRender={selectTagRender}
|
||||
tokenSeparators={[',', ' ']}
|
||||
placeholder={t('pages.inbounds.form.pinnedPeerCertSha256Placeholder')}
|
||||
style={{ width: 'calc(100% - 64px)' }}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import { Alert, Form, InputNumber, Segmented, Select, Switch } from 'antd';
|
||||
import { Controller, useFormContext, useWatch } from 'react-hook-form';
|
||||
|
||||
import { selectTagRender } from '@/components/form';
|
||||
import { FormField } from '@/components/form/rhf';
|
||||
import { SockoptCustomField } from '@/lib/xray/forms/fields';
|
||||
import { TCP_CONGESTION_OPTION } from '@/schemas/primitives';
|
||||
@@ -228,6 +229,7 @@ export default function SockoptForm({
|
||||
>
|
||||
<Select
|
||||
mode="tags"
|
||||
tagRender={selectTagRender}
|
||||
style={{ width: '100%' }}
|
||||
tokenSeparators={[',']}
|
||||
options={[
|
||||
|
||||
@@ -178,7 +178,7 @@ export default function GeodataSection({ active, onBusy, onClose }: GeodataSecti
|
||||
<div>
|
||||
{modalContextHolder}
|
||||
{messageContextHolder}
|
||||
<Spin spinning={loading}>
|
||||
<Spin aria-live="off" spinning={loading}>
|
||||
<Alert type="info" className="mb-12" title={t('pages.index.geodataHint')} showIcon />
|
||||
<Form layout="vertical">
|
||||
<Form.Item
|
||||
|
||||
@@ -174,6 +174,7 @@ export default function IndexPage() {
|
||||
<Layout className="content-shell">
|
||||
<Layout.Content className="content-area">
|
||||
<Spin
|
||||
aria-live="off"
|
||||
spinning={loading || !fetched}
|
||||
delay={200}
|
||||
description={loading ? loadingTip : t('loading')}
|
||||
|
||||
@@ -103,7 +103,7 @@ export default function VersionModal({ open, status, onClose, onBusy }: VersionM
|
||||
return (
|
||||
<Modal open={open} title={t('pages.index.xrayUpdates')} footer={null} onCancel={onClose}>
|
||||
{modalContextHolder}
|
||||
<Spin spinning={loading}>
|
||||
<Spin aria-live="off" spinning={loading}>
|
||||
<Collapse
|
||||
accordion
|
||||
activeKey={activeKey}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useId, useMemo } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { Card, theme } from 'antd';
|
||||
|
||||
@@ -31,6 +31,7 @@ export default function VitalTile({
|
||||
isMobile,
|
||||
}: VitalTileProps) {
|
||||
const { token } = theme.useToken();
|
||||
const labelId = useId();
|
||||
const meanColor = token.colorTextTertiary;
|
||||
|
||||
const referenceLines = useMemo(
|
||||
@@ -39,10 +40,18 @@ export default function VitalTile({
|
||||
);
|
||||
|
||||
return (
|
||||
<Card hoverable className="ov-tile" styles={{ body: { padding: 0 } }}>
|
||||
<Card
|
||||
hoverable
|
||||
className="ov-tile"
|
||||
styles={{ body: { padding: 0 } }}
|
||||
role="group"
|
||||
aria-labelledby={labelId}
|
||||
>
|
||||
<div className="ov-tile-head">
|
||||
<span className="ov-tile-icon">{icon}</span>
|
||||
<span className="ov-kicker">{label}</span>
|
||||
<span className="ov-kicker" id={labelId}>
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="ov-tile-value">
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
message,
|
||||
} from 'antd';
|
||||
import { FormProvider, useForm, useWatch } from 'react-hook-form';
|
||||
import { selectTagRender } from '@/components/form';
|
||||
import type { NodeRecord } from '@/api/queries/useNodesQuery';
|
||||
import type { RemoteInboundOption } from '@/api/queries/useNodeMutations';
|
||||
import type { Msg } from '@/utils';
|
||||
@@ -431,6 +432,7 @@ export default function NodeFormModal({
|
||||
>
|
||||
<Select
|
||||
mode="multiple"
|
||||
tagRender={selectTagRender}
|
||||
allowClear
|
||||
loading={fetchingInbounds}
|
||||
placeholder={t('pages.nodes.inboundTagsPlaceholder')}
|
||||
|
||||
@@ -290,7 +290,13 @@ export default function NodesPage() {
|
||||
|
||||
<Layout className="content-shell">
|
||||
<Layout.Content id="content-layout" className="content-area">
|
||||
<Spin spinning={!fetched} delay={200} description={t('loading')} size="large">
|
||||
<Spin
|
||||
aria-live="off"
|
||||
spinning={!fetched}
|
||||
delay={200}
|
||||
description={t('loading')}
|
||||
size="large"
|
||||
>
|
||||
{!fetched ? (
|
||||
<div className="loading-spacer" />
|
||||
) : fetchError ? (
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
SafetyCertificateOutlined,
|
||||
SettingOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { selectTagRender } from '@/components/form';
|
||||
import type { AllSetting } from '@/models/setting';
|
||||
import { isOutboundProtocol } from '@/schemas/primitives';
|
||||
import { HttpUtil, LanguageManager } from '@/utils';
|
||||
@@ -637,6 +638,7 @@ export default function GeneralTab({ allSetting, updateSetting }: GeneralTabProp
|
||||
<>
|
||||
<Select
|
||||
mode="multiple"
|
||||
tagRender={selectTagRender}
|
||||
value={ldapInboundTagList}
|
||||
onChange={setLdapInboundTagList}
|
||||
style={{ width: '100%' }}
|
||||
|
||||
@@ -381,7 +381,7 @@ export default function SecurityTab({ allSetting, updateSetting, saveSetting }:
|
||||
+ {t('pages.settings.security.apiTokenNew') || 'New token'}
|
||||
</Button>
|
||||
</div>
|
||||
<Spin spinning={apiTokensLoading}>
|
||||
<Spin aria-live="off" spinning={apiTokensLoading}>
|
||||
{!apiTokens.length && !apiTokensLoading && (
|
||||
<Empty
|
||||
description={t('pages.settings.security.apiTokenEmpty') || 'No tokens yet'}
|
||||
|
||||
@@ -242,6 +242,7 @@ export default function SettingsPage() {
|
||||
<Layout className="content-shell">
|
||||
<Layout.Content id="content-layout" className="content-area">
|
||||
<Spin
|
||||
aria-live="off"
|
||||
spinning={spinning || !fetched}
|
||||
delay={200}
|
||||
description={t('loading')}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import { Form, Input, InputNumber, Modal, Select, Switch, message } from 'antd';
|
||||
import { Controller, FormProvider, useForm, useWatch } from 'react-hook-form';
|
||||
|
||||
import { selectTagRender } from '@/components/form';
|
||||
import { FormField, rhfZodValidate } from '@/components/form/rhf';
|
||||
import SelectAllClearButtons from '@/components/form/SelectAllClearButtons';
|
||||
import { useInboundOptions } from '@/api/queries/useInboundOptions';
|
||||
@@ -166,8 +167,8 @@ export default function SubBalancerFormModal({
|
||||
>
|
||||
<Select
|
||||
mode="multiple"
|
||||
tagRender={selectTagRender}
|
||||
options={inboundOptions}
|
||||
maxTagCount="responsive"
|
||||
listHeight={220}
|
||||
showSearch={{ optionFilterProp: 'label' }}
|
||||
/>
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
import type { AllSetting } from '@/models/setting';
|
||||
import { onNumber } from '@/utils/onNumber';
|
||||
import { SettingListItem } from '@/components/ui';
|
||||
import { GoRegexInput } from '@/components/form';
|
||||
import { GoRegexInput, selectTagRender } from '@/components/form';
|
||||
import { useMediaQuery } from '@/hooks/useMediaQuery';
|
||||
import { catTabLabel } from './catTabLabel';
|
||||
import { sanitizePath, normalizePath } from './uriPath';
|
||||
@@ -418,6 +418,7 @@ export default function SubscriptionFormatsTab({
|
||||
<SettingListItem paddings="small" title={<>{t('pages.settings.direct')} IPs</>}>
|
||||
<Select
|
||||
mode="tags"
|
||||
tagRender={selectTagRender}
|
||||
value={ruleValues('direct', 'ip')}
|
||||
style={{ width: '100%' }}
|
||||
onChange={(v) => setRuleValues('direct', 'ip', DEFAULT_DIRECT_RULES[1], v)}
|
||||
@@ -434,6 +435,7 @@ export default function SubscriptionFormatsTab({
|
||||
>
|
||||
<Select
|
||||
mode="tags"
|
||||
tagRender={selectTagRender}
|
||||
value={ruleValues('direct', 'domain')}
|
||||
style={{ width: '100%' }}
|
||||
onChange={(v) =>
|
||||
@@ -467,6 +469,7 @@ export default function SubscriptionFormatsTab({
|
||||
<SettingListItem paddings="small" title={t('pages.xray.blockdomains')}>
|
||||
<Select
|
||||
mode="tags"
|
||||
tagRender={selectTagRender}
|
||||
value={ruleValues('block', 'domain')}
|
||||
style={{ width: '100%' }}
|
||||
onChange={(v) => setRuleValues('block', 'domain', DEFAULT_BLOCK_RULES[0], v)}
|
||||
@@ -476,6 +479,7 @@ export default function SubscriptionFormatsTab({
|
||||
<SettingListItem paddings="small" title={t('pages.xray.blockips')}>
|
||||
<Select
|
||||
mode="tags"
|
||||
tagRender={selectTagRender}
|
||||
value={ruleValues('block', 'ip')}
|
||||
style={{ width: '100%' }}
|
||||
onChange={(v) => setRuleValues('block', 'ip', BLOCK_IP_RULE, v)}
|
||||
|
||||
@@ -72,7 +72,7 @@ export default function SponsorsPage() {
|
||||
<BecomeButton contact={data.contact} />
|
||||
</div>
|
||||
|
||||
<Spin spinning={!fetched} delay={200}>
|
||||
<Spin aria-live="off" spinning={!fetched} delay={200}>
|
||||
{sponsors.length === 0 ? (
|
||||
fetched && <YourBrandCard contact={data.contact} />
|
||||
) : (
|
||||
|
||||
@@ -319,6 +319,7 @@ export default function XrayPage() {
|
||||
<Layout className="content-shell">
|
||||
<Layout.Content id="content-layout" className="content-area">
|
||||
<Spin
|
||||
aria-live="off"
|
||||
spinning={spinning || !fetched}
|
||||
delay={200}
|
||||
description={t('loading')}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { MinusOutlined, PlusOutlined } from '@ant-design/icons';
|
||||
import { Controller, FormProvider, useForm, useWatch } from 'react-hook-form';
|
||||
import type { Path } from 'react-hook-form';
|
||||
|
||||
import { selectTagRender } from '@/components/form';
|
||||
import { InputAddon } from '@/components/ui';
|
||||
import { FormField } from '@/components/form/rhf';
|
||||
import type { XraySettingsValue } from '@/hooks/useXraySetting';
|
||||
@@ -253,6 +254,7 @@ export default function BalancerFormModal({
|
||||
<FormField name="selector" label={t('pages.xray.balancer.selector')} required>
|
||||
<Select
|
||||
mode="tags"
|
||||
tagRender={selectTagRender}
|
||||
tokenSeparators={[',']}
|
||||
options={outboundTags.map((tg) => ({ value: tg, label: tg }))}
|
||||
/>
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
SettingOutlined,
|
||||
} from '@ant-design/icons';
|
||||
|
||||
import { selectTagRender } from '@/components/form';
|
||||
import { onNumber } from '@/utils/onNumber';
|
||||
import { SettingListItem } from '@/components/ui';
|
||||
import { useMediaQuery } from '@/hooks/useMediaQuery';
|
||||
@@ -417,6 +418,7 @@ export default function DnsTab({ templateSettings, setTemplateSettings }: DnsTab
|
||||
/>
|
||||
<Select
|
||||
mode="tags"
|
||||
tagRender={selectTagRender}
|
||||
value={row.values}
|
||||
aria-label={t('pages.xray.dns.hostsValues')}
|
||||
placeholder={t('pages.xray.dns.hostsValues')}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { AutoComplete, Button, Form, Input, InputNumber, Select, Switch } from '
|
||||
import { DeleteOutlined, PlusOutlined } from '@ant-design/icons';
|
||||
import { useFieldArray, useFormContext, useWatch } from 'react-hook-form';
|
||||
|
||||
import { selectTagRender } from '@/components/form';
|
||||
import { FormField } from '@/components/form/rhf';
|
||||
import { activateOnKey } from '@/utils/a11y';
|
||||
import { OutboundDomainStrategies } from '@/schemas/primitives';
|
||||
@@ -274,6 +275,7 @@ export default function FreedomFields() {
|
||||
<FormField label="IP / CIDR / geoip" name={['settings', 'finalRules', index, 'ip']}>
|
||||
<Select
|
||||
mode="tags"
|
||||
tagRender={selectTagRender}
|
||||
tokenSeparators={[',', ' ']}
|
||||
placeholder="e.g. 10.0.0.0/8, geoip:private"
|
||||
/>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Select } from 'antd';
|
||||
|
||||
import { selectTagRender } from '@/components/form';
|
||||
import { FormField } from '@/components/form/rhf';
|
||||
|
||||
export default function MasqueFields() {
|
||||
@@ -9,6 +10,7 @@ export default function MasqueFields() {
|
||||
<FormField label={t('pages.xray.outboundForm.remoteDNS')} name={['settings', 'remoteDNS']}>
|
||||
<Select
|
||||
mode="tags"
|
||||
tagRender={selectTagRender}
|
||||
tokenSeparators={[',', ' ']}
|
||||
open={false}
|
||||
placeholder="1.1.1.1, 2606:4700:4700::1111"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Input, Select, Switch } from 'antd';
|
||||
|
||||
import { selectTagRender } from '@/components/form';
|
||||
import { FormField } from '@/components/form/rhf';
|
||||
|
||||
import { ALPN_OPTIONS, UTLS_OPTIONS } from '../outbound-form-constants';
|
||||
@@ -20,7 +21,7 @@ export default function TlsForm() {
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="ALPN" name={['streamSettings', 'tlsSettings', 'alpn']}>
|
||||
<Select mode="multiple" options={ALPN_OPTIONS} />
|
||||
<Select mode="multiple" tagRender={selectTagRender} options={ALPN_OPTIONS} />
|
||||
</FormField>
|
||||
<FormField label="ECH" name={['streamSettings', 'tlsSettings', 'echConfigList']}>
|
||||
<Input />
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import { Form, Input, Select, Switch } from 'antd';
|
||||
import { useFormContext, useWatch } from 'react-hook-form';
|
||||
|
||||
import { HeaderMapEditor } from '@/components/form';
|
||||
import { HeaderMapEditor, selectTagRender } from '@/components/form';
|
||||
import { FormField } from '@/components/form/rhf';
|
||||
import { MASQUE_DEFAULT_PATH } from '@/schemas/protocols/stream/masque';
|
||||
|
||||
@@ -79,6 +79,7 @@ export default function MasqueForm() {
|
||||
>
|
||||
<Select
|
||||
mode="tags"
|
||||
tagRender={selectTagRender}
|
||||
tokenSeparators={[',', ' ']}
|
||||
open={false}
|
||||
placeholder="172.16.0.2, 2606:4700:110:8a36::2"
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useCallback, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Alert, Select, Switch } from 'antd';
|
||||
|
||||
import { selectTagRender } from '@/components/form';
|
||||
import { SettingListItem } from '@/components/ui';
|
||||
import type { XraySettingsValue, SetTemplate } from '@/hooks/useXraySetting';
|
||||
import {
|
||||
@@ -105,6 +106,7 @@ export default function RoutingBasic({ templateSettings, setTemplateSettings }:
|
||||
control={
|
||||
<Select
|
||||
mode="tags"
|
||||
tagRender={selectTagRender}
|
||||
value={blockedIPs}
|
||||
style={{ width: '100%' }}
|
||||
options={IPS_OPTIONS}
|
||||
@@ -119,6 +121,7 @@ export default function RoutingBasic({ templateSettings, setTemplateSettings }:
|
||||
control={
|
||||
<Select
|
||||
mode="tags"
|
||||
tagRender={selectTagRender}
|
||||
value={blockedDomains}
|
||||
style={{ width: '100%' }}
|
||||
options={BLOCK_DOMAINS_OPTIONS}
|
||||
@@ -140,6 +143,7 @@ export default function RoutingBasic({ templateSettings, setTemplateSettings }:
|
||||
control={
|
||||
<Select
|
||||
mode="tags"
|
||||
tagRender={selectTagRender}
|
||||
value={directIPs}
|
||||
style={{ width: '100%' }}
|
||||
options={IPS_OPTIONS}
|
||||
@@ -159,6 +163,7 @@ export default function RoutingBasic({ templateSettings, setTemplateSettings }:
|
||||
control={
|
||||
<Select
|
||||
mode="tags"
|
||||
tagRender={selectTagRender}
|
||||
value={directDomains}
|
||||
style={{ width: '100%' }}
|
||||
options={DOMAINS_OPTIONS}
|
||||
@@ -179,6 +184,7 @@ export default function RoutingBasic({ templateSettings, setTemplateSettings }:
|
||||
control={
|
||||
<Select
|
||||
mode="tags"
|
||||
tagRender={selectTagRender}
|
||||
value={ipv4Domains}
|
||||
style={{ width: '100%' }}
|
||||
options={SERVICES_OPTIONS}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import { Button, Form, Input, Modal, Select, Space, Switch } from 'antd';
|
||||
import { PlusOutlined, MinusOutlined } from '@ant-design/icons';
|
||||
import { FormProvider, useForm, useWatch } from 'react-hook-form';
|
||||
import { selectTagRender } from '@/components/form';
|
||||
import { InputAddon } from '@/components/ui';
|
||||
import { GeoTokenInput } from '@/components/geodata';
|
||||
import { FormField } from '@/components/form/rhf';
|
||||
@@ -222,7 +223,11 @@ export default function RuleFormModal({
|
||||
</FormField>
|
||||
|
||||
<FormField name="protocol" label={t('pages.inbounds.protocol')}>
|
||||
<Select mode="multiple" options={PROTOCOLS.map((p) => ({ value: p, label: p }))} />
|
||||
<Select
|
||||
mode="multiple"
|
||||
tagRender={selectTagRender}
|
||||
options={PROTOCOLS.map((p) => ({ value: p, label: p }))}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<Form.Item label={t('pages.xray.ruleForm.attributes')}>
|
||||
@@ -292,6 +297,7 @@ export default function RuleFormModal({
|
||||
>
|
||||
<Select
|
||||
mode="tags"
|
||||
tagRender={selectTagRender}
|
||||
tokenSeparators={[',']}
|
||||
allowClear
|
||||
loading={clientsLoading}
|
||||
@@ -319,6 +325,7 @@ export default function RuleFormModal({
|
||||
<FormField name="inboundTag" label={t('pages.xray.ruleForm.inboundTags')}>
|
||||
<Select
|
||||
mode="multiple"
|
||||
tagRender={selectTagRender}
|
||||
options={inboundTags.map((tag) => ({
|
||||
value: tag,
|
||||
label: formatInboundTag(tag, remarkByTag),
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
// The Spin/Select screen-reader guards walk oxlint's AST by hand, so an oxlint
|
||||
// bump can kill them silently. These tests fail loudly when it does.
|
||||
const FIXTURES = 'tools/oxlint/__fixtures__';
|
||||
const SPIN_RULE = 'antd-a11y(spin-content-not-live)';
|
||||
const SELECT_RULE = 'antd-a11y(select-tag-remove)';
|
||||
|
||||
function runGuard(target: string): string {
|
||||
try {
|
||||
execFileSync(
|
||||
process.execPath,
|
||||
[
|
||||
'node_modules/oxlint/bin/oxlint',
|
||||
'-c',
|
||||
`${FIXTURES}/antd-a11y.oxlintrc.json`,
|
||||
'-f',
|
||||
'unix',
|
||||
target,
|
||||
],
|
||||
{ encoding: 'utf8', stdio: 'pipe' },
|
||||
);
|
||||
return '';
|
||||
} catch (error) {
|
||||
const { status, stdout } = error as { status?: number | null; stdout?: string };
|
||||
if (typeof status !== 'number') throw error;
|
||||
return String(stdout ?? '');
|
||||
}
|
||||
}
|
||||
|
||||
function hitLines(output: string, rule: string): number[] {
|
||||
return output
|
||||
.split('\n')
|
||||
.filter((line) => line.includes(rule))
|
||||
.map((line) => Number(line.split(':')[1]));
|
||||
}
|
||||
|
||||
describe('antd-a11y-guard oxlint plugin', () => {
|
||||
it('flags a Spin whose content would be announced on every update', () => {
|
||||
expect(hitLines(runGuard(`${FIXTURES}/antd-a11y-flagged.tsx`), SPIN_RULE)).toEqual([5]);
|
||||
});
|
||||
|
||||
it('flags tag Selects without a focusable remove button or with hidden tags', () => {
|
||||
expect(hitLines(runGuard(`${FIXTURES}/antd-a11y-flagged.tsx`), SELECT_RULE)).toEqual([6, 7]);
|
||||
});
|
||||
|
||||
it('accepts quiet Spins, bare indicators, single Selects, tagRender and spreads', () => {
|
||||
const output = runGuard(`${FIXTURES}/antd-a11y-ok.tsx`);
|
||||
expect(output).not.toContain(SPIN_RULE);
|
||||
expect(output).not.toContain(SELECT_RULE);
|
||||
});
|
||||
});
|
||||
|
||||
describe('antd-a11y-guard wiring', () => {
|
||||
const config = JSON.parse(readFileSync('.oxlintrc.json', 'utf8')) as {
|
||||
jsPlugins: string[];
|
||||
overrides: { files: string[]; rules: Record<string, unknown> }[];
|
||||
};
|
||||
|
||||
it('enforces both rules on app sources but not on stories or tests', () => {
|
||||
expect(config.jsPlugins).toContain('./tools/oxlint/antd-a11y-guard.mjs');
|
||||
for (const rule of ['antd-a11y/spin-content-not-live', 'antd-a11y/select-tag-remove']) {
|
||||
expect(config.overrides.find((o) => o.rules[rule] === 'error')?.files).toEqual([
|
||||
'src/**/*.tsx',
|
||||
]);
|
||||
expect(config.overrides.find((o) => o.rules[rule] === 'off')?.files).toEqual([
|
||||
'src/**/*.stories.tsx',
|
||||
'src/test/**',
|
||||
]);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
import { useState } from 'react';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { Select } from 'antd';
|
||||
|
||||
import { selectTagRender } from '@/components/form';
|
||||
|
||||
const SNIS = ['www.microsoft.com', 'www.apple.com', 'dl.google.com'];
|
||||
|
||||
function SniSelect({ onValue }: { onValue?: (v: string[]) => void }) {
|
||||
const [value, setValue] = useState(SNIS);
|
||||
return (
|
||||
<Select
|
||||
aria-label="SNI"
|
||||
mode="tags"
|
||||
tagRender={selectTagRender}
|
||||
value={value}
|
||||
onChange={(next) => {
|
||||
setValue(next);
|
||||
onValue?.(next);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Ant Design's own tag "x" is an aria-hidden span with no tabIndex, so a screen-reader
|
||||
// user could only Backspace the last SNI away, never one from the middle.
|
||||
describe('selectTagRender', () => {
|
||||
it('gives every tag a named, focusable remove button', () => {
|
||||
render(<SniSelect />);
|
||||
const names = screen.getAllByRole('button').map((b) => b.getAttribute('aria-label'));
|
||||
expect(names).toEqual([
|
||||
'Remove www.microsoft.com',
|
||||
'Remove www.apple.com',
|
||||
'Remove dl.google.com',
|
||||
]);
|
||||
});
|
||||
|
||||
it('removes a middle tag and moves focus to the next remove button', async () => {
|
||||
let latest: string[] = [];
|
||||
render(<SniSelect onValue={(v) => (latest = v)} />);
|
||||
const middle = screen.getByRole('button', { name: 'Remove www.apple.com' });
|
||||
middle.focus();
|
||||
fireEvent.click(middle);
|
||||
expect(latest).toEqual(['www.microsoft.com', 'dl.google.com']);
|
||||
await waitFor(() =>
|
||||
expect(document.activeElement).toBe(
|
||||
screen.getByRole('button', { name: 'Remove dl.google.com' }),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back to the search input after removing the only tag', async () => {
|
||||
function Single() {
|
||||
const [value, setValue] = useState(['example.com']);
|
||||
return (
|
||||
<Select
|
||||
aria-label="SNI"
|
||||
mode="tags"
|
||||
tagRender={selectTagRender}
|
||||
value={value}
|
||||
onChange={setValue}
|
||||
/>
|
||||
);
|
||||
}
|
||||
render(<Single />);
|
||||
const remove = screen.getByRole('button', { name: 'Remove example.com' });
|
||||
remove.focus();
|
||||
fireEvent.click(remove);
|
||||
await waitFor(() =>
|
||||
expect(document.activeElement).toBe(screen.getByRole('combobox', { name: 'SNI' })),
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps Enter and mouse presses on the remove button from opening the dropdown', () => {
|
||||
render(<SniSelect />);
|
||||
const combobox = screen.getByRole('combobox', { name: 'SNI' });
|
||||
const remove = screen.getByRole('button', { name: 'Remove www.apple.com' });
|
||||
fireEvent.keyDown(remove, { key: 'Enter', code: 'Enter', keyCode: 13 });
|
||||
fireEvent.mouseDown(remove);
|
||||
expect(combobox.getAttribute('aria-expanded')).toBe('false');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { Spin } from 'antd';
|
||||
|
||||
// Every page wraps its live stats in Spin, which sets aria-live="polite" on that wrapper.
|
||||
// The fix relies on our aria-live="off" winning; an antd upgrade that flips the prop order breaks it.
|
||||
describe('Spin live region', () => {
|
||||
it('lets aria-live="off" override the polite region around page content', () => {
|
||||
render(
|
||||
<Spin aria-live="off" spinning={false}>
|
||||
<span>47.2%</span>
|
||||
</Spin>,
|
||||
);
|
||||
const region = screen.getByText('47.2%').closest('[aria-live]');
|
||||
expect(region?.getAttribute('aria-live')).toBe('off');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
|
||||
vi.mock('@/components/viz', () => ({ Sparkline: () => null }));
|
||||
|
||||
import VitalTile from '@/pages/index/VitalTile';
|
||||
|
||||
// A screen reader walking the dashboard heard a bare "47.2 %" with no hint it was CPU;
|
||||
// the tile is now a group named by its label, so the number is read in context.
|
||||
describe('VitalTile', () => {
|
||||
it('names the tile group after its metric so the value is read with it', () => {
|
||||
render(
|
||||
<VitalTile
|
||||
icon={null}
|
||||
label="CPU"
|
||||
percent={47.2}
|
||||
statusColor="#000"
|
||||
detail="4 / 8T"
|
||||
footLeft="Avg 40%"
|
||||
footRight="Peak 90%"
|
||||
data={[40, 47.2]}
|
||||
isMobile={false}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByRole('group', { name: 'CPU' }).textContent).toContain('47.2%');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
import { Select, Spin } from 'antd';
|
||||
|
||||
declare const loading: boolean;
|
||||
|
||||
export const LivePage = () => <Spin spinning={loading}><span>42%</span></Spin>;
|
||||
export const BareTags = () => <Select mode="tags" aria-label="SNI" />;
|
||||
export const HiddenTags = () => <Select mode="multiple" aria-label="Inbounds" tagRender={() => null} maxTagCount="responsive" />;
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Select, Spin } from 'antd';
|
||||
|
||||
declare const loading: boolean;
|
||||
declare const rest: Record<string, unknown>;
|
||||
declare function tagRender(): null;
|
||||
declare function Other(props: { mode: string; children?: unknown }): null;
|
||||
|
||||
export const QuietPage = () => <Spin spinning={loading} aria-live="off"><span>42%</span></Spin>;
|
||||
export const Indicator = () => <Spin size="large" />;
|
||||
export const Single = () => <Select aria-label="Protocol" />;
|
||||
export const Tags = () => <Select mode="tags" aria-label="SNI" tagRender={tagRender} />;
|
||||
export const Forwarded = () => <Select mode="tags" aria-label="SNI" {...rest} />;
|
||||
export const NotAntd = () => <Other mode="tags"><span>x</span></Other>;
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"plugins": [],
|
||||
"categories": {},
|
||||
"jsPlugins": ["../antd-a11y-guard.mjs"],
|
||||
"rules": {
|
||||
"antd-a11y/spin-content-not-live": "error",
|
||||
"antd-a11y/select-tag-remove": "error"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
// Ant Design Spin wraps its content in aria-live="polite", so every live stat inside is
|
||||
// read aloud; Select tags hide their remove "x" from screen readers and keyboards.
|
||||
const SPIN_LIVE =
|
||||
'Spin wraps its content in aria-live="polite", so every update inside is read aloud; pass aria-live="off".';
|
||||
const NO_TAG_RENDER =
|
||||
'Select tag remove buttons are aria-hidden and unfocusable; pass tagRender={selectTagRender}.';
|
||||
const HIDDEN_TAGS =
|
||||
'maxTagCount hides selected values behind "+N", out of reach of screen readers; show every tag.';
|
||||
|
||||
function attrs(node) {
|
||||
const map = new Map();
|
||||
let spread = false;
|
||||
for (const attr of node.attributes) {
|
||||
if (attr.type === 'JSXAttribute') map.set(attr.name.name, attr);
|
||||
else spread = true;
|
||||
}
|
||||
return { map, spread };
|
||||
}
|
||||
|
||||
function hasContent(element) {
|
||||
return element.children.some((c) => c.type !== 'JSXText' || c.value.trim() !== '');
|
||||
}
|
||||
|
||||
export default {
|
||||
meta: { name: 'antd-a11y' },
|
||||
rules: {
|
||||
'spin-content-not-live': {
|
||||
create(context) {
|
||||
const spins = new Set();
|
||||
return {
|
||||
ImportDeclaration(node) {
|
||||
if (node.source.value !== 'antd') return;
|
||||
for (const spec of node.specifiers) {
|
||||
if (spec.type === 'ImportSpecifier' && spec.imported.name === 'Spin') {
|
||||
spins.add(spec.local.name);
|
||||
}
|
||||
}
|
||||
},
|
||||
JSXElement(node) {
|
||||
const opening = node.openingElement;
|
||||
if (opening.name.type !== 'JSXIdentifier' || !spins.has(opening.name.name)) return;
|
||||
if (!hasContent(node)) return;
|
||||
const { map, spread } = attrs(opening);
|
||||
if (!spread && !map.has('aria-live'))
|
||||
context.report({ node: opening, message: SPIN_LIVE });
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
'select-tag-remove': {
|
||||
create(context) {
|
||||
const selects = new Set();
|
||||
return {
|
||||
ImportDeclaration(node) {
|
||||
if (node.source.value !== 'antd') return;
|
||||
for (const spec of node.specifiers) {
|
||||
if (spec.type === 'ImportSpecifier' && spec.imported.name === 'Select') {
|
||||
selects.add(spec.local.name);
|
||||
}
|
||||
}
|
||||
},
|
||||
JSXOpeningElement(node) {
|
||||
if (node.name.type !== 'JSXIdentifier' || !selects.has(node.name.name)) return;
|
||||
const { map, spread } = attrs(node);
|
||||
if (!map.has('mode')) return;
|
||||
if (map.has('maxTagCount')) context.report({ node, message: HIDDEN_TAGS });
|
||||
else if (!spread && !map.has('tagRender'))
|
||||
context.report({ node, message: NO_TAG_RENDER });
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -10,6 +10,7 @@
|
||||
"create": "إنشاء",
|
||||
"add": "إضافة",
|
||||
"remove": "إزالة",
|
||||
"removeItem": "إزالة {item}",
|
||||
"update": "تحديث",
|
||||
"copy": "نسخ",
|
||||
"copied": "اتنسخ",
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
"create": "Create",
|
||||
"add": "Add",
|
||||
"remove": "Remove",
|
||||
"removeItem": "Remove {item}",
|
||||
"update": "Update",
|
||||
"copy": "Copy",
|
||||
"copied": "Copied",
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
"create": "Crear",
|
||||
"add": "Añadir",
|
||||
"remove": "Quitar",
|
||||
"removeItem": "Quitar {item}",
|
||||
"update": "Actualizar",
|
||||
"copy": "Copiar",
|
||||
"copied": "Copiado",
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
"create": "ایجاد",
|
||||
"add": "افزودن",
|
||||
"remove": "حذف",
|
||||
"removeItem": "حذف {item}",
|
||||
"update": "بهروزرسانی",
|
||||
"copy": "کپی",
|
||||
"copied": "کپی شد",
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
"create": "Buat",
|
||||
"add": "Tambah",
|
||||
"remove": "Hapus",
|
||||
"removeItem": "Hapus {item}",
|
||||
"update": "Perbarui",
|
||||
"copy": "Salin",
|
||||
"copied": "Tersalin",
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
"create": "作成",
|
||||
"add": "追加",
|
||||
"remove": "削除",
|
||||
"removeItem": "{item} を削除",
|
||||
"update": "更新",
|
||||
"copy": "コピー",
|
||||
"copied": "コピー済み",
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
"create": "Criar",
|
||||
"add": "Adicionar",
|
||||
"remove": "Remover",
|
||||
"removeItem": "Remover {item}",
|
||||
"update": "Atualizar",
|
||||
"copy": "Copiar",
|
||||
"copied": "Copiado",
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
"create": "Создать",
|
||||
"add": "Добавить",
|
||||
"remove": "Удалить",
|
||||
"removeItem": "Удалить {item}",
|
||||
"update": "Обновить",
|
||||
"copy": "Копировать",
|
||||
"copied": "Скопировано",
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
"create": "Vytvoriť",
|
||||
"add": "Pridať",
|
||||
"remove": "Odstrániť",
|
||||
"removeItem": "Odstrániť {item}",
|
||||
"update": "Aktualizovať",
|
||||
"copy": "Kopírovať",
|
||||
"copied": "Skopírované",
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
"create": "Oluştur",
|
||||
"add": "Ekle",
|
||||
"remove": "Kaldır",
|
||||
"removeItem": "{item} öğesini kaldır",
|
||||
"update": "Güncelle",
|
||||
"copy": "Kopyala",
|
||||
"copied": "Kopyalandı",
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
"create": "Створити",
|
||||
"add": "Додати",
|
||||
"remove": "Видалити",
|
||||
"removeItem": "Видалити {item}",
|
||||
"update": "Оновити",
|
||||
"copy": "Копіювати",
|
||||
"copied": "Скопійовано",
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
"create": "Tạo",
|
||||
"add": "Thêm",
|
||||
"remove": "Xóa",
|
||||
"removeItem": "Xóa {item}",
|
||||
"update": "Cập nhật",
|
||||
"copy": "Sao chép",
|
||||
"copied": "Đã sao chép",
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
"create": "创建",
|
||||
"add": "添加",
|
||||
"remove": "移除",
|
||||
"removeItem": "移除 {item}",
|
||||
"update": "更新",
|
||||
"copy": "复制",
|
||||
"copied": "已复制",
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
"create": "建立",
|
||||
"add": "新增",
|
||||
"remove": "移除",
|
||||
"removeItem": "移除 {item}",
|
||||
"update": "更新",
|
||||
"copy": "複製",
|
||||
"copied": "已複製",
|
||||
|
||||
Reference in New Issue
Block a user