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}