refactor(Anchor): use composition api (#4054)

pull/4134/head
言肆 2021-05-24 16:42:54 +08:00 committed by GitHub
parent 1139aa5f87
commit 1877d66cc5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
3 changed files with 380 additions and 362 deletions

View File

@ -1,12 +1,21 @@
import { defineComponent, inject, nextTick, provide } from 'vue';
import {
defineComponent,
inject,
nextTick,
onBeforeUnmount,
onMounted,
onUpdated,
provide,
reactive,
ref,
getCurrentInstance,
} from 'vue';
import PropTypes from '../_util/vue-types';
import classNames from '../_util/classNames';
import addEventListener from '../vc-util/Dom/addEventListener';
import Affix from '../affix';
import scrollTo from '../_util/scrollTo';
import getScroll from '../_util/getScroll';
import { findDOMNode } from '../_util/props-util';
import BaseMixin from '../_util/BaseMixin';
import { defaultConfigProvider } from '../config-provider';
function getDefaultContainer() {
@ -77,76 +86,26 @@ export interface AnchorState {
export default defineComponent({
name: 'AAnchor',
mixins: [BaseMixin],
inheritAttrs: false,
props: AnchorProps,
emits: ['change', 'click'],
setup() {
return {
configProvider: inject('configProvider', defaultConfigProvider),
};
},
data() {
// this.links = [];
// this.sPrefixCls = '';
return {
setup(props, { emit, attrs, slots }) {
const configProvider = inject('configProvider', defaultConfigProvider);
const instance = getCurrentInstance();
const inkNodeRef = ref();
const anchorRef = ref();
const state = reactive<AnchorState>({
activeLink: null,
links: [],
sPrefixCls: '',
scrollContainer: null,
scrollEvent: null,
animating: false,
} as AnchorState;
},
created() {
provide('antAnchor', {
registerLink: (link: string) => {
if (!this.links.includes(link)) {
this.links.push(link);
}
},
unregisterLink: (link: string) => {
const index = this.links.indexOf(link);
if (index !== -1) {
this.links.splice(index, 1);
}
},
$data: this.$data,
scrollTo: this.handleScrollTo,
} as AntAnchor);
provide('antAnchorContext', this);
},
mounted() {
nextTick(() => {
const { getContainer } = this;
this.scrollContainer = getContainer();
this.scrollEvent = addEventListener(this.scrollContainer, 'scroll', this.handleScroll);
this.handleScroll();
});
},
updated() {
nextTick(() => {
if (this.scrollEvent) {
const { getContainer } = this;
const currentContainer = getContainer();
if (this.scrollContainer !== currentContainer) {
this.scrollContainer = currentContainer;
this.scrollEvent.remove();
this.scrollEvent = addEventListener(this.scrollContainer, 'scroll', this.handleScroll);
this.handleScroll();
}
}
this.updateInk();
});
},
beforeUnmount() {
if (this.scrollEvent) {
this.scrollEvent.remove();
}
},
methods: {
getCurrentActiveLink(offsetTop = 0, bounds = 5) {
const { getCurrentAnchor } = this;
// func...
const getCurrentActiveLink = (offsetTop = 0, bounds = 5) => {
const { getCurrentAnchor } = props;
if (typeof getCurrentAnchor === 'function') {
return getCurrentAnchor();
@ -157,9 +116,9 @@ export default defineComponent({
}
const linkSections: Array<Section> = [];
const { getContainer } = this;
const { getContainer } = props;
const container = getContainer();
this.links.forEach(link => {
state.links.forEach(link => {
const sharpLinkMatch = sharpMatcherRegx.exec(link.toString());
if (!sharpLinkMatch) {
return;
@ -181,12 +140,18 @@ export default defineComponent({
return maxSection.link;
}
return '';
},
};
const setCurrentActiveLink = (link: string) => {
const { activeLink } = state;
if (activeLink !== link) {
state.activeLink = link;
emit('change', link);
}
};
const handleScrollTo = (link: string) => {
const { offsetTop, getContainer, targetOffset } = props;
handleScrollTo(link: string) {
const { offsetTop, getContainer, targetOffset } = this;
this.setCurrentActiveLink(link);
setCurrentActiveLink(link);
const container = getContainer();
const scrollTop = getScroll(container, true);
const sharpLinkMatch = sharpMatcherRegx.exec(link);
@ -201,99 +166,129 @@ export default defineComponent({
const eleOffsetTop = getOffsetTop(targetElement, container);
let y = scrollTop + eleOffsetTop;
y -= targetOffset !== undefined ? targetOffset : offsetTop || 0;
this.animating = true;
state.animating = true;
scrollTo(y, {
callback: () => {
this.animating = false;
state.animating = false;
},
getContainer,
});
},
setCurrentActiveLink(link: string) {
const { activeLink } = this;
if (activeLink !== link) {
this.setState({
activeLink: link,
});
this.$emit('change', link);
}
},
handleScroll() {
if (this.animating) {
};
const handleScroll = () => {
if (state.animating) {
return;
}
const { offsetTop, bounds, targetOffset } = this;
const currentActiveLink = this.getCurrentActiveLink(
const { offsetTop, bounds, targetOffset } = props;
const currentActiveLink = getCurrentActiveLink(
targetOffset !== undefined ? targetOffset : offsetTop || 0,
bounds,
);
this.setCurrentActiveLink(currentActiveLink);
},
setCurrentActiveLink(currentActiveLink);
};
updateInk() {
const updateInk = () => {
if (typeof document === 'undefined') {
return;
}
const { sPrefixCls } = this;
const linkNode = findDOMNode(this).getElementsByClassName(
`${sPrefixCls}-link-title-active`,
)[0];
const { sPrefixCls } = state;
const linkNode = anchorRef.value.getElementsByClassName(`${sPrefixCls}-link-title-active`)[0];
if (linkNode) {
(this.$refs.inkNode as HTMLElement).style.top = `${linkNode.offsetTop +
(inkNodeRef.value as HTMLElement).style.top = `${linkNode.offsetTop +
linkNode.clientHeight / 2 -
4.5}px`;
}
},
},
render() {
const {
prefixCls: customizePrefixCls,
offsetTop,
affix,
showInkInFixed,
activeLink,
$slots,
getContainer,
} = this;
const getPrefixCls = this.configProvider.getPrefixCls;
const prefixCls = getPrefixCls('anchor', customizePrefixCls);
this.sPrefixCls = prefixCls;
const inkClass = classNames(`${prefixCls}-ink-ball`, {
visible: activeLink,
});
const wrapperClass = classNames(this.wrapperClass, `${prefixCls}-wrapper`);
const anchorClass = classNames(prefixCls, {
fixed: !affix && !showInkInFixed,
});
const wrapperStyle = {
maxHeight: offsetTop ? `calc(100vh - ${offsetTop}px)` : '100vh',
...this.wrapperStyle,
};
const anchorContent = (
<div class={wrapperClass} style={wrapperStyle}>
<div class={anchorClass}>
<div class={`${prefixCls}-ink`}>
<span class={inkClass} ref="inkNode" />
</div>
{$slots.default?.()}
</div>
</div>
);
return !affix ? (
anchorContent
) : (
<Affix {...this.$attrs} offsetTop={offsetTop} target={getContainer}>
{anchorContent}
</Affix>
);
// provide data
provide('antAnchor', {
registerLink: (link: string) => {
if (!state.links.includes(link)) {
state.links.push(link);
}
},
unregisterLink: (link: string) => {
const index = state.links.indexOf(link);
if (index !== -1) {
state.links.splice(index, 1);
}
},
$data: state,
scrollTo: handleScrollTo,
} as AntAnchor);
provide('antAnchorContext', instance);
onMounted(() => {
nextTick(() => {
const { getContainer } = props;
state.scrollContainer = getContainer();
state.scrollEvent = addEventListener(state.scrollContainer, 'scroll', handleScroll);
handleScroll();
});
});
onBeforeUnmount(() => {
if (state.scrollEvent) {
state.scrollEvent.remove();
}
});
onUpdated(() => {
if (state.scrollEvent) {
const { getContainer } = props;
const currentContainer = getContainer();
if (state.scrollContainer !== currentContainer) {
state.scrollContainer = currentContainer;
state.scrollEvent.remove();
state.scrollEvent = addEventListener(state.scrollContainer, 'scroll', handleScroll);
handleScroll();
}
}
updateInk();
});
return () => {
const {
prefixCls: customizePrefixCls,
offsetTop,
affix,
showInkInFixed,
getContainer,
} = props;
const getPrefixCls = configProvider.getPrefixCls;
const prefixCls = getPrefixCls('anchor', customizePrefixCls);
state.sPrefixCls = prefixCls;
const inkClass = classNames(`${prefixCls}-ink-ball`, {
visible: state.activeLink,
});
const wrapperClass = classNames(props.wrapperClass, `${prefixCls}-wrapper`);
const anchorClass = classNames(prefixCls, {
fixed: !affix && !showInkInFixed,
});
const wrapperStyle = {
maxHeight: offsetTop ? `calc(100vh - ${offsetTop}px)` : '100vh',
...props.wrapperStyle,
};
const anchorContent = (
<div class={wrapperClass} style={wrapperStyle} ref={anchorRef}>
<div class={anchorClass}>
<div class={`${prefixCls}-ink`}>
<span class={inkClass} ref={inkNodeRef} />
</div>
{slots.default?.()}
</div>
</div>
);
return !affix ? (
anchorContent
) : (
<Affix {...attrs} offsetTop={offsetTop} target={getContainer}>
{anchorContent}
</Affix>
);
};
},
});

View File

@ -1,6 +1,14 @@
import { ComponentPublicInstance, defineComponent, inject, nextTick } from 'vue';
import {
ComponentInternalInstance,
defineComponent,
inject,
nextTick,
onBeforeUnmount,
onMounted,
watch,
} from 'vue';
import PropTypes from '../_util/vue-types';
import { getComponent } from '../_util/props-util';
import { getPropsSlot } from '../_util/props-util';
import classNames from '../_util/classNames';
import { defaultConfigProvider } from '../config-provider';
import { AntAnchor } from './Anchor';
@ -18,72 +26,72 @@ const AnchorLinkProps = {
export default defineComponent({
name: 'AAnchorLink',
props: AnchorLinkProps,
setup() {
return {
antAnchor: inject('antAnchor', {
registerLink: noop,
unregisterLink: noop,
scrollTo: noop,
$data: {},
} as AntAnchor),
antAnchorContext: inject('antAnchorContext', {}) as ComponentPublicInstance,
configProvider: inject('configProvider', defaultConfigProvider),
};
},
watch: {
href(val, oldVal) {
nextTick(() => {
this.antAnchor.unregisterLink(oldVal);
this.antAnchor.registerLink(val);
});
},
},
setup(props, { slots }) {
const antAnchor = inject('antAnchor', {
registerLink: noop,
unregisterLink: noop,
scrollTo: noop,
$data: {},
} as AntAnchor);
const antAnchorContext = inject('antAnchorContext', {}) as ComponentInternalInstance;
const configProvider = inject('configProvider', defaultConfigProvider);
mounted() {
this.antAnchor.registerLink(this.href);
},
beforeUnmount() {
this.antAnchor.unregisterLink(this.href);
},
methods: {
handleClick(e: Event) {
this.antAnchor.scrollTo(this.href);
const { scrollTo } = this.antAnchor;
const { href, title } = this.$props;
if (this.antAnchorContext.$emit) {
this.antAnchorContext.$emit('click', e, { title, href });
const handleClick = (e: Event) => {
// antAnchor.scrollTo(props.href);
const { scrollTo } = antAnchor;
const { href, title } = props;
if (antAnchorContext.emit) {
antAnchorContext.emit('click', e, { title, href });
}
scrollTo(href);
},
},
render() {
const { prefixCls: customizePrefixCls, href, $slots, target } = this;
};
const getPrefixCls = this.configProvider.getPrefixCls;
const prefixCls = getPrefixCls('anchor', customizePrefixCls);
const title = getComponent(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 (
<div class={wrapperClassName}>
<a
class={titleClassName}
href={href}
title={typeof title === 'string' ? title : ''}
target={target}
onClick={this.handleClick}
>
{title}
</a>
{$slots.default?.()}
</div>
watch(
() => props.href,
(val, oldVal) => {
nextTick(() => {
antAnchor.unregisterLink(oldVal);
antAnchor.registerLink(val);
});
},
);
onMounted(() => {
antAnchor.registerLink(props.href);
});
onBeforeUnmount(() => {
antAnchor.unregisterLink(props.href);
});
return () => {
const { prefixCls: customizePrefixCls, href, target } = props;
const getPrefixCls = configProvider.getPrefixCls;
const prefixCls = getPrefixCls('anchor', customizePrefixCls);
const title = getPropsSlot(slots, props, 'title');
const active = 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 (
<div class={wrapperClassName}>
<a
class={titleClassName}
href={href}
title={typeof title === 'string' ? title : ''}
target={target}
onClick={handleClick}
>
{title}
</a>
{slots.default?.()}
</div>
);
};
},
});

View File

@ -1,6 +1,5 @@
import { mount } from '@vue/test-utils';
import * as Vue from 'vue';
import { asyncExpect } from '@/tests/utils';
import { ref } from 'vue';
import Anchor from '..';
const { Link } = Anchor;
@ -9,13 +8,20 @@ let idCounter = 0;
const getHashUrl = () => `Anchor-API-${idCounter++}`;
describe('Anchor Render', () => {
it('Anchor render perfectly', done => {
it('Anchor render perfectly', async done => {
const hash = getHashUrl();
const anchor = ref(null);
const activeLink = ref(null);
const wrapper = mount(
{
render() {
return (
<Anchor ref="anchor">
<Anchor
ref={anchor}
onChange={current => {
activeLink.value = current;
}}
>
<Link href={`#${hash}`} title={hash} />
</Anchor>
);
@ -23,22 +29,28 @@ describe('Anchor Render', () => {
},
{ sync: false },
);
Vue.nextTick(() => {
wrapper.vm.$nextTick(() => {
wrapper.find(`a[href="#${hash}`).trigger('click');
wrapper.vm.$refs.anchor.handleScroll();
setTimeout(() => {
expect(wrapper.vm.$refs.anchor.$data.activeLink).not.toBe(null);
expect(activeLink.value).not.toBe(hash);
done();
}, 1000);
});
});
it('Anchor render perfectly for complete href - click', done => {
it('Anchor render perfectly for complete href - click', async done => {
const currentActiveLink = ref(null);
const wrapper = mount(
{
render() {
return (
<Anchor ref="anchor">
<Anchor
ref="anchor"
onChange={current => {
currentActiveLink.value = current;
}}
>
<Link href="http://www.example.com/#API" title="API" />
</Anchor>
);
@ -46,160 +58,163 @@ describe('Anchor Render', () => {
},
{ sync: false },
);
Vue.nextTick(() => {
wrapper.vm.$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');
expect(currentActiveLink.value).toBe('http://www.example.com/#API');
done();
});
});
it('Anchor render perfectly for complete href - scroll', done => {
const wrapper = mount(
{
render() {
return (
<div>
<div id="API">Hello</div>
<Anchor ref="anchor">
<Link href="http://www.example.com/#API" title="API" />
</Anchor>
</div>
);
},
},
{ sync: false, attachTo: 'body' },
);
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 - scrollTo', async () => {
const scrollToSpy = jest.spyOn(window, 'scrollTo');
const wrapper = mount(
{
render() {
return (
<div>
<div id="API">Hello</div>
<Anchor ref="anchor">
<Link href="##API" title="API" />
</Anchor>
</div>
);
},
},
{ sync: false, attachTo: 'body' },
);
await asyncExpect(() => {
wrapper.vm.$refs.anchor.handleScrollTo('##API');
expect(wrapper.vm.$refs.anchor.$data.activeLink).toBe('##API');
expect(scrollToSpy).not.toHaveBeenCalled();
});
await asyncExpect(() => {
expect(scrollToSpy).toHaveBeenCalled();
}, 1000);
});
it('should remove listener when unmount', async () => {
const wrapper = mount(
{
render() {
return (
<Anchor ref="anchor">
<Link href="#API" title="API" />
</Anchor>
);
},
},
{ sync: false, attachTo: 'body' },
);
await asyncExpect(() => {
const removeListenerSpy = jest.spyOn(wrapper.vm.$refs.anchor.scrollEvent, 'remove');
wrapper.unmount();
expect(removeListenerSpy).toHaveBeenCalled();
});
});
it('should unregister link when unmount children', async () => {
const wrapper = mount(
{
props: {
showLink: {
type: Boolean,
default: true,
/*
it('Anchor render perfectly for complete href - scroll', done => {
const wrapper = mount(
{
render() {
return (
<div>
<div id="API">Hello</div>
<Anchor ref="anchor">
<Link href="http://www.example.com/#API" title="API" />
</Anchor>
</div>
);
},
},
render() {
return (
<Anchor ref="anchor">{this.showLink ? <Link href="#API" title="API" /> : null}</Anchor>
);
},
},
{ sync: false, attachTo: 'body' },
);
await asyncExpect(() => {
expect(wrapper.vm.$refs.anchor.links).toEqual(['#API']);
wrapper.setProps({ showLink: false });
{ sync: false, attachTo: 'body' },
);
wrapper.vm.$nextTick(() => {
wrapper.vm.$refs.anchor.handleScroll();
expect(wrapper.vm.$refs.anchor.$data.activeLink).toBe('http://www.example.com/#API');
done();
});
});
await asyncExpect(() => {
expect(wrapper.vm.$refs.anchor.links).toEqual([]);
});
});
it('should update links when link href update', async () => {
const wrapper = mount(
{
props: ['href'],
render() {
return (
<Anchor ref="anchor">
<Link href={this.href} title="API" />
</Anchor>
);
},
},
{
sync: false,
attachTo: 'body',
props: {
href: '#API',
},
},
);
await asyncExpect(() => {
expect(wrapper.vm.$refs.anchor.links).toEqual(['#API']);
wrapper.setProps({ href: '#API_1' });
});
await asyncExpect(() => {
expect(wrapper.vm.$refs.anchor.links).toEqual(['#API_1']);
});
});
it('Anchor onClick event', () => {
let event;
let link;
const handleClick = (...arg) => ([event, link] = arg);
const href = '#API';
const title = 'API';
const wrapper = mount({
render() {
return (
<Anchor ref="anchorRef" onClick={handleClick}>
<Link href={href} title={title} />
</Anchor>
it('Anchor render perfectly for complete href - scrollTo', async () => {
const scrollToSpy = jest.spyOn(window, 'scrollTo');
const wrapper = mount(
{
render() {
return (
<div>
<div id="API">Hello</div>
<Anchor ref="anchor">
<Link href="##API" title="API" />
</Anchor>
</div>
);
},
},
{ sync: false, attachTo: 'body' },
);
},
});
await asyncExpect(() => {
wrapper.vm.$refs.anchor.handleScrollTo('##API');
expect(wrapper.vm.$refs.anchor.$data.activeLink).toBe('##API');
expect(scrollToSpy).not.toHaveBeenCalled();
});
await asyncExpect(() => {
expect(scrollToSpy).toHaveBeenCalled();
}, 1000);
});
wrapper.find(`a[href="${href}"]`).trigger('click');
it('should remove listener when unmount', async () => {
const wrapper = mount(
{
render() {
return (
<Anchor ref="anchor">
<Link href="#API" title="API" />
</Anchor>
);
},
},
{ sync: false, attachTo: 'body' },
);
await asyncExpect(() => {
const removeListenerSpy = jest.spyOn(wrapper.vm.$refs.anchor.scrollEvent, 'remove');
wrapper.unmount();
expect(removeListenerSpy).toHaveBeenCalled();
});
});
wrapper.vm.$refs.anchorRef.handleScroll();
expect(event).not.toBe(undefined);
expect(link).toEqual({ href, title });
});
it('should unregister link when unmount children', async () => {
const wrapper = mount(
{
props: {
showLink: {
type: Boolean,
default: true,
},
},
render() {
return (
<Anchor ref="anchor">{this.showLink ? <Link href="#API" title="API" /> : null}</Anchor>
);
},
},
{ sync: false, attachTo: 'body' },
);
await asyncExpect(() => {
expect(wrapper.vm.$refs.anchor.links).toEqual(['#API']);
wrapper.setProps({ showLink: false });
});
await asyncExpect(() => {
expect(wrapper.vm.$refs.anchor.links).toEqual([]);
});
});
it('should update links when link href update', async () => {
const wrapper = mount(
{
props: ['href'],
render() {
return (
<Anchor ref="anchor">
<Link href={this.href} title="API" />
</Anchor>
);
},
},
{
sync: false,
attachTo: 'body',
props: {
href: '#API',
},
},
);
await asyncExpect(() => {
expect(wrapper.vm.$refs.anchor.links).toEqual(['#API']);
wrapper.setProps({ href: '#API_1' });
});
await asyncExpect(() => {
expect(wrapper.vm.$refs.anchor.links).toEqual(['#API_1']);
});
});
it('Anchor onClick event', () => {
let event;
let link;
const handleClick = (...arg) => ([event, link] = arg);
const href = '#API';
const title = 'API';
const anchorRef = Vue.ref(null);
const wrapper = mount({
render() {
return (
<Anchor ref={anchorRef} onClick={handleClick}>
<Link href={href} title={title} />
</Anchor>
);
},
});
wrapper.find(`a[href="${href}"]`).trigger('click');
anchorRef.value.handleScroll();
expect(event).not.toBe(undefined);
expect(link).toEqual({ href, title });
}); */
});