diff --git a/packages/ra-ui-materialui/src/input/InPlaceEditor/InPlaceEditor.spec.tsx b/packages/ra-ui-materialui/src/input/InPlaceEditor/InPlaceEditor.spec.tsx
index b9bc471acdc..0fdba29ec7f 100644
--- a/packages/ra-ui-materialui/src/input/InPlaceEditor/InPlaceEditor.spec.tsx
+++ b/packages/ra-ui-materialui/src/input/InPlaceEditor/InPlaceEditor.spec.tsx
@@ -1,7 +1,17 @@
import * as React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
-import { Basic } from './InPlaceEditor.stories';
+import { Basic, CancelOnBlur, Editor } from './InPlaceEditor.stories';
+
+const DismissibleBasic = () => {
+ const [isVisible, setIsVisible] = React.useState(true);
+ return (
+ <>
+ {isVisible ? : null}
+
+ >
+ );
+};
describe('InPlaceEditor', () => {
it('should render the field value on mount', async () => {
@@ -23,6 +33,41 @@ describe('InPlaceEditor', () => {
fireEvent.blur(input);
await screen.findByText('Jane Doe');
});
+ it('should save when focus moves outside the editor', async () => {
+ render(
+ <>
+
+
+ >
+ );
+ const value = await screen.findByText('John Doe');
+ value.click();
+ const input = await screen.findByDisplayValue('John Doe');
+ input.focus();
+ fireEvent.change(input, { target: { value: 'Jane Doe' } });
+ const nextButton = screen.getByRole('button', { name: 'Next' });
+ nextButton.focus();
+ await screen.findByText('Jane Doe');
+ });
+ it('should save before an outside action unmounts the editor', async () => {
+ render();
+ const value = await screen.findByText('John Doe');
+ value.click();
+ const input = await screen.findByDisplayValue('John Doe');
+ input.focus();
+ fireEvent.change(input, { target: { value: 'Jane Doe' } });
+ const form = input.closest('form');
+ if (!form) {
+ throw new Error('Could not find the InPlaceEditor form');
+ }
+ const handleSubmit = jest.fn();
+ form.addEventListener('submit', handleSubmit);
+ const nextButton = screen.getByRole('button', { name: 'Next' });
+ nextButton.focus();
+ fireEvent.click(nextButton);
+ expect(screen.queryByDisplayValue('Jane Doe')).toBeNull();
+ expect(handleSubmit).toHaveBeenCalledTimes(1);
+ });
it('should revert to the previous version on error', async () => {
jest.spyOn(console, 'error').mockImplementation(() => {});
render();
@@ -34,6 +79,41 @@ describe('InPlaceEditor', () => {
await screen.findByText('Jane Doe');
await screen.findByText('John Doe');
});
+ describe('cancelOnBlur', () => {
+ it('should cancel when focus moves outside the editor', async () => {
+ render(
+ <>
+
+
+ >
+ );
+ const value = await screen.findByText('John Doe');
+ value.click();
+ const input = await screen.findByDisplayValue('John Doe');
+ input.focus();
+ fireEvent.change(input, { target: { value: 'Jane Doe' } });
+ const nextButton = screen.getByRole('button', { name: 'Next' });
+ nextButton.focus();
+ await screen.findByText('John Doe');
+ });
+ });
+ describe('editor', () => {
+ it('should keep editing when focus moves to a portaled control', async () => {
+ render();
+ const value = await screen.findByText('Customer');
+ value.click();
+ await screen.findByRole('listbox', undefined, {
+ timeout: 1000,
+ });
+ const selectedOption = await screen.findByRole(
+ 'option',
+ { name: 'Customer' },
+ { timeout: 1000 }
+ );
+ expect(document.activeElement).toBe(selectedOption);
+ await screen.findByRole('combobox', { hidden: true });
+ });
+ });
describe('notifyOnSuccess', () => {
it('should show a notification on success', async () => {
render();
@@ -53,5 +133,16 @@ describe('InPlaceEditor', () => {
await screen.findByLabelText('Save');
await screen.findByLabelText('Cancel');
});
+ it('should keep editing when focus moves to an action button', async () => {
+ render();
+ const value = await screen.findByText('John Doe');
+ value.click();
+ const input = await screen.findByDisplayValue('John Doe');
+ input.focus();
+ fireEvent.change(input, { target: { value: 'Jane Doe' } });
+ const saveButton = await screen.findByLabelText('Save');
+ saveButton.focus();
+ await screen.findByDisplayValue('Jane Doe');
+ });
});
});
diff --git a/packages/ra-ui-materialui/src/input/InPlaceEditor/InPlaceEditor.tsx b/packages/ra-ui-materialui/src/input/InPlaceEditor/InPlaceEditor.tsx
index d8756b61b12..b7581d0f8ae 100644
--- a/packages/ra-ui-materialui/src/input/InPlaceEditor/InPlaceEditor.tsx
+++ b/packages/ra-ui-materialui/src/input/InPlaceEditor/InPlaceEditor.tsx
@@ -6,6 +6,7 @@ import {
useResourceContext,
useTranslate,
useUpdate,
+ useEvent,
Form,
RecordContextProvider,
type UseUpdateOptions,
@@ -101,6 +102,8 @@ export const InPlaceEditor = <
}
const submitButtonRef = useRef(null);
+ const pendingBlurRef = useRef(false);
+ const focusDocumentRef = useRef(null);
const [state, dispatch] = useReducer<
(
@@ -195,10 +198,7 @@ export const InPlaceEditor = <
}
};
- const handleBlur = (event: React.FocusEvent) => {
- if (event.relatedTarget) {
- return;
- }
+ const handleBlurAway = useEvent(() => {
if (cancelOnBlur) {
dispatch({ type: 'cancel' });
return;
@@ -206,8 +206,47 @@ export const InPlaceEditor = <
if (state.state === 'editing') {
// trigger the parent form submit
// to save the changes
- (submitButtonRef.current as HTMLButtonElement).click();
+ submitButtonRef.current?.click();
+ }
+ });
+ const handleDocumentFocusIn = useEvent(() => {
+ focusDocumentRef.current = null;
+ if (!pendingBlurRef.current) {
+ return;
+ }
+ pendingBlurRef.current = false;
+ handleBlurAway();
+ });
+ React.useEffect(
+ () => () => {
+ focusDocumentRef.current?.removeEventListener(
+ 'focusin',
+ handleDocumentFocusIn
+ );
+ },
+ [handleDocumentFocusIn]
+ );
+ const handleBlur = (event: React.FocusEvent) => {
+ if (!event.relatedTarget) {
+ handleBlurAway();
+ return;
}
+ pendingBlurRef.current = true;
+ // React focus events from portals bubble through their component tree
+ // before reaching document, so handleFocus can cancel internal moves.
+ // External moves are handled here before the destination's click event.
+ const ownerDocument = event.currentTarget.ownerDocument;
+ focusDocumentRef.current?.removeEventListener(
+ 'focusin',
+ handleDocumentFocusIn
+ );
+ focusDocumentRef.current = ownerDocument;
+ ownerDocument.addEventListener('focusin', handleDocumentFocusIn, {
+ once: true,
+ });
+ };
+ const handleFocus = () => {
+ pendingBlurRef.current = false;
};
const renderContent = () => {
@@ -227,6 +266,7 @@ export const InPlaceEditor = <
{editor}