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,14 @@
.native-select {
padding-inline: 11px;
font-family: inherit;
cursor: pointer;
}
.native-select:focus-visible {
outline: 2px solid currentColor;
outline-offset: 2px;
}
.native-select:disabled {
cursor: not-allowed;
}
@@ -0,0 +1,67 @@
import { useState } from 'react';
import type { Meta, StoryObj } from '@storybook/react-vite';
import { expect, within } from 'storybook/test';
import { NativeSelect } from './NativeSelect';
const meta = {
title: 'Form/NativeSelect',
component: NativeSelect,
tags: ['autodocs'],
parameters: {
docs: {
description: {
component:
'Native single selection for short, fixed lists. Labels, values, keyboard interaction and the mobile picker belong to the browser. Index keys keep number and string values distinct in onChange; searchable, clearable and multi-select fields continue using Ant Design.',
},
},
},
argTypes: {
options: {
description: 'Ordered labels, typed string/number values and optional disabled choices.',
},
value: {
description: 'Controlled form value. Saved values absent from the list remain visible.',
},
onChange: {
description: 'Receives the original typed option value for RHF or Ant Design Form.',
},
placeholder: { description: 'Shown until the form has a value.' },
disabled: {
description: 'Uses native disabled behavior and preserves the controlled form value.',
},
size: {
description: 'Ant Design token size, without turning the control into a multi-row listbox.',
},
},
args: {
'aria-label': 'Protocol',
options: [
{ value: 'vless', label: 'VLESS' },
{ value: 'shadowsocks', label: 'Shadowsocks' },
{ value: 'tuic', label: 'TUIC', disabled: true },
],
},
} satisfies Meta<typeof NativeSelect>;
export default meta;
type Story = StoryObj<typeof meta>;
export const SingleSelection: Story = {
render: function Render(args) {
const [value, setValue] = useState<string | number>('vless');
return <NativeSelect {...args} value={value} onChange={setValue} />;
},
play: async ({ canvas, userEvent }) => {
const select = canvas.getByRole('combobox', { name: 'Protocol' });
await expect(select).toHaveDisplayValue('VLESS');
await userEvent.selectOptions(
select,
within(select).getByRole('option', { name: 'Shadowsocks' }),
);
await expect(select).toHaveDisplayValue('Shadowsocks');
await expect(within(select).getByRole('option', { name: 'Shadowsocks' })).toHaveProperty(
'selected',
true,
);
},
};
@@ -0,0 +1,70 @@
import type { ComponentPropsWithRef } from 'react';
import { theme } from 'antd';
import './NativeSelect.css';
export interface NativeSelectProps<Value extends string | number> extends Omit<
ComponentPropsWithRef<'select'>,
'value' | 'defaultValue' | 'onChange' | 'multiple' | 'children' | 'size'
> {
value?: Value | null;
onChange?: (value: Value) => void;
options: readonly { value: Value; label: string | number; disabled?: boolean }[];
placeholder?: string;
size?: 'small' | 'middle' | 'large';
}
export function NativeSelect<Value extends string | number>({
value,
options,
onChange,
placeholder,
disabled,
className,
style,
size,
...props
}: NativeSelectProps<Value>) {
const { token } = theme.useToken();
const selected = options.findIndex((option) => Object.is(option.value, value));
const hasUnlistedValue = selected < 0 && value != null;
return (
<select
{...props}
className={['native-select', className].filter(Boolean).join(' ')}
disabled={disabled}
value={selected < 0 ? '' : String(selected)}
onChange={(event) => {
const option = options[Number(event.target.value)];
if (event.target.value !== '' && option && !option.disabled) onChange?.(option.value);
}}
style={{
width: '100%',
minWidth: 0,
height:
size === 'small'
? token.controlHeightSM
: size === 'large'
? token.controlHeightLG
: token.controlHeight,
border: `1px solid ${props['aria-invalid'] === true || props['aria-invalid'] === 'true' ? token.colorError : token.colorBorder}`,
borderRadius: token.borderRadius,
backgroundColor: disabled ? token.colorBgContainerDisabled : token.colorBgContainer,
color: disabled ? token.colorTextDisabled : token.colorText,
fontSize: token.fontSize,
...style,
}}
>
{selected < 0 && (
<option value="" disabled={!hasUnlistedValue} hidden={!hasUnlistedValue}>
{hasUnlistedValue ? String(value) : (placeholder ?? '')}
</option>
)}
{options.map((option, index) => (
<option key={String(index)} value={String(index)} disabled={option.disabled}>
{option.label}
</option>
))}
</select>
);
}
@@ -221,6 +221,9 @@ export const ValidationErrors: Story = {
play: async ({ canvas, userEvent }) => {
const remarkError = await canvas.findByText('Remark is required');
await waitFor(() => expect(remarkError).toBeVisible());
await expect(canvas.getByRole('textbox', { name: 'Remark' })).toHaveAccessibleDescription(
'Remark is required',
);
await waitFor(() => expect(canvas.getByText('Port must be between 1 and 65535')).toBeVisible());
await userEvent.type(canvas.getByPlaceholderText('vless-reality-443'), 'vless-reality-443');
await userEvent.click(canvas.getByRole('button', { name: 'Validate' }));
+12 -9
View File
@@ -1,5 +1,5 @@
import { cloneElement, useId } from 'react';
import type { CSSProperties, ReactElement, ReactNode } from 'react';
import type { AriaAttributes, CSSProperties, ReactElement, ReactNode } from 'react';
import { Controller } from 'react-hook-form';
import type { Control, ControllerProps, FieldValues, Path } from 'react-hook-form';
import { Form } from 'antd';
@@ -50,12 +50,10 @@ export function FormField<T extends FieldValues = FieldValues>({
const dottedName = toDotted(name) as Path<T>;
const generatedId = useId();
const hintId = useId();
const childOwnProps = (children as ReactElement<{ id?: string; 'aria-describedby'?: string }>)
.props;
const errorId = `${generatedId}-error`;
const extraId = `${generatedId}-extra`;
const childOwnProps = (children as ReactElement<{ id?: string } & AriaAttributes>).props;
const fieldId = childOwnProps.id ?? generatedId;
const describedBy = tooltip
? [childOwnProps['aria-describedby'], hintId].filter(Boolean).join(' ')
: undefined;
return (
<Controller
@@ -67,8 +65,14 @@ export function FormField<T extends FieldValues = FieldValues>({
const help = fieldState.error?.message
? t(fieldState.error.message, { defaultValue: fieldState.error.message })
: undefined;
const describedBy =
[childOwnProps['aria-describedby'], tooltip && hintId, extra && extraId, help && errorId]
.filter(Boolean)
.join(' ') || undefined;
const childProps: Record<string, unknown> = {
id: fieldId,
'aria-describedby': describedBy,
'aria-invalid': fieldState.invalid || childOwnProps['aria-invalid'],
[valueProp]: displayValue,
onChange: (...args: unknown[]) => {
const raw = normalizeAntdOnChange(args, valueProp);
@@ -79,16 +83,15 @@ export function FormField<T extends FieldValues = FieldValues>({
onBlur: field.onBlur,
ref: field.ref,
};
if (describedBy) childProps['aria-describedby'] = describedBy;
return (
<Form.Item
label={label}
htmlFor={label ? fieldId : undefined}
tooltip={tooltip}
extra={extra}
extra={extra ? <span id={extraId}>{extra}</span> : extra}
required={required}
validateStatus={fieldState.error ? 'error' : undefined}
help={help}
help={help ? <span id={errorId}>{help}</span> : undefined}
noStyle={noStyle}
className={className}
style={style}
@@ -1,3 +1,4 @@
import { NativeSelect } from '@/components/form/NativeSelect';
import { Button, Divider, Form, Input, Select } from 'antd';
import { DeleteOutlined, PlusOutlined } from '@ant-design/icons';
import { useTranslation } from 'react-i18next';
@@ -68,7 +69,7 @@ export default function CustomSockoptList({
<Input placeholder="decimal, e.g. 19" />
</Form.Item>
<Form.Item label="Type" name={[field.name, 'type']}>
<Select options={TYPE_OPTIONS} />
<NativeSelect options={TYPE_OPTIONS} />
</Form.Item>
<Form.Item label="Value" name={[field.name, 'value']}>
<Input placeholder="value" />
@@ -1,3 +1,4 @@
import { NativeSelect } from '@/components/form/NativeSelect';
import { useEffect, useId, useRef, useState } from 'react';
import {
AutoComplete,
@@ -407,7 +408,7 @@ function TcpMasksList({
}
/>
{allowPresets && (
<Select
<NativeSelect
id={`${uid}-tcp-presets`}
size="small"
style={{ minWidth: 160 }}
@@ -478,7 +479,7 @@ function TcpMaskItem({
</Divider>
<Form.Item label="Type" name={[fieldName, 'type']}>
<Select
<NativeSelect
onChange={(v) =>
form.setFieldValue([...absolutePath, 'settings'], defaultTcpMaskSettings(v))
}
@@ -1123,7 +1124,7 @@ function UdpMaskItem({
</Divider>
<Form.Item label="Type" name={[fieldName, 'type']}>
<Select onChange={onTypeChange} options={options} />
<NativeSelect onChange={onTypeChange} options={options} />
</Form.Item>
<Form.Item
@@ -1147,7 +1148,7 @@ function UdpMaskItem({
return (
<>
<Form.Item label="Header" name={[fieldName, 'settings', 'header']}>
<Select
<NativeSelect
options={[
{ value: '', label: 'Original / AES-128-GCM' },
{ value: 'dns', label: 'DNS' },
@@ -1327,7 +1328,7 @@ function SalamanderUdpMaskSettings({
: 'Scrambles each packet into random-looking bytes.'
}
>
<Select
<NativeSelect
id={`${uid}-salamander-mode`}
value={mode}
onChange={(next) => {
@@ -1947,7 +1948,7 @@ function ItemEditor({
<>
{allowVariables && (
<Form.Item label="Kind" htmlFor={`${uid}-kind`}>
<Select
<NativeSelect
id={`${uid}-kind`}
value={kind}
onChange={onKindChange}
@@ -1957,7 +1958,7 @@ function ItemEditor({
)}
{kind === 'bytes' && (
<Form.Item label="Type" name={[fieldName, 'type']}>
<Select
<NativeSelect
onChange={onTypeChange}
options={[
{ value: 'array', label: 'Array' },
@@ -2122,7 +2123,7 @@ function QuicParamsForm({
return (
<>
<Form.Item label="Congestion" name={[...base, 'congestion']}>
<Select
<NativeSelect
options={[
{ value: 'reno', label: 'Reno' },
{ value: 'bbr', label: 'BBR' },
@@ -1,3 +1,4 @@
import { NativeSelect } from '@/components/form/NativeSelect';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Input, InputNumber, Select, Switch, Typography } from 'antd';
@@ -89,7 +90,7 @@ export default function XDriveForm() {
return (
<>
<FormField label={t('pages.inbounds.form.xdrive.service')} name={[...BASE, 'service']}>
<Select options={serviceOptions} />
<NativeSelect options={serviceOptions} />
</FormField>
<FormField
label={t('pages.inbounds.form.xdrive.remoteFolder')}
@@ -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' },
@@ -0,0 +1,133 @@
import { describe, expect, it, vi } from 'vitest';
import '@testing-library/jest-dom/vitest';
import { screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { ConfigProvider } from 'antd';
import InboundFormModal from '@/pages/inbounds/form/InboundFormModal';
import { DBInbound } from '@/models/dbinbound';
import { HttpUtil } from '@/utils';
import { renderWithProviders } from './test-utils';
function renderInbound(mode: 'add' | 'edit' = 'add') {
const inbound = new DBInbound({
id: 41,
protocol: 'vless',
enable: true,
port: 14443,
settings: { clients: [], decryption: 'none', encryption: 'none' },
streamSettings: { network: 'tcp', security: 'none', tcpSettings: {} },
});
renderWithProviders(
<ConfigProvider theme={{ token: { motion: false } }}>
<InboundFormModal
open
mode={mode}
dbInbound={mode === 'edit' ? inbound : null}
dbInbounds={[]}
availableNodes={[]}
onClose={() => {}}
onSaved={() => {}}
/>
</ConfigProvider>,
);
return userEvent.setup();
}
describe('Inbound screen-reader interaction', () => {
it('exposes the selected protocol as the closed control value', () => {
renderInbound();
expect(screen.getByRole('combobox', { name: 'Protocol' })).toHaveDisplayValue('vless');
});
it('exposes all visible protocol options as selectable items within its dialog', async () => {
const user = renderInbound();
const dialog = screen.getByRole('dialog', { name: 'Add Inbound' });
const protocol = within(dialog).getByRole('combobox', { name: 'Protocol' });
const shadowsocks = within(protocol).getByRole('option', { name: 'shadowsocks' });
expect(within(protocol).getByRole('option', { name: 'tuic' })).toBeInTheDocument();
expect(
within(protocol).getByRole('option', { name: 'vless', selected: true }),
).toBeInTheDocument();
await user.selectOptions(protocol, shadowsocks);
expect(protocol).toHaveDisplayValue('shadowsocks');
await user.click(screen.getByRole('tab', { name: 'Protocol' }));
expect(screen.getByRole('combobox', { name: 'Encryption method' })).toBeVisible();
});
it('selects transport by its option role and submits the actual create payload', async () => {
const post = vi.mocked(HttpUtil.post).mockClear();
const user = renderInbound();
await user.click(screen.getByRole('tab', { name: 'Stream' }));
const transport = screen.getByRole('combobox', { name: 'Transmission' });
await user.selectOptions(
transport,
within(transport).getByRole('option', { name: 'WebSocket' }),
);
const path = screen.getByRole('textbox', { name: 'Path' });
await user.clear(path);
await user.type(path, '/accessible');
await user.click(screen.getByRole('button', { name: 'Create' }));
await waitFor(() =>
expect(post).toHaveBeenCalledWith('/panel/api/inbounds/add', expect.anything()),
);
const payload = post.mock.calls.find(
([url]) => url === '/panel/api/inbounds/add',
)![1] as Record<string, unknown>;
expect(JSON.parse(String(payload.streamSettings))).toMatchObject({
network: 'ws',
security: 'none',
wsSettings: { path: '/accessible' },
});
});
it('preserves the disabled protocol in edit mode and exposes a named security group', async () => {
const user = renderInbound('edit');
expect(screen.getByRole('combobox', { name: 'Protocol' })).toBeDisabled();
await user.click(screen.getByRole('tab', { name: 'Security' }));
const security = screen.getByRole('radiogroup', { name: 'Security' });
expect(
within(security)
.getAllByRole('radio')
.map((radio) => (radio as HTMLInputElement).value),
).toEqual(['none', 'tls', 'reality']);
});
it('preserves edit serialization after a keyboard-accessible transport change', async () => {
const post = vi.mocked(HttpUtil.post).mockClear();
const user = renderInbound('edit');
await user.click(screen.getByRole('tab', { name: 'Stream' }));
const transport = screen.getByRole('combobox', { name: 'Transmission' });
await user.selectOptions(transport, within(transport).getByRole('option', { name: 'gRPC' }));
await user.click(screen.getByRole('button', { name: 'Save Changes' }));
await waitFor(() =>
expect(post).toHaveBeenCalledWith('/panel/api/inbounds/update/41', expect.anything()),
);
const payload = post.mock.calls.find(
([url]) => url === '/panel/api/inbounds/update/41',
)![1] as Record<string, unknown>;
expect(payload).toMatchObject({ protocol: 'vless', port: 14443, enable: true });
expect(JSON.parse(String(payload.streamSettings))).toMatchObject({
network: 'grpc',
security: 'none',
grpcSettings: { serviceName: '' },
});
expect(JSON.parse(String(payload.settings))).not.toHaveProperty('clients');
});
it('reveals and describes a validation error on a hidden tab without submitting', async () => {
const post = vi.mocked(HttpUtil.post).mockClear();
const user = renderInbound();
const strategy = screen.getByRole('combobox', { name: 'Share address strategy' });
await user.selectOptions(strategy, within(strategy).getByRole('option', { name: 'Custom' }));
const address = screen.getByRole('textbox', { name: 'Custom share address' });
await user.type(address, 'https://example.test/path');
await user.click(screen.getByRole('tab', { name: 'Stream' }));
await user.click(screen.getByRole('button', { name: 'Create' }));
await waitFor(() => expect(address).toBeVisible());
expect(address).toHaveAttribute('aria-invalid', 'true');
expect(address).toHaveAccessibleDescription(expect.stringContaining('without a scheme'));
await waitFor(() => expect(address).toHaveFocus());
expect(post.mock.calls.filter(([url]) => url === '/panel/api/inbounds/add')).toEqual([]);
});
});
+31 -53
View File
@@ -1,11 +1,12 @@
import { describe, it, expect, onTestFinished, vi } from 'vitest';
import { screen, act, render, cleanup, fireEvent, waitFor } from '@testing-library/react';
import { screen, act, render, cleanup, fireEvent, waitFor, within } from '@testing-library/react';
import InboundFormModal from '@/pages/inbounds/form/InboundFormModal';
import { DBInbound } from '@/models/dbinbound';
import { ThemeProvider } from '@/hooks/useTheme';
import { AntdRootConfig } from '@/i18n/AntdRootConfig';
import { HttpUtil } from '@/utils';
import { Protocols } from '@/schemas/primitives';
import {
renderWithProviders,
fieldLabels,
@@ -40,20 +41,8 @@ function renderModal() {
);
}
// The protocol dropdown is virtualized, so jsdom renders only its first options; step
// the active option by keyboard from the current protocol in the options' own order.
function chooseVirtualizedProtocol(optionText: string) {
const input = document.getElementById('protocol') as HTMLElement;
const select = input.closest('.ant-select') as HTMLElement;
const current = (select.textContent ?? '').trim();
const order = Object.values(Protocols) as string[];
const steps = order.indexOf(optionText) - order.indexOf(current);
if (order.indexOf(optionText) < 0 || order.indexOf(current) < 0 || steps < 0) {
throw new Error(`cannot step from '${current}' to '${optionText}'`);
}
fireEvent.mouseDown(select.querySelector('.ant-select-selector') ?? select);
for (let i = 0; i < steps; i++) fireEvent.keyDown(input, { key: 'ArrowDown', keyCode: 40 });
fireEvent.keyDown(input, { key: 'Enter', keyCode: 13 });
function chooseProtocol(optionText: string) {
chooseSelectOption('protocol', optionText);
}
function primaryButton(): HTMLElement {
@@ -115,12 +104,6 @@ async function renderCloneLikeEdit(dbInbound: DBInbound) {
}
describe('InboundFormModal', () => {
it('renders add mode without crashing', () => {
renderModal();
expect(document.querySelector('.ant-modal')).toBeTruthy();
expect(fieldLabels().length).toBeGreaterThan(0);
});
it('field structure differs per protocol (not a vacuous snapshot loop)', async () => {
renderModal();
const protocols = listSelectOptions('protocol');
@@ -143,9 +126,8 @@ describe('InboundFormModal', () => {
expect(distinctShapes.size).toBeGreaterThan(1);
// Spot-check a protocol-distinguishing field that must appear after the switch.
if (labelsByProto.shadowsocks) {
expect(labelsByProto.shadowsocks).toContain('Encryption method');
}
expect(labelsByProto.shadowsocks).toContain('Encryption method');
expect(labelsByProto.vless).not.toContain('Encryption method');
}, 30000); // iterates every protocol, re-rendering a heavy modal each time — slow on CI runners
it('preserves custom share address strategy when editing a local inbound', async () => {
@@ -241,27 +223,31 @@ describe('InboundFormModal', () => {
await new Promise((r) => setTimeout(r, 0));
});
};
const strategyItem = (title: string) =>
document.querySelector(`.ant-select-content[title="${title}"]`);
const selectedStrategy = () =>
within(screen.getByRole('combobox', { name: 'Share address strategy' })).getByRole('option', {
selected: true,
}).textContent;
const modal = (nodes: never[], fetched: boolean) => (
<ThemeProvider>
<InboundFormModal
open
mode="edit"
dbInbound={buildInbound()}
dbInbounds={[]}
availableNodes={nodes}
availableNodesFetched={fetched}
onClose={() => {}}
onSaved={() => {}}
/>
</ThemeProvider>
<AntdRootConfig>
<ThemeProvider>
<InboundFormModal
open
mode="edit"
dbInbound={buildInbound()}
dbInbounds={[]}
availableNodes={nodes}
availableNodesFetched={fetched}
onClose={() => {}}
onSaved={() => {}}
/>
</ThemeProvider>
</AntdRootConfig>
);
// Baseline: nodes already loaded, so the node option is offered and selected.
render(modal([node], true));
await flush();
expect(strategyItem('Node address')).toBeTruthy();
expect(selectedStrategy()).toBe('Node address');
cleanup();
// Race: the modal mounts before /nodes/list resolves (empty placeholder),
@@ -271,8 +257,7 @@ describe('InboundFormModal', () => {
await flush();
rerender(modal([node], true));
await flush();
expect(strategyItem('Node address')).toBeTruthy();
expect(strategyItem('Inbound listen')).toBeFalsy();
expect(selectedStrategy()).toBe('Node address');
});
it('surfaces a Reality validation error and switches to its tab', async () => {
@@ -344,16 +329,9 @@ describe('InboundFormModal', () => {
await renderCloneLikeEdit(cloneLikeVlessInbound('example.com:443'));
fireEvent.click(screen.getByRole('tab', { name: 'Stream' }));
const transmission = Array.from(document.querySelectorAll('.ant-form-item')).find(
(el) =>
el.querySelector('.ant-form-item-label label')?.textContent?.trim() === 'Transmission',
);
const select = transmission?.querySelector('.ant-select') as HTMLElement;
fireEvent.mouseDown(select.querySelector('.ant-select-selector') ?? select);
const option = Array.from(document.querySelectorAll('.ant-select-item-option')).find(
(o) => (o.getAttribute('title') ?? o.textContent ?? '').trim() === 'XDRIVE',
) as HTMLElement;
fireEvent.click(option);
const transport = screen.getByRole('combobox', { name: 'Transmission' });
const option = within(transport).getByRole('option', { name: 'XDRIVE' }) as HTMLOptionElement;
fireEvent.change(transport, { target: { value: option.value } });
for (const [label, value] of [
['Remote folder', 'xray-tunnel'],
['Client ID', 'id.apps.googleusercontent.com'],
@@ -387,7 +365,7 @@ describe('InboundFormModal', () => {
post.mockClear();
renderModal();
chooseVirtualizedProtocol('masque');
chooseProtocol('masque');
await act(async () => {
await new Promise((r) => setTimeout(r, 0));
});
+99
View File
@@ -0,0 +1,99 @@
import { describe, expect, it, vi } from 'vitest';
import '@testing-library/jest-dom/vitest';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { FormProvider, useForm } from 'react-hook-form';
import { NativeSelect } from '@/components/form/NativeSelect';
import { FormField } from '@/components/form/rhf';
const options = [
{ value: 7, label: 'Number seven' },
{ value: '7', label: 'Text seven' },
{ value: 0, label: 'Zero' },
{ value: '', label: 'None' },
{ value: 9, label: 'Unavailable', disabled: true },
];
function Harness({
onSubmit,
disabled = false,
}: {
onSubmit: (values: { choice: string | number }) => void;
disabled?: boolean;
}) {
const methods = useForm({ defaultValues: { choice: 7 as string | number } });
return (
<FormProvider {...methods}>
<form onSubmit={methods.handleSubmit(onSubmit)}>
<FormField name="choice" label="Choice">
<NativeSelect<string | number> disabled={disabled} options={options} />
</FormField>
<button type="submit">Save</button>
</form>
</FormProvider>
);
}
describe('NativeSelect form values', () => {
it('preserves numeric, string and empty values through RHF instead of coercing to DOM strings', async () => {
const onSubmit = vi.fn();
render(<Harness onSubmit={onSubmit} />);
const user = userEvent.setup();
const select = screen.getByRole('combobox', { name: 'Choice' });
expect(select).toHaveDisplayValue('Number seven');
for (const [label, value] of [
['Text seven', '7'],
['Zero', 0],
['None', ''],
] as const) {
await user.selectOptions(select, screen.getByRole('option', { name: label }));
await user.click(screen.getByRole('button', { name: 'Save' }));
await waitFor(() =>
expect(onSubmit).toHaveBeenLastCalledWith({ choice: value }, expect.anything()),
);
}
await user.selectOptions(select, screen.getByRole('option', { name: 'Unavailable' }));
expect(select).toHaveDisplayValue('None');
});
it('keeps disabled values intact and skips the control during tab navigation', async () => {
const onSubmit = vi.fn();
render(<Harness disabled onSubmit={onSubmit} />);
const user = userEvent.setup();
await user.tab();
expect(screen.getByRole('button', { name: 'Save' })).toHaveFocus();
await user.keyboard('{Enter}');
await waitFor(() => expect(onSubmit).toHaveBeenCalledWith({ choice: 7 }, expect.anything()));
});
it('preserves an unlisted edit value and follows reordered dynamic options without changing it', async () => {
const onChange = vi.fn();
const view = render(
<NativeSelect
aria-label="Choice"
value="saved-custom"
options={[{ value: 'default', label: 'Default' }]}
onChange={onChange}
/>,
);
expect(screen.getByRole('combobox', { name: 'Choice' })).toHaveDisplayValue('saved-custom');
view.rerender(
<NativeSelect
aria-label="Choice"
value="saved-custom"
options={[
{ value: 'new', label: 'New' },
{ value: 'saved-custom', label: 'Saved choice' },
]}
onChange={onChange}
/>,
);
expect(screen.getByRole('combobox', { name: 'Choice' })).toHaveDisplayValue('Saved choice');
expect(onChange).not.toHaveBeenCalled();
await userEvent
.setup()
.selectOptions(screen.getByRole('combobox'), screen.getByRole('option', { name: 'New' }));
expect(onChange).toHaveBeenCalledWith('new');
});
});
@@ -31,6 +31,13 @@ function formItemByLabel(label: string): Element | undefined {
}
function chooseOptionByLabel(label: string, optionText: string) {
const native = formItemByLabel(label)?.querySelector('select');
if (native) {
const option = Array.from(native.options).find((item) => item.text === optionText);
if (!option) throw new Error(`Option '${optionText}' not found for ${label}`);
fireEvent.change(native, { target: { value: option.value } });
return;
}
const select = formItemByLabel(label)?.querySelector('.ant-select');
if (!select) throw new Error(`${label} select not found`);
fireEvent.mouseDown(select.querySelector('.ant-select-selector') ?? select);
@@ -1,4 +1,5 @@
import { describe, expect, it, vi } from 'vitest';
import '@testing-library/jest-dom/vitest';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { FormProvider, useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
@@ -93,6 +94,10 @@ describe('FormField', () => {
fireEvent.click(screen.getByText('Save'));
await screen.findByText('email-required');
expect(screen.getByRole('textbox', { name: 'email' })).toHaveAccessibleDescription(
'email-required',
);
expect(screen.getByRole('textbox', { name: 'email' })).toHaveAttribute('aria-invalid', 'true');
expect(onSubmit).not.toHaveBeenCalled();
});
});
+13
View File
@@ -49,6 +49,12 @@ function openDropdownOptions(): string[] {
}
export function listSelectOptions(fieldId: string): string[] {
const native = document.getElementById(fieldId);
if (native instanceof HTMLSelectElement) {
return Array.from(native.options)
.filter((option) => !option.hidden)
.map((option) => option.text);
}
const select = selectRootForField(fieldId);
openSelect(select);
const opts = openDropdownOptions();
@@ -57,6 +63,13 @@ export function listSelectOptions(fieldId: string): string[] {
}
export function chooseSelectOption(fieldId: string, optionText: string) {
const native = document.getElementById(fieldId);
if (native instanceof HTMLSelectElement) {
const option = Array.from(native.options).find((item) => item.text === optionText);
if (!option) throw new Error(`Option '${optionText}' not found for field '${fieldId}'`);
fireEvent.change(native, { target: { value: option.value } });
return;
}
const select = selectRootForField(fieldId);
openSelect(select);
const option = Array.from(document.querySelectorAll('.ant-select-item-option')).find(