Files
3x-ui/frontend/src/components/form/rhf/FormField.tsx
T
Hamed f4030c889c 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.
2026-11-05 18:08:49 +01:00

114 lines
3.6 KiB
TypeScript

import { cloneElement, useId } 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';
import { useTranslation } from 'react-i18next';
import { normalizeAntdOnChange, type ValueProp } from './normalizeAntdOnChange';
import { toDotted, type FieldName } from './toDotted';
interface FormFieldTransform {
input?: (value: unknown) => unknown;
output?: (eventValue: unknown) => unknown;
}
export interface FormFieldProps<T extends FieldValues = FieldValues> {
name: FieldName;
control?: Control<T>;
label?: ReactNode;
tooltip?: ReactNode;
extra?: ReactNode;
valueProp?: ValueProp;
transform?: FormFieldTransform;
onAfterChange?: (value: unknown) => void;
rules?: ControllerProps<T>['rules'];
required?: boolean;
noStyle?: boolean;
className?: string;
style?: CSSProperties;
children: ReactElement;
}
export function FormField<T extends FieldValues = FieldValues>({
name,
control,
label,
tooltip,
extra,
valueProp = 'value',
transform,
onAfterChange,
rules,
required,
noStyle,
className,
style,
children,
}: FormFieldProps<T>) {
const { t } = useTranslation();
const dottedName = toDotted(name) as Path<T>;
const generatedId = useId();
const hintId = useId();
const errorId = `${generatedId}-error`;
const extraId = `${generatedId}-extra`;
const childOwnProps = (children as ReactElement<{ id?: string } & AriaAttributes>).props;
const fieldId = childOwnProps.id ?? generatedId;
return (
<Controller
control={control}
name={dottedName}
rules={rules}
render={({ field, fieldState }) => {
const displayValue = transform?.input ? transform.input(field.value) : field.value;
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);
const next = transform?.output ? transform.output(raw) : raw;
field.onChange(next);
onAfterChange?.(next);
},
onBlur: field.onBlur,
ref: field.ref,
};
return (
<Form.Item
label={label}
htmlFor={label ? fieldId : undefined}
tooltip={tooltip}
extra={extra ? <span id={extraId}>{extra}</span> : extra}
required={required}
validateStatus={fieldState.error ? 'error' : undefined}
help={help ? <span id={errorId}>{help}</span> : undefined}
noStyle={noStyle}
className={className}
style={style}
>
{cloneElement(children as ReactElement<Record<string, unknown>>, childProps)}
{/* The tooltip only opens on hover, so screen readers get its text as a description. */}
{tooltip && (
<span id={hintId} hidden>
{tooltip}
</span>
)}
</Form.Item>
);
}}
/>
);
}
export default FormField;