diff --git a/components/anchor/Anchor.jsx b/components/anchor/Anchor.jsx new file mode 100644 index 000000000..c93007809 --- /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.bool, + showInkInFixed: PropTypes.bool, + 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__/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/__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/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 new file mode 100644 index 000000000..264f56255 --- /dev/null +++ b/components/anchor/index.en-US.md @@ -0,0 +1,20 @@ + +## 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..ac3a084f2 --- /dev/null +++ b/components/anchor/index.zh-CN.md @@ -0,0 +1,20 @@ + +## 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' 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",