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

View File

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

View File

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