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