Skip to content

Commit c69a5fc

Browse files
authored
Add blur() and focusLast() to fragment instances (#32654)
`focus()` was added in #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.
1 parent a35aaf7 commit c69a5fc

File tree

6 files changed

+300
-72
lines changed

6 files changed

+300
-72
lines changed
Original file line numberDiff line numberDiff line change
@@ -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+
}

Diff for: fixtures/dom/src/components/fixtures/fragment-refs/index.js

+2
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import FixtureSet from '../../FixtureSet';
22
import EventListenerCase from './EventListenerCase';
33
import IntersectionObserverCase from './IntersectionObserverCase';
44
import ResizeObserverCase from './ResizeObserverCase';
5+
import FocusCase from './FocusCase';
56

67
const React = window.React;
78

@@ -11,6 +12,7 @@ export default function FragmentRefsPage() {
1112
<EventListenerCase />
1213
<IntersectionObserverCase />
1314
<ResizeObserverCase />
15+
<FocusCase />
1416
</FixtureSet>
1517
);
1618
}

Diff for: fixtures/dom/src/style.css

+4
Original file line numberDiff line numberDiff line change
@@ -358,3 +358,7 @@ tbody tr:nth-child(even) {
358358
.onscreen {
359359
background-color: green;
360360
}
361+
362+
.highlight-focused-children *:focus {
363+
outline: 2px solid green;
364+
}

Diff for: packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js

+62-5
Original file line numberDiff line numberDiff line change
@@ -2205,6 +2205,11 @@ type StoredEventListener = {
22052205
optionsOrUseCapture: void | EventListenerOptionsOrUseCapture,
22062206
};
22072207

2208+
type FocusOptions = {
2209+
preventScroll?: boolean,
2210+
focusVisible?: boolean,
2211+
};
2212+
22082213
export type FragmentInstanceType = {
22092214
_fragmentFiber: Fiber,
22102215
_eventListeners: null | Array<StoredEventListener>,
@@ -2219,7 +2224,9 @@ export type FragmentInstanceType = {
22192224
listener: EventListener,
22202225
optionsOrUseCapture?: EventListenerOptionsOrUseCapture,
22212226
): void,
2222-
focus(): void,
2227+
focus(focusOptions?: FocusOptions): void,
2228+
focusLast(focusOptions?: FocusOptions): void,
2229+
blur(): void,
22232230
observeUsing(observer: IntersectionObserver | ResizeObserver): void,
22242231
unobserveUsing(observer: IntersectionObserver | ResizeObserver): void,
22252232
};
@@ -2307,10 +2314,57 @@ function removeEventListenerFromChild(
23072314
return false;
23082315
}
23092316
// $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+
);
23122326
};
23132327
// $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]
23142368
FragmentInstance.prototype.observeUsing = function (
23152369
this: FragmentInstanceType,
23162370
observer: IntersectionObserver | ResizeObserver,
@@ -3190,7 +3244,10 @@ export function isHiddenSubtree(fiber: Fiber): boolean {
31903244
return fiber.tag === HostComponent && fiber.memoizedProps.hidden === true;
31913245
}
31923246

3193-
export function setFocusIfFocusable(node: Instance): boolean {
3247+
export function setFocusIfFocusable(
3248+
node: Instance,
3249+
focusOptions?: FocusOptions,
3250+
): boolean {
31943251
// The logic for determining if an element is focusable is kind of complex,
31953252
// and since we want to actually change focus anyway- we can just skip it.
31963253
// 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 {
32063263
try {
32073264
element.addEventListener('focus', handleFocus);
32083265
// $FlowFixMe[method-unbinding]
3209-
(element.focus || HTMLElement.prototype.focus).call(element);
3266+
(element.focus || HTMLElement.prototype.focus).call(element, focusOptions);
32103267
} finally {
32113268
element.removeEventListener('focus', handleFocus);
32123269
}

0 commit comments

Comments
 (0)