diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8d82bb8cb..e17f1eb25 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -365,6 +365,15 @@ jobs: - name: Build Storybook run: npm run build-storybook working-directory: frontend + - name: Inbound keyboard and accessibility tree + working-directory: frontend + run: | + set -euo pipefail + ./node_modules/.bin/vite preview --outDir storybook-static --host 127.0.0.1 --port 6006 > /tmp/inbound-preview.log 2>&1 & + preview_pid=$! + trap 'kill "$preview_pid" 2>/dev/null || true' EXIT + curl --retry 30 --retry-connrefused --retry-delay 1 --retry-max-time 45 -fsS http://127.0.0.1:6006/iframe.html > /dev/null + node tools/check-inbound-keyboard.mjs http://127.0.0.1:6006 - name: Audit run: npm run audit working-directory: frontend diff --git a/frontend/README.md b/frontend/README.md index 77e32aee6..5ed72ea67 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -47,6 +47,33 @@ production-style links work without round-tripping through Go. CI runs `typecheck`, `lint`, `format:check`, `test`, `build`, and `build-storybook` on every PR (see `../.github/workflows/ci.yml`). +### Inbound accessibility verification + +`npm test` includes the real inbound form and native-select regression fixtures. +After `npm run build-storybook`, serve the built stories with +`npx vite preview --outDir storybook-static --host 127.0.0.1 --port 6006` and, in +another terminal, run `node tools/check-inbound-keyboard.mjs`. CI runs this check +too. It uses real Chromium Tab/Arrow/Enter input, checks native selected values +in the accessibility tree, captures create/edit requests, and verifies focus +containment and return. Its HTTP boundary is stubbed; backend configuration +generation and server validation are unchanged. + +Mobile viewport emulation and accessibility-tree checks do not prove TalkBack +or NVDA compatibility. On actual Android TalkBack/Chrome and Windows NVDA: + +1. Open Add Inbound, explore Protocol, open the native picker, choose Shadowsocks + and confirm the control announces its selected value. Cancel another picker + and confirm the value and focus stay unchanged. +2. Visit Protocol, select an encryption method, then Stream; choose RAW, + WebSocket, gRPC and XHTTP and verify the dependent fields and their labels. +3. Check Security's None/TLS/REALITY radios; try certificate/uTLS selections and + the ALPN multi-select, searchable node/fallback fields, clearing and custom tags. +4. Trigger a custom-share-address validation error from another tab. Confirm + the form reveals/focuses the field and reads its hint/error. Correct it and save. +5. Edit the inbound, confirm Protocol remains disabled, save a transport change, + and close/reopen with keyboard and touch exploration. Focus must return to + the opener and Tab/Shift+Tab must stay inside the open dialog. + ### One-off: scan for deprecated APIs Run this command to sweep the codebase for usages of APIs marked diff --git a/frontend/src/components/form/NativeSelect.css b/frontend/src/components/form/NativeSelect.css new file mode 100644 index 000000000..7d1d00b48 --- /dev/null +++ b/frontend/src/components/form/NativeSelect.css @@ -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; +} diff --git a/frontend/src/components/form/NativeSelect.stories.tsx b/frontend/src/components/form/NativeSelect.stories.tsx new file mode 100644 index 000000000..cfe0eca95 --- /dev/null +++ b/frontend/src/components/form/NativeSelect.stories.tsx @@ -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; +export default meta; +type Story = StoryObj; + +export const SingleSelection: Story = { + render: function Render(args) { + const [value, setValue] = useState('vless'); + return ; + }, + 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, + ); + }, +}; diff --git a/frontend/src/components/form/NativeSelect.tsx b/frontend/src/components/form/NativeSelect.tsx new file mode 100644 index 000000000..52307a299 --- /dev/null +++ b/frontend/src/components/form/NativeSelect.tsx @@ -0,0 +1,70 @@ +import type { ComponentPropsWithRef } from 'react'; +import { theme } from 'antd'; + +import './NativeSelect.css'; + +export interface NativeSelectProps 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, + options, + onChange, + placeholder, + disabled, + className, + style, + size, + ...props +}: NativeSelectProps) { + const { token } = theme.useToken(); + const selected = options.findIndex((option) => Object.is(option.value, value)); + const hasUnlistedValue = selected < 0 && value != null; + return ( + + ); +} diff --git a/frontend/src/components/form/rhf/FormField.stories.tsx b/frontend/src/components/form/rhf/FormField.stories.tsx index 37541574b..5bf09ca2c 100644 --- a/frontend/src/components/form/rhf/FormField.stories.tsx +++ b/frontend/src/components/form/rhf/FormField.stories.tsx @@ -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' })); diff --git a/frontend/src/components/form/rhf/FormField.tsx b/frontend/src/components/form/rhf/FormField.tsx index aeca5064c..db04469a9 100644 --- a/frontend/src/components/form/rhf/FormField.tsx +++ b/frontend/src/components/form/rhf/FormField.tsx @@ -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({ const dottedName = toDotted(name) as Path; 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 ( ({ 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 = { 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({ onBlur: field.onBlur, ref: field.ref, }; - if (describedBy) childProps['aria-describedby'] = describedBy; return ( {extra} : extra} required={required} validateStatus={fieldState.error ? 'error' : undefined} - help={help} + help={help ? {help} : undefined} noStyle={noStyle} className={className} style={style} diff --git a/frontend/src/lib/xray/forms/transport/CustomSockoptList.tsx b/frontend/src/lib/xray/forms/transport/CustomSockoptList.tsx index baa8af201..76d3e6cbe 100644 --- a/frontend/src/lib/xray/forms/transport/CustomSockoptList.tsx +++ b/frontend/src/lib/xray/forms/transport/CustomSockoptList.tsx @@ -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({ - diff --git a/frontend/src/lib/xray/forms/transport/FinalMaskForm.tsx b/frontend/src/lib/xray/forms/transport/FinalMaskForm.tsx index 269fd604d..ca1d7e75d 100644 --- a/frontend/src/lib/xray/forms/transport/FinalMaskForm.tsx +++ b/frontend/src/lib/xray/forms/transport/FinalMaskForm.tsx @@ -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 && ( - form.setFieldValue([...absolutePath, 'settings'], defaultTcpMaskSettings(v)) } @@ -1123,7 +1124,7 @@ function UdpMaskItem({ - - - - + - ({ value: r, label: t(`pages.inbounds.periodicTrafficReset.${r}`), @@ -958,7 +994,7 @@ export default function InboundFormModal({ <> {hasSelectableTransport && ( - - ({ value: m, label: m }))} /> + ({ value: m, label: m }))} + /> {isSSWith2022 && ( @@ -48,7 +51,7 @@ export default function ShadowsocksFields({ isSSWith2022 }: ShadowsocksFieldsPro )} - - - setAuthKind(v)} diff --git a/frontend/src/pages/inbounds/form/security/reality.tsx b/frontend/src/pages/inbounds/form/security/reality.tsx index 0b5649138..2bd4848ac 100644 --- a/frontend/src/pages/inbounds/form/security/reality.tsx +++ b/frontend/src/pages/inbounds/form/security/reality.tsx @@ -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" > - ({ value: u, label: u }))} /> @@ -212,7 +213,7 @@ function EchSockoptSection() { name={['streamSettings', 'tlsSettings', 'echSockopt', 'domainStrategy']} label={t('pages.xray.wireguard.domainStrategy')} > - ({ value: v, label: v }))} /> - ({ value: fp, label: fp })), diff --git a/frontend/src/pages/inbounds/form/transport/sockopt.tsx b/frontend/src/pages/inbounds/form/transport/sockopt.tsx index b9f2a84dd..8863d3f84 100644 --- a/frontend/src/pages/inbounds/form/transport/sockopt.tsx +++ b/frontend/src/pages/inbounds/form/transport/sockopt.tsx @@ -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')} > - - - - -