Skip to content
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

feat(Anchor): add targetOffset prop #17827

Merged
merged 30 commits into from Aug 17, 2019
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
9803eba
feat(Anchor): add targetOffset prop
shaodahong Jul 23, 2019
b02e433
docs(Anchor): add targetOffset doc
shaodahong Jul 23, 2019
b3537d5
docs(Anchor): fix targetOffset prop doc of review change
shaodahong Jul 23, 2019
a103d21
feat(Anchor): add targetOffset prop test case
shaodahong Jul 24, 2019
5e48353
feat(Anchor): add targetOffset prop demo
shaodahong Jul 24, 2019
90a65d1
feat(Anchor): fix add targetOffset prop deme test fail
shaodahong Jul 24, 2019
ccef308
perf: optimization BackTop and Anchor components scrollTo func
shaodahong Jul 24, 2019
06abd95
fix: some bad writing
shaodahong Jul 24, 2019
fcd39e5
fix: modify naming
shaodahong Jul 25, 2019
b6c5db8
fix: modify naming
shaodahong Jul 25, 2019
5f153c1
fix: remove Easings easeOutCubic func
shaodahong Jul 25, 2019
32b09fc
feat(Anchor): perf easings export
shaodahong Jul 30, 2019
a266f45
feat(Anchor): remove scrollTo option
shaodahong Jul 30, 2019
d45dc1b
feat(Anchor): fix test fail
shaodahong Jul 30, 2019
b4a228c
perf: replace all test suite delay func to common test util sleep func
shaodahong Jul 31, 2019
554d8cb
fix: review change
shaodahong Jul 31, 2019
32621cf
feat(Anchor): fix getCurrentAnchor offset
shaodahong Aug 4, 2019
40c53c8
feat(Anchor): change version
shaodahong Aug 5, 2019
3ab3b01
test: use jest.useFakeTimers replace sleep
shaodahong Aug 6, 2019
a796f87
test: use jest.useFakeTimers replace sleep
shaodahong Aug 6, 2019
3b692bc
test: fix test fail
shaodahong Aug 6, 2019
ebdbd0e
test: fix test fail
shaodahong Aug 6, 2019
b1cd8e3
test: add easings.ts test suite
shaodahong Aug 7, 2019
b277a96
fix: eslint fail
shaodahong Aug 7, 2019
83022a4
test: try mock Date.now to reduce test time
shaodahong Aug 8, 2019
ce5375f
test: remove unused var
shaodahong Aug 14, 2019
6729ce3
test: fix lint
shaodahong Aug 14, 2019
9a3feda
test: reduce test time
shaodahong Aug 14, 2019
2416d17
test: reduce test time
shaodahong Aug 14, 2019
4968052
test: fix lint fail
shaodahong Aug 16, 2019
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
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. */
shaodahong marked this conversation as resolved.
Show resolved Hide resolved
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();
});
});