Skip to content

Commit

Permalink
Merge pull request #17827 from shaodahong/feat-anchor-targetOffset
Browse files Browse the repository at this point in the history
feat(Anchor): add targetOffset prop
  • Loading branch information
afc163 committed Aug 17, 2019
2 parents 4e26107 + 4968052 commit 92b36c0
Show file tree
Hide file tree
Showing 16 changed files with 382 additions and 108 deletions.
13 changes: 13 additions & 0 deletions components/_util/__tests__/easings.test.js
@@ -0,0 +1,13 @@
import { easeInOutCubic } from '../easings';

describe('Test easings', () => {
it('easeInOutCubic return value', () => {
const nums = [];
// eslint-disable-next-line no-plusplus
for (let index = 0; index < 5; index++) {
nums.push(easeInOutCubic(index, 1, 5, 4));
}

expect(nums).toEqual([1, 1.25, 3, 4.75, 5]);
});
});
56 changes: 56 additions & 0 deletions components/_util/__tests__/scrollTo.test.js
@@ -0,0 +1,56 @@
import scrollTo from '../scrollTo';

describe('Test ScrollTo function', () => {
let dateNowMock;

beforeAll(() => {
jest.useFakeTimers();
});

afterAll(() => {
jest.useRealTimers();
});

beforeEach(() => {
dateNowMock = jest
.spyOn(Date, 'now')
.mockImplementationOnce(() => 0)
.mockImplementationOnce(() => 1000);
});

afterEach(() => {
dateNowMock.mockRestore();
});

it('test scrollTo', async () => {
const scrollToSpy = jest.spyOn(window, 'scrollTo').mockImplementation((x, y) => {
window.scrollY = y;
window.pageYOffset = y;
});

scrollTo(1000);

jest.runAllTimers();
expect(window.pageYOffset).toBe(1000);

scrollToSpy.mockRestore();
});

it('test callback - option', async () => {
const cbMock = jest.fn();
scrollTo(1000, {
callback: cbMock,
});
jest.runAllTimers();
expect(cbMock).toHaveBeenCalledTimes(1);
});

it('test getContainer - option', async () => {
const div = document.createElement('div');
scrollTo(1000, {
getContainer: () => div,
});
jest.runAllTimers();
expect(div.scrollTop).toBe(1000);
});
});
9 changes: 9 additions & 0 deletions components/_util/easings.ts
@@ -0,0 +1,9 @@
// eslint-disable-next-line import/prefer-default-export
export function easeInOutCubic(t: number, b: number, c: number, d: number) {
const cc = c - b;
t /= d / 2;
if (t < 1) {
return (cc / 2) * t * t * t + b;
}
return (cc / 2) * ((t -= 2) * t * t + 2) + b;
}
37 changes: 37 additions & 0 deletions components/_util/scrollTo.ts
@@ -0,0 +1,37 @@
import raf from 'raf';
import getScroll from './getScroll';
import { easeInOutCubic } from './easings';

interface ScrollToOptions {
/** Scroll container, default as window */
getContainer?: () => HTMLElement | Window;
/** Scroll end callback */
callback?: () => any;
/** Animation duration, default as 450 */
duration?: number;
}

export default function scrollTo(y: number, options: ScrollToOptions = {}) {
const { getContainer = () => window, callback, duration = 450 } = options;

const container = getContainer();
const scrollTop = getScroll(container, true);
const startTime = Date.now();

const frameFunc = () => {
const timestamp = Date.now();
const time = timestamp - startTime;
const nextScrollTop = easeInOutCubic(time > duration ? duration : time, scrollTop, y, duration);
if (container === window) {
window.scrollTo(window.pageXOffset, nextScrollTop);
} else {
(container as HTMLElement).scrollTop = nextScrollTop;
}
if (time < duration) {
raf(frameFunc);
} else if (typeof callback === 'function') {
callback();
}
};
raf(frameFunc);
}
93 changes: 36 additions & 57 deletions components/anchor/Anchor.tsx
Expand Up @@ -3,10 +3,10 @@ import * as ReactDOM from 'react-dom';
import * as PropTypes from 'prop-types';
import classNames from 'classnames';
import addEventListener from 'rc-util/lib/Dom/addEventListener';
import raf from 'raf';
import Affix from '../affix';
import AnchorLink from './AnchorLink';
import { ConfigConsumer, ConfigConsumerProps } from '../config-provider';
import scrollTo from '../_util/scrollTo';
import getScroll from '../_util/getScroll';

function getDefaultContainer() {
Expand Down Expand Up @@ -35,52 +35,7 @@ function getOffsetTop(element: HTMLElement, container: AnchorContainer): number
return rect.top;
}

function easeInOutCubic(t: number, b: number, c: number, d: number) {
const cc = c - b;
t /= d / 2;
if (t < 1) {
return (cc / 2) * t * t * t + b;
}
return (cc / 2) * ((t -= 2) * t * t + 2) + b;
}

const sharpMatcherRegx = /#([^#]+)$/;
function scrollTo(
href: string,
offsetTop = 0,
getContainer: () => AnchorContainer,
callback = () => {},
) {
const container = getContainer();
const scrollTop = getScroll(container, true);
const sharpLinkMatch = sharpMatcherRegx.exec(href);
if (!sharpLinkMatch) {
return;
}
const targetElement = document.getElementById(sharpLinkMatch[1]);
if (!targetElement) {
return;
}
const eleOffsetTop = getOffsetTop(targetElement, container);
const targetScrollTop = scrollTop + eleOffsetTop - offsetTop;
const startTime = Date.now();
const frameFunc = () => {
const timestamp = Date.now();
const time = timestamp - startTime;
const nextScrollTop = easeInOutCubic(time, scrollTop, targetScrollTop, 450);
if (container === window) {
window.scrollTo(window.pageXOffset, nextScrollTop);
} else {
(container as HTMLElement).scrollTop = nextScrollTop;
}
if (time < 450) {
raf(frameFunc);
} else {
callback();
}
};
raf(frameFunc);
}

type Section = {
link: string;
Expand All @@ -105,6 +60,8 @@ export interface AnchorProps {
e: React.MouseEvent<HTMLElement>,
link: { title: React.ReactNode; href: string },
) => void;
/** Scroll to target offset value, if none, it's offsetTop prop value or 0. */
targetOffset?: number;
}

export interface AnchorState {
Expand Down Expand Up @@ -245,6 +202,34 @@ export default class Anchor extends React.Component<AnchorProps, AnchorState> {
return '';
}

handleScrollTo = (link: string) => {
const { offsetTop, getContainer, targetOffset } = this.props as AnchorDefaultProps;

this.setState({ activeLink: link });
const container = getContainer();
const scrollTop = getScroll(container, true);
const sharpLinkMatch = sharpMatcherRegx.exec(link);
if (!sharpLinkMatch) {
return;
}
const targetElement = document.getElementById(sharpLinkMatch[1]);
if (!targetElement) {
return;
}

const eleOffsetTop = getOffsetTop(targetElement, container);
let y = scrollTop + eleOffsetTop;
y -= targetOffset !== undefined ? targetOffset : offsetTop || 0;
this.animating = true;

scrollTo(y, {
callback: () => {
this.animating = false;
},
getContainer,
});
};

saveInkNode = (node: HTMLSpanElement) => {
this.inkNode = node;
};
Expand All @@ -254,24 +239,18 @@ export default class Anchor extends React.Component<AnchorProps, AnchorState> {
return;
}
const { activeLink } = this.state;
const { offsetTop, bounds } = this.props;
const currentActiveLink = this.getCurrentAnchor(offsetTop, bounds);
const { offsetTop, bounds, targetOffset } = this.props;
const currentActiveLink = this.getCurrentAnchor(
targetOffset !== undefined ? targetOffset : offsetTop || 0,
bounds,
);
if (activeLink !== currentActiveLink) {
this.setState({
activeLink: currentActiveLink,
});
}
};

handleScrollTo = (link: string) => {
const { offsetTop, getContainer } = this.props as AnchorDefaultProps;
this.animating = true;
this.setState({ activeLink: link });
scrollTo(link, offsetTop, getContainer, () => {
this.animating = false;
});
};

updateInk = () => {
if (typeof document === 'undefined') {
return;
Expand Down
76 changes: 70 additions & 6 deletions components/anchor/__tests__/Anchor.test.js
@@ -1,12 +1,29 @@
import React from 'react';
import { mount } from 'enzyme';
import Anchor from '..';
import { spyElementPrototypes } from '../../__tests__/util/domHook';
import { sleep } from '../../../tests/utils';

const { Link } = Anchor;

const delay = timeout => new Promise(resolve => setTimeout(resolve, timeout));

describe('Anchor Render', () => {
const getBoundingClientRectMock = jest.fn(() => ({
width: 100,
height: 100,
top: 1000,
}));
const getClientRectsMock = jest.fn(() => ({
length: 1,
}));
const headingSpy = spyElementPrototypes(HTMLHeadingElement, {
getBoundingClientRect: getBoundingClientRectMock,
getClientRects: getClientRectsMock,
});

afterAll(() => {
headingSpy.mockRestore();
});

it('Anchor render perfectly', () => {
const wrapper = mount(
<Anchor>
Expand Down Expand Up @@ -64,7 +81,7 @@ describe('Anchor Render', () => {
wrapper.instance().handleScrollTo('##API');
expect(wrapper.instance().state.activeLink).toBe('##API');
expect(scrollToSpy).not.toHaveBeenCalled();
await delay(1000);
await sleep(1000);
expect(scrollToSpy).toHaveBeenCalled();
});

Expand Down Expand Up @@ -154,7 +171,7 @@ describe('Anchor Render', () => {
</Anchor>,
);
const removeListenerSpy = jest.spyOn(wrapper.instance().scrollEvent, 'remove');
await delay(1000);
await sleep(1000);
wrapper.setProps({ getContainer: getContainerB });
expect(removeListenerSpy).not.toHaveBeenCalled();
});
Expand Down Expand Up @@ -187,7 +204,7 @@ describe('Anchor Render', () => {
);
const removeListenerSpy = jest.spyOn(wrapper.instance().scrollEvent, 'remove');
expect(removeListenerSpy).not.toHaveBeenCalled();
await delay(1000);
await sleep(1000);
wrapper.setProps({ getContainer: getContainerB });
expect(removeListenerSpy).toHaveBeenCalled();
});
Expand Down Expand Up @@ -239,7 +256,7 @@ describe('Anchor Render', () => {
);
const removeListenerSpy = jest.spyOn(wrapper.instance().scrollEvent, 'remove');
expect(removeListenerSpy).not.toHaveBeenCalled();
await delay(1000);
await sleep(1000);
holdContainer.container = document.getElementById('API2');
wrapper.setProps({ 'data-only-trigger-re-render': true });
expect(removeListenerSpy).toHaveBeenCalled();
Expand All @@ -255,4 +272,51 @@ describe('Anchor Render', () => {
);
expect(wrapper.instance().state.activeLink).toBe('#API2');
});

it('Anchor targetOffset prop', async () => {
jest.useFakeTimers();

let dateNowMock;

function dataNowMockFn() {
return jest
.spyOn(Date, 'now')
.mockImplementationOnce(() => 0)
.mockImplementationOnce(() => 1000);
}

dateNowMock = dataNowMockFn();

const scrollToSpy = jest.spyOn(window, 'scrollTo');
let root = document.getElementById('root');
if (!root) {
root = document.createElement('div', { id: 'root' });
root.id = 'root';
document.body.appendChild(root);
}
mount(<h1 id="API">Hello</h1>, { attachTo: root });
const wrapper = mount(
<Anchor>
<Link href="#API" title="API" />
</Anchor>,
);
wrapper.instance().handleScrollTo('#API');
jest.runAllTimers();
expect(scrollToSpy).toHaveBeenLastCalledWith(0, 1000);
dateNowMock = dataNowMockFn();

wrapper.setProps({ offsetTop: 100 });
wrapper.instance().handleScrollTo('#API');
jest.runAllTimers();
expect(scrollToSpy).toHaveBeenLastCalledWith(0, 900);
dateNowMock = dataNowMockFn();

wrapper.setProps({ targetOffset: 200 });
wrapper.instance().handleScrollTo('#API');
jest.runAllTimers();
expect(scrollToSpy).toHaveBeenLastCalledWith(0, 800);

dateNowMock.mockRestore();
jest.useRealTimers();
});
});

0 comments on commit 92b36c0

Please sign in to comment.