Skip to content

Commit 71c6448

Browse files
LukasTyclaude
andauthored
[pickers] Fix spurious onBlur/onFocus firing during field focus transitions (#22098)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent c1fa13d commit 71c6448

2 files changed

Lines changed: 83 additions & 2 deletions

File tree

packages/x-date-pickers/src/DateField/tests/DateField.test.tsx

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { spy } from 'sinon';
12
import InputAdornment, { InputAdornmentProps } from '@mui/material/InputAdornment';
23
import { DateField } from '@mui/x-date-pickers/DateField';
34
import { screen } from '@mui/internal-test-utils';
@@ -40,6 +41,69 @@ describe('<DateField />', () => {
4041
});
4142
});
4243

44+
describe('slotProps.textField focus/blur behavior', () => {
45+
it('should not call `slotProps.textField.onBlur` when focus enters the field via tab', async () => {
46+
const onBlur = spy();
47+
const view = render(<DateField slotProps={{ textField: { onBlur } }} />);
48+
49+
// Tabbing into the field moves focus to the PickersSectionList root (tabIndex=0)
50+
// first, then programmatically to section 0. The transient root blur must not
51+
// dispatch the user's onBlur callback.
52+
await view.user.tab();
53+
54+
expect(onBlur.callCount).to.equal(0);
55+
});
56+
57+
it('should call `slotProps.textField.onFocus` only once when focus enters the field via tab', async () => {
58+
const onFocus = spy();
59+
const view = render(<DateField slotProps={{ textField: { onFocus } }} />);
60+
61+
// Tabbing into the field fires a focus on the root and then on section 0.
62+
// Only the first focus (from outside the field) should reach the user.
63+
await view.user.tab();
64+
65+
expect(onFocus.callCount).to.equal(1);
66+
});
67+
68+
it('should call `slotProps.textField.onFocus` only once when focus is applied programmatically via autoFocus', () => {
69+
const onFocus = spy();
70+
// `autoFocus` triggers a programmatic `.focus()` on section 0 from a mount effect,
71+
// which can produce a focus event with `relatedTarget === null`. The user callback
72+
// must still fire exactly once.
73+
render(<DateField autoFocus slotProps={{ textField: { onFocus } }} />);
74+
75+
expect(onFocus.callCount).to.equal(1);
76+
});
77+
78+
it('should call `slotProps.textField.onBlur` when focus leaves the field via tab', async () => {
79+
const onBlur = spy();
80+
const view = render(<DateField slotProps={{ textField: { onBlur } }} />);
81+
82+
await view.user.tab();
83+
expect(onBlur.callCount).to.equal(0);
84+
85+
// Tab out of the field (to the document body or next focusable).
86+
await view.user.tab();
87+
expect(onBlur.callCount).to.equal(1);
88+
});
89+
90+
it('should not fire `slotProps.textField.onBlur` or `onFocus` when focus moves between sections', async () => {
91+
const onBlur = spy();
92+
const onFocus = spy();
93+
const view = render(<DateField slotProps={{ textField: { onBlur, onFocus } }} />);
94+
95+
await view.user.tab();
96+
const blurCallCountAfterTabIn = onBlur.callCount;
97+
const focusCallCountAfterTabIn = onFocus.callCount;
98+
99+
// Navigate across sections inside the field.
100+
await view.user.keyboard('[ArrowRight][ArrowRight][ArrowLeft]');
101+
102+
expect(onBlur.callCount).to.equal(blurCallCountAfterTabIn);
103+
expect(onFocus.callCount).to.equal(focusCallCountAfterTabIn);
104+
});
105+
});
106+
43107
describe('slotProps.inputAdornment behavior', () => {
44108
function CustomInputAdornment(props: InputAdornmentProps) {
45109
const { children, ...other } = props;

packages/x-date-pickers/src/internals/hooks/useField/useField.ts

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -163,13 +163,30 @@ export const useField = <
163163
});
164164

165165
const handleRootBlur = useEventCallback((event: React.FocusEvent<HTMLDivElement>) => {
166-
onBlur?.(event);
167166
rootProps.onBlur(event);
167+
// Skip the user callback when focus is only moving to another element inside the field
168+
// (e.g. the section that gains focus after the focusable root gives it up).
169+
const next = event.relatedTarget;
170+
if (domGetters.isReady() && next instanceof Node && domGetters.getRoot().contains(next)) {
171+
return;
172+
}
173+
onBlur?.(event);
168174
});
169175

170176
const handleRootFocus = useEventCallback((event: React.FocusEvent<HTMLDivElement>) => {
171-
onFocus?.(event);
172177
rootProps.onFocus(event);
178+
// Skip the user callback when focus is only arriving from another element inside the field
179+
// (e.g. the focusable root receiving it before it is forwarded to a section, and the section
180+
// focus event bubbling back up to the root).
181+
const previous = event.relatedTarget;
182+
if (
183+
domGetters.isReady() &&
184+
previous instanceof Node &&
185+
domGetters.getRoot().contains(previous)
186+
) {
187+
return;
188+
}
189+
onFocus?.(event);
173190
});
174191

175192
const handleRootClick = useEventCallback((event: React.MouseEvent<HTMLDivElement>) => {

0 commit comments

Comments
 (0)