Files
3x-ui/frontend/src/components/form/rhf/FormField.tsx
T
Alireza Ahmadi e2184777a0 fix(frontend): make the panel usable with screen readers (#6907)
* fix(frontend): make the panel usable with screen readers

* fix(frontend): keep mobile tab names and the inbound expiry hint

Review of #6907 found two gaps in the screen-reader pass:

- On phone-width screens catTabLabel renders only the icon and names the
  tab by putting aria-label on it, but callers now pass that icon with
  aria-hidden for the desktop label, so every Settings, Xray and Host
  category tab lost its name under TalkBack. catTabLabel now clears
  aria-hidden on the icon it uses as the name.
- DateTimePicker forwarded id and aria-label but not aria-describedby,
  so the inbound expiry hint ("leave blank to never expire") was never
  attached. It now forwards it on both the Gregorian and Jalali input.
2026-10-23 18:29:47 +02:00

111 lines
3.3 KiB
TypeScript

import { cloneElement, useId } from 'react';
import type { 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 childOwnProps = (children as ReactElement<{ id?: string; 'aria-describedby'?: string }>)
.props;
const fieldId = childOwnProps.id ?? generatedId;
const describedBy = tooltip
? [childOwnProps['aria-describedby'], hintId].filter(Boolean).join(' ')
: undefined;
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 childProps: Record<string, unknown> = {
id: fieldId,
[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,
};
if (describedBy) childProps['aria-describedby'] = describedBy;
return (
<Form.Item
label={label}
htmlFor={label ? fieldId : undefined}
tooltip={tooltip}
extra={extra}
required={required}
validateStatus={fieldState.error ? 'error' : undefined}
help={help}
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;