From 6cca925987b273b3558842a5b77c64953dfc9fab Mon Sep 17 00:00:00 2001 From: olitreadwell Date: Wed, 5 Aug 2026 10:26:15 +1200 Subject: [PATCH] Fix InPlaceEditor not saving when focus moves outside the editor The blur handler returned for any non-null relatedTarget. Browsers set relatedTarget to the element receiving focus, so tabbing or clicking to a neighboring focusable control (button, link, input) skipped the save/cancel logic and left the editor open. Only skip save/cancel when focus stays inside the editor (for example, on the Save and Cancel buttons). When focus leaves the editor, save the value, or cancel it when cancelOnBlur is set. Closes #11303 --- .../InPlaceEditor/InPlaceEditor.spec.tsx | 35 ++++++++++++++++++- .../InPlaceEditor/InPlaceEditor.stories.tsx | 15 ++++++++ .../src/input/InPlaceEditor/InPlaceEditor.tsx | 8 ++++- 3 files changed, 56 insertions(+), 2 deletions(-) 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..0a27fb6c480 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,7 @@ import * as React from 'react'; import { render, screen, fireEvent } from '@testing-library/react'; -import { Basic } from './InPlaceEditor.stories'; +import { Basic, WithSiblingControl } from './InPlaceEditor.stories'; describe('InPlaceEditor', () => { it('should render the field value on mount', async () => { @@ -54,4 +54,37 @@ describe('InPlaceEditor', () => { await screen.findByLabelText('Cancel'); }); }); + describe('blur', () => { + it('should save when focus moves to a control outside the editor', async () => { + render(); + const value = await screen.findByText('John Doe'); + value.click(); + const input = await screen.findByDisplayValue('John Doe'); + fireEvent.change(input, { target: { value: 'Jane Doe' } }); + const nextButton = screen.getByRole('button', { name: 'Next' }); + fireEvent.blur(input, { relatedTarget: nextButton }); + await screen.findByText('Jane Doe'); + }); + it('should cancel when focus moves to a control outside the editor and cancelOnBlur is set', async () => { + render(); + const value = await screen.findByText('John Doe'); + value.click(); + const input = await screen.findByDisplayValue('John Doe'); + fireEvent.change(input, { target: { value: 'Jane Doe' } }); + const nextButton = screen.getByRole('button', { name: 'Next' }); + fireEvent.blur(input, { relatedTarget: nextButton }); + await screen.findByText('John Doe'); + expect(screen.queryByDisplayValue('Jane Doe')).toBeNull(); + }); + it('should keep editing when focus moves to a button inside the editor', async () => { + render(); + const value = await screen.findByText('John Doe'); + value.click(); + const input = await screen.findByDisplayValue('John Doe'); + fireEvent.change(input, { target: { value: 'Jane Doe' } }); + const saveButton = screen.getByLabelText('Save'); + fireEvent.blur(input, { relatedTarget: saveButton }); + expect(screen.getByDisplayValue('Jane Doe')).not.toBeNull(); + }); + }); }); diff --git a/packages/ra-ui-materialui/src/input/InPlaceEditor/InPlaceEditor.stories.tsx b/packages/ra-ui-materialui/src/input/InPlaceEditor/InPlaceEditor.stories.tsx index 19ad352c879..ff2b420fb28 100644 --- a/packages/ra-ui-materialui/src/input/InPlaceEditor/InPlaceEditor.stories.tsx +++ b/packages/ra-ui-materialui/src/input/InPlaceEditor/InPlaceEditor.stories.tsx @@ -175,6 +175,21 @@ export const CancelOnBlur = () => ( ); +export const WithSiblingControl = ({ + cancelOnBlur, +}: { + cancelOnBlur?: boolean; +}) => ( + + + + +); + export const MutationMode = () => ( diff --git a/packages/ra-ui-materialui/src/input/InPlaceEditor/InPlaceEditor.tsx b/packages/ra-ui-materialui/src/input/InPlaceEditor/InPlaceEditor.tsx index d8756b61b12..4138e15c2a4 100644 --- a/packages/ra-ui-materialui/src/input/InPlaceEditor/InPlaceEditor.tsx +++ b/packages/ra-ui-materialui/src/input/InPlaceEditor/InPlaceEditor.tsx @@ -196,7 +196,13 @@ export const InPlaceEditor = < }; const handleBlur = (event: React.FocusEvent) => { - if (event.relatedTarget) { + // Keep editing when focus moves to another element inside the editor + // (e.g. the Save and Cancel buttons). Only save or cancel when focus + // leaves the editor entirely. + if ( + event.relatedTarget && + event.currentTarget.contains(event.relatedTarget as Node) + ) { return; } if (cancelOnBlur) {