diff --git a/packages/ra-ui-materialui/src/input/ResettableTextField.tsx b/packages/ra-ui-materialui/src/input/ResettableTextField.tsx
index ab61e04b132..4c67b591b09 100644
--- a/packages/ra-ui-materialui/src/input/ResettableTextField.tsx
+++ b/packages/ra-ui-materialui/src/input/ResettableTextField.tsx
@@ -40,6 +40,8 @@ export const ResettableTextField = forwardRef(
variant,
margin,
className,
+ 'aria-required': ariaRequired,
+ inputProps: htmlInputPropsFromConsumer,
...rest
} = props;
@@ -159,11 +161,36 @@ export const ResettableTextField = forwardRef(
...InputPropsWithoutEndAdornment,
};
+ // Props forwarded to the native element. We translate the
+ // `aria-required` prop here (rather than forwarding the native
+ // `required` attribute) because react-admin relies on JS validation,
+ // not the browser's native validation. This marks inputs with a
+ // `required()` validator as required for assistive technologies
+ // without triggering native browser validation. See issue #9585.
+ const htmlInputProps = {
+ ...htmlInputPropsFromConsumer,
+ ...(ariaRequired ? { 'aria-required': true } : {}),
+ };
+ const hasHtmlInputProps = Object.keys(htmlInputProps).length > 0;
+
+ // @ts-expect-error slotProps do not yet exist in MUI v5
+ const htmlInputSlot = rest.slotProps?.htmlInput;
+ // slotProps.htmlInput may be an object or, in MUI v6+, a callback
+ // receiving the owner state. Preserve the caller's form when merging.
+ const mergedHtmlInputSlot =
+ typeof htmlInputSlot === 'function'
+ ? ownerState => ({
+ ...htmlInputProps,
+ ...htmlInputSlot(ownerState),
+ })
+ : { ...htmlInputProps, ...htmlInputSlot };
+
const mergedSlotProps = {
// @ts-expect-error slotProps do not yet exist in MUI v5
...rest.slotProps,
// @ts-expect-error slotProps do not yet exist in MUI v5
input: { ...inputProps, ...rest.slotProps?.input },
+ ...(hasHtmlInputProps ? { htmlInput: mergedHtmlInputSlot } : {}),
};
return (
@@ -175,7 +202,11 @@ export const ResettableTextField = forwardRef(
margin={margin}
className={className}
{...rest}
- {...(muiMajor >= 6 ? { slotProps: mergedSlotProps } : {})}
+ {...(muiMajor >= 6
+ ? { slotProps: mergedSlotProps }
+ : hasHtmlInputProps
+ ? { inputProps: htmlInputProps }
+ : {})}
inputRef={ref}
/>
);
diff --git a/packages/ra-ui-materialui/src/input/TextInput.spec.tsx b/packages/ra-ui-materialui/src/input/TextInput.spec.tsx
index 4b00302573e..42039c36dc6 100644
--- a/packages/ra-ui-materialui/src/input/TextInput.spec.tsx
+++ b/packages/ra-ui-materialui/src/input/TextInput.spec.tsx
@@ -123,6 +123,67 @@ describe('', () => {
});
});
+ describe('aria-required', () => {
+ it('should mark the input as required with aria-required (not the native required attribute) when a required() validator is set', () => {
+ render(
+
+
+
+
+
+
+
+ );
+ const input = screen.getByLabelText(
+ 'resources.posts.fields.title *'
+ );
+ expect(input).toBeRequired();
+ expect(input).toHaveAttribute('aria-required', 'true');
+ // react-admin uses JS validation, so the native required attribute
+ // must not be set (it would trigger native browser validation).
+ expect(input).not.toHaveAttribute('required');
+ });
+
+ it('should not mark the input as required when no required validator is set', () => {
+ render(
+
+
+
+
+
+
+
+ );
+ const input = screen.getByLabelText('resources.posts.fields.title');
+ expect(input).not.toBeRequired();
+ expect(input).not.toHaveAttribute('aria-required');
+ });
+
+ it('should preserve caller-provided inputProps while adding aria-required', () => {
+ render(
+
+
+
+
+
+
+
+ );
+ const input = screen.getByLabelText(
+ 'resources.posts.fields.title *'
+ );
+ expect(input).toHaveAttribute('aria-required', 'true');
+ expect(input).toHaveAttribute('data-custom', 'kept');
+ });
+ });
+
it('should keep null values', async () => {
const onSuccess = jest.fn();
render();
diff --git a/packages/ra-ui-materialui/src/input/TextInput.tsx b/packages/ra-ui-materialui/src/input/TextInput.tsx
index e3a2b4ce2e5..a8cfa33ed65 100644
--- a/packages/ra-ui-materialui/src/input/TextInput.tsx
+++ b/packages/ra-ui-materialui/src/input/TextInput.tsx
@@ -71,6 +71,7 @@ export const TextInput = (props: TextInputProps) => {