From b883cc7ab2e647faf70872cacbceebf4d5dbdd32 Mon Sep 17 00:00:00 2001 From: tangjinzhou <415800467@qq.com> Date: Sat, 2 Jun 2018 18:11:50 +0800 Subject: [PATCH 1/2] feat: add anchor component --- components/anchor/Anchor.jsx | 251 +++++++++++++++++++++++ components/anchor/AnchorLink.jsx | 61 ++++++ components/anchor/__tests__/demo.test.js | 3 + components/anchor/index.en-US.md | 32 +++ components/anchor/index.jsx | 8 + components/anchor/index.zh-CN.md | 33 +++ components/anchor/style/index.js | 2 + components/anchor/style/index.less | 81 ++++++++ components/index.js | 5 +- components/style.js | 1 + 10 files changed, 476 insertions(+), 1 deletion(-) create mode 100644 components/anchor/Anchor.jsx create mode 100644 components/anchor/AnchorLink.jsx create mode 100644 components/anchor/__tests__/demo.test.js create mode 100644 components/anchor/index.en-US.md create mode 100644 components/anchor/index.jsx create mode 100644 components/anchor/index.zh-CN.md create mode 100644 components/anchor/style/index.js create mode 100644 components/anchor/style/index.less diff --git a/components/anchor/Anchor.jsx b/components/anchor/Anchor.jsx new file mode 100644 index 000000000..cbadc4be9 --- /dev/null +++ b/components/anchor/Anchor.jsx @@ -0,0 +1,251 @@ +import PropTypes from '../_util/vue-types' +import classNames from 'classnames' +import addEventListener from '../_util/Dom/addEventListener' +import Affix from '../affix' +import getScroll from '../_util/getScroll' +import getRequestAnimationFrame from '../_util/getRequestAnimationFrame' +import { initDefaultProps, getClass, getStyle } from '../_util/props-util' +import BaseMixin from '../_util/BaseMixin' + +function getDefaultContainer () { + return window +} + +function getOffsetTop (element, container) { + if (!element) { + return 0 + } + + if (!element.getClientRects().length) { + return 0 + } + + const rect = element.getBoundingClientRect() + + if (rect.width || rect.height) { + if (container === window) { + container = element.ownerDocument.documentElement + return rect.top - container.clientTop + } + return rect.top - container.getBoundingClientRect().top + } + + return rect.top +} + +function easeInOutCubic (t, b, c, d) { + 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 reqAnimFrame = getRequestAnimationFrame() +const sharpMatcherRegx = /#([^#]+)$/ +function scrollTo (href, offsetTop = 0, getContainer, 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.scrollTop = nextScrollTop + } + if (time < 450) { + reqAnimFrame(frameFunc) + } else { + callback() + } + } + reqAnimFrame(frameFunc) + history.pushState(null, '', href) +} + +export const AnchorProps = { + prefixCls: PropTypes.string, + offsetTop: PropTypes.number, + bounds: PropTypes.number, + affix: PropTypes.boolean, + showInkInFixed: PropTypes.boolean, + getContainer: PropTypes.func, +} + +export default { + name: 'AAnchor', + mixins: [BaseMixin], + inheritAttrs: false, + props: initDefaultProps(AnchorProps, { + prefixCls: 'ant-anchor', + affix: true, + showInkInFixed: false, + getContainer: getDefaultContainer, + }), + + data () { + this.links = [] + return { + activeLink: null, + } + }, + provide () { + return { + antAnchor: { + registerLink: (link) => { + if (!this.links.includes(link)) { + this.links.push(link) + } + }, + unregisterLink: (link) => { + const index = this.links.indexOf(link) + if (index !== -1) { + this.links.splice(index, 1) + } + }, + $data: this.$data, + scrollTo: this.handleScrollTo, + }, + } + }, + + mount () { + this.$nextTick(() => { + const { getContainer } = this + this.scrollEvent = addEventListener(getContainer(), 'scroll', this.handleScroll) + this.handleScroll() + }) + }, + + beforeDestroy () { + if (this.scrollEvent) { + this.scrollEvent.remove() + } + }, + + updated () { + this.$nextTick(() => { + this.updateInk() + }) + }, + methods: { + handleScroll () { + if (this.animating) { + return + } + const { offsetTop, bounds } = this + this.setState({ + activeLink: this.getCurrentAnchor(offsetTop, bounds), + }) + }, + + handleScrollTo (link) { + const { offsetTop, getContainer } = this + this.animating = true + this.setState({ activeLink: link }) + scrollTo(link, offsetTop, getContainer, () => { + this.animating = false + }) + }, + + getCurrentAnchor (offsetTop = 0, bounds = 5) { + const activeLink = '' + if (typeof document === 'undefined') { + return activeLink + } + + const linkSections = [] + const { getContainer } = this + const container = getContainer() + this.links.forEach(link => { + const sharpLinkMatch = sharpMatcherRegx.exec(link.toString()) + if (!sharpLinkMatch) { return } + const target = document.getElementById(sharpLinkMatch[1]) + if (target) { + const top = getOffsetTop(target, container) + if (top < offsetTop + bounds) { + linkSections.push({ + link, + top, + }) + } + } + }) + + if (linkSections.length) { + const maxSection = linkSections.reduce((prev, curr) => curr.top > prev.top ? curr : prev) + return maxSection.link + } + return '' + }, + + updateInk () { + if (typeof document === 'undefined') { + return + } + const { prefixCls } = this + const linkNode = this.$el.getElementsByClassName(`${prefixCls}-link-title-active`)[0] + if (linkNode) { + this.$refs.linkNode.style.top = `${(linkNode).offsetTop + linkNode.clientHeight / 2 - 4.5}px` + } + }, + }, + + render () { + const { + prefixCls, + offsetTop, + affix, + showInkInFixed, + activeLink, + $slots, + } = this + + const inkClass = classNames(`${prefixCls}-ink-ball`, { + visible: activeLink, + }) + + const wrapperClass = classNames(getClass(this), `${prefixCls}-wrapper`) + + const anchorClass = classNames(prefixCls, { + 'fixed': !affix && !showInkInFixed, + }) + + const wrapperStyle = { + maxHeight: offsetTop ? `calc(100vh - ${offsetTop}px)` : '100vh', + ...getStyle(this, true), + } + + const anchorContent = ( +
+
+
+ +
+ {$slots.default} +
+
+ ) + + return !affix ? anchorContent : ( + + {anchorContent} + + ) + }, +} diff --git a/components/anchor/AnchorLink.jsx b/components/anchor/AnchorLink.jsx new file mode 100644 index 000000000..420528a96 --- /dev/null +++ b/components/anchor/AnchorLink.jsx @@ -0,0 +1,61 @@ +import PropTypes from '../_util/vue-types' +import { initDefaultProps, getComponentFromProp } from '../_util/props-util' +import classNames from 'classnames' + +export const AnchorLinkProps = { + prefixCls: PropTypes.string, + href: PropTypes.string, + title: PropTypes.any, +} + +export default { + name: 'AAnchorLink', + props: initDefaultProps(AnchorLinkProps, { + prefixCls: 'ant-anchor', + href: '#', + }), + inject: { + antAnchor: { default: {}}, + }, + + mounted () { + this.antAnchor.registerLink(this.href) + }, + + beforeDestroy () { + this.antAnchor.unregisterLink(this.href) + }, + methods: { + handleClick () { + this.antAnchor.scrollTo(this.href) + }, + }, + render () { + const { + prefixCls, + href, + $slots, + } = this + const title = getComponentFromProp(this, 'title') + const active = this.antAnchor.$data.activeLink === href + const wrapperClassName = classNames(`${prefixCls}-link`, { + [`${prefixCls}-link-active`]: active, + }) + const titleClassName = classNames(`${prefixCls}-link-title`, { + [`${prefixCls}-link-title-active`]: active, + }) + return ( +
+ + {title} + + {$slots.default} +
+ ) + }, +} diff --git a/components/anchor/__tests__/demo.test.js b/components/anchor/__tests__/demo.test.js new file mode 100644 index 000000000..540c6b296 --- /dev/null +++ b/components/anchor/__tests__/demo.test.js @@ -0,0 +1,3 @@ +import demoTest from '../../../tests/shared/demoTest' + +demoTest('anchor') diff --git a/components/anchor/index.en-US.md b/components/anchor/index.en-US.md new file mode 100644 index 000000000..de521789e --- /dev/null +++ b/components/anchor/index.en-US.md @@ -0,0 +1,32 @@ +--- +category: Components +type: Other +cols: 2 +title: Anchor +--- + +Hyperlinks to scroll on one page. + +## When To Use + +For displaying anchor hyperlinks on page and jumping between them. + +## API + +### Anchor Props + +| Property | Description | Type | Default | +| -------- | ----------- | ---- | ------- | +| affix | Fixed mode of Anchor | boolean | true | +| bounds | Bounding distance of anchor area | number | 5(px) | +| getContainer | Scrolling container | () => HTMLElement | () => window | +| offsetBottom | Pixels to offset from bottom when calculating position of scroll | number | - | +| offsetTop | Pixels to offset from top when calculating position of scroll | number | 0 | +| showInkInFixed | Whether show ink-balls in Fixed mode | boolean | false | + +### Link Props + +| Property | Description | Type | Default | +| -------- | ----------- | ---- | ------- | +| href | target of hyperlink | string | | +| title | content of hyperlink | string\|slot | | diff --git a/components/anchor/index.jsx b/components/anchor/index.jsx new file mode 100644 index 000000000..b0926f03a --- /dev/null +++ b/components/anchor/index.jsx @@ -0,0 +1,8 @@ +import Anchor from './Anchor' +import AnchorLink from './AnchorLink' + +export { AnchorProps } from './Anchor' +export { AnchorLinkProps } from './AnchorLink' + +Anchor.Link = AnchorLink +export default Anchor diff --git a/components/anchor/index.zh-CN.md b/components/anchor/index.zh-CN.md new file mode 100644 index 000000000..71d753ee4 --- /dev/null +++ b/components/anchor/index.zh-CN.md @@ -0,0 +1,33 @@ +--- +category: Components +subtitle: 锚点 +cols: 2 +type: Other +title: Anchor +--- + +用于跳转到页面指定位置。 + +## 何时使用 + +需要展现当前页面上可供跳转的锚点链接,以及快速在锚点之间跳转。 + +## API + +### Anchor Props + +| 成员 | 说明 | 类型 | 默认值 | +| --- | --- | --- | --- | +| affix | 固定模式 | boolean | true | +| bounds | 锚点区域边界 | number | 5(px) | +| getContainer | 指定滚动的容器 | () => HTMLElement | () => window | +| offsetBottom | 距离窗口底部达到指定偏移量后触发 | number | | +| offsetTop | 距离窗口顶部达到指定偏移量后触发 | number | | +| showInkInFixed | 固定模式是否显示小圆点 | boolean | false | + +### Link Props + +| 成员 | 说明 | 类型 | 默认值 | +| --- | --- | --- | --- | +| href | 锚点链接 | string | | +| title | 文字内容 | string\|slot | | diff --git a/components/anchor/style/index.js b/components/anchor/style/index.js new file mode 100644 index 000000000..cf31ed80f --- /dev/null +++ b/components/anchor/style/index.js @@ -0,0 +1,2 @@ +import '../../style/index.less' +import './index.less' diff --git a/components/anchor/style/index.less b/components/anchor/style/index.less new file mode 100644 index 000000000..235eb7815 --- /dev/null +++ b/components/anchor/style/index.less @@ -0,0 +1,81 @@ +@import "../../style/themes/default"; +@import "../../style/mixins/index"; + +@anchor-border-width: 2px; + +.@{ant-prefix}-anchor { + .reset-component; + position: relative; + padding-left: @anchor-border-width; + + &-wrapper { + background-color: @component-background; + overflow: auto; + padding-left: 4px; + margin-left: -4px; + } + + &-ink { + position: absolute; + height: 100%; + left: 0; + top: 0; + &:before { + content: ' '; + position: relative; + width: @anchor-border-width; + height: 100%; + display: block; + background-color: @border-color-split; + margin: 0 auto; + } + &-ball { + display: none; + position: absolute; + width: 8px; + height: 8px; + border-radius: 8px; + border: 2px solid @primary-color; + background-color: @component-background; + left: 50%; + transition: top .3s ease-in-out; + transform: translateX(-50%); + &.visible { + display: inline-block; + } + } + } + + &.fixed &-ink &-ink-ball { + display: none; + } + + &-link { + padding: 8px 0 8px 16px; + line-height: 1; + + &-title { + display: block; + position: relative; + transition: all .3s; + color: @text-color; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + margin-bottom: 8px; + + &:only-child { + margin-bottom: 0; + } + } + + &-active > &-title { + color: @primary-color; + } + } + + &-link &-link { + padding-top: 6px; + padding-bottom: 6px; + } +} diff --git a/components/index.js b/components/index.js index 7acf7ffd6..ee1b260f1 100644 --- a/components/index.js +++ b/components/index.js @@ -14,7 +14,7 @@ if (ENV !== 'production' && ENV !== 'test' && import { default as Affix } from './affix' -// import { default as Anchor } from './anchor' +import { default as Anchor } from './anchor' import { default as AutoComplete } from './auto-complete' @@ -122,6 +122,8 @@ import { default as version } from './version' const components = [ Affix, + Anchor, + Anchor.Link, AutoComplete, Alert, Avatar, @@ -223,6 +225,7 @@ export { message, notification, Affix, + Anchor, AutoComplete, Alert, Avatar, diff --git a/components/style.js b/components/style.js index 867369804..b2e783035 100644 --- a/components/style.js +++ b/components/style.js @@ -44,3 +44,4 @@ import './tree/style' import './upload/style' import './layout/style' import './form/style' +import './anchor/style' From 4e2163363d4ccb1e0d2515f86d30410c6083e96b Mon Sep 17 00:00:00 2001 From: tangjinzhou <415800467@qq.com> Date: Mon, 4 Jun 2018 17:09:01 +0800 Subject: [PATCH 2/2] feat: add anchor component docs & test --- components/anchor/Anchor.jsx | 4 +- components/anchor/__tests__/Anchor.test.js | 82 +++++++++++++++++++ .../__tests__/__snapshots__/demo.test.js.snap | 51 ++++++++++++ components/anchor/demo/basic.md | 22 +++++ components/anchor/demo/index.vue | 50 +++++++++++ components/anchor/demo/static.md | 22 +++++ components/anchor/index.en-US.md | 12 --- components/anchor/index.zh-CN.md | 13 --- site/components.js | 4 +- site/demo.js | 1 + tests/__snapshots__/index.test.js.snap | 1 + 11 files changed, 234 insertions(+), 28 deletions(-) create mode 100644 components/anchor/__tests__/Anchor.test.js create mode 100644 components/anchor/__tests__/__snapshots__/demo.test.js.snap create mode 100644 components/anchor/demo/basic.md create mode 100644 components/anchor/demo/index.vue create mode 100644 components/anchor/demo/static.md diff --git a/components/anchor/Anchor.jsx b/components/anchor/Anchor.jsx index cbadc4be9..c93007809 100644 --- a/components/anchor/Anchor.jsx +++ b/components/anchor/Anchor.jsx @@ -79,8 +79,8 @@ export const AnchorProps = { prefixCls: PropTypes.string, offsetTop: PropTypes.number, bounds: PropTypes.number, - affix: PropTypes.boolean, - showInkInFixed: PropTypes.boolean, + affix: PropTypes.bool, + showInkInFixed: PropTypes.bool, getContainer: PropTypes.func, } diff --git a/components/anchor/__tests__/Anchor.test.js b/components/anchor/__tests__/Anchor.test.js new file mode 100644 index 000000000..91b259319 --- /dev/null +++ b/components/anchor/__tests__/Anchor.test.js @@ -0,0 +1,82 @@ +import { mount } from '@vue/test-utils' +import Vue from 'vue' +import Anchor from '..' + +const { Link } = Anchor + +describe('Anchor Render', () => { + it('Anchor render perfectly', (done) => { + const wrapper = mount({ + render () { + return ( + + + + ) + }, + }, { sync: false }) + Vue.nextTick(() => { + wrapper.find('a[href="#API"]').trigger('click') + wrapper.vm.$refs.anchor.handleScroll() + expect(wrapper.vm.$refs.anchor.$data.activeLink).not.toBe(null) + done() + }) + }) + + it('Anchor render perfectly for complete href - click', (done) => { + const wrapper = mount({ + render () { + return ( + + + + ) + }, + }, { sync: false }) + Vue.nextTick(() => { + wrapper.find('a[href="http://www.example.com/#API"]').trigger('click') + expect(wrapper.vm.$refs.anchor.$data.activeLink).toBe('http://www.example.com/#API') + done() + }) + }) + + it('Anchor render perfectly for complete href - scoll', (done) => { + const wrapper = mount({ + render () { + return ( +
+
Hello
+ + + +
+ ) + }, + }, { sync: false, attachToDocument: true }) + Vue.nextTick(() => { + wrapper.vm.$refs.anchor.handleScroll() + expect(wrapper.vm.$refs.anchor.$data.activeLink).toBe('http://www.example.com/#API') + done() + }) + }) + + it('Anchor render perfectly for complete href - scollTo', (done) => { + const wrapper = mount({ + render () { + return ( +
+
Hello
+ + + +
+ ) + }, + }, { sync: false, attachToDocument: true }) + Vue.nextTick(() => { + wrapper.vm.$refs.anchor.handleScrollTo('##API') + expect(wrapper.vm.$refs.anchor.$data.activeLink).toBe('##API') + done() + }) + }) +}) diff --git a/components/anchor/__tests__/__snapshots__/demo.test.js.snap b/components/anchor/__tests__/__snapshots__/demo.test.js.snap new file mode 100644 index 000000000..92b2618e4 --- /dev/null +++ b/components/anchor/__tests__/__snapshots__/demo.test.js.snap @@ -0,0 +1,51 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`renders ./components/anchor/demo/basic.md correctly 1`] = ` +
+
+
+
+
+ + + +
+
+
+
+`; + +exports[`renders ./components/anchor/demo/static.md correctly 1`] = ` +
+
+
+ + + +
+
+`; diff --git a/components/anchor/demo/basic.md b/components/anchor/demo/basic.md new file mode 100644 index 000000000..0dcc0ed6a --- /dev/null +++ b/components/anchor/demo/basic.md @@ -0,0 +1,22 @@ + +#### 基本 +最简单的用法。 + + + +#### basic +The simplest usage. + + +```html + +``` diff --git a/components/anchor/demo/index.vue b/components/anchor/demo/index.vue new file mode 100644 index 000000000..8dfe8c482 --- /dev/null +++ b/components/anchor/demo/index.vue @@ -0,0 +1,50 @@ + + diff --git a/components/anchor/demo/static.md b/components/anchor/demo/static.md new file mode 100644 index 000000000..0c8f02eea --- /dev/null +++ b/components/anchor/demo/static.md @@ -0,0 +1,22 @@ + +#### 静态位置 +不浮动,状态不随页面滚动变化。 + + + +#### Static Anchor +Do not change state when page is scrolling. + + +```html + +``` diff --git a/components/anchor/index.en-US.md b/components/anchor/index.en-US.md index de521789e..264f56255 100644 --- a/components/anchor/index.en-US.md +++ b/components/anchor/index.en-US.md @@ -1,15 +1,3 @@ ---- -category: Components -type: Other -cols: 2 -title: Anchor ---- - -Hyperlinks to scroll on one page. - -## When To Use - -For displaying anchor hyperlinks on page and jumping between them. ## API diff --git a/components/anchor/index.zh-CN.md b/components/anchor/index.zh-CN.md index 71d753ee4..ac3a084f2 100644 --- a/components/anchor/index.zh-CN.md +++ b/components/anchor/index.zh-CN.md @@ -1,16 +1,3 @@ ---- -category: Components -subtitle: 锚点 -cols: 2 -type: Other -title: Anchor ---- - -用于跳转到页面指定位置。 - -## 何时使用 - -需要展现当前页面上可供跳转的锚点链接,以及快速在锚点之间跳转。 ## API diff --git a/site/components.js b/site/components.js index e001b8298..6422abf9b 100644 --- a/site/components.js +++ b/site/components.js @@ -1,7 +1,7 @@ import Vue from 'vue' import { Affix, - // Anchor, + Anchor, AutoComplete, Alert, Avatar, @@ -57,6 +57,8 @@ import { } from 'antd' Vue.component(Affix.name, Affix) // a-affix +Vue.component(Anchor.name, Anchor) +Vue.component(Anchor.Link.name, Anchor.Link) Vue.component(AutoComplete.name, AutoComplete) Vue.component(Alert.name, Alert) Vue.component(Avatar.name, Avatar) diff --git a/site/demo.js b/site/demo.js index b26f78e09..23694d32d 100644 --- a/site/demo.js +++ b/site/demo.js @@ -45,3 +45,4 @@ export { default as upload } from 'antd/upload/demo/index.vue' export { default as tree } from 'antd/tree/demo/index.vue' export { default as layout } from 'antd/layout/demo/index.vue' export { default as form } from 'antd/form/demo/index.vue' +export { default as anchor } from 'antd/anchor/demo/index.vue' diff --git a/tests/__snapshots__/index.test.js.snap b/tests/__snapshots__/index.test.js.snap index b18df367a..9adcc0cc1 100644 --- a/tests/__snapshots__/index.test.js.snap +++ b/tests/__snapshots__/index.test.js.snap @@ -7,6 +7,7 @@ Array [ "message", "notification", "Affix", + "Anchor", "AutoComplete", "Alert", "Avatar",