diff --git a/components/_util/throttleByAnimationFrame.ts b/components/_util/throttleByAnimationFrame.ts index 4b3c80826a..8b0f1c7a47 100644 --- a/components/_util/throttleByAnimationFrame.ts +++ b/components/_util/throttleByAnimationFrame.ts @@ -1,9 +1,5 @@ import raf from 'rc-util/lib/raf'; -type throttledFn = (...args: any[]) => void; - -type throttledCancelFn = { cancel: () => void }; - function throttleByAnimationFrame(fn: (...args: T) => void) { let requestId: number | null; @@ -12,7 +8,7 @@ function throttleByAnimationFrame(fn: (...args: T) => void) { fn(...args); }; - const throttled: throttledFn & throttledCancelFn = (...args: T) => { + const throttled = (...args: T) => { if (requestId == null) { requestId = raf(later(args)); } diff --git a/components/affix/__tests__/Affix.test.tsx b/components/affix/__tests__/Affix.test.tsx index cbe9b44815..5f2f594bc5 100644 --- a/components/affix/__tests__/Affix.test.tsx +++ b/components/affix/__tests__/Affix.test.tsx @@ -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) => interface AffixProps { offsetTop?: number; offsetBottom?: number; - style?: CSSProperties; + style?: React.CSSProperties; onChange?: () => void; onTestUpdatePosition?: () => void; - getInstance?: (inst: InternalAffixClass) => void; } -const AffixMounter: React.FC = ({ getInstance, ...restProps }) => { +const AffixMounter: React.FC = (props) => { const container = useRef(null); useEffect(() => { if (container.current) { @@ -31,7 +29,7 @@ const AffixMounter: React.FC = ({ getInstance, ...restProps }) => { }, []); return (
- container.current} {...restProps}> + container.current} {...props}>
@@ -124,34 +122,14 @@ describe('Affix Render', () => { }); describe('updatePosition when target changed', () => { - it('function change', async () => { - document.body.innerHTML = '
'; - const container = document.getElementById('mounter'); - const getTarget = () => container; - let affixInstance: InternalAffixClass; - const { rerender } = render( - { - affixInstance = node as InternalAffixClass; - }} - target={getTarget} - > - {null} - , - ); - rerender( - { - affixInstance = node as InternalAffixClass; - }} - target={() => null} - > - {null} - , - ); - expect(affixInstance!.state.status).toBe(0); - expect(affixInstance!.state.affixStyle).toBe(undefined); - expect(affixInstance!.state.placeholderStyle).toBe(undefined); + it('function change', () => { + document.body.innerHTML = `
`; + const target = document.getElementById('mounter'); + const getTarget = () => target; + const { container, rerender } = render({null}); + rerender( null}>{null}); + 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('.ant-affix')).toBeTruthy(); }); it('do not measure when hidden', async () => { - let affixInstance: InternalAffixClass | null = null; - - const { rerender } = render( - { - affixInstance = inst; - }} - offsetBottom={0} - />, - ); + const { container, rerender } = render(); await waitFakeTimer(); - const firstAffixStyle = affixInstance!.state.affixStyle; + const affixStyleEle = container.querySelector('.ant-affix'); + const firstAffixStyle = affixStyleEle ? affixStyleEle.getAttribute('style') : null; - rerender( - { - affixInstance = inst; - }} - offsetBottom={0} - style={{ display: 'none' }} - />, - ); + rerender(); 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 = '
'; - let affixInstance: InternalAffixClass | null = null; - render( - { - affixInstance = inst; - }} - offsetBottom={0} - />, - { - container: document.getElementById('mounter')!, - }, - ); + const { container } = render(, { + 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 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( , - { - container: document.getElementById('mounter')!, - }, + { container: document.getElementById('mounter')! }, ); updateCalled.mockReset(); diff --git a/components/affix/__tests__/__snapshots__/demo-extend.test.ts.snap b/components/affix/__tests__/__snapshots__/demo-extend.test.ts.snap index d9bb5f346e..65485c157a 100644 --- a/components/affix/__tests__/__snapshots__/demo-extend.test.ts.snap +++ b/components/affix/__tests__/__snapshots__/demo-extend.test.ts.snap @@ -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`] = `
{ - const [top, setTop] = useState(10); - const [bottom, setBottom] = useState(10); - + const [top, setTop] = React.useState(100); + const [bottom, setBottom] = React.useState(100); return ( <> diff --git a/components/affix/demo/target.tsx b/components/affix/demo/target.tsx index 9a00d26495..bf8a39b1d9 100644 --- a/components/affix/demo/target.tsx +++ b/components/affix/demo/target.tsx @@ -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(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(null); return ( -
-
+
+
container}> diff --git a/components/affix/index.en-US.md b/components/affix/index.en-US.md index 0e63c68a5e..2b208c8bdf 100644 --- a/components/affix/index.en-US.md +++ b/components/affix/index.en-US.md @@ -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 diff --git a/components/affix/index.tsx b/components/affix/index.tsx index 9c37baff91..1f8d61865d 100644 --- a/components/affix/index.tsx +++ b/components/affix/index.tsx @@ -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 { - static contextType = ConfigContext; +interface AffixRef { + updatePosition: ReturnType; +} - state: AffixState = { - status: AffixStatus.None, - lastAffix: false, - prevTarget: null, - }; +const InternalAffix = React.forwardRef((props, ref) => { + const { + style, + offsetTop, + offsetBottom, + affixPrefixCls, + rootClassName, + children, + target, + onChange, + } = props; - private placeholderNodeRef = createRef(); + const [lastAffix, setLastAffix] = React.useState(false); + const [affixStyle, setAffixStyle] = React.useState(); + const [placeholderStyle, setPlaceholderStyle] = React.useState(); - private fixedNodeRef = createRef(); + const status = React.useRef(AffixStatus.None); - private timer: ReturnType | null; + const prevTarget = React.useRef(null); + const prevListener = React.useRef(); - context: ConfigConsumerProps; + const placeholderNodeRef = React.useRef(null); + const fixedNodeRef = React.useRef(null); + const timer = React.useRef | null>(null); - private getTargetFunc() { - const { getTargetContainer } = this.context; - const { target } = this.props; + const { getTargetContainer } = React.useContext(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 = { 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 { } 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 { } 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 { } // 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 ( - -
- {affixStyle && - - ); + 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((props, ref) => { + return ( + +
+ {affixStyle && + + ); +}); + +const Affix = React.forwardRef((props, ref) => { const { prefixCls: customizePrefixCls, rootClassName } = props; - const { getPrefixCls } = useContext(ConfigContext); + const { getPrefixCls } = React.useContext(ConfigContext); const affixPrefixCls = getPrefixCls('affix', customizePrefixCls); const [wrapSSR, hashId] = useStyle(affixPrefixCls); diff --git a/components/affix/index.zh-CN.md b/components/affix/index.zh-CN.md index e19580dc1c..29bdea6b9d 100644 --- a/components/affix/index.zh-CN.md +++ b/components/affix/index.zh-CN.md @@ -19,6 +19,10 @@ group: 页面可视范围过小时,慎用此功能以免遮挡页面内容。 +> 开发者注意事项: +> +> 自 `5.10.0` 起,由于 Affix 组件由 class 重构为 FC,之前获取 `ref` 并调用内部实例方法的写法都会失效。 + ## 代码演示