Skip to content
126 changes: 96 additions & 30 deletions packages/components/affix/Affix.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
import React, { useEffect, forwardRef, useCallback, useImperativeHandle, useRef } from 'react';
import React, { forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState } from 'react';
import { isFunction } from 'lodash-es';
import { StyledProps, ScrollContainerElement } from '../common';
import { TdAffixProps } from './type';

import { isWindow } from '../_util/dom';
import { getScrollContainer } from '../_util/scroll';
import useConfig from '../hooks/useConfig';
import { affixDefaultProps } from './defaultProps';
import useDefaultProps from '../hooks/useDefaultProps';
import { getScrollContainer } from '../_util/scroll';
import { affixDefaultProps } from './defaultProps';

import type { ScrollContainerElement, StyledProps } from '../common';
import type { TdAffixProps } from './type';

export interface AffixProps extends TdAffixProps, StyledProps {}

Expand All @@ -19,6 +22,8 @@ const Affix = forwardRef<AffixRef, AffixProps>((props, ref) => {

const { classPrefix } = useConfig();

const [containerReady, setContainerReady] = useState(false);

const affixRef = useRef<HTMLDivElement>(null);
const affixWrapRef = useRef<HTMLDivElement>(null);
const placeholderEL = useRef<HTMLElement>(null);
Expand All @@ -29,35 +34,49 @@ const Affix = forwardRef<AffixRef, AffixProps>((props, ref) => {
// 这里是通过控制 wrap 的 border-top 到浏览器顶部距离和 offsetTop 比较
const handleScroll = useCallback(() => {
if (!ticking.current) {
window.requestAnimationFrame(() => {
requestAnimationFrame(() => {
// top = 节点到页面顶部的距离,包含 scroll 中的高度
const {
top: wrapToTop = 0,
bottom: wrapToBottom = 0,
width: wrapWidth = 0,
height: wrapHeight = 0,
} = affixWrapRef.current?.getBoundingClientRect() ?? { top: 0 };
} = affixWrapRef.current?.getBoundingClientRect() ?? {};

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

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;
if (props.offsetBottom !== undefined && props.offsetTop === undefined) {
const bottomThreshold = containerToBottom - (offsetBottom ?? 0);
if (wrapToBottom >= bottomThreshold) {
fixedTop = bottomThreshold - wrapHeight;
} else {
fixedTop = false;
}
} else {
fixedTop = false;
const containerHeight =
scrollContainer.current?.[isWindow(scrollContainer.current) ? 'innerHeight' : 'clientHeight'] - wrapHeight;
const calcBottom = containerToTop + containerHeight - (offsetBottom ?? 0); // 计算 bottom 相对应的 top 值
if (calcTop <= offsetTop) {
// top 的触发
fixedTop = containerToTop + offsetTop;
} else if (wrapToTop >= calcBottom) {
// bottom 的触发
fixedTop = calcBottom;
} else {
fixedTop = false;
}
}

if (affixRef.current) {
Expand Down Expand Up @@ -102,7 +121,7 @@ const Affix = forwardRef<AffixRef, AffixProps>((props, ref) => {
});
}
ticking.current = true;
}, [classPrefix, offsetBottom, offsetTop, onFixedChange, zIndex]);
}, [classPrefix, offsetBottom, offsetTop, zIndex, onFixedChange, props.offsetBottom, props.offsetTop]);

useImperativeHandle(ref, () => ({
handleScroll,
Expand All @@ -114,18 +133,65 @@ const Affix = forwardRef<AffixRef, AffixProps>((props, ref) => {
}, []);

useEffect(() => {
scrollContainer.current = getScrollContainer(container);
const checkContainerExist = () => {
Comment thread
uyarn marked this conversation as resolved.
const el = getScrollContainer(container);
const isReady = isWindow(el) || el instanceof HTMLElement;
setContainerReady(isReady);
return isReady;
};

if (checkContainerExist()) return;

const observer = new MutationObserver(() => {
if (checkContainerExist()) {
observer.disconnect();
}
});

observer.observe(document.body, {
childList: true,
subtree: true,
});

return () => {
observer.disconnect();
};
}, [container]);

useEffect(() => {
if (!containerReady) return;

const newContainer = getScrollContainer(container);
if (!newContainer) return; // 容器没准备好

// 清理旧的监听器
if (scrollContainer.current) {
handleScroll();
scrollContainer.current.addEventListener('scroll', handleScroll);
window.addEventListener('resize', handleScroll);
scrollContainer.current.removeEventListener('scroll', handleScroll);
}

return () => {
scrollContainer.current.removeEventListener('scroll', handleScroll);
window.removeEventListener('resize', handleScroll);
};
scrollContainer.current = newContainer;

handleScroll();
scrollContainer.current.addEventListener('scroll', handleScroll);
window.addEventListener('resize', handleScroll);

// 当 container 不是 window 时,也需要监听 window 的 scroll 事件
// 这样当整个页面滚动时,可以确保 affix 元素不会超出容器范围
const isContainerNotWindow = !isWindow(scrollContainer.current);
if (isContainerNotWindow) {
window.addEventListener('scroll', handleScroll);
}
}, [container, handleScroll]);

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

return (
<div ref={affixWrapRef} className={className} style={style}>
Expand Down
18 changes: 12 additions & 6 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 { describe, mockTimeout, render, vi } from '@test/utils';
import Affix from '../index';

describe('Affix 组件测试', () => {
Expand Down Expand Up @@ -97,12 +97,18 @@ describe('Affix 组件测试', () => {
expect(getByText('固钉').parentNode).not.toHaveClass('t-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);
mockFn.mockImplementation(() => ({
top: innerHeight - 10,
bottom: innerHeight,
left: 0,
right: 0,
height: 10,
width: 0,
x: 0,
y: 0,
toJSON: () => ({}),
}));
await mockTimeout(() => false, 200);
expect(onFixedChangeMock).toHaveBeenCalledTimes(1);

Expand Down
13 changes: 8 additions & 5 deletions packages/components/affix/_example/base.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,19 @@
import React, { useState } from 'react';
import { Affix, Button } from 'tdesign-react';

import type { AffixProps } from 'tdesign-react';

export default function BaseExample() {
const [top, setTop] = useState(150);
const [affixed, setAffixed] = useState(false);

const handleClick = () => {
setTop(top + 10);
const handleFixedChange: AffixProps['onFixedChange'] = (affixed, { top }) => {
console.log('top', top);
setAffixed(affixed);
};

return (
<Affix offsetTop={top} offsetBottom={10}>
<Button onClick={handleClick}>固钉</Button>
<Affix offsetTop={150} zIndex={2000} onFixedChange={handleFixedChange}>
<Button theme={affixed ? 'success' : 'primary'}>Affixed: {`${affixed}`}</Button>
</Affix>
);
}
41 changes: 11 additions & 30 deletions packages/components/affix/_example/container.tsx
Original file line number Diff line number Diff line change
@@ -1,38 +1,24 @@
import React, { useState, useRef, useEffect } from 'react';
import React, { useState } from 'react';
import { Affix, Button } from 'tdesign-react';
import type { AffixProps } from 'tdesign-react';

export default function ContainerExample() {
const [container, setContainer] = useState(null);
const [affixed, setAffixed] = useState(false);
const affixRef = useRef(null);

const handleFixedChange: AffixProps['onFixedChange'] = (affixed, { top }) => {
console.log('top', top);
setAffixed(affixed);
};

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

const backgroundStyle = {
height: '1500px',
paddingTop: '700px',
backgroundColor: '#eee',
backgroundImage:
'linear-gradient(45deg,#bbb 25%,transparent 0),linear-gradient(45deg,transparent 75%,#bbb 0),linear-gradient(45deg,#bbb 25%,transparent 0),linear-gradient(45deg,transparent 75%,#bbb 0)',
backgroundSize: '30px 30px',
backgroundPosition: '0 0,15px 15px,15px 15px,0 0',
};
display: 'flex',
flexDirection: 'column',
justifyContent: 'space-between',
} as React.CSSProperties;

return (
<div
ref={setContainer}
style={{
border: '1px solid var(--component-stroke)',
borderRadius: '3px',
Expand All @@ -41,18 +27,13 @@ export default function ContainerExample() {
overflowY: 'auto',
overscrollBehavior: 'none',
}}
ref={setContainer}
>
<div style={backgroundStyle}>
<Affix
offsetTop={50}
offsetBottom={50}
container={container}
zIndex={5}
onFixedChange={handleFixedChange}
ref={affixRef}
>
<Button>affixed: {`${affixed}`}</Button>
<Affix zIndex={10} offsetTop={50} container={container}>
<Button>Top</Button>
</Affix>
<Affix offsetBottom={50} container={container}>
<Button>Bottom</Button>
</Affix>
</div>
</div>
Expand Down
2 changes: 1 addition & 1 deletion packages/components/affix/affix.en-US.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ style | Object | - | CSS(Cascading Style Sheets),Typescript: `React.CSSPropert
children | TNode | - | Typescript: `string \| TNode`。[see more ts definition](https://github.com/Tencent/tdesign-react/blob/develop/packages/components/common.ts) | N
container | String / Function | () => (() => window) | Typescript: `ScrollContainer`。[see more ts definition](https://github.com/Tencent/tdesign-react/blob/develop/packages/components/common.ts) | N
content | TNode | - | Typescript: `string \| TNode`。[see more ts definition](https://github.com/Tencent/tdesign-react/blob/develop/packages/components/common.ts) | N
offsetBottom | Number | 0 | When the distance from the bottom of the container reaches the specified distance, the trigger is fixed | N
offsetBottom | Number | - | When the distance from the bottom of the container reaches the specified distance, the trigger is fixed | N
offsetTop | Number | 0 | When the distance from the top of the container reaches the specified distance, the trigger is fixed | N
zIndex | Number | - | \- | N
onFixedChange | Function | | Typescript: `(affixed: boolean, context: { top: number }) => void`<br/> | N
2 changes: 1 addition & 1 deletion packages/components/affix/affix.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ style | Object | - | 样式,TS 类型:`React.CSSProperties` | N
children | TNode | - | 内容,同 content。TS 类型:`string \| TNode`。[通用类型定义](https://github.com/Tencent/tdesign-react/blob/develop/packages/components/common.ts) | N
container | String / Function | () => (() => window) | 指定滚动的容器。数据类型为 String 时,会被当作选择器处理,进行节点查询。示例:'body' 或 () => document.body。TS 类型:`ScrollContainer`。[通用类型定义](https://github.com/Tencent/tdesign-react/blob/develop/packages/components/common.ts) | N
content | TNode | - | 内容。TS 类型:`string \| TNode`。[通用类型定义](https://github.com/Tencent/tdesign-react/blob/develop/packages/components/common.ts) | N
offsetBottom | Number | 0 | 距离容器顶部达到指定距离后触发固定 | N
offsetBottom | Number | - | 距离容器顶部达到指定距离后触发固定 | N
Comment thread
uyarn marked this conversation as resolved.
offsetTop | Number | 0 | 距离容器底部达到指定距离后触发固定 | N
zIndex | Number | - | 固钉定位层级,样式默认为 500 | N
onFixedChange | Function | | TS 类型:`(affixed: boolean, context: { top: number }) => void`<br/>固定状态发生变化时触发 | N
2 changes: 1 addition & 1 deletion packages/components/affix/defaultProps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,4 @@

import { TdAffixProps } from './type';

export const affixDefaultProps: TdAffixProps = { container: () => window, offsetBottom: 0, offsetTop: 0 };
export const affixDefaultProps: TdAffixProps = { container: () => window, offsetTop: 0 };
Loading