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
@@ -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']}