fix(frontend): make inbound forms accessible to screen readers (#7025)

Expose selectable options and selected values, associate validation guidance with controls, and contain keyboard focus in inbound dialogs. Preserve create and edit serialization and add Chromium keyboard and accessibility checks.
This commit is contained in:
Hamed
2026-11-05 20:38:49 +03:30
committed by GitHub
parent dbb6212988
commit f4030c889c
29 changed files with 875 additions and 110 deletions
@@ -1,8 +1,22 @@
import { NativeSelect } from '@/components/form/NativeSelect';
import { useEffect, useRef, useState, useId } from 'react';
import type { KeyboardEvent } from 'react';
import { useTranslation } from 'react-i18next';
import dayjs from 'dayjs';
import { Alert, Form, Input, InputNumber, Modal, Radio, Select, Switch, Tabs, message } from 'antd';
import {
Alert,
ConfigProvider,
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';
@@ -92,6 +106,28 @@ const SHARE_ADDR_STRATEGIES = ['node', 'listen', 'custom'] as const;
const SHARE_ADDR_HOSTNAME_RE =
/^[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?(\.[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?)*$/;
function inboundPopupContainer(trigger?: HTMLElement): HTMLElement {
return (
trigger?.closest<HTMLElement>('.ant-modal-body') ?? trigger?.parentElement ?? document.body
);
}
function loopInboundFocus(event: KeyboardEvent<HTMLDivElement>) {
if (event.key !== 'Tab') return;
const first = event.currentTarget.querySelector<HTMLButtonElement>(
'.ant-modal-close:not(:disabled)',
);
const last = Array.from(
event.currentTarget.querySelectorAll<HTMLButtonElement>(
'.ant-modal-footer button:not(:disabled)',
),
).at(-1);
if (first && last && event.target === (event.shiftKey ? first : last)) {
event.preventDefault();
(event.shiftKey ? last : first).focus();
}
}
function isValidShareAddrInput(value: string): boolean {
const v = value.trim();
if (v.length === 0) return true;
@@ -677,7 +713,7 @@ export default function InboundFormModal({
)}
<FormField name="protocol" label={t('pages.inbounds.protocol')}>
<Select id="protocol" disabled={mode === 'edit'} options={PROTOCOL_OPTIONS} />
<NativeSelect id="protocol" disabled={mode === 'edit'} options={PROTOCOL_OPTIONS} />
</FormField>
<FormField
@@ -695,7 +731,7 @@ export default function InboundFormModal({
label={t('pages.inbounds.form.shareAddrStrategy')}
tooltip={t('pages.inbounds.form.shareAddrStrategyHelp')}
>
<Select
<NativeSelect
options={SHARE_ADDR_STRATEGIES.filter(
(strategy) => strategy !== 'node' || nodeShareOptionAvailable,
).map((strategy) => ({
@@ -780,7 +816,7 @@ export default function InboundFormModal({
</Form.Item>
<FormField name="trafficReset" label={t('pages.inbounds.periodicTrafficResetTitle')}>
<Select
<NativeSelect
options={TRAFFIC_RESETS.map((r) => ({
value: r,
label: t(`pages.inbounds.periodicTrafficReset.${r}`),
@@ -958,7 +994,7 @@ export default function InboundFormModal({
<>
{hasSelectableTransport && (
<Form.Item htmlFor={`${uid}-network`} label={t('transmission')}>
<Select
<NativeSelect
id={`${uid}-network`}
style={{ width: '75%' }}
value={network}
@@ -1034,8 +1070,11 @@ export default function InboundFormModal({
const securityTab = (
<>
<Form.Item label={t('pages.inbounds.securityTab')}>
<Form.Item
label={<span id={`${uid}-security-label`}>{t('pages.inbounds.securityTab')}</span>}
>
<Radio.Group
aria-labelledby={`${uid}-security-label`}
value={security}
buttonStyle="solid"
disabled={!tlsOk}
@@ -1174,7 +1213,7 @@ export default function InboundFormModal({
const sniffingTab = <SniffingTab />;
return (
<>
<ConfigProvider virtual={false} getPopupContainer={inboundPopupContainer}>
{messageContextHolder}
{modalContextHolder}
<Modal
@@ -1188,6 +1227,11 @@ export default function InboundFormModal({
onOk={submit}
onCancel={onClose}
destroyOnHidden
modalRender={(content) => (
<div role="presentation" onKeyDown={loopInboundFocus}>
{content}
</div>
)}
>
<FormProvider {...methods}>
<Form
@@ -1273,6 +1317,6 @@ export default function InboundFormModal({
</Form>
</FormProvider>
</Modal>
</>
</ConfigProvider>
);
}