Skip to content

Commit 3434ff4

Browse files
authored
Add scrollIntoView to fragment instances (#32814)
This adds `experimental_scrollIntoView(alignToTop)`. It doesn't yet support `scrollIntoView(options)`. Cases: - No host children: Without host children, we represent the virtual space of the Fragment by attempting to scroll to the nearest edge by using its siblings. If the preferred sibling is not found, we'll try the other side, and then the parent. - 1 or more host children: In order to handle the case of children spread between multiple scroll containers, we scroll to each child in reverse order based on the `alignToTop` flag. Due to the complexity of multiple scroll containers and dealing with portals, I've added this under a separate feature flag with an experimental prefix. We may stabilize it along with the other APIs, but this allows us to not block the whole feature on it. This PR was previously implementing a much more complex approach to handling multiple scroll containers and portals. We're going to start with the simple loop and see if we can find any concrete use cases where that doesn't suffice. 01f31d4 is the diff between approaches here.
1 parent bd5b1b7 commit 3434ff4

21 files changed

+755
-47
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: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
import TestCase from '../../TestCase';
2+
import Fixture from '../../Fixture';
3+
import ScrollIntoViewCaseComplex from './ScrollIntoViewCaseComplex';
4+
import ScrollIntoViewCaseSimple from './ScrollIntoViewCaseSimple';
5+
import ScrollIntoViewTargetElement from './ScrollIntoViewTargetElement';
6+
7+
const React = window.React;
8+
const {Fragment, useRef, useState, useEffect} = React;
9+
const ReactDOM = window.ReactDOM;
10+
11+
function Controls({
12+
alignToTop,
13+
setAlignToTop,
14+
scrollVertical,
15+
exampleType,
16+
setExampleType,
17+
}) {
18+
return (
19+
<div>
20+
<label>
21+
Example Type:
22+
<select
23+
value={exampleType}
24+
onChange={e => setExampleType(e.target.value)}>
25+
<option value="simple">Simple</option>
26+
<option value="multiple">Multiple Scroll Containers</option>
27+
<option value="horizontal">Horizontal</option>
28+
<option value="empty">Empty Fragment</option>
29+
</select>
30+
</label>
31+
<div>
32+
<label>
33+
Align to Top:
34+
<input
35+
type="checkbox"
36+
checked={alignToTop}
37+
onChange={e => setAlignToTop(e.target.checked)}
38+
/>
39+
</label>
40+
</div>
41+
<div>
42+
<button onClick={scrollVertical}>scrollIntoView()</button>
43+
</div>
44+
</div>
45+
);
46+
}
47+
48+
export default function ScrollIntoViewCase() {
49+
const [exampleType, setExampleType] = useState('simple');
50+
const [alignToTop, setAlignToTop] = useState(true);
51+
const [caseInViewport, setCaseInViewport] = useState(false);
52+
const fragmentRef = useRef(null);
53+
const testCaseRef = useRef(null);
54+
const noChildRef = useRef(null);
55+
const scrollContainerRef = useRef(null);
56+
57+
const scrollVertical = () => {
58+
fragmentRef.current.experimental_scrollIntoView(alignToTop);
59+
};
60+
61+
const scrollVerticalNoChildren = () => {
62+
noChildRef.current.experimental_scrollIntoView(alignToTop);
63+
};
64+
65+
useEffect(() => {
66+
const observer = new IntersectionObserver(entries => {
67+
entries.forEach(entry => {
68+
if (entry.isIntersecting) {
69+
setCaseInViewport(true);
70+
} else {
71+
setCaseInViewport(false);
72+
}
73+
});
74+
});
75+
testCaseRef.current.observeUsing(observer);
76+
77+
const lastRef = testCaseRef.current;
78+
return () => {
79+
lastRef.unobserveUsing(observer);
80+
observer.disconnect();
81+
};
82+
});
83+
84+
return (
85+
<Fragment ref={testCaseRef}>
86+
<TestCase title="ScrollIntoView">
87+
<TestCase.Steps>
88+
<li>Toggle alignToTop and click the buttons to scroll</li>
89+
</TestCase.Steps>
90+
<TestCase.ExpectedResult>
91+
<p>When the Fragment has children:</p>
92+
<p>
93+
In order to handle the case where children are split between
94+
multiple scroll containers, we call scrollIntoView on each child in
95+
reverse order.
96+
</p>
97+
<p>When the Fragment does not have children:</p>
98+
<p>
99+
The Fragment still represents a virtual space. We can scroll to the
100+
nearest edge by selecting the host sibling before if
101+
alignToTop=false, or after if alignToTop=true|undefined. We'll fall
102+
back to the other sibling or parent in the case that the preferred
103+
sibling target doesn't exist.
104+
</p>
105+
</TestCase.ExpectedResult>
106+
<Fixture>
107+
<Fixture.Controls>
108+
<Controls
109+
alignToTop={alignToTop}
110+
setAlignToTop={setAlignToTop}
111+
scrollVertical={scrollVertical}
112+
exampleType={exampleType}
113+
setExampleType={setExampleType}
114+
/>
115+
</Fixture.Controls>
116+
{exampleType === 'simple' && (
117+
<Fragment ref={fragmentRef}>
118+
<ScrollIntoViewCaseSimple />
119+
</Fragment>
120+
)}
121+
{exampleType === 'horizontal' && (
122+
<div
123+
style={{
124+
display: 'flex',
125+
overflowX: 'auto',
126+
flexDirection: 'row',
127+
border: '1px solid #ccc',
128+
padding: '1rem 10rem',
129+
marginBottom: '1rem',
130+
width: '100%',
131+
whiteSpace: 'nowrap',
132+
justifyContent: 'space-between',
133+
}}>
134+
<Fragment ref={fragmentRef}>
135+
<ScrollIntoViewCaseSimple />
136+
</Fragment>
137+
</div>
138+
)}
139+
{exampleType === 'multiple' && (
140+
<Fragment>
141+
<div
142+
style={{
143+
height: '50vh',
144+
overflowY: 'auto',
145+
border: '1px solid black',
146+
marginBottom: '1rem',
147+
}}
148+
ref={scrollContainerRef}
149+
/>
150+
<Fragment ref={fragmentRef}>
151+
<ScrollIntoViewCaseComplex
152+
caseInViewport={caseInViewport}
153+
scrollContainerRef={scrollContainerRef}
154+
/>
155+
</Fragment>
156+
</Fragment>
157+
)}
158+
{exampleType === 'empty' && (
159+
<Fragment>
160+
<ScrollIntoViewTargetElement
161+
color="lightyellow"
162+
id="ABOVE EMPTY FRAGMENT"
163+
/>
164+
<Fragment ref={fragmentRef}></Fragment>
165+
<ScrollIntoViewTargetElement
166+
color="lightblue"
167+
id="BELOW EMPTY FRAGMENT"
168+
/>
169+
</Fragment>
170+
)}
171+
<Fixture.Controls>
172+
<Controls
173+
alignToTop={alignToTop}
174+
setAlignToTop={setAlignToTop}
175+
scrollVertical={scrollVertical}
176+
exampleType={exampleType}
177+
setExampleType={setExampleType}
178+
/>
179+
</Fixture.Controls>
180+
</Fixture>
181+
</TestCase>
182+
</Fragment>
183+
);
184+
}
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import ScrollIntoViewTargetElement from './ScrollIntoViewTargetElement';
2+
3+
const React = window.React;
4+
const {Fragment, useRef, useState, useEffect} = React;
5+
const ReactDOM = window.ReactDOM;
6+
7+
export default function ScrollIntoViewCaseComplex({
8+
caseInViewport,
9+
scrollContainerRef,
10+
}) {
11+
const [didMount, setDidMount] = useState(false);
12+
// Hack to portal child into the scroll container
13+
// after the first render. This is to simulate a case where
14+
// an item is portaled into another scroll container.
15+
useEffect(() => {
16+
if (!didMount) {
17+
setDidMount(true);
18+
}
19+
}, []);
20+
return (
21+
<Fragment>
22+
{caseInViewport && (
23+
<div
24+
style={{position: 'fixed', top: 0, backgroundColor: 'red'}}
25+
id="header">
26+
Fixed header
27+
</div>
28+
)}
29+
{didMount &&
30+
ReactDOM.createPortal(
31+
<ScrollIntoViewTargetElement color="red" id="FROM_PORTAL" />,
32+
scrollContainerRef.current
33+
)}
34+
<ScrollIntoViewTargetElement color="lightgreen" id="A" />
35+
<ScrollIntoViewTargetElement color="lightcoral" id="B" />
36+
<ScrollIntoViewTargetElement color="lightblue" id="C" />
37+
{caseInViewport && (
38+
<div
39+
style={{
40+
position: 'fixed',
41+
bottom: 0,
42+
backgroundColor: 'purple',
43+
}}
44+
id="footer">
45+
Fixed footer
46+
</div>
47+
)}
48+
</Fragment>
49+
);
50+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import ScrollIntoViewTargetElement from './ScrollIntoViewTargetElement';
2+
3+
const React = window.React;
4+
const {Fragment} = React;
5+
6+
export default function ScrollIntoViewCaseSimple() {
7+
return (
8+
<Fragment>
9+
<ScrollIntoViewTargetElement color="lightyellow" id="SCROLLABLE-1" />
10+
<ScrollIntoViewTargetElement color="lightpink" id="SCROLLABLE-2" />
11+
<ScrollIntoViewTargetElement color="lightcyan" id="SCROLLABLE-3" />
12+
</Fragment>
13+
);
14+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
const React = window.React;
2+
3+
export default function ScrollIntoViewTargetElement({color, id, top}) {
4+
return (
5+
<div
6+
id={id}
7+
style={{
8+
height: 500,
9+
minWidth: 300,
10+
backgroundColor: color,
11+
marginTop: top ? '50vh' : 0,
12+
marginBottom: 100,
13+
flexShrink: 0,
14+
}}>
15+
{id}
16+
</div>
17+
);
18+
}

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
}

fixtures/dom/src/index.js

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,23 @@ import './polyfills';
22
import loadReact, {isLocal} from './react-loader';
33

44
if (isLocal()) {
5-
Promise.all([import('react'), import('react-dom/client')])
6-
.then(([React, ReactDOMClient]) => {
7-
if (React === undefined || ReactDOMClient === undefined) {
5+
Promise.all([
6+
import('react'),
7+
import('react-dom'),
8+
import('react-dom/client'),
9+
])
10+
.then(([React, ReactDOM, ReactDOMClient]) => {
11+
if (
12+
React === undefined ||
13+
ReactDOM === undefined ||
14+
ReactDOMClient === undefined
15+
) {
816
throw new Error(
917
'Unable to load React. Build experimental and then run `yarn dev` again'
1018
);
1119
}
1220
window.React = React;
21+
window.ReactDOM = ReactDOM;
1322
window.ReactDOMClient = ReactDOMClient;
1423
})
1524
.then(() => import('./components/App'))

0 commit comments

Comments
 (0)