Skip to content

Commit 4206fe4

Browse files
authored
Allow fragment refs to attempt focus/focusLast on nested host children (facebook#33058)
This enables `focus` and `focusLast` methods on FragmentInstances to search nested host components, depth first. Attempts focus on each child and bails if one is successful. Previously, only the first level of host children would attempt focus. Now if we have an example like ``` component MenuItem() { return (<div><a>{...}</a></div>) } component Menu() { return <Fragment>{items.map(i => <MenuItem i={i} />)}</Fragment> } ``` We can target focus on the first or last a tag, rather than checking each wrapping div and then noop.
1 parent 4a70286 commit 4206fe4

File tree

5 files changed

+88
-23
lines changed

5 files changed

+88
-23
lines changed

fixtures/dom/src/components/fixtures/fragment-refs/FocusCase.js

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -43,11 +43,18 @@ export default function FocusCase() {
4343
</Fixture.Controls>
4444
<div className="highlight-focused-children" style={{display: 'flex'}}>
4545
<Fragment ref={fragmentRef}>
46-
<div style={{outline: '1px solid black'}}>Unfocusable div</div>
47-
<button>Button 1</button>
46+
<div style={{outline: '1px solid black'}}>
47+
<p>Unfocusable div</p>
48+
</div>
49+
<div style={{outline: '1px solid black'}}>
50+
<p>Unfocusable div with nested focusable button</p>
51+
<button>Button 1</button>
52+
</div>
4853
<button>Button 2</button>
4954
<input type="text" placeholder="Input field" />
50-
<div style={{outline: '1px solid black'}}>Unfocusable div</div>
55+
<div style={{outline: '1px solid black'}}>
56+
<p>Unfocusable div</p>
57+
</div>
5158
</Fragment>
5259
</div>
5360
</Fixture>

fixtures/dom/src/style.css

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -365,6 +365,10 @@ tbody tr:nth-child(even) {
365365
background-color: green;
366366
}
367367

368+
.highlight-focused-children * {
369+
margin-left: 10px;
370+
}
371+
368372
.highlight-focused-children *:focus {
369373
outline: 2px solid green;
370374
}

packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ import {
6868
getFragmentParentHostFiber,
6969
getNextSiblingHostFiber,
7070
getInstanceFromHostFiber,
71+
traverseFragmentInstanceDeeply,
7172
} from 'react-reconciler/src/ReactFiberTreeReflection';
7273

7374
export {detachDeletedInstance};
@@ -2698,7 +2699,7 @@ FragmentInstance.prototype.focus = function (
26982699
this: FragmentInstanceType,
26992700
focusOptions?: FocusOptions,
27002701
): void {
2701-
traverseFragmentInstance(
2702+
traverseFragmentInstanceDeeply(
27022703
this._fragmentFiber,
27032704
setFocusOnFiberIfFocusable,
27042705
focusOptions,
@@ -2717,7 +2718,11 @@ FragmentInstance.prototype.focusLast = function (
27172718
focusOptions?: FocusOptions,
27182719
): void {
27192720
const children: Array<Fiber> = [];
2720-
traverseFragmentInstance(this._fragmentFiber, collectChildren, children);
2721+
traverseFragmentInstanceDeeply(
2722+
this._fragmentFiber,
2723+
collectChildren,
2724+
children,
2725+
);
27212726
for (let i = children.length - 1; i >= 0; i--) {
27222727
const child = children[i];
27232728
if (setFocusOnFiberIfFocusable(child, focusOptions)) {

packages/react-dom/src/__tests__/ReactDOMFragmentRefs-test.js

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,32 @@ describe('FragmentRefs', () => {
145145
document.activeElement.blur();
146146
});
147147

148+
// @gate enableFragmentRefs
149+
it('focuses deeply nested focusable children, depth first', async () => {
150+
const fragmentRef = React.createRef();
151+
const root = ReactDOMClient.createRoot(container);
152+
153+
function Test() {
154+
return (
155+
<Fragment ref={fragmentRef}>
156+
<div id="child-a">
157+
<div tabIndex={0} id="grandchild-a">
158+
<a id="greatgrandchild-a" href="/" />
159+
</div>
160+
</div>
161+
<a id="child-b" href="/" />
162+
</Fragment>
163+
);
164+
}
165+
await act(() => {
166+
root.render(<Test />);
167+
});
168+
await act(() => {
169+
fragmentRef.current.focus();
170+
});
171+
expect(document.activeElement.id).toEqual('grandchild-a');
172+
});
173+
148174
// @gate enableFragmentRefs
149175
it('preserves document order when adding and removing children', async () => {
150176
const fragmentRef = React.createRef();
@@ -228,6 +254,34 @@ describe('FragmentRefs', () => {
228254
expect(document.activeElement.id).toEqual('child-c');
229255
document.activeElement.blur();
230256
});
257+
258+
// @gate enableFragmentRefs
259+
it('focuses deeply nested focusable children, depth first', async () => {
260+
const fragmentRef = React.createRef();
261+
const root = ReactDOMClient.createRoot(container);
262+
263+
function Test() {
264+
return (
265+
<Fragment ref={fragmentRef}>
266+
<div id="child-a" href="/">
267+
<a id="grandchild-a" href="/" />
268+
<a id="grandchild-b" href="/" />
269+
</div>
270+
<div tabIndex={0} id="child-b">
271+
<a id="grandchild-a" href="/" />
272+
<a id="grandchild-b" href="/" />
273+
</div>
274+
</Fragment>
275+
);
276+
}
277+
await act(() => {
278+
root.render(<Test />);
279+
});
280+
await act(() => {
281+
fragmentRef.current.focusLast();
282+
});
283+
expect(document.activeElement.id).toEqual('grandchild-b');
284+
});
231285
});
232286

233287
describe('blur()', () => {

packages/react-reconciler/src/ReactFiberTreeReflection.js

Lines changed: 13 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -354,6 +354,16 @@ export function traverseFragmentInstance<A, B, C>(
354354
traverseVisibleHostChildren(fragmentFiber.child, false, fn, a, b, c);
355355
}
356356

357+
export function traverseFragmentInstanceDeeply<A, B, C>(
358+
fragmentFiber: Fiber,
359+
fn: (Fiber, A, B, C) => boolean,
360+
a: A,
361+
b: B,
362+
c: C,
363+
): void {
364+
traverseVisibleHostChildren(fragmentFiber.child, true, fn, a, b, c);
365+
}
366+
357367
function traverseVisibleHostChildren<A, B, C>(
358368
child: Fiber | null,
359369
searchWithinHosts: boolean,
@@ -363,31 +373,16 @@ function traverseVisibleHostChildren<A, B, C>(
363373
c: C,
364374
): boolean {
365375
while (child !== null) {
366-
if (child.tag === HostComponent) {
367-
if (fn(child, a, b, c)) {
368-
return true;
369-
}
370-
if (searchWithinHosts) {
371-
if (
372-
traverseVisibleHostChildren(
373-
child.child,
374-
searchWithinHosts,
375-
fn,
376-
a,
377-
b,
378-
c,
379-
)
380-
) {
381-
return true;
382-
}
383-
}
376+
if (child.tag === HostComponent && fn(child, a, b, c)) {
377+
return true;
384378
} else if (
385379
child.tag === OffscreenComponent &&
386380
child.memoizedState !== null
387381
) {
388382
// Skip hidden subtrees
389383
} else {
390384
if (
385+
(searchWithinHosts || child.tag !== HostComponent) &&
391386
traverseVisibleHostChildren(child.child, searchWithinHosts, fn, a, b, c)
392387
) {
393388
return true;

0 commit comments

Comments
 (0)