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}