@samitouri / QOS-React / commits / c69a5fc53a

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>(