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
@@ -38,7 +38,7 @@ export default function CustomSockoptList({
<Button
type="dashed"
size="small"
icon={<PlusOutlined />}
icon={<PlusOutlined aria-hidden />}
onClick={() => add({ type: 'int', level: '6', opt: '', value: '' })}
>
{t('pages.inbounds.form.addCustomOption')}
@@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from 'react';
import { useEffect, useId, useRef, useState } from 'react';
import {
AutoComplete,
Button,
@@ -271,6 +271,7 @@ export default function FinalMaskForm({
showAll = false,
side = 'client',
}: FinalMaskFormProps) {
const uid = useId();
const base = asPath(name);
// Migrate legacy mask shapes once on mount so configs saved before #6334 (fragment
@@ -346,8 +347,9 @@ export default function FinalMaskForm({
)}
{showQuic && (
<>
<Form.Item label="QUIC Params">
<Form.Item label="QUIC Params" htmlFor={`${uid}-quic-params`}>
<Switch
id={`${uid}-quic-params`}
checked={hasQuicParams}
onChange={(v) => {
form.setFieldValue([...base, 'quicParams'], v ? defaultQuicParams() : undefined);
@@ -385,7 +387,7 @@ function TcpMasksList({
<Button
type="primary"
size="small"
icon={<PlusOutlined />}
icon={<PlusOutlined aria-hidden />}
aria-label={t('add')}
onClick={() =>
add({ type: 'fragment', settings: defaultTcpMaskSettings('fragment') })
@@ -531,12 +533,13 @@ function TcpMaskItem({
rules={[{ required: true, message: 'Password is required' }]}
>
<Input
aria-label="Password"
placeholder="Obfuscation password"
style={{ width: 'calc(100% - 32px)' }}
/>
</Form.Item>
<Button
icon={<ReloadOutlined />}
icon={<ReloadOutlined aria-hidden />}
aria-label={t('regenerate')}
onClick={() =>
form.setFieldValue(
@@ -558,36 +561,47 @@ function TcpMaskItem({
}
const FILTER_MASK_OPTION = { value: 'filter', label: 'Filter (source IPs)' };
const FILTER_ALLOWED_HINT = 'Only these sources may connect; empty allows every source';
const FILTER_BLOCKED_HINT = 'These sources are dropped before any other mask runs';
// xray-core 26.10.21 (#7206) screens a listener's source IPs; a blocked match wins over an allowed
// one, and an empty list leaves that side open. Links and JSON subscriptions drop it.
function FilterMaskSettings({ fieldName }: { fieldName: number }) {
const uid = useId();
return (
<>
<Form.Item
label="Allowed Source IPs"
name={[fieldName, 'settings', 'allowedSourceIPs']}
tooltip="Only these sources may connect; empty allows every source"
tooltip={FILTER_ALLOWED_HINT}
>
<Select
aria-describedby={`${uid}-allowed-hint`}
mode="tags"
style={{ width: '100%' }}
tokenSeparators={[',', ' ']}
placeholder="10.0.0.0/8, geoip:ir"
/>
</Form.Item>
<span id={`${uid}-allowed-hint`} hidden>
{FILTER_ALLOWED_HINT}
</span>
<Form.Item
label="Blocked Source IPs"
name={[fieldName, 'settings', 'blockedSourceIPs']}
tooltip="These sources are dropped before any other mask runs"
tooltip={FILTER_BLOCKED_HINT}
>
<Select
aria-describedby={`${uid}-blocked-hint`}
mode="tags"
style={{ width: '100%' }}
tokenSeparators={[',', ' ']}
placeholder="203.0.113.7, geoip:cn"
/>
</Form.Item>
<span id={`${uid}-blocked-hint`} hidden>
{FILTER_BLOCKED_HINT}
</span>
</>
);
}
@@ -683,7 +697,7 @@ function FragmentRangeList({
<Button
type="primary"
size="small"
icon={<PlusOutlined />}
icon={<PlusOutlined aria-hidden />}
aria-label={t('add')}
onClick={() => add('')}
/>
@@ -776,7 +790,7 @@ function XmcProfilesList({ tcpFieldName }: { tcpFieldName: number }) {
<Button
type="primary"
size="small"
icon={<PlusOutlined />}
icon={<PlusOutlined aria-hidden />}
aria-label={t('add')}
onClick={() => add(defaultXmcProfile())}
/>
@@ -846,6 +860,21 @@ const HEADER_CUSTOM_TCP_GROUPS = [
},
] as const;
const UDP_MODE_HINT =
'Prefix puts the header on every packet; Standalone exchanges it as separate handshake packets first';
const NOISE_RESET_HINT = 'Seconds before the noise is sent to a destination again; 0 sends it once';
const NOISE_SCRIPT_HINT =
'A Lua file xray-core runs to build the noise, found beside the xray binary; replaces the noise list';
const DOH_TLS_HINT =
'tlsSettings for the https:// addresses, e.g. a serverName to front the domain';
const DOH_QUIC_HINT = 'QUIC parameters for an https:// address reached over h3';
const REUSE_HINT = 'Writes the bytes an earlier item captured under this name';
const TRANSFORM_HINT =
'An expression tree, e.g. {"op": "concat", "args": [{"type": "str", "bytes": "OK "}, {"reuse": "nonce"}]}';
const CAPTURE_HINT = 'Saves the bytes this item writes or reads under a name later items can reuse';
const UDP_HOP_HINT =
'Advertised to clients as a hop range; the server listens on its own port only';
function HeaderCustomGroups({
tcpFieldName,
form,
@@ -856,6 +885,7 @@ function HeaderCustomGroups({
absoluteSettingsPath: (string | number)[];
}) {
const { t } = useTranslation();
const uid = useId();
return (
<>
{HEADER_CUSTOM_TCP_GROUPS.map(({ key: groupKey, label, tooltip }) => (
@@ -866,10 +896,14 @@ function HeaderCustomGroups({
<Button
type="primary"
size="small"
icon={<PlusOutlined />}
icon={<PlusOutlined aria-hidden />}
aria-label={t('add')}
aria-describedby={`${uid}-${groupKey}-hint`}
onClick={() => addGroup([defaultClientServerItem()])}
/>
<span id={`${uid}-${groupKey}-hint`} hidden>
{tooltip}
</span>
</Form.Item>
{groups.map((group, gi) => (
<div key={group.key}>
@@ -890,7 +924,7 @@ function HeaderCustomGroups({
<Form.Item label="Items">
<Button
size="small"
icon={<PlusOutlined />}
icon={<PlusOutlined aria-hidden />}
aria-label={t('add')}
onClick={() => addItem(defaultClientServerItem())}
/>
@@ -950,7 +984,7 @@ function UdpMasksList({
<Button
type="primary"
size="small"
icon={<PlusOutlined />}
icon={<PlusOutlined aria-hidden />}
aria-label={t('add')}
onClick={() => {
const def = isHysteria || isWireguard ? 'salamander' : 'mkcp-legacy';
@@ -1228,6 +1262,7 @@ function SalamanderUdpMaskSettings({
absolutePath: (string | number)[];
}) {
const { t } = useTranslation();
const uid = useId();
const packetSizePath = [...absolutePath, 'settings', 'packetSize'];
const packetSize = Form.useWatch(packetSizePath, { form, preserve: true });
const mode = typeof packetSize === 'string' && packetSize.trim() !== '' ? 'gecko' : 'salamander';
@@ -1235,6 +1270,7 @@ function SalamanderUdpMaskSettings({
return (
<>
<Form.Item
htmlFor={`${uid}-salamander-mode`}
label="Mode"
extra={
mode === 'gecko'
@@ -1243,6 +1279,7 @@ function SalamanderUdpMaskSettings({
}
>
<Select
id={`${uid}-salamander-mode`}
value={mode}
onChange={(next) => {
if (next === 'gecko') {
@@ -1274,10 +1311,14 @@ function SalamanderUdpMaskSettings({
noStyle
rules={[{ validator: validateSalamanderPassword }]}
>
<Input placeholder="Obfuscation password" style={{ width: 'calc(100% - 32px)' }} />
<Input
aria-label="Password"
placeholder="Obfuscation password"
style={{ width: 'calc(100% - 32px)' }}
/>
</Form.Item>
<Button
icon={<ReloadOutlined />}
icon={<ReloadOutlined aria-hidden />}
aria-label={t('regenerate')}
onClick={() =>
form.setFieldValue(
@@ -1304,9 +1345,11 @@ function SalamanderUdpMaskSettings({
}
function GeckoPacketSizeInput({
id,
value,
onChange,
}: {
id?: string;
value?: string;
onChange?: (value: string) => void;
}) {
@@ -1315,6 +1358,7 @@ function GeckoPacketSizeInput({
return (
<Space.Compact block>
<InputNumber
id={id}
prefix="Min"
min={GECKO_MIN_PACKET_SIZE}
max={GECKO_MAX_PACKET_SIZE}
@@ -1325,6 +1369,7 @@ function GeckoPacketSizeInput({
style={{ width: '50%' }}
/>
<InputNumber
aria-label="Packet size max"
prefix="Max"
min={GECKO_MIN_PACKET_SIZE}
max={GECKO_MAX_PACKET_SIZE}
@@ -1348,14 +1393,12 @@ function UdpHeaderCustom({
absoluteSettingsPath: (string | number)[];
}) {
const { t } = useTranslation();
const uid = useId();
return (
<>
<Form.Item
label="Mode"
name={[udpFieldName, 'settings', 'mode']}
tooltip="Prefix puts the header on every packet; Standalone exchanges it as separate handshake packets first"
>
<Form.Item label="Mode" name={[udpFieldName, 'settings', 'mode']} tooltip={UDP_MODE_HINT}>
<Select
aria-describedby={`${uid}-mode-hint`}
allowClear
placeholder="Prefix"
options={[
@@ -1364,6 +1407,9 @@ function UdpHeaderCustom({
]}
/>
</Form.Item>
<span id={`${uid}-mode-hint`} hidden>
{UDP_MODE_HINT}
</span>
{(['client', 'server'] as const).map((groupKey) => (
<Form.List key={groupKey} name={[udpFieldName, 'settings', groupKey]}>
{(items, { add, remove }) => (
@@ -1372,7 +1418,7 @@ function UdpHeaderCustom({
<Button
type="primary"
size="small"
icon={<PlusOutlined />}
icon={<PlusOutlined aria-hidden />}
aria-label={t('add')}
onClick={() => add(defaultUdpClientServerItem())}
/>
@@ -1418,6 +1464,7 @@ const XDNS_RECORD_TYPE_OPTIONS = [
// Resolvers and extraPoll are read by clients only; a server keeps them for the share link.
function XdnsSettings({ udpFieldName }: { udpFieldName: number }) {
const { t } = useTranslation();
const uid = useId();
return (
<>
<Form.List name={[udpFieldName, 'settings', 'domains']}>
@@ -1427,7 +1474,7 @@ function XdnsSettings({ udpFieldName }: { udpFieldName: number }) {
<Button
type="primary"
size="small"
icon={<PlusOutlined />}
icon={<PlusOutlined aria-hidden />}
aria-label={t('add')}
onClick={() => add({ names: [], edns0: XDNS_LEGACY_EDNS0 })}
/>
@@ -1481,7 +1528,7 @@ function XdnsSettings({ udpFieldName }: { udpFieldName: number }) {
<Button
type="primary"
size="small"
icon={<PlusOutlined />}
icon={<PlusOutlined aria-hidden />}
aria-label={t('add')}
onClick={() => add({ addrs: [] })}
/>
@@ -1513,17 +1560,29 @@ function XdnsSettings({ udpFieldName }: { udpFieldName: number }) {
<Form.Item
label="DoH TLS"
name={[resolver.name, 'tlsConfig']}
tooltip="tlsSettings for the https:// addresses, e.g. a serverName to front the domain"
tooltip={DOH_TLS_HINT}
>
<JsonObjectInput placeholder='{"serverName": "dns.google", "fingerprint": "chrome"}' />
<JsonObjectInput
aria-describedby={`${uid}-doh-tls-hint-${ri}`}
placeholder='{"serverName": "dns.google", "fingerprint": "chrome"}'
/>
</Form.Item>
<span id={`${uid}-doh-tls-hint-${ri}`} hidden>
{DOH_TLS_HINT}
</span>
<Form.Item
label="DoH QUIC"
name={[resolver.name, 'quicConfig']}
tooltip="QUIC parameters for an https:// address reached over h3"
tooltip={DOH_QUIC_HINT}
>
<JsonObjectInput placeholder='{"congestion": "bbr"}' />
<JsonObjectInput
aria-describedby={`${uid}-doh-quic-hint-${ri}`}
placeholder='{"congestion": "bbr"}'
/>
</Form.Item>
<span id={`${uid}-doh-quic-hint-${ri}`} hidden>
{DOH_QUIC_HINT}
</span>
</div>
))}
</>
@@ -1619,25 +1678,32 @@ function NoiseItems({
form: FormInstance;
absoluteSettingsPath: (string | number)[];
}) {
const uid = useId();
const script = Form.useWatch([...absoluteSettingsPath, 'script'], form) as string | undefined;
return (
<>
<Form.Item
label="Reset"
name={[udpFieldName, 'settings', 'reset']}
tooltip="Seconds before the noise is sent to a destination again; 0 sends it once"
tooltip={NOISE_RESET_HINT}
rules={[{ validator: validateNoiseReset }]}
>
<Input placeholder="0 or 60-120" />
<Input placeholder="0 or 60-120" aria-describedby={`${uid}-reset-hint`} />
</Form.Item>
<span id={`${uid}-reset-hint`} hidden>
{NOISE_RESET_HINT}
</span>
<Form.Item
label="Script"
name={[udpFieldName, 'settings', 'script']}
tooltip="A Lua file xray-core runs to build the noise, found beside the xray binary; replaces the noise list"
tooltip={NOISE_SCRIPT_HINT}
normalize={(value: string) => value || undefined}
>
<Input allowClear placeholder="noise.lua" />
<Input allowClear placeholder="noise.lua" aria-describedby={`${uid}-script-hint`} />
</Form.Item>
<span id={`${uid}-script-hint`} hidden>
{NOISE_SCRIPT_HINT}
</span>
{!script && (
<NoiseItemList
udpFieldName={udpFieldName}
@@ -1668,7 +1734,7 @@ function NoiseItemList({
<Button
type="primary"
size="small"
icon={<PlusOutlined />}
icon={<PlusOutlined aria-hidden />}
aria-label={t('add')}
onClick={() => add(defaultNoiseItem())}
/>
@@ -1728,10 +1794,14 @@ function validateCustomVarName(_: unknown, value: unknown) {
// Hands the form only a parsed JSON object, so half-typed text never reaches the config.
function JsonObjectInput({
id,
'aria-describedby': ariaDescribedBy,
value,
onChange,
placeholder,
}: {
id?: string;
'aria-describedby'?: string;
value?: Record<string, unknown>;
onChange?: (next: Record<string, unknown> | undefined) => void;
placeholder?: string;
@@ -1741,6 +1811,8 @@ function JsonObjectInput({
return (
<>
<Input.TextArea
id={id}
aria-describedby={ariaDescribedBy}
value={text}
autoSize={{ minRows: 2, maxRows: 8 }}
placeholder={placeholder}
@@ -1783,6 +1855,7 @@ function ItemEditor({
onRemove?: () => void;
}) {
const { t } = useTranslation();
const uid = useId();
const [kind, setKind] = useState<ItemKind>(() =>
allowVariables ? itemKindOf(form.getFieldValue(absoluteItemPath)) : 'bytes',
);
@@ -1816,8 +1889,13 @@ function ItemEditor({
return (
<>
{allowVariables && (
<Form.Item label="Kind">
<Select value={kind} onChange={onKindChange} options={ITEM_KIND_OPTIONS} />
<Form.Item label="Kind" htmlFor={`${uid}-kind`}>
<Select
id={`${uid}-kind`}
value={kind}
onChange={onKindChange}
options={ITEM_KIND_OPTIONS}
/>
</Form.Item>
)}
{kind === 'bytes' && (
@@ -1848,27 +1926,40 @@ function ItemEditor({
)}
{kind === 'reuse' && (
<Form.Item
label="Reuse"
name={[fieldName, 'reuse']}
tooltip="Writes the bytes an earlier item captured under this name"
rules={[
{ required: true, message: 'name the captured variable' },
{ validator: validateCustomVarName },
]}
>
<Input placeholder="variable name" />
</Form.Item>
<>
<Form.Item
label="Reuse"
name={[fieldName, 'reuse']}
tooltip={REUSE_HINT}
rules={[
{ required: true, message: 'name the captured variable' },
{ validator: validateCustomVarName },
]}
>
<Input placeholder="variable name" aria-describedby={`${uid}-reuse-hint`} />
</Form.Item>
<span id={`${uid}-reuse-hint`} hidden>
{REUSE_HINT}
</span>
</>
)}
{kind === 'transform' && (
<Form.Item
label="Transform"
name={[fieldName, 'transform']}
tooltip='An expression tree, e.g. {"op": "concat", "args": [{"type": "str", "bytes": "OK "}, {"reuse": "nonce"}]}'
rules={[{ required: true, message: 'enter the transform expression' }]}
>
<JsonObjectInput placeholder='{"op": "concat", "args": [{"reuse": "nonce"}]}' />
</Form.Item>
<>
<Form.Item
label="Transform"
name={[fieldName, 'transform']}
tooltip={TRANSFORM_HINT}
rules={[{ required: true, message: 'enter the transform expression' }]}
>
<JsonObjectInput
aria-describedby={`${uid}-transform-hint`}
placeholder='{"op": "concat", "args": [{"reuse": "nonce"}]}'
/>
</Form.Item>
<span id={`${uid}-transform-hint`} hidden>
{TRANSFORM_HINT}
</span>
</>
)}
<Form.Item
@@ -1910,10 +2001,14 @@ function ItemEditor({
<Form.Item label="Packet">
<Space.Compact block>
<Form.Item name={[fieldName, 'packet']} noStyle>
<Input placeholder="binary data" style={{ width: 'calc(100% - 32px)' }} />
<Input
aria-label="Packet"
placeholder="binary data"
style={{ width: 'calc(100% - 32px)' }}
/>
</Form.Item>
<Button
icon={<ReloadOutlined />}
icon={<ReloadOutlined aria-hidden />}
aria-label={t('regenerate')}
onClick={() =>
form.setFieldValue([...absoluteItemPath, 'packet'], RandomUtil.randomBase64())
@@ -1932,15 +2027,20 @@ function ItemEditor({
</Form.Item>
{allowVariables && (
<Form.Item
label="Capture"
name={[fieldName, 'capture']}
tooltip="Saves the bytes this item writes or reads under a name later items can reuse"
normalize={(v) => (v === '' ? undefined : v)}
rules={[{ validator: validateCustomVarName }]}
>
<Input placeholder="variable name" />
</Form.Item>
<>
<Form.Item
label="Capture"
name={[fieldName, 'capture']}
tooltip={CAPTURE_HINT}
normalize={(v) => (v === '' ? undefined : v)}
rules={[{ validator: validateCustomVarName }]}
>
<Input placeholder="variable name" aria-describedby={`${uid}-capture-hint`} />
</Form.Item>
<span id={`${uid}-capture-hint`} hidden>
{CAPTURE_HINT}
</span>
</>
)}
</>
);
@@ -1955,6 +2055,7 @@ function QuicParamsForm({
form: FormInstance;
showUdpHop: boolean;
}) {
const uid = useId();
const congestion = Form.useWatch([...base, 'congestion'], form) as string | undefined;
const udpHop = Form.useWatch([...base, 'udpHop'], { form, preserve: true }) as
| Record<string, unknown>
@@ -2012,16 +2113,18 @@ function QuicParamsForm({
itself listens on its own port. */}
{showUdpHop && (
<>
<Form.Item
label="UDP Hop"
tooltip="Advertised to clients as a hop range; the server listens on its own port only"
>
<Form.Item htmlFor={`${uid}-udp-hop`} label="UDP Hop" tooltip={UDP_HOP_HINT}>
<Switch
id={`${uid}-udp-hop`}
aria-describedby={`${uid}-udp-hop-hint`}
checked={hasUdpHop}
onChange={(v) => {
form.setFieldValue([...base, 'udpHop'], v ? defaultUdpHop() : undefined);
}}
/>
<span id={`${uid}-udp-hop-hint`} hidden>
{UDP_HOP_HINT}
</span>
</Form.Item>
{hasUdpHop && (
<>