Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions packages/components/_util/dom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,3 +77,11 @@ export function getWindowSize(): { width: number; height: number } {
}
return { width: 0, height: 0 };
}

export type BindElement = HTMLElement | Window | null | undefined;

export function getTargetRect(target: BindElement): DOMRect {
return target !== window
? (target as HTMLElement).getBoundingClientRect()
: ({ top: 0, bottom: window.innerHeight } as DOMRect);
}
225 changes: 121 additions & 104 deletions packages/components/affix/Affix.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,24 @@
import React, { useEffect, forwardRef, useCallback, useImperativeHandle, useRef } from 'react';
import { isFunction } from 'lodash-es';
import { StyledProps, ScrollContainerElement } from '../common';
import React, { useEffect, forwardRef, useImperativeHandle, useRef, useState } from 'react';
import classNames from 'classnames';
import { StyledProps } from '../common';
import { TdAffixProps } from './type';
import useConfig from '../hooks/useConfig';
import { affixDefaultProps } from './defaultProps';
import useDefaultProps from '../hooks/useDefaultProps';
import { getScrollContainer } from '../_util/scroll';
import useEventCallback from '../hooks/useEventCallback';
import { getTargetRect } from '../_util/dom';
import { getFixedBottom, getFixedTop } from './utils';
import useResizeObserver from '../hooks/useResizeObserver';

function getDefaultTarget() {
return typeof window !== 'undefined' ? window : null;
}

interface AffixState {
affixStyle?: React.CSSProperties;
placeholderStyle?: React.CSSProperties;
}

export interface AffixProps extends TdAffixProps, StyledProps {}

Expand All @@ -14,122 +27,126 @@ export interface AffixRef {
}

const Affix = forwardRef<AffixRef, AffixProps>((props, ref) => {
const { children, content, zIndex, container, offsetBottom, offsetTop, className, style, onFixedChange } =
useDefaultProps(props, affixDefaultProps);
const {
children,
content,
zIndex,
container,
offsetBottom,
offsetTop,
className,
style,
onFixedChange,
...restProps
} = useDefaultProps(props, affixDefaultProps);

const { classPrefix } = useConfig();

const affixRef = useRef<HTMLDivElement>(null);
const affixWrapRef = useRef<HTMLDivElement>(null);
const placeholderEL = useRef<HTMLElement>(null);
const scrollContainer = useRef<ScrollContainerElement>(null);

const ticking = useRef(false);

// 这里是通过控制 wrap 的 border-top 到浏览器顶部距离和 offsetTop 比较
const handleScroll = useCallback(() => {
if (!ticking.current) {
window.requestAnimationFrame(() => {
// top = 节点到页面顶部的距离,包含 scroll 中的高度
const {
top: wrapToTop = 0,
width: wrapWidth = 0,
height: wrapHeight = 0,
} = affixWrapRef.current?.getBoundingClientRect() ?? { top: 0 };

// 容器到页面顶部的距离, windows 为0
let containerToTop = 0;
if (scrollContainer.current instanceof HTMLElement) {
containerToTop = scrollContainer.current.getBoundingClientRect().top;
}

const calcTop = wrapToTop - containerToTop; // 节点顶部到 container 顶部的距离
const containerHeight =
scrollContainer.current[scrollContainer.current instanceof Window ? 'innerHeight' : 'clientHeight'] -
wrapHeight;
const calcBottom = containerToTop + containerHeight - (offsetBottom ?? 0); // 计算 bottom 相对应的 top 值

let fixedTop: number | false;
if (calcTop <= offsetTop) {
// top 的触发
fixedTop = containerToTop + offsetTop;
} else if (wrapToTop >= calcBottom) {
// bottom 的触发
fixedTop = calcBottom;
} else {
fixedTop = false;
}

if (affixRef.current) {
const affixed = fixedTop !== false;
let placeholderStatus = affixWrapRef.current.contains(placeholderEL.current);
const prePlaceholderStatus = placeholderStatus;

if (affixed) {
// 定位
affixRef.current.className = `${classPrefix}-affix`;
affixRef.current.style.top = `${fixedTop}px`;
affixRef.current.style.width = `${wrapWidth}px`;
affixRef.current.style.height = `${wrapHeight}px`;

if (zIndex) {
affixRef.current.style.zIndex = `${zIndex}`;
}

// 插入占位节点
if (!placeholderStatus) {
placeholderEL.current.style.width = `${wrapWidth}px`;
placeholderEL.current.style.height = `${wrapHeight}px`;
affixWrapRef.current.appendChild(placeholderEL.current);
placeholderStatus = true;
}
} else {
affixRef.current.removeAttribute('class');
affixRef.current.removeAttribute('style');

// 删除占位节点
if (placeholderStatus) {
placeholderEL.current.remove();
placeholderStatus = false;
}
}
if (prePlaceholderStatus !== placeholderStatus && isFunction(onFixedChange)) {
onFixedChange(affixed, { top: +fixedTop });
}
}

ticking.current = false;
const [affixStyle, setAffixStyle] = useState<React.CSSProperties>();
const [placeholderStyle, setPlaceholderStyle] = useState<React.CSSProperties>();

const placeholderNodeRef = useRef<HTMLDivElement>(null);
const fixedNodeRef = useRef<HTMLDivElement>(null);

const scrollContainer = container ?? getDefaultTarget;

const internalOffsetTop = offsetBottom === undefined && offsetTop === undefined ? 0 : offsetTop;

const measure = () => {
if (!fixedNodeRef.current || !placeholderNodeRef.current || !scrollContainer) {
return;
}

const targetNode = getScrollContainer(scrollContainer);
if (targetNode) {
const newState: Partial<AffixState> = {};
const placeholderRect = getTargetRect(placeholderNodeRef.current);

if (
placeholderRect.top === 0 &&
placeholderRect.left === 0 &&
placeholderRect.width === 0 &&
placeholderRect.height === 0
) {
return;
}

const targetRect = getTargetRect(targetNode);
const fixedTop = getFixedTop(placeholderRect, targetRect, internalOffsetTop);
const fixedBottom = getFixedBottom(placeholderRect, targetRect, offsetBottom);
let top = 0;
if (fixedTop !== undefined) {
newState.affixStyle = {
position: 'fixed',
top: fixedTop,
width: placeholderRect.width,
height: placeholderRect.height,
zIndex,
};
newState.placeholderStyle = {
width: placeholderRect.width,
height: placeholderRect.height,
};
top = fixedTop;
} else if (fixedBottom !== undefined) {
newState.affixStyle = {
position: 'fixed',
bottom: fixedBottom,
width: placeholderRect.width,
height: placeholderRect.height,
zIndex,
};
newState.placeholderStyle = {
width: placeholderRect.width,
height: placeholderRect.height,
Comment on lines +77 to +101
Copy link

Copilot AI Sep 6, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The width and height properties are duplicated in both the fixedTop and fixedBottom branches. Consider extracting these common properties into a shared object to reduce duplication.

Suggested change
let top = 0;
if (fixedTop !== undefined) {
newState.affixStyle = {
position: 'fixed',
top: fixedTop,
width: placeholderRect.width,
height: placeholderRect.height,
zIndex,
};
newState.placeholderStyle = {
width: placeholderRect.width,
height: placeholderRect.height,
};
top = fixedTop;
} else if (fixedBottom !== undefined) {
newState.affixStyle = {
position: 'fixed',
bottom: fixedBottom,
width: placeholderRect.width,
height: placeholderRect.height,
zIndex,
};
newState.placeholderStyle = {
width: placeholderRect.width,
height: placeholderRect.height,
const sharedRectStyle = {
width: placeholderRect.width,
height: placeholderRect.height,
};
let top = 0;
if (fixedTop !== undefined) {
newState.affixStyle = {
position: 'fixed',
top: fixedTop,
...sharedRectStyle,
zIndex,
};
newState.placeholderStyle = {
...sharedRectStyle,
};
top = fixedTop;
} else if (fixedBottom !== undefined) {
newState.affixStyle = {
position: 'fixed',
bottom: fixedBottom,
...sharedRectStyle,
zIndex,
};
newState.placeholderStyle = {
...sharedRectStyle,

Copilot uses AI. Check for mistakes.

};
top = fixedBottom;
}

setAffixStyle(newState.affixStyle);
setPlaceholderStyle(newState.placeholderStyle);
onFixedChange?.(!!newState.affixStyle, {
top,
});
}
ticking.current = true;
}, [classPrefix, offsetBottom, offsetTop, onFixedChange, zIndex]);
};

useImperativeHandle(ref, () => ({
handleScroll,
}));
const onScroll = useEventCallback(() => {
measure();
});

useEffect(() => {
// 创建占位节点
placeholderEL.current = document.createElement('div');
}, []);
useResizeObserver(placeholderNodeRef, measure);

useResizeObserver(fixedNodeRef, measure);

useEffect(() => {
scrollContainer.current = getScrollContainer(container);
if (scrollContainer.current) {
handleScroll();
scrollContainer.current.addEventListener('scroll', handleScroll);
window.addEventListener('resize', handleScroll);
const scrollContainer = getScrollContainer(container);
if (scrollContainer) {
onScroll();
scrollContainer.addEventListener('scroll', onScroll);
window.addEventListener('resize', onScroll);

return () => {
scrollContainer.current.removeEventListener('scroll', handleScroll);
window.removeEventListener('resize', handleScroll);
scrollContainer.removeEventListener('scroll', onScroll);
window.removeEventListener('resize', onScroll);
};
}
}, [container, handleScroll]);
}, [container, onScroll]);

useImperativeHandle(ref, () => ({
handleScroll: onScroll,
}));

const rootCls = classNames(`${classPrefix}-affix`);

const mergedCls = classNames({ [rootCls]: affixStyle });

return (
<div ref={affixWrapRef} className={className} style={style}>
<div ref={affixRef}>{children || content}</div>
<div style={style} className={className} ref={placeholderNodeRef} {...restProps}>
{affixStyle && <div style={placeholderStyle} aria-hidden="true" />}
<div className={mergedCls} ref={fixedNodeRef} style={affixStyle}>
{children || content}
</div>
</div>
);
});
Expand Down
34 changes: 21 additions & 13 deletions packages/components/affix/__tests__/affix.test.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import React from 'react';
import { render, describe, vi, mockTimeout } from '@test/utils';
import { render, describe, vi, mockTimeout, fireEvent } from '@test/utils';
import Affix from '../index';

describe('Affix 组件测试', () => {
Expand Down Expand Up @@ -69,6 +69,7 @@ describe('Affix 组件测试', () => {
<div>固钉</div>
</Affix>,
);

// 默认
expect(onFixedChangeMock).toHaveBeenCalledTimes(0);
expect(getByText('固钉').parentNode).not.toHaveClass('t-affix');
Expand All @@ -78,6 +79,10 @@ describe('Affix 组件测试', () => {
await mockScrollTo(30);
await mockScrollTo(10);
await mockTimeout(() => false, 200);

Object.defineProperty(window, 'scrollTop', { value: 50, writable: true });
await fireEvent.scroll(window);

expect(onFixedChangeMock).toHaveBeenCalledTimes(1);

expect(getByText('固钉').parentNode).toHaveClass('t-affix');
Expand All @@ -88,7 +93,7 @@ describe('Affix 组件测试', () => {
const onFixedChangeMock = vi.fn();

const { getByText } = render(
<Affix offsetBottom={20} onFixedChange={onFixedChangeMock} zIndex={2}>
<Affix offsetBottom={20} offsetTop={20} onFixedChange={onFixedChangeMock} zIndex={2}>
<div>固钉</div>
</Affix>,
);
Expand All @@ -98,12 +103,13 @@ describe('Affix 组件测试', () => {
expect(getByText('固钉').parentElement?.style.zIndex).toBe('');

// offsetBottom
const isWindow = getByText('固钉').parentElement && window instanceof Window;
const { clientHeight } = document.documentElement;
const { innerHeight } = window;
await mockScrollTo((isWindow ? innerHeight : clientHeight) - 40);
await mockScrollTo(isWindow ? innerHeight : clientHeight);
await mockScrollTo(30);
await mockScrollTo(10);
await mockTimeout(() => false, 200);

Object.defineProperty(window, 'scrollTop', { value: 50, writable: true });
await fireEvent.scroll(window);

expect(onFixedChangeMock).toHaveBeenCalledTimes(1);

expect(getByText('固钉').parentNode).toHaveClass('t-affix');
Expand All @@ -127,17 +133,19 @@ describe('Affix 组件测试', () => {
await mockScrollTo(30);
await mockScrollTo(10);
await mockTimeout(() => false, 200);
Object.defineProperty(window, 'scrollTop', { value: 50, writable: true });
await fireEvent.scroll(window);
expect(onFixedChangeMock).toHaveBeenCalledTimes(1);

// offsetBottom
const isWindow = typeof window !== 'undefined' && window.innerHeight !== undefined;
const { clientHeight } = document.documentElement;
const { innerHeight } = window;
await mockScrollTo((isWindow ? innerHeight : clientHeight) - 40);
await mockScrollTo(isWindow ? innerHeight : clientHeight);
await mockScrollTo(30);
await mockScrollTo(10);
await mockTimeout(() => false, 200);

expect(onFixedChangeMock).toHaveBeenCalledTimes(1);
Object.defineProperty(window, 'scrollTop', { value: 50, writable: true });
await fireEvent.scroll(window);

expect(onFixedChangeMock).toHaveBeenCalledTimes(2);

expect(getByText('固钉').parentNode).toHaveClass('t-affix');
expect(getByText('固钉').parentElement?.style.zIndex).toBe('2');
Expand Down
10 changes: 5 additions & 5 deletions packages/components/affix/_example/container.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,12 @@ export default function ContainerExample() {
};

useEffect(() => {
if (affixRef.current) {
const { handleScroll } = affixRef.current;
// 防止 affix 移动到容器外
window.addEventListener('scroll', handleScroll);
return () => window.removeEventListener('scroll', handleScroll);
function onScroll() {
affixRef.current?.handleScroll();
}
// 防止 affix 移动到容器外
window.addEventListener('scroll', onScroll);
return () => window.removeEventListener('scroll', onScroll);
}, []);

const backgroundStyle = {
Expand Down
14 changes: 14 additions & 0 deletions packages/components/affix/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
export function getFixedTop(placeholderRect: DOMRect, targetRect: DOMRect, offsetTop?: number) {
if (offsetTop !== undefined && Math.round(targetRect.top) > Math.round(placeholderRect.top) - offsetTop) {
return offsetTop + targetRect.top;
}
return undefined;
}

export function getFixedBottom(placeholderRect: DOMRect, targetRect: DOMRect, offsetBottom?: number) {
if (offsetBottom !== undefined && Math.round(targetRect.bottom) < Math.round(placeholderRect.bottom) + offsetBottom) {
const targetBottomOffset = window.innerHeight - targetRect.bottom;
return offsetBottom + targetBottomOffset;
}
return undefined;
}
Loading
Loading