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
@@ -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={{
+2 -2
View File
@@ -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')}
+2 -1
View File
@@ -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">
+5 -4
View File
@@ -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}
+7 -1
View File
@@ -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 ? (
+24 -4
View File
@@ -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,
+1 -1
View File
@@ -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={[
+1 -1
View File
@@ -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
+1
View File
@@ -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')}
+1 -1
View File
@@ -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}
+12 -3
View File
@@ -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')}
+7 -1
View File
@@ -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%' }}
+1 -1
View File
@@ -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)}
+1 -1
View File
@@ -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} />
) : (
+1
View File
@@ -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 }))}
/>
+2
View File
@@ -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),