Add blur() and focusLast() to fragment instances (#32654)
`focus()` was added in https://github.com/facebook/react/pull/32465. Here we add `focusLast()` and `blur()`. I also extended `focus` to take options. `focus` will focus the first focusable element. `focusLast` will focus the last focusable element. We could consider a `focusFirst` naming or even the `focusWithin` used by test selector APIs as well. `blur` will only have an effect if the current `document.activeElement` is one of the fragment children.
Jack Pope committed
Mar 18, 2025 at 11:58 UTC
c69a5fc53a5135136668ca878f99b634d2374837
6 files changed
+300
-72
fixtures/dom/src/components/fixtures/fragment-refs/FocusCase.js
new
+55
@@ -0,0 +1,55 @@
1
+import TestCase from '../../TestCase';
2
+import Fixture from '../../Fixture';
3
+
4
+const React = window.React;
5
+
6
+const {Fragment, useEffect, useRef, useState} = React;
7
+
8
+export default function FocusCase() {
9
+ const fragmentRef = useRef(null);
10
+
11
+ return (
12
+ <TestCase title="Focus Management">
13
+ <TestCase.Steps>
14
+ <li>Click to focus the first child</li>
15
+ <li>Click to focus the last child</li>
16
+ <li>Click to blur any focus within the fragment</li>
17
+ </TestCase.Steps>
18
+
19
+ <TestCase.ExpectedResult>
20
+ <p>
21
+ The focus method will focus the first focusable child within the
22
+ fragment, skipping any unfocusable children.
23
+ </p>
24
+ <p>
25
+ The focusLast method is the reverse, focusing the last focusable
26
+ child.
27
+ </p>
28
+ <p>
29
+ Blur will call blur on the document, only if one of the children
30
+ within the fragment is the active element.
31
+ </p>
32
+ </TestCase.ExpectedResult>
33
+
34
+ <button onClick={() => fragmentRef.current.focus()}>
35
+ Focus first child
36
+ </button>
37
+ <button onClick={() => fragmentRef.current.focusLast()}>
38
+ Focus last child
39
+ </button>
40
+ <button onClick={() => fragmentRef.current.blur()}>Blur</button>
41
+
42
+ <Fixture>
43
+ <div className="highlight-focused-children" style={{display: 'flex'}}>
44
+ <Fragment ref={fragmentRef}>
45
+ <div style={{outline: '1px solid black'}}>Unfocusable div</div>
46
+ <button>Button 1</button>
47
+ <button>Button 2</button>
48
+ <input type="text" placeholder="Input field" />
49
+ <div style={{outline: '1px solid black'}}>Unfocusable div</div>
50
+ </Fragment>
51
+ </div>
52
+ </Fixture>
53
+ </TestCase>
54
+ );
55
+}
fixtures/dom/src/components/fixtures/fragment-refs/index.js
+2
@@ -2,6 +2,7 @@ import FixtureSet from '../../FixtureSet';
2
import EventListenerCase from './EventListenerCase';
3
import IntersectionObserverCase from './IntersectionObserverCase';
4
import ResizeObserverCase from './ResizeObserverCase';
5
+import FocusCase from './FocusCase';
6
7
const React = window.React;
8
@@ -11,6 +12,7 @@ export default function FragmentRefsPage() {
12
<EventListenerCase />
13
<IntersectionObserverCase />
14
<ResizeObserverCase />
15
+ <FocusCase />
16
</FixtureSet>
17
);
18
}
fixtures/dom/src/style.css
+4
@@ -358,3 +358,7 @@ tbody tr:nth-child(even) {
358
.onscreen {
359
background-color: green;
360
}
361
+
362
+.highlight-focused-children *:focus {
363
+ outline: 2px solid green;
364
+}
packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js
+62
-5
@@ -2205,6 +2205,11 @@ type StoredEventListener = {
2205
optionsOrUseCapture: void | EventListenerOptionsOrUseCapture,
2206
};
2207
2208
+type FocusOptions = {
2209
+ preventScroll?: boolean,
2210
+ focusVisible?: boolean,
2211
+};
2212
+
2213
export type FragmentInstanceType = {
2214
_fragmentFiber: Fiber,
2215
_eventListeners: null | Array<StoredEventListener>,
@@ -2219,7 +2224,9 @@ export type FragmentInstanceType = {
2224
listener: EventListener,
2225
optionsOrUseCapture?: EventListenerOptionsOrUseCapture,
2226
): void,
2222
- focus(): void,
2227
+ focus(focusOptions?: FocusOptions): void,
2228
+ focusLast(focusOptions?: FocusOptions): void,
2229
+ blur(): void,
2230
observeUsing(observer: IntersectionObserver | ResizeObserver): void,
2231
unobserveUsing(observer: IntersectionObserver | ResizeObserver): void,
2232
};
@@ -2307,10 +2314,57 @@ function removeEventListenerFromChild(
2314
return false;
2315
}
2316
// $FlowFixMe[prop-missing]
2310
-FragmentInstance.prototype.focus = function (this: FragmentInstanceType) {
2311
- traverseFragmentInstance(this._fragmentFiber, setFocusIfFocusable);
2317
+FragmentInstance.prototype.focus = function (
2318
+ this: FragmentInstanceType,
2319
+ focusOptions?: FocusOptions,
2320
+): void {
2321
+ traverseFragmentInstance(
2322
+ this._fragmentFiber,
2323
+ setFocusIfFocusable,
2324
+ focusOptions,
2325
+ );
2326
};
2327
// $FlowFixMe[prop-missing]
2328
+FragmentInstance.prototype.focusLast = function (
2329
+ this: FragmentInstanceType,
2330
+ focusOptions?: FocusOptions,
2331
+) {
2332
+ const children: Array<Instance> = [];
2333
+ traverseFragmentInstance(this._fragmentFiber, collectChildren, children);
2334
+ for (let i = children.length - 1; i >= 0; i--) {
2335
+ const child = children[i];
2336
+ if (setFocusIfFocusable(child, focusOptions)) {
2337
+ break;
2338
+ }
2339
+ }
2340
+};
2341
+function collectChildren(
2342
+ child: Instance,
2343
+ collection: Array<Instance>,
2344
+): boolean {
2345
+ collection.push(child);
2346
+ return false;
2347
+}
2348
+// $FlowFixMe[prop-missing]
2349
+FragmentInstance.prototype.blur = function (this: FragmentInstanceType): void {
2350
+ // TODO: When we have a parent element reference, we can skip traversal if the fragment's parent
2351
+ // does not contain document.activeElement
2352
+ traverseFragmentInstance(
2353
+ this._fragmentFiber,
2354
+ blurActiveElementWithinFragment,
2355
+ );
2356
+};
2357
+function blurActiveElementWithinFragment(child: Instance): boolean {
2358
+ // TODO: We can get the activeElement from the parent outside of the loop when we have a reference.
2359
+ const ownerDocument = child.ownerDocument;
2360
+ if (child === ownerDocument.activeElement) {
2361
+ // $FlowFixMe[prop-missing]
2362
+ child.blur();
2363
+ return true;
2364
+ }
2365
+ return false;
2366
+}
2367
+// $FlowFixMe[prop-missing]
2368
FragmentInstance.prototype.observeUsing = function (
2369
this: FragmentInstanceType,
2370
observer: IntersectionObserver | ResizeObserver,
@@ -3190,7 +3244,10 @@ export function isHiddenSubtree(fiber: Fiber): boolean {
3244
return fiber.tag === HostComponent && fiber.memoizedProps.hidden === true;
3245
}
3246
3193
-export function setFocusIfFocusable(node: Instance): boolean {
3247
+export function setFocusIfFocusable(
3248
+ node: Instance,
3249
+ focusOptions?: FocusOptions,
3250
+): boolean {
3251
// The logic for determining if an element is focusable is kind of complex,
3252
// and since we want to actually change focus anyway- we can just skip it.
3253
// Instead we'll just listen for a "focus" event to verify that focus was set.
@@ -3206,7 +3263,7 @@ export function setFocusIfFocusable(node: Instance): boolean {
3263
try {
3264
element.addEventListener('focus', handleFocus);
3265
// $FlowFixMe[method-unbinding]
3209
- (element.focus || HTMLElement.prototype.focus).call(element);
3266
+ (element.focus || HTMLElement.prototype.focus).call(element, focusOptions);
3267
} finally {
3268
element.removeEventListener('focus', handleFocus);
3269
}
packages/react-dom/src/__tests__/ReactDOMFragmentRefs-test.js
+176
-66
@@ -19,6 +19,10 @@ let mockIntersectionObserver;
19
let simulateIntersection;
20
let assertConsoleErrorDev;
21
22
+function Wrapper({children}) {
23
+ return children;
24
+}
25
+
26
describe('FragmentRefs', () => {
27
beforeEach(() => {
28
jest.resetModules();
@@ -99,82 +103,192 @@ describe('FragmentRefs', () => {
103
await act(() => root.render(<Test />));
104
});
105
102
- describe('focus()', () => {
103
- // @gate enableFragmentRefs
104
- it('focuses the first focusable child', async () => {
105
- const fragmentRef = React.createRef();
106
- const root = ReactDOMClient.createRoot(container);
106
+ describe('focus methods', () => {
107
+ describe('focus()', () => {
108
+ // @gate enableFragmentRefs
109
+ it('focuses the first focusable child', async () => {
110
+ const fragmentRef = React.createRef();
111
+ const root = ReactDOMClient.createRoot(container);
112
108
- function Test() {
109
- return (
110
- <div>
113
+ function Test() {
114
+ return (
115
+ <div>
116
+ <Fragment ref={fragmentRef}>
117
+ <div id="child-a" />
118
+ <style>{`#child-c {}`}</style>
119
+ <a id="child-b" href="/">
120
+ B
121
+ </a>
122
+ <a id="child-c" href="/">
123
+ C
124
+ </a>
125
+ </Fragment>
126
+ </div>
127
+ );
128
+ }
129
+
130
+ await act(() => {
131
+ root.render(<Test />);
132
+ });
133
+
134
+ await act(() => {
135
+ fragmentRef.current.focus();
136
+ });
137
+ expect(document.activeElement.id).toEqual('child-b');
138
+ document.activeElement.blur();
139
+ });
140
+
141
+ // @gate enableFragmentRefs
142
+ it('preserves document order when adding and removing children', async () => {
143
+ const fragmentRef = React.createRef();
144
+ const root = ReactDOMClient.createRoot(container);
145
+
146
+ function Test({showA, showB}) {
147
+ return (
148
<Fragment ref={fragmentRef}>
112
- <div id="child-a" />
113
- <style>{`#child-c {}`}</style>
114
- <a id="child-b" href="/">
115
- B
116
- </a>
117
- <a id="child-c" href="/">
118
- C
119
- </a>
149
+ {showA && <a href="/" id="child-a" />}
150
+ {showB && <a href="/" id="child-b" />}
151
</Fragment>
121
- </div>
122
- );
123
- }
152
+ );
153
+ }
154
125
- await act(() => {
126
- root.render(<Test />);
127
- });
155
+ // Render with A as the first focusable child
156
+ await act(() => {
157
+ root.render(<Test showA={true} showB={false} />);
158
+ });
159
+ await act(() => {
160
+ fragmentRef.current.focus();
161
+ });
162
+ expect(document.activeElement.id).toEqual('child-a');
163
+ document.activeElement.blur();
164
+ // A is still the first focusable child, but B is also tracked
165
+ await act(() => {
166
+ root.render(<Test showA={true} showB={true} />);
167
+ });
168
+ await act(() => {
169
+ fragmentRef.current.focus();
170
+ });
171
+ expect(document.activeElement.id).toEqual('child-a');
172
+ document.activeElement.blur();
173
129
- await act(() => {
130
- fragmentRef.current.focus();
174
+ // B is now the first focusable child
175
+ await act(() => {
176
+ root.render(<Test showA={false} showB={true} />);
177
+ });
178
+ await act(() => {
179
+ fragmentRef.current.focus();
180
+ });
181
+ expect(document.activeElement.id).toEqual('child-b');
182
+ document.activeElement.blur();
183
});
132
- expect(document.activeElement.id).toEqual('child-b');
133
- document.activeElement.blur();
184
});
185
136
- // @gate enableFragmentRefs
137
- it('preserves document order when adding and removing children', async () => {
138
- const fragmentRef = React.createRef();
139
- const root = ReactDOMClient.createRoot(container);
186
+ describe('focusLast()', () => {
187
+ // @gate enableFragmentRefs
188
+ it('focuses the last focusable child', async () => {
189
+ const fragmentRef = React.createRef();
190
+ const root = ReactDOMClient.createRoot(container);
191
141
- function Test({showA, showB}) {
142
- return (
143
- <Fragment ref={fragmentRef}>
144
- {showA && <a href="/" id="child-a" />}
145
- {showB && <a href="/" id="child-b" />}
146
- </Fragment>
147
- );
148
- }
192
+ function Test() {
193
+ return (
194
+ <div>
195
+ <Fragment ref={fragmentRef}>
196
+ <a id="child-a" href="/">
197
+ A
198
+ </a>
199
+ <a id="child-b" href="/">
200
+ B
201
+ </a>
202
+ <Wrapper>
203
+ <a id="child-c" href="/">
204
+ C
205
+ </a>
206
+ </Wrapper>
207
+ <div id="child-d" />
208
+ <style id="child-e">{`#child-d {}`}</style>
209
+ </Fragment>
210
+ </div>
211
+ );
212
+ }
213
150
- // Render with A as the first focusable child
151
- await act(() => {
152
- root.render(<Test showA={true} showB={false} />);
153
- });
154
- await act(() => {
155
- fragmentRef.current.focus();
156
- });
157
- expect(document.activeElement.id).toEqual('child-a');
158
- document.activeElement.blur();
159
- // A is still the first focusable child, but B is also tracked
160
- await act(() => {
161
- root.render(<Test showA={true} showB={true} />);
162
- });
163
- await act(() => {
164
- fragmentRef.current.focus();
214
+ await act(() => {
215
+ root.render(<Test />);
216
+ });
217
+
218
+ await act(() => {
219
+ fragmentRef.current.focusLast();
220
+ });
221
+ expect(document.activeElement.id).toEqual('child-c');
222
+ document.activeElement.blur();
223
});
166
- expect(document.activeElement.id).toEqual('child-a');
167
- document.activeElement.blur();
224
+ });
225
169
- // B is now the first focusable child
170
- await act(() => {
171
- root.render(<Test showA={false} showB={true} />);
226
+ describe('blur()', () => {
227
+ // @gate enableFragmentRefs
228
+ it('removes focus from an element inside of the Fragment', async () => {
229
+ const fragmentRef = React.createRef();
230
+ const root = ReactDOMClient.createRoot(container);
231
+
232
+ function Test() {
233
+ return (
234
+ <Fragment ref={fragmentRef}>
235
+ <a id="child-a" href="/">
236
+ A
237
+ </a>
238
+ </Fragment>
239
+ );
240
+ }
241
+
242
+ await act(() => {
243
+ root.render(<Test />);
244
+ });
245
+
246
+ await act(() => {
247
+ fragmentRef.current.focus();
248
+ });
249
+ expect(document.activeElement.id).toEqual('child-a');
250
+
251
+ await act(() => {
252
+ fragmentRef.current.blur();
253
+ });
254
+ expect(document.activeElement).toEqual(document.body);
255
});
173
- await act(() => {
174
- fragmentRef.current.focus();
256
+
257
+ // @gate enableFragmentRefs
258
+ it('does not remove focus from elements outside of the Fragment', async () => {
259
+ const fragmentRefA = React.createRef();
260
+ const fragmentRefB = React.createRef();
261
+ const root = ReactDOMClient.createRoot(container);
262
+
263
+ function Test() {
264
+ return (
265
+ <Fragment ref={fragmentRefA}>
266
+ <a id="child-a" href="/">
267
+ A
268
+ </a>
269
+ <Fragment ref={fragmentRefB}>
270
+ <a id="child-b" href="/">
271
+ B
272
+ </a>
273
+ </Fragment>
274
+ </Fragment>
275
+ );
276
+ }
277
+
278
+ await act(() => {
279
+ root.render(<Test />);
280
+ });
281
+
282
+ await act(() => {
283
+ fragmentRefA.current.focus();
284
+ });
285
+ expect(document.activeElement.id).toEqual('child-a');
286
+
287
+ await act(() => {
288
+ fragmentRefB.current.blur();
289
+ });
290
+ expect(document.activeElement.id).toEqual('child-a');
291
});
176
- expect(document.activeElement.id).toEqual('child-b');
177
- document.activeElement.blur();
292
});
293
});
294
@@ -389,10 +503,6 @@ describe('FragmentRefs', () => {
503
const nestedChildRef = React.createRef();
504
const root = ReactDOMClient.createRoot(container);
505
392
- function Wrapper({children}) {
393
- return children;
394
- }
395
-
506
await act(() => {
507
root.render(
508
<div>
packages/react-reconciler/src/ReactFiberTreeReflection.js
+1
-1
@@ -326,7 +326,7 @@ export function traverseFragmentInstance<A, B, C>(
326
b: B,
327
c: C,
328
): void {
329
- return traverseFragmentInstanceChildren(fragmentFiber.child, fn, a, b, c);
329
+ traverseFragmentInstanceChildren(fragmentFiber.child, fn, a, b, c);
330
}
331
332
function traverseFragmentInstanceChildren<A, B, C>(