Skip to content

Commit

Permalink
Merge pull request #4006 from ProjectMirador/scroll-to-fn
Browse files Browse the repository at this point in the history
Rewrite ScrollTo as a function.
  • Loading branch information
marlo-longley authored Nov 25, 2024
2 parents c1fa0cc + 9a4b93b commit 3257448
Show file tree
Hide file tree
Showing 2 changed files with 83 additions and 167 deletions.
110 changes: 45 additions & 65 deletions __tests__/src/components/ScrollTo.test.js
Original file line number Diff line number Diff line change
@@ -1,85 +1,65 @@
import { render } from 'test-utils';
import { render, screen } from 'test-utils';
import { createRef } from 'react';
import { ScrollTo } from '../../../src/components/ScrollTo';

describe('ScrollTo', () => {
let containerRef;
const originalGetBoundingClientRect = Element.prototype.getBoundingClientRect;
const originalScrollTo = Element.prototype.scrollTo;
const originalOffsetTop = Element.prototype.offsetTop;

const containerBoundingRect = { bottom: 500, height: 440, top: 0 };
let scrollTo;
beforeEach(() => {
scrollTo = jest.fn();
containerRef = createRef();
render(<div data-testid="container" ref={containerRef} />);
Element.prototype.getBoundingClientRect = function mockBoundingClientRect() {
if (this.dataset.mockboundingrect) {
return JSON.parse(this.dataset.mockboundingrect);
}

containerRef.current = {
getBoundingClientRect: () => containerBoundingRect,
getElementsByClassName: () => [{ scrollTo }],
return originalGetBoundingClientRect.call(this);
};
});

const scrollToElAboveBoundingRect = { bottom: -200, top: -300 };
const scrollToElBelowBoundingRect = { bottom: 601, top: 501 };
const visibleScrollToElBoundingRect = { bottom: 300, top: 200 };

describe('when updating the scrollTo prop', () => {
beforeEach(() => {
jest.spyOn(ScrollTo.prototype, 'elementToScrollTo').mockImplementation(() => ({ offsetTop: 450 }));
});
describe('when setting from true to false', () => {
it('does not scroll to the selected element', () => {
jest.spyOn(ScrollTo.prototype, 'scrollToBoundingRect').mockImplementation(() => ({
...scrollToElAboveBoundingRect,
}));

const { rerender } = render(<ScrollTo scrollTo containerRef={containerRef}><div>Child</div></ScrollTo>);

// It is called once when initially rendered w/ true
expect(scrollTo).toHaveBeenCalled();
scrollTo.mockReset();

rerender(<ScrollTo containerRef={containerRef}><div>Child</div></ScrollTo>);

// But it is not called on the re-render w/ false
expect(scrollTo).not.toHaveBeenCalled();
});
});

describe('when set from false to true', () => {
it('scrolls to the selected element when it is hidden above the container', () => {
jest.spyOn(ScrollTo.prototype, 'scrollToBoundingRect').mockImplementation(() => ({
...scrollToElAboveBoundingRect,
}));
const { rerender } = render(<ScrollTo containerRef={containerRef}><div>Child</div></ScrollTo>);

rerender(<ScrollTo scrollTo containerRef={containerRef}><div>Child</div></ScrollTo>);
Element.prototype.scrollTo = jest.fn();
});

expect(scrollTo).toHaveBeenCalledWith(0, 230);
});
afterEach(() => {
Element.prototype.getBoundingClientRect = originalGetBoundingClientRect;
Element.prototype.scrollTo = originalScrollTo;
Element.prototype.offsetTop = originalOffsetTop;
});

it('scrolls to the selected element when it is hidden below the container', () => {
jest.spyOn(ScrollTo.prototype, 'scrollToBoundingRect').mockImplementation(() => ({
...scrollToElBelowBoundingRect,
}));
it('renders the children', () => {
render(<ScrollTo><div data-testid="a" /></ScrollTo>);

const { rerender } = render(<ScrollTo containerRef={containerRef}><div>Child</div></ScrollTo>);
expect(screen.getByTestId('a')).toBeInTheDocument();
});

rerender(<ScrollTo scrollTo containerRef={containerRef}><div>Child</div></ScrollTo>);
it('scrolls to the element if it is off-screen ', () => {
const containerRef = createRef();

expect(scrollTo).toHaveBeenCalledWith(0, 230);
});
render(
<div data-testid="container" ref={containerRef} data-mockboundingrect={JSON.stringify({ bottom: 100, height: 100, top: 0 })}>
<div data-testid="scrollableContainer" style={{ height: 100, overflowY: true }} className="mirador-scrollto-scrollable">
<ScrollTo containerRef={containerRef}><div data-testid="a" style={{ height: 75 }} /></ScrollTo>
<ScrollTo containerRef={containerRef}><div data-testid="b" style={{ height: 75 }} /></ScrollTo>
<ScrollTo containerRef={containerRef} scrollTo><div data-testid="c" data-mockboundingrect={JSON.stringify({ bottom: 225, top: 150 })} style={{ height: 75 }} /></ScrollTo>
</div>
</div>,
);

it('does not scroll to the selected element when it is visible', () => {
jest.spyOn(ScrollTo.prototype, 'scrollToBoundingRect').mockImplementation(() => ({
...visibleScrollToElBoundingRect,
}));
expect(Element.prototype.scrollTo).toHaveBeenCalled();
});

const { rerender } = render(<ScrollTo containerRef={containerRef}><div>Child</div></ScrollTo>);
it('does nothing if the element is visible', () => {
const containerRef = createRef();

rerender(<ScrollTo scrollTo containerRef={containerRef}><div>Child</div></ScrollTo>);
render(
<div data-testid="container" ref={containerRef} data-mockboundingrect={JSON.stringify({ bottom: 100, height: 100, top: 0 })}>
<div data-testid="scrollableContainer" style={{ height: 100, overflowY: true }} className="mirador-scrollto-scrollable">
<ScrollTo containerRef={containerRef}><div data-testid="a" style={{ height: 75 }} /></ScrollTo>
<ScrollTo containerRef={containerRef}><div data-testid="b" style={{ height: 75 }} /></ScrollTo>
<ScrollTo containerRef={containerRef} scrollTo><div data-testid="c" data-mockboundingrect={JSON.stringify({ bottom: 100, top: 25 })} style={{ height: 75 }} /></ScrollTo>
</div>
</div>,
);

expect(scrollTo).not.toHaveBeenCalled();
});
});
expect(Element.prototype.scrollTo).not.toHaveBeenCalled();
});
});
140 changes: 38 additions & 102 deletions src/components/ScrollTo.js
Original file line number Diff line number Diff line change
@@ -1,117 +1,57 @@
import { cloneElement, createRef, Component } from 'react';
import { cloneElement, useEffect, useRef } from 'react';
import PropTypes from 'prop-types';
import isEmpty from 'lodash/isEmpty';
import ns from '../config/css-ns';

/** */
function usePrevious(value) {
const ref = useRef();
useEffect(() => {
ref.current = value;
}, [value]);
return ref.current;
}

/**
* ScrollTo ~
*/
export class ScrollTo extends Component {
/** */
constructor(props) {
super(props);

this.scrollToRef = createRef();
}

/** */
componentDidMount() {
const { scrollTo } = this.props;
if (!scrollTo) return;

this.scrollToElement();
}

/**
* If the scrollTo prop is true (and has been updated) scroll to the selected element
*/
componentDidUpdate(prevProps) {
const { scrollTo } = this.props;
if (scrollTo && (prevProps.scrollTo !== scrollTo)) {
this.scrollToElement();
}
}

/**
* Return the getBoundingClientRect() of the containerRef prop
*/
containerBoundingRect() {
const { containerRef } = this.props;
export function ScrollTo({
children, containerRef, offsetTop = 0, scrollTo, nodeId, ...otherProps
}) {
const scrollToRef = useRef();
const prevScrollTo = usePrevious(scrollTo);

if (!containerRef || !containerRef.current) return {};
useEffect(() => {
if (!scrollTo || scrollTo === prevScrollTo) return;

return containerRef.current.getBoundingClientRect();
}
const elementToScrollTo = scrollToRef?.current;
if (!elementToScrollTo) return;

/**
* Return the getBoundingClientRect() of the scrollTo ref prop
*/
scrollToBoundingRect() {
if (!this.elementToScrollTo()) return {};
return this.elementToScrollTo().getBoundingClientRect();
}
const scrollableContainer = containerRef?.current?.querySelector(`.${ns('scrollto-scrollable')}`);
if (!scrollableContainer) return;

/**
* Return the current scrollToRef
*/
elementToScrollTo() {
if (!this.scrollToRef || !this.scrollToRef.current) return null;
const containerBoundingRect = containerRef?.current?.getBoundingClientRect() || {};
const scrollToBoundingRect = elementToScrollTo?.getBoundingClientRect() || {};
const elementIsVisible = (() => {
if (scrollToBoundingRect.top < (containerBoundingRect.top + offsetTop)) {
return false;
} if (scrollToBoundingRect.bottom > containerBoundingRect.bottom) {
return false;
}

return this.scrollToRef.current;
}
return true;
})();

/**
* The container provided in the containersRef dom structure in which scrolling
* should happen.
*/
scrollableContainer() {
const { containerRef } = this.props;
if (elementIsVisible) return;

if (!containerRef || !containerRef.current) return null;
return containerRef.current.getElementsByClassName('mirador-scrollto-scrollable')[0];
}
const scrollBy = elementToScrollTo.offsetTop - (containerBoundingRect.height / 2) + offsetTop;

/**
* Determine if the scrollTo element is visible within the given containerRef prop.
* Currently only supports vertical elements but could be extended to support horizontal
*/
elementIsVisible() {
const { offsetTop } = this.props;
scrollableContainer.scrollTo(0, scrollBy);
}, [containerRef, scrollToRef, scrollTo, prevScrollTo, offsetTop]);

if (this.scrollToBoundingRect().top < (this.containerBoundingRect().top + offsetTop)) {
return false;
} if (this.scrollToBoundingRect().bottom > this.containerBoundingRect().bottom) {
return false;
}
if (!scrollTo && isEmpty(otherProps)) return children;

return true;
}

/**
* Scroll to the element if it is set to be scolled and is not visible
*/
scrollToElement() {
const { offsetTop, scrollTo } = this.props;
if (!scrollTo) return;
if (!this.elementToScrollTo()) return;
if (this.elementIsVisible()) return;
if (!this.scrollableContainer()) return;
const scrollBy = this.elementToScrollTo().offsetTop
- (this.containerBoundingRect().height / 2) + offsetTop;
this.scrollableContainer().scrollTo(0, scrollBy);
}

/**
* Returns the rendered component
*/
render() {
const {
children, containerRef, offsetTop, scrollTo, nodeId, ...otherProps
} = this.props;

if (!scrollTo && isEmpty(otherProps)) return children;

return cloneElement(children, { ref: this.scrollToRef, ...otherProps });
}
return cloneElement(children, { ref: scrollToRef, ...otherProps });
}

ScrollTo.propTypes = {
Expand All @@ -124,7 +64,3 @@ ScrollTo.propTypes = {
offsetTop: PropTypes.number,
scrollTo: PropTypes.bool.isRequired,
};

ScrollTo.defaultProps = {
offsetTop: 0,
};

0 comments on commit 3257448

Please sign in to comment.