refactor: rewrite Affix CC => FC (#42674)

* fix

* test

* Update index.ts

* fix

* fix

* fix

* fix: fix

* fix: fix

* fix: fix

* fix: fix

* fix: fix

* fix: fix

* update docs

* Update components/affix/index.tsx

Co-authored-by: kiner-tang(文辉) <1127031143@qq.com>
Signed-off-by: lijianan <574980606@qq.com>

* fix

* test: add test case

* Update components/affix/index.zh-CN.md

Co-authored-by: afc163 <afc163@gmail.com>
Signed-off-by: lijianan <574980606@qq.com>

* update demo

---------

Signed-off-by: lijianan <574980606@qq.com>
Co-authored-by: kiner-tang(文辉) <1127031143@qq.com>
Co-authored-by: afc163 <afc163@gmail.com>
This commit is contained in:
lijianan 2023-10-07 18:53:14 +08:00 committed by GitHub
parent e76606f771
commit be92498f15
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 188 additions and 252 deletions

View File

@ -1,9 +1,5 @@
import raf from 'rc-util/lib/raf';
type throttledFn = (...args: any[]) => void;
type throttledCancelFn = { cancel: () => void };
function throttleByAnimationFrame<T extends any[]>(fn: (...args: T) => void) {
let requestId: number | null;
@ -12,7 +8,7 @@ function throttleByAnimationFrame<T extends any[]>(fn: (...args: T) => void) {
fn(...args);
};
const throttled: throttledFn & throttledCancelFn = (...args: T) => {
const throttled = (...args: T) => {
if (requestId == null) {
requestId = raf(later(args));
}

View File

@ -1,6 +1,5 @@
import type { CSSProperties } from 'react';
import React, { useEffect, useRef } from 'react';
import type { InternalAffixClass } from '..';
import Affix from '..';
import accessibilityTest from '../../../tests/shared/accessibilityTest';
import rtlTest from '../../../tests/shared/rtlTest';
@ -12,13 +11,12 @@ const events: Partial<Record<keyof HTMLElementEventMap, (ev: Partial<Event>) =>
interface AffixProps {
offsetTop?: number;
offsetBottom?: number;
style?: CSSProperties;
style?: React.CSSProperties;
onChange?: () => void;
onTestUpdatePosition?: () => void;
getInstance?: (inst: InternalAffixClass) => void;
}
const AffixMounter: React.FC<AffixProps> = ({ getInstance, ...restProps }) => {
const AffixMounter: React.FC<AffixProps> = (props) => {
const container = useRef<HTMLDivElement>(null);
useEffect(() => {
if (container.current) {
@ -31,7 +29,7 @@ const AffixMounter: React.FC<AffixProps> = ({ getInstance, ...restProps }) => {
}, []);
return (
<div ref={container} className="container">
<Affix className="fixed" ref={getInstance} target={() => container.current} {...restProps}>
<Affix className="fixed" target={() => container.current} {...props}>
<Button type="primary">Fixed at the top of container</Button>
</Affix>
</div>
@ -124,34 +122,14 @@ describe('Affix Render', () => {
});
describe('updatePosition when target changed', () => {
it('function change', async () => {
document.body.innerHTML = '<div id="mounter" />';
const container = document.getElementById('mounter');
const getTarget = () => container;
let affixInstance: InternalAffixClass;
const { rerender } = render(
<Affix
ref={(node) => {
affixInstance = node as InternalAffixClass;
}}
target={getTarget}
>
{null}
</Affix>,
);
rerender(
<Affix
ref={(node) => {
affixInstance = node as InternalAffixClass;
}}
target={() => null}
>
{null}
</Affix>,
);
expect(affixInstance!.state.status).toBe(0);
expect(affixInstance!.state.affixStyle).toBe(undefined);
expect(affixInstance!.state.placeholderStyle).toBe(undefined);
it('function change', () => {
document.body.innerHTML = `<div id="mounter" />`;
const target = document.getElementById('mounter');
const getTarget = () => target;
const { container, rerender } = render(<Affix target={getTarget}>{null}</Affix>);
rerender(<Affix target={() => null}>{null}</Affix>);
expect(container.querySelector(`div[aria-hidden="true"]`)).toBeNull();
expect(container.querySelector('.ant-affix')?.getAttribute('style')).toBeUndefined();
});
it('check position change before measure', async () => {
@ -167,34 +145,18 @@ describe('Affix Render', () => {
);
await waitFakeTimer();
await movePlaceholder(1000);
expect(container.querySelector('.ant-affix')).toBeTruthy();
expect(container.querySelector<HTMLDivElement>('.ant-affix')).toBeTruthy();
});
it('do not measure when hidden', async () => {
let affixInstance: InternalAffixClass | null = null;
const { rerender } = render(
<AffixMounter
getInstance={(inst) => {
affixInstance = inst;
}}
offsetBottom={0}
/>,
);
const { container, rerender } = render(<AffixMounter offsetBottom={0} />);
await waitFakeTimer();
const firstAffixStyle = affixInstance!.state.affixStyle;
const affixStyleEle = container.querySelector('.ant-affix');
const firstAffixStyle = affixStyleEle ? affixStyleEle.getAttribute('style') : null;
rerender(
<AffixMounter
getInstance={(inst) => {
affixInstance = inst;
}}
offsetBottom={0}
style={{ display: 'none' }}
/>,
);
rerender(<AffixMounter offsetBottom={0} style={{ display: 'none' }} />);
await waitFakeTimer();
const secondAffixStyle = affixInstance!.state.affixStyle;
const secondAffixStyle = affixStyleEle ? affixStyleEle.getAttribute('style') : null;
expect(firstAffixStyle).toEqual(secondAffixStyle);
});
@ -204,36 +166,23 @@ describe('Affix Render', () => {
it('add class automatically', async () => {
document.body.innerHTML = '<div id="mounter" />';
let affixInstance: InternalAffixClass | null = null;
render(
<AffixMounter
getInstance={(inst) => {
affixInstance = inst;
}}
offsetBottom={0}
/>,
{
container: document.getElementById('mounter')!,
},
);
const { container } = render(<AffixMounter offsetBottom={0} />, {
container: document.getElementById('mounter')!,
});
await waitFakeTimer();
await movePlaceholder(300);
expect(affixInstance!.state.affixStyle).toBeTruthy();
expect(container.querySelector(`div[aria-hidden="true"]`)).toBeTruthy();
expect(container.querySelector('.ant-affix')?.getAttribute('style')).toBeTruthy();
});
// Trigger inner and outer element for the two <ResizeObserver>s.
[
'.ant-btn', // inner
'.fixed', // outer
].forEach((selector) => {
['.ant-btn', '.fixed'].forEach((selector) => {
it(`trigger listener when size change: ${selector}`, async () => {
const updateCalled = jest.fn();
const { container } = render(
<AffixMounter offsetBottom={0} onTestUpdatePosition={updateCalled} />,
{
container: document.getElementById('mounter')!,
},
{ container: document.getElementById('mounter')! },
);
updateCalled.mockReset();

View File

@ -90,10 +90,10 @@ exports[`renders components/affix/demo/on-change.tsx extend context correctly 2`
exports[`renders components/affix/demo/target.tsx extend context correctly 1`] = `
<div
class="scrollable-container"
style="width: 100%; height: 100px; overflow: auto;"
>
<div
class="background"
style="width: 100%; height: 1000px;"
>
<div>
<div

View File

@ -84,10 +84,10 @@ exports[`renders components/affix/demo/on-change.tsx correctly 1`] = `
exports[`renders components/affix/demo/target.tsx correctly 1`] = `
<div
class="scrollable-container"
style="width:100%;height:100px;overflow:auto"
>
<div
class="background"
style="width:100%;height:1000px"
>
<div>
<div

View File

@ -1,10 +1,9 @@
import React, { useState } from 'react';
import React from 'react';
import { Affix, Button } from 'antd';
const App: React.FC = () => {
const [top, setTop] = useState(10);
const [bottom, setBottom] = useState(10);
const [top, setTop] = React.useState<number>(100);
const [bottom, setBottom] = React.useState<number>(100);
return (
<>
<Affix offsetTop={top}>

View File

@ -1,12 +1,22 @@
import React, { useState } from 'react';
import React from 'react';
import { Affix, Button } from 'antd';
const App: React.FC = () => {
const [container, setContainer] = useState<HTMLDivElement | null>(null);
const containerStyle: React.CSSProperties = {
width: '100%',
height: 100,
overflow: 'auto',
};
const style: React.CSSProperties = {
width: '100%',
height: 1000,
};
const App: React.FC = () => {
const [container, setContainer] = React.useState<HTMLDivElement | null>(null);
return (
<div className="scrollable-container" ref={setContainer}>
<div className="background">
<div style={containerStyle} ref={setContainer}>
<div style={style}>
<Affix target={() => container}>
<Button type="primary">Fixed at the top of container</Button>
</Affix>

View File

@ -18,6 +18,10 @@ On longer web pages, it's helpful to stick component into the viewport. This is
Please note that Affix should not cover other content on the page, especially when the size of the viewport is small.
> Notes for developers
>
> After version `5.10.0`, we rewrite Affix use FC, Some methods of obtaining `ref` and calling internal instance methods will invalid.
## Examples
<!-- prettier-ignore -->

View File

@ -1,5 +1,4 @@
import React, { createRef, forwardRef, useContext } from 'react';
import React from 'react';
import classNames from 'classnames';
import ResizeObserver from 'rc-resize-observer';
import omit from 'rc-util/lib/omit';
@ -41,16 +40,16 @@ export interface AffixProps {
children: React.ReactNode;
}
interface InternalAffixProps extends AffixProps {
affixPrefixCls: string;
}
enum AffixStatus {
None,
Prepare,
}
export interface AffixState {
interface InternalAffixProps extends AffixProps {
affixPrefixCls: string;
}
interface AffixState {
affixStyle?: React.CSSProperties;
placeholderStyle?: React.CSSProperties;
status: AffixStatus;
@ -58,117 +57,58 @@ export interface AffixState {
prevTarget: Window | HTMLElement | null;
}
class InternalAffix extends React.Component<InternalAffixProps, AffixState> {
static contextType = ConfigContext;
interface AffixRef {
updatePosition: ReturnType<typeof throttleByAnimationFrame>;
}
state: AffixState = {
status: AffixStatus.None,
lastAffix: false,
prevTarget: null,
};
const InternalAffix = React.forwardRef<AffixRef, InternalAffixProps>((props, ref) => {
const {
style,
offsetTop,
offsetBottom,
affixPrefixCls,
rootClassName,
children,
target,
onChange,
} = props;
private placeholderNodeRef = createRef<HTMLDivElement>();
const [lastAffix, setLastAffix] = React.useState(false);
const [affixStyle, setAffixStyle] = React.useState<React.CSSProperties>();
const [placeholderStyle, setPlaceholderStyle] = React.useState<React.CSSProperties>();
private fixedNodeRef = createRef<HTMLDivElement>();
const status = React.useRef<AffixStatus>(AffixStatus.None);
private timer: ReturnType<typeof setTimeout> | null;
const prevTarget = React.useRef<Window | HTMLElement | null>(null);
const prevListener = React.useRef<EventListener>();
context: ConfigConsumerProps;
const placeholderNodeRef = React.useRef<HTMLDivElement>(null);
const fixedNodeRef = React.useRef<HTMLDivElement>(null);
const timer = React.useRef<ReturnType<typeof setTimeout> | null>(null);
private getTargetFunc() {
const { getTargetContainer } = this.context;
const { target } = this.props;
const { getTargetContainer } = React.useContext<ConfigConsumerProps>(ConfigContext);
if (target !== undefined) {
return target;
}
const targetFunc = target ?? getTargetContainer ?? getDefaultTarget;
return getTargetContainer ?? getDefaultTarget;
}
addListeners = () => {
const targetFunc = this.getTargetFunc();
const target = targetFunc?.();
const { prevTarget } = this.state;
if (prevTarget !== target) {
TRIGGER_EVENTS.forEach((eventName) => {
prevTarget?.removeEventListener(eventName, this.lazyUpdatePosition);
target?.addEventListener(eventName, this.lazyUpdatePosition);
});
this.updatePosition();
this.setState({ prevTarget: target });
}
};
removeListeners = () => {
if (this.timer) {
clearTimeout(this.timer);
this.timer = null;
}
const { prevTarget } = this.state;
const targetFunc = this.getTargetFunc();
const newTarget = targetFunc?.();
TRIGGER_EVENTS.forEach((eventName) => {
newTarget?.removeEventListener(eventName, this.lazyUpdatePosition);
prevTarget?.removeEventListener(eventName, this.lazyUpdatePosition);
});
this.updatePosition.cancel();
// https://github.com/ant-design/ant-design/issues/22683
this.lazyUpdatePosition.cancel();
};
// Event handler
componentDidMount() {
// [Legacy] Wait for parent component ref has its value.
// We should use target as directly element instead of function which makes element check hard.
this.timer = setTimeout(this.addListeners);
}
componentDidUpdate(prevProps: AffixProps) {
this.addListeners();
if (
prevProps.offsetTop !== this.props.offsetTop ||
prevProps.offsetBottom !== this.props.offsetBottom
) {
this.updatePosition();
}
this.measure();
}
componentWillUnmount() {
this.removeListeners();
}
getOffsetTop = () => {
const { offsetBottom, offsetTop } = this.props;
return offsetBottom === undefined && offsetTop === undefined ? 0 : offsetTop;
};
getOffsetBottom = () => this.props.offsetBottom;
const internalOffsetTop = offsetBottom === undefined && offsetTop === undefined ? 0 : offsetTop;
// =================== Measure ===================
measure = () => {
const { status, lastAffix } = this.state;
const { onChange } = this.props;
const targetFunc = this.getTargetFunc();
const measure = () => {
if (
status !== AffixStatus.Prepare ||
!this.fixedNodeRef.current ||
!this.placeholderNodeRef.current ||
status.current !== AffixStatus.Prepare ||
!fixedNodeRef.current ||
!placeholderNodeRef.current ||
!targetFunc
) {
return;
}
const offsetTop = this.getOffsetTop();
const offsetBottom = this.getOffsetBottom();
const targetNode = targetFunc();
if (targetNode) {
const newState: Partial<AffixState> = {
status: AffixStatus.None,
};
const placeholderRect = getTargetRect(this.placeholderNodeRef.current);
const placeholderRect = getTargetRect(placeholderNodeRef.current);
if (
placeholderRect.top === 0 &&
@ -180,7 +120,7 @@ class InternalAffix extends React.Component<InternalAffixProps, AffixState> {
}
const targetRect = getTargetRect(targetNode);
const fixedTop = getFixedTop(placeholderRect, targetRect, offsetTop);
const fixedTop = getFixedTop(placeholderRect, targetRect, internalOffsetTop);
const fixedBottom = getFixedBottom(placeholderRect, targetRect, offsetBottom);
if (fixedTop !== undefined) {
@ -208,46 +148,38 @@ class InternalAffix extends React.Component<InternalAffixProps, AffixState> {
}
newState.lastAffix = !!newState.affixStyle;
if (onChange && lastAffix !== newState.lastAffix) {
onChange(newState.lastAffix);
if (lastAffix !== newState.lastAffix) {
onChange?.(newState.lastAffix);
}
this.setState(newState as AffixState);
status.current = newState.status!;
setAffixStyle(newState.affixStyle);
setPlaceholderStyle(newState.placeholderStyle);
setLastAffix(newState.lastAffix);
}
};
prepareMeasure = () => {
// event param is used before. Keep compatible ts define here.
this.setState({
status: AffixStatus.Prepare,
affixStyle: undefined,
placeholderStyle: undefined,
});
// Test if `updatePosition` called
const prepareMeasure = () => {
status.current = AffixStatus.Prepare;
measure();
if (process.env.NODE_ENV === 'test') {
const { onTestUpdatePosition } = this.props as any;
onTestUpdatePosition?.();
(props as any)?.onTestUpdatePosition?.();
}
};
updatePosition = throttleByAnimationFrame(() => {
this.prepareMeasure();
const updatePosition = throttleByAnimationFrame(() => {
prepareMeasure();
});
lazyUpdatePosition = throttleByAnimationFrame(() => {
const targetFunc = this.getTargetFunc();
const { affixStyle } = this.state;
const lazyUpdatePosition = throttleByAnimationFrame(() => {
// Check position change before measure to make Safari smooth
if (targetFunc && affixStyle) {
const offsetTop = this.getOffsetTop();
const offsetBottom = this.getOffsetBottom();
const targetNode = targetFunc();
if (targetNode && this.placeholderNodeRef.current) {
if (targetNode && placeholderNodeRef.current) {
const targetRect = getTargetRect(targetNode);
const placeholderRect = getTargetRect(this.placeholderNodeRef.current);
const fixedTop = getFixedTop(placeholderRect, targetRect, offsetTop);
const placeholderRect = getTargetRect(placeholderNodeRef.current);
const fixedTop = getFixedTop(placeholderRect, targetRect, internalOffsetTop);
const fixedBottom = getFixedBottom(placeholderRect, targetRect, offsetBottom);
if (
@ -260,49 +192,91 @@ class InternalAffix extends React.Component<InternalAffixProps, AffixState> {
}
// Directly call prepare measure since it's already throttled.
this.prepareMeasure();
prepareMeasure();
});
// =================== Render ===================
render() {
const { affixStyle, placeholderStyle } = this.state;
const { affixPrefixCls, rootClassName, children } = this.props;
const className = classNames(affixStyle && rootClassName, {
[affixPrefixCls]: !!affixStyle,
const addListeners = () => {
const listenerTarget = targetFunc?.();
TRIGGER_EVENTS.forEach((eventName) => {
if (prevListener.current) {
prevTarget.current?.removeEventListener(eventName, prevListener.current);
}
listenerTarget?.addEventListener(eventName, lazyUpdatePosition);
});
prevTarget.current = listenerTarget;
prevListener.current = lazyUpdatePosition;
};
let props = omit(this.props, [
'prefixCls',
'offsetTop',
'offsetBottom',
'target',
'onChange',
'affixPrefixCls',
'rootClassName',
]);
// Omit this since `onTestUpdatePosition` only works on test.
if (process.env.NODE_ENV === 'test') {
props = omit(props as typeof props & { onTestUpdatePosition: any }, ['onTestUpdatePosition']);
const removeListeners = () => {
if (timer.current) {
clearTimeout(timer.current);
timer.current = null;
}
const newTarget = targetFunc?.();
TRIGGER_EVENTS.forEach((eventName) => {
newTarget?.removeEventListener(eventName, lazyUpdatePosition);
if (prevListener.current) {
prevTarget.current?.removeEventListener(eventName, prevListener.current);
}
});
updatePosition.cancel();
lazyUpdatePosition.cancel();
};
return (
<ResizeObserver onResize={this.updatePosition}>
<div {...props} ref={this.placeholderNodeRef}>
{affixStyle && <div style={placeholderStyle} aria-hidden="true" />}
<div className={className} ref={this.fixedNodeRef} style={affixStyle}>
<ResizeObserver onResize={this.updatePosition}>{children}</ResizeObserver>
</div>
</div>
</ResizeObserver>
);
React.useImperativeHandle(ref, () => ({ updatePosition }));
// mount & unmount
React.useEffect(() => {
// [Legacy] Wait for parent component ref has its value.
// We should use target as directly element instead of function which makes element check hard.
timer.current = setTimeout(addListeners);
return () => removeListeners();
}, []);
React.useEffect(() => {
addListeners();
}, [target, affixStyle]);
React.useEffect(() => {
updatePosition();
}, [target, offsetTop, offsetBottom]);
const className = classNames({
[affixPrefixCls]: affixStyle,
[rootClassName!]: affixStyle && rootClassName,
});
let otherProps = omit(props, [
'prefixCls',
'offsetTop',
'offsetBottom',
'target',
'onChange',
'affixPrefixCls',
'rootClassName',
]);
if (process.env.NODE_ENV === 'test') {
otherProps = omit(otherProps as typeof otherProps & { onTestUpdatePosition: any }, [
'onTestUpdatePosition',
]);
}
}
// just use in test
export type InternalAffixClass = InternalAffix;
const Affix = forwardRef<InternalAffix, AffixProps>((props, ref) => {
return (
<ResizeObserver onResize={updatePosition}>
<div style={style} ref={placeholderNodeRef} {...otherProps}>
{affixStyle && <div style={placeholderStyle} aria-hidden="true" />}
<div className={className} ref={fixedNodeRef} style={affixStyle}>
<ResizeObserver onResize={updatePosition}>{children}</ResizeObserver>
</div>
</div>
</ResizeObserver>
);
});
const Affix = React.forwardRef<AffixRef, AffixProps>((props, ref) => {
const { prefixCls: customizePrefixCls, rootClassName } = props;
const { getPrefixCls } = useContext<ConfigConsumerProps>(ConfigContext);
const { getPrefixCls } = React.useContext<ConfigConsumerProps>(ConfigContext);
const affixPrefixCls = getPrefixCls('affix', customizePrefixCls);
const [wrapSSR, hashId] = useStyle(affixPrefixCls);

View File

@ -19,6 +19,10 @@ group:
页面可视范围过小时,慎用此功能以免遮挡页面内容。
> 开发者注意事项:
>
> 自 `5.10.0` 起,由于 Affix 组件由 class 重构为 FC之前获取 `ref` 并调用内部实例方法的写法都会失效。
## 代码演示
<!-- prettier-ignore -->