refactor: Anchor.Link with FC (#37957)

* fix: fix

* fix: fix

* fix: revert

* fix: fix

* test: fix test case

* test: fix

* test: fix

* fix: fix

* fix: fix
This commit is contained in:
lijianan 2022-10-17 21:36:56 +08:00 committed by GitHub
parent e3abd1e77f
commit b3a37b7ca9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 87 additions and 108 deletions

View File

@ -35,10 +35,10 @@ function getOffsetTop(element: HTMLElement, container: AnchorContainer): number
const sharpMatcherRegx = /#([\S ]+)$/;
type Section = {
interface Section {
link: string;
top: number;
};
}
export interface AnchorProps {
prefixCls?: string;
@ -89,11 +89,6 @@ export interface AntAnchor {
}
class Anchor extends React.Component<InternalAnchorProps, AnchorState, ConfigConsumerProps> {
static defaultProps = {
affix: true,
showInkInFixed: false,
};
static contextType = ConfigContext;
state = {
@ -111,20 +106,20 @@ class Anchor extends React.Component<InternalAnchorProps, AnchorState, ConfigCon
private links: string[] = [];
private scrollEvent: any;
private scrollEvent: ReturnType<typeof addEventListener>;
private animating: boolean;
private prefixCls?: string;
// Context
registerLink = (link: string) => {
registerLink: AntAnchor['registerLink'] = link => {
if (!this.links.includes(link)) {
this.links.push(link);
}
};
unregisterLink = (link: string) => {
unregisterLink: AntAnchor['unregisterLink'] = link => {
const index = this.links.indexOf(link);
if (index !== -1) {
this.links.splice(index, 1);
@ -174,7 +169,7 @@ class Anchor extends React.Component<InternalAnchorProps, AnchorState, ConfigCon
const linkSections: Array<Section> = [];
const container = this.getContainer();
this.links.forEach(link => {
const sharpLinkMatch = sharpMatcherRegx.exec(link.toString());
const sharpLinkMatch = sharpMatcherRegx.exec(link?.toString());
if (!sharpLinkMatch) {
return;
}
@ -182,10 +177,7 @@ class Anchor extends React.Component<InternalAnchorProps, AnchorState, ConfigCon
if (target) {
const top = getOffsetTop(target, container);
if (top < offsetTop + bounds) {
linkSections.push({
link,
top,
});
linkSections.push({ link, top });
}
}
});
@ -259,10 +251,9 @@ class Anchor extends React.Component<InternalAnchorProps, AnchorState, ConfigCon
updateInk = () => {
const { prefixCls, wrapperRef } = this;
const anchorNode = wrapperRef.current;
const linkNode = anchorNode?.getElementsByClassName(`${prefixCls}-link-title-active`)[0];
const linkNode = anchorNode?.querySelector<HTMLElement>(`.${prefixCls}-link-title-active`);
if (linkNode) {
this.inkNode.style.top = `${(linkNode as any).offsetTop + linkNode.clientHeight / 2 - 4.5}px`;
this.inkNode.style.top = `${linkNode.offsetTop + linkNode.clientHeight / 2 - 4.5}px`;
}
};
@ -283,8 +274,8 @@ class Anchor extends React.Component<InternalAnchorProps, AnchorState, ConfigCon
className = '',
style,
offsetTop,
affix,
showInkInFixed,
affix = true,
showInkInFixed = false,
children,
onClick,
} = this.props;
@ -311,7 +302,7 @@ class Anchor extends React.Component<InternalAnchorProps, AnchorState, ConfigCon
[`${prefixCls}-fixed`]: !affix && !showInkInFixed,
});
const wrapperStyle = {
const wrapperStyle: React.CSSProperties = {
maxHeight: offsetTop ? `calc(100vh - ${offsetTop}px)` : '100vh',
...style,
};
@ -331,33 +322,26 @@ class Anchor extends React.Component<InternalAnchorProps, AnchorState, ConfigCon
return (
<AnchorContext.Provider value={contextValue}>
{!affix ? (
anchorContent
) : (
{affix ? (
<Affix offsetTop={offsetTop} target={this.getContainer}>
{anchorContent}
</Affix>
) : (
anchorContent
)}
</AnchorContext.Provider>
);
}
}
// just use in test
export type InternalAnchorClass = Anchor;
const AnchorFC = React.forwardRef<Anchor, AnchorProps>((props, ref) => {
const { prefixCls: customizePrefixCls } = props;
const { getPrefixCls } = React.useContext(ConfigContext);
const anchorPrefixCls = getPrefixCls('anchor', customizePrefixCls);
const anchorProps: InternalAnchorProps = {
...props,
anchorPrefixCls,
};
return <Anchor {...anchorProps} ref={ref} />;
return <Anchor {...props} ref={ref} anchorPrefixCls={anchorPrefixCls} />;
});
export default AnchorFC;

View File

@ -14,71 +14,53 @@ export interface AnchorLinkProps {
className?: string;
}
class AnchorLink extends React.Component<AnchorLinkProps, any, AntAnchor> {
static defaultProps = {
href: '#',
};
const AnchorLink: React.FC<AnchorLinkProps> = props => {
const { href = '#', title, prefixCls: customizePrefixCls, children, className, target } = props;
static contextType = AnchorContext;
const context = React.useContext<AntAnchor | undefined>(AnchorContext);
context: AntAnchor;
const { registerLink, unregisterLink, scrollTo, onClick, activeLink } = context || {};
componentDidMount() {
this.context.registerLink(this.props.href);
}
React.useEffect(() => {
registerLink?.(href);
return () => {
unregisterLink?.(href);
};
}, [href]);
componentDidUpdate({ href: prevHref }: AnchorLinkProps) {
const { href } = this.props;
if (prevHref !== href) {
this.context.unregisterLink(prevHref);
this.context.registerLink(href);
}
}
componentWillUnmount() {
this.context.unregisterLink(this.props.href);
}
handleClick = (e: React.MouseEvent<HTMLElement>) => {
const { scrollTo, onClick } = this.context;
const { href, title } = this.props;
const handleClick = (e: React.MouseEvent<HTMLElement>) => {
onClick?.(e, { title, href });
scrollTo(href);
scrollTo?.(href);
};
renderAnchorLink = ({ getPrefixCls }: ConfigConsumerProps) => {
const { prefixCls: customizePrefixCls, href, title, children, className, target } = this.props;
const prefixCls = getPrefixCls('anchor', customizePrefixCls);
const active = this.context.activeLink === href;
const wrapperClassName = classNames(
`${prefixCls}-link`,
{
[`${prefixCls}-link-active`]: active,
},
className,
);
const titleClassName = classNames(`${prefixCls}-link-title`, {
[`${prefixCls}-link-title-active`]: active,
});
return (
<div className={wrapperClassName}>
<a
className={titleClassName}
href={href}
title={typeof title === 'string' ? title : ''}
target={target}
onClick={this.handleClick}
>
{title}
</a>
{children}
</div>
);
};
render() {
return <ConfigConsumer>{this.renderAnchorLink}</ConfigConsumer>;
}
}
return (
<ConfigConsumer>
{({ getPrefixCls }: ConfigConsumerProps) => {
const prefixCls = getPrefixCls('anchor', customizePrefixCls);
const active = activeLink === href;
const wrapperClassName = classNames(`${prefixCls}-link`, className, {
[`${prefixCls}-link-active`]: active,
});
const titleClassName = classNames(`${prefixCls}-link-title`, {
[`${prefixCls}-link-title-active`]: active,
});
return (
<div className={wrapperClassName}>
<a
className={titleClassName}
href={href}
title={typeof title === 'string' ? title : ''}
target={target}
onClick={handleClick}
>
{title}
</a>
{children}
</div>
);
}}
</ConfigConsumer>
);
};
export default AnchorLink;

View File

@ -41,11 +41,17 @@ describe('Anchor Render', () => {
getClientRectsMock.mockReturnValue({ length: 1 } as DOMRectList);
});
beforeEach(() => {
jest.useFakeTimers();
});
afterEach(() => {
jest.clearAllTimers();
jest.useRealTimers();
});
afterAll(() => {
jest.clearAllTimers();
jest.useRealTimers();
getBoundingClientRectMock.mockRestore();
getClientRectsMock.mockRestore();
@ -69,7 +75,7 @@ describe('Anchor Render', () => {
expect(anchorInstance!.state).not.toBe(null);
});
it('Anchor render perfectly for complete href - click', () => {
it('Anchor render perfectly for complete href - click', async () => {
const hash = getHashUrl();
let anchorInstance: InternalAnchorClass;
const { container } = render(
@ -82,6 +88,7 @@ describe('Anchor Render', () => {
</Anchor>,
);
fireEvent.click(container.querySelector(`a[href="http://www.example.com/#${hash}"]`)!);
await waitFakeTimer();
expect(anchorInstance!.state!.activeLink).toBe(`http://www.example.com/#${hash}`);
});
@ -100,13 +107,12 @@ describe('Anchor Render', () => {
</Anchor>,
);
anchorInstance!.handleScrollTo('/#/faq?locale=en#Q1');
expect(anchorInstance!.state.activeLink).toBe('/#/faq?locale=en#Q1');
expect(scrollToSpy).not.toHaveBeenCalled();
await waitFakeTimer();
expect(anchorInstance!.state.activeLink).toBe('/#/faq?locale=en#Q1');
expect(scrollToSpy).toHaveBeenCalled();
});
it('Anchor render perfectly for complete href - scroll', () => {
it('Anchor render perfectly for complete href - scroll', async () => {
const hash = getHashUrl();
const root = createDiv();
render(<div id={hash}>Hello</div>, { container: root });
@ -121,6 +127,7 @@ describe('Anchor Render', () => {
</Anchor>,
);
anchorInstance!.handleScroll();
await waitFakeTimer();
expect(anchorInstance!.state!.activeLink).toBe(`http://www.example.com/#${hash}`);
});
@ -141,10 +148,10 @@ describe('Anchor Render', () => {
);
anchorInstance!.handleScrollTo(`##${hash}`);
await waitFakeTimer();
expect(anchorInstance!.state.activeLink).toBe(`##${hash}`);
const calls = scrollToSpy.mock.calls.length;
await waitFakeTimer();
expect(scrollToSpy.mock.calls.length).toBeGreaterThan(calls);
expect(scrollToSpy.mock.calls.length).toBe(calls);
});
it('should remove listener when unmount', async () => {
@ -373,7 +380,7 @@ describe('Anchor Render', () => {
it('Anchor targetOffset prop', async () => {
const hash = getHashUrl();
let dateNowMock;
let dateNowMock: jest.SpyInstance;
function dataNowMockFn() {
let start = 0;
@ -438,7 +445,7 @@ describe('Anchor Render', () => {
// https://github.com/ant-design/ant-design/issues/31941
it('Anchor targetOffset prop when contain spaces', async () => {
const hash = `${getHashUrl()} s p a c e s`;
let dateNowMock;
let dateNowMock: jest.SpyInstance;
function dataNowMockFn() {
let start = 0;
@ -543,13 +550,9 @@ describe('Anchor Render', () => {
});
it('test edge case when getBoundingClientRect return zero size', async () => {
getBoundingClientRectMock.mockReturnValue({
width: 0,
height: 0,
top: 1000,
} as DOMRect);
getBoundingClientRectMock.mockReturnValue({ width: 0, height: 0, top: 1000 } as DOMRect);
const hash = getHashUrl();
let dateNowMock;
let dateNowMock: jest.SpyInstance;
function dataNowMockFn() {
let start = 0;
@ -615,7 +618,7 @@ describe('Anchor Render', () => {
it('test edge case when container is not windows', async () => {
const hash = getHashUrl();
let dateNowMock;
let dateNowMock: jest.SpyInstance;
function dataNowMockFn() {
let start = 0;
@ -758,5 +761,15 @@ describe('Anchor Render', () => {
rerender(<Demo current={hash2} />);
expect(container.querySelector(`.ant-anchor-link-title-active`)?.textContent).toBe(hash2);
});
it('should correct render when href is null', () => {
expect(() => {
render(
<Anchor>
<Link href={null as unknown as string} title="test" />
</Anchor>,
);
fireEvent.scroll(window || document);
}).not.toThrow();
});
});
});

View File

@ -1,6 +1,6 @@
import * as React from 'react';
import type { AntAnchor } from './Anchor';
const AnchorContext = React.createContext<AntAnchor>(null as any);
const AnchorContext = React.createContext<AntAnchor | undefined>(undefined);
export default AnchorContext;