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
@@ -0,0 +1,121 @@
import { useState } from 'react';
import type { Meta, StoryObj } from '@storybook/react-vite';
import { expect, within, waitFor } from 'storybook/test';
import { Button } from 'antd';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { AntdRootConfig } from '@/i18n/AntdRootConfig';
import { DBInbound } from '@/models/dbinbound';
import { ThemeProvider } from '@/hooks/useTheme';
import { setDatepicker } from '@/hooks/useDatepicker';
import InboundFormModal from './InboundFormModal';
const meta = {
title: 'Inbounds/InboundFormModal',
component: InboundFormModal,
tags: ['autodocs'],
beforeEach: () => {
setDatepicker('gregorian');
},
args: {
open: false,
mode: 'add',
dbInbound: null,
dbInbounds: [],
onClose: () => {},
onSaved: () => {},
},
parameters: {
docs: {
description: {
component:
'The real inbound form. Exercise named options, keyboard navigation, validation and modal focus here; verify TalkBack/Chrome and NVDA separately before claiming screen-reader compatibility.',
},
},
},
} satisfies Meta<typeof InboundFormModal>;
export default meta;
type Story = StoryObj<typeof meta>;
function InboundDemo({ edit = false }: { edit?: boolean }) {
const [open, setOpen] = useState(false);
const [queryClient] = useState(
() => new QueryClient({ defaultOptions: { queries: { retry: false } } }),
);
const [inbound] = useState(
() =>
new DBInbound({
id: 41,
protocol: 'shadowsocks',
enable: true,
port: 14443,
settings: {
method: 'chacha20-ietf-poly1305',
password: 'example-password',
network: 'tcp,udp',
clients: [],
},
streamSettings: { network: 'tcp', security: 'none', tcpSettings: {} },
}),
);
return (
<QueryClientProvider client={queryClient}>
<AntdRootConfig>
<ThemeProvider>
<Button onClick={() => setOpen(true)}>{edit ? 'Edit inbound' : 'Add inbound'}</Button>
<InboundFormModal
open={open}
mode={edit ? 'edit' : 'add'}
dbInbound={edit ? inbound : null}
dbInbounds={[]}
availableNodes={[]}
onClose={() => setOpen(false)}
onSaved={() => {}}
/>
</ThemeProvider>
</AntdRootConfig>
</QueryClientProvider>
);
}
export const Create: Story = {
render: () => <InboundDemo />,
};
export const SelectableOptions: Story = {
...Create,
play: async ({ canvas, canvasElement, userEvent }) => {
const page = within(canvasElement.ownerDocument.body);
const opener = canvas.getByRole('button', { name: 'Add inbound' });
await userEvent.click(opener);
const dialog = await page.findByRole('dialog', { name: 'Add Inbound' });
const protocol = within(dialog).getByRole('combobox', { name: 'Protocol' });
await userEvent.selectOptions(
protocol,
within(protocol).getByRole('option', { name: 'shadowsocks' }),
);
await expect(protocol).toHaveDisplayValue('shadowsocks');
await expect(protocol).toHaveFocus();
await userEvent.click(within(dialog).getByRole('tab', { name: 'Protocol' }));
const cipher = within(dialog).getByRole('combobox', { name: 'Encryption method' });
await userEvent.selectOptions(
cipher,
within(cipher).getByRole('option', { name: 'chacha20-ietf-poly1305' }),
);
await userEvent.click(within(dialog).getByRole('tab', { name: 'Stream' }));
const transport = within(dialog).getByRole('combobox', { name: 'Transmission' });
await userEvent.selectOptions(
transport,
within(transport).getByRole('option', { name: 'WebSocket' }),
);
await expect(within(dialog).getByRole('textbox', { name: 'Path' })).toHaveValue('/');
await userEvent.click(within(dialog).getAllByRole('button', { name: 'Close' })[0]);
await waitFor(() => expect(opener).toHaveFocus());
},
};
export const Edit: Story = {
render: () => <InboundDemo edit />,
};
@@ -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>
);
}
@@ -1,6 +1,7 @@
import { NativeSelect } from '@/components/form/NativeSelect';
import { useId } from 'react';
import { useTranslation } from 'react-i18next';
import { Form, Input, InputNumber, Select, Switch } from 'antd';
import { Form, Input, InputNumber, Switch } from 'antd';
import { useFormContext, useWatch } from 'react-hook-form';
import { HeaderMapEditor } from '@/components/form';
@@ -60,7 +61,7 @@ export default function HysteriaFields() {
{masq && (
<>
<FormField label={t('pages.inbounds.form.type')} name={[...MASQ_PATH, 'type']}>
<Select
<NativeSelect
options={[
{ value: '', label: 'default (404 page)' },
{ value: 'proxy', label: 'proxy (reverse proxy)' },
@@ -1,5 +1,6 @@
import { NativeSelect } from '@/components/form/NativeSelect';
import { useTranslation } from 'react-i18next';
import { Input, Select, Switch } from 'antd';
import { Input, Switch } from 'antd';
import { FormField } from '@/components/form/rhf';
import AccountsList from './accounts-list';
@@ -10,7 +11,7 @@ export default function MixedFields({ mixedUdpOn }: { mixedUdpOn: boolean }) {
<>
<AccountsList />
<FormField name={['settings', 'auth']} label={t('pages.inbounds.info.auth')}>
<Select
<NativeSelect
options={[
{ value: 'noauth', label: 'noauth' },
{ value: 'password', label: 'password' },
@@ -1,6 +1,7 @@
import { NativeSelect } from '@/components/form/NativeSelect';
import { useId } from 'react';
import { useTranslation } from 'react-i18next';
import { Button, Form, Input, Select, Space, Switch } from 'antd';
import { Button, Form, Input, Space, Switch } from 'antd';
import { ReloadOutlined } from '@ant-design/icons';
import { useFormContext } from 'react-hook-form';
@@ -25,7 +26,9 @@ export default function ShadowsocksFields({ isSSWith2022 }: ShadowsocksFieldsPro
setValue('settings.password', RandomUtil.randomShadowsocksPassword(v as string));
}}
>
<Select options={SSInboundMethodSchema.options.map((m) => ({ value: m, label: m }))} />
<NativeSelect
options={SSInboundMethodSchema.options.map((m) => ({ value: m, label: m }))}
/>
</FormField>
{isSSWith2022 && (
<Form.Item htmlFor={`${uid}-password`} label={t('password')}>
@@ -48,7 +51,7 @@ export default function ShadowsocksFields({ isSSWith2022 }: ShadowsocksFieldsPro
</Form.Item>
)}
<FormField name={['settings', 'network']} label={t('pages.inbounds.network')}>
<Select
<NativeSelect
style={{ width: 120 }}
options={[
{ value: 'tcp,udp', label: 'TCP, UDP' },
@@ -1,3 +1,4 @@
import { NativeSelect } from '@/components/form/NativeSelect';
import { useState, useId } from 'react';
import { useTranslation } from 'react-i18next';
import {
@@ -111,7 +112,7 @@ export default function TuicFields() {
label={t('pages.xray.tuic.logLevel')}
tooltip={t('pages.xray.tuic.logLevelHint')}
>
<Select
<NativeSelect
options={[
{ label: 'Info', value: 'info' },
{ label: 'Warn', value: 'warn' },
@@ -199,7 +200,7 @@ export default function TuicFields() {
label={t('pages.xray.tuic.congestionControl')}
tooltip={t('pages.xray.tuic.congestionControlHint')}
>
<Select
<NativeSelect
options={[
{ label: 'BBR', value: 'bbr' },
{ label: 'CUBIC', value: 'cubic' },
@@ -244,7 +245,7 @@ export default function TuicFields() {
label={t('pages.xray.tuic.udpRelayMode')}
tooltip={t('pages.xray.tuic.udpRelayModeHint')}
>
<Select
<NativeSelect
options={[
{ label: 'Native (Recommended)', value: 'native' },
{ label: 'QUIC', value: 'quic' },
@@ -1,5 +1,6 @@
import { NativeSelect } from '@/components/form/NativeSelect';
import { useTranslation } from 'react-i18next';
import { Input, InputNumber, Select, Switch } from 'antd';
import { Input, InputNumber, Switch } from 'antd';
import { HeaderMapEditor } from '@/components/form';
import { FormField } from '@/components/form/rhf';
@@ -21,7 +22,7 @@ export default function TunnelFields() {
name={['settings', 'allowedNetwork']}
label={t('pages.inbounds.form.allowedNetwork')}
>
<Select
<NativeSelect
options={[
{ value: 'tcp,udp', label: 'TCP, UDP' },
{ value: 'tcp', label: 'TCP' },
@@ -1,6 +1,7 @@
import { NativeSelect } from '@/components/form/NativeSelect';
import { useState, useId } from 'react';
import { useTranslation } from 'react-i18next';
import { Button, Form, Input, InputNumber, Select, Space, Typography } from 'antd';
import { Button, Form, Input, InputNumber, Space, Typography } from 'antd';
import { Controller, useFormContext } from 'react-hook-form';
import { FormField } from '@/components/form/rhf';
@@ -50,7 +51,7 @@ export default function VlessFields({
</FormField>
<Form.Item htmlFor={`${uid}-authKind`} label={t('pages.inbounds.vlessAuthGenerate')}>
<Space size={8} wrap>
<Select
<NativeSelect
id={`${uid}-authKind`}
value={authKind}
onChange={(v) => setAuthKind(v)}
@@ -1,3 +1,4 @@
import { NativeSelect } from '@/components/form/NativeSelect';
import { useState, useId } from 'react';
import { useFormContext, useWatch } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
@@ -112,7 +113,9 @@ export default function RealityForm({
name={['streamSettings', 'realitySettings', 'settings', 'fingerprint']}
label="uTLS"
>
<Select options={Object.values(UTLS_FINGERPRINT).map((fp) => ({ value: fp, label: fp }))} />
<NativeSelect
options={Object.values(UTLS_FINGERPRINT).map((fp) => ({ value: fp, label: fp }))}
/>
</FormField>
<Form.Item
htmlFor={`${uid}-target`}
@@ -1,3 +1,4 @@
import { NativeSelect } from '@/components/form/NativeSelect';
import { useId } from 'react';
import { useTranslation } from 'react-i18next';
import {
@@ -154,7 +155,7 @@ function CertRow({
name={['streamSettings', 'tlsSettings', 'certificates', index, 'usage']}
label={t('pages.inbounds.form.usageOption')}
>
<Select
<NativeSelect
style={{ width: '50%' }}
options={Object.values(USAGE_OPTION).map((u) => ({ value: u, label: u }))}
/>
@@ -212,7 +213,7 @@ function EchSockoptSection() {
name={['streamSettings', 'tlsSettings', 'echSockopt', 'domainStrategy']}
label={t('pages.xray.wireguard.domainStrategy')}
>
<Select
<NativeSelect
options={Object.values(DOMAIN_STRATEGY_OPTION).map((v) => ({ value: v, label: v }))}
/>
</FormField>
@@ -354,14 +355,14 @@ export default function TlsForm({
<Form.Item htmlFor={`${uid}-minVersion`} label={t('pages.inbounds.form.minMaxVersion')}>
<Space.Compact block>
<FormField name={['streamSettings', 'tlsSettings', 'minVersion']} noStyle>
<Select
<NativeSelect
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
<NativeSelect
aria-label={t('pages.inbounds.form.maxVersion')}
style={{ width: '50%' }}
options={Object.values(TLS_VERSION_OPTION).map((v) => ({ value: v, label: v }))}
@@ -370,7 +371,7 @@ export default function TlsForm({
</Space.Compact>
</Form.Item>
<FormField name={['streamSettings', 'tlsSettings', 'settings', 'fingerprint']} label="uTLS">
<Select
<NativeSelect
options={[
{ value: '', label: 'None' },
...Object.values(UTLS_FINGERPRINT).map((fp) => ({ value: fp, label: fp })),
@@ -1,3 +1,4 @@
import { NativeSelect } from '@/components/form/NativeSelect';
import { useId } from 'react';
import { useTranslation } from 'react-i18next';
import { Alert, Form, InputNumber, Segmented, Select, Switch } from 'antd';
@@ -207,13 +208,13 @@ export default function SockoptForm({
name={['streamSettings', 'sockopt', 'tcpcongestion']}
label={t('pages.inbounds.form.tcpCongestion')}
>
<Select
<NativeSelect
style={{ width: '50%' }}
options={Object.values(TCP_CONGESTION_OPTION).map((c) => ({ value: c, label: c }))}
/>
</FormField>
<FormField name={['streamSettings', 'sockopt', 'tproxy']} label="TProxy">
<Select
<NativeSelect
style={{ width: '50%' }}
options={[
{ value: 'off', label: 'Off' },
@@ -1,5 +1,6 @@
import { NativeSelect } from '@/components/form/NativeSelect';
import { useTranslation } from 'react-i18next';
import { AutoComplete, Input, InputNumber, Select, Switch } from 'antd';
import { AutoComplete, Input, InputNumber, Switch } from 'antd';
import { useFormContext, useWatch } from 'react-hook-form';
import { HeaderMapEditor } from '@/components/form';
@@ -81,7 +82,7 @@ export default function XhttpForm() {
name={['streamSettings', 'xhttpSettings', 'mode']}
label={t('pages.inbounds.info.mode')}
>
<Select
<NativeSelect
style={{ width: '50%' }}
options={(['auto', 'packet-up', 'stream-up', 'stream-one'] as const).map((m) => ({
value: m,
@@ -149,7 +150,7 @@ export default function XhttpForm() {
name={['streamSettings', 'xhttpSettings', 'uplinkHTTPMethod']}
label={t('pages.inbounds.form.uplinkHttpMethod')}
>
<Select
<NativeSelect
options={[
{ value: '', label: 'Default (POST)' },
{ value: 'POST', label: 'POST' },
@@ -187,7 +188,7 @@ export default function XhttpForm() {
name={['streamSettings', 'xhttpSettings', 'xPaddingPlacement']}
label={t('pages.inbounds.form.paddingPlacement')}
>
<Select
<NativeSelect
options={[
{ value: '', label: 'Default (queryInHeader)' },
{ value: 'queryInHeader', label: 'queryInHeader' },
@@ -201,7 +202,7 @@ export default function XhttpForm() {
name={['streamSettings', 'xhttpSettings', 'xPaddingMethod']}
label={t('pages.inbounds.form.paddingMethod')}
>
<Select
<NativeSelect
options={[
{ value: '', label: 'Default (repeat-x)' },
{ value: 'repeat-x', label: 'repeat-x' },
@@ -215,7 +216,7 @@ export default function XhttpForm() {
name={['streamSettings', 'xhttpSettings', 'sessionIDPlacement']}
label={t('pages.inbounds.form.sessionPlacement')}
>
<Select
<NativeSelect
options={[
{ value: '', label: 'Default (path)' },
{ value: 'path', label: 'path' },
@@ -259,7 +260,7 @@ export default function XhttpForm() {
name={['streamSettings', 'xhttpSettings', 'seqPlacement']}
label={t('pages.inbounds.form.sequencePlacement')}
>
<Select
<NativeSelect
options={[
{ value: '', label: 'Default (path)' },
{ value: 'path', label: 'path' },
@@ -283,7 +284,7 @@ export default function XhttpForm() {
name={['streamSettings', 'xhttpSettings', 'uplinkDataPlacement']}
label={t('pages.inbounds.form.uplinkDataPlacement')}
>
<Select
<NativeSelect
options={[
{ value: '', label: 'Default (auto)' },
{ value: 'auto', label: 'auto' },