diff --git a/components/_util/__tests__/easings.test.js b/components/_util/__tests__/easings.test.js new file mode 100644 index 000000000000..d32e0dd7f053 --- /dev/null +++ b/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]); + }); +}); diff --git a/components/_util/__tests__/scrollTo.test.js b/components/_util/__tests__/scrollTo.test.js new file mode 100644 index 000000000000..3e41a60ee9fd --- /dev/null +++ b/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); + }); +}); diff --git a/components/_util/easings.ts b/components/_util/easings.ts new file mode 100644 index 000000000000..45f574a49fbc --- /dev/null +++ b/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; +} diff --git a/components/_util/scrollTo.ts b/components/_util/scrollTo.ts new file mode 100644 index 000000000000..8f4115cba5b5 --- /dev/null +++ b/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); +} diff --git a/components/anchor/Anchor.tsx b/components/anchor/Anchor.tsx index 750da9e377b2..df77875265da 100644 --- a/components/anchor/Anchor.tsx +++ b/components/anchor/Anchor.tsx @@ -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() { @@ -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; @@ -105,6 +60,8 @@ export interface AnchorProps { e: React.MouseEvent, 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 { @@ -245,6 +202,34 @@ export default class Anchor extends React.Component { 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; }; @@ -254,8 +239,11 @@ export default class Anchor extends React.Component { 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, @@ -263,15 +251,6 @@ export default class Anchor extends React.Component { } }; - 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; diff --git a/components/anchor/__tests__/Anchor.test.js b/components/anchor/__tests__/Anchor.test.js index 332d4ab76439..3ddd087dbbec 100644 --- a/components/anchor/__tests__/Anchor.test.js +++ b/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( @@ -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(); }); @@ -154,7 +171,7 @@ describe('Anchor Render', () => { , ); const removeListenerSpy = jest.spyOn(wrapper.instance().scrollEvent, 'remove'); - await delay(1000); + await sleep(1000); wrapper.setProps({ getContainer: getContainerB }); expect(removeListenerSpy).not.toHaveBeenCalled(); }); @@ -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(); }); @@ -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(); @@ -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(

Hello

, { attachTo: root }); + const wrapper = mount( + + + , + ); + 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(); + }); }); diff --git a/components/anchor/__tests__/__snapshots__/demo.test.js.snap b/components/anchor/__tests__/__snapshots__/demo.test.js.snap index ba9e60649be0..4f5e30d5aedb 100644 --- a/components/anchor/__tests__/__snapshots__/demo.test.js.snap +++ b/components/anchor/__tests__/__snapshots__/demo.test.js.snap @@ -301,3 +301,83 @@ exports[`renders ./components/anchor/demo/static.md correctly 1`] = ` `; + +exports[`renders ./components/anchor/demo/targetOffset.md correctly 1`] = ` +
+ +
+`; diff --git a/components/anchor/demo/targetOffset.md b/components/anchor/demo/targetOffset.md new file mode 100644 index 000000000000..5740803ee490 --- /dev/null +++ b/components/anchor/demo/targetOffset.md @@ -0,0 +1,47 @@ +--- +order: 4 +title: + zh-CN: 设置锚点滚动偏移量 + en-US: Set Anchor scroll offset +--- + +## zh-CN + +锚点目标滚动到屏幕正中间。 + +## en-US + +Anchor target scroll to screen center. + +```jsx +import { Anchor } from 'antd'; + +const { Link } = Anchor; + +class AnchorExample extends React.Component { + state = { + targetOffset: undefined, + }; + + componentDidMount() { + this.setState({ + targetOffset: window.innerHeight / 2, + }); + } + + render() { + return ( + + + + + + + + + ); + } +} + +ReactDOM.render(, mountNode); +``` diff --git a/components/anchor/index.en-US.md b/components/anchor/index.en-US.md index af6b8721521b..7e6886b61f78 100644 --- a/components/anchor/index.en-US.md +++ b/components/anchor/index.en-US.md @@ -25,6 +25,7 @@ For displaying anchor hyperlinks on page and jumping between them. | showInkInFixed | Whether show ink-balls in Fixed mode | boolean | false | | | onClick | set the handler to handle `click` event | Function(e: Event, link: Object) | - | 3.9.0 | | getCurrentAnchor | Customize the anchor highlight | () => string | - | 3.21.0 | +| targetOffset | Anchor scroll offset, default as `offsetTop`, [example](#components-anchor-demo-targetOffset) | number | `offsetTop` | 3.22.0 | ### Link Props diff --git a/components/anchor/index.zh-CN.md b/components/anchor/index.zh-CN.md index abef2ff7c27a..87695d461418 100644 --- a/components/anchor/index.zh-CN.md +++ b/components/anchor/index.zh-CN.md @@ -26,6 +26,7 @@ title: Anchor | showInkInFixed | 固定模式是否显示小圆点 | boolean | false | | | onClick | `click` 事件的 handler | Function(e: Event, link: Object) | - | 3.9.0 | | getCurrentAnchor | 自定义高亮的锚点 | () => string | - | 3.21.0 | +| targetOffset | 锚点滚动偏移量,默认与 offsetTop 相同,[例子](#components-anchor-demo-targetOffset) | number | `offsetTop` | 3.22.0 | ### Link Props diff --git a/components/back-top/__tests__/index.test.js b/components/back-top/__tests__/index.test.js index 62c8a6a66f84..5dfe3b8bf762 100644 --- a/components/back-top/__tests__/index.test.js +++ b/components/back-top/__tests__/index.test.js @@ -1,16 +1,22 @@ import React from 'react'; import { mount } from 'enzyme'; +import { sleep } from '../../../tests/utils'; import BackTop from '..'; describe('BackTop', () => { it('should scroll to top after click it', async () => { const wrapper = mount(); - document.documentElement.scrollTop = 400; + const scrollToSpy = jest.spyOn(window, 'scrollTo').mockImplementation((x, y) => { + window.scrollY = y; + window.pageYOffset = y; + }); + window.scrollTo(0, 400); // trigger scroll manually wrapper.instance().handleScroll(); - await new Promise(resolve => setTimeout(resolve, 0)); + await sleep(); wrapper.find('.ant-back-top').simulate('click'); - await new Promise(resolve => setTimeout(resolve, 1000)); - expect(Math.abs(Math.round(document.documentElement.scrollTop))).toBe(0); + await sleep(500); + expect(window.pageYOffset).toBe(0); + scrollToSpy.mockRestore(); }); }); diff --git a/components/back-top/index.tsx b/components/back-top/index.tsx index f994cc573c4c..2c22d0d3ccc5 100755 --- a/components/back-top/index.tsx +++ b/components/back-top/index.tsx @@ -3,20 +3,9 @@ import Animate from 'rc-animate'; import addEventListener from 'rc-util/lib/Dom/addEventListener'; import classNames from 'classnames'; import omit from 'omit.js'; -import raf from 'raf'; import { ConfigConsumer, ConfigConsumerProps } from '../config-provider'; import getScroll from '../_util/getScroll'; - -const 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; -}; - -function noop() {} +import scrollTo from '../_util/scrollTo'; function getDefaultTarget() { return window; @@ -79,20 +68,13 @@ export default class BackTop extends React.Component { }; scrollToTop = (e: React.MouseEvent) => { - const scrollTop = this.getCurrentScrollTop(); - const startTime = Date.now(); - const frameFunc = () => { - const timestamp = Date.now(); - const time = timestamp - startTime; - this.setScrollTop(easeInOutCubic(time, scrollTop, 0, 450)); - if (time < 450) { - raf(frameFunc); - } else { - this.setScrollTop(0); - } - }; - raf(frameFunc); - (this.props.onClick || noop)(e); + const { target = getDefaultTarget } = this.props; + scrollTo(0, { + getContainer: target, + }); + if (typeof this.props.onClick === 'function') { + this.props.onClick(e); + } }; handleScroll = () => { diff --git a/components/statistic/__tests__/index.test.js b/components/statistic/__tests__/index.test.js index bfa20245976f..affb9003fc44 100644 --- a/components/statistic/__tests__/index.test.js +++ b/components/statistic/__tests__/index.test.js @@ -4,8 +4,7 @@ import moment from 'moment'; import { mount } from 'enzyme'; import Statistic from '..'; import { formatTimeStr } from '../utils'; - -const delay = timeout => new Promise(resolve => setTimeout(resolve, timeout)); +import { sleep } from '../../../tests/utils'; describe('Statistic', () => { beforeAll(() => { @@ -70,7 +69,7 @@ describe('Statistic', () => { const instance = wrapper.instance(); expect(instance.countdownId).not.toBe(undefined); - await delay(10); + await sleep(10); wrapper.unmount(); expect(instance.countdownId).toBe(undefined); diff --git a/components/tooltip/__tests__/tooltip.test.js b/components/tooltip/__tests__/tooltip.test.js index c5d5d7a25dec..8004ba805a6b 100644 --- a/components/tooltip/__tests__/tooltip.test.js +++ b/components/tooltip/__tests__/tooltip.test.js @@ -7,8 +7,7 @@ import Checkbox from '../../checkbox'; import DatePicker from '../../date-picker'; import Input from '../../input'; import Group from '../../input/Group'; - -const delay = timeout => new Promise(resolve => setTimeout(resolve, timeout)); +import { sleep } from '../../../tests/utils'; describe('Tooltip', () => { it('check `onVisibleChange` arguments', () => { @@ -205,12 +204,12 @@ describe('Tooltip', () => { expect(wrapper.find('span.ant-calendar-picker')).toHaveLength(1); const picker = wrapper.find('span.ant-calendar-picker').at(0); picker.simulate('mouseenter'); - await delay(100); + await sleep(100); expect(onVisibleChange).toHaveBeenCalledWith(true); expect(wrapper.instance().tooltip.props.visible).toBe(true); picker.simulate('mouseleave'); - await delay(100); + await sleep(100); expect(onVisibleChange).toHaveBeenCalledWith(false); expect(wrapper.instance().tooltip.props.visible).toBe(false); }); @@ -230,12 +229,12 @@ describe('Tooltip', () => { expect(wrapper.find('Group')).toHaveLength(1); const picker = wrapper.find('Group').at(0); picker.simulate('mouseenter'); - await delay(100); + await sleep(100); expect(onVisibleChange).toHaveBeenCalledWith(true); expect(wrapper.instance().tooltip.props.visible).toBe(true); picker.simulate('mouseleave'); - await delay(100); + await sleep(100); expect(onVisibleChange).toHaveBeenCalledWith(false); expect(wrapper.instance().tooltip.props.visible).toBe(false); }); diff --git a/components/upload/__tests__/uploadlist.test.js b/components/upload/__tests__/uploadlist.test.js index 6f42e20168ac..8690ba6d27c8 100644 --- a/components/upload/__tests__/uploadlist.test.js +++ b/components/upload/__tests__/uploadlist.test.js @@ -6,8 +6,7 @@ import Form from '../../form'; import { spyElementPrototypes } from '../../__tests__/util/domHook'; import { errorRequest, successRequest } from './requests'; import { setup, teardown } from './mock'; - -const delay = timeout => new Promise(resolve => setTimeout(resolve, timeout)); +import { sleep } from '../../../tests/utils'; const fileList = [ { @@ -124,7 +123,7 @@ describe('Upload List', () => { .at(0) .find('.anticon-close') .simulate('click'); - await delay(400); + await sleep(400); wrapper.update(); expect(wrapper.find('.ant-upload-list-item').hostNodes().length).toBe(1); }); @@ -245,7 +244,7 @@ describe('Upload List', () => { .at(1) .simulate('click'); expect(handleRemove).toHaveBeenCalledWith(fileList[1]); - await delay(0); + await sleep(); expect(handleChange.mock.calls.length).toBe(2); }); @@ -274,7 +273,7 @@ describe('Upload List', () => { , ); wrapper.setState({}); - await delay(0); + await sleep(); expect(wrapper.state().fileList[2].thumbUrl).not.toBe(undefined); expect(onDrawImage).toHaveBeenCalled(); @@ -525,7 +524,7 @@ describe('Upload List', () => { , ); wrapper.setState({}); - await delay(0); + await sleep(); expect(previewFile).toHaveBeenCalledWith(file.originFileObj); wrapper.update(); diff --git a/tests/utils.js b/tests/utils.js index 5422e931ed5f..423e01c0c572 100644 --- a/tests/utils.js +++ b/tests/utils.js @@ -8,3 +8,5 @@ export function setMockDate(dateString = '2017-09-18T03:30:07.795') { export function resetMockDate() { MockDate.reset(); } + +export const sleep = (timeout = 0) => new Promise(resolve => setTimeout(resolve, timeout));