Skip to content

Commit faecda7

Browse files
committed
Add scrollIntoView to fragment instances
This adds `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 host child: The simplest case where its the equivalent of calling the method on the child element directly - Multiple host children in same scroll container: - Here we find the first child in the list for `alignToTop=true|undefined` or the last child `alignToTop=false`. We call scroll on that element. - Multiple host children in multiple scroll containers (fixed positioning or portal-ed into other containers): - In order to handle the possibility of children being fixed or portal-ed, where the assumption is that isn't where you want to stop scroll, we work through groups of host children by scroll container and may scroll to multiple elements. - `scrollIntoView` will only be called again if scrolling to the next element wouldn't scroll the previous one out of the viewport. - `alignToTop=true` means iterate in reverse, scrolling the first child of each container - `alignToTop=false` means iterate in normal order, scrolling the last child of each container
1 parent 96c61b7 commit faecda7

File tree

13 files changed

+992
-35
lines changed

13 files changed

+992
-35
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: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
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 scrollContainerRef = useRef(null);
55+
56+
const scrollVertical = () => {
57+
fragmentRef.current.scrollIntoView(alignToTop);
58+
};
59+
60+
const scrollVerticalNoChildren = () => {
61+
noChildRef.current.scrollIntoView(alignToTop);
62+
};
63+
64+
useEffect(() => {
65+
const observer = new IntersectionObserver(entries => {
66+
entries.forEach(entry => {
67+
if (entry.isIntersecting) {
68+
setCaseInViewport(true);
69+
} else {
70+
setCaseInViewport(false);
71+
}
72+
});
73+
});
74+
testCaseRef.current.observeUsing(observer);
75+
76+
const lastRef = testCaseRef.current;
77+
return () => {
78+
lastRef.unobserveUsing(observer);
79+
observer.disconnect();
80+
};
81+
});
82+
83+
return (
84+
<Fragment ref={testCaseRef}>
85+
<TestCase title="ScrollIntoView">
86+
<TestCase.Steps>
87+
<li>Toggle alignToTop and click the buttons to scroll</li>
88+
</TestCase.Steps>
89+
<TestCase.ExpectedResult>
90+
<p>When the Fragment has children:</p>
91+
<p>
92+
The simple path is that all children are in the same scroll
93+
container. If alignToTop=true|undefined, we will select the first
94+
Fragment host child to call scrollIntoView on. Otherwise we'll call
95+
on the last host child.
96+
</p>
97+
<p>
98+
In the case of fixed elements and inserted elements or portals
99+
causing fragment siblings to be in different scroll containers, we
100+
split up the host children into groups of scroll containers. If we
101+
hit a fixed element, we'll always attempt to scroll on the first or
102+
last element of the next group.
103+
</p>
104+
<p>When the Fragment does not have children:</p>
105+
<p>
106+
The Fragment still represents a virtual space. We can scroll to the
107+
nearest edge by selecting the host sibling before if
108+
alignToTop=false, or after if alignToTop=true|undefined. We'll fall
109+
back to the other sibling or parent in the case that the preferred
110+
sibling target doesn't exist.
111+
</p>
112+
</TestCase.ExpectedResult>
113+
<Fixture>
114+
<Fixture.Controls>
115+
<Controls
116+
alignToTop={alignToTop}
117+
setAlignToTop={setAlignToTop}
118+
scrollVertical={scrollVertical}
119+
exampleType={exampleType}
120+
setExampleType={setExampleType}
121+
/>
122+
</Fixture.Controls>
123+
{exampleType === 'simple' && (
124+
<Fragment ref={fragmentRef}>
125+
<ScrollIntoViewCaseSimple />
126+
</Fragment>
127+
)}
128+
{exampleType === 'horizontal' && (
129+
<div
130+
style={{
131+
display: 'flex',
132+
overflowX: 'auto',
133+
flexDirection: 'row',
134+
border: '1px solid #ccc',
135+
padding: '1rem 10rem',
136+
marginBottom: '1rem',
137+
width: '100%',
138+
whiteSpace: 'nowrap',
139+
justifyContent: 'space-between',
140+
}}>
141+
<Fragment ref={fragmentRef}>
142+
<ScrollIntoViewCaseSimple />
143+
</Fragment>
144+
</div>
145+
)}
146+
{exampleType === 'multiple' && (
147+
<Fragment>
148+
<div
149+
style={{
150+
height: '50vh',
151+
overflowY: 'auto',
152+
border: '1px solid black',
153+
marginBottom: '1rem',
154+
}}
155+
ref={scrollContainerRef}
156+
/>
157+
<Fragment ref={fragmentRef}>
158+
<ScrollIntoViewCaseComplex
159+
caseInViewport={caseInViewport}
160+
scrollContainerRef={scrollContainerRef}
161+
/>
162+
</Fragment>
163+
</Fragment>
164+
)}
165+
{exampleType === 'empty' && (
166+
<Fragment>
167+
<ScrollIntoViewTargetElement
168+
color="lightyellow"
169+
id="ABOVE EMPTY FRAGMENT"
170+
/>
171+
<Fragment ref={fragmentRef}></Fragment>
172+
<ScrollIntoViewTargetElement
173+
color="lightblue"
174+
id="BELOW EMPTY FRAGMENT"
175+
/>
176+
</Fragment>
177+
)}
178+
<Fixture.Controls>
179+
<Controls
180+
alignToTop={alignToTop}
181+
setAlignToTop={setAlignToTop}
182+
scrollVertical={scrollVertical}
183+
exampleType={exampleType}
184+
setExampleType={setExampleType}
185+
/>
186+
</Fixture.Controls>
187+
</Fixture>
188+
</TestCase>
189+
</Fragment>
190+
);
191+
}
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" top={true} 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)