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] 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'