Skip to content

Commit 805336f

Browse files
committed
Add scrollIntoView to fragment instances
1 parent 19a18a6 commit 805336f

File tree

8 files changed

+648
-10
lines changed

8 files changed

+648
-10
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: 115 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -35,11 +35,6 @@ import {runWithFiberInDEV} from 'react-reconciler/src/ReactCurrentFiber';
3535
import hasOwnProperty from 'shared/hasOwnProperty';
3636
import {checkAttributeStringCoercion} from 'shared/CheckStringCoercion';
3737
import {REACT_CONTEXT_TYPE} from 'shared/ReactSymbols';
38-
import {
39-
isFiberContainedBy,
40-
isFiberFollowing,
41-
isFiberPreceding,
42-
} from 'react-reconciler/src/ReactFiberTreeReflection';
4338

4439
export {
4540
setCurrentUpdatePriority,
@@ -66,6 +61,11 @@ import {
6661
getFragmentParentHostFiber,
6762
getNextSiblingHostFiber,
6863
getInstanceFromHostFiber,
64+
groupFragmentChildrenByScrollContainer,
65+
isFiberContainedBy,
66+
isFiberFollowing,
67+
isFiberPreceding,
68+
getFragmentInstanceSiblings,
6969
} from 'react-reconciler/src/ReactFiberTreeReflection';
7070

7171
export {detachDeletedInstance};
@@ -2427,6 +2427,7 @@ export type FragmentInstanceType = {
24272427
composed: boolean,
24282428
}): Document | ShadowRoot | FragmentInstanceType,
24292429
compareDocumentPosition(otherNode: Instance): number,
2430+
scrollIntoView(alignToTop?: boolean): void,
24302431
};
24312432

24322433
function FragmentInstance(this: FragmentInstanceType, fragmentFiber: Fiber) {
@@ -2654,10 +2655,10 @@ FragmentInstance.prototype.getClientRects = function (
26542655
this: FragmentInstanceType,
26552656
): Array<DOMRect> {
26562657
const rects: Array<DOMRect> = [];
2657-
traverseFragmentInstance(this._fragmentFiber, collectClientRects, rects);
2658+
traverseFragmentInstance(this._fragmentFiber, collectClientRectsFlat, rects);
26582659
return rects;
26592660
};
2660-
function collectClientRects(child: Fiber, rects: Array<DOMRect>): boolean {
2661+
function collectClientRectsFlat(child: Fiber, rects: Array<DOMRect>): boolean {
26612662
const instance = getInstanceFromHostFiber(child);
26622663
// $FlowFixMe[method-unbinding]
26632664
rects.push.apply(rects, instance.getClientRects());
@@ -2802,6 +2803,113 @@ function validateDocumentPositionWithFiberTree(
28022803

28032804
return false;
28042805
}
2806+
// $FlowFixMe[prop-missing]
2807+
FragmentInstance.prototype.scrollIntoView = function (
2808+
this: FragmentInstanceType,
2809+
alignToTop?: boolean,
2810+
): void {
2811+
if (typeof alignToTop === 'object') {
2812+
throw new Error(
2813+
'FragmentInstance.scrollIntoView() does not support ' +
2814+
'scrollIntoViewOptions. Use the alignToTop boolean instead.',
2815+
);
2816+
}
2817+
2818+
const childrenByScrollContainer = groupFragmentChildrenByScrollContainer(
2819+
this._fragmentFiber,
2820+
fiber => {
2821+
const instance = getInstanceFromHostFiber(fiber);
2822+
const position = getComputedStyle(instance).position;
2823+
return position === 'sticky' || position === 'fixed';
2824+
},
2825+
);
2826+
2827+
// If there are no children, go off the previous or next sibling
2828+
if (childrenByScrollContainer[0].length === 0) {
2829+
const hostSiblings = getFragmentInstanceSiblings(this._fragmentFiber);
2830+
const targetFiber =
2831+
(alignToTop === false
2832+
? hostSiblings[0] || hostSiblings[1]
2833+
: hostSiblings[1] || hostSiblings[0]) ||
2834+
getFragmentParentHostFiber(this._fragmentFiber);
2835+
if (targetFiber === null) {
2836+
if (__DEV__) {
2837+
console.error(
2838+
'You are attempting to scroll a FragmentInstance that has no ' +
2839+
'children, siblings, or parent. No scroll was performed.',
2840+
);
2841+
}
2842+
return;
2843+
}
2844+
const target = getInstanceFromHostFiber(targetFiber);
2845+
target.scrollIntoView(alignToTop);
2846+
} else {
2847+
iterateFragmentChildrenScrollContainers(
2848+
childrenByScrollContainer,
2849+
alignToTop !== false,
2850+
(targetFiber, alignToTopArg, scrollState) => {
2851+
if (targetFiber) {
2852+
const target = getInstanceFromHostFiber(targetFiber);
2853+
const targetPosition = getComputedStyle(target).position;
2854+
const isStickyOrFixed =
2855+
targetPosition === 'sticky' || targetPosition === 'fixed';
2856+
const targetRect = target.getBoundingClientRect();
2857+
const distanceToTargetEdge = Math.abs(targetRect.bottom);
2858+
const hasNotScrolled =
2859+
scrollState.nextScrollThreshold === Number.MAX_SAFE_INTEGER;
2860+
const ownerDocument = target.ownerDocument;
2861+
const documentElement = ownerDocument.documentElement;
2862+
const targetWithinViewport =
2863+
documentElement &&
2864+
(targetRect.top >= 0 ||
2865+
targetRect.bottom <= documentElement.clientHeight);
2866+
// If we've already scrolled, only scroll again if
2867+
// 1) The previous scroll target was sticky or fixed OR
2868+
// 2) Scrolling to the next target won't remove previous target from viewport AND
2869+
// 3) The next target is not already in the viewport
2870+
if (
2871+
hasNotScrolled ||
2872+
scrollState.prevWasStickyOrFixed ||
2873+
(distanceToTargetEdge < scrollState.nextScrollThreshold &&
2874+
!targetWithinViewport)
2875+
) {
2876+
target.scrollIntoView(alignToTopArg);
2877+
scrollState.nextScrollThreshold = targetRect.height;
2878+
scrollState.prevWasStickyOrFixed = isStickyOrFixed;
2879+
}
2880+
}
2881+
},
2882+
);
2883+
}
2884+
};
2885+
2886+
function iterateFragmentChildrenScrollContainers(
2887+
childrenByScrollContainer: Array<Array<Fiber>>,
2888+
alignToTop: boolean,
2889+
callback: (
2890+
child: Fiber | null,
2891+
arg: boolean,
2892+
scrollState: {nextScrollThreshold: number, prevWasStickyOrFixed: boolean},
2893+
) => void,
2894+
) {
2895+
const scrollState = {
2896+
nextScrollThreshold: Number.MAX_SAFE_INTEGER,
2897+
prevWasStickyOrFixed: false,
2898+
};
2899+
if (alignToTop) {
2900+
for (let i = 0; i < childrenByScrollContainer.length; i++) {
2901+
const children = childrenByScrollContainer[i];
2902+
const child = children[0];
2903+
callback(child, alignToTop, scrollState);
2904+
}
2905+
} else {
2906+
for (let i = childrenByScrollContainer.length - 1; i >= 0; i--) {
2907+
const children = childrenByScrollContainer[i];
2908+
const child = children[children.length - 1];
2909+
callback(child, alignToTop, scrollState);
2910+
}
2911+
}
2912+
}
28052913

28062914
function normalizeListenerOptions(
28072915
opts: ?EventListenerOptionsOrUseCapture,

0 commit comments

Comments
 (0)