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
125 changes: 96 additions & 29 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 { isFunction } from 'lodash-es';
import { StyledProps, ScrollContainerElement } from '../common';
import { TdAffixProps } from './type';
import React, { forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState } from 'react';

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 @@ -33,31 +38,46 @@ const Affix = forwardRef<AffixRef, AffixProps>((props, ref) => {
// top = 节点到页面顶部的距离,包含 scroll 中的高度
const {
top: wrapToTop = 0,
bottom: wrapToBottom = 0,
width: wrapWidth = 0,
height: wrapHeight = 0,
} = affixWrapRef.current?.getBoundingClientRect() ?? { top: 0 };
} = affixWrapRef.current?.getBoundingClientRect() ?? { top: 0, bottom: 0 };

// 容器到页面顶部的距离, 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 +122,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 +134,65 @@ const Affix = forwardRef<AffixRef, AffixProps>((props, ref) => {
}, []);

useEffect(() => {
scrollContainer.current = getScrollContainer(container);
const checkContainerExist = () => {
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 事件
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

监听 window 直接交给组件库内部处理更合理吧...

// 这样当整个页面滚动时,可以确保 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
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

offsetTopoffsetBottom 会冲突,理论上只能二选一

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
Loading