Skip to content

Add blur() and focusLast() to fragment instances #32654

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 3 commits into from
Mar 18, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions fixtures/dom/src/components/fixtures/fragment-refs/FocusCase.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import TestCase from '../../TestCase';
import Fixture from '../../Fixture';

const React = window.React;

const {Fragment, useEffect, useRef, useState} = React;

export default function FocusCase() {
const fragmentRef = useRef(null);

return (
<TestCase title="Focus Management">
<TestCase.Steps>
<li>Click to focus the first child</li>
<li>Click to focus the last child</li>
<li>Click to blur any focus within the fragment</li>
</TestCase.Steps>

<TestCase.ExpectedResult>
<p>
The focus method will focus the first focusable child within the
fragment, skipping any unfocusable children.
</p>
<p>
The focusLast method is the reverse, focusing the last focusable
child.
</p>
<p>
Blur will call blur on the document, only if one of the children
within the fragment is the active element.
</p>
</TestCase.ExpectedResult>

<button onClick={() => fragmentRef.current.focus()}>
Focus first child
</button>
<button onClick={() => fragmentRef.current.focusLast()}>
Focus last child
</button>
<button onClick={() => fragmentRef.current.blur()}>Blur</button>

<Fixture>
<div className="highlight-focused-children" style={{display: 'flex'}}>
<Fragment ref={fragmentRef}>
<div style={{outline: '1px solid black'}}>Unfocusable div</div>
<button>Button 1</button>
<button>Button 2</button>
<input type="text" placeholder="Input field" />
<div style={{outline: '1px solid black'}}>Unfocusable div</div>
</Fragment>
</div>
</Fixture>
</TestCase>
);
}
2 changes: 2 additions & 0 deletions fixtures/dom/src/components/fixtures/fragment-refs/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import FixtureSet from '../../FixtureSet';
import EventListenerCase from './EventListenerCase';
import IntersectionObserverCase from './IntersectionObserverCase';
import ResizeObserverCase from './ResizeObserverCase';
import FocusCase from './FocusCase';

const React = window.React;

Expand All @@ -11,6 +12,7 @@ export default function FragmentRefsPage() {
<EventListenerCase />
<IntersectionObserverCase />
<ResizeObserverCase />
<FocusCase />
</FixtureSet>
);
}
4 changes: 4 additions & 0 deletions fixtures/dom/src/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -358,3 +358,7 @@ tbody tr:nth-child(even) {
.onscreen {
background-color: green;
}

.highlight-focused-children *:focus {
outline: 2px solid green;
}
67 changes: 62 additions & 5 deletions packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js
Original file line number Diff line number Diff line change
Expand Up @@ -2183,6 +2183,11 @@ type StoredEventListener = {
optionsOrUseCapture: void | EventListenerOptionsOrUseCapture,
};

type FocusOptions = {
preventScroll?: boolean,
focusVisible?: boolean,
};

export type FragmentInstanceType = {
_fragmentFiber: Fiber,
_eventListeners: null | Array<StoredEventListener>,
Expand All @@ -2197,7 +2202,9 @@ export type FragmentInstanceType = {
listener: EventListener,
optionsOrUseCapture?: EventListenerOptionsOrUseCapture,
): void,
focus(): void,
focus(focusOptions?: FocusOptions): void,
focusLast(focusOptions?: FocusOptions): void,
blur(): void,
observeUsing(observer: IntersectionObserver | ResizeObserver): void,
unobserveUsing(observer: IntersectionObserver | ResizeObserver): void,
};
Expand Down Expand Up @@ -2285,10 +2292,57 @@ function removeEventListenerFromChild(
return false;
}
// $FlowFixMe[prop-missing]
FragmentInstance.prototype.focus = function (this: FragmentInstanceType) {
traverseFragmentInstance(this._fragmentFiber, setFocusIfFocusable);
FragmentInstance.prototype.focus = function (
this: FragmentInstanceType,
focusOptions?: FocusOptions,
): void {
traverseFragmentInstance(
this._fragmentFiber,
setFocusIfFocusable,
focusOptions,
);
};
// $FlowFixMe[prop-missing]
FragmentInstance.prototype.focusLast = function (
this: FragmentInstanceType,
focusOptions?: FocusOptions,
) {
const children: Array<Instance> = [];
traverseFragmentInstance(this._fragmentFiber, collectChildren, children);
for (let i = children.length - 1; i >= 0; i--) {
const child = children[i];
if (setFocusIfFocusable(child, focusOptions)) {
break;
}
}
};
function collectChildren(
child: Instance,
collection: Array<Instance>,
): boolean {
collection.push(child);
return false;
}
// $FlowFixMe[prop-missing]
FragmentInstance.prototype.blur = function (this: FragmentInstanceType): void {
// TODO: When we have a parent element reference, we can skip traversal if the fragment's parent
// does not contain document.activeElement
traverseFragmentInstance(
this._fragmentFiber,
blurActiveElementWithinFragment,
);
};
function blurActiveElementWithinFragment(child: Instance): boolean {
// TODO: We can get the activeElement from the parent outside of the loop when we have a reference.
const ownerDocument = child.ownerDocument;
if (child === ownerDocument.activeElement) {
// $FlowFixMe[prop-missing]
child.blur();
return true;
}
return false;
}
// $FlowFixMe[prop-missing]
FragmentInstance.prototype.observeUsing = function (
this: FragmentInstanceType,
observer: IntersectionObserver | ResizeObserver,
Expand Down Expand Up @@ -3168,7 +3222,10 @@ export function isHiddenSubtree(fiber: Fiber): boolean {
return fiber.tag === HostComponent && fiber.memoizedProps.hidden === true;
}

export function setFocusIfFocusable(node: Instance): boolean {
export function setFocusIfFocusable(
node: Instance,
focusOptions?: FocusOptions,
): boolean {
// The logic for determining if an element is focusable is kind of complex,
// and since we want to actually change focus anyway- we can just skip it.
// Instead we'll just listen for a "focus" event to verify that focus was set.
Expand All @@ -3184,7 +3241,7 @@ export function setFocusIfFocusable(node: Instance): boolean {
try {
element.addEventListener('focus', handleFocus);
// $FlowFixMe[method-unbinding]
(element.focus || HTMLElement.prototype.focus).call(element);
(element.focus || HTMLElement.prototype.focus).call(element, focusOptions);
} finally {
element.removeEventListener('focus', handleFocus);
}
Expand Down
Loading
Loading