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) => {