fix(frontend): make the panel usable with screen readers (#6907)

* fix(frontend): make the panel usable with screen readers

* fix(frontend): keep mobile tab names and the inbound expiry hint

Review of #6907 found two gaps in the screen-reader pass:

- On phone-width screens catTabLabel renders only the icon and names the
  tab by putting aria-label on it, but callers now pass that icon with
  aria-hidden for the desktop label, so every Settings, Xray and Host
  category tab lost its name under TalkBack. catTabLabel now clears
  aria-hidden on the icon it uses as the name.
- DateTimePicker forwarded id and aria-label but not aria-describedby,
  so the inbound expiry hint ("leave blank to never expire") was never
  attached. It now forwards it on both the Gregorian and Jalali input.
This commit is contained in:
Alireza Ahmadi
2026-10-23 18:29:47 +02:00
committed by GitHub
parent 775b2b0b26
commit e2184777a0
156 changed files with 2373 additions and 863 deletions
+6 -4
View File
@@ -754,9 +754,11 @@ export default function InboundsPage() {
value={0}
formatter={() => (
<span>
<ArrowUpOutlined /> {SizeFormatter.sizeFormat(totals.up)}
<ArrowUpOutlined aria-hidden />{' '}
{SizeFormatter.sizeFormat(totals.up)}
{' / '}
<ArrowDownOutlined /> {SizeFormatter.sizeFormat(totals.down)}
<ArrowDownOutlined aria-hidden />{' '}
{SizeFormatter.sizeFormat(totals.down)}
</span>
)}
/>
@@ -765,14 +767,14 @@ export default function InboundsPage() {
<Statistic
title={t('pages.inbounds.totalUsage')}
value={SizeFormatter.sizeFormat(totals.up + totals.down)}
prefix={<PieChartOutlined />}
prefix={<PieChartOutlined aria-hidden />}
/>
</Col>
<Col xs={24} sm={24} md={8}>
<Statistic
title={t('pages.inbounds.inboundCount')}
value={String(dbInbounds.length)}
prefix={<BarsOutlined />}
prefix={<BarsOutlined aria-hidden />}
/>
</Col>
</Row>
@@ -32,7 +32,13 @@ export default function FallbacksCard({
const addButtons = (
<Space size={8} wrap>
<Button type="primary" ghost size="small" icon={<PlusOutlined />} onClick={addFallback}>
<Button
type="primary"
ghost
size="small"
icon={<PlusOutlined aria-hidden />}
onClick={addFallback}
>
{t('pages.inbounds.fallbacks.add') || 'Add fallback'}
</Button>
<Button
@@ -90,25 +96,26 @@ export default function FallbacksCard({
disabled={idx === 0}
onClick={() => moveFallback(idx, -1)}
title={t('pages.inbounds.form.moveUp')}
icon={<ArrowUpOutlined />}
icon={<ArrowUpOutlined aria-hidden />}
/>
<Button
aria-label={t('pages.inbounds.form.moveDown')}
disabled={idx === fallbacks.length - 1}
onClick={() => moveFallback(idx, 1)}
title={t('pages.inbounds.form.moveDown')}
icon={<ArrowDownOutlined />}
icon={<ArrowDownOutlined aria-hidden />}
/>
<Button
aria-label={t('delete')}
danger
onClick={() => removeFallback(idx)}
icon={<DeleteOutlined />}
icon={<DeleteOutlined aria-hidden />}
/>
</Space.Compact>
<Row gutter={[8, 8]}>
<Col xs={24} sm={12}>
<Input
aria-label="SNI"
prefix="SNI"
placeholder={t('pages.inbounds.fallbacks.matchAny') || 'any'}
value={record.name}
@@ -117,6 +124,7 @@ export default function FallbacksCard({
</Col>
<Col xs={24} sm={12}>
<Input
aria-label="ALPN"
prefix="ALPN"
placeholder={t('pages.inbounds.fallbacks.matchAny') || 'any'}
value={record.alpn}
@@ -125,6 +133,7 @@ export default function FallbacksCard({
</Col>
<Col xs={24} sm={12}>
<Input
aria-label="Path"
prefix="Path"
placeholder="/"
value={record.path}
@@ -133,6 +142,7 @@ export default function FallbacksCard({
</Col>
<Col xs={24} sm={12}>
<Input
aria-label="Dest"
prefix="Dest"
placeholder={t('pages.inbounds.fallbacks.destPlaceholder') || 'auto'}
value={record.dest}
@@ -141,6 +151,7 @@ export default function FallbacksCard({
</Col>
<Col xs={24} sm={12}>
<InputNumber
aria-label="xver"
prefix="xver"
min={0}
max={2}
@@ -1,20 +1,8 @@
import { useEffect, useRef, useState } from 'react';
import { useEffect, useRef, useState, useId } from 'react';
import { useTranslation } from 'react-i18next';
import { QuestionCircleOutlined } from '@ant-design/icons';
import dayjs from 'dayjs';
import {
Alert,
Form,
Input,
InputNumber,
Modal,
Radio,
Select,
Switch,
Tabs,
Tooltip,
message,
} from 'antd';
import { Alert, Form, Input, InputNumber, Modal, Radio, Select, Switch, Tabs, message } from 'antd';
import { Controller, FormProvider, useForm, useWatch } from 'react-hook-form';
import { HttpUtil, NumberFormatter, RandomUtil, SizeFormatter, Wireguard } from '@/utils';
@@ -99,16 +87,6 @@ import SniffingTab from './SniffingTab';
import type { DBInbound } from '@/models/dbinbound';
import type { NodeRecord } from '@/api/queries/useNodesQuery';
/* Render a field label with a hover tooltip icon instead of an `extra` help line below. */
const labelWithHint = (label: string, hint: string) => (
<span>
{label}
<Tooltip title={hint}>
<QuestionCircleOutlined style={{ marginInlineStart: 4, color: 'rgba(128,128,128,0.65)' }} />
</Tooltip>
</span>
);
const PROTOCOL_OPTIONS = Object.values(Protocols).map((p) => ({ value: p, label: p }));
const SHARE_ADDR_STRATEGIES = ['node', 'listen', 'custom'] as const;
const SHARE_ADDR_HOSTNAME_RE =
@@ -240,6 +218,7 @@ export default function InboundFormModal({
availableNodesFetched = true,
}: InboundFormModalProps) {
const { t } = useTranslation();
const uid = useId();
const [messageApi, messageContextHolder] = message.useMessage();
const [modal, modalContextHolder] = Modal.useModal();
const methods = useForm<InboundFormValues>({ defaultValues: buildAddModeValues() });
@@ -668,7 +647,8 @@ export default function InboundFormModal({
<FormField
name="listen"
label={labelWithHint(t('pages.inbounds.address'), t('pages.inbounds.form.listenHelp'))}
label={t('pages.inbounds.address')}
tooltip={t('pages.inbounds.form.listenHelp')}
>
<Input placeholder={t('pages.inbounds.monitorDesc')} />
</FormField>
@@ -677,10 +657,8 @@ export default function InboundFormModal({
<>
<FormField
name="shareAddrStrategy"
label={labelWithHint(
t('pages.inbounds.form.shareAddrStrategy'),
t('pages.inbounds.form.shareAddrStrategyHelp'),
)}
label={t('pages.inbounds.form.shareAddrStrategy')}
tooltip={t('pages.inbounds.form.shareAddrStrategyHelp')}
>
<Select
options={SHARE_ADDR_STRATEGIES.filter(
@@ -695,10 +673,8 @@ export default function InboundFormModal({
{shareAddrStrategy === 'custom' && (
<FormField
name="shareAddr"
label={labelWithHint(
t('pages.inbounds.form.shareAddr'),
t('pages.inbounds.form.shareAddrHelp'),
)}
label={t('pages.inbounds.form.shareAddr')}
tooltip={t('pages.inbounds.form.shareAddrHelp')}
rules={{
validate: (value) =>
isValidShareAddrInput(String(value ?? '')) ||
@@ -713,10 +689,8 @@ export default function InboundFormModal({
<FormField
name="subSortIndex"
label={labelWithHint(
t('pages.inbounds.form.subSortIndex'),
t('pages.inbounds.form.subSortIndexHelp'),
)}
label={t('pages.inbounds.form.subSortIndex')}
tooltip={t('pages.inbounds.form.subSortIndexHelp')}
>
<InputNumber />
</FormField>
@@ -724,10 +698,8 @@ export default function InboundFormModal({
<FormField
name="excludeFromSub"
valueProp="checked"
label={labelWithHint(
t('pages.inbounds.form.excludeFromSub'),
t('pages.inbounds.form.excludeFromSubHelp'),
)}
label={t('pages.inbounds.form.excludeFromSub')}
tooltip={t('pages.inbounds.form.excludeFromSubHelp')}
>
<Switch />
</FormField>
@@ -736,10 +708,8 @@ export default function InboundFormModal({
<FormField
name="disableFlow"
valueProp="checked"
label={labelWithHint(
t('pages.inbounds.form.disableFlow'),
t('pages.inbounds.form.disableFlowHelp'),
)}
label={t('pages.inbounds.form.disableFlow')}
tooltip={t('pages.inbounds.form.disableFlowHelp')}
>
<Switch />
</FormField>
@@ -754,13 +724,13 @@ export default function InboundFormModal({
</FormField>
<Form.Item
label={
<Tooltip title={t('pages.inbounds.meansNoLimit')}>
{t('pages.inbounds.totalFlow')}
</Tooltip>
}
htmlFor={`${uid}-totalFlow`}
label={t('pages.inbounds.totalFlow')}
tooltip={t('pages.inbounds.meansNoLimit')}
>
<InputNumber
id={`${uid}-totalFlow`}
aria-describedby={`${uid}-totalFlow-hint`}
value={wTotal ? Math.round((wTotal / SizeFormatter.ONE_GB) * 100) / 100 : 0}
min={0}
step={1}
@@ -769,6 +739,9 @@ export default function InboundFormModal({
setV('total', bytes);
}}
/>
<span id={`${uid}-totalFlow-hint`} hidden>
{t('pages.inbounds.meansNoLimit')}
</span>
</Form.Item>
<FormField name="trafficReset" label={t('pages.inbounds.periodicTrafficResetTitle')}>
@@ -791,16 +764,19 @@ export default function InboundFormModal({
)}
<Form.Item
label={
<Tooltip title={t('pages.inbounds.leaveBlankToNeverExpire')}>
{t('pages.inbounds.expireDate')}
</Tooltip>
}
htmlFor={`${uid}-expiryTime`}
label={t('pages.inbounds.expireDate')}
tooltip={t('pages.inbounds.leaveBlankToNeverExpire')}
>
<DateTimePicker
id={`${uid}-expiryTime`}
aria-describedby={`${uid}-expiryTime-hint`}
value={wExpiry > 0 ? dayjs(wExpiry) : null}
onChange={(d) => setV('expiryTime', d ? d.valueOf() : 0)}
/>
<span id={`${uid}-expiryTime-hint`} hidden>
{t('pages.inbounds.leaveBlankToNeverExpire')}
</span>
</Form.Item>
</>
);
@@ -936,8 +912,9 @@ export default function InboundFormModal({
const streamTab = (
<>
{hasSelectableTransport && (
<Form.Item label={t('transmission')}>
<Form.Item htmlFor={`${uid}-network`} label={t('transmission')}>
<Select
id={`${uid}-network`}
style={{ width: '75%' }}
value={network}
onChange={onNetworkChange}
@@ -23,7 +23,7 @@ export default function AccountsList() {
})
}
>
<PlusOutlined /> {t('add')}
<PlusOutlined aria-hidden /> {t('add')}
</Button>
</Form.Item>
{fields.length > 0 && (
@@ -32,13 +32,13 @@ export default function AccountsList() {
<Space.Compact key={field.id} className="mb-8" block>
<InputAddon>{String(idx + 1)}</InputAddon>
<FormField name={['settings', 'accounts', idx, 'user']} noStyle>
<Input placeholder={t('username')} />
<Input aria-label={`${t('username')} ${idx + 1}`} placeholder={t('username')} />
</FormField>
<FormField name={['settings', 'accounts', idx, 'pass']} noStyle>
<Input placeholder={t('password')} />
<Input aria-label={`${t('password')} ${idx + 1}`} placeholder={t('password')} />
</FormField>
<Button aria-label={t('remove')} onClick={() => remove(idx)}>
<MinusOutlined />
<MinusOutlined aria-hidden />
</Button>
</Space.Compact>
))}
@@ -1,3 +1,4 @@
import { useId } from 'react';
import { useTranslation } from 'react-i18next';
import { Button, Form, Input, InputNumber, Space, Switch } from 'antd';
import { ReloadOutlined } from '@ant-design/icons';
@@ -16,22 +17,23 @@ export default function AmneziawgFields({
regenInboundAwgObfuscation,
}: AmneziawgFieldsProps) {
const { t } = useTranslation();
const uid = useId();
return (
<>
<Form.Item label={t('pages.xray.amneziawg.privateKey')}>
<Form.Item htmlFor={`${uid}-privateKey`} label={t('pages.xray.amneziawg.privateKey')}>
<Space.Compact block>
<FormField name={['settings', 'server', 'privateKey']} noStyle>
<Input style={{ width: 'calc(100% - 32px)' }} />
<Input id={`${uid}-privateKey`} style={{ width: 'calc(100% - 32px)' }} />
</FormField>
<Button
aria-label={t('regenerate')}
icon={<ReloadOutlined />}
icon={<ReloadOutlined aria-hidden />}
onClick={regenInboundAwg}
/>
</Space.Compact>
</Form.Item>
<Form.Item label={t('pages.xray.amneziawg.publicKey')}>
<Input value={awgPubKey} disabled />
<Form.Item htmlFor={`${uid}-publicKey`} label={t('pages.xray.amneziawg.publicKey')}>
<Input id={`${uid}-publicKey`} value={awgPubKey} disabled />
</Form.Item>
<FormField
name={['settings', 'server', 'subnetIp']}
@@ -89,7 +91,7 @@ export default function AmneziawgFields({
<Input placeholder="eth0" />
</FormField>
<Form.Item label={t('pages.xray.amneziawg.obfuscation')}>
<Button icon={<ReloadOutlined />} onClick={regenInboundAwgObfuscation}>
<Button icon={<ReloadOutlined aria-hidden />} onClick={regenInboundAwgObfuscation}>
{t('pages.xray.amneziawg.regenerateObfuscation')}
</Button>
</Form.Item>
@@ -1,3 +1,4 @@
import { useId } from 'react';
import { useTranslation } from 'react-i18next';
import { Form, Input, InputNumber, Select, Switch } from 'antd';
import { useFormContext, useWatch } from 'react-hook-form';
@@ -9,6 +10,7 @@ const MASQ_PATH = ['streamSettings', 'hysteriaSettings', 'masquerade'];
export default function HysteriaFields() {
const { t } = useTranslation();
const uid = useId();
const { control, setValue } = useFormContext();
const masq = useWatch({ control, name: 'streamSettings.hysteriaSettings.masquerade' }) as
| { type?: string }
@@ -32,8 +34,9 @@ export default function HysteriaFields() {
<InputNumber min={2} max={600} style={{ width: '100%' }} />
</FormField>
<Form.Item label={t('pages.inbounds.form.masquerade')}>
<Form.Item htmlFor={`${uid}-masquerade`} label={t('pages.inbounds.form.masquerade')}>
<Switch
id={`${uid}-masquerade`}
checked={!!masq}
onChange={(checked) =>
setValue(
@@ -1,3 +1,4 @@
import { useId } from 'react';
import { useTranslation } from 'react-i18next';
import { Button, Form, Input, Select, Space, Switch } from 'antd';
import { ReloadOutlined } from '@ant-design/icons';
@@ -13,6 +14,7 @@ interface ShadowsocksFieldsProps {
export default function ShadowsocksFields({ isSSWith2022 }: ShadowsocksFieldsProps) {
const { t } = useTranslation();
const uid = useId();
const { getValues, setValue } = useFormContext();
return (
<>
@@ -26,14 +28,14 @@ export default function ShadowsocksFields({ isSSWith2022 }: ShadowsocksFieldsPro
<Select options={SSInboundMethodSchema.options.map((m) => ({ value: m, label: m }))} />
</FormField>
{isSSWith2022 && (
<Form.Item label={t('password')}>
<Form.Item htmlFor={`${uid}-password`} label={t('password')}>
<Space.Compact block>
<FormField name={['settings', 'password']} noStyle>
<Input style={{ width: 'calc(100% - 32px)' }} />
<Input id={`${uid}-password`} style={{ width: 'calc(100% - 32px)' }} />
</FormField>
<Button
aria-label={t('regenerate')}
icon={<ReloadOutlined />}
icon={<ReloadOutlined aria-hidden />}
onClick={() => {
const method = getValues('settings.method');
setValue(
@@ -1,4 +1,4 @@
import { useState } from 'react';
import { useState, useId } from 'react';
import { useTranslation } from 'react-i18next';
import {
AutoComplete,
@@ -21,6 +21,7 @@ import { HttpUtil } from '@/utils';
export default function TuicFields() {
const { t } = useTranslation();
const uid = useId();
const [messageApi, messageContextHolder] = message.useMessage();
const { control, setValue } = useFormContext();
const [loadingPanelCert, setLoadingPanelCert] = useState(false);
@@ -147,8 +148,9 @@ export default function TuicFields() {
return (
<>
{messageContextHolder}
<Form.Item label={t('pages.inbounds.publicKey')}>
<Form.Item htmlFor={`${uid}-certificate`} label={t('pages.inbounds.publicKey')}>
<AutoComplete
id={`${uid}-certificate`}
value={certificate}
options={certOptions}
onChange={(v) => setValue('settings.server.certificate', v)}
@@ -156,8 +158,9 @@ export default function TuicFields() {
/>
</Form.Item>
<Form.Item label={t('pages.inbounds.privatekey')}>
<Form.Item htmlFor={`${uid}-privateKey`} label={t('pages.inbounds.privatekey')}>
<AutoComplete
id={`${uid}-privateKey`}
value={privateKey}
options={keyOptions}
onChange={(v) => setValue('settings.server.private_key', v)}
@@ -169,13 +172,13 @@ export default function TuicFields() {
<Space wrap style={{ display: 'flex', flexWrap: 'wrap', width: '100%' }}>
<Button
type="primary"
icon={<CloudDownloadOutlined />}
icon={<CloudDownloadOutlined aria-hidden />}
loading={loadingPanelCert}
onClick={setCertFromPanel}
>
{t('pages.inbounds.setDefaultCert')}
</Button>
<Button icon={<SyncOutlined />} onClick={autofillFromSni}>
<Button icon={<SyncOutlined aria-hidden />} onClick={autofillFromSni}>
{t('pages.inbounds.form.autoFill')}
</Button>
<Button
@@ -217,12 +220,21 @@ export default function TuicFields() {
<Divider titlePlacement="start">{t('pages.xray.tuic.profileOptions')}</Divider>
<Form.Item label={t('pages.xray.tuic.sni')} tooltip={t('pages.xray.tuic.sniHint')}>
<Form.Item
htmlFor={`${uid}-sni`}
label={t('pages.xray.tuic.sni')}
tooltip={t('pages.xray.tuic.sniHint')}
>
<Input
id={`${uid}-sni`}
aria-describedby={`${uid}-sni-hint`}
value={sni}
placeholder="example.com"
onChange={(e) => handleSniChange(e.target.value)}
/>
<span id={`${uid}-sni-hint`} hidden>
{t('pages.xray.tuic.sniHint')}
</span>
</Form.Item>
<FormField
@@ -1,6 +1,6 @@
import type { ReactNode } from 'react';
import { useId, type ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import { Button, Form, Input, InputNumber, Select, Space, Switch, Tooltip } from 'antd';
import { Button, Form, Input, InputNumber, Select, Space, Switch } from 'antd';
import { MinusOutlined, PlusOutlined } from '@ant-design/icons';
import { useFieldArray, useFormContext } from 'react-hook-form';
@@ -9,28 +9,39 @@ import { FormField } from '@/components/form/rhf';
interface StringListProps {
name: string[];
label: ReactNode;
tooltip?: string;
placeholder: (index: number) => string;
}
function StringList({ name, label, placeholder }: StringListProps) {
function StringList({ name, label, tooltip, placeholder }: StringListProps) {
const { t } = useTranslation();
const labelId = useId();
const { control } = useFormContext();
const { fields, append, remove } = useFieldArray({ control, name: name.join('.') });
return (
<Form.Item label={label}>
<Form.Item label={<span id={labelId}>{label}</span>} tooltip={tooltip}>
<Button aria-label={t('add')} size="small" onClick={() => append('')}>
<PlusOutlined />
<PlusOutlined aria-hidden />
</Button>
{fields.map((field, j) => (
<Space.Compact key={field.id} block className="mt-4">
<FormField name={[...name, j]} noStyle>
<Input placeholder={placeholder(j)} />
<Input
aria-labelledby={labelId}
aria-describedby={tooltip ? `${labelId}-hint` : undefined}
placeholder={placeholder(j)}
/>
</FormField>
<Button aria-label={t('remove')} size="small" onClick={() => remove(j)}>
<MinusOutlined />
<MinusOutlined aria-hidden />
</Button>
</Space.Compact>
))}
{tooltip && (
<span id={`${labelId}-hint`} hidden>
{tooltip}
</span>
)}
</Form.Item>
);
}
@@ -73,20 +84,14 @@ export default function TunFields() {
</FormField>
<StringList
name={['settings', 'autoSystemRoutingTable']}
label={
<Tooltip title={t('pages.inbounds.form.autoSystemRoutesTooltip')}>
{t('pages.inbounds.info.autoSystemRoutes')}
</Tooltip>
}
label={t('pages.inbounds.info.autoSystemRoutes')}
tooltip={t('pages.inbounds.form.autoSystemRoutesTooltip')}
placeholder={(j) => (j === 0 ? '0.0.0.0/0' : '::/0')}
/>
<FormField
name={['settings', 'autoOutboundsInterface']}
label={
<Tooltip title={t('pages.inbounds.form.autoOutboundsInterfaceTooltip')}>
{t('pages.inbounds.form.autoOutboundsInterface')}
</Tooltip>
}
label={t('pages.inbounds.form.autoOutboundsInterface')}
tooltip={t('pages.inbounds.form.autoOutboundsInterfaceTooltip')}
>
<Input placeholder="auto" />
</FormField>
@@ -1,4 +1,4 @@
import { useState } from 'react';
import { useState, useId } from 'react';
import { useTranslation } from 'react-i18next';
import { Button, Form, Input, InputNumber, Select, Space, Typography } from 'antd';
import { Controller, useFormContext } from 'react-hook-form';
@@ -26,6 +26,7 @@ export default function VlessFields({
clearVlessEnc,
}: VlessFieldsProps) {
const { t } = useTranslation();
const uid = useId();
const { control } = useFormContext();
const [authKind, setAuthKind] = useState<VlessAuthKind>(vlessAuthKind ?? 'x25519');
@@ -47,9 +48,10 @@ export default function VlessFields({
<FormField name={['settings', 'encryption']} label={t('pages.inbounds.encryption')}>
<Input />
</FormField>
<Form.Item label={t('pages.inbounds.vlessAuthGenerate')}>
<Form.Item htmlFor={`${uid}-authKind`} label={t('pages.inbounds.vlessAuthGenerate')}>
<Space size={8} wrap>
<Select
id={`${uid}-authKind`}
value={authKind}
onChange={(v) => setAuthKind(v)}
options={authOptions}
@@ -80,6 +82,7 @@ export default function VlessFields({
defaultValue={def}
render={({ field }) => (
<InputNumber
aria-label={`${t('pages.inbounds.form.visionTestseed')} ${i + 1}`}
min={1}
style={{ width: '25%' }}
value={field.value as number}
@@ -1,3 +1,4 @@
import { useId } from 'react';
import { useTranslation } from 'react-i18next';
import { Button, Form, Input, InputNumber, Space, Switch } from 'antd';
import { ReloadOutlined } from '@ant-design/icons';
@@ -11,18 +12,23 @@ interface WireguardFieldsProps {
export default function WireguardFields({ wgPubKey, regenInboundWg }: WireguardFieldsProps) {
const { t } = useTranslation();
const uid = useId();
return (
<>
<Form.Item label={t('pages.xray.wireguard.secretKey')}>
<Form.Item htmlFor={`${uid}-secretKey`} label={t('pages.xray.wireguard.secretKey')}>
<Space.Compact block>
<FormField name={['settings', 'secretKey']} noStyle>
<Input style={{ width: 'calc(100% - 32px)' }} />
<Input id={`${uid}-secretKey`} style={{ width: 'calc(100% - 32px)' }} />
</FormField>
<Button aria-label={t('regenerate')} icon={<ReloadOutlined />} onClick={regenInboundWg} />
<Button
aria-label={t('regenerate')}
icon={<ReloadOutlined aria-hidden />}
onClick={regenInboundWg}
/>
</Space.Compact>
</Form.Item>
<Form.Item label={t('pages.xray.wireguard.publicKey')}>
<Input value={wgPubKey} disabled />
<Form.Item htmlFor={`${uid}-publicKey`} label={t('pages.xray.wireguard.publicKey')}>
<Input id={`${uid}-publicKey`} value={wgPubKey} disabled />
</Form.Item>
<FormField name={['settings', 'subnetIp']} label={t('pages.xray.wireguard.subnetIp')}>
<Input placeholder="10.0.0.0" />
@@ -199,6 +199,7 @@ export default function RealityTargetScannerModal({
{t('pages.inbounds.form.scanModalDesc')}
</Typography.Paragraph>
<Input.Search
aria-label={t('pages.inbounds.form.scanDiscoverPlaceholder')}
allowClear
enterButton={t('pages.inbounds.form.scan')}
loading={loading}
@@ -1,4 +1,4 @@
import { useState } from 'react';
import { useState, useId } from 'react';
import { useFormContext, useWatch } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import {
@@ -59,6 +59,7 @@ export default function RealityForm({
clearMldsa65,
}: RealityFormProps) {
const { t } = useTranslation();
const uid = useId();
const { getFieldState, trigger } = useFormContext();
const [scannerOpen, setScannerOpen] = useState(false);
const mldsa65Seed = useWatch({ name: 'streamSettings.realitySettings.mldsa65Seed' });
@@ -113,6 +114,7 @@ export default function RealityForm({
<Select options={Object.values(UTLS_FINGERPRINT).map((fp) => ({ value: fp, label: fp }))} />
</FormField>
<Form.Item
htmlFor={`${uid}-target`}
label={t('pages.inbounds.form.target')}
tooltip={t('pages.inbounds.form.realityTargetHint')}
>
@@ -127,19 +129,27 @@ export default function RealityForm({
},
}}
>
<Input style={{ flex: 1 }} placeholder="example.com:443" />
<Input
id={`${uid}-target`}
aria-describedby={`${uid}-target-hint`}
style={{ flex: 1 }}
placeholder="example.com:443"
/>
</FormField>
<Button
icon={<RadarChartOutlined />}
icon={<RadarChartOutlined aria-hidden />}
loading={scanning}
onClick={() => scanRealityTarget()}
>
{t('pages.inbounds.form.scan')}
</Button>
<Button icon={<SearchOutlined />} onClick={() => setScannerOpen(true)}>
<Button icon={<SearchOutlined aria-hidden />} onClick={() => setScannerOpen(true)}>
{t('pages.inbounds.form.findTargets')}
</Button>
</Space.Compact>
<span id={`${uid}-target-hint`} hidden>
{t('pages.inbounds.form.realityTargetHint')}
</span>
</Form.Item>
{scanResult && (
<Form.Item label=" " colon={false}>
@@ -235,32 +245,45 @@ export default function RealityForm({
>
<Input placeholder="x.y.z" />
</FormField>
<Form.Item label={t('pages.inbounds.form.shortIds')}>
<Form.Item htmlFor={`${uid}-shortIds`} label={t('pages.inbounds.form.shortIds')}>
<Space.Compact block style={{ display: 'flex' }}>
<FormField name={['streamSettings', 'realitySettings', 'shortIds']} noStyle>
<Select mode="tags" tokenSeparators={[',']} style={{ flex: 1 }} />
<Select
id={`${uid}-shortIds`}
mode="tags"
tokenSeparators={[',']}
style={{ flex: 1 }}
/>
</FormField>
<Button
aria-label={t('regenerate')}
icon={<ReloadOutlined />}
icon={<ReloadOutlined aria-hidden />}
onClick={randomizeShortIds}
/>
</Space.Compact>
</Form.Item>
<Form.Item
htmlFor={`${uid}-spiderX`}
label={t('pages.inbounds.form.spiderX')}
tooltip={t('pages.inbounds.form.spiderXHint')}
>
<Space.Compact block style={{ display: 'flex' }}>
<FormField name={['streamSettings', 'realitySettings', 'settings', 'spiderX']} noStyle>
<Input style={{ flex: 1 }} />
<Input
id={`${uid}-spiderX`}
aria-describedby={`${uid}-spiderX-hint`}
style={{ flex: 1 }}
/>
</FormField>
<Button
aria-label={t('regenerate')}
icon={<ReloadOutlined />}
icon={<ReloadOutlined aria-hidden />}
onClick={randomizeSpiderX}
/>
</Space.Compact>
<span id={`${uid}-spiderX-hint`} hidden>
{t('pages.inbounds.form.spiderXHint')}
</span>
</Form.Item>
<FormField
name={['streamSettings', 'realitySettings', 'settings', 'publicKey']}
@@ -1,3 +1,4 @@
import { useId } from 'react';
import { useTranslation } from 'react-i18next';
import { Button, Form, Input, InputNumber, Radio, Select, Space, Switch } from 'antd';
import {
@@ -77,7 +78,7 @@ function CertRow({
{total > 1 && (
<Form.Item label=" ">
<Button size="small" danger onClick={onRemove}>
<MinusOutlined /> {t('remove')}
<MinusOutlined aria-hidden /> {t('remove')}
</Button>
</Form.Item>
)}
@@ -161,16 +162,20 @@ function CertRow({
function EchSockoptSection() {
const { t } = useTranslation();
const uid = useId();
const { control, setValue } = useFormContext();
const echSockopt = useWatch({ control, name: 'streamSettings.tlsSettings.echSockopt' });
const on = !!echSockopt;
return (
<>
<Form.Item
htmlFor={`${uid}-echSockopt`}
label={t('pages.inbounds.form.echSockopt')}
tooltip={t('pages.inbounds.form.echSockoptTip')}
>
<Switch
id={`${uid}-echSockopt`}
aria-describedby={`${uid}-echSockopt-hint`}
checked={on}
onChange={(v) =>
setValue(
@@ -179,6 +184,9 @@ function EchSockoptSection() {
)
}
/>
<span id={`${uid}-echSockopt-hint`} hidden>
{t('pages.inbounds.form.echSockoptTip')}
</span>
</Form.Item>
{on && (
<>
@@ -226,6 +234,7 @@ export default function TlsForm({
clearEchCert,
}: TlsFormProps) {
const { t } = useTranslation();
const uid = useId();
const { control } = useFormContext();
const { fields, append, remove } = useFieldArray({
control,
@@ -242,16 +251,18 @@ export default function TlsForm({
>
<CipherSuitesSelect placeholder={t('pages.inbounds.form.autoOption')} />
</FormField>
<Form.Item label={t('pages.inbounds.form.minMaxVersion')}>
<Form.Item htmlFor={`${uid}-minVersion`} label={t('pages.inbounds.form.minMaxVersion')}>
<Space.Compact block>
<FormField name={['streamSettings', 'tlsSettings', 'minVersion']} noStyle>
<Select
id={`${uid}-minVersion`}
style={{ width: '50%' }}
options={Object.values(TLS_VERSION_OPTION).map((v) => ({ value: v, label: v }))}
/>
</FormField>
<FormField name={['streamSettings', 'tlsSettings', 'maxVersion']} noStyle>
<Select
aria-label={t('pages.inbounds.form.maxVersion')}
style={{ width: '50%' }}
options={Object.values(TLS_VERSION_OPTION).map((v) => ({ value: v, label: v }))}
/>
@@ -330,7 +341,7 @@ export default function TlsForm({
})
}
>
<PlusOutlined />
<PlusOutlined aria-hidden />
</Button>
</Form.Item>
{fields.map((field, idx) => (
@@ -375,6 +386,7 @@ export default function TlsForm({
</Space>
</Form.Item>
<Form.Item
htmlFor={`${uid}-pinnedPeerCertSha256`}
label={t('pages.inbounds.form.pinnedPeerCertSha256')}
tooltip={t('pages.inbounds.form.pinnedPeerCertSha256Tip')}
>
@@ -384,6 +396,8 @@ export default function TlsForm({
noStyle
>
<Select
id={`${uid}-pinnedPeerCertSha256`}
aria-describedby={`${uid}-pinnedPeerCertSha256-hint`}
mode="tags"
tokenSeparators={[',', ' ']}
placeholder={t('pages.inbounds.form.pinnedPeerCertSha256Placeholder')}
@@ -391,18 +405,23 @@ export default function TlsForm({
/>
</FormField>
<Button
icon={<FileProtectOutlined />}
icon={<FileProtectOutlined aria-hidden />}
onClick={pinFromCert}
loading={saving}
aria-label={t('pages.inbounds.form.pinFromCert')}
title={t('pages.inbounds.form.pinFromCert')}
/>
<Button
icon={<CloudDownloadOutlined />}
icon={<CloudDownloadOutlined aria-hidden />}
onClick={() => pinFromRemote()}
loading={saving}
aria-label={t('pages.inbounds.form.pinFromRemote')}
title={t('pages.inbounds.form.pinFromRemote')}
/>
</Space.Compact>
<span id={`${uid}-pinnedPeerCertSha256-hint`} hidden>
{t('pages.inbounds.form.pinnedPeerCertSha256Tip')}
</span>
</Form.Item>
<FormField
name={['streamSettings', 'tlsSettings', 'settings', 'verifyPeerCertByName']}
@@ -1,3 +1,4 @@
import { useId } from 'react';
import { useTranslation } from 'react-i18next';
import { Form, Input, Switch } from 'antd';
import { useFormContext, useWatch } from 'react-hook-form';
@@ -7,6 +8,7 @@ import { FormField } from '@/components/form/rhf';
export default function RawForm() {
const { t } = useTranslation();
const uid = useId();
const { control, setValue } = useFormContext();
const headerType = (useWatch({
control,
@@ -21,8 +23,9 @@ export default function RawForm() {
>
<Switch />
</FormField>
<Form.Item label={`HTTP ${t('camouflage')}`}>
<Form.Item htmlFor={`${uid}-httpCamouflage`} label={`HTTP ${t('camouflage')}`}>
<Switch
id={`${uid}-httpCamouflage`}
checked={headerType === 'http'}
onChange={(v) => {
setValue(
@@ -1,3 +1,4 @@
import { useId } from 'react';
import { useTranslation } from 'react-i18next';
import { Alert, Form, InputNumber, Segmented, Select, Switch } from 'antd';
import { Controller, useFormContext, useWatch } from 'react-hook-form';
@@ -28,6 +29,7 @@ export default function SockoptForm({
network: string;
}) {
const { t } = useTranslation();
const uid = useId();
const { control, getValues, setValue } = useFormContext();
const sock = useWatch({ control, name: 'streamSettings.sockopt' });
const on = !!sock && typeof sock === 'object' && Object.keys(sock).length > 0;
@@ -101,6 +103,8 @@ export default function SockoptForm({
tooltip={t('pages.inbounds.form.realClientIpHint')}
>
<Segmented
aria-label={t('pages.inbounds.form.realClientIp')}
aria-describedby={`${uid}-real-client-ip-hint`}
value={presetValue}
onChange={(v) => applyRealClientIpPreset(v as RealClientIpPreset)}
options={[
@@ -112,6 +116,9 @@ export default function SockoptForm({
{ value: 'proxy', label: t('pages.inbounds.form.realClientIpPresetProxyProtocol') },
]}
/>
<span id={`${uid}-real-client-ip-hint`} hidden>
{t('pages.inbounds.form.realClientIpHint')}
</span>
</Form.Item>
{trustedMismatch && (
<Alert
@@ -533,7 +533,7 @@ export default function InboundInfoModal({
<Tooltip title={t('copy')}>
<Button
size="small"
icon={<CopyOutlined />}
icon={<CopyOutlined aria-hidden />}
aria-label={t('copy')}
onClick={() => copyText(clientSettings.tgId, t)}
/>
@@ -552,7 +552,7 @@ export default function InboundInfoModal({
<Tooltip title={t('copy')}>
<Button
size="small"
icon={<CopyOutlined />}
icon={<CopyOutlined aria-hidden />}
aria-label={t('copy')}
onClick={() => copyText(link.link, t)}
/>
@@ -573,7 +573,7 @@ export default function InboundInfoModal({
<Tooltip title={t('copy')}>
<Button
size="small"
icon={<CopyOutlined />}
icon={<CopyOutlined aria-hidden />}
aria-label={t('copy')}
onClick={() => copyText(subLink, t)}
/>
@@ -595,7 +595,7 @@ export default function InboundInfoModal({
<Tooltip title={t('copy')}>
<Button
size="small"
icon={<CopyOutlined />}
icon={<CopyOutlined aria-hidden />}
aria-label={t('copy')}
onClick={() => copyText(subJsonLink, t)}
/>
@@ -714,7 +714,7 @@ export default function InboundInfoModal({
<Button
size="small"
className="value-copy"
icon={<CopyOutlined />}
icon={<CopyOutlined aria-hidden />}
aria-label={t('copy')}
onClick={() => copyText(encryptionLabel, t)}
/>
@@ -984,7 +984,7 @@ export default function InboundInfoModal({
<Button
size="small"
type="text"
icon={<CopyOutlined />}
icon={<CopyOutlined aria-hidden />}
aria-label={t('copy')}
onClick={() => copyText(`${account.user}:${account.pass}`, t)}
/>
@@ -1100,7 +1100,7 @@ export default function InboundInfoModal({
<Tooltip title={t('copy')}>
<Button
size="small"
icon={<CopyOutlined />}
icon={<CopyOutlined aria-hidden />}
aria-label={t('copy')}
onClick={() => copyText(`${account.user}:${account.pass}`, t)}
/>
@@ -1201,7 +1201,7 @@ export default function InboundInfoModal({
<Tooltip title={t('copy')}>
<Button
size="small"
icon={<CopyOutlined />}
icon={<CopyOutlined aria-hidden />}
aria-label={t('copy')}
onClick={() => copyText(cfg, t)}
/>
@@ -1209,7 +1209,7 @@ export default function InboundInfoModal({
<Tooltip title={t('download')}>
<Button
size="small"
icon={<DownloadOutlined />}
icon={<DownloadOutlined aria-hidden />}
aria-label={t('download')}
onClick={() =>
downloadText(cfg, peerConfFileName(idx, j, all.length))
@@ -1230,7 +1230,7 @@ export default function InboundInfoModal({
<Tooltip title={t('copy')}>
<Button
size="small"
icon={<CopyOutlined />}
icon={<CopyOutlined aria-hidden />}
aria-label={t('copy')}
onClick={() => copyText(link, t)}
/>
@@ -1261,7 +1261,7 @@ export default function InboundInfoModal({
<Tooltip title={t('copy')}>
<Button
size="small"
icon={<CopyOutlined />}
icon={<CopyOutlined aria-hidden />}
aria-label={t('copy')}
onClick={() => copyText(cfg, t)}
/>
@@ -1269,7 +1269,7 @@ export default function InboundInfoModal({
<Tooltip title={t('download')}>
<Button
size="small"
icon={<DownloadOutlined />}
icon={<DownloadOutlined aria-hidden />}
aria-label={t('download')}
onClick={() => downloadText(cfg, peerConfFileName(idx, j, all.length))}
/>
@@ -1288,7 +1288,7 @@ export default function InboundInfoModal({
<Tooltip title={t('copy')}>
<Button
size="small"
icon={<CopyOutlined />}
icon={<CopyOutlined aria-hidden />}
aria-label={t('copy')}
onClick={() => copyText(link, t)}
/>
@@ -193,20 +193,24 @@ export default function InboundList({
const generalActionsMenu: MenuProps = {
items: [
{ key: 'import', icon: <ImportOutlined />, label: t('pages.inbounds.importInbound') },
{ key: 'export', icon: <ExportOutlined />, label: t('pages.inbounds.export') },
{
key: 'import',
icon: <ImportOutlined aria-hidden />,
label: t('pages.inbounds.importInbound'),
},
{ key: 'export', icon: <ExportOutlined aria-hidden />, label: t('pages.inbounds.export') },
...(subEnable
? [
{
key: 'subs',
icon: <ExportOutlined />,
icon: <ExportOutlined aria-hidden />,
label: `${t('pages.inbounds.export')} — ${t('pages.settings.subSettings')}`,
},
]
: []),
{
key: 'resetInbounds',
icon: <ReloadOutlined />,
icon: <ReloadOutlined aria-hidden />,
label: t('pages.inbounds.resetAllTraffic'),
},
],
@@ -221,7 +225,7 @@ export default function InboundList({
<Button
type="primary"
onClick={onAddInbound}
icon={<PlusOutlined />}
icon={<PlusOutlined aria-hidden />}
aria-label={t('pages.inbounds.addInbound')}
>
{!isMobile && t('pages.inbounds.addInbound')}
@@ -229,7 +233,7 @@ export default function InboundList({
<Dropdown trigger={['click']} menu={generalActionsMenu}>
<Button
type="primary"
icon={<MenuOutlined />}
icon={<MenuOutlined aria-hidden />}
aria-label={t('pages.inbounds.generalActions')}
>
{!isMobile && t('pages.inbounds.generalActions')}
@@ -251,7 +255,7 @@ export default function InboundList({
onChange={(e) => setSearchKey(e.target.value)}
placeholder={t('search')}
allowClear
prefix={<SearchOutlined />}
prefix={<SearchOutlined aria-hidden />}
style={{ maxWidth: isMobile ? 110 : 200 }}
aria-label={t('search')}
/>
@@ -267,7 +271,7 @@ export default function InboundList({
</Tag>
<Button
danger
icon={<DeleteOutlined />}
icon={<DeleteOutlined aria-hidden />}
onClick={handleBulkDelete}
aria-label={t('delete')}
>
@@ -283,7 +287,7 @@ export default function InboundList({
<div className="inbound-cards">
{visibleInbounds.length === 0 ? (
<div className="card-empty">
<ImportOutlined style={{ fontSize: 28, opacity: 0.5 }} />
<ImportOutlined aria-hidden style={{ fontSize: 28, opacity: 0.5 }} />
<div>{t('noData')}</div>
</div>
) : (
@@ -327,6 +331,7 @@ export default function InboundList({
/>
</Tooltip>
<Switch
aria-label={t('pages.inbounds.enable')}
checked={record.enable}
size="small"
onChange={(next) => onSwitchEnable(record, next)}
@@ -350,7 +355,7 @@ export default function InboundList({
type="text"
size="small"
className="row-action-trigger"
icon={<MoreOutlined />}
icon={<MoreOutlined aria-hidden />}
aria-label={t('more')}
/>
</Dropdown>
@@ -377,7 +382,7 @@ export default function InboundList({
locale={{
emptyText: (
<div className="card-empty">
<ImportOutlined style={{ fontSize: 32, marginBottom: 8 }} />
<ImportOutlined aria-hidden style={{ fontSize: 32, marginBottom: 8 }} />
<div>{t('noData')}</div>
</div>
),
+28 -15
View File
@@ -40,69 +40,82 @@ export function buildRowActionsMenu({
}): MenuProps['items'] {
const items: MenuProps['items'] = [];
if (isMobile) {
items.push({ key: 'edit', icon: <EditOutlined />, label: t('edit') });
items.push({ key: 'edit', icon: <EditOutlined aria-hidden />, label: t('edit') });
}
if (isInboundMultiUser(record)) {
items.push({ key: 'export', icon: <ExportOutlined />, label: t('pages.inbounds.export') });
items.push({
key: 'export',
icon: <ExportOutlined aria-hidden />,
label: t('pages.inbounds.export'),
});
if (subEnable) {
items.push({
key: 'subs',
icon: <ExportOutlined />,
icon: <ExportOutlined aria-hidden />,
label: `${t('pages.inbounds.export')} — ${t('pages.settings.subSettings')}`,
});
}
} else {
items.push({
key: 'showInfo',
icon: <InfoCircleOutlined />,
icon: <InfoCircleOutlined aria-hidden />,
label: t('pages.inbounds.inboundInfo'),
});
}
items.push({
key: 'clipboard',
icon: <CopyOutlined />,
icon: <CopyOutlined aria-hidden />,
label: t('pages.inbounds.exportInbound'),
});
items.push({
key: 'resetTraffic',
icon: <RetweetOutlined />,
icon: <RetweetOutlined aria-hidden />,
label: t('pages.inbounds.resetTraffic'),
});
items.push({ key: 'clone', icon: <BlockOutlined />, label: t('pages.inbounds.clone') });
items.push({
key: 'clone',
icon: <BlockOutlined aria-hidden />,
label: t('pages.inbounds.clone'),
});
if (isInboundMultiUser(record)) {
items.push({
key: 'attachExisting',
icon: <UsergroupAddOutlined />,
icon: <UsergroupAddOutlined aria-hidden />,
label: t('pages.inbounds.attachExistingClients'),
});
}
if (isInboundMultiUser(record) && hasClients) {
items.push({
key: 'attachClients',
icon: <UsergroupAddOutlined />,
icon: <UsergroupAddOutlined aria-hidden />,
label: t('pages.inbounds.attachClients'),
});
items.push({
key: 'detachClients',
icon: <UsergroupDeleteOutlined />,
icon: <UsergroupDeleteOutlined aria-hidden />,
label: t('pages.inbounds.detachClients'),
});
items.push({
key: 'addToGroup',
icon: <TagsOutlined />,
icon: <TagsOutlined aria-hidden />,
label: t('pages.inbounds.addClientsToGroup'),
});
items.push({ type: 'divider' });
items.push({
key: 'delAllClients',
icon: <UsergroupDeleteOutlined />,
icon: <UsergroupDeleteOutlined aria-hidden />,
danger: true,
label: t('pages.inbounds.delAllClients'),
});
} else {
items.push({ type: 'divider' });
}
items.push({ key: 'delete', icon: <DeleteOutlined />, danger: true, label: t('delete') });
items.push({
key: 'delete',
icon: <DeleteOutlined aria-hidden />,
danger: true,
label: t('delete'),
});
return items;
}
@@ -114,7 +127,7 @@ export function RowActionsCell({ record, subEnable, hasClients, onClick }: RowAc
type="text"
size="small"
style={{ fontSize: 16 }}
icon={<EditOutlined />}
icon={<EditOutlined aria-hidden />}
aria-label={t('edit')}
onClick={() => onClick('edit')}
/>
@@ -129,7 +142,7 @@ export function RowActionsCell({ record, subEnable, hasClients, onClick }: RowAc
type="text"
size="small"
style={{ fontSize: 16 }}
icon={<MoreOutlined />}
icon={<MoreOutlined aria-hidden />}
aria-label={t('more')}
/>
</Dropdown>
@@ -130,7 +130,11 @@ export function useInboundColumns({
align: 'center',
width: 80,
render: (_, record) => (
<Switch checked={record.enable} onChange={(next) => onSwitchEnable(record, next)} />
<Switch
aria-label={t('pages.inbounds.enable')}
checked={record.enable}
onChange={(next) => onSwitchEnable(record, next)}
/>
),
},
];
@@ -293,7 +297,7 @@ export function useInboundColumns({
className="client-count-tag"
style={{ margin: 0, marginRight: 4, padding: '0 2px' }}
>
<TeamOutlined /> {cc.clients}
<TeamOutlined aria-hidden /> {cc.clients}
</Tag>
{cc.active.length > 0 ? (
<Popover
+8 -3
View File
@@ -102,13 +102,18 @@ export default function QrPanel({
{remark}
</Tag>
<Tooltip title={t('copy')}>
<Button size="small" icon={<CopyOutlined />} aria-label={t('copy')} onClick={copy} />
<Button
size="small"
icon={<CopyOutlined aria-hidden />}
aria-label={t('copy')}
onClick={copy}
/>
</Tooltip>
{showQr && (
<Tooltip title={t('downloadImage')}>
<Button
size="small"
icon={<PictureOutlined />}
icon={<PictureOutlined aria-hidden />}
aria-label={t('downloadImage')}
onClick={downloadImage}
/>
@@ -118,7 +123,7 @@ export default function QrPanel({
<Tooltip title={t('download')}>
<Button
size="small"
icon={<DownloadOutlined />}
icon={<DownloadOutlined aria-hidden />}
aria-label={t('download')}
onClick={download}
/>