Skip to content

Commit 4b46c8c

Browse files
committed
Add scrollIntoView to fragment instances
1 parent cc19468 commit 4b46c8c

File tree

8 files changed

+673
-16
lines changed

8 files changed

+673
-16
lines changed

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import Fixture from '../../Fixture';
33

44
const React = window.React;
55

6-
const {Fragment, useEffect, useRef, useState} = React;
6+
const {Fragment, useRef} = React;
77

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

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import TestCase from '../../TestCase';
22
import Fixture from '../../Fixture';
33

44
const React = window.React;
5-
const {Fragment, useEffect, useRef, useState} = React;
5+
const {Fragment, useRef, useState} = React;
66

77
export default function GetClientRectsCase() {
88
const fragmentRef = useRef(null);
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
import TestCase from '../../TestCase';
2+
import Fixture from '../../Fixture';
3+
4+
const React = window.React;
5+
const {Fragment, useRef, useState} = React;
6+
7+
function Controls({
8+
alignToTop,
9+
setAlignToTop,
10+
scrollVertical,
11+
scrollVerticalNoChildren,
12+
}) {
13+
return (
14+
<div>
15+
<label>
16+
Align to Top:
17+
<input
18+
type="checkbox"
19+
checked={alignToTop}
20+
onChange={e => setAlignToTop(e.target.checked)}
21+
/>
22+
</label>
23+
<div>
24+
<button onClick={scrollVertical}>scrollIntoView() - Vertical</button>
25+
<button onClick={scrollVerticalNoChildren}>
26+
scrollIntoView() - Vertical, No children
27+
</button>
28+
</div>
29+
</div>
30+
);
31+
}
32+
33+
function TargetElement({color, top, id}) {
34+
return (
35+
<div
36+
id={id}
37+
style={{
38+
height: 500,
39+
backgroundColor: color,
40+
marginTop: top ? '50vh' : 0,
41+
marginBottom: 100,
42+
flexShrink: 0,
43+
}}>
44+
{id}
45+
</div>
46+
);
47+
}
48+
49+
export default function ScrollIntoViewCase() {
50+
const [alignToTop, setAlignToTop] = useState(true);
51+
const verticalRef = useRef(null);
52+
const noChildRef = useRef(null);
53+
54+
const scrollVertical = () => {
55+
verticalRef.current.scrollIntoView(alignToTop);
56+
};
57+
58+
const scrollVerticalNoChildren = () => {
59+
noChildRef.current.scrollIntoView(alignToTop);
60+
};
61+
62+
return (
63+
<TestCase title="ScrollIntoView">
64+
<TestCase.Steps>
65+
<li>Toggle alignToTop and click the buttons to scroll</li>
66+
</TestCase.Steps>
67+
<TestCase.ExpectedResult>
68+
<p>When the Fragment has children:</p>
69+
<p>
70+
The simple path is that all children are in the same scroll container.
71+
If alignToTop=true|undefined, we will select the first Fragment host
72+
child to call scrollIntoView on. Otherwise we'll call on the last host
73+
child.
74+
</p>
75+
<p>
76+
In the case of fixed or sticky elements and portals (we have here
77+
sticky header and footer), we split up the host children into groups
78+
of scroll containers. If we hit a sticky/fixed element, we'll always
79+
attempt to scroll on the first or last element of the next group.
80+
</p>
81+
<p>When the Fragment does not have children:</p>
82+
<p>
83+
The Fragment still represents a virtual space. We can scroll to the
84+
nearest edge by selecting the host sibling before if alignToTop=false,
85+
or after if alignToTop=true|undefined. We'll fall back to the other
86+
sibling or parent in the case that the preferred sibling target
87+
doesn't exist.
88+
</p>
89+
</TestCase.ExpectedResult>
90+
<Fixture>
91+
<Fixture.Controls>
92+
<Controls
93+
alignToTop={alignToTop}
94+
setAlignToTop={setAlignToTop}
95+
scrollVertical={scrollVertical}
96+
scrollVerticalNoChildren={scrollVerticalNoChildren}
97+
/>
98+
</Fixture.Controls>
99+
<Fragment ref={verticalRef}>
100+
<div
101+
style={{position: 'sticky', top: 100, backgroundColor: 'red'}}
102+
id="header">
103+
Sticky header
104+
</div>
105+
<TargetElement color="lightgreen" top={true} id="A" />
106+
<Fragment ref={noChildRef}></Fragment>
107+
<TargetElement color="lightcoral" id="B" />
108+
<TargetElement color="lightblue" id="C" />
109+
<div
110+
style={{position: 'sticky', bottom: 0, backgroundColor: 'purple'}}
111+
id="footer">
112+
Sticky footer
113+
</div>
114+
</Fragment>
115+
116+
<Fixture.Controls>
117+
<Controls
118+
alignToTop={alignToTop}
119+
setAlignToTop={setAlignToTop}
120+
scrollVertical={scrollVertical}
121+
scrollVerticalNoChildren={scrollVerticalNoChildren}
122+
/>
123+
</Fixture.Controls>
124+
</Fixture>
125+
</TestCase>
126+
);
127+
}

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import IntersectionObserverCase from './IntersectionObserverCase';
55
import ResizeObserverCase from './ResizeObserverCase';
66
import FocusCase from './FocusCase';
77
import GetClientRectsCase from './GetClientRectsCase';
8+
import ScrollIntoViewCase from './ScrollIntoViewCase';
89

910
const React = window.React;
1011

@@ -17,6 +18,7 @@ export default function FragmentRefsPage() {
1718
<ResizeObserverCase />
1819
<FocusCase />
1920
<GetClientRectsCase />
21+
<ScrollIntoViewCase />
2022
</FixtureSet>
2123
);
2224
}

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

Lines changed: 123 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,10 @@ import {
5555
} from './ReactDOMComponentTree';
5656
import {
5757
traverseFragmentInstance,
58-
getFragmentParentHostInstance,
58+
getFragmentInstanceHostParent,
59+
getFragmentInstanceSiblings,
60+
getHostNodeFromHostFiber,
61+
groupFragmentChildrenByScrollContainer,
5962
} from 'react-reconciler/src/ReactFiberTreeReflection';
6063

6164
export {detachDeletedInstance};
@@ -2247,6 +2250,7 @@ export type FragmentInstanceType = {
22472250
composed: boolean,
22482251
}): Document | ShadowRoot | FragmentInstanceType,
22492252
compareDocumentPosition(otherNode: Instance): number,
2253+
scrollIntoView(alignToTop?: boolean): void,
22502254
};
22512255

22522256
function FragmentInstance(this: FragmentInstanceType, fragmentFiber: Fiber) {
@@ -2336,8 +2340,8 @@ FragmentInstance.prototype.dispatchEvent = function (
23362340
this: FragmentInstanceType,
23372341
event: Event,
23382342
): boolean {
2339-
const parentHostInstance = getFragmentParentHostInstance(this._fragmentFiber);
2340-
if (parentHostInstance === null) {
2343+
const parentHostFiber = getFragmentInstanceHostParent(this._fragmentFiber);
2344+
if (parentHostFiber === null) {
23412345
if (__DEV__) {
23422346
console.error(
23432347
'You are attempting to dispatch an event on a disconnected ' +
@@ -2346,6 +2350,7 @@ FragmentInstance.prototype.dispatchEvent = function (
23462350
}
23472351
return true;
23482352
}
2353+
const parentHostInstance = getHostNodeFromHostFiber(parentHostFiber);
23492354
return parentHostInstance.dispatchEvent(event);
23502355
};
23512356
// $FlowFixMe[prop-missing]
@@ -2446,10 +2451,13 @@ FragmentInstance.prototype.getClientRects = function (
24462451
this: FragmentInstanceType,
24472452
): Array<DOMRect> {
24482453
const rects: Array<DOMRect> = [];
2449-
traverseFragmentInstance(this._fragmentFiber, collectClientRects, rects);
2454+
traverseFragmentInstance(this._fragmentFiber, collectClientRectsFlat, rects);
24502455
return rects;
24512456
};
2452-
function collectClientRects(child: Instance, rects: Array<DOMRect>): boolean {
2457+
function collectClientRectsFlat(
2458+
child: Instance,
2459+
rects: Array<DOMRect>,
2460+
): boolean {
24532461
// $FlowFixMe[method-unbinding]
24542462
rects.push.apply(rects, child.getClientRects());
24552463
return false;
@@ -2459,10 +2467,11 @@ FragmentInstance.prototype.getRootNode = function (
24592467
this: FragmentInstanceType,
24602468
getRootNodeOptions?: {composed: boolean},
24612469
): Document | ShadowRoot | FragmentInstanceType {
2462-
const parentHostInstance = getFragmentParentHostInstance(this._fragmentFiber);
2463-
if (parentHostInstance === null) {
2470+
const parentHostFiber = getFragmentInstanceHostParent(this._fragmentFiber);
2471+
if (parentHostFiber === null) {
24642472
return this;
24652473
}
2474+
const parentHostInstance = getHostNodeFromHostFiber(parentHostFiber);
24662475
const rootNode =
24672476
// $FlowFixMe[incompatible-cast] Flow expects Node
24682477
(parentHostInstance.getRootNode(getRootNodeOptions): Document | ShadowRoot);
@@ -2503,6 +2512,113 @@ FragmentInstance.prototype.compareDocumentPosition = function (
25032512

25042513
return result;
25052514
};
2515+
// $FlowFixMe[prop-missing]
2516+
FragmentInstance.prototype.scrollIntoView = function (
2517+
this: FragmentInstanceType,
2518+
alignToTop?: boolean,
2519+
): void {
2520+
if (typeof alignToTop === 'object') {
2521+
throw new Error(
2522+
'FragmentInstance.scrollIntoView() does not support ' +
2523+
'scrollIntoViewOptions. Use the alignToTop boolean instead.',
2524+
);
2525+
}
2526+
2527+
const childrenByScrollContainer = groupFragmentChildrenByScrollContainer(
2528+
this._fragmentFiber,
2529+
fiber => {
2530+
const hostNode = getHostNodeFromHostFiber(fiber);
2531+
const position = getComputedStyle(hostNode).position;
2532+
return position === 'sticky' || position === 'fixed';
2533+
},
2534+
);
2535+
2536+
// If there are no children, go off the previous or next sibling
2537+
if (childrenByScrollContainer[0].length === 0) {
2538+
const hostSiblings = getFragmentInstanceSiblings(this._fragmentFiber);
2539+
const targetFiber =
2540+
(alignToTop === false
2541+
? hostSiblings[0] || hostSiblings[1]
2542+
: hostSiblings[1] || hostSiblings[0]) ||
2543+
getFragmentInstanceHostParent(this._fragmentFiber);
2544+
if (targetFiber === null) {
2545+
if (__DEV__) {
2546+
console.error(
2547+
'You are attempting to scroll a FragmentInstance that has no ' +
2548+
'children, siblings, or parent. No scroll was performed.',
2549+
);
2550+
}
2551+
return;
2552+
}
2553+
const target = getHostNodeFromHostFiber(targetFiber);
2554+
target.scrollIntoView(alignToTop);
2555+
} else {
2556+
iterateFragmentChildrenScrollContainers(
2557+
childrenByScrollContainer,
2558+
alignToTop !== false,
2559+
(targetFiber, alignToTopArg, scrollState) => {
2560+
if (targetFiber) {
2561+
const target = getHostNodeFromHostFiber(targetFiber);
2562+
const targetPosition = getComputedStyle(target).position;
2563+
const isStickyOrFixed =
2564+
targetPosition === 'sticky' || targetPosition === 'fixed';
2565+
const targetRect = target.getBoundingClientRect();
2566+
const distanceToTargetEdge = Math.abs(targetRect.bottom);
2567+
const hasNotScrolled =
2568+
scrollState.nextScrollThreshold === Number.MAX_SAFE_INTEGER;
2569+
const ownerDocument = target.ownerDocument;
2570+
const documentElement = ownerDocument.documentElement;
2571+
const targetWithinViewport =
2572+
documentElement &&
2573+
(targetRect.top >= 0 ||
2574+
targetRect.bottom <= documentElement.clientHeight);
2575+
// If we've already scrolled, only scroll again if
2576+
// 1) The previous scroll target was sticky or fixed OR
2577+
// 2) Scrolling to the next target won't remove previous target from viewport AND
2578+
// 3) The next target is not already in the viewport
2579+
if (
2580+
hasNotScrolled ||
2581+
scrollState.prevWasStickyOrFixed ||
2582+
(distanceToTargetEdge < scrollState.nextScrollThreshold &&
2583+
!targetWithinViewport)
2584+
) {
2585+
target.scrollIntoView(alignToTopArg);
2586+
scrollState.nextScrollThreshold = targetRect.height;
2587+
scrollState.prevWasStickyOrFixed = isStickyOrFixed;
2588+
}
2589+
}
2590+
},
2591+
);
2592+
}
2593+
};
2594+
2595+
function iterateFragmentChildrenScrollContainers(
2596+
childrenByScrollContainer: Array<Array<Fiber>>,
2597+
alignToTop: boolean,
2598+
callback: (
2599+
child: Fiber | null,
2600+
arg: boolean,
2601+
scrollState: {nextScrollThreshold: number, prevWasStickyOrFixed: boolean},
2602+
) => void,
2603+
) {
2604+
const scrollState = {
2605+
nextScrollThreshold: Number.MAX_SAFE_INTEGER,
2606+
prevWasStickyOrFixed: false,
2607+
};
2608+
if (alignToTop) {
2609+
for (let i = 0; i < childrenByScrollContainer.length; i++) {
2610+
const children = childrenByScrollContainer[i];
2611+
const child = children[0];
2612+
callback(child, alignToTop, scrollState);
2613+
}
2614+
} else {
2615+
for (let i = childrenByScrollContainer.length - 1; i >= 0; i--) {
2616+
const children = childrenByScrollContainer[i];
2617+
const child = children[children.length - 1];
2618+
callback(child, alignToTop, scrollState);
2619+
}
2620+
}
2621+
}
25062622

25072623
function normalizeListenerOptions(
25082624
opts: ?EventListenerOptionsOrUseCapture,

0 commit comments

Comments
 (0)