Skip to content

Add scrollIntoView to fragment instances #32814

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

Open
wants to merge 9 commits into
base: main
Choose a base branch
from
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
import TestCase from '../../TestCase';
import Fixture from '../../Fixture';

const React = window.React;
const {Fragment, useRef, useState} = React;

function WrapperComponent(props) {
return props.children;
}

const initialState = {
child: false,
parent: false,
grandparent: false,
};

export default function EventListenerCase() {
const fragmentRef = useRef(null);
const [clickedState, setClickedState] = useState({...initialState});
const [fragmentEventFired, setFragmentEventFired] = useState(false);
const [bubblesState, setBubblesState] = useState(true);

function setClick(id) {
setClickedState(prev => ({...prev, [id]: true}));
}

function fragmentClickHandler(e) {
setFragmentEventFired(true);
}

return (
<TestCase title="Event Dispatch">
<TestCase.Steps>
<li>
Each box has regular click handlers, you can click each one to observe
the status changing through standard bubbling.
</li>
<li>Clear the clicked state</li>
<li>
Click the "Dispatch click event" button to dispatch a click event on
the Fragment. The event will be dispatched on the Fragment's parent,
so the child will not change state.
</li>
<li>
Click the "Add event listener" button to add a click event listener on
the Fragment. This registers a handler that will turn the child blue
on click.
</li>
<li>
Now click the "Dispatch click event" button again. You can see that it
will fire the Fragment's event handler in addition to bubbling the
click from the parent.
</li>
<li>
If you turn off bubbling, only the Fragment's event handler will be
called.
</li>
</TestCase.Steps>

<TestCase.ExpectedResult>
<p>
Dispatching an event on a Fragment will forward the dispatch to its
parent for the standard case. You can observe when dispatching that
the parent handler is called in additional to bubbling from there. A
delay is added to make the bubbling more clear.{' '}
</p>
<p>
When there have been event handlers added to the Fragment, the
Fragment's event handler will be called in addition to bubbling from
the parent. Without bubbling, only the Fragment's event handler will
be called.
</p>
</TestCase.ExpectedResult>

<Fixture>
<Fixture.Controls>
<select
value={bubblesState ? 'true' : 'false'}
onChange={e => {
setBubblesState(e.target.value === 'true');
}}>
<option value="true">Bubbles: true</option>
<option value="false">Bubbles: false</option>
</select>
<button
onClick={() => {
fragmentRef.current.dispatchEvent(
new MouseEvent('click', {bubbles: bubblesState})
);
}}>
Dispatch click event
</button>
<button
onClick={() => {
setClickedState({...initialState});
setFragmentEventFired(false);
}}>
Reset clicked state
</button>
<button
onClick={() => {
fragmentRef.current.addEventListener(
'click',
fragmentClickHandler
);
}}>
Add event listener
</button>
<button
onClick={() => {
fragmentRef.current.removeEventListener(
'click',
fragmentClickHandler
);
}}>
Remove event listener
</button>
</Fixture.Controls>
<div
id="grandparent"
onClick={e => {
setTimeout(() => {
setClick('grandparent');
}, 200);
}}
className="card">
Fragment grandparent - clicked:{' '}
{clickedState.grandparent ? 'true' : 'false'}
<div
id="parent"
onClick={e => {
setTimeout(() => {
setClick('parent');
}, 100);
}}
className="card">
Fragment parent - clicked: {clickedState.parent ? 'true' : 'false'}
<Fragment ref={fragmentRef}>
<div
style={{
backgroundColor: fragmentEventFired ? 'lightblue' : 'inherit',
}}
id="child"
className="card"
onClick={e => {
setClick('child');
}}>
Fragment child - clicked:{' '}
{clickedState.child ? 'true' : 'false'}
</div>
</Fragment>
</div>
</div>
</Fixture>
</TestCase>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import Fixture from '../../Fixture';

const React = window.React;

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

export default function FocusCase() {
const fragmentRef = useRef(null);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import TestCase from '../../TestCase';
import Fixture from '../../Fixture';

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

export default function GetClientRectsCase() {
const fragmentRef = useRef(null);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
import TestCase from '../../TestCase';
import Fixture from '../../Fixture';

const React = window.React;
const {Fragment, useRef, useState, useEffect} = React;
const ReactDOM = window.ReactDOM;

function Controls({
alignToTop,
setAlignToTop,
scrollVertical,
scrollVerticalNoChildren,
}) {
return (
<div>
<label>
Align to Top:
<input
type="checkbox"
checked={alignToTop}
onChange={e => setAlignToTop(e.target.checked)}
/>
</label>
<div>
<button onClick={scrollVertical}>scrollIntoView() - Vertical</button>
<button onClick={scrollVerticalNoChildren}>
scrollIntoView() - Vertical, No children
</button>
</div>
</div>
);
}

function TargetElement({color, top, id}) {
return (
<div
id={id}
style={{
height: 500,
backgroundColor: color,
marginTop: top ? '50vh' : 0,
marginBottom: 100,
flexShrink: 0,
}}>
{id}
</div>
);
}

export default function ScrollIntoViewCase() {
const [alignToTop, setAlignToTop] = useState(true);
const [displayFixedElements, setDisplayFixedElements] = useState(false);
const [didMount, setDidMount] = useState(false);
const verticalRef = useRef(null);
const noChildRef = useRef(null);
const testCaseRef = useRef(null);
const scrollContainerRef = useRef(null);

const scrollVertical = () => {
verticalRef.current.scrollIntoView(alignToTop);
};

const scrollVerticalNoChildren = () => {
noChildRef.current.scrollIntoView(alignToTop);
};

// Hack to portal child into the scroll container
// after the first render. This is to simulate a case where
// an item is portaled into another scroll container.
useEffect(() => {
if (!didMount) {
setDidMount(true);
}
}, []);

useEffect(() => {
const observer = new IntersectionObserver(entries => {
entries.forEach(entry => {
if (entry.isIntersecting) {
setDisplayFixedElements(true);
} else {
setDisplayFixedElements(false);
}
});
});
testCaseRef.current.observeUsing(observer);

const lastRef = testCaseRef.current;
return () => {
lastRef.unobserveUsing(observer);
observer.disconnect();
};
});

return (
<Fragment ref={testCaseRef}>
<TestCase title="ScrollIntoView">
<TestCase.Steps>
<li>Toggle alignToTop and click the buttons to scroll</li>
</TestCase.Steps>
<TestCase.ExpectedResult>
<p>When the Fragment has children:</p>
<p>
The simple path is that all children are in the same scroll
container. If alignToTop=true|undefined, we will select the first
Fragment host child to call scrollIntoView on. Otherwise we'll call
on the last host child.
</p>
<p>
In the case of fixed elements and inserted elements or portals
causing fragment siblings to be in different scroll containers, we
split up the host children into groups of scroll containers. If we
hit a fixed element, we'll always attempt to scroll on the first or
last element of the next group.
</p>
<p>When the Fragment does not have children:</p>
<p>
The Fragment still represents a virtual space. We can scroll to the
nearest edge by selecting the host sibling before if
alignToTop=false, or after if alignToTop=true|undefined. We'll fall
back to the other sibling or parent in the case that the preferred
sibling target doesn't exist.
</p>
</TestCase.ExpectedResult>
<Fixture>
<Fixture.Controls>
<Controls
alignToTop={alignToTop}
setAlignToTop={setAlignToTop}
scrollVertical={scrollVertical}
scrollVerticalNoChildren={scrollVerticalNoChildren}
/>
</Fixture.Controls>
<div
style={{
height: '50vh',
overflowY: 'auto',
border: '1px solid black',
marginBottom: '1rem',
}}
ref={scrollContainerRef}>
<TargetElement color="lightyellow" id="SCROLLABLE-1" />
<TargetElement color="lightpink" id="SCROLLABLE-2" />
<TargetElement color="lightcyan" id="SCROLLABLE-3" />
</div>
<Fragment ref={verticalRef}>
{displayFixedElements && (
<div
style={{position: 'fixed', top: 0, backgroundColor: 'red'}}
id="header">
Fixed header
</div>
)}
{didMount &&
ReactDOM.createPortal(
<TargetElement color="red" id="SCROLLABLE-4" />,
scrollContainerRef.current
)}
<TargetElement color="lightgreen" top={true} id="A" />
<Fragment ref={noChildRef}></Fragment>
<TargetElement color="lightcoral" id="B" />
<TargetElement color="lightblue" id="C" />
{displayFixedElements && (
<div
style={{
position: 'fixed',
bottom: 0,
backgroundColor: 'purple',
}}
id="footer">
Fixed footer
</div>
)}
</Fragment>
<Fixture.Controls>
<Controls
alignToTop={alignToTop}
setAlignToTop={setAlignToTop}
scrollVertical={scrollVertical}
scrollVerticalNoChildren={scrollVerticalNoChildren}
/>
</Fixture.Controls>
</Fixture>
</TestCase>
</Fragment>
);
}
4 changes: 4 additions & 0 deletions fixtures/dom/src/components/fixtures/fragment-refs/index.js
Original file line number Diff line number Diff line change
@@ -1,20 +1,24 @@
import FixtureSet from '../../FixtureSet';
import EventListenerCase from './EventListenerCase';
import EventDispatchCase from './EventDispatchCase';
import IntersectionObserverCase from './IntersectionObserverCase';
import ResizeObserverCase from './ResizeObserverCase';
import FocusCase from './FocusCase';
import GetClientRectsCase from './GetClientRectsCase';
import ScrollIntoViewCase from './ScrollIntoViewCase';

const React = window.React;

export default function FragmentRefsPage() {
return (
<FixtureSet title="Fragment Refs">
<EventListenerCase />
<EventDispatchCase />
<IntersectionObserverCase />
<ResizeObserverCase />
<FocusCase />
<GetClientRectsCase />
<ScrollIntoViewCase />
</FixtureSet>
);
}
Loading